1use std::collections::BTreeMap;
11use std::collections::btree_map::Iter;
12
13use serde::{Deserialize, Serialize};
14
15use crate::config::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 pub size: u64,
70 pub preserve: bool,
74 #[serde(default)]
76 pub cover_jpg: Option<ArtifactState>,
77 #[serde(default)]
79 pub cover_webp: Option<ArtifactState>,
80 #[serde(default)]
82 pub details_txt: Option<ArtifactState>,
83 #[serde(default)]
85 pub lyrics_txt: Option<ArtifactState>,
86 #[serde(default)]
90 pub lrc: Option<ArtifactState>,
91 #[serde(default)]
94 pub synced_lyrics: Option<SyncedLyricsCheck>,
95 #[serde(default)]
97 pub video_mp4: Option<ArtifactState>,
98 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
105 pub stems: BTreeMap<String, ArtifactState>,
106}
107
108impl ManifestEntry {
109 pub(crate) fn artifact_paths(&self) -> impl Iterator<Item = &str> {
113 [
114 self.cover_jpg.as_ref(),
115 self.cover_webp.as_ref(),
116 self.details_txt.as_ref(),
117 self.lyrics_txt.as_ref(),
118 self.lrc.as_ref(),
119 self.video_mp4.as_ref(),
120 ]
121 .into_iter()
122 .flatten()
123 .chain(self.stems.values())
124 .map(|state| state.path.as_str())
125 }
126}
127
128#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(transparent)]
134pub struct Manifest {
135 pub entries: BTreeMap<String, ManifestEntry>,
137}
138
139impl Manifest {
140 pub fn new() -> Self {
142 Self::default()
143 }
144
145 pub fn get(&self, clip_id: &str) -> Option<&ManifestEntry> {
147 self.entries.get(clip_id)
148 }
149
150 pub fn insert(
152 &mut self,
153 clip_id: impl Into<String>,
154 entry: ManifestEntry,
155 ) -> Option<ManifestEntry> {
156 self.entries.insert(clip_id.into(), entry)
157 }
158
159 pub fn remove(&mut self, clip_id: &str) -> Option<ManifestEntry> {
161 self.entries.remove(clip_id)
162 }
163
164 pub fn contains(&self, clip_id: &str) -> bool {
166 self.entries.contains_key(clip_id)
167 }
168
169 pub fn iter(&self) -> Iter<'_, String, ManifestEntry> {
171 self.entries.iter()
172 }
173
174 pub fn len(&self) -> usize {
176 self.entries.len()
177 }
178
179 pub fn is_empty(&self) -> bool {
181 self.entries.is_empty()
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
190 ManifestEntry {
191 path: path.to_string(),
192 format,
193 meta_hash: "m".to_string(),
194 art_hash: "a".to_string(),
195 size: 42,
196 preserve: false,
197 ..Default::default()
198 }
199 }
200
201 #[test]
202 fn new_is_empty() {
203 let m = Manifest::new();
204 assert!(m.is_empty());
205 assert_eq!(m.len(), 0);
206 }
207
208 #[test]
209 fn insert_get_contains() {
210 let mut m = Manifest::new();
211 assert!(m.insert("a", entry("a.flac", AudioFormat::Flac)).is_none());
212 assert!(m.contains("a"));
213 assert_eq!(m.get("a").unwrap().path, "a.flac");
214 assert_eq!(m.len(), 1);
215 assert!(!m.is_empty());
216 }
217
218 #[test]
219 fn insert_replaces_and_returns_prior() {
220 let mut m = Manifest::new();
221 m.insert("a", entry("a.flac", AudioFormat::Flac));
222 let prior = m.insert("a", entry("a.mp3", AudioFormat::Mp3));
223 assert_eq!(prior.unwrap().path, "a.flac");
224 assert_eq!(m.get("a").unwrap().format, AudioFormat::Mp3);
225 assert_eq!(m.len(), 1);
226 }
227
228 #[test]
229 fn remove_returns_prior_then_absent() {
230 let mut m = Manifest::new();
231 m.insert("a", entry("a.flac", AudioFormat::Flac));
232 let removed = m.remove("a");
233 assert_eq!(removed.unwrap().path, "a.flac");
234 assert!(!m.contains("a"));
235 assert!(m.remove("a").is_none());
236 }
237
238 #[test]
239 fn get_absent_is_none() {
240 let m = Manifest::new();
241 assert!(m.get("missing").is_none());
242 }
243
244 #[test]
245 fn iter_is_clip_id_sorted() {
246 let mut m = Manifest::new();
247 m.insert("c", entry("c.flac", AudioFormat::Flac));
248 m.insert("a", entry("a.flac", AudioFormat::Flac));
249 m.insert("b", entry("b.flac", AudioFormat::Flac));
250 let ids: Vec<&str> = m.iter().map(|(id, _)| id.as_str()).collect();
251 assert_eq!(ids, ["a", "b", "c"]);
252 }
253
254 #[test]
255 fn serde_roundtrip_preserves_entries() {
256 let mut m = Manifest::new();
257 m.insert("a", entry("a.flac", AudioFormat::Flac));
258 m.insert("b", entry("b.mp3", AudioFormat::Mp3));
259 let mut c = entry("c.flac", AudioFormat::Flac);
261 c.cover_jpg = Some(ArtifactState {
262 path: "c/cover.jpg".to_string(),
263 hash: "jpg-hash".to_string(),
264 });
265 c.cover_webp = Some(ArtifactState {
266 path: "c/cover.webp".to_string(),
267 hash: "webp-hash".to_string(),
268 });
269 c.details_txt = Some(ArtifactState {
270 path: "c.details.txt".to_string(),
271 hash: "details-hash".to_string(),
272 });
273 c.lyrics_txt = Some(ArtifactState {
274 path: "c.lyrics.txt".to_string(),
275 hash: "lyrics-hash".to_string(),
276 });
277 c.lrc = Some(ArtifactState {
278 path: "c.lrc".to_string(),
279 hash: "lrc-hash".to_string(),
280 });
281 m.insert("c", c);
282 let json = serde_json::to_string(&m).unwrap();
283 let back: Manifest = serde_json::from_str(&json).unwrap();
284 assert_eq!(m, back);
285 }
286
287 #[test]
288 fn serde_is_unversioned_flat_object() {
289 let mut m = Manifest::new();
290 m.insert("clip1", entry("song.flac", AudioFormat::Flac));
291 let value: serde_json::Value = serde_json::to_value(&m).unwrap();
292 assert!(value.is_object());
294 assert!(value.get("entries").is_none());
295 assert!(value.get("version").is_none());
296 let entry = value.get("clip1").unwrap();
297 assert_eq!(entry.get("format").unwrap(), "flac");
298 assert_eq!(entry.get("path").unwrap(), "song.flac");
299 }
300
301 #[test]
302 fn empty_manifest_roundtrips() {
303 let m = Manifest::new();
304 let json = serde_json::to_string(&m).unwrap();
305 assert_eq!(json, "{}");
306 let back: Manifest = serde_json::from_str(&json).unwrap();
307 assert!(back.is_empty());
308 }
309
310 #[test]
311 fn unicode_and_reserved_ids_roundtrip() {
312 let mut m = Manifest::new();
313 m.insert("ünïcode-🎵", entry("音楽.flac", AudioFormat::Flac));
314 m.insert("with\"quote", entry("a.flac", AudioFormat::Flac));
315 let json = serde_json::to_string(&m).unwrap();
316 let back: Manifest = serde_json::from_str(&json).unwrap();
317 assert_eq!(m, back);
318 assert!(back.contains("ünïcode-🎵"));
319 }
320
321 #[test]
322 fn default_format_deserialises_when_absent() {
323 let json = r#"{"clip1":{"path":"a.flac","meta_hash":"","art_hash":"","size":0}}"#;
325 let m: Manifest = serde_json::from_str(json).unwrap();
326 assert_eq!(m.get("clip1").unwrap().format, AudioFormat::default());
327 }
328
329 #[test]
330 fn preserve_defaults_to_false_when_absent() {
331 let json =
334 r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"","art_hash":"","size":1}}"#;
335 let m: Manifest = serde_json::from_str(json).unwrap();
336 assert!(!m.get("clip1").unwrap().preserve);
337 }
338
339 #[test]
340 fn preserve_roundtrips() {
341 let mut m = Manifest::new();
342 let mut e = entry("a.flac", AudioFormat::Flac);
343 e.preserve = true;
344 m.insert("a", e);
345 let json = serde_json::to_string(&m).unwrap();
346 let back: Manifest = serde_json::from_str(&json).unwrap();
347 assert!(back.get("a").unwrap().preserve);
348 assert_eq!(m, back);
349 }
350
351 #[test]
352 fn cover_artifacts_default_to_none_when_absent() {
353 let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
357 let m: Manifest = serde_json::from_str(json).unwrap();
358 let e = m.get("clip1").unwrap();
359 assert_eq!(e.cover_jpg, None);
360 assert_eq!(e.cover_webp, None);
361 assert_eq!(e.details_txt, None);
362 assert_eq!(e.lyrics_txt, None);
363 assert_eq!(e.lrc, None);
364 assert_eq!(e.synced_lyrics, None);
365 assert!(e.stems.is_empty());
366 assert!(!e.preserve);
367 }
368
369 #[test]
370 fn synced_lyrics_check_roundtrips_and_defaults() {
371 let json =
374 r#"{"c":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
375 assert_eq!(
376 serde_json::from_str::<Manifest>(json)
377 .unwrap()
378 .get("c")
379 .unwrap()
380 .synced_lyrics,
381 None
382 );
383
384 let mut m = Manifest::new();
385 let mut e = entry("a.flac", AudioFormat::Flac);
386 e.synced_lyrics = Some(SyncedLyricsCheck {
387 version: 1,
388 checked_unix: 1_700_000_000,
389 empty: true,
390 timed: false,
391 });
392 m.insert("a", e);
393 let back: Manifest = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
394 assert_eq!(m, back);
395 }
396
397 #[test]
398 fn stems_default_to_empty_and_are_omitted_when_serialised_empty() {
399 let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
403 let m: Manifest = serde_json::from_str(json).unwrap();
404 assert!(m.get("clip1").unwrap().stems.is_empty());
405 let value: serde_json::Value = serde_json::to_value(&m).unwrap();
406 assert!(value.get("clip1").unwrap().get("stems").is_none());
407 }
408
409 #[test]
410 fn stems_map_roundtrips_and_reports_paths() {
411 let mut e = entry("song.flac", AudioFormat::Flac);
412 e.stems.insert(
413 "stem-vocals".to_string(),
414 ArtifactState {
415 path: "song.stems/song - Vocals [stem-voc].mp3".to_string(),
416 hash: "voc-hash".to_string(),
417 },
418 );
419 e.stems.insert(
420 "stem-drums".to_string(),
421 ArtifactState {
422 path: "song.stems/song - Drums [stem-drm].mp3".to_string(),
423 hash: "drm-hash".to_string(),
424 },
425 );
426 let mut m = Manifest::new();
427 m.insert("clip1", e);
428 let json = serde_json::to_string(&m).unwrap();
429 let back: Manifest = serde_json::from_str(&json).unwrap();
430 assert_eq!(m, back);
431 let paths: Vec<&str> = back.get("clip1").unwrap().artifact_paths().collect();
434 assert!(paths.contains(&"song.stems/song - Vocals [stem-voc].mp3"));
435 assert!(paths.contains(&"song.stems/song - Drums [stem-drm].mp3"));
436 }
437
438 #[test]
439 fn artifact_state_defaults_and_roundtrips() {
440 let empty = ArtifactState::default();
441 assert_eq!(empty.path, "");
442 assert_eq!(empty.hash, "");
443 let json = serde_json::to_string(&empty).unwrap();
444 let back: ArtifactState = serde_json::from_str(&json).unwrap();
445 assert_eq!(empty, back);
446
447 let populated = ArtifactState {
448 path: "x/cover.webp".to_string(),
449 hash: "content-hash".to_string(),
450 };
451 let json = serde_json::to_string(&populated).unwrap();
452 let back: ArtifactState = serde_json::from_str(&json).unwrap();
453 assert_eq!(populated, back);
454 }
455}