From 780beaa248037135ab3139f21fb7515d5843035f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 26 Apr 2026 16:20:08 -0400 Subject: [PATCH] feat(db): add M2 likes schema (general_likes, _albums, _artists) Three tables keyed on (user_id, entity_id) with liked_at. Per-table indexes on (user_id, liked_at DESC) for the recently-liked feed. sqlc queries cover like/unlike/list-rows/count/list-ids per entity. --- internal/db/dbq/likes.sql.go | 342 +++++++++++++++++++++ internal/db/dbq/models.go | 18 ++ internal/db/migrations/0006_likes.down.sql | 3 + internal/db/migrations/0006_likes.up.sql | 27 ++ internal/db/queries/likes.sql | 62 ++++ 5 files changed, 452 insertions(+) create mode 100644 internal/db/dbq/likes.sql.go create mode 100644 internal/db/migrations/0006_likes.down.sql create mode 100644 internal/db/migrations/0006_likes.up.sql create mode 100644 internal/db/queries/likes.sql diff --git a/internal/db/dbq/likes.sql.go b/internal/db/dbq/likes.sql.go new file mode 100644 index 00000000..3ded9371 --- /dev/null +++ b/internal/db/dbq/likes.sql.go @@ -0,0 +1,342 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: likes.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countLikedAlbums = `-- name: CountLikedAlbums :one +SELECT count(*) FROM general_likes_albums WHERE user_id = $1 +` + +func (q *Queries) CountLikedAlbums(ctx context.Context, userID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countLikedAlbums, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countLikedArtists = `-- name: CountLikedArtists :one +SELECT count(*) FROM general_likes_artists WHERE user_id = $1 +` + +func (q *Queries) CountLikedArtists(ctx context.Context, userID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countLikedArtists, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countLikedTracks = `-- name: CountLikedTracks :one +SELECT count(*) FROM general_likes WHERE user_id = $1 +` + +func (q *Queries) CountLikedTracks(ctx context.Context, userID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countLikedTracks, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const likeAlbum = `-- name: LikeAlbum :exec +INSERT INTO general_likes_albums (user_id, album_id) +VALUES ($1, $2) +ON CONFLICT (user_id, album_id) DO NOTHING +` + +type LikeAlbumParams struct { + UserID pgtype.UUID + AlbumID pgtype.UUID +} + +func (q *Queries) LikeAlbum(ctx context.Context, arg LikeAlbumParams) error { + _, err := q.db.Exec(ctx, likeAlbum, arg.UserID, arg.AlbumID) + return err +} + +const likeArtist = `-- name: LikeArtist :exec +INSERT INTO general_likes_artists (user_id, artist_id) +VALUES ($1, $2) +ON CONFLICT (user_id, artist_id) DO NOTHING +` + +type LikeArtistParams struct { + UserID pgtype.UUID + ArtistID pgtype.UUID +} + +func (q *Queries) LikeArtist(ctx context.Context, arg LikeArtistParams) error { + _, err := q.db.Exec(ctx, likeArtist, arg.UserID, arg.ArtistID) + return err +} + +const likeTrack = `-- name: LikeTrack :exec +INSERT INTO general_likes (user_id, track_id) +VALUES ($1, $2) +ON CONFLICT (user_id, track_id) DO NOTHING +` + +type LikeTrackParams struct { + UserID pgtype.UUID + TrackID pgtype.UUID +} + +func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) error { + _, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID) + return err +} + +const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many +SELECT album_id FROM general_likes_albums WHERE user_id = $1 ORDER BY liked_at DESC +` + +func (q *Queries) ListLikedAlbumIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listLikedAlbumIDs, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var album_id pgtype.UUID + if err := rows.Scan(&album_id); err != nil { + return nil, err + } + items = append(items, album_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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 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 +LIMIT $2 OFFSET $3 +` + +type ListLikedAlbumRowsParams struct { + UserID pgtype.UUID + Limit int32 + Offset int32 +} + +func (q *Queries) ListLikedAlbumRows(ctx context.Context, arg ListLikedAlbumRowsParams) ([]Album, error) { + rows, err := q.db.Query(ctx, listLikedAlbumRows, arg.UserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Album + for rows.Next() { + var i Album + if err := rows.Scan( + &i.ID, + &i.Title, + &i.SortTitle, + &i.ArtistID, + &i.ReleaseDate, + &i.Mbid, + &i.CoverArtPath, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLikedArtistIDs = `-- name: ListLikedArtistIDs :many +SELECT artist_id FROM general_likes_artists WHERE user_id = $1 ORDER BY liked_at DESC +` + +func (q *Queries) ListLikedArtistIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listLikedArtistIDs, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var artist_id pgtype.UUID + if err := rows.Scan(&artist_id); err != nil { + return nil, err + } + items = append(items, artist_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLikedArtistRows = `-- name: ListLikedArtistRows :many +SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at FROM artists a +JOIN general_likes_artists l ON l.artist_id = a.id +WHERE l.user_id = $1 +ORDER BY l.liked_at DESC +LIMIT $2 OFFSET $3 +` + +type ListLikedArtistRowsParams struct { + UserID pgtype.UUID + Limit int32 + Offset int32 +} + +func (q *Queries) ListLikedArtistRows(ctx context.Context, arg ListLikedArtistRowsParams) ([]Artist, error) { + rows, err := q.db.Query(ctx, listLikedArtistRows, arg.UserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Artist + for rows.Next() { + var i Artist + if err := rows.Scan( + &i.ID, + &i.Name, + &i.SortName, + &i.Mbid, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLikedTrackIDs = `-- name: ListLikedTrackIDs :many +SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC +` + +func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listLikedTrackIDs, userID) + 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 +} + +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 FROM tracks t +JOIN general_likes l ON l.track_id = t.id +WHERE l.user_id = $1 +ORDER BY l.liked_at DESC +LIMIT $2 OFFSET $3 +` + +type ListLikedTrackRowsParams struct { + UserID pgtype.UUID + Limit int32 + Offset int32 +} + +func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRowsParams) ([]Track, error) { + rows, err := q.db.Query(ctx, listLikedTrackRows, arg.UserID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Track + for rows.Next() { + var i Track + if err := rows.Scan( + &i.ID, + &i.Title, + &i.AlbumID, + &i.ArtistID, + &i.TrackNumber, + &i.DiscNumber, + &i.DurationMs, + &i.FilePath, + &i.FileSize, + &i.FileFormat, + &i.Bitrate, + &i.Mbid, + &i.Genre, + &i.AddedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const unlikeAlbum = `-- name: UnlikeAlbum :exec +DELETE FROM general_likes_albums WHERE user_id = $1 AND album_id = $2 +` + +type UnlikeAlbumParams struct { + UserID pgtype.UUID + AlbumID pgtype.UUID +} + +func (q *Queries) UnlikeAlbum(ctx context.Context, arg UnlikeAlbumParams) error { + _, err := q.db.Exec(ctx, unlikeAlbum, arg.UserID, arg.AlbumID) + return err +} + +const unlikeArtist = `-- name: UnlikeArtist :exec +DELETE FROM general_likes_artists WHERE user_id = $1 AND artist_id = $2 +` + +type UnlikeArtistParams struct { + UserID pgtype.UUID + ArtistID pgtype.UUID +} + +func (q *Queries) UnlikeArtist(ctx context.Context, arg UnlikeArtistParams) error { + _, err := q.db.Exec(ctx, unlikeArtist, arg.UserID, arg.ArtistID) + return err +} + +const unlikeTrack = `-- name: UnlikeTrack :exec +DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2 +` + +type UnlikeTrackParams struct { + UserID pgtype.UUID + TrackID pgtype.UUID +} + +func (q *Queries) UnlikeTrack(ctx context.Context, arg UnlikeTrackParams) error { + _, err := q.db.Exec(ctx, unlikeTrack, arg.UserID, arg.TrackID) + return err +} diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 1783cd70..4d6c4199 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -29,6 +29,24 @@ type Artist struct { UpdatedAt pgtype.Timestamptz } +type GeneralLike struct { + UserID pgtype.UUID + TrackID pgtype.UUID + LikedAt pgtype.Timestamptz +} + +type GeneralLikesAlbum struct { + UserID pgtype.UUID + AlbumID pgtype.UUID + LikedAt pgtype.Timestamptz +} + +type GeneralLikesArtist struct { + UserID pgtype.UUID + ArtistID pgtype.UUID + LikedAt pgtype.Timestamptz +} + type PlayEvent struct { ID pgtype.UUID UserID pgtype.UUID diff --git a/internal/db/migrations/0006_likes.down.sql b/internal/db/migrations/0006_likes.down.sql new file mode 100644 index 00000000..b3ca0692 --- /dev/null +++ b/internal/db/migrations/0006_likes.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS general_likes_artists; +DROP TABLE IF EXISTS general_likes_albums; +DROP TABLE IF EXISTS general_likes; diff --git a/internal/db/migrations/0006_likes.up.sql b/internal/db/migrations/0006_likes.up.sql new file mode 100644 index 00000000..fdbf33c8 --- /dev/null +++ b/internal/db/migrations/0006_likes.up.sql @@ -0,0 +1,27 @@ +-- M2 likes: three tables, one per entity type. Schema follows the spec ยง5 +-- pattern (general_likes was originally track-only); this slice extends it +-- to albums and artists for full Subsonic star/unstar support. + +CREATE TABLE general_likes ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + liked_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, track_id) +); +CREATE INDEX general_likes_user_liked_at_idx ON general_likes (user_id, liked_at DESC); + +CREATE TABLE general_likes_albums ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE, + liked_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, album_id) +); +CREATE INDEX general_likes_albums_user_liked_at_idx ON general_likes_albums (user_id, liked_at DESC); + +CREATE TABLE general_likes_artists ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + liked_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, artist_id) +); +CREATE INDEX general_likes_artists_user_liked_at_idx ON general_likes_artists (user_id, liked_at DESC); diff --git a/internal/db/queries/likes.sql b/internal/db/queries/likes.sql new file mode 100644 index 00000000..b4a38473 --- /dev/null +++ b/internal/db/queries/likes.sql @@ -0,0 +1,62 @@ +-- name: LikeTrack :exec +INSERT INTO general_likes (user_id, track_id) +VALUES ($1, $2) +ON CONFLICT (user_id, track_id) DO NOTHING; + +-- name: UnlikeTrack :exec +DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2; + +-- name: ListLikedTrackRows :many +SELECT t.* FROM tracks t +JOIN general_likes l ON l.track_id = t.id +WHERE l.user_id = $1 +ORDER BY l.liked_at DESC +LIMIT $2 OFFSET $3; + +-- name: CountLikedTracks :one +SELECT count(*) FROM general_likes WHERE user_id = $1; + +-- name: ListLikedTrackIDs :many +SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC; + +-- name: LikeAlbum :exec +INSERT INTO general_likes_albums (user_id, album_id) +VALUES ($1, $2) +ON CONFLICT (user_id, album_id) DO NOTHING; + +-- name: UnlikeAlbum :exec +DELETE FROM general_likes_albums WHERE user_id = $1 AND album_id = $2; + +-- name: ListLikedAlbumRows :many +SELECT a.* 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 +LIMIT $2 OFFSET $3; + +-- name: CountLikedAlbums :one +SELECT count(*) FROM general_likes_albums WHERE user_id = $1; + +-- name: ListLikedAlbumIDs :many +SELECT album_id FROM general_likes_albums WHERE user_id = $1 ORDER BY liked_at DESC; + +-- name: LikeArtist :exec +INSERT INTO general_likes_artists (user_id, artist_id) +VALUES ($1, $2) +ON CONFLICT (user_id, artist_id) DO NOTHING; + +-- name: UnlikeArtist :exec +DELETE FROM general_likes_artists WHERE user_id = $1 AND artist_id = $2; + +-- name: ListLikedArtistRows :many +SELECT a.* FROM artists a +JOIN general_likes_artists l ON l.artist_id = a.id +WHERE l.user_id = $1 +ORDER BY l.liked_at DESC +LIMIT $2 OFFSET $3; + +-- name: CountLikedArtists :one +SELECT count(*) FROM general_likes_artists WHERE user_id = $1; + +-- name: ListLikedArtistIDs :many +SELECT artist_id FROM general_likes_artists WHERE user_id = $1 ORDER BY liked_at DESC;