release / govulncheck (push) Successful in 25s
release / web (push) Failing after 26s
release / go (push) Failing after 58s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m25s
release / Build signed APK (releases and dev) (push) Canceled after 3m26s
release / android (push) Canceled after 3m28s
- The resolver also merges cross-release and mismatch groups where Lidarr
maps exactly one copy: the others fulfil nothing, so removing them opens no
hole (D-a rule 1). That covers a rip beside the clean copy on another
release and a wrong-file import Lidarr holds unmapped. With two or more
mapped copies each fulfils its own release and nothing is removed.
- A track whose file name carries a video-rip marker is held back from radio
and the system mixes (tracks.source_verdict, migration 0077). It still plays
when chosen. A renamed file is released; the operator's "fine" sticks.
- Suspect sources shows what was done to each track, with "This one is fine"
and "Hold back again" (PUT /api/admin/library/suspect-sources/{id}).
- The Liked list prefers a copy that is not held back.
Replacing a rip that has no clean copy is left for the operator to decide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
792 lines
26 KiB
Go
792 lines
26 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"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).
|
|
//
|
|
// 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:
|
|
//
|
|
// - 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
|
|
}
|
|
|
|
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.
|
|
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
|
|
// 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
|
|
}
|
|
|
|
// 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.
|
|
type resolveGroup struct {
|
|
id pgtype.UUID
|
|
tier string
|
|
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 {
|
|
switch g.class {
|
|
case ClassSameRelease:
|
|
return g.tracked() <= 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
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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.
|
|
func ResolveDuplicates(
|
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, lid LidarrLibrary, act bool,
|
|
) (DuplicateResolveResult, error) {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
q := dbq.New(pool)
|
|
res := DuplicateResolveResult{Classes: map[DuplicateClass]int{}}
|
|
|
|
rows, err := q.ListDuplicateGroupsForResolve(ctx)
|
|
if err != nil {
|
|
return res, fmt.Errorf("list duplicate groups: %w", err)
|
|
}
|
|
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)
|
|
res.Classes[g.class]++
|
|
}
|
|
if err := writeResolveVerdicts(ctx, q, groups); err != nil {
|
|
return res, err
|
|
}
|
|
|
|
if !act {
|
|
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)] }
|
|
for _, g := range groups {
|
|
if res.Merged >= resolveMergeCap {
|
|
break
|
|
}
|
|
if ctx.Err() != nil {
|
|
return res, ctx.Err()
|
|
}
|
|
if !autoMergeable(g) || g.settling(settling) {
|
|
continue
|
|
}
|
|
if err := autoMerge(ctx, pool, logger, dataDir, g, removable); err != nil {
|
|
res.MergeFailed++
|
|
logger.Warn("duplicate resolve: merge failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
|
|
continue
|
|
}
|
|
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
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
if res.Merged > 0 || len(changes) > 0 {
|
|
notifyAdmins(ctx, notifications.KindDuplicatesResolved, notifications.Payload{
|
|
Count: int64(res.Merged + len(changes)),
|
|
Detail: resolvedDetail(res.Merged, changes),
|
|
})
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// 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,
|
|
// 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) {
|
|
linked := 0
|
|
for _, g := range groups {
|
|
if g.class != ClassCrossRelease {
|
|
continue
|
|
}
|
|
if ctx.Err() != nil {
|
|
return linked, ctx.Err()
|
|
}
|
|
ids := make([]pgtype.UUID, len(g.members))
|
|
for i, m := range g.members {
|
|
ids[i] = m.TrackID
|
|
}
|
|
moved, err := linkSong(ctx, pool, ids)
|
|
if err != nil {
|
|
logger.Warn("duplicate resolve: song link failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
|
|
continue
|
|
}
|
|
if moved {
|
|
linked++
|
|
}
|
|
}
|
|
return linked, nil
|
|
}
|
|
|
|
// linkSong links the tracks as one song and, when that changed anything,
|
|
// shares their likes across the song and logs each added like for sync.
|
|
func linkSong(ctx context.Context, pool *pgxpool.Pool, ids []pgtype.UUID) (bool, error) {
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
tq := dbq.New(tx)
|
|
link, err := tq.LinkTracksAsSong(ctx, ids)
|
|
if err != nil {
|
|
return false, fmt.Errorf("link: %w", err)
|
|
}
|
|
if link.Moved == 0 {
|
|
return false, nil
|
|
}
|
|
added, err := tq.ShareSongLikes(ctx, link.SongKey)
|
|
if err != nil {
|
|
return false, fmt.Errorf("share likes: %w", err)
|
|
}
|
|
likeIDs := make([]string, len(added))
|
|
for i, a := range added {
|
|
likeIDs[i] = syncpkg.EncodeLikeID(syncpkg.FormatUUID(a.UserID), syncpkg.FormatUUID(a.TrackID))
|
|
}
|
|
if err := syncpkg.LogChanges(ctx, tx, syncpkg.EntityLikeTrack, likeIDs, syncpkg.OpUpsert); err != nil {
|
|
return false, fmt.Errorf("log shared likes: %w", err)
|
|
}
|
|
return true, tx.Commit(ctx)
|
|
}
|
|
|
|
func foldResolveGroups(rows []dbq.ListDuplicateGroupsForResolveRow) []*resolveGroup {
|
|
var groups []*resolveGroup
|
|
for _, r := range rows {
|
|
if n := len(groups); n == 0 || groups[n-1].id != r.GroupID {
|
|
groups = append(groups, &resolveGroup{id: r.GroupID, tier: r.Tier})
|
|
}
|
|
g := groups[len(groups)-1]
|
|
g.members = append(g.members, r)
|
|
}
|
|
// A group whose other copies went missing has nothing left to resolve.
|
|
out := groups[:0]
|
|
for _, g := range groups {
|
|
if len(g.members) >= 2 {
|
|
out = append(out, g)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// resolveNote is what the report says beside a group the resolver leaves for
|
|
// the operator, 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."
|
|
case ClassMismatch:
|
|
return "Identical audio filed under different titles: one file carries another song's tags."
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
// survivorCandidates builds what ProposeSurvivor weighs for a group's copies.
|
|
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),
|
|
}
|
|
}
|
|
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.
|
|
func autoMerge(
|
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, g *resolveGroup, removable RemovableFunc,
|
|
) error {
|
|
survivorKey, reason := ProposeSurvivor(survivorCandidates(g))
|
|
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)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := dbq.New(pool).MarkDuplicateGroupResolvedAutomatically(ctx, g.id); err != nil {
|
|
logger.Warn("duplicate resolve: marking the merge automatic failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
|
|
}
|
|
removed := make([]map[string]string, 0, len(res.Removed))
|
|
for _, c := range res.Removed {
|
|
removed = append(removed, map[string]string{"track_id": syncpkg.FormatUUID(c.TrackID), "file_path": c.FilePath})
|
|
}
|
|
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionDuplicateMerge, map[string]any{
|
|
"automatic": true,
|
|
"group_id": syncpkg.FormatUUID(g.id),
|
|
"tier": res.Tier,
|
|
"class": string(g.class),
|
|
"survivor_track_id": syncpkg.FormatUUID(res.Survivor.TrackID),
|
|
"survivor_path": res.Survivor.FilePath,
|
|
"survivor_reason": reason,
|
|
"removed": removed,
|
|
"moved": map[string]any{
|
|
"play_events": res.PlayEvents, "skip_events": res.SkipEvents,
|
|
"likes": res.Likes, "playlist_entries": res.PlaylistEntries,
|
|
},
|
|
})
|
|
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 {
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
return many
|
|
}
|
|
|
|
// DuplicateResolveWorker runs a resolver pass every hour.
|
|
type DuplicateResolveWorker struct {
|
|
pool *pgxpool.Pool
|
|
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,
|
|
) *DuplicateResolveWorker {
|
|
return &DuplicateResolveWorker{
|
|
pool: pool, logger: logger, dataDir: dataDir, settings: settings, lidarr: lidarrFn, tick: duplicateResolveTick,
|
|
}
|
|
}
|
|
|
|
// Run blocks until ctx is cancelled, running a pass at start and then each tick.
|
|
func (w *DuplicateResolveWorker) Run(ctx context.Context) {
|
|
w.tickOnce(ctx)
|
|
t := time.NewTicker(w.tick)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
w.tickOnce(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// tickOnce contains one pass so nothing it does can stop the next tick (rule 157).
|
|
func (w *DuplicateResolveWorker) tickOnce(ctx context.Context) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
w.logger.Error("duplicate resolve: tick panicked", "panic", r)
|
|
}
|
|
}()
|
|
// A sweep in flight is rewriting the groups; the next tick sees its result.
|
|
_, err := dbq.New(w.pool).GetInFlightDuplicateSweep(ctx)
|
|
switch {
|
|
case err == nil:
|
|
return
|
|
case !errors.Is(err, pgx.ErrNoRows):
|
|
if ctx.Err() == nil {
|
|
w.logger.Warn("duplicate resolve: sweep check failed", "err", err)
|
|
}
|
|
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)
|
|
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)
|
|
}
|