1use serde::{Deserialize, Serialize};
12
13use crate::LineageStore;
14use crate::manifest::ArtifactState;
15use crate::reconcile::ArtifactKind;
16
17#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(default)]
27pub struct AlbumArt {
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub folder_jpg: Option<ArtifactState>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub folder_webp: Option<ArtifactState>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub folder_mp4: Option<ArtifactState>,
38}
39
40impl AlbumArt {
41 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 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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(default)]
95pub struct PlaylistState {
96 pub name: String,
98 pub path: String,
100 pub hash: String,
102}
103
104impl LineageStore {
105 pub fn album_art(&self, root_id: &str) -> Option<&AlbumArt> {
107 self.albums.get(root_id)
108 }
109
110 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 pub fn playlist(&self, id: &str) -> Option<&PlaylistState> {
142 self.playlists.get(id)
143 }
144
145 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 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 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
213 }
214
215 #[test]
216 fn empty_album_art_omits_slots_when_serialised() {
217 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 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 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 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 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 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 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 store.set_playlist("pl1", None);
330 assert!(store.playlist("pl1").is_none());
331 assert!(store.playlists.is_empty());
332 }
333}