Skip to main content

suno_core/
manifest.rs

1//! The on-disk manifest: the engine's record of prior download state.
2//!
3//! The manifest is the prior on the reconcile engine: it records, per clip id,
4//! where the file lives, its format, the content hashes used to detect tag and
5//! art drift, its size, and the state of each external sidecar artifact. The CLI
6//! loads and saves it; this module only models it and provides pure helpers. It
7//! is unversioned: serde round-trips it to a flat JSON object keyed by clip id
8//! with no envelope.
9
10use std::collections::BTreeMap;
11use std::collections::btree_map::Iter;
12
13use serde::{Deserialize, Serialize};
14
15use crate::vocab::{ArtifactKind, AudioFormat};
16
17/// The prior known state of one external sidecar artifact for a clip.
18///
19/// Records where the sidecar lives and a hash of the content or source it was
20/// rendered from, so a later reconcile can detect drift and trigger a rewrite.
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(default)]
23pub struct ArtifactState {
24    /// Relative path of the sidecar file under the account root.
25    pub path: String,
26    /// Content/source change hash; a change triggers a rewrite.
27    pub hash: String,
28}
29
30/// The record that a clip's synced lyrics were resolved (fetched) this run.
31///
32/// Suno's forced alignment for a clip is immutable in practice, so once a clip's
33/// alignment has been fetched it need not be fetched again until the render
34/// [`version`](Self::version) bumps. Instrumentals and untimed-fallback clips
35/// are re-checked after [`checked_unix`](Self::checked_unix) ages past the
36/// re-check window, to pick up alignment Suno may compute after generation.
37#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(default)]
39pub struct SyncedLyricsCheck {
40    /// The render version this clip's synced lyrics were last resolved at. A
41    /// bump forces a re-fetch and re-render (the `.lrc` format changed).
42    pub version: u32,
43    /// Unix seconds of the last alignment fetch, for the bounded re-check.
44    pub checked_unix: u64,
45    /// Whether the clip resolved to no lyrics (an instrumental): no `.lrc` was
46    /// written.
47    pub empty: bool,
48    /// Whether the written `.lrc` carries timed (word/line) alignment, as
49    /// opposed to an untimed plain-text fallback. Untimed clips are re-checked
50    /// after the window, the same as instrumentals, so a later-available
51    /// alignment upgrades the `.lrc` and `SYLT`. Defaults to `false` so
52    /// pre-existing manifests written before this field existed are re-checked.
53    pub timed: bool,
54}
55
56/// One manifest record: the prior known state of a single downloaded clip.
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(default)]
59pub struct ManifestEntry {
60    /// Relative path of the audio file under the account root.
61    pub path: String,
62    /// Format the file was written in.
63    pub format: AudioFormat,
64    /// Hash of the clip's tag-bearing metadata, for detecting retag needs.
65    pub meta_hash: String,
66    /// Hash of the embedded cover art, for detecting art drift.
67    pub art_hash: String,
68    /// Fingerprint of the aligned lyrics currently embedded in the audio tag
69    /// (the FLAC `LYRICS` / MP3 `USLT` / ALAC `©lyr` frame), or empty when none
70    /// are embedded. Tracked separately from [`meta_hash`](Self::meta_hash)
71    /// because the embedded text is Suno's fetched alignment, not `clip.lyrics`,
72    /// so a drift here re-tags to back-fill the embed (#354). Its value is the
73    /// content hash of the `.lrc` body the embed was rendered from, mirroring how
74    /// [`lrc`](Self::lrc) tracks the sidecar. Additive: old manifests load with
75    /// `""` and the common no-embed case is omitted from the serialised object.
76    #[serde(default, skip_serializing_if = "String::is_empty")]
77    pub embedded_lyrics_hash: String,
78    /// Size of the file in bytes when last written.
79    pub size: u64,
80    /// When set, this clip is held by a copy or archive source, or is private,
81    /// so it must never be deleted as an orphan no matter the current selection.
82    /// The caller writes this marker; the reconcile engine only reads it.
83    pub preserve: bool,
84    /// Prior state of the external `cover.jpg` sidecar, when one was written.
85    #[serde(default)]
86    pub cover_jpg: Option<ArtifactState>,
87    /// Prior state of the external `cover.webp` sidecar, when one was written.
88    #[serde(default)]
89    pub cover_webp: Option<ArtifactState>,
90    /// Prior state of the plain-text `.details.txt` sidecar, when one was written.
91    #[serde(default)]
92    pub details_txt: Option<ArtifactState>,
93    /// Prior state of the plain-text `.lyrics.txt` sidecar, when one was written.
94    #[serde(default)]
95    pub lyrics_txt: Option<ArtifactState>,
96    /// Prior state of the synced `.lrc` sidecar, when one was written. Its hash
97    /// is the content hash of the rendered `.lrc` body, so an alignment or
98    /// renderer change rewrites it.
99    #[serde(default)]
100    pub lrc: Option<ArtifactState>,
101    /// The synced-lyrics resolution marker, gating whether the clip's alignment
102    /// is re-fetched. Present once the clip has been resolved (written or empty).
103    #[serde(default)]
104    pub synced_lyrics: Option<SyncedLyricsCheck>,
105    /// Prior state of the standalone `.mp4` music video, when one was written.
106    #[serde(default)]
107    pub video_mp4: Option<ArtifactState>,
108    /// Prior state of each downloaded stem, keyed by a stable per-stem key
109    /// (the server stem id, falling back to its label). Unlike the single-slot
110    /// sidecars above, a clip owns a *set* of stems, so this is a keyed map:
111    /// individual stems are added, rewritten, or removed without disturbing the
112    /// others (no whole-folder deletes). Empty and omitted from older manifests,
113    /// so the growth is purely additive.
114    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
115    pub stems: BTreeMap<String, ArtifactState>,
116}
117
118impl ManifestEntry {
119    /// Every per-clip sidecar (and stem) path this entry currently records,
120    /// enumerated in one place so its consumers — the executor's stale-copy
121    /// cleanup, orphan detection, and the local-file stat passes — all agree on
122    /// which paths a clip still owns.
123    pub fn artifact_paths(&self) -> impl Iterator<Item = &str> {
124        [
125            self.cover_jpg.as_ref(),
126            self.cover_webp.as_ref(),
127            self.details_txt.as_ref(),
128            self.lyrics_txt.as_ref(),
129            self.lrc.as_ref(),
130            self.video_mp4.as_ref(),
131        ]
132        .into_iter()
133        .flatten()
134        .chain(self.stems.values())
135        .map(|state| state.path.as_str())
136    }
137
138    /// The stored state for one per-clip sidecar `kind`, if present. Album and
139    /// library kinds have no per-clip slot and map to `None`. Mirrors
140    /// [`AlbumArt::artifact`](crate::album_art::AlbumArt::artifact) so the
141    /// kind-to-slot read lives in one place.
142    pub(crate) fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
143        match kind {
144            ArtifactKind::CoverJpg => self.cover_jpg.as_ref(),
145            ArtifactKind::CoverWebp => self.cover_webp.as_ref(),
146            ArtifactKind::DetailsTxt => self.details_txt.as_ref(),
147            ArtifactKind::LyricsTxt => self.lyrics_txt.as_ref(),
148            ArtifactKind::Lrc => self.lrc.as_ref(),
149            ArtifactKind::VideoMp4 => self.video_mp4.as_ref(),
150            ArtifactKind::FolderJpg
151            | ArtifactKind::FolderWebp
152            | ArtifactKind::FolderMp4
153            | ArtifactKind::Playlist => None,
154        }
155    }
156}
157
158/// The full prior download state, keyed by clip id.
159///
160/// Backed by a [`BTreeMap`] so iteration order is stable, which keeps any plan
161/// derived from it deterministic.
162#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(transparent)]
164pub struct Manifest {
165    /// Records keyed by clip id.
166    pub entries: BTreeMap<String, ManifestEntry>,
167}
168
169impl Manifest {
170    /// Create an empty manifest.
171    pub fn new() -> Self {
172        Self::default()
173    }
174
175    /// Return the entry for `clip_id`, if present.
176    pub fn get(&self, clip_id: &str) -> Option<&ManifestEntry> {
177        self.entries.get(clip_id)
178    }
179
180    /// Insert or replace the entry for `clip_id`, returning any prior value.
181    pub fn insert(
182        &mut self,
183        clip_id: impl Into<String>,
184        entry: ManifestEntry,
185    ) -> Option<ManifestEntry> {
186        self.entries.insert(clip_id.into(), entry)
187    }
188
189    /// Remove and return the entry for `clip_id`, if present.
190    pub fn remove(&mut self, clip_id: &str) -> Option<ManifestEntry> {
191        self.entries.remove(clip_id)
192    }
193
194    /// Return true when an entry exists for `clip_id`.
195    pub fn contains(&self, clip_id: &str) -> bool {
196        self.entries.contains_key(clip_id)
197    }
198
199    /// Iterate entries in clip-id order.
200    pub fn iter(&self) -> Iter<'_, String, ManifestEntry> {
201        self.entries.iter()
202    }
203
204    /// Number of entries.
205    pub fn len(&self) -> usize {
206        self.entries.len()
207    }
208
209    /// True when there are no entries.
210    pub fn is_empty(&self) -> bool {
211        self.entries.is_empty()
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
220        ManifestEntry {
221            path: path.to_string(),
222            format,
223            meta_hash: "m".to_string(),
224            art_hash: "a".to_string(),
225            size: 42,
226            preserve: false,
227            ..Default::default()
228        }
229    }
230
231    #[test]
232    fn new_is_empty() {
233        let m = Manifest::new();
234        assert!(m.is_empty());
235        assert_eq!(m.len(), 0);
236    }
237
238    #[test]
239    fn insert_get_contains() {
240        let mut m = Manifest::new();
241        assert!(m.insert("a", entry("a.flac", AudioFormat::Flac)).is_none());
242        assert!(m.contains("a"));
243        assert_eq!(m.get("a").unwrap().path, "a.flac");
244        assert_eq!(m.len(), 1);
245        assert!(!m.is_empty());
246    }
247
248    #[test]
249    fn insert_replaces_and_returns_prior() {
250        let mut m = Manifest::new();
251        m.insert("a", entry("a.flac", AudioFormat::Flac));
252        let prior = m.insert("a", entry("a.mp3", AudioFormat::Mp3));
253        assert_eq!(prior.unwrap().path, "a.flac");
254        assert_eq!(m.get("a").unwrap().format, AudioFormat::Mp3);
255        assert_eq!(m.len(), 1);
256    }
257
258    #[test]
259    fn remove_returns_prior_then_absent() {
260        let mut m = Manifest::new();
261        m.insert("a", entry("a.flac", AudioFormat::Flac));
262        let removed = m.remove("a");
263        assert_eq!(removed.unwrap().path, "a.flac");
264        assert!(!m.contains("a"));
265        assert!(m.remove("a").is_none());
266    }
267
268    #[test]
269    fn get_absent_is_none() {
270        let m = Manifest::new();
271        assert!(m.get("missing").is_none());
272    }
273
274    #[test]
275    fn iter_is_clip_id_sorted() {
276        let mut m = Manifest::new();
277        m.insert("c", entry("c.flac", AudioFormat::Flac));
278        m.insert("a", entry("a.flac", AudioFormat::Flac));
279        m.insert("b", entry("b.flac", AudioFormat::Flac));
280        let ids: Vec<&str> = m.iter().map(|(id, _)| id.as_str()).collect();
281        assert_eq!(ids, ["a", "b", "c"]);
282    }
283
284    #[test]
285    fn serde_roundtrip_preserves_entries() {
286        let mut m = Manifest::new();
287        m.insert("a", entry("a.flac", AudioFormat::Flac));
288        m.insert("b", entry("b.mp3", AudioFormat::Mp3));
289        // An entry carrying every sidecar artifact must round-trip intact.
290        let mut c = entry("c.flac", AudioFormat::Flac);
291        c.cover_jpg = Some(ArtifactState {
292            path: "c/cover.jpg".to_string(),
293            hash: "jpg-hash".to_string(),
294        });
295        c.cover_webp = Some(ArtifactState {
296            path: "c/cover.webp".to_string(),
297            hash: "webp-hash".to_string(),
298        });
299        c.details_txt = Some(ArtifactState {
300            path: "c.details.txt".to_string(),
301            hash: "details-hash".to_string(),
302        });
303        c.lyrics_txt = Some(ArtifactState {
304            path: "c.lyrics.txt".to_string(),
305            hash: "lyrics-hash".to_string(),
306        });
307        c.lrc = Some(ArtifactState {
308            path: "c.lrc".to_string(),
309            hash: "lrc-hash".to_string(),
310        });
311        m.insert("c", c);
312        let json = serde_json::to_string(&m).unwrap();
313        let back: Manifest = serde_json::from_str(&json).unwrap();
314        assert_eq!(m, back);
315    }
316
317    #[test]
318    fn serde_is_unversioned_flat_object() {
319        let mut m = Manifest::new();
320        m.insert("clip1", entry("song.flac", AudioFormat::Flac));
321        let value: serde_json::Value = serde_json::to_value(&m).unwrap();
322        // Top level is the clip-id map itself, with no envelope or version key.
323        assert!(value.is_object());
324        assert!(value.get("entries").is_none());
325        assert!(value.get("version").is_none());
326        let entry = value.get("clip1").unwrap();
327        assert_eq!(entry.get("format").unwrap(), "flac");
328        assert_eq!(entry.get("path").unwrap(), "song.flac");
329    }
330
331    #[test]
332    fn empty_manifest_roundtrips() {
333        let m = Manifest::new();
334        let json = serde_json::to_string(&m).unwrap();
335        assert_eq!(json, "{}");
336        let back: Manifest = serde_json::from_str(&json).unwrap();
337        assert!(back.is_empty());
338    }
339
340    #[test]
341    fn unicode_and_reserved_ids_roundtrip() {
342        let mut m = Manifest::new();
343        m.insert("ünïcode-🎵", entry("音楽.flac", AudioFormat::Flac));
344        m.insert("with\"quote", entry("a.flac", AudioFormat::Flac));
345        let json = serde_json::to_string(&m).unwrap();
346        let back: Manifest = serde_json::from_str(&json).unwrap();
347        assert_eq!(m, back);
348        assert!(back.contains("ünïcode-🎵"));
349    }
350
351    #[test]
352    fn default_format_deserialises_when_absent() {
353        // A record missing the format key falls back to the compiled default.
354        let json = r#"{"clip1":{"path":"a.flac","meta_hash":"","art_hash":"","size":0}}"#;
355        let m: Manifest = serde_json::from_str(json).unwrap();
356        assert_eq!(m.get("clip1").unwrap().format, AudioFormat::default());
357    }
358
359    #[test]
360    fn preserve_defaults_to_false_when_absent() {
361        // Older manifests written before the marker existed must load as not
362        // preserved, so the field is purely additive.
363        let json =
364            r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"","art_hash":"","size":1}}"#;
365        let m: Manifest = serde_json::from_str(json).unwrap();
366        assert!(!m.get("clip1").unwrap().preserve);
367    }
368
369    #[test]
370    fn preserve_roundtrips() {
371        let mut m = Manifest::new();
372        let mut e = entry("a.flac", AudioFormat::Flac);
373        e.preserve = true;
374        m.insert("a", e);
375        let json = serde_json::to_string(&m).unwrap();
376        let back: Manifest = serde_json::from_str(&json).unwrap();
377        assert!(back.get("a").unwrap().preserve);
378        assert_eq!(m, back);
379    }
380
381    #[test]
382    fn cover_artifacts_default_to_none_when_absent() {
383        // A pre-growth manifest, written before the sidecar fields existed, must
384        // load with no artifacts and unpreserved, proving the growth is purely
385        // additive and backwards compatible.
386        let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
387        let m: Manifest = serde_json::from_str(json).unwrap();
388        let e = m.get("clip1").unwrap();
389        assert_eq!(e.cover_jpg, None);
390        assert_eq!(e.cover_webp, None);
391        assert_eq!(e.details_txt, None);
392        assert_eq!(e.lyrics_txt, None);
393        assert_eq!(e.lrc, None);
394        assert_eq!(e.synced_lyrics, None);
395        assert!(e.stems.is_empty());
396        assert!(!e.preserve);
397    }
398
399    #[test]
400    fn synced_lyrics_check_roundtrips_and_defaults() {
401        // A pre-feature manifest loads with no synced-lyrics marker; a populated
402        // one round-trips intact, so the field is purely additive.
403        let json =
404            r#"{"c":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
405        assert_eq!(
406            serde_json::from_str::<Manifest>(json)
407                .unwrap()
408                .get("c")
409                .unwrap()
410                .synced_lyrics,
411            None
412        );
413
414        let mut m = Manifest::new();
415        let mut e = entry("a.flac", AudioFormat::Flac);
416        e.synced_lyrics = Some(SyncedLyricsCheck {
417            version: 1,
418            checked_unix: 1_700_000_000,
419            empty: true,
420            timed: false,
421        });
422        m.insert("a", e);
423        let back: Manifest = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
424        assert_eq!(m, back);
425    }
426
427    #[test]
428    fn stems_default_to_empty_and_are_omitted_when_serialised_empty() {
429        // A pre-stems manifest loads with an empty stem map (additive growth),
430        // and an entry with no stems serialises without a `stems` key so the
431        // on-disk manifest is byte-identical for anyone not using the feature.
432        let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
433        let m: Manifest = serde_json::from_str(json).unwrap();
434        assert!(m.get("clip1").unwrap().stems.is_empty());
435        let value: serde_json::Value = serde_json::to_value(&m).unwrap();
436        assert!(value.get("clip1").unwrap().get("stems").is_none());
437    }
438
439    #[test]
440    fn stems_map_roundtrips_and_reports_paths() {
441        let mut e = entry("song.flac", AudioFormat::Flac);
442        e.stems.insert(
443            "stem-vocals".to_string(),
444            ArtifactState {
445                path: "song.stems/song - Vocals [stem-voc].mp3".to_string(),
446                hash: "voc-hash".to_string(),
447            },
448        );
449        e.stems.insert(
450            "stem-drums".to_string(),
451            ArtifactState {
452                path: "song.stems/song - Drums [stem-drm].mp3".to_string(),
453                hash: "drm-hash".to_string(),
454            },
455        );
456        let mut m = Manifest::new();
457        m.insert("clip1", e);
458        let json = serde_json::to_string(&m).unwrap();
459        let back: Manifest = serde_json::from_str(&json).unwrap();
460        assert_eq!(m, back);
461        // Both stem paths are reported as owned artifact paths (so the executor
462        // co-deletes them with the song and never orphans the `.stems` folder).
463        let paths: Vec<&str> = back.get("clip1").unwrap().artifact_paths().collect();
464        assert!(paths.contains(&"song.stems/song - Vocals [stem-voc].mp3"));
465        assert!(paths.contains(&"song.stems/song - Drums [stem-drm].mp3"));
466    }
467
468    #[test]
469    fn artifact_returns_the_slot_for_each_per_clip_kind() {
470        let mut e = entry("song.flac", AudioFormat::Flac);
471        let state = |name: &str| ArtifactState {
472            path: name.to_string(),
473            hash: format!("{name}-hash"),
474        };
475        e.cover_jpg = Some(state("cover.jpg"));
476        e.cover_webp = Some(state("cover.webp"));
477        e.details_txt = Some(state("details.txt"));
478        e.lyrics_txt = Some(state("lyrics.txt"));
479        e.lrc = Some(state("song.lrc"));
480        e.video_mp4 = Some(state("song.mp4"));
481
482        assert_eq!(e.artifact(ArtifactKind::CoverJpg), e.cover_jpg.as_ref());
483        assert_eq!(e.artifact(ArtifactKind::CoverWebp), e.cover_webp.as_ref());
484        assert_eq!(e.artifact(ArtifactKind::DetailsTxt), e.details_txt.as_ref());
485        assert_eq!(e.artifact(ArtifactKind::LyricsTxt), e.lyrics_txt.as_ref());
486        assert_eq!(e.artifact(ArtifactKind::Lrc), e.lrc.as_ref());
487        assert_eq!(e.artifact(ArtifactKind::VideoMp4), e.video_mp4.as_ref());
488
489        // Album/library kinds have no per-clip slot.
490        assert_eq!(e.artifact(ArtifactKind::FolderJpg), None);
491        assert_eq!(e.artifact(ArtifactKind::FolderWebp), None);
492        assert_eq!(e.artifact(ArtifactKind::FolderMp4), None);
493        assert_eq!(e.artifact(ArtifactKind::Playlist), None);
494    }
495
496    #[test]
497    fn artifact_state_defaults_and_roundtrips() {
498        let empty = ArtifactState::default();
499        assert_eq!(empty.path, "");
500        assert_eq!(empty.hash, "");
501        let json = serde_json::to_string(&empty).unwrap();
502        let back: ArtifactState = serde_json::from_str(&json).unwrap();
503        assert_eq!(empty, back);
504
505        let populated = ArtifactState {
506            path: "x/cover.webp".to_string(),
507            hash: "content-hash".to_string(),
508        };
509        let json = serde_json::to_string(&populated).unwrap();
510        let back: ArtifactState = serde_json::from_str(&json).unwrap();
511        assert_eq!(populated, back);
512    }
513
514    #[test]
515    fn embedded_lyrics_hash_defaults_and_roundtrips() {
516        // A pre-field manifest loads with an empty embed fingerprint (additive
517        // growth), an empty value is omitted from the serialised object (so the
518        // on-disk manifest is byte-identical for the no-embed majority), and a
519        // populated value round-trips.
520        let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
521        let m: Manifest = serde_json::from_str(json).unwrap();
522        assert_eq!(m.get("clip1").unwrap().embedded_lyrics_hash, "");
523        let value: serde_json::Value = serde_json::to_value(&m).unwrap();
524        assert!(
525            value
526                .get("clip1")
527                .unwrap()
528                .get("embedded_lyrics_hash")
529                .is_none(),
530            "an empty embed hash is omitted from the manifest"
531        );
532
533        let mut e = entry("a.flac", AudioFormat::Flac);
534        e.embedded_lyrics_hash = "lrc-content-hash".to_string();
535        let mut m2 = Manifest::new();
536        m2.insert("a", e);
537        let serialised = serde_json::to_string(&m2).unwrap();
538        assert!(serialised.contains("embedded_lyrics_hash"));
539        let back: Manifest = serde_json::from_str(&serialised).unwrap();
540        assert_eq!(m2, back);
541        assert_eq!(
542            back.get("a").unwrap().embedded_lyrics_hash,
543            "lrc-content-hash"
544        );
545    }
546}