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
//! Reconciled album and playlist art-file state.
//!
//! The sync writes album folder art (`folder.jpg`/`cover.webp`/`cover.mp4`) and
//! per-playlist `.m3u8` sidecars beside the library, and records what it wrote
//! here so a later reconcile rewrites only on a genuine content change. These
//! rows live on the durable [`LineageStore`](crate::LineageStore) (its `albums`
//! and `playlists` maps) but are a concern distinct from the lineage graph, so
//! the types and their store accessors live in their own module. Kept
//! relational so they migrate cleanly to SQLite `album_art`/`playlists` tables.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::manifest::ArtifactState;
use crate::vocab::ArtifactKind;
/// The reconciled folder-art state for one album (one stable root id).
///
/// Folder art is album-scoped, not per-clip, so it lives here rather than on a
/// [`ManifestEntry`](crate::manifest::ManifestEntry). Each slot records the
/// sidecar's path and the content hash of the art it was rendered from, so a
/// later reconcile rewrites only on a genuine content change (a most-played flip
/// that yields the same art hash is a no-op). Kept relational so it migrates
/// cleanly to a SQLite `album_art` table.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AlbumArt {
/// The album's static `folder.jpg`, sourced from the most-played variant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder_jpg: Option<ArtifactState>,
/// The album's animated `cover.webp`, from the first-created animated variant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder_webp: Option<ArtifactState>,
/// The album's raw `cover.mp4`: the same variant's `video_cover_url` kept
/// verbatim (no transcode).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder_mp4: Option<ArtifactState>,
}
impl AlbumArt {
/// The stored state for one folder-art `kind`, if present. Per-clip and
/// library kinds have no album slot and map to `None`.
pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
match kind {
ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
ArtifactKind::FolderMp4 => self.folder_mp4.as_ref(),
ArtifactKind::CoverJpg
| ArtifactKind::CoverWebp
| ArtifactKind::DetailsTxt
| ArtifactKind::LyricsTxt
| ArtifactKind::Lrc
| ArtifactKind::VideoMp4
| ArtifactKind::Playlist => None,
}
}
/// Set (or clear, with `None`) the state for one folder-art `kind`.
///
/// The executor calls this after a folder-art write (with the new state) or
/// delete (with `None`), so the kind-to-slot mapping lives in one place.
/// Non-album kinds have no slot here and are no-ops.
pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
match kind {
ArtifactKind::FolderJpg => self.folder_jpg = state,
ArtifactKind::FolderWebp => self.folder_webp = state,
ArtifactKind::FolderMp4 => self.folder_mp4 = state,
ArtifactKind::CoverJpg
| ArtifactKind::CoverWebp
| ArtifactKind::DetailsTxt
| ArtifactKind::LyricsTxt
| ArtifactKind::Lrc
| ArtifactKind::VideoMp4
| ArtifactKind::Playlist => {}
}
}
/// True when the album holds no folder art at all (every slot empty), so the
/// store can prune the now-dead album row.
pub fn is_empty(&self) -> bool {
self.folder_jpg.is_none() && self.folder_webp.is_none() && self.folder_mp4.is_none()
}
}
/// The reconciled `.m3u8` state for one playlist.
///
/// A playlist's body is generated, not fetched, so its change detection is a
/// single content hash over the full rendered text (name, order, and every
/// member's path/title/duration feed it). The `path` is the sidecar's
/// library-relative location, tracked so a rename (a playlist renamed on Suno)
/// is detected and the old file removed. Kept as a flat row so it migrates
/// cleanly to a SQLite `playlists` table.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct PlaylistState {
/// The playlist's display name at the time it was last written.
pub name: String,
/// The `.m3u8` file's library-relative path (`<sanitised name>.m3u8`).
pub path: String,
/// The content hash of the rendered `.m3u8` this row was written from.
pub hash: String,
}
/// Upsert (`Some`) or clear (`None`) one folder-art `kind` for the album rooted
/// at `root_id`. A clear that empties the row removes it, so the store never
/// keeps a dead all-`None` album entry. Single home for the prune-when-empty
/// invariant shared by the executor write/clear paths.
pub(crate) fn set_album_artifact(
albums: &mut BTreeMap<String, AlbumArt>,
root_id: &str,
kind: ArtifactKind,
state: Option<ArtifactState>,
) {
match state {
Some(state) => albums
.entry(root_id.to_owned())
.or_default()
.set(kind, Some(state)),
None => {
if let Some(art) = albums.get_mut(root_id) {
art.set(kind, None);
if art.is_empty() {
albums.remove(root_id);
}
}
}
}
}
/// Upsert (`Some`) or remove (`None`) the `.m3u8` state for playlist `id`, so a
/// delete never leaves a dangling row.
pub(crate) fn set_playlist(
playlists: &mut BTreeMap<String, PlaylistState>,
id: &str,
state: Option<PlaylistState>,
) {
match state {
Some(state) => {
playlists.insert(id.to_owned(), state);
}
None => {
playlists.remove(id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LineageStore;
#[test]
fn album_art_roundtrips_and_reads_by_kind() {
let mut store = LineageStore::new();
store.albums.insert(
"root-1".to_owned(),
AlbumArt {
folder_jpg: Some(ArtifactState {
path: "alice/Album/folder.jpg".to_owned(),
hash: "jpg-h".to_owned(),
}),
folder_webp: Some(ArtifactState {
path: "alice/Album/cover.webp".to_owned(),
hash: "webp-h".to_owned(),
}),
folder_mp4: Some(ArtifactState {
path: "alice/Album/cover.mp4".to_owned(),
hash: "mp4-h".to_owned(),
}),
},
);
let json = serde_json::to_string(&store).unwrap();
let back: LineageStore = serde_json::from_str(&json).unwrap();
assert_eq!(store, back);
// The serialised shape is a relational `albums` map keyed by root id.
let value: serde_json::Value = serde_json::to_value(&store).unwrap();
let album = value.get("albums").unwrap().get("root-1").unwrap();
assert_eq!(
album.get("folder_jpg").unwrap().get("hash").unwrap(),
"jpg-h"
);
let art = back.albums.get("root-1").unwrap();
assert_eq!(
art.artifact(ArtifactKind::FolderJpg).unwrap().path,
"alice/Album/folder.jpg"
);
assert_eq!(
art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
"webp-h"
);
assert_eq!(art.artifact(ArtifactKind::FolderMp4).unwrap().hash, "mp4-h");
// A per-clip kind has no album slot.
assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
}
#[test]
fn empty_album_art_omits_slots_when_serialised() {
// An all-`None` AlbumArt round-trips and writes an empty object, so the
// absent-slot default holds both ways.
let empty = AlbumArt::default();
assert!(empty.is_empty());
let value = serde_json::to_value(&empty).unwrap();
assert!(value.get("folder_jpg").is_none());
assert!(value.get("folder_webp").is_none());
let back: AlbumArt = serde_json::from_str("{}").unwrap();
assert_eq!(back, empty);
}
#[test]
fn set_album_artifact_upserts_then_prunes_when_emptied() {
let mut store = LineageStore::new();
let jpg = ArtifactState {
path: "a/folder.jpg".to_owned(),
hash: "h1".to_owned(),
};
set_album_artifact(
&mut store.albums,
"root-1",
ArtifactKind::FolderJpg,
Some(jpg.clone()),
);
assert_eq!(store.albums.get("root-1").unwrap().folder_jpg, Some(jpg));
// Clearing the only slot prunes the whole album row (no dead entries).
set_album_artifact(&mut store.albums, "root-1", ArtifactKind::FolderJpg, None);
assert!(!store.albums.contains_key("root-1"));
assert!(store.albums.is_empty());
}
#[test]
fn album_row_survives_until_the_last_slot_including_folder_mp4_is_cleared() {
// Regression: `is_empty` must count every slot. A `both`-retention album
// owns folder_webp + folder_mp4; clearing folder_webp first must NOT
// prune the row while folder_mp4 is still stored, or the later cover.mp4
// delete would lose its store entry and never retry on failure.
let mut store = LineageStore::new();
let state = |p: &str| ArtifactState {
path: p.to_owned(),
hash: "h".to_owned(),
};
set_album_artifact(
&mut store.albums,
"root-1",
ArtifactKind::FolderWebp,
Some(state("a/cover.webp")),
);
set_album_artifact(
&mut store.albums,
"root-1",
ArtifactKind::FolderMp4,
Some(state("a/cover.mp4")),
);
// FolderWebp is cleared first (its kind sorts before FolderMp4); the row
// must stay because the raw cover is still tracked.
set_album_artifact(&mut store.albums, "root-1", ArtifactKind::FolderWebp, None);
let art = store
.albums
.get("root-1")
.expect("row kept while folder_mp4 remains");
assert!(!art.is_empty());
assert!(art.folder_mp4.is_some());
// Clearing the last slot finally prunes the row.
set_album_artifact(&mut store.albums, "root-1", ArtifactKind::FolderMp4, None);
assert!(!store.albums.contains_key("root-1"));
assert!(store.albums.is_empty());
}
#[test]
fn playlist_state_roundtrips_by_id() {
let mut store = LineageStore::new();
store.playlists.insert(
"pl1".to_owned(),
PlaylistState {
name: "Road Trip".to_owned(),
path: "Road Trip.m3u8".to_owned(),
hash: "abc123".to_owned(),
},
);
let json = serde_json::to_string(&store).unwrap();
let back: LineageStore = serde_json::from_str(&json).unwrap();
assert_eq!(store, back);
// The serialised shape is a relational `playlists` map keyed by id.
let value: serde_json::Value = serde_json::to_value(&store).unwrap();
let pl = value.get("playlists").unwrap().get("pl1").unwrap();
assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
assert_eq!(pl.get("hash").unwrap(), "abc123");
let stored = back.playlists.get("pl1").unwrap();
assert_eq!(stored.name, "Road Trip");
assert_eq!(stored.hash, "abc123");
}
#[test]
fn set_playlist_upserts_then_clears() {
let mut store = LineageStore::new();
let state = PlaylistState {
name: "Mix".to_owned(),
path: "Mix.m3u8".to_owned(),
hash: "h1".to_owned(),
};
set_playlist(&mut store.playlists, "pl1", Some(state.clone()));
assert_eq!(store.playlists.get("pl1"), Some(&state));
// A rewrite replaces the row in place.
let renamed = PlaylistState {
name: "Mix v2".to_owned(),
path: "Mix v2.m3u8".to_owned(),
hash: "h2".to_owned(),
};
set_playlist(&mut store.playlists, "pl1", Some(renamed.clone()));
assert_eq!(store.playlists.get("pl1"), Some(&renamed));
// Clearing removes the row so no dangling entry survives a delete.
set_playlist(&mut store.playlists, "pl1", None);
assert!(!store.playlists.contains_key("pl1"));
assert!(store.playlists.is_empty());
}
}