feat: the same song on several releases counts as one song (M498 #5438)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m7s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m11s
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 / android (push) Canceled after 5m58s
release / Build signed APK (releases and dev) (push) Canceled after 5m59s

A single and the album it is on stay two files, since each fulfils its own
release in Lidarr, but they are one song to the listener.

- tracks.song_id links copies; the generated song_key (song_id, else the
  track's own id) is what they share (migration 0076).
- The resolver links each cross-release group every pass (idempotent; only
  with auto-resolve on) and, when a link is new, shares existing likes across
  the song and logs them for sync.
- A like or unlike (web and Subsonic) reaches every copy; each change is
  logged and published so clients update every heart.
- The Liked list and its count show the song once; Shuffle and the mix writer
  take one copy per song.
- A merge keeps the removed copy's song link; "Not the same song" on the
  Across releases tab dismisses the group and undoes the link.

Shared plays ("heard via another copy") are left for a later step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:07:55 -04:00
co-authored by Claude Opus 5.5
parent 4ecff52f19
commit 5b372f61d7
22 changed files with 799 additions and 60 deletions
+76 -3
View File
@@ -89,6 +89,8 @@ type DuplicateResolveResult struct {
Merged int
MergeFailed int
ReleaseChanges []ReleaseChange
// SongsLinked counts cross-release groups newly linked as one song.
SongsLinked int
}
// LidarrPathKey is the part of a path Minstrel and Lidarr agree on: the last
@@ -177,7 +179,18 @@ func ResolveDuplicates(
return res, err
}
if !act || !res.LidarrConsulted {
if !act {
return res, nil
}
// Linking removes nothing and needs nothing from Lidarr.
linked, err := linkCrossRelease(ctx, pool, logger, groups)
res.SongsLinked = linked
if err != nil {
return res, err
}
if !res.LidarrConsulted {
return res, nil
}
@@ -222,6 +235,66 @@ func ResolveDuplicates(
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 {
@@ -250,7 +323,7 @@ func resolveNote(g *resolveGroup) string {
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."
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."
}
@@ -674,5 +747,5 @@ func (w *DuplicateResolveWorker) tickOnce(ctx context.Context) {
}
w.logger.Info("duplicate resolve complete",
"groups", res.Groups, "lidarr", res.LidarrConsulted, "merged", res.Merged,
"merge_failed", res.MergeFailed, "release_changes", len(res.ReleaseChanges))
"merge_failed", res.MergeFailed, "release_changes", len(res.ReleaseChanges), "songs_linked", res.SongsLinked)
}