Skip to main content

xil_web/
lib.rs

1//! The `xil gui` web dashboard. Port of `xil_gui.py`, with Gradio replaced by
2//! axum and htmx.
3//!
4//! The Python dashboard is a Gradio `Blocks` app with twelve tabs and three
5//! FastAPI routes the timeline editor calls. This crate serves the same tabs
6//! as server-rendered HTML, swapped in place by htmx, and keeps the three
7//! `/xil/*` JSON routes to their exact contracts, so a timeline written by
8//! either implementation's `xil daw --timeline-html` works against either
9//! server.
10//!
11//! What does not carry over is Gradio's plumbing: `/gradio_api/file=` becomes
12//! a workspace file server under `/ws/` (the timeline's audio links are
13//! relative, so they resolve the same way), progress bars become htmx
14//! indicators, and generator callbacks become a job table streamed over
15//! server-sent events. Stages run as subprocesses of the Rust `xil`.
16
17pub mod activity;
18pub mod audio;
19pub mod configs;
20pub mod entries;
21pub mod episodes;
22pub mod grades;
23mod html;
24mod pages;
25pub mod parsed;
26pub mod runner;
27pub mod scripts;
28pub mod sfx_routes;
29
30use std::collections::{BTreeMap, HashMap};
31use std::net::SocketAddr;
32use std::path::PathBuf;
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::sync::{Arc, Mutex};
35use std::time::Instant;
36
37use axum::routing::{get, post};
38use axum::Router;
39use tokio::sync::mpsc::UnboundedReceiver;
40use tower_http::services::ServeDir;
41use xil_core::workspace::workspace_root;
42
43/// Freshness glyphs for one Episodes-table row, from `xil status`'s engine.
44#[derive(Clone, Debug, Default, PartialEq)]
45pub struct StageCells {
46    pub parse: String,
47    pub produce: String,
48    pub daw: String,
49    pub master: String,
50    pub overall: String,
51}
52
53/// `_stage_status(slug, tag)`. The engine lives in the `xil` binary crate, so
54/// the binary hands it in.
55pub type StatusFn = Arc<dyn Fn(&str, &str) -> StageCells + Send + Sync>;
56
57/// Episodes-table rows: tag, slug, title, then the five status cells.
58pub type Rows = Vec<Vec<String>>;
59
60/// One line of a running stage's output, or its exit.
61#[derive(Debug)]
62pub enum JobEvent {
63    Line(String),
64    Exit(String),
65}
66
67/// The dropdown lists every tab draws from. Listing configs and scripts
68/// crosses the network on a NAS workspace, so they are built once and
69/// rebuilt only on ⟳ Refresh or after something that can change them (a
70/// finished stage, a saved script) — as the Python app builds them at startup
71/// and refreshes them from the same events.
72#[derive(Clone, Debug, Default)]
73pub struct Choices {
74    pub episodes: Vec<String>,
75    pub scripts: Vec<String>,
76    pub shows: Vec<String>,
77    pub speakers: Vec<String>,
78    pub cast: Vec<String>,
79    pub sfx: Vec<String>,
80}
81
82impl Choices {
83    pub fn load() -> Choices {
84        Choices {
85            episodes: episodes::episode_choices(),
86            scripts: scripts::script_choices(),
87            shows: configs::list_available_shows(),
88            speakers: configs::find_speakers_configs(),
89            cast: configs::find_cast_configs(),
90            sfx: configs::find_sfx_configs(),
91        }
92    }
93}
94
95/// Everything a handler needs. Workspace paths are read from the environment
96/// on each call, as the Python module does.
97pub struct AppState {
98    /// The `xil` executable stages run under.
99    pub xil_exe: PathBuf,
100    pub status: StatusFn,
101    pub(crate) jobs: Mutex<HashMap<u64, UnboundedReceiver<JobEvent>>>,
102    next_job: AtomicU64,
103    /// `_EPISODES_CACHE`: rows memoised per workspace root.
104    pub(crate) episodes_cache: Mutex<HashMap<PathBuf, (Instant, Rows)>>,
105    /// `_sfx_grade_cache`: `{path: grade}`, rebuilt only on Load/Refresh.
106    pub grade_cache: Mutex<BTreeMap<String, String>>,
107    choices: Mutex<Option<Arc<Choices>>>,
108}
109
110impl AppState {
111    pub fn new(xil_exe: PathBuf, status: StatusFn) -> Arc<AppState> {
112        Arc::new(AppState {
113            xil_exe,
114            status,
115            jobs: Mutex::new(HashMap::new()),
116            next_job: AtomicU64::new(1),
117            episodes_cache: Mutex::new(HashMap::new()),
118            grade_cache: Mutex::new(BTreeMap::new()),
119            choices: Mutex::new(None),
120        })
121    }
122
123    /// The cached dropdown lists, built on first use. Blocking.
124    pub fn choices(&self) -> Arc<Choices> {
125        let mut g = self.choices.lock().unwrap_or_else(|e| e.into_inner());
126        g.get_or_insert_with(|| Arc::new(Choices::load())).clone()
127    }
128
129    /// Drop the cached lists so the next request rebuilds them.
130    pub fn invalidate_choices(&self) {
131        if let Ok(mut g) = self.choices.lock() {
132            *g = None;
133        }
134    }
135
136    pub(crate) fn job_id(&self) -> u64 {
137        self.next_job.fetch_add(1, Ordering::Relaxed)
138    }
139}
140
141/// Every route the dashboard serves.
142pub fn router(state: Arc<AppState>) -> Router {
143    Router::new()
144        .route("/", get(pages::index))
145        .route("/assets/{name}", get(pages::asset))
146        .route("/episodes/table", get(pages::episodes_table))
147        .route("/choices/episodes", get(pages::episode_options))
148        .route("/choices/scripts", get(pages::script_options))
149        .route("/choices/shows", get(pages::show_options))
150        .route("/choices/invalidate", post(pages::invalidate_choices))
151        .route("/setup/use", post(pages::setup_use))
152        .route("/setup/init", post(pages::setup_init))
153        .route("/project", get(pages::project_load))
154        .route("/project/save", post(pages::project_save))
155        .route("/scripts/analyze", post(pages::scripts_analyze))
156        .route("/scripts/save", post(pages::scripts_save))
157        .route("/run/{stage}", post(pages::run_stage))
158        .route("/jobs/{id}/stream", get(pages::job_stream))
159        .route("/config/{kind}/choices", get(pages::config_choices))
160        .route("/config/{kind}/load", get(pages::config_load))
161        .route("/config/{kind}/save", post(pages::config_save))
162        .route("/parsed/load", get(pages::parsed_load))
163        .route("/parsed/save", post(pages::parsed_save))
164        .route("/audio/stems", get(pages::audio_stems))
165        .route("/audio/play", get(pages::audio_play))
166        .route("/audio/play-all", post(pages::audio_play_all))
167        .route("/grades/list", get(pages::grades_list))
168        .route("/grades/select", get(pages::grades_select))
169        .route("/grades/apply", post(pages::grades_apply))
170        .route("/timeline", get(pages::timeline))
171        .route("/xil/get-sfx", get(sfx_routes::get_sfx))
172        .route("/xil/update-sfx", post(sfx_routes::update_sfx))
173        .route(
174            "/xil/update-sfx-defaults",
175            post(sfx_routes::update_sfx_defaults),
176        )
177        .route("/xil/get-parsed-entry", get(sfx_routes::get_parsed_entry))
178        .route(
179            "/xil/update-parsed-entry",
180            post(sfx_routes::update_parsed_entry),
181        )
182        .route("/parsed/dialogue", get(pages::parsed_dialogue))
183        .route(
184            "/parsed/entry",
185            get(pages::parsed_entry_load).post(pages::parsed_entry_save),
186        )
187        .nest_service("/ws", ServeDir::new(workspace_root()))
188        .nest_service("/cache", ServeDir::new(audio::audio_cache_dir()))
189        .with_state(state)
190}
191
192/// `_print_workspace_banner()`: say which workspace this process is frozen to
193/// before anything else starts.
194pub fn print_workspace_banner() -> PathBuf {
195    let workspace = workspace_root();
196    println!("xil-gui: workspace root = {}", workspace.display());
197    if !workspace.is_dir() {
198        println!(
199            "xil-gui: WARNING — workspace root does not exist: {}",
200            workspace.display()
201        );
202    }
203    workspace
204}
205
206/// Bind and serve until the process is stopped.
207pub async fn serve(host: &str, port: u16, state: Arc<AppState>) -> anyhow::Result<()> {
208    let addr: SocketAddr = tokio::net::lookup_host((host, port))
209        .await?
210        .next()
211        .ok_or_else(|| anyhow::anyhow!("cannot resolve {host}"))?;
212    let listener = tokio::net::TcpListener::bind(addr).await?;
213    println!("* Running on local URL:  http://{addr}");
214    // Warm the dropdown lists so the first page load does not pay for them.
215    let warm = state.clone();
216    tokio::task::spawn_blocking(move || {
217        warm.choices();
218    });
219    axum::serve(listener, router(state)).await?;
220    Ok(())
221}