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}
95
96impl ManifestEntry {
97 pub(crate) fn artifact_paths(&self) -> impl Iterator<Item = &str> {
101 [
102 self.cover_jpg.as_ref(),
103 self.cover_webp.as_ref(),
104 self.details_txt.as_ref(),
105 self.lyrics_txt.as_ref(),
106 self.lrc.as_ref(),
107 self.video_mp4.as_ref(),
108 ]
109 .into_iter()
110 .flatten()
111 .map(|state| state.path.as_str())
112 }
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(transparent)]
121pub struct Manifest {
122 pub entries: BTreeMap<String, ManifestEntry>,
124}
125
126impl Manifest {
127 pub fn new() -> Self {
129 Self::default()
130 }
131
132 pub fn get(&self, clip_id: &str) -> Option<&ManifestEntry> {
134 self.entries.get(clip_id)
135 }
136
137 pub fn insert(
139 &mut self,
140 clip_id: impl Into<String>,
141 entry: ManifestEntry,
142 ) -> Option<ManifestEntry> {
143 self.entries.insert(clip_id.into(), entry)
144 }
145
146 pub fn remove(&mut self, clip_id: &str) -> Option<ManifestEntry> {
148 self.entries.remove(clip_id)
149 }
150
151 pub fn contains(&self, clip_id: &str) -> bool {
153 self.entries.contains_key(clip_id)
154 }
155
156 pub fn iter(&self) -> Iter<'_, String, ManifestEntry> {
158 self.entries.iter()
159 }
160
161 pub fn len(&self) -> usize {
163 self.entries.len()
164 }
165
166 pub fn is_empty(&self) -> bool {
168 self.entries.is_empty()
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
177 ManifestEntry {
178 path: path.to_string(),
179 format,
180 meta_hash: "m".to_string(),
181 art_hash: "a".to_string(),
182 size: 42,
183 preserve: false,
184 ..Default::default()
185 }
186 }
187
188 #[test]
189 fn new_is_empty() {
190 let m = Manifest::new();
191 assert!(m.is_empty());
192 assert_eq!(m.len(), 0);
193 }
194
195 #[test]
196 fn insert_get_contains() {
197 let mut m = Manifest::new();
198 assert!(m.insert("a", entry("a.flac", AudioFormat::Flac)).is_none());
199 assert!(m.contains("a"));
200 assert_eq!(m.get("a").unwrap().path, "a.flac");
201 assert_eq!(m.len(), 1);
202 assert!(!m.is_empty());
203 }
204
205 #[test]
206 fn insert_replaces_and_returns_prior() {
207 let mut m = Manifest::new();
208 m.insert("a", entry("a.flac", AudioFormat::Flac));
209 let prior = m.insert("a", entry("a.mp3", AudioFormat::Mp3));
210 assert_eq!(prior.unwrap().path, "a.flac");
211 assert_eq!(m.get("a").unwrap().format, AudioFormat::Mp3);
212 assert_eq!(m.len(), 1);
213 }
214
215 #[test]
216 fn remove_returns_prior_then_absent() {
217 let mut m = Manifest::new();
218 m.insert("a", entry("a.flac", AudioFormat::Flac));
219 let removed = m.remove("a");
220 assert_eq!(removed.unwrap().path, "a.flac");
221 assert!(!m.contains("a"));
222 assert!(m.remove("a").is_none());
223 }
224
225 #[test]
226 fn get_absent_is_none() {
227 let m = Manifest::new();
228 assert!(m.get("missing").is_none());
229 }
230
231 #[test]
232 fn iter_is_clip_id_sorted() {
233 let mut m = Manifest::new();
234 m.insert("c", entry("c.flac", AudioFormat::Flac));
235 m.insert("a", entry("a.flac", AudioFormat::Flac));
236 m.insert("b", entry("b.flac", AudioFormat::Flac));
237 let ids: Vec<&str> = m.iter().map(|(id, _)| id.as_str()).collect();
238 assert_eq!(ids, ["a", "b", "c"]);
239 }
240
241 #[test]
242 fn serde_roundtrip_preserves_entries() {
243 let mut m = Manifest::new();
244 m.insert("a", entry("a.flac", AudioFormat::Flac));
245 m.insert("b", entry("b.mp3", AudioFormat::Mp3));
246 let mut c = entry("c.flac", AudioFormat::Flac);
248 c.cover_jpg = Some(ArtifactState {
249 path: "c/cover.jpg".to_string(),
250 hash: "jpg-hash".to_string(),
251 });
252 c.cover_webp = Some(ArtifactState {
253 path: "c/cover.webp".to_string(),
254 hash: "webp-hash".to_string(),
255 });
256 c.details_txt = Some(ArtifactState {
257 path: "c.details.txt".to_string(),
258 hash: "details-hash".to_string(),
259 });
260 c.lyrics_txt = Some(ArtifactState {
261 path: "c.lyrics.txt".to_string(),
262 hash: "lyrics-hash".to_string(),
263 });
264 c.lrc = Some(ArtifactState {
265 path: "c.lrc".to_string(),
266 hash: "lrc-hash".to_string(),
267 });
268 m.insert("c", c);
269 let json = serde_json::to_string(&m).unwrap();
270 let back: Manifest = serde_json::from_str(&json).unwrap();
271 assert_eq!(m, back);
272 }
273
274 #[test]
275 fn serde_is_unversioned_flat_object() {
276 let mut m = Manifest::new();
277 m.insert("clip1", entry("song.flac", AudioFormat::Flac));
278 let value: serde_json::Value = serde_json::to_value(&m).unwrap();
279 assert!(value.is_object());
281 assert!(value.get("entries").is_none());
282 assert!(value.get("version").is_none());
283 let entry = value.get("clip1").unwrap();
284 assert_eq!(entry.get("format").unwrap(), "flac");
285 assert_eq!(entry.get("path").unwrap(), "song.flac");
286 }
287
288 #[test]
289 fn empty_manifest_roundtrips() {
290 let m = Manifest::new();
291 let json = serde_json::to_string(&m).unwrap();
292 assert_eq!(json, "{}");
293 let back: Manifest = serde_json::from_str(&json).unwrap();
294 assert!(back.is_empty());
295 }
296
297 #[test]
298 fn unicode_and_reserved_ids_roundtrip() {
299 let mut m = Manifest::new();
300 m.insert("ünïcode-🎵", entry("音楽.flac", AudioFormat::Flac));
301 m.insert("with\"quote", entry("a.flac", AudioFormat::Flac));
302 let json = serde_json::to_string(&m).unwrap();
303 let back: Manifest = serde_json::from_str(&json).unwrap();
304 assert_eq!(m, back);
305 assert!(back.contains("ünïcode-🎵"));
306 }
307
308 #[test]
309 fn default_format_deserialises_when_absent() {
310 let json = r#"{"clip1":{"path":"a.flac","meta_hash":"","art_hash":"","size":0}}"#;
312 let m: Manifest = serde_json::from_str(json).unwrap();
313 assert_eq!(m.get("clip1").unwrap().format, AudioFormat::default());
314 }
315
316 #[test]
317 fn preserve_defaults_to_false_when_absent() {
318 let json =
321 r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"","art_hash":"","size":1}}"#;
322 let m: Manifest = serde_json::from_str(json).unwrap();
323 assert!(!m.get("clip1").unwrap().preserve);
324 }
325
326 #[test]
327 fn preserve_roundtrips() {
328 let mut m = Manifest::new();
329 let mut e = entry("a.flac", AudioFormat::Flac);
330 e.preserve = true;
331 m.insert("a", e);
332 let json = serde_json::to_string(&m).unwrap();
333 let back: Manifest = serde_json::from_str(&json).unwrap();
334 assert!(back.get("a").unwrap().preserve);
335 assert_eq!(m, back);
336 }
337
338 #[test]
339 fn cover_artifacts_default_to_none_when_absent() {
340 let json = r#"{"clip1":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
344 let m: Manifest = serde_json::from_str(json).unwrap();
345 let e = m.get("clip1").unwrap();
346 assert_eq!(e.cover_jpg, None);
347 assert_eq!(e.cover_webp, None);
348 assert_eq!(e.details_txt, None);
349 assert_eq!(e.lyrics_txt, None);
350 assert_eq!(e.lrc, None);
351 assert_eq!(e.synced_lyrics, None);
352 assert!(!e.preserve);
353 }
354
355 #[test]
356 fn synced_lyrics_check_roundtrips_and_defaults() {
357 let json =
360 r#"{"c":{"path":"a.flac","format":"flac","meta_hash":"m","art_hash":"a","size":1}}"#;
361 assert_eq!(
362 serde_json::from_str::<Manifest>(json)
363 .unwrap()
364 .get("c")
365 .unwrap()
366 .synced_lyrics,
367 None
368 );
369
370 let mut m = Manifest::new();
371 let mut e = entry("a.flac", AudioFormat::Flac);
372 e.synced_lyrics = Some(SyncedLyricsCheck {
373 version: 1,
374 checked_unix: 1_700_000_000,
375 empty: true,
376 });
377 m.insert("a", e);
378 let back: Manifest = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
379 assert_eq!(m, back);
380 }
381
382 #[test]
383 fn artifact_state_defaults_and_roundtrips() {
384 let empty = ArtifactState::default();
385 assert_eq!(empty.path, "");
386 assert_eq!(empty.hash, "");
387 let json = serde_json::to_string(&empty).unwrap();
388 let back: ArtifactState = serde_json::from_str(&json).unwrap();
389 assert_eq!(empty, back);
390
391 let populated = ArtifactState {
392 path: "x/cover.webp".to_string(),
393 hash: "content-hash".to_string(),
394 };
395 let json = serde_json::to_string(&populated).unwrap();
396 let back: ArtifactState = serde_json::from_str(&json).unwrap();
397 assert_eq!(populated, back);
398 }
399}