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