suno-core 0.20.1

Engine for a download-only Suno.ai library tool: feed selection, sync reconciliation, and audio tagging.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
//! The on-disk manifest: the engine's record of prior download state.
//!
//! The manifest is the prior on the reconcile engine: it records, per clip id,
//! where the file lives, its format, the content hashes used to detect tag and
//! art drift, its size, and the state of each external sidecar artifact. The CLI
//! loads and saves it; this module only models it and provides pure helpers. It
//! is unversioned: serde round-trips it to a flat JSON object keyed by clip id
//! with no envelope.

use std::collections::BTreeMap;
use std::collections::btree_map::Iter;

use serde::{Deserialize, Serialize};

use crate::config::AudioFormat;

/// The prior known state of one external sidecar artifact for a clip.
///
/// Records where the sidecar lives and a hash of the content or source it was
/// rendered from, so a later reconcile can detect drift and trigger a rewrite.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ArtifactState {
    /// Relative path of the sidecar file under the account root.
    pub path: String,
    /// Content/source change hash; a change triggers a rewrite.
    pub hash: String,
}

/// The record that a clip's synced lyrics were resolved (fetched) this run.
///
/// Suno's forced alignment for a clip is immutable in practice, so once a clip's
/// alignment has been fetched it need not be fetched again until the render
/// [`version`](Self::version) bumps. A clip that resolved to no lyrics (an
/// instrumental) writes no `.lrc`, so without this marker it would be re-fetched
/// every run; the marker records the check so it is not. A genuinely-empty clip
/// is re-checked only after [`checked_unix`](Self::checked_unix) ages past the
/// re-check window, to pick up alignment Suno may compute after generation.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SyncedLyricsCheck {
    /// The render version this clip's synced lyrics were last resolved at. A
    /// bump forces a re-fetch and re-render (the `.lrc` format changed).
    pub version: u32,
    /// Unix seconds of the last alignment fetch, for the bounded empty re-check.
    pub checked_unix: u64,
    /// Whether the clip resolved to no lyrics (an instrumental): no `.lrc` was
    /// written, and only such clips are re-checked once the window elapses.
    pub empty: bool,
}

/// One manifest record: the prior known state of a single downloaded clip.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ManifestEntry {
    /// Relative path of the audio file under the account root.
    pub path: String,
    /// Format the file was written in.
    pub format: AudioFormat,
    /// Hash of the clip's tag-bearing metadata, for detecting retag needs.
    pub meta_hash: String,
    /// Hash of the embedded cover art, for detecting art drift.
    pub art_hash: String,
    /// Size of the file in bytes when last written.
    pub size: u64,
    /// When set, this clip is held by a copy or archive source, or is private,
    /// so it must never be deleted as an orphan no matter the current selection.
    /// The caller writes this marker; the reconcile engine only reads it.
    pub preserve: bool,
    /// Prior state of the external `cover.jpg` sidecar, when one was written.
    #[serde(default)]
    pub cover_jpg: Option<ArtifactState>,
    /// Prior state of the external `cover.webp` sidecar, when one was written.
    #[serde(default)]
    pub cover_webp: Option<ArtifactState>,
    /// Prior state of the plain-text `.details.txt` sidecar, when one was written.
    #[serde(default)]
    pub details_txt: Option<ArtifactState>,
    /// Prior state of the plain-text `.lyrics.txt` sidecar, when one was written.
    #[serde(default)]
    pub lyrics_txt: Option<ArtifactState>,
    /// Prior state of the synced `.lrc` sidecar, when one was written. Its hash
    /// is the content hash of the rendered `.lrc` body, so an alignment or
    /// renderer change rewrites it.
    #[serde(default)]
    pub lrc: Option<ArtifactState>,
    /// The synced-lyrics resolution marker, gating whether the clip's alignment
    /// is re-fetched. Present once the clip has been resolved (written or empty).
    #[serde(default)]
    pub synced_lyrics: Option<SyncedLyricsCheck>,
    /// Prior state of the standalone `.mp4` music video, when one was written.
    #[serde(default)]
    pub video_mp4: Option<ArtifactState>,
    /// Prior state of each downloaded stem, keyed by a stable per-stem key
    /// (the server stem id, falling back to its label). Unlike the single-slot
    /// sidecars above, a clip owns a *set* of stems, so this is a keyed map:
    /// individual stems are added, rewritten, or removed without disturbing the
    /// others (no whole-folder deletes). Empty and omitted from older manifests,
    /// so the growth is purely additive.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub stems: BTreeMap<String, ArtifactState>,
}

