use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::manifest::ArtifactState;
use crate::reconcile::ArtifactKind;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AlbumArt {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder_jpg: Option<ArtifactState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder_webp: Option<ArtifactState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder_mp4: Option<ArtifactState>,
}
impl AlbumArt {
pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
match kind {
ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
ArtifactKind::FolderMp4 => self.folder_mp4.as_ref(),
ArtifactKind::CoverJpg
| ArtifactKind::CoverWebp
| ArtifactKind::DetailsTxt
| ArtifactKind::LyricsTxt
| ArtifactKind::Lrc
| ArtifactKind::VideoMp4
| ArtifactKind::Playlist => None,
}
}
pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
match kind {
ArtifactKind::FolderJpg => self.folder_jpg = state,
ArtifactKind::FolderWebp => self.folder_webp = state,
ArtifactKind::FolderMp4 => self.folder_mp4 = state,
ArtifactKind::CoverJpg
| ArtifactKind::CoverWebp
| ArtifactKind::DetailsTxt
| ArtifactKind::LyricsTxt
| ArtifactKind::Lrc
| ArtifactKind::VideoMp4
| ArtifactKind::Playlist => {}
}
}
pub fn is_empty(&self) -> bool {
self.folder_jpg.is_none() && self.folder_webp.is_none() && self.folder_mp4.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct PlaylistState {
pub name: String,
pub path: String,
pub hash: String,
}
pub(crate) fn set_album_artifact(
albums: &mut BTreeMap<String, AlbumArt>,
root_id: &str,
kind: ArtifactKind,
state: Option<ArtifactState>,
) {
match state {
Some(state) => albums
.entry(root_id.to_owned())
.or_default()
.set(kind, Some(state)),
None => {
if let Some(art) = albums.get_mut(root_id) {
art.set(kind, None);
if art.is_empty() {
albums.remove(root_id);
}
}
}
}
}
pub(crate) fn set_playlist(
playlists: &mut BTreeMap<String, PlaylistState>,
id: &str,
state: Option<PlaylistState>,
) {
match state {
Some(state) => {
playlists.insert(id.to_owned(), state);
}
None => {
playlists.remove(id);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LineageStore;
#[test]
fn album_art_roundtrips_and_reads_by_kind() {
let mut store = LineageStore::new();
store.albums.insert(
"root-1".to_owned(),
AlbumArt {
folder_jpg: Some(ArtifactState {
path: "alice/Album/folder.jpg".to_owned(),
hash: "jpg-h".to_owned(),
}),
folder_webp: Some(ArtifactState {
path: "alice/Album/cover.webp".to_owned(),
hash: "webp-h".to_owned(),
}),
folder_mp4: Some(ArtifactState {
path: "alice/Album/cover.mp4".to_owned(),
hash: "mp4-h".to_owned(),
}),
},
);
let json = serde_json::to_string(&store).unwrap();
let back: LineageStore = serde_json::from_str(&json).unwrap();
assert_eq!(store, back);
let value: serde_json::Value = serde_json::to_value(&store).unwrap();
let album = value.get("albums").unwrap().get("root-1").unwrap();
assert_eq!(
album.get("folder_jpg").unwrap().get("hash").unwrap(),
"jpg-h"
);
let art = back.albums.get("root-1").unwrap();
assert_eq!(
art.artifact(ArtifactKind::FolderJpg).unwrap().path,
"alice/Album/folder.jpg"
);
assert_eq!(
art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
"webp-h"
);
assert_eq!(art.artifact(ArtifactKind::FolderMp4).unwrap().hash, "mp4-h");
assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
}
#[test]
fn empty_album_art_omits_slots_when_serialised() {
let empty = AlbumArt::default();
assert!(empty.is_empty());
let value = serde_json::to_value(&empty).unwrap();
assert!(value.get("folder_jpg").is_none());
assert!(value.get("folder_webp").is_none());
let back: AlbumArt = serde_json::from_str("{}").unwrap();
assert_eq!(back, empty);
}
#[test]
fn set_album_artifact_upserts_then_prunes_when_emptied() {
let mut store = LineageStore::new();
let jpg = ArtifactState {
path: "a/folder.jpg".to_owned(),
hash: "h1".to_owned(),
};
set_album_artifact(
&mut store.albums,
"root-1",
ArtifactKind::FolderJpg,
Some(jpg.clone()),
);
assert_eq!(store.albums.get("root-1").unwrap().folder_jpg, Some(jpg));
set_album_artifact(&mut store.albums, "root-1", ArtifactKind::FolderJpg, None);
assert!(!store.albums.contains_key("root-1"));
assert!(store.albums.is_empty());
}
#[test]
fn album_row_survives_until_the_last_slot_including_folder_mp4_is_cleared() {
let mut store = LineageStore::new();
let state = |p: &str| ArtifactState {
path: p.to_owned(),
hash: "h".to_owned(),
};
set_album_artifact(
&mut store.albums,
"root-1",
ArtifactKind::FolderWebp,
Some(state("a/cover.webp")),
);
set_album_artifact(
&mut store.albums,
"root-1",
ArtifactKind::FolderMp4,
Some(state("a/cover.mp4")),
);
set_album_artifact(&mut store.albums, "root-1", ArtifactKind::FolderWebp, None);
let art = store
.albums
.get("root-1")
.expect("row kept while folder_mp4 remains");
assert!(!art.is_empty());
assert!(art.folder_mp4.is_some());
set_album_artifact(&mut store.albums, "root-1", ArtifactKind::FolderMp4, None);
assert!(!store.albums.contains_key("root-1"));
assert!(store.albums.is_empty());
}
#[test]
fn playlist_state_roundtrips_by_id() {
let mut store = LineageStore::new();
store.playlists.insert(
"pl1".to_owned(),
PlaylistState {
name: "Road Trip".to_owned(),
path: "Road Trip.m3u8".to_owned(),
hash: "abc123".to_owned(),
},
);
let json = serde_json::to_string(&store).unwrap();
let back: LineageStore = serde_json::from_str(&json).unwrap();
assert_eq!(store, back);
let value: serde_json::Value = serde_json::to_value(&store).unwrap();
let pl = value.get("playlists").unwrap().get("pl1").unwrap();
assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
assert_eq!(pl.get("hash").unwrap(), "abc123");
let stored = back.playlists.get("pl1").unwrap();
assert_eq!(stored.name, "Road Trip");
assert_eq!(stored.hash, "abc123");
}
#[test]
fn set_playlist_upserts_then_clears() {
let mut store = LineageStore::new();
let state = PlaylistState {
name: "Mix".to_owned(),
path: "Mix.m3u8".to_owned(),
hash: "h1".to_owned(),
};
set_playlist(&mut store.playlists, "pl1", Some(state.clone()));
assert_eq!(store.playlists.get("pl1"), Some(&state));
let renamed = PlaylistState {
name: "Mix v2".to_owned(),
path: "Mix v2.m3u8".to_owned(),
hash: "h2".to_owned(),
};
set_playlist(&mut store.playlists, "pl1", Some(renamed.clone()));
assert_eq!(store.playlists.get("pl1"), Some(&renamed));
set_playlist(&mut store.playlists, "pl1", None);
assert!(!store.playlists.contains_key("pl1"));
assert!(store.playlists.is_empty());
}
}