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
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>
288 lines
8.5 KiB
Go
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}
|
|
}
|