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 -19
View File
@@ -14,7 +14,6 @@ import (
"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/tracks"
)
// duplicateMemberView is one copy in a proposed duplicate group. LikeCount and
@@ -33,12 +32,8 @@ type duplicateMemberView struct {
AddedAt string `json:"added_at"`
LikeCount int64 `json:"like_count"`
PlayCount int64 `json:"play_count"`
// LidarrState is Lidarr's view of the file as of the resolver's last pass
// (M498): "tracked" (mapped to a track, so it cannot be removed without
// Lidarr downloading it again), "unmapped", or null when not asked.
LidarrState *string `json:"lidarr_state"`
DiscNumber *int32 `json:"disc_number"`
TrackNumber *int32 `json:"track_number"`
DiscNumber *int32 `json:"disc_number"`
TrackNumber *int32 `json:"track_number"`
}
// duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are
@@ -291,14 +286,12 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
AddedAt: formatTimestamp(row.AddedAt),
LikeCount: row.LikeCount,
PlayCount: row.PlayCount,
LidarrState: row.LidarrState,
DiscNumber: row.DiscNumber,
TrackNumber: row.TrackNumber,
})
candidates[n] = append(candidates[n], library.SurvivorCandidate{
TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time,
LidarrTracked: row.LidarrState != nil && *row.LidarrState == "tracked",
TagFit: library.TagFitScore(row.TrackNumber != nil, row.PositionClash, row.FilePath, row.HasMbid),
TagFit: library.TagFitScore(row.TrackNumber != nil, row.PositionClash, row.FilePath, row.HasMbid),
})
}
for i := range groups {
@@ -398,10 +391,6 @@ const mergeRequestBodyLimit = 1 << 16
// Errors:
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
// removed — nothing was changed (fileRemoveAPIError)
// - 409 copy_tracked_by_lidarr when a copy to remove is one Lidarr maps to a
// track: removing it would make Lidarr download it again (M498)
// - 503 lidarr_unavailable when Lidarr is on but did not answer, so that
// cannot be checked
// - 404 duplicate_group_not_pending when the group was already resolved
// - 400 survivor_not_in_group, invalid_id, invalid_body
func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Request) {
@@ -439,11 +428,6 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
case errors.Is(err, library.ErrSurvivorNotInGroup):
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
case errors.Is(err, library.ErrCopyTrackedByLidarr):
writeAdminJSONErr(w, http.StatusConflict, "copy_tracked_by_lidarr")
case errors.Is(err, tracks.ErrLidarrUnavailable):
h.logger.Warn("admin: merge duplicate group: lidarr unavailable", "group_id", uuidToString(groupID), "err", err)
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unavailable")
default:
h.logger.Error("admin: merge duplicate group", "group_id", uuidToString(groupID), "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
+9 -10
View File
@@ -65,27 +65,26 @@ func TestFoldDuplicateGroups(t *testing.T) {
}
// M498: the proposal shown beside a group follows the resolver's rule. The copy
// Lidarr tracks is proposed even when the other is lossless, because removing
// the tracked one would make Lidarr download it again.
func TestFoldDuplicateGroups_ProposesTheCopyLidarrTracks(t *testing.T) {
// whose tags fit the album is proposed even when the other is lossless.
func TestFoldDuplicateGroups_ProposesTheCopyWhoseTagsFit(t *testing.T) {
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
tracked, unmapped := "tracked", "unmapped"
class := "same_release"
three := int32(3)
rows := []dbq.ListPendingDuplicateGroupMembersRow{
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(30), Class: &class,
Title: "Song", FileFormat: "flac", FileSize: 30_000_000, AddedAt: dupTS(at), LidarrState: &unmapped},
Title: "Song", FileFormat: "flac", FileSize: 30_000_000, AddedAt: dupTS(at)},
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(31), Class: &class,
Title: "Song", FileFormat: "mp3", FileSize: 9_000_000, AddedAt: dupTS(at), LidarrState: &tracked},
Title: "Song", FileFormat: "mp3", FileSize: 9_000_000, AddedAt: dupTS(at), TrackNumber: &three, HasMbid: true},
}
got := foldDuplicateGroups(rows)
if len(got) != 1 {
t.Fatalf("folded %d groups, want 1", len(got))
}
g := got[0]
if g.SurvivorTrackID != uuidToString(dupUUID(31)) || g.SurvivorReason != "the copy Lidarr tracks" {
t.Errorf("survivor = (%s, %q), want the tracked mp3", g.SurvivorTrackID, g.SurvivorReason)
if g.SurvivorTrackID != uuidToString(dupUUID(31)) || g.SurvivorReason != "tags fit the album" {
t.Errorf("survivor = (%s, %q), want the tagged mp3", g.SurvivorTrackID, g.SurvivorReason)
}
if g.Class == nil || *g.Class != "same_release" || g.Members[1].LidarrState == nil || *g.Members[1].LidarrState != "tracked" {
t.Errorf("class or lidarr state not carried: %+v", g)
if g.Class == nil || *g.Class != "same_release" {
t.Errorf("class not carried: %+v", g)
}
}