refactor(progress): SaveProgressRequest becomes the canonical PUT payload; GET serializes from the row

Batch H1 steps 1-2 of the hardening plan. Wire contracts frozen; the
pinned shapes are unchanged modulo the whitelisted additive GET keys
(docs/contract_pins.md).

1. One canonical request struct. The web PUT binds SaveProgressRequest
   directly (identity/attribution fields are json:"-" so they stay
   server-owned) and copies its server fields once — the hand-rolled DTO
   and the 20-line field-copy block are gone. Parse set for existing
   clients is IDENTICAL to the old DTO (verified in web/src: the ebook
   reader sends percentage/current_page/total_pages/reading_mode/epubcfi/
   context_text/chapter/zoom_level — reader.ts:3113-3143; the audiobook
   reader sends percentage/chapter/audio_offset_seconds/elapsed_seconds/
   reading_mode — audio.ts:518-520).

2. GET /api/media-items/:id/progress now serializes through one
   row-struct-driven builder instead of a hand-map, so a row column
   cannot be silently forgotten again (the audio_offset_seconds
   omission). Existing keys are verbatim; the row already carried
   audio_offset_seconds/reading_mode/viewport_x-y/zoom_level/
   scroll_position_x-y/panel_number and they surface as WHITELISTED
   additive keys. The web audiobook reader already reads
   audio_offset_seconds (audio.ts:219) — with the key absent,
   durationless books resumed at chapter start (offset always 0); they
   now resume correctly. The conditional context_text bolt-on and the
   server-derived css_selector/anchor_href block are unchanged —
   restore precedence stays percentage-first, never re-derived.
This commit is contained in:
John O'Keefe committed 2026-10-16 17:22:32 -04:00
1 parent a2ea34232c
commit 28bd323505
3 files changed
+93 -98

No files matched your search

+29 -25
View File
@@ -294,39 +294,43 @@ func NewProgressService(db *database.Queries, connManager *ConnectionManager) *P
return &ProgressService{db: db, connManager: connManager}
}
// SaveProgressRequest is the CANONICAL progress write payload — the web
// PUT binds it directly (identity fields are json:"-": server-owned, never
// client-settable) and every internal writer fills it programmatically.
// Wire keys are frozen: only additive json names are allowed here.
type SaveProgressRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
Source string
DeviceID pgtype.UUID
MediaItemID pgtype.UUID `json:"-"`
UserID pgtype.UUID `json:"-"`
Source string `json:"-"`
DeviceID pgtype.UUID `json:"-"`
Percentage *float64
Epubcfi *string
ContextText *string
CharacterOffset *int64
Chapter *int
ChapterProgress *float64
CurrentPage *int
TotalPages *int
ViewportX *float64
ViewportY *float64
ZoomLevel *float64
ScrollX *float64
ScrollY *float64
PanelNumber *int
ReadingMode *string
Percentage *float64 `json:"percentage"`
Epubcfi *string `json:"epubcfi"`
ContextText *string `json:"context_text"`
CharacterOffset *int64 `json:"character_offset"`
Chapter *int `json:"chapter"`
ChapterProgress *float64 `json:"chapter_progress"`
CurrentPage *int `json:"current_page"`
TotalPages *int `json:"total_pages"`
ViewportX *float64 `json:"viewport_x"`
ViewportY *float64 `json:"viewport_y"`
ZoomLevel *float64 `json:"zoom_level"`
ScrollX *float64 `json:"scroll_position_x"`
ScrollY *float64 `json:"scroll_position_y"`
PanelNumber *int `json:"panel_number"`
ReadingMode *string `json:"reading_mode"`
// Audiobook locator (seconds into the current chapter)
AudioOffsetSeconds *int
AudioOffsetSeconds *int `json:"audio_offset_seconds"`
// AudioElapsedSeconds: the player's honest wall-clock PLAYING time
// since the last push (audio pushes fire while playing; pauses don't
// count). The capture prefers it over the inter-push delta, capped to
// the same 2h attributed ceiling.
AudioElapsedSeconds *int32
AudioElapsedSeconds *int32 `json:"elapsed_seconds"`
DeviceType string
DeviceName string
Broadcast bool
DeviceType string `json:"-"`
DeviceName string `json:"-"`
Broadcast bool `json:"-"`
// ClearLocator declares that this write INTENDS to carry no position
// locator: percentage-only writers (devices whose converter declined,
@@ -336,7 +340,7 @@ type SaveProgressRequest struct {
// client-side far-anchor restore floor. Locater-less rows are honest:
// percentage restores are approximate, and the next anchor-bearing
// write re-establishes the pair. Request-supplied fields always win.
ClearLocator bool
ClearLocator bool `json:"-"`
}
func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequest) (database.ReadingProgress, error) {