diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index 04ce65e6..ff5aa098 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -72,7 +72,7 @@ func (q *Queries) DeleteAlbumIfEmpty(ctx context.Context, id pgtype.UUID) (Delet } const getAlbumByArtistAndTitle = `-- name: GetAlbumByArtistAndTitle :one -SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1 +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version, release_group_mbid FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1 ` type GetAlbumByArtistAndTitleParams struct { @@ -96,12 +96,13 @@ func (q *Queries) GetAlbumByArtistAndTitle(ctx context.Context, arg GetAlbumByAr &i.UpdatedAt, &i.CoverArtSource, &i.CoverArtSourcesVersion, + &i.ReleaseGroupMbid, ) return i, err } const getAlbumByID = `-- name: GetAlbumByID :one -SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE id = $1 +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version, release_group_mbid FROM albums WHERE id = $1 ` func (q *Queries) GetAlbumByID(ctx context.Context, id pgtype.UUID) (Album, error) { @@ -119,6 +120,7 @@ func (q *Queries) GetAlbumByID(ctx context.Context, id pgtype.UUID) (Album, erro &i.UpdatedAt, &i.CoverArtSource, &i.CoverArtSourcesVersion, + &i.ReleaseGroupMbid, ) return i, err } @@ -172,7 +174,7 @@ func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageR } const getAlbumWithArtist = `-- name: GetAlbumWithArtist :one -SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid, artists.name AS artist_name FROM albums JOIN artists ON artists.id = albums.artist_id WHERE albums.id = $1 @@ -201,13 +203,14 @@ func (q *Queries) GetAlbumWithArtist(ctx context.Context, id pgtype.UUID) (GetAl &i.Album.UpdatedAt, &i.Album.CoverArtSource, &i.Album.CoverArtSourcesVersion, + &i.Album.ReleaseGroupMbid, &i.ArtistName, ) return i, err } const getAlbumsByIDs = `-- name: GetAlbumsByIDs :many -SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE id = ANY($1::uuid[]) +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version, release_group_mbid FROM albums WHERE id = ANY($1::uuid[]) ` // Batched lookup used by /api/library/sync to hydrate upsert payloads @@ -233,6 +236,7 @@ func (q *Queries) GetAlbumsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([ &i.UpdatedAt, &i.CoverArtSource, &i.CoverArtSourcesVersion, + &i.ReleaseGroupMbid, ); err != nil { return nil, err } @@ -245,7 +249,7 @@ func (q *Queries) GetAlbumsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([ } const listAlbumsAlphaByArtist = `-- name: ListAlbumsAlphaByArtist :many -SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.sort_name AS artist_sort_name +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid, artists.sort_name AS artist_sort_name FROM albums JOIN artists ON artists.id = albums.artist_id ORDER BY artists.sort_name, albums.sort_title @@ -285,6 +289,7 @@ func (q *Queries) ListAlbumsAlphaByArtist(ctx context.Context, arg ListAlbumsAlp &i.Album.UpdatedAt, &i.Album.CoverArtSource, &i.Album.CoverArtSourcesVersion, + &i.Album.ReleaseGroupMbid, &i.ArtistSortName, ); err != nil { return nil, err @@ -298,7 +303,7 @@ func (q *Queries) ListAlbumsAlphaByArtist(ctx context.Context, arg ListAlbumsAlp } const listAlbumsAlphaByName = `-- name: ListAlbumsAlphaByName :many -SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums ORDER BY sort_title LIMIT $1 OFFSET $2 +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version, release_group_mbid FROM albums ORDER BY sort_title LIMIT $1 OFFSET $2 ` type ListAlbumsAlphaByNameParams struct { @@ -327,6 +332,7 @@ func (q *Queries) ListAlbumsAlphaByName(ctx context.Context, arg ListAlbumsAlpha &i.UpdatedAt, &i.CoverArtSource, &i.CoverArtSourcesVersion, + &i.ReleaseGroupMbid, ); err != nil { return nil, err } @@ -339,7 +345,7 @@ func (q *Queries) ListAlbumsAlphaByName(ctx context.Context, arg ListAlbumsAlpha } const listAlbumsAlphaWithArtist = `-- name: ListAlbumsAlphaWithArtist :many -SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid, artists.name AS artist_name FROM albums JOIN artists ON artists.id = albums.artist_id ORDER BY albums.sort_title, albums.id @@ -379,6 +385,7 @@ func (q *Queries) ListAlbumsAlphaWithArtist(ctx context.Context, arg ListAlbumsA &i.Album.UpdatedAt, &i.Album.CoverArtSource, &i.Album.CoverArtSourcesVersion, + &i.Album.ReleaseGroupMbid, &i.ArtistName, ); err != nil { return nil, err @@ -392,7 +399,7 @@ func (q *Queries) ListAlbumsAlphaWithArtist(ctx context.Context, arg ListAlbumsA } const listAlbumsByArtist = `-- name: ListAlbumsByArtist :many -SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE artist_id = $1 ORDER BY release_date NULLS LAST, sort_title +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version, release_group_mbid FROM albums WHERE artist_id = $1 ORDER BY release_date NULLS LAST, sort_title ` func (q *Queries) ListAlbumsByArtist(ctx context.Context, artistID pgtype.UUID) ([]Album, error) { @@ -416,6 +423,7 @@ func (q *Queries) ListAlbumsByArtist(ctx context.Context, artistID pgtype.UUID) &i.UpdatedAt, &i.CoverArtSource, &i.CoverArtSourcesVersion, + &i.ReleaseGroupMbid, ); err != nil { return nil, err } @@ -428,7 +436,7 @@ func (q *Queries) ListAlbumsByArtist(ctx context.Context, artistID pgtype.UUID) } const listAlbumsByArtistWithTrackCount = `-- name: ListAlbumsByArtistWithTrackCount :many -SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid, (SELECT count(*) FROM tracks t WHERE t.album_id = albums.id)::bigint AS track_count FROM albums @@ -465,6 +473,7 @@ func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID &i.Album.UpdatedAt, &i.Album.CoverArtSource, &i.Album.CoverArtSourcesVersion, + &i.Album.ReleaseGroupMbid, &i.TrackCount, ); err != nil { return nil, err @@ -478,7 +487,7 @@ func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID } const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many -SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid FROM albums WHERE EXISTS ( SELECT 1 @@ -531,6 +540,7 @@ func (q *Queries) ListAlbumsByGenre(ctx context.Context, arg ListAlbumsByGenrePa &i.UpdatedAt, &i.CoverArtSource, &i.CoverArtSourcesVersion, + &i.ReleaseGroupMbid, ); err != nil { return nil, err } @@ -599,7 +609,7 @@ func (q *Queries) ListAlbumsMissingMbidWithTrack(ctx context.Context, limit int3 } const listAlbumsNewest = `-- name: ListAlbumsNewest :many -SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums ORDER BY created_at DESC LIMIT $1 OFFSET $2 +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version, release_group_mbid FROM albums ORDER BY created_at DESC LIMIT $1 OFFSET $2 ` type ListAlbumsNewestParams struct { @@ -628,6 +638,7 @@ func (q *Queries) ListAlbumsNewest(ctx context.Context, arg ListAlbumsNewestPara &i.UpdatedAt, &i.CoverArtSource, &i.CoverArtSourcesVersion, + &i.ReleaseGroupMbid, ); err != nil { return nil, err } @@ -640,7 +651,7 @@ func (q *Queries) ListAlbumsNewest(ctx context.Context, arg ListAlbumsNewestPara } const listAlbumsRandom = `-- name: ListAlbumsRandom :many -SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums ORDER BY random() LIMIT $1 +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version, release_group_mbid FROM albums ORDER BY random() LIMIT $1 ` func (q *Queries) ListAlbumsRandom(ctx context.Context, limit int32) ([]Album, error) { @@ -664,6 +675,7 @@ func (q *Queries) ListAlbumsRandom(ctx context.Context, limit int32) ([]Album, e &i.UpdatedAt, &i.CoverArtSource, &i.CoverArtSourcesVersion, + &i.ReleaseGroupMbid, ); err != nil { return nil, err } @@ -676,7 +688,7 @@ func (q *Queries) ListAlbumsRandom(ctx context.Context, limit int32) ([]Album, e } const listRecentlyAddedAlbumsWithArtist = `-- name: ListRecentlyAddedAlbumsWithArtist :many -SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid, artists.name AS artist_name FROM albums JOIN artists ON artists.id = albums.artist_id ORDER BY albums.created_at DESC, albums.id @@ -713,6 +725,7 @@ func (q *Queries) ListRecentlyAddedAlbumsWithArtist(ctx context.Context, limit i &i.Album.UpdatedAt, &i.Album.CoverArtSource, &i.Album.CoverArtSourcesVersion, + &i.Album.ReleaseGroupMbid, &i.ArtistName, ); err != nil { return nil, err @@ -726,7 +739,7 @@ func (q *Queries) ListRecentlyAddedAlbumsWithArtist(ctx context.Context, limit i } const searchAlbums = `-- name: SearchAlbums :many -SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version, release_group_mbid FROM albums WHERE title ILIKE '%' || $1 || '%' ORDER BY sort_title LIMIT $2 OFFSET $3 @@ -759,6 +772,7 @@ func (q *Queries) SearchAlbums(ctx context.Context, arg SearchAlbumsParams) ([]A &i.UpdatedAt, &i.CoverArtSource, &i.CoverArtSourcesVersion, + &i.ReleaseGroupMbid, ); err != nil { return nil, err } @@ -818,9 +832,29 @@ func (q *Queries) SetAlbumMbidIfNull(ctx context.Context, arg SetAlbumMbidIfNull return err } +const setAlbumReleaseGroupMbidIfNull = `-- name: SetAlbumReleaseGroupMbidIfNull :exec +UPDATE albums + SET release_group_mbid = $2, + updated_at = now() + WHERE id = $1 AND release_group_mbid IS NULL +` + +type SetAlbumReleaseGroupMbidIfNullParams struct { + ID pgtype.UUID + ReleaseGroupMbid *string +} + +// #5241: heal the release-group id on an existing album row, from a rescan's +// tags or from the re-acquisition sweeper's MusicBrainz lookup. Only fills a +// NULL, so a tag never gets overwritten by a later guess or vice versa. +func (q *Queries) SetAlbumReleaseGroupMbidIfNull(ctx context.Context, arg SetAlbumReleaseGroupMbidIfNullParams) error { + _, err := q.db.Exec(ctx, setAlbumReleaseGroupMbidIfNull, arg.ID, arg.ReleaseGroupMbid) + return err +} + const upsertAlbum = `-- name: UpsertAlbum :one -INSERT INTO albums (title, sort_title, artist_id, release_date, mbid, cover_art_path) -VALUES ($1, $2, $3, $4, $5, $6) +INSERT INTO albums (title, sort_title, artist_id, release_date, mbid, cover_art_path, release_group_mbid) +VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (mbid) WHERE mbid IS NOT NULL DO UPDATE SET title = EXCLUDED.title, @@ -828,17 +862,19 @@ DO UPDATE SET artist_id = EXCLUDED.artist_id, release_date = EXCLUDED.release_date, cover_art_path = EXCLUDED.cover_art_path, + release_group_mbid = COALESCE(EXCLUDED.release_group_mbid, albums.release_group_mbid), updated_at = now() -RETURNING id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version +RETURNING id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version, release_group_mbid ` type UpsertAlbumParams struct { - Title string - SortTitle string - ArtistID pgtype.UUID - ReleaseDate pgtype.Date - Mbid *string - CoverArtPath *string + Title string + SortTitle string + ArtistID pgtype.UUID + ReleaseDate pgtype.Date + Mbid *string + CoverArtPath *string + ReleaseGroupMbid *string } func (q *Queries) UpsertAlbum(ctx context.Context, arg UpsertAlbumParams) (Album, error) { @@ -849,6 +885,7 @@ func (q *Queries) UpsertAlbum(ctx context.Context, arg UpsertAlbumParams) (Album arg.ReleaseDate, arg.Mbid, arg.CoverArtPath, + arg.ReleaseGroupMbid, ) var i Album err := row.Scan( @@ -863,6 +900,7 @@ func (q *Queries) UpsertAlbum(ctx context.Context, arg UpsertAlbumParams) (Album &i.UpdatedAt, &i.CoverArtSource, &i.CoverArtSourcesVersion, + &i.ReleaseGroupMbid, ) return i, err } diff --git a/internal/db/dbq/browse.sql.go b/internal/db/dbq/browse.sql.go index 3b22be31..0477431f 100644 --- a/internal/db/dbq/browse.sql.go +++ b/internal/db/dbq/browse.sql.go @@ -100,7 +100,7 @@ func (q *Queries) ListAlbumYearsWithCount(ctx context.Context) ([]ListAlbumYears } const listAlbumsByGenreWithArtist = `-- name: ListAlbumsByGenreWithArtist :many -SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid, artists.name AS artist_name FROM albums JOIN artists ON artists.id = albums.artist_id WHERE EXISTS ( @@ -152,6 +152,7 @@ func (q *Queries) ListAlbumsByGenreWithArtist(ctx context.Context, arg ListAlbum &i.Album.UpdatedAt, &i.Album.CoverArtSource, &i.Album.CoverArtSourcesVersion, + &i.Album.ReleaseGroupMbid, &i.ArtistName, ); err != nil { return nil, err @@ -165,7 +166,7 @@ func (q *Queries) ListAlbumsByGenreWithArtist(ctx context.Context, arg ListAlbum } const listAlbumsByYearRangeWithArtist = `-- name: ListAlbumsByYearRangeWithArtist :many -SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid, artists.name AS artist_name FROM albums JOIN artists ON artists.id = albums.artist_id WHERE albums.release_date IS NOT NULL @@ -219,6 +220,7 @@ func (q *Queries) ListAlbumsByYearRangeWithArtist(ctx context.Context, arg ListA &i.Album.UpdatedAt, &i.Album.CoverArtSource, &i.Album.CoverArtSourcesVersion, + &i.Album.ReleaseGroupMbid, &i.ArtistName, ); err != nil { return nil, err diff --git a/internal/db/dbq/likes.sql.go b/internal/db/dbq/likes.sql.go index 047d9c50..b85e1d49 100644 --- a/internal/db/dbq/likes.sql.go +++ b/internal/db/dbq/likes.sql.go @@ -120,7 +120,7 @@ func (q *Queries) ListLikedAlbumIDs(ctx context.Context, userID pgtype.UUID) ([] } const listLikedAlbumRows = `-- name: ListLikedAlbumRows :many -SELECT a.id, a.title, a.sort_title, a.artist_id, a.release_date, a.mbid, a.cover_art_path, a.created_at, a.updated_at, a.cover_art_source, a.cover_art_sources_version FROM albums a +SELECT a.id, a.title, a.sort_title, a.artist_id, a.release_date, a.mbid, a.cover_art_path, a.created_at, a.updated_at, a.cover_art_source, a.cover_art_sources_version, a.release_group_mbid FROM albums a JOIN general_likes_albums l ON l.album_id = a.id WHERE l.user_id = $1 ORDER BY l.liked_at DESC @@ -154,6 +154,7 @@ func (q *Queries) ListLikedAlbumRows(ctx context.Context, arg ListLikedAlbumRows &i.UpdatedAt, &i.CoverArtSource, &i.CoverArtSourcesVersion, + &i.ReleaseGroupMbid, ); err != nil { return nil, err } diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 4034fb2c..c14ff1a3 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -207,6 +207,7 @@ type Album struct { UpdatedAt pgtype.Timestamptz CoverArtSource *string CoverArtSourcesVersion int32 + ReleaseGroupMbid *string } type AlbumLoudness struct { diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index a1e9a59e..9ddd88b2 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -370,7 +370,7 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP } const listRediscoverAlbumsFallbackForUser = `-- name: ListRediscoverAlbumsFallbackForUser :many -SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid, artists.name AS artist_name FROM general_likes_albums gla JOIN albums ON albums.id = gla.album_id JOIN artists ON artists.id = albums.artist_id @@ -418,6 +418,7 @@ func (q *Queries) ListRediscoverAlbumsFallbackForUser(ctx context.Context, arg L &i.Album.UpdatedAt, &i.Album.CoverArtSource, &i.Album.CoverArtSourcesVersion, + &i.Album.ReleaseGroupMbid, &i.ArtistName, ); err != nil { return nil, err @@ -459,7 +460,7 @@ eligible AS ( HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz) < now() - interval '14 days' ) -SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid, artists.name AS artist_name FROM eligible e JOIN albums ON albums.id = e.album_id JOIN artists ON artists.id = albums.artist_id @@ -510,6 +511,7 @@ func (q *Queries) ListRediscoverAlbumsForUser(ctx context.Context, arg ListRedis &i.Album.UpdatedAt, &i.Album.CoverArtSource, &i.Album.CoverArtSourcesVersion, + &i.Album.ReleaseGroupMbid, &i.ArtistName, ); err != nil { return nil, err diff --git a/internal/db/dbq/you_might_like.sql.go b/internal/db/dbq/you_might_like.sql.go index ed4027f1..066e01f6 100644 --- a/internal/db/dbq/you_might_like.sql.go +++ b/internal/db/dbq/you_might_like.sql.go @@ -102,7 +102,7 @@ WITH liked_album_ids AS ( JOIN tracks trk ON trk.id = likt.track_id WHERE likt.user_id = $1 ) -SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid, artists.name AS artist_name FROM liked_album_ids lai JOIN albums ON albums.id = lai.album_id JOIN artists ON artists.id = albums.artist_id @@ -143,6 +143,7 @@ func (q *Queries) ListYouMightLikeAlbumFallbackForUser(ctx context.Context, arg &i.Album.UpdatedAt, &i.Album.CoverArtSource, &i.Album.CoverArtSourcesVersion, + &i.Album.ReleaseGroupMbid, &i.ArtistName, ); err != nil { return nil, err @@ -156,7 +157,7 @@ func (q *Queries) ListYouMightLikeAlbumFallbackForUser(ctx context.Context, arg } const listYouMightLikeAlbumsForUser = `-- name: ListYouMightLikeAlbumsForUser :many -SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid, artists.name AS artist_name FROM you_might_like_albums yml JOIN albums ON albums.id = yml.album_id JOIN artists ON artists.id = albums.artist_id @@ -201,6 +202,7 @@ func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYou &i.Album.UpdatedAt, &i.Album.CoverArtSource, &i.Album.CoverArtSourcesVersion, + &i.Album.ReleaseGroupMbid, &i.ArtistName, ); err != nil { return nil, err diff --git a/internal/db/migrations/0070_album_release_group_mbid.down.sql b/internal/db/migrations/0070_album_release_group_mbid.down.sql new file mode 100644 index 00000000..1deb2957 --- /dev/null +++ b/internal/db/migrations/0070_album_release_group_mbid.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS albums_release_group_mbid_idx; +ALTER TABLE albums DROP COLUMN IF EXISTS release_group_mbid; diff --git a/internal/db/migrations/0070_album_release_group_mbid.up.sql b/internal/db/migrations/0070_album_release_group_mbid.up.sql new file mode 100644 index 00000000..08a85f2e --- /dev/null +++ b/internal/db/migrations/0070_album_release_group_mbid.up.sql @@ -0,0 +1,10 @@ +-- MusicBrainz release-group id for an album (#5241). albums.mbid holds the +-- *release* id (Picard's musicbrainz_albumid: one edition), but Lidarr names +-- albums by release group, so re-acquisition and request completion need +-- this one. Not unique: several releases of one group can each be an album +-- row. Filled by the scanner from musicbrainz_releasegroupid, or by the +-- re-acquisition sweeper resolving the release through MusicBrainz. +ALTER TABLE albums ADD COLUMN release_group_mbid text; + +CREATE INDEX albums_release_group_mbid_idx ON albums (release_group_mbid) + WHERE release_group_mbid IS NOT NULL; diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql index c9b88fa9..1a7ec259 100644 --- a/internal/db/queries/albums.sql +++ b/internal/db/queries/albums.sql @@ -1,6 +1,6 @@ -- name: UpsertAlbum :one -INSERT INTO albums (title, sort_title, artist_id, release_date, mbid, cover_art_path) -VALUES ($1, $2, $3, $4, $5, $6) +INSERT INTO albums (title, sort_title, artist_id, release_date, mbid, cover_art_path, release_group_mbid) +VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (mbid) WHERE mbid IS NOT NULL DO UPDATE SET title = EXCLUDED.title, @@ -8,6 +8,7 @@ DO UPDATE SET artist_id = EXCLUDED.artist_id, release_date = EXCLUDED.release_date, cover_art_path = EXCLUDED.cover_art_path, + release_group_mbid = COALESCE(EXCLUDED.release_group_mbid, albums.release_group_mbid), updated_at = now() RETURNING *; @@ -142,6 +143,15 @@ UPDATE albums updated_at = now() WHERE id = $1 AND mbid IS NULL; +-- name: SetAlbumReleaseGroupMbidIfNull :exec +-- #5241: heal the release-group id on an existing album row, from a rescan's +-- tags or from the re-acquisition sweeper's MusicBrainz lookup. Only fills a +-- NULL, so a tag never gets overwritten by a later guess or vice versa. +UPDATE albums + SET release_group_mbid = $2, + updated_at = now() + WHERE id = $1 AND release_group_mbid IS NULL; + -- name: ListAlbumsMissingMbidWithTrack :many -- One-shot MBID backfill: returns each album where mbid IS NULL alongside -- one of its tracks' file_path so the worker can re-read tags. LIMIT diff --git a/internal/library/mbids.go b/internal/library/mbids.go index 3287b012..e63550b7 100644 --- a/internal/library/mbids.go +++ b/internal/library/mbids.go @@ -41,6 +41,16 @@ func extractRecordingMBID(m tag.Metadata) string { return cleanMBID(mbz.Extract(m).Get(mbz.Recording)) } +// extractReleaseGroupMBID reads the MusicBrainz *release-group* ID — +// Picard's musicbrainz_releasegroupid, surfaced by dhowden/tag as +// mbz.ReleaseGroup. mbz.Album (what albums.mbid stores) is the release: one +// edition. Lidarr names albums by release group, so re-acquisition and +// request completion key on this one (#5241). Separate from extractMBIDs for +// the same reason extractRecordingMBID is. +func extractReleaseGroupMBID(m tag.Metadata) string { + return cleanMBID(mbz.Extract(m).Get(mbz.ReleaseGroup)) +} + func cleanMBID(s string) string { if s == "" { return "" diff --git a/internal/library/mbids_test.go b/internal/library/mbids_test.go index ccafeb32..db5225dc 100644 --- a/internal/library/mbids_test.go +++ b/internal/library/mbids_test.go @@ -163,3 +163,40 @@ func TestCleanMBID_OnlyNULReturnsEmpty(t *testing.T) { t.Errorf("got %q, want empty", got) } } + +// #5241: the release-group id is read beside the release id, from each +// format's own key, and the two are never confused. +func TestExtractReleaseGroupMBID(t *testing.T) { + cases := []struct { + name string + meta stubMeta + }{ + {"vorbis", stubMeta{format: tag.VORBIS, raw: map[string]interface{}{ + "musicbrainz_albumid": "release-1", + "musicbrainz_releasegroupid": "group-1", + }}}, + {"id3v2", stubMeta{format: tag.ID3v2_4, raw: map[string]interface{}{ + "TXXX": &tag.Comm{Description: "MusicBrainz Album Id", Text: "release-1"}, + "TXXX_0": &tag.Comm{Description: "MusicBrainz Release Group Id", Text: "group-1\x00"}, + }}}, + {"mp4", stubMeta{format: tag.MP4, raw: map[string]interface{}{ + "MusicBrainz Album Id": "release-1", + "MusicBrainz Release Group Id": "group-1", + }}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := extractReleaseGroupMBID(c.meta); got != "group-1" { + t.Errorf("release group = %q, want group-1", got) + } + if album, _ := extractMBIDs(c.meta); album != "release-1" { + t.Errorf("album (release) = %q, want release-1", album) + } + }) + } + + none := stubMeta{format: tag.VORBIS, raw: map[string]interface{}{"musicbrainz_albumid": "release-1"}} + if got := extractReleaseGroupMBID(none); got != "" { + t.Errorf("untagged release group = %q, want empty", got) + } +} diff --git a/internal/library/scanner.go b/internal/library/scanner.go index 496142eb..32fb0b26 100644 --- a/internal/library/scanner.go +++ b/internal/library/scanner.go @@ -56,7 +56,11 @@ var audioExtensions = map[string]bool{ // "EDM", "Children'S Music" -> "Children's Music". Bumped rather than left // to new files only because taste_profile.sql reads tracks.genre directly, // so a half-repaired library would carry both spellings as separate tags. -const tagReadVersion int16 = 2 +// 3: the MusicBrainz release-group id is read into albums.release_group_mbid +// (#5241). Lidarr names albums by release group, not by the release id +// albums.mbid holds, so re-acquisition and request completion need it for +// the albums already in the library, not only for files added from now on. +const tagReadVersion int16 = 3 type Stats struct { Scanned int `json:"scanned"` @@ -258,6 +262,7 @@ func (s *Scanner) scanFile( } albumMBID, artistMBID := extractMBIDs(meta) recordingMBID := extractRecordingMBID(meta) + releaseGroupMBID := extractReleaseGroupMBID(meta) artistName := meta.Artist() if artistName == "" { @@ -276,7 +281,7 @@ func (s *Scanner) scanFile( if err != nil { return pgtype.UUID{}, false, fmt.Errorf("artist: %w", err) } - album, err := s.resolveAlbum(ctx, q, artist.ID, albumTitle, meta.Year(), albumMBID) + album, err := s.resolveAlbum(ctx, q, artist.ID, albumTitle, meta.Year(), albumMBID, releaseGroupMBID) if err != nil { return pgtype.UUID{}, false, fmt.Errorf("album: %w", err) } @@ -518,9 +523,23 @@ func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name, mbid return artist, nil } -func (s *Scanner) resolveAlbum(ctx context.Context, q *dbq.Queries, artistID pgtype.UUID, title string, year int, mbid string) (dbq.Album, error) { +func (s *Scanner) resolveAlbum(ctx context.Context, q *dbq.Queries, artistID pgtype.UUID, title string, year int, mbid, releaseGroupMBID string) (dbq.Album, error) { existing, err := q.GetAlbumByArtistAndTitle(ctx, dbq.GetAlbumByArtistAndTitleParams{ArtistID: artistID, Title: title}) if err == nil { + // Heal the release group the same way. Not unique, so no conflict to + // handle: every release of a group may carry it. + if releaseGroupMBID != "" && existing.ReleaseGroupMbid == nil { + rg := releaseGroupMBID + if uerr := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{ + ID: existing.ID, + ReleaseGroupMbid: &rg, + }); uerr != nil { + s.logger.Warn("library scan: heal album release group failed", + "album_id", existing.ID, "err", uerr) + } else { + existing.ReleaseGroupMbid = &rg + } + } // Heal: backfill mbid on a previously-imported row if we have one now. if mbid != "" && (existing.Mbid == nil || *existing.Mbid == "") { m := mbid @@ -564,6 +583,10 @@ func (s *Scanner) resolveAlbum(ctx context.Context, q *dbq.Queries, artistID pgt m := mbid params.Mbid = &m } + if releaseGroupMBID != "" { + rg := releaseGroupMBID + params.ReleaseGroupMbid = &rg + } album, err := q.UpsertAlbum(ctx, params) if err != nil { return dbq.Album{}, err diff --git a/internal/library/scanner_test.go b/internal/library/scanner_test.go index b17741a9..b5404c63 100644 --- a/internal/library/scanner_test.go +++ b/internal/library/scanner_test.go @@ -308,3 +308,80 @@ func TestScanner_AdoptsMovedFile_Integration(t *testing.T) { t.Errorf("tracks = %d, want 8 — a rename must not add a row", total) } } + +// TestScanner_ReleaseGroupMBID_Integration is #5241's scan half: a new album +// takes its release-group id from the tag, and an album indexed before this +// read existed gets it on the next scan, through the tagReadVersion bump, +// without its file changing. +func TestScanner_ReleaseGroupMBID_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping scanner integration in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + ctx := context.Background() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + if err := db.Migrate(dsn, logger); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + if _, err := pool.Exec(ctx, "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { + t.Fatalf("truncate: %v", err) + } + + const ( + groupA = "aaaaaaaa-1111-2222-3333-444444444444" + groupB = "bbbbbbbb-1111-2222-3333-444444444444" + ) + root := t.TempDir() + writeTestMP3(t, filepath.Join(root, "rg/A/01.mp3"), map[string]string{ + "TIT2": "One", "TPE1": "Artist RG", "TALB": "Album A", + "TXXX": "MusicBrainz Release Group Id\x00" + groupA, + }) + pathB := filepath.Join(root, "rg/B/01.mp3") + writeTestMP3(t, pathB, map[string]string{ + "TIT2": "Two", "TPE1": "Artist RG", "TALB": "Album B", + "TXXX": "MusicBrainz Release Group Id\x00" + groupB, + }) + + scanner := New(pool, logger, []string{root}, nil) + if _, err := scanner.Scan(ctx, nil); err != nil { + t.Fatalf("first scan: %v", err) + } + groupOf := func(title string) *string { + t.Helper() + var rg *string + if err := pool.QueryRow(ctx, "SELECT release_group_mbid FROM albums WHERE title = $1", title).Scan(&rg); err != nil { + t.Fatalf("album %q: %v", title, err) + } + return rg + } + if rg := groupOf("Album A"); rg == nil || *rg != groupA { + t.Fatalf("new album release group = %v, want %s", rg, groupA) + } + + // Album B as a library indexed under tag-read version 2 left it: no + // release group, file untouched since. + if _, err := pool.Exec(ctx, "UPDATE albums SET release_group_mbid = NULL WHERE title = 'Album B'"); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, "UPDATE tracks SET tag_read_version = 2 WHERE file_path = $1", pathB); err != nil { + t.Fatal(err) + } + if _, err := scanner.Scan(ctx, nil); err != nil { + t.Fatalf("second scan: %v", err) + } + if rg := groupOf("Album B"); rg == nil || *rg != groupB { + t.Errorf("existing album release group after rescan = %v, want %s", rg, groupB) + } + if rg := groupOf("Album A"); rg == nil || *rg != groupA { + t.Errorf("album A release group changed to %v", rg) + } +}