1use std::collections::BTreeMap;
11use std::collections::btree_map::Iter;
12
13use serde::{Deserialize, Serialize};
14
15use crate::vocab::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(crate) fn artifact_paths(&self) -> impl Iterator<Item = &str> {
123 [
124 self.cover_jpg.as_ref(),
125 self.cover_webp.as_ref(),
126 self.details_txt.as_ref(),
127 self.lyrics_txt.as_ref(),
128 self.lrc.as_ref(),
129 self.video_mp4.as_ref(),
130 ]
131 .into_iter()
132 .flatten()
133 .chain(self.stems.values())
134 .map(|state| state.path.as_str())
135 }
136}
137
138#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(transparent)]
144pub struct Manifest {
145 pub entries: BTreeMap<String, ManifestEntry>,
147}
148
149impl Manifest {
150 pub fn new() -> Self {
152 Self::default()
153 }
154
155 pub fn get(&self, clip_id: &str) -> Option<&ManifestEntry> {
157 self.entries.get(clip_id)
158 }
159
160 pub fn insert(
162 &mut self,
163 clip_id: impl Into<String>,
164 entry: ManifestEntry,
165 ) -> Option<ManifestEntry> {
166 self.entries.insert(clip_id.into(), entry)
167 }
168
169 pub fn remove(&mut self, clip_id: &str) -> Option<ManifestEntry> {
171 self.entries.remove(clip_id)
172 }
173
174 pub fn contains(&self, clip_id: &str) -> bool {
176 self.entries.contains_key(clip_id)
177 }
178
179 pub fn iter(&self) -> Iter<'_, String, ManifestEntry> {
181 self.entries.iter()
182 }
183
184 pub fn len(&self) -> usize {
186 self.entries.len()
187 }
188
189 pub fn is_empty(&self) -> bool {
191 self.entries.is_empty()
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
200 ManifestEntry {
201 path: path.to_string(),
202 format,
203 meta_hash: "m".to_string(),
204 art_hash: "a".to_string(),
205 size: 42,
206 preserve: false,
207 ..Default::default()
208 }
209 }
210
211 #[test]
212 fn new_is_empty() {
213 let m = Manifest::new();
214 assert!(m.is_empty());
215 assert_eq!(m.len(), 0);
216 }
217
218 #[test]
219 fn insert_get_contains() {
220 let mut m = Manifest::new();
221 assert!(m.insert("a", entry("a.flac", AudioFormat::Flac)).is_none());
222 assert!(m.contains("a"));
223 assert_eq!(m.get("a").unwrap().path, "a.flac");
224 assert_eq!(m.len(), 1);
225 assert!(!m.is_empty());
226 }
227
228 #[test]
229 fn insert_replaces_and_returns_prior() {
230 let mut m = Manifest::new();
231 m.insert("a", entry("a.flac", AudioFormat::Flac));
232 let prior = m.insert("a", entry("a.mp3", AudioFormat::Mp3));
233 assert_eq!(prior.unwrap().path, "a.flac");
234 assert_eq!(m.get("a").unwrap().format, AudioFormat::Mp3);
235 assert_eq!(m.len(), 1);
236 }
237
238 #[test]
239 fn remove_returns_prior_then_absent() {
240 let mut m = Manifest::new();
241 m.insert("a", entry("a.flac", AudioFormat::Flac));
242 let removed = m.remove("a");
243 assert_eq!(removed.unwrap().path, "a.flac");
244 assert!(!m.contains("a"));
245 assert!(m.remove("a").is_none());
246 }
247
248 #[test]
249 fn get_absent_is_none() {
250 let m = Manifest::new();
251 assert!(m.get("missing").is_none());
252 }
253
254 #[test]
255 fn iter_is_clip_id_sorted() {
256 let mut m = Manifest::new();
257 m.insert("c", entry("c.flac", AudioFormat::Flac));
258 m.insert("a", entry("a.flac", AudioFormat::Flac));
259 m.insert("b", entry("b.flac", AudioFormat::Flac));
260 let ids: Vec<&str> = m.iter().map(|(id, _)| id.as_str()).collect();
261 assert_eq!(ids, ["a", "b", "c"]);
262 }
263
264 #[test]
265 fn serde_roundtrip_preserves_entries() {
266 let mut m = Manifest::new();
267 m.insert("a", entry("a.flac", AudioFormat::Flac));
268 m.insert("b", entry("b.mp3", AudioFormat::Mp3));
269 let mut c = entry("c.flac", AudioFormat::Flac);
271 c.cover_jpg = Some(ArtifactState {
272 path: "c/cover.jpg".to_string(),
273 hash: "jpg-hash".to_string(),
274 });
275 c.cover_webp = Some(ArtifactState {
276 path: "c/cover.webp".to_string(),
277 hash: "webp-hash".to_string(),
278 });
279 c.details_txt = Some(ArtifactState {
280 path: "c.details.txt".to_string(),
281 hash: "details-hash".to_string(),
282 });
283 c.lyrics_txt = Some(ArtifactState {
284 path: "c.lyrics.txt".to_string(),
285 hash: "lyrics-hash".to_string(),
286 });
287 c.lrc = Some(ArtifactState {
288 path: "c.lrc".to_string(),
289 hash: "lrc-hash".to_string(),
290 });
291 m.insert("c", c);
292 let json = serde_json::to_string(&m).unwrap();
293 let back: Manifest = serde_json::from_str(&json).unwrap();
294 assert_eq!(m, back);
295 }
296
297 #[test]
298 fn serde_is_unversioned_flat_object() {
299 let mut m = Manifest::new();
300 m.insert("clip1", entry("song.flac", AudioFormat::Flac));
301 let value: serde_json::Value = serde_json::to_value(&m).unwrap();
302 assert!(value.is_object());
304 assert!(value.get("entries").is_none());
305 assert!(value.get("version").is_none());
306 let entry = value.get("clip1").unwrap();
307 assert_eq!(entry.get("format").unwrap(), "flac");
308 assert_eq!(entry.get("path").unwrap(), "song.flac");
309 }
310
311 #[test]
312 fn empty_manifest_roundtrips() {
313 let m = Manifest::new();
314 let json = serde_json::to_string(&m).unwrap();
315 assert_eq!(json, "{}");
316 let back: Manifest = serde_json::from_str(&json).unwrap();
317 assert!(back.is_empty());
318 }
319
320 #[test]
321 fn unicode_and_reserved_ids_roundtrip() {
322 let mut m = Manifest::new();
323 m.insert("ünïcode-🎵", entry("音楽.flac", AudioFormat::Flac));
324 m.insert("with\"quote", entry("a.flac", AudioFormat::Flac));
325 let json = serde_json::to_string(&m).unwrap();
326 let back: Manifest = serde_json::from_str(&json).unwrap();
327 assert_eq!(m, back);
328 assert!(back.contains("ünïcode-🎵"));
329 }
330
331 #[test]
332 fn default_format_deserialises_when_absent() {
333 let json = r#"{"clip1":{"path":"a.flac","meta_hash":"","art_hash":"","size":0}}"#;
335 let m: Manifest = serde_json::from_str(json).unwrap();
336 assert_eq!(m.get("clip1").unwrap().format, AudioFormat::default());
337 }
338
339 #[test]
340 fn preserve_defaults_to_false_when_absent() {
341 let json =
344 r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"","art_hash":"","size":1}}"#;
345 let m: Manifest = serde_json::from_str(json).unwrap();
346 assert!(!m.get("clip1").unwrap().preserve);
347 }
348
349 #[test]
350 fn preserve_roundtrips() {
351 let mut m = Manifest::new();
352 let mut e = entry("a.flac", AudioFormat::Flac);
353 e.preserve = true;
354 m.insert("a", e);
355 let json = serde_json::to_string(&m).unwrap();
356 let back: Manifest = serde_json::from_str(&json).unwrap();
357 assert!(back.get("a").unwrap().preserve);
358 assert_eq!(m, back);
359 }
360
361 #[test]
362 fn cover_artifacts_default_to_none_when_absent() {
363 let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
367 let m: Manifest = serde_json::from_str(json).unwrap();
368 let e = m.get("clip1").unwrap();
369 assert_eq!(e.cover_jpg, None);
370 assert_eq!(e.cover_webp, None);
371 assert_eq!(e.details_txt, None);
372 assert_eq!(e.lyrics_txt, None);
373 assert_eq!(e.lrc, None);
374 assert_eq!(e.synced_lyrics, None);
375 assert!(e.stems.is_empty());
376 assert!(!e.preserve);
377 }
378
379 #[test]
380 fn synced_lyrics_check_roundtrips_and_defaults() {
381 let json =
384 r#"{"c":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
385 assert_eq!(
386 serde_json::from_str::<Manifest>(json)
387 .unwrap()
388 .get("c")
389 .unwrap()
390 .synced_lyrics,
391 None
392 );
393
394 let mut m = Manifest::new();
395 let mut e = entry("a.flac", AudioFormat::Flac);
396 e.synced_lyrics = Some(SyncedLyricsCheck {
397 version: 1,
398 checked_unix: 1_700_000_000,
399 empty: true,
400 timed: false,
401 });
402 m.insert("a", e);
403 let back: Manifest = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
404 assert_eq!(m, back);
405 }
406
407 #[test]
408 fn stems_default_to_empty_and_are_omitted_when_serialised_empty() {
409 let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
413 let m: Manifest = serde_json::from_str(json).unwrap();
414 assert!(m.get("clip1").unwrap().stems.is_empty());
415 let value: serde_json::Value = serde_json::to_value(&m).unwrap();
416 assert!(value.get("clip1").unwrap().get("stems").is_none());
417 }
418
419 #[test]
420 fn stems_map_roundtrips_and_reports_paths() {
421 let mut e = entry("song.flac", AudioFormat::Flac);
422 e.stems.insert(
423 "stem-vocals".to_string(),
424 ArtifactState {
425 path: "song.stems/song - Vocals [stem-voc].mp3".to_string(),
426 hash: "voc-hash".to_string(),
427 },
428 );
429 e.stems.insert(
430 "stem-drums".to_string(),
431 ArtifactState {
432 path: "song.stems/song - Drums [stem-drm].mp3".to_string(),
433 hash: "drm-hash".to_string(),
434 },
435 );
436 let mut m = Manifest::new();
437 m.insert("clip1", e);
438 let json = serde_json::to_string(&m).unwrap();
439 let back: Manifest = serde_json::from_str(&json).unwrap();
440 assert_eq!(m, back);
441 let paths: Vec<&str> = back.get("clip1").unwrap().artifact_paths().collect();
444 assert!(paths.contains(&"song.stems/song - Vocals [stem-voc].mp3"));
445 assert!(paths.contains(&"song.stems/song - Drums [stem-drm].mp3"));
446 }
447
448 #[test]
449 fn artifact_state_defaults_and_roundtrips() {
450 let empty = ArtifactState::default();
451 assert_eq!(empty.path, "");
452 assert_eq!(empty.hash, "");
453 let json = serde_json::to_string(&empty).unwrap();
454 let back: ArtifactState = serde_json::from_str(&json).unwrap();
455 assert_eq!(empty, back);
456
457 let populated = ArtifactState {
458 path: "x/cover.webp".to_string(),
459 hash: "content-hash".to_string(),
460 };
461 let json = serde_json::to_string(&populated).unwrap();
462 let back: ArtifactState = serde_json::from_str(&json).unwrap();
463 assert_eq!(populated, back);
464 }
465
466 #[test]
467 fn embedded_lyrics_hash_defaults_and_roundtrips() {
468 let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
473 let m: Manifest = serde_json::from_str(json).unwrap();
474 assert_eq!(m.get("clip1").unwrap().embedded_lyrics_hash, "");
475 let value: serde_json::Value = serde_json::to_value(&m).unwrap();
476 assert!(
477 value
478 .get("clip1")
479 .unwrap()
480 .get("embedded_lyrics_hash")
481 .is_none(),
482 "an empty embed hash is omitted from the manifest"
483 );
484
485 let mut e = entry("a.flac", AudioFormat::Flac);
486 e.embedded_lyrics_hash = "lrc-content-hash".to_string();
487 let mut m2 = Manifest::new();
488 m2.insert("a", e);
489 let serialised = serde_json::to_string(&m2).unwrap();
490 assert!(serialised.contains("embedded_lyrics_hash"));
491 let back: Manifest = serde_json::from_str(&serialised).unwrap();
492 assert_eq!(m2, back);
493 assert_eq!(
494 back.get("a").unwrap().embedded_lyrics_hash,
495 "lrc-content-hash"
496 );
497 }
498}