From 9bf4b504b30e358f14373af0016a13909c7ad7bf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 9 May 2026 22:36:26 -0400 Subject: [PATCH] feat(server): GET /api/library/sync endpoint Returns batched upserts + deletes since the supplied cursor. Empty cursor returns full snapshot; subsequent calls pull deltas. Per-user entities (likes, playlists) are scoped to the authed user. Composite-key entities (likes, playlist_tracks) use stable string ids encoded by sync.EncodeLikeID / sync.EncodePlaylistTrackID. Behavior: 204 No Content - no changes since cursor 200 OK - JSON syncResponse {cursor, upserts, deletes} 410 Gone - cursor older than oldest log row; client must reset 401 / 500 - standard envelope errors Adds sqlc queries GetAlbumsByIDs, GetTracksByIDs, GetPlaylistsByIDs to mirror the existing GetArtistsByIDs. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/library_sync.go | 287 ++++++++++++++++++++++++++++++ internal/api/library_sync_test.go | 219 +++++++++++++++++++++++ internal/db/dbq/albums.sql.go | 38 ++++ internal/db/dbq/playlists.sql.go | 40 +++++ internal/db/dbq/tracks.sql.go | 42 +++++ internal/db/queries/albums.sql | 5 + internal/db/queries/playlists.sql | 5 + internal/db/queries/tracks.sql | 5 + 8 files changed, 641 insertions(+) create mode 100644 internal/api/library_sync.go create mode 100644 internal/api/library_sync_test.go diff --git a/internal/api/library_sync.go b/internal/api/library_sync.go new file mode 100644 index 00000000..0b84baaf --- /dev/null +++ b/internal/api/library_sync.go @@ -0,0 +1,287 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" +) + +// SyncBatchLimit caps the number of change rows returned per call. +// Clients that need more loop with the returned cursor. +const SyncBatchLimit = 1000 + +// syncResponse is the wire shape returned by GET /api/library/sync. +// upserts and deletes are separated by entity type so clients can apply +// them to typed local tables. cursor is the largest library_changes.id +// the server returned in this batch — clients pass it back as `since` +// next time. +type syncResponse struct { + Cursor int64 `json:"cursor"` + Upserts map[string][]json.RawMessage `json:"upserts"` + Deletes map[string][]string `json:"deletes"` +} + +// handleLibrarySync returns batched upserts + deletes since the supplied +// cursor. Cursor is the last `library_changes.id` the client has seen. +// Empty / zero / invalid cursor means "give me everything" (initial sync). +// +// Responses: +// - 204 No Content — no changes since cursor +// - 200 OK — JSON syncResponse with upserts, deletes, new cursor +// - 410 Gone — cursor older than oldest log row; client must reset +// - 401 / 500 — standard envelope errors +func (h *handlers) handleLibrarySync(w http.ResponseWriter, r *http.Request) { + user, ok := requireUser(w, r) + if !ok { + return + } + ctx := r.Context() + + since, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) + if err != nil || since < 0 { + since = 0 + } + + q := dbq.New(h.pool) + + // 410 path: if the requested cursor is older than the oldest row, + // tell the client to reset. (No compaction job in this slice; the + // path exists for future compaction support.) + if since > 0 { + minCursor, err := q.GetMinLibraryChangeCursor(ctx) + if err != nil { + writeErrWithLog(w, h.logger, "library_sync: GetMinLibraryChangeCursor", err) + return + } + if minCursor > 0 && since+1 < minCursor { + writeErr(w, &apierror.Error{ + Status: http.StatusGone, + Code: "cursor_too_old", + Message: "reset client cursor to 0 and resync", + }) + return + } + } + + changes, err := q.GetLibraryChangesSince(ctx, dbq.GetLibraryChangesSinceParams{ + ID: since, + Limit: SyncBatchLimit, + }) + if err != nil { + writeErrWithLog(w, h.logger, "library_sync: GetLibraryChangesSince", err) + return + } + + if len(changes) == 0 { + w.WriteHeader(http.StatusNoContent) + return + } + + // Group ids by entity type so we can fetch upsert payloads in batches. + upsertIDs := map[syncpkg.EntityType][]string{} + deleteIDs := map[syncpkg.EntityType][]string{} + var maxID int64 + for _, c := range changes { + et := syncpkg.EntityType(c.EntityType) + if c.ID > maxID { + maxID = c.ID + } + if c.Op == string(syncpkg.OpUpsert) { + upsertIDs[et] = append(upsertIDs[et], c.EntityID) + } else { + deleteIDs[et] = append(deleteIDs[et], c.EntityID) + } + } + + upserts, err := h.hydrateUpserts(ctx, q, user.ID, upsertIDs) + if err != nil { + writeErrWithLog(w, h.logger, "library_sync: hydrateUpserts", err) + return + } + + // Format deletes — convert EntityType keys to strings for JSON. + deletes := make(map[string][]string, len(deleteIDs)) + for et, ids := range deleteIDs { + deletes[string(et)] = ids + } + + writeJSON(w, http.StatusOK, syncResponse{ + Cursor: maxID, + Upserts: upserts, + Deletes: deletes, + }) +} + +// hydrateUpserts loads the current row payloads for each upserted id, +// keyed by entity type (string form). Returns json.RawMessage values so +// each entity's full sqlc row shape passes through unchanged. +// +// Per-user entities (likes, playlists, playlist_tracks) are scoped to +// userID — the change log is global but each user only sees rows that +// concern them. +func (h *handlers) hydrateUpserts( + ctx context.Context, + q *dbq.Queries, + userID pgtype.UUID, + ids map[syncpkg.EntityType][]string, +) (map[string][]json.RawMessage, error) { + out := map[string][]json.RawMessage{} + + if rows := ids[syncpkg.EntityArtist]; len(rows) > 0 { + uuids := stringsToUUIDs(rows) + artists, err := q.GetArtistsByIDs(ctx, uuids) + if err != nil { + return nil, err + } + for _, a := range artists { + b, _ := json.Marshal(a) + out["artist"] = append(out["artist"], b) + } + } + if rows := ids[syncpkg.EntityAlbum]; len(rows) > 0 { + uuids := stringsToUUIDs(rows) + albums, err := q.GetAlbumsByIDs(ctx, uuids) + if err != nil { + return nil, err + } + for _, a := range albums { + b, _ := json.Marshal(a) + out["album"] = append(out["album"], b) + } + } + if rows := ids[syncpkg.EntityTrack]; len(rows) > 0 { + uuids := stringsToUUIDs(rows) + tracks, err := q.GetTracksByIDs(ctx, uuids) + if err != nil { + return nil, err + } + for _, t := range tracks { + b, _ := json.Marshal(t) + out["track"] = append(out["track"], b) + } + } + + userIDStr := uuidToString(userID) + + if rows := ids[syncpkg.EntityLikeTrack]; len(rows) > 0 { + out["like_track"] = scopedLikeRows(rows, userIDStr, "track_id") + } + if rows := ids[syncpkg.EntityLikeAlbum]; len(rows) > 0 { + out["like_album"] = scopedLikeRows(rows, userIDStr, "album_id") + } + if rows := ids[syncpkg.EntityLikeArtist]; len(rows) > 0 { + out["like_artist"] = scopedLikeRows(rows, userIDStr, "artist_id") + } + + if rows := ids[syncpkg.EntityPlaylist]; len(rows) > 0 { + uuids := stringsToUUIDs(rows) + playlists, err := q.GetPlaylistsByIDs(ctx, uuids) + if err != nil { + return nil, err + } + for _, p := range playlists { + // Filter: only return playlists owned by this user OR public ones + if uuidToString(p.UserID) != userIDStr && !p.IsPublic { + continue + } + b, _ := json.Marshal(p) + out["playlist"] = append(out["playlist"], b) + } + } + if rows := ids[syncpkg.EntityPlaylistTrack]; len(rows) > 0 { + // playlist_track ids are ":" — we don't know + // playlist ownership here without a JOIN. Pass through; client + // reconciles against its locally-cached playlists. + var msgs []json.RawMessage + for _, id := range rows { + parts := splitOnce(id, ":") + if len(parts) != 2 { + continue + } + b, _ := json.Marshal(map[string]string{ + "playlist_id": parts[0], + "track_id": parts[1], + }) + msgs = append(msgs, b) + } + out["playlist_track"] = msgs + } + return out, nil +} + +// scopedLikeRows filters composite-key like rows to those owned by userID +// and emits {user_id, } JSON shapes. entityKey is "track_id", +// "album_id", or "artist_id" depending on the like table. +func scopedLikeRows(rows []string, userIDStr, entityKey string) []json.RawMessage { + var msgs []json.RawMessage + for _, id := range rows { + parts := splitOnce(id, ":") + if len(parts) != 2 || parts[0] != userIDStr { + continue + } + b, _ := json.Marshal(map[string]string{ + "user_id": parts[0], + entityKey: parts[1], + }) + msgs = append(msgs, b) + } + return msgs +} + +// stringsToUUIDs parses each string as a pgtype.UUID. Invalid entries +// are skipped silently — they can't have come from a valid scan/mutation. +func stringsToUUIDs(strs []string) []pgtype.UUID { + out := make([]pgtype.UUID, 0, len(strs)) + for _, s := range strs { + u, ok := parseUUID(s) + if !ok { + continue + } + out = append(out, u) + } + return out +} + +// uuidToString renders a pgtype.UUID as the canonical 8-4-4-4-12 hex +// form. Returns "" if the UUID is not valid. +func uuidToString(u pgtype.UUID) string { + if !u.Valid { + return "" + } + b := u.Bytes + return formatUUIDBytes(b) +} + +func formatUUIDBytes(b [16]byte) string { + const hex = "0123456789abcdef" + out := make([]byte, 36) + pos := 0 + for i := 0; i < 16; i++ { + if i == 4 || i == 6 || i == 8 || i == 10 { + out[pos] = '-' + pos++ + } + out[pos] = hex[b[i]>>4] + out[pos+1] = hex[b[i]&0x0f] + pos += 2 + } + return string(out) +} + +// splitOnce returns [before, after] split at the first occurrence of sep, +// or [s] if sep doesn't appear. Used for composite-key parsing. +func splitOnce(s, sep string) []string { + for i := 0; i+len(sep) <= len(s); i++ { + if s[i:i+len(sep)] == sep { + return []string{s[:i], s[i+len(sep):]} + } + } + return []string{s} +} diff --git a/internal/api/library_sync_test.go b/internal/api/library_sync_test.go new file mode 100644 index 00000000..11ade966 --- /dev/null +++ b/internal/api/library_sync_test.go @@ -0,0 +1,219 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/sync" +) + +func callLibrarySync(h *handlers, user dbq.User, since string) *httptest.ResponseRecorder { + url := "/api/library/sync" + if since != "" { + url += "?since=" + since + } + req := httptest.NewRequest(http.MethodGet, url, nil) + req = req.WithContext(context.WithValue(req.Context(), auth.UserCtxKeyForTest(), user)) + w := httptest.NewRecorder() + h.handleLibrarySync(w, req) + return w +} + +func TestLibrarySync_NoChanges_Returns204(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + u := seedUser(t, pool, "alice", "x", false) + + w := callLibrarySync(h, u, "0") + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } +} + +func TestLibrarySync_AfterArtistUpsert_ReturnsHydratedPayload(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + u := seedUser(t, pool, "alice", "x", false) + + // Seed an artist + write a change row in the same tx — that's the + // invariant the scanner wiring (Task 5) will enforce, and the + // handler relies on. + ctx := context.Background() + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback(ctx) + var artistID string + if err := tx.QueryRow(ctx, + `INSERT INTO artists (name, sort_name) VALUES ($1, $2) RETURNING id::text`, + "Boards of Canada", "Boards of Canada", + ).Scan(&artistID); err != nil { + t.Fatalf("insert artist: %v", err) + } + if err := sync.LogChange(ctx, tx, sync.EntityArtist, artistID, sync.OpUpsert); err != nil { + t.Fatalf("LogChange: %v", err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit: %v", err) + } + + w := callLibrarySync(h, u, "0") + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + var body struct { + Cursor int64 `json:"cursor"` + Upserts map[string][]json.RawMessage `json:"upserts"` + Deletes map[string][]string `json:"deletes"` + } + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Cursor <= 0 { + t.Fatalf("expected positive cursor, got %d", body.Cursor) + } + if len(body.Upserts["artist"]) != 1 { + t.Fatalf("expected 1 artist upsert, got %d", len(body.Upserts["artist"])) + } +} + +func TestLibrarySync_CursorMonotonic(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + u := seedUser(t, pool, "alice", "x", false) + + ctx := context.Background() + tx, _ := pool.Begin(ctx) + var artistID string + _ = tx.QueryRow(ctx, + `INSERT INTO artists (name, sort_name) VALUES ($1, $2) RETURNING id::text`, + "A", "A", + ).Scan(&artistID) + _ = sync.LogChange(ctx, tx, sync.EntityArtist, artistID, sync.OpUpsert) + _ = tx.Commit(ctx) + + w1 := callLibrarySync(h, u, "0") + if w1.Code != http.StatusOK { + t.Fatalf("first call status = %d", w1.Code) + } + var body1 struct { + Cursor int64 `json:"cursor"` + } + if err := json.NewDecoder(w1.Body).Decode(&body1); err != nil { + t.Fatalf("decode: %v", err) + } + + w2 := callLibrarySync(h, u, strconv.FormatInt(body1.Cursor, 10)) + if w2.Code != http.StatusNoContent { + t.Fatalf("second call status = %d body = %s", w2.Code, w2.Body.String()) + } +} + +func TestLibrarySync_DeleteIsReflected(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + u := seedUser(t, pool, "alice", "x", false) + + // Just write a delete change row directly (no underlying mutation). + // Scanner wiring (Task 5) is what couples real deletes to LogChange; + // here we test the handler reflects what's in the log. + ctx := context.Background() + tx, _ := pool.Begin(ctx) + _ = sync.LogChange(ctx, tx, sync.EntityArtist, "deadbeef-dead-beef-dead-beefdeadbeef", sync.OpDelete) + _ = tx.Commit(ctx) + + w := callLibrarySync(h, u, "0") + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + var body struct { + Cursor int64 `json:"cursor"` + Upserts map[string][]json.RawMessage `json:"upserts"` + Deletes map[string][]string `json:"deletes"` + } + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if got := body.Deletes["artist"]; len(got) != 1 || got[0] != "deadbeef-dead-beef-dead-beefdeadbeef" { + t.Fatalf("expected one artist delete; got %#v", body.Deletes) + } +} + +func TestLibrarySync_LikeRowScopedToUser(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + u := seedUser(t, pool, "alice", "x", false) + other := seedUser(t, pool, "bob", "x", false) + + // Two like_track rows: one for u, one for other. u's call should + // see only their own row. + ctx := context.Background() + tx, _ := pool.Begin(ctx) + _ = sync.LogChange(ctx, tx, sync.EntityLikeTrack, + sync.EncodeLikeID(uuidToString(u.ID), "11111111-1111-1111-1111-111111111111"), + sync.OpUpsert) + _ = sync.LogChange(ctx, tx, sync.EntityLikeTrack, + sync.EncodeLikeID(uuidToString(other.ID), "22222222-2222-2222-2222-222222222222"), + sync.OpUpsert) + _ = tx.Commit(ctx) + + w := callLibrarySync(h, u, "0") + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + var body struct { + Upserts map[string][]json.RawMessage `json:"upserts"` + } + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + got := body.Upserts["like_track"] + if len(got) != 1 { + t.Fatalf("expected 1 like row scoped to alice, got %d", len(got)) + } +} + +// Pure unit tests below — run even with -short. + +func TestSplitOnce(t *testing.T) { + cases := []struct { + in, sep string + want []string + }{ + {"a:b", ":", []string{"a", "b"}}, + {"a:b:c", ":", []string{"a", "b:c"}}, + {"abc", ":", []string{"abc"}}, + {":x", ":", []string{"", "x"}}, + } + for _, c := range cases { + got := splitOnce(c.in, c.sep) + if len(got) != len(c.want) { + t.Errorf("splitOnce(%q,%q): len got=%d want=%d", c.in, c.sep, len(got), len(c.want)) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("splitOnce(%q,%q)[%d] = %q want %q", c.in, c.sep, i, got[i], c.want[i]) + } + } + } +} + +func TestFormatUUIDBytes(t *testing.T) { + var b [16]byte + for i := range b { + b[i] = byte(i) + } + got := formatUUIDBytes(b) + want := "00010203-0405-0607-0809-0a0b0c0d0e0f" + if got != want { + t.Errorf("formatUUIDBytes = %q want %q", got, want) + } +} diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index bbd72e86..5168f686 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -168,6 +168,44 @@ func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageR 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[]) +` + +// Batched lookup used by /api/library/sync to hydrate upsert payloads +// (#357). Mirror of GetArtistsByIDs. +func (q *Queries) GetAlbumsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Album, error) { + rows, err := q.db.Query(ctx, getAlbumsByIDs, dollar_1) + 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, + &i.CoverArtSource, + &i.CoverArtSourcesVersion, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + 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 FROM albums diff --git a/internal/db/dbq/playlists.sql.go b/internal/db/dbq/playlists.sql.go index 80d97dcc..71825f80 100644 --- a/internal/db/dbq/playlists.sql.go +++ b/internal/db/dbq/playlists.sql.go @@ -173,6 +173,46 @@ func (q *Queries) GetPlaylist(ctx context.Context, id pgtype.UUID) (GetPlaylistR return i, err } +const getPlaylistsByIDs = `-- name: GetPlaylistsByIDs :many +SELECT id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at, kind, system_variant, seed_artist_id FROM playlists WHERE id = ANY($1::uuid[]) +` + +// Batched lookup used by /api/library/sync to hydrate upsert payloads +// (#357). Mirror of GetArtistsByIDs. +func (q *Queries) GetPlaylistsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Playlist, error) { + rows, err := q.db.Query(ctx, getPlaylistsByIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Playlist + for rows.Next() { + var i Playlist + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.IsPublic, + &i.CoverPath, + &i.TrackCount, + &i.DurationSec, + &i.CreatedAt, + &i.UpdatedAt, + &i.Kind, + &i.SystemVariant, + &i.SeedArtistID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAllPlaylistTracksForCollage = `-- name: ListAllPlaylistTracksForCollage :many SELECT pt.position, albums.cover_art_path AS album_cover_path diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index cef03a9f..2f522c3a 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -143,6 +143,48 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e return i, err } +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 FROM tracks WHERE id = ANY($1::uuid[]) +` + +// Batched lookup used by /api/library/sync to hydrate upsert payloads +// (#357). Mirror of GetArtistsByIDs. +func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Track, error) { + rows, err := q.db.Query(ctx, getTracksByIDs, dollar_1) + 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 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, albums.title AS album_title, diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql index 375f219f..38f3c34a 100644 --- a/internal/db/queries/albums.sql +++ b/internal/db/queries/albums.sql @@ -159,3 +159,8 @@ UPDATE albums updated_at = now() WHERE id = $1; + +-- name: GetAlbumsByIDs :many +-- Batched lookup used by /api/library/sync to hydrate upsert payloads +-- (#357). Mirror of GetArtistsByIDs. +SELECT * FROM albums WHERE id = ANY($1::uuid[]); diff --git a/internal/db/queries/playlists.sql b/internal/db/queries/playlists.sql index afed223a..349e1940 100644 --- a/internal/db/queries/playlists.sql +++ b/internal/db/queries/playlists.sql @@ -108,3 +108,8 @@ LEFT JOIN albums ON albums.id = t.album_id WHERE pt.playlist_id = $1 ORDER BY pt.position LIMIT $2; + +-- name: GetPlaylistsByIDs :many +-- Batched lookup used by /api/library/sync to hydrate upsert payloads +-- (#357). Mirror of GetArtistsByIDs. +SELECT * FROM playlists WHERE id = ANY($1::uuid[]); diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql index 9168ceb2..1e661ce0 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -95,3 +95,8 @@ SELECT COUNT(*) FROM tracks WHERE artist_id = $1; -- checks the service does next. DELETE FROM tracks WHERE id = $1 RETURNING id, album_id, artist_id, file_path, mbid; + +-- name: GetTracksByIDs :many +-- Batched lookup used by /api/library/sync to hydrate upsert payloads +-- (#357). Mirror of GetArtistsByIDs. +SELECT * FROM tracks WHERE id = ANY($1::uuid[]);