suno_core/synced.rs
1//! Pure synced-lyrics resolution: which clips to fetch alignment for, and how
2//! each fetched result maps onto a clip's desired `.lrc` and deferred
3//! `.lyrics.txt` artifacts.
4//!
5//! The alignment fetch itself is IO and lives in the CLI (through the `Http`
6//! port); everything here is pure so the fetch-gating, the timed/untimed body
7//! choice, the "keep existing on failure" rule, and the instrumental "checked"
8//! marker are unit-tested without a network.
9//!
10//! Suno's forced alignment for a clip is immutable in practice (the audio and
11//! its lyrics are fixed once generated), so a clip is fetched at most once per
12//! render [`SYNCED_LRC_VERSION`] — recorded by [`SyncedLyricsCheck`] on the
13//! manifest — except that a clip that resolved to no lyrics (an instrumental) or
14//! to an untimed plain-text fallback is re-checked after [`SYNCED_LRC_RECHECK_SECS`]
15//! to pick up alignment Suno may compute after generation, and a clip whose audio
16//! is renamed is re-fetched so its sidecar moves with it. A version bump
17//! re-resolves everything.
18//!
19//! The `.lyrics.txt` sidecar (F1, #357) is resolved here too: its body is
20//! `clip.lyrics` when the feed carries them, else the aligned plain text, so a
21//! real-feed clip (whose `clip.lyrics` is empty) still gets a populated file.
22//! Every desired lyric slot is an independent fetch trigger: a clip is fetched
23//! when its `.lrc` OR its `.lyrics.txt` is unresolved, so enabling
24//! `lyrics_sidecar` on a library whose `.lrc`s have already converged still
25//! back-fills the `.lyrics.txt` (and the reverse), and a lyrics-only clip
26//! (`lyrics_sidecar` on, `lrc_sidecar` off) is a target in its own right. The
27//! single per-clip marker is stamped only once every slot the fetch wrote has
28//! landed, so a partial write is retried; once every desired slot is resolved
29//! the clip converges (fetched once, then skipped) instead of re-fetching
30//! forever.
31//!
32//! Known limitation (tracked follow-up, not fixed here): #354's embedded-lyrics
33//! back-fill is keyed on the `.lrc` artifact hash (`embedded_lyrics_hash`), so a
34//! lyrics-only clip gets an aligned `.lyrics.txt` on disk but its MP3
35//! `LYRICS`/`USLT` tag is NOT back-filled (the embed sentinel only runs when a
36//! `.lrc` is desired). This does not regress today's behaviour (the tag was
37//! empty for these clips before too); decoupling the embed from the `.lrc` is
38//! #354's seam, tracked separately.
39
40use std::collections::{BTreeSet, HashMap};
41
42use crate::hash::{
43 SYNCED_LRC_VERSION, content_hash, lyrics_txt_source_hash, synced_lrc_source_hash,
44};
45use crate::lyrics::{AlignedLyrics, render_clip_lrc, render_clip_lyrics, render_synced_lrc};
46use crate::manifest::{Manifest, ManifestEntry};
47use crate::model::Clip;
48use crate::reconcile::Desired;
49use crate::vocab::ArtifactKind;
50
51/// How long a clip that resolved to no lyrics (instrumental) or to an untimed
52/// plain-text fallback is trusted before its alignment is re-checked (14 days).
53/// Bounds the re-fetch to catch alignment Suno may compute after generation.
54pub const SYNCED_LRC_RECHECK_SECS: u64 = 14 * 24 * 60 * 60;
55
56/// One clip's synced-lyrics outcome this run, for the caller to record as a
57/// manifest [`SyncedLyricsCheck`](crate::SyncedLyricsCheck) once the sidecar
58/// write (if any) has safely landed.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct PendingCheck {
61 /// The clip this outcome concerns.
62 pub clip_id: String,
63 /// Whether the clip resolved to no lyrics (an instrumental). An instrumental
64 /// writes no sidecar and is marked unconditionally.
65 pub empty: bool,
66 /// Whether the fetched alignment was timed (as opposed to an untimed
67 /// plain-text fallback). Only meaningful when `empty` is false; a timed clip
68 /// is exempt from the periodic re-check.
69 pub timed: bool,
70 /// Every lyric sidecar slot this clip's fetch produced a body for, paired
71 /// with the content hash the manifest slot must reflect. The caller stamps
72 /// the durable marker only once EVERY listed slot has landed, so a partial
73 /// write (one slot ok, another failed non-fatally) leaves no marker and is
74 /// retried next run. Empty for an instrumental (nothing written).
75 /// Deterministically ordered ([`Lrc`](ArtifactKind::Lrc) before
76 /// [`LyricsTxt`](ArtifactKind::LyricsTxt)).
77 pub written_slots: Vec<(ArtifactKind, String)>,
78}
79
80/// The outcome of resolving one clip's desired lyric sidecar slot against a
81/// fetched alignment. Feeds [`build_pending_check`], which folds a clip's per-slot
82/// outcomes into the single durable marker.
83#[derive(Debug, Clone, PartialEq, Eq)]
84enum SlotOutcome {
85 /// The slot was not desired, or the clip was not fetched this run (its
86 /// stored body, if any, was kept). Contributes nothing to the marker.
87 Inert,
88 /// The clip was fetched and resolved to no lyrics for this slot (an
89 /// instrumental): the artifact was dropped and nothing written.
90 Instrumental,
91 /// The clip was fetched and a body was rendered for this slot, carrying the
92 /// content hash the manifest slot must come to reflect.
93 Wrote(String),
94}
95
96/// The `.lyrics.txt` body for a clip: its own `clip.lyrics` when the feed
97/// carries them (preferred, for back-compat), else Suno's fetched aligned plain
98/// text. `None` for an instrumental (both empty), so no empty sidecar is written.
99///
100/// Normalised to exactly one trailing newline to match the sidecar convention:
101/// [`render_clip_lyrics`] already appends one, but
102/// [`AlignedLyrics::plain_text`](crate::AlignedLyrics::plain_text) joins lines
103/// with no trailing newline, so this appends it.
104fn plain_lyrics(clip: &Clip, aligned: &AlignedLyrics) -> Option<String> {
105 if let Some(text) = render_clip_lyrics(clip) {
106 return Some(text);
107 }
108 let plain = aligned.plain_text();
109 let plain = plain.trim_end();
110 if plain.is_empty() {
111 return None;
112 }
113 Some(format!("{plain}\n"))
114}
115
116/// Whether a clip's alignment must be (re)fetched this run to resolve one desired
117/// lyric sidecar slot.
118///
119/// `desired_path`/`desired_kind` name the specific sidecar under test. Each slot
120/// is an INDEPENDENT trigger: a converged `.lrc` does not exempt a still-missing
121/// `.lyrics.txt` (and the reverse), so enabling a second sidecar back-fills it.
122/// The rename-drift check anchors on the matching manifest slot so a resolved
123/// clip converges instead of re-fetching every run.
124fn needs_fetch(
125 entry: Option<&ManifestEntry>,
126 desired_path: &str,
127 desired_kind: ArtifactKind,
128 now_unix: u64,
129) -> bool {
130 let Some(entry) = entry else {
131 return true; // never downloaded -> resolve on first sight
132 };
133 // One-time back-fill (#354): a persisted `.lrc` exists but the audio tag's
134 // embed is missing or stale relative to it. Fetching re-embeds; once the
135 // embed stamps `embedded_lyrics_hash = lrc.hash` this is false again, so the
136 // back-fill is bounded to one run per drift. This clause is `.lrc`-keyed by
137 // design (see the module note on the lyrics-only embed limitation): a clip
138 // with no `.lrc` slot has both sides empty and never triggers it.
139 if let Some(slot) = entry.lrc.as_ref()
140 && slot.hash != entry.embedded_lyrics_hash
141 {
142 return true;
143 }
144 let Some(check) = entry.synced_lyrics.as_ref() else {
145 return true; // never resolved (e.g. downloaded before the feature)
146 };
147 if check.version != SYNCED_LRC_VERSION {
148 return true; // the render changed -> re-resolve and re-render
149 }
150 if check.empty {
151 // Instrumental: writing no sidecar IS the converged state, so an absent
152 // slot here is not a "missing desired slot" to back-fill. Re-check only
153 // once the window elapses, to pick up alignment Suno adds later. This
154 // clause MUST precede the slot-presence check below.
155 return now_unix.saturating_sub(check.checked_unix) > SYNCED_LRC_RECHECK_SECS;
156 }
157 // The clip has lyrics: the SPECIFIC desired slot drives the decision, so each
158 // sidecar is resolved on its own timeline. Match the kind explicitly so a
159 // future artifact kind fails loud rather than silently reusing `.lyrics.txt`.
160 let slot = match desired_kind {
161 ArtifactKind::Lrc => entry.lrc.as_ref(),
162 ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
163 _ => None,
164 };
165 match slot {
166 // The desired sidecar was never written: a back-fill (its feature was
167 // just enabled) or an interrupted prior write. This is the fix for a
168 // clip whose OTHER slot had already converged.
169 None => true,
170 // Present but the audio was renamed: move the sidecar with it.
171 Some(s) if s.path != desired_path => true,
172 // Untimed fallback: re-check once the window elapses, to pick up
173 // alignment Suno may compute after generation.
174 Some(_) if !check.timed => {
175 now_unix.saturating_sub(check.checked_unix) > SYNCED_LRC_RECHECK_SECS
176 }
177 // Timed and in place: converged, no re-fetch.
178 Some(_) => false,
179 }
180}
181
182/// The clip ids whose alignment must be fetched this run, in a stable order.
183///
184/// Empty when `enabled` is false, so the synced-lyrics feature being off means
185/// zero alignment fetches. Only clips carrying a desired lyric artifact (a `.lrc`
186/// or a deferred `.lyrics.txt`) are considered; such a clip is a target when ANY
187/// of its desired lyric slots still needs a fetch (see [`needs_fetch`]), so
188/// enabling one sidecar on a library whose other sidecar has long converged still
189/// back-fills the new one. Each slot is fetched at most once per render version.
190pub fn synced_lyrics_targets(
191 desired: &[Desired],
192 manifest: &Manifest,
193 now_unix: u64,
194 enabled: bool,
195) -> BTreeSet<String> {
196 if !enabled {
197 return BTreeSet::new();
198 }
199 let mut out = BTreeSet::new();
200 for d in desired {
201 // Only a clip that desires at least one lyric sidecar is ever fetched.
202 let mut lyric_slots = d
203 .artifacts
204 .iter()
205 .filter(|a| matches!(a.kind, ArtifactKind::Lrc | ArtifactKind::LyricsTxt))
206 .peekable();
207 if lyric_slots.peek().is_none() {
208 continue;
209 }
210 let entry = manifest.get(&d.clip.id);
211 // Reformat re-embed (#354): a format change re-encodes the audio and
212 // drops any embedded lyrics, so re-fetch when the format will change AND
213 // a persisted `.lrc` is worth re-embedding (an already-migrated clip is
214 // neither a back-fill nor a rename target, so nothing else would refetch
215 // it). Pure and bounded: after the Reformat commits `entry.format ==
216 // d.format`, so it fires once. A clip with no `.lrc` never fetches here.
217 let reformat_reembed = entry.is_some_and(|e| e.format != d.format && e.lrc.is_some());
218 // Fetch when ANY desired lyric slot is unresolved: the per-slot decision
219 // makes each sidecar an independent trigger.
220 let any_slot_needs =
221 lyric_slots.any(|a| needs_fetch(entry, a.path.as_str(), a.kind, now_unix));
222 if reformat_reembed || any_slot_needs {
223 out.insert(d.clip.id.clone());
224 }
225 }
226 out
227}
228
229/// Resolve each clip's desired `.lrc` and deferred `.lyrics.txt` artifacts from
230/// the fetched alignment, returning the checks to persist for the clips that
231/// were successfully fetched.
232///
233/// `successes` holds the alignment for clips whose fetch returned `200` (an empty
234/// value for an instrumental); a clip absent from it either was not fetched
235/// (resolved recently) or its fetch FAILED. In both of those cases the existing
236/// sidecar is KEPT untouched — the artifact's hash is reset to the stored slot so
237/// reconcile skips it (no rewrite, no downgrade of a timed file to untimed), or
238/// the artifact is dropped when there is nothing on disk yet — and no check is
239/// returned, so a failed fetch is simply retried next run.
240///
241/// For a successful fetch the `.lrc` body is the timed render when Suno has
242/// alignment, else the untimed lyrics as a fallback; the `.lyrics.txt` body is
243/// `clip.lyrics` when the feed carries them, else the aligned plain text. An
244/// instrumental (no body for a slot) drops that artifact. A produced body sets
245/// the artifact's content and its content hash, so reconcile rewrites only when
246/// the body actually changes (including an untimed->timed upgrade after a
247/// re-check). At most one check is returned per clip: its `timed` flag
248/// distinguishes timed alignment from an untimed fallback (only timed clips are
249/// exempt from the periodic re-check), and its `written_slots` list names every
250/// slot a body was rendered for, so the caller marks the clip resolved only once
251/// EVERY such slot has landed (a partial write is retried). An instrumental
252/// records an empty `written_slots` and is unconditionally durable.
253pub fn apply_synced_lrc(
254 desired: &mut [Desired],
255 manifest: &Manifest,
256 successes: &HashMap<String, AlignedLyrics>,
257) -> Vec<PendingCheck> {
258 let mut pending = Vec::new();
259 for d in desired.iter_mut() {
260 // Carry-forward baseline (#354, the loop-freedom crux): every clip keeps
261 // its persisted embed fingerprint unless it is actually fetched this run
262 // (the `.lrc` override below). So a lyrics-driven `Retag` can only fire
263 // when alignment was fetched, never stamping a matching hash over an
264 // empty write. This assignment MUST stay ABOVE the per-slot resolution:
265 // a clip with no desired `.lrc` (the sidecar off, an instrumental, or a
266 // lyrics-only clip) must still carry its value forward, otherwise its
267 // sentinel would drift to the default and it would spuriously retag with
268 // an empty `synced` map.
269 d.embedded_lyrics_hash = manifest
270 .get(&d.clip.id)
271 .map(|e| e.embedded_lyrics_hash.clone())
272 .unwrap_or_default();
273
274 let aligned = successes.get(&d.clip.id);
275 // Resolve BOTH lyric sidecars from the same fetched alignment, each on
276 // its own slot. The `.lrc` also drives the #354 embed (its resolution
277 // stamps `embedded_lyrics_hash`); the `.lyrics.txt` carries no embed. The
278 // two outcomes fold into a single durable marker that lists every slot
279 // written, so back-filling one sidecar never masks an unwritten other.
280 let lrc = apply_lrc_slot(d, manifest, aligned);
281 let lyrics_txt = apply_lyrics_txt_slot(d, manifest, aligned);
282 if let Some(check) = build_pending_check(&d.clip.id, aligned, &lrc, &lyrics_txt) {
283 pending.push(check);
284 }
285 }
286 pending
287}
288
289/// Fold a clip's per-slot resolution outcomes into the single durable marker to
290/// persist, or `None` when the clip was not fetched this run (a resolved-but-
291/// untouched clip records nothing, so an existing marker is left intact) or
292/// desired no lyric slot at all.
293///
294/// `written_slots` lists every slot that rendered a body ([`Lrc`](ArtifactKind::Lrc)
295/// before [`LyricsTxt`](ArtifactKind::LyricsTxt)); the caller stamps the marker
296/// only once every listed slot has landed, so a partial write (one slot ok,
297/// another failed non-fatally) records nothing and is retried. `empty` is set for
298/// an instrumental (fetched, desired a sidecar, rendered no body for any slot).
299/// `timed` mirrors the fetched alignment, gating the periodic re-check.
300fn build_pending_check(
301 clip_id: &str,
302 aligned: Option<&AlignedLyrics>,
303 lrc: &SlotOutcome,
304 lyrics_txt: &SlotOutcome,
305) -> Option<PendingCheck> {
306 // Only a fetched clip records a marker; a miss keeps its stored state.
307 let aligned = aligned?;
308 // A clip that desired no lyric slot (both Inert) is not a lyric outcome.
309 let desired_lyric =
310 !matches!(lrc, SlotOutcome::Inert) || !matches!(lyrics_txt, SlotOutcome::Inert);
311 if !desired_lyric {
312 return None;
313 }
314 let mut written_slots = Vec::new();
315 if let SlotOutcome::Wrote(hash) = lrc {
316 written_slots.push((ArtifactKind::Lrc, hash.clone()));
317 }
318 if let SlotOutcome::Wrote(hash) = lyrics_txt {
319 written_slots.push((ArtifactKind::LyricsTxt, hash.clone()));
320 }
321 Some(PendingCheck {
322 clip_id: clip_id.to_string(),
323 empty: written_slots.is_empty(),
324 timed: !aligned.is_empty(),
325 written_slots,
326 })
327}
328
329/// Resolve a clip's desired `.lrc` artifact from the fetched alignment (or keep
330/// the stored slot on a miss). Returns the slot's [`SlotOutcome`]: `Inert` when
331/// the clip has no `.lrc` desired or was not fetched, `Wrote(hash)` when a body
332/// was rendered, `Instrumental` when the fetch resolved to no lyrics. Also stamps
333/// the #354 embed fingerprint from the same body.
334fn apply_lrc_slot(
335 d: &mut Desired,
336 manifest: &Manifest,
337 aligned: Option<&AlignedLyrics>,
338) -> SlotOutcome {
339 let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
340 return SlotOutcome::Inert;
341 };
342 let clip_id = d.clip.id.clone();
343 let slot_hash = manifest
344 .get(&clip_id)
345 .and_then(|e| e.lrc.as_ref())
346 .map(|slot| slot.hash.clone());
347 let Some(aligned) = aligned else {
348 // Not fetched this run (resolved recently) or the fetch failed: keep
349 // whatever is already on disk. Reuse the stored slot hash so reconcile
350 // skips the write; drop the artifact when nothing was ever written.
351 // `embedded_lyrics_hash` keeps its carry-forward baseline, so a failed
352 // back-fill neither retags nor stamps and is simply retried next run.
353 match slot_hash {
354 Some(hash) => {
355 let artifact = &mut d.artifacts[idx];
356 artifact.hash = hash;
357 artifact.content = None;
358 }
359 None => {
360 d.artifacts.remove(idx);
361 }
362 }
363 return SlotOutcome::Inert;
364 };
365 let timed = !aligned.is_empty();
366 let body = if timed {
367 render_synced_lrc(&d.clip, &d.lineage, aligned)
368 } else {
369 render_clip_lrc(&d.clip, &d.lineage)
370 };
371 match body {
372 Some(text) => {
373 let hash = content_hash(&text);
374 // The embed is rendered from this same fetched alignment, so its
375 // fingerprint moves in lock-step with the `.lrc` body.
376 d.embedded_lyrics_hash = hash.clone();
377 let artifact = &mut d.artifacts[idx];
378 artifact.hash = hash.clone();
379 artifact.content = Some(text);
380 SlotOutcome::Wrote(hash)
381 }
382 None => {
383 // Fetched but the clip is an instrumental: nothing embedded.
384 d.embedded_lyrics_hash = String::new();
385 d.artifacts.remove(idx);
386 SlotOutcome::Instrumental
387 }
388 }
389}
390
391/// Resolve a clip's deferred `.lyrics.txt` artifact from `clip.lyrics` (preferred)
392/// else the fetched aligned plain text (or keep the stored slot on a miss).
393/// Returns the slot's [`SlotOutcome`]: `Inert` when the clip has no `.lyrics.txt`
394/// desired or was not fetched, `Wrote(hash)` when a body was rendered,
395/// `Instrumental` when neither source yields lyrics. The `.lyrics.txt` carries no
396/// embed, so it never touches `embedded_lyrics_hash` (#354's back-fill stays
397/// `.lrc`-keyed; see the module note on the lyrics-only embed limitation).
398fn apply_lyrics_txt_slot(
399 d: &mut Desired,
400 manifest: &Manifest,
401 aligned: Option<&AlignedLyrics>,
402) -> SlotOutcome {
403 let Some(idx) = d
404 .artifacts
405 .iter()
406 .position(|a| a.kind == ArtifactKind::LyricsTxt)
407 else {
408 return SlotOutcome::Inert;
409 };
410 let slot_hash = manifest
411 .get(&d.clip.id)
412 .and_then(|e| e.lyrics_txt.as_ref())
413 .map(|slot| slot.hash.clone());
414 let Some(aligned) = aligned else {
415 match slot_hash {
416 Some(hash) => {
417 let artifact = &mut d.artifacts[idx];
418 artifact.hash = hash;
419 artifact.content = None;
420 }
421 None => {
422 d.artifacts.remove(idx);
423 }
424 }
425 return SlotOutcome::Inert;
426 };
427 match plain_lyrics(&d.clip, aligned) {
428 Some(text) => {
429 let hash = content_hash(&text);
430 let artifact = &mut d.artifacts[idx];
431 artifact.hash = hash.clone();
432 artifact.content = Some(text);
433 SlotOutcome::Wrote(hash)
434 }
435 None => {
436 d.artifacts.remove(idx);
437 SlotOutcome::Instrumental
438 }
439 }
440}
441
442/// Adjust each clip's desired `.lrc` and deferred `.lyrics.txt` artifacts for a
443/// dry run, without any fetch.
444///
445/// Each desired lyric slot is previewed on its OWN fetch decision (mirroring
446/// [`needs_fetch`]): a slot that WOULD be (re)fetched keeps a distinct pending
447/// source hash so the previewed plan reports the write; a slot already resolved
448/// reuses its stored slot hash (so it shows as skipped) or is dropped when it is
449/// a known instrumental. Per-slot (not clip-level) so a back-fill clip previews
450/// its converged `.lrc` as skipped while its missing `.lyrics.txt` shows as a
451/// write. The preview is an upper bound on synced sidecar writes (it cannot know
452/// which targets will turn out to be instrumentals).
453pub fn preview_synced_lrc(
454 desired: &mut [Desired],
455 manifest: &Manifest,
456 now_unix: u64,
457 enabled: bool,
458) {
459 for d in desired.iter_mut() {
460 let entry = manifest.get(&d.clip.id);
461 // Carry-forward baseline (#354): mirror `apply_synced_lrc`. This MUST run
462 // for every clip (including one with no desired `.lrc`: the sidecar off,
463 // a lyrics-only clip, or an instrumental) so it keeps its persisted embed
464 // fingerprint and previews as skipped, rather than drifting to the default
465 // and reporting a spurious retag.
466 d.embedded_lyrics_hash = entry
467 .map(|e| e.embedded_lyrics_hash.clone())
468 .unwrap_or_default();
469
470 // `.lrc` preview: the pending case also previews the expected post-fetch
471 // embed (the #354 back-fill), so it stays coupled to the `.lrc` slot.
472 if let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) {
473 let path = d.artifacts[idx].path.clone();
474 if enabled && needs_fetch(entry, &path, ArtifactKind::Lrc, now_unix) {
475 // A fetch target previews the expected post-fetch embed: the
476 // persisted `.lrc` slot hash, so a pending back-fill reports drift
477 // (not "up to date"). It converges to the apply value once
478 // alignment is fetched.
479 d.embedded_lyrics_hash = entry
480 .and_then(|e| e.lrc.as_ref())
481 .map(|s| s.hash.clone())
482 .unwrap_or_default();
483 d.artifacts[idx].hash = synced_lrc_source_hash(&d.clip.id);
484 } else {
485 match entry.and_then(|e| e.lrc.as_ref()) {
486 Some(slot) => d.artifacts[idx].hash = slot.hash.clone(),
487 None => {
488 d.artifacts.remove(idx);
489 }
490 }
491 }
492 }
493
494 // `.lyrics.txt` preview (F1): the same per-slot treatment as the `.lrc`,
495 // but no embed (the plain-text sidecar is not embedded in audio).
496 if let Some(idx) = d
497 .artifacts
498 .iter()
499 .position(|a| a.kind == ArtifactKind::LyricsTxt)
500 {
501 let path = d.artifacts[idx].path.clone();
502 if enabled && needs_fetch(entry, &path, ArtifactKind::LyricsTxt, now_unix) {
503 d.artifacts[idx].hash = lyrics_txt_source_hash(&d.clip.id);
504 } else {
505 match entry.and_then(|e| e.lyrics_txt.as_ref()) {
506 Some(slot) => d.artifacts[idx].hash = slot.hash.clone(),
507 None => {
508 d.artifacts.remove(idx);
509 }
510 }
511 }
512 }
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use crate::lineage::LineageContext;
520 use crate::lyrics::{AlignedLine, AlignedLineWord};
521 use crate::manifest::{ArtifactState, SyncedLyricsCheck};
522 use crate::model::Clip;
523 use crate::reconcile::DesiredArtifact;
524 use crate::vocab::AudioFormat;
525
526 fn clip(id: &str, lyrics: &str) -> Clip {
527 Clip {
528 id: id.to_string(),
529 title: "Song".to_string(),
530 lyrics: lyrics.to_string(),
531 prompt: "a prompt".to_string(),
532 ..Default::default()
533 }
534 }
535
536 fn lrc_artifact(clip_id: &str) -> DesiredArtifact {
537 DesiredArtifact {
538 kind: ArtifactKind::Lrc,
539 path: format!("{clip_id}.lrc"),
540 source_url: String::new(),
541 hash: synced_lrc_source_hash(clip_id),
542 content: None,
543 }
544 }
545
546 fn desired(id: &str, lyrics: &str) -> Desired {
547 let c = clip(id, lyrics);
548 Desired {
549 lineage: LineageContext::own_root(&c),
550 path: format!("{id}.flac"),
551 format: AudioFormat::Flac,
552 meta_hash: "m".to_string(),
553 art_hash: "a".to_string(),
554 embedded_lyrics_hash: String::new(),
555 modes: vec![crate::vocab::SourceMode::Mirror],
556 trashed: false,
557 private: false,
558 artifacts: vec![lrc_artifact(id)],
559 clip: c,
560 stems: None,
561 }
562 }
563
564 /// A deferred `.lyrics.txt` artifact in its placeholder state (no inline
565 /// body, the source-hash sentinel), mirroring [`lrc_artifact`].
566 fn lyrics_txt_artifact(clip_id: &str) -> DesiredArtifact {
567 DesiredArtifact {
568 kind: ArtifactKind::LyricsTxt,
569 path: format!("{clip_id}.lyrics.txt"),
570 source_url: String::new(),
571 hash: lyrics_txt_source_hash(clip_id),
572 content: None,
573 }
574 }
575
576 /// A lyrics-only clip (F1): `lyrics_sidecar` on, `lrc_sidecar` off, so the
577 /// only desired lyric artifact is the deferred `.lyrics.txt`.
578 fn desired_lyrics_only(id: &str, lyrics: &str) -> Desired {
579 let mut d = desired(id, lyrics);
580 d.artifacts = vec![lyrics_txt_artifact(id)];
581 d
582 }
583
584 /// A clip with both lyric sidecars desired (`.lrc` and `.lyrics.txt`), for
585 /// asserting each slot is resolved and recorded independently.
586 fn desired_both(id: &str, lyrics: &str) -> Desired {
587 let mut d = desired(id, lyrics);
588 d.artifacts = vec![lrc_artifact(id), lyrics_txt_artifact(id)];
589 d
590 }
591
592 fn one_line_alignment() -> AlignedLyrics {
593 AlignedLyrics {
594 lines: vec![AlignedLine {
595 text: "hi there".to_owned(),
596 start_s: 0.5,
597 end_s: 1.2,
598 section: "Verse 1".to_owned(),
599 words: vec![
600 AlignedLineWord {
601 text: "hi".to_owned(),
602 start_s: 0.5,
603 end_s: 0.8,
604 },
605 AlignedLineWord {
606 text: "there".to_owned(),
607 start_s: 0.9,
608 end_s: 1.2,
609 },
610 ],
611 }],
612 ..Default::default()
613 }
614 }
615
616 fn entry(lrc: Option<ArtifactState>, check: Option<SyncedLyricsCheck>) -> ManifestEntry {
617 // Default to a fully-migrated clip: the embed fingerprint matches the
618 // `.lrc` slot hash, so an ordinarily-resolved clip is NOT a #354 back-fill
619 // target. Back-fill/instrumental tests override `embedded_lyrics_hash`.
620 let embedded_lyrics_hash = lrc.as_ref().map(|s| s.hash.clone()).unwrap_or_default();
621 ManifestEntry {
622 path: "song.flac".to_string(),
623 format: AudioFormat::Flac,
624 lrc,
625 embedded_lyrics_hash,
626 synced_lyrics: check,
627 ..Default::default()
628 }
629 }
630
631 #[test]
632 fn targets_empty_when_feature_off() {
633 let d = vec![desired("a", "")];
634 let manifest = Manifest::new();
635 assert!(synced_lyrics_targets(&d, &manifest, 0, false).is_empty());
636 }
637
638 #[test]
639 fn targets_new_clip_but_not_a_recently_resolved_one() {
640 let d = vec![desired("new", ""), desired("done", "")];
641 let mut manifest = Manifest::new();
642 // `done` was timed-resolved at the current version; `new` is unseen.
643 manifest.insert(
644 "done",
645 entry(
646 Some(ArtifactState {
647 path: "done.lrc".to_string(),
648 hash: "h".to_string(),
649 }),
650 Some(SyncedLyricsCheck {
651 version: SYNCED_LRC_VERSION,
652 checked_unix: 1_000,
653 empty: false,
654 timed: true,
655 }),
656 ),
657 );
658 let targets = synced_lyrics_targets(&d, &manifest, 2_000, true);
659 assert!(targets.contains("new"));
660 assert!(!targets.contains("done"));
661 }
662
663 #[test]
664 fn instrumental_is_rechecked_only_after_the_window() {
665 let d = vec![desired("instr", "")];
666 let mut manifest = Manifest::new();
667 manifest.insert(
668 "instr",
669 entry(
670 None,
671 Some(SyncedLyricsCheck {
672 version: SYNCED_LRC_VERSION,
673 checked_unix: 1_000,
674 empty: true,
675 timed: false,
676 }),
677 ),
678 );
679 // Within the window: not re-fetched (this is the fix for forever-refetch).
680 let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
681 assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
682 // Past the window: re-checked, to pick up late alignment.
683 let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
684 assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("instr"));
685 }
686
687 #[test]
688 fn untimed_fallback_is_rechecked_after_the_window() {
689 // A clip that previously resolved to an untimed fallback (empty alignment
690 // but non-empty lyrics) must be re-checked after the window so a later
691 // Suno alignment upgrades it to a timed `.lrc`.
692 let d = vec![desired("a", "some lyrics")];
693 let mut manifest = Manifest::new();
694 manifest.insert(
695 "a",
696 entry(
697 Some(ArtifactState {
698 path: "a.lrc".to_string(),
699 hash: "untimed-hash".to_string(),
700 }),
701 Some(SyncedLyricsCheck {
702 version: SYNCED_LRC_VERSION,
703 checked_unix: 1_000,
704 empty: false,
705 timed: false,
706 }),
707 ),
708 );
709 // Within the window: no re-fetch (avoids churn on every run).
710 let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
711 assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
712 // Past the window: re-checked, to upgrade to timed if alignment arrived.
713 let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
714 assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("a"));
715 }
716
717 #[test]
718 fn timed_clip_is_not_rechecked_without_rename() {
719 // A timed clip must not be re-fetched just because the window elapsed;
720 // only a rename (path drift) or missing slot should trigger a re-fetch.
721 let d = vec![desired("a", "")];
722 let mut manifest = Manifest::new();
723 manifest.insert(
724 "a",
725 entry(
726 Some(ArtifactState {
727 path: "a.lrc".to_string(),
728 hash: "h".to_string(),
729 }),
730 Some(SyncedLyricsCheck {
731 version: SYNCED_LRC_VERSION,
732 checked_unix: 0, // maximally stale
733 empty: false,
734 timed: true,
735 }),
736 ),
737 );
738 // Even long after the window: still not re-fetched.
739 let very_late = 2 * SYNCED_LRC_RECHECK_SECS;
740 assert!(synced_lyrics_targets(&d, &manifest, very_late, true).is_empty());
741 }
742
743 #[test]
744 fn version_bump_refetches_everything() {
745 let d = vec![desired("done", "")];
746 let mut manifest = Manifest::new();
747 manifest.insert(
748 "done",
749 entry(
750 Some(ArtifactState {
751 path: "done.lrc".to_string(),
752 hash: "h".to_string(),
753 }),
754 Some(SyncedLyricsCheck {
755 version: SYNCED_LRC_VERSION + 1, // resolved at a different version
756 checked_unix: 1_000,
757 empty: false,
758 timed: true,
759 }),
760 ),
761 );
762 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("done"));
763 }
764
765 #[test]
766 fn rename_refetches_a_written_clip() {
767 let mut d = vec![desired("a", "")];
768 // The audio (and so the `.lrc`) moved to a new path.
769 d[0].artifacts[0].path = "new/a.lrc".to_string();
770 let mut manifest = Manifest::new();
771 manifest.insert(
772 "a",
773 entry(
774 Some(ArtifactState {
775 path: "old/a.lrc".to_string(),
776 hash: "h".to_string(),
777 }),
778 Some(SyncedLyricsCheck {
779 version: SYNCED_LRC_VERSION,
780 checked_unix: 1_000,
781 empty: false,
782 timed: true,
783 }),
784 ),
785 );
786 assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"));
787 }
788
789 #[test]
790 fn apply_sets_timed_body_and_content_hash() {
791 let mut d = vec![desired("a", "")];
792 let mut successes = HashMap::new();
793 successes.insert("a".to_string(), one_line_alignment());
794 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
795
796 let art = &d[0].artifacts[0];
797 let body = art.content.as_deref().unwrap();
798 assert!(body.contains("[00:00.50]hi there"));
799 assert_eq!(art.hash, content_hash(body));
800 assert_eq!(
801 pending,
802 vec![PendingCheck {
803 clip_id: "a".to_string(),
804 empty: false,
805 timed: true,
806 written_slots: vec![(ArtifactKind::Lrc, content_hash(body))],
807 }]
808 );
809 }
810
811 #[test]
812 fn apply_untimed_fallback_marks_not_timed() {
813 // When Suno returns empty alignment but the clip has lyrics, the untimed
814 // plain-text fallback is written but `timed` is false so the check is
815 // subject to the periodic re-check window.
816 let mut d = vec![desired("a", "some lyrics")];
817 let mut successes = HashMap::new();
818 successes.insert("a".to_string(), AlignedLyrics::default());
819 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
820
821 let art = &d[0].artifacts[0];
822 assert!(art.content.is_some(), "untimed body written");
823 let check = &pending[0];
824 assert!(!check.empty, "clip has lyrics, not an instrumental");
825 assert!(!check.timed, "alignment was empty -> untimed fallback");
826 }
827
828 #[test]
829 fn apply_drops_instrumental_and_marks_empty() {
830 let mut d = vec![desired("instr", "")];
831 let mut successes = HashMap::new();
832 successes.insert("instr".to_string(), AlignedLyrics::default());
833 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
834
835 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
836 assert_eq!(
837 pending,
838 vec![PendingCheck {
839 clip_id: "instr".to_string(),
840 empty: true,
841 timed: false,
842 written_slots: vec![],
843 }]
844 );
845 }
846
847 #[test]
848 fn apply_keeps_existing_on_fetch_failure_no_downgrade() {
849 // The clip has an existing timed `.lrc` (slot present) but its fetch
850 // failed this run (absent from successes). The artifact is reset to the
851 // stored slot hash with no content, so reconcile skips it — the good
852 // timed file is neither rewritten nor downgraded — and no check is
853 // recorded, so it is retried next run.
854 let mut d = vec![desired("a", "")];
855 let mut manifest = Manifest::new();
856 manifest.insert(
857 "a",
858 entry(
859 Some(ArtifactState {
860 path: "a.lrc".to_string(),
861 hash: "timed-hash".to_string(),
862 }),
863 Some(SyncedLyricsCheck {
864 version: SYNCED_LRC_VERSION,
865 checked_unix: 1_000,
866 empty: false,
867 timed: true,
868 }),
869 ),
870 );
871 let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
872
873 let art = &d[0].artifacts[0];
874 assert_eq!(art.hash, "timed-hash");
875 assert_eq!(art.content, None);
876 assert!(
877 pending.is_empty(),
878 "no check recorded on failure -> retried"
879 );
880 }
881
882 #[test]
883 fn apply_drops_write_on_failure_when_nothing_on_disk() {
884 // A brand-new clip whose fetch failed: no slot to keep, so the write is
885 // dropped (retried next run) rather than written empty.
886 let mut d = vec![desired("a", "")];
887 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &HashMap::new());
888 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
889 assert!(pending.is_empty());
890 }
891
892 #[test]
893 fn apply_upgrades_untimed_to_timed_when_alignment_appears() {
894 // The clip previously resolved to an untimed fallback (empty alignment,
895 // body written, timed: false). A re-check now returns alignment, so the
896 // timed body's content hash differs and reconcile will rewrite.
897 let mut d = vec![desired("a", "some lyrics")];
898 let untimed_hash = "untimed".to_string();
899 let mut manifest = Manifest::new();
900 manifest.insert(
901 "a",
902 entry(
903 Some(ArtifactState {
904 path: "a.lrc".to_string(),
905 hash: untimed_hash.clone(),
906 }),
907 Some(SyncedLyricsCheck {
908 version: SYNCED_LRC_VERSION,
909 checked_unix: 1_000,
910 empty: false,
911 timed: false,
912 }),
913 ),
914 );
915 let mut successes = HashMap::new();
916 successes.insert("a".to_string(), one_line_alignment());
917 let pending = apply_synced_lrc(&mut d, &manifest, &successes);
918 let art = &d[0].artifacts[0];
919 assert!(
920 art.content
921 .as_deref()
922 .unwrap()
923 .contains("[00:00.50]hi there")
924 );
925 assert_ne!(art.hash, untimed_hash, "a changed body triggers a rewrite");
926 assert!(pending[0].timed, "upgraded to timed");
927 }
928
929 #[test]
930 fn preview_shows_write_for_targets_and_skips_resolved() {
931 let mut d = vec![desired("new", ""), desired("done", "")];
932 let mut manifest = Manifest::new();
933 manifest.insert(
934 "done",
935 entry(
936 Some(ArtifactState {
937 path: "done.lrc".to_string(),
938 hash: "slot-hash".to_string(),
939 }),
940 Some(SyncedLyricsCheck {
941 version: SYNCED_LRC_VERSION,
942 checked_unix: 1_000,
943 empty: false,
944 timed: true,
945 }),
946 ),
947 );
948 preview_synced_lrc(&mut d, &manifest, 2_000, true);
949 // `new` keeps a pending hash (would write); `done` reuses its slot hash.
950 assert_eq!(d[0].artifacts[0].hash, synced_lrc_source_hash("new"));
951 assert_eq!(d[1].artifacts[0].hash, "slot-hash");
952 }
953
954 // ---- #354: embedded aligned-lyrics back-fill (embedded_lyrics_hash) ----
955
956 /// A timed, resolved marker at the current version (the stable-clip baseline).
957 fn timed_check() -> SyncedLyricsCheck {
958 SyncedLyricsCheck {
959 version: SYNCED_LRC_VERSION,
960 checked_unix: 1_000,
961 empty: false,
962 timed: true,
963 }
964 }
965
966 fn slot(path: &str, hash: &str) -> Option<ArtifactState> {
967 Some(ArtifactState {
968 path: path.to_string(),
969 hash: hash.to_string(),
970 })
971 }
972
973 #[test]
974 fn needs_fetch_backfills_when_embed_missing() {
975 // A timed, resolved clip whose `.lrc` slot has a hash but whose persisted
976 // embed is empty is a one-time back-fill target.
977 let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
978 e.embedded_lyrics_hash = String::new();
979 assert!(needs_fetch(Some(&e), "a.lrc", ArtifactKind::Lrc, 2_000));
980 }
981
982 #[test]
983 fn needs_fetch_skips_when_embed_matches_lrc() {
984 // Already back-filled: the embed matches the `.lrc` slot, so no re-fetch.
985 let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
986 e.embedded_lyrics_hash = "H".to_string();
987 assert!(!needs_fetch(Some(&e), "a.lrc", ArtifactKind::Lrc, 2_000));
988 }
989
990 #[test]
991 fn needs_fetch_no_backfill_without_lrc_slot() {
992 // Instrumental: no `.lrc` slot and an empty embed, so the back-fill clause
993 // is skipped (both sides empty) and, within the re-check window, the
994 // instrumental clause is false too.
995 let mut e = entry(
996 None,
997 Some(SyncedLyricsCheck {
998 empty: true,
999 timed: false,
1000 ..timed_check()
1001 }),
1002 );
1003 e.embedded_lyrics_hash = String::new();
1004 let within_window = 1_000 + SYNCED_LRC_RECHECK_SECS;
1005 assert!(!needs_fetch(
1006 Some(&e),
1007 "a.lrc",
1008 ArtifactKind::Lrc,
1009 within_window
1010 ));
1011 }
1012
1013 #[test]
1014 fn apply_sets_embedded_lyrics_hash_from_body() {
1015 // A timed fetch stamps the sentinel with the `.lrc` body content hash,
1016 // equal to the resolved `.lrc` artifact hash (lock-step with the embed).
1017 let mut d = vec![desired("a", "")];
1018 let mut successes = HashMap::new();
1019 successes.insert("a".to_string(), one_line_alignment());
1020 apply_synced_lrc(&mut d, &Manifest::new(), &successes);
1021
1022 let art = &d[0].artifacts[0];
1023 let body = art.content.as_deref().unwrap();
1024 assert_eq!(d[0].embedded_lyrics_hash, content_hash(body));
1025 assert_eq!(d[0].embedded_lyrics_hash, art.hash);
1026 }
1027
1028 #[test]
1029 fn apply_clears_embedded_lyrics_hash_for_instrumental() {
1030 // A previously-embedded clip that resolves to no lyrics this run drops the
1031 // artifact and clears the sentinel to "" (nothing embedded now).
1032 let mut d = vec![desired("instr", "")];
1033 let mut manifest = Manifest::new();
1034 let mut e = entry(slot("instr.lrc", "H"), Some(timed_check()));
1035 e.embedded_lyrics_hash = "H".to_string();
1036 manifest.insert("instr", e);
1037 let mut successes = HashMap::new();
1038 successes.insert("instr".to_string(), AlignedLyrics::default());
1039 apply_synced_lrc(&mut d, &manifest, &successes);
1040
1041 assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
1042 assert_eq!(d[0].embedded_lyrics_hash, "");
1043 }
1044
1045 #[test]
1046 fn apply_carries_forward_embedded_lyrics_hash_when_not_fetched() {
1047 // No fetch this run: the sentinel carries the PERSISTED embed value, not
1048 // the `.lrc` slot hash. They differ in the not-yet-embedded / failed
1049 // cases, which is exactly why the field is required (loop-freedom).
1050 let mut d = vec![desired("a", "")];
1051 let mut manifest = Manifest::new();
1052 let mut e = entry(slot("a.lrc", "slot"), Some(timed_check()));
1053 e.embedded_lyrics_hash = "embed".to_string();
1054 manifest.insert("a", e);
1055 apply_synced_lrc(&mut d, &manifest, &HashMap::new());
1056
1057 assert_eq!(
1058 d[0].embedded_lyrics_hash, "embed",
1059 "carry-forward, not the slot hash"
1060 );
1061 // The `.lrc` artifact hash resets to the slot so reconcile skips the write.
1062 assert_eq!(d[0].artifacts[0].hash, "slot");
1063 }
1064
1065 #[test]
1066 fn apply_carries_forward_for_clip_without_lrc_artifact() {
1067 // A clip with no desired `.lrc` artifact (feature off / instrumental) keeps
1068 // its persisted embed value and never spuriously retags.
1069 let mut d = vec![desired("a", "")];
1070 d[0].artifacts.clear();
1071 let mut manifest = Manifest::new();
1072 let mut e = entry(None, None);
1073 e.embedded_lyrics_hash = "H".to_string();
1074 manifest.insert("a", e);
1075 apply_synced_lrc(&mut d, &manifest, &HashMap::new());
1076
1077 assert_eq!(d[0].embedded_lyrics_hash, "H");
1078 }
1079
1080 #[test]
1081 fn preview_marks_backfill_target_as_pending() {
1082 // A stale-embed clip (lrc.hash = H, embed = "") previews the expected
1083 // post-fetch value H so `check` reports drift; a resolved clip carries its
1084 // value forward and matches the manifest (skipped).
1085 let mut d = vec![desired("stale", ""), desired("done", "")];
1086 let mut manifest = Manifest::new();
1087 let mut stale = entry(slot("stale.lrc", "H"), Some(timed_check()));
1088 stale.embedded_lyrics_hash = String::new();
1089 manifest.insert("stale", stale);
1090 let mut done = entry(slot("done.lrc", "D"), Some(timed_check()));
1091 done.embedded_lyrics_hash = "D".to_string();
1092 manifest.insert("done", done);
1093
1094 preview_synced_lrc(&mut d, &manifest, 2_000, true);
1095 assert_eq!(
1096 d[0].embedded_lyrics_hash, "H",
1097 "target previews the back-fill"
1098 );
1099 assert_ne!(
1100 d[0].embedded_lyrics_hash, "",
1101 "differs from the manifest embed -> drift reported"
1102 );
1103 assert_eq!(
1104 d[1].embedded_lyrics_hash, "D",
1105 "resolved clip carries forward"
1106 );
1107 }
1108
1109 #[test]
1110 fn reformat_makes_migrated_clip_a_target() {
1111 // An already-migrated clip (embed == lrc.hash == H, entry.format = FLAC) is
1112 // neither a back-fill nor a rename target, but a pending FLAC->MP3 reformat
1113 // re-encodes and drops the embed, so the reformat re-embed trigger fires.
1114 let mut manifest = Manifest::new();
1115 let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
1116 e.embedded_lyrics_hash = "H".to_string(); // entry.format is FLAC (helper)
1117 manifest.insert("a", e);
1118
1119 let mut reformat = vec![desired("a", "")];
1120 reformat[0].format = AudioFormat::Mp3;
1121 assert!(
1122 synced_lyrics_targets(&reformat, &manifest, 2_000, true).contains("a"),
1123 "a format change re-embeds a migrated clip"
1124 );
1125
1126 // No format change: the same stable clip is not a target.
1127 let stable = vec![desired("a", "")]; // FLAC == entry.format
1128 assert!(
1129 synced_lyrics_targets(&stable, &manifest, 2_000, true).is_empty(),
1130 "no reformat, no back-fill -> no fetch"
1131 );
1132
1133 // A clip with no persisted `.lrc` and a format change is not a target
1134 // (nothing to re-embed).
1135 let mut no_lrc = Manifest::new();
1136 no_lrc.insert(
1137 "a",
1138 entry(
1139 None,
1140 Some(SyncedLyricsCheck {
1141 empty: true,
1142 timed: false,
1143 ..timed_check()
1144 }),
1145 ),
1146 );
1147 let mut reformat_no_lrc = vec![desired("a", "")];
1148 reformat_no_lrc[0].format = AudioFormat::Mp3;
1149 assert!(
1150 synced_lyrics_targets(&reformat_no_lrc, &no_lrc, 2_000, true).is_empty(),
1151 "no `.lrc` to re-embed -> not a target despite the reformat"
1152 );
1153 }
1154
1155 // ---- F1 (#357): the deferred `.lyrics.txt` and the lyrics-only lifecycle ----
1156
1157 #[test]
1158 fn apply_fills_lyrics_txt_from_aligned_when_clip_lyrics_empty() {
1159 // A real-feed lyrics-only clip: `clip.lyrics` is empty, so the deferred
1160 // `.lyrics.txt` body comes from Suno's fetched aligned plain text (the F1
1161 // fix for the previously-dead sidecar). The placeholder is replaced with
1162 // the resolved body and its content hash.
1163 let mut d = vec![desired_lyrics_only("a", "")];
1164 let mut successes = HashMap::new();
1165 successes.insert("a".to_string(), one_line_alignment());
1166 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
1167
1168 let art = d[0]
1169 .artifacts
1170 .iter()
1171 .find(|a| a.kind == ArtifactKind::LyricsTxt)
1172 .expect("the `.lyrics.txt` survives a lyric fetch");
1173 assert_eq!(art.content.as_deref(), Some("hi there\n"));
1174 assert_eq!(art.hash, content_hash("hi there\n"));
1175 assert_eq!(
1176 pending,
1177 vec![PendingCheck {
1178 clip_id: "a".to_string(),
1179 empty: false,
1180 timed: true,
1181 written_slots: vec![(ArtifactKind::LyricsTxt, content_hash("hi there\n"))],
1182 }]
1183 );
1184 }
1185
1186 #[test]
1187 fn apply_prefers_clip_lyrics_over_aligned_for_lyrics_txt() {
1188 // When the feed DOES carry `clip.lyrics`, they win over the aligned plain
1189 // text, matching the historical `.lyrics.txt` body (back-compat).
1190 let mut d = vec![desired_lyrics_only("a", "my own words")];
1191 let mut successes = HashMap::new();
1192 successes.insert("a".to_string(), one_line_alignment()); // "hi there"
1193 apply_synced_lrc(&mut d, &Manifest::new(), &successes);
1194
1195 let art = d[0]
1196 .artifacts
1197 .iter()
1198 .find(|a| a.kind == ArtifactKind::LyricsTxt)
1199 .unwrap();
1200 assert_eq!(art.content.as_deref(), Some("my own words\n"));
1201 }
1202
1203 #[test]
1204 fn apply_drops_lyrics_txt_for_instrumental() {
1205 // Empty `clip.lyrics` AND empty alignment -> a genuine instrumental: the
1206 // `.lyrics.txt` is dropped (no empty file) and an empty marker recorded.
1207 let mut d = vec![desired_lyrics_only("a", "")];
1208 let mut successes = HashMap::new();
1209 successes.insert("a".to_string(), AlignedLyrics::default());
1210 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
1211
1212 assert!(
1213 d[0].artifacts
1214 .iter()
1215 .all(|a| a.kind != ArtifactKind::LyricsTxt),
1216 "no `.lyrics.txt` written for an instrumental"
1217 );
1218 assert_eq!(
1219 pending,
1220 vec![PendingCheck {
1221 clip_id: "a".to_string(),
1222 empty: true,
1223 timed: false,
1224 written_slots: vec![],
1225 }]
1226 );
1227 }
1228
1229 #[test]
1230 fn apply_keeps_lyrics_txt_on_failed_fetch() {
1231 // A lyrics-only clip with an existing `.lyrics.txt` slot whose fetch
1232 // failed (absent from `successes`): the artifact resets to the stored slot
1233 // hash with no content, so reconcile skips it (the good file is kept), and
1234 // no marker is recorded, so it retries next run.
1235 let mut d = vec![desired_lyrics_only("a", "")];
1236 let mut manifest = Manifest::new();
1237 let mut e = entry(None, Some(timed_check()));
1238 e.lyrics_txt = Some(ArtifactState {
1239 path: "a.lyrics.txt".to_string(),
1240 hash: "stored".to_string(),
1241 });
1242 manifest.insert("a", e);
1243 let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
1244
1245 let art = d[0]
1246 .artifacts
1247 .iter()
1248 .find(|a| a.kind == ArtifactKind::LyricsTxt)
1249 .unwrap();
1250 assert_eq!(art.hash, "stored", "reset to the stored slot -> skipped");
1251 assert_eq!(art.content, None);
1252 assert!(pending.is_empty(), "no marker on failure -> retried");
1253 }
1254
1255 #[test]
1256 fn lyrics_txt_body_has_exactly_one_trailing_newline() {
1257 // Both body sources normalise to exactly one trailing newline (matching
1258 // the `render_clip_lyrics` convention): `clip.lyrics` via
1259 // `render_clip_lyrics`, and the aligned plain text via the explicit append.
1260 for lyrics in ["from the feed", ""] {
1261 let mut d = vec![desired_lyrics_only("a", lyrics)];
1262 let mut successes = HashMap::new();
1263 successes.insert("a".to_string(), one_line_alignment());
1264 apply_synced_lrc(&mut d, &Manifest::new(), &successes);
1265 let body = d[0]
1266 .artifacts
1267 .iter()
1268 .find(|a| a.kind == ArtifactKind::LyricsTxt)
1269 .unwrap()
1270 .content
1271 .clone()
1272 .unwrap();
1273 assert!(body.ends_with('\n'), "one trailing newline: {body:?}");
1274 assert!(!body.ends_with("\n\n"), "not two: {body:?}");
1275 }
1276 }
1277
1278 #[test]
1279 fn lyrics_only_clip_is_a_fetch_target() {
1280 // A lyrics-only clip (only a deferred `.lyrics.txt` desired, no `.lrc`) is
1281 // a first-sight alignment-fetch target, so its body can be resolved.
1282 let d = vec![desired_lyrics_only("a", "")];
1283 assert!(
1284 synced_lyrics_targets(&d, &Manifest::new(), 1_000, true).contains("a"),
1285 "a fresh lyrics-only clip is fetched"
1286 );
1287 }
1288
1289 #[test]
1290 fn lyrics_only_clip_is_a_stable_fetch_target_after_first_run() {
1291 // Convergence (F1): a lyrics-only clip is fetched once, then its
1292 // rename-drift check anchors on the `.lyrics.txt` slot, so it is NOT a
1293 // target on the next run. This is the fix for the old `unwrap_or(true)`
1294 // that re-fetched a lyrics-only clip on every run forever.
1295 let d = vec![desired_lyrics_only("a", "")];
1296
1297 // First run: unseen clip -> a target.
1298 assert!(
1299 synced_lyrics_targets(&d, &Manifest::new(), 1_000, true).contains("a"),
1300 "fetched once"
1301 );
1302
1303 // After the fetch resolved and the marker + `.lyrics.txt` slot landed:
1304 let mut manifest = Manifest::new();
1305 let mut e = entry(None, Some(timed_check()));
1306 e.lyrics_txt = Some(ArtifactState {
1307 path: "a.lyrics.txt".to_string(),
1308 hash: "body-hash".to_string(),
1309 });
1310 manifest.insert("a", e);
1311 assert!(
1312 synced_lyrics_targets(&d, &manifest, 2_000, true).is_empty(),
1313 "a resolved lyrics-only clip converges (no forever re-fetch)"
1314 );
1315
1316 // But a rename still moves it: the drifted path re-fetches.
1317 let mut renamed = d.clone();
1318 renamed[0].artifacts[0].path = "new/a.lyrics.txt".to_string();
1319 assert!(
1320 synced_lyrics_targets(&renamed, &manifest, 2_000, true).contains("a"),
1321 "a rename re-fetches so the sidecar moves with the audio"
1322 );
1323 }
1324
1325 #[test]
1326 fn lyrics_only_marker_anchors_on_lyrics_txt_slot() {
1327 // A lyrics-only clip records its single marker listing the `.lyrics.txt`
1328 // slot it wrote, so durability tracks the file actually written.
1329 let mut d = vec![desired_lyrics_only("a", "")];
1330 let mut successes = HashMap::new();
1331 successes.insert("a".to_string(), one_line_alignment());
1332 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
1333 assert_eq!(
1334 pending[0].written_slots,
1335 vec![(ArtifactKind::LyricsTxt, content_hash("hi there\n"))]
1336 );
1337 }
1338
1339 #[test]
1340 fn both_slots_recorded_in_the_single_pending_check_when_both_desired() {
1341 // A clip with BOTH sidecars desired records exactly ONE marker whose
1342 // `written_slots` lists BOTH the `.lrc` and the `.lyrics.txt` (each body
1343 // resolved from the same fetched alignment). The marker is durable only
1344 // once every listed slot has landed, so back-filling one never masks an
1345 // unwritten other.
1346 let mut d = vec![desired_both("a", "")];
1347 let mut successes = HashMap::new();
1348 successes.insert("a".to_string(), one_line_alignment());
1349 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
1350
1351 assert_eq!(pending.len(), 1, "one marker per clip");
1352 let lrc = &d[0]
1353 .artifacts
1354 .iter()
1355 .find(|a| a.kind == ArtifactKind::Lrc)
1356 .expect("the `.lrc` body is resolved");
1357 assert!(lrc.content.is_some(), "the `.lrc` body is resolved");
1358 assert_eq!(
1359 pending[0].written_slots,
1360 vec![
1361 (ArtifactKind::Lrc, lrc.hash.clone()),
1362 (ArtifactKind::LyricsTxt, content_hash("hi there\n")),
1363 ],
1364 "both slots recorded, `.lrc` first"
1365 );
1366 assert!(
1367 d[0].artifacts
1368 .iter()
1369 .any(|a| a.kind == ArtifactKind::LyricsTxt && a.content.is_some()),
1370 "the `.lyrics.txt` body is resolved too"
1371 );
1372 }
1373
1374 #[test]
1375 fn preview_marks_lyrics_txt_pending() {
1376 // Preview mirrors the `.lrc`: a lyrics-only target keeps the placeholder
1377 // source hash (previews as a write); a resolved one reuses its stored slot
1378 // hash (previews as skipped).
1379 let mut d = vec![
1380 desired_lyrics_only("new", ""),
1381 desired_lyrics_only("done", ""),
1382 ];
1383 let mut manifest = Manifest::new();
1384 let mut done = entry(None, Some(timed_check()));
1385 done.lyrics_txt = Some(ArtifactState {
1386 path: "done.lyrics.txt".to_string(),
1387 hash: "slot-hash".to_string(),
1388 });
1389 manifest.insert("done", done);
1390
1391 preview_synced_lrc(&mut d, &manifest, 2_000, true);
1392 let new_art = d[0]
1393 .artifacts
1394 .iter()
1395 .find(|a| a.kind == ArtifactKind::LyricsTxt)
1396 .unwrap();
1397 let done_art = d[1]
1398 .artifacts
1399 .iter()
1400 .find(|a| a.kind == ArtifactKind::LyricsTxt)
1401 .unwrap();
1402 assert_eq!(new_art.hash, lyrics_txt_source_hash("new"));
1403 assert_eq!(done_art.hash, "slot-hash");
1404 }
1405
1406 // ---- F1 blocker (#357 review): back-fill a `.lyrics.txt` for a clip whose
1407 // `.lrc` has already converged, and never mark resolved on a partial write ----
1408
1409 #[test]
1410 fn both_sidecars_lrc_already_resolved_backfills_lyrics_txt() {
1411 // The blocker: a clip whose `.lrc` has FULLY converged (timed marker at
1412 // the current version, matching path, embed in sync) but whose
1413 // `.lyrics.txt` was never written (lyrics_sidecar newly enabled) MUST
1414 // still be a fetch target so the `.lyrics.txt` back-fills, INDEPENDENT of
1415 // the converged `.lrc`. Then a second run converges to no re-fetch.
1416 let mut manifest = Manifest::new();
1417 let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
1418 e.embedded_lyrics_hash = "H".to_string(); // the `.lrc` is fully back-filled
1419 // ...but there is no `.lyrics.txt` slot yet.
1420 manifest.insert("a", e);
1421
1422 let d = vec![desired_both("a", "")];
1423
1424 // 1) The clip IS a target despite the converged `.lrc`.
1425 assert!(
1426 synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"),
1427 "an unresolved `.lyrics.txt` re-targets even when the `.lrc` converged"
1428 );
1429
1430 // 2) The fetch back-fills the `.lyrics.txt` (from the aligned plain text).
1431 let mut d2 = d.clone();
1432 let mut successes = HashMap::new();
1433 successes.insert("a".to_string(), one_line_alignment());
1434 let pending = apply_synced_lrc(&mut d2, &manifest, &successes);
1435 let txt = d2[0]
1436 .artifacts
1437 .iter()
1438 .find(|a| a.kind == ArtifactKind::LyricsTxt)
1439 .expect("the `.lyrics.txt` is back-filled");
1440 assert_eq!(txt.content.as_deref(), Some("hi there\n"));
1441 assert_eq!(pending.len(), 1, "one marker");
1442 assert!(
1443 pending[0]
1444 .written_slots
1445 .iter()
1446 .any(|(k, _)| *k == ArtifactKind::LyricsTxt),
1447 "the marker lists the back-filled `.lyrics.txt` slot"
1448 );
1449
1450 // 3) Once the `.lyrics.txt` slot has landed, the clip converges: BOTH
1451 // slots are resolved, so no forever re-fetch.
1452 let mut converged = Manifest::new();
1453 let mut e2 = entry(slot("a.lrc", "H"), Some(timed_check()));
1454 e2.embedded_lyrics_hash = "H".to_string();
1455 e2.lyrics_txt = Some(ArtifactState {
1456 path: "a.lyrics.txt".to_string(),
1457 hash: content_hash("hi there\n"),
1458 });
1459 converged.insert("a", e2);
1460 assert!(
1461 synced_lyrics_targets(&d, &converged, 3_000, true).is_empty(),
1462 "both slots resolved -> converged (no re-fetch loop)"
1463 );
1464 }
1465
1466 #[test]
1467 fn inline_lyrics_clip_still_gets_lyrics_txt() {
1468 // Regression: the deferred `.lyrics.txt` must still be produced for a clip
1469 // whose feed carries inline `clip.lyrics` (the old eager emit wrote it
1470 // directly; the deferred model resolves it via the fetch path). A fresh
1471 // clip with both sidecars is a target, and the fetch writes the
1472 // `.lyrics.txt` from the inline lyrics.
1473 let d0 = desired_both("a", "hello world");
1474 assert!(
1475 synced_lyrics_targets(std::slice::from_ref(&d0), &Manifest::new(), 1_000, true)
1476 .contains("a"),
1477 "a fresh clip with both sidecars is a fetch target"
1478 );
1479
1480 let mut d = vec![d0];
1481 let mut successes = HashMap::new();
1482 successes.insert("a".to_string(), one_line_alignment());
1483 apply_synced_lrc(&mut d, &Manifest::new(), &successes);
1484 let txt = d[0]
1485 .artifacts
1486 .iter()
1487 .find(|a| a.kind == ArtifactKind::LyricsTxt)
1488 .expect("the `.lyrics.txt` is written");
1489 assert_eq!(
1490 txt.content.as_deref(),
1491 Some("hello world\n"),
1492 "inline `clip.lyrics` win over the aligned text"
1493 );
1494 }
1495
1496 #[test]
1497 fn lyrics_txt_marker_lists_both_slots_so_a_partial_write_is_retried() {
1498 // Marker durability across both slots (the secondary hole): when a fetch
1499 // writes BOTH sidecars, the returned marker lists BOTH, so the caller (see
1500 // `record_synced_lyrics_checks`) only stamps the clip resolved once every
1501 // listed slot has landed. If the `.lrc` write lands but the `.lyrics.txt`
1502 // fails non-fatally, no marker is recorded and the missing `.lyrics.txt`
1503 // slot re-targets next run (proven by the back-fill test above).
1504 let mut d = vec![desired_both("a", "")];
1505 let mut successes = HashMap::new();
1506 successes.insert("a".to_string(), one_line_alignment());
1507 let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
1508
1509 let lrc_hash = d[0]
1510 .artifacts
1511 .iter()
1512 .find(|a| a.kind == ArtifactKind::Lrc)
1513 .unwrap()
1514 .hash
1515 .clone();
1516 assert_eq!(
1517 pending[0].written_slots,
1518 vec![
1519 (ArtifactKind::Lrc, lrc_hash),
1520 (ArtifactKind::LyricsTxt, content_hash("hi there\n")),
1521 ],
1522 "the marker enumerates every written slot for the caller to gate on"
1523 );
1524 }
1525}