// Package tracks owns the track-level admin actions exposed by the // M7 #372 track-actions menu. Today that's RemoveTrack: the destructive // part is always handled directly by Minstrel (os.Remove + DB delete + // cascade); when the operator opts in via `unmonitor=true` the service // also tells Lidarr to flip the track's monitored flag off so Lidarr // doesn't search for a replacement. // // History: an earlier shape (commit 50a231f, since rewritten) routed // Lidarr-managed tracks through lidarrquarantine.DeleteViaLidarr — but // that primitive deletes the entire **album** in Lidarr (Lidarr is // album-granular and has no per-track delete API), which would silently // drop sibling tracks the operator didn't ask to remove. The current // shape per spec revision 723eee9 is "always direct delete; opt-in // Lidarr unmonitor for replacement-suppression." package tracks import ( "context" "errors" "fmt" "log/slog" "os" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // ErrNotFound is returned when the track id doesn't resolve. Aliased // to apierror.ErrNotFound so handlers can errors.Is against the shared // sentinel; existing tracks.ErrNotFound callsites still resolve to the // same pointer. var ErrNotFound = apierror.ErrNotFound // LidarrUnmonitorer is the subset of *lidarr.Client RemoveTrack uses. // Defined as an interface so tests can stub without spinning up a Lidarr // httptest server. UnmonitorTrack failures are non-fatal at the service // layer — the file + DB are already gone — so any error returned here // surfaces as a `lidarr_unmonitor_failed` flag in the response, not as // a hard error. type LidarrUnmonitorer interface { UnmonitorTrack(ctx context.Context, trackMbid, albumMbid string) error } // Service owns RemoveTrack. lidarr may be nil — when it is, the // unmonitor branch is skipped entirely (with `lidarrUnmonitorFailed` // remaining false) regardless of the unmonitor query param. This is // the right fallback when Lidarr isn't configured: file + DB delete // still happen. type Service struct { pool *pgxpool.Pool logger *slog.Logger lidarr LidarrUnmonitorer dataDir string } // NewService constructs a Service. logger may be nil (defaults to // slog.Default). lidarr may be nil to disable the unmonitor branch. // dataDir is the on-disk root for cached artifacts; used to clean up // artist-art on artist delete. Empty string disables the cleanup. func NewService(pool *pgxpool.Pool, logger *slog.Logger, lidarr LidarrUnmonitorer, dataDir string) *Service { if logger == nil { logger = slog.Default() } return &Service{pool: pool, logger: logger, lidarr: lidarr, dataDir: dataDir} } // RemoveTrack deletes the file from disk and the DB rows, runs the // album-empty / artist-empty cascade tidy-up, and (when unmonitor is // true and the track is Lidarr-managed) tells Lidarr to flip the track's // monitored flag off so it won't search for a replacement. // // Returns: // - deletedAlbumID: non-nil when removing the track left the album // empty and the album row was deleted. // - deletedArtistID: non-nil when both album AND artist were left // empty (only set if deletedAlbumID is also set). // - lidarrUnmonitorFailed: true when the operator requested unmonitor // and the Lidarr call failed; the file + DB delete still succeeded. // - err: only for failures *before* the destructive part completes. // A failed os.Remove is logged and tolerated. A failed Lidarr // unmonitor is reflected in the bool flag, not the error. // // adminID is currently unused — the cascade audit-log line that would // reference it isn't wired in this slice. It's threaded through the // signature so the upcoming admin_tracks handler doesn't have to // re-plumb when audit logging lands. func (s *Service) RemoveTrack( ctx context.Context, trackID, adminID pgtype.UUID, //nolint:revive // adminID reserved for audit-log wiring in a follow-up unmonitor bool, ) (deletedAlbumID *pgtype.UUID, deletedArtistID *pgtype.UUID, lidarrUnmonitorFailed bool, err error) { q := dbq.New(s.pool) track, err := q.GetTrackByID(ctx, trackID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil, false, ErrNotFound } return nil, nil, false, fmt.Errorf("get track: %w", err) } // Capture the album's mbid *before* the cascade-delete transaction. // If removing this track empties the album, DeleteAlbumIfEmpty // removes the row and a post-commit GetAlbumByID would return // pgx.ErrNoRows — leaving the unmonitor walk with no album mbid. var albumMbid string if track.Mbid != nil && *track.Mbid != "" && unmonitor && s.lidarr != nil { alb, alerr := q.GetAlbumByID(ctx, track.AlbumID) if alerr == nil && alb.Mbid != nil { albumMbid = *alb.Mbid } // Lookup failure is tolerated — handled below as // "no albumMbid → can't unmonitor → flag failure." } // Always: remove the file. Tolerate already-missing. if track.FilePath != "" { if rerr := os.Remove(track.FilePath); rerr != nil && !errors.Is(rerr, os.ErrNotExist) { s.logger.Warn("track delete: file remove failed", "path", track.FilePath, "track_id", trackID, "err", rerr) // Proceed: DB consistency is the priority. } } // DB cleanup in a transaction so a midway failure leaves things consistent. tx, err := s.pool.Begin(ctx) if err != nil { return nil, nil, false, fmt.Errorf("begin tx: %w", err) } defer func() { _ = tx.Rollback(ctx) }() tq := dbq.New(tx) deleted, err := tq.DeleteTrack(ctx, trackID) if err != nil { return nil, nil, false, fmt.Errorf("delete track: %w", err) } albumRow, aerr := tq.DeleteAlbumIfEmpty(ctx, deleted.AlbumID) switch { case aerr == nil: albumID := albumRow.ID deletedAlbumID = &albumID artistID, arerr := tq.DeleteArtistIfEmpty(ctx, albumRow.ArtistID) switch { case arerr == nil: id := artistID deletedArtistID = &id case errors.Is(arerr, pgx.ErrNoRows): // Artist still has other albums or stray tracks. OK. default: return nil, nil, false, fmt.Errorf("delete artist if empty: %w", arerr) } case errors.Is(aerr, pgx.ErrNoRows): // Album still has other tracks. OK. default: return nil, nil, false, fmt.Errorf("delete album if empty: %w", aerr) } if err := tx.Commit(ctx); err != nil { return nil, nil, false, fmt.Errorf("commit: %w", err) } // Cleanup artist-art filesystem cache if the delete cascade orphaned // an artist. Non-fatal — the destructive part is done; we just want // to keep the dataDir tidy. if deletedArtistID != nil && s.dataDir != "" { if err := coverart.CleanupArtistArt(s.dataDir, *deletedArtistID); err != nil { s.logger.Warn("track delete: artist-art cleanup failed", "artist_id", *deletedArtistID, "err", err) } } // Lidarr unmonitor — non-fatal. The destructive part is done; any // failure here is informational so the operator can retry manually. if unmonitor && track.Mbid != nil && *track.Mbid != "" && s.lidarr != nil { if albumMbid == "" { // Couldn't capture the album mbid (album row had nil mbid // or was missing somehow). The Lidarr walk needs it; mark // failure rather than calling with an empty string. s.logger.Warn("track delete: lidarr unmonitor skipped — no album mbid", "track_id", trackID, "track_mbid", *track.Mbid) lidarrUnmonitorFailed = true } else if uerr := s.lidarr.UnmonitorTrack(ctx, *track.Mbid, albumMbid); uerr != nil { s.logger.Warn("track delete: lidarr unmonitor failed", "track_id", trackID, "track_mbid", *track.Mbid, "err", uerr) lidarrUnmonitorFailed = true } } return deletedAlbumID, deletedArtistID, lidarrUnmonitorFailed, nil }