impl ManifestEntry {
    /// Every per-clip sidecar path this entry currently records. The kind list
    /// lives here once so the executor can tell whether a path is still owned by
    /// some artifact before it removes a stale copy.
    pub(crate) fn artifact_paths(&self) -> impl Iterator<Item = &str> {
        [
            self.cover_jpg.as_ref(),
            self.cover_webp.as_ref(),
            self.details_txt.as_ref(),
            self.lyrics_txt.as_ref(),
            self.lrc.as_ref(),
            self.video_mp4.as_ref(),
        ]
        .into_iter()
        .flatten()
        .chain(self.stems.values())
        .map(|state| state.path.as_str())
    }
}

/// The full prior download state, keyed by clip id.
///
/// Backed by a [`BTreeMap`] so iteration order is stable, which keeps any plan
/// derived from it deterministic.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Manifest {
    /// Records keyed by clip id.
    pub entries: BTreeMap<String, ManifestEntry>,
}

impl Manifest {
    /// Create an empty manifest.
    pub fn new() -> Self {
        Self::default()
    }

    /// Return the entry for `clip_id`, if present.
    pub fn get(&self, clip_id: &str) -> Option<&ManifestEntry> {
        self.entries.get(clip_id)
    }

    /// Insert or replace the entry for `clip_id`, returning any prior value.
    pub fn insert(
        &mut self,
        clip_id: impl Into<String>,
        entry: ManifestEntry,
    ) -> Option<ManifestEntry> {
        self.entries.insert(clip_id.into(), entry)
    }

    /// Remove and return the entry for `clip_id`, if present.
    pub fn remove(&mut self, clip_id: &str) -> Option<ManifestEntry> {
        self.entries.remove(clip_id)
    }

    /// Return true when an entry exists for `clip_id`.
    pub fn contains(&self, clip_id: &str) -> bool {
        self.entries.contains_key(clip_id)
    }

