feat: Lidarr searches for a better copy of a rip that has none (M498 #5447)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m26s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m6s
release / android (push) Successful in 5m58s
release / Build signed APK (releases and dev) (push) Successful in 6m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped

The operator chose option 2: an album holding a video rip that is the only
copy of its song is searched in Lidarr (AlbumSearch), at most once a week
and ten albums a pass. Lidarr grabs only when the quality profile allows an
upgrade, so nothing is removed and the rip stays held back either way.

- lidarr.Client.SearchAlbums posts the AlbumSearch command.
- ListAlbumsWithSoleCopyRips finds the albums: a held-back rip with no
  present clean copy sharing its song or its pending group, and not searched
  in the last week (audit action lidarr_rip_search).
- Suspect sources shows when Lidarr last searched the track's album.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:32:30 -04:00
co-authored by Claude Opus 5.5
parent d59b7fdc2a
commit e17556dce9
12 changed files with 337 additions and 7 deletions
@@ -16,7 +16,8 @@ function track(id: string): AdminSuspectTrack {
track_number: null,
markers: [],
verdict: null,
verdict_at: null
verdict_at: null,
searched_at: null
};
}
+2
View File
@@ -443,6 +443,8 @@ export type AdminSuspectTrack = {
// back. null: the resolver hasn't looked yet (M498 #5439).
verdict: SuspectVerdict | null;
verdict_at: string | null;
// When Lidarr was last asked to search the album for a better copy (#5447).
searched_at: string | null;
};
export type SuspectVerdict = 'suspect' | 'fine';
@@ -17,7 +17,8 @@
// The duplicate resolver holds each one back from radio and the mixes, and
// merges it away where Lidarr keeps a clean copy (M498 #5439). A marker is a
// reason to look, not proof, so each row says what was done and offers the
// undo: "This one is fine" lets the track back for good.
// undo: "This one is fine" lets the track back for good. A rip that is the
// only copy of its song has its album searched in Lidarr (#5447).
const queryStore = createSuspectSourcesQuery();
const query = $derived($queryStore);
@@ -101,7 +102,8 @@
a video site, and can be the wrong audio: a live take, a music-video edit, or
something else entirely. Each one is kept out of radio and the mixes, though it still
plays when you choose it, and it is removed where Lidarr keeps a clean copy of the same
song. If one is fine, say so and it goes back in.
song. Where it is the only copy, Lidarr is asked once a week to search the album for a
better release. If one is fine, say so and it goes back in.
</p>
</header>
@@ -178,6 +180,11 @@
<span class="text-xs text-text-secondary" data-testid="suspect-verdict">
Held back{#if t.verdict_at && !changed[t.track_id]}&nbsp;{relativeTime(t.verdict_at)}{/if}
</span>
{#if t.searched_at}
<span class="text-xs text-text-muted" data-testid="suspect-searched">
Lidarr searched for a better copy {relativeTime(t.searched_at)}
</span>
{/if}
<button
type="button"
onclick={() => setVerdict(t, 'fine')}
@@ -29,7 +29,8 @@ function track(title: string, file: string, markers: string[], disc: number | nu
track_number: 4,
markers,
verdict: 'suspect',
verdict_at: new Date(Date.now() - 3_600_000).toISOString()
verdict_at: new Date(Date.now() - 3_600_000).toISOString(),
searched_at: null
};
}
@@ -136,4 +137,14 @@ describe('Suspect sources page', () => {
expect(await screen.findByText('Marked fine')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Hold back again' })).toBeInTheDocument();
});
// #5447: a rip with no clean copy has its album searched in Lidarr, and the
// row says when.
test('shows when Lidarr searched for a better copy', () => {
const t = track('Strobelite', 'Gorillaz - Strobelite (Official Video).mp3', ['music video']);
t.searched_at = new Date(Date.now() - 7_200_000).toISOString();
mocked.mockReturnValue(infinite([page(0, 1, [{ directory: HUMANZ, tracks: [t] }])]));
render(SuspectSourcesPage);
expect(screen.getByTestId('suspect-searched')).toHaveTextContent(/Lidarr searched for a better copy/);
});
});