test-web / test (push) Successful in 57s
test-go / test (push) Successful in 1m16s
test-go / integration (push) Successful in 3m39s
release / Build signed APK (releases and dev) (push) Successful in 4m46s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Merge keeps one copy of a duplicate group and removes the rest. Every
table that references tracks does so ON DELETE CASCADE, so deleting a
duplicate's row outright would silently destroy its likes, plays,
playlist entries and tags. The merge moves all of that onto the kept
copy first, then deletes the empty row.
In one transaction, holding a lock on the group:
- repoints play_events, skip_events, contextual_likes, playback_errors,
lidarr_requests.matched_track_id and playlist_tracks. The last is
keyed by position, so every entry stays where it was.
- merges general_likes one per user, dated to the earlier like
- takes the union of track_tags, keeping the kept copy's own weight on
a shared tag
- rewrites track_similarity onto the kept copy, dropping edges that
would point a track at itself and keeping the kept copy's existing
edge on a collision
- lets the kept copy take a recording MBID only the removed copy had
- deletes the removed copies' rows, tidies emptied albums and artists,
marks the group merged
- logs sync changes: track deletes, and like and playlist-track
delete/upsert pairs
The removed copies' files are deleted first, before any row changes,
through the same helper as DeleteTrackFile (now shared, along with the
album tidy-up). A merge that left the file behind would be undone by
the next scan re-importing it. An unwritable library answers 409
library_not_writable and nothing changes.
tracks.Service.MergeDuplicates wraps it with the opt-in Lidarr unmonitor
from RemoveTrack, skipped when the removed copy is a second file of the
kept copy's own album track: unmonitoring that would stop Lidarr
managing the kept file. It writes a duplicate_merge audit row after
commit, per the audit package's best-effort contract, naming both
paths.
POST /api/admin/library/duplicates/{id}/merge takes an optional
survivor_track_id (the report's proposal otherwise) and unmonitor.
On the report page:
- each copy gets a Keep choice, defaulting to the proposed one
- Merge needs a second click, on a button that says how many files it
removes, with the consequence stated beside an opt-in Lidarr checkbox
Integration tests cover:
- every piece of history landing on the kept copy exactly: likes
deduped at the earlier time, plays and skips counted, playlist
position unchanged, tags unioned, similarity rewritten with no
duplicate or self-edge, MBID inherited
- the removed file gone, and a second merge refused
- an unwritable file leaving likes, plays, row and group untouched
- a survivor outside the group refused
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
219 lines
9.3 KiB
Go
219 lines
9.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/audit"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
|
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
|
|
|
|
// 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.MergeDuplicateGroup), 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) {
|
|
res, err = library.MergeDuplicateGroup(ctx, s.pool, s.logger, s.dataDir, groupID, survivorID)
|
|
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
|
|
}
|
|
|
|
// 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 != ""
|
|
}
|