1use std::collections::{BTreeSet, HashMap};
19
20use crate::hash::{SYNCED_LRC_VERSION, content_hash, synced_lrc_source_hash};
21use crate::lyrics::{AlignedLyrics, render_clip_lrc, render_synced_lrc};
22use crate::manifest::{Manifest, ManifestEntry};
23use crate::reconcile::Desired;
24use crate::vocab::ArtifactKind;
25
26pub const SYNCED_LRC_RECHECK_SECS: u64 = 14 * 24 * 60 * 60;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct PendingCheck {
36 pub clip_id: String,
38 pub empty: bool,
40 pub timed: bool,
43 pub body_hash: Option<String>,
47}
48
49fn desired_lrc(desired: &Desired) -> Option<&str> {
51 desired
52 .artifacts
53 .iter()
54 .find(|a| a.kind == ArtifactKind::Lrc)
55 .map(|a| a.path.as_str())
56}
57
58fn needs_fetch(entry: Option<&ManifestEntry>, desired_lrc_path: &str, now_unix: u64) -> bool {
60 let Some(entry) = entry else {
61 return true; };
63 if let Some(slot) = entry.lrc.as_ref()
70 && slot.hash != entry.embedded_lyrics_hash
71 {
72 return true;
73 }
74 match &entry.synced_lyrics {
75 None => true,
77 Some(check) => {
78 if check.version != SYNCED_LRC_VERSION {
79 return true; }
81 if check.empty || !check.timed {
82 now_unix.saturating_sub(check.checked_unix) > SYNCED_LRC_RECHECK_SECS
85 } else {
86 entry
90 .lrc
91 .as_ref()
92 .map(|slot| slot.path != desired_lrc_path)
93 .unwrap_or(true)
94 }
95 }
96 }
97}
98
99pub fn synced_lyrics_targets(
106 desired: &[Desired],
107 manifest: &Manifest,
108 now_unix: u64,
109 enabled: bool,
110) -> BTreeSet<String> {
111 if !enabled {
112 return BTreeSet::new();
113 }
114 let mut out = BTreeSet::new();
115 for d in desired {
116 let Some(path) = desired_lrc(d) else {
117 continue;
118 };
119 let entry = manifest.get(&d.clip.id);
120 let reformat_reembed = entry.is_some_and(|e| e.format != d.format && e.lrc.is_some());
127 if reformat_reembed || needs_fetch(entry, path, now_unix) {
128 out.insert(d.clip.id.clone());
129 }
130 }
131 out
132}
133
134pub fn apply_synced_lrc(
153 desired: &mut [Desired],
154 manifest: &Manifest,
155 successes: &HashMap<String, AlignedLyrics>,
156) -> Vec<PendingCheck> {
157 let mut pending = Vec::new();
158 for d in desired.iter_mut() {
159 d.embedded_lyrics_hash = manifest
168 .get(&d.clip.id)
169 .map(|e| e.embedded_lyrics_hash.clone())
170 .unwrap_or_default();
171
172 let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
173 continue;
174 };
175 let clip_id = d.clip.id.clone();
176 let slot_hash = manifest
177 .get(&clip_id)
178 .and_then(|e| e.lrc.as_ref())
179 .map(|slot| slot.hash.clone());
180
181 if let Some(aligned) = successes.get(&clip_id) {
182 let timed = !aligned.is_empty();
183 let body = if timed {
184 render_synced_lrc(&d.clip, &d.lineage, aligned)
185 } else {
186 render_clip_lrc(&d.clip, &d.lineage)
187 };
188 match body {
189 Some(text) => {
190 let hash = content_hash(&text);
191 d.embedded_lyrics_hash = hash.clone();
194 let artifact = &mut d.artifacts[idx];
195 artifact.hash = hash.clone();
196 artifact.content = Some(text);
197 pending.push(PendingCheck {
198 clip_id,
199 empty: false,
200 timed,
201 body_hash: Some(hash),
202 });
203 }
204 None => {
205 d.embedded_lyrics_hash = String::new();
207 d.artifacts.remove(idx);
208 pending.push(PendingCheck {
209 clip_id,
210 empty: true,
211 timed: false,
212 body_hash: None,
213 });
214 }
215 }
216 } else {
217 match slot_hash {
223 Some(hash) => {
224 let artifact = &mut d.artifacts[idx];
225 artifact.hash = hash;
226 artifact.content = None;
227 }
228 None => {
229 d.artifacts.remove(idx);
230 }
231 }
232 }
233 }
234 pending
235}
236
237pub fn preview_synced_lrc(
245 desired: &mut [Desired],
246 manifest: &Manifest,
247 now_unix: u64,
248 enabled: bool,
249) {
250 let targets = synced_lyrics_targets(desired, manifest, now_unix, enabled);
251 for d in desired.iter_mut() {
252 d.embedded_lyrics_hash = manifest
258 .get(&d.clip.id)
259 .map(|e| e.embedded_lyrics_hash.clone())
260 .unwrap_or_default();
261
262 let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
263 continue;
264 };
265 if targets.contains(&d.clip.id) {
266 d.embedded_lyrics_hash = manifest
270 .get(&d.clip.id)
271 .and_then(|e| e.lrc.as_ref())
272 .map(|s| s.hash.clone())
273 .unwrap_or_default();
274 d.artifacts[idx].hash = synced_lrc_source_hash(&d.clip.id);
275 continue;
276 }
277 match manifest.get(&d.clip.id).and_then(|e| e.lrc.as_ref()) {
278 Some(slot) => d.artifacts[idx].hash = slot.hash.clone(),
279 None => {
280 d.artifacts.remove(idx);
281 }
282 }
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::lineage::LineageContext;
290 use crate::lyrics::{AlignedLine, AlignedLineWord};
291 use crate::manifest::{ArtifactState, SyncedLyricsCheck};
292 use crate::model::Clip;
293 use crate::reconcile::DesiredArtifact;
294 use crate::vocab::AudioFormat;
295
296 fn clip(id: &str, lyrics: &str) -> Clip {
297 Clip {
298 id: id.to_string(),
299 title: "Song".to_string(),
300 lyrics: lyrics.to_string(),
301 prompt: "a prompt".to_string(),
302 ..Default::default()
303 }
304 }
305
306 fn lrc_artifact(clip_id: &str) -> DesiredArtifact {
307 DesiredArtifact {
308 kind: ArtifactKind::Lrc,
309 path: format!("{clip_id}.lrc"),
310 source_url: String::new(),
311 hash: synced_lrc_source_hash(clip_id),
312 content: None,
313 }
314 }
315
316 fn desired(id: &str, lyrics: &str) -> Desired {
317 let c = clip(id, lyrics);
318 Desired {
319 lineage: LineageContext::own_root(&c),
320 path: format!("{id}.flac"),
321 format: AudioFormat::Flac,
322 meta_hash: "m".to_string(),
323 art_hash: "a".to_string(),
324 embedded_lyrics_hash: String::new(),
325 modes: vec![crate::vocab::SourceMode::Mirror],
326 trashed: false,
327 private: false,
328 artifacts: vec![lrc_artifact(id)],
329 clip: c,
330 stems: None,
331 }
332 }
333
334 fn one_line_alignment() -> AlignedLyrics {
335 AlignedLyrics {
336 lines: vec![AlignedLine {
337 text: "hi there".to_owned(),
338 start_s: 0.5,
339 end_s: 1.2,
340 section: "Verse 1".to_owned(),
341 words: vec![
342 AlignedLineWord {
343 text: "hi".to_owned(),
344 start_s: 0.5,
345 end_s: 0.8,
346 },
347 AlignedLineWord {
348 text: "there".to_owned(),
349 start_s: 0.9,
350 end_s: 1.2,
351 },
352 ],
353 }],
354 ..Default::default()
355 }
356 }
357
358 fn entry(lrc: Option<ArtifactState>, check: Option<SyncedLyricsCheck>) -> ManifestEntry {
359 let embedded_lyrics_hash = lrc.as_ref().map(|s| s.hash.clone()).unwrap_or_default();
363 ManifestEntry {
364 path: "song.flac".to_string(),
365 format: AudioFormat::Flac,
366 lrc,
367 embedded_lyrics_hash,
368 synced_lyrics: check,
369 ..Default::default()
370 }
371 }
372
373 #[test]
374 fn targets_empty_when_feature_off() {
375 let d = vec![desired("a", "")];
376 let manifest = Manifest::new();
377 assert!(synced_lyrics_targets(&d, &manifest, 0, false).is_empty());
378 }
379
380 #[test]
381 fn targets_new_clip_but_not_a_recently_resolved_one() {
382 let d = vec![desired("new", ""), desired("done", "")];
383 let mut manifest = Manifest::new();
384 manifest.insert(
386 "done",
387 entry(
388 Some(ArtifactState {
389 path: "done.lrc".to_string(),
390 hash: "h".to_string(),
391 }),
392 Some(SyncedLyricsCheck {
393 version: SYNCED_LRC_VERSION,
394 checked_unix: 1_000,
395 empty: false,
396 timed: true,
397 }),
398 ),
399 );
400 let targets = synced_lyrics_targets(&d, &manifest, 2_000, true);
401 assert!(targets.contains("new"));
402 assert!(!targets.contains("done"));
403 }
404
405 #[test]
406 fn instrumental_is_rechecked_only_after_the_window() {
407 let d = vec![desired("instr", "")];
408 let mut manifest = Manifest::new();
409 manifest.insert(
410 "instr",
411 entry(
412 None,
413 Some(SyncedLyricsCheck {
414 version: SYNCED_LRC_VERSION,
415 checked_unix: 1_000,
416 empty: true,
417 timed: false,
418 }),
419 ),
420 );
421 let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
423 assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
424 let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
426 assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("instr"));
427 }
428
429 #[test]
430 fn untimed_fallback_is_rechecked_after_the_window() {
431 let d = vec![desired("a", "some lyrics")];
435 let mut manifest = Manifest::new();
436 manifest.insert(
437 "a",
438 entry(
439 Some(ArtifactState {
440 path: "a.lrc".to_string(),
441 hash: "untimed-hash".to_string(),
442 }),
443 Some(SyncedLyricsCheck {
444 version: SYNCED_LRC_VERSION,
445 checked_unix: 1_000,
446 empty: false,
447 timed: false,
448 }),
449 ),
450 );
451 let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
453 assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
454 let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
456 assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("a"));
457 }
458
459 #[test]
460 fn timed_clip_is_not_rechecked_without_rename() {
461 let d = vec![desired("a", "")];
464 let mut manifest = Manifest::new();
465 manifest.insert(
466 "a",
467 entry(
468 Some(ArtifactState {
469 path: "a.lrc".to_string(),
470 hash: "h".to_string(),
471 }),
472 Some(SyncedLyricsCheck {
473 version: SYNCED_LRC_VERSION,
474 checked_unix: 0, empty: false,
476 timed: true,
477 }),
478 ),
479 );
480 let very_late = 2 * SYNCED_LRC_RECHECK_SECS;
482 assert!(synced_lyrics_targets(&d, &manifest, very_late, true).is_empty());
483 }
484
485 #[test]
486 fn version_bump_refetches_everything() {
487 let d = vec![desired("done", "")];
488 let mut manifest = Manifest::new();
489 manifest.insert(
490 "done",
491 entry(
492 Some(ArtifactState {
493 path: "done.lrc".to_string(),
494 hash: "h".to_string(),
495 }),
496 Some(SyncedLyricsCheck {
497 version: SYNCED_LRC_VERSION + 1, checked_unix: 1_000,
499 empty: false,
500 timed: true,
501 }),
502 ),
503 );
504 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("done"));
505 }
506
507 #[test]
508 fn rename_refetches_a_written_clip() {
509 let mut d = vec![desired("a", "")];
510 d[0].artifacts[0].path = "new/a.lrc".to_string();
512 let mut manifest = Manifest::new();
513 manifest.insert(
514 "a",
515 entry(
516 Some(ArtifactState {
517 path: "old/a.lrc".to_string(),
518 hash: "h".to_string(),
519 }),
520 Some(SyncedLyricsCheck {
521 version: SYNCED_LRC_VERSION,
522 checked_unix: 1_000,
523 empty: false,
524 timed: true,
525 }),
526 ),
527 );
528 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"));
529 }
530
531 #[test]
532 fn apply_sets_timed_body_and_content_hash() {
533 let mut d = vec![desired("a", "")];
534 let mut successes = HashMap::new();
535 successes.insert("a".to_string(), one_line_alignment());
536 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
537
538 let art = &d[0].artifacts[0];
539 let body = art.content.as_deref().unwrap();
540 assert!(body.contains("[00:00.50]hi there"));
541 assert_eq!(art.hash, content_hash(body));
542 assert_eq!(
543 pending,
544 vec![PendingCheck {
545 clip_id: "a".to_string(),
546 empty: false,
547 timed: true,
548 body_hash: Some(content_hash(body)),
549 }]
550 );
551 }
552
553 #[test]
554 fn apply_untimed_fallback_marks_not_timed() {
555 let mut d = vec![desired("a", "some lyrics")];
559 let mut successes = HashMap::new();
560 successes.insert("a".to_string(), AlignedLyrics::default());
561 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
562
563 let art = &d[0].artifacts[0];
564 assert!(art.content.is_some(), "untimed body written");
565 let check = &pending[0];
566 assert!(!check.empty, "clip has lyrics, not an instrumental");
567 assert!(!check.timed, "alignment was empty -> untimed fallback");
568 }
569
570 #[test]
571 fn apply_drops_instrumental_and_marks_empty() {
572 let mut d = vec![desired("instr", "")];
573 let mut successes = HashMap::new();
574 successes.insert("instr".to_string(), AlignedLyrics::default());
575 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
576
577 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
578 assert_eq!(
579 pending,
580 vec![PendingCheck {
581 clip_id: "instr".to_string(),
582 empty: true,
583 timed: false,
584 body_hash: None,
585 }]
586 );
587 }
588
589 #[test]
590 fn apply_keeps_existing_on_fetch_failure_no_downgrade() {
591 let mut d = vec![desired("a", "")];
597 let mut manifest = Manifest::new();
598 manifest.insert(
599 "a",
600 entry(
601 Some(ArtifactState {
602 path: "a.lrc".to_string(),
603 hash: "timed-hash".to_string(),
604 }),
605 Some(SyncedLyricsCheck {
606 version: SYNCED_LRC_VERSION,
607 checked_unix: 1_000,
608 empty: false,
609 timed: true,
610 }),
611 ),
612 );
613 let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
614
615 let art = &d[0].artifacts[0];
616 assert_eq!(art.hash, "timed-hash");
617 assert_eq!(art.content, None);
618 assert!(
619 pending.is_empty(),
620 "no check recorded on failure -> retried"
621 );
622 }
623
624 #[test]
625 fn apply_drops_write_on_failure_when_nothing_on_disk() {
626 let mut d = vec![desired("a", "")];
629 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &HashMap::new());
630 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
631 assert!(pending.is_empty());
632 }
633
634 #[test]
635 fn apply_upgrades_untimed_to_timed_when_alignment_appears() {
636 let mut d = vec![desired("a", "some lyrics")];
640 let untimed_hash = "untimed".to_string();
641 let mut manifest = Manifest::new();
642 manifest.insert(
643 "a",
644 entry(
645 Some(ArtifactState {
646 path: "a.lrc".to_string(),
647 hash: untimed_hash.clone(),
648 }),
649 Some(SyncedLyricsCheck {
650 version: SYNCED_LRC_VERSION,
651 checked_unix: 1_000,
652 empty: false,
653 timed: false,
654 }),
655 ),
656 );
657 let mut successes = HashMap::new();
658 successes.insert("a".to_string(), one_line_alignment());
659 let pending = apply_synced_lrc(&mut d, &manifest, &successes);
660 let art = &d[0].artifacts[0];
661 assert!(
662 art.content
663 .as_deref()
664 .unwrap()
665 .contains("[00:00.50]hi there")
666 );
667 assert_ne!(art.hash, untimed_hash, "a changed body triggers a rewrite");
668 assert!(pending[0].timed, "upgraded to timed");
669 }
670
671 #[test]
672 fn preview_shows_write_for_targets_and_skips_resolved() {
673 let mut d = vec![desired("new", ""), desired("done", "")];
674 let mut manifest = Manifest::new();
675 manifest.insert(
676 "done",
677 entry(
678 Some(ArtifactState {
679 path: "done.lrc".to_string(),
680 hash: "slot-hash".to_string(),
681 }),
682 Some(SyncedLyricsCheck {
683 version: SYNCED_LRC_VERSION,
684 checked_unix: 1_000,
685 empty: false,
686 timed: true,
687 }),
688 ),
689 );
690 preview_synced_lrc(&mut d, &manifest, 2_000, true);
691 assert_eq!(d[0].artifacts[0].hash, synced_lrc_source_hash("new"));
693 assert_eq!(d[1].artifacts[0].hash, "slot-hash");
694 }
695
696 fn timed_check() -> SyncedLyricsCheck {
700 SyncedLyricsCheck {
701 version: SYNCED_LRC_VERSION,
702 checked_unix: 1_000,
703 empty: false,
704 timed: true,
705 }
706 }
707
708 fn slot(path: &str, hash: &str) -> Option<ArtifactState> {
709 Some(ArtifactState {
710 path: path.to_string(),
711 hash: hash.to_string(),
712 })
713 }
714
715 #[test]
716 fn needs_fetch_backfills_when_embed_missing() {
717 let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
720 e.embedded_lyrics_hash = String::new();
721 assert!(needs_fetch(Some(&e), "a.lrc", 2_000));
722 }
723
724 #[test]
725 fn needs_fetch_skips_when_embed_matches_lrc() {
726 let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
728 e.embedded_lyrics_hash = "H".to_string();
729 assert!(!needs_fetch(Some(&e), "a.lrc", 2_000));
730 }
731
732 #[test]
733 fn needs_fetch_no_backfill_without_lrc_slot() {
734 let mut e = entry(
738 None,
739 Some(SyncedLyricsCheck {
740 empty: true,
741 timed: false,
742 ..timed_check()
743 }),
744 );
745 e.embedded_lyrics_hash = String::new();
746 let within_window = 1_000 + SYNCED_LRC_RECHECK_SECS;
747 assert!(!needs_fetch(Some(&e), "a.lrc", within_window));
748 }
749
750 #[test]
751 fn apply_sets_embedded_lyrics_hash_from_body() {
752 let mut d = vec![desired("a", "")];
755 let mut successes = HashMap::new();
756 successes.insert("a".to_string(), one_line_alignment());
757 apply_synced_lrc(&mut d, &Manifest::new(), &successes);
758
759 let art = &d[0].artifacts[0];
760 let body = art.content.as_deref().unwrap();
761 assert_eq!(d[0].embedded_lyrics_hash, content_hash(body));
762 assert_eq!(d[0].embedded_lyrics_hash, art.hash);
763 }
764
765 #[test]
766 fn apply_clears_embedded_lyrics_hash_for_instrumental() {
767 let mut d = vec![desired("instr", "")];
770 let mut manifest = Manifest::new();
771 let mut e = entry(slot("instr.lrc", "H"), Some(timed_check()));
772 e.embedded_lyrics_hash = "H".to_string();
773 manifest.insert("instr", e);
774 let mut successes = HashMap::new();
775 successes.insert("instr".to_string(), AlignedLyrics::default());
776 apply_synced_lrc(&mut d, &manifest, &successes);
777
778 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
779 assert_eq!(d[0].embedded_lyrics_hash, "");
780 }
781
782 #[test]
783 fn apply_carries_forward_embedded_lyrics_hash_when_not_fetched() {
784 let mut d = vec![desired("a", "")];
788 let mut manifest = Manifest::new();
789 let mut e = entry(slot("a.lrc", "slot"), Some(timed_check()));
790 e.embedded_lyrics_hash = "embed".to_string();
791 manifest.insert("a", e);
792 apply_synced_lrc(&mut d, &manifest, &HashMap::new());
793
794 assert_eq!(
795 d[0].embedded_lyrics_hash, "embed",
796 "carry-forward, not the slot hash"
797 );
798 assert_eq!(d[0].artifacts[0].hash, "slot");
800 }
801
802 #[test]
803 fn apply_carries_forward_for_clip_without_lrc_artifact() {
804 let mut d = vec![desired("a", "")];
807 d[0].artifacts.clear();
808 let mut manifest = Manifest::new();
809 let mut e = entry(None, None);
810 e.embedded_lyrics_hash = "H".to_string();
811 manifest.insert("a", e);
812 apply_synced_lrc(&mut d, &manifest, &HashMap::new());
813
814 assert_eq!(d[0].embedded_lyrics_hash, "H");
815 }
816
817 #[test]
818 fn preview_marks_backfill_target_as_pending() {
819 let mut d = vec![desired("stale", ""), desired("done", "")];
823 let mut manifest = Manifest::new();
824 let mut stale = entry(slot("stale.lrc", "H"), Some(timed_check()));
825 stale.embedded_lyrics_hash = String::new();
826 manifest.insert("stale", stale);
827 let mut done = entry(slot("done.lrc", "D"), Some(timed_check()));
828 done.embedded_lyrics_hash = "D".to_string();
829 manifest.insert("done", done);
830
831 preview_synced_lrc(&mut d, &manifest, 2_000, true);
832 assert_eq!(
833 d[0].embedded_lyrics_hash, "H",
834 "target previews the back-fill"
835 );
836 assert_ne!(
837 d[0].embedded_lyrics_hash, "",
838 "differs from the manifest embed -> drift reported"
839 );
840 assert_eq!(
841 d[1].embedded_lyrics_hash, "D",
842 "resolved clip carries forward"
843 );
844 }
845
846 #[test]
847 fn reformat_makes_migrated_clip_a_target() {
848 let mut manifest = Manifest::new();
852 let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
853 e.embedded_lyrics_hash = "H".to_string(); manifest.insert("a", e);
855
856 let mut reformat = vec![desired("a", "")];
857 reformat[0].format = AudioFormat::Mp3;
858 assert!(
859 synced_lyrics_targets(&reformat, &manifest, 2_000, true).contains("a"),
860 "a format change re-embeds a migrated clip"
861 );
862
863 let stable = vec![desired("a", "")]; assert!(
866 synced_lyrics_targets(&stable, &manifest, 2_000, true).is_empty(),
867 "no reformat, no back-fill -> no fetch"
868 );
869
870 let mut no_lrc = Manifest::new();
873 no_lrc.insert(
874 "a",
875 entry(
876 None,
877 Some(SyncedLyricsCheck {
878 empty: true,
879 timed: false,
880 ..timed_check()
881 }),
882 ),
883 );
884 let mut reformat_no_lrc = vec![desired("a", "")];
885 reformat_no_lrc[0].format = AudioFormat::Mp3;
886 assert!(
887 synced_lyrics_targets(&reformat_no_lrc, &no_lrc, 2_000, true).is_empty(),
888 "no `.lrc` to re-embed -> not a target despite the reformat"
889 );
890 }
891}