Files
minstrel/internal/tracks/service.go
T
bvandeusenandClaude Opus 5 d7a8e5f300
test-go / test (push) Failing after 55s
test-web / test (push) Successful in 56s
test-go / integration (push) Failing after 4m50s
android / Build + lint + test (push) Successful in 5m52s
release / Build signed APK (releases and dev) (push) Successful in 6m5s
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(library): a track delete that cannot remove its file deletes nothing — #3918
Two delete paths had opposite failure policies. tracks.RemoveTrack
logged a failed os.Remove and deleted the row anyway, which CASCADEs
likes, plays, playlist memberships and tags, while the file survived
for the next scan to re-import as a stranger. library.DeleteTrackFile
stopped correctly but reported it as a bare 500 nobody could read.

One path now: library.DeleteTrackFile removes the file first and, on
anything but ErrNotExist, returns *FileRemoveError with nothing
deleted. Only then does it delete the row and tidy an emptied album
and artist in one transaction, log the sync change and clear orphaned
artist art. RemoveTrack calls it, which also fixes RemoveTrack never
logging a sync change. Quarantine Delete file now tidies emptied
albums and artists too.

Both endpoints answer an unwritable library (EROFS, EACCES, EPERM) with
409 library_not_writable. The message names the directory (removal
writes to the parent), the uid:gid the server runs as, and that
nothing was deleted. Other remove errors are 500 file_delete_failed
with the path.

The reachable surface is quarantine Delete file, which failed
silently: no copy for the code on either client, and Android swallowed
the exception so the row just reappeared. Web and Android now have
copy for both codes and append the server message for exactly those
two. Android's quarantine screen shows it in a snackbar.

DELETE /api/admin/tracks/{id} has had no client since f7278f24, which
kept it on purpose for a safer admin surface, so its history loss was
latent. Fixed rather than removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 14:23:01 -04:00

150 lines
6.3 KiB
Go

// Package tracks owns the track-level admin actions behind DELETE
// /api/admin/tracks/{id}. Today that's RemoveTrack: the destructive part goes
// through library.DeleteTrackFile — the one path that deletes a track file —
// and 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."
//
// The web track-kebab entry that called this was removed in f7278f24; the
// endpoint was kept deliberately for a safer admin surface to rebind.
package tracks
import (
"context"
"errors"
"fmt"
"log/slog"
"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/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// 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 track's file and then its row, tidies away an album
// or artist the delete empties, 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: ErrNotFound, or a failure before anything was deleted. When the
// file cannot be removed it is a *library.FileRemoveError and NOTHING was
// deleted — see library.DeleteTrackFile for why that order is the contract
// (#3918). A failed Lidarr unmonitor is reflected in the bool, not here.
//
// 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 delete. If removing this track
// empties the album, its row is gone afterwards and the unmonitor walk
// would have no album mbid to name.
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."
}
deleted, err := library.DeleteTrackFile(ctx, s.pool, s.logger, s.dataDir, trackID)
if err != nil {
if errors.Is(err, library.ErrTrackNotFound) {
return nil, nil, false, ErrNotFound
}
return nil, nil, false, fmt.Errorf("delete track: %w", 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 deleted.AlbumID, deleted.ArtistID, lidarrUnmonitorFailed, nil
}