1pub 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#[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
53pub type StatusFn = Arc<dyn Fn(&str, &str) -> StageCells + Send + Sync>;
56
57pub type Rows = Vec<Vec<String>>;
59
60#[derive(Debug)]
62pub enum JobEvent {
63 Line(String),
64 Exit(String),
65}
66
67#[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
95pub struct AppState {
98 pub xil_exe: PathBuf,
100 pub status: StatusFn,
101 pub(crate) jobs: Mutex<HashMap<u64, UnboundedReceiver<JobEvent>>>,
102 next_job: AtomicU64,
103 pub(crate) episodes_cache: Mutex<HashMap<PathBuf, (Instant, Rows)>>,
105 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 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 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
141pub 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
192pub 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
206pub 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 let warm = state.clone();
216 tokio::task::spawn_blocking(move || {
217 warm.choices();
218 });
219 axum::serve(listener, router(state)).await?;
220 Ok(())
221}