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 match &entry.synced_lyrics {
64 None => true,
66 Some(check) => {
67 if check.version != SYNCED_LRC_VERSION {
68 return true; }
70 if check.empty || !check.timed {
71 now_unix.saturating_sub(check.checked_unix) > SYNCED_LRC_RECHECK_SECS
74 } else {
75 entry
79 .lrc
80 .as_ref()
81 .map(|slot| slot.path != desired_lrc_path)
82 .unwrap_or(true)
83 }
84 }
85 }
86}
87
88pub fn synced_lyrics_targets(
95 desired: &[Desired],
96 manifest: &Manifest,
97 now_unix: u64,
98 enabled: bool,
99) -> BTreeSet<String> {
100 if !enabled {
101 return BTreeSet::new();
102 }
103 let mut out = BTreeSet::new();
104 for d in desired {
105 let Some(path) = desired_lrc(d) else {
106 continue;
107 };
108 if needs_fetch(manifest.get(&d.clip.id), path, now_unix) {
109 out.insert(d.clip.id.clone());
110 }
111 }
112 out
113}
114
115pub fn apply_synced_lrc(
134 desired: &mut [Desired],
135 manifest: &Manifest,
136 successes: &HashMap<String, AlignedLyrics>,
137) -> Vec<PendingCheck> {
138 let mut pending = Vec::new();
139 for d in desired.iter_mut() {
140 let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
141 continue;
142 };
143 let clip_id = d.clip.id.clone();
144 let slot_hash = manifest
145 .get(&clip_id)
146 .and_then(|e| e.lrc.as_ref())
147 .map(|slot| slot.hash.clone());
148
149 if let Some(aligned) = successes.get(&clip_id) {
150 let timed = !aligned.is_empty();
151 let body = if timed {
152 render_synced_lrc(&d.clip, &d.lineage, aligned)
153 } else {
154 render_clip_lrc(&d.clip, &d.lineage)
155 };
156 match body {
157 Some(text) => {
158 let hash = content_hash(&text);
159 let artifact = &mut d.artifacts[idx];
160 artifact.hash = hash.clone();
161 artifact.content = Some(text);
162 pending.push(PendingCheck {
163 clip_id,
164 empty: false,
165 timed,
166 body_hash: Some(hash),
167 });
168 }
169 None => {
170 d.artifacts.remove(idx);
171 pending.push(PendingCheck {
172 clip_id,
173 empty: true,
174 timed: false,
175 body_hash: None,
176 });
177 }
178 }
179 } else {
180 match slot_hash {
184 Some(hash) => {
185 let artifact = &mut d.artifacts[idx];
186 artifact.hash = hash;
187 artifact.content = None;
188 }
189 None => {
190 d.artifacts.remove(idx);
191 }
192 }
193 }
194 }
195 pending
196}
197
198pub fn preview_synced_lrc(
206 desired: &mut [Desired],
207 manifest: &Manifest,
208 now_unix: u64,
209 enabled: bool,
210) {
211 let targets = synced_lyrics_targets(desired, manifest, now_unix, enabled);
212 for d in desired.iter_mut() {
213 let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
214 continue;
215 };
216 if targets.contains(&d.clip.id) {
217 d.artifacts[idx].hash = synced_lrc_source_hash(&d.clip.id);
218 continue;
219 }
220 match manifest.get(&d.clip.id).and_then(|e| e.lrc.as_ref()) {
221 Some(slot) => d.artifacts[idx].hash = slot.hash.clone(),
222 None => {
223 d.artifacts.remove(idx);
224 }
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::lineage::LineageContext;
233 use crate::lyrics::{AlignedLine, AlignedLineWord};
234 use crate::manifest::{ArtifactState, SyncedLyricsCheck};
235 use crate::model::Clip;
236 use crate::reconcile::DesiredArtifact;
237 use crate::vocab::AudioFormat;
238
239 fn clip(id: &str, lyrics: &str) -> Clip {
240 Clip {
241 id: id.to_string(),
242 title: "Song".to_string(),
243 lyrics: lyrics.to_string(),
244 prompt: "a prompt".to_string(),
245 ..Default::default()
246 }
247 }
248
249 fn lrc_artifact(clip_id: &str) -> DesiredArtifact {
250 DesiredArtifact {
251 kind: ArtifactKind::Lrc,
252 path: format!("{clip_id}.lrc"),
253 source_url: String::new(),
254 hash: synced_lrc_source_hash(clip_id),
255 content: None,
256 }
257 }
258
259 fn desired(id: &str, lyrics: &str) -> Desired {
260 let c = clip(id, lyrics);
261 Desired {
262 lineage: LineageContext::own_root(&c),
263 path: format!("{id}.flac"),
264 format: AudioFormat::Flac,
265 meta_hash: "m".to_string(),
266 art_hash: "a".to_string(),
267 modes: vec![crate::vocab::SourceMode::Mirror],
268 trashed: false,
269 private: false,
270 artifacts: vec![lrc_artifact(id)],
271 clip: c,
272 stems: None,
273 }
274 }
275
276 fn one_line_alignment() -> AlignedLyrics {
277 AlignedLyrics {
278 lines: vec![AlignedLine {
279 text: "hi there".to_owned(),
280 start_s: 0.5,
281 end_s: 1.2,
282 section: "Verse 1".to_owned(),
283 words: vec![
284 AlignedLineWord {
285 text: "hi".to_owned(),
286 start_s: 0.5,
287 end_s: 0.8,
288 },
289 AlignedLineWord {
290 text: "there".to_owned(),
291 start_s: 0.9,
292 end_s: 1.2,
293 },
294 ],
295 }],
296 ..Default::default()
297 }
298 }
299
300 fn entry(lrc: Option<ArtifactState>, check: Option<SyncedLyricsCheck>) -> ManifestEntry {
301 ManifestEntry {
302 path: "song.flac".to_string(),
303 format: AudioFormat::Flac,
304 lrc,
305 synced_lyrics: check,
306 ..Default::default()
307 }
308 }
309
310 #[test]
311 fn targets_empty_when_feature_off() {
312 let d = vec![desired("a", "")];
313 let manifest = Manifest::new();
314 assert!(synced_lyrics_targets(&d, &manifest, 0, false).is_empty());
315 }
316
317 #[test]
318 fn targets_new_clip_but_not_a_recently_resolved_one() {
319 let d = vec![desired("new", ""), desired("done", "")];
320 let mut manifest = Manifest::new();
321 manifest.insert(
323 "done",
324 entry(
325 Some(ArtifactState {
326 path: "done.lrc".to_string(),
327 hash: "h".to_string(),
328 }),
329 Some(SyncedLyricsCheck {
330 version: SYNCED_LRC_VERSION,
331 checked_unix: 1_000,
332 empty: false,
333 timed: true,
334 }),
335 ),
336 );
337 let targets = synced_lyrics_targets(&d, &manifest, 2_000, true);
338 assert!(targets.contains("new"));
339 assert!(!targets.contains("done"));
340 }
341
342 #[test]
343 fn instrumental_is_rechecked_only_after_the_window() {
344 let d = vec![desired("instr", "")];
345 let mut manifest = Manifest::new();
346 manifest.insert(
347 "instr",
348 entry(
349 None,
350 Some(SyncedLyricsCheck {
351 version: SYNCED_LRC_VERSION,
352 checked_unix: 1_000,
353 empty: true,
354 timed: false,
355 }),
356 ),
357 );
358 let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
360 assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
361 let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
363 assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("instr"));
364 }
365
366 #[test]
367 fn untimed_fallback_is_rechecked_after_the_window() {
368 let d = vec![desired("a", "some lyrics")];
372 let mut manifest = Manifest::new();
373 manifest.insert(
374 "a",
375 entry(
376 Some(ArtifactState {
377 path: "a.lrc".to_string(),
378 hash: "untimed-hash".to_string(),
379 }),
380 Some(SyncedLyricsCheck {
381 version: SYNCED_LRC_VERSION,
382 checked_unix: 1_000,
383 empty: false,
384 timed: false,
385 }),
386 ),
387 );
388 let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
390 assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
391 let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
393 assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("a"));
394 }
395
396 #[test]
397 fn timed_clip_is_not_rechecked_without_rename() {
398 let d = vec![desired("a", "")];
401 let mut manifest = Manifest::new();
402 manifest.insert(
403 "a",
404 entry(
405 Some(ArtifactState {
406 path: "a.lrc".to_string(),
407 hash: "h".to_string(),
408 }),
409 Some(SyncedLyricsCheck {
410 version: SYNCED_LRC_VERSION,
411 checked_unix: 0, empty: false,
413 timed: true,
414 }),
415 ),
416 );
417 let very_late = 2 * SYNCED_LRC_RECHECK_SECS;
419 assert!(synced_lyrics_targets(&d, &manifest, very_late, true).is_empty());
420 }
421
422 #[test]
423 fn version_bump_refetches_everything() {
424 let d = vec![desired("done", "")];
425 let mut manifest = Manifest::new();
426 manifest.insert(
427 "done",
428 entry(
429 Some(ArtifactState {
430 path: "done.lrc".to_string(),
431 hash: "h".to_string(),
432 }),
433 Some(SyncedLyricsCheck {
434 version: SYNCED_LRC_VERSION + 1, checked_unix: 1_000,
436 empty: false,
437 timed: true,
438 }),
439 ),
440 );
441 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("done"));
442 }
443
444 #[test]
445 fn rename_refetches_a_written_clip() {
446 let mut d = vec![desired("a", "")];
447 d[0].artifacts[0].path = "new/a.lrc".to_string();
449 let mut manifest = Manifest::new();
450 manifest.insert(
451 "a",
452 entry(
453 Some(ArtifactState {
454 path: "old/a.lrc".to_string(),
455 hash: "h".to_string(),
456 }),
457 Some(SyncedLyricsCheck {
458 version: SYNCED_LRC_VERSION,
459 checked_unix: 1_000,
460 empty: false,
461 timed: true,
462 }),
463 ),
464 );
465 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"));
466 }
467
468 #[test]
469 fn apply_sets_timed_body_and_content_hash() {
470 let mut d = vec![desired("a", "")];
471 let mut successes = HashMap::new();
472 successes.insert("a".to_string(), one_line_alignment());
473 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
474
475 let art = &d[0].artifacts[0];
476 let body = art.content.as_deref().unwrap();
477 assert!(body.contains("[00:00.50]hi there"));
478 assert_eq!(art.hash, content_hash(body));
479 assert_eq!(
480 pending,
481 vec![PendingCheck {
482 clip_id: "a".to_string(),
483 empty: false,
484 timed: true,
485 body_hash: Some(content_hash(body)),
486 }]
487 );
488 }
489
490 #[test]
491 fn apply_untimed_fallback_marks_not_timed() {
492 let mut d = vec![desired("a", "some lyrics")];
496 let mut successes = HashMap::new();
497 successes.insert("a".to_string(), AlignedLyrics::default());
498 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
499
500 let art = &d[0].artifacts[0];
501 assert!(art.content.is_some(), "untimed body written");
502 let check = &pending[0];
503 assert!(!check.empty, "clip has lyrics, not an instrumental");
504 assert!(!check.timed, "alignment was empty -> untimed fallback");
505 }
506
507 #[test]
508 fn apply_drops_instrumental_and_marks_empty() {
509 let mut d = vec![desired("instr", "")];
510 let mut successes = HashMap::new();
511 successes.insert("instr".to_string(), AlignedLyrics::default());
512 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
513
514 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
515 assert_eq!(
516 pending,
517 vec![PendingCheck {
518 clip_id: "instr".to_string(),
519 empty: true,
520 timed: false,
521 body_hash: None,
522 }]
523 );
524 }
525
526 #[test]
527 fn apply_keeps_existing_on_fetch_failure_no_downgrade() {
528 let mut d = vec![desired("a", "")];
534 let mut manifest = Manifest::new();
535 manifest.insert(
536 "a",
537 entry(
538 Some(ArtifactState {
539 path: "a.lrc".to_string(),
540 hash: "timed-hash".to_string(),
541 }),
542 Some(SyncedLyricsCheck {
543 version: SYNCED_LRC_VERSION,
544 checked_unix: 1_000,
545 empty: false,
546 timed: true,
547 }),
548 ),
549 );
550 let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
551
552 let art = &d[0].artifacts[0];
553 assert_eq!(art.hash, "timed-hash");
554 assert_eq!(art.content, None);
555 assert!(
556 pending.is_empty(),
557 "no check recorded on failure -> retried"
558 );
559 }
560
561 #[test]
562 fn apply_drops_write_on_failure_when_nothing_on_disk() {
563 let mut d = vec![desired("a", "")];
566 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &HashMap::new());
567 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
568 assert!(pending.is_empty());
569 }
570
571 #[test]
572 fn apply_upgrades_untimed_to_timed_when_alignment_appears() {
573 let mut d = vec![desired("a", "some lyrics")];
577 let untimed_hash = "untimed".to_string();
578 let mut manifest = Manifest::new();
579 manifest.insert(
580 "a",
581 entry(
582 Some(ArtifactState {
583 path: "a.lrc".to_string(),
584 hash: untimed_hash.clone(),
585 }),
586 Some(SyncedLyricsCheck {
587 version: SYNCED_LRC_VERSION,
588 checked_unix: 1_000,
589 empty: false,
590 timed: false,
591 }),
592 ),
593 );
594 let mut successes = HashMap::new();
595 successes.insert("a".to_string(), one_line_alignment());
596 let pending = apply_synced_lrc(&mut d, &manifest, &successes);
597 let art = &d[0].artifacts[0];
598 assert!(
599 art.content
600 .as_deref()
601 .unwrap()
602 .contains("[00:00.50]hi there")
603 );
604 assert_ne!(art.hash, untimed_hash, "a changed body triggers a rewrite");
605 assert!(pending[0].timed, "upgraded to timed");
606 }
607
608 #[test]
609 fn preview_shows_write_for_targets_and_skips_resolved() {
610 let mut d = vec![desired("new", ""), desired("done", "")];
611 let mut manifest = Manifest::new();
612 manifest.insert(
613 "done",
614 entry(
615 Some(ArtifactState {
616 path: "done.lrc".to_string(),
617 hash: "slot-hash".to_string(),
618 }),
619 Some(SyncedLyricsCheck {
620 version: SYNCED_LRC_VERSION,
621 checked_unix: 1_000,
622 empty: false,
623 timed: true,
624 }),
625 ),
626 );
627 preview_synced_lrc(&mut d, &manifest, 2_000, true);
628 assert_eq!(d[0].artifacts[0].hash, synced_lrc_source_hash("new"));
630 assert_eq!(d[1].artifacts[0].hash, "slot-hash");
631 }
632}