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:
@@ -34,9 +34,12 @@ func (q *Queries) CountLikedArtists(ctx context.Context, userID pgtype.UUID) (in
|
||||
}
|
||||
|
||||
const countLikedTracks = `-- name: CountLikedTracks :one
|
||||
SELECT count(*) FROM general_likes WHERE user_id = $1
|
||||
SELECT count(DISTINCT t.song_key) FROM general_likes l
|
||||
JOIN tracks t ON t.id = l.track_id
|
||||
WHERE l.user_id = $1
|
||||
`
|
||||
|
||||
// Liked songs, each counted once however many releases hold it.
|
||||
func (q *Queries) CountLikedTracks(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countLikedTracks, userID)
|
||||
var count int64
|
||||
@@ -76,10 +79,13 @@ func (q *Queries) LikeArtist(ctx context.Context, arg LikeArtistParams) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const likeTrack = `-- name: LikeTrack :execrows
|
||||
const likeTrack = `-- name: LikeTrack :many
|
||||
INSERT INTO general_likes (user_id, track_id)
|
||||
VALUES ($1, $2)
|
||||
SELECT $1::uuid, t.id
|
||||
FROM tracks t
|
||||
WHERE t.song_key = (SELECT s.song_key FROM tracks s WHERE s.id = $2::uuid)
|
||||
ON CONFLICT (user_id, track_id) DO NOTHING
|
||||
RETURNING track_id
|
||||
`
|
||||
|
||||
type LikeTrackParams struct {
|
||||
@@ -87,12 +93,27 @@ type LikeTrackParams struct {
|
||||
TrackID pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID)
|
||||
// A like is on the song (M498 #5438): every copy of it sharing the track's
|
||||
// song_key is liked. Returns the tracks newly liked, for the sync log; empty
|
||||
// when the song was already liked.
|
||||
func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, likeTrack, arg.UserID, arg.TrackID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
defer rows.Close()
|
||||
var items []pgtype.UUID
|
||||
for rows.Next() {
|
||||
var track_id pgtype.UUID
|
||||
if err := rows.Scan(&track_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, track_id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many
|
||||
@@ -260,10 +281,14 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
|
||||
}
|
||||
|
||||
const listLikedTrackRows = `-- name: ListLikedTrackRows :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 FROM tracks t
|
||||
JOIN general_likes l ON l.track_id = t.id
|
||||
WHERE l.user_id = $1
|
||||
ORDER BY l.liked_at DESC
|
||||
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 FROM tracks t
|
||||
JOIN (SELECT DISTINCT ON (lt.song_key) l.track_id, l.liked_at
|
||||
FROM general_likes l
|
||||
JOIN tracks lt ON lt.id = l.track_id
|
||||
WHERE l.user_id = $1
|
||||
ORDER BY lt.song_key, (lt.missing_since IS NOT NULL), l.liked_at, lt.id) l
|
||||
ON l.track_id = t.id
|
||||
ORDER BY l.liked_at DESC, t.id
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
@@ -273,6 +298,8 @@ type ListLikedTrackRowsParams struct {
|
||||
Offset int32
|
||||
}
|
||||
|
||||
// One row per liked song: of its copies, the one present on disk, then the
|
||||
// one liked first.
|
||||
func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRowsParams) ([]Track, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedTrackRows, arg.UserID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
@@ -303,6 +330,8 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
|
||||
&i.TagReadVersion,
|
||||
&i.MissingSince,
|
||||
&i.MbidSource,
|
||||
&i.SongID,
|
||||
&i.SongKey,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -342,8 +371,13 @@ func (q *Queries) UnlikeArtist(ctx context.Context, arg UnlikeArtistParams) erro
|
||||
return err
|
||||
}
|
||||
|
||||
const unlikeTrack = `-- name: UnlikeTrack :exec
|
||||
DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2
|
||||
const unlikeTrack = `-- name: UnlikeTrack :many
|
||||
DELETE FROM general_likes
|
||||
WHERE user_id = $1::uuid
|
||||
AND (track_id = $2::uuid
|
||||
OR track_id IN (SELECT t.id FROM tracks t
|
||||
WHERE t.song_key = (SELECT s.song_key FROM tracks s WHERE s.id = $2::uuid)))
|
||||
RETURNING track_id
|
||||
`
|
||||
|
||||
type UnlikeTrackParams struct {
|
||||
@@ -351,7 +385,24 @@ type UnlikeTrackParams struct {
|
||||
TrackID pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) UnlikeTrack(ctx context.Context, arg UnlikeTrackParams) error {
|
||||
_, err := q.db.Exec(ctx, unlikeTrack, arg.UserID, arg.TrackID)
|
||||
return err
|
||||
// Unlikes the song: every copy sharing the track's song_key. Returns the
|
||||
// tracks unliked, for the sync log.
|
||||
func (q *Queries) UnlikeTrack(ctx context.Context, arg UnlikeTrackParams) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, unlikeTrack, arg.UserID, arg.TrackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []pgtype.UUID
|
||||
for rows.Next() {
|
||||
var track_id pgtype.UUID
|
||||
if err := rows.Scan(&track_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, track_id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user