Skip to main content

xil_web/
pages.rs

1//! The dashboard page and the htmx fragments behind each tab.
2
3use 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
42/// Run blocking filesystem work off the async executor.
43async 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
53/// A fragment plus an `HX-Trigger` event for other elements to react to.
54fn with_trigger(body: String, event: &'static str) -> Response {
55    ([(HeaderName::from_static("hx-trigger"), event)], Html(body)).into_response()
56}
57
58// ── page and assets ──────────────────────────────────────────────────────
59
60fn default_python(venv: &str) -> String {
61    resolve_venv_python(venv, None, None).unwrap_or_default()
62}
63
64/// One config-editor tab: a file dropdown, the editor, and Reload / Save.
65///
66/// Reload includes `#{kind}-path` alone rather than the whole form. The form
67/// holds the editor too, and on a GET that would ride up the query string —
68/// a megabyte-class config (some in `configs/` are over 1 MB) then pushes the
69/// URI past what the server accepts and Reload silently does nothing. Save is
70/// a POST, so it can and must include the whole form.
71fn 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
182/// `GET /`: the whole dashboard.
183pub 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
314/// `GET /assets/{name}`: the page's script, style and the vendored htmx.
315pub 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
325// ── choices ──────────────────────────────────────────────────────────────
326
327/// `GET /choices/episodes?ep=`: episode `<option>`s, keeping the current
328/// selection.
329pub 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
334/// `GET /choices/scripts`.
335pub async fn script_options(State(state): Shared) -> Html<String> {
336    Html(blocking(move || options(&state.choices().scripts, None)).await)
337}
338
339/// `GET /choices/shows`: shows with the active one selected.
340pub 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
350/// `POST /choices/invalidate`: the ⟳ Refresh button, before it asks every
351/// list to redraw.
352pub async fn invalidate_choices(State(state): Shared) -> StatusCode {
353    state.invalidate_choices();
354    StatusCode::NO_CONTENT
355}
356
357// ── Setup and Project ────────────────────────────────────────────────────
358
359/// `POST /setup/use`: `xil use SHOW`, then tell the Project tab to reload.
360pub 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
398/// `POST /setup/init`: `xil init`, streamed.
399pub 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
434/// `GET /project`.
435pub 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
445/// `POST /project/save`.
446pub 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
453// ── Scripts ──────────────────────────────────────────────────────────────
454
455/// `POST /scripts/analyze`.
456pub 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
462/// `POST /scripts/save`: then refresh every script and episode list.
463pub 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
473// ── Run Stage ────────────────────────────────────────────────────────────
474
475const NO_TAG: &str = "⚠️ No episode tag — run Parse first (Run Stage → Parse tab).";
476
477/// `POST /run/{stage}`: validate, build the command, start it, and return a
478/// log that follows the job.
479pub 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
626/// `GET /jobs/{id}/stream`: the job's lines as server-sent events, ending
627/// with one `exit` event. A job can be followed once.
628pub 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            // A carriage return redraws a terminal line; keep what the
636            // terminal would finally show.
637            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
648// ── config editors ───────────────────────────────────────────────────────
649
650/// `GET /config/{kind}/choices`.
651pub 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
665/// `GET /config/{kind}/load?path=`: a fresh editor textarea.
666pub 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
680/// `POST /config/{kind}/save`.
681pub 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
695// ── Edit Parsed JSON ─────────────────────────────────────────────────────
696
697/// The editor textarea, used both for the page's first render and for the
698/// fragment that replaces it — one definition, so the two cannot drift.
699///
700/// It is a plain textarea: the episode `<select>` and the Reload button both
701/// target it by id and swap it whole, so it needs no triggers of its own.
702fn parsed_editor(content: &str) -> String {
703    format!(
704        "<textarea id=\"parsed-editor\" name=\"text\" rows=\"30\">{}</textarea>",
705        esc(content)
706    )
707}
708
709/// `GET /parsed/load?ep=`: a fresh editor textarea.
710pub 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
715/// `POST /parsed/save`.
716pub 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
721// ── Dialogue sub-tab ──────────────────────────────────────────────────────
722
723const 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
737/// The `#dlg-panel` fragment: search results, paginated.
738fn 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
780/// A banner naming the dialogue lines edited since their audio was last
781/// produced, with a button that jumps to Run Stage → Produce, pre-filled
782/// with this episode, `--seq-list` and `--force`. Empty when nothing is
783/// pending. The user still presses Run themselves — this dashboard never
784/// starts a pipeline stage on its own behalf.
785fn 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
806/// `GET /parsed/dialogue?ep=&q=&page=`.
807pub 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
816/// The inline speaker+text editor for one dialogue line.
817fn 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        // No speakers.json for this show yet: free-text, but still carrying
821        // the current value — `text_input` only sets a placeholder, which
822        // would silently drop the speaker on save if left untouched.
823        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
849/// `GET /parsed/entry?ep=&seq=`: the inline edit panel for one dialogue line.
850pub 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
867/// `POST /parsed/entry`: save one dialogue line, then tell `#dlg-panel` to
868/// reload so the table reflects the edit.
869pub 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
890// ── Audio Preview ────────────────────────────────────────────────────────
891
892fn 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
910/// `GET /audio/stems?ep=&filter=`: the stem dropdown, which loads the first
911/// stem into the player as soon as it lands.
912pub 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
937/// `GET /audio/play?ep=&filter=&stem=`: copy the stem into the local cache and
938/// return a player. Only a stem of the chosen episode is accepted.
939pub 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
966/// `POST /audio/play-all`: every stem of one kind, joined.
967pub 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
985// ── Audio Grading ────────────────────────────────────────────────────────
986
987fn 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
1028/// `GET /grades/list?filter=&refresh=`.
1029pub 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
1046/// `GET /grades/select?path=`: player plus the grade line.
1047pub 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
1073/// `POST /grades/apply`: write the grade, then show the list again with the
1074/// same file selected, or the first one when it left the filter.
1075pub 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
1109// ── Timeline ─────────────────────────────────────────────────────────────
1110
1111/// `_timeline_iframe_html(html_path)`: the timeline in an iframe, with its
1112/// mtime as a cache-buster so a regenerated file is always refetched.
1113pub 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
1144/// `GET /timeline?ep=`.
1145pub 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
1170// ── Episodes ─────────────────────────────────────────────────────────────
1171
1172/// `GET /episodes/table?force=`.
1173pub 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}