295d9da361
Parallels EnrichAlbum but with no sidecar layer (artists have no per-artist directory in a music library). Iterates EnabledArtistProviders, writes thumb.jpg + fanart.jpg to <data_dir>/artist-art/<artist_id>/, stamps artist_art_source + artist_art_sources_version atomically. Atomicity: provider returns whichever images it has — partial success (thumb-only or fanart-only) is persisted with whichever paths landed, source still stamped. ErrNotFound from every enabled provider settles the row to 'none' with the current version stamp. Any ErrTransient leaves the row NULL for next-pass retry. CleanupArtistArt removes <data_dir>/artist-art/<artist_id>/ on artist delete; idempotent on missing dir. Hooked into the artist- delete cascade in tracks/service.go after the transaction commits; non-fatal on failure (logs but doesn't fail the delete). EnrichArtistBatch drains rows from ListArtistsMissingArt; the orchestrator's 4th stage (added in T10) calls this. Tests cover the eligibility table, atomicity (thumb-only / fanart-only), all-404-settles, ErrTransient-leaves-NULL, filesystem layout, and CleanupArtistArt idempotency. tracks.Service gains a dataDir field; tracks.NewService takes it as a new parameter. All three call sites updated (server/server.go, api/auth_test.go, api/admin_tracks_test.go). The dataDir flows from cfg.Storage.DataDir → server.New → s.DataDir → tracks.NewService without any change to cmd/minstrel/main.go.
195 lines
7.5 KiB
Go
195 lines
7.5 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/coverart"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// ErrNotFound is returned when the track id doesn't resolve. Handler
|
|
// maps to 404 `not_found`.
|
|
var ErrNotFound = errors.New("tracks: not found")
|
|
|
|
// 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
|
|
}
|