// Package tracks owns the track-level admin actions exposed by the // M7 #372 track-actions menu. Today that's RemoveTrack, which deletes // a single track (Lidarr-managed via lidarrquarantine.DeleteViaLidarr, // or local via os.Remove) and cascades the album-empty / artist-empty // tidy-up so we don't leak orphan rows. 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/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" ) // Public errors. Handlers map these to API codes. var ( ErrNotFound = errors.New("tracks: not found") ErrLidarrUnreachable = errors.New("tracks: lidarr unreachable") ErrLidarrUnauthorized = errors.New("tracks: lidarr unauthorized") ErrLidarrServerError = errors.New("tracks: lidarr server error") ) // LidarrDeleter is the subset of *lidarrquarantine.Service the tracks // service needs. Defined as an interface so tests can stub without // spinning up a real Lidarr stub. type LidarrDeleter interface { DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) } // Service owns RemoveTrack. lidarrDeleter may be nil — non-Lidarr-managed // tracks (no mbid) still work fine; a Lidarr-managed track with a nil // deleter falls back to direct os.Remove of the local file. type Service struct { pool *pgxpool.Pool logger *slog.Logger lidarrDeleter LidarrDeleter } // NewService constructs a Service. logger may be nil (defaults to // slog.Default). lidarrDeleter may be nil to disable the Lidarr-aware // path entirely. func NewService(pool *pgxpool.Pool, logger *slog.Logger, lidarrDeleter LidarrDeleter) *Service { if logger == nil { logger = slog.Default() } return &Service{pool: pool, logger: logger, lidarrDeleter: lidarrDeleter} } // RemoveTrack deletes the track, runs the album-empty / artist-empty // cascade, and returns the IDs of any rows deleted by the cascade so // the API caller can invalidate caches. // // For Lidarr-managed tracks (mbid set on the track), Lidarr is told to // delete the file first via lidarrquarantine.DeleteViaLidarr — this // sets the import-list-exclusion flag so the next sync doesn't re-import // the file. For local tracks (no mbid), os.Remove handles the file. // // Lidarr errors propagate as typed errors and the DB is left untouched // so the operator can retry. A missing local file is tolerated — DB // consistency takes priority over a noisy filesystem. func (s *Service) RemoveTrack(ctx context.Context, trackID, adminID pgtype.UUID) (deletedAlbumID, deletedArtistID *pgtype.UUID, 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, ErrNotFound } return nil, nil, fmt.Errorf("get track: %w", err) } // Lidarr-managed → Lidarr deletes the album (and so the file) first. // The lidarrquarantine path also deletes the local rows for every // track on the album, so we pre-empt the cascade-tidy step here. If // the lidarr deleter is nil, fall through to the local os.Remove path. if track.Mbid != nil && *track.Mbid != "" && s.lidarrDeleter != nil { _, _, lerr := s.lidarrDeleter.DeleteViaLidarr(ctx, trackID, adminID) switch { case lerr == nil: // lidarrquarantine.DeleteViaLidarr already removed the tracks // row(s) for the album. Cascade cleanup still needs to run // against the album/artist IDs we captured before the call. return s.cascadeAfterLidarr(ctx, q, track) case errors.Is(lerr, lidarr.ErrUnreachable): return nil, nil, ErrLidarrUnreachable case errors.Is(lerr, lidarr.ErrAuthFailed): return nil, nil, ErrLidarrUnauthorized case errors.Is(lerr, lidarr.ErrServerError): return nil, nil, ErrLidarrServerError default: return nil, nil, fmt.Errorf("lidarr delete: %w", lerr) } } // Local path: best-effort os.Remove, then DB cleanup in a tx. 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. } } return s.deleteAndCascade(ctx, trackID) } // deleteAndCascade runs DeleteTrack → DeleteAlbumIfEmpty → // DeleteArtistIfEmpty in a single transaction. Returns the cascaded // album / artist IDs (nil pointers when the cascade didn't fire because // other tracks/albums still reference the parent). func (s *Service) deleteAndCascade(ctx context.Context, trackID pgtype.UUID) (deletedAlbumID, deletedArtistID *pgtype.UUID, err error) { tx, err := s.pool.Begin(ctx) if err != nil { return nil, nil, 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, 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 default: return nil, nil, fmt.Errorf("delete artist if empty: %w", arerr) } case errors.Is(aerr, pgx.ErrNoRows): // album still has other tracks default: return nil, nil, fmt.Errorf("delete album if empty: %w", aerr) } if err := tx.Commit(ctx); err != nil { return nil, nil, fmt.Errorf("commit: %w", err) } return deletedAlbumID, deletedArtistID, nil } // cascadeAfterLidarr runs the album-empty / artist-empty checks against // the album/artist IDs from the track snapshot we took before delegating // to lidarrquarantine. lidarrquarantine.DeleteViaLidarr already deleted // the tracks row(s) for the entire album, so this is a tidy-up of the // now-orphan album + artist parents. func (s *Service) cascadeAfterLidarr(ctx context.Context, q *dbq.Queries, track dbq.Track) (deletedAlbumID, deletedArtistID *pgtype.UUID, err error) { albumRow, aerr := q.DeleteAlbumIfEmpty(ctx, track.AlbumID) switch { case aerr == nil: albumID := albumRow.ID deletedAlbumID = &albumID artistID, arerr := q.DeleteArtistIfEmpty(ctx, albumRow.ArtistID) switch { case arerr == nil: id := artistID deletedArtistID = &id case errors.Is(arerr, pgx.ErrNoRows): // artist still has other albums default: return nil, nil, fmt.Errorf("delete artist if empty: %w", arerr) } case errors.Is(aerr, pgx.ErrNoRows): // album still has other tracks (unexpected after Lidarr delete, // but tolerate — the operator may have re-imported between calls) default: return nil, nil, fmt.Errorf("delete album if empty: %w", aerr) } return deletedAlbumID, deletedArtistID, nil }