Skip to main content

suno_core/
album_art.rs

1//! Reconciled album and playlist art-file state.
2//!
3//! The sync writes album folder art (`folder.jpg`/`cover.webp`/`cover.mp4`) and
4//! per-playlist `.m3u8` sidecars beside the library, and records what it wrote
5//! here so a later reconcile rewrites only on a genuine content change. These
6//! rows live on the durable [`LineageStore`](crate::LineageStore) (its `albums`
7//! and `playlists` maps) but are a concern distinct from the lineage graph, so
8//! the types and their store accessors live in their own module. Kept
9//! relational so they migrate cleanly to SQLite `album_art`/`playlists` tables.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::manifest::ArtifactState;
16use crate::reconcile::ArtifactKind;
17
18/// The reconciled folder-art state for one album (one stable root id).
19///
20/// Folder art is album-scoped, not per-clip, so it lives here rather than on a
21/// [`ManifestEntry`](crate::manifest::ManifestEntry). Each slot records the
22/// sidecar's path and the content hash of the art it was rendered from, so a
23/// later reconcile rewrites only on a genuine content change (HARDENING H1: a
24/// most-played flip that yields the same art hash is a no-op). Kept relational
25/// (two explicit slots) so it migrates cleanly to a SQLite `album_art` table.
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(default)]
28pub struct AlbumArt {
29    /// The album's static `folder.jpg`, sourced from the most-played variant.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub folder_jpg: Option<ArtifactState>,
32    /// The album's animated `cover.webp`, from the first-created animated variant.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub folder_webp: Option<ArtifactState>,
35    /// The album's raw `cover.mp4`: the same variant's `video_cover_url` kept
36    /// verbatim (no transcode).
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub folder_mp4: Option<ArtifactState>,
39}
40
41impl AlbumArt {
42    /// The stored state for one folder-art `kind`, if present. Per-clip and
43    /// library kinds have no album slot and map to `None`.
44    pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
45        match kind {
46            ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
47            ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
48            ArtifactKind::FolderMp4 => self.folder_mp4.as_ref(),
49            ArtifactKind::CoverJpg
50            | ArtifactKind::CoverWebp
51            | ArtifactKind::DetailsTxt
52            | ArtifactKind::LyricsTxt
53            | ArtifactKind::Lrc
54            | ArtifactKind::VideoMp4
55            | ArtifactKind::Playlist => None,
56        }
57    }
58
59    /// Set (or clear, with `None`) the state for one folder-art `kind`.
60    ///
61    /// The executor calls this after a folder-art write (with the new state) or
62    /// delete (with `None`), so the kind-to-slot mapping lives in one place.
63    /// Non-album kinds have no slot here and are no-ops.
64    pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
65        match kind {
66            ArtifactKind::FolderJpg => self.folder_jpg = state,
67            ArtifactKind::FolderWebp => self.folder_webp = state,
68            ArtifactKind::FolderMp4 => self.folder_mp4 = state,
69            ArtifactKind::CoverJpg
70            | ArtifactKind::CoverWebp
71            | ArtifactKind::DetailsTxt
72            | ArtifactKind::LyricsTxt
73            | ArtifactKind::Lrc
74            | ArtifactKind::VideoMp4
75            | ArtifactKind::Playlist => {}
76        }
77    }
78
79    /// True when the album holds no folder art at all (every slot empty), so the
80    /// store can prune the now-dead album row.
81    pub fn is_empty(&self) -> bool {
82        self.folder_jpg.is_none() && self.folder_webp.is_none() && self.folder_mp4.is_none()
83    }
84}
85
86/// The reconciled `.m3u8` state for one playlist.
87///
88/// A playlist's body is *generated*, not fetched, so unlike per-clip artifacts
89/// its change detection is a single content hash over the full rendered text
90/// (HARDENING B1: name, order, and every member's path/title/duration feed it).
91/// The `path` is the sidecar's library-relative location, tracked so a rename
92/// (a playlist renamed on Suno) is detected and the old file removed. Kept as a
93/// flat row so it migrates cleanly to a SQLite `playlists` table.
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(default)]
96pub struct PlaylistState {
97    /// The playlist's display name at the time it was last written.
98    pub name: String,
99    /// The `.m3u8` file's library-relative path (`<sanitised name>.m3u8`).
100    pub path: String,
101    /// The content hash of the rendered `.m3u8` this row was written from.
102    pub hash: String,
103}
104
105/// Upsert (`Some`) or clear (`None`) one folder-art `kind` for the album rooted
106/// at `root_id`. A clear that empties the row removes it, so the store never
107/// keeps a dead all-`None` album entry. Single home for the prune-when-empty
108/// invariant shared by the executor write/clear paths.
109pub(crate) fn set_album_artifact(
110    albums: &mut BTreeMap<String, AlbumArt>,
111    root_id: &str,
112    kind: ArtifactKind,
113    state: Option<ArtifactState>,
114) {
115    match state {
116        Some(state) => albums
117            .entry(root_id.to_owned())
118            .or_default()
119            .set(kind, Some(state)),
120        None => {
121            if let Some(art) = albums.get_mut(root_id) {
122                art.set(kind, None);
123                if art.is_empty() {
124                    albums.remove(root_id);
125                }
126            }
127        }
128    }
129}
130
131/// Upsert (`Some`) or remove (`None`) the `.m3u8` state for playlist `id`, so a
132/// delete never leaves a dangling row.
133pub(crate) fn set_playlist(
134    playlists: &mut BTreeMap<String, PlaylistState>,
135    id: &str,
136    state: Option<PlaylistState>,
137) {
138    match state {
139        Some(state) => {
140            playlists.insert(id.to_owned(), state);
141        }
142        None => {
143            playlists.remove(id);
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::LineageStore;
152
153    #[test]
154    fn album_art_roundtrips_and_reads_by_kind() {
155        let mut store = LineageStore::new();
156        store.albums.insert(
157            "root-1".to_owned(),
158            AlbumArt {
159                folder_jpg: Some(ArtifactState {
160                    path: "alice/Album/folder.jpg".to_owned(),
161                    hash: "jpg-h".to_owned(),
162                }),
163                folder_webp: Some(ArtifactState {
164                    path: "alice/Album/cover.webp".to_owned(),
165                    hash: "webp-h".to_owned(),
166                }),
167                folder_mp4: Some(ArtifactState {
168                    path: "alice/Album/cover.mp4".to_owned(),
169                    hash: "mp4-h".to_owned(),
170                }),
171            },
172        );
173
174        let json = serde_json::to_string(&store).unwrap();
175        let back: LineageStore = serde_json::from_str(&json).unwrap();
176        assert_eq!(store, back);
177
178        // The serialised shape is a relational `albums` map keyed by root id.
179        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
180        let album = value.get("albums").unwrap().get("root-1").unwrap();
181        assert_eq!(
182            album.get("folder_jpg").unwrap().get("hash").unwrap(),
183            "jpg-h"
184        );
185
186        let art = back.albums.get("root-1").unwrap();
187        assert_eq!(
188            art.artifact(ArtifactKind::FolderJpg).unwrap().path,
189            "alice/Album/folder.jpg"
190        );
191        assert_eq!(
192            art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
193            "webp-h"
194        );
195        assert_eq!(art.artifact(ArtifactKind::FolderMp4).unwrap().hash, "mp4-h");
196        // A per-clip kind has no album slot.
197        assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
198    }
199
200    #[test]
201    fn empty_album_art_omits_slots_when_serialised() {
202        // An all-`None` AlbumArt round-trips and writes an empty object, so the
203        // absent-slot default holds both ways.
204        let empty = AlbumArt::default();
205        assert!(empty.is_empty());
206        let value = serde_json::to_value(&empty).unwrap();
207        assert!(value.get("folder_jpg").is_none());
208        assert!(value.get("folder_webp").is_none());
209        let back: AlbumArt = serde_json::from_str("{}").unwrap();
210        assert_eq!(back, empty);
211    }
212
213    #[test]
214    fn set_album_artifact_upserts_then_prunes_when_emptied() {
215        let mut store = LineageStore::new();
216        let jpg = ArtifactState {
217            path: "a/folder.jpg".to_owned(),
218            hash: "h1".to_owned(),
219        };
220        set_album_artifact(
221            &mut store.albums,
222            "root-1",
223            ArtifactKind::FolderJpg,
224            Some(jpg.clone()),
225        );
226        assert_eq!(store.albums.get("root-1").unwrap().folder_jpg, Some(jpg));
227
228        // Clearing the only slot prunes the whole album row (no dead entries).
229        set_album_artifact(&mut store.albums, "root-1", ArtifactKind::FolderJpg, None);
230        assert!(!store.albums.contains_key("root-1"));
231        assert!(store.albums.is_empty());
232    }
233
234    #[test]
235    fn album_row_survives_until_the_last_slot_including_folder_mp4_is_cleared() {
236        // Regression: `is_empty` must count every slot. A `both`-retention album
237        // owns folder_webp + folder_mp4; clearing folder_webp first must NOT
238        // prune the row while folder_mp4 is still stored, or the later cover.mp4
239        // delete would lose its store entry and never retry on failure.
240        let mut store = LineageStore::new();
241        let state = |p: &str| ArtifactState {
242            path: p.to_owned(),
243            hash: "h".to_owned(),
244        };
245        set_album_artifact(
246            &mut store.albums,
247            "root-1",
248            ArtifactKind::FolderWebp,
249            Some(state("a/cover.webp")),
250        );
251        set_album_artifact(
252            &mut store.albums,
253            "root-1",
254            ArtifactKind::FolderMp4,
255            Some(state("a/cover.mp4")),
256        );
257
258        // FolderWebp is cleared first (its kind sorts before FolderMp4); the row
259        // must stay because the raw cover is still tracked.
260        set_album_artifact(&mut store.albums, "root-1", ArtifactKind::FolderWebp, None);
261        let art = store
262            .albums
263            .get("root-1")
264            .expect("row kept while folder_mp4 remains");
265        assert!(!art.is_empty());
266        assert!(art.folder_mp4.is_some());
267
268        // Clearing the last slot finally prunes the row.
269        set_album_artifact(&mut store.albums, "root-1", ArtifactKind::FolderMp4, None);
270        assert!(!store.albums.contains_key("root-1"));
271        assert!(store.albums.is_empty());
272    }
273
274    #[test]
275    fn playlist_state_roundtrips_by_id() {
276        let mut store = LineageStore::new();
277        store.playlists.insert(
278            "pl1".to_owned(),
279            PlaylistState {
280                name: "Road Trip".to_owned(),
281                path: "Road Trip.m3u8".to_owned(),
282                hash: "abc123".to_owned(),
283            },
284        );
285
286        let json = serde_json::to_string(&store).unwrap();
287        let back: LineageStore = serde_json::from_str(&json).unwrap();
288        assert_eq!(store, back);
289
290        // The serialised shape is a relational `playlists` map keyed by id.
291        let value: serde_json::Value = serde_json::to_value(&store).unwrap();
292        let pl = value.get("playlists").unwrap().get("pl1").unwrap();
293        assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
294        assert_eq!(pl.get("hash").unwrap(), "abc123");
295
296        let stored = back.playlists.get("pl1").unwrap();
297        assert_eq!(stored.name, "Road Trip");
298        assert_eq!(stored.hash, "abc123");
299    }
300
301    #[test]
302    fn set_playlist_upserts_then_clears() {
303        let mut store = LineageStore::new();
304        let state = PlaylistState {
305            name: "Mix".to_owned(),
306            path: "Mix.m3u8".to_owned(),
307            hash: "h1".to_owned(),
308        };
309        set_playlist(&mut store.playlists, "pl1", Some(state.clone()));
310        assert_eq!(store.playlists.get("pl1"), Some(&state));
311
312        // A rewrite replaces the row in place.
313        let renamed = PlaylistState {
314            name: "Mix v2".to_owned(),
315            path: "Mix v2.m3u8".to_owned(),
316            hash: "h2".to_owned(),
317        };
318        set_playlist(&mut store.playlists, "pl1", Some(renamed.clone()));
319        assert_eq!(store.playlists.get("pl1"), Some(&renamed));
320
321        // Clearing removes the row so no dangling entry survives a delete.
322        set_playlist(&mut store.playlists, "pl1", None);
323        assert!(!store.playlists.contains_key("pl1"));
324        assert!(store.playlists.is_empty());
325    }
326}