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::{ArtifactKind, Desired};
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::config::AudioFormat;
233 use crate::lineage::LineageContext;
234 use crate::manifest::{ArtifactState, SyncedLyricsCheck};
235 use crate::model::Clip;
236 use crate::reconcile::DesiredArtifact;
237
238 fn clip(id: &str, lyrics: &str) -> Clip {
239 Clip {
240 id: id.to_string(),
241 title: "Song".to_string(),
242 lyrics: lyrics.to_string(),
243 prompt: "a prompt".to_string(),
244 ..Default::default()
245 }
246 }
247
248 fn lrc_artifact(clip_id: &str) -> DesiredArtifact {
249 DesiredArtifact {
250 kind: ArtifactKind::Lrc,
251 path: format!("{clip_id}.lrc"),
252 source_url: String::new(),
253 hash: synced_lrc_source_hash(clip_id),
254 content: None,
255 }
256 }
257
258 fn desired(id: &str, lyrics: &str) -> Desired {
259 let c = clip(id, lyrics);
260 Desired {
261 lineage: LineageContext::own_root(&c),
262 path: format!("{id}.flac"),
263 format: AudioFormat::Flac,
264 meta_hash: "m".to_string(),
265 art_hash: "a".to_string(),
266 modes: vec![crate::reconcile::SourceMode::Mirror],
267 trashed: false,
268 private: false,
269 artifacts: vec![lrc_artifact(id)],
270 clip: c,
271 stems: None,
272 }
273 }
274
275 fn one_line_alignment() -> AlignedLyrics {
276 AlignedLyrics::from_json(&serde_json::json!({
277 "aligned_words": [],
278 "aligned_lyrics": [
279 {"text": "hi there", "start_s": 0.5, "end_s": 1.2, "section": "Verse 1",
280 "words": [
281 {"text": "hi", "start_s": 0.5, "end_s": 0.8},
282 {"text": "there", "start_s": 0.9, "end_s": 1.2}
283 ]}
284 ]
285 }))
286 }
287
288 fn entry(lrc: Option<ArtifactState>, check: Option<SyncedLyricsCheck>) -> ManifestEntry {
289 ManifestEntry {
290 path: "song.flac".to_string(),
291 format: AudioFormat::Flac,
292 lrc,
293 synced_lyrics: check,
294 ..Default::default()
295 }
296 }
297
298 #[test]
299 fn targets_empty_when_feature_off() {
300 let d = vec![desired("a", "")];
301 let manifest = Manifest::new();
302 assert!(synced_lyrics_targets(&d, &manifest, 0, false).is_empty());
303 }
304
305 #[test]
306 fn targets_new_clip_but_not_a_recently_resolved_one() {
307 let d = vec![desired("new", ""), desired("done", "")];
308 let mut manifest = Manifest::new();
309 manifest.insert(
311 "done",
312 entry(
313 Some(ArtifactState {
314 path: "done.lrc".to_string(),
315 hash: "h".to_string(),
316 }),
317 Some(SyncedLyricsCheck {
318 version: SYNCED_LRC_VERSION,
319 checked_unix: 1_000,
320 empty: false,
321 timed: true,
322 }),
323 ),
324 );
325 let targets = synced_lyrics_targets(&d, &manifest, 2_000, true);
326 assert!(targets.contains("new"));
327 assert!(!targets.contains("done"));
328 }
329
330 #[test]
331 fn instrumental_is_rechecked_only_after_the_window() {
332 let d = vec![desired("instr", "")];
333 let mut manifest = Manifest::new();
334 manifest.insert(
335 "instr",
336 entry(
337 None,
338 Some(SyncedLyricsCheck {
339 version: SYNCED_LRC_VERSION,
340 checked_unix: 1_000,
341 empty: true,
342 timed: false,
343 }),
344 ),
345 );
346 let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
348 assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
349 let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
351 assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("instr"));
352 }
353
354 #[test]
355 fn untimed_fallback_is_rechecked_after_the_window() {
356 let d = vec![desired("a", "some lyrics")];
360 let mut manifest = Manifest::new();
361 manifest.insert(
362 "a",
363 entry(
364 Some(ArtifactState {
365 path: "a.lrc".to_string(),
366 hash: "untimed-hash".to_string(),
367 }),
368 Some(SyncedLyricsCheck {
369 version: SYNCED_LRC_VERSION,
370 checked_unix: 1_000,
371 empty: false,
372 timed: false,
373 }),
374 ),
375 );
376 let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
378 assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
379 let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
381 assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("a"));
382 }
383
384 #[test]
385 fn timed_clip_is_not_rechecked_without_rename() {
386 let d = vec![desired("a", "")];
389 let mut manifest = Manifest::new();
390 manifest.insert(
391 "a",
392 entry(
393 Some(ArtifactState {
394 path: "a.lrc".to_string(),
395 hash: "h".to_string(),
396 }),
397 Some(SyncedLyricsCheck {
398 version: SYNCED_LRC_VERSION,
399 checked_unix: 0, empty: false,
401 timed: true,
402 }),
403 ),
404 );
405 let very_late = 2 * SYNCED_LRC_RECHECK_SECS;
407 assert!(synced_lyrics_targets(&d, &manifest, very_late, true).is_empty());
408 }
409
410 #[test]
411 fn version_bump_refetches_everything() {
412 let d = vec![desired("done", "")];
413 let mut manifest = Manifest::new();
414 manifest.insert(
415 "done",
416 entry(
417 Some(ArtifactState {
418 path: "done.lrc".to_string(),
419 hash: "h".to_string(),
420 }),
421 Some(SyncedLyricsCheck {
422 version: SYNCED_LRC_VERSION + 1, checked_unix: 1_000,
424 empty: false,
425 timed: true,
426 }),
427 ),
428 );
429 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("done"));
430 }
431
432 #[test]
433 fn rename_refetches_a_written_clip() {
434 let mut d = vec![desired("a", "")];
435 d[0].artifacts[0].path = "new/a.lrc".to_string();
437 let mut manifest = Manifest::new();
438 manifest.insert(
439 "a",
440 entry(
441 Some(ArtifactState {
442 path: "old/a.lrc".to_string(),
443 hash: "h".to_string(),
444 }),
445 Some(SyncedLyricsCheck {
446 version: SYNCED_LRC_VERSION,
447 checked_unix: 1_000,
448 empty: false,
449 timed: true,
450 }),
451 ),
452 );
453 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"));
454 }
455
456 #[test]
457 fn apply_sets_timed_body_and_content_hash() {
458 let mut d = vec![desired("a", "")];
459 let mut successes = HashMap::new();
460 successes.insert("a".to_string(), one_line_alignment());
461 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
462
463 let art = &d[0].artifacts[0];
464 let body = art.content.as_deref().unwrap();
465 assert!(body.contains("[00:00.50]hi there"));
466 assert_eq!(art.hash, content_hash(body));
467 assert_eq!(
468 pending,
469 vec![PendingCheck {
470 clip_id: "a".to_string(),
471 empty: false,
472 timed: true,
473 body_hash: Some(content_hash(body)),
474 }]
475 );
476 }
477
478 #[test]
479 fn apply_untimed_fallback_marks_not_timed() {
480 let mut d = vec![desired("a", "some lyrics")];
484 let mut successes = HashMap::new();
485 successes.insert("a".to_string(), AlignedLyrics::default());
486 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
487
488 let art = &d[0].artifacts[0];
489 assert!(art.content.is_some(), "untimed body written");
490 let check = &pending[0];
491 assert!(!check.empty, "clip has lyrics, not an instrumental");
492 assert!(!check.timed, "alignment was empty -> untimed fallback");
493 }
494
495 #[test]
496 fn apply_drops_instrumental_and_marks_empty() {
497 let mut d = vec![desired("instr", "")];
498 let mut successes = HashMap::new();
499 successes.insert("instr".to_string(), AlignedLyrics::default());
500 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
501
502 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
503 assert_eq!(
504 pending,
505 vec![PendingCheck {
506 clip_id: "instr".to_string(),
507 empty: true,
508 timed: false,
509 body_hash: None,
510 }]
511 );
512 }
513
514 #[test]
515 fn apply_keeps_existing_on_fetch_failure_no_downgrade() {
516 let mut d = vec![desired("a", "")];
522 let mut manifest = Manifest::new();
523 manifest.insert(
524 "a",
525 entry(
526 Some(ArtifactState {
527 path: "a.lrc".to_string(),
528 hash: "timed-hash".to_string(),
529 }),
530 Some(SyncedLyricsCheck {
531 version: SYNCED_LRC_VERSION,
532 checked_unix: 1_000,
533 empty: false,
534 timed: true,
535 }),
536 ),
537 );
538 let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
539
540 let art = &d[0].artifacts[0];
541 assert_eq!(art.hash, "timed-hash");
542 assert_eq!(art.content, None);
543 assert!(
544 pending.is_empty(),
545 "no check recorded on failure -> retried"
546 );
547 }
548
549 #[test]
550 fn apply_drops_write_on_failure_when_nothing_on_disk() {
551 let mut d = vec![desired("a", "")];
554 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &HashMap::new());
555 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
556 assert!(pending.is_empty());
557 }
558
559 #[test]
560 fn apply_upgrades_untimed_to_timed_when_alignment_appears() {
561 let mut d = vec![desired("a", "some lyrics")];
565 let untimed_hash = "untimed".to_string();
566 let mut manifest = Manifest::new();
567 manifest.insert(
568 "a",
569 entry(
570 Some(ArtifactState {
571 path: "a.lrc".to_string(),
572 hash: untimed_hash.clone(),
573 }),
574 Some(SyncedLyricsCheck {
575 version: SYNCED_LRC_VERSION,
576 checked_unix: 1_000,
577 empty: false,
578 timed: false,
579 }),
580 ),
581 );
582 let mut successes = HashMap::new();
583 successes.insert("a".to_string(), one_line_alignment());
584 let pending = apply_synced_lrc(&mut d, &manifest, &successes);
585 let art = &d[0].artifacts[0];
586 assert!(
587 art.content
588 .as_deref()
589 .unwrap()
590 .contains("[00:00.50]hi there")
591 );
592 assert_ne!(art.hash, untimed_hash, "a changed body triggers a rewrite");
593 assert!(pending[0].timed, "upgraded to timed");
594 }
595
596 #[test]
597 fn preview_shows_write_for_targets_and_skips_resolved() {
598 let mut d = vec![desired("new", ""), desired("done", "")];
599 let mut manifest = Manifest::new();
600 manifest.insert(
601 "done",
602 entry(
603 Some(ArtifactState {
604 path: "done.lrc".to_string(),
605 hash: "slot-hash".to_string(),
606 }),
607 Some(SyncedLyricsCheck {
608 version: SYNCED_LRC_VERSION,
609 checked_unix: 1_000,
610 empty: false,
611 timed: true,
612 }),
613 ),
614 );
615 preview_synced_lrc(&mut d, &manifest, 2_000, true);
616 assert_eq!(d[0].artifacts[0].hash, synced_lrc_source_hash("new"));
618 assert_eq!(d[1].artifacts[0].hash, "slot-hash");
619 }
620}