1use std::fs::{self, OpenOptions};
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use serde_json::{Map, Value};
18
19use crate::pyjson::{dumps, Style};
20
21pub const SFX_EDIT_FIELDS: [&str; 5] = [
24 "volume_percentage",
25 "ramp_in_seconds",
26 "ramp_out_seconds",
27 "play_duration",
28 "source",
29];
30
31pub fn sfx_edits_path(sfx_path: &Path) -> PathBuf {
33 let s = sfx_path.to_string_lossy();
34 let stem = match (s.rfind('.'), s.rfind('/')) {
36 (Some(dot), Some(slash)) if dot > slash + 1 => &s[..dot],
37 (Some(dot), None) if dot > 0 => &s[..dot],
38 _ => &s[..],
39 };
40 PathBuf::from(format!("{stem}_edits.jsonl"))
41}
42
43pub fn append_sfx_edit(
46 sfx_path: &Path,
47 key: &str,
48 fields: &Map<String, Value>,
49) -> std::io::Result<()> {
50 let mut record = Map::new();
51 record.insert("ts".into(), Value::String(utc_now()));
52 record.insert("key".into(), Value::String(key.to_string()));
53 record.insert("fields".into(), Value::Object(fields.clone()));
54 append_record(sfx_path, &Value::Object(record))
55}
56
57pub fn append_sfx_defaults_edit(
59 sfx_path: &Path,
60 fields: &Map<String, Value>,
61) -> std::io::Result<()> {
62 let mut record = Map::new();
63 record.insert("ts".into(), Value::String(utc_now()));
64 record.insert("scope".into(), Value::String("defaults".into()));
65 record.insert("fields".into(), Value::Object(fields.clone()));
66 append_record(sfx_path, &Value::Object(record))
67}
68
69pub fn dialogue_edits_path(parsed_path: &Path) -> PathBuf {
72 sfx_edits_path(parsed_path)
73}
74
75pub fn append_dialogue_edit(
79 parsed_path: &Path,
80 seq: i64,
81 speaker: &str,
82 text: &str,
83) -> std::io::Result<()> {
84 let mut fields = Map::new();
85 fields.insert("speaker".into(), Value::String(speaker.to_string()));
86 fields.insert("text".into(), Value::String(text.to_string()));
87 let mut record = Map::new();
88 record.insert("ts".into(), Value::String(utc_now()));
89 record.insert("seq".into(), Value::from(seq));
90 record.insert("fields".into(), Value::Object(fields));
91 append_record(parsed_path, &Value::Object(record))
92}
93
94fn append_record(sfx_path: &Path, record: &Value) -> std::io::Result<()> {
95 let journal = sfx_edits_path(sfx_path);
96 if let Some(d) = journal.parent() {
97 if !d.as_os_str().is_empty() {
98 fs::create_dir_all(d)?;
99 }
100 }
101 let mut f = OpenOptions::new()
102 .append(true)
103 .create(true)
104 .open(&journal)?;
105 writeln!(f, "{}", dumps(record, Style::COMPACT_UTF8))
106}
107
108fn utc_now() -> String {
110 chrono::Utc::now()
111 .format("%Y-%m-%dT%H:%M:%S+00:00")
112 .to_string()
113}
114
115pub struct Replay {
118 pub applied: usize,
119 pub orphans: Vec<String>,
120}
121
122pub fn replay_sfx_edits(
128 sfx_path: &Path,
129 dry_run: bool,
130 warn: &mut dyn FnMut(String),
131) -> std::io::Result<Replay> {
132 let journal = sfx_edits_path(sfx_path);
133 if !journal.exists() {
134 return Ok(Replay {
135 applied: 0,
136 orphans: Vec::new(),
137 });
138 }
139
140 let text = fs::read_to_string(sfx_path)?;
141 let mut data: Value = serde_json::from_str(&text).unwrap_or(Value::Object(Map::new()));
142 if !data.is_object() {
143 data = Value::Object(Map::new());
144 }
145 let obj = data.as_object_mut().expect("object");
146 obj.entry("effects")
147 .or_insert_with(|| Value::Object(Map::new()));
148
149 let mut applied = 0usize;
150 let mut orphans: Vec<String> = Vec::new();
151
152 let journal_text = fs::read_to_string(&journal)?;
153 for (i, raw) in journal_text.lines().enumerate() {
154 let line = raw.trim();
155 if line.is_empty() {
156 continue;
157 }
158 let Ok(record) = serde_json::from_str::<Value>(line) else {
159 warn(format!(
160 " Skipping malformed journal line {} in {}",
161 i + 1,
162 journal.display()
163 ));
164 continue;
165 };
166
167 if record.get("scope").and_then(Value::as_str) == Some("defaults") {
168 let fields = record
169 .get("fields")
170 .and_then(Value::as_object)
171 .cloned()
172 .unwrap_or_default();
173 let defaults = obj
174 .entry("defaults")
175 .or_insert_with(|| Value::Object(Map::new()))
176 .as_object_mut()
177 .expect("defaults object");
178 for (k, v) in fields {
179 if v.is_null() {
180 defaults.remove(&k);
181 } else {
182 defaults.insert(k, v);
183 }
184 }
185 applied += 1;
186 continue;
187 }
188
189 let (Some(key), Some(fields)) = (
190 record
191 .get("key")
192 .and_then(Value::as_str)
193 .map(str::to_string),
194 record.get("fields").and_then(Value::as_object).cloned(),
195 ) else {
196 warn(format!(
197 " Skipping malformed journal line {} in {}",
198 i + 1,
199 journal.display()
200 ));
201 continue;
202 };
203
204 let effects = obj["effects"].as_object_mut().expect("effects object");
205 if !effects.contains_key(&key) && !orphans.contains(&key) {
206 orphans.push(key.clone());
207 }
208 let effect = effects
209 .entry(key.clone())
210 .or_insert_with(|| Value::Object(Map::new()));
211 let Some(effect) = effect.as_object_mut() else {
212 applied += 1;
213 continue;
214 };
215 let mut warnings = Vec::new();
216 for field in SFX_EDIT_FIELDS {
217 let Some(new) = fields.get(field) else {
218 continue;
219 };
220 if new.is_null() {
221 effect.remove(field);
222 continue;
223 }
224 if field == "source" {
225 if let Some(cur) = effect.get(field).filter(|c| !c.is_null()) {
226 if cur != new {
227 warnings.push(format!(
228 " Journal overrides script hint for '{key}': {} -> {} \
229 (re-run xil sfx-hydrate --force to make the script win)",
230 cur.as_str().unwrap_or_default(),
231 new.as_str().unwrap_or_default()
232 ));
233 }
234 }
235 }
236 effect.insert(field.to_string(), new.clone());
237 }
238 for w in warnings {
239 warn(w);
240 }
241 applied += 1;
242 }
243
244 if applied > 0 && !dry_run {
245 fs::write(sfx_path, format!("{}\n", dumps(&data, Style::INDENT2_UTF8)))?;
249 }
250 Ok(Replay { applied, orphans })
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use serde_json::json;
257
258 fn write(p: &Path, s: &str) {
259 fs::create_dir_all(p.parent().unwrap()).unwrap();
260 fs::write(p, s).unwrap();
261 }
262
263 #[test]
264 fn journal_path_swaps_the_extension() {
265 assert_eq!(
266 sfx_edits_path(Path::new("configs/s/sfx_S01E01.json")),
267 Path::new("configs/s/sfx_S01E01_edits.jsonl")
268 );
269 assert_eq!(
270 sfx_edits_path(Path::new("sfx_X")),
271 Path::new("sfx_X_edits.jsonl")
272 );
273 assert_eq!(
274 sfx_edits_path(Path::new("a.b/sfx_X")),
275 Path::new("a.b/sfx_X_edits.jsonl")
276 );
277 }
278
279 #[test]
280 fn replay_sets_clears_and_reports_orphans() {
281 let tmp = tempfile::tempdir().unwrap();
282 let cfg = tmp.path().join("sfx_S01E01.json");
283 write(
284 &cfg,
285 r#"{"effects":{"BEAT":{"type":"silence","volume_percentage":50}}}"#,
286 );
287 let j = sfx_edits_path(&cfg);
288 write(
289 &j,
290 concat!(
291 r#"{"ts":"t","key":"BEAT","fields":{"volume_percentage":null,"play_duration":25}}"#,
292 "\n",
293 r#"{"ts":"t","key":"GONE","fields":{"source":"SFX/x.mp3"}}"#,
294 "\n",
295 "\n",
296 r#"not json"#,
297 "\n",
298 r#"{"ts":"t","scope":"defaults","fields":{"music_volume_percentage":40}}"#,
299 "\n",
300 ),
301 );
302 let mut warns = Vec::new();
303 let r = replay_sfx_edits(&cfg, false, &mut |m| warns.push(m)).unwrap();
304 assert_eq!(r.applied, 3);
305 assert_eq!(r.orphans, vec!["GONE"]);
306 assert_eq!(warns.len(), 1, "the unparseable line warns");
307 let out: Value = serde_json::from_str(&fs::read_to_string(&cfg).unwrap()).unwrap();
308 assert_eq!(
309 out["effects"]["BEAT"],
310 json!({"type": "silence", "play_duration": 25})
311 );
312 assert_eq!(out["effects"]["GONE"], json!({"source": "SFX/x.mp3"}));
313 assert_eq!(out["defaults"], json!({"music_volume_percentage": 40}));
314 assert!(fs::read_to_string(&cfg).unwrap().ends_with("}\n"));
315 }
316
317 #[test]
318 fn replay_warns_when_journal_beats_a_script_hint() {
319 let tmp = tempfile::tempdir().unwrap();
320 let cfg = tmp.path().join("sfx_a.json");
321 write(
322 &cfg,
323 r#"{"effects":{"MUSIC: X":{"source":"SFX/from-script.mp3"}}}"#,
324 );
325 write(
326 &sfx_edits_path(&cfg),
327 "{\"key\":\"MUSIC: X\",\"fields\":{\"source\":\"SFX/from-journal.mp3\"}}\n",
328 );
329 let mut warns = Vec::new();
330 replay_sfx_edits(&cfg, false, &mut |m| warns.push(m)).unwrap();
331 assert_eq!(warns.len(), 1);
332 assert!(
333 warns[0].contains("SFX/from-script.mp3 -> SFX/from-journal.mp3"),
334 "{}",
335 warns[0]
336 );
337 }
338
339 #[test]
340 fn dry_run_leaves_the_file_alone() {
341 let tmp = tempfile::tempdir().unwrap();
342 let cfg = tmp.path().join("sfx_a.json");
343 let before = r#"{"effects":{"BEAT":{}}}"#;
344 write(&cfg, before);
345 write(
346 &sfx_edits_path(&cfg),
347 "{\"key\":\"BEAT\",\"fields\":{\"play_duration\":10}}\n",
348 );
349 let r = replay_sfx_edits(&cfg, true, &mut |_| {}).unwrap();
350 assert_eq!(r.applied, 1);
351 assert_eq!(fs::read_to_string(&cfg).unwrap(), before);
352 }
353
354 #[test]
355 fn no_journal_is_a_no_op() {
356 let tmp = tempfile::tempdir().unwrap();
357 let cfg = tmp.path().join("sfx_a.json");
358 write(&cfg, "{}");
359 let r = replay_sfx_edits(&cfg, false, &mut |_| panic!("no warnings expected")).unwrap();
360 assert_eq!((r.applied, r.orphans.len()), (0, 0));
361 }
362
363 #[test]
364 fn dialogue_edit_appends_seq_and_fields() {
365 let tmp = tempfile::tempdir().unwrap();
366 let parsed = tmp.path().join("parsed_S01E01.json");
367 append_dialogue_edit(&parsed, 47, "adam", "Well — hi").unwrap();
368 let text = fs::read_to_string(dialogue_edits_path(&parsed)).unwrap();
369 let lines: Vec<&str> = text.lines().collect();
370 assert_eq!(lines.len(), 1);
371 let record: Value = serde_json::from_str(lines[0]).unwrap();
372 assert_eq!(record["seq"], 47);
373 assert_eq!(record["fields"]["speaker"], "adam");
374 assert_eq!(record["fields"]["text"], "Well — hi");
375 assert_eq!(
376 dialogue_edits_path(&parsed),
377 tmp.path().join("parsed_S01E01_edits.jsonl")
378 );
379 }
380
381 #[test]
382 fn append_writes_one_compact_line_per_record() {
383 let tmp = tempfile::tempdir().unwrap();
384 let cfg = tmp.path().join("sfx_a.json");
385 let mut fields = Map::new();
386 fields.insert("source".into(), Value::String("SFX/é.mp3".into()));
387 fields.insert("volume_percentage".into(), Value::Null);
388 append_sfx_edit(&cfg, "SFX: X", &fields).unwrap();
389 append_sfx_edit(&cfg, "SFX: Y", &fields).unwrap();
390 let text = fs::read_to_string(sfx_edits_path(&cfg)).unwrap();
391 let lines: Vec<&str> = text.lines().collect();
392 assert_eq!(lines.len(), 2);
393 assert!(lines[0].contains(r#""key": "SFX: X""#), "{}", lines[0]);
394 assert!(
395 lines[0].contains("é.mp3"),
396 "ensure_ascii=False is preserved"
397 );
398 assert!(lines[0].contains(r#""volume_percentage": null"#));
399 }
400}