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
+24 -3
View File
@@ -331,15 +331,17 @@ func (h *handlers) handleRunDuplicateSweep(w http.ResponseWriter, _ *http.Reques
// handleDismissDuplicateGroup implements POST
// /api/admin/library/duplicates/{id}/dismiss: "these are not duplicates". The
// sweep keeps the dismissal and will not propose that set of tracks again. 404
// duplicate_group_not_pending when the group was already resolved or is gone.
// sweep keeps the dismissal and will not propose that set of tracks again. On a
// cross-release group it also undoes the song link (M498 #5438): the copies
// stop sharing likes from here on. 404 duplicate_group_not_pending when the
// group was already resolved or is gone.
func (h *handlers) handleDismissDuplicateGroup(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
n, err := dbq.New(h.pool).DismissDuplicateGroup(r.Context(), id)
n, err := h.dismissDuplicateGroup(r.Context(), id)
if err != nil {
h.logger.Error("admin: dismiss duplicate group", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
@@ -352,6 +354,25 @@ func (h *handlers) handleDismissDuplicateGroup(w http.ResponseWriter, r *http.Re
writeJSON(w, http.StatusOK, map[string]string{"status": "dismissed"})
}
// dismissDuplicateGroup dismisses the group and unlinks its copies as one song,
// together.
func (h *handlers) dismissDuplicateGroup(ctx context.Context, id pgtype.UUID) (int64, error) {
tx, err := h.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback(ctx) }()
tq := dbq.New(tx)
n, err := tq.DismissDuplicateGroup(ctx, id)
if err != nil || n == 0 {
return n, err
}
if err := tq.UnlinkDuplicateGroupSongs(ctx, id); err != nil {
return 0, err
}
return n, tx.Commit(ctx)
}
// mergeDuplicateRequest chooses the copy to keep. An empty survivor_track_id
// keeps the report's proposal.
type mergeDuplicateRequest struct {
+27 -7
View File
@@ -3,6 +3,7 @@ package api
import (
"errors"
"net/http"
"slices"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
@@ -50,20 +51,36 @@ func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) {
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id})
liked, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id})
if err != nil {
h.logger.Error("api: like track insert", "err", err)
writeErr(w, apierror.InternalMsg("insert failed", err))
return
}
if rows == 1 {
if slices.Contains(liked, id) {
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger)
}
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpUpsert)
h.publishLikeEvent(user.ID, id, "track", true)
// The like is on the song (M498 #5438): every copy it reached changes on
// the client too.
for _, t := range withTrack(id, liked) {
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, t, syncpkg.OpUpsert)
h.publishLikeEvent(user.ID, t, "track", true)
}
w.WriteHeader(http.StatusNoContent)
}
// withTrack is the tracks a like or unlike changed, led by the one asked for,
// which is reported even when it was already in that state.
func withTrack(id pgtype.UUID, changed []pgtype.UUID) []pgtype.UUID {
out := []pgtype.UUID{id}
for _, t := range changed {
if t != id {
out = append(out, t)
}
}
return out
}
func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
@@ -74,7 +91,8 @@ func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
return
}
q := dbq.New(h.pool)
if err := q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {
unliked, err := q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id})
if err != nil {
h.logger.Error("api: unlike track", "err", err)
writeErr(w, apierror.InternalMsg("delete failed", err))
return
@@ -83,8 +101,10 @@ func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
h.logger.Error("api: soft-delete contextual_likes", "err", err)
// Don't fail the response — soft-delete is best-effort.
}
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpDelete)
h.publishLikeEvent(user.ID, id, "track", false)
for _, t := range withTrack(id, unliked) {
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, t, syncpkg.OpDelete)
h.publishLikeEvent(user.ID, t, "track", false)
}
w.WriteHeader(http.StatusNoContent)
}