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 stems: None,
262 }
263 }
264
265 fn one_line_alignment() -> AlignedLyrics {
266 AlignedLyrics::from_json(&serde_json::json!({
267 "aligned_words": [],
268 "aligned_lyrics": [
269 {"text": "hi there", "start_s": 0.5, "end_s": 1.2, "section": "Verse 1",
270 "words": [
271 {"text": "hi", "start_s": 0.5, "end_s": 0.8},
272 {"text": "there", "start_s": 0.9, "end_s": 1.2}
273 ]}
274 ]
275 }))
276 }
277
278 fn entry(lrc: Option<ArtifactState>, check: Option<SyncedLyricsCheck>) -> ManifestEntry {
279 ManifestEntry {
280 path: "song.flac".to_string(),
281 format: AudioFormat::Flac,
282 lrc,
283 synced_lyrics: check,
284 ..Default::default()
285 }
286 }
287
288 #[test]
289 fn targets_empty_when_feature_off() {
290 let d = vec![desired("a", "")];
291 let manifest = Manifest::new();
292 assert!(synced_lyrics_targets(&d, &manifest, 0, false).is_empty());
293 }
294
295 #[test]
296 fn targets_new_clip_but_not_a_recently_resolved_one() {
297 let d = vec![desired("new", ""), desired("done", "")];
298 let mut manifest = Manifest::new();
299 manifest.insert(
301 "done",
302 entry(
303 Some(ArtifactState {
304 path: "done.lrc".to_string(),
305 hash: "h".to_string(),
306 }),
307 Some(SyncedLyricsCheck {
308 version: SYNCED_LRC_VERSION,
309 checked_unix: 1_000,
310 empty: false,
311 }),
312 ),
313 );
314 let targets = synced_lyrics_targets(&d, &manifest, 2_000, true);
315 assert!(targets.contains("new"));
316 assert!(!targets.contains("done"));
317 }
318
319 #[test]
320 fn instrumental_is_rechecked_only_after_the_window() {
321 let d = vec![desired("instr", "")];
322 let mut manifest = Manifest::new();
323 manifest.insert(
324 "instr",
325 entry(
326 None,
327 Some(SyncedLyricsCheck {
328 version: SYNCED_LRC_VERSION,
329 checked_unix: 1_000,
330 empty: true,
331 }),
332 ),
333 );
334 let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
336 assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
337 let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
339 assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("instr"));
340 }
341
342 #[test]
343 fn version_bump_refetches_everything() {
344 let d = vec![desired("done", "")];
345 let mut manifest = Manifest::new();
346 manifest.insert(
347 "done",
348 entry(
349 Some(ArtifactState {
350 path: "done.lrc".to_string(),
351 hash: "h".to_string(),
352 }),
353 Some(SyncedLyricsCheck {
354 version: SYNCED_LRC_VERSION + 1, checked_unix: 1_000,
356 empty: false,
357 }),
358 ),
359 );
360 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("done"));
361 }
362
363 #[test]
364 fn rename_refetches_a_written_clip() {
365 let mut d = vec![desired("a", "")];
366 d[0].artifacts[0].path = "new/a.lrc".to_string();
368 let mut manifest = Manifest::new();
369 manifest.insert(
370 "a",
371 entry(
372 Some(ArtifactState {
373 path: "old/a.lrc".to_string(),
374 hash: "h".to_string(),
375 }),
376 Some(SyncedLyricsCheck {
377 version: SYNCED_LRC_VERSION,
378 checked_unix: 1_000,
379 empty: false,
380 }),
381 ),
382 );
383 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"));
384 }
385
386 #[test]
387 fn apply_sets_timed_body_and_content_hash() {
388 let mut d = vec![desired("a", "")];
389 let mut successes = HashMap::new();
390 successes.insert("a".to_string(), one_line_alignment());
391 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
392
393 let art = &d[0].artifacts[0];
394 let body = art.content.as_deref().unwrap();
395 assert!(body.contains("[00:00.50]hi there"));
396 assert_eq!(art.hash, content_hash(body));
397 assert_eq!(
398 pending,
399 vec![PendingCheck {
400 clip_id: "a".to_string(),
401 empty: false,
402 body_hash: Some(content_hash(body)),
403 }]
404 );
405 }
406
407 #[test]
408 fn apply_drops_instrumental_and_marks_empty() {
409 let mut d = vec![desired("instr", "")];
410 let mut successes = HashMap::new();
411 successes.insert("instr".to_string(), AlignedLyrics::default());
412 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
413
414 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
415 assert_eq!(
416 pending,
417 vec![PendingCheck {
418 clip_id: "instr".to_string(),
419 empty: true,
420 body_hash: None,
421 }]
422 );
423 }
424
425 #[test]
426 fn apply_keeps_existing_on_fetch_failure_no_downgrade() {
427 let mut d = vec![desired("a", "")];
433 let mut manifest = Manifest::new();
434 manifest.insert(
435 "a",
436 entry(
437 Some(ArtifactState {
438 path: "a.lrc".to_string(),
439 hash: "timed-hash".to_string(),
440 }),
441 Some(SyncedLyricsCheck {
442 version: SYNCED_LRC_VERSION,
443 checked_unix: 1_000,
444 empty: false,
445 }),
446 ),
447 );
448 let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
449
450 let art = &d[0].artifacts[0];
451 assert_eq!(art.hash, "timed-hash");
452 assert_eq!(art.content, None);
453 assert!(
454 pending.is_empty(),
455 "no check recorded on failure -> retried"
456 );
457 }
458
459 #[test]
460 fn apply_drops_write_on_failure_when_nothing_on_disk() {
461 let mut d = vec![desired("a", "")];
464 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &HashMap::new());
465 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
466 assert!(pending.is_empty());
467 }
468
469 #[test]
470 fn apply_upgrades_untimed_to_timed_when_alignment_appears() {
471 let mut d = vec![desired("a", "")];
475 let untimed_hash = "untimed".to_string();
476 let mut manifest = Manifest::new();
477 manifest.insert(
478 "a",
479 entry(
480 Some(ArtifactState {
481 path: "a.lrc".to_string(),
482 hash: untimed_hash.clone(),
483 }),
484 Some(SyncedLyricsCheck {
485 version: SYNCED_LRC_VERSION,
486 checked_unix: 1_000,
487 empty: true,
488 }),
489 ),
490 );
491 let mut successes = HashMap::new();
492 successes.insert("a".to_string(), one_line_alignment());
493 apply_synced_lrc(&mut d, &manifest, &successes);
494 let art = &d[0].artifacts[0];
495 assert!(
496 art.content
497 .as_deref()
498 .unwrap()
499 .contains("[00:00.50]hi there")
500 );
501 assert_ne!(art.hash, untimed_hash, "a changed body triggers a rewrite");
502 }
503
504 #[test]
505 fn preview_shows_write_for_targets_and_skips_resolved() {
506 let mut d = vec![desired("new", ""), desired("done", "")];
507 let mut manifest = Manifest::new();
508 manifest.insert(
509 "done",
510 entry(
511 Some(ArtifactState {
512 path: "done.lrc".to_string(),
513 hash: "slot-hash".to_string(),
514 }),
515 Some(SyncedLyricsCheck {
516 version: SYNCED_LRC_VERSION,
517 checked_unix: 1_000,
518 empty: false,
519 }),
520 ),
521 );
522 preview_synced_lrc(&mut d, &manifest, 2_000, true);
523 assert_eq!(d[0].artifacts[0].hash, synced_lrc_source_hash("new"));
525 assert_eq!(d[1].artifacts[0].hash, "slot-hash");
526 }
527}