package library import ( "context" "errors" "fmt" "io/fs" "log/slog" "os" "path/filepath" "syscall" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) // ErrTrackNotFound is returned when DeleteTrackFile is called with an id // that has no row in tracks. var ErrTrackNotFound = errors.New("library: track not found") // removeFile is os.Remove behind a variable so a test can make removal fail the // way a read-only mount or a wrongly-owned directory does. A chmod-based test // cannot stand in for that: root ignores permission bits, so in a CI container // running as root it would pass without ever exercising the failure. var removeFile = os.Remove // FileRemoveError reports that a track's file exists but could not be removed. // When DeleteTrackFile returns one, NOTHING was deleted: the row, its likes, its // play history and its playlist memberships are all intact. type FileRemoveError struct { Path string // UID and GID are the identity the server process runs as — the half of a // permission problem the operator cannot see from the host side. UID, GID int Err error } func (e *FileRemoveError) Error() string { return fmt.Sprintf("remove track file: %v", e.Err) } func (e *FileRemoveError) Unwrap() error { return e.Err } // Dir is the directory removal needs write access to. Unlinking a file writes to // its PARENT, so a world-writable file inside a read-only directory still cannot // be removed — naming the file's own permissions would send the operator to the // wrong place. func (e *FileRemoveError) Dir() string { return filepath.Dir(e.Path) } // NotWritable reports whether the library is unwritable for this process — a // read-only mount or a permission denial — rather than an I/O fault. It is the // case the operator can fix, so callers answer it differently. func (e *FileRemoveError) NotWritable() bool { return errors.Is(e.Err, fs.ErrPermission) || errors.Is(e.Err, syscall.EROFS) } // Reason is the underlying cause without the path os.Remove already wrapped // around it, for messages that name the directory themselves. func (e *FileRemoveError) Reason() string { var pathErr *fs.PathError if errors.As(e.Err, &pathErr) { return pathErr.Err.Error() } return e.Err.Error() } // DeletedTrack reports what a delete tidied away beyond the track itself. type DeletedTrack struct { // AlbumID is set when the track was its album's last, so the album went too. AlbumID *pgtype.UUID // ArtistID is set when that album was its artist's last, so the artist went too. ArtistID *pgtype.UUID } // DeleteTrackFile removes a track's file from disk and then its row, tidying // away an album or artist the delete leaves empty. It is the ONLY path that // deletes a track file: the admin remove-track endpoint and quarantine's Delete // file both come through here (#3918). // // Order is the whole contract. The file goes first, and if it cannot go — a // read-only mount, a permission denial, an I/O error — nothing else happens and // a *FileRemoveError comes back. Proceeding past that failure is how #3918 lost // history: tracks CASCADEs to play_events, general_likes, contextual_likes, // playlist_tracks, track_tags and playback_errors, so the row and everything // hanging off it were destroyed while the file survived, and the next scan // re-imported it as a brand-new track with none of it. // // A file that is already gone (fs.ErrNotExist) is not a failure; the row is // removed as asked. // // This is NOT the missing-file path. That lifecycle is deliberately // non-destructive: reconcile stamps missing_since (#2523), selection paths // filter on it, and a returning file is un-marked or adopted (#2528). This is the // explicit, irreversible "remove this recording", never the way to tidy up a row // whose file merely went away. // // dataDir, when set, also clears the cached art of an artist the delete removed. // logger may be nil. func DeleteTrackFile( ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, trackID pgtype.UUID, ) (DeletedTrack, error) { if logger == nil { logger = slog.Default() } q := dbq.New(pool) track, err := q.GetTrackByID(ctx, trackID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return DeletedTrack{}, ErrTrackNotFound } return DeletedTrack{}, fmt.Errorf("get track: %w", err) } if err := removeFile(track.FilePath); err != nil && !errors.Is(err, fs.ErrNotExist) { return DeletedTrack{}, &FileRemoveError{ Path: track.FilePath, UID: os.Getuid(), GID: os.Getgid(), Err: err, } } // The row and any album or artist it empties go together, so a failure // partway cannot leave a deleted track with a ghost album behind it. tx, err := pool.Begin(ctx) if err != nil { return DeletedTrack{}, fmt.Errorf("begin tx: %w", err) } defer func() { _ = tx.Rollback(ctx) }() tq := dbq.New(tx) deleted, err := tq.DeleteTrack(ctx, trackID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { // Removed by someone else between the lookup and here. return DeletedTrack{}, ErrTrackNotFound } return DeletedTrack{}, fmt.Errorf("delete track: %w", err) } var out DeletedTrack album, err := tq.DeleteAlbumIfEmpty(ctx, deleted.AlbumID) switch { case err == nil: albumID := album.ID out.AlbumID = &albumID artistID, aerr := tq.DeleteArtistIfEmpty(ctx, album.ArtistID) switch { case aerr == nil: out.ArtistID = &artistID case errors.Is(aerr, pgx.ErrNoRows): // The artist still has other albums or stray tracks. default: return DeletedTrack{}, fmt.Errorf("delete artist if empty: %w", aerr) } case errors.Is(err, pgx.ErrNoRows): // The album still has other tracks. default: return DeletedTrack{}, fmt.Errorf("delete album if empty: %w", err) } if err := tx.Commit(ctx); err != nil { return DeletedTrack{}, fmt.Errorf("commit: %w", err) } // Both of these run after the delete has committed, so neither may fail // it: the recording is gone either way. An unlogged change leaves the track // in offline clients' caches until the next scan touches its album; a // leftover art directory is only disk. if err := syncpkg.LogChange(ctx, pool, syncpkg.EntityTrack, syncpkg.FormatUUID(trackID), syncpkg.OpDelete); err != nil { logger.Warn("track delete: LogChange failed", "track_id", syncpkg.FormatUUID(trackID), "err", err) } if out.ArtistID != nil && dataDir != "" { if err := coverart.CleanupArtistArt(dataDir, *out.ArtistID); err != nil { logger.Warn("track delete: artist-art cleanup failed", "artist_id", syncpkg.FormatUUID(*out.ArtistID), "err", err) } } return out, nil }