1use std::collections::{BTreeSet, HashMap};
19
20use crate::extras::{render_clip_lrc, render_synced_lrc};
21use crate::hash::{SYNCED_LRC_VERSION, content_hash, synced_lrc_source_hash};
22use crate::lyrics::AlignedLyrics;
23use crate::manifest::{Manifest, ManifestEntry};
24use crate::reconcile::Desired;
25use crate::vocab::ArtifactKind;
26
27pub const SYNCED_LRC_RECHECK_SECS: u64 = 14 * 24 * 60 * 60;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct PendingCheck {
37 pub clip_id: String,
39 pub empty: bool,
41 pub timed: bool,
44 pub body_hash: Option<String>,
48}
49
50fn desired_lrc(desired: &Desired) -> Option<&str> {
52 desired
53 .artifacts
54 .iter()
55 .find(|a| a.kind == ArtifactKind::Lrc)
56 .map(|a| a.path.as_str())
57}
58
59fn needs_fetch(entry: Option<&ManifestEntry>, desired_lrc_path: &str, now_unix: u64) -> bool {
61 let Some(entry) = entry else {
62 return true; };
64 match &entry.synced_lyrics {
65 None => true,
67 Some(check) => {
68 if check.version != SYNCED_LRC_VERSION {
69 return true; }
71 if check.empty || !check.timed {
72 now_unix.saturating_sub(check.checked_unix) > SYNCED_LRC_RECHECK_SECS
75 } else {
76 entry
80 .lrc
81 .as_ref()
82 .map(|slot| slot.path != desired_lrc_path)
83 .unwrap_or(true)
84 }
85 }
86 }
87}
88
89pub fn synced_lyrics_targets(
96 desired: &[Desired],
97 manifest: &Manifest,
98 now_unix: u64,
99 enabled: bool,
100) -> BTreeSet<String> {
101 if !enabled {
102 return BTreeSet::new();
103 }
104 let mut out = BTreeSet::new();
105 for d in desired {
106 let Some(path) = desired_lrc(d) else {
107 continue;
108 };
109 if needs_fetch(manifest.get(&d.clip.id), path, now_unix) {
110 out.insert(d.clip.id.clone());
111 }
112 }
113 out
114}
115
116pub fn apply_synced_lrc(
135 desired: &mut [Desired],
136 manifest: &Manifest,
137 successes: &HashMap<String, AlignedLyrics>,
138) -> Vec<PendingCheck> {
139 let mut pending = Vec::new();
140 for d in desired.iter_mut() {
141 let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
142 continue;
143 };
144 let clip_id = d.clip.id.clone();
145 let slot_hash = manifest
146 .get(&clip_id)
147 .and_then(|e| e.lrc.as_ref())
148 .map(|slot| slot.hash.clone());
149
150 if let Some(aligned) = successes.get(&clip_id) {
151 let timed = !aligned.is_empty();
152 let body = if timed {
153 render_synced_lrc(&d.clip, &d.lineage, aligned)
154 } else {
155 render_clip_lrc(&d.clip, &d.lineage)
156 };
157 match body {
158 Some(text) => {
159 let hash = content_hash(&text);
160 let artifact = &mut d.artifacts[idx];
161 artifact.hash = hash.clone();
162 artifact.content = Some(text);
163 pending.push(PendingCheck {
164 clip_id,
165 empty: false,
166 timed,
167 body_hash: Some(hash),
168 });
169 }
170 None => {
171 d.artifacts.remove(idx);
172 pending.push(PendingCheck {
173 clip_id,
174 empty: true,
175 timed: false,
176 body_hash: None,
177 });
178 }
179 }
180 } else {
181 match slot_hash {
185 Some(hash) => {
186 let artifact = &mut d.artifacts[idx];
187 artifact.hash = hash;
188 artifact.content = None;
189 }
190 None => {
191 d.artifacts.remove(idx);
192 }
193 }
194 }
195 }
196 pending
197}
198
199pub fn preview_synced_lrc(
207 desired: &mut [Desired],
208 manifest: &Manifest,
209 now_unix: u64,
210 enabled: bool,
211) {
212 let targets = synced_lyrics_targets(desired, manifest, now_unix, enabled);
213 for d in desired.iter_mut() {
214 let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
215 continue;
216 };
217 if targets.contains(&d.clip.id) {
218 d.artifacts[idx].hash = synced_lrc_source_hash(&d.clip.id);
219 continue;
220 }
221 match manifest.get(&d.clip.id).and_then(|e| e.lrc.as_ref()) {
222 Some(slot) => d.artifacts[idx].hash = slot.hash.clone(),
223 None => {
224 d.artifacts.remove(idx);
225 }
226 }
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::lineage::LineageContext;
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::from_json(&serde_json::json!({
278 "aligned_words": [],
279 "aligned_lyrics": [
280 {"text": "hi there", "start_s": 0.5, "end_s": 1.2, "section": "Verse 1",
281 "words": [
282 {"text": "hi", "start_s": 0.5, "end_s": 0.8},
283 {"text": "there", "start_s": 0.9, "end_s": 1.2}
284 ]}
285 ]
286 }))
287 }
288
289 fn entry(lrc: Option<ArtifactState>, check: Option<SyncedLyricsCheck>) -> ManifestEntry {
290 ManifestEntry {
291 path: "song.flac".to_string(),
292 format: AudioFormat::Flac,
293 lrc,
294 synced_lyrics: check,
295 ..Default::default()
296 }
297 }
298
299 #[test]
300 fn targets_empty_when_feature_off() {
301 let d = vec![desired("a", "")];
302 let manifest = Manifest::new();
303 assert!(synced_lyrics_targets(&d, &manifest, 0, false).is_empty());
304 }
305
306 #[test]
307 fn targets_new_clip_but_not_a_recently_resolved_one() {
308 let d = vec![desired("new", ""), desired("done", "")];
309 let mut manifest = Manifest::new();
310 manifest.insert(
312 "done",
313 entry(
314 Some(ArtifactState {
315 path: "done.lrc".to_string(),
316 hash: "h".to_string(),
317 }),
318 Some(SyncedLyricsCheck {
319 version: SYNCED_LRC_VERSION,
320 checked_unix: 1_000,
321 empty: false,
322 timed: true,
323 }),
324 ),
325 );
326 let targets = synced_lyrics_targets(&d, &manifest, 2_000, true);
327 assert!(targets.contains("new"));
328 assert!(!targets.contains("done"));
329 }
330
331 #[test]
332 fn instrumental_is_rechecked_only_after_the_window() {
333 let d = vec![desired("instr", "")];
334 let mut manifest = Manifest::new();
335 manifest.insert(
336 "instr",
337 entry(
338 None,
339 Some(SyncedLyricsCheck {
340 version: SYNCED_LRC_VERSION,
341 checked_unix: 1_000,
342 empty: true,
343 timed: false,
344 }),
345 ),
346 );
347 let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
349 assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
350 let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
352 assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("instr"));
353 }
354
355 #[test]
356 fn untimed_fallback_is_rechecked_after_the_window() {
357 let d = vec![desired("a", "some lyrics")];
361 let mut manifest = Manifest::new();
362 manifest.insert(
363 "a",
364 entry(
365 Some(ArtifactState {
366 path: "a.lrc".to_string(),
367 hash: "untimed-hash".to_string(),
368 }),
369 Some(SyncedLyricsCheck {
370 version: SYNCED_LRC_VERSION,
371 checked_unix: 1_000,
372 empty: false,
373 timed: false,
374 }),
375 ),
376 );
377 let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
379 assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
380 let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
382 assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("a"));
383 }
384
385 #[test]
386 fn timed_clip_is_not_rechecked_without_rename() {
387 let d = vec![desired("a", "")];
390 let mut manifest = Manifest::new();
391 manifest.insert(
392 "a",
393 entry(
394 Some(ArtifactState {
395 path: "a.lrc".to_string(),
396 hash: "h".to_string(),
397 }),
398 Some(SyncedLyricsCheck {
399 version: SYNCED_LRC_VERSION,
400 checked_unix: 0, empty: false,
402 timed: true,
403 }),
404 ),
405 );
406 let very_late = 2 * SYNCED_LRC_RECHECK_SECS;
408 assert!(synced_lyrics_targets(&d, &manifest, very_late, true).is_empty());
409 }
410
411 #[test]
412 fn version_bump_refetches_everything() {
413 let d = vec![desired("done", "")];
414 let mut manifest = Manifest::new();
415 manifest.insert(
416 "done",
417 entry(
418 Some(ArtifactState {
419 path: "done.lrc".to_string(),
420 hash: "h".to_string(),
421 }),
422 Some(SyncedLyricsCheck {
423 version: SYNCED_LRC_VERSION + 1, checked_unix: 1_000,
425 empty: false,
426 timed: true,
427 }),
428 ),
429 );
430 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("done"));
431 }
432
433 #[test]
434 fn rename_refetches_a_written_clip() {
435 let mut d = vec![desired("a", "")];
436 d[0].artifacts[0].path = "new/a.lrc".to_string();
438 let mut manifest = Manifest::new();
439 manifest.insert(
440 "a",
441 entry(
442 Some(ArtifactState {
443 path: "old/a.lrc".to_string(),
444 hash: "h".to_string(),
445 }),
446 Some(SyncedLyricsCheck {
447 version: SYNCED_LRC_VERSION,
448 checked_unix: 1_000,
449 empty: false,
450 timed: true,
451 }),
452 ),
453 );
454 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"));
455 }
456
457 #[test]
458 fn apply_sets_timed_body_and_content_hash() {
459 let mut d = vec![desired("a", "")];
460 let mut successes = HashMap::new();
461 successes.insert("a".to_string(), one_line_alignment());
462 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
463
464 let art = &d[0].artifacts[0];
465 let body = art.content.as_deref().unwrap();
466 assert!(body.contains("[00:00.50]hi there"));
467 assert_eq!(art.hash, content_hash(body));
468 assert_eq!(
469 pending,
470 vec![PendingCheck {
471 clip_id: "a".to_string(),
472 empty: false,
473 timed: true,
474 body_hash: Some(content_hash(body)),
475 }]
476 );
477 }
478
479 #[test]
480 fn apply_untimed_fallback_marks_not_timed() {
481 let mut d = vec![desired("a", "some lyrics")];
485 let mut successes = HashMap::new();
486 successes.insert("a".to_string(), AlignedLyrics::default());
487 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
488
489 let art = &d[0].artifacts[0];
490 assert!(art.content.is_some(), "untimed body written");
491 let check = &pending[0];
492 assert!(!check.empty, "clip has lyrics, not an instrumental");
493 assert!(!check.timed, "alignment was empty -> untimed fallback");
494 }
495
496 #[test]
497 fn apply_drops_instrumental_and_marks_empty() {
498 let mut d = vec![desired("instr", "")];
499 let mut successes = HashMap::new();
500 successes.insert("instr".to_string(), AlignedLyrics::default());
501 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
502
503 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
504 assert_eq!(
505 pending,
506 vec![PendingCheck {
507 clip_id: "instr".to_string(),
508 empty: true,
509 timed: false,
510 body_hash: None,
511 }]
512 );
513 }
514
515 #[test]
516 fn apply_keeps_existing_on_fetch_failure_no_downgrade() {
517 let mut d = vec![desired("a", "")];
523 let mut manifest = Manifest::new();
524 manifest.insert(
525 "a",
526 entry(
527 Some(ArtifactState {
528 path: "a.lrc".to_string(),
529 hash: "timed-hash".to_string(),
530 }),
531 Some(SyncedLyricsCheck {
532 version: SYNCED_LRC_VERSION,
533 checked_unix: 1_000,
534 empty: false,
535 timed: true,
536 }),
537 ),
538 );
539 let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
540
541 let art = &d[0].artifacts[0];
542 assert_eq!(art.hash, "timed-hash");
543 assert_eq!(art.content, None);
544 assert!(
545 pending.is_empty(),
546 "no check recorded on failure -> retried"
547 );
548 }
549
550 #[test]
551 fn apply_drops_write_on_failure_when_nothing_on_disk() {
552 let mut d = vec![desired("a", "")];
555 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &HashMap::new());
556 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
557 assert!(pending.is_empty());
558 }
559
560 #[test]
561 fn apply_upgrades_untimed_to_timed_when_alignment_appears() {
562 let mut d = vec![desired("a", "some lyrics")];
566 let untimed_hash = "untimed".to_string();
567 let mut manifest = Manifest::new();
568 manifest.insert(
569 "a",
570 entry(
571 Some(ArtifactState {
572 path: "a.lrc".to_string(),
573 hash: untimed_hash.clone(),
574 }),
575 Some(SyncedLyricsCheck {
576 version: SYNCED_LRC_VERSION,
577 checked_unix: 1_000,
578 empty: false,
579 timed: false,
580 }),
581 ),
582 );
583 let mut successes = HashMap::new();
584 successes.insert("a".to_string(), one_line_alignment());
585 let pending = apply_synced_lrc(&mut d, &manifest, &successes);
586 let art = &d[0].artifacts[0];
587 assert!(
588 art.content
589 .as_deref()
590 .unwrap()
591 .contains("[00:00.50]hi there")
592 );
593 assert_ne!(art.hash, untimed_hash, "a changed body triggers a rewrite");
594 assert!(pending[0].timed, "upgraded to timed");
595 }
596
597 #[test]
598 fn preview_shows_write_for_targets_and_skips_resolved() {
599 let mut d = vec![desired("new", ""), desired("done", "")];
600 let mut manifest = Manifest::new();
601 manifest.insert(
602 "done",
603 entry(
604 Some(ArtifactState {
605 path: "done.lrc".to_string(),
606 hash: "slot-hash".to_string(),
607 }),
608 Some(SyncedLyricsCheck {
609 version: SYNCED_LRC_VERSION,
610 checked_unix: 1_000,
611 empty: false,
612 timed: true,
613 }),
614 ),
615 );
616 preview_synced_lrc(&mut d, &manifest, 2_000, true);
617 assert_eq!(d[0].artifacts[0].hash, synced_lrc_source_hash("new"));
619 assert_eq!(d[1].artifacts[0].hash, "slot-hash");
620 }
621}