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
+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)
}