1use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::manifest::ArtifactState;
16use crate::reconcile::ArtifactKind;
17
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(default)]
28pub struct AlbumArt {
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub folder_jpg: Option<ArtifactState>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub folder_webp: Option<ArtifactState>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub folder_mp4: Option<ArtifactState>,
39}
40
41impl AlbumArt {
42 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 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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(default)]
96pub struct PlaylistState {
97 pub name: String,
99 pub path: String,
101 pub hash: String,
103}
104
105pub(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
131pub(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 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 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
198 }
199
200 #[test]
201 fn empty_album_art_omits_slots_when_serialised() {
202 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 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 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 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 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 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 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 set_playlist(&mut store.playlists, "pl1", None);
323 assert!(!store.playlists.contains_key("pl1"));
324 assert!(store.playlists.is_empty());
325 }
326}