1use std::collections::HashMap;
4use std::convert::Infallible;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use axum::extract::{Form, Path as UrlPath, Query, State};
9use axum::http::{header, HeaderName, StatusCode};
10use axum::response::sse::{Event, KeepAlive, Sse};
11use axum::response::{Html, IntoResponse, Response};
12use tokio_stream::wrappers::UnboundedReceiverStream;
13use tokio_stream::StreamExt;
14use xil_core::workspace::{active_show, resolve_slug, resolve_venv_python, workspace_root};
15
16use crate::html::{checkbox, esc, labelled_options, number_input, options, status, text_input};
17use crate::runner::{self, DawOpts, ParseOpts, ProduceOpts};
18use crate::{
19 activity, audio, configs, entries, episodes, grades, parsed, scripts, AppState, JobEvent,
20};
21
22type Params = HashMap<String, String>;
23type Shared = State<Arc<AppState>>;
24
25const INDEX: &str = include_str!("../assets/index.html");
26
27fn param<'a>(p: &'a Params, key: &str) -> &'a str {
28 p.get(key).map(String::as_str).unwrap_or("")
29}
30
31fn on(p: &Params, key: &str) -> bool {
32 p.contains_key(key)
33}
34
35fn int(p: &Params, key: &str, default: i64) -> i64 {
36 p.get(key)
37 .and_then(|v| v.trim().parse::<f64>().ok())
38 .map(|f| f as i64)
39 .unwrap_or(default)
40}
41
42async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
44 tokio::task::spawn_blocking(f)
45 .await
46 .expect("blocking task panicked")
47}
48
49fn log_pre(text: &str) -> Html<String> {
50 Html(format!("<pre class=\"log\">{}</pre>", esc(text)))
51}
52
53fn with_trigger(body: String, event: &'static str) -> Response {
55 ([(HeaderName::from_static("hx-trigger"), event)], Html(body)).into_response()
56}
57
58fn default_python(venv: &str) -> String {
61 resolve_venv_python(venv, None, None).unwrap_or_default()
62}
63
64fn config_tab(kind: &str, title: &str, choices: &[String]) -> String {
72 let first = choices.first().cloned();
73 let content = first
74 .as_deref()
75 .map(|p| configs::load_config_file(p, kind_label(kind)))
76 .unwrap_or_default();
77 format!(
78 r##"<section class="tab" data-group="main" data-tab="{kind}">
79 <form id="{kind}-form">
80 <label class="field"><span>{title}</span>
81 <select name="path" id="{kind}-path" hx-get="/config/{kind}/load" hx-trigger="change" hx-target="#{kind}-editor" hx-swap="outerHTML">{opts}</select>
82 </label>
83 <textarea id="{kind}-editor" name="text" rows="30">{content}</textarea>
84 <div class="row">
85 <button type="button" class="small" hx-get="/config/{kind}/load" hx-include="#{kind}-path" hx-target="#{kind}-editor" hx-swap="outerHTML">↺ Reload</button>
86 <button type="button" class="small primary" hx-post="/config/{kind}/save" hx-include="#{kind}-form" hx-target="#{kind}-status">💾 Save</button>
87 </div>
88 <div id="{kind}-status"></div>
89 </form>
90</section>"##,
91 opts = options(choices, first.as_deref()),
92 content = esc(&content),
93 )
94}
95
96fn kind_label(kind: &str) -> &'static str {
97 match kind {
98 "speakers" => "speakers",
99 "cast" => "cast",
100 _ => "sfx",
101 }
102}
103
104fn is_config_kind(kind: &str) -> bool {
105 matches!(kind, "speakers" | "cast" | "sfx")
106}
107
108fn config_choice_list(state: &AppState, kind: &str) -> Option<Vec<String>> {
109 let c = state.choices();
110 match kind {
111 "speakers" => Some(c.speakers.clone()),
112 "cast" => Some(c.cast.clone()),
113 "sfx" => Some(c.sfx.clone()),
114 _ => None,
115 }
116}
117
118fn produce_form() -> String {
119 let cb_default = default_python("venv-chatterbox");
120 let mm_default = default_python("venv-mmaudio");
121 format!(
122 r##"<div class="row">{dry}
123 <label class="field"><span>--backend (dialogue voice generator)</span>
124 <select name="backend" id="prod-backend"><option>elevenlabs</option><option>gtts</option><option selected>chatterbox-turbo</option></select>
125 </label></div>
126<div class="row">{sfx}{music}{amb}{local}{terse}</div>
127<label class="field"><span>--sfx-backend (SFX / music / ambience generator)</span>
128 <select name="sfx_backend"><option selected>elevenlabs</option><option>mmaudio</option></select>
129</label>
130{mm_python}
131{mm_nc}
132<div class="row">{start}{stop}</div>
133{seq_list}
134{cb_python}
135{device}
136<div class="row">{force}</div>"##,
137 dry = checkbox("dry_run", "--dry-run", true),
138 sfx = checkbox("gen_sfx", "--gen-sfx", false),
139 music = checkbox("gen_music", "--gen-music", false),
140 amb = checkbox("gen_ambience", "--gen-ambience", false),
141 local = checkbox("local_only", "--local-only", true),
142 terse = checkbox("terse", "--terse", false),
143 mm_python = text_input("mmaudio_python", "--mmaudio-python (blank = auto-detect venv-mmaudio/)", &mm_default),
144 mm_nc = checkbox(
145 "mmaudio_accept_nc",
146 "--mmaudio-accept-noncommercial ⚠️ MMAudio weights are CC BY-NC 4.0 — generated audio must NOT be used commercially",
147 false
148 ),
149 start = number_input("start_from", "--start-from (seq, 0 = beginning)", 0),
150 stop = number_input("stop_at", "--stop-at (seq, 0 = all)", 0),
151 seq_list = text_input(
152 "seq_list",
153 "--seq-list (comma-separated seqs, e.g. 12,47,48 — overrides start/stop, dialogue only)",
154 "",
155 ),
156 cb_python = text_input("chatterbox_python", "--chatterbox-python (blank = auto-detect venv-chatterbox/)", &cb_default),
157 device = "<label class=\"field\"><span>--device (Chatterbox Turbo only — falls back to cpu on its own if cuda isn't available; force cpu here to skip the GPU on purpose, e.g. it's busy)</span><select name=\"device\"><option selected>cuda</option><option>cpu</option></select></label>",
158 force = checkbox("force", "--force ⚠️ overwrite existing stems (API cost!)", false),
159 )
160}
161
162fn script_meta(h: &scripts::HeaderFields) -> String {
163 let ro = |label: &str, v: &str| {
164 format!(
165 "<label class=\"field\"><span>{}</span><input readonly value=\"{}\"></label>",
166 esc(label),
167 esc(v)
168 )
169 };
170 format!(
171 "<p><b>Derived metadata</b></p><div class=\"row\">{}{}{}</div><div class=\"row\">{}{}</div>\
172 <label class=\"field\"><span>Filename (editable)</span><input name=\"filename\" value=\"{}\"></label>",
173 ro("Show", &h.show),
174 ro("Season", &h.season),
175 ro("Episode", &h.episode),
176 ro("Title", &h.title),
177 ro("Arc / Season Title", &h.arc),
178 esc(&h.filename)
179 )
180}
181
182pub async fn index(State(state): Shared) -> Html<String> {
184 Html(
185 blocking(move || {
186 let workspace = workspace_root().to_string_lossy().into_owned();
187 let c = state.choices();
188 let (episodes, shows, scripts) = (&c.episodes, &c.shows, &c.scripts);
189 let active = configs::active_show_name(shows);
190 let configs_html = [
191 config_tab("speakers", "Speakers file", &c.speakers),
192 config_tab("cast", "Cast config file", &c.cast),
193 config_tab("sfx", "SFX config file", &c.sfx),
194 ]
195 .join("\n");
196 let speakers_placeholder = "configs/the413/speakers.json";
197 INDEX
198 .replace("@@WORKSPACE@@", &esc(&workspace))
199 .replace("@@SHOW_OPTIONS@@", &options(shows, active.as_deref()))
200 .replace("@@EPISODE_OPTIONS@@", &options(episodes, None))
201 .replace(
202 "@@AUDIO_EPISODE_OPTIONS@@",
203 &options(episodes, episodes.first().map(String::as_str)),
204 )
205 .replace("@@SCRIPT_OPTIONS@@", &options(scripts, None))
206 .replace(
207 "@@SCRIPT_META@@",
208 &script_meta(&scripts::HeaderFields::default()),
209 )
210 .replace(
211 "@@SCAN_SPEAKERS@@",
212 &text_input(
213 "speakers",
214 "--speakers (optional override)",
215 speakers_placeholder,
216 ),
217 )
218 .replace(
219 "@@SCAN_JSON@@",
220 &checkbox("json", "--json (machine-readable output)", false),
221 )
222 .replace(
223 "@@PARSE_SPEAKERS@@",
224 &text_input(
225 "speakers",
226 "--speakers (optional override)",
227 speakers_placeholder,
228 ),
229 )
230 .replace(
231 "@@PARSE_OPTIONS@@",
232 &[
233 number_input("preview", "--preview (show first N entries, 0 = all)", 0),
234 checkbox("quiet", "--quiet (JSON only, skip summary)", false),
235 checkbox("debug", "--debug (write diagnostic CSV)", true),
236 checkbox(
237 "stats",
238 "--stats (per-speaker line/word/char distribution)",
239 false,
240 ),
241 ]
242 .concat(),
243 )
244 .replace("@@PRODUCE_FORM@@", &produce_form())
245 .replace(
246 "@@ASSEMBLE_FORM@@",
247 &[
248 "<div class=\"row\">".to_string(),
249 number_input("gap_ms", "--gap-ms (silence between stems, ms)", 600),
250 "</div><div class=\"row\">".into(),
251 text_input(
252 "parsed",
253 "--parsed (override parsed JSON path, blank = auto)",
254 "parsed/the413/parsed_S01E01.json",
255 ),
256 text_input(
257 "output",
258 "--output (override master MP3 path, blank = auto)",
259 "masters/S01E01_the413_master.mp3",
260 ),
261 "</div>".into(),
262 ]
263 .concat(),
264 )
265 .replace(
266 "@@DAW_FORM@@",
267 &[
268 "<div class=\"row\">".to_string(),
269 checkbox("dry_run", "--dry-run", true),
270 number_input("gap_ms", "--gap-ms (ms)", 600),
271 "</div><div class=\"row\">".into(),
272 checkbox("timeline", "--timeline (ASCII)", false),
273 checkbox("timeline_html", "--timeline-html", true),
274 checkbox("macro", "--macro (Audacity)", true),
275 "</div>".into(),
276 text_input("output_dir", "--output-dir (blank = auto)", "daw/S01E01/"),
277 ]
278 .concat(),
279 )
280 .replace(
281 "@@MASTER_FORM@@",
282 &[
283 checkbox("dry_run", "--dry-run", true),
284 "<div class=\"row\">".into(),
285 text_input(
286 "output",
287 "--output (blank = auto)",
288 "masters/S01E01_the413_2026-04-26.mp3",
289 ),
290 text_input("daw_dir", "--daw-dir (blank = auto)", "daw/S01E01/"),
291 "</div>".into(),
292 ]
293 .concat(),
294 )
295 .replace("@@CONFIG_TABS@@", &configs_html)
296 .replace(
297 "@@PARSED_EPISODE_OPTIONS@@",
298 &options(episodes, episodes.first().map(String::as_str)),
299 )
300 .replace(
301 "@@PARSED_EDITOR@@",
302 &parsed_editor(
303 &episodes
304 .first()
305 .map(|e| parsed::load(e))
306 .unwrap_or_default(),
307 ),
308 )
309 })
310 .await,
311 )
312}
313
314pub async fn asset(UrlPath(name): UrlPath<String>) -> Response {
316 let (body, mime): (&'static str, &str) = match name.as_str() {
317 "htmx.min.js" => (include_str!("../assets/htmx.min.js"), "text/javascript"),
318 "app.js" => (include_str!("../assets/app.js"), "text/javascript"),
319 "app.css" => (include_str!("../assets/app.css"), "text/css"),
320 _ => return StatusCode::NOT_FOUND.into_response(),
321 };
322 ([(header::CONTENT_TYPE, mime)], body).into_response()
323}
324
325pub async fn episode_options(State(state): Shared, Query(p): Query<Params>) -> Html<String> {
330 let selected = param(&p, "ep").to_string();
331 Html(blocking(move || options(&state.choices().episodes, Some(&selected))).await)
332}
333
334pub async fn script_options(State(state): Shared) -> Html<String> {
336 Html(blocking(move || options(&state.choices().scripts, None)).await)
337}
338
339pub async fn show_options(State(state): Shared) -> Html<String> {
341 Html(
342 blocking(move || {
343 let c = state.choices();
344 options(&c.shows, configs::active_show_name(&c.shows).as_deref())
345 })
346 .await,
347 )
348}
349
350pub async fn invalidate_choices(State(state): Shared) -> StatusCode {
353 state.invalidate_choices();
354 StatusCode::NO_CONTENT
355}
356
357pub async fn setup_use(State(state): Shared, Form(p): Form<Params>) -> Response {
361 let show = param(&p, "show").to_string();
362 if show.is_empty() {
363 return Html(status("No show selected.")).into_response();
364 }
365 activity::log(&format!("USE show → {show}"));
366 let out = tokio::process::Command::new(&state.xil_exe)
367 .arg("use")
368 .arg(&show)
369 .current_dir(workspace_root())
370 .output()
371 .await;
372 let text = match out {
373 Ok(o) => {
374 let s = format!(
375 "{}{}",
376 String::from_utf8_lossy(&o.stdout),
377 String::from_utf8_lossy(&o.stderr)
378 );
379 let s = s.trim().to_string();
380 if s.is_empty() {
381 format!("Active show set to: {show}")
382 } else {
383 s
384 }
385 }
386 Err(e) => format!("Error: {e}"),
387 };
388 with_trigger(status(&text), "project-changed")
389}
390
391fn job_fragment(cmd: &[String], id: u64) -> Html<String> {
392 Html(format!(
393 "<pre class=\"log\" data-job=\"{id}\">{}</pre>",
394 esc(&runner::header(cmd))
395 ))
396}
397
398pub async fn setup_init(State(state): Shared, Form(p): Form<Params>) -> Html<String> {
400 let show = param(&p, "show");
401 if show.trim().is_empty() {
402 return log_pre("Show name is required.");
403 }
404 let cmd = runner::cmd_init(
405 &state.xil_exe,
406 show,
407 match param(&p, "type") {
408 "" => "podcast",
409 t => t,
410 },
411 param(&p, "season"),
412 param(&p, "season_title"),
413 );
414 let id = runner::start_job(&state, cmd.clone());
415 job_fragment(&cmd, id)
416}
417
418fn project_panel(content: &str, path: &Path, note: &str) -> String {
419 format!(
420 r##"<form id="project-form">
421 <label class="field"><span>File</span><input readonly value="{path}"></label>
422 <label class="field"><span>project.json</span><textarea name="text" rows="20">{content}</textarea></label>
423 <div class="row">
424 <button type="button" class="small" hx-get="/project" hx-target="#project-panel">↺ Reload</button>
425 <button type="button" class="small primary" hx-post="/project/save" hx-include="#project-form" hx-target="#project-status">💾 Save</button>
426 </div>
427 <div id="project-status">{note}</div>
428</form>"##,
429 path = esc(&path.to_string_lossy()),
430 content = esc(content),
431 )
432}
433
434pub async fn project_load() -> Html<String> {
436 Html(
437 blocking(|| {
438 let (content, path) = configs::load_project_json();
439 project_panel(&content, &path, "")
440 })
441 .await,
442 )
443}
444
445pub async fn project_save(Form(p): Form<Params>) -> Html<String> {
447 let text = param(&p, "text").to_string();
448 Html(status(
449 &blocking(move || configs::save_project_json(&text)).await,
450 ))
451}
452
453pub async fn scripts_analyze(Form(p): Form<Params>) -> Html<String> {
457 Html(script_meta(&scripts::analyze_script_header(param(
458 &p, "text",
459 ))))
460}
461
462pub async fn scripts_save(State(state): Shared, Form(p): Form<Params>) -> Response {
464 let (text, filename) = (
465 param(&p, "text").to_string(),
466 param(&p, "filename").to_string(),
467 );
468 let msg = blocking(move || scripts::save_script_file(&text, &filename)).await;
469 state.invalidate_choices();
470 with_trigger(status(&msg), "scripts-changed")
471}
472
473const NO_TAG: &str = "⚠️ No episode tag — run Parse first (Run Stage → Parse tab).";
476
477pub async fn run_stage(
480 State(state): Shared,
481 UrlPath(stage): UrlPath<String>,
482 Form(p): Form<Params>,
483) -> Html<String> {
484 let ep = param(&p, "ep").trim().to_string();
485 let exe = state.xil_exe.clone();
486 let cmd: Result<Vec<String>, String> = match stage.as_str() {
487 "scan" => {
488 if ep.is_empty() {
489 return log_pre("Select an episode first.");
490 }
491 let (slug, _) = episodes::parse_choice(&ep);
492 runner::cmd_scan(
493 &exe,
494 &slug,
495 param(&p, "script"),
496 param(&p, "speakers"),
497 on(&p, "json"),
498 )
499 }
500 "parse" => {
501 if ep.is_empty() {
502 return log_pre("Select an episode or type a new episode tag (e.g. S04E04).");
503 }
504 let (mut slug, mut tag) = episodes::parse_choice(&ep);
505 if tag.is_empty() {
506 if episodes::looks_like_tag(&ep) {
507 slug = active_show().filter(|s| !s.is_empty()).unwrap_or_else(|| {
508 resolve_slug(
509 None,
510 &workspace_root().join("project.json").to_string_lossy(),
511 )
512 });
513 tag = ep.clone();
514 } else {
515 return log_pre(
516 "⚠️ Show selected but no episode tag. Type an episode tag in the field above (e.g. S01E01) and try again.",
517 );
518 }
519 }
520 let (script, speakers) = (
521 param(&p, "script").to_string(),
522 param(&p, "speakers").to_string(),
523 );
524 let (preview, quiet, debug, stats) = (
525 int(&p, "preview", 0),
526 on(&p, "quiet"),
527 on(&p, "debug"),
528 on(&p, "stats"),
529 );
530 blocking(move || {
531 let o = ParseOpts {
532 script: &script,
533 preview,
534 quiet,
535 debug,
536 stats,
537 speakers: &speakers,
538 };
539 runner::cmd_parse(&exe, &slug, &tag, &o)
540 })
541 .await
542 }
543 "produce" | "assemble" | "daw" | "master" => {
544 if ep.is_empty() {
545 return log_pre("Select an episode first.");
546 }
547 let (_, tag) = episodes::parse_choice(&ep);
548 if tag.is_empty() {
549 return log_pre(NO_TAG);
550 }
551 Ok(match stage.as_str() {
552 "produce" => runner::cmd_produce(
553 &exe,
554 &tag,
555 &ProduceOpts {
556 dry_run: on(&p, "dry_run"),
557 backend: param(&p, "backend"),
558 gen_sfx: on(&p, "gen_sfx"),
559 gen_music: on(&p, "gen_music"),
560 gen_ambience: on(&p, "gen_ambience"),
561 local_only: on(&p, "local_only"),
562 terse: on(&p, "terse"),
563 start_from: int(&p, "start_from", 0),
564 stop_at: int(&p, "stop_at", 0),
565 seq_list: param(&p, "seq_list"),
566 chatterbox_python: param(&p, "chatterbox_python"),
567 device: param(&p, "device"),
568 force: on(&p, "force"),
569 sfx_backend: match param(&p, "sfx_backend") {
570 "" => "elevenlabs",
571 s => s,
572 },
573 mmaudio_python: param(&p, "mmaudio_python"),
574 mmaudio_accept_nc: on(&p, "mmaudio_accept_nc"),
575 },
576 ),
577 "assemble" => runner::cmd_assemble(
578 &exe,
579 &tag,
580 match int(&p, "gap_ms", 600) {
581 0 => 600,
582 g => g,
583 },
584 param(&p, "parsed"),
585 param(&p, "output"),
586 ),
587 "daw" => runner::cmd_daw(
588 &exe,
589 &tag,
590 &DawOpts {
591 dry_run: on(&p, "dry_run"),
592 gap_ms: match int(&p, "gap_ms", 600) {
593 0 => 600,
594 g => g,
595 },
596 timeline: on(&p, "timeline"),
597 timeline_html: on(&p, "timeline_html"),
598 macro_: on(&p, "macro"),
599 save_aup3: false,
600 output_dir: param(&p, "output_dir"),
601 },
602 ),
603 _ => runner::cmd_master(
604 &exe,
605 &tag,
606 on(&p, "dry_run"),
607 param(&p, "output"),
608 param(&p, "daw_dir"),
609 ),
610 })
611 }
612 other => Err(format!(
613 "Unknown stage: {}",
614 xil_core::script::hints::py_repr(other)
615 )),
616 };
617 match cmd {
618 Ok(cmd) => {
619 let id = runner::start_job(&state, cmd.clone());
620 job_fragment(&cmd, id)
621 }
622 Err(msg) => log_pre(&msg),
623 }
624}
625
626pub async fn job_stream(State(state): Shared, UrlPath(id): UrlPath<u64>) -> Response {
629 let rx = state.jobs.lock().ok().and_then(|mut j| j.remove(&id));
630 let Some(rx) = rx else {
631 return StatusCode::NOT_FOUND.into_response();
632 };
633 let stream = UnboundedReceiverStream::new(rx).map(|ev| {
634 Ok::<_, Infallible>(match ev {
635 JobEvent::Line(l) => Event::default()
638 .event("line")
639 .data(l.rsplit('\r').next().unwrap_or("")),
640 JobEvent::Exit(e) => Event::default().event("exit").data(e),
641 })
642 });
643 Sse::new(stream)
644 .keep_alive(KeepAlive::default())
645 .into_response()
646}
647
648pub async fn config_choices(
652 State(state): Shared,
653 UrlPath(kind): UrlPath<String>,
654 Query(p): Query<Params>,
655) -> Response {
656 let selected = param(&p, "path").to_string();
657 match blocking(move || config_choice_list(&state, &kind).map(|c| options(&c, Some(&selected))))
658 .await
659 {
660 Some(html) => Html(html).into_response(),
661 None => StatusCode::NOT_FOUND.into_response(),
662 }
663}
664
665pub async fn config_load(UrlPath(kind): UrlPath<String>, Query(p): Query<Params>) -> Response {
667 if !is_config_kind(&kind) {
668 return StatusCode::NOT_FOUND.into_response();
669 }
670 let path = param(&p, "path").to_string();
671 let label = kind_label(&kind);
672 let content = blocking(move || configs::load_config_file(&path, label)).await;
673 Html(format!(
674 "<textarea id=\"{kind}-editor\" name=\"text\" rows=\"30\">{}</textarea>",
675 esc(&content)
676 ))
677 .into_response()
678}
679
680pub async fn config_save(UrlPath(kind): UrlPath<String>, Form(p): Form<Params>) -> Response {
682 let what = match kind.as_str() {
683 "speakers" => "speakers.json",
684 "cast" => "cast config",
685 "sfx" => "sfx config",
686 _ => return StatusCode::NOT_FOUND.into_response(),
687 };
688 let (path, text) = (param(&p, "path").to_string(), param(&p, "text").to_string());
689 Html(status(
690 &blocking(move || configs::save_config_file(&path, &text, what)).await,
691 ))
692 .into_response()
693}
694
695fn parsed_editor(content: &str) -> String {
703 format!(
704 "<textarea id=\"parsed-editor\" name=\"text\" rows=\"30\">{}</textarea>",
705 esc(content)
706 )
707}
708
709pub async fn parsed_load(Query(p): Query<Params>) -> Html<String> {
711 let ep = param(&p, "ep").to_string();
712 Html(blocking(move || parsed_editor(&parsed::load(&ep))).await)
713}
714
715pub async fn parsed_save(Form(p): Form<Params>) -> Html<String> {
717 let (ep, text) = (param(&p, "ep").to_string(), param(&p, "text").to_string());
718 Html(status(&blocking(move || parsed::save(&ep, &text)).await))
719}
720
721const DIALOGUE_PAGE_SIZE: i64 = 25;
724
725fn dialogue_row(r: &entries::DialogueRow) -> String {
726 format!(
727 "<tr><td>{seq}</td><td>{speaker}</td><td>{text}</td><td>\
728 <button type=\"button\" class=\"small\" hx-get=\"/parsed/entry\" \
729 hx-vals='{{\"seq\": {seq}}}' hx-include=\"#parsed-ep\" hx-target=\"#parsed-entry-edit\">✎ Edit</button>\
730 </td></tr>",
731 seq = r.seq,
732 speaker = esc(&r.speaker),
733 text = esc(&xil_core::pyfmt::head(&r.text, 80)),
734 )
735}
736
737fn dialogue_panel(ep: &str, q: &str, page: i64) -> String {
739 if ep.is_empty() {
740 return "<p class=\"muted\">Select an episode above.</p>".to_string();
741 }
742 let (slug, tag) = episodes::parse_choice(ep);
743 if tag.is_empty() {
744 return "<p class=\"muted\">Select an episode, not a show.</p>".to_string();
745 }
746 let (rows, total) = match entries::list_dialogue(&slug, &tag, q, page, DIALOGUE_PAGE_SIZE) {
747 Ok(r) => r,
748 Err(e) => return status(&e),
749 };
750 let banner = reprocess_banner(ep, &slug, &tag);
751 if total == 0 {
752 return format!("{banner}<p class=\"muted\">No dialogue lines match.</p>");
753 }
754 let body: String = rows.iter().map(dialogue_row).collect();
755 let last_page = (total as i64 - 1) / DIALOGUE_PAGE_SIZE;
756 let page = page.clamp(0, last_page.max(0));
757 let nav = |label: &str, target: i64, enabled: bool| -> String {
758 if !enabled {
759 return format!("<button type=\"button\" class=\"small\" disabled>{label}</button>");
760 }
761 format!(
762 "<button type=\"button\" class=\"small\" hx-get=\"/parsed/dialogue\" \
763 hx-vals='{{\"page\": {target}}}' hx-include=\"#parsed-ep,#dlg-q\" hx-target=\"#dlg-panel\">{label}</button>"
764 )
765 };
766 format!(
767 "{banner}<p class=\"muted\">{shown_from}–{shown_to} of {total} dialogue line(s)</p>\
768 <table><thead><tr><th>Seq</th><th>Speaker</th><th>Text</th><th></th></tr></thead>\
769 <tbody>{body}</tbody></table>\
770 <div class=\"row\">{prev}<span class=\"muted\">Page {page1} of {pages}</span>{next}</div>",
771 shown_from = page * DIALOGUE_PAGE_SIZE + 1,
772 shown_to = (page * DIALOGUE_PAGE_SIZE + rows.len() as i64).max(0),
773 prev = nav("← Prev", page - 1, page > 0),
774 next = nav("Next →", page + 1, page < last_page),
775 page1 = page + 1,
776 pages = last_page + 1,
777 )
778}
779
780fn reprocess_banner(ep: &str, slug: &str, tag: &str) -> String {
786 let pending = match entries::pending_reprocess(slug, tag) {
787 Ok(p) if !p.is_empty() => p,
788 _ => return String::new(),
789 };
790 let list = pending
791 .iter()
792 .map(i64::to_string)
793 .collect::<Vec<_>>()
794 .join(",");
795 format!(
796 "<div class=\"status\">⚠ {n} dialogue line(s) changed since last produce: {list_esc} \
797 <button type=\"button\" class=\"small\" data-ep=\"{ep}\" data-seqs=\"{list}\" \
798 onclick=\"xilSendToProduce(this)\">▶ Send to Produce</button></div>",
799 n = pending.len(),
800 list_esc = esc(&list),
801 ep = esc(ep),
802 list = esc(&list),
803 )
804}
805
806pub async fn parsed_dialogue(Query(p): Query<Params>) -> Html<String> {
808 let (ep, q, page) = (
809 param(&p, "ep").to_string(),
810 param(&p, "q").to_string(),
811 int(&p, "page", 0),
812 );
813 Html(blocking(move || dialogue_panel(&ep, &q, page)).await)
814}
815
816fn entry_edit_form(ep: &str, slug: &str, row: &entries::DialogueRow) -> String {
818 let choices = entries::speaker_choices(slug);
819 let speaker_field = if choices.is_empty() {
820 format!(
824 "<label class=\"field\"><span>Speaker key</span><input name=\"speaker\" value=\"{}\"></label>",
825 esc(&row.speaker)
826 )
827 } else {
828 format!(
829 "<label class=\"field\"><span>Speaker</span><select name=\"speaker\">{}</select></label>",
830 labelled_options(&choices, Some(&row.speaker))
831 )
832 };
833 format!(
834 "<form id=\"parsed-entry-form\">\
835 <input type=\"hidden\" name=\"ep\" value=\"{ep}\">\
836 <input type=\"hidden\" name=\"seq\" value=\"{seq}\">\
837 <p><b>Editing seq {seq}</b></p>\
838 {speaker_field}\
839 <label class=\"field\"><span>Text</span><textarea name=\"text\" rows=\"4\">{text}</textarea></label>\
840 <div class=\"row\">\
841 <button type=\"button\" class=\"small primary\" hx-post=\"/parsed/entry\" hx-include=\"#parsed-entry-form\" hx-target=\"#parsed-entry-edit\">💾 Save</button>\
842 </div></form>",
843 ep = esc(ep),
844 seq = row.seq,
845 text = esc(&row.text),
846 )
847}
848
849pub async fn parsed_entry_load(Query(p): Query<Params>) -> Html<String> {
851 let (ep, seq) = (param(&p, "ep").to_string(), int(&p, "seq", -1));
852 Html(
853 blocking(move || {
854 let (slug, tag) = episodes::parse_choice(&ep);
855 if tag.is_empty() {
856 return status("Select an episode first.");
857 }
858 match entries::get_entry(&slug, &tag, seq) {
859 Ok(row) => entry_edit_form(&ep, &slug, &row),
860 Err(e) => status(&e),
861 }
862 })
863 .await,
864 )
865}
866
867pub async fn parsed_entry_save(Form(p): Form<Params>) -> Response {
870 let (ep, seq, speaker, text) = (
871 param(&p, "ep").to_string(),
872 int(&p, "seq", -1),
873 param(&p, "speaker").to_string(),
874 param(&p, "text").to_string(),
875 );
876 let msg = blocking(move || {
877 let (slug, tag) = episodes::parse_choice(&ep);
878 if tag.is_empty() {
879 return "Select an episode first.".to_string();
880 }
881 match entries::save_entry(&slug, &tag, seq, &speaker, &text) {
882 Ok(row) => format!("Saved seq {}.", row.seq),
883 Err(e) => e,
884 }
885 })
886 .await;
887 with_trigger(status(&msg), "dialogue-saved")
888}
889
890fn player(path: Option<PathBuf>) -> String {
893 let Some(path) = path else {
894 return String::new();
895 };
896 let url = audio::cache_url(&path).or_else(|| {
897 path.strip_prefix(workspace_root())
898 .ok()
899 .map(|rel| format!("/ws/{}", rel.to_string_lossy()))
900 });
901 match url {
902 Some(u) => format!(
903 "<audio controls preload=\"auto\" src=\"{}\"></audio>",
904 esc(&u)
905 ),
906 None => String::new(),
907 }
908}
909
910pub async fn audio_stems(Query(p): Query<Params>) -> Html<String> {
913 let (ep, filter) = (param(&p, "ep").to_string(), param(&p, "filter").to_string());
914 Html(
915 blocking(move || {
916 if ep.is_empty() {
917 return String::new();
918 }
919 activity::log(&format!("PREVIEW episode → {ep} [{filter}]"));
920 let (slug, tag) = episodes::parse_choice(&ep);
921 let stems: Vec<(String, String)> = episodes::load_stems(&slug, &tag, &filter)
922 .into_iter()
923 .map(|(l, p)| (l, p.to_string_lossy().into_owned()))
924 .collect();
925 let first = stems.first().map(|s| s.1.clone());
926 format!(
927 "<label class=\"field\"><span>Stem</span>\
928 <select name=\"stem\" class=\"mono\" hx-get=\"/audio/play\" hx-include=\"#audio-form\" \
929 hx-trigger=\"load, change\" hx-target=\"#audio-player\">{}</select></label>",
930 labelled_options(&stems, first.as_deref())
931 )
932 })
933 .await,
934 )
935}
936
937pub async fn audio_play(Query(p): Query<Params>) -> Html<String> {
940 let (ep, filter, stem) = (
941 param(&p, "ep").to_string(),
942 param(&p, "filter").to_string(),
943 param(&p, "stem").to_string(),
944 );
945 Html(
946 blocking(move || {
947 if ep.is_empty() || stem.is_empty() {
948 return String::new();
949 }
950 let (slug, tag) = episodes::parse_choice(&ep);
951 let found = episodes::load_stems(&slug, &tag, &filter)
952 .into_iter()
953 .find(|(_, path)| path.to_string_lossy() == stem);
954 match found {
955 Some((label, path)) => {
956 activity::log(&format!("PREVIEW stem → {label}"));
957 player(Some(audio::cached_audio_path(&path)))
958 }
959 None => String::new(),
960 }
961 })
962 .await,
963 )
964}
965
966pub async fn audio_play_all(Form(p): Form<Params>) -> Html<String> {
968 let (ep, filter) = (param(&p, "ep").to_string(), param(&p, "filter").to_string());
969 Html(
970 blocking(move || {
971 if ep.is_empty() {
972 return String::new();
973 }
974 activity::log(&format!("PLAY {filter} → {ep}"));
975 let (slug, tag) = episodes::parse_choice(&ep);
976 match audio::concatenate_stems(&slug, &tag, &filter) {
977 Some(out) => player(Some(out)),
978 None => format!("<p class=\"muted\">No {} stems to play.</p>", esc(&filter)),
979 }
980 })
981 .await,
982 )
983}
984
985fn grade_status_line(state: &AppState, path: &str) -> String {
988 if path.is_empty() {
989 return String::new();
990 }
991 let label = match grades::grade_of(state, path).as_str() {
992 "accurate" => "✓ accurate",
993 "rejected" => "✗ rejected",
994 _ => "• ungraded",
995 };
996 format!(
997 "<p><b>{}</b> — {label}</p>",
998 esc(&xil_core::fsutil::basename(Path::new(path)))
999 )
1000}
1001
1002fn grade_panel(state: &AppState, filter: &str, selected: Option<&str>) -> String {
1003 let choices = grades::sfx_choices(state, filter);
1004 let sel = selected
1005 .filter(|s| choices.iter().any(|(_, p)| p == s))
1006 .map(str::to_string)
1007 .or_else(|| choices.first().map(|c| c.1.clone()));
1008 format!(
1009 r##"<p>{summary}</p>
1010<form id="grade-select-form">
1011 <input type="hidden" name="filter" value="{filter}">
1012 <label class="field"><span>SFX file</span>
1013 <select name="path" hx-get="/grades/select" hx-include="#grade-select-form" hx-trigger="load, change" hx-target="#grade-player">{opts}</select>
1014 </label>
1015 <div id="grade-player"></div>
1016 <div class="row">
1017 <button type="button" class="small primary" hx-post="/grades/apply" hx-include="#grade-select-form" hx-vals='{{"grade": "accurate"}}' hx-target="#grade-panel">✓ Mark Accurate</button>
1018 <button type="button" class="small stop" hx-post="/grades/apply" hx-include="#grade-select-form" hx-vals='{{"grade": "rejected"}}' hx-target="#grade-panel">✗ Mark Rejected</button>
1019 <button type="button" class="small" hx-post="/grades/apply" hx-include="#grade-select-form" hx-vals='{{"grade": ""}}' hx-target="#grade-panel">Clear grade</button>
1020 </div>
1021</form>"##,
1022 summary = esc(&grades::sfx_summary(state)),
1023 filter = esc(filter),
1024 opts = labelled_options(&choices, sel.as_deref()),
1025 )
1026}
1027
1028pub async fn grades_list(State(state): Shared, Query(p): Query<Params>) -> Html<String> {
1030 let filter = match param(&p, "filter") {
1031 "" => "all".to_string(),
1032 f => f.to_string(),
1033 };
1034 let refresh = on(&p, "refresh");
1035 Html(
1036 blocking(move || {
1037 if refresh {
1038 grades::scan_sfx_grades(&state);
1039 }
1040 grade_panel(&state, &filter, None)
1041 })
1042 .await,
1043 )
1044}
1045
1046pub async fn grades_select(State(state): Shared, Query(p): Query<Params>) -> Html<String> {
1048 let path = param(&p, "path").to_string();
1049 Html(
1050 blocking(move || {
1051 let known = state
1052 .grade_cache
1053 .lock()
1054 .map(|c| c.contains_key(&path))
1055 .unwrap_or(false);
1056 if !known {
1057 return String::new();
1058 }
1059 activity::log(&format!(
1060 "GRADE preview → {}",
1061 xil_core::fsutil::basename(Path::new(&path))
1062 ));
1063 format!(
1064 "{}{}",
1065 player(Some(audio::cached_audio_path(Path::new(&path)))),
1066 grade_status_line(&state, &path)
1067 )
1068 })
1069 .await,
1070 )
1071}
1072
1073pub async fn grades_apply(State(state): Shared, Form(p): Form<Params>) -> Html<String> {
1076 let (path, grade, filter) = (
1077 param(&p, "path").to_string(),
1078 param(&p, "grade").to_string(),
1079 match param(&p, "filter") {
1080 "" => "all".to_string(),
1081 f => f.to_string(),
1082 },
1083 );
1084 Html(
1085 blocking(move || {
1086 if path.is_empty() {
1087 return grade_panel(&state, &filter, None);
1088 }
1089 match grades::apply_grade(&state, &path, &grade) {
1090 Ok(()) => activity::log(&format!(
1091 "GRADE {} → {}",
1092 if grade.is_empty() { "cleared" } else { &grade },
1093 xil_core::fsutil::basename(Path::new(&path))
1094 )),
1095 Err(e) => {
1096 return format!(
1097 "{}{}",
1098 status(&e.to_string()),
1099 grade_panel(&state, &filter, Some(&path))
1100 )
1101 }
1102 }
1103 grade_panel(&state, &filter, Some(&path))
1104 })
1105 .await,
1106 )
1107}
1108
1109pub fn timeline_iframe_html(html_path: &Path) -> String {
1114 let v = std::fs::metadata(html_path)
1115 .ok()
1116 .and_then(|m| m.modified().ok())
1117 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1118 .map(|d| d.as_secs())
1119 .unwrap_or(0);
1120 let rel = html_path
1121 .strip_prefix(workspace_root())
1122 .map(|r| r.to_string_lossy().replace('\\', "/"))
1123 .unwrap_or_default();
1124 let url: String = rel
1125 .split('/')
1126 .map(|seg| {
1127 seg.bytes()
1128 .map(|b| match b {
1129 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1130 (b as char).to_string()
1131 }
1132 _ => format!("%{b:02X}"),
1133 })
1134 .collect::<String>()
1135 })
1136 .collect::<Vec<_>>()
1137 .join("/");
1138 format!(
1139 "<iframe src=\"/ws/{}?v={v}\" style=\"width:100%;height:600px;border:none;\"></iframe>",
1140 esc(&url)
1141 )
1142}
1143
1144pub async fn timeline(Query(p): Query<Params>) -> Html<String> {
1146 let ep = param(&p, "ep").to_string();
1147 Html(
1148 blocking(move || {
1149 if ep.is_empty() {
1150 return "<p>Select an episode above.</p>".to_string();
1151 }
1152 let (slug, tag) = episodes::parse_choice(&ep);
1153 if tag.is_empty() {
1154 return "<p>Select an episode above.</p>".to_string();
1155 }
1156 let paths = xil_core::workspace::derive_paths(&slug, &tag);
1157 let html_path = paths["daw"].join(format!("{tag}_timeline.html"));
1158 if !html_path.exists() {
1159 return format!(
1160 "<p>No timeline found for <b>{t}</b>.<br>Generate it first:<br><code>xil daw --episode {t} --timeline-html</code></p>",
1161 t = esc(&tag)
1162 );
1163 }
1164 timeline_iframe_html(&html_path)
1165 })
1166 .await,
1167 )
1168}
1169
1170pub async fn episodes_table(State(state): Shared, Query(p): Query<Params>) -> Html<String> {
1174 let force = on(&p, "force");
1175 Html(
1176 blocking(move || {
1177 let rows = episodes::refresh_episodes(&state, force);
1178 let head = ["Tag", "Slug", "Title [Arc]", "Parse", "Stems", "DAW", "Master", "Overall"]
1179 .iter()
1180 .map(|h| format!("<th>{}</th>", esc(h)))
1181 .collect::<String>();
1182 let body = rows
1183 .iter()
1184 .map(|r| format!("<tr>{}</tr>", r.iter().map(|c| format!("<td>{}</td>", esc(c))).collect::<String>()))
1185 .collect::<String>();
1186 format!("<div class=\"scroll\"><table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>")
1187 })
1188 .await,
1189 )
1190}