Files
minstrel/internal/api/library_sync.go
T
bvandeusenandClaude Opus 5.5 598abeb8cf
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m44s
release / android (push) Successful in 4m54s
release / Build signed APK (push) Successful in 5m10s
release / integration (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: a fresh app install syncs the whole library, not the last 30 days of changes (#6778)
The change feed dropped every row older than 30 days, and an app syncing
from cursor 0 builds its library from the feed, so a fresh install, cleared
data or a 410 reset got only what had changed lately.

The compactor now keeps each entity's latest change however old. It drops a
row once a newer one for the same entity exists, and a delete once it is 30
days old, raising a stored mark (library_changes_trimmed) past it; an app
whose cursor is below the mark is sent back to 0, as before. Migration 0101
adds the index the compactor needs, seeds the mark where the old compactor
left off, and gives every artist, album and track whose latest row is not an
upsert a fresh one, so the rows the old compactor dropped come back. The
backup carries the mark with the feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:07:42 -05:00

291 lines
8.7 KiB
Go

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"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
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 — the client must reset its cursor to 0: the cursor is
// older than a delete the log has dropped, or past anything this server
// has handed out
// - 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)
// The log keeps every entity's latest change, so a client syncing from
// 0 gets the whole library. It drops deletes older than its retention,
// and a cursor below the newest dropped one may have missed it: the
// client resets and syncs again from 0.
if since > 0 {
trimmed, err := q.GetLibraryChangesTrimmedThrough(ctx)
if err != nil {
writeErrWithLog(w, h.logger, "library_sync: GetLibraryChangesTrimmedThrough", err)
return
}
if since < trimmed {
writeErr(w, &apierror.Error{
Status: http.StatusGone,
Code: "cursor_too_old",
Message: "reset client cursor to 0 and resync",
})
return
}
// A cursor past anything this server has handed out comes from a
// server restored from an older backup: the changes between the
// backup and the cursor are gone, and the ones made from here on would
// reuse those ids. The app resets the same way.
lastCursor, err := q.GetLastIssuedLibraryChangeCursor(ctx)
if err != nil {
writeErrWithLog(w, h.logger, "library_sync: GetLastIssuedLibraryChangeCursor", err)
return
}
if since > lastCursor {
writeErr(w, &apierror.Error{
Status: http.StatusGone,
Code: "cursor_ahead",
Message: "this server holds no changes up to that cursor; 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(toArtistSyncView(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
}
gains, err := library.ReplayGainForAlbums(ctx, q, uuids)
if err != nil {
return nil, err
}
for _, a := range albums {
b, _ := json.Marshal(toAlbumSyncView(a, gains[a.ID]))
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
}
gains, err := library.ReplayGainForTracks(ctx, q, uuids)
if err != nil {
return nil, err
}
for _, t := range tracks {
b, _ := json.Marshal(toTrackSyncView(t, gains[t.ID]))
out["track"] = append(out["track"], b)
}
}
userIDStr := syncpkg.FormatUUID(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 syncpkg.FormatUUID(p.UserID) != userIDStr && !p.IsPublic {
continue
}
b, _ := json.Marshal(toPlaylistSyncView(p))
out["playlist"] = append(out["playlist"], b)
}
}
if rows := ids[syncpkg.EntityPlaylistTrack]; len(rows) > 0 {
// playlist_track ids are "<playlist>:<track>" — 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, <entityKey>} 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
}
// 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}
}