diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index acbdeb5b..2065580f 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -313,6 +313,54 @@ func (q *Queries) ListAlbumsRandom(ctx context.Context, limit int32) ([]Album, e return items, nil } +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, artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +ORDER BY albums.created_at DESC, albums.id +LIMIT $1 +` + +type ListRecentlyAddedAlbumsWithArtistRow struct { + Album Album + ArtistName string +} + +// M6a: recently-added albums joined with artist_name + artist_id for the +// home-page section. created_at is the row-insert timestamp from the +// scanner — the closest proxy to "added to library". Stable secondary +// ordering by id keeps pagination tie-break deterministic. +func (q *Queries) ListRecentlyAddedAlbumsWithArtist(ctx context.Context, limit int32) ([]ListRecentlyAddedAlbumsWithArtistRow, error) { + rows, err := q.db.Query(ctx, listRecentlyAddedAlbumsWithArtist, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRecentlyAddedAlbumsWithArtistRow + for rows.Next() { + var i ListRecentlyAddedAlbumsWithArtistRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const searchAlbums = `-- name: SearchAlbums :many SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums WHERE title ILIKE '%' || $1 || '%' diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 0ccf9a66..ee0e45dc 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -11,6 +11,151 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many +SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count, + max_started.started_at::timestamptz AS last_played_at +FROM artists a +JOIN LATERAL ( + SELECT max(pe.started_at) AS started_at + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 AND t.artist_id = a.id +) max_started ON max_started.started_at IS NOT NULL +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = a.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = a.id +) cnt ON true +ORDER BY max_started.started_at DESC, a.id +LIMIT $2 +` + +type ListLastPlayedArtistsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListLastPlayedArtistsForUserRow struct { + Artist Artist + CoverAlbumID pgtype.UUID + AlbumCount int64 + LastPlayedAt pgtype.Timestamptz +} + +// M6a: artists ranked by max(play_events.started_at) for the user, with +// a derived cover_album_id via a representative-album lateral join (most +// recent album that has cover_art_path set). album_count joined for the +// ArtistRef wire shape. +func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLastPlayedArtistsForUserParams) ([]ListLastPlayedArtistsForUserRow, error) { + rows, err := q.db.Query(ctx, listLastPlayedArtistsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListLastPlayedArtistsForUserRow + for rows.Next() { + var i ListLastPlayedArtistsForUserRow + if err := rows.Scan( + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + &i.CoverAlbumID, + &i.AlbumCount, + &i.LastPlayedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :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, + albums.title AS album_title, + artists.name AS artist_name +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +JOIN play_events pe ON pe.track_id = t.id +WHERE pe.user_id = $1 + AND pe.was_skipped = false + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) +GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, + t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, + t.mbid, t.genre, t.added_at, t.updated_at, + albums.title, artists.name +ORDER BY count(*) DESC, t.id +LIMIT $2 +` + +type ListMostPlayedTracksForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListMostPlayedTracksForUserRow struct { + Track Track + AlbumTitle string + ArtistName string +} + +// M6a: top-N tracks by completed-play count for the user. was_skipped +// excludes skips so a user spamming next doesn't fabricate a top track. +// Quarantined tracks (per-user soft-hide from M5b) are filtered out. +func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostPlayedTracksForUserParams) ([]ListMostPlayedTracksForUserRow, error) { + rows, err := q.db.Query(ctx, listMostPlayedTracksForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListMostPlayedTracksForUserRow + for rows.Next() { + var i ListMostPlayedTracksForUserRow + if err := rows.Scan( + &i.Track.ID, + &i.Track.Title, + &i.Track.AlbumID, + &i.Track.ArtistID, + &i.Track.TrackNumber, + &i.Track.DiscNumber, + &i.Track.DurationMs, + &i.Track.FilePath, + &i.Track.FileSize, + &i.Track.FileFormat, + &i.Track.Bitrate, + &i.Track.Mbid, + &i.Track.Genre, + &i.Track.AddedAt, + &i.Track.UpdatedAt, + &i.AlbumTitle, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const loadRadioCandidates = `-- name: LoadRadioCandidates :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, diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql index 63b8ec6f..e56acaa7 100644 --- a/internal/db/queries/albums.sql +++ b/internal/db/queries/albums.sql @@ -56,3 +56,14 @@ LIMIT $2 OFFSET $3; -- name: CountAlbumsMatching :one SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%'; + +-- name: ListRecentlyAddedAlbumsWithArtist :many +-- M6a: recently-added albums joined with artist_name + artist_id for the +-- home-page section. created_at is the row-insert timestamp from the +-- scanner — the closest proxy to "added to library". Stable secondary +-- ordering by id keeps pagination tie-break deterministic. +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +ORDER BY albums.created_at DESC, albums.id +LIMIT $1; diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index af4f525c..a236bf42 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -201,3 +201,55 @@ FROM contributions GROUP BY candidate_mbid, candidate_name ORDER BY total_score DESC LIMIT $3; + +-- name: ListMostPlayedTracksForUser :many +-- M6a: top-N tracks by completed-play count for the user. was_skipped +-- excludes skips so a user spamming next doesn't fabricate a top track. +-- Quarantined tracks (per-user soft-hide from M5b) are filtered out. +SELECT sqlc.embed(t), + albums.title AS album_title, + artists.name AS artist_name +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +JOIN play_events pe ON pe.track_id = t.id +WHERE pe.user_id = $1 + AND pe.was_skipped = false + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) +GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, + t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number, + t.mbid, t.genre, t.added_at, t.updated_at, + albums.title, artists.name +ORDER BY count(*) DESC, t.id +LIMIT $2; + +-- name: ListLastPlayedArtistsForUser :many +-- M6a: artists ranked by max(play_events.started_at) for the user, with +-- a derived cover_album_id via a representative-album lateral join (most +-- recent album that has cover_art_path set). album_count joined for the +-- ArtistRef wire shape. +SELECT sqlc.embed(a), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count, + max_started.started_at::timestamptz AS last_played_at +FROM artists a +JOIN LATERAL ( + SELECT max(pe.started_at) AS started_at + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 AND t.artist_id = a.id +) max_started ON max_started.started_at IS NOT NULL +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = a.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = a.id +) cnt ON true +ORDER BY max_started.started_at DESC, a.id +LIMIT $2;