release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicate sweep proposed 4,197 groups and every one waited for the operator. Most are safe to settle, and Lidarr defines what safe means: it maps one file to each track of the release it monitors and downloads any mapped file that disappears. Deleting a mapped copy opens exactly the hole the operator saw Lidarr fill. Classify (#5435) - Migration 0075: duplicate_groups.class (same_release, cross_release, mismatch, review), resolve_note, resolved_automatically; duplicate_group_members.lidarr_state (tracked, unmapped); fingerprint_settings.auto_resolve; notification kind duplicates_resolved with both kind CHECKs swapped (rule 36). - library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring credits, remaster notes and video-rip markers, and keeping live, demo, remix and instrumental. The rip markers move from api to library. Choose the copy to keep (#5436) - ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a clash-free track number, no rip marker in the name, an MBID), then the quality rules. File size picked the wrong Humanz copy in 6 of 21 groups. Act (#5437) - An hourly resolver pass reads Lidarr's unmapped files, matched by the last three path components, and records each copy's state. - Same album, with at most one copy mapped: merged into the mapped copy. The merge is guarded, so a mapped copy can never be removed (MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr). - Same album, every copy mapped: the monitored release lists the song twice (Humanz's 14x12" box set). The pass moves Lidarr to the release that lists each song once and best covers what is on disk. It never picks one covering less, and is capped at 10 albums per pass. - Fixed point (lesson #4183): the chosen release no longer repeats. - The album is left alone for 24h while Lidarr rescans, so "every copy unmapped" mid-rescan is never read as licence to merge. - Both actions are audited with no actor and summarised to admins. The operator can switch them off in the Fingerprinting card (rule 25). - Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503 lidarr_unavailable when Lidarr cannot say. Web - Duplicates gets tabs: Needs review, Across releases, Resolved automatically. Each loads as you scroll (rule 172), replacing the pager. - Each copy says whether Lidarr uses it. - The resolver's note shows on each group. - The merge confirm blocks, before sending, a merge that would remove the copy Lidarr uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
11 KiB
Go
265 lines
11 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/audit"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
|
)
|
|
|
|
// 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
|
|
|
|
// ErrLidarrDisabled is what a LidarrUnmonitorer that also lists Lidarr's
|
|
// unmapped files returns when Lidarr is turned off: there is then no Lidarr
|
|
// record a removal could break, and a merge goes ahead unguarded.
|
|
var ErrLidarrDisabled = errors.New("tracks: lidarr disabled")
|
|
|
|
// ErrLidarrUnavailable means Lidarr is on but did not answer, so a merge cannot
|
|
// confirm the copies it would remove are ones Lidarr does not need (M498), and
|
|
// refuses rather than guess.
|
|
var ErrLidarrUnavailable = errors.New("tracks: lidarr did not answer")
|
|
|
|
// lidarrFileLister is the optional part of the Lidarr dependency a merge uses to
|
|
// learn which files Lidarr holds without mapping to any track.
|
|
type lidarrFileLister interface {
|
|
ListUnmappedTrackFiles(ctx context.Context) ([]lidarr.TrackFile, error)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// MergeDuplicates merges a duplicate group into the copy to keep
|
|
// (library.MergeDuplicateGroupGuarded, refusing to remove a copy Lidarr maps:
|
|
// library.ErrCopyTrackedByLidarr, or ErrLidarrUnavailable when Lidarr cannot
|
|
// say), then — when asked — tells Lidarr to stop
|
|
// monitoring the removed copies so it does not download them again, and records
|
|
// the merge in the audit log.
|
|
//
|
|
// lidarrUnmonitorFailed reports that unmonitoring was asked for and at least one
|
|
// removed copy could not be unmonitored. Like RemoveTrack, that never fails the
|
|
// merge: the files and rows are already gone.
|
|
func (s *Service) MergeDuplicates(
|
|
ctx context.Context, groupID, survivorID, actorID pgtype.UUID, unmonitor bool,
|
|
) (res library.MergeResult, lidarrUnmonitorFailed bool, err error) {
|
|
removable, err := s.lidarrRemovable(ctx)
|
|
if err != nil {
|
|
return res, false, err
|
|
}
|
|
res, err = library.MergeDuplicateGroupGuarded(ctx, s.pool, s.logger, s.dataDir, groupID, survivorID, removable)
|
|
if err != nil {
|
|
return res, false, err
|
|
}
|
|
|
|
if unmonitor && s.lidarr != nil {
|
|
for _, removed := range res.Removed {
|
|
if sameLidarrTrack(res.Survivor, removed) {
|
|
continue
|
|
}
|
|
if !hasLidarrIdentity(removed) {
|
|
s.logger.Warn("duplicate merge: lidarr unmonitor skipped — removed copy has no mbids",
|
|
"track_id", syncpkg.FormatUUID(removed.TrackID))
|
|
lidarrUnmonitorFailed = true
|
|
continue
|
|
}
|
|
if uerr := s.lidarr.UnmonitorTrack(ctx, *removed.TrackMbid, *removed.AlbumMbid); uerr != nil {
|
|
s.logger.Warn("duplicate merge: lidarr unmonitor failed",
|
|
"track_id", syncpkg.FormatUUID(removed.TrackID), "err", uerr)
|
|
lidarrUnmonitorFailed = true
|
|
}
|
|
}
|
|
}
|
|
|
|
removed := make([]map[string]string, 0, len(res.Removed))
|
|
for _, c := range res.Removed {
|
|
removed = append(removed, map[string]string{"track_id": syncpkg.FormatUUID(c.TrackID), "file_path": c.FilePath})
|
|
}
|
|
audit.WriteOrLog(ctx, s.pool, s.logger, actorID, pgtype.UUID{}, audit.ActionDuplicateMerge, map[string]any{
|
|
"group_id": syncpkg.FormatUUID(groupID),
|
|
"tier": res.Tier,
|
|
"survivor_track_id": syncpkg.FormatUUID(res.Survivor.TrackID),
|
|
"survivor_path": res.Survivor.FilePath,
|
|
"removed": removed,
|
|
"moved": map[string]any{
|
|
"play_events": res.PlayEvents, "skip_events": res.SkipEvents,
|
|
"likes": res.Likes, "playlist_entries": res.PlaylistEntries,
|
|
},
|
|
})
|
|
return res, lidarrUnmonitorFailed, nil
|
|
}
|
|
|
|
// lidarrRemovable is the merge's guard (M498): only a copy Lidarr holds without
|
|
// mapping it to a track may be removed, because removing a mapped one makes
|
|
// Lidarr download it again. Nil, allowing everything, when Lidarr is disabled
|
|
// or the dependency cannot list files.
|
|
func (s *Service) lidarrRemovable(ctx context.Context) (library.RemovableFunc, error) {
|
|
lister, ok := s.lidarr.(lidarrFileLister)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
files, err := lister.ListUnmappedTrackFiles(ctx)
|
|
if errors.Is(err, ErrLidarrDisabled) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrLidarrUnavailable, err)
|
|
}
|
|
unmapped := make(map[string]bool, len(files))
|
|
for _, f := range files {
|
|
unmapped[library.LidarrPathKey(f.Path)] = true
|
|
}
|
|
return func(p string) bool { return unmapped[library.LidarrPathKey(p)] }, nil
|
|
}
|
|
|
|
// sameLidarrTrack reports whether two copies are the same Lidarr track: one
|
|
// recording on one album. Lidarr monitors per album track, so when the removed
|
|
// copy is a second file of the kept copy's own album track — the #3885 case —
|
|
// unmonitoring it would also stop Lidarr managing the file the operator chose to
|
|
// keep. Those are skipped, and are not a failure.
|
|
func sameLidarrTrack(a, b library.MergedCopy) bool {
|
|
return hasLidarrIdentity(a) && hasLidarrIdentity(b) &&
|
|
*a.TrackMbid == *b.TrackMbid && *a.AlbumMbid == *b.AlbumMbid
|
|
}
|
|
|
|
func hasLidarrIdentity(c library.MergedCopy) bool {
|
|
return c.TrackMbid != nil && *c.TrackMbid != "" && c.AlbumMbid != nil && *c.AlbumMbid != ""
|
|
}
|