feat: the same song on several releases counts as one song (M498 #5438)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m7s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m11s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m58s
release / Build signed APK (releases and dev) (push) Canceled after 5m59s

A single and the album it is on stay two files, since each fulfils its own
release in Lidarr, but they are one song to the listener.

- tracks.song_id links copies; the generated song_key (song_id, else the
  track's own id) is what they share (migration 0076).
- The resolver links each cross-release group every pass (idempotent; only
  with auto-resolve on) and, when a link is new, shares existing likes across
  the song and logs them for sync.
- A like or unlike (web and Subsonic) reaches every copy; each change is
  logged and published so clients update every heart.
- The Liked list and its count show the song once; Shuffle and the mix writer
  take one copy per song.
- A merge keeps the removed copy's song link; "Not the same song" on the
  Across releases tab dismisses the group and undoes the link.

Shared plays ("heard via another copy") are left for a later step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:07:55 -04:00
co-authored by Claude Opus 5.5
parent 4ecff52f19
commit 5b372f61d7
22 changed files with 799 additions and 60 deletions
+5 -3
View File
@@ -30,7 +30,8 @@
// purpose. A third tab lists what the resolver did.
//
// Dismissing a group says "these are not duplicates", and the sweep will not
// propose that set again. Merging (#3911) keeps one copy, moves the others'
// propose that set again. On the cross-release tab it reads "Not the same
// song" and also undoes the link that made the copies one song (#5438). Merging (#3911) keeps one copy, moves the others'
// likes, plays and playlist entries onto it, and deletes their files, so it
// asks twice. The server refuses to remove a copy Lidarr uses.
@@ -337,7 +338,8 @@
<p class="mt-3 text-text-primary">No songs repeated across releases.</p>
<p class="mt-1 text-sm text-text-secondary">
A single and the album it's on, or two editions of one album, show here. Each copy
belongs to its own release, so none is removed.
belongs to its own release, so none is removed. They count as one song: a like on one
is a like on all, and a mix plays it once.
</p>
{:else}
<p class="mt-3 text-text-primary">Nothing needs review.</p>
@@ -366,7 +368,7 @@
disabled={dismissing === group.id || merging === group.id}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
Not duplicates
{tab === 'cross_release' ? 'Not the same song' : 'Not duplicates'}
</button>
<button
type="button"
@@ -323,6 +323,15 @@ describe('admin duplicates', () => {
expect(createDuplicatesQuery).toHaveBeenLastCalledWith('cross_release');
});
// Copies across releases are linked as one song; dismissing one says they
// are not, which is what the button names.
test('Across releases dismisses as Not the same song', async () => {
renderWith(response());
await fireEvent.click(screen.getByRole('tab', { name: /across releases/i }));
await fireEvent.click(screen.getByRole('button', { name: 'Not the same song' }));
expect(dismissDuplicateGroup).toHaveBeenCalledWith('g-1');
});
test('Resolved automatically lists merges and release changes', async () => {
renderWith(response(), [
{