    /// Iterate entries in clip-id order.
    pub fn iter(&self) -> Iter<'_, String, ManifestEntry> {
        self.entries.iter()
    }

    /// Number of entries.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// True when there are no entries.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
        ManifestEntry {
            path: path.to_string(),
            format,
            meta_hash: "m".to_string(),
            art_hash: "a".to_string(),
            size: 42,
            preserve: false,
            ..Default::default()
        }
    }

    #[test]
    fn new_is_empty() {
        let m = Manifest::new();
        assert!(m.is_empty());
        assert_eq!(m.len(), 0);
    }

    #[test]
    fn insert_get_contains() {
        let mut m = Manifest::new();
        assert!(m.insert("a", entry("a.flac", AudioFormat::Flac)).is_none());
        assert!(m.contains("a"));
        assert_eq!(m.get("a").unwrap().path, "a.flac");
        assert_eq!(m.len(), 1);
        assert!(!m.is_empty());
    }

    #[test]
    fn insert_replaces_and_returns_prior() {
        let mut m = Manifest::new();
        m.insert("a", entry("a.flac", AudioFormat::Flac));
        let prior = m.insert("a", entry("a.mp3", AudioFormat::Mp3));
        assert_eq!(prior.unwrap().path, "a.flac");
        assert_eq!(m.get("a").unwrap().format, AudioFormat::Mp3);
        assert_eq!(m.len(), 1);
    }

    #[test]
    fn remove_returns_prior_then_absent() {
        let mut m = Manifest::new();
        m.insert("a", entry("a.flac", AudioFormat::Flac));
        let removed = m.remove("a");
        assert_eq!(removed.unwrap().path, "a.flac");
        assert!(!m.contains("a"));
        assert!(m.remove("a").is_none());
    }

    #[test]
    fn get_absent_is_none() {
        let m = Manifest::new();
        assert!(m.get("missing").is_none());
    }

    #[test]
    fn iter_is_clip_id_sorted() {
        let mut m = Manifest::new();
        m.insert("c", entry("c.flac", AudioFormat::Flac));
        m.insert("a", entry("a.flac", AudioFormat::Flac));
        m.insert("b", entry("b.flac", AudioFormat::Flac));
        let ids: Vec<&str> = m.iter().map(|(id, _)| id.as_str()).collect();
        assert_eq!(ids, ["a", "b", "c"]);
    }

    #[test]
    fn serde_roundtrip_preserves_entries() {
        let mut m = Manifest::new();
        m.insert("a", entry("a.flac", AudioFormat::Flac));
        m.insert("b", entry("b.mp3", AudioFormat::Mp3));
        // An entry carrying every sidecar artifact must round-trip intact.
        let mut c = entry("c.flac", AudioFormat::Flac);
        c.cover_jpg = Some(ArtifactState {
            path: "c/cover.jpg".to_string(),
            hash: "jpg-hash".to_string(),
        });
        c.cover_webp = Some(ArtifactState {
            path: "c/cover.webp".to_string(),
            hash: "webp-hash".to_string(),
        });
        c.details_txt = Some(ArtifactState {
            path: "c.details.txt".to_string(),
            hash: "details-hash".to_string(),
        });
        c.lyrics_txt = Some(ArtifactState {
            path: "c.lyrics.txt".to_string(),
            hash: "lyrics-hash".to_string(),
        });
        c.lrc = Some(ArtifactState {
            path: "c.lrc".to_string(),
            hash: "lrc-hash".to_string(),
        });
        m.insert("c", c);
        let json = serde_json::to_string(&m).unwrap();
        let back: Manifest = serde_json::from_str(&json).unwrap();
        assert_eq!(m, back);
    }

    #[test]
    fn serde_is_unversioned_flat_object() {
        let mut m = Manifest::new();
        m.insert("clip1", entry("song.flac", AudioFormat::Flac));
        let value: serde_json::Value = serde_json::to_value(&m).unwrap();
        // Top level is the clip-id map itself, with no envelope or version key.
        assert!(value.is_object());
        assert!(value.get("entries").is_none());
        assert!(value.get("version").is_none());
        let entry = value.get("clip1").unwrap();
        assert_eq!(entry.get("format").unwrap(), "flac");
        assert_eq!(entry.get("path").unwrap(), "song.flac");
    }

    #[test]
    fn empty_manifest_roundtrips() {
        let m = Manifest::new();
        let json = serde_json::to_string(&m).unwrap();
        assert_eq!(json, "{}");
        let back: Manifest = serde_json::from_str(&json).unwrap();
        assert!(back.is_empty());
    }

    #[test]
    fn unicode_and_reserved_ids_roundtrip() {
        let mut m = Manifest::new();
        m.insert("ünïcode-🎵", entry("音楽.flac", AudioFormat::Flac));
        m.insert("with\"quote", entry("a.flac", AudioFormat::Flac));
        let json = serde_json::to_string(&m).unwrap();
        let back: Manifest = serde_json::from_str(&json).unwrap();
        assert_eq!(m, back);
        assert!(back.contains("ünïcode-🎵"));
    }

    #[test]
    fn default_format_deserialises_when_absent() {
        // A record missing the format key falls back to the compiled default.
        let json = r#"{"clip1":{"path":"a.flac","meta_hash":"","art_hash":"","size":0}}"#;
        let m: Manifest = serde_json::from_str(json).unwrap();
        assert_eq!(m.get("clip1").unwrap().format, AudioFormat::default());
    }

    #[test]
    fn preserve_defaults_to_false_when_absent() {
        // Older manifests written before the marker existed must load as not
        // preserved, so the field is purely additive.
        let json =
            r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"","art_hash":"","size":1}}"#;
        let m: Manifest = serde_json::from_str(json).unwrap();
        assert!(!m.get("clip1").unwrap().preserve);
    }

    #[test]
    fn preserve_roundtrips() {
        let mut m = Manifest::new();
        let mut e = entry("a.flac", AudioFormat::Flac);
        e.preserve = true;
        m.insert("a", e);
        let json = serde_json::to_string(&m).unwrap();
        let back: Manifest = serde_json::from_str(&json).unwrap();
        assert!(back.get("a").unwrap().preserve);
        assert_eq!(m, back);
    }

    #[test]
    fn cover_artifacts_default_to_none_when_absent() {
        // A pre-growth manifest, written before the sidecar fields existed, must
        // load with no artifacts and unpreserved, proving the growth is purely
        // additive and backwards compatible.
        let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
        let m: Manifest = serde_json::from_str(json).unwrap();
        let e = m.get("clip1").unwrap();
        assert_eq!(e.cover_jpg, None);
        assert_eq!(e.cover_webp, None);
        assert_eq!(e.details_txt, None);
        assert_eq!(e.lyrics_txt, None);
        assert_eq!(e.lrc, None);
        assert_eq!(e.synced_lyrics, None);
        assert!(e.stems.is_empty());
        assert!(!e.preserve);
    }

    #[test]
    fn synced_lyrics_check_roundtrips_and_defaults() {
        // A pre-feature manifest loads with no synced-lyrics marker; a populated
        // one round-trips intact, so the field is purely additive.
        let json =
            r#"{"c":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
        assert_eq!(
            serde_json::from_str::<Manifest>(json)
                .unwrap()
                .get("c")
                .unwrap()
                .synced_lyrics,
            None
        );

        let mut m = Manifest::new();
        let mut e = entry("a.flac", AudioFormat::Flac);
        e.synced_lyrics = Some(SyncedLyricsCheck {
            version: 1,
            checked_unix: 1_700_000_000,
            empty: true,
        });
        m.insert("a", e);
        let back: Manifest = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
        assert_eq!(m, back);
    }

    #[test]
    fn stems_default_to_empty_and_are_omitted_when_serialised_empty() {
        // A pre-stems manifest loads with an empty stem map (additive growth),
        // and an entry with no stems serialises without a `stems` key so the
        // on-disk manifest is byte-identical for anyone not using the feature.
        let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
        let m: Manifest = serde_json::from_str(json).unwrap();
        assert!(m.get("clip1").unwrap().stems.is_empty());
        let value: serde_json::Value = serde_json::to_value(&m).unwrap();
        assert!(value.get("clip1").unwrap().get("stems").is_none());
    }

    #[test]
    fn stems_map_roundtrips_and_reports_paths() {
        let mut e = entry("song.flac", AudioFormat::Flac);
        e.stems.insert(
            "stem-vocals".to_string(),
            ArtifactState {
                path: "song.stems/song - Vocals [stem-voc].mp3".to_string(),
                hash: "voc-hash".to_string(),
            },
        );
        e.stems.insert(
            "stem-drums".to_string(),
            ArtifactState {
                path: "song.stems/song - Drums [stem-drm].mp3".to_string(),
                hash: "drm-hash".to_string(),
            },
        );
        let mut m = Manifest::new();
        m.insert("clip1", e);
        let json = serde_json::to_string(&m).unwrap();
        let back: Manifest = serde_json::from_str(&json).unwrap();
        assert_eq!(m, back);
        // Both stem paths are reported as owned artifact paths (so the executor
        // co-deletes them with the song and never orphans the `.stems` folder).
        let paths: Vec<&str> = back.get("clip1").unwrap().artifact_paths().collect();
        assert!(paths.contains(&"song.stems/song - Vocals [stem-voc].mp3"));
        assert!(paths.contains(&"song.stems/song - Drums [stem-drm].mp3"));
    }

    #[test]
    fn artifact_state_defaults_and_roundtrips() {
        let empty = ArtifactState::default();
        assert_eq!(empty.path, "");
        assert_eq!(empty.hash, "");
        let json = serde_json::to_string(&empty).unwrap();
        let back: ArtifactState = serde_json::from_str(&json).unwrap();
        assert_eq!(empty, back);

        let populated = ArtifactState {
            path: "x/cover.webp".to_string(),
            hash: "content-hash".to_string(),
        };
        let json = serde_json::to_string(&populated).unwrap();
        let back: ArtifactState = serde_json::from_str(&json).unwrap();
        assert_eq!(populated, back);
    }
}