198 lines
7.7 KiB
Go
198 lines
7.7 KiB
Go
// 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
|
|
}
|