50a231fdc1
RemoveTrack handles both Lidarr-managed (delegates to existing lidarrquarantine.DeleteViaLidarr) and non-Lidarr (direct os.Remove) file deletion, then runs the cascade album-if-empty / artist-if-empty DB cleanup in a single transaction. Lidarr errors propagate as typed errors so the API layer can map them to the existing wire codes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
6.9 KiB
Go
189 lines
6.9 KiB
Go
// 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
|
|
}
|