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:
1 parent
a093529b80
commit
902cbe5915
20 files changed
+1425
-212
No files matched your search
@@ -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 };
|
||||
@@ -2,6 +2,7 @@ import "./alpine";
|
||||
import { Alpine } from "./alpine";
|
||||
|
||||
import "./admin";
|
||||
import "./audio";
|
||||
import "./api";
|
||||
import "./api-explorer";
|
||||
import "./api-explorer-docs";
|
||||
|
||||
Reference in new issue
Block a user