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::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 path this entry currently records. The kind list
120    /// lives here once so the executor can tell whether a path is still owned by
121    /// some artifact before it removes a stale copy.
122    pub(crate) fn artifact_paths(&self) -> impl Iterator<Item = &str> {
123        [
124            self.cover_jpg.as_ref(),
125            self.cover_webp.as_ref(),
126            self.details_txt.as_ref(),
127            self.lyrics_txt.as_ref(),
128            self.lrc.as_ref(),
129            self.video_mp4.as_ref(),
130        ]
131        .into_iter()
132        .flatten()
133        .chain(self.stems.values())
134        .map(|state| state.path.as_str())
135    }
136}
137
138/// The full prior download state, keyed by clip id.
139///
140/// Backed by a [`BTreeMap`] so iteration order is stable, which keeps any plan
141/// derived from it deterministic.
142#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(transparent)]
144pub struct Manifest {
145    /// Records keyed by clip id.
146    pub entries: BTreeMap<String, ManifestEntry>,
147}
148
149impl Manifest {
150    /// Create an empty manifest.
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    /// Return the entry for `clip_id`, if present.
156    pub fn get(&self, clip_id: &str) -> Option<&ManifestEntry> {
157        self.entries.get(clip_id)
158    }
159
160    /// Insert or replace the entry for `clip_id`, returning any prior value.
161    pub fn insert(
162        &mut self,
163        clip_id: impl Into<String>,
164        entry: ManifestEntry,
165    ) -> Option<ManifestEntry> {
166        self.entries.insert(clip_id.into(), entry)
167    }
168
169    /// Remove and return the entry for `clip_id`, if present.
170    pub fn remove(&mut self, clip_id: &str) -> Option<ManifestEntry> {
171        self.entries.remove(clip_id)
172    }
173
174    /// Return true when an entry exists for `clip_id`.
175    pub fn contains(&self, clip_id: &str) -> bool {
176        self.entries.contains_key(clip_id)
177    }
178
179    /// Iterate entries in clip-id order.
180    pub fn iter(&self) -> Iter<'_, String, ManifestEntry> {
181        self.entries.iter()
182    }
183
184    /// Number of entries.
185    pub fn len(&self) -> usize {
186        self.entries.len()
187    }
188
189    /// True when there are no entries.
190    pub fn is_empty(&self) -> bool {
191        self.entries.is_empty()
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
200        ManifestEntry {
201            path: path.to_string(),
202            format,
203            meta_hash: "m".to_string(),
204            art_hash: "a".to_string(),
205            size: 42,
206            preserve: false,
207            ..Default::default()
208        }
209    }
210
211    #[test]
212    fn new_is_empty() {
213        let m = Manifest::new();
214        assert!(m.is_empty());
215        assert_eq!(m.len(), 0);
216    }
217
218    #[test]
219    fn insert_get_contains() {
220        let mut m = Manifest::new();
221        assert!(m.insert("a", entry("a.flac", AudioFormat::Flac)).is_none());
222        assert!(m.contains("a"));
223        assert_eq!(m.get("a").unwrap().path, "a.flac");
224        assert_eq!(m.len(), 1);
225        assert!(!m.is_empty());
226    }
227
228    #[test]
229    fn insert_replaces_and_returns_prior() {
230        let mut m = Manifest::new();
231        m.insert("a", entry("a.flac", AudioFormat::Flac));
232        let prior = m.insert("a", entry("a.mp3", AudioFormat::Mp3));
233        assert_eq!(prior.unwrap().path, "a.flac");
234        assert_eq!(m.get("a").unwrap().format, AudioFormat::Mp3);
235        assert_eq!(m.len(), 1);
236    }
237
238    #[test]
239    fn remove_returns_prior_then_absent() {
240        let mut m = Manifest::new();
241        m.insert("a", entry("a.flac", AudioFormat::Flac));
242        let removed = m.remove("a");
243        assert_eq!(removed.unwrap().path, "a.flac");
244        assert!(!m.contains("a"));
245        assert!(m.remove("a").is_none());
246    }
247
248    #[test]
249    fn get_absent_is_none() {
250        let m = Manifest::new();
251        assert!(m.get("missing").is_none());
252    }
253
254    #[test]
255    fn iter_is_clip_id_sorted() {
256        let mut m = Manifest::new();
257        m.insert("c", entry("c.flac", AudioFormat::Flac));
258        m.insert("a", entry("a.flac", AudioFormat::Flac));
259        m.insert("b", entry("b.flac", AudioFormat::Flac));
260        let ids: Vec<&str> = m.iter().map(|(id, _)| id.as_str()).collect();
261        assert_eq!(ids, ["a", "b", "c"]);
262    }
263
264    #[test]
265    fn serde_roundtrip_preserves_entries() {
266        let mut m = Manifest::new();
267        m.insert("a", entry("a.flac", AudioFormat::Flac));
268        m.insert("b", entry("b.mp3", AudioFormat::Mp3));
269        // An entry carrying every sidecar artifact must round-trip intact.
270        let mut c = entry("c.flac", AudioFormat::Flac);
271        c.cover_jpg = Some(ArtifactState {
272            path: "c/cover.jpg".to_string(),
273            hash: "jpg-hash".to_string(),
274        });
275        c.cover_webp = Some(ArtifactState {
276            path: "c/cover.webp".to_string(),
277            hash: "webp-hash".to_string(),
278        });
279        c.details_txt = Some(ArtifactState {
280            path: "c.details.txt".to_string(),
281            hash: "details-hash".to_string(),
282        });
283        c.lyrics_txt = Some(ArtifactState {
284            path: "c.lyrics.txt".to_string(),
285            hash: "lyrics-hash".to_string(),
286        });
287        c.lrc = Some(ArtifactState {
288            path: "c.lrc".to_string(),
289            hash: "lrc-hash".to_string(),
290        });
291        m.insert("c", c);
292        let json = serde_json::to_string(&m).unwrap();
293        let back: Manifest = serde_json::from_str(&json).unwrap();
294        assert_eq!(m, back);
295    }
296
297    #[test]
298    fn serde_is_unversioned_flat_object() {
299        let mut m = Manifest::new();
300        m.insert("clip1", entry("song.flac", AudioFormat::Flac));
301        let value: serde_json::Value = serde_json::to_value(&m).unwrap();
302        // Top level is the clip-id map itself, with no envelope or version key.
303        assert!(value.is_object());
304        assert!(value.get("entries").is_none());
305        assert!(value.get("version").is_none());
306        let entry = value.get("clip1").unwrap();
307        assert_eq!(entry.get("format").unwrap(), "flac");
308        assert_eq!(entry.get("path").unwrap(), "song.flac");
309    }
310
311    #[test]
312    fn empty_manifest_roundtrips() {
313        let m = Manifest::new();
314        let json = serde_json::to_string(&m).unwrap();
315        assert_eq!(json, "{}");
316        let back: Manifest = serde_json::from_str(&json).unwrap();
317        assert!(back.is_empty());
318    }
319
320    #[test]
321    fn unicode_and_reserved_ids_roundtrip() {
322        let mut m = Manifest::new();
323        m.insert("ünïcode-🎵", entry("音楽.flac", AudioFormat::Flac));
324        m.insert("with\"quote", entry("a.flac", AudioFormat::Flac));
325        let json = serde_json::to_string(&m).unwrap();
326        let back: Manifest = serde_json::from_str(&json).unwrap();
327        assert_eq!(m, back);
328        assert!(back.contains("ünïcode-🎵"));
329    }
330
331    #[test]
332    fn default_format_deserialises_when_absent() {
333        // A record missing the format key falls back to the compiled default.
334        let json = r#"{"clip1":{"path":"a.flac","meta_hash":"","art_hash":"","size":0}}"#;
335        let m: Manifest = serde_json::from_str(json).unwrap();
336        assert_eq!(m.get("clip1").unwrap().format, AudioFormat::default());
337    }
338
339    #[test]
340    fn preserve_defaults_to_false_when_absent() {
341        // Older manifests written before the marker existed must load as not
342        // preserved, so the field is purely additive.
343        let json =
344            r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"","art_hash":"","size":1}}"#;
345        let m: Manifest = serde_json::from_str(json).unwrap();
346        assert!(!m.get("clip1").unwrap().preserve);
347    }
348
349    #[test]
350    fn preserve_roundtrips() {
351        let mut m = Manifest::new();
352        let mut e = entry("a.flac", AudioFormat::Flac);
353        e.preserve = true;
354        m.insert("a", e);
355        let json = serde_json::to_string(&m).unwrap();
356        let back: Manifest = serde_json::from_str(&json).unwrap();
357        assert!(back.get("a").unwrap().preserve);
358        assert_eq!(m, back);
359    }
360
361    #[test]
362    fn cover_artifacts_default_to_none_when_absent() {
363        // A pre-growth manifest, written before the sidecar fields existed, must
364        // load with no artifacts and unpreserved, proving the growth is purely
365        // additive and backwards compatible.
366        let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
367        let m: Manifest = serde_json::from_str(json).unwrap();
368        let e = m.get("clip1").unwrap();
369        assert_eq!(e.cover_jpg, None);
370        assert_eq!(e.cover_webp, None);
371        assert_eq!(e.details_txt, None);
372        assert_eq!(e.lyrics_txt, None);
373        assert_eq!(e.lrc, None);
374        assert_eq!(e.synced_lyrics, None);
375        assert!(e.stems.is_empty());
376        assert!(!e.preserve);
377    }
378
379    #[test]
380    fn synced_lyrics_check_roundtrips_and_defaults() {
381        // A pre-feature manifest loads with no synced-lyrics marker; a populated
382        // one round-trips intact, so the field is purely additive.
383        let json =
384            r#"{"c":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
385        assert_eq!(
386            serde_json::from_str::<Manifest>(json)
387                .unwrap()
388                .get("c")
389                .unwrap()
390                .synced_lyrics,
391            None
392        );
393
394        let mut m = Manifest::new();
395        let mut e = entry("a.flac", AudioFormat::Flac);
396        e.synced_lyrics = Some(SyncedLyricsCheck {
397            version: 1,
398            checked_unix: 1_700_000_000,
399            empty: true,
400            timed: false,
401        });
402        m.insert("a", e);
403        let back: Manifest = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
404        assert_eq!(m, back);
405    }
406
407    #[test]
408    fn stems_default_to_empty_and_are_omitted_when_serialised_empty() {
409        // A pre-stems manifest loads with an empty stem map (additive growth),
410        // and an entry with no stems serialises without a `stems` key so the
411        // on-disk manifest is byte-identical for anyone not using the feature.
412        let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
413        let m: Manifest = serde_json::from_str(json).unwrap();
414        assert!(m.get("clip1").unwrap().stems.is_empty());
415        let value: serde_json::Value = serde_json::to_value(&m).unwrap();
416        assert!(value.get("clip1").unwrap().get("stems").is_none());
417    }
418
419    #[test]
420    fn stems_map_roundtrips_and_reports_paths() {
421        let mut e = entry("song.flac", AudioFormat::Flac);
422        e.stems.insert(
423            "stem-vocals".to_string(),
424            ArtifactState {
425                path: "song.stems/song - Vocals [stem-voc].mp3".to_string(),
426                hash: "voc-hash".to_string(),
427            },
428        );
429        e.stems.insert(
430            "stem-drums".to_string(),
431            ArtifactState {
432                path: "song.stems/song - Drums [stem-drm].mp3".to_string(),
433                hash: "drm-hash".to_string(),
434            },
435        );
436        let mut m = Manifest::new();
437        m.insert("clip1", e);
438        let json = serde_json::to_string(&m).unwrap();
439        let back: Manifest = serde_json::from_str(&json).unwrap();
440        assert_eq!(m, back);
441        // Both stem paths are reported as owned artifact paths (so the executor
442        // co-deletes them with the song and never orphans the `.stems` folder).
443        let paths: Vec<&str> = back.get("clip1").unwrap().artifact_paths().collect();
444        assert!(paths.contains(&"song.stems/song - Vocals [stem-voc].mp3"));
445        assert!(paths.contains(&"song.stems/song - Drums [stem-drm].mp3"));
446    }
447
448    #[test]
449    fn artifact_state_defaults_and_roundtrips() {
450        let empty = ArtifactState::default();
451        assert_eq!(empty.path, "");
452        assert_eq!(empty.hash, "");
453        let json = serde_json::to_string(&empty).unwrap();
454        let back: ArtifactState = serde_json::from_str(&json).unwrap();
455        assert_eq!(empty, back);
456
457        let populated = ArtifactState {
458            path: "x/cover.webp".to_string(),
459            hash: "content-hash".to_string(),
460        };
461        let json = serde_json::to_string(&populated).unwrap();
462        let back: ArtifactState = serde_json::from_str(&json).unwrap();
463        assert_eq!(populated, back);
464    }
465
466    #[test]
467    fn embedded_lyrics_hash_defaults_and_roundtrips() {
468        // A pre-field manifest loads with an empty embed fingerprint (additive
469        // growth), an empty value is omitted from the serialised object (so the
470        // on-disk manifest is byte-identical for the no-embed majority), and a
471        // populated value round-trips.
472        let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
473        let m: Manifest = serde_json::from_str(json).unwrap();
474        assert_eq!(m.get("clip1").unwrap().embedded_lyrics_hash, "");
475        let value: serde_json::Value = serde_json::to_value(&m).unwrap();
476        assert!(
477            value
478                .get("clip1")
479                .unwrap()
480                .get("embedded_lyrics_hash")
481                .is_none(),
482            "an empty embed hash is omitted from the manifest"
483        );
484
485        let mut e = entry("a.flac", AudioFormat::Flac);
486        e.embedded_lyrics_hash = "lrc-content-hash".to_string();
487        let mut m2 = Manifest::new();
488        m2.insert("a", e);
489        let serialised = serde_json::to_string(&m2).unwrap();
490        assert!(serialised.contains("embedded_lyrics_hash"));
491        let back: Manifest = serde_json::from_str(&serialised).unwrap();
492        assert_eq!(m2, back);
493        assert_eq!(
494            back.get("a").unwrap().embedded_lyrics_hash,
495            "lrc-content-hash"
496        );
497    }
498}