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` artifact.
3//!
4//! The alignment fetch itself is IO and lives in the CLI (through the `Http`
5//! port); everything here is pure so the fetch-gating, the timed/untimed body
6//! choice, the "keep existing on failure" rule, and the instrumental "checked"
7//! marker are unit-tested without a network.
8//!
9//! Suno's forced alignment for a clip is immutable in practice (the audio and
10//! its lyrics are fixed once generated), so a clip is fetched at most once per
11//! render [`SYNCED_LRC_VERSION`] — recorded by [`SyncedLyricsCheck`] on the
12//! manifest — except that a clip that resolved to no lyrics (an instrumental) or
13//! to an untimed plain-text fallback is re-checked after [`SYNCED_LRC_RECHECK_SECS`]
14//! to pick up alignment Suno may compute after generation, and a clip whose audio
15//! is renamed is re-fetched so its `.lrc` moves with it. A version bump
16//! re-resolves everything.
17
18use std::collections::{BTreeSet, HashMap};
19
20use crate::hash::{SYNCED_LRC_VERSION, content_hash, synced_lrc_source_hash};
21use crate::lyrics::{AlignedLyrics, render_clip_lrc, render_synced_lrc};
22use crate::manifest::{Manifest, ManifestEntry};
23use crate::reconcile::Desired;
24use crate::vocab::ArtifactKind;
25
26/// How long a clip that resolved to no lyrics (instrumental) or to an untimed
27/// plain-text fallback is trusted before its alignment is re-checked (14 days).
28/// Bounds the re-fetch to catch alignment Suno may compute after generation.
29pub const SYNCED_LRC_RECHECK_SECS: u64 = 14 * 24 * 60 * 60;
30
31/// One clip's synced-lyrics outcome this run, for the caller to record as a
32/// manifest [`SyncedLyricsCheck`](crate::SyncedLyricsCheck) once the `.lrc` write
33/// (if any) has safely landed.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct PendingCheck {
36    /// The clip this outcome concerns.
37    pub clip_id: String,
38    /// Whether the clip resolved to no lyrics (an instrumental).
39    pub empty: bool,
40    /// Whether the written `.lrc` body carries timed alignment (as opposed to
41    /// an untimed plain-text fallback). Only meaningful when `empty` is false.
42    pub timed: bool,
43    /// The content hash of the rendered `.lrc` body, when one was produced. The
44    /// caller records the marker only once the manifest slot reflects this hash,
45    /// so an interrupted or failed write is re-resolved next run.
46    pub body_hash: Option<String>,
47}
48
49/// The relative `.lrc` path a clip's desired artifact targets, if it has one.
50fn desired_lrc(desired: &Desired) -> Option<&str> {
51    desired
52        .artifacts
53        .iter()
54        .find(|a| a.kind == ArtifactKind::Lrc)
55        .map(|a| a.path.as_str())
56}
57
58/// Whether a clip's alignment must be (re)fetched this run.
59fn needs_fetch(entry: Option<&ManifestEntry>, desired_lrc_path: &str, now_unix: u64) -> bool {
60    let Some(entry) = entry else {
61        return true; // never downloaded -> resolve on first sight
62    };
63    // One-time back-fill (#354): a persisted `.lrc` exists but the audio tag's
64    // embed is missing or stale relative to it. Fetching re-embeds; once the
65    // embed stamps `embedded_lyrics_hash = lrc.hash` this is false again, so the
66    // back-fill is bounded to one run per drift. A clip with no `.lrc` slot (an
67    // instrumental, or the feature off) has both sides empty and is never a
68    // target for this reason.
69    if let Some(slot) = entry.lrc.as_ref()
70        && slot.hash != entry.embedded_lyrics_hash
71    {
72        return true;
73    }
74    match &entry.synced_lyrics {
75        // Never resolved (e.g. a clip downloaded before the feature existed).
76        None => true,
77        Some(check) => {
78            if check.version != SYNCED_LRC_VERSION {
79                return true; // the render changed -> re-resolve and re-render
80            }
81            if check.empty || !check.timed {
82                // Instrumental (no `.lrc`) or untimed fallback: re-check once
83                // the window elapses, to pick up alignment Suno adds later.
84                now_unix.saturating_sub(check.checked_unix) > SYNCED_LRC_RECHECK_SECS
85            } else {
86                // Timed: re-fetch only to move the `.lrc` when the audio is
87                // renamed (its `.lrc` path drifts), or if the slot is somehow
88                // missing (an interrupted prior write).
89                entry
90                    .lrc
91                    .as_ref()
92                    .map(|slot| slot.path != desired_lrc_path)
93                    .unwrap_or(true)
94            }
95        }
96    }
97}
98
99/// The clip ids whose alignment must be fetched this run, in a stable order.
100///
101/// Empty when `enabled` is false, so the synced-lyrics feature being off means
102/// zero alignment fetches. Only clips carrying a desired `.lrc` artifact (a
103/// lyric signal) are considered; each is fetched at most once per render version
104/// (see [`needs_fetch`]).
105pub fn synced_lyrics_targets(
106    desired: &[Desired],
107    manifest: &Manifest,
108    now_unix: u64,
109    enabled: bool,
110) -> BTreeSet<String> {
111    if !enabled {
112        return BTreeSet::new();
113    }
114    let mut out = BTreeSet::new();
115    for d in desired {
116        let Some(path) = desired_lrc(d) else {
117            continue;
118        };
119        let entry = manifest.get(&d.clip.id);
120        // Reformat re-embed (#354): a format change re-encodes the audio and
121        // drops any embedded lyrics, so re-fetch when the format will change AND
122        // a persisted `.lrc` is worth re-embedding (an already-migrated clip is
123        // neither a back-fill nor a rename target, so nothing else would refetch
124        // it). Pure and bounded: after the Reformat commits `entry.format ==
125        // d.format`, so it fires once. A clip with no `.lrc` never fetches here.
126        let reformat_reembed = entry.is_some_and(|e| e.format != d.format && e.lrc.is_some());
127        if reformat_reembed || needs_fetch(entry, path, now_unix) {
128            out.insert(d.clip.id.clone());
129        }
130    }
131    out
132}
133
134/// Resolve each clip's desired `.lrc` artifact from the fetched alignment,
135/// returning the checks to persist for the clips that were successfully fetched.
136///
137/// `successes` holds the alignment for clips whose fetch returned `200` (an empty
138/// value for an instrumental); a clip absent from it either was not fetched
139/// (resolved recently) or its fetch FAILED. In both of those cases the existing
140/// `.lrc` is KEPT untouched — the artifact's hash is reset to the stored slot so
141/// reconcile skips it (no rewrite, no downgrade of a timed file to untimed), or
142/// the artifact is dropped when there is nothing on disk yet — and no check is
143/// returned, so a failed fetch is simply retried next run.
144///
145/// For a successful fetch the body is the timed render when Suno has alignment,
146/// else the untimed lyrics as a fallback; an instrumental (no body) drops the
147/// artifact and records an empty check. A produced body sets the artifact's
148/// content and its content hash, so reconcile rewrites only when the body
149/// actually changes (including an untimed→timed upgrade after a re-check). The
150/// `timed` flag in the returned check distinguishes timed alignment from an
151/// untimed fallback: only timed clips are exempt from the periodic re-check.
152pub fn apply_synced_lrc(
153    desired: &mut [Desired],
154    manifest: &Manifest,
155    successes: &HashMap<String, AlignedLyrics>,
156) -> Vec<PendingCheck> {
157    let mut pending = Vec::new();
158    for d in desired.iter_mut() {
159        // Carry-forward baseline (#354, the loop-freedom crux): every clip keeps
160        // its persisted embed fingerprint unless it is actually fetched this run
161        // (the overrides below). So a lyrics-driven `Retag` can only fire when
162        // alignment was fetched, never stamping a matching hash over an empty
163        // write. This assignment MUST stay ABOVE the `.lrc`-artifact `continue`:
164        // a clip with no desired `.lrc` (the sidecar off, or an instrumental)
165        // must still carry its value forward, otherwise its sentinel would drift
166        // to the default and it would spuriously retag with an empty `synced` map.
167        d.embedded_lyrics_hash = manifest
168            .get(&d.clip.id)
169            .map(|e| e.embedded_lyrics_hash.clone())
170            .unwrap_or_default();
171
172        let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
173            continue;
174        };
175        let clip_id = d.clip.id.clone();
176        let slot_hash = manifest
177            .get(&clip_id)
178            .and_then(|e| e.lrc.as_ref())
179            .map(|slot| slot.hash.clone());
180
181        if let Some(aligned) = successes.get(&clip_id) {
182            let timed = !aligned.is_empty();
183            let body = if timed {
184                render_synced_lrc(&d.clip, &d.lineage, aligned)
185            } else {
186                render_clip_lrc(&d.clip, &d.lineage)
187            };
188            match body {
189                Some(text) => {
190                    let hash = content_hash(&text);
191                    // The embed is rendered from this same fetched alignment, so
192                    // its fingerprint moves in lock-step with the `.lrc` body.
193                    d.embedded_lyrics_hash = hash.clone();
194                    let artifact = &mut d.artifacts[idx];
195                    artifact.hash = hash.clone();
196                    artifact.content = Some(text);
197                    pending.push(PendingCheck {
198                        clip_id,
199                        empty: false,
200                        timed,
201                        body_hash: Some(hash),
202                    });
203                }
204                None => {
205                    // Fetched but the clip is an instrumental: nothing embedded.
206                    d.embedded_lyrics_hash = String::new();
207                    d.artifacts.remove(idx);
208                    pending.push(PendingCheck {
209                        clip_id,
210                        empty: true,
211                        timed: false,
212                        body_hash: None,
213                    });
214                }
215            }
216        } else {
217            // Not fetched this run (resolved recently) or the fetch failed: keep
218            // whatever is already on disk. Reuse the stored slot hash so reconcile
219            // skips the write; drop the artifact when nothing was ever written.
220            // `embedded_lyrics_hash` keeps its carry-forward baseline, so a failed
221            // back-fill neither retags nor stamps and is simply retried next run.
222            match slot_hash {
223                Some(hash) => {
224                    let artifact = &mut d.artifacts[idx];
225                    artifact.hash = hash;
226                    artifact.content = None;
227                }
228                None => {
229                    d.artifacts.remove(idx);
230                }
231            }
232        }
233    }
234    pending
235}
236
237/// Adjust each clip's desired `.lrc` artifact for a dry run, without any fetch.
238///
239/// A clip that WOULD be fetched (a target) keeps a distinct pending hash so the
240/// previewed plan reports its `.lrc` write; a clip already resolved reuses its
241/// stored slot hash (so it shows as skipped) or drops the artifact when it is a
242/// known instrumental. The preview is therefore an upper bound on synced `.lrc`
243/// writes (it cannot know which targets will turn out to be instrumentals).
244pub fn preview_synced_lrc(
245    desired: &mut [Desired],
246    manifest: &Manifest,
247    now_unix: u64,
248    enabled: bool,
249) {
250    let targets = synced_lyrics_targets(desired, manifest, now_unix, enabled);
251    for d in desired.iter_mut() {
252        // Carry-forward baseline (#354): mirror `apply_synced_lrc`. This MUST stay
253        // ABOVE the `.lrc`-artifact `continue` so a non-target (and any clip with
254        // no desired `.lrc`: the sidecar off, or an instrumental) keeps its
255        // persisted embed fingerprint and previews as skipped, rather than
256        // drifting to the default and reporting a spurious retag.
257        d.embedded_lyrics_hash = manifest
258            .get(&d.clip.id)
259            .map(|e| e.embedded_lyrics_hash.clone())
260            .unwrap_or_default();
261
262        let Some(idx) = d.artifacts.iter().position(|a| a.kind == ArtifactKind::Lrc) else {
263            continue;
264        };
265        if targets.contains(&d.clip.id) {
266            // A fetch target previews the expected post-fetch embed: the persisted
267            // `.lrc` slot hash, so a pending back-fill reports drift (not "up to
268            // date"). It converges to the apply value once alignment is fetched.
269            d.embedded_lyrics_hash = manifest
270                .get(&d.clip.id)
271                .and_then(|e| e.lrc.as_ref())
272                .map(|s| s.hash.clone())
273                .unwrap_or_default();
274            d.artifacts[idx].hash = synced_lrc_source_hash(&d.clip.id);
275            continue;
276        }
277        match manifest.get(&d.clip.id).and_then(|e| e.lrc.as_ref()) {
278            Some(slot) => d.artifacts[idx].hash = slot.hash.clone(),
279            None => {
280                d.artifacts.remove(idx);
281            }
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::lineage::LineageContext;
290    use crate::lyrics::{AlignedLine, AlignedLineWord};
291    use crate::manifest::{ArtifactState, SyncedLyricsCheck};
292    use crate::model::Clip;
293    use crate::reconcile::DesiredArtifact;
294    use crate::vocab::AudioFormat;
295
296    fn clip(id: &str, lyrics: &str) -> Clip {
297        Clip {
298            id: id.to_string(),
299            title: "Song".to_string(),
300            lyrics: lyrics.to_string(),
301            prompt: "a prompt".to_string(),
302            ..Default::default()
303        }
304    }
305
306    fn lrc_artifact(clip_id: &str) -> DesiredArtifact {
307        DesiredArtifact {
308            kind: ArtifactKind::Lrc,
309            path: format!("{clip_id}.lrc"),
310            source_url: String::new(),
311            hash: synced_lrc_source_hash(clip_id),
312            content: None,
313        }
314    }
315
316    fn desired(id: &str, lyrics: &str) -> Desired {
317        let c = clip(id, lyrics);
318        Desired {
319            lineage: LineageContext::own_root(&c),
320            path: format!("{id}.flac"),
321            format: AudioFormat::Flac,
322            meta_hash: "m".to_string(),
323            art_hash: "a".to_string(),
324            embedded_lyrics_hash: String::new(),
325            modes: vec![crate::vocab::SourceMode::Mirror],
326            trashed: false,
327            private: false,
328            artifacts: vec![lrc_artifact(id)],
329            clip: c,
330            stems: None,
331        }
332    }
333
334    fn one_line_alignment() -> AlignedLyrics {
335        AlignedLyrics {
336            lines: vec![AlignedLine {
337                text: "hi there".to_owned(),
338                start_s: 0.5,
339                end_s: 1.2,
340                section: "Verse 1".to_owned(),
341                words: vec![
342                    AlignedLineWord {
343                        text: "hi".to_owned(),
344                        start_s: 0.5,
345                        end_s: 0.8,
346                    },
347                    AlignedLineWord {
348                        text: "there".to_owned(),
349                        start_s: 0.9,
350                        end_s: 1.2,
351                    },
352                ],
353            }],
354            ..Default::default()
355        }
356    }
357
358    fn entry(lrc: Option<ArtifactState>, check: Option<SyncedLyricsCheck>) -> ManifestEntry {
359        // Default to a fully-migrated clip: the embed fingerprint matches the
360        // `.lrc` slot hash, so an ordinarily-resolved clip is NOT a #354 back-fill
361        // target. Back-fill/instrumental tests override `embedded_lyrics_hash`.
362        let embedded_lyrics_hash = lrc.as_ref().map(|s| s.hash.clone()).unwrap_or_default();
363        ManifestEntry {
364            path: "song.flac".to_string(),
365            format: AudioFormat::Flac,
366            lrc,
367            embedded_lyrics_hash,
368            synced_lyrics: check,
369            ..Default::default()
370        }
371    }
372
373    #[test]
374    fn targets_empty_when_feature_off() {
375        let d = vec![desired("a", "")];
376        let manifest = Manifest::new();
377        assert!(synced_lyrics_targets(&d, &manifest, 0, false).is_empty());
378    }
379
380    #[test]
381    fn targets_new_clip_but_not_a_recently_resolved_one() {
382        let d = vec![desired("new", ""), desired("done", "")];
383        let mut manifest = Manifest::new();
384        // `done` was timed-resolved at the current version; `new` is unseen.
385        manifest.insert(
386            "done",
387            entry(
388                Some(ArtifactState {
389                    path: "done.lrc".to_string(),
390                    hash: "h".to_string(),
391                }),
392                Some(SyncedLyricsCheck {
393                    version: SYNCED_LRC_VERSION,
394                    checked_unix: 1_000,
395                    empty: false,
396                    timed: true,
397                }),
398            ),
399        );
400        let targets = synced_lyrics_targets(&d, &manifest, 2_000, true);
401        assert!(targets.contains("new"));
402        assert!(!targets.contains("done"));
403    }
404
405    #[test]
406    fn instrumental_is_rechecked_only_after_the_window() {
407        let d = vec![desired("instr", "")];
408        let mut manifest = Manifest::new();
409        manifest.insert(
410            "instr",
411            entry(
412                None,
413                Some(SyncedLyricsCheck {
414                    version: SYNCED_LRC_VERSION,
415                    checked_unix: 1_000,
416                    empty: true,
417                    timed: false,
418                }),
419            ),
420        );
421        // Within the window: not re-fetched (this is the fix for forever-refetch).
422        let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
423        assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
424        // Past the window: re-checked, to pick up late alignment.
425        let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
426        assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("instr"));
427    }
428
429    #[test]
430    fn untimed_fallback_is_rechecked_after_the_window() {
431        // A clip that previously resolved to an untimed fallback (empty alignment
432        // but non-empty lyrics) must be re-checked after the window so a later
433        // Suno alignment upgrades it to a timed `.lrc`.
434        let d = vec![desired("a", "some lyrics")];
435        let mut manifest = Manifest::new();
436        manifest.insert(
437            "a",
438            entry(
439                Some(ArtifactState {
440                    path: "a.lrc".to_string(),
441                    hash: "untimed-hash".to_string(),
442                }),
443                Some(SyncedLyricsCheck {
444                    version: SYNCED_LRC_VERSION,
445                    checked_unix: 1_000,
446                    empty: false,
447                    timed: false,
448                }),
449            ),
450        );
451        // Within the window: no re-fetch (avoids churn on every run).
452        let soon = 1_000 + SYNCED_LRC_RECHECK_SECS;
453        assert!(synced_lyrics_targets(&d, &manifest, soon, true).is_empty());
454        // Past the window: re-checked, to upgrade to timed if alignment arrived.
455        let later = 1_001 + SYNCED_LRC_RECHECK_SECS;
456        assert!(synced_lyrics_targets(&d, &manifest, later, true).contains("a"));
457    }
458
459    #[test]
460    fn timed_clip_is_not_rechecked_without_rename() {
461        // A timed clip must not be re-fetched just because the window elapsed;
462        // only a rename (path drift) or missing slot should trigger a re-fetch.
463        let d = vec![desired("a", "")];
464        let mut manifest = Manifest::new();
465        manifest.insert(
466            "a",
467            entry(
468                Some(ArtifactState {
469                    path: "a.lrc".to_string(),
470                    hash: "h".to_string(),
471                }),
472                Some(SyncedLyricsCheck {
473                    version: SYNCED_LRC_VERSION,
474                    checked_unix: 0, // maximally stale
475                    empty: false,
476                    timed: true,
477                }),
478            ),
479        );
480        // Even long after the window: still not re-fetched.
481        let very_late = 2 * SYNCED_LRC_RECHECK_SECS;
482        assert!(synced_lyrics_targets(&d, &manifest, very_late, true).is_empty());
483    }
484
485    #[test]
486    fn version_bump_refetches_everything() {
487        let d = vec![desired("done", "")];
488        let mut manifest = Manifest::new();
489        manifest.insert(
490            "done",
491            entry(
492                Some(ArtifactState {
493                    path: "done.lrc".to_string(),
494                    hash: "h".to_string(),
495                }),
496                Some(SyncedLyricsCheck {
497                    version: SYNCED_LRC_VERSION + 1, // resolved at a different version
498                    checked_unix: 1_000,
499                    empty: false,
500                    timed: true,
501                }),
502            ),
503        );
504        assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("done"));
505    }
506
507    #[test]
508    fn rename_refetches_a_written_clip() {
509        let mut d = vec![desired("a", "")];
510        // The audio (and so the `.lrc`) moved to a new path.
511        d[0].artifacts[0].path = "new/a.lrc".to_string();
512        let mut manifest = Manifest::new();
513        manifest.insert(
514            "a",
515            entry(
516                Some(ArtifactState {
517                    path: "old/a.lrc".to_string(),
518                    hash: "h".to_string(),
519                }),
520                Some(SyncedLyricsCheck {
521                    version: SYNCED_LRC_VERSION,
522                    checked_unix: 1_000,
523                    empty: false,
524                    timed: true,
525                }),
526            ),
527        );
528        assert!(synced_lyrics_targets(&d, &manifest, 2_000, true).contains("a"));
529    }
530
531    #[test]
532    fn apply_sets_timed_body_and_content_hash() {
533        let mut d = vec![desired("a", "")];
534        let mut successes = HashMap::new();
535        successes.insert("a".to_string(), one_line_alignment());
536        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
537
538        let art = &d[0].artifacts[0];
539        let body = art.content.as_deref().unwrap();
540        assert!(body.contains("[00:00.50]hi there"));
541        assert_eq!(art.hash, content_hash(body));
542        assert_eq!(
543            pending,
544            vec![PendingCheck {
545                clip_id: "a".to_string(),
546                empty: false,
547                timed: true,
548                body_hash: Some(content_hash(body)),
549            }]
550        );
551    }
552
553    #[test]
554    fn apply_untimed_fallback_marks_not_timed() {
555        // When Suno returns empty alignment but the clip has lyrics, the untimed
556        // plain-text fallback is written but `timed` is false so the check is
557        // subject to the periodic re-check window.
558        let mut d = vec![desired("a", "some lyrics")];
559        let mut successes = HashMap::new();
560        successes.insert("a".to_string(), AlignedLyrics::default());
561        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
562
563        let art = &d[0].artifacts[0];
564        assert!(art.content.is_some(), "untimed body written");
565        let check = &pending[0];
566        assert!(!check.empty, "clip has lyrics, not an instrumental");
567        assert!(!check.timed, "alignment was empty -> untimed fallback");
568    }
569
570    #[test]
571    fn apply_drops_instrumental_and_marks_empty() {
572        let mut d = vec![desired("instr", "")];
573        let mut successes = HashMap::new();
574        successes.insert("instr".to_string(), AlignedLyrics::default());
575        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &successes);
576
577        assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
578        assert_eq!(
579            pending,
580            vec![PendingCheck {
581                clip_id: "instr".to_string(),
582                empty: true,
583                timed: false,
584                body_hash: None,
585            }]
586        );
587    }
588
589    #[test]
590    fn apply_keeps_existing_on_fetch_failure_no_downgrade() {
591        // The clip has an existing timed `.lrc` (slot present) but its fetch
592        // failed this run (absent from successes). The artifact is reset to the
593        // stored slot hash with no content, so reconcile skips it — the good
594        // timed file is neither rewritten nor downgraded — and no check is
595        // recorded, so it is retried next run.
596        let mut d = vec![desired("a", "")];
597        let mut manifest = Manifest::new();
598        manifest.insert(
599            "a",
600            entry(
601                Some(ArtifactState {
602                    path: "a.lrc".to_string(),
603                    hash: "timed-hash".to_string(),
604                }),
605                Some(SyncedLyricsCheck {
606                    version: SYNCED_LRC_VERSION,
607                    checked_unix: 1_000,
608                    empty: false,
609                    timed: true,
610                }),
611            ),
612        );
613        let pending = apply_synced_lrc(&mut d, &manifest, &HashMap::new());
614
615        let art = &d[0].artifacts[0];
616        assert_eq!(art.hash, "timed-hash");
617        assert_eq!(art.content, None);
618        assert!(
619            pending.is_empty(),
620            "no check recorded on failure -> retried"
621        );
622    }
623
624    #[test]
625    fn apply_drops_write_on_failure_when_nothing_on_disk() {
626        // A brand-new clip whose fetch failed: no slot to keep, so the write is
627        // dropped (retried next run) rather than written empty.
628        let mut d = vec![desired("a", "")];
629        let pending = apply_synced_lrc(&mut d, &Manifest::new(), &HashMap::new());
630        assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
631        assert!(pending.is_empty());
632    }
633
634    #[test]
635    fn apply_upgrades_untimed_to_timed_when_alignment_appears() {
636        // The clip previously resolved to an untimed fallback (empty alignment,
637        // body written, timed: false). A re-check now returns alignment, so the
638        // timed body's content hash differs and reconcile will rewrite.
639        let mut d = vec![desired("a", "some lyrics")];
640        let untimed_hash = "untimed".to_string();
641        let mut manifest = Manifest::new();
642        manifest.insert(
643            "a",
644            entry(
645                Some(ArtifactState {
646                    path: "a.lrc".to_string(),
647                    hash: untimed_hash.clone(),
648                }),
649                Some(SyncedLyricsCheck {
650                    version: SYNCED_LRC_VERSION,
651                    checked_unix: 1_000,
652                    empty: false,
653                    timed: false,
654                }),
655            ),
656        );
657        let mut successes = HashMap::new();
658        successes.insert("a".to_string(), one_line_alignment());
659        let pending = apply_synced_lrc(&mut d, &manifest, &successes);
660        let art = &d[0].artifacts[0];
661        assert!(
662            art.content
663                .as_deref()
664                .unwrap()
665                .contains("[00:00.50]hi there")
666        );
667        assert_ne!(art.hash, untimed_hash, "a changed body triggers a rewrite");
668        assert!(pending[0].timed, "upgraded to timed");
669    }
670
671    #[test]
672    fn preview_shows_write_for_targets_and_skips_resolved() {
673        let mut d = vec![desired("new", ""), desired("done", "")];
674        let mut manifest = Manifest::new();
675        manifest.insert(
676            "done",
677            entry(
678                Some(ArtifactState {
679                    path: "done.lrc".to_string(),
680                    hash: "slot-hash".to_string(),
681                }),
682                Some(SyncedLyricsCheck {
683                    version: SYNCED_LRC_VERSION,
684                    checked_unix: 1_000,
685                    empty: false,
686                    timed: true,
687                }),
688            ),
689        );
690        preview_synced_lrc(&mut d, &manifest, 2_000, true);
691        // `new` keeps a pending hash (would write); `done` reuses its slot hash.
692        assert_eq!(d[0].artifacts[0].hash, synced_lrc_source_hash("new"));
693        assert_eq!(d[1].artifacts[0].hash, "slot-hash");
694    }
695
696    // ---- #354: embedded aligned-lyrics back-fill (embedded_lyrics_hash) ----
697
698    /// A timed, resolved marker at the current version (the stable-clip baseline).
699    fn timed_check() -> SyncedLyricsCheck {
700        SyncedLyricsCheck {
701            version: SYNCED_LRC_VERSION,
702            checked_unix: 1_000,
703            empty: false,
704            timed: true,
705        }
706    }
707
708    fn slot(path: &str, hash: &str) -> Option<ArtifactState> {
709        Some(ArtifactState {
710            path: path.to_string(),
711            hash: hash.to_string(),
712        })
713    }
714
715    #[test]
716    fn needs_fetch_backfills_when_embed_missing() {
717        // A timed, resolved clip whose `.lrc` slot has a hash but whose persisted
718        // embed is empty is a one-time back-fill target.
719        let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
720        e.embedded_lyrics_hash = String::new();
721        assert!(needs_fetch(Some(&e), "a.lrc", 2_000));
722    }
723
724    #[test]
725    fn needs_fetch_skips_when_embed_matches_lrc() {
726        // Already back-filled: the embed matches the `.lrc` slot, so no re-fetch.
727        let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
728        e.embedded_lyrics_hash = "H".to_string();
729        assert!(!needs_fetch(Some(&e), "a.lrc", 2_000));
730    }
731
732    #[test]
733    fn needs_fetch_no_backfill_without_lrc_slot() {
734        // Instrumental: no `.lrc` slot and an empty embed, so the back-fill clause
735        // is skipped (both sides empty) and, within the re-check window, the
736        // instrumental clause is false too.
737        let mut e = entry(
738            None,
739            Some(SyncedLyricsCheck {
740                empty: true,
741                timed: false,
742                ..timed_check()
743            }),
744        );
745        e.embedded_lyrics_hash = String::new();
746        let within_window = 1_000 + SYNCED_LRC_RECHECK_SECS;
747        assert!(!needs_fetch(Some(&e), "a.lrc", within_window));
748    }
749
750    #[test]
751    fn apply_sets_embedded_lyrics_hash_from_body() {
752        // A timed fetch stamps the sentinel with the `.lrc` body content hash,
753        // equal to the resolved `.lrc` artifact hash (lock-step with the embed).
754        let mut d = vec![desired("a", "")];
755        let mut successes = HashMap::new();
756        successes.insert("a".to_string(), one_line_alignment());
757        apply_synced_lrc(&mut d, &Manifest::new(), &successes);
758
759        let art = &d[0].artifacts[0];
760        let body = art.content.as_deref().unwrap();
761        assert_eq!(d[0].embedded_lyrics_hash, content_hash(body));
762        assert_eq!(d[0].embedded_lyrics_hash, art.hash);
763    }
764
765    #[test]
766    fn apply_clears_embedded_lyrics_hash_for_instrumental() {
767        // A previously-embedded clip that resolves to no lyrics this run drops the
768        // artifact and clears the sentinel to "" (nothing embedded now).
769        let mut d = vec![desired("instr", "")];
770        let mut manifest = Manifest::new();
771        let mut e = entry(slot("instr.lrc", "H"), Some(timed_check()));
772        e.embedded_lyrics_hash = "H".to_string();
773        manifest.insert("instr", e);
774        let mut successes = HashMap::new();
775        successes.insert("instr".to_string(), AlignedLyrics::default());
776        apply_synced_lrc(&mut d, &manifest, &successes);
777
778        assert!(d[0].artifacts.iter().all(|a| a.kind != ArtifactKind::Lrc));
779        assert_eq!(d[0].embedded_lyrics_hash, "");
780    }
781
782    #[test]
783    fn apply_carries_forward_embedded_lyrics_hash_when_not_fetched() {
784        // No fetch this run: the sentinel carries the PERSISTED embed value, not
785        // the `.lrc` slot hash. They differ in the not-yet-embedded / failed
786        // cases, which is exactly why the field is required (loop-freedom).
787        let mut d = vec![desired("a", "")];
788        let mut manifest = Manifest::new();
789        let mut e = entry(slot("a.lrc", "slot"), Some(timed_check()));
790        e.embedded_lyrics_hash = "embed".to_string();
791        manifest.insert("a", e);
792        apply_synced_lrc(&mut d, &manifest, &HashMap::new());
793
794        assert_eq!(
795            d[0].embedded_lyrics_hash, "embed",
796            "carry-forward, not the slot hash"
797        );
798        // The `.lrc` artifact hash resets to the slot so reconcile skips the write.
799        assert_eq!(d[0].artifacts[0].hash, "slot");
800    }
801
802    #[test]
803    fn apply_carries_forward_for_clip_without_lrc_artifact() {
804        // A clip with no desired `.lrc` artifact (feature off / instrumental) keeps
805        // its persisted embed value and never spuriously retags.
806        let mut d = vec![desired("a", "")];
807        d[0].artifacts.clear();
808        let mut manifest = Manifest::new();
809        let mut e = entry(None, None);
810        e.embedded_lyrics_hash = "H".to_string();
811        manifest.insert("a", e);
812        apply_synced_lrc(&mut d, &manifest, &HashMap::new());
813
814        assert_eq!(d[0].embedded_lyrics_hash, "H");
815    }
816
817    #[test]
818    fn preview_marks_backfill_target_as_pending() {
819        // A stale-embed clip (lrc.hash = H, embed = "") previews the expected
820        // post-fetch value H so `check` reports drift; a resolved clip carries its
821        // value forward and matches the manifest (skipped).
822        let mut d = vec![desired("stale", ""), desired("done", "")];
823        let mut manifest = Manifest::new();
824        let mut stale = entry(slot("stale.lrc", "H"), Some(timed_check()));
825        stale.embedded_lyrics_hash = String::new();
826        manifest.insert("stale", stale);
827        let mut done = entry(slot("done.lrc", "D"), Some(timed_check()));
828        done.embedded_lyrics_hash = "D".to_string();
829        manifest.insert("done", done);
830
831        preview_synced_lrc(&mut d, &manifest, 2_000, true);
832        assert_eq!(
833            d[0].embedded_lyrics_hash, "H",
834            "target previews the back-fill"
835        );
836        assert_ne!(
837            d[0].embedded_lyrics_hash, "",
838            "differs from the manifest embed -> drift reported"
839        );
840        assert_eq!(
841            d[1].embedded_lyrics_hash, "D",
842            "resolved clip carries forward"
843        );
844    }
845
846    #[test]
847    fn reformat_makes_migrated_clip_a_target() {
848        // An already-migrated clip (embed == lrc.hash == H, entry.format = FLAC) is
849        // neither a back-fill nor a rename target, but a pending FLAC->MP3 reformat
850        // re-encodes and drops the embed, so the reformat re-embed trigger fires.
851        let mut manifest = Manifest::new();
852        let mut e = entry(slot("a.lrc", "H"), Some(timed_check()));
853        e.embedded_lyrics_hash = "H".to_string(); // entry.format is FLAC (helper)
854        manifest.insert("a", e);
855
856        let mut reformat = vec![desired("a", "")];
857        reformat[0].format = AudioFormat::Mp3;
858        assert!(
859            synced_lyrics_targets(&reformat, &manifest, 2_000, true).contains("a"),
860            "a format change re-embeds a migrated clip"
861        );
862
863        // No format change: the same stable clip is not a target.
864        let stable = vec![desired("a", "")]; // FLAC == entry.format
865        assert!(
866            synced_lyrics_targets(&stable, &manifest, 2_000, true).is_empty(),
867            "no reformat, no back-fill -> no fetch"
868        );
869
870        // A clip with no persisted `.lrc` and a format change is not a target
871        // (nothing to re-embed).
872        let mut no_lrc = Manifest::new();
873        no_lrc.insert(
874            "a",
875            entry(
876                None,
877                Some(SyncedLyricsCheck {
878                    empty: true,
879                    timed: false,
880                    ..timed_check()
881                }),
882            ),
883        );
884        let mut reformat_no_lrc = vec![desired("a", "")];
885        reformat_no_lrc[0].format = AudioFormat::Mp3;
886        assert!(
887            synced_lyrics_targets(&reformat_no_lrc, &no_lrc, 2_000, true).is_empty(),
888            "no `.lrc` to re-embed -> not a target despite the reformat"
889        );
890    }
891}