feat(audiobooks): Batch A — foundation, m4b ingest, playback sync, web player

Phase 1 (foundation): 'audiobooks' library type seeded (schema + DB);
audio_metadata JSONB on media_items (the kobo_metadata precedent —
only audio paths read it); reading_progress.audio_offset_seconds for
the honest {chapter, offset} locator; extensions/mimes registered for
ALL breadth formats (m4b/m4a/mp3/flac — deliberate per the DOC/LIT
doctrine, m4b scans richly first); classifyFormatGroup 'audiobook'
case (gotcha 39 recomputes on rescan); Go mime table extended at boot
so media elements accept the streams; ffmpeg/ffprobe in the image
(8.1.2 — also the xHE-AAC identification tripwire).

Phase 2 (ingest): sidecar-first (Libation/ABS metadata.json —
narrators/ASIN/curated chapters), ffprobe fallback/filler (duration,
bitrate, codec/profile, embedded chapters). Fixed three real
parser brittlenesses the Libation sidecar exposed: series accepts
string OR array-of-objects, publishedYear accepts number OR string,
ffprobe chapter timings accept number OR string (flexFloat). Folder-
structure fallback now runs on the UPDATE path too (rescans were
clearing sidecar-absent series/author); audio facts re-derive on
every rescan (gotcha 39).

Phase 3 (playback + sync): GET /api/media-items/:id/chapters (the
format-abstraction list); streaming rides the existing cookie-authed
Range /uploads path; PUT progress carries audio_offset_seconds +
elapsed_seconds (honest wall-clock playing time) — the reading-
history capture prefers player-reported elapsed over the inter-push
delta, same 2h ceiling; reading_mode='audio' marks the row.

Phase 4 (web player): dedicated audio reader page (foliate not
involved) — cover, chapter title, whole-book scrubber, skip ± (chips
1/5/10/15/30s, default 10s), speed (0.75×–2×), chapter list with
jump-to, Media Session lockscreen/notification transport, periodic
playing-only progress pushes, position restore on open.

Verified live: ingest of the Libation sample (Gardens of the Moon —
title/author/series/cover/date from sidecar+folders, 34 chapters,
26h duration, aac/LC); chapters endpoint; the reader route serving
the player; playback with position restore (0:57 round-trip);
progress rows in audio mode; wall-clock session rows in
reading_history.
This commit is contained in:
John O'Keefe committed 2026-10-15 16:14:34 -04:00
1 parent a093529b80
commit 902cbe5915
20 files changed
+1425 -212

No files matched your search

+22
View File
@@ -31,6 +31,10 @@ type ProgressData struct {
Panel *int `json:"panel,omitempty"`
ReadingMode *string `json:"reading_mode,omitempty"`
TotalCharacters *int64 `json:"total_characters,omitempty"`
// Audiobook locator: seconds into the current chapter. Pairs with
// Chapter; the anti-reset guard still owns pushes that would drag
// the percentage backwards.
AudioOffsetSeconds *int `json:"audio_offset_seconds,omitempty"`
}
// DeviceProgress represents progress from a specific device
@@ -311,6 +315,14 @@ type SaveProgressRequest struct {
ScrollY *float64
PanelNumber *int
ReadingMode *string
// Audiobook locator (seconds into the current chapter)
AudioOffsetSeconds *int
// 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
DeviceType string
DeviceName string
@@ -358,6 +370,7 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
params.ContextText = existing.ContextText
params.Chapter = existing.Chapter
params.ChapterProgress = existing.ChapterProgress
params.AudioOffsetSeconds = existing.AudioOffsetSeconds
params.ViewportX = existing.ViewportX
params.ViewportY = existing.ViewportY
params.ZoomLevel = existing.ZoomLevel
@@ -389,6 +402,9 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
if req.ChapterProgress != nil {
params.ChapterProgress = pgtype.Float8{Float64: *req.ChapterProgress, Valid: true}
}
if req.AudioOffsetSeconds != nil {
params.AudioOffsetSeconds = pgtype.Int4{Int32: int32(*req.AudioOffsetSeconds), Valid: true}
}
if req.CurrentPage != nil {
params.CurrentPage = pgtype.Int4{Int32: int32(*req.CurrentPage), Valid: true}
}
@@ -628,6 +644,12 @@ func (s *ProgressService) captureReadingSession(ctx context.Context, req SavePro
seconds = elapsed // verified-continuous span
} // else: idle gap — marker row, zero time
}
// Wall-clock override: honest player-reported playing time beats
// the inter-push delta (pauses shouldn't count as reading). Same
// 2h ceiling applies.
if req.AudioElapsedSeconds != nil && *req.AudioElapsedSeconds > 0 && *req.AudioElapsedSeconds <= maxAttributedSessionSeconds {
seconds = *req.AudioElapsedSeconds
}
}
// Pages read: only when both sides carry a real page index (0 means