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