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::config::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. A clip that resolved to no lyrics (an
35/// instrumental) writes no `.lrc`, so without this marker it would be re-fetched
36/// every run; the marker records the check so it is not. A genuinely-empty clip
37/// is re-checked only after [`checked_unix`](Self::checked_unix) ages past the
38/// re-check window, to pick up alignment Suno may compute after generation.
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(default)]
41pub struct SyncedLyricsCheck {
42    /// The render version this clip's synced lyrics were last resolved at. A
43    /// bump forces a re-fetch and re-render (the `.lrc` format changed).
44    pub version: u32,
45    /// Unix seconds of the last alignment fetch, for the bounded empty re-check.
46    pub checked_unix: u64,
47    /// Whether the clip resolved to no lyrics (an instrumental): no `.lrc` was
48    /// written, and only such clips are re-checked once the window elapses.
49    pub empty: bool,
50}
51
52/// One manifest record: the prior known state of a single downloaded clip.
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(default)]
55pub struct ManifestEntry {
56    /// Relative path of the audio file under the account root.
57    pub path: String,
58    /// Format the file was written in.
59    pub format: AudioFormat,
60    /// Hash of the clip's tag-bearing metadata, for detecting retag needs.
61    pub meta_hash: String,
62    /// Hash of the embedded cover art, for detecting art drift.
63    pub art_hash: String,
64    /// Size of the file in bytes when last written.
65    pub size: u64,
66    /// When set, this clip is held by a copy or archive source, or is private,
67    /// so it must never be deleted as an orphan no matter the current selection.
68    /// The caller writes this marker; the reconcile engine only reads it.
69    pub preserve: bool,
70    /// Prior state of the external `cover.jpg` sidecar, when one was written.
71    #[serde(default)]
72    pub cover_jpg: Option<ArtifactState>,
73    /// Prior state of the external `cover.webp` sidecar, when one was written.
74    #[serde(default)]
75    pub cover_webp: Option<ArtifactState>,
76    /// Prior state of the plain-text `.details.txt` sidecar, when one was written.
77    #[serde(default)]
78    pub details_txt: Option<ArtifactState>,
79    /// Prior state of the plain-text `.lyrics.txt` sidecar, when one was written.
80    #[serde(default)]
81    pub lyrics_txt: Option<ArtifactState>,
82    /// Prior state of the synced `.lrc` sidecar, when one was written. Its hash
83    /// is the content hash of the rendered `.lrc` body, so an alignment or
84    /// renderer change rewrites it.
85    #[serde(default)]
86    pub lrc: Option<ArtifactState>,
87    /// The synced-lyrics resolution marker, gating whether the clip's alignment
88    /// is re-fetched. Present once the clip has been resolved (written or empty).
89    #[serde(default)]
90    pub synced_lyrics: Option<SyncedLyricsCheck>,
91    /// Prior state of the standalone `.mp4` music video, when one was written.
92    #[serde(default)]
93    pub video_mp4: Option<ArtifactState>,
94}
95
96impl ManifestEntry {
97    /// Every per-clip sidecar path this entry currently records. The kind list
98    /// lives here once so the executor can tell whether a path is still owned by
99    /// some artifact before it removes a stale copy.
100    pub(crate) fn artifact_paths(&self) -> impl Iterator<Item = &str> {
101        [
102            self.cover_jpg.as_ref(),
103            self.cover_webp.as_ref(),
104            self.details_txt.as_ref(),
105            self.lyrics_txt.as_ref(),
106            self.lrc.as_ref(),
107            self.video_mp4.as_ref(),
108        ]
109        .into_iter()
110        .flatten()
111        .map(|state| state.path.as_str())
112    }
113}
114
115/// The full prior download state, keyed by clip id.
116///
117/// Backed by a [`BTreeMap`] so iteration order is stable, which keeps any plan
118/// derived from it deterministic.
119#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(transparent)]
121pub struct Manifest {
122    /// Records keyed by clip id.
123    pub entries: BTreeMap<String, ManifestEntry>,
124}
125
126impl Manifest {
127    /// Create an empty manifest.
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// Return the entry for `clip_id`, if present.
133    pub fn get(&self, clip_id: &str) -> Option<&ManifestEntry> {
134        self.entries.get(clip_id)
135    }
136
137    /// Insert or replace the entry for `clip_id`, returning any prior value.
138    pub fn insert(
139        &mut self,
140        clip_id: impl Into<String>,
141        entry: ManifestEntry,
142    ) -> Option<ManifestEntry> {
143        self.entries.insert(clip_id.into(), entry)
144    }
145
146    /// Remove and return the entry for `clip_id`, if present.
147    pub fn remove(&mut self, clip_id: &str) -> Option<ManifestEntry> {
148        self.entries.remove(clip_id)
149    }
150
151    /// Return true when an entry exists for `clip_id`.
152    pub fn contains(&self, clip_id: &str) -> bool {
153        self.entries.contains_key(clip_id)
154    }
155
156    /// Iterate entries in clip-id order.
157    pub fn iter(&self) -> Iter<'_, String, ManifestEntry> {
158        self.entries.iter()
159    }
160
161    /// Number of entries.
162    pub fn len(&self) -> usize {
163        self.entries.len()
164    }
165
166    /// True when there are no entries.
167    pub fn is_empty(&self) -> bool {
168        self.entries.is_empty()
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
177        ManifestEntry {
178            path: path.to_string(),
179            format,
180            meta_hash: "m".to_string(),
181            art_hash: "a".to_string(),
182            size: 42,
183            preserve: false,
184            ..Default::default()
185        }
186    }
187
188    #[test]
189    fn new_is_empty() {
190        let m = Manifest::new();
191        assert!(m.is_empty());
192        assert_eq!(m.len(), 0);
193    }
194
195    #[test]
196    fn insert_get_contains() {
197        let mut m = Manifest::new();
198        assert!(m.insert("a", entry("a.flac", AudioFormat::Flac)).is_none());
199        assert!(m.contains("a"));
200        assert_eq!(m.get("a").unwrap().path, "a.flac");
201        assert_eq!(m.len(), 1);
202        assert!(!m.is_empty());
203    }
204
205    #[test]
206    fn insert_replaces_and_returns_prior() {
207        let mut m = Manifest::new();
208        m.insert("a", entry("a.flac", AudioFormat::Flac));
209        let prior = m.insert("a", entry("a.mp3", AudioFormat::Mp3));
210        assert_eq!(prior.unwrap().path, "a.flac");
211        assert_eq!(m.get("a").unwrap().format, AudioFormat::Mp3);
212        assert_eq!(m.len(), 1);
213    }
214
215    #[test]
216    fn remove_returns_prior_then_absent() {
217        let mut m = Manifest::new();
218        m.insert("a", entry("a.flac", AudioFormat::Flac));
219        let removed = m.remove("a");
220        assert_eq!(removed.unwrap().path, "a.flac");
221        assert!(!m.contains("a"));
222        assert!(m.remove("a").is_none());
223    }
224
225    #[test]
226    fn get_absent_is_none() {
227        let m = Manifest::new();
228        assert!(m.get("missing").is_none());
229    }
230
231    #[test]
232    fn iter_is_clip_id_sorted() {
233        let mut m = Manifest::new();
234        m.insert("c", entry("c.flac", AudioFormat::Flac));
235        m.insert("a", entry("a.flac", AudioFormat::Flac));
236        m.insert("b", entry("b.flac", AudioFormat::Flac));
237        let ids: Vec<&str> = m.iter().map(|(id, _)| id.as_str()).collect();
238        assert_eq!(ids, ["a", "b", "c"]);
239    }
240
241    #[test]
242    fn serde_roundtrip_preserves_entries() {
243        let mut m = Manifest::new();
244        m.insert("a", entry("a.flac", AudioFormat::Flac));
245        m.insert("b", entry("b.mp3", AudioFormat::Mp3));
246        // An entry carrying every sidecar artifact must round-trip intact.
247        let mut c = entry("c.flac", AudioFormat::Flac);
248        c.cover_jpg = Some(ArtifactState {
249            path: "c/cover.jpg".to_string(),
250            hash: "jpg-hash".to_string(),
251        });
252        c.cover_webp = Some(ArtifactState {
253            path: "c/cover.webp".to_string(),
254            hash: "webp-hash".to_string(),
255        });
256        c.details_txt = Some(ArtifactState {
257            path: "c.details.txt".to_string(),
258            hash: "details-hash".to_string(),
259        });
260        c.lyrics_txt = Some(ArtifactState {
261            path: "c.lyrics.txt".to_string(),
262            hash: "lyrics-hash".to_string(),
263        });
264        c.lrc = Some(ArtifactState {
265            path: "c.lrc".to_string(),
266            hash: "lrc-hash".to_string(),
267        });
268        m.insert("c", c);
269        let json = serde_json::to_string(&m).unwrap();
270        let back: Manifest = serde_json::from_str(&json).unwrap();
271        assert_eq!(m, back);
272    }
273
274    #[test]
275    fn serde_is_unversioned_flat_object() {
276        let mut m = Manifest::new();
277        m.insert("clip1", entry("song.flac", AudioFormat::Flac));
278        let value: serde_json::Value = serde_json::to_value(&m).unwrap();
279        // Top level is the clip-id map itself, with no envelope or version key.
280        assert!(value.is_object());
281        assert!(value.get("entries").is_none());
282        assert!(value.get("version").is_none());
283        let entry = value.get("clip1").unwrap();
284        assert_eq!(entry.get("format").unwrap(), "flac");
285        assert_eq!(entry.get("path").unwrap(), "song.flac");
286    }
287
288    #[test]
289    fn empty_manifest_roundtrips() {
290        let m = Manifest::new();
291        let json = serde_json::to_string(&m).unwrap();
292        assert_eq!(json, "{}");
293        let back: Manifest = serde_json::from_str(&json).unwrap();
294        assert!(back.is_empty());
295    }
296
297    #[test]
298    fn unicode_and_reserved_ids_roundtrip() {
299        let mut m = Manifest::new();
300        m.insert("ünïcode-🎵", entry("音楽.flac", AudioFormat::Flac));
301        m.insert("with\"quote", entry("a.flac", AudioFormat::Flac));
302        let json = serde_json::to_string(&m).unwrap();
303        let back: Manifest = serde_json::from_str(&json).unwrap();
304        assert_eq!(m, back);
305        assert!(back.contains("ünïcode-🎵"));
306    }
307
308    #[test]
309    fn default_format_deserialises_when_absent() {
310        // A record missing the format key falls back to the compiled default.
311        let json = r#"{"clip1":{"path":"a.flac","meta_hash":"","art_hash":"","size":0}}"#;
312        let m: Manifest = serde_json::from_str(json).unwrap();
313        assert_eq!(m.get("clip1").unwrap().format, AudioFormat::default());
314    }
315
316    #[test]
317    fn preserve_defaults_to_false_when_absent() {
318        // Older manifests written before the marker existed must load as not
319        // preserved, so the field is purely additive.
320        let json =
321            r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"","art_hash":"","size":1}}"#;
322        let m: Manifest = serde_json::from_str(json).unwrap();
323        assert!(!m.get("clip1").unwrap().preserve);
324    }
325
326    #[test]
327    fn preserve_roundtrips() {
328        let mut m = Manifest::new();
329        let mut e = entry("a.flac", AudioFormat::Flac);
330        e.preserve = true;
331        m.insert("a", e);
332        let json = serde_json::to_string(&m).unwrap();
333        let back: Manifest = serde_json::from_str(&json).unwrap();
334        assert!(back.get("a").unwrap().preserve);
335        assert_eq!(m, back);
336    }
337
338    #[test]
339    fn cover_artifacts_default_to_none_when_absent() {
340        // A pre-growth manifest, written before the sidecar fields existed, must
341        // load with no artifacts and unpreserved, proving the growth is purely
342        // additive and backwards compatible.
343        let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
344        let m: Manifest = serde_json::from_str(json).unwrap();
345        let e = m.get("clip1").unwrap();
346        assert_eq!(e.cover_jpg, None);
347        assert_eq!(e.cover_webp, None);
348        assert_eq!(e.details_txt, None);
349        assert_eq!(e.lyrics_txt, None);
350        assert_eq!(e.lrc, None);
351        assert_eq!(e.synced_lyrics, None);
352        assert!(!e.preserve);
353    }
354
355    #[test]
356    fn synced_lyrics_check_roundtrips_and_defaults() {
357        // A pre-feature manifest loads with no synced-lyrics marker; a populated
358        // one round-trips intact, so the field is purely additive.
359        let json =
360            r#"{"c":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
361        assert_eq!(
362            serde_json::from_str::<Manifest>(json)
363                .unwrap()
364                .get("c")
365                .unwrap()
366                .synced_lyrics,
367            None
368        );
369
370        let mut m = Manifest::new();
371        let mut e = entry("a.flac", AudioFormat::Flac);
372        e.synced_lyrics = Some(SyncedLyricsCheck {
373            version: 1,
374            checked_unix: 1_700_000_000,
375            empty: true,
376        });
377        m.insert("a", e);
378        let back: Manifest = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
379        assert_eq!(m, back);
380    }
381
382    #[test]
383    fn artifact_state_defaults_and_roundtrips() {
384        let empty = ArtifactState::default();
385        assert_eq!(empty.path, "");
386        assert_eq!(empty.hash, "");
387        let json = serde_json::to_string(&empty).unwrap();
388        let back: ArtifactState = serde_json::from_str(&json).unwrap();
389        assert_eq!(empty, back);
390
391        let populated = ArtifactState {
392            path: "x/cover.webp".to_string(),
393            hash: "content-hash".to_string(),
394        };
395        let json = serde_json::to_string(&populated).unwrap();
396        let back: ArtifactState = serde_json::from_str(&json).unwrap();
397        assert_eq!(populated, back);
398    }
399}