1use std::collections::BTreeMap;
11use std::collections::btree_map::Iter;
12
13use serde::{Deserialize, Serialize};
14
15use crate::vocab::{ArtifactKind, AudioFormat};
16
17#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(default)]
23pub struct ArtifactState {
24 pub path: String,
26 pub hash: String,
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(default)]
39pub struct SyncedLyricsCheck {
40 pub version: u32,
43 pub checked_unix: u64,
45 pub empty: bool,
48 pub timed: bool,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(default)]
59pub struct ManifestEntry {
60 pub path: String,
62 pub format: AudioFormat,
64 pub meta_hash: String,
66 pub art_hash: String,
68 #[serde(default, skip_serializing_if = "String::is_empty")]
77 pub embedded_lyrics_hash: String,
78 pub size: u64,
80 pub preserve: bool,
84 #[serde(default)]
86 pub cover_jpg: Option<ArtifactState>,
87 #[serde(default)]
89 pub cover_webp: Option<ArtifactState>,
90 #[serde(default)]
92 pub details_txt: Option<ArtifactState>,
93 #[serde(default)]
95 pub lyrics_txt: Option<ArtifactState>,
96 #[serde(default)]
100 pub lrc: Option<ArtifactState>,
101 #[serde(default)]
104 pub synced_lyrics: Option<SyncedLyricsCheck>,
105 #[serde(default)]
107 pub video_mp4: Option<ArtifactState>,
108 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
115 pub stems: BTreeMap<String, ArtifactState>,
116}
117
118impl ManifestEntry {
119 pub fn artifact_paths(&self) -> impl Iterator<Item = &str> {
124 [
125 self.cover_jpg.as_ref(),
126 self.cover_webp.as_ref(),
127 self.details_txt.as_ref(),
128 self.lyrics_txt.as_ref(),
129 self.lrc.as_ref(),
130 self.video_mp4.as_ref(),
131 ]
132 .into_iter()
133 .flatten()
134 .chain(self.stems.values())
135 .map(|state| state.path.as_str())
136 }
137
138 pub(crate) fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
143 match kind {
144 ArtifactKind::CoverJpg => self.cover_jpg.as_ref(),
145 ArtifactKind::CoverWebp => self.cover_webp.as_ref(),
146 ArtifactKind::DetailsTxt => self.details_txt.as_ref(),
147 ArtifactKind::LyricsTxt => self.lyrics_txt.as_ref(),
148 ArtifactKind::Lrc => self.lrc.as_ref(),
149 ArtifactKind::VideoMp4 => self.video_mp4.as_ref(),
150 ArtifactKind::FolderJpg
151 | ArtifactKind::FolderWebp
152 | ArtifactKind::FolderMp4
153 | ArtifactKind::Playlist => None,
154 }
155 }
156}
157
158#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(transparent)]
164pub struct Manifest {
165 pub entries: BTreeMap<String, ManifestEntry>,
167}
168
169impl Manifest {
170 pub fn new() -> Self {
172 Self::default()
173 }
174
175 pub fn get(&self, clip_id: &str) -> Option<&ManifestEntry> {
177 self.entries.get(clip_id)
178 }
179
180 pub fn insert(
182 &mut self,
183 clip_id: impl Into<String>,
184 entry: ManifestEntry,
185 ) -> Option<ManifestEntry> {
186 self.entries.insert(clip_id.into(), entry)
187 }
188
189 pub fn remove(&mut self, clip_id: &str) -> Option<ManifestEntry> {
191 self.entries.remove(clip_id)
192 }
193
194 pub fn contains(&self, clip_id: &str) -> bool {
196 self.entries.contains_key(clip_id)
197 }
198
199 pub fn iter(&self) -> Iter<'_, String, ManifestEntry> {
201 self.entries.iter()
202 }
203
204 pub fn len(&self) -> usize {
206 self.entries.len()
207 }
208
209 pub fn is_empty(&self) -> bool {
211 self.entries.is_empty()
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
220 ManifestEntry {
221 path: path.to_string(),
222 format,
223 meta_hash: "m".to_string(),
224 art_hash: "a".to_string(),
225 size: 42,
226 preserve: false,
227 ..Default::default()
228 }
229 }
230
231 #[test]
232 fn new_is_empty() {
233 let m = Manifest::new();
234 assert!(m.is_empty());
235 assert_eq!(m.len(), 0);
236 }
237
238 #[test]
239 fn insert_get_contains() {
240 let mut m = Manifest::new();
241 assert!(m.insert("a", entry("a.flac", AudioFormat::Flac)).is_none());
242 assert!(m.contains("a"));
243 assert_eq!(m.get("a").unwrap().path, "a.flac");
244 assert_eq!(m.len(), 1);
245 assert!(!m.is_empty());
246 }
247
248 #[test]
249 fn insert_replaces_and_returns_prior() {
250 let mut m = Manifest::new();
251 m.insert("a", entry("a.flac", AudioFormat::Flac));
252 let prior = m.insert("a", entry("a.mp3", AudioFormat::Mp3));
253 assert_eq!(prior.unwrap().path, "a.flac");
254 assert_eq!(m.get("a").unwrap().format, AudioFormat::Mp3);
255 assert_eq!(m.len(), 1);
256 }
257
258 #[test]
259 fn remove_returns_prior_then_absent() {
260 let mut m = Manifest::new();
261 m.insert("a", entry("a.flac", AudioFormat::Flac));
262 let removed = m.remove("a");
263 assert_eq!(removed.unwrap().path, "a.flac");
264 assert!(!m.contains("a"));
265 assert!(m.remove("a").is_none());
266 }
267
268 #[test]
269 fn get_absent_is_none() {
270 let m = Manifest::new();
271 assert!(m.get("missing").is_none());
272 }
273
274 #[test]
275 fn iter_is_clip_id_sorted() {
276 let mut m = Manifest::new();
277 m.insert("c", entry("c.flac", AudioFormat::Flac));
278 m.insert("a", entry("a.flac", AudioFormat::Flac));
279 m.insert("b", entry("b.flac", AudioFormat::Flac));
280 let ids: Vec<&str> = m.iter().map(|(id, _)| id.as_str()).collect();
281 assert_eq!(ids, ["a", "b", "c"]);
282 }
283
284 #[test]
285 fn serde_roundtrip_preserves_entries() {
286 let mut m = Manifest::new();
287 m.insert("a", entry("a.flac", AudioFormat::Flac));
288 m.insert("b", entry("b.mp3", AudioFormat::Mp3));
289 let mut c = entry("c.flac", AudioFormat::Flac);
291 c.cover_jpg = Some(ArtifactState {
292 path: "c/cover.jpg".to_string(),
293 hash: "jpg-hash".to_string(),
294 });
295 c.cover_webp = Some(ArtifactState {
296 path: "c/cover.webp".to_string(),
297 hash: "webp-hash".to_string(),
298 });
299 c.details_txt = Some(ArtifactState {
300 path: "c.details.txt".to_string(),
301 hash: "details-hash".to_string(),
302 });
303 c.lyrics_txt = Some(ArtifactState {
304 path: "c.lyrics.txt".to_string(),
305 hash: "lyrics-hash".to_string(),
306 });
307 c.lrc = Some(ArtifactState {
308 path: "c.lrc".to_string(),
309 hash: "lrc-hash".to_string(),
310 });
311 m.insert("c", c);
312 let json = serde_json::to_string(&m).unwrap();
313 let back: Manifest = serde_json::from_str(&json).unwrap();
314 assert_eq!(m, back);
315 }
316
317 #[test]
318 fn serde_is_unversioned_flat_object() {
319 let mut m = Manifest::new();
320 m.insert("clip1", entry("song.flac", AudioFormat::Flac));
321 let value: serde_json::Value = serde_json::to_value(&m).unwrap();
322 assert!(value.is_object());
324 assert!(value.get("entries").is_none());
325 assert!(value.get("version").is_none());
326 let entry = value.get("clip1").unwrap();
327 assert_eq!(entry.get("format").unwrap(), "flac");
328 assert_eq!(entry.get("path").unwrap(), "song.flac");
329 }
330
331 #[test]
332 fn empty_manifest_roundtrips() {
333 let m = Manifest::new();
334 let json = serde_json::to_string(&m).unwrap();
335 assert_eq!(json, "{}");
336 let back: Manifest = serde_json::from_str(&json).unwrap();
337 assert!(back.is_empty());
338 }
339
340 #[test]
341 fn unicode_and_reserved_ids_roundtrip() {
342 let mut m = Manifest::new();
343 m.insert("ünïcode-🎵", entry("音楽.flac", AudioFormat::Flac));
344 m.insert("with\"quote", entry("a.flac", AudioFormat::Flac));
345 let json = serde_json::to_string(&m).unwrap();
346 let back: Manifest = serde_json::from_str(&json).unwrap();
347 assert_eq!(m, back);
348 assert!(back.contains("ünïcode-🎵"));
349 }
350
351 #[test]
352 fn default_format_deserialises_when_absent() {
353 let json = r#"{"clip1":{"path":"a.flac","meta_hash":"","art_hash":"","size":0}}"#;
355 let m: Manifest = serde_json::from_str(json).unwrap();
356 assert_eq!(m.get("clip1").unwrap().format, AudioFormat::default());
357 }
358
359 #[test]
360 fn preserve_defaults_to_false_when_absent() {
361 let json =
364 r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"","art_hash":"","size":1}}"#;
365 let m: Manifest = serde_json::from_str(json).unwrap();
366 assert!(!m.get("clip1").unwrap().preserve);
367 }
368
369 #[test]
370 fn preserve_roundtrips() {
371 let mut m = Manifest::new();
372 let mut e = entry("a.flac", AudioFormat::Flac);
373 e.preserve = true;
374 m.insert("a", e);
375 let json = serde_json::to_string(&m).unwrap();
376 let back: Manifest = serde_json::from_str(&json).unwrap();
377 assert!(back.get("a").unwrap().preserve);
378 assert_eq!(m, back);
379 }
380
381 #[test]
382 fn cover_artifacts_default_to_none_when_absent() {
383 let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
387 let m: Manifest = serde_json::from_str(json).unwrap();
388 let e = m.get("clip1").unwrap();
389 assert_eq!(e.cover_jpg, None);
390 assert_eq!(e.cover_webp, None);
391 assert_eq!(e.details_txt, None);
392 assert_eq!(e.lyrics_txt, None);
393 assert_eq!(e.lrc, None);
394 assert_eq!(e.synced_lyrics, None);
395 assert!(e.stems.is_empty());
396 assert!(!e.preserve);
397 }
398
399 #[test]
400 fn synced_lyrics_check_roundtrips_and_defaults() {
401 let json =
404 r#"{"c":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
405 assert_eq!(
406 serde_json::from_str::<Manifest>(json)
407 .unwrap()
408 .get("c")
409 .unwrap()
410 .synced_lyrics,
411 None
412 );
413
414 let mut m = Manifest::new();
415 let mut e = entry("a.flac", AudioFormat::Flac);
416 e.synced_lyrics = Some(SyncedLyricsCheck {
417 version: 1,
418 checked_unix: 1_700_000_000,
419 empty: true,
420 timed: false,
421 });
422 m.insert("a", e);
423 let back: Manifest = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
424 assert_eq!(m, back);
425 }
426
427 #[test]
428 fn stems_default_to_empty_and_are_omitted_when_serialised_empty() {
429 let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
433 let m: Manifest = serde_json::from_str(json).unwrap();
434 assert!(m.get("clip1").unwrap().stems.is_empty());
435 let value: serde_json::Value = serde_json::to_value(&m).unwrap();
436 assert!(value.get("clip1").unwrap().get("stems").is_none());
437 }
438
439 #[test]
440 fn stems_map_roundtrips_and_reports_paths() {
441 let mut e = entry("song.flac", AudioFormat::Flac);
442 e.stems.insert(
443 "stem-vocals".to_string(),
444 ArtifactState {
445 path: "song.stems/song - Vocals [stem-voc].mp3".to_string(),
446 hash: "voc-hash".to_string(),
447 },
448 );
449 e.stems.insert(
450 "stem-drums".to_string(),
451 ArtifactState {
452 path: "song.stems/song - Drums [stem-drm].mp3".to_string(),
453 hash: "drm-hash".to_string(),
454 },
455 );
456 let mut m = Manifest::new();
457 m.insert("clip1", e);
458 let json = serde_json::to_string(&m).unwrap();
459 let back: Manifest = serde_json::from_str(&json).unwrap();
460 assert_eq!(m, back);
461 let paths: Vec<&str> = back.get("clip1").unwrap().artifact_paths().collect();
464 assert!(paths.contains(&"song.stems/song - Vocals [stem-voc].mp3"));
465 assert!(paths.contains(&"song.stems/song - Drums [stem-drm].mp3"));
466 }
467
468 #[test]
469 fn artifact_returns_the_slot_for_each_per_clip_kind() {
470 let mut e = entry("song.flac", AudioFormat::Flac);
471 let state = |name: &str| ArtifactState {
472 path: name.to_string(),
473 hash: format!("{name}-hash"),
474 };
475 e.cover_jpg = Some(state("cover.jpg"));
476 e.cover_webp = Some(state("cover.webp"));
477 e.details_txt = Some(state("details.txt"));
478 e.lyrics_txt = Some(state("lyrics.txt"));
479 e.lrc = Some(state("song.lrc"));
480 e.video_mp4 = Some(state("song.mp4"));
481
482 assert_eq!(e.artifact(ArtifactKind::CoverJpg), e.cover_jpg.as_ref());
483 assert_eq!(e.artifact(ArtifactKind::CoverWebp), e.cover_webp.as_ref());
484 assert_eq!(e.artifact(ArtifactKind::DetailsTxt), e.details_txt.as_ref());
485 assert_eq!(e.artifact(ArtifactKind::LyricsTxt), e.lyrics_txt.as_ref());
486 assert_eq!(e.artifact(ArtifactKind::Lrc), e.lrc.as_ref());
487 assert_eq!(e.artifact(ArtifactKind::VideoMp4), e.video_mp4.as_ref());
488
489 assert_eq!(e.artifact(ArtifactKind::FolderJpg), None);
491 assert_eq!(e.artifact(ArtifactKind::FolderWebp), None);
492 assert_eq!(e.artifact(ArtifactKind::FolderMp4), None);
493 assert_eq!(e.artifact(ArtifactKind::Playlist), None);
494 }
495
496 #[test]
497 fn artifact_state_defaults_and_roundtrips() {
498 let empty = ArtifactState::default();
499 assert_eq!(empty.path, "");
500 assert_eq!(empty.hash, "");
501 let json = serde_json::to_string(&empty).unwrap();
502 let back: ArtifactState = serde_json::from_str(&json).unwrap();
503 assert_eq!(empty, back);
504
505 let populated = ArtifactState {
506 path: "x/cover.webp".to_string(),
507 hash: "content-hash".to_string(),
508 };
509 let json = serde_json::to_string(&populated).unwrap();
510 let back: ArtifactState = serde_json::from_str(&json).unwrap();
511 assert_eq!(populated, back);
512 }
513
514 #[test]
515 fn embedded_lyrics_hash_defaults_and_roundtrips() {
516 let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
521 let m: Manifest = serde_json::from_str(json).unwrap();
522 assert_eq!(m.get("clip1").unwrap().embedded_lyrics_hash, "");
523 let value: serde_json::Value = serde_json::to_value(&m).unwrap();
524 assert!(
525 value
526 .get("clip1")
527 .unwrap()
528 .get("embedded_lyrics_hash")
529 .is_none(),
530 "an empty embed hash is omitted from the manifest"
531 );
532
533 let mut e = entry("a.flac", AudioFormat::Flac);
534 e.embedded_lyrics_hash = "lrc-content-hash".to_string();
535 let mut m2 = Manifest::new();
536 m2.insert("a", e);
537 let serialised = serde_json::to_string(&m2).unwrap();
538 assert!(serialised.contains("embedded_lyrics_hash"));
539 let back: Manifest = serde_json::from_str(&serialised).unwrap();
540 assert_eq!(m2, back);
541 assert_eq!(
542 back.get("a").unwrap().embedded_lyrics_hash,
543 "lrc-content-hash"
544 );
545 }
546}