1#![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
52fn body_repr(v: &Value) -> String {
54 dumps(v, Style::COMPACT_UTF8)
55}
56
57fn 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
111fn 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
132pub 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
193pub 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
279pub 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
390pub 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
424pub 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}