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
+7
View File
@@ -31,6 +31,9 @@ type suspectTrackView struct {
// first pass (M498 #5439). VerdictAt is when it was last set.
Verdict *string `json:"verdict"`
VerdictAt *string `json:"verdict_at"`
// SearchedAt is when the resolver last asked Lidarr to search the album for
// a better copy (#5447): null when it has not.
SearchedAt *string `json:"searched_at"`
}
// suspectGroupView is a folder's worth of flagged tracks. As on the
@@ -112,6 +115,10 @@ func groupSuspectByDirectory(rows []dbq.ListSuspectSourceTracksRow) []suspectGro
at := row.SourceVerdictAt.Time.UTC().Format(time.RFC3339)
t.VerdictAt = &at
}
if row.SearchedAt.Valid {
at := row.SearchedAt.Time.UTC().Format(time.RFC3339)
t.SearchedAt = &at
}
if n := len(groups); n > 0 && groups[n-1].Directory == row.Directory {
groups[n-1].Tracks = append(groups[n-1].Tracks, t)
continue
+5
View File
@@ -66,6 +66,11 @@ const (
// Written with no actor; the metadata names the album and both releases.
ActionLidarrReleaseChange Action = "lidarr_release_change"
// Lidarr rip search (M498 #5447): the resolver asked Lidarr to search an
// album whose only copy of a song is a video rip, for a better release.
// Written with no actor; the metadata names the album and how many rips.
ActionLidarrRipSearch Action = "lidarr_rip_search"
// Subsonic password (#5026): generated in Settings for t/s-only clients.
ActionSubsonicPasswordSet Action = "subsonic_password_set"
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
+82 -1
View File
@@ -405,6 +405,78 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
return items, nil
}
const listAlbumsWithSoleCopyRips = `-- name: ListAlbumsWithSoleCopyRips :many
SELECT a.id AS album_id,
a.release_group_mbid::text AS release_group_mbid,
a.title AS album_title,
ar.name AS artist_name,
count(*)::bigint AS rips
FROM tracks t
JOIN albums a ON a.id = t.album_id
JOIN artists ar ON ar.id = a.artist_id
WHERE t.source_verdict = 'suspect'
AND t.missing_since IS NULL
AND a.release_group_mbid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM tracks c
WHERE c.song_key = t.song_key AND c.id <> t.id
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
AND NOT EXISTS (
SELECT 1 FROM duplicate_group_members m
JOIN duplicate_groups g ON g.id = m.group_id AND g.status = 'pending'
JOIN duplicate_group_members m2 ON m2.group_id = g.id AND m2.track_id <> t.id
JOIN tracks c ON c.id = m2.track_id
WHERE m.track_id = t.id
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
AND NOT EXISTS (
SELECT 1 FROM audit_log l
WHERE l.action = 'lidarr_rip_search'
AND l.metadata->>'album_id' = a.id::text
AND l.created_at > now() - interval '7 days')
GROUP BY a.id, a.release_group_mbid, a.title, ar.name
ORDER BY count(*) DESC, a.id
LIMIT $1
`
type ListAlbumsWithSoleCopyRipsRow struct {
AlbumID pgtype.UUID
ReleaseGroupMbid string
AlbumTitle string
ArtistName string
Rips int64
}
// Albums holding a held-back video rip that is the only copy of its song
// (#5447): no present copy that is not a rip shares its song, and no pending
// duplicate group pairs it with one. Lidarr counts such a track fulfilled, so
// the resolver asks it to search the album for a better release. Albums
// searched within the last week are left out, most rips first.
func (q *Queries) ListAlbumsWithSoleCopyRips(ctx context.Context, pageLimit int32) ([]ListAlbumsWithSoleCopyRipsRow, error) {
rows, err := q.db.Query(ctx, listAlbumsWithSoleCopyRips, pageLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAlbumsWithSoleCopyRipsRow
for rows.Next() {
var i ListAlbumsWithSoleCopyRipsRow
if err := rows.Scan(
&i.AlbumID,
&i.ReleaseGroupMbid,
&i.AlbumTitle,
&i.ArtistName,
&i.Rips,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
albums.title AS album_title,
@@ -659,10 +731,17 @@ SELECT t.id,
artists.id AS artist_id,
artists.name AS artist_name,
t.source_verdict,
t.source_verdict_at
t.source_verdict_at,
searched.at AS searched_at
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
-- When the resolver last asked Lidarr to search the album for a better copy
-- (#5447).
LEFT JOIN LATERAL (
SELECT max(l.created_at)::timestamptz AS at FROM audit_log l
WHERE l.action = 'lidarr_rip_search' AND l.metadata->>'album_id' = albums.id::text
) searched ON true
WHERE t.missing_since IS NULL
AND regexp_replace(t.file_path, '^.*/', '') ~* $1::text
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
@@ -689,6 +768,7 @@ type ListSuspectSourceTracksRow struct {
ArtistName string
SourceVerdict *string
SourceVerdictAt pgtype.Timestamptz
SearchedAt pgtype.Timestamptz
}
// The admin report of present tracks whose filename looks like a video rip
@@ -724,6 +804,7 @@ func (q *Queries) ListSuspectSourceTracks(ctx context.Context, arg ListSuspectSo
&i.ArtistName,
&i.SourceVerdict,
&i.SourceVerdictAt,
&i.SearchedAt,
); err != nil {
return nil, err
}
+45 -1
View File
@@ -287,10 +287,17 @@ SELECT t.id,
artists.id AS artist_id,
artists.name AS artist_name,
t.source_verdict,
t.source_verdict_at
t.source_verdict_at,
searched.at AS searched_at
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
-- When the resolver last asked Lidarr to search the album for a better copy
-- (#5447).
LEFT JOIN LATERAL (
SELECT max(l.created_at)::timestamptz AS at FROM audit_log l
WHERE l.action = 'lidarr_rip_search' AND l.metadata->>'album_id' = albums.id::text
) searched ON true
WHERE t.missing_since IS NULL
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
@@ -327,3 +334,40 @@ UPDATE tracks
SET source_verdict = sqlc.arg(verdict)::text, source_verdict_at = now()
WHERE id = sqlc.arg(id)
AND source_verdict IS NOT NULL;
-- name: ListAlbumsWithSoleCopyRips :many
-- Albums holding a held-back video rip that is the only copy of its song
-- (#5447): no present copy that is not a rip shares its song, and no pending
-- duplicate group pairs it with one. Lidarr counts such a track fulfilled, so
-- the resolver asks it to search the album for a better release. Albums
-- searched within the last week are left out, most rips first.
SELECT a.id AS album_id,
a.release_group_mbid::text AS release_group_mbid,
a.title AS album_title,
ar.name AS artist_name,
count(*)::bigint AS rips
FROM tracks t
JOIN albums a ON a.id = t.album_id
JOIN artists ar ON ar.id = a.artist_id
WHERE t.source_verdict = 'suspect'
AND t.missing_since IS NULL
AND a.release_group_mbid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM tracks c
WHERE c.song_key = t.song_key AND c.id <> t.id
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
AND NOT EXISTS (
SELECT 1 FROM duplicate_group_members m
JOIN duplicate_groups g ON g.id = m.group_id AND g.status = 'pending'
JOIN duplicate_group_members m2 ON m2.group_id = g.id AND m2.track_id <> t.id
JOIN tracks c ON c.id = m2.track_id
WHERE m.track_id = t.id
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
AND NOT EXISTS (
SELECT 1 FROM audit_log l
WHERE l.action = 'lidarr_rip_search'
AND l.metadata->>'album_id' = a.id::text
AND l.created_at > now() - interval '7 days')
GROUP BY a.id, a.release_group_mbid, a.title, ar.name
ORDER BY count(*) DESC, a.id
LIMIT sqlc.arg(page_limit);
+67 -1
View File
@@ -50,6 +50,7 @@ type LidarrLibrary interface {
GetAlbumReleases(ctx context.Context, albumID int) ([]lidarr.AlbumRelease, error)
ListReleaseTracks(ctx context.Context, releaseID int) ([]lidarr.ReleaseTrack, error)
SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error
SearchAlbums(ctx context.Context, albumIDs []int) error
}
const (
@@ -94,6 +95,9 @@ type DuplicateResolveResult struct {
// HeldBack counts tracks newly held back from radio and the mixes as video
// rips; Released counts held-back tracks whose name no longer says so.
HeldBack, Released int64
// RipSearches counts albums Lidarr was asked to search for a better copy
// of a rip that has no clean copy (#5447).
RipSearches int
}
// LidarrPathKey is the part of a path Minstrel and Lidarr agree on: the last
@@ -265,6 +269,13 @@ func ResolveDuplicates(
return res, err
}
// Last, rips with no clean copy anywhere: ask Lidarr for something better.
searched, err := searchSoleCopyRips(ctx, q, pool, logger, lid)
res.RipSearches = searched
if err != nil {
logger.Warn("duplicate resolve: rip search failed", "err", err)
}
if res.Merged > 0 || len(changes) > 0 {
notifyAdmins(ctx, notifications.KindDuplicatesResolved, notifications.Payload{
Count: int64(res.Merged + len(changes)),
@@ -334,6 +345,61 @@ func linkSong(ctx context.Context, pool *pgxpool.Pool, ids []pgtype.UUID) (bool,
return true, tx.Commit(ctx)
}
// ripSearchCap bounds the albums one pass asks Lidarr to search, so the first
// pass after deploy does not queue the whole backlog of rips at once.
const ripSearchCap = 10
// searchSoleCopyRips asks Lidarr to search each album holding a video rip that
// is the only copy of its song (#5447, operator's choice: option 2). While
// Lidarr maps the rip it counts the track fulfilled, so nothing else asks for
// better. An album search grabs only when the quality profile allows an
// upgrade, so this removes nothing and may change nothing: the rip stays held
// back either way. An album is searched at most once a week (the query reads
// the audit log), and an album Lidarr does not hold is skipped.
func searchSoleCopyRips(
ctx context.Context, q *dbq.Queries, pool *pgxpool.Pool, logger *slog.Logger, lid LidarrLibrary,
) (int, error) {
albums, err := q.ListAlbumsWithSoleCopyRips(ctx, ripSearchCap)
if err != nil {
return 0, fmt.Errorf("list albums with sole-copy rips: %w", err)
}
type found struct {
row dbq.ListAlbumsWithSoleCopyRipsRow
lidarrID int
}
var targets []found
for _, a := range albums {
la, err := lid.LookupAlbumByMBID(ctx, a.ReleaseGroupMbid)
if errors.Is(err, lidarr.ErrNotFound) {
continue
}
if err != nil {
return 0, fmt.Errorf("look up %s: %w", a.ReleaseGroupMbid, err)
}
targets = append(targets, found{row: a, lidarrID: la.ID})
}
if len(targets) == 0 {
return 0, nil
}
ids := make([]int, len(targets))
for i, t := range targets {
ids[i] = t.lidarrID
}
if err := lid.SearchAlbums(ctx, ids); err != nil {
return 0, fmt.Errorf("search albums: %w", err)
}
for _, t := range targets {
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionLidarrRipSearch, map[string]any{
"album_id": syncpkg.FormatUUID(t.row.AlbumID),
"album_title": t.row.AlbumTitle,
"artist_name": t.row.ArtistName,
"lidarr_album_id": t.lidarrID,
"rips": t.row.Rips,
})
}
return len(targets), nil
}
func foldResolveGroups(rows []dbq.ListDuplicateGroupsForResolveRow) []*resolveGroup {
var groups []*resolveGroup
for _, r := range rows {
@@ -787,5 +853,5 @@ func (w *DuplicateResolveWorker) tickOnce(ctx context.Context) {
w.logger.Info("duplicate resolve complete",
"groups", res.Groups, "lidarr", res.LidarrConsulted, "merged", res.Merged,
"merge_failed", res.MergeFailed, "release_changes", len(res.ReleaseChanges), "songs_linked", res.SongsLinked,
"held_back", res.HeldBack, "released", res.Released)
"held_back", res.HeldBack, "released", res.Released, "rip_searches", res.RipSearches)
}
@@ -82,6 +82,7 @@ type fakeLidarr struct {
releases []lidarr.AlbumRelease
releaseTracks map[int][]lidarr.ReleaseTrack
setCalls [][2]int
searchCalls [][]int
}
func (f *fakeLidarr) ListUnmappedTrackFiles(context.Context) ([]lidarr.TrackFile, error) {
@@ -116,6 +117,11 @@ func (f *fakeLidarr) ListReleaseTracks(_ context.Context, releaseID int) ([]lida
return f.releaseTracks[releaseID], nil
}
func (f *fakeLidarr) SearchAlbums(_ context.Context, albumIDs []int) error {
f.searchCalls = append(f.searchCalls, albumIDs)
return nil
}
func (f *fakeLidarr) SetMonitoredRelease(_ context.Context, albumID, releaseID int) error {
f.setCalls = append(f.setCalls, [2]int{albumID, releaseID})
for i := range f.releases {
@@ -502,3 +508,60 @@ func TestResolveDuplicates_HoldsBackRips_Integration(t *testing.T) {
t.Errorf("set on an unflagged track: %d, %v; want 0 rows", n, err)
}
}
// A rip that is the only copy of its song: Lidarr is asked to search its album
// for a better release (#5447), once a week at most, and the rip is not touched.
// A rip with a clean copy elsewhere is not searched for.
func TestResolveDuplicates_SearchesForASoleCopyRip_Integration(t *testing.T) {
f := newCrossReleaseRipFixture(t)
ctx := context.Background()
exec := func(sql string, args ...any) {
t.Helper()
if _, err := f.pool.Exec(ctx, sql, args...); err != nil {
t.Fatalf("exec %q: %v", sql, err)
}
}
// The single's rip has a clean copy on the album: never searched for.
exec(`UPDATE albums SET release_group_mbid = 'rg-single' WHERE id = $1`, f.rip.AlbumID)
lid := &fakeLidarr{album: lidarr.LidarrAlbum{ID: 77, ForeignAlbumID: "rg-single"}}
if _, err := ResolveDuplicates(ctx, f.pool, nil, "", lid, true); err != nil {
t.Fatal(err)
}
if len(lid.searchCalls) != 0 {
t.Fatalf("searched %v while a clean copy exists", lid.searchCalls)
}
// Without the group and the song link, the rip is the only copy.
exec(`DELETE FROM duplicate_groups WHERE id = $1`, f.groupID)
exec(`UPDATE tracks SET song_id = NULL WHERE id IN ($1, $2)`, f.rip.ID, f.clean.ID)
res, err := ResolveDuplicates(ctx, f.pool, nil, "", lid, true)
if err != nil {
t.Fatal(err)
}
if res.RipSearches != 1 || len(lid.searchCalls) != 1 || len(lid.searchCalls[0]) != 1 || lid.searchCalls[0][0] != 77 {
t.Fatalf("rip searches %d, calls %v; want album 77 searched once", res.RipSearches, lid.searchCalls)
}
if !exists(f.ripPath) {
t.Error("the rip was removed; a search must not touch it")
}
// The next pass, inside the week, leaves the album alone.
if res, err := ResolveDuplicates(ctx, f.pool, nil, "", lid, true); err != nil || res.RipSearches != 0 {
t.Errorf("second pass searched %d (%v), want 0", res.RipSearches, err)
}
var searchedAt pgtype.Timestamptz
rows, err := dbq.New(f.pool).ListSuspectSourceTracks(ctx, dbq.ListSuspectSourceTracksParams{
Pattern: SuspectSourcePattern, PageLimit: 10,
})
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if r.ID == f.rip.ID {
searchedAt = r.SearchedAt
}
}
if !searchedAt.Valid {
t.Error("the suspect-sources report doesn't show the search")
}
}
+21
View File
@@ -152,3 +152,24 @@ func (c *Client) SetMonitoredRelease(ctx context.Context, albumID, releaseID int
_ = putResp.Body.Close()
return nil
}
// SearchAlbums asks Lidarr to search its indexers for the albums
// (POST /api/v1/command, AlbumSearch). Lidarr grabs a release only when it
// beats the files the album already has under the quality profile, so for an
// album whose tracks are all present this is an upgrade search: harmless when
// nothing better exists. The command is queued; the call does not wait for it.
func (c *Client) SearchAlbums(ctx context.Context, albumIDs []int) error {
if len(albumIDs) == 0 {
return nil
}
body, err := json.Marshal(map[string]any{"name": "AlbumSearch", "albumIds": albumIDs})
if err != nil {
return fmt.Errorf("%w: marshal command: %v", ErrInvalidPayload, err)
}
resp, err := c.post(ctx, "/api/v1/command", body)
if err != nil {
return err
}
_ = resp.Body.Close()
return nil
}
+22
View File
@@ -114,3 +114,25 @@ func TestSetMonitoredRelease_UnknownReleaseSendsNothing(t *testing.T) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestSearchAlbums_PostsTheCommand(t *testing.T) {
var got map[string]any
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/command" {
t.Errorf("request = %s %s, want POST /api/v1/command", r.Method, r.URL.Path)
}
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &got)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":1,"name":"AlbumSearch"}`))
})
defer srv.Close()
if err := c.SearchAlbums(context.Background(), []int{4723, 12}); err != nil {
t.Fatalf("SearchAlbums: %v", err)
}
ids, _ := got["albumIds"].([]any)
if got["name"] != "AlbumSearch" || len(ids) != 2 || ids[0] != float64(4723) || ids[1] != float64(12) {
t.Errorf("command = %v", got)
}
}
@@ -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/);
});
});