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