feat: duplicates resolve themselves without Lidarr (M502 #5517)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped
Lidarr is off, and with it the resolver had stopped merging: it only merged when Lidarr had said which copies it mapped. Minstrel is now the only program writing the library, so the rule is about the copies: - A song held twice on one album: the extra copies merge into the one whose tags fit best (then lossless, larger, older). - Across releases or under another title: when every copy but one is a video rip (held back, or named like one and not marked fine), the rips merge into the one that is not. - Clean copies on different releases are linked as one song, as before. - Anything else waits for the operator. Gone with Lidarr: release switches and their settle window, the unmapped-file read, the rip search, the merge guard and its 409/503, lidarr.Client's release methods, and duplicate_group_members.lidarr_state (migration 0078). The duplicates page drops the per-copy Lidarr badge and the blocked-merge notice; past release changes stay in its history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,11 +13,11 @@ import (
|
||||
// 498's body) splits into four shapes:
|
||||
//
|
||||
// - same_release: every copy is on one album. An album holds a song once, so
|
||||
// the extra copies are the ones to fold away — when Lidarr does not need
|
||||
// them (the resolver checks).
|
||||
// the extra copies are the ones to fold away.
|
||||
// - cross_release: the copies are on different albums under one title. A
|
||||
// single and the album it came from, a deluxe edition and the standard one:
|
||||
// each copy fulfils its own release in Lidarr, so none is deleted.
|
||||
// each copy belongs to its own release, so none is deleted (unless it is a
|
||||
// video rip; the resolver decides).
|
||||
// - mismatch: byte-identical audio on different albums under different
|
||||
// titles. One file carries another song's tags — a wrong-file import.
|
||||
// - review: anything else. The operator decides.
|
||||
@@ -123,13 +123,6 @@ func MatchTitleKey(title, artist string) string {
|
||||
return alnumKey(title)
|
||||
}
|
||||
|
||||
// ReleaseTitleKey is the lighter comparison for asking whether one release
|
||||
// lists a song twice: case, "&" and punctuation only. Every word stays, so a
|
||||
// box set's "Song" and "Song (demo)" are two songs, as the release means them.
|
||||
func ReleaseTitleKey(title string) string {
|
||||
return alnumKey(title)
|
||||
}
|
||||
|
||||
// alnumKey lowercases, reads "&" as "and", and keeps only letters and digits.
|
||||
func alnumKey(s string) string {
|
||||
s = strings.ReplaceAll(strings.ToLower(s), "&", " and ")
|
||||
|
||||
@@ -59,13 +59,3 @@ func TestMatchTitleKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The release check keeps every word: a box set's demo is its own song.
|
||||
func TestReleaseTitleKey(t *testing.T) {
|
||||
if ReleaseTitleKey("Song (demo)") == ReleaseTitleKey("Song") {
|
||||
t.Error("ReleaseTitleKey collapsed a demo into the song")
|
||||
}
|
||||
if ReleaseTitleKey("Hallelujah Money") != ReleaseTitleKey("hallelujah money!") {
|
||||
t.Error("ReleaseTitleKey kept case or punctuation")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,16 +25,6 @@ var ErrDuplicateGroupNotPending = errors.New("library: duplicate group is not pe
|
||||
// ErrSurvivorNotInGroup means the copy chosen to keep is not a member of the group.
|
||||
var ErrSurvivorNotInGroup = errors.New("library: survivor is not a member of the group")
|
||||
|
||||
// ErrCopyTrackedByLidarr means a copy the merge would remove is one Lidarr maps
|
||||
// to a track of the release it monitors (M498). Removing it would open a hole
|
||||
// that Lidarr fills by downloading the song again, so the merge refuses and
|
||||
// changes nothing.
|
||||
var ErrCopyTrackedByLidarr = errors.New("library: a copy to remove is tracked by Lidarr")
|
||||
|
||||
// RemovableFunc reports whether the file at filePath may be removed. A merge
|
||||
// asks it about every copy it would remove, before touching anything.
|
||||
type RemovableFunc func(filePath string) bool
|
||||
|
||||
// MergedCopy is one copy a merge kept or removed.
|
||||
type MergedCopy struct {
|
||||
TrackID pgtype.UUID
|
||||
@@ -85,17 +75,6 @@ type MergeResult struct {
|
||||
func MergeDuplicateGroup(
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
|
||||
groupID, survivorID pgtype.UUID,
|
||||
) (MergeResult, error) {
|
||||
return MergeDuplicateGroupGuarded(ctx, pool, logger, dataDir, groupID, survivorID, nil)
|
||||
}
|
||||
|
||||
// MergeDuplicateGroupGuarded is MergeDuplicateGroup with a check on what it may
|
||||
// remove: when removable says no to any copy that would go, the merge returns
|
||||
// ErrCopyTrackedByLidarr (wrapped with the path) and changes nothing. A nil
|
||||
// removable allows every copy.
|
||||
func MergeDuplicateGroupGuarded(
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
|
||||
groupID, survivorID pgtype.UUID, removable RemovableFunc,
|
||||
) (MergeResult, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
@@ -130,13 +109,6 @@ func MergeDuplicateGroupGuarded(
|
||||
return MergeResult{}, err
|
||||
}
|
||||
|
||||
if removable != nil {
|
||||
for _, l := range losers {
|
||||
if !removable(l.FilePath) {
|
||||
return MergeResult{}, fmt.Errorf("%w: %s", ErrCopyTrackedByLidarr, l.FilePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, l := range losers {
|
||||
if err := removeTrackFileOnDisk(l.FilePath); err != nil {
|
||||
return MergeResult{}, err
|
||||
|
||||
@@ -2,12 +2,9 @@ package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -16,100 +13,46 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
// Duplicate resolver (M498 #5435, #5437).
|
||||
// Duplicate resolver (M498 #5435, #5437; without Lidarr since M502 #5517).
|
||||
//
|
||||
// The sweep proposes duplicate groups; the resolver decides what each one is
|
||||
// and settles the ones that are safe to settle without asking. Safety is
|
||||
// Lidarr's to define: Lidarr maps one file to each track of the release it
|
||||
// monitors, and re-downloads any mapped file that disappears. So:
|
||||
// and settles the ones that are safe to settle without asking. Minstrel is the
|
||||
// only program writing the library, so no copy is kept because something else
|
||||
// depends on it. What decides is what the copies are:
|
||||
//
|
||||
// - A copy Lidarr maps is never removed.
|
||||
// - On one album, copies Lidarr does not map are merged into the copy it does
|
||||
// (or, when it maps none, into the best by ProposeSurvivor).
|
||||
// - When Lidarr maps every copy on one album, the release it monitors lists
|
||||
// the song twice: the Humanz box set, 14 vinyl sides then the digital
|
||||
// medium. The resolver moves Lidarr to a release of that album that lists
|
||||
// each song once. Lidarr then rescans the folder, maps one copy and leaves
|
||||
// the rest unmapped, and the next pass merges them.
|
||||
//
|
||||
// The release change has a fixed point (lesson #4183): it happens only while
|
||||
// the monitored release repeats a title, and the release it chooses repeats
|
||||
// none, so the next pass finds nothing to change.
|
||||
|
||||
// LidarrLibrary is the part of Lidarr's API the resolver uses. *lidarr.Client
|
||||
// satisfies it.
|
||||
type LidarrLibrary interface {
|
||||
ListUnmappedTrackFiles(ctx context.Context) ([]lidarr.TrackFile, error)
|
||||
LookupAlbumByMBID(ctx context.Context, mbid string) (lidarr.LidarrAlbum, error)
|
||||
ListAlbumTracks(ctx context.Context, albumID int) ([]lidarr.ReleaseTrack, error)
|
||||
GetAlbumReleases(ctx context.Context, albumID int) ([]lidarr.AlbumRelease, error)
|
||||
ListReleaseTracks(ctx context.Context, releaseID int) ([]lidarr.ReleaseTrack, error)
|
||||
SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error
|
||||
SearchAlbums(ctx context.Context, albumIDs []int) error
|
||||
}
|
||||
// - On one album, a song is held once: the extra copies are merged into the
|
||||
// best by ProposeSurvivor.
|
||||
// - Across releases, each copy belongs to its own release (a single and its
|
||||
// album): nothing is removed, and the copies are linked as one song.
|
||||
// - Except that a video rip is no release's copy. When every copy but one is
|
||||
// a rip, across releases or under another title, the rips are merged into
|
||||
// the one that is not.
|
||||
// - Anything else waits for the operator.
|
||||
|
||||
const (
|
||||
// resolveMergeCap bounds the merges one pass makes. The first pass after
|
||||
// deploy meets the whole backlog (433 groups measured on 2026-10-09); a
|
||||
// cap spreads it over a few hours, each pass short.
|
||||
// deploy meets the whole backlog; a cap spreads it over a few hours, each
|
||||
// pass short.
|
||||
resolveMergeCap = 200
|
||||
// resolveReleaseCap bounds the Lidarr release changes one pass makes. Each
|
||||
// makes Lidarr rescan an artist folder.
|
||||
resolveReleaseCap = 10
|
||||
// releaseChangeSettle is how long after a release change the resolver
|
||||
// leaves that album's groups alone. Lidarr unmaps every file of the album
|
||||
// and rescans; until the rescan lands, every copy reads as unmapped, and a
|
||||
// merge then could remove the very file Lidarr is about to map.
|
||||
releaseChangeSettle = 24 * time.Hour
|
||||
// duplicateResolveTick is how often the worker runs a pass.
|
||||
duplicateResolveTick = time.Hour
|
||||
)
|
||||
|
||||
const (
|
||||
lidarrStateTracked = "tracked"
|
||||
lidarrStateUnmapped = "unmapped"
|
||||
)
|
||||
|
||||
// ReleaseChange is one Lidarr release the resolver switched.
|
||||
type ReleaseChange struct {
|
||||
AlbumTitle string
|
||||
ArtistName string
|
||||
From, To lidarr.AlbumRelease
|
||||
}
|
||||
|
||||
// DuplicateResolveResult tallies one pass.
|
||||
type DuplicateResolveResult struct {
|
||||
Groups int
|
||||
Classes map[DuplicateClass]int
|
||||
LidarrConsulted bool
|
||||
Merged int
|
||||
MergeFailed int
|
||||
ReleaseChanges []ReleaseChange
|
||||
Groups int
|
||||
Classes map[DuplicateClass]int
|
||||
Merged int
|
||||
MergeFailed int
|
||||
// SongsLinked counts cross-release groups newly linked as one song.
|
||||
SongsLinked int
|
||||
// HeldBack counts tracks newly held back from radio and the mixes as video
|
||||
// rips; Released counts held-back tracks whose name no longer says so.
|
||||
HeldBack, Released int64
|
||||
// RipSearches counts albums Lidarr was asked to search for a better copy
|
||||
// of a rip that has no clean copy (#5447).
|
||||
RipSearches int
|
||||
}
|
||||
|
||||
// LidarrPathKey is the part of a path Minstrel and Lidarr agree on: the last
|
||||
// three components (artist folder, album folder, file). Both see the library
|
||||
// under their own mount; on the operator's deploy both happen to say /music,
|
||||
// and the key keeps the match from depending on that.
|
||||
func LidarrPathKey(p string) string {
|
||||
parts := strings.Split(strings.Trim(p, "/"), "/")
|
||||
if len(parts) > 3 {
|
||||
parts = parts[len(parts)-3:]
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// resolveGroup is one pending group and its present copies.
|
||||
@@ -119,53 +62,52 @@ type resolveGroup struct {
|
||||
members []dbq.ListDuplicateGroupsForResolveRow
|
||||
class DuplicateClass
|
||||
note string
|
||||
states []string // per member: tracked, unmapped, or "" when Lidarr was not asked
|
||||
}
|
||||
|
||||
// autoMergeable says whether the resolver may merge the group on its own: when
|
||||
// every copy it would remove is one Lidarr does not map (D-a rule 1). On one
|
||||
// album that is any group with at most one mapped copy. Across releases each
|
||||
// mapped copy fulfils its own release (D-b), so only a group with exactly one
|
||||
// mapped copy qualifies: the others fulfil nothing — a rip beside the clean
|
||||
// copy on another release, or a wrong-file import Lidarr holds unmapped
|
||||
// (#5439) — and the mapped copy is the clear one to keep.
|
||||
func autoMergeable(g *resolveGroup) bool {
|
||||
// isRip says whether a copy is a video rip: held back as one, or named like
|
||||
// one and not kept by the operator. The name is read as well as the verdict
|
||||
// because a pass reads the groups before it flags new rips.
|
||||
func isRip(m dbq.ListDuplicateGroupsForResolveRow) bool {
|
||||
if m.SourceVerdict != nil {
|
||||
return *m.SourceVerdict == "suspect"
|
||||
}
|
||||
return len(SourceMarkersFor(m.FilePath)) > 0
|
||||
}
|
||||
|
||||
// lone returns the index of the group's only copy that is not a rip, or -1
|
||||
// when there is none or more than one.
|
||||
func (g *resolveGroup) lone() int {
|
||||
at := -1
|
||||
for i, m := range g.members {
|
||||
if isRip(m) {
|
||||
continue
|
||||
}
|
||||
if at >= 0 {
|
||||
return -1
|
||||
}
|
||||
at = i
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// mergePlan says whether the resolver may merge the group on its own, and into
|
||||
// which copy (an index into members; -1 means ProposeSurvivor decides).
|
||||
func mergePlan(g *resolveGroup) (ok bool, survivor int) {
|
||||
switch g.class {
|
||||
case ClassSameRelease:
|
||||
return g.tracked() <= 1
|
||||
return true, -1
|
||||
case ClassCrossRelease, ClassMismatch:
|
||||
return g.tracked() == 1
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// settling says whether any of the group's albums had its Lidarr release
|
||||
// changed recently, so Lidarr may still be remapping its files.
|
||||
func (g *resolveGroup) settling(albums map[string]bool) bool {
|
||||
for _, m := range g.members {
|
||||
if albums[syncpkg.FormatUUID(m.AlbumID)] {
|
||||
return true
|
||||
if i := g.lone(); i >= 0 {
|
||||
return true, i
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false, -1
|
||||
}
|
||||
|
||||
func (g *resolveGroup) tracked() int {
|
||||
n := 0
|
||||
for _, s := range g.states {
|
||||
if s == lidarrStateTracked {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ResolveDuplicates runs one pass. lid is nil when Lidarr is disabled; act is
|
||||
// the operator's auto-resolve setting. Without Lidarr, or with act off, the
|
||||
// pass only classifies: nothing is removed when Lidarr cannot say what is safe.
|
||||
// ResolveDuplicates runs one pass. act is the operator's auto-resolve setting;
|
||||
// with it off, the pass only classifies.
|
||||
func ResolveDuplicates(
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, lid LidarrLibrary, act bool,
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, act bool,
|
||||
) (DuplicateResolveResult, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
@@ -180,32 +122,10 @@ func ResolveDuplicates(
|
||||
groups := foldResolveGroups(rows)
|
||||
res.Groups = len(groups)
|
||||
|
||||
var unmapped map[string]bool
|
||||
if lid != nil {
|
||||
files, err := lid.ListUnmappedTrackFiles(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("duplicate resolve: Lidarr's unmapped files unavailable; classifying only", "err", err)
|
||||
} else {
|
||||
unmapped = make(map[string]bool, len(files))
|
||||
for _, f := range files {
|
||||
unmapped[LidarrPathKey(f.Path)] = true
|
||||
}
|
||||
res.LidarrConsulted = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, g := range groups {
|
||||
cm := make([]ClassifyMember, len(g.members))
|
||||
g.states = make([]string, len(g.members))
|
||||
for i, m := range g.members {
|
||||
cm[i] = ClassifyMember{AlbumID: syncpkg.FormatUUID(m.AlbumID), Title: m.Title, ArtistName: m.ArtistName}
|
||||
if res.LidarrConsulted {
|
||||
if unmapped[LidarrPathKey(m.FilePath)] {
|
||||
g.states[i] = lidarrStateUnmapped
|
||||
} else {
|
||||
g.states[i] = lidarrStateTracked
|
||||
}
|
||||
}
|
||||
}
|
||||
g.class = ClassifyDuplicateGroup(g.tier, cm)
|
||||
g.note = resolveNote(g)
|
||||
@@ -219,31 +139,15 @@ func ResolveDuplicates(
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Holding rips back and linking songs remove nothing and need nothing from
|
||||
// Lidarr.
|
||||
if res.HeldBack, err = q.FlagSuspectSourceTracks(ctx, SuspectSourcePattern); err != nil {
|
||||
return res, fmt.Errorf("hold back video rips: %w", err)
|
||||
}
|
||||
if res.Released, err = q.ClearStaleSuspectSourceTracks(ctx, SuspectSourcePattern); err != nil {
|
||||
return res, fmt.Errorf("release renamed tracks: %w", err)
|
||||
}
|
||||
linked, err := linkCrossRelease(ctx, pool, logger, groups)
|
||||
res.SongsLinked = linked
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
if !res.LidarrConsulted {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
settling, err := recentlyChangedAlbums(ctx, q, time.Now())
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
// Merges first, on the state Lidarr reported at the start of the pass.
|
||||
removable := func(p string) bool { return unmapped[LidarrPathKey(p)] }
|
||||
// Merges before links, so a rip merged away is not linked first.
|
||||
merged := map[pgtype.UUID]bool{}
|
||||
for _, g := range groups {
|
||||
if res.Merged >= resolveMergeCap {
|
||||
break
|
||||
@@ -251,35 +155,29 @@ func ResolveDuplicates(
|
||||
if ctx.Err() != nil {
|
||||
return res, ctx.Err()
|
||||
}
|
||||
if !autoMergeable(g) || g.settling(settling) {
|
||||
ok, survivor := mergePlan(g)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := autoMerge(ctx, pool, logger, dataDir, g, removable); err != nil {
|
||||
if err := autoMerge(ctx, pool, logger, dataDir, g, survivor); err != nil {
|
||||
res.MergeFailed++
|
||||
logger.Warn("duplicate resolve: merge failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
|
||||
continue
|
||||
}
|
||||
merged[g.id] = true
|
||||
res.Merged++
|
||||
}
|
||||
|
||||
// Then release changes, for albums where Lidarr maps every copy.
|
||||
changes, err := changeRepeatingReleases(ctx, q, pool, logger, lid, groups, settling)
|
||||
res.ReleaseChanges = changes
|
||||
linked, err := linkCrossRelease(ctx, pool, logger, groups, merged)
|
||||
res.SongsLinked = linked
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
// Last, rips with no clean copy anywhere: ask Lidarr for something better.
|
||||
searched, err := searchSoleCopyRips(ctx, q, pool, logger, lid)
|
||||
res.RipSearches = searched
|
||||
if err != nil {
|
||||
logger.Warn("duplicate resolve: rip search failed", "err", err)
|
||||
}
|
||||
|
||||
if res.Merged > 0 || len(changes) > 0 {
|
||||
if res.Merged > 0 {
|
||||
notifyAdmins(ctx, notifications.KindDuplicatesResolved, notifications.Payload{
|
||||
Count: int64(res.Merged + len(changes)),
|
||||
Detail: resolvedDetail(res.Merged, changes),
|
||||
Count: int64(res.Merged),
|
||||
Detail: fmt.Sprintf("Merged %d duplicate %s.", res.Merged, pluralWord(res.Merged, "copy", "copies")),
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
@@ -287,13 +185,15 @@ func ResolveDuplicates(
|
||||
|
||||
// linkCrossRelease links each cross-release group's copies as one song
|
||||
// (#5438): a like on one is a like on all, and the Liked list, shuffle and the
|
||||
// mixes count the song once. Linking an already-linked group changes nothing,
|
||||
// mixes count the song once. A group the pass merged has nothing left to link. Linking an already-linked group changes nothing,
|
||||
// so this runs every pass; likes are shared only when a link is new, since a
|
||||
// like made after it reaches every copy as it is made.
|
||||
func linkCrossRelease(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, groups []*resolveGroup) (int, error) {
|
||||
func linkCrossRelease(
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, groups []*resolveGroup, merged map[pgtype.UUID]bool,
|
||||
) (int, error) {
|
||||
linked := 0
|
||||
for _, g := range groups {
|
||||
if g.class != ClassCrossRelease {
|
||||
if g.class != ClassCrossRelease || merged[g.id] {
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
@@ -345,61 +245,6 @@ func linkSong(ctx context.Context, pool *pgxpool.Pool, ids []pgtype.UUID) (bool,
|
||||
return true, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ripSearchCap bounds the albums one pass asks Lidarr to search, so the first
|
||||
// pass after deploy does not queue the whole backlog of rips at once.
|
||||
const ripSearchCap = 10
|
||||
|
||||
// searchSoleCopyRips asks Lidarr to search each album holding a video rip that
|
||||
// is the only copy of its song (#5447, operator's choice: option 2). While
|
||||
// Lidarr maps the rip it counts the track fulfilled, so nothing else asks for
|
||||
// better. An album search grabs only when the quality profile allows an
|
||||
// upgrade, so this removes nothing and may change nothing: the rip stays held
|
||||
// back either way. An album is searched at most once a week (the query reads
|
||||
// the audit log), and an album Lidarr does not hold is skipped.
|
||||
func searchSoleCopyRips(
|
||||
ctx context.Context, q *dbq.Queries, pool *pgxpool.Pool, logger *slog.Logger, lid LidarrLibrary,
|
||||
) (int, error) {
|
||||
albums, err := q.ListAlbumsWithSoleCopyRips(ctx, ripSearchCap)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list albums with sole-copy rips: %w", err)
|
||||
}
|
||||
type found struct {
|
||||
row dbq.ListAlbumsWithSoleCopyRipsRow
|
||||
lidarrID int
|
||||
}
|
||||
var targets []found
|
||||
for _, a := range albums {
|
||||
la, err := lid.LookupAlbumByMBID(ctx, a.ReleaseGroupMbid)
|
||||
if errors.Is(err, lidarr.ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("look up %s: %w", a.ReleaseGroupMbid, err)
|
||||
}
|
||||
targets = append(targets, found{row: a, lidarrID: la.ID})
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ids := make([]int, len(targets))
|
||||
for i, t := range targets {
|
||||
ids[i] = t.lidarrID
|
||||
}
|
||||
if err := lid.SearchAlbums(ctx, ids); err != nil {
|
||||
return 0, fmt.Errorf("search albums: %w", err)
|
||||
}
|
||||
for _, t := range targets {
|
||||
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionLidarrRipSearch, map[string]any{
|
||||
"album_id": syncpkg.FormatUUID(t.row.AlbumID),
|
||||
"album_title": t.row.AlbumTitle,
|
||||
"artist_name": t.row.ArtistName,
|
||||
"lidarr_album_id": t.lidarrID,
|
||||
"rips": t.row.Rips,
|
||||
})
|
||||
}
|
||||
return len(targets), nil
|
||||
}
|
||||
|
||||
func foldResolveGroups(rows []dbq.ListDuplicateGroupsForResolveRow) []*resolveGroup {
|
||||
var groups []*resolveGroup
|
||||
for _, r := range rows {
|
||||
@@ -419,16 +264,14 @@ func foldResolveGroups(rows []dbq.ListDuplicateGroupsForResolveRow) []*resolveGr
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveNote is what the report says beside a group the resolver leaves for
|
||||
// the operator, when there is something to say.
|
||||
// resolveNote is what the report says beside a group, when there is something
|
||||
// to say.
|
||||
func resolveNote(g *resolveGroup) string {
|
||||
switch g.class {
|
||||
case ClassSameRelease:
|
||||
if g.tracked() > 1 {
|
||||
return "Lidarr tracks every copy as its own track, so removing one would make Lidarr download it again."
|
||||
}
|
||||
case ClassCrossRelease:
|
||||
return "Each copy belongs to its own release, and Lidarr keeps each one. Nothing is removed; they count as one song."
|
||||
if g.lone() < 0 {
|
||||
return "Each copy belongs to its own release, so none is removed; they count as one song."
|
||||
}
|
||||
case ClassMismatch:
|
||||
return "Identical audio filed under different titles: one file carries another song's tags."
|
||||
}
|
||||
@@ -437,16 +280,10 @@ func resolveNote(g *resolveGroup) string {
|
||||
|
||||
func writeResolveVerdicts(ctx context.Context, q *dbq.Queries, groups []*resolveGroup) error {
|
||||
classes := dbq.SetDuplicateGroupClassesParams{}
|
||||
states := dbq.SetDuplicateMemberLidarrStatesParams{}
|
||||
for _, g := range groups {
|
||||
classes.GroupIds = append(classes.GroupIds, g.id)
|
||||
classes.Classes = append(classes.Classes, string(g.class))
|
||||
classes.Notes = append(classes.Notes, g.note)
|
||||
for i, m := range g.members {
|
||||
states.GroupIds = append(states.GroupIds, g.id)
|
||||
states.TrackIds = append(states.TrackIds, m.TrackID)
|
||||
states.States = append(states.States, g.states[i])
|
||||
}
|
||||
}
|
||||
if len(classes.GroupIds) == 0 {
|
||||
return nil
|
||||
@@ -454,9 +291,6 @@ func writeResolveVerdicts(ctx context.Context, q *dbq.Queries, groups []*resolve
|
||||
if err := q.SetDuplicateGroupClasses(ctx, classes); err != nil {
|
||||
return fmt.Errorf("write duplicate classes: %w", err)
|
||||
}
|
||||
if err := q.SetDuplicateMemberLidarrStates(ctx, states); err != nil {
|
||||
return fmt.Errorf("write lidarr states: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -465,29 +299,31 @@ func survivorCandidates(g *resolveGroup) []SurvivorCandidate {
|
||||
cands := make([]SurvivorCandidate, len(g.members))
|
||||
for i, m := range g.members {
|
||||
cands[i] = SurvivorCandidate{
|
||||
TrackID: syncpkg.FormatUUID(m.TrackID),
|
||||
FileFormat: m.FileFormat,
|
||||
FileSize: m.FileSize,
|
||||
AddedAt: m.AddedAt.Time,
|
||||
LidarrTracked: g.states[i] == lidarrStateTracked,
|
||||
TagFit: TagFitScore(m.TrackNumber != nil, m.PositionClash, m.FilePath, m.HasMbid),
|
||||
TrackID: syncpkg.FormatUUID(m.TrackID),
|
||||
FileFormat: m.FileFormat,
|
||||
FileSize: m.FileSize,
|
||||
AddedAt: m.AddedAt.Time,
|
||||
TagFit: TagFitScore(m.TrackNumber != nil, m.PositionClash, m.FilePath, m.HasMbid),
|
||||
}
|
||||
}
|
||||
return cands
|
||||
}
|
||||
|
||||
// autoMerge merges one group into the copy ProposeSurvivor picks, which is the
|
||||
// copy Lidarr tracks when there is one. removable is the guard: a copy Lidarr
|
||||
// maps can never be among those removed, whatever the survivor rule said.
|
||||
// autoMerge merges one group into the copy at index survivor, or the copy
|
||||
// ProposeSurvivor picks when survivor is -1, and records the merge as the
|
||||
// resolver's.
|
||||
func autoMerge(
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, g *resolveGroup, removable RemovableFunc,
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, g *resolveGroup, survivor int,
|
||||
) error {
|
||||
survivorKey, reason := ProposeSurvivor(survivorCandidates(g))
|
||||
if survivor >= 0 {
|
||||
survivorKey, reason = syncpkg.FormatUUID(g.members[survivor].TrackID), "the copy that is not a video rip"
|
||||
}
|
||||
var survivorID pgtype.UUID
|
||||
if err := survivorID.Scan(survivorKey); err != nil {
|
||||
return fmt.Errorf("parse survivor id: %w", err)
|
||||
}
|
||||
res, err := MergeDuplicateGroupGuarded(ctx, pool, logger, dataDir, g.id, survivorID, removable)
|
||||
res, err := MergeDuplicateGroup(ctx, pool, logger, dataDir, g.id, survivorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -515,294 +351,6 @@ func autoMerge(
|
||||
return nil
|
||||
}
|
||||
|
||||
// recentlyChangedAlbums is the Minstrel albums whose Lidarr release the
|
||||
// resolver changed within releaseChangeSettle, read from the audit log so a
|
||||
// restart does not forget them.
|
||||
func recentlyChangedAlbums(ctx context.Context, q *dbq.Queries, now time.Time) (map[string]bool, error) {
|
||||
rows, err := q.ListAuditLogByActions(ctx, dbq.ListAuditLogByActionsParams{
|
||||
Actions: []string{string(audit.ActionLidarrReleaseChange)}, SystemOnly: true,
|
||||
PageLimit: 200, PageOffset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read recent release changes: %w", err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, r := range rows {
|
||||
if now.Sub(r.CreatedAt.Time) > releaseChangeSettle {
|
||||
break // newest first
|
||||
}
|
||||
var meta struct {
|
||||
AlbumID string `json:"album_id"`
|
||||
}
|
||||
if json.Unmarshal(r.Metadata, &meta) == nil && meta.AlbumID != "" {
|
||||
out[meta.AlbumID] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// repeatingAlbum is a Minstrel album where Lidarr maps more than one copy of a
|
||||
// song, and the groups that showed it.
|
||||
type repeatingAlbum struct {
|
||||
albumID pgtype.UUID
|
||||
releaseGroupMbid string
|
||||
title, artist string
|
||||
groups []*resolveGroup
|
||||
}
|
||||
|
||||
func changeRepeatingReleases(
|
||||
ctx context.Context, q *dbq.Queries, pool *pgxpool.Pool, logger *slog.Logger,
|
||||
lid LidarrLibrary, groups []*resolveGroup, settling map[string]bool,
|
||||
) ([]ReleaseChange, error) {
|
||||
albums := map[string]*repeatingAlbum{}
|
||||
for _, g := range groups {
|
||||
if g.class != ClassSameRelease || g.tracked() < 2 {
|
||||
continue
|
||||
}
|
||||
m := g.members[0]
|
||||
key := syncpkg.FormatUUID(m.AlbumID)
|
||||
if settling[key] || m.ReleaseGroupMbid == nil || *m.ReleaseGroupMbid == "" {
|
||||
continue
|
||||
}
|
||||
a := albums[key]
|
||||
if a == nil {
|
||||
a = &repeatingAlbum{albumID: m.AlbumID, releaseGroupMbid: *m.ReleaseGroupMbid, title: m.AlbumTitle, artist: m.ArtistName}
|
||||
albums[key] = a
|
||||
}
|
||||
a.groups = append(a.groups, g)
|
||||
}
|
||||
keys := make([]string, 0, len(albums))
|
||||
for k := range albums {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var changes []ReleaseChange
|
||||
for _, k := range keys {
|
||||
if len(changes) >= resolveReleaseCap {
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return changes, ctx.Err()
|
||||
}
|
||||
a := albums[k]
|
||||
change, note, err := changeRelease(ctx, q, lid, a)
|
||||
if err != nil {
|
||||
logger.Warn("duplicate resolve: Lidarr release check failed", "album", a.title, "err", err)
|
||||
continue
|
||||
}
|
||||
if change != nil {
|
||||
changes = append(changes, *change)
|
||||
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionLidarrReleaseChange, map[string]any{
|
||||
"automatic": true,
|
||||
"album_id": k,
|
||||
"album_title": a.title,
|
||||
"artist_name": a.artist,
|
||||
"from_release": releaseLabel(change.From),
|
||||
"to_release": releaseLabel(change.To),
|
||||
})
|
||||
}
|
||||
if note != "" {
|
||||
if err := setGroupNotes(ctx, q, a.groups, note); err != nil {
|
||||
return changes, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// changeRelease checks one album's monitored release and, when it lists a
|
||||
// song twice, moves Lidarr to the release that lists each song once and best
|
||||
// covers what is on disk. It returns the change made (nil when none) and the
|
||||
// note for the album's groups.
|
||||
func changeRelease(
|
||||
ctx context.Context, q *dbq.Queries, lid LidarrLibrary, a *repeatingAlbum,
|
||||
) (*ReleaseChange, string, error) {
|
||||
la, err := lid.LookupAlbumByMBID(ctx, a.releaseGroupMbid)
|
||||
if errors.Is(err, lidarr.ErrNotFound) {
|
||||
return nil, "Lidarr does not hold this album, so its copies are left for you.", nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
current, err := lid.ListAlbumTracks(ctx, la.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !releaseRepeats(current) {
|
||||
return nil, "Lidarr maps each copy to a different track of this album, so none can go without a download.", nil
|
||||
}
|
||||
releases, err := lid.GetAlbumReleases(ctx, la.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
titles, err := q.ListAlbumPresentTrackTitles(ctx, a.albumID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("list album titles: %w", err)
|
||||
}
|
||||
onDisk := map[string]bool{}
|
||||
for _, t := range titles {
|
||||
if k := ReleaseTitleKey(t); k != "" {
|
||||
onDisk[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
var from lidarr.AlbumRelease
|
||||
var candidates []releaseCandidate
|
||||
for _, r := range releases {
|
||||
if r.Monitored {
|
||||
from = r
|
||||
continue
|
||||
}
|
||||
tracks, err := lid.ListReleaseTracks(ctx, r.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(tracks) == 0 || releaseRepeats(tracks) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, releaseCandidate{release: r, coverage: coverage(tracks, onDisk)})
|
||||
}
|
||||
best, ok := pickRelease(candidates, coverage(current, onDisk))
|
||||
if !ok {
|
||||
return nil, "Lidarr's release of this album lists songs twice, and no other release lists each once while keeping what is on disk.", nil
|
||||
}
|
||||
if err := lid.SetMonitoredRelease(ctx, la.ID, best.ID); err != nil {
|
||||
// Lidarr's album update unlinks the album's files and queues the rescan
|
||||
// before it answers, and on a large album that outlasts the client's
|
||||
// timeout while the change still lands (Cracker Island on the first
|
||||
// deploy pass). An unanswered PUT is read back: when the release did
|
||||
// change, it is recorded like any other, so the album gets its settle
|
||||
// window and the report shows it.
|
||||
if !errors.Is(err, lidarr.ErrUnreachable) || !releaseMonitored(ctx, lid, la.ID, best.ID) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
note := fmt.Sprintf("Lidarr now monitors the %s release, which lists each song once. The extra copies are merged once Lidarr has rescanned.", releaseLabel(best))
|
||||
return &ReleaseChange{AlbumTitle: a.title, ArtistName: a.artist, From: from, To: best}, note, nil
|
||||
}
|
||||
|
||||
// releaseMonitored reads whether the album's monitored release is now
|
||||
// releaseID. A failed read answers false: the change is not assumed.
|
||||
func releaseMonitored(ctx context.Context, lid LidarrLibrary, albumID, releaseID int) bool {
|
||||
releases, err := lid.GetAlbumReleases(ctx, albumID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, r := range releases {
|
||||
if r.ID == releaseID {
|
||||
return r.Monitored
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type releaseCandidate struct {
|
||||
release lidarr.AlbumRelease
|
||||
coverage int
|
||||
}
|
||||
|
||||
// pickRelease chooses among releases that list each song once: the one
|
||||
// covering most of the titles on disk, then the fewest tracks (the plainest
|
||||
// edition that holds them), then a digital one, then the lowest id so the
|
||||
// choice is stable. It refuses a release covering fewer titles on disk than
|
||||
// the current one does: the change must not drop a song Lidarr now keeps.
|
||||
func pickRelease(cands []releaseCandidate, currentCoverage int) (lidarr.AlbumRelease, bool) {
|
||||
if len(cands) == 0 {
|
||||
return lidarr.AlbumRelease{}, false
|
||||
}
|
||||
sort.SliceStable(cands, func(i, j int) bool {
|
||||
a, b := cands[i], cands[j]
|
||||
if a.coverage != b.coverage {
|
||||
return a.coverage > b.coverage
|
||||
}
|
||||
if a.release.TrackCount != b.release.TrackCount {
|
||||
return a.release.TrackCount < b.release.TrackCount
|
||||
}
|
||||
if da, db := isDigital(a.release), isDigital(b.release); da != db {
|
||||
return da
|
||||
}
|
||||
return a.release.ID < b.release.ID
|
||||
})
|
||||
if cands[0].coverage < currentCoverage {
|
||||
return lidarr.AlbumRelease{}, false
|
||||
}
|
||||
return cands[0].release, true
|
||||
}
|
||||
|
||||
func isDigital(r lidarr.AlbumRelease) bool {
|
||||
return strings.Contains(strings.ToLower(r.Format), "digital")
|
||||
}
|
||||
|
||||
// releaseRepeats reports whether a release lists one title more than once.
|
||||
func releaseRepeats(tracks []lidarr.ReleaseTrack) bool {
|
||||
seen := map[string]bool{}
|
||||
for _, t := range tracks {
|
||||
k := ReleaseTitleKey(t.Title)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if seen[k] {
|
||||
return true
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// coverage counts the distinct titles on disk that the release lists.
|
||||
func coverage(tracks []lidarr.ReleaseTrack, onDisk map[string]bool) int {
|
||||
hit := map[string]bool{}
|
||||
for _, t := range tracks {
|
||||
if k := ReleaseTitleKey(t.Title); onDisk[k] {
|
||||
hit[k] = true
|
||||
}
|
||||
}
|
||||
return len(hit)
|
||||
}
|
||||
|
||||
func releaseLabel(r lidarr.AlbumRelease) string {
|
||||
label := r.Format
|
||||
if label == "" {
|
||||
label = r.Title
|
||||
}
|
||||
if r.Disambiguation != "" {
|
||||
label = r.Disambiguation + ", " + label
|
||||
}
|
||||
return fmt.Sprintf("%s (%d tracks)", label, r.TrackCount)
|
||||
}
|
||||
|
||||
func setGroupNotes(ctx context.Context, q *dbq.Queries, groups []*resolveGroup, note string) error {
|
||||
p := dbq.SetDuplicateGroupClassesParams{}
|
||||
for _, g := range groups {
|
||||
g.note = note
|
||||
p.GroupIds = append(p.GroupIds, g.id)
|
||||
p.Classes = append(p.Classes, string(g.class))
|
||||
p.Notes = append(p.Notes, note)
|
||||
}
|
||||
if err := q.SetDuplicateGroupClasses(ctx, p); err != nil {
|
||||
return fmt.Errorf("write duplicate notes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolvedDetail(merged int, changes []ReleaseChange) string {
|
||||
var parts []string
|
||||
if merged > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Merged %d %s Lidarr does not need.", merged, pluralWord(merged, "copy", "copies")))
|
||||
}
|
||||
switch len(changes) {
|
||||
case 0:
|
||||
case 1:
|
||||
c := changes[0]
|
||||
parts = append(parts, fmt.Sprintf("Lidarr now monitors the %s release of %s by %s, which lists each song once.",
|
||||
releaseLabel(c.To), c.AlbumTitle, c.ArtistName))
|
||||
default:
|
||||
parts = append(parts, fmt.Sprintf("Lidarr now monitors a release that lists each song once for %d albums.", len(changes)))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func pluralWord(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return one
|
||||
@@ -816,20 +364,14 @@ type DuplicateResolveWorker struct {
|
||||
logger *slog.Logger
|
||||
dataDir string
|
||||
settings *FingerprintSettingsService
|
||||
lidarr func() LidarrLibrary
|
||||
tick time.Duration
|
||||
}
|
||||
|
||||
// NewDuplicateResolveWorker builds a worker with the production cadence.
|
||||
// lidarrFn is asked each pass, so a Lidarr setting saved in admin takes effect
|
||||
// without a restart; it returns nil while Lidarr is disabled.
|
||||
func NewDuplicateResolveWorker(
|
||||
pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
|
||||
settings *FingerprintSettingsService, lidarrFn func() LidarrLibrary,
|
||||
pool *pgxpool.Pool, logger *slog.Logger, dataDir string, settings *FingerprintSettingsService,
|
||||
) *DuplicateResolveWorker {
|
||||
return &DuplicateResolveWorker{
|
||||
pool: pool, logger: logger, dataDir: dataDir, settings: settings, lidarr: lidarrFn, tick: duplicateResolveTick,
|
||||
}
|
||||
return &DuplicateResolveWorker{pool: pool, logger: logger, dataDir: dataDir, settings: settings, tick: duplicateResolveTick}
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled, running a pass at start and then each tick.
|
||||
@@ -865,16 +407,11 @@ func (w *DuplicateResolveWorker) tickOnce(ctx context.Context) {
|
||||
}
|
||||
return
|
||||
}
|
||||
var lid LidarrLibrary
|
||||
if w.lidarr != nil {
|
||||
lid = w.lidarr()
|
||||
}
|
||||
res, err := ResolveDuplicates(ctx, w.pool, w.logger, w.dataDir, lid, w.settings.Get().AutoResolve)
|
||||
res, err := ResolveDuplicates(ctx, w.pool, w.logger, w.dataDir, w.settings.Get().AutoResolve)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
w.logger.Warn("duplicate resolve: pass failed", "err", err)
|
||||
}
|
||||
w.logger.Info("duplicate resolve complete",
|
||||
"groups", res.Groups, "lidarr", res.LidarrConsulted, "merged", res.Merged,
|
||||
"merge_failed", res.MergeFailed, "release_changes", len(res.ReleaseChanges), "songs_linked", res.SongsLinked,
|
||||
"held_back", res.HeldBack, "released", res.Released, "rip_searches", res.RipSearches)
|
||||
"groups", res.Groups, "merged", res.Merged, "merge_failed", res.MergeFailed,
|
||||
"songs_linked", res.SongsLinked, "held_back", res.HeldBack, "released", res.Released)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -12,128 +10,9 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
func TestLidarrPathKey(t *testing.T) {
|
||||
// Minstrel and Lidarr see the library under different mounts; the key is
|
||||
// what they agree on.
|
||||
a := LidarrPathKey("/music/Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3")
|
||||
b := LidarrPathKey("/data/media/music/Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3")
|
||||
if a != b || a != "Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3" {
|
||||
t.Errorf("keys differ or are wrong: %q, %q", a, b)
|
||||
}
|
||||
if LidarrPathKey("/music/Gorillaz/Humanz (2017)/a.mp3") == LidarrPathKey("/music/Gorillaz/Demon Days (2005)/a.mp3") {
|
||||
t.Error("two albums' files share a key")
|
||||
}
|
||||
}
|
||||
|
||||
func rt(titles ...string) []lidarr.ReleaseTrack {
|
||||
out := make([]lidarr.ReleaseTrack, len(titles))
|
||||
for i, title := range titles {
|
||||
out[i] = lidarr.ReleaseTrack{ID: i + 1, Title: title}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestReleaseRepeats(t *testing.T) {
|
||||
if !releaseRepeats(rt("Ascension", "Strobelite", "Ascension")) {
|
||||
t.Error("the box set lists Ascension twice, but no repeat was found")
|
||||
}
|
||||
if releaseRepeats(rt("Charger", "Charger (demo)")) {
|
||||
t.Error("a demo was read as a repeat of the song")
|
||||
}
|
||||
}
|
||||
|
||||
// The Humanz releases as Lidarr lists them (2026-10-09): the box set is
|
||||
// monitored, and the 40-track digital release is the one that lists each song
|
||||
// once and covers what is on disk.
|
||||
func TestPickRelease(t *testing.T) {
|
||||
digital40 := lidarr.AlbumRelease{ID: 10149, Format: "Digital Media", TrackCount: 40}
|
||||
deluxe26 := lidarr.AlbumRelease{ID: 10150, Format: "CD", TrackCount: 26}
|
||||
standard20 := lidarr.AlbumRelease{ID: 10151, Format: "Digital Media", TrackCount: 20}
|
||||
|
||||
got, ok := pickRelease([]releaseCandidate{
|
||||
{release: standard20, coverage: 20}, {release: deluxe26, coverage: 26}, {release: digital40, coverage: 34},
|
||||
}, 34)
|
||||
if !ok || got.ID != digital40.ID {
|
||||
t.Errorf("picked %+v (ok=%v), want the 40-track digital release", got, ok)
|
||||
}
|
||||
|
||||
// Equal coverage: the plainer edition, then digital.
|
||||
cd := lidarr.AlbumRelease{ID: 1, Format: "CD", TrackCount: 26}
|
||||
web := lidarr.AlbumRelease{ID: 2, Format: "Digital Media", TrackCount: 26}
|
||||
if got, _ := pickRelease([]releaseCandidate{{release: cd, coverage: 26}, {release: web, coverage: 26}}, 26); got.ID != web.ID {
|
||||
t.Errorf("picked %+v, want the digital one of two equal releases", got)
|
||||
}
|
||||
|
||||
// Never a release that keeps fewer of the songs on disk than the current one.
|
||||
if got, ok := pickRelease([]releaseCandidate{{release: deluxe26, coverage: 26}}, 34); ok {
|
||||
t.Errorf("picked %+v, which covers fewer songs on disk than the current release", got)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLidarr is one Lidarr album with several releases. Switching the
|
||||
// monitored release changes what ListAlbumTracks answers, as in Lidarr.
|
||||
type fakeLidarr struct {
|
||||
unmapped []string
|
||||
unmappedErr error
|
||||
album lidarr.LidarrAlbum
|
||||
releases []lidarr.AlbumRelease
|
||||
releaseTracks map[int][]lidarr.ReleaseTrack
|
||||
setCalls [][2]int
|
||||
searchCalls [][]int
|
||||
// setErr is what SetMonitoredRelease answers after applying the change:
|
||||
// Lidarr timing out on a change that landed.
|
||||
setErr error
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) ListUnmappedTrackFiles(context.Context) ([]lidarr.TrackFile, error) {
|
||||
out := make([]lidarr.TrackFile, len(f.unmapped))
|
||||
for i, p := range f.unmapped {
|
||||
out[i] = lidarr.TrackFile{ID: i + 1, Path: p}
|
||||
}
|
||||
return out, f.unmappedErr
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) LookupAlbumByMBID(_ context.Context, mbid string) (lidarr.LidarrAlbum, error) {
|
||||
if f.album.ForeignAlbumID != mbid {
|
||||
return lidarr.LidarrAlbum{}, lidarr.ErrNotFound
|
||||
}
|
||||
return f.album, nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) ListAlbumTracks(context.Context, int) ([]lidarr.ReleaseTrack, error) {
|
||||
for _, r := range f.releases {
|
||||
if r.Monitored {
|
||||
return f.releaseTracks[r.ID], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) GetAlbumReleases(context.Context, int) ([]lidarr.AlbumRelease, error) {
|
||||
return append([]lidarr.AlbumRelease(nil), f.releases...), nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) ListReleaseTracks(_ context.Context, releaseID int) ([]lidarr.ReleaseTrack, error) {
|
||||
return f.releaseTracks[releaseID], nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) SearchAlbums(_ context.Context, albumIDs []int) error {
|
||||
f.searchCalls = append(f.searchCalls, albumIDs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) SetMonitoredRelease(_ context.Context, albumID, releaseID int) error {
|
||||
f.setCalls = append(f.setCalls, [2]int{albumID, releaseID})
|
||||
for i := range f.releases {
|
||||
f.releases[i].Monitored = f.releases[i].ID == releaseID
|
||||
}
|
||||
return f.setErr
|
||||
}
|
||||
|
||||
// resolveFixture is the Humanz shape: one album, one song twice, both copies on
|
||||
// disk under an artist/album folder, in one pending exact-tier group.
|
||||
type resolveFixture struct {
|
||||
@@ -178,7 +57,6 @@ func newResolveFixture(t *testing.T) resolveFixture {
|
||||
}
|
||||
}
|
||||
exec(`UPDATE tracks SET title = 'Saturnz Barz' WHERE id = $1`, f.clean.ID)
|
||||
exec(`UPDATE albums SET release_group_mbid = 'rg-humanz' WHERE id = $1`, album.ID)
|
||||
if err := pool.QueryRow(ctx,
|
||||
`INSERT INTO duplicate_groups (member_key, tier) VALUES ('resolve-fixture', 'exact') RETURNING id`,
|
||||
).Scan(&f.groupID); err != nil {
|
||||
@@ -188,9 +66,6 @@ func newResolveFixture(t *testing.T) resolveFixture {
|
||||
return f
|
||||
}
|
||||
|
||||
// lidarrPath is where Lidarr, mounted elsewhere, sees a fixture file.
|
||||
func lidarrPath(p string) string { return "/music/" + LidarrPathKey(p) }
|
||||
|
||||
func (f resolveFixture) group(t *testing.T) (status string, class *string, note *string, auto bool) {
|
||||
t.Helper()
|
||||
if err := f.pool.QueryRow(context.Background(),
|
||||
@@ -203,18 +78,32 @@ func (f resolveFixture) group(t *testing.T) (status string, class *string, note
|
||||
|
||||
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
|
||||
|
||||
// Lidarr maps the clean copy and holds the rip unmapped: the rip goes, the
|
||||
// clean copy stays, and the merge is recorded as the resolver's.
|
||||
func TestResolveDuplicates_MergesTheUnmappedCopy_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
|
||||
// autoMergeAudit reads the resolver's audit row for a group: how many there
|
||||
// are, and the reason the last one gave for its survivor.
|
||||
func autoMergeAudit(t *testing.T, pool *pgxpool.Pool, groupID pgtype.UUID) (n int, reason string) {
|
||||
t.Helper()
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT count(*), coalesce(max(metadata->>'survivor_reason'), '') FROM audit_log
|
||||
WHERE action = 'duplicate_merge' AND actor_id IS NULL
|
||||
AND metadata->>'group_id' = $1 AND (metadata->>'automatic')::boolean`,
|
||||
syncpkg.FormatUUID(groupID)).Scan(&n, &reason); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n, reason
|
||||
}
|
||||
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||
// One album holding a song twice: the extra copy goes, the copy whose name and
|
||||
// tags fit the album stays, and the merge is recorded as the resolver's. No
|
||||
// Lidarr is asked (M502 #5517): Minstrel is the only program writing the
|
||||
// library.
|
||||
func TestResolveDuplicates_MergesASameReleaseCopy_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if res.Merged != 1 || !res.LidarrConsulted {
|
||||
t.Fatalf("result = %+v, want one merge with Lidarr consulted", res)
|
||||
if res.Merged != 1 || res.Classes[ClassSameRelease] != 1 {
|
||||
t.Fatalf("result = %+v, want the same-release group merged", res)
|
||||
}
|
||||
if exists(f.ripPath) || !exists(f.cleanPath) {
|
||||
t.Errorf("rip exists=%v clean exists=%v; want only the clean copy left", exists(f.ripPath), exists(f.cleanPath))
|
||||
@@ -223,138 +112,24 @@ func TestResolveDuplicates_MergesTheUnmappedCopy_Integration(t *testing.T) {
|
||||
if status != "merged" || class == nil || *class != string(ClassSameRelease) || !auto {
|
||||
t.Errorf("group = (%s, %v, auto=%v), want merged same_release by the resolver", status, class, auto)
|
||||
}
|
||||
var audits int
|
||||
if err := f.pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM audit_log WHERE action = 'duplicate_merge' AND actor_id IS NULL
|
||||
AND metadata->>'group_id' = $1 AND (metadata->>'automatic')::boolean`,
|
||||
syncpkg.FormatUUID(f.groupID)).Scan(&audits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if audits != 1 {
|
||||
t.Errorf("automatic merge audit rows = %d, want 1", audits)
|
||||
if n, _ := autoMergeAudit(t, f.pool, f.groupID); n != 1 {
|
||||
t.Errorf("automatic merge audit rows = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Lidarr maps both copies, and its release lists the song twice: nothing is
|
||||
// deleted, the release moves to the one listing each song once, and the next
|
||||
// pass leaves it there (lesson #4183's fixed point).
|
||||
func TestResolveDuplicates_ChangesARepeatingRelease_Integration(t *testing.T) {
|
||||
// With auto-resolve off, the pass classifies and removes nothing.
|
||||
func TestResolveDuplicates_ClassifiesOnlyWithoutPermission_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
lid := &fakeLidarr{
|
||||
album: lidarr.LidarrAlbum{ID: 4723, ForeignAlbumID: "rg-humanz", Title: "Humanz"},
|
||||
releases: []lidarr.AlbumRelease{
|
||||
{ID: 10148, Format: "14x12\" Vinyl, Digital Media", TrackCount: 4, Monitored: true},
|
||||
{ID: 10149, Format: "Digital Media", TrackCount: 2},
|
||||
},
|
||||
releaseTracks: map[int][]lidarr.ReleaseTrack{
|
||||
10148: rt("Saturnz Barz", "Ascension", "Saturnz Barz", "Ascension"),
|
||||
10149: rt("Saturnz Barz", "Ascension"),
|
||||
},
|
||||
}
|
||||
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) || !exists(f.cleanPath) {
|
||||
t.Fatalf("merged %d; a copy Lidarr maps was touched", res.Merged)
|
||||
}
|
||||
if len(lid.setCalls) != 1 || lid.setCalls[0] != [2]int{4723, 10149} {
|
||||
t.Fatalf("release changes = %v, want album 4723 to release 10149", lid.setCalls)
|
||||
}
|
||||
status, _, note, _ := f.group(t)
|
||||
if status != "pending" || note == nil || *note == "" {
|
||||
t.Errorf("group = (%s, note %v), want pending with a note saying what changed", status, note)
|
||||
}
|
||||
|
||||
// The next pass: Lidarr has not rescanned yet, so both copies still read
|
||||
// as tracked. The album is settling, so nothing changes.
|
||||
if _, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true); err != nil {
|
||||
t.Fatalf("second resolve: %v", err)
|
||||
}
|
||||
if len(lid.setCalls) != 1 {
|
||||
t.Errorf("release changed again: %v", lid.setCalls)
|
||||
}
|
||||
// And once it has settled, the check itself finds nothing to do: the
|
||||
// release it chose lists each song once.
|
||||
change, _, err := changeRelease(context.Background(), dbq.New(f.pool), lid,
|
||||
&repeatingAlbum{albumID: f.clean.AlbumID, releaseGroupMbid: "rg-humanz"})
|
||||
if err != nil || change != nil || len(lid.setCalls) != 1 {
|
||||
t.Errorf("after the change, changeRelease = (%+v, %v) with %d calls; want no change", change, err, len(lid.setCalls))
|
||||
}
|
||||
}
|
||||
|
||||
// After a release change Lidarr unmaps every file of the album until it has
|
||||
// rescanned. A pass in that window must not read "every copy unmapped" as
|
||||
// licence to merge: the copy it would remove may be the one Lidarr maps next.
|
||||
func TestResolveDuplicates_LeavesAnAlbumAloneWhileLidarrRescans_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
lid := &fakeLidarr{
|
||||
album: lidarr.LidarrAlbum{ID: 4723, ForeignAlbumID: "rg-humanz"},
|
||||
releases: []lidarr.AlbumRelease{
|
||||
{ID: 10148, TrackCount: 2, Monitored: true}, {ID: 10149, TrackCount: 1},
|
||||
},
|
||||
releaseTracks: map[int][]lidarr.ReleaseTrack{10148: rt("Saturnz Barz", "Saturnz Barz"), 10149: rt("Saturnz Barz")},
|
||||
}
|
||||
if _, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lid.unmapped = []string{lidarrPath(f.cleanPath), lidarrPath(f.ripPath)}
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) || !exists(f.cleanPath) {
|
||||
t.Errorf("merged %d during the rescan window", res.Merged)
|
||||
if res.Merged != 0 || !exists(f.ripPath) {
|
||||
t.Errorf("merged %d", res.Merged)
|
||||
}
|
||||
}
|
||||
|
||||
// Without Lidarr, or with auto-resolve off, the pass classifies and removes
|
||||
// nothing.
|
||||
func TestResolveDuplicates_ClassifiesOnlyWithoutLidarrOrPermission_Integration(t *testing.T) {
|
||||
for name, run := range map[string]func(resolveFixture) (DuplicateResolveResult, error){
|
||||
"lidarr disabled": func(f resolveFixture) (DuplicateResolveResult, error) {
|
||||
return ResolveDuplicates(context.Background(), f.pool, nil, "", nil, true)
|
||||
},
|
||||
"lidarr unreachable": func(f resolveFixture) (DuplicateResolveResult, error) {
|
||||
return ResolveDuplicates(context.Background(), f.pool, nil, "", &fakeLidarr{unmappedErr: errors.New("timeout")}, true)
|
||||
},
|
||||
"auto-resolve off": func(f resolveFixture) (DuplicateResolveResult, error) {
|
||||
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
|
||||
return ResolveDuplicates(context.Background(), f.pool, nil, "", lid, false)
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
res, err := run(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) {
|
||||
t.Errorf("merged %d", res.Merged)
|
||||
}
|
||||
status, class, _, _ := f.group(t)
|
||||
if status != "pending" || class == nil || *class != string(ClassSameRelease) {
|
||||
t.Errorf("group = (%s, %v), want pending and classified", status, class)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The guard itself: a merge asked to remove a copy the check refuses changes
|
||||
// nothing, file or row.
|
||||
func TestMergeDuplicateGroupGuarded_RefusesATrackedCopy_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
refuse := func(p string) bool { return p != f.ripPath }
|
||||
_, err := MergeDuplicateGroupGuarded(context.Background(), f.pool, nil, "", f.groupID, f.clean.ID, refuse)
|
||||
if !errors.Is(err, ErrCopyTrackedByLidarr) {
|
||||
t.Fatalf("err = %v, want ErrCopyTrackedByLidarr", err)
|
||||
}
|
||||
if !exists(f.ripPath) {
|
||||
t.Error("the refused copy's file was removed")
|
||||
}
|
||||
if status, _, _, _ := f.group(t); status != "pending" {
|
||||
t.Errorf("group status = %s, want pending", status)
|
||||
status, class, _, _ := f.group(t)
|
||||
if status != "pending" || class == nil || *class != string(ClassSameRelease) {
|
||||
t.Errorf("group = (%s, %v), want pending and classified", status, class)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,12 +191,12 @@ func newCrossReleaseRipFixture(t *testing.T) crossReleaseRipFixture {
|
||||
return f
|
||||
}
|
||||
|
||||
// The rip fulfils nothing in Lidarr while the clean copy on the album does:
|
||||
// the rip goes and the album copy stays (#5439, D-a rule 1).
|
||||
func TestResolveDuplicates_MergesAnUnmappedRipAcrossReleases_Integration(t *testing.T) {
|
||||
// A video rip on one release with the clean copy on another: the rip is no
|
||||
// release's copy, so it goes and the album cut stays (#5439, now without
|
||||
// Lidarr).
|
||||
func TestResolveDuplicates_MergesARipAcrossReleases_Integration(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -431,21 +206,65 @@ func TestResolveDuplicates_MergesAnUnmappedRipAcrossReleases_Integration(t *test
|
||||
if exists(f.ripPath) || !exists(f.cleanPath) {
|
||||
t.Errorf("rip exists %v, clean exists %v; want only the clean copy", exists(f.ripPath), exists(f.cleanPath))
|
||||
}
|
||||
if _, reason := autoMergeAudit(t, f.pool, f.groupID); reason != "the copy that is not a video rip" {
|
||||
t.Errorf("survivor reason %q, want the rip rule", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// When Lidarr maps both copies, each fulfils its own release: nothing is
|
||||
// removed, and the copies are linked as one song instead.
|
||||
func TestResolveDuplicates_KeepsCopiesLidarrMapsOnTwoReleases_Integration(t *testing.T) {
|
||||
// Copies on two releases that are both clean each belong to their release:
|
||||
// nothing is removed, and they are linked as one song. A rip the operator
|
||||
// marked fine counts as clean.
|
||||
func TestResolveDuplicates_KeepsCleanCopiesOnTwoReleases_Integration(t *testing.T) {
|
||||
for name, prepare := range map[string]func(f crossReleaseRipFixture) (string, []any){
|
||||
"renamed": func(f crossReleaseRipFixture) (string, []any) {
|
||||
return `UPDATE tracks SET title = 'Feel Good Inc.', file_path = $2 WHERE id = $1`,
|
||||
[]any{f.rip.ID, filepath.Join(filepath.Dir(f.ripPath), "01 - Feel Good Inc.mp3")}
|
||||
},
|
||||
"kept as fine": func(f crossReleaseRipFixture) (string, []any) {
|
||||
return `UPDATE tracks SET source_verdict = 'fine' WHERE id = $1`, []any{f.rip.ID}
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
sql, args := prepare(f)
|
||||
if _, err := f.pool.Exec(context.Background(), sql, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) {
|
||||
t.Errorf("merged %d; want both copies kept", res.Merged)
|
||||
}
|
||||
if res.SongsLinked != 1 {
|
||||
t.Errorf("linked %d, want the pair linked as one song", res.SongsLinked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Identical audio under two titles on two albums, neither a rip: which tags
|
||||
// are wrong is the operator's call, so nothing is merged or linked.
|
||||
func TestResolveDuplicates_LeavesAMismatchForTheOperator_Integration(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", &fakeLidarr{}, true)
|
||||
ctx := context.Background()
|
||||
if _, err := f.pool.Exec(ctx, `UPDATE duplicate_groups SET tier = 'exact' WHERE id = $1`, f.groupID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.pool.Exec(ctx, `UPDATE tracks SET title = 'DARE', file_path = $2 WHERE id = $1`,
|
||||
f.rip.ID, filepath.Join(filepath.Dir(f.ripPath), "01 - DARE.mp3")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := ResolveDuplicates(ctx, f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) {
|
||||
t.Errorf("merged %d; want both copies kept", res.Merged)
|
||||
if res.Classes[ClassMismatch] != 1 || res.Merged != 0 || res.SongsLinked != 0 {
|
||||
t.Fatalf("classes %v, merged %d, linked %d; want a mismatch left alone", res.Classes, res.Merged, res.SongsLinked)
|
||||
}
|
||||
if res.SongsLinked != 1 {
|
||||
t.Errorf("linked %d, want the pair linked as one song", res.SongsLinked)
|
||||
if status, _, note, _ := (resolveFixture{pool: f.pool, groupID: f.groupID}).group(t); status != "pending" || note == nil || *note == "" {
|
||||
t.Errorf("group = (%s, note %v), want pending with a note", status, note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,6 +274,11 @@ func TestResolveDuplicates_HoldsBackRips_Integration(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
ctx := context.Background()
|
||||
q := dbq.New(f.pool)
|
||||
// Without its group the rip has no clean copy to merge into, so it stays
|
||||
// on disk and only its verdict changes.
|
||||
if _, err := f.pool.Exec(ctx, `DELETE FROM duplicate_groups WHERE id = $1`, f.groupID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verdict := func(id pgtype.UUID) *string {
|
||||
t.Helper()
|
||||
var v *string
|
||||
@@ -465,7 +289,7 @@ func TestResolveDuplicates_HoldsBackRips_Integration(t *testing.T) {
|
||||
}
|
||||
resolve := func() DuplicateResolveResult {
|
||||
t.Helper()
|
||||
res, err := ResolveDuplicates(ctx, f.pool, nil, "", &fakeLidarr{}, true)
|
||||
res, err := ResolveDuplicates(ctx, f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -512,123 +336,3 @@ func TestResolveDuplicates_HoldsBackRips_Integration(t *testing.T) {
|
||||
t.Errorf("set on an unflagged track: %d, %v; want 0 rows", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A rip that is the only copy of its song: Lidarr is asked to search its album
|
||||
// for a better release (#5447), once a week at most, and the rip is not touched.
|
||||
// A rip with a clean copy elsewhere is not searched for.
|
||||
func TestResolveDuplicates_SearchesForASoleCopyRip_Integration(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
ctx := context.Background()
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := f.pool.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatalf("exec %q: %v", sql, err)
|
||||
}
|
||||
}
|
||||
// The single's rip has a clean copy on the album: never searched for.
|
||||
exec(`UPDATE albums SET release_group_mbid = 'rg-single' WHERE id = $1`, f.rip.AlbumID)
|
||||
lid := &fakeLidarr{album: lidarr.LidarrAlbum{ID: 77, ForeignAlbumID: "rg-single"}}
|
||||
if _, err := ResolveDuplicates(ctx, f.pool, nil, "", lid, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lid.searchCalls) != 0 {
|
||||
t.Fatalf("searched %v while a clean copy exists", lid.searchCalls)
|
||||
}
|
||||
|
||||
// Without the group and the song link, the rip is the only copy.
|
||||
exec(`DELETE FROM duplicate_groups WHERE id = $1`, f.groupID)
|
||||
exec(`UPDATE tracks SET song_id = NULL WHERE id IN ($1, $2)`, f.rip.ID, f.clean.ID)
|
||||
res, err := ResolveDuplicates(ctx, f.pool, nil, "", lid, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.RipSearches != 1 || len(lid.searchCalls) != 1 || len(lid.searchCalls[0]) != 1 || lid.searchCalls[0][0] != 77 {
|
||||
t.Fatalf("rip searches %d, calls %v; want album 77 searched once", res.RipSearches, lid.searchCalls)
|
||||
}
|
||||
if !exists(f.ripPath) {
|
||||
t.Error("the rip was removed; a search must not touch it")
|
||||
}
|
||||
|
||||
// The next pass, inside the week, leaves the album alone.
|
||||
if res, err := ResolveDuplicates(ctx, f.pool, nil, "", lid, true); err != nil || res.RipSearches != 0 {
|
||||
t.Errorf("second pass searched %d (%v), want 0", res.RipSearches, err)
|
||||
}
|
||||
var searchedAt pgtype.Timestamptz
|
||||
rows, err := dbq.New(f.pool).ListSuspectSourceTracks(ctx, dbq.ListSuspectSourceTracksParams{
|
||||
Pattern: SuspectSourcePattern, PageLimit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.ID == f.rip.ID {
|
||||
searchedAt = r.SearchedAt
|
||||
}
|
||||
}
|
||||
if !searchedAt.Valid {
|
||||
t.Error("the suspect-sources report doesn't show the search")
|
||||
}
|
||||
}
|
||||
|
||||
// Lidarr applies the release change but the PUT times out (Cracker Island on
|
||||
// the first deploy pass). The change is read back and recorded, so the album
|
||||
// settles; when it did not land, the error stands and nothing is recorded.
|
||||
func TestResolveDuplicates_RecordsAReleaseChangeLidarrDidNotAnswer_Integration(t *testing.T) {
|
||||
for name, landed := range map[string]bool{"landed": true, "did not land": false} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
lid := &fakeLidarr{
|
||||
album: lidarr.LidarrAlbum{ID: 4706, ForeignAlbumID: "rg-humanz", Title: "Cracker Island"},
|
||||
releases: []lidarr.AlbumRelease{
|
||||
{ID: 1, Format: "Digital Media", TrackCount: 4, Monitored: true},
|
||||
{ID: 2, Format: "Digital Media", TrackCount: 2},
|
||||
},
|
||||
releaseTracks: map[int][]lidarr.ReleaseTrack{
|
||||
1: rt("Saturnz Barz", "Ascension", "Saturnz Barz", "Ascension"),
|
||||
2: rt("Saturnz Barz", "Ascension"),
|
||||
},
|
||||
setErr: fmt.Errorf("%w: context deadline exceeded", lidarr.ErrUnreachable),
|
||||
}
|
||||
if !landed {
|
||||
lid.setErr = fmt.Errorf("%w: connection refused", lidarr.ErrUnreachable)
|
||||
}
|
||||
// ResetDB keeps audit_log, so other tests' release changes are
|
||||
// still in it: count what this pass adds.
|
||||
audited := func() (n int) {
|
||||
t.Helper()
|
||||
if err := f.pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM audit_log WHERE action = 'lidarr_release_change'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
before := audited()
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", &undoingLidarr{fakeLidarr: lid, undo: !landed}, true)
|
||||
if landed {
|
||||
if err != nil || len(res.ReleaseChanges) != 1 {
|
||||
t.Fatalf("changes %d (%v), want the landed change recorded", len(res.ReleaseChanges), err)
|
||||
}
|
||||
} else if len(res.ReleaseChanges) != 0 {
|
||||
t.Fatalf("recorded %d changes that did not land", len(res.ReleaseChanges))
|
||||
}
|
||||
if got, want := audited()-before, map[bool]int{true: 1, false: 0}[landed]; got != want {
|
||||
t.Errorf("audited %d release changes, want %d", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// undoingLidarr is a Lidarr whose release change fails without landing when
|
||||
// undo is set: the PUT never reached it.
|
||||
type undoingLidarr struct {
|
||||
*fakeLidarr
|
||||
undo bool
|
||||
}
|
||||
|
||||
func (u *undoingLidarr) SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error {
|
||||
if !u.undo {
|
||||
return u.fakeLidarr.SetMonitoredRelease(ctx, albumID, releaseID)
|
||||
}
|
||||
u.setCalls = append(u.setCalls, [2]int{albumID, releaseID})
|
||||
return u.setErr
|
||||
}
|
||||
|
||||
@@ -14,10 +14,6 @@ type SurvivorCandidate struct {
|
||||
FileSize int64
|
||||
AddedAt time.Time
|
||||
|
||||
// LidarrTracked: Lidarr maps this file to a track of the release it
|
||||
// monitors. Removing it opens a hole Lidarr downloads again (M498), so a
|
||||
// tracked copy outranks everything else. False when Lidarr was not asked.
|
||||
LidarrTracked bool
|
||||
// TagFit is TagFitScore: how well the copy's tags and name fit the album.
|
||||
TagFit int
|
||||
}
|
||||
@@ -56,9 +52,9 @@ var losslessFormats = map[string]bool{"flac": true, "wav": true}
|
||||
// the report shows it and the merge (#3911) lets the operator choose another.
|
||||
//
|
||||
// In order:
|
||||
// 0. the copy Lidarr maps, then the copy whose tags fit the album best
|
||||
// (TagFitScore). Both are zero for every copy when the caller does not
|
||||
// know them, and the order falls through to the quality rules below
|
||||
// 0. the copy whose tags fit the album best (TagFitScore). It is zero for
|
||||
// every copy when the caller does not know it, and the order falls through
|
||||
// to the quality rules below
|
||||
// 1. lossless over lossy — the one difference no later step can recover
|
||||
// 2. the larger file — for one recording at one duration that is the higher
|
||||
// bitrate. The scanner does not record bitrate (tracks.bitrate is never
|
||||
@@ -81,8 +77,6 @@ func ProposeSurvivor(cands []SurvivorCandidate) (trackID, reason string) {
|
||||
// runner-up — the rule that actually decided, not every rule it passed.
|
||||
next := ranked[1]
|
||||
switch {
|
||||
case best.LidarrTracked != next.LidarrTracked:
|
||||
return best.TrackID, "the copy Lidarr tracks"
|
||||
case best.TagFit != next.TagFit:
|
||||
return best.TrackID, "tags fit the album"
|
||||
case isLossless(best) != isLossless(next):
|
||||
@@ -97,9 +91,6 @@ func ProposeSurvivor(cands []SurvivorCandidate) (trackID, reason string) {
|
||||
}
|
||||
|
||||
func survivorBefore(a, b SurvivorCandidate) bool {
|
||||
if a.LidarrTracked != b.LidarrTracked {
|
||||
return a.LidarrTracked
|
||||
}
|
||||
if a.TagFit != b.TagFit {
|
||||
return a.TagFit > b.TagFit
|
||||
}
|
||||
|
||||
@@ -90,11 +90,10 @@ func TestProposeSurvivor_ReasonIsTheDecidingRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// M498 #5436: the copy Lidarr maps outranks everything, then the copy whose
|
||||
// tags fit the album; quality decides only between copies equal on both. Each
|
||||
// case is built so the older rules would choose the other copy, so a reorder
|
||||
// that drops the new rules fails.
|
||||
func TestProposeSurvivor_LidarrThenTagFit(t *testing.T) {
|
||||
// M498 #5436: the copy whose tags fit the album outranks quality, which decides
|
||||
// only between copies whose tags fit equally. Each case is built so the quality
|
||||
// rules would choose the other copy, so a reorder that drops tag fit fails.
|
||||
func TestProposeSurvivor_TagFitFirst(t *testing.T) {
|
||||
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -103,12 +102,12 @@ func TestProposeSurvivor_LidarrThenTagFit(t *testing.T) {
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "tracked beats lossless and better tags",
|
||||
name: "tag fit beats lossless",
|
||||
cands: []SurvivorCandidate{
|
||||
{TrackID: "flac", FileFormat: "flac", FileSize: 40_000_000, AddedAt: at, TagFit: 3},
|
||||
{TrackID: "tracked", FileFormat: "mp3", FileSize: 5_000_000, AddedAt: at, TagFit: 1, LidarrTracked: true},
|
||||
{TrackID: "flac", FileFormat: "flac", FileSize: 40_000_000, AddedAt: at, TagFit: 1},
|
||||
{TrackID: "fits", FileFormat: "mp3", FileSize: 5_000_000, AddedAt: at, TagFit: 3},
|
||||
},
|
||||
wantID: "tracked", wantReason: "the copy Lidarr tracks",
|
||||
wantID: "fits", wantReason: "tags fit the album",
|
||||
},
|
||||
{
|
||||
// The Humanz shape: two rips a few bytes apart, one carrying a
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestCrossReleaseCopiesCountAsOneSong_Integration(t *testing.T) {
|
||||
q := dbq.New(pool)
|
||||
f := newSongLinkFixture(t, pool)
|
||||
|
||||
res, err := ResolveDuplicates(ctx, pool, nil, "", nil, true)
|
||||
res, err := ResolveDuplicates(ctx, pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func TestCrossReleaseCopiesCountAsOneSong_Integration(t *testing.T) {
|
||||
}
|
||||
|
||||
// A second pass changes nothing.
|
||||
if res, err := ResolveDuplicates(ctx, pool, nil, "", nil, true); err != nil || res.SongsLinked != 0 {
|
||||
if res, err := ResolveDuplicates(ctx, pool, nil, "", true); err != nil || res.SongsLinked != 0 {
|
||||
t.Errorf("second pass linked %d (%v), want 0", res.SongsLinked, err)
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestCrossReleaseLinkNeedsAutoResolve_Integration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
q := dbq.New(pool)
|
||||
f := newSongLinkFixture(t, pool)
|
||||
if _, err := ResolveDuplicates(ctx, pool, nil, "", nil, false); err != nil {
|
||||
if _, err := ResolveDuplicates(ctx, pool, nil, "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys := songKeys(t, q, f.single.ID, f.albumCut.ID)
|
||||
|
||||
Reference in New Issue
Block a user