diff --git a/migrations/0009_story_audiobook.sql b/migrations/0009_story_audiobook.sql new file mode 100644 index 0000000..5dc4e31 --- /dev/null +++ b/migrations/0009_story_audiobook.sql @@ -0,0 +1,7 @@ +-- A story can have a single stitched audiobook — all its chapter +-- renders concatenated into one chaptered file (see the m4b built +-- from per-chapter narration_runs). audiobook_path is the path the +-- web server serves it from (e.g. /audio/The-Coast-Down.m4b); NULL +-- means no audiobook has been stitched yet. +ALTER TABLE stories + ADD COLUMN IF NOT EXISTS audiobook_path text; diff --git a/skald/src/web.rs b/skald/src/web.rs index 6f254f1..32fe495 100644 --- a/skald/src/web.rs +++ b/skald/src/web.rs @@ -45,6 +45,7 @@ pub fn router(state: WebState) -> Router { "/stories/{id}/chapters/{n}/narrate", post(chapter_narrate_fire), ) + .route("/stories/{id}/listen", get(listen_view)) .route("/stories/{id}/runs", get(runs_view)) .nest_service("/audio", ServeDir::new(audio_dir)) .with_state(Arc::new(state)) @@ -402,7 +403,18 @@ async fn story_detail( .await .unwrap_or_default(); - let panel = story_panel(&story, &chapters, &characters, &canon_facts); + let has_audiobook: bool = sqlx::query_scalar::<_, Option>( + "SELECT audiobook_path FROM stories WHERE id = $1", + ) + .bind(id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .flatten() + .is_some(); + + let panel = story_panel(&story, &chapters, &characters, &canon_facts, has_audiobook); Ok(Html(render_shell(&stories, Some(id), panel).into_string())) } @@ -513,6 +525,73 @@ async fn chapter_narrate_fire( Ok(Redirect::to(&format!("/stories/{id}/chapters/{n}"))) } +/// One chapter as the audiobook player sees it: its start offset +/// (seconds from the top of the stitched file) and its length. +#[derive(Debug, Clone)] +struct AudiobookChapter { + n: i32, + title: Option, + offset_seconds: f64, + duration_seconds: f64, +} + +async fn listen_view( + State(state): State>, + Path(id): Path, +) -> Result, StatusCode> { + let stories = fetch_stories(&state.pool).await; + let Some(story) = stories.iter().find(|s| s.id == id).cloned() else { + return Err(StatusCode::NOT_FOUND); + }; + + let audiobook_path: Option = sqlx::query_scalar::<_, Option>( + "SELECT audiobook_path FROM stories WHERE id = $1", + ) + .bind(id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .flatten(); + + // Per-chapter durations come from each chapter's most recent + // succeeded narration_run. Summed cumulatively in chapter order + // they give every chapter's start offset inside the stitched + // file — the same order the file was stitched in. + let rows: Vec<(i32, Option, Option)> = sqlx::query_as( + r#" + SELECT c.n, c.title, + (SELECT nr.duration_seconds FROM narration_runs nr + WHERE nr.chapter_id = c.id AND nr.status = 'succeeded' + AND nr.duration_seconds IS NOT NULL + ORDER BY nr.ended_at DESC LIMIT 1) + FROM chapters c + WHERE c.story_id = $1 + ORDER BY c.n + "#, + ) + .bind(id) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let mut chapters = Vec::new(); + let mut cursor = 0.0_f64; + for (n, title, dur) in rows { + let d = dur.unwrap_or(0.0) as f64; + chapters.push(AudiobookChapter { + n, + title, + offset_seconds: cursor, + duration_seconds: d, + }); + cursor += d; + } + + let panel = audiobook_panel(&story, audiobook_path.as_deref(), &chapters); + Ok(Html(render_shell(&stories, Some(id), panel).into_string())) +} + async fn runs_view( State(state): State>, Path(id): Path, @@ -714,6 +793,7 @@ fn story_panel( chapters: &[(i32, Option, i32, bool)], characters: &[(String, String, String)], canon_facts: &[(String, String, String)], + has_audiobook: bool, ) -> Markup { let real_chars: Vec<_> = characters.iter().filter(|c| c.1 == "real").collect(); let fictional_chars: Vec<_> = characters.iter().filter(|c| c.1 == "fictional").collect(); @@ -731,6 +811,9 @@ fn story_panel( } nav.story-actions { a.action-primary href=(format!("/stories/{}/continue", s.id)) { "✦ continue this saga" } + @if has_audiobook { + a.action-listen href=(format!("/stories/{}/listen", s.id)) { "♪ listen" } + } a.action-secondary href=(format!("/stories/{}/runs", s.id)) { "generation log →" } } @@ -977,6 +1060,96 @@ fn runs_panel(story_id: Uuid, runs: &[(Uuid, String, String, DateTime, Opti } } +/// Audiobook player — one `