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
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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user