1use std::collections::{BTreeSet, HashMap};
18
19use crate::extras::{render_clip_lrc, render_synced_lrc};
20use crate::hash::{SYNCED_LRC_VERSION, content_hash, synced_lrc_source_hash};
21use crate::lyrics::AlignedLyrics;
22use crate::manifest::{Manifest, ManifestEntry};
23use crate::reconcile::{ArtifactKind, Desired};
24
25pub const SYNCED_LRC_RECHECK_SECS: u64 = 14 * 24 * 60 * 60;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PendingCheck {
35 pub clip_id: String,
37 pub empty: bool,
39 pub body_hash: Option<String>,
43}
44
45fn desired_lrc(desired: &Desired) -> Option<&str> {
47 desired
48 .artifacts
49 .iter()
50 .find(|a| a.kind == ArtifactKind::Lrc)
51 .map(|a| a.path.as_str())
52}
53
54fn needs_fetch(entry: Option<&ManifestEntry>, desired_lrc_path: &str, now_unix: u64) -> bool {
56 let Some(entry) = entry else {
57 return true; };
59 match &entry.synced_lyrics {
60 None => true,
62 Some(check) => {
63 if check.version != SYNCED_LRC_VERSION {
64 return true; }
66 if check.empty {
67 now_unix.saturating_sub(check.checked_unix) > SYNCED_LRC_RECHECK_SECS
69 } else {
70 entry
74 .lrc
75 .as_ref()
76 .map(|slot| slot.path != desired_lrc_path)
77 .unwrap_or(true)
78 }
79 }
80 }
81}
82
83pub fn synced_lyrics_targets(
90 desired: &[Desired],
91 manifest: &Manifest,
92 now_unix: u64,
93 enabled: bool,
94) -> BTreeSet<String> {
95 if !enabled {
96 return BTreeSet::new();
97 }
98 let mut out = BTreeSet::new();
99 for d in desired {
100 let Some(path) = desired_lrc(d) else {
101 continue;
102 };
103 if needs_fetch(manifest.get(&d.clip.id), path, now_unix) {
104 out.insert(d.clip.id.clone());
105 }
106 }
107 out
108}
109
110pub fn apply_synced_lrc(
127 desired: &mut [Desired],
128 manifest: &Manifest,
129 successes: &HashMap<String, AlignedLyrics>,
130) -> Vec<PendingCheck> {
131 let mut pending = Vec::new();
132 for d in desired.iter_mut() {
133 let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
134 continue;
135 };
136 let clip_id = d.clip.id.clone();
137 let slot_hash = manifest
138 .get(&clip_id)
139 .and_then(|e| e.lrc.as_ref())
140 .map(|slot| slot.hash.clone());
141
142 if let Some(aligned) = successes.get(&clip_id) {
143 let body = if aligned.is_empty() {
144 render_clip_lrc(&d.clip, &d.lineage)
145 } else {
146 render_synced_lrc(&d.clip, &d.lineage, aligned)
147 };
148 match body {
149 Some(text) => {
150 let hash = content_hash(&text);
151 let artifact = &mut d.artifacts[idx];
152 artifact.hash = hash.clone();
153 artifact.content = Some(text);
154 pending.push(PendingCheck {
155 clip_id,
156 empty: false,
157 body_hash: Some(hash),
158 });
159 }
160 None => {
161 d.artifacts.remove(idx);
162 pending.push(PendingCheck {
163 clip_id,
164 empty: true,
165 body_hash: None,
166 });
167 }
168 }
169 } else {
170 match slot_hash {
174 Some(hash) => {
175 let artifact = &mut d.artifacts[idx];
176 artifact.hash = hash;
177 artifact.content = None;
178 }
179 None => {
180 d.artifacts.remove(idx);
181 }
182 }
183 }
184 }
185 pending
186}
187
188pub fn preview_synced_lrc(
196 desired: &mut [Desired],
197 manifest: &Manifest,
198 now_unix: u64,
199 enabled: bool,
200) {
201 let targets = synced_lyrics_targets(desired, manifest, now_unix, enabled);
202 for d in desired.iter_mut() {
203 let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
204 continue;
205 };
206 if targets.contains(&d.clip.id) {
207 d.artifacts[idx].hash = synced_lrc_source_hash(&d.clip.id);
208 continue;
209 }
210 match manifest.get(&d.clip.id).and_then(|e| e.lrc.as_ref()) {
211 Some(slot) => d.artifacts[idx].hash = slot.hash.clone(),
212 None => {
213 d.artifacts.remove(idx);
214 }
215 }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::config::AudioFormat;
223 use crate::lineage::LineageContext;
224 use crate::manifest::{ArtifactState, SyncedLyricsCheck};
225 use crate::model::Clip;
226 use crate::reconcile::DesiredArtifact;
227
228 fn clip(id: &str, lyrics: &str) -> Clip {
229 Clip {
230 id: id.to_string(),
231 title: "Song".to_string(),
232 lyrics: lyrics.to_string(),
233 prompt: "a prompt".to_string(),
234 ..Default::default()
235 }
236 }
237
238 fn lrc_artifact(clip_id: &str) -> DesiredArtifact {
239 DesiredArtifact {
240 kind: ArtifactKind::Lrc,
241 path: format!("{clip_id}.lrc"),
242 source_url: String::new(),
243 hash: synced_lrc_source_hash(clip_id),
244 content: None,
245 }
246 }
247
248 fn desired(id: &str, lyrics: &str) -> Desired {
249 let c = clip(id, lyrics);
250 Desired {
251 lineage: LineageContext::own_root(&c),
252 path: format!("{id}.flac"),
253 format: AudioFormat::Flac,
254 meta_hash: "m".to_string(),
255 art_hash: "a".to_string(),
256 modes: vec![crate::reconcile::SourceMode::Mirror],
257 trashed: false,
258 private: false,
259 artifacts: vec![lrc_artifact(id)],
260 clip: c,
261 }
262 }
263
264 fn one_line_alignment() -> AlignedLyrics {
265 AlignedLyrics::from_json(&serde_json::json!({
266 "aligned_words": [],
267 "aligned_lyrics": [
268 {"text": "hi there", "start_s": 0.5, "end_s": 1.2, "section": "Verse 1",
269 "words": [
270 {"text": "hi", "start_s": 0.5, "end_s": 0.8},
271 {"text": "there", "start_s": 0.9, "end_s": 1.2}
272 ]}
273 ]
274 }))
275 }
276
277 fn entry(lrc: Option<ArtifactState>, check: Option<SyncedLyricsCheck>) -> ManifestEntry {
278 ManifestEntry {
279 path: "song.flac".to_string(),
280 format: AudioFormat::Flac,
281 lrc,
282 synced_lyrics: check,
283 ..Default::default()
284 }
285 }
286
287 #[test]
288 fn targets_empty_when_feature_off() {
289 let d = vec![desired("a", "")];
290 let manifest = Manifest::new();
291 assert!(synced_lyrics_targets(&d, &manifest, 0, false).is_empty());
292 }
293
294 #[test]
295 fn targets_new_clip_but_not_a_recently_resolved_one() {
296 let d = vec![desired("new", ""), desired("done", "")];
297 let mut manifest = Manifest::new();
298 manifest.insert(
300 "done",
301 entry(
302 Some(ArtifactState {
303 path: "done.lrc".to_string(),
304 hash: "h".to_string(),
305 }),
306 Some(SyncedLyricsCheck {
307 version: SYNCED_LRC_VERSION,
308 checked_unix: 1_000,
309 empty: false,
310 }),
311 ),
312 );
313 let targets = synced_lyrics_targets(&d, &manifest, 2_000, true);
314 assert!(targets.contains("new"));
315 assert!(!targets.contains("done"));
316 }
317
318 #[test]
319 fn instrumental_is_rechecked_only_after_the_window() {
320 let d = vec![desired("instr", "")];
321 let mut manifest = Manifest::new();
322 manifest.insert(
323 "instr",
324 entry(
325 None,
326 Some(SyncedLyricsCheck {
327 version: SYNCED_LRC_VERSION,
328 checked_unix: 1_000,
329 empty: true,
330 }),
331 ),
332 );
333 let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
335 assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
336 let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
338 assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("instr"));
339 }
340
341 #[test]
342 fn version_bump_refetches_everything() {
343 let d = vec![desired("done", "")];
344 let mut manifest = Manifest::new();
345 manifest.insert(
346 "done",
347 entry(
348 Some(ArtifactState {
349 path: "done.lrc".to_string(),
350 hash: "h".to_string(),
351 }),
352 Some(SyncedLyricsCheck {
353 version: SYNCED_LRC_VERSION + 1, checked_unix: 1_000,
355 empty: false,
356 }),
357 ),
358 );
359 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("done"));
360 }
361
362 #[test]
363 fn rename_refetches_a_written_clip() {
364 let mut d = vec![desired("a", "")];
365 d[0].artifacts[0].path = "new/a.lrc".to_string();
367 let mut manifest = Manifest::new();
368 manifest.insert(
369 "a",
370 entry(
371 Some(ArtifactState {
372 path: "old/a.lrc".to_string(),
373 hash: "h".to_string(),
374 }),
375 Some(SyncedLyricsCheck {
376 version: SYNCED_LRC_VERSION,
377 checked_unix: 1_000,
378 empty: false,
379 }),
380 ),
381 );
382 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"));
383 }
384
385 #[test]
386 fn apply_sets_timed_body_and_content_hash() {
387 let mut d = vec![desired("a", "")];
388 let mut successes = HashMap::new();
389 successes.insert("a".to_string(), one_line_alignment());
390 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
391
392 let art = &d[0].artifacts[0];
393 let body = art.content.as_deref().unwrap();
394 assert!(body.contains("[00:00.50]hi there"));
395 assert_eq!(art.hash, content_hash(body));
396 assert_eq!(
397 pending,
398 vec![PendingCheck {
399 clip_id: "a".to_string(),
400 empty: false,
401 body_hash: Some(content_hash(body)),
402 }]
403 );
404 }
405
406 #[test]
407 fn apply_drops_instrumental_and_marks_empty() {
408 let mut d = vec![desired("instr", "")];
409 let mut successes = HashMap::new();
410 successes.insert("instr".to_string(), AlignedLyrics::default());
411 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
412
413 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
414 assert_eq!(
415 pending,
416 vec![PendingCheck {
417 clip_id: "instr".to_string(),
418 empty: true,
419 body_hash: None,
420 }]
421 );
422 }
423
424 #[test]
425 fn apply_keeps_existing_on_fetch_failure_no_downgrade() {
426 let mut d = vec![desired("a", "")];
432 let mut manifest = Manifest::new();
433 manifest.insert(
434 "a",
435 entry(
436 Some(ArtifactState {
437 path: "a.lrc".to_string(),
438 hash: "timed-hash".to_string(),
439 }),
440 Some(SyncedLyricsCheck {
441 version: SYNCED_LRC_VERSION,
442 checked_unix: 1_000,
443 empty: false,
444 }),
445 ),
446 );
447 let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
448
449 let art = &d[0].artifacts[0];
450 assert_eq!(art.hash, "timed-hash");
451 assert_eq!(art.content, None);
452 assert!(
453 pending.is_empty(),
454 "no check recorded on failure -> retried"
455 );
456 }
457
458 #[test]
459 fn apply_drops_write_on_failure_when_nothing_on_disk() {
460 let mut d = vec![desired("a", "")];
463 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &HashMap::new());
464 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
465 assert!(pending.is_empty());
466 }
467
468 #[test]
469 fn apply_upgrades_untimed_to_timed_when_alignment_appears() {
470 let mut d = vec![desired("a", "")];
474 let untimed_hash = "untimed".to_string();
475 let mut manifest = Manifest::new();
476 manifest.insert(
477 "a",
478 entry(
479 Some(ArtifactState {
480 path: "a.lrc".to_string(),
481 hash: untimed_hash.clone(),
482 }),
483 Some(SyncedLyricsCheck {
484 version: SYNCED_LRC_VERSION,
485 checked_unix: 1_000,
486 empty: true,
487 }),
488 ),
489 );
490 let mut successes = HashMap::new();
491 successes.insert("a".to_string(), one_line_alignment());
492 apply_synced_lrc(&mut d, &manifest, &successes);
493 let art = &d[0].artifacts[0];
494 assert!(
495 art.content
496 .as_deref()
497 .unwrap()
498 .contains("[00:00.50]hi there")
499 );
500 assert_ne!(art.hash, untimed_hash, "a changed body triggers a rewrite");
501 }
502
503 #[test]
504 fn preview_shows_write_for_targets_and_skips_resolved() {
505 let mut d = vec![desired("new", ""), desired("done", "")];
506 let mut manifest = Manifest::new();
507 manifest.insert(
508 "done",
509 entry(
510 Some(ArtifactState {
511 path: "done.lrc".to_string(),
512 hash: "slot-hash".to_string(),
513 }),
514 Some(SyncedLyricsCheck {
515 version: SYNCED_LRC_VERSION,
516 checked_unix: 1_000,
517 empty: false,
518 }),
519 ),
520 );
521 preview_synced_lrc(&mut d, &manifest, 2_000, true);
522 assert_eq!(d[0].artifacts[0].hash, synced_lrc_source_hash("new"));
524 assert_eq!(d[1].artifacts[0].hash, "slot-hash");
525 }
526}