Duplicates and video rips resolve themselves (M498) #155

Merged
bvandeusen merged 5 commits from dev into main 2026-10-08 22:30:10 -04:00
22 changed files with 799 additions and 60 deletions
Showing only changes of commit 5b372f61d7 - Show all commits
+24 -3
View File
@@ -331,15 +331,17 @@ func (h *handlers) handleRunDuplicateSweep(w http.ResponseWriter, _ *http.Reques
// handleDismissDuplicateGroup implements POST // handleDismissDuplicateGroup implements POST
// /api/admin/library/duplicates/{id}/dismiss: "these are not duplicates". The // /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 // sweep keeps the dismissal and will not propose that set of tracks again. On a
// duplicate_group_not_pending when the group was already resolved or is gone. // 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) { func (h *handlers) handleDismissDuplicateGroup(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUID(chi.URLParam(r, "id")) id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok { if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return return
} }
n, err := dbq.New(h.pool).DismissDuplicateGroup(r.Context(), id) n, err := h.dismissDuplicateGroup(r.Context(), id)
if err != nil { if err != nil {
h.logger.Error("admin: dismiss duplicate group", "err", err) h.logger.Error("admin: dismiss duplicate group", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") 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"}) 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 // mergeDuplicateRequest chooses the copy to keep. An empty survivor_track_id
// keeps the report's proposal. // keeps the report's proposal.
type mergeDuplicateRequest struct { type mergeDuplicateRequest struct {
+27 -7
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"errors" "errors"
"net/http" "net/http"
"slices"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "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)) writeErr(w, apierror.InternalMsg("lookup failed", err))
return 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 { if err != nil {
h.logger.Error("api: like track insert", "err", err) h.logger.Error("api: like track insert", "err", err)
writeErr(w, apierror.InternalMsg("insert failed", err)) writeErr(w, apierror.InternalMsg("insert failed", err))
return return
} }
if rows == 1 { if slices.Contains(liked, id) {
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger) _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger)
} }
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpUpsert) // The like is on the song (M498 #5438): every copy it reached changes on
h.publishLikeEvent(user.ID, id, "track", true) // 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) 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) { func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r) user, ok := requireUser(w, r)
if !ok { if !ok {
@@ -74,7 +91,8 @@ func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
return return
} }
q := dbq.New(h.pool) 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) h.logger.Error("api: unlike track", "err", err)
writeErr(w, apierror.InternalMsg("delete failed", err)) writeErr(w, apierror.InternalMsg("delete failed", err))
return 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) h.logger.Error("api: soft-delete contextual_likes", "err", err)
// Don't fail the response — soft-delete is best-effort. // Don't fail the response — soft-delete is best-effort.
} }
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpDelete) for _, t := range withTrack(id, unliked) {
h.publishLikeEvent(user.ID, id, "track", false) h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, t, syncpkg.OpDelete)
h.publishLikeEvent(user.ID, t, "track", false)
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
+3 -1
View File
@@ -261,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
} }
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many const listRecentSessionTracks = `-- name: ListRecentSessionTracks :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 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 play_events pe ON pe.track_id = t.id JOIN play_events pe ON pe.track_id = t.id
WHERE pe.session_id = $1 WHERE pe.session_id = $1
AND pe.started_at < $2 AND pe.started_at < $2
@@ -308,6 +308,8 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
+3 -1
View File
@@ -14,7 +14,7 @@ import (
const listUserHistory = `-- name: ListUserHistory :many const listUserHistory = `-- name: ListUserHistory :many
SELECT pe.id AS event_id, SELECT pe.id AS event_id,
pe.started_at, pe.started_at,
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.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,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM play_events pe FROM play_events pe
@@ -82,6 +82,8 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
+67 -16
View File
@@ -34,9 +34,12 @@ func (q *Queries) CountLikedArtists(ctx context.Context, userID pgtype.UUID) (in
} }
const countLikedTracks = `-- name: CountLikedTracks :one 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) { func (q *Queries) CountLikedTracks(ctx context.Context, userID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countLikedTracks, userID) row := q.db.QueryRow(ctx, countLikedTracks, userID)
var count int64 var count int64
@@ -76,10 +79,13 @@ func (q *Queries) LikeArtist(ctx context.Context, arg LikeArtistParams) error {
return err return err
} }
const likeTrack = `-- name: LikeTrack :execrows const likeTrack = `-- name: LikeTrack :many
INSERT INTO general_likes (user_id, track_id) 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 ON CONFLICT (user_id, track_id) DO NOTHING
RETURNING track_id
` `
type LikeTrackParams struct { type LikeTrackParams struct {
@@ -87,12 +93,27 @@ type LikeTrackParams struct {
TrackID pgtype.UUID TrackID pgtype.UUID
} }
func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) (int64, error) { // A like is on the song (M498 #5438): every copy of it sharing the track's
result, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID) // 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 { 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 const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many
@@ -260,10 +281,14 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
} }
const listLikedTrackRows = `-- name: ListLikedTrackRows :many 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 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 general_likes l ON l.track_id = t.id JOIN (SELECT DISTINCT ON (lt.song_key) l.track_id, l.liked_at
WHERE l.user_id = $1 FROM general_likes l
ORDER BY l.liked_at DESC 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 LIMIT $2 OFFSET $3
` `
@@ -273,6 +298,8 @@ type ListLikedTrackRowsParams struct {
Offset int32 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) { func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRowsParams) ([]Track, error) {
rows, err := q.db.Query(ctx, listLikedTrackRows, arg.UserID, arg.Limit, arg.Offset) rows, err := q.db.Query(ctx, listLikedTrackRows, arg.UserID, arg.Limit, arg.Offset)
if err != nil { if err != nil {
@@ -303,6 +330,8 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -342,8 +371,13 @@ func (q *Queries) UnlikeArtist(ctx context.Context, arg UnlikeArtistParams) erro
return err return err
} }
const unlikeTrack = `-- name: UnlikeTrack :exec const unlikeTrack = `-- name: UnlikeTrack :many
DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2 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 { type UnlikeTrackParams struct {
@@ -351,7 +385,24 @@ type UnlikeTrackParams struct {
TrackID pgtype.UUID TrackID pgtype.UUID
} }
func (q *Queries) UnlikeTrack(ctx context.Context, arg UnlikeTrackParams) error { // Unlikes the song: every copy sharing the track's song_key. Returns the
_, err := q.db.Exec(ctx, unlikeTrack, arg.UserID, arg.TrackID) // tracks unliked, for the sync log.
return err 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
} }
+2
View File
@@ -754,6 +754,8 @@ type Track struct {
TagReadVersion int16 TagReadVersion int16
MissingSince pgtype.Timestamptz MissingSince pgtype.Timestamptz
MbidSource *string MbidSource *string
SongID pgtype.UUID
SongKey pgtype.UUID
} }
type TrackAcoustidLookup struct { type TrackAcoustidLookup struct {
+12 -4
View File
@@ -208,7 +208,7 @@ WITH plays AS (
WHERE user_id = $2 AND was_skipped = false WHERE user_id = $2 AND was_skipped = false
GROUP BY track_id GROUP BY track_id
) )
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, 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,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM plays p FROM plays p
@@ -271,6 +271,8 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -291,7 +293,7 @@ WITH plays AS (
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id GROUP BY track_id
) )
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, 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,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM plays p FROM plays p
@@ -356,6 +358,8 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -695,7 +699,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
const loadRadioCandidates = `-- name: LoadRadioCandidates :many const loadRadioCandidates = `-- name: LoadRadioCandidates :many
SELECT 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.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,
(l.user_id IS NOT NULL)::bool AS is_liked, (l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at, pe.last_played_at::timestamptz AS last_played_at,
pe.play_count, pe.play_count,
@@ -777,6 +781,8 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.IsLiked, &i.IsLiked,
&i.LastPlayedAt, &i.LastPlayedAt,
&i.PlayCount, &i.PlayCount,
@@ -925,7 +931,7 @@ random_fill AS (
LIMIT $9 LIMIT $9
) )
SELECT 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.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,
(l.user_id IS NOT NULL)::bool AS is_liked, (l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at, pe.last_played_at::timestamptz AS last_played_at,
pe.play_count, pe.play_count,
@@ -1053,6 +1059,8 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.IsLiked, &i.IsLiked,
&i.LastPlayedAt, &i.LastPlayedAt,
&i.PlayCount, &i.PlayCount,
+150
View File
@@ -0,0 +1,150 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: songs.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const linkTracksAsSong = `-- name: LinkTracksAsSong :one
WITH keys AS (
SELECT DISTINCT song_key FROM tracks WHERE id = ANY($1::uuid[])
), target AS (
SELECT song_key FROM keys ORDER BY song_key LIMIT 1
), moved AS (
UPDATE tracks t
SET song_id = (SELECT song_key FROM target)
WHERE t.song_key IN (SELECT song_key FROM keys)
AND t.song_key <> (SELECT song_key FROM target)
RETURNING t.id
)
SELECT (SELECT song_key FROM target)::uuid AS song_key,
(SELECT count(*) FROM moved)::bigint AS moved
`
type LinkTracksAsSongRow struct {
SongKey pgtype.UUID
Moved int64
}
// Song links (M498 #5438): copies of one song on different releases share a
// song_key (migration 0076).
// Links the tracks into one song, joining any songs they already belong to.
// The song keeps the lowest key among them, so linking the same set again
// changes nothing. Returns that key and how many tracks moved to it.
func (q *Queries) LinkTracksAsSong(ctx context.Context, trackIds []pgtype.UUID) (LinkTracksAsSongRow, error) {
row := q.db.QueryRow(ctx, linkTracksAsSong, trackIds)
var i LinkTracksAsSongRow
err := row.Scan(&i.SongKey, &i.Moved)
return i, err
}
const listTrackSongKeys = `-- name: ListTrackSongKeys :many
SELECT id, song_key FROM tracks WHERE id = ANY($1::uuid[])
`
type ListTrackSongKeysRow struct {
ID pgtype.UUID
SongKey pgtype.UUID
}
func (q *Queries) ListTrackSongKeys(ctx context.Context, ids []pgtype.UUID) ([]ListTrackSongKeysRow, error) {
rows, err := q.db.Query(ctx, listTrackSongKeys, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTrackSongKeysRow
for rows.Next() {
var i ListTrackSongKeysRow
if err := rows.Scan(&i.ID, &i.SongKey); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const mergeInheritSongLink = `-- name: MergeInheritSongLink :exec
UPDATE tracks s
SET song_id = l.song_key
FROM tracks l
WHERE s.id = $1::uuid
AND l.id = $2::uuid
AND s.song_id IS NULL
AND l.song_key <> l.id
`
type MergeInheritSongLinkParams struct {
SurvivorID pgtype.UUID
LoserID pgtype.UUID
}
// A merge keeps the removed copy's song link: when the copy being removed was
// linked to copies on other releases and the survivor was not, the survivor
// joins that song.
func (q *Queries) MergeInheritSongLink(ctx context.Context, arg MergeInheritSongLinkParams) error {
_, err := q.db.Exec(ctx, mergeInheritSongLink, arg.SurvivorID, arg.LoserID)
return err
}
const shareSongLikes = `-- name: ShareSongLikes :many
INSERT INTO general_likes (user_id, track_id, liked_at)
SELECT l.user_id, t.id, min(l.liked_at)
FROM general_likes l
JOIN tracks lt ON lt.id = l.track_id
JOIN tracks t ON t.song_key = lt.song_key
WHERE lt.song_key = $1::uuid
GROUP BY l.user_id, t.id
ON CONFLICT (user_id, track_id) DO NOTHING
RETURNING user_id, track_id
`
type ShareSongLikesRow struct {
UserID pgtype.UUID
TrackID pgtype.UUID
}
// A like on any copy of the song becomes a like on every copy, dated the
// earliest. Returns the likes added, for the sync log.
func (q *Queries) ShareSongLikes(ctx context.Context, songKey pgtype.UUID) ([]ShareSongLikesRow, error) {
rows, err := q.db.Query(ctx, shareSongLikes, songKey)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ShareSongLikesRow
for rows.Next() {
var i ShareSongLikesRow
if err := rows.Scan(&i.UserID, &i.TrackID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const unlinkDuplicateGroupSongs = `-- name: UnlinkDuplicateGroupSongs :exec
UPDATE tracks SET song_id = NULL
WHERE song_id IS NOT NULL
AND id IN (SELECT track_id FROM duplicate_group_members WHERE group_id = $1)
`
// "Not the same song": the group's copies stop sharing a song. Likes already
// shared stay where they are.
func (q *Queries) UnlinkDuplicateGroupSongs(ctx context.Context, groupID pgtype.UUID) error {
_, err := q.db.Exec(ctx, unlinkDuplicateGroupSongs, groupID)
return err
}
+24 -8
View File
@@ -247,7 +247,7 @@ func (q *Queries) FindMissingTrackByMbid(ctx context.Context, mbid string) ([]Fi
} }
const getTrackByID = `-- name: GetTrackByID :one const getTrackByID = `-- name: GetTrackByID :one
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks WHERE id = $1 SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key FROM tracks WHERE id = $1
` `
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) { func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
@@ -274,12 +274,14 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
) )
return i, err return i, err
} }
const getTrackByPath = `-- name: GetTrackByPath :one const getTrackByPath = `-- name: GetTrackByPath :one
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks WHERE file_path = $1 SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key FROM tracks WHERE file_path = $1
` `
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) { func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
@@ -306,12 +308,14 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
) )
return i, err return i, err
} }
const getTracksByIDs = `-- name: GetTracksByIDs :many const getTracksByIDs = `-- name: GetTracksByIDs :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks WHERE id = ANY($1::uuid[]) SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key FROM tracks WHERE id = ANY($1::uuid[])
` `
// Batched lookup used by /api/library/sync to hydrate upsert payloads // Batched lookup used by /api/library/sync to hydrate upsert payloads
@@ -346,6 +350,8 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -358,7 +364,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
} }
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many const listArtistTracksForUser = `-- name: ListArtistTracksForUser :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, 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,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM tracks t FROM tracks t
@@ -419,6 +425,8 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -519,7 +527,7 @@ func (q *Queries) ListMissingTracks(ctx context.Context, arg ListMissingTracksPa
} }
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many const listRandomTracksForUser = `-- name: ListRandomTracksForUser :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, 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,
albums.title AS album_title, albums.title AS album_title,
artists.name AS artist_name artists.name AS artist_name
FROM tracks t FROM tracks t
@@ -577,6 +585,8 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
&i.Track.TagReadVersion, &i.Track.TagReadVersion,
&i.Track.MissingSince, &i.Track.MissingSince,
&i.Track.MbidSource, &i.Track.MbidSource,
&i.Track.SongID,
&i.Track.SongKey,
&i.AlbumTitle, &i.AlbumTitle,
&i.ArtistName, &i.ArtistName,
); err != nil { ); err != nil {
@@ -709,7 +719,7 @@ func (q *Queries) ListTrackPathsForReconcile(ctx context.Context) ([]ListTrackPa
} }
const listTracksByAlbum = `-- name: ListTracksByAlbum :many const listTracksByAlbum = `-- name: ListTracksByAlbum :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key FROM tracks
WHERE album_id = $1 WHERE album_id = $1
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
@@ -756,6 +766,8 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -829,7 +841,7 @@ func (q *Queries) MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int
} }
const searchTracks = `-- name: SearchTracks :many const searchTracks = `-- name: SearchTracks :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key FROM tracks
WHERE title ILIKE '%' || $1::text || '%' WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
@@ -883,6 +895,8 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -939,7 +953,7 @@ ON CONFLICT (file_path) DO UPDATE SET
-- next scan can short-circuit them again (#2499). -- next scan can short-circuit them again (#2499).
tag_read_version = EXCLUDED.tag_read_version, tag_read_version = EXCLUDED.tag_read_version,
updated_at = now() updated_at = now()
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key
` `
type UpsertTrackParams struct { type UpsertTrackParams struct {
@@ -999,6 +1013,8 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
&i.TagReadVersion, &i.TagReadVersion,
&i.MissingSince, &i.MissingSince,
&i.MbidSource, &i.MbidSource,
&i.SongID,
&i.SongKey,
) )
return i, err return i, err
} }
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS tracks_song_key_idx;
ALTER TABLE tracks DROP COLUMN IF EXISTS song_key;
ALTER TABLE tracks DROP COLUMN IF EXISTS song_id;
@@ -0,0 +1,15 @@
-- 0076_track_song_link.up.sql — the same song on several releases counts as
-- one song (Scribe milestone #498, #5438; operator decision D-b).
--
-- A single and the album it came from each hold the song, and Lidarr keeps
-- both: each fulfils its own release. Minstrel keeps both files and links
-- them, so a like on one is a like on the song, and a mix or radio picks the
-- song once rather than once per release.
--
-- song_id is set only on a linked track, to the song_key the link chose.
-- song_key is what everything reads: the track's own id until it is linked.
-- No foreign key: the track whose id names a song may itself be merged away
-- later, and the key still groups the copies left.
ALTER TABLE tracks ADD COLUMN song_id uuid;
ALTER TABLE tracks ADD COLUMN song_key uuid GENERATED ALWAYS AS (COALESCE(song_id, id)) STORED;
CREATE INDEX tracks_song_key_idx ON tracks (song_key);
+31 -9
View File
@@ -1,20 +1,42 @@
-- name: LikeTrack :execrows -- name: LikeTrack :many
-- 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.
INSERT INTO general_likes (user_id, track_id) INSERT INTO general_likes (user_id, track_id)
VALUES ($1, $2) SELECT sqlc.arg(user_id)::uuid, t.id
ON CONFLICT (user_id, track_id) DO NOTHING; FROM tracks t
WHERE t.song_key = (SELECT s.song_key FROM tracks s WHERE s.id = sqlc.arg(track_id)::uuid)
ON CONFLICT (user_id, track_id) DO NOTHING
RETURNING track_id;
-- name: UnlikeTrack :exec -- name: UnlikeTrack :many
DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2; -- Unlikes the song: every copy sharing the track's song_key. Returns the
-- tracks unliked, for the sync log.
DELETE FROM general_likes
WHERE user_id = sqlc.arg(user_id)::uuid
AND (track_id = sqlc.arg(track_id)::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 = sqlc.arg(track_id)::uuid)))
RETURNING track_id;
-- name: ListLikedTrackRows :many -- name: ListLikedTrackRows :many
-- One row per liked song: of its copies, the one present on disk, then the
-- one liked first.
SELECT t.* FROM tracks t SELECT t.* FROM tracks t
JOIN general_likes l ON l.track_id = t.id JOIN (SELECT DISTINCT ON (lt.song_key) l.track_id, l.liked_at
WHERE l.user_id = $1 FROM general_likes l
ORDER BY l.liked_at DESC 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; LIMIT $2 OFFSET $3;
-- name: CountLikedTracks :one -- name: CountLikedTracks :one
SELECT count(*) FROM general_likes WHERE user_id = $1; -- Liked songs, each counted once however many releases hold it.
SELECT count(DISTINCT t.song_key) FROM general_likes l
JOIN tracks t ON t.id = l.track_id
WHERE l.user_id = $1;
-- name: ListLikedTrackIDs :many -- name: ListLikedTrackIDs :many
SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC; SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC;
+55
View File
@@ -0,0 +1,55 @@
-- Song links (M498 #5438): copies of one song on different releases share a
-- song_key (migration 0076).
-- name: LinkTracksAsSong :one
-- Links the tracks into one song, joining any songs they already belong to.
-- The song keeps the lowest key among them, so linking the same set again
-- changes nothing. Returns that key and how many tracks moved to it.
WITH keys AS (
SELECT DISTINCT song_key FROM tracks WHERE id = ANY(sqlc.arg(track_ids)::uuid[])
), target AS (
SELECT song_key FROM keys ORDER BY song_key LIMIT 1
), moved AS (
UPDATE tracks t
SET song_id = (SELECT song_key FROM target)
WHERE t.song_key IN (SELECT song_key FROM keys)
AND t.song_key <> (SELECT song_key FROM target)
RETURNING t.id
)
SELECT (SELECT song_key FROM target)::uuid AS song_key,
(SELECT count(*) FROM moved)::bigint AS moved;
-- name: ShareSongLikes :many
-- A like on any copy of the song becomes a like on every copy, dated the
-- earliest. Returns the likes added, for the sync log.
INSERT INTO general_likes (user_id, track_id, liked_at)
SELECT l.user_id, t.id, min(l.liked_at)
FROM general_likes l
JOIN tracks lt ON lt.id = l.track_id
JOIN tracks t ON t.song_key = lt.song_key
WHERE lt.song_key = sqlc.arg(song_key)::uuid
GROUP BY l.user_id, t.id
ON CONFLICT (user_id, track_id) DO NOTHING
RETURNING user_id, track_id;
-- name: UnlinkDuplicateGroupSongs :exec
-- "Not the same song": the group's copies stop sharing a song. Likes already
-- shared stay where they are.
UPDATE tracks SET song_id = NULL
WHERE song_id IS NOT NULL
AND id IN (SELECT track_id FROM duplicate_group_members WHERE group_id = sqlc.arg(group_id));
-- name: ListTrackSongKeys :many
SELECT id, song_key FROM tracks WHERE id = ANY(sqlc.arg(ids)::uuid[]);
-- name: MergeInheritSongLink :exec
-- A merge keeps the removed copy's song link: when the copy being removed was
-- linked to copies on other releases and the survivor was not, the survivor
-- joins that song.
UPDATE tracks s
SET song_id = l.song_key
FROM tracks l
WHERE s.id = sqlc.arg(survivor_id)::uuid
AND l.id = sqlc.arg(loser_id)::uuid
AND s.song_id IS NULL
AND l.song_key <> l.id;
+3
View File
@@ -269,6 +269,9 @@ func foldTrackInto(
if err := tq.MergeInheritTrackMbid(ctx, dbq.MergeInheritTrackMbidParams{SurvivorID: survivorID, LoserID: loserID}); err != nil { if err := tq.MergeInheritTrackMbid(ctx, dbq.MergeInheritTrackMbidParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("inherit recording mbid: %w", err) return f, fmt.Errorf("inherit recording mbid: %w", err)
} }
if err := tq.MergeInheritSongLink(ctx, dbq.MergeInheritSongLinkParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
return f, fmt.Errorf("inherit song link: %w", err)
}
// Everything the loser carried now sits on the survivor, so the CASCADE // Everything the loser carried now sits on the survivor, so the CASCADE
// this delete sets off has nothing left to destroy. // this delete sets off has nothing left to destroy.
+76 -3
View File
@@ -89,6 +89,8 @@ type DuplicateResolveResult struct {
Merged int Merged int
MergeFailed int MergeFailed int
ReleaseChanges []ReleaseChange 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 // LidarrPathKey is the part of a path Minstrel and Lidarr agree on: the last
@@ -177,7 +179,18 @@ func ResolveDuplicates(
return res, err 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 return res, nil
} }
@@ -222,6 +235,66 @@ func ResolveDuplicates(
return res, nil 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 { func foldResolveGroups(rows []dbq.ListDuplicateGroupsForResolveRow) []*resolveGroup {
var groups []*resolveGroup var groups []*resolveGroup
for _, r := range rows { 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." return "Lidarr tracks every copy as its own track, so removing one would make Lidarr download it again."
} }
case ClassCrossRelease: 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: case ClassMismatch:
return "Identical audio filed under different titles: one file carries another song's tags." 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", w.logger.Info("duplicate resolve complete",
"groups", res.Groups, "lidarr", res.LidarrConsulted, "merged", res.Merged, "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)
} }
+208
View File
@@ -0,0 +1,208 @@
package library
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
// songLinkFixture is the single and the album it came from: one song on two
// releases, in one pending acoustic group, and a user who liked the single.
type songLinkFixture struct {
single, albumCut dbq.Track
groupID pgtype.UUID
user dbq.User
}
func newSongLinkFixture(t *testing.T, pool *pgxpool.Pool) songLinkFixture {
t.Helper()
ctx := context.Background()
q := dbq.New(pool)
exec := func(sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, sql, args...); err != nil {
t.Fatalf("exec %q: %v", sql, err)
}
}
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Link Artist", SortName: "Link Artist"})
if err != nil {
t.Fatal(err)
}
track := func(album, path string) dbq.Track {
t.Helper()
a, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: album, SortTitle: album, ArtistID: artist.ID})
if err != nil {
t.Fatal(err)
}
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: "Feel Good Inc.", AlbumID: a.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: path, FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatal(err)
}
return tr
}
f := songLinkFixture{
single: track("Feel Good Inc. (single)", "/music/Link Artist/Feel Good Inc/01 Feel Good Inc.mp3"),
albumCut: track("Demon Days", "/music/Link Artist/Demon Days/06 Feel Good Inc.mp3"),
}
f.user, err = q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "song-link", PasswordHash: "x", ApiTokenHash: "song-link-token",
})
if err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx,
`INSERT INTO duplicate_groups (member_key, tier) VALUES ('song-link', 'acoustic') RETURNING id`,
).Scan(&f.groupID); err != nil {
t.Fatal(err)
}
exec(`INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`, f.groupID, f.single.ID, f.albumCut.ID)
exec(`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, f.user.ID, f.single.ID)
return f
}
// A cross-release group becomes one song: its copies share a song key, the
// like on the single reaches the album cut (and is logged for sync), the Liked
// list shows the song once, a like or unlike on either copy reaches both, and
// "not the same song" undoes the link.
func TestCrossReleaseCopiesCountAsOneSong_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
f := newSongLinkFixture(t, pool)
res, err := ResolveDuplicates(ctx, pool, nil, "", nil, true)
if err != nil {
t.Fatal(err)
}
if res.Classes[ClassCrossRelease] != 1 || res.SongsLinked != 1 {
t.Fatalf("classes %v, linked %d; want one cross-release group linked", res.Classes, res.SongsLinked)
}
keys := songKeys(t, q, f.single.ID, f.albumCut.ID)
if keys[0] != keys[1] {
t.Fatalf("song keys differ after linking: %v", keys)
}
liked, err := q.ListLikedTrackIDs(ctx, f.user.ID)
if err != nil {
t.Fatal(err)
}
if len(liked) != 2 {
t.Errorf("liked track ids = %d, want both copies", len(liked))
}
var logged int
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM library_changes WHERE entity_type = 'like_track' AND op = 'upsert'`,
).Scan(&logged); err != nil {
t.Fatal(err)
}
if logged != 1 {
t.Errorf("logged %d shared likes, want 1 (the album cut)", logged)
}
if n, err := q.CountLikedTracks(ctx, f.user.ID); err != nil || n != 1 {
t.Errorf("CountLikedTracks = %d (%v), want 1 song", n, err)
}
rows, err := q.ListLikedTrackRows(ctx, dbq.ListLikedTrackRowsParams{UserID: f.user.ID, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Errorf("Liked list has %d rows, want the song once", len(rows))
}
// A second pass changes nothing.
if res, err := ResolveDuplicates(ctx, pool, nil, "", nil, true); err != nil || res.SongsLinked != 0 {
t.Errorf("second pass linked %d (%v), want 0", res.SongsLinked, err)
}
unliked, err := q.UnlikeTrack(ctx, dbq.UnlikeTrackParams{UserID: f.user.ID, TrackID: f.albumCut.ID})
if err != nil {
t.Fatal(err)
}
if len(unliked) != 2 {
t.Errorf("unlike removed %d likes, want both copies", len(unliked))
}
added, err := q.LikeTrack(ctx, dbq.LikeTrackParams{UserID: f.user.ID, TrackID: f.single.ID})
if err != nil {
t.Fatal(err)
}
if len(added) != 2 {
t.Errorf("like added %d, want both copies", len(added))
}
if again, err := q.LikeTrack(ctx, dbq.LikeTrackParams{UserID: f.user.ID, TrackID: f.single.ID}); err != nil || len(again) != 0 {
t.Errorf("repeat like added %d (%v), want 0", len(again), err)
}
if err := q.UnlinkDuplicateGroupSongs(ctx, f.groupID); err != nil {
t.Fatal(err)
}
keys = songKeys(t, q, f.single.ID, f.albumCut.ID)
if keys[0] == keys[1] {
t.Error("song keys still shared after unlinking")
}
}
// Without the operator's permission the resolver links nothing.
func TestCrossReleaseLinkNeedsAutoResolve_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
f := newSongLinkFixture(t, pool)
if _, err := ResolveDuplicates(ctx, pool, nil, "", nil, false); err != nil {
t.Fatal(err)
}
keys := songKeys(t, q, f.single.ID, f.albumCut.ID)
if keys[0] == keys[1] {
t.Error("linked with auto-resolve off")
}
}
// A merge keeps the removed copy's link to the song on another release.
func TestMergeInheritsSongLink_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
f := newSongLinkFixture(t, pool)
if _, err := q.LinkTracksAsSong(ctx, []pgtype.UUID{f.single.ID, f.albumCut.ID}); err != nil {
t.Fatal(err)
}
// A third, unlinked copy of the album cut stands in for a survivor.
other, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: "Feel Good Inc.", AlbumID: f.albumCut.AlbumID, ArtistID: f.albumCut.ArtistID,
DurationMs: 1000, FilePath: "/music/Link Artist/Demon Days/06 Feel Good Inc (1).mp3", FileSize: 90, FileFormat: "mp3",
})
if err != nil {
t.Fatal(err)
}
if err := q.MergeInheritSongLink(ctx, dbq.MergeInheritSongLinkParams{SurvivorID: other.ID, LoserID: f.albumCut.ID}); err != nil {
t.Fatal(err)
}
keys := songKeys(t, q, other.ID, f.single.ID)
if keys[0] != keys[1] {
t.Error("survivor did not join the song")
}
}
func songKeys(t *testing.T, q *dbq.Queries, ids ...pgtype.UUID) []pgtype.UUID {
t.Helper()
rows, err := q.ListTrackSongKeys(context.Background(), ids)
if err != nil {
t.Fatal(err)
}
byID := map[pgtype.UUID]pgtype.UUID{}
for _, r := range rows {
byID[r.ID] = r.SongKey
}
out := make([]pgtype.UUID, len(ids))
for i, id := range ids {
out[i] = byID[id]
}
return out
}
+32
View File
@@ -1070,6 +1070,10 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
return pgtype.UUID{}, fmt.Errorf("insert playlist row: %w", err) return pgtype.UUID{}, fmt.Errorf("insert playlist row: %w", err)
} }
tracks, err = oneCopyPerSong(ctx, qtx, tracks)
if err != nil {
return pgtype.UUID{}, err
}
for _, t := range tracks { for _, t := range tracks {
var pickKind *string var pickKind *string
if t.PickKind != "" { if t.PickKind != "" {
@@ -1096,6 +1100,34 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
return p.ID, nil return p.ID, nil
} }
// oneCopyPerSong keeps the first of a song's copies on different releases
// (M498 #5438): a mix holds the song once, in its best-ranked place.
func oneCopyPerSong(ctx context.Context, qtx *dbq.Queries, tracks []rankedCandidate) ([]rankedCandidate, error) {
ids := make([]pgtype.UUID, len(tracks))
for i, t := range tracks {
ids[i] = t.TrackID
}
rows, err := qtx.ListTrackSongKeys(ctx, ids)
if err != nil {
return nil, fmt.Errorf("song keys: %w", err)
}
songOf := make(map[pgtype.UUID]pgtype.UUID, len(rows))
for _, r := range rows {
songOf[r.ID] = r.SongKey
}
seen := make(map[pgtype.UUID]bool, len(tracks))
out := tracks[:0:0]
for _, t := range tracks {
song, ok := songOf[t.TrackID]
if ok && seen[song] {
continue
}
seen[song] = true
out = append(out, t)
}
return out, nil
}
// uuidStringPL renders a pgtype.UUID as the canonical 8-4-4-4-12 form. // uuidStringPL renders a pgtype.UUID as the canonical 8-4-4-4-12 form.
// (pgtype.UUID's String method exists on some pgx versions but not others; // (pgtype.UUID's String method exists on some pgx versions but not others;
// also the playlists package needs this independent of test helpers.) // also the playlists package needs this independent of test helpers.)
+18 -2
View File
@@ -104,11 +104,24 @@ func Shuffle(
deferred := make([]Candidate, 0, len(scored)-limit) deferred := make([]Candidate, 0, len(scored)-limit)
artistCount := map[pgtype.UUID]int{} artistCount := map[pgtype.UUID]int{}
albumCount := map[pgtype.UUID]int{} albumCount := map[pgtype.UUID]int{}
// One song on several releases is played once (M498 #5438): after its
// best-scoring copy, the others are dropped, not held back.
songs := map[pgtype.UUID]bool{}
repeat := func(c Candidate) bool { return c.Track.SongKey.Valid && songs[c.Track.SongKey] }
take := func(c Candidate) {
if c.Track.SongKey.Valid {
songs[c.Track.SongKey] = true
}
out = append(out, c)
}
for _, s := range scored { for _, s := range scored {
if len(out) == limit { if len(out) == limit {
break break
} }
if repeat(s.c) {
continue
}
overArtist := caps.MaxPerArtist > 0 && artistCount[s.c.Track.ArtistID] >= caps.MaxPerArtist overArtist := caps.MaxPerArtist > 0 && artistCount[s.c.Track.ArtistID] >= caps.MaxPerArtist
overAlbum := caps.MaxPerAlbum > 0 && albumCount[s.c.Track.AlbumID] >= caps.MaxPerAlbum overAlbum := caps.MaxPerAlbum > 0 && albumCount[s.c.Track.AlbumID] >= caps.MaxPerAlbum
if overArtist || overAlbum { if overArtist || overAlbum {
@@ -118,7 +131,7 @@ func Shuffle(
} }
artistCount[s.c.Track.ArtistID]++ artistCount[s.c.Track.ArtistID]++
albumCount[s.c.Track.AlbumID]++ albumCount[s.c.Track.AlbumID]++
out = append(out, s.c) take(s.c)
} }
// Pass two: the caps could not fill the request, so relax them rather // Pass two: the caps could not fill the request, so relax them rather
@@ -128,7 +141,10 @@ func Shuffle(
if len(out) == limit { if len(out) == limit {
break break
} }
out = append(out, c) if repeat(c) {
continue
}
take(c)
} }
return out return out
} }
+27
View File
@@ -84,3 +84,30 @@ func titles(cs []Candidate) []string {
// pure tests; the Track.Title field is the human-readable handle. Track ID // pure tests; the Track.Title field is the human-readable handle. Track ID
// is left as zero pgtype.UUID and never compared. // is left as zero pgtype.UUID and never compared.
var _ pgtype.UUID var _ pgtype.UUID
// One song on two releases is played once, in its better-scoring copy's place,
// and a capped copy held back for pass two does not slip back in either.
func TestShuffle_OneCopyPerSong(t *testing.T) {
song := func(c Candidate, key string) Candidate {
_ = c.Track.SongKey.Scan("00000000-0000-0000-0000-" + key)
return c
}
album := pgtype.UUID{Bytes: [16]byte{9}, Valid: true}
single := song(cand("000000000001", ScoringInputs{IsGeneralLiked: true}), "00000000000a")
albumCut := song(cand("000000000002", ScoringInputs{}), "00000000000a")
other := cand("000000000003", ScoringInputs{})
single.Track.AlbumID, albumCut.Track.AlbumID = album, pgtype.UUID{Bytes: [16]byte{8}, Valid: true}
out := Shuffle([]Candidate{albumCut, single, other}, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
if got := titles(out); len(got) != 2 || got[0] != "000000000001" {
t.Errorf("got %v, want the liked single once and the other song", got)
}
// The single is capped out of pass one; pass two takes one copy, not both.
capped := cand("000000000004", ScoringInputs{IsGeneralLiked: true})
capped.Track.AlbumID = album
out = Shuffle([]Candidate{capped, single, albumCut}, defaultWeights(), time.Now(), fixedRNG(0.5), 3, DiversityCaps{MaxPerAlbum: 1})
if len(out) != 2 {
t.Errorf("got %v, want two tracks: one copy of the song", titles(out))
}
}
+5 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"slices"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@@ -39,12 +40,13 @@ func (m *mediaHandlers) handleStar(w http.ResponseWriter, r *http.Request) {
return return
} }
if trackID.Valid { if trackID.Valid {
rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}) // Stars the song: every copy of it (M498 #5438).
liked, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID})
if err != nil { if err != nil {
WriteFail(w, r, ErrGeneric, "Could not star track") WriteFail(w, r, ErrGeneric, "Could not star track")
return return
} }
if rows == 1 { if slices.Contains(liked, trackID) {
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, trackID, m.logger) _ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, trackID, m.logger)
} }
} }
@@ -75,7 +77,7 @@ func (m *mediaHandlers) handleUnstar(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query() params := r.URL.Query()
q := dbq.New(m.pool) q := dbq.New(m.pool)
if t, ok := parseUUID(params.Get("id")); ok { if t, ok := parseUUID(params.Get("id")); ok {
_ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t}) _, _ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t})
_ = playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, t) _ = playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, t)
} }
if a, ok := parseUUID(params.Get("albumId")); ok { if a, ok := parseUUID(params.Get("albumId")); ok {
+5 -3
View File
@@ -30,7 +30,8 @@
// purpose. A third tab lists what the resolver did. // purpose. A third tab lists what the resolver did.
// //
// Dismissing a group says "these are not duplicates", and the sweep will not // Dismissing a group says "these are not duplicates", and the sweep will not
// propose that set again. Merging (#3911) keeps one copy, moves the others' // propose that set again. On the cross-release tab it reads "Not the same
// song" and also undoes the link that made the copies one song (#5438). Merging (#3911) keeps one copy, moves the others'
// likes, plays and playlist entries onto it, and deletes their files, so it // likes, plays and playlist entries onto it, and deletes their files, so it
// asks twice. The server refuses to remove a copy Lidarr uses. // asks twice. The server refuses to remove a copy Lidarr uses.
@@ -337,7 +338,8 @@
<p class="mt-3 text-text-primary">No songs repeated across releases.</p> <p class="mt-3 text-text-primary">No songs repeated across releases.</p>
<p class="mt-1 text-sm text-text-secondary"> <p class="mt-1 text-sm text-text-secondary">
A single and the album it's on, or two editions of one album, show here. Each copy A single and the album it's on, or two editions of one album, show here. Each copy
belongs to its own release, so none is removed. belongs to its own release, so none is removed. They count as one song: a like on one
is a like on all, and a mix plays it once.
</p> </p>
{:else} {:else}
<p class="mt-3 text-text-primary">Nothing needs review.</p> <p class="mt-3 text-text-primary">Nothing needs review.</p>
@@ -366,7 +368,7 @@
disabled={dismissing === group.id || merging === group.id} disabled={dismissing === group.id || merging === group.id}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50" class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
> >
Not duplicates {tab === 'cross_release' ? 'Not the same song' : 'Not duplicates'}
</button> </button>
<button <button
type="button" type="button"
@@ -323,6 +323,15 @@ describe('admin duplicates', () => {
expect(createDuplicatesQuery).toHaveBeenLastCalledWith('cross_release'); expect(createDuplicatesQuery).toHaveBeenLastCalledWith('cross_release');
}); });
// Copies across releases are linked as one song; dismissing one says they
// are not, which is what the button names.
test('Across releases dismisses as Not the same song', async () => {
renderWith(response());
await fireEvent.click(screen.getByRole('tab', { name: /across releases/i }));
await fireEvent.click(screen.getByRole('button', { name: 'Not the same song' }));
expect(dismissDuplicateGroup).toHaveBeenCalledWith('g-1');
});
test('Resolved automatically lists merges and release changes', async () => { test('Resolved automatically lists merges and release changes', async () => {
renderWith(response(), [ renderWith(response(), [
{ {