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

+337
View File
@@ -0,0 +1,337 @@
// Audio reader (web audiobook player — handoff item 3 Batch A).
// Player settings live IN the player as quick controls and persist via
// localStorage (skip duration, playback speed); the settings-section
// placement question stays open in the handoff.
//
// Progress sync: pushes {percentage, chapter, audio_offset_seconds,
// elapsed_seconds} every 30s WHILE PLAYING and on pause/hide. The
// elapsed seconds are honest wall-clock playing time — pauses never
// count — and the server keeps the 2h attributed ceiling.
//
// Media Session: lockscreen/notification transport controls (play,
// pause, skip backward/forward honoring the skip setting, chapter
// tracks) — the mobile-web native feel from handoff item 6.
import { Alpine } from "./alpine";
import { showToast } from "./toast";
type AudioChapter = { index: number; title: string; start: number; end: number };
const PROGRESS_INTERVAL_MS = 30_000;
const PLAYING_THRESHOLD = 0.5; // seconds of audio that count as "playing"
interface AudioConfig {
chapterEndpoint: string;
progressEndpoint: string;
}
function readConfig(): AudioConfig {
const body = document.body as HTMLElement;
return JSON.parse(body.dataset.json || "{}");
}
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem("token");
return token ? { Authorization: `Bearer ${token}` } : {};
}
function fmtTime(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
return `${m}:${String(sec).padStart(2, "0")}`;
}
Alpine.data("audioReader", () => ({
mediaItemId: "",
title: "",
chapters: [] as AudioChapter[],
chaptersOpen: false,
currentTime: 0,
playing: false,
speed: Number(localStorage.getItem("audio_speed") || 1),
skipSeconds: Number(localStorage.getItem("audio_skip") || 10),
// chapters are absolute seconds into the book; the scrubber spans the
// whole book, not per-chapter
bookDuration: 0,
currentChapterIdx: 0,
// progress-push bookkeeping
pendingElapsed: 0,
lastPush: Date.now(),
pushTimer: undefined as ReturnType<typeof setInterval> | undefined,
errorMessage: "",
initialized: false,
init() {
if (this.initialized) return; // Alpine can re-init the body tree
this.initialized = true;
window.addEventListener("error", (e) => {
document.title = "ERR: " + (e.message || "unknown");
});
window.addEventListener("unhandledrejection", (e) => {
document.title = "REJ: " + (e.reason?.message || String(e.reason));
});
const body = document.body as HTMLElement;
const config = readConfig();
const mediaId = body.getAttribute("data-media-item-id") || "";
const title = body.getAttribute("data-title") || "";
const cover = body.getAttribute("data-cover") || "";
const fileUrl = body.getAttribute("data-file-url") || "";
this.mediaItemId = mediaId;
this.title = title;
const audio = this.audio();
if (!audio) return;
audio.src = fileUrl;
audio.playbackRate = Number(this.speed) || 1;
audio.preload = "auto";
// Restore position: chapters + progress from the server.
this.restorePosition();
// Periodic progress push (playing-only; pause/hide push too)
this.startPushTimer();
// Media Session (lockscreen/notification transport).
if ("mediaSession" in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title,
album: "Bookhoard",
artwork: cover ? [{ src: cover, sizes: "512x512" }] : [],
});
navigator.mediaSession.setActionHandler("play", () => this.togglePlay());
navigator.mediaSession.setActionHandler("pause", () => this.togglePlay());
navigator.mediaSession.setActionHandler("seekbackward", () => this.skip(-1));
navigator.mediaSession.setActionHandler("seekforward", () => this.skip(1));
navigator.mediaSession.setActionHandler("previoustrack", () => this.stepChapter(-1));
navigator.mediaSession.setActionHandler("nexttrack", () => this.stepChapter(1));
}
// Element events.
audio.addEventListener("timeupdate", () => {
this.currentTime = audio.currentTime;
this.tickChapter();
this.pendingElapsed = Math.max(
this.pendingElapsed,
Math.round(audio.currentTime - this.sessionStart),
);
});
audio.addEventListener("play", () => {
this.playing = true;
this.sessionStart = audio.currentTime;
this.updateMediaSessionState();
});
audio.addEventListener("pause", () => {
this.playing = false;
this.pushProgress(true);
this.updateMediaSessionState();
});
window.addEventListener("pagehide", () => this.pushProgress(true));
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") this.pushProgress(true);
});
// Spacebar = play/pause (the reader's keyboard contract, audio flavor).
document.addEventListener("keydown", (e) => {
const target = e.target as HTMLElement | null;
if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA") return;
if (e.code === "Space") {
e.preventDefault();
this.togglePlay();
} else if (e.code === "ArrowLeft") {
e.preventDefault();
this.skip(-1);
} else if (e.code === "ArrowRight") {
e.preventDefault();
this.skip(1);
}
});
},
audio(): HTMLAudioElement | null {
return document.getElementById("audio-element") as HTMLAudioElement | null;
},
async restorePosition() {
try {
const [chaptersResp, progress] = await Promise.all([
fetch(readConfig().chapterEndpoint, { headers: getAuthHeaders() }),
fetch(
`/api/media-items/${this.mediaItemId}/progress`,
{ headers: getAuthHeaders() },
),
]);
if (chaptersResp.ok) {
const data = await chaptersResp.json();
this.chapters = (data.chapters || []) as AudioChapter[];
this.bookDuration = data.duration_seconds || this.chapterEndEstimate();
}
if (progress.ok) {
const p = await progress.json();
const chapter = Number(p.chapter ?? 0);
const offset = Number(p.audio_offset_seconds ?? 0);
if (Number.isFinite(p.percentage) && this.bookDuration > 0) {
const at = Number(p.percentage) * this.bookDuration;
this.seekTo(at);
} else if (chapter > 0 && this.chapters[chapter]?.start != null) {
this.seekTo(this.chapters[chapter].start + offset);
}
this.currentChapterIdx = Math.max(0, chapter);
}
} catch {
// Offline start: still playable; sync resumes when reachable.
}
},
chapterEndEstimate(): number {
const last = this.chapters[this.chapters.length - 1];
return last ? Number(last.end) || 0 : 0;
},
tickChapter() {
// Keep currentChapterIdx pointing at the chapter containing the playhead.
for (let i = this.chapters.length - 1; i >= 0; i -= 1) {
const ch = this.chapters[i];
if (this.currentTime >= ch.start) {
if (this.currentChapterIdx !== i) {
this.currentChapterIdx = i;
this.updateMediaSessionMetadata();
}
return;
}
}
},
chapterTitle(): string {
const ch = this.chapters[this.currentChapterIdx];
return ch
? ch.title || `Chapter ${ch.index + 1}`
: this.title;
},
currentChapter(): number {
return this.chapters.length ? this.currentChapterIdx : -1;
},
chaptersDuration(): number {
return this.bookDuration || this.chapterEndEstimate();
},
togglePlay() {
const audio = this.audio();
if (!audio) return;
if (audio.paused) audio.play().catch(() => showToast("Playback failed to start", "error"));
else audio.pause();
},
seekTo(seconds: number | string) {
const audio = this.audio();
if (!audio) return;
const target = Number(seconds);
if (Number.isFinite(target)) audio.currentTime = Math.max(0, target);
this.currentTime = audio.currentTime;
},
skip(direction: 1 | -1) {
const audio = this.audio();
if (!audio) return;
audio.currentTime = Math.max(0, audio.currentTime + direction * (Number(this.skipSeconds) || 10));
},
stepChapter(delta: 1 | -1) {
const next = this.currentChapterIdx + delta;
if (next < 0 || next >= this.chapters.length) return;
this.jumpToChapter(next);
},
jumpToChapter(index: number) {
const ch = this.chapters[index];
if (ch) this.seekTo(ch.start);
this.currentChapterIdx = index;
},
setSpeed(rate: number) {
this.speed = rate;
localStorage.setItem("audio_speed", String(rate));
const audio = this.audio();
if (audio) audio.playbackRate = rate;
},
setSkip(seconds: number) {
this.skipSeconds = seconds;
localStorage.setItem("audio_skip", String(seconds));
},
updateMediaSessionMetadata() {
if (!("mediaSession" in navigator)) return;
const ch = this.chapters[this.currentChapterIdx];
navigator.mediaSession.metadata = new MediaMetadata({
title: ch ? ch.title || this.title : this.title,
artist: this.title,
album: "Bookhoard",
});
},
updateMediaSessionState() {
if (!("mediaSession" in navigator)) return;
navigator.mediaSession.playbackState = this.playing ? "playing" : "paused";
},
// ---- progress sync ----
async pushProgress(exit = false) {
if (this.chaptersDuration() <= 0) return;
const audio = this.audio();
if (!audio) return;
const at = audio.currentTime;
let chapter = 0;
let offset = 0;
for (let i = 0; i < this.chapters.length; i += 1) {
if (at >= this.chapters[i].start) {
chapter = i;
offset = Math.max(0, Math.round(at - this.chapters[i].start));
}
}
const playing = this.playing && audio.currentTime - this.sessionStart > PLAYING_THRESHOLD;
const elapsed = Math.max(0, Math.round(this.pendingElapsed));
const body: Record<string, unknown> = {
percentage: this.chaptersDuration() ? at / this.chaptersDuration() : 0,
chapter,
audio_offset_seconds: offset,
elapsed_seconds: playing || exit ? elapsed : 0,
reading_mode: "audio",
};
this.pendingElapsed = 0;
try {
await fetch(`/api/media-items/${this.mediaItemId}/progress`, {
method: "PUT",
headers: {
...getAuthHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
this.lastPush = Date.now();
if (exit) clearInterval(this.pushTimer);
} catch {
// non-critical; the next tick syncs
}
},
startPushTimer() {
this.pushTimer = setInterval(() => {
const audio = this.audio();
if (audio?.paused) return; // paused = nothing to sync
this.pushProgress();
}, PROGRESS_INTERVAL_MS);
},
fmtTime,
}));
export { fmtTime };
+1
View File
@@ -2,6 +2,7 @@ import "./alpine";
import { Alpine } from "./alpine";
import "./admin";
import "./audio";
import "./api";
import "./api-explorer";
import "./api-explorer-docs";