Skip to main content

xil_web/
sfx_routes.rs

1//! The timeline editor's JSON routes, with the contracts of
2//! `_register_sfx_routes` in `xil_gui.py`, plus two routes of our own for
3//! the Timeline's dialogue-edit modal:
4//!
5//! - `GET /xil/get-sfx?slug=&tag=&key=` → `{effect, defaults, natural_s}`
6//! - `POST /xil/update-sfx` → sets or clears one cue's four sound fields
7//! - `POST /xil/update-sfx-defaults` → sets or clears one layer's defaults
8//! - `GET /xil/get-parsed-entry?slug=&tag=&seq=` → `{speaker, text, speakers}`
9//! - `POST /xil/update-parsed-entry` → `{slug, tag, seq, speaker, text}`
10//!
11//! Every save rewrites `sfx_{tag}.json` and appends to its `_edits.jsonl`
12//! journal; a journal failure never fails the save. The two parsed-entry
13//! routes are a thin JSON front end over [`crate::entries`], which the
14//! dashboard's Dialogue sub-tab also calls — one shared save, two callers.
15
16// Helpers return the finished HTTP reply as their `Err`, which the handler
17// sends straight back; boxing it would only add an allocation per request.
18#![allow(clippy::result_large_err)]
19
20use std::collections::HashMap;
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use axum::extract::Query;
25use axum::http::StatusCode;
26use axum::response::{IntoResponse, Response};
27use axum::Json;
28use serde_json::{json, Map, Value};
29use xil_core::journal::{append_sfx_defaults_edit, append_sfx_edit, SFX_EDIT_FIELDS};
30use xil_core::log;
31use xil_core::pyjson::{dumps, Style};
32use xil_core::script::hints::py_repr;
33use xil_core::workspace::{derive_paths, workspace_root};
34
35use crate::activity;
36use crate::configs::check_workspace_path;
37use crate::entries;
38use crate::episodes::is_safe_slug_or_tag;
39
40const CUE_FIELDS: [&str; 4] = [
41    "volume_percentage",
42    "ramp_in_seconds",
43    "ramp_out_seconds",
44    "play_duration",
45];
46const DEFAULT_FIELDS: [&str; 3] = ["volume_percentage", "ramp_in_seconds", "ramp_out_seconds"];
47
48fn reply(status: StatusCode, body: Value) -> Response {
49    (status, Json(body)).into_response()
50}
51
52/// Python's `repr()` of a JSON request body, for the debug log.
53fn body_repr(v: &Value) -> String {
54    dumps(v, Style::COMPACT_UTF8)
55}
56
57/// The episode's sfx config, or the error response to send instead.
58fn sfx_config_path(slug: &str, tag: &str, ok_key: bool, route: &str) -> Result<PathBuf, Response> {
59    let err = |code, msg: &str| {
60        let mut body = Map::new();
61        if ok_key {
62            body.insert("ok".into(), false.into());
63        }
64        body.insert("error".into(), msg.into());
65        reply(code, Value::Object(body))
66    };
67    if !(is_safe_slug_or_tag(slug) && is_safe_slug_or_tag(tag)) {
68        log::warning(&format!(
69            "{route} rejected: invalid slug/tag slug={} tag={}",
70            py_repr(slug),
71            py_repr(tag)
72        ));
73        return Err(err(StatusCode::BAD_REQUEST, "invalid slug or tag"));
74    }
75    let path = derive_paths(slug, tag)["sfx"].clone();
76    if check_workspace_path(&path).is_err() {
77        return Err(err(StatusCode::BAD_REQUEST, "invalid slug or tag"));
78    }
79    if !path.exists() {
80        let msg = format!("{route}: sfx config not found at {}", path.display());
81        if ok_key {
82            log::warning(&msg);
83        } else {
84            log::debug(&msg);
85        }
86        return Err(err(StatusCode::NOT_FOUND, "sfx config not found"));
87    }
88    Ok(path)
89}
90
91fn read_config(path: &Path) -> Result<Map<String, Value>, Response> {
92    let parsed = fs::read_to_string(path)
93        .ok()
94        .and_then(|s| serde_json::from_str::<Value>(&s).ok());
95    match parsed {
96        Some(Value::Object(m)) => Ok(m),
97        _ => Err(reply(
98            StatusCode::INTERNAL_SERVER_ERROR,
99            json!({"ok": false, "error": "sfx config is not valid JSON"}),
100        )),
101    }
102}
103
104fn write_config(path: &Path, data: &Map<String, Value>) -> std::io::Result<()> {
105    fs::write(
106        path,
107        dumps(&Value::Object(data.clone()), Style::INDENT2_UTF8) + "\n",
108    )
109}
110
111/// `_source_duration_s(source)`: a cue's source file length, or `None` for a
112/// generated cue or an unreadable file. Relative sources are workspace paths.
113fn source_duration_s(source: Option<&str>) -> Option<f64> {
114    let source = source.filter(|s| !s.is_empty())?;
115    let p = Path::new(source);
116    let path = if p.is_absolute() {
117        p.to_path_buf()
118    } else {
119        workspace_root().join(p)
120    };
121    check_workspace_path(&path).ok()?;
122    match xil_audio::mpeg::duration_ms(&path) {
123        Ok(ms) if ms > 0 => Some(ms as f64 / 1000.0),
124        Ok(_) => None,
125        Err(e) => {
126            log::debug(&format!("get-sfx: could not probe {source} ({e})"));
127            None
128        }
129    }
130}
131
132/// `GET /xil/get-sfx`.
133pub async fn get_sfx(Query(q): Query<HashMap<String, String>>) -> Response {
134    let get = |k: &str| q.get(k).cloned();
135    let (Some(slug), Some(tag), Some(key)) = (get("slug"), get("tag"), get("key")) else {
136        return reply(
137            StatusCode::UNPROCESSABLE_ENTITY,
138            json!({"error": "slug, tag and key are required"}),
139        );
140    };
141    log::debug(&format!(
142        "get-sfx request: slug={} tag={} key={}",
143        py_repr(&slug),
144        py_repr(&tag),
145        py_repr(&key)
146    ));
147    let path = match sfx_config_path(&slug, &tag, false, "get-sfx") {
148        Ok(p) => p,
149        Err(r) => return r,
150    };
151    let data = match read_config(&path) {
152        Ok(d) => d,
153        Err(r) => return r,
154    };
155    let effect = data
156        .get("effects")
157        .and_then(|e| e.get(&key))
158        .cloned()
159        .unwrap_or_else(|| json!({}));
160    let defaults = data.get("defaults").cloned().unwrap_or_else(|| json!({}));
161    let natural_s = source_duration_s(effect.get("source").and_then(Value::as_str));
162    log::debug(&format!(
163        "get-sfx: returned effect={} defaults={} natural_s={} for key={}",
164        body_repr(&effect),
165        body_repr(&defaults),
166        natural_s
167            .map(|n| n.to_string())
168            .unwrap_or_else(|| "None".into()),
169        py_repr(&key)
170    ));
171    let mut body = Map::new();
172    body.insert("effect".into(), effect);
173    body.insert("defaults".into(), defaults);
174    body.insert(
175        "natural_s".into(),
176        natural_s
177            .map(xil_core::pyjson::py_float)
178            .unwrap_or(Value::Null),
179    );
180    reply(StatusCode::OK, Value::Object(body))
181}
182
183fn body_str(body: &Value, key: &str) -> Result<String, Response> {
184    match body.get(key) {
185        Some(Value::String(s)) => Ok(s.clone()),
186        _ => Err(reply(
187            StatusCode::BAD_REQUEST,
188            json!({"ok": false, "error": format!("missing {key}")}),
189        )),
190    }
191}
192
193/// `POST /xil/update-sfx`.
194pub async fn update_sfx(Json(body): Json<Value>) -> Response {
195    log::debug(&format!("update-sfx request body: {}", body_repr(&body)));
196    let fields = (|| {
197        Ok::<_, Response>((
198            body_str(&body, "slug")?,
199            body_str(&body, "tag")?,
200            body_str(&body, "key")?,
201        ))
202    })();
203    let (slug, tag, key) = match fields {
204        Ok(f) => f,
205        Err(r) => return r,
206    };
207    let path = match sfx_config_path(&slug, &tag, true, "update-sfx") {
208        Ok(p) => p,
209        Err(r) => return r,
210    };
211    let mut data = match read_config(&path) {
212        Ok(d) => d,
213        Err(r) => return r,
214    };
215    let mut written = Map::new();
216    {
217        let effects = data.entry("effects").or_insert_with(|| json!({}));
218        let Some(effects) = effects.as_object_mut() else {
219            return reply(
220                StatusCode::INTERNAL_SERVER_ERROR,
221                json!({"ok": false, "error": "effects is not an object"}),
222            );
223        };
224        let effect = effects.entry(key.clone()).or_insert_with(|| json!({}));
225        let Some(effect) = effect.as_object_mut() else {
226            return reply(
227                StatusCode::INTERNAL_SERVER_ERROR,
228                json!({"ok": false, "error": "effect is not an object"}),
229            );
230        };
231        for field in CUE_FIELDS {
232            let val = body.get(field).cloned().unwrap_or(Value::Null);
233            if val.is_null() {
234                effect.shift_remove(field);
235            } else {
236                effect.insert(field.into(), val.clone());
237            }
238            written.insert(field.into(), val);
239        }
240    }
241    if let Err(e) = write_config(&path, &data) {
242        return reply(
243            StatusCode::INTERNAL_SERVER_ERROR,
244            json!({"ok": false, "error": e.to_string()}),
245        );
246    }
247    log::debug(&format!(
248        "update-sfx: wrote {} (key={} fields={})",
249        path.display(),
250        py_repr(&key),
251        body_repr(&Value::Object(written))
252    ));
253    let journal: Map<String, Value> = SFX_EDIT_FIELDS
254        .iter()
255        .map(|f| (f.to_string(), body.get(*f).cloned().unwrap_or(Value::Null)))
256        .collect();
257    if let Err(e) = append_sfx_edit(&path, &key, &journal) {
258        activity::log(&format!("[WARN] sfx edit journal write failed: {e}"));
259        log::warning(&format!(
260            "sfx edit journal write failed for {} (key={}): {e}",
261            path.display(),
262            py_repr(&key)
263        ));
264    }
265    activity::log(&format!(
266        "SFX edit via timeline: {slug}/{tag} → {}",
267        py_repr(&key)
268    ));
269    log::info(&format!(
270        "SFX edit via timeline: {slug}/{tag} → {}",
271        py_repr(&key)
272    ));
273    reply(
274        StatusCode::OK,
275        json!({"ok": true, "message": format!("Saved {} — re-run xil daw to apply.", py_repr(&key))}),
276    )
277}
278
279/// `POST /xil/update-sfx-defaults`: the `{layer}_{field}` defaults.
280pub async fn update_sfx_defaults(Json(body): Json<Value>) -> Response {
281    log::debug(&format!(
282        "update-sfx-defaults request body: {}",
283        body_repr(&body)
284    ));
285    let fields = (|| {
286        Ok::<_, Response>((
287            body_str(&body, "slug")?,
288            body_str(&body, "tag")?,
289            body_str(&body, "layer")?,
290        ))
291    })();
292    let (slug, tag, layer) = match fields {
293        Ok(f) => f,
294        Err(r) => return r,
295    };
296    if !(is_safe_slug_or_tag(&slug) && is_safe_slug_or_tag(&tag)) {
297        log::warning(&format!(
298            "update-sfx-defaults rejected: invalid slug/tag slug={} tag={}",
299            py_repr(&slug),
300            py_repr(&tag)
301        ));
302        return reply(
303            StatusCode::BAD_REQUEST,
304            json!({"ok": false, "error": "invalid slug or tag"}),
305        );
306    }
307    if !matches!(layer.as_str(), "music" | "sfx" | "ambience") {
308        log::warning(&format!(
309            "update-sfx-defaults rejected: invalid layer {}",
310            py_repr(&layer)
311        ));
312        return reply(
313            StatusCode::BAD_REQUEST,
314            json!({"ok": false, "error": "invalid layer"}),
315        );
316    }
317    let path = match sfx_config_path(&slug, &tag, true, "update-sfx-defaults") {
318        Ok(p) => p,
319        Err(r) => return r,
320    };
321    let mut data = match read_config(&path) {
322        Ok(d) => d,
323        Err(r) => return r,
324    };
325    let mut written = Map::new();
326    {
327        let defaults = data.entry("defaults").or_insert_with(|| json!({}));
328        let Some(defaults) = defaults.as_object_mut() else {
329            return reply(
330                StatusCode::INTERNAL_SERVER_ERROR,
331                json!({"ok": false, "error": "defaults is not an object"}),
332            );
333        };
334        for field in DEFAULT_FIELDS {
335            let val = body.get(field).cloned().unwrap_or(Value::Null);
336            let prefixed = format!("{layer}_{field}");
337            if val.is_null() {
338                defaults.shift_remove(&prefixed);
339            } else {
340                defaults.insert(prefixed.clone(), val.clone());
341            }
342            written.insert(prefixed, val);
343        }
344    }
345    if let Err(e) = write_config(&path, &data) {
346        return reply(
347            StatusCode::INTERNAL_SERVER_ERROR,
348            json!({"ok": false, "error": e.to_string()}),
349        );
350    }
351    log::debug(&format!(
352        "update-sfx-defaults: wrote {} (layer={} fields={})",
353        path.display(),
354        py_repr(&layer),
355        body_repr(&Value::Object(written.clone()))
356    ));
357    if let Err(e) = append_sfx_defaults_edit(&path, &written) {
358        activity::log(&format!(
359            "[WARN] sfx defaults edit journal write failed: {e}"
360        ));
361        log::warning(&format!(
362            "sfx defaults edit journal write failed for {} (layer={}): {e}",
363            path.display(),
364            py_repr(&layer)
365        ));
366    }
367    activity::log(&format!(
368        "SFX defaults edit via timeline: {slug}/{tag} → {}",
369        py_repr(&layer)
370    ));
371    log::info(&format!(
372        "SFX defaults edit via timeline: {slug}/{tag} → {}",
373        py_repr(&layer)
374    ));
375    reply(
376        StatusCode::OK,
377        json!({"ok": true, "message": format!("Saved {} defaults — re-run xil daw to apply.", py_repr(&layer))}),
378    )
379}
380
381fn speakers_json(slug: &str) -> Value {
382    Value::Array(
383        entries::speaker_choices(slug)
384            .into_iter()
385            .map(|(label, key)| json!({"key": key, "label": label}))
386            .collect(),
387    )
388}
389
390/// `GET /xil/get-parsed-entry?slug=&tag=&seq=`: the Timeline modal's one
391/// fetch to open — the entry's current fields plus the speaker dropdown
392/// options, so no second round trip is needed.
393pub async fn get_parsed_entry(Query(q): Query<HashMap<String, String>>) -> Response {
394    let get = |k: &str| q.get(k).cloned();
395    let (Some(slug), Some(tag), Some(seq)) = (get("slug"), get("tag"), get("seq")) else {
396        return reply(
397            StatusCode::UNPROCESSABLE_ENTITY,
398            json!({"error": "slug, tag and seq are required"}),
399        );
400    };
401    let Ok(seq) = seq.parse::<i64>() else {
402        return reply(
403            StatusCode::BAD_REQUEST,
404            json!({"error": "seq must be an integer"}),
405        );
406    };
407    log::debug(&format!(
408        "get-parsed-entry request: slug={} tag={} seq={seq}",
409        py_repr(&slug),
410        py_repr(&tag),
411    ));
412    match entries::get_entry(&slug, &tag, seq) {
413        Ok(row) => reply(
414            StatusCode::OK,
415            json!({"speaker": row.speaker, "text": row.text, "speakers": speakers_json(&slug)}),
416        ),
417        Err(e) => {
418            log::debug(&format!("get-parsed-entry: {e}"));
419            reply(StatusCode::NOT_FOUND, json!({"error": e}))
420        }
421    }
422}
423
424/// `POST /xil/update-parsed-entry`: `{slug, tag, seq, speaker, text}` → the
425/// same validate/save/journal path [`entries::save_entry`] runs for the
426/// dashboard's Dialogue sub-tab.
427pub async fn update_parsed_entry(Json(body): Json<Value>) -> Response {
428    log::debug(&format!(
429        "update-parsed-entry request body: {}",
430        body_repr(&body)
431    ));
432    let fields = (|| {
433        Ok::<_, Response>((
434            body_str(&body, "slug")?,
435            body_str(&body, "tag")?,
436            body_str(&body, "speaker")?,
437            body_str(&body, "text")?,
438        ))
439    })();
440    let (slug, tag, speaker, text) = match fields {
441        Ok(f) => f,
442        Err(r) => return r,
443    };
444    let seq = match body.get("seq") {
445        Some(Value::Number(n)) => n.as_i64(),
446        Some(Value::String(s)) => s.parse::<i64>().ok(),
447        _ => None,
448    };
449    let Some(seq) = seq else {
450        return reply(
451            StatusCode::BAD_REQUEST,
452            json!({"ok": false, "error": "seq must be an integer"}),
453        );
454    };
455    match entries::save_entry(&slug, &tag, seq, &speaker, &text) {
456        Ok(row) => {
457            activity::log(&format!(
458                "Dialogue edit via timeline: {slug}/{tag} → seq={seq}"
459            ));
460            reply(
461                StatusCode::OK,
462                json!({"ok": true, "message": format!("Saved seq {} — re-run xil produce --seq-list {} --force to regenerate.", row.seq, row.seq)}),
463            )
464        }
465        Err(e) => reply(StatusCode::BAD_REQUEST, json!({"ok": false, "error": e})),
466    }
467}