Files
minstrel/internal/api/library_sync.go
T
bvandeusenandClaude Opus 5.5 29042e4eb2
release / govulncheck (push) Successful in 29s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m26s
release / android (push) Successful in 5m27s
release / Build signed APK (push) Successful in 5m44s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: a new Minstrel restores a backup, from the web app or the command line, then rescans (#6775)
A new server (no accounts, no library) offers "Restore a backup" beside
"Create account". The file goes up as the request body to
POST /api/auth/restore with the setup token from the log in X-Setup-Token,
since the backup's admins become this server's. It loads in one transaction,
then a library scan starts and finds each file at its path. The same load runs
offline as `minstrel restore FILE`, which migrates the schema first.

That path alone gets a 1 GiB, 15-minute body allowance instead of 4 MiB and
30s, and skips the JSON-only check for cookie writes: a stale cookie from the
old server would otherwise refuse the upload, and the custom header already
rules out a cross-site form.

The library sync answers 410 cursor_ahead to a cursor past the last one the
server has handed out, read from the change sequence, so an app synced with
the old server after the backup was made starts over instead of skipping the
ids the restored server reuses. Restore now leaves each sequence as having
issued the restored maximum, so that reading holds; a compacted log still
counts its handed-out cursors.

backup.Read keeps the underlying error in its chain, so an oversized upload
answers 413 rather than "not a backup". The hosting docs say how to put the
music back first and what is entered again afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:10:06 -05:00

288 lines
8.5 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 — 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
}
// 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}
}