Skip to main content

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}