feat: duplicates resolve themselves where Lidarr says it is safe (M498)
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped

The duplicate sweep proposed 4,197 groups and every one waited for the
operator. Most are safe to settle, and Lidarr defines what safe means: it
maps one file to each track of the release it monitors and downloads any
mapped file that disappears. Deleting a mapped copy opens exactly the hole
the operator saw Lidarr fill.

Classify (#5435)
- Migration 0075: duplicate_groups.class (same_release, cross_release,
  mismatch, review), resolve_note, resolved_automatically;
  duplicate_group_members.lidarr_state (tracked, unmapped);
  fingerprint_settings.auto_resolve; notification kind
  duplicates_resolved with both kind CHECKs swapped (rule 36).
- library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring
  credits, remaster notes and video-rip markers, and keeping live, demo,
  remix and instrumental. The rip markers move from api to library.

Choose the copy to keep (#5436)
- ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a
  clash-free track number, no rip marker in the name, an MBID), then the
  quality rules. File size picked the wrong Humanz copy in 6 of 21 groups.

Act (#5437)
- An hourly resolver pass reads Lidarr's unmapped files, matched by the
  last three path components, and records each copy's state.
- Same album, with at most one copy mapped: merged into the mapped copy.
  The merge is guarded, so a mapped copy can never be removed
  (MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr).
- Same album, every copy mapped: the monitored release lists the song
  twice (Humanz's 14x12" box set). The pass moves Lidarr to the release
  that lists each song once and best covers what is on disk. It never
  picks one covering less, and is capped at 10 albums per pass.
  - Fixed point (lesson #4183): the chosen release no longer repeats.
  - The album is left alone for 24h while Lidarr rescans, so "every copy
    unmapped" mid-rescan is never read as licence to merge.
- Both actions are audited with no actor and summarised to admins. The
  operator can switch them off in the Fingerprinting card (rule 25).
- Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503
  lidarr_unavailable when Lidarr cannot say.

Web
- Duplicates gets tabs: Needs review, Across releases, Resolved
  automatically. Each loads as you scroll (rule 172), replacing the
  pager.
- Each copy says whether Lidarr uses it.
- The resolver's note shows on each group.
- The merge confirm blocks, before sending, a merge that would remove
  the copy Lidarr uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 21:49:37 -04:00
co-authored by Claude Opus 5.5
parent 01e2294471
commit 4ecff52f19
48 changed files with 3068 additions and 254 deletions
@@ -0,0 +1,17 @@
DELETE FROM user_notifications WHERE kind = 'duplicates_resolved';
DELETE FROM user_notification_prefs WHERE kind = 'duplicates_resolved';
ALTER TABLE user_notification_prefs DROP CONSTRAINT user_notification_prefs_kind_check;
ALTER TABLE user_notification_prefs ADD CONSTRAINT user_notification_prefs_kind_check
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
'request_pending', 'quarantine_flagged', 'scan_failed',
'tracks_missing', 'duplicates_found', 'playback_errors'));
ALTER TABLE user_notifications DROP CONSTRAINT user_notifications_kind_check;
ALTER TABLE user_notifications ADD CONSTRAINT user_notifications_kind_check
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
'request_pending', 'quarantine_flagged', 'scan_failed',
'tracks_missing', 'duplicates_found', 'playback_errors'));
ALTER TABLE fingerprint_settings DROP COLUMN IF EXISTS auto_resolve;
ALTER TABLE duplicate_group_members DROP COLUMN IF EXISTS lidarr_state;
ALTER TABLE duplicate_groups DROP COLUMN IF EXISTS resolved_automatically;
ALTER TABLE duplicate_groups DROP COLUMN IF EXISTS resolve_note;
ALTER TABLE duplicate_groups DROP COLUMN IF EXISTS class;
@@ -0,0 +1,56 @@
-- 0075_duplicate_resolve.up.sql — duplicates resolve themselves (Scribe
-- milestone #498: #5435, #5436, #5437).
--
-- The sweep (0059) proposes groups and, until now, every proposal waited for the
-- operator. The resolver classifies each pending group and acts on the classes
-- that are safe. What makes deletion safe is Lidarr's view of the file: Lidarr
-- maps one file to each track of the release it monitors, so deleting a mapped
-- file opens a hole it fills by downloading again. Only a copy Lidarr does not
-- map may go.
-- What kind of duplicate a group is. NULL until the resolver has classified it.
-- same_release every copy is on one album
-- cross_release the same song on different releases (a single and its album)
-- mismatch identical audio under different titles on different albums:
-- a file imported as the wrong song
-- review anything else: the operator decides
-- Rule 36: a new value swaps this constraint in the same migration.
ALTER TABLE duplicate_groups ADD COLUMN class text;
ALTER TABLE duplicate_groups ADD CONSTRAINT duplicate_groups_class_check
CHECK (class IN ('same_release', 'cross_release', 'mismatch', 'review'));
-- Why the resolver left a group for the operator, in words the report shows
-- ("Lidarr tracks every copy as its own track"). NULL when it had nothing to say.
ALTER TABLE duplicate_groups ADD COLUMN resolve_note text;
-- True when the resolver merged the group itself, so the report can tell its
-- merges from the operator's.
ALTER TABLE duplicate_groups ADD COLUMN resolved_automatically boolean NOT NULL DEFAULT false;
-- Lidarr's view of each copy, as of the resolver's last pass:
-- tracked Lidarr holds the file and it is not among its unmapped files
-- unmapped Lidarr holds the file but maps it to no track: removing it opens
-- no hole
-- NULL when Lidarr was not consulted (disabled, or unreachable that pass).
ALTER TABLE duplicate_group_members ADD COLUMN lidarr_state text;
ALTER TABLE duplicate_group_members ADD CONSTRAINT duplicate_group_members_lidarr_state_check
CHECK (lidarr_state IN ('tracked', 'unmapped'));
-- Rule 25: the operator can turn the resolver off. On by default: the operator
-- asked for duplicates to resolve themselves (2026-10-09).
ALTER TABLE fingerprint_settings ADD COLUMN auto_resolve boolean NOT NULL DEFAULT true;
-- The resolver tells admins what it did (rule 36: the kind CHECKs of 0073 are
-- swapped to carry the new value). Postgres named both from their columns.
ALTER TABLE user_notifications DROP CONSTRAINT user_notifications_kind_check;
ALTER TABLE user_notifications ADD CONSTRAINT user_notifications_kind_check
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
'request_pending', 'quarantine_flagged', 'scan_failed',
'tracks_missing', 'duplicates_found', 'duplicates_resolved',
'playback_errors'));
ALTER TABLE user_notification_prefs DROP CONSTRAINT user_notification_prefs_kind_check;
ALTER TABLE user_notification_prefs ADD CONSTRAINT user_notification_prefs_kind_check
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
'request_pending', 'quarantine_flagged', 'scan_failed',
'tracks_missing', 'duplicates_found', 'duplicates_resolved',
'playback_errors'));