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

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:
2026-10-09 20:12:49 -04:00
co-authored by Claude Opus 5.5
parent 8bd48b4302
commit 5306a261c1
27 changed files with 301 additions and 1743 deletions
+3 -12
View File
@@ -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
}