feat: duplicates resolve themselves without Lidarr (M502 #5517)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped
Lidarr is off, and with it the resolver had stopped merging: it only merged when Lidarr had said which copies it mapped. Minstrel is now the only program writing the library, so the rule is about the copies: - A song held twice on one album: the extra copies merge into the one whose tags fit best (then lossless, larger, older). - Across releases or under another title: when every copy but one is a video rip (held back, or named like one and not marked fine), the rips merge into the one that is not. - Clean copies on different releases are linked as one song, as before. - Anything else waits for the operator. Gone with Lidarr: release switches and their settle window, the unmapped-file read, the rip search, the merge guard and its 409/503, lidarr.Client's release methods, and duplicate_group_members.lidarr_state (migration 0078). The duplicates page drops the per-copy Lidarr badge and the blocked-merge notice; past release changes stay in its history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,6 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||
)
|
||||
|
||||
// duplicateMemberView is one copy in a proposed duplicate group. LikeCount and
|
||||
@@ -33,12 +32,8 @@ type duplicateMemberView struct {
|
||||
AddedAt string `json:"added_at"`
|
||||
LikeCount int64 `json:"like_count"`
|
||||
PlayCount int64 `json:"play_count"`
|
||||
// LidarrState is Lidarr's view of the file as of the resolver's last pass
|
||||
// (M498): "tracked" (mapped to a track, so it cannot be removed without
|
||||
// Lidarr downloading it again), "unmapped", or null when not asked.
|
||||
LidarrState *string `json:"lidarr_state"`
|
||||
DiscNumber *int32 `json:"disc_number"`
|
||||
TrackNumber *int32 `json:"track_number"`
|
||||
DiscNumber *int32 `json:"disc_number"`
|
||||
TrackNumber *int32 `json:"track_number"`
|
||||
}
|
||||
|
||||
// duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are
|
||||
@@ -291,14 +286,12 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
|
||||
AddedAt: formatTimestamp(row.AddedAt),
|
||||
LikeCount: row.LikeCount,
|
||||
PlayCount: row.PlayCount,
|
||||
LidarrState: row.LidarrState,
|
||||
DiscNumber: row.DiscNumber,
|
||||
TrackNumber: row.TrackNumber,
|
||||
})
|
||||
candidates[n] = append(candidates[n], library.SurvivorCandidate{
|
||||
TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time,
|
||||
LidarrTracked: row.LidarrState != nil && *row.LidarrState == "tracked",
|
||||
TagFit: library.TagFitScore(row.TrackNumber != nil, row.PositionClash, row.FilePath, row.HasMbid),
|
||||
TagFit: library.TagFitScore(row.TrackNumber != nil, row.PositionClash, row.FilePath, row.HasMbid),
|
||||
})
|
||||
}
|
||||
for i := range groups {
|
||||
@@ -398,10 +391,6 @@ const mergeRequestBodyLimit = 1 << 16
|
||||
// Errors:
|
||||
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
|
||||
// removed — nothing was changed (fileRemoveAPIError)
|
||||
// - 409 copy_tracked_by_lidarr when a copy to remove is one Lidarr maps to a
|
||||
// track: removing it would make Lidarr download it again (M498)
|
||||
// - 503 lidarr_unavailable when Lidarr is on but did not answer, so that
|
||||
// cannot be checked
|
||||
// - 404 duplicate_group_not_pending when the group was already resolved
|
||||
// - 400 survivor_not_in_group, invalid_id, invalid_body
|
||||
func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -439,11 +428,6 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
|
||||
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
|
||||
case errors.Is(err, library.ErrSurvivorNotInGroup):
|
||||
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
|
||||
case errors.Is(err, library.ErrCopyTrackedByLidarr):
|
||||
writeAdminJSONErr(w, http.StatusConflict, "copy_tracked_by_lidarr")
|
||||
case errors.Is(err, tracks.ErrLidarrUnavailable):
|
||||
h.logger.Warn("admin: merge duplicate group: lidarr unavailable", "group_id", uuidToString(groupID), "err", err)
|
||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unavailable")
|
||||
default:
|
||||
h.logger.Error("admin: merge duplicate group", "group_id", uuidToString(groupID), "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
|
||||
@@ -65,27 +65,26 @@ func TestFoldDuplicateGroups(t *testing.T) {
|
||||
}
|
||||
|
||||
// M498: the proposal shown beside a group follows the resolver's rule. The copy
|
||||
// Lidarr tracks is proposed even when the other is lossless, because removing
|
||||
// the tracked one would make Lidarr download it again.
|
||||
func TestFoldDuplicateGroups_ProposesTheCopyLidarrTracks(t *testing.T) {
|
||||
// whose tags fit the album is proposed even when the other is lossless.
|
||||
func TestFoldDuplicateGroups_ProposesTheCopyWhoseTagsFit(t *testing.T) {
|
||||
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
tracked, unmapped := "tracked", "unmapped"
|
||||
class := "same_release"
|
||||
three := int32(3)
|
||||
rows := []dbq.ListPendingDuplicateGroupMembersRow{
|
||||
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(30), Class: &class,
|
||||
Title: "Song", FileFormat: "flac", FileSize: 30_000_000, AddedAt: dupTS(at), LidarrState: &unmapped},
|
||||
Title: "Song", FileFormat: "flac", FileSize: 30_000_000, AddedAt: dupTS(at)},
|
||||
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(31), Class: &class,
|
||||
Title: "Song", FileFormat: "mp3", FileSize: 9_000_000, AddedAt: dupTS(at), LidarrState: &tracked},
|
||||
Title: "Song", FileFormat: "mp3", FileSize: 9_000_000, AddedAt: dupTS(at), TrackNumber: &three, HasMbid: true},
|
||||
}
|
||||
got := foldDuplicateGroups(rows)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("folded %d groups, want 1", len(got))
|
||||
}
|
||||
g := got[0]
|
||||
if g.SurvivorTrackID != uuidToString(dupUUID(31)) || g.SurvivorReason != "the copy Lidarr tracks" {
|
||||
t.Errorf("survivor = (%s, %q), want the tracked mp3", g.SurvivorTrackID, g.SurvivorReason)
|
||||
if g.SurvivorTrackID != uuidToString(dupUUID(31)) || g.SurvivorReason != "tags fit the album" {
|
||||
t.Errorf("survivor = (%s, %q), want the tagged mp3", g.SurvivorTrackID, g.SurvivorReason)
|
||||
}
|
||||
if g.Class == nil || *g.Class != "same_release" || g.Members[1].LidarrState == nil || *g.Members[1].LidarrState != "tracked" {
|
||||
t.Errorf("class or lidarr state not carried: %+v", g)
|
||||
if g.Class == nil || *g.Class != "same_release" {
|
||||
t.Errorf("class not carried: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,32 +210,6 @@ func (q *Queries) GetLatestFingerprintComputedAt(ctx context.Context) (pgtype.Ti
|
||||
return latest, err
|
||||
}
|
||||
|
||||
const listAlbumPresentTrackTitles = `-- name: ListAlbumPresentTrackTitles :many
|
||||
SELECT title FROM tracks WHERE album_id = $1 AND missing_since IS NULL
|
||||
`
|
||||
|
||||
// The titles an album has on disk, for choosing the Lidarr release that covers
|
||||
// them best (M498 #5437).
|
||||
func (q *Queries) ListAlbumPresentTrackTitles(ctx context.Context, albumID pgtype.UUID) ([]string, error) {
|
||||
rows, err := q.db.Query(ctx, listAlbumPresentTrackTitles, albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var title string
|
||||
if err := rows.Scan(&title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, title)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDismissedDuplicateMemberSets = `-- name: ListDismissedDuplicateMemberSets :many
|
||||
SELECT g.id, array_agg(m.track_id ORDER BY m.track_id)::uuid[] AS track_ids
|
||||
FROM duplicate_groups g
|
||||
@@ -343,6 +317,7 @@ SELECT g.id AS group_id,
|
||||
t.id AS track_id,
|
||||
t.album_id,
|
||||
t.title,
|
||||
t.source_verdict,
|
||||
artists.name AS artist_name,
|
||||
t.file_path,
|
||||
t.file_format,
|
||||
@@ -351,8 +326,6 @@ SELECT g.id AS group_id,
|
||||
t.disc_number,
|
||||
t.track_number,
|
||||
(t.mbid IS NOT NULL)::boolean AS has_mbid,
|
||||
albums.release_group_mbid,
|
||||
albums.title AS album_title,
|
||||
EXISTS (SELECT 1 FROM tracks o
|
||||
WHERE o.album_id = t.album_id AND o.id <> t.id AND o.missing_since IS NULL
|
||||
AND t.track_number IS NOT NULL
|
||||
@@ -361,7 +334,6 @@ SELECT g.id AS group_id,
|
||||
FROM duplicate_groups g
|
||||
JOIN duplicate_group_members m ON m.group_id = g.id
|
||||
JOIN tracks t ON t.id = m.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE g.status = 'pending'
|
||||
AND t.missing_since IS NULL
|
||||
@@ -369,29 +341,29 @@ SELECT g.id AS group_id,
|
||||
`
|
||||
|
||||
type ListDuplicateGroupsForResolveRow struct {
|
||||
GroupID pgtype.UUID
|
||||
Tier string
|
||||
TrackID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
Title string
|
||||
ArtistName string
|
||||
FilePath string
|
||||
FileFormat string
|
||||
FileSize int64
|
||||
AddedAt pgtype.Timestamptz
|
||||
DiscNumber *int32
|
||||
TrackNumber *int32
|
||||
HasMbid bool
|
||||
ReleaseGroupMbid *string
|
||||
AlbumTitle string
|
||||
PositionClash bool
|
||||
GroupID pgtype.UUID
|
||||
Tier string
|
||||
TrackID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
Title string
|
||||
SourceVerdict *string
|
||||
ArtistName string
|
||||
FilePath string
|
||||
FileFormat string
|
||||
FileSize int64
|
||||
AddedAt pgtype.Timestamptz
|
||||
DiscNumber *int32
|
||||
TrackNumber *int32
|
||||
HasMbid bool
|
||||
PositionClash bool
|
||||
}
|
||||
|
||||
// Every pending group's present copies, for the resolver (M498 #5435): what it
|
||||
// needs to classify the group and to choose the copy to keep. One row per copy,
|
||||
// grouped by group. position_clash is true when another present track on the
|
||||
// same album claims the same disc and track number: tags that do not fit the
|
||||
// album, which the survivor rule weighs against a copy.
|
||||
// album, which the survivor rule weighs against a copy. source_verdict says
|
||||
// whether the copy is a video rip ('suspect') or one the operator kept ('fine').
|
||||
func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDuplicateGroupsForResolveRow, error) {
|
||||
rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve)
|
||||
if err != nil {
|
||||
@@ -407,6 +379,7 @@ func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDupl
|
||||
&i.TrackID,
|
||||
&i.AlbumID,
|
||||
&i.Title,
|
||||
&i.SourceVerdict,
|
||||
&i.ArtistName,
|
||||
&i.FilePath,
|
||||
&i.FileFormat,
|
||||
@@ -415,8 +388,6 @@ func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDupl
|
||||
&i.DiscNumber,
|
||||
&i.TrackNumber,
|
||||
&i.HasMbid,
|
||||
&i.ReleaseGroupMbid,
|
||||
&i.AlbumTitle,
|
||||
&i.PositionClash,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -487,7 +458,6 @@ SELECT p.id AS group_id,
|
||||
p.detected_at,
|
||||
p.class,
|
||||
p.resolve_note,
|
||||
m.lidarr_state,
|
||||
t.disc_number,
|
||||
t.track_number,
|
||||
(t.mbid IS NOT NULL)::boolean AS has_mbid,
|
||||
@@ -529,7 +499,6 @@ type ListPendingDuplicateGroupMembersRow struct {
|
||||
DetectedAt pgtype.Timestamptz
|
||||
Class *string
|
||||
ResolveNote *string
|
||||
LidarrState *string
|
||||
DiscNumber *int32
|
||||
TrackNumber *int32
|
||||
HasMbid bool
|
||||
@@ -552,8 +521,7 @@ type ListPendingDuplicateGroupMembersRow struct {
|
||||
// newest first, flattened to one row per member so the handler folds them
|
||||
// without a query per group. What each copy carries — likes and plays from
|
||||
// every user — is here because it is what the operator weighs when deciding
|
||||
// which copy to keep; Lidarr's view of it, because a copy Lidarr tracks cannot
|
||||
// be removed without Lidarr downloading it again.
|
||||
// which copy to keep.
|
||||
func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg ListPendingDuplicateGroupMembersParams) ([]ListPendingDuplicateGroupMembersRow, error) {
|
||||
rows, err := q.db.Query(ctx, listPendingDuplicateGroupMembers, arg.View, arg.PageOffset, arg.PageLimit)
|
||||
if err != nil {
|
||||
@@ -570,7 +538,6 @@ func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg List
|
||||
&i.DetectedAt,
|
||||
&i.Class,
|
||||
&i.ResolveNote,
|
||||
&i.LidarrState,
|
||||
&i.DiscNumber,
|
||||
&i.TrackNumber,
|
||||
&i.HasMbid,
|
||||
@@ -632,30 +599,6 @@ func (q *Queries) SetDuplicateGroupClasses(ctx context.Context, arg SetDuplicate
|
||||
return err
|
||||
}
|
||||
|
||||
const setDuplicateMemberLidarrStates = `-- name: SetDuplicateMemberLidarrStates :exec
|
||||
UPDATE duplicate_group_members m
|
||||
SET lidarr_state = NULLIF(v.state, '')
|
||||
FROM (SELECT unnest($1::uuid[]) AS group_id,
|
||||
unnest($2::uuid[]) AS track_id,
|
||||
unnest($3::text[]) AS state) v
|
||||
WHERE m.group_id = v.group_id
|
||||
AND m.track_id = v.track_id
|
||||
AND m.lidarr_state IS DISTINCT FROM NULLIF(v.state, '')
|
||||
`
|
||||
|
||||
type SetDuplicateMemberLidarrStatesParams struct {
|
||||
GroupIds []pgtype.UUID
|
||||
TrackIds []pgtype.UUID
|
||||
States []string
|
||||
}
|
||||
|
||||
// Lidarr's view of each copy as of this pass; an empty state clears it (Lidarr
|
||||
// not consulted). Unchanged rows are left alone.
|
||||
func (q *Queries) SetDuplicateMemberLidarrStates(ctx context.Context, arg SetDuplicateMemberLidarrStatesParams) error {
|
||||
_, err := q.db.Exec(ctx, setDuplicateMemberLidarrStates, arg.GroupIds, arg.TrackIds, arg.States)
|
||||
return err
|
||||
}
|
||||
|
||||
const startDuplicateSweep = `-- name: StartDuplicateSweep :one
|
||||
INSERT INTO duplicate_sweeps DEFAULT VALUES RETURNING id, started_at
|
||||
`
|
||||
|
||||
@@ -337,9 +337,8 @@ type DuplicateGroup struct {
|
||||
}
|
||||
|
||||
type DuplicateGroupMember struct {
|
||||
GroupID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
LidarrState *string
|
||||
GroupID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
}
|
||||
|
||||
type DuplicateSweep struct {
|
||||
|
||||
@@ -405,78 +405,6 @@ 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,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
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'));
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 0078_drop_duplicate_lidarr_state.up.sql — the duplicate resolver works
|
||||
-- without Lidarr (Scribe milestone #502, #5517).
|
||||
--
|
||||
-- 0075 recorded Lidarr's view of each duplicate copy (tracked or unmapped)
|
||||
-- because a copy Lidarr mapped could not be removed without Lidarr downloading
|
||||
-- it again. Lidarr is gone: Minstrel is the only program writing the library,
|
||||
-- so no copy is anyone else's to keep, and the column has nothing to say.
|
||||
ALTER TABLE duplicate_group_members DROP CONSTRAINT IF EXISTS duplicate_group_members_lidarr_state_check;
|
||||
ALTER TABLE duplicate_group_members DROP COLUMN IF EXISTS lidarr_state;
|
||||
@@ -131,8 +131,7 @@ SELECT count(*)::bigint
|
||||
-- newest first, flattened to one row per member so the handler folds them
|
||||
-- without a query per group. What each copy carries — likes and plays from
|
||||
-- every user — is here because it is what the operator weighs when deciding
|
||||
-- which copy to keep; Lidarr's view of it, because a copy Lidarr tracks cannot
|
||||
-- be removed without Lidarr downloading it again.
|
||||
-- which copy to keep.
|
||||
WITH page AS (
|
||||
SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at, g.class, g.resolve_note
|
||||
FROM duplicate_groups g
|
||||
@@ -151,7 +150,6 @@ SELECT p.id AS group_id,
|
||||
p.detected_at,
|
||||
p.class,
|
||||
p.resolve_note,
|
||||
m.lidarr_state,
|
||||
t.disc_number,
|
||||
t.track_number,
|
||||
(t.mbid IS NOT NULL)::boolean AS has_mbid,
|
||||
@@ -201,12 +199,14 @@ SELECT count(*)::bigint
|
||||
-- needs to classify the group and to choose the copy to keep. One row per copy,
|
||||
-- grouped by group. position_clash is true when another present track on the
|
||||
-- same album claims the same disc and track number: tags that do not fit the
|
||||
-- album, which the survivor rule weighs against a copy.
|
||||
-- album, which the survivor rule weighs against a copy. source_verdict says
|
||||
-- whether the copy is a video rip ('suspect') or one the operator kept ('fine').
|
||||
SELECT g.id AS group_id,
|
||||
g.tier,
|
||||
t.id AS track_id,
|
||||
t.album_id,
|
||||
t.title,
|
||||
t.source_verdict,
|
||||
artists.name AS artist_name,
|
||||
t.file_path,
|
||||
t.file_format,
|
||||
@@ -215,8 +215,6 @@ SELECT g.id AS group_id,
|
||||
t.disc_number,
|
||||
t.track_number,
|
||||
(t.mbid IS NOT NULL)::boolean AS has_mbid,
|
||||
albums.release_group_mbid,
|
||||
albums.title AS album_title,
|
||||
EXISTS (SELECT 1 FROM tracks o
|
||||
WHERE o.album_id = t.album_id AND o.id <> t.id AND o.missing_since IS NULL
|
||||
AND t.track_number IS NOT NULL
|
||||
@@ -225,7 +223,6 @@ SELECT g.id AS group_id,
|
||||
FROM duplicate_groups g
|
||||
JOIN duplicate_group_members m ON m.group_id = g.id
|
||||
JOIN tracks t ON t.id = m.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE g.status = 'pending'
|
||||
AND t.missing_since IS NULL
|
||||
@@ -244,22 +241,5 @@ UPDATE duplicate_groups g
|
||||
AND g.status = 'pending'
|
||||
AND (g.class IS DISTINCT FROM v.class OR g.resolve_note IS DISTINCT FROM NULLIF(v.note, ''));
|
||||
|
||||
-- name: SetDuplicateMemberLidarrStates :exec
|
||||
-- Lidarr's view of each copy as of this pass; an empty state clears it (Lidarr
|
||||
-- not consulted). Unchanged rows are left alone.
|
||||
UPDATE duplicate_group_members m
|
||||
SET lidarr_state = NULLIF(v.state, '')
|
||||
FROM (SELECT unnest(sqlc.arg(group_ids)::uuid[]) AS group_id,
|
||||
unnest(sqlc.arg(track_ids)::uuid[]) AS track_id,
|
||||
unnest(sqlc.arg(states)::text[]) AS state) v
|
||||
WHERE m.group_id = v.group_id
|
||||
AND m.track_id = v.track_id
|
||||
AND m.lidarr_state IS DISTINCT FROM NULLIF(v.state, '');
|
||||
|
||||
-- name: MarkDuplicateGroupResolvedAutomatically :exec
|
||||
UPDATE duplicate_groups SET resolved_automatically = true WHERE id = sqlc.arg(id);
|
||||
|
||||
-- name: ListAlbumPresentTrackTitles :many
|
||||
-- The titles an album has on disk, for choosing the Lidarr release that covers
|
||||
-- them best (M498 #5437).
|
||||
SELECT title FROM tracks WHERE album_id = sqlc.arg(album_id) AND missing_since IS NULL;
|
||||
|
||||
@@ -334,40 +334,3 @@ 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);
|
||||
|
||||
@@ -13,11 +13,11 @@ import (
|
||||
// 498's body) splits into four shapes:
|
||||
//
|
||||
// - same_release: every copy is on one album. An album holds a song once, so
|
||||
// the extra copies are the ones to fold away — when Lidarr does not need
|
||||
// them (the resolver checks).
|
||||
// the extra copies are the ones to fold away.
|
||||
// - cross_release: the copies are on different albums under one title. A
|
||||
// single and the album it came from, a deluxe edition and the standard one:
|
||||
// each copy fulfils its own release in Lidarr, so none is deleted.
|
||||
// each copy belongs to its own release, so none is deleted (unless it is a
|
||||
// video rip; the resolver decides).
|
||||
// - mismatch: byte-identical audio on different albums under different
|
||||
// titles. One file carries another song's tags — a wrong-file import.
|
||||
// - review: anything else. The operator decides.
|
||||
@@ -123,13 +123,6 @@ func MatchTitleKey(title, artist string) string {
|
||||
return alnumKey(title)
|
||||
}
|
||||
|
||||
// ReleaseTitleKey is the lighter comparison for asking whether one release
|
||||
// lists a song twice: case, "&" and punctuation only. Every word stays, so a
|
||||
// box set's "Song" and "Song (demo)" are two songs, as the release means them.
|
||||
func ReleaseTitleKey(title string) string {
|
||||
return alnumKey(title)
|
||||
}
|
||||
|
||||
// alnumKey lowercases, reads "&" as "and", and keeps only letters and digits.
|
||||
func alnumKey(s string) string {
|
||||
s = strings.ReplaceAll(strings.ToLower(s), "&", " and ")
|
||||
|
||||
@@ -59,13 +59,3 @@ func TestMatchTitleKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The release check keeps every word: a box set's demo is its own song.
|
||||
func TestReleaseTitleKey(t *testing.T) {
|
||||
if ReleaseTitleKey("Song (demo)") == ReleaseTitleKey("Song") {
|
||||
t.Error("ReleaseTitleKey collapsed a demo into the song")
|
||||
}
|
||||
if ReleaseTitleKey("Hallelujah Money") != ReleaseTitleKey("hallelujah money!") {
|
||||
t.Error("ReleaseTitleKey kept case or punctuation")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,16 +25,6 @@ var ErrDuplicateGroupNotPending = errors.New("library: duplicate group is not pe
|
||||
// ErrSurvivorNotInGroup means the copy chosen to keep is not a member of the group.
|
||||
var ErrSurvivorNotInGroup = errors.New("library: survivor is not a member of the group")
|
||||
|
||||
// ErrCopyTrackedByLidarr means a copy the merge would remove is one Lidarr maps
|
||||
// to a track of the release it monitors (M498). Removing it would open a hole
|
||||
// that Lidarr fills by downloading the song again, so the merge refuses and
|
||||
// changes nothing.
|
||||
var ErrCopyTrackedByLidarr = errors.New("library: a copy to remove is tracked by Lidarr")
|
||||
|
||||
// RemovableFunc reports whether the file at filePath may be removed. A merge
|
||||
// asks it about every copy it would remove, before touching anything.
|
||||
type RemovableFunc func(filePath string) bool
|
||||
|
||||
// MergedCopy is one copy a merge kept or removed.
|
||||
type MergedCopy struct {
|
||||
TrackID pgtype.UUID
|
||||
@@ -85,17 +75,6 @@ type MergeResult struct {
|
||||
func MergeDuplicateGroup(
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
|
||||
groupID, survivorID pgtype.UUID,
|
||||
) (MergeResult, error) {
|
||||
return MergeDuplicateGroupGuarded(ctx, pool, logger, dataDir, groupID, survivorID, nil)
|
||||
}
|
||||
|
||||
// MergeDuplicateGroupGuarded is MergeDuplicateGroup with a check on what it may
|
||||
// remove: when removable says no to any copy that would go, the merge returns
|
||||
// ErrCopyTrackedByLidarr (wrapped with the path) and changes nothing. A nil
|
||||
// removable allows every copy.
|
||||
func MergeDuplicateGroupGuarded(
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
|
||||
groupID, survivorID pgtype.UUID, removable RemovableFunc,
|
||||
) (MergeResult, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
@@ -130,13 +109,6 @@ func MergeDuplicateGroupGuarded(
|
||||
return MergeResult{}, err
|
||||
}
|
||||
|
||||
if removable != nil {
|
||||
for _, l := range losers {
|
||||
if !removable(l.FilePath) {
|
||||
return MergeResult{}, fmt.Errorf("%w: %s", ErrCopyTrackedByLidarr, l.FilePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, l := range losers {
|
||||
if err := removeTrackFileOnDisk(l.FilePath); err != nil {
|
||||
return MergeResult{}, err
|
||||
|
||||
@@ -2,12 +2,9 @@ package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -16,100 +13,46 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
// Duplicate resolver (M498 #5435, #5437).
|
||||
// Duplicate resolver (M498 #5435, #5437; without Lidarr since M502 #5517).
|
||||
//
|
||||
// The sweep proposes duplicate groups; the resolver decides what each one is
|
||||
// and settles the ones that are safe to settle without asking. Safety is
|
||||
// Lidarr's to define: Lidarr maps one file to each track of the release it
|
||||
// monitors, and re-downloads any mapped file that disappears. So:
|
||||
// and settles the ones that are safe to settle without asking. Minstrel is the
|
||||
// only program writing the library, so no copy is kept because something else
|
||||
// depends on it. What decides is what the copies are:
|
||||
//
|
||||
// - A copy Lidarr maps is never removed.
|
||||
// - On one album, copies Lidarr does not map are merged into the copy it does
|
||||
// (or, when it maps none, into the best by ProposeSurvivor).
|
||||
// - When Lidarr maps every copy on one album, the release it monitors lists
|
||||
// the song twice: the Humanz box set, 14 vinyl sides then the digital
|
||||
// medium. The resolver moves Lidarr to a release of that album that lists
|
||||
// each song once. Lidarr then rescans the folder, maps one copy and leaves
|
||||
// the rest unmapped, and the next pass merges them.
|
||||
//
|
||||
// The release change has a fixed point (lesson #4183): it happens only while
|
||||
// the monitored release repeats a title, and the release it chooses repeats
|
||||
// none, so the next pass finds nothing to change.
|
||||
|
||||
// LidarrLibrary is the part of Lidarr's API the resolver uses. *lidarr.Client
|
||||
// satisfies it.
|
||||
type LidarrLibrary interface {
|
||||
ListUnmappedTrackFiles(ctx context.Context) ([]lidarr.TrackFile, error)
|
||||
LookupAlbumByMBID(ctx context.Context, mbid string) (lidarr.LidarrAlbum, error)
|
||||
ListAlbumTracks(ctx context.Context, albumID int) ([]lidarr.ReleaseTrack, error)
|
||||
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
|
||||
}
|
||||
// - On one album, a song is held once: the extra copies are merged into the
|
||||
// best by ProposeSurvivor.
|
||||
// - Across releases, each copy belongs to its own release (a single and its
|
||||
// album): nothing is removed, and the copies are linked as one song.
|
||||
// - Except that a video rip is no release's copy. When every copy but one is
|
||||
// a rip, across releases or under another title, the rips are merged into
|
||||
// the one that is not.
|
||||
// - Anything else waits for the operator.
|
||||
|
||||
const (
|
||||
// resolveMergeCap bounds the merges one pass makes. The first pass after
|
||||
// deploy meets the whole backlog (433 groups measured on 2026-10-09); a
|
||||
// cap spreads it over a few hours, each pass short.
|
||||
// deploy meets the whole backlog; a cap spreads it over a few hours, each
|
||||
// pass short.
|
||||
resolveMergeCap = 200
|
||||
// resolveReleaseCap bounds the Lidarr release changes one pass makes. Each
|
||||
// makes Lidarr rescan an artist folder.
|
||||
resolveReleaseCap = 10
|
||||
// releaseChangeSettle is how long after a release change the resolver
|
||||
// leaves that album's groups alone. Lidarr unmaps every file of the album
|
||||
// and rescans; until the rescan lands, every copy reads as unmapped, and a
|
||||
// merge then could remove the very file Lidarr is about to map.
|
||||
releaseChangeSettle = 24 * time.Hour
|
||||
// duplicateResolveTick is how often the worker runs a pass.
|
||||
duplicateResolveTick = time.Hour
|
||||
)
|
||||
|
||||
const (
|
||||
lidarrStateTracked = "tracked"
|
||||
lidarrStateUnmapped = "unmapped"
|
||||
)
|
||||
|
||||
// ReleaseChange is one Lidarr release the resolver switched.
|
||||
type ReleaseChange struct {
|
||||
AlbumTitle string
|
||||
ArtistName string
|
||||
From, To lidarr.AlbumRelease
|
||||
}
|
||||
|
||||
// DuplicateResolveResult tallies one pass.
|
||||
type DuplicateResolveResult struct {
|
||||
Groups int
|
||||
Classes map[DuplicateClass]int
|
||||
LidarrConsulted bool
|
||||
Merged int
|
||||
MergeFailed int
|
||||
ReleaseChanges []ReleaseChange
|
||||
Groups int
|
||||
Classes map[DuplicateClass]int
|
||||
Merged int
|
||||
MergeFailed int
|
||||
// SongsLinked counts cross-release groups newly linked as one song.
|
||||
SongsLinked int
|
||||
// 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
|
||||
// three components (artist folder, album folder, file). Both see the library
|
||||
// under their own mount; on the operator's deploy both happen to say /music,
|
||||
// and the key keeps the match from depending on that.
|
||||
func LidarrPathKey(p string) string {
|
||||
parts := strings.Split(strings.Trim(p, "/"), "/")
|
||||
if len(parts) > 3 {
|
||||
parts = parts[len(parts)-3:]
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
// resolveGroup is one pending group and its present copies.
|
||||
@@ -119,53 +62,52 @@ type resolveGroup struct {
|
||||
members []dbq.ListDuplicateGroupsForResolveRow
|
||||
class DuplicateClass
|
||||
note string
|
||||
states []string // per member: tracked, unmapped, or "" when Lidarr was not asked
|
||||
}
|
||||
|
||||
// autoMergeable says whether the resolver may merge the group on its own: when
|
||||
// every copy it would remove is one Lidarr does not map (D-a rule 1). On one
|
||||
// album that is any group with at most one mapped copy. Across releases each
|
||||
// mapped copy fulfils its own release (D-b), so only a group with exactly one
|
||||
// mapped copy qualifies: the others fulfil nothing — a rip beside the clean
|
||||
// copy on another release, or a wrong-file import Lidarr holds unmapped
|
||||
// (#5439) — and the mapped copy is the clear one to keep.
|
||||
func autoMergeable(g *resolveGroup) bool {
|
||||
// isRip says whether a copy is a video rip: held back as one, or named like
|
||||
// one and not kept by the operator. The name is read as well as the verdict
|
||||
// because a pass reads the groups before it flags new rips.
|
||||
func isRip(m dbq.ListDuplicateGroupsForResolveRow) bool {
|
||||
if m.SourceVerdict != nil {
|
||||
return *m.SourceVerdict == "suspect"
|
||||
}
|
||||
return len(SourceMarkersFor(m.FilePath)) > 0
|
||||
}
|
||||
|
||||
// lone returns the index of the group's only copy that is not a rip, or -1
|
||||
// when there is none or more than one.
|
||||
func (g *resolveGroup) lone() int {
|
||||
at := -1
|
||||
for i, m := range g.members {
|
||||
if isRip(m) {
|
||||
continue
|
||||
}
|
||||
if at >= 0 {
|
||||
return -1
|
||||
}
|
||||
at = i
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// mergePlan says whether the resolver may merge the group on its own, and into
|
||||
// which copy (an index into members; -1 means ProposeSurvivor decides).
|
||||
func mergePlan(g *resolveGroup) (ok bool, survivor int) {
|
||||
switch g.class {
|
||||
case ClassSameRelease:
|
||||
return g.tracked() <= 1
|
||||
return true, -1
|
||||
case ClassCrossRelease, ClassMismatch:
|
||||
return g.tracked() == 1
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// settling says whether any of the group's albums had its Lidarr release
|
||||
// changed recently, so Lidarr may still be remapping its files.
|
||||
func (g *resolveGroup) settling(albums map[string]bool) bool {
|
||||
for _, m := range g.members {
|
||||
if albums[syncpkg.FormatUUID(m.AlbumID)] {
|
||||
return true
|
||||
if i := g.lone(); i >= 0 {
|
||||
return true, i
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false, -1
|
||||
}
|
||||
|
||||
func (g *resolveGroup) tracked() int {
|
||||
n := 0
|
||||
for _, s := range g.states {
|
||||
if s == lidarrStateTracked {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ResolveDuplicates runs one pass. lid is nil when Lidarr is disabled; act is
|
||||
// the operator's auto-resolve setting. Without Lidarr, or with act off, the
|
||||
// pass only classifies: nothing is removed when Lidarr cannot say what is safe.
|
||||
// ResolveDuplicates runs one pass. act is the operator's auto-resolve setting;
|
||||
// with it off, the pass only classifies.
|
||||
func ResolveDuplicates(
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, lid LidarrLibrary, act bool,
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, act bool,
|
||||
) (DuplicateResolveResult, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
@@ -180,32 +122,10 @@ func ResolveDuplicates(
|
||||
groups := foldResolveGroups(rows)
|
||||
res.Groups = len(groups)
|
||||
|
||||
var unmapped map[string]bool
|
||||
if lid != nil {
|
||||
files, err := lid.ListUnmappedTrackFiles(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("duplicate resolve: Lidarr's unmapped files unavailable; classifying only", "err", err)
|
||||
} else {
|
||||
unmapped = make(map[string]bool, len(files))
|
||||
for _, f := range files {
|
||||
unmapped[LidarrPathKey(f.Path)] = true
|
||||
}
|
||||
res.LidarrConsulted = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, g := range groups {
|
||||
cm := make([]ClassifyMember, len(g.members))
|
||||
g.states = make([]string, len(g.members))
|
||||
for i, m := range g.members {
|
||||
cm[i] = ClassifyMember{AlbumID: syncpkg.FormatUUID(m.AlbumID), Title: m.Title, ArtistName: m.ArtistName}
|
||||
if res.LidarrConsulted {
|
||||
if unmapped[LidarrPathKey(m.FilePath)] {
|
||||
g.states[i] = lidarrStateUnmapped
|
||||
} else {
|
||||
g.states[i] = lidarrStateTracked
|
||||
}
|
||||
}
|
||||
}
|
||||
g.class = ClassifyDuplicateGroup(g.tier, cm)
|
||||
g.note = resolveNote(g)
|
||||
@@ -219,31 +139,15 @@ func ResolveDuplicates(
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Holding rips back and linking songs remove nothing and need nothing from
|
||||
// Lidarr.
|
||||
if res.HeldBack, err = q.FlagSuspectSourceTracks(ctx, SuspectSourcePattern); err != nil {
|
||||
return res, fmt.Errorf("hold back video rips: %w", err)
|
||||
}
|
||||
if res.Released, err = q.ClearStaleSuspectSourceTracks(ctx, SuspectSourcePattern); err != nil {
|
||||
return res, fmt.Errorf("release renamed tracks: %w", err)
|
||||
}
|
||||
linked, err := linkCrossRelease(ctx, pool, logger, groups)
|
||||
res.SongsLinked = linked
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
if !res.LidarrConsulted {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
settling, err := recentlyChangedAlbums(ctx, q, time.Now())
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
// Merges first, on the state Lidarr reported at the start of the pass.
|
||||
removable := func(p string) bool { return unmapped[LidarrPathKey(p)] }
|
||||
// Merges before links, so a rip merged away is not linked first.
|
||||
merged := map[pgtype.UUID]bool{}
|
||||
for _, g := range groups {
|
||||
if res.Merged >= resolveMergeCap {
|
||||
break
|
||||
@@ -251,35 +155,29 @@ func ResolveDuplicates(
|
||||
if ctx.Err() != nil {
|
||||
return res, ctx.Err()
|
||||
}
|
||||
if !autoMergeable(g) || g.settling(settling) {
|
||||
ok, survivor := mergePlan(g)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := autoMerge(ctx, pool, logger, dataDir, g, removable); err != nil {
|
||||
if err := autoMerge(ctx, pool, logger, dataDir, g, survivor); err != nil {
|
||||
res.MergeFailed++
|
||||
logger.Warn("duplicate resolve: merge failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
|
||||
continue
|
||||
}
|
||||
merged[g.id] = true
|
||||
res.Merged++
|
||||
}
|
||||
|
||||
// Then release changes, for albums where Lidarr maps every copy.
|
||||
changes, err := changeRepeatingReleases(ctx, q, pool, logger, lid, groups, settling)
|
||||
res.ReleaseChanges = changes
|
||||
linked, err := linkCrossRelease(ctx, pool, logger, groups, merged)
|
||||
res.SongsLinked = linked
|
||||
if err != nil {
|
||||
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 {
|
||||
if res.Merged > 0 {
|
||||
notifyAdmins(ctx, notifications.KindDuplicatesResolved, notifications.Payload{
|
||||
Count: int64(res.Merged + len(changes)),
|
||||
Detail: resolvedDetail(res.Merged, changes),
|
||||
Count: int64(res.Merged),
|
||||
Detail: fmt.Sprintf("Merged %d duplicate %s.", res.Merged, pluralWord(res.Merged, "copy", "copies")),
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
@@ -287,13 +185,15 @@ func ResolveDuplicates(
|
||||
|
||||
// linkCrossRelease links each cross-release group's copies as one song
|
||||
// (#5438): a like on one is a like on all, and the Liked list, shuffle and the
|
||||
// mixes count the song once. Linking an already-linked group changes nothing,
|
||||
// mixes count the song once. A group the pass merged has nothing left to link. Linking an already-linked group changes nothing,
|
||||
// so this runs every pass; likes are shared only when a link is new, since a
|
||||
// like made after it reaches every copy as it is made.
|
||||
func linkCrossRelease(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, groups []*resolveGroup) (int, error) {
|
||||
func linkCrossRelease(
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, groups []*resolveGroup, merged map[pgtype.UUID]bool,
|
||||
) (int, error) {
|
||||
linked := 0
|
||||
for _, g := range groups {
|
||||
if g.class != ClassCrossRelease {
|
||||
if g.class != ClassCrossRelease || merged[g.id] {
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
@@ -345,61 +245,6 @@ 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 {
|
||||
@@ -419,16 +264,14 @@ func foldResolveGroups(rows []dbq.ListDuplicateGroupsForResolveRow) []*resolveGr
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveNote is what the report says beside a group the resolver leaves for
|
||||
// the operator, when there is something to say.
|
||||
// resolveNote is what the report says beside a group, when there is something
|
||||
// to say.
|
||||
func resolveNote(g *resolveGroup) string {
|
||||
switch g.class {
|
||||
case ClassSameRelease:
|
||||
if g.tracked() > 1 {
|
||||
return "Lidarr tracks every copy as its own track, so removing one would make Lidarr download it again."
|
||||
}
|
||||
case ClassCrossRelease:
|
||||
return "Each copy belongs to its own release, and Lidarr keeps each one. Nothing is removed; they count as one song."
|
||||
if g.lone() < 0 {
|
||||
return "Each copy belongs to its own release, so none is removed; they count as one song."
|
||||
}
|
||||
case ClassMismatch:
|
||||
return "Identical audio filed under different titles: one file carries another song's tags."
|
||||
}
|
||||
@@ -437,16 +280,10 @@ func resolveNote(g *resolveGroup) string {
|
||||
|
||||
func writeResolveVerdicts(ctx context.Context, q *dbq.Queries, groups []*resolveGroup) error {
|
||||
classes := dbq.SetDuplicateGroupClassesParams{}
|
||||
states := dbq.SetDuplicateMemberLidarrStatesParams{}
|
||||
for _, g := range groups {
|
||||
classes.GroupIds = append(classes.GroupIds, g.id)
|
||||
classes.Classes = append(classes.Classes, string(g.class))
|
||||
classes.Notes = append(classes.Notes, g.note)
|
||||
for i, m := range g.members {
|
||||
states.GroupIds = append(states.GroupIds, g.id)
|
||||
states.TrackIds = append(states.TrackIds, m.TrackID)
|
||||
states.States = append(states.States, g.states[i])
|
||||
}
|
||||
}
|
||||
if len(classes.GroupIds) == 0 {
|
||||
return nil
|
||||
@@ -454,9 +291,6 @@ func writeResolveVerdicts(ctx context.Context, q *dbq.Queries, groups []*resolve
|
||||
if err := q.SetDuplicateGroupClasses(ctx, classes); err != nil {
|
||||
return fmt.Errorf("write duplicate classes: %w", err)
|
||||
}
|
||||
if err := q.SetDuplicateMemberLidarrStates(ctx, states); err != nil {
|
||||
return fmt.Errorf("write lidarr states: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -465,29 +299,31 @@ func survivorCandidates(g *resolveGroup) []SurvivorCandidate {
|
||||
cands := make([]SurvivorCandidate, len(g.members))
|
||||
for i, m := range g.members {
|
||||
cands[i] = SurvivorCandidate{
|
||||
TrackID: syncpkg.FormatUUID(m.TrackID),
|
||||
FileFormat: m.FileFormat,
|
||||
FileSize: m.FileSize,
|
||||
AddedAt: m.AddedAt.Time,
|
||||
LidarrTracked: g.states[i] == lidarrStateTracked,
|
||||
TagFit: TagFitScore(m.TrackNumber != nil, m.PositionClash, m.FilePath, m.HasMbid),
|
||||
TrackID: syncpkg.FormatUUID(m.TrackID),
|
||||
FileFormat: m.FileFormat,
|
||||
FileSize: m.FileSize,
|
||||
AddedAt: m.AddedAt.Time,
|
||||
TagFit: TagFitScore(m.TrackNumber != nil, m.PositionClash, m.FilePath, m.HasMbid),
|
||||
}
|
||||
}
|
||||
return cands
|
||||
}
|
||||
|
||||
// autoMerge merges one group into the copy ProposeSurvivor picks, which is the
|
||||
// copy Lidarr tracks when there is one. removable is the guard: a copy Lidarr
|
||||
// maps can never be among those removed, whatever the survivor rule said.
|
||||
// autoMerge merges one group into the copy at index survivor, or the copy
|
||||
// ProposeSurvivor picks when survivor is -1, and records the merge as the
|
||||
// resolver's.
|
||||
func autoMerge(
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, g *resolveGroup, removable RemovableFunc,
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, g *resolveGroup, survivor int,
|
||||
) error {
|
||||
survivorKey, reason := ProposeSurvivor(survivorCandidates(g))
|
||||
if survivor >= 0 {
|
||||
survivorKey, reason = syncpkg.FormatUUID(g.members[survivor].TrackID), "the copy that is not a video rip"
|
||||
}
|
||||
var survivorID pgtype.UUID
|
||||
if err := survivorID.Scan(survivorKey); err != nil {
|
||||
return fmt.Errorf("parse survivor id: %w", err)
|
||||
}
|
||||
res, err := MergeDuplicateGroupGuarded(ctx, pool, logger, dataDir, g.id, survivorID, removable)
|
||||
res, err := MergeDuplicateGroup(ctx, pool, logger, dataDir, g.id, survivorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -515,294 +351,6 @@ func autoMerge(
|
||||
return nil
|
||||
}
|
||||
|
||||
// recentlyChangedAlbums is the Minstrel albums whose Lidarr release the
|
||||
// resolver changed within releaseChangeSettle, read from the audit log so a
|
||||
// restart does not forget them.
|
||||
func recentlyChangedAlbums(ctx context.Context, q *dbq.Queries, now time.Time) (map[string]bool, error) {
|
||||
rows, err := q.ListAuditLogByActions(ctx, dbq.ListAuditLogByActionsParams{
|
||||
Actions: []string{string(audit.ActionLidarrReleaseChange)}, SystemOnly: true,
|
||||
PageLimit: 200, PageOffset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read recent release changes: %w", err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, r := range rows {
|
||||
if now.Sub(r.CreatedAt.Time) > releaseChangeSettle {
|
||||
break // newest first
|
||||
}
|
||||
var meta struct {
|
||||
AlbumID string `json:"album_id"`
|
||||
}
|
||||
if json.Unmarshal(r.Metadata, &meta) == nil && meta.AlbumID != "" {
|
||||
out[meta.AlbumID] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// repeatingAlbum is a Minstrel album where Lidarr maps more than one copy of a
|
||||
// song, and the groups that showed it.
|
||||
type repeatingAlbum struct {
|
||||
albumID pgtype.UUID
|
||||
releaseGroupMbid string
|
||||
title, artist string
|
||||
groups []*resolveGroup
|
||||
}
|
||||
|
||||
func changeRepeatingReleases(
|
||||
ctx context.Context, q *dbq.Queries, pool *pgxpool.Pool, logger *slog.Logger,
|
||||
lid LidarrLibrary, groups []*resolveGroup, settling map[string]bool,
|
||||
) ([]ReleaseChange, error) {
|
||||
albums := map[string]*repeatingAlbum{}
|
||||
for _, g := range groups {
|
||||
if g.class != ClassSameRelease || g.tracked() < 2 {
|
||||
continue
|
||||
}
|
||||
m := g.members[0]
|
||||
key := syncpkg.FormatUUID(m.AlbumID)
|
||||
if settling[key] || m.ReleaseGroupMbid == nil || *m.ReleaseGroupMbid == "" {
|
||||
continue
|
||||
}
|
||||
a := albums[key]
|
||||
if a == nil {
|
||||
a = &repeatingAlbum{albumID: m.AlbumID, releaseGroupMbid: *m.ReleaseGroupMbid, title: m.AlbumTitle, artist: m.ArtistName}
|
||||
albums[key] = a
|
||||
}
|
||||
a.groups = append(a.groups, g)
|
||||
}
|
||||
keys := make([]string, 0, len(albums))
|
||||
for k := range albums {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var changes []ReleaseChange
|
||||
for _, k := range keys {
|
||||
if len(changes) >= resolveReleaseCap {
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return changes, ctx.Err()
|
||||
}
|
||||
a := albums[k]
|
||||
change, note, err := changeRelease(ctx, q, lid, a)
|
||||
if err != nil {
|
||||
logger.Warn("duplicate resolve: Lidarr release check failed", "album", a.title, "err", err)
|
||||
continue
|
||||
}
|
||||
if change != nil {
|
||||
changes = append(changes, *change)
|
||||
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionLidarrReleaseChange, map[string]any{
|
||||
"automatic": true,
|
||||
"album_id": k,
|
||||
"album_title": a.title,
|
||||
"artist_name": a.artist,
|
||||
"from_release": releaseLabel(change.From),
|
||||
"to_release": releaseLabel(change.To),
|
||||
})
|
||||
}
|
||||
if note != "" {
|
||||
if err := setGroupNotes(ctx, q, a.groups, note); err != nil {
|
||||
return changes, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// changeRelease checks one album's monitored release and, when it lists a
|
||||
// song twice, moves Lidarr to the release that lists each song once and best
|
||||
// covers what is on disk. It returns the change made (nil when none) and the
|
||||
// note for the album's groups.
|
||||
func changeRelease(
|
||||
ctx context.Context, q *dbq.Queries, lid LidarrLibrary, a *repeatingAlbum,
|
||||
) (*ReleaseChange, string, error) {
|
||||
la, err := lid.LookupAlbumByMBID(ctx, a.releaseGroupMbid)
|
||||
if errors.Is(err, lidarr.ErrNotFound) {
|
||||
return nil, "Lidarr does not hold this album, so its copies are left for you.", nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
current, err := lid.ListAlbumTracks(ctx, la.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !releaseRepeats(current) {
|
||||
return nil, "Lidarr maps each copy to a different track of this album, so none can go without a download.", nil
|
||||
}
|
||||
releases, err := lid.GetAlbumReleases(ctx, la.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
titles, err := q.ListAlbumPresentTrackTitles(ctx, a.albumID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("list album titles: %w", err)
|
||||
}
|
||||
onDisk := map[string]bool{}
|
||||
for _, t := range titles {
|
||||
if k := ReleaseTitleKey(t); k != "" {
|
||||
onDisk[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
var from lidarr.AlbumRelease
|
||||
var candidates []releaseCandidate
|
||||
for _, r := range releases {
|
||||
if r.Monitored {
|
||||
from = r
|
||||
continue
|
||||
}
|
||||
tracks, err := lid.ListReleaseTracks(ctx, r.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(tracks) == 0 || releaseRepeats(tracks) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, releaseCandidate{release: r, coverage: coverage(tracks, onDisk)})
|
||||
}
|
||||
best, ok := pickRelease(candidates, coverage(current, onDisk))
|
||||
if !ok {
|
||||
return nil, "Lidarr's release of this album lists songs twice, and no other release lists each once while keeping what is on disk.", nil
|
||||
}
|
||||
if err := lid.SetMonitoredRelease(ctx, la.ID, best.ID); err != nil {
|
||||
// Lidarr's album update unlinks the album's files and queues the rescan
|
||||
// before it answers, and on a large album that outlasts the client's
|
||||
// timeout while the change still lands (Cracker Island on the first
|
||||
// deploy pass). An unanswered PUT is read back: when the release did
|
||||
// change, it is recorded like any other, so the album gets its settle
|
||||
// window and the report shows it.
|
||||
if !errors.Is(err, lidarr.ErrUnreachable) || !releaseMonitored(ctx, lid, la.ID, best.ID) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
note := fmt.Sprintf("Lidarr now monitors the %s release, which lists each song once. The extra copies are merged once Lidarr has rescanned.", releaseLabel(best))
|
||||
return &ReleaseChange{AlbumTitle: a.title, ArtistName: a.artist, From: from, To: best}, note, nil
|
||||
}
|
||||
|
||||
// releaseMonitored reads whether the album's monitored release is now
|
||||
// releaseID. A failed read answers false: the change is not assumed.
|
||||
func releaseMonitored(ctx context.Context, lid LidarrLibrary, albumID, releaseID int) bool {
|
||||
releases, err := lid.GetAlbumReleases(ctx, albumID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, r := range releases {
|
||||
if r.ID == releaseID {
|
||||
return r.Monitored
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type releaseCandidate struct {
|
||||
release lidarr.AlbumRelease
|
||||
coverage int
|
||||
}
|
||||
|
||||
// pickRelease chooses among releases that list each song once: the one
|
||||
// covering most of the titles on disk, then the fewest tracks (the plainest
|
||||
// edition that holds them), then a digital one, then the lowest id so the
|
||||
// choice is stable. It refuses a release covering fewer titles on disk than
|
||||
// the current one does: the change must not drop a song Lidarr now keeps.
|
||||
func pickRelease(cands []releaseCandidate, currentCoverage int) (lidarr.AlbumRelease, bool) {
|
||||
if len(cands) == 0 {
|
||||
return lidarr.AlbumRelease{}, false
|
||||
}
|
||||
sort.SliceStable(cands, func(i, j int) bool {
|
||||
a, b := cands[i], cands[j]
|
||||
if a.coverage != b.coverage {
|
||||
return a.coverage > b.coverage
|
||||
}
|
||||
if a.release.TrackCount != b.release.TrackCount {
|
||||
return a.release.TrackCount < b.release.TrackCount
|
||||
}
|
||||
if da, db := isDigital(a.release), isDigital(b.release); da != db {
|
||||
return da
|
||||
}
|
||||
return a.release.ID < b.release.ID
|
||||
})
|
||||
if cands[0].coverage < currentCoverage {
|
||||
return lidarr.AlbumRelease{}, false
|
||||
}
|
||||
return cands[0].release, true
|
||||
}
|
||||
|
||||
func isDigital(r lidarr.AlbumRelease) bool {
|
||||
return strings.Contains(strings.ToLower(r.Format), "digital")
|
||||
}
|
||||
|
||||
// releaseRepeats reports whether a release lists one title more than once.
|
||||
func releaseRepeats(tracks []lidarr.ReleaseTrack) bool {
|
||||
seen := map[string]bool{}
|
||||
for _, t := range tracks {
|
||||
k := ReleaseTitleKey(t.Title)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if seen[k] {
|
||||
return true
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// coverage counts the distinct titles on disk that the release lists.
|
||||
func coverage(tracks []lidarr.ReleaseTrack, onDisk map[string]bool) int {
|
||||
hit := map[string]bool{}
|
||||
for _, t := range tracks {
|
||||
if k := ReleaseTitleKey(t.Title); onDisk[k] {
|
||||
hit[k] = true
|
||||
}
|
||||
}
|
||||
return len(hit)
|
||||
}
|
||||
|
||||
func releaseLabel(r lidarr.AlbumRelease) string {
|
||||
label := r.Format
|
||||
if label == "" {
|
||||
label = r.Title
|
||||
}
|
||||
if r.Disambiguation != "" {
|
||||
label = r.Disambiguation + ", " + label
|
||||
}
|
||||
return fmt.Sprintf("%s (%d tracks)", label, r.TrackCount)
|
||||
}
|
||||
|
||||
func setGroupNotes(ctx context.Context, q *dbq.Queries, groups []*resolveGroup, note string) error {
|
||||
p := dbq.SetDuplicateGroupClassesParams{}
|
||||
for _, g := range groups {
|
||||
g.note = note
|
||||
p.GroupIds = append(p.GroupIds, g.id)
|
||||
p.Classes = append(p.Classes, string(g.class))
|
||||
p.Notes = append(p.Notes, note)
|
||||
}
|
||||
if err := q.SetDuplicateGroupClasses(ctx, p); err != nil {
|
||||
return fmt.Errorf("write duplicate notes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolvedDetail(merged int, changes []ReleaseChange) string {
|
||||
var parts []string
|
||||
if merged > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Merged %d %s Lidarr does not need.", merged, pluralWord(merged, "copy", "copies")))
|
||||
}
|
||||
switch len(changes) {
|
||||
case 0:
|
||||
case 1:
|
||||
c := changes[0]
|
||||
parts = append(parts, fmt.Sprintf("Lidarr now monitors the %s release of %s by %s, which lists each song once.",
|
||||
releaseLabel(c.To), c.AlbumTitle, c.ArtistName))
|
||||
default:
|
||||
parts = append(parts, fmt.Sprintf("Lidarr now monitors a release that lists each song once for %d albums.", len(changes)))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func pluralWord(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return one
|
||||
@@ -816,20 +364,14 @@ type DuplicateResolveWorker struct {
|
||||
logger *slog.Logger
|
||||
dataDir string
|
||||
settings *FingerprintSettingsService
|
||||
lidarr func() LidarrLibrary
|
||||
tick time.Duration
|
||||
}
|
||||
|
||||
// NewDuplicateResolveWorker builds a worker with the production cadence.
|
||||
// lidarrFn is asked each pass, so a Lidarr setting saved in admin takes effect
|
||||
// without a restart; it returns nil while Lidarr is disabled.
|
||||
func NewDuplicateResolveWorker(
|
||||
pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
|
||||
settings *FingerprintSettingsService, lidarrFn func() LidarrLibrary,
|
||||
pool *pgxpool.Pool, logger *slog.Logger, dataDir string, settings *FingerprintSettingsService,
|
||||
) *DuplicateResolveWorker {
|
||||
return &DuplicateResolveWorker{
|
||||
pool: pool, logger: logger, dataDir: dataDir, settings: settings, lidarr: lidarrFn, tick: duplicateResolveTick,
|
||||
}
|
||||
return &DuplicateResolveWorker{pool: pool, logger: logger, dataDir: dataDir, settings: settings, tick: duplicateResolveTick}
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled, running a pass at start and then each tick.
|
||||
@@ -865,16 +407,11 @@ func (w *DuplicateResolveWorker) tickOnce(ctx context.Context) {
|
||||
}
|
||||
return
|
||||
}
|
||||
var lid LidarrLibrary
|
||||
if w.lidarr != nil {
|
||||
lid = w.lidarr()
|
||||
}
|
||||
res, err := ResolveDuplicates(ctx, w.pool, w.logger, w.dataDir, lid, w.settings.Get().AutoResolve)
|
||||
res, err := ResolveDuplicates(ctx, w.pool, w.logger, w.dataDir, w.settings.Get().AutoResolve)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
w.logger.Warn("duplicate resolve: pass failed", "err", err)
|
||||
}
|
||||
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, "rip_searches", res.RipSearches)
|
||||
"groups", res.Groups, "merged", res.Merged, "merge_failed", res.MergeFailed,
|
||||
"songs_linked", res.SongsLinked, "held_back", res.HeldBack, "released", res.Released)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -12,128 +10,9 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
func TestLidarrPathKey(t *testing.T) {
|
||||
// Minstrel and Lidarr see the library under different mounts; the key is
|
||||
// what they agree on.
|
||||
a := LidarrPathKey("/music/Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3")
|
||||
b := LidarrPathKey("/data/media/music/Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3")
|
||||
if a != b || a != "Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3" {
|
||||
t.Errorf("keys differ or are wrong: %q, %q", a, b)
|
||||
}
|
||||
if LidarrPathKey("/music/Gorillaz/Humanz (2017)/a.mp3") == LidarrPathKey("/music/Gorillaz/Demon Days (2005)/a.mp3") {
|
||||
t.Error("two albums' files share a key")
|
||||
}
|
||||
}
|
||||
|
||||
func rt(titles ...string) []lidarr.ReleaseTrack {
|
||||
out := make([]lidarr.ReleaseTrack, len(titles))
|
||||
for i, title := range titles {
|
||||
out[i] = lidarr.ReleaseTrack{ID: i + 1, Title: title}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestReleaseRepeats(t *testing.T) {
|
||||
if !releaseRepeats(rt("Ascension", "Strobelite", "Ascension")) {
|
||||
t.Error("the box set lists Ascension twice, but no repeat was found")
|
||||
}
|
||||
if releaseRepeats(rt("Charger", "Charger (demo)")) {
|
||||
t.Error("a demo was read as a repeat of the song")
|
||||
}
|
||||
}
|
||||
|
||||
// The Humanz releases as Lidarr lists them (2026-10-09): the box set is
|
||||
// monitored, and the 40-track digital release is the one that lists each song
|
||||
// once and covers what is on disk.
|
||||
func TestPickRelease(t *testing.T) {
|
||||
digital40 := lidarr.AlbumRelease{ID: 10149, Format: "Digital Media", TrackCount: 40}
|
||||
deluxe26 := lidarr.AlbumRelease{ID: 10150, Format: "CD", TrackCount: 26}
|
||||
standard20 := lidarr.AlbumRelease{ID: 10151, Format: "Digital Media", TrackCount: 20}
|
||||
|
||||
got, ok := pickRelease([]releaseCandidate{
|
||||
{release: standard20, coverage: 20}, {release: deluxe26, coverage: 26}, {release: digital40, coverage: 34},
|
||||
}, 34)
|
||||
if !ok || got.ID != digital40.ID {
|
||||
t.Errorf("picked %+v (ok=%v), want the 40-track digital release", got, ok)
|
||||
}
|
||||
|
||||
// Equal coverage: the plainer edition, then digital.
|
||||
cd := lidarr.AlbumRelease{ID: 1, Format: "CD", TrackCount: 26}
|
||||
web := lidarr.AlbumRelease{ID: 2, Format: "Digital Media", TrackCount: 26}
|
||||
if got, _ := pickRelease([]releaseCandidate{{release: cd, coverage: 26}, {release: web, coverage: 26}}, 26); got.ID != web.ID {
|
||||
t.Errorf("picked %+v, want the digital one of two equal releases", got)
|
||||
}
|
||||
|
||||
// Never a release that keeps fewer of the songs on disk than the current one.
|
||||
if got, ok := pickRelease([]releaseCandidate{{release: deluxe26, coverage: 26}}, 34); ok {
|
||||
t.Errorf("picked %+v, which covers fewer songs on disk than the current release", got)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLidarr is one Lidarr album with several releases. Switching the
|
||||
// monitored release changes what ListAlbumTracks answers, as in Lidarr.
|
||||
type fakeLidarr struct {
|
||||
unmapped []string
|
||||
unmappedErr error
|
||||
album lidarr.LidarrAlbum
|
||||
releases []lidarr.AlbumRelease
|
||||
releaseTracks map[int][]lidarr.ReleaseTrack
|
||||
setCalls [][2]int
|
||||
searchCalls [][]int
|
||||
// setErr is what SetMonitoredRelease answers after applying the change:
|
||||
// Lidarr timing out on a change that landed.
|
||||
setErr error
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) ListUnmappedTrackFiles(context.Context) ([]lidarr.TrackFile, error) {
|
||||
out := make([]lidarr.TrackFile, len(f.unmapped))
|
||||
for i, p := range f.unmapped {
|
||||
out[i] = lidarr.TrackFile{ID: i + 1, Path: p}
|
||||
}
|
||||
return out, f.unmappedErr
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) LookupAlbumByMBID(_ context.Context, mbid string) (lidarr.LidarrAlbum, error) {
|
||||
if f.album.ForeignAlbumID != mbid {
|
||||
return lidarr.LidarrAlbum{}, lidarr.ErrNotFound
|
||||
}
|
||||
return f.album, nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) ListAlbumTracks(context.Context, int) ([]lidarr.ReleaseTrack, error) {
|
||||
for _, r := range f.releases {
|
||||
if r.Monitored {
|
||||
return f.releaseTracks[r.ID], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) GetAlbumReleases(context.Context, int) ([]lidarr.AlbumRelease, error) {
|
||||
return append([]lidarr.AlbumRelease(nil), f.releases...), nil
|
||||
}
|
||||
|
||||
func (f *fakeLidarr) ListReleaseTracks(_ context.Context, releaseID int) ([]lidarr.ReleaseTrack, error) {
|
||||
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 {
|
||||
f.releases[i].Monitored = f.releases[i].ID == releaseID
|
||||
}
|
||||
return f.setErr
|
||||
}
|
||||
|
||||
// resolveFixture is the Humanz shape: one album, one song twice, both copies on
|
||||
// disk under an artist/album folder, in one pending exact-tier group.
|
||||
type resolveFixture struct {
|
||||
@@ -178,7 +57,6 @@ func newResolveFixture(t *testing.T) resolveFixture {
|
||||
}
|
||||
}
|
||||
exec(`UPDATE tracks SET title = 'Saturnz Barz' WHERE id = $1`, f.clean.ID)
|
||||
exec(`UPDATE albums SET release_group_mbid = 'rg-humanz' WHERE id = $1`, album.ID)
|
||||
if err := pool.QueryRow(ctx,
|
||||
`INSERT INTO duplicate_groups (member_key, tier) VALUES ('resolve-fixture', 'exact') RETURNING id`,
|
||||
).Scan(&f.groupID); err != nil {
|
||||
@@ -188,9 +66,6 @@ func newResolveFixture(t *testing.T) resolveFixture {
|
||||
return f
|
||||
}
|
||||
|
||||
// lidarrPath is where Lidarr, mounted elsewhere, sees a fixture file.
|
||||
func lidarrPath(p string) string { return "/music/" + LidarrPathKey(p) }
|
||||
|
||||
func (f resolveFixture) group(t *testing.T) (status string, class *string, note *string, auto bool) {
|
||||
t.Helper()
|
||||
if err := f.pool.QueryRow(context.Background(),
|
||||
@@ -203,18 +78,32 @@ func (f resolveFixture) group(t *testing.T) (status string, class *string, note
|
||||
|
||||
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
|
||||
|
||||
// Lidarr maps the clean copy and holds the rip unmapped: the rip goes, the
|
||||
// clean copy stays, and the merge is recorded as the resolver's.
|
||||
func TestResolveDuplicates_MergesTheUnmappedCopy_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
|
||||
// autoMergeAudit reads the resolver's audit row for a group: how many there
|
||||
// are, and the reason the last one gave for its survivor.
|
||||
func autoMergeAudit(t *testing.T, pool *pgxpool.Pool, groupID pgtype.UUID) (n int, reason string) {
|
||||
t.Helper()
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT count(*), coalesce(max(metadata->>'survivor_reason'), '') FROM audit_log
|
||||
WHERE action = 'duplicate_merge' AND actor_id IS NULL
|
||||
AND metadata->>'group_id' = $1 AND (metadata->>'automatic')::boolean`,
|
||||
syncpkg.FormatUUID(groupID)).Scan(&n, &reason); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n, reason
|
||||
}
|
||||
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||
// One album holding a song twice: the extra copy goes, the copy whose name and
|
||||
// tags fit the album stays, and the merge is recorded as the resolver's. No
|
||||
// Lidarr is asked (M502 #5517): Minstrel is the only program writing the
|
||||
// library.
|
||||
func TestResolveDuplicates_MergesASameReleaseCopy_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if res.Merged != 1 || !res.LidarrConsulted {
|
||||
t.Fatalf("result = %+v, want one merge with Lidarr consulted", res)
|
||||
if res.Merged != 1 || res.Classes[ClassSameRelease] != 1 {
|
||||
t.Fatalf("result = %+v, want the same-release group merged", res)
|
||||
}
|
||||
if exists(f.ripPath) || !exists(f.cleanPath) {
|
||||
t.Errorf("rip exists=%v clean exists=%v; want only the clean copy left", exists(f.ripPath), exists(f.cleanPath))
|
||||
@@ -223,138 +112,24 @@ func TestResolveDuplicates_MergesTheUnmappedCopy_Integration(t *testing.T) {
|
||||
if status != "merged" || class == nil || *class != string(ClassSameRelease) || !auto {
|
||||
t.Errorf("group = (%s, %v, auto=%v), want merged same_release by the resolver", status, class, auto)
|
||||
}
|
||||
var audits int
|
||||
if err := f.pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM audit_log WHERE action = 'duplicate_merge' AND actor_id IS NULL
|
||||
AND metadata->>'group_id' = $1 AND (metadata->>'automatic')::boolean`,
|
||||
syncpkg.FormatUUID(f.groupID)).Scan(&audits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if audits != 1 {
|
||||
t.Errorf("automatic merge audit rows = %d, want 1", audits)
|
||||
if n, _ := autoMergeAudit(t, f.pool, f.groupID); n != 1 {
|
||||
t.Errorf("automatic merge audit rows = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Lidarr maps both copies, and its release lists the song twice: nothing is
|
||||
// deleted, the release moves to the one listing each song once, and the next
|
||||
// pass leaves it there (lesson #4183's fixed point).
|
||||
func TestResolveDuplicates_ChangesARepeatingRelease_Integration(t *testing.T) {
|
||||
// With auto-resolve off, the pass classifies and removes nothing.
|
||||
func TestResolveDuplicates_ClassifiesOnlyWithoutPermission_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
lid := &fakeLidarr{
|
||||
album: lidarr.LidarrAlbum{ID: 4723, ForeignAlbumID: "rg-humanz", Title: "Humanz"},
|
||||
releases: []lidarr.AlbumRelease{
|
||||
{ID: 10148, Format: "14x12\" Vinyl, Digital Media", TrackCount: 4, Monitored: true},
|
||||
{ID: 10149, Format: "Digital Media", TrackCount: 2},
|
||||
},
|
||||
releaseTracks: map[int][]lidarr.ReleaseTrack{
|
||||
10148: rt("Saturnz Barz", "Ascension", "Saturnz Barz", "Ascension"),
|
||||
10149: rt("Saturnz Barz", "Ascension"),
|
||||
},
|
||||
}
|
||||
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) || !exists(f.cleanPath) {
|
||||
t.Fatalf("merged %d; a copy Lidarr maps was touched", res.Merged)
|
||||
}
|
||||
if len(lid.setCalls) != 1 || lid.setCalls[0] != [2]int{4723, 10149} {
|
||||
t.Fatalf("release changes = %v, want album 4723 to release 10149", lid.setCalls)
|
||||
}
|
||||
status, _, note, _ := f.group(t)
|
||||
if status != "pending" || note == nil || *note == "" {
|
||||
t.Errorf("group = (%s, note %v), want pending with a note saying what changed", status, note)
|
||||
}
|
||||
|
||||
// The next pass: Lidarr has not rescanned yet, so both copies still read
|
||||
// as tracked. The album is settling, so nothing changes.
|
||||
if _, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true); err != nil {
|
||||
t.Fatalf("second resolve: %v", err)
|
||||
}
|
||||
if len(lid.setCalls) != 1 {
|
||||
t.Errorf("release changed again: %v", lid.setCalls)
|
||||
}
|
||||
// And once it has settled, the check itself finds nothing to do: the
|
||||
// release it chose lists each song once.
|
||||
change, _, err := changeRelease(context.Background(), dbq.New(f.pool), lid,
|
||||
&repeatingAlbum{albumID: f.clean.AlbumID, releaseGroupMbid: "rg-humanz"})
|
||||
if err != nil || change != nil || len(lid.setCalls) != 1 {
|
||||
t.Errorf("after the change, changeRelease = (%+v, %v) with %d calls; want no change", change, err, len(lid.setCalls))
|
||||
}
|
||||
}
|
||||
|
||||
// After a release change Lidarr unmaps every file of the album until it has
|
||||
// rescanned. A pass in that window must not read "every copy unmapped" as
|
||||
// licence to merge: the copy it would remove may be the one Lidarr maps next.
|
||||
func TestResolveDuplicates_LeavesAnAlbumAloneWhileLidarrRescans_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
lid := &fakeLidarr{
|
||||
album: lidarr.LidarrAlbum{ID: 4723, ForeignAlbumID: "rg-humanz"},
|
||||
releases: []lidarr.AlbumRelease{
|
||||
{ID: 10148, TrackCount: 2, Monitored: true}, {ID: 10149, TrackCount: 1},
|
||||
},
|
||||
releaseTracks: map[int][]lidarr.ReleaseTrack{10148: rt("Saturnz Barz", "Saturnz Barz"), 10149: rt("Saturnz Barz")},
|
||||
}
|
||||
if _, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lid.unmapped = []string{lidarrPath(f.cleanPath), lidarrPath(f.ripPath)}
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) || !exists(f.cleanPath) {
|
||||
t.Errorf("merged %d during the rescan window", res.Merged)
|
||||
if res.Merged != 0 || !exists(f.ripPath) {
|
||||
t.Errorf("merged %d", res.Merged)
|
||||
}
|
||||
}
|
||||
|
||||
// Without Lidarr, or with auto-resolve off, the pass classifies and removes
|
||||
// nothing.
|
||||
func TestResolveDuplicates_ClassifiesOnlyWithoutLidarrOrPermission_Integration(t *testing.T) {
|
||||
for name, run := range map[string]func(resolveFixture) (DuplicateResolveResult, error){
|
||||
"lidarr disabled": func(f resolveFixture) (DuplicateResolveResult, error) {
|
||||
return ResolveDuplicates(context.Background(), f.pool, nil, "", nil, true)
|
||||
},
|
||||
"lidarr unreachable": func(f resolveFixture) (DuplicateResolveResult, error) {
|
||||
return ResolveDuplicates(context.Background(), f.pool, nil, "", &fakeLidarr{unmappedErr: errors.New("timeout")}, true)
|
||||
},
|
||||
"auto-resolve off": func(f resolveFixture) (DuplicateResolveResult, error) {
|
||||
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
|
||||
return ResolveDuplicates(context.Background(), f.pool, nil, "", lid, false)
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
res, err := run(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) {
|
||||
t.Errorf("merged %d", res.Merged)
|
||||
}
|
||||
status, class, _, _ := f.group(t)
|
||||
if status != "pending" || class == nil || *class != string(ClassSameRelease) {
|
||||
t.Errorf("group = (%s, %v), want pending and classified", status, class)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The guard itself: a merge asked to remove a copy the check refuses changes
|
||||
// nothing, file or row.
|
||||
func TestMergeDuplicateGroupGuarded_RefusesATrackedCopy_Integration(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
refuse := func(p string) bool { return p != f.ripPath }
|
||||
_, err := MergeDuplicateGroupGuarded(context.Background(), f.pool, nil, "", f.groupID, f.clean.ID, refuse)
|
||||
if !errors.Is(err, ErrCopyTrackedByLidarr) {
|
||||
t.Fatalf("err = %v, want ErrCopyTrackedByLidarr", err)
|
||||
}
|
||||
if !exists(f.ripPath) {
|
||||
t.Error("the refused copy's file was removed")
|
||||
}
|
||||
if status, _, _, _ := f.group(t); status != "pending" {
|
||||
t.Errorf("group status = %s, want pending", status)
|
||||
status, class, _, _ := f.group(t)
|
||||
if status != "pending" || class == nil || *class != string(ClassSameRelease) {
|
||||
t.Errorf("group = (%s, %v), want pending and classified", status, class)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,12 +191,12 @@ func newCrossReleaseRipFixture(t *testing.T) crossReleaseRipFixture {
|
||||
return f
|
||||
}
|
||||
|
||||
// The rip fulfils nothing in Lidarr while the clean copy on the album does:
|
||||
// the rip goes and the album copy stays (#5439, D-a rule 1).
|
||||
func TestResolveDuplicates_MergesAnUnmappedRipAcrossReleases_Integration(t *testing.T) {
|
||||
// A video rip on one release with the clean copy on another: the rip is no
|
||||
// release's copy, so it goes and the album cut stays (#5439, now without
|
||||
// Lidarr).
|
||||
func TestResolveDuplicates_MergesARipAcrossReleases_Integration(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -431,21 +206,65 @@ func TestResolveDuplicates_MergesAnUnmappedRipAcrossReleases_Integration(t *test
|
||||
if exists(f.ripPath) || !exists(f.cleanPath) {
|
||||
t.Errorf("rip exists %v, clean exists %v; want only the clean copy", exists(f.ripPath), exists(f.cleanPath))
|
||||
}
|
||||
if _, reason := autoMergeAudit(t, f.pool, f.groupID); reason != "the copy that is not a video rip" {
|
||||
t.Errorf("survivor reason %q, want the rip rule", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// When Lidarr maps both copies, each fulfils its own release: nothing is
|
||||
// removed, and the copies are linked as one song instead.
|
||||
func TestResolveDuplicates_KeepsCopiesLidarrMapsOnTwoReleases_Integration(t *testing.T) {
|
||||
// Copies on two releases that are both clean each belong to their release:
|
||||
// nothing is removed, and they are linked as one song. A rip the operator
|
||||
// marked fine counts as clean.
|
||||
func TestResolveDuplicates_KeepsCleanCopiesOnTwoReleases_Integration(t *testing.T) {
|
||||
for name, prepare := range map[string]func(f crossReleaseRipFixture) (string, []any){
|
||||
"renamed": func(f crossReleaseRipFixture) (string, []any) {
|
||||
return `UPDATE tracks SET title = 'Feel Good Inc.', file_path = $2 WHERE id = $1`,
|
||||
[]any{f.rip.ID, filepath.Join(filepath.Dir(f.ripPath), "01 - Feel Good Inc.mp3")}
|
||||
},
|
||||
"kept as fine": func(f crossReleaseRipFixture) (string, []any) {
|
||||
return `UPDATE tracks SET source_verdict = 'fine' WHERE id = $1`, []any{f.rip.ID}
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
sql, args := prepare(f)
|
||||
if _, err := f.pool.Exec(context.Background(), sql, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) {
|
||||
t.Errorf("merged %d; want both copies kept", res.Merged)
|
||||
}
|
||||
if res.SongsLinked != 1 {
|
||||
t.Errorf("linked %d, want the pair linked as one song", res.SongsLinked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Identical audio under two titles on two albums, neither a rip: which tags
|
||||
// are wrong is the operator's call, so nothing is merged or linked.
|
||||
func TestResolveDuplicates_LeavesAMismatchForTheOperator_Integration(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", &fakeLidarr{}, true)
|
||||
ctx := context.Background()
|
||||
if _, err := f.pool.Exec(ctx, `UPDATE duplicate_groups SET tier = 'exact' WHERE id = $1`, f.groupID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.pool.Exec(ctx, `UPDATE tracks SET title = 'DARE', file_path = $2 WHERE id = $1`,
|
||||
f.rip.ID, filepath.Join(filepath.Dir(f.ripPath), "01 - DARE.mp3")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := ResolveDuplicates(ctx, f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Merged != 0 || !exists(f.ripPath) {
|
||||
t.Errorf("merged %d; want both copies kept", res.Merged)
|
||||
if res.Classes[ClassMismatch] != 1 || res.Merged != 0 || res.SongsLinked != 0 {
|
||||
t.Fatalf("classes %v, merged %d, linked %d; want a mismatch left alone", res.Classes, res.Merged, res.SongsLinked)
|
||||
}
|
||||
if res.SongsLinked != 1 {
|
||||
t.Errorf("linked %d, want the pair linked as one song", res.SongsLinked)
|
||||
if status, _, note, _ := (resolveFixture{pool: f.pool, groupID: f.groupID}).group(t); status != "pending" || note == nil || *note == "" {
|
||||
t.Errorf("group = (%s, note %v), want pending with a note", status, note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,6 +274,11 @@ func TestResolveDuplicates_HoldsBackRips_Integration(t *testing.T) {
|
||||
f := newCrossReleaseRipFixture(t)
|
||||
ctx := context.Background()
|
||||
q := dbq.New(f.pool)
|
||||
// Without its group the rip has no clean copy to merge into, so it stays
|
||||
// on disk and only its verdict changes.
|
||||
if _, err := f.pool.Exec(ctx, `DELETE FROM duplicate_groups WHERE id = $1`, f.groupID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verdict := func(id pgtype.UUID) *string {
|
||||
t.Helper()
|
||||
var v *string
|
||||
@@ -465,7 +289,7 @@ func TestResolveDuplicates_HoldsBackRips_Integration(t *testing.T) {
|
||||
}
|
||||
resolve := func() DuplicateResolveResult {
|
||||
t.Helper()
|
||||
res, err := ResolveDuplicates(ctx, f.pool, nil, "", &fakeLidarr{}, true)
|
||||
res, err := ResolveDuplicates(ctx, f.pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -512,123 +336,3 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
// Lidarr applies the release change but the PUT times out (Cracker Island on
|
||||
// the first deploy pass). The change is read back and recorded, so the album
|
||||
// settles; when it did not land, the error stands and nothing is recorded.
|
||||
func TestResolveDuplicates_RecordsAReleaseChangeLidarrDidNotAnswer_Integration(t *testing.T) {
|
||||
for name, landed := range map[string]bool{"landed": true, "did not land": false} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newResolveFixture(t)
|
||||
lid := &fakeLidarr{
|
||||
album: lidarr.LidarrAlbum{ID: 4706, ForeignAlbumID: "rg-humanz", Title: "Cracker Island"},
|
||||
releases: []lidarr.AlbumRelease{
|
||||
{ID: 1, Format: "Digital Media", TrackCount: 4, Monitored: true},
|
||||
{ID: 2, Format: "Digital Media", TrackCount: 2},
|
||||
},
|
||||
releaseTracks: map[int][]lidarr.ReleaseTrack{
|
||||
1: rt("Saturnz Barz", "Ascension", "Saturnz Barz", "Ascension"),
|
||||
2: rt("Saturnz Barz", "Ascension"),
|
||||
},
|
||||
setErr: fmt.Errorf("%w: context deadline exceeded", lidarr.ErrUnreachable),
|
||||
}
|
||||
if !landed {
|
||||
lid.setErr = fmt.Errorf("%w: connection refused", lidarr.ErrUnreachable)
|
||||
}
|
||||
// ResetDB keeps audit_log, so other tests' release changes are
|
||||
// still in it: count what this pass adds.
|
||||
audited := func() (n int) {
|
||||
t.Helper()
|
||||
if err := f.pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM audit_log WHERE action = 'lidarr_release_change'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
before := audited()
|
||||
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", &undoingLidarr{fakeLidarr: lid, undo: !landed}, true)
|
||||
if landed {
|
||||
if err != nil || len(res.ReleaseChanges) != 1 {
|
||||
t.Fatalf("changes %d (%v), want the landed change recorded", len(res.ReleaseChanges), err)
|
||||
}
|
||||
} else if len(res.ReleaseChanges) != 0 {
|
||||
t.Fatalf("recorded %d changes that did not land", len(res.ReleaseChanges))
|
||||
}
|
||||
if got, want := audited()-before, map[bool]int{true: 1, false: 0}[landed]; got != want {
|
||||
t.Errorf("audited %d release changes, want %d", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// undoingLidarr is a Lidarr whose release change fails without landing when
|
||||
// undo is set: the PUT never reached it.
|
||||
type undoingLidarr struct {
|
||||
*fakeLidarr
|
||||
undo bool
|
||||
}
|
||||
|
||||
func (u *undoingLidarr) SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error {
|
||||
if !u.undo {
|
||||
return u.fakeLidarr.SetMonitoredRelease(ctx, albumID, releaseID)
|
||||
}
|
||||
u.setCalls = append(u.setCalls, [2]int{albumID, releaseID})
|
||||
return u.setErr
|
||||
}
|
||||
|
||||
@@ -14,10 +14,6 @@ type SurvivorCandidate struct {
|
||||
FileSize int64
|
||||
AddedAt time.Time
|
||||
|
||||
// LidarrTracked: Lidarr maps this file to a track of the release it
|
||||
// monitors. Removing it opens a hole Lidarr downloads again (M498), so a
|
||||
// tracked copy outranks everything else. False when Lidarr was not asked.
|
||||
LidarrTracked bool
|
||||
// TagFit is TagFitScore: how well the copy's tags and name fit the album.
|
||||
TagFit int
|
||||
}
|
||||
@@ -56,9 +52,9 @@ var losslessFormats = map[string]bool{"flac": true, "wav": true}
|
||||
// the report shows it and the merge (#3911) lets the operator choose another.
|
||||
//
|
||||
// In order:
|
||||
// 0. the copy Lidarr maps, then the copy whose tags fit the album best
|
||||
// (TagFitScore). Both are zero for every copy when the caller does not
|
||||
// know them, and the order falls through to the quality rules below
|
||||
// 0. the copy whose tags fit the album best (TagFitScore). It is zero for
|
||||
// every copy when the caller does not know it, and the order falls through
|
||||
// to the quality rules below
|
||||
// 1. lossless over lossy — the one difference no later step can recover
|
||||
// 2. the larger file — for one recording at one duration that is the higher
|
||||
// bitrate. The scanner does not record bitrate (tracks.bitrate is never
|
||||
@@ -81,8 +77,6 @@ func ProposeSurvivor(cands []SurvivorCandidate) (trackID, reason string) {
|
||||
// runner-up — the rule that actually decided, not every rule it passed.
|
||||
next := ranked[1]
|
||||
switch {
|
||||
case best.LidarrTracked != next.LidarrTracked:
|
||||
return best.TrackID, "the copy Lidarr tracks"
|
||||
case best.TagFit != next.TagFit:
|
||||
return best.TrackID, "tags fit the album"
|
||||
case isLossless(best) != isLossless(next):
|
||||
@@ -97,9 +91,6 @@ func ProposeSurvivor(cands []SurvivorCandidate) (trackID, reason string) {
|
||||
}
|
||||
|
||||
func survivorBefore(a, b SurvivorCandidate) bool {
|
||||
if a.LidarrTracked != b.LidarrTracked {
|
||||
return a.LidarrTracked
|
||||
}
|
||||
if a.TagFit != b.TagFit {
|
||||
return a.TagFit > b.TagFit
|
||||
}
|
||||
|
||||
@@ -90,11 +90,10 @@ func TestProposeSurvivor_ReasonIsTheDecidingRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// M498 #5436: the copy Lidarr maps outranks everything, then the copy whose
|
||||
// tags fit the album; quality decides only between copies equal on both. Each
|
||||
// case is built so the older rules would choose the other copy, so a reorder
|
||||
// that drops the new rules fails.
|
||||
func TestProposeSurvivor_LidarrThenTagFit(t *testing.T) {
|
||||
// M498 #5436: the copy whose tags fit the album outranks quality, which decides
|
||||
// only between copies whose tags fit equally. Each case is built so the quality
|
||||
// rules would choose the other copy, so a reorder that drops tag fit fails.
|
||||
func TestProposeSurvivor_TagFitFirst(t *testing.T) {
|
||||
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -103,12 +102,12 @@ func TestProposeSurvivor_LidarrThenTagFit(t *testing.T) {
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "tracked beats lossless and better tags",
|
||||
name: "tag fit beats lossless",
|
||||
cands: []SurvivorCandidate{
|
||||
{TrackID: "flac", FileFormat: "flac", FileSize: 40_000_000, AddedAt: at, TagFit: 3},
|
||||
{TrackID: "tracked", FileFormat: "mp3", FileSize: 5_000_000, AddedAt: at, TagFit: 1, LidarrTracked: true},
|
||||
{TrackID: "flac", FileFormat: "flac", FileSize: 40_000_000, AddedAt: at, TagFit: 1},
|
||||
{TrackID: "fits", FileFormat: "mp3", FileSize: 5_000_000, AddedAt: at, TagFit: 3},
|
||||
},
|
||||
wantID: "tracked", wantReason: "the copy Lidarr tracks",
|
||||
wantID: "fits", wantReason: "tags fit the album",
|
||||
},
|
||||
{
|
||||
// The Humanz shape: two rips a few bytes apart, one carrying a
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestCrossReleaseCopiesCountAsOneSong_Integration(t *testing.T) {
|
||||
q := dbq.New(pool)
|
||||
f := newSongLinkFixture(t, pool)
|
||||
|
||||
res, err := ResolveDuplicates(ctx, pool, nil, "", nil, true)
|
||||
res, err := ResolveDuplicates(ctx, pool, nil, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func TestCrossReleaseCopiesCountAsOneSong_Integration(t *testing.T) {
|
||||
}
|
||||
|
||||
// A second pass changes nothing.
|
||||
if res, err := ResolveDuplicates(ctx, pool, nil, "", nil, true); err != nil || res.SongsLinked != 0 {
|
||||
if res, err := ResolveDuplicates(ctx, pool, nil, "", true); err != nil || res.SongsLinked != 0 {
|
||||
t.Errorf("second pass linked %d (%v), want 0", res.SongsLinked, err)
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestCrossReleaseLinkNeedsAutoResolve_Integration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
q := dbq.New(pool)
|
||||
f := newSongLinkFixture(t, pool)
|
||||
if _, err := ResolveDuplicates(ctx, pool, nil, "", nil, false); err != nil {
|
||||
if _, err := ResolveDuplicates(ctx, pool, nil, "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys := songKeys(t, q, f.single.ID, f.albumCut.ID)
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
package lidarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// What Lidarr holds on disk and how it maps it (Scribe milestone #498).
|
||||
//
|
||||
// Lidarr maps one file to each track of the ONE release it monitors per album.
|
||||
// A file it holds but maps to no track is "unmapped": removing it opens no hole.
|
||||
// Removing a mapped file does, and Lidarr fills the hole by downloading again —
|
||||
// which is why Minstrel's duplicate resolver asks before it deletes anything.
|
||||
|
||||
// TrackFile is a file Lidarr holds on disk.
|
||||
type TrackFile struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
AlbumID int `json:"albumId"`
|
||||
}
|
||||
|
||||
// ListUnmappedTrackFiles hits GET /api/v1/trackfile?unmapped=true: files under
|
||||
// Lidarr's root folders that it has scanned but matched to no track.
|
||||
func (c *Client) ListUnmappedTrackFiles(ctx context.Context) ([]TrackFile, error) {
|
||||
resp, err := c.get(ctx, "/api/v1/trackfile", url.Values{"unmapped": {"true"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var out []TrackFile
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode unmapped track files: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AlbumRelease is one edition of an album as Lidarr knows it: the 14×12" vinyl
|
||||
// box set, the 2×CD deluxe, the digital standard. Exactly one is Monitored.
|
||||
type AlbumRelease struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
Format string `json:"format"`
|
||||
TrackCount int `json:"trackCount"`
|
||||
MediumCount int `json:"mediumCount"`
|
||||
Monitored bool `json:"monitored"`
|
||||
}
|
||||
|
||||
// ReleaseTrack is one track of a release.
|
||||
type ReleaseTrack struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
MediumNumber int `json:"mediumNumber"`
|
||||
TrackFileID int `json:"trackFileId"`
|
||||
}
|
||||
|
||||
// GetAlbumReleases hits GET /api/v1/album/{id} and returns the album's releases.
|
||||
func (c *Client) GetAlbumReleases(ctx context.Context, albumID int) ([]AlbumRelease, error) {
|
||||
resp, err := c.get(ctx, "/api/v1/album/"+strconv.Itoa(albumID), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var album struct {
|
||||
Releases []AlbumRelease `json:"releases"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&album); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode album: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
return album.Releases, nil
|
||||
}
|
||||
|
||||
// ListAlbumTracks hits GET /api/v1/track?albumId= : the tracks of the release
|
||||
// Lidarr monitors for that album.
|
||||
func (c *Client) ListAlbumTracks(ctx context.Context, albumID int) ([]ReleaseTrack, error) {
|
||||
return c.listTracks(ctx, url.Values{"albumId": {strconv.Itoa(albumID)}})
|
||||
}
|
||||
|
||||
// ListReleaseTracks hits GET /api/v1/track?albumReleaseId= : the tracks of one
|
||||
// release, monitored or not.
|
||||
func (c *Client) ListReleaseTracks(ctx context.Context, releaseID int) ([]ReleaseTrack, error) {
|
||||
return c.listTracks(ctx, url.Values{"albumReleaseId": {strconv.Itoa(releaseID)}})
|
||||
}
|
||||
|
||||
func (c *Client) listTracks(ctx context.Context, q url.Values) ([]ReleaseTrack, error) {
|
||||
resp, err := c.get(ctx, "/api/v1/track", q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var out []ReleaseTrack
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode tracks: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetMonitoredRelease makes releaseID the album's monitored release.
|
||||
//
|
||||
// It is the round trip Lidarr's own "edit album" makes: GET the album resource,
|
||||
// flip `monitored` on its releases, PUT the whole resource back. Lidarr's
|
||||
// UpdateAlbum publishes AlbumEditedEvent, and when the monitored release changed,
|
||||
// AlbumEditedService unlinks the album's track files and queues
|
||||
// RescanFoldersCommand for the artist's folder, which maps the files on disk to
|
||||
// the new release's tracks. Files the new release has no track for come out of
|
||||
// that rescan unmapped. (Lidarr source, develop: Music/Services/
|
||||
// AlbumEditedService.cs and AlbumService.UpdateAlbum.)
|
||||
//
|
||||
// The resource is kept as raw JSON so every field Lidarr sent goes back
|
||||
// unchanged; only the releases' monitored flags differ. Returns ErrNotFound when
|
||||
// the album has no release with that id.
|
||||
func (c *Client) SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error {
|
||||
path := "/api/v1/album/" + strconv.Itoa(albumID)
|
||||
resp, err := c.get(ctx, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var album map[string]any
|
||||
err = json.NewDecoder(resp.Body).Decode(&album)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: decode album: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
|
||||
releases, _ := album["releases"].([]any)
|
||||
found := false
|
||||
for _, r := range releases {
|
||||
rel, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := rel["id"].(float64)
|
||||
match := int(id) == releaseID
|
||||
rel["monitored"] = match
|
||||
found = found || match
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("%w: release %d on album %d", ErrNotFound, releaseID, albumID)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(album)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: marshal album: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
putResp, err := c.put(ctx, path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = 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
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package lidarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListUnmappedTrackFiles(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/trackfile" || r.URL.Query().Get("unmapped") != "true" {
|
||||
t.Errorf("request = %s?%s, want /api/v1/trackfile?unmapped=true", r.URL.Path, r.URL.RawQuery)
|
||||
}
|
||||
_, _ = w.Write([]byte(`[{"id":7,"path":"/music/A/B (2017)/A - B - 02 - X.mp3","albumId":0}]`))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
got, err := c.ListUnmappedTrackFiles(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListUnmappedTrackFiles: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != 7 || got[0].Path != "/music/A/B (2017)/A - B - 02 - X.mp3" {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseReads(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/api/v1/album/4723":
|
||||
_, _ = w.Write([]byte(`{"id":4723,"releases":[
|
||||
{"id":10148,"title":"Humanz","format":"14x12\" Vinyl, Digital Media","trackCount":68,"mediumCount":15,"monitored":true},
|
||||
{"id":10149,"title":"Humanz","format":"Digital Media","trackCount":40,"mediumCount":1,"monitored":false}]}`))
|
||||
case r.URL.Path == "/api/v1/track" && r.URL.Query().Get("albumId") == "4723":
|
||||
_, _ = w.Write([]byte(`[{"id":1,"title":"Ascension","mediumNumber":1,"trackFileId":9}]`))
|
||||
case r.URL.Path == "/api/v1/track" && r.URL.Query().Get("albumReleaseId") == "10149":
|
||||
_, _ = w.Write([]byte(`[{"id":2,"title":"Ascension","mediumNumber":1,"trackFileId":0}]`))
|
||||
default:
|
||||
t.Errorf("unexpected request %s?%s", r.URL.Path, r.URL.RawQuery)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
defer srv.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
rels, err := c.GetAlbumReleases(ctx, 4723)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbumReleases: %v", err)
|
||||
}
|
||||
if len(rels) != 2 || !rels[0].Monitored || rels[0].MediumCount != 15 || rels[1].TrackCount != 40 {
|
||||
t.Errorf("releases = %+v", rels)
|
||||
}
|
||||
tr, err := c.ListAlbumTracks(ctx, 4723)
|
||||
if err != nil || len(tr) != 1 || tr[0].TrackFileID != 9 {
|
||||
t.Errorf("ListAlbumTracks = %+v, %v", tr, err)
|
||||
}
|
||||
tr, err = c.ListReleaseTracks(ctx, 10149)
|
||||
if err != nil || len(tr) != 1 || tr[0].Title != "Ascension" {
|
||||
t.Errorf("ListReleaseTracks = %+v, %v", tr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The PUT carries the whole resource back, with only the releases' monitored
|
||||
// flags changed: exactly the chosen release is monitored, and a field Minstrel
|
||||
// does not model survives the round trip.
|
||||
func TestSetMonitoredRelease_RoundTripsTheResource(t *testing.T) {
|
||||
var put map[string]any
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/album/4723" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
_, _ = w.Write([]byte(`{"id":4723,"anyReleaseOk":false,"secondaryTypes":["Studio"],"releases":[
|
||||
{"id":10148,"monitored":true},{"id":10149,"monitored":false}]}`))
|
||||
case http.MethodPut:
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(b, &put); err != nil {
|
||||
t.Errorf("PUT body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
if err := c.SetMonitoredRelease(context.Background(), 4723, 10149); err != nil {
|
||||
t.Fatalf("SetMonitoredRelease: %v", err)
|
||||
}
|
||||
rels := put["releases"].([]any)
|
||||
if m := rels[0].(map[string]any)["monitored"]; m != false {
|
||||
t.Errorf("old release monitored = %v, want false", m)
|
||||
}
|
||||
if m := rels[1].(map[string]any)["monitored"]; m != true {
|
||||
t.Errorf("new release monitored = %v, want true", m)
|
||||
}
|
||||
if st, _ := put["secondaryTypes"].([]any); len(st) != 1 || st[0] != "Studio" {
|
||||
t.Errorf("secondaryTypes = %v, want it carried through unchanged", put["secondaryTypes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMonitoredRelease_UnknownReleaseSendsNothing(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPut {
|
||||
t.Error("PUT sent for a release the album does not have")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":4723,"releases":[{"id":10148,"monitored":true}]}`))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
if err := c.SetMonitoredRelease(context.Background(), 4723, 999); !errors.Is(err, ErrNotFound) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -57,17 +57,6 @@ func (a lidarrUnmonitorAdapter) UnmonitorTrack(ctx context.Context, trackMbid, a
|
||||
return c.UnmonitorTrack(ctx, trackMbid, albumMbid)
|
||||
}
|
||||
|
||||
// ListUnmappedTrackFiles lets the duplicate merge refuse to remove a copy
|
||||
// Lidarr maps (M498). tracks.ErrLidarrDisabled tells it there is no Lidarr to
|
||||
// protect.
|
||||
func (a lidarrUnmonitorAdapter) ListUnmappedTrackFiles(ctx context.Context) ([]lidarr.TrackFile, error) {
|
||||
c := a.fn()
|
||||
if c == nil {
|
||||
return nil, tracks.ErrLidarrDisabled
|
||||
}
|
||||
return c.ListUnmappedTrackFiles(ctx)
|
||||
}
|
||||
|
||||
// ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an
|
||||
// interface so tests can stub it without touching the DB. The progressCb
|
||||
// parameter (added in m7-scan-progress) lets the orchestrator drive partial-
|
||||
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
@@ -41,22 +40,6 @@ import (
|
||||
// same pointer.
|
||||
var ErrNotFound = apierror.ErrNotFound
|
||||
|
||||
// ErrLidarrDisabled is what a LidarrUnmonitorer that also lists Lidarr's
|
||||
// unmapped files returns when Lidarr is turned off: there is then no Lidarr
|
||||
// record a removal could break, and a merge goes ahead unguarded.
|
||||
var ErrLidarrDisabled = errors.New("tracks: lidarr disabled")
|
||||
|
||||
// ErrLidarrUnavailable means Lidarr is on but did not answer, so a merge cannot
|
||||
// confirm the copies it would remove are ones Lidarr does not need (M498), and
|
||||
// refuses rather than guess.
|
||||
var ErrLidarrUnavailable = errors.New("tracks: lidarr did not answer")
|
||||
|
||||
// lidarrFileLister is the optional part of the Lidarr dependency a merge uses to
|
||||
// learn which files Lidarr holds without mapping to any track.
|
||||
type lidarrFileLister interface {
|
||||
ListUnmappedTrackFiles(ctx context.Context) ([]lidarr.TrackFile, error)
|
||||
}
|
||||
|
||||
// LidarrUnmonitorer is the subset of *lidarr.Client RemoveTrack uses.
|
||||
// Defined as an interface so tests can stub without spinning up a Lidarr
|
||||
// httptest server. UnmonitorTrack failures are non-fatal at the service
|
||||
@@ -168,9 +151,7 @@ func (s *Service) RemoveTrack(
|
||||
}
|
||||
|
||||
// MergeDuplicates merges a duplicate group into the copy to keep
|
||||
// (library.MergeDuplicateGroupGuarded, refusing to remove a copy Lidarr maps:
|
||||
// library.ErrCopyTrackedByLidarr, or ErrLidarrUnavailable when Lidarr cannot
|
||||
// say), then — when asked — tells Lidarr to stop
|
||||
// (library.MergeDuplicateGroup), then — when asked — tells Lidarr to stop
|
||||
// monitoring the removed copies so it does not download them again, and records
|
||||
// the merge in the audit log.
|
||||
//
|
||||
@@ -180,11 +161,7 @@ func (s *Service) RemoveTrack(
|
||||
func (s *Service) MergeDuplicates(
|
||||
ctx context.Context, groupID, survivorID, actorID pgtype.UUID, unmonitor bool,
|
||||
) (res library.MergeResult, lidarrUnmonitorFailed bool, err error) {
|
||||
removable, err := s.lidarrRemovable(ctx)
|
||||
if err != nil {
|
||||
return res, false, err
|
||||
}
|
||||
res, err = library.MergeDuplicateGroupGuarded(ctx, s.pool, s.logger, s.dataDir, groupID, survivorID, removable)
|
||||
res, err = library.MergeDuplicateGroup(ctx, s.pool, s.logger, s.dataDir, groupID, survivorID)
|
||||
if err != nil {
|
||||
return res, false, err
|
||||
}
|
||||
@@ -226,29 +203,6 @@ func (s *Service) MergeDuplicates(
|
||||
return res, lidarrUnmonitorFailed, nil
|
||||
}
|
||||
|
||||
// lidarrRemovable is the merge's guard (M498): only a copy Lidarr holds without
|
||||
// mapping it to a track may be removed, because removing a mapped one makes
|
||||
// Lidarr download it again. Nil, allowing everything, when Lidarr is disabled
|
||||
// or the dependency cannot list files.
|
||||
func (s *Service) lidarrRemovable(ctx context.Context) (library.RemovableFunc, error) {
|
||||
lister, ok := s.lidarr.(lidarrFileLister)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
files, err := lister.ListUnmappedTrackFiles(ctx)
|
||||
if errors.Is(err, ErrLidarrDisabled) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrLidarrUnavailable, err)
|
||||
}
|
||||
unmapped := make(map[string]bool, len(files))
|
||||
for _, f := range files {
|
||||
unmapped[library.LidarrPathKey(f.Path)] = true
|
||||
}
|
||||
return func(p string) bool { return unmapped[library.LidarrPathKey(p)] }, nil
|
||||
}
|
||||
|
||||
// sameLidarrTrack reports whether two copies are the same Lidarr track: one
|
||||
// recording on one album. Lidarr monitors per album track, so when the removed
|
||||
// copy is a second file of the kept copy's own album track — the #3885 case —
|
||||
|
||||
Reference in New Issue
Block a user