use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use crate::config::AudioFormat;
use crate::graph::{AlbumArt, PlaylistState};
use crate::hash::{art_hash, art_url_hash};
use crate::lineage::LineageContext;
use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
use crate::model::Clip;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ArtifactKind {
CoverJpg,
CoverWebp,
FolderJpg,
FolderWebp,
Playlist,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SourceMode {
Mirror,
Copy,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Desired {
pub clip: Clip,
pub lineage: LineageContext,
pub path: String,
pub format: AudioFormat,
pub meta_hash: String,
pub art_hash: String,
pub modes: Vec<SourceMode>,
pub trashed: bool,
pub private: bool,
pub artifacts: Vec<DesiredArtifact>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DesiredArtifact {
pub kind: ArtifactKind,
pub path: String,
pub source_url: String,
pub hash: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AlbumDesired {
pub root_id: String,
pub folder_jpg: Option<DesiredArtifact>,
pub folder_webp: Option<DesiredArtifact>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlaylistDesired {
pub id: String,
pub name: String,
pub path: String,
pub content: String,
pub hash: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct LocalFile {
pub exists: bool,
pub size: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SourceStatus {
pub mode: SourceMode,
pub fully_enumerated: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
Download {
clip: Clip,
lineage: LineageContext,
path: String,
format: AudioFormat,
},
Reformat {
clip: Clip,
path: String,
from_path: String,
from: AudioFormat,
to: AudioFormat,
},
Retag {
clip: Clip,
lineage: LineageContext,
path: String,
},
Rename { from: String, to: String },
Delete { path: String, clip_id: String },
Skip { clip_id: String },
WriteArtifact {
kind: ArtifactKind,
path: String,
source_url: String,
hash: String,
owner_id: String,
content: Option<String>,
},
DeleteArtifact {
kind: ArtifactKind,
path: String,
owner_id: String,
},
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Plan {
pub actions: Vec<Action>,
}
impl Plan {
pub fn len(&self) -> usize {
self.actions.len()
}
pub fn is_empty(&self) -> bool {
self.actions.is_empty()
}
pub fn downloads(&self) -> usize {
self.count(|a| matches!(a, Action::Download { .. }))
}
pub fn reformats(&self) -> usize {
self.count(|a| matches!(a, Action::Reformat { .. }))
}
pub fn retags(&self) -> usize {
self.count(|a| matches!(a, Action::Retag { .. }))
}
pub fn renames(&self) -> usize {
self.count(|a| matches!(a, Action::Rename { .. }))
}
pub fn deletes(&self) -> usize {
self.count(|a| matches!(a, Action::Delete { .. }))
}
pub fn skips(&self) -> usize {
self.count(|a| matches!(a, Action::Skip { .. }))
}
pub fn artifact_writes(&self) -> usize {
self.count(|a| matches!(a, Action::WriteArtifact { .. }))
}
pub fn artifact_deletes(&self) -> usize {
self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
}
fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
self.actions.iter().filter(|a| pred(a)).count()
}
}
pub fn reconcile(
manifest: &Manifest,
desired: &[Desired],
local: &HashMap<String, LocalFile>,
sources: &[SourceStatus],
) -> Plan {
let mut actions: Vec<Action> = Vec::new();
let desired = aggregate_desired(desired);
let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.clip.id.as_str()).collect();
let can_delete = deletion_allowed(sources);
for d in &desired {
let before = actions.len();
plan_desired(d, manifest, local, can_delete, &mut actions);
let audio_deleted = actions[before..]
.iter()
.any(|a| matches!(a, Action::Delete { .. }));
if audio_deleted {
co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
} else {
plan_clip_artifacts(d, manifest, can_delete, &mut actions);
}
}
for (clip_id, _entry) in manifest.iter() {
if desired_ids.contains(clip_id.as_str()) {
continue;
}
match delete_action(clip_id, manifest, can_delete) {
Some(action) => {
actions.push(action);
co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
}
None => actions.push(Action::Skip {
clip_id: clip_id.clone(),
}),
}
}
suppress_path_aliasing(&mut actions);
Plan { actions }
}
pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
let mut saw_mirror = false;
for status in sources {
if !status.fully_enumerated {
return false;
}
if status.mode == SourceMode::Mirror {
saw_mirror = true;
}
}
saw_mirror
}
fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
if !can_delete {
return None;
}
let entry = manifest.get(clip_id)?;
if entry.path.is_empty() || entry.preserve {
return None;
}
Some(Action::Delete {
path: entry.path.clone(),
clip_id: clip_id.to_string(),
})
}
fn delete_artifact_action(
owner_id: &str,
kind: ArtifactKind,
path: &str,
manifest: &Manifest,
can_delete: bool,
) -> Option<Action> {
if !can_delete {
return None;
}
let entry = manifest.get(owner_id)?;
if path.is_empty() || entry.preserve {
return None;
}
Some(Action::DeleteArtifact {
kind,
path: path.to_string(),
owner_id: owner_id.to_string(),
})
}
fn is_per_clip_kind(kind: ArtifactKind) -> bool {
matches!(kind, ArtifactKind::CoverJpg | ArtifactKind::CoverWebp)
}
fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
match kind {
ArtifactKind::CoverJpg | ArtifactKind::CoverWebp => false,
ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => true,
}
}
fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
match kind {
ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => None,
}
}
fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
let mut out = Vec::new();
if let Some(state) = &entry.cover_jpg {
out.push((ArtifactKind::CoverJpg, state));
}
if let Some(state) = &entry.cover_webp {
out.push((ArtifactKind::CoverWebp, state));
}
out
}
pub(crate) fn set_manifest_artifact(
entry: &mut ManifestEntry,
kind: ArtifactKind,
state: Option<ArtifactState>,
) {
match kind {
ArtifactKind::CoverJpg => entry.cover_jpg = state,
ArtifactKind::CoverWebp => entry.cover_webp = state,
ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::Playlist => {}
}
}
fn plan_clip_artifacts(d: &Desired, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
let owner_id = d.clip.id.as_str();
let entry = manifest.get(owner_id);
for artifact in &d.artifacts {
if !is_per_clip_kind(artifact.kind) {
continue;
}
let needs_write = match entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind)) {
None => true,
Some(state) => state.hash != artifact.hash || state.path != artifact.path,
};
if needs_write {
out.push(Action::WriteArtifact {
kind: artifact.kind,
path: artifact.path.clone(),
source_url: artifact.source_url.clone(),
hash: artifact.hash.clone(),
owner_id: owner_id.to_string(),
content: None,
});
}
}
let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
if !protected_now && let Some(entry) = entry {
let desired_kinds: BTreeSet<ArtifactKind> = d
.artifacts
.iter()
.filter(|a| is_per_clip_kind(a.kind))
.map(|a| a.kind)
.collect();
for (kind, state) in manifest_artifacts(entry) {
if removed_kind_delete_eligible(kind)
&& !desired_kinds.contains(&kind)
&& let Some(action) =
delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
{
out.push(action);
}
}
}
}
fn co_delete_artifacts(
owner_id: &str,
manifest: &Manifest,
can_delete: bool,
out: &mut Vec<Action>,
) {
let Some(entry) = manifest.get(owner_id) else {
return;
};
for (kind, state) in manifest_artifacts(entry) {
if let Some(action) =
delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
{
out.push(action);
}
}
}
fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
for d in desired {
match by_id.get_mut(d.clip.id.as_str()) {
None => {
by_id.insert(d.clip.id.as_str(), d.clone());
}
Some(acc) => {
let take = rep_key(d) < rep_key(acc);
acc.private = acc.private || d.private;
acc.trashed = acc.trashed && d.trashed;
for mode in &d.modes {
if !acc.modes.contains(mode) {
acc.modes.push(*mode);
}
}
if take {
acc.clip = d.clip.clone();
acc.path = d.path.clone();
acc.format = d.format;
acc.meta_hash = d.meta_hash.clone();
acc.art_hash = d.art_hash.clone();
acc.artifacts = d.artifacts.clone();
}
}
}
}
let mut out: Vec<Desired> = by_id.into_values().collect();
for d in &mut out {
let has_mirror = d.modes.contains(&SourceMode::Mirror);
let has_copy = d.modes.contains(&SourceMode::Copy);
d.modes.clear();
if has_mirror {
d.modes.push(SourceMode::Mirror);
}
if has_copy {
d.modes.push(SourceMode::Copy);
}
}
out
}
fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
let format = match d.format {
AudioFormat::Mp3 => 0,
AudioFormat::Flac => 1,
AudioFormat::Wav => 2,
};
(
d.path.as_str(),
d.meta_hash.as_str(),
d.art_hash.as_str(),
format,
)
}
fn suppress_path_aliasing(actions: &mut [Action]) {
let targets: BTreeSet<String> = actions
.iter()
.filter_map(|a| match a {
Action::Download { path, .. }
| Action::Reformat { path, .. }
| Action::WriteArtifact { path, .. } => Some(path.clone()),
Action::Rename { to, .. } => Some(to.clone()),
_ => None,
})
.collect();
for a in actions.iter_mut() {
if let Action::Delete { path, clip_id } = a
&& targets.contains(path.as_str())
{
*a = Action::Skip {
clip_id: clip_id.clone(),
};
}
if let Action::DeleteArtifact { path, owner_id, .. } = a
&& targets.contains(path.as_str())
{
*a = Action::Skip {
clip_id: owner_id.clone(),
};
}
}
}
fn plan_desired(
d: &Desired,
manifest: &Manifest,
local: &HashMap<String, LocalFile>,
can_delete: bool,
out: &mut Vec<Action>,
) {
let clip_id = d.clip.id.as_str();
let copy_held = d.modes.contains(&SourceMode::Copy);
if d.trashed && !d.private && !copy_held {
match delete_action(clip_id, manifest, can_delete) {
Some(action) => out.push(action),
None => out.push(Action::Skip {
clip_id: clip_id.to_string(),
}),
}
return;
}
let Some(entry) = manifest.get(clip_id) else {
out.push(Action::Download {
clip: d.clip.clone(),
lineage: d.lineage.clone(),
path: d.path.clone(),
format: d.format,
});
return;
};
let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
if missing {
out.push(Action::Download {
clip: d.clip.clone(),
lineage: d.lineage.clone(),
path: d.path.clone(),
format: d.format,
});
return;
}
if d.format != entry.format {
out.push(Action::Reformat {
clip: d.clip.clone(),
path: d.path.clone(),
from_path: entry.path.clone(),
from: entry.format,
to: d.format,
});
return;
}
if d.path != entry.path {
out.push(Action::Rename {
from: entry.path.clone(),
to: d.path.clone(),
});
if meta_or_art_changed(d, entry) {
out.push(Action::Retag {
clip: d.clip.clone(),
lineage: d.lineage.clone(),
path: d.path.clone(),
});
}
return;
}
if meta_or_art_changed(d, entry) {
out.push(Action::Retag {
clip: d.clip.clone(),
lineage: d.lineage.clone(),
path: entry.path.clone(),
});
return;
}
out.push(Action::Skip {
clip_id: clip_id.to_string(),
});
}
fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
}
pub fn album_desired(desired: &[Desired], animated_covers: bool) -> Vec<AlbumDesired> {
let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
for d in desired {
groups
.entry(d.lineage.root_id.as_str())
.or_default()
.push(d);
}
groups
.into_iter()
.map(|(root_id, members)| {
let album_dir = album_dir_of(&members);
let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
kind: ArtifactKind::FolderJpg,
path: album_child(&album_dir, "folder.jpg"),
source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
hash: art_hash(&source.clip),
});
let folder_webp = animated_covers
.then(|| folder_webp_source(&members))
.flatten()
.map(|source| DesiredArtifact {
kind: ArtifactKind::FolderWebp,
path: album_child(&album_dir, "cover.webp"),
source_url: source.clip.video_cover_url.clone(),
hash: art_url_hash(&source.clip.video_cover_url),
});
AlbumDesired {
root_id: root_id.to_owned(),
folder_jpg,
folder_webp,
}
})
.collect()
}
fn album_dir_of(members: &[&Desired]) -> String {
members
.iter()
.map(|d| parent_dir(&d.path))
.min()
.unwrap_or("")
.to_owned()
}
fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
members
.iter()
.copied()
.filter(|d| {
d.clip
.selected_image_url()
.is_some_and(|url| !url.is_empty())
})
.min_by(|a, b| {
b.clip
.play_count
.cmp(&a.clip.play_count)
.then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
.then_with(|| a.clip.id.cmp(&b.clip.id))
})
}
fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
members
.iter()
.copied()
.filter(|d| !d.clip.video_cover_url.is_empty())
.min_by(|a, b| {
a.clip
.created_at
.cmp(&b.clip.created_at)
.then_with(|| a.clip.id.cmp(&b.clip.id))
})
}
fn parent_dir(path: &str) -> &str {
match path.rsplit_once('/') {
Some((dir, _)) => dir,
None => "",
}
}
fn album_child(album_dir: &str, name: &str) -> String {
if album_dir.is_empty() {
name.to_owned()
} else {
format!("{album_dir}/{name}")
}
}
pub fn plan_album_artifacts(
desired: &[AlbumDesired],
albums: &BTreeMap<String, AlbumArt>,
can_delete: bool,
) -> Vec<Action> {
let mut actions: Vec<Action> = Vec::new();
let by_root: BTreeMap<&str, &AlbumDesired> =
desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
for d in desired {
let stored = albums.get(&d.root_id);
for artifact in [d.folder_jpg.as_ref(), d.folder_webp.as_ref()]
.into_iter()
.flatten()
{
let needs_write = match stored.and_then(|a| a.artifact(artifact.kind)) {
None => true,
Some(state) => state.hash != artifact.hash || state.path != artifact.path,
};
if needs_write {
actions.push(Action::WriteArtifact {
kind: artifact.kind,
path: artifact.path.clone(),
source_url: artifact.source_url.clone(),
hash: artifact.hash.clone(),
owner_id: d.root_id.clone(),
content: None,
});
}
}
}
if can_delete {
for (root_id, art) in albums {
for (kind, state) in album_artifacts(art) {
let desired_here = by_root
.get(root_id.as_str())
.is_some_and(|d| album_desires_kind(d, kind));
if !desired_here && !state.path.is_empty() {
actions.push(Action::DeleteArtifact {
kind,
path: state.path.clone(),
owner_id: root_id.clone(),
});
}
}
}
}
actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
actions
}
fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
let mut out = Vec::new();
if let Some(state) = &art.folder_jpg {
out.push((ArtifactKind::FolderJpg, state));
}
if let Some(state) = &art.folder_webp {
out.push((ArtifactKind::FolderWebp, state));
}
out
}
fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
match kind {
ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
ArtifactKind::FolderWebp => d.folder_webp.is_some(),
ArtifactKind::CoverJpg | ArtifactKind::CoverWebp | ArtifactKind::Playlist => false,
}
}
fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
match action {
Action::WriteArtifact { owner_id, kind, .. }
| Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
_ => ("", ArtifactKind::CoverJpg),
}
}
pub fn plan_playlist_artifacts(
desired: &[PlaylistDesired],
stored: &BTreeMap<String, PlaylistState>,
can_delete: bool,
list_fully_enumerated: bool,
) -> Vec<Action> {
let mut actions: Vec<Action> = Vec::new();
let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
let deletes_allowed = can_delete && list_fully_enumerated;
for d in desired {
let stored_here = stored.get(&d.id);
let needs_write = match stored_here {
None => true,
Some(state) => state.hash != d.hash || state.path != d.path,
};
if needs_write {
actions.push(Action::WriteArtifact {
kind: ArtifactKind::Playlist,
path: d.path.clone(),
source_url: String::new(),
hash: d.hash.clone(),
owner_id: d.id.clone(),
content: Some(d.content.clone()),
});
}
if deletes_allowed
&& let Some(state) = stored_here
&& !state.path.is_empty()
&& state.path != d.path
{
actions.push(Action::DeleteArtifact {
kind: ArtifactKind::Playlist,
path: state.path.clone(),
owner_id: d.id.clone(),
});
}
}
if deletes_allowed {
for (id, state) in stored {
if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
actions.push(Action::DeleteArtifact {
kind: ArtifactKind::Playlist,
path: state.path.clone(),
owner_id: id.clone(),
});
}
}
}
actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
suppress_path_aliasing(&mut actions);
actions
}
fn playlist_action_key(action: &Action) -> (&str, u8) {
match action {
Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
Action::Skip { clip_id } => (clip_id.as_str(), 2),
_ => ("", 3),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn clip(id: &str) -> Clip {
Clip {
id: id.to_string(),
title: "Song".to_string(),
..Default::default()
}
}
fn lineage(id: &str) -> LineageContext {
LineageContext::own_root(&clip(id))
}
fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
ManifestEntry {
path: path.to_string(),
format,
meta_hash: meta.to_string(),
art_hash: art.to_string(),
size: 100,
preserve: false,
..Default::default()
}
}
fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
ManifestEntry {
preserve: true,
..entry(path, format, meta, art)
}
}
fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
Desired {
clip: clip(id),
lineage: lineage(id),
path: path.to_string(),
format,
meta_hash: meta.to_string(),
art_hash: art.to_string(),
modes: vec![SourceMode::Mirror],
trashed: false,
private: false,
artifacts: Vec::new(),
}
}
fn present(size: u64) -> LocalFile {
LocalFile { exists: true, size }
}
fn local_present(id: &str) -> HashMap<String, LocalFile> {
[(id.to_string(), present(100))].into_iter().collect()
}
fn mirror_ok() -> Vec<SourceStatus> {
vec![SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: true,
}]
}
#[test]
fn not_in_manifest_downloads() {
let manifest = Manifest::new();
let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
assert_eq!(
plan.actions,
vec![Action::Download {
clip: clip("a"),
lineage: lineage("a"),
path: "a.flac".to_string(),
format: AudioFormat::Flac,
}]
);
}
#[test]
fn unchanged_clip_skips() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(
plan.actions,
vec![Action::Skip {
clip_id: "a".to_string()
}]
);
}
#[test]
fn meta_change_retags_in_place() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(
plan.actions,
vec![Action::Retag {
clip: clip("a"),
lineage: lineage("a"),
path: "a.flac".to_string(),
}]
);
}
#[test]
fn art_change_retags_in_place() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(
plan.actions,
vec![Action::Retag {
clip: clip("a"),
lineage: lineage("a"),
path: "a.flac".to_string(),
}]
);
}
#[test]
fn rename_when_path_changes() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(
plan.actions,
vec![Action::Rename {
from: "old/a.flac".to_string(),
to: "new/a.flac".to_string(),
}]
);
}
#[test]
fn rename_with_meta_change_also_retags() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(
plan.actions,
vec![
Action::Rename {
from: "old/a.flac".to_string(),
to: "new/a.flac".to_string(),
},
Action::Retag {
clip: clip("a"),
lineage: lineage("a"),
path: "new/a.flac".to_string(),
},
]
);
}
#[test]
fn rename_without_meta_change_does_not_retag() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(plan.renames(), 1);
assert_eq!(plan.retags(), 0);
}
#[test]
fn format_change_reformats() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(
plan.actions,
vec![Action::Reformat {
clip: clip("a"),
path: "a.mp3".to_string(),
from_path: "a.flac".to_string(),
from: AudioFormat::Flac,
to: AudioFormat::Mp3,
}]
);
}
#[test]
fn format_change_takes_precedence_over_rename_and_retag() {
let mut manifest = Manifest::new();
manifest.insert(
"a",
entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
);
let d = vec![desired(
"a",
"new/a.mp3",
AudioFormat::Mp3,
"new",
"new-art",
)];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(plan.reformats(), 1);
assert_eq!(plan.renames(), 0);
assert_eq!(plan.retags(), 0);
}
#[test]
fn zero_length_file_downloads_even_when_hashes_match() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let local: HashMap<String, LocalFile> = [(
"a".to_string(),
LocalFile {
exists: true,
size: 0,
},
)]
.into_iter()
.collect();
let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
let plan = reconcile(&manifest, &d, &local, &mirror_ok());
assert_eq!(plan.downloads(), 1);
assert_eq!(plan.skips(), 0);
}
#[test]
fn missing_file_downloads_even_when_hashes_match() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let local: HashMap<String, LocalFile> = [(
"a".to_string(),
LocalFile {
exists: false,
size: 0,
},
)]
.into_iter()
.collect();
let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
let plan = reconcile(&manifest, &d, &local, &mirror_ok());
assert_eq!(plan.downloads(), 1);
}
#[test]
fn absent_local_probe_treated_as_missing() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
assert_eq!(plan.downloads(), 1);
}
#[test]
fn missing_file_download_wins_over_format_difference() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let local: HashMap<String, LocalFile> = [(
"a".to_string(),
LocalFile {
exists: false,
size: 0,
},
)]
.into_iter()
.collect();
let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
let plan = reconcile(&manifest, &d, &local, &mirror_ok());
assert_eq!(plan.downloads(), 1);
assert_eq!(plan.reformats(), 0);
}
#[test]
fn trashed_clip_deletes_local_file() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
d.trashed = true;
let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
assert_eq!(
plan.actions,
vec![Action::Delete {
path: "a.flac".to_string(),
clip_id: "a".to_string(),
}]
);
}
#[test]
fn trashed_clip_not_in_manifest_skips() {
let manifest = Manifest::new();
let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
d.trashed = true;
let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
assert_eq!(
plan.actions,
vec![Action::Skip {
clip_id: "a".to_string()
}]
);
}
#[test]
fn private_clip_is_kept() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
d.private = true;
let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
assert_eq!(
plan.actions,
vec![Action::Skip {
clip_id: "a".to_string()
}]
);
}
#[test]
fn private_beats_trashed_never_deletes() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
d.trashed = true;
d.private = true;
let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.skips(), 1);
}
#[test]
fn copy_held_trashed_clip_is_not_deleted() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
d.modes = vec![SourceMode::Copy];
d.trashed = true;
let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
assert_eq!(plan.deletes(), 0);
assert_eq!(
plan.actions,
vec![Action::Skip {
clip_id: "a".to_string()
}]
);
}
#[test]
fn absent_clip_deleted_when_all_mirrors_enumerated() {
let mut manifest = Manifest::new();
manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
assert_eq!(
plan.actions,
vec![Action::Delete {
path: "gone.flac".to_string(),
clip_id: "gone".to_string(),
}]
);
}
#[test]
fn absent_clip_kept_when_any_mirror_not_enumerated() {
let mut manifest = Manifest::new();
manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
let sources = vec![
SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: true,
},
SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: false,
},
];
let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
assert_eq!(plan.deletes(), 0);
assert_eq!(
plan.actions,
vec![Action::Skip {
clip_id: "gone".to_string()
}]
);
}
#[test]
fn empty_listing_cannot_cause_deletion() {
let mut manifest = Manifest::new();
manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
let sources = vec![SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: false,
}];
let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.skips(), 1);
}
#[test]
fn no_mirror_sources_means_no_deletion() {
let mut manifest = Manifest::new();
manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
let copy_only = vec![SourceStatus {
mode: SourceMode::Copy,
fully_enumerated: true,
}];
assert_eq!(
reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
0
);
assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
}
#[test]
fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
let mut manifest = Manifest::new();
manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
let sources = vec![
SourceStatus {
mode: SourceMode::Copy,
fully_enumerated: true,
},
SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: false,
},
];
assert_eq!(
reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
0
);
}
#[test]
fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
let mut manifest = Manifest::new();
manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
held.modes = vec![SourceMode::Copy];
let local: HashMap<String, LocalFile> = [
("keep".to_string(), present(100)),
("gone".to_string(), present(100)),
]
.into_iter()
.collect();
let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
assert!(plan.actions.contains(&Action::Skip {
clip_id: "keep".to_string()
}));
assert!(plan.actions.contains(&Action::Delete {
path: "gone.flac".to_string(),
clip_id: "gone".to_string(),
}));
assert!(
!plan
.actions
.iter()
.any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
);
}
#[test]
fn orphan_with_preserve_marker_is_kept() {
let mut manifest = Manifest::new();
manifest.insert(
"gone",
preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
);
let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
assert_eq!(plan.deletes(), 0);
assert_eq!(
plan.actions,
vec![Action::Skip {
clip_id: "gone".to_string()
}]
);
}
#[test]
fn trashed_clip_with_preserve_marker_is_kept() {
let mut manifest = Manifest::new();
manifest.insert(
"a",
preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
);
let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
d.trashed = true;
let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.skips(), 1);
}
#[test]
fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
d.trashed = true;
let sources = vec![SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: false,
}];
let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.skips(), 1);
}
#[test]
fn trashed_clip_kept_when_sources_empty() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
d.trashed = true;
let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.skips(), 1);
}
#[test]
fn failed_copy_listing_suppresses_orphan_deletion() {
let mut manifest = Manifest::new();
manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
let sources = vec![
SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: true,
},
SourceStatus {
mode: SourceMode::Copy,
fully_enumerated: false,
},
];
let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
assert_eq!(plan.deletes(), 0);
}
#[test]
fn failed_copy_listing_suppresses_trashed_deletion() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
d.trashed = true;
let sources = vec![
SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: true,
},
SourceStatus {
mode: SourceMode::Copy,
fully_enumerated: false,
},
];
let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.skips(), 1);
}
#[test]
fn empty_path_entry_never_deletes() {
let mut manifest = Manifest::new();
manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
assert_eq!(plan.deletes(), 0);
assert_eq!(
plan.actions,
vec![Action::Skip {
clip_id: "gone".to_string()
}]
);
}
#[test]
fn delete_suppressed_when_path_aliases_rename_target() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
let local: HashMap<String, LocalFile> = [
("a".to_string(), present(100)),
("b".to_string(), present(100)),
]
.into_iter()
.collect();
let plan = reconcile(&manifest, &d, &local, &mirror_ok());
assert!(plan.actions.contains(&Action::Rename {
from: "old/a.flac".to_string(),
to: "new/a.flac".to_string(),
}));
assert!(
!plan
.actions
.iter()
.any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
);
assert!(plan.actions.contains(&Action::Skip {
clip_id: "b".to_string()
}));
}
#[test]
fn delete_suppressed_when_path_aliases_download_target() {
let mut manifest = Manifest::new();
manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
assert!(
!plan
.actions
.iter()
.any(|a| matches!(a, Action::Delete { .. }))
);
assert_eq!(plan.downloads(), 1);
}
#[test]
fn delete_artifact_suppressed_when_path_aliases_rename_target() {
let mut actions = vec![
Action::Rename {
from: "old/song.flac".to_string(),
to: "new/cover.jpg".to_string(),
},
Action::DeleteArtifact {
kind: ArtifactKind::CoverJpg,
path: "new/cover.jpg".to_string(),
owner_id: "a".to_string(),
},
];
suppress_path_aliasing(&mut actions);
assert!(
!actions
.iter()
.any(|a| matches!(a, Action::DeleteArtifact { .. })),
"a sidecar delete must not alias a rename target"
);
assert!(actions.contains(&Action::Skip {
clip_id: "a".to_string()
}));
assert!(actions.contains(&Action::Rename {
from: "old/song.flac".to_string(),
to: "new/cover.jpg".to_string(),
}));
}
#[test]
fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
let mut actions = vec![
Action::WriteArtifact {
kind: ArtifactKind::FolderJpg,
path: "creator/album/folder.jpg".to_string(),
source_url: "https://art/large.jpg".to_string(),
hash: "h".to_string(),
owner_id: "root".to_string(),
content: None,
},
Action::DeleteArtifact {
kind: ArtifactKind::FolderJpg,
path: "creator/album/folder.jpg".to_string(),
owner_id: "root-old".to_string(),
},
];
suppress_path_aliasing(&mut actions);
assert!(
!actions
.iter()
.any(|a| matches!(a, Action::DeleteArtifact { .. }))
);
assert!(actions.contains(&Action::Skip {
clip_id: "root-old".to_string()
}));
}
#[test]
fn duplicate_trashed_does_not_defeat_copy_sibling() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
copy_entry.modes = vec![SourceMode::Copy];
let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
trashed_entry.modes = vec![SourceMode::Mirror];
trashed_entry.trashed = true;
let plan = reconcile(
&manifest,
&[copy_entry, trashed_entry],
&local_present("a"),
&mirror_ok(),
);
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.skips(), 1);
}
#[test]
fn duplicate_trashed_does_not_defeat_private_sibling() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
private_entry.private = true;
let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
trashed_entry.trashed = true;
let plan = reconcile(
&manifest,
&[private_entry, trashed_entry],
&local_present("a"),
&mirror_ok(),
);
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.skips(), 1);
}
#[test]
fn duplicate_trashed_deletes_only_when_all_trashed() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
first.trashed = true;
let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
second.trashed = true;
let plan = reconcile(
&manifest,
&[first, second],
&local_present("a"),
&mirror_ok(),
);
assert_eq!(plan.deletes(), 1);
}
#[test]
fn duplicate_desired_unions_modes() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
mirror_entry.modes = vec![SourceMode::Mirror];
mirror_entry.trashed = true;
let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
copy_entry.modes = vec![SourceMode::Copy];
let plan = reconcile(
&manifest,
&[mirror_entry, copy_entry],
&local_present("a"),
&mirror_ok(),
);
assert_eq!(plan.deletes(), 0);
}
#[test]
fn private_new_clip_downloads() {
let manifest = Manifest::new();
let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
d.private = true;
let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
assert_eq!(plan.downloads(), 1);
}
#[test]
fn private_zero_length_file_redownloads() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let local: HashMap<String, LocalFile> = [(
"a".to_string(),
LocalFile {
exists: true,
size: 0,
},
)]
.into_iter()
.collect();
let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
d.private = true;
let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
assert_eq!(plan.downloads(), 1);
}
#[test]
fn private_meta_change_retags() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
d.private = true;
let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
assert_eq!(plan.retags(), 1);
assert_eq!(plan.deletes(), 0);
}
#[test]
fn absent_private_clip_protected_by_preserve_marker() {
let mut manifest = Manifest::new();
manifest.insert(
"a",
preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
);
let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.skips(), 1);
}
#[test]
fn output_is_deterministic_regardless_of_input_order() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
let local: HashMap<String, LocalFile> = ["a", "b", "z"]
.iter()
.map(|id| (id.to_string(), present(100)))
.collect();
let forward = vec![
desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
];
let mut reversed = forward.clone();
reversed.reverse();
let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
assert_eq!(p1.actions, p2.actions);
let ids: Vec<&str> = p1
.actions
.iter()
.map(|a| match a {
Action::Skip { clip_id } => clip_id.as_str(),
Action::Retag { clip, .. } => clip.id.as_str(),
Action::Download { clip, .. } => clip.id.as_str(),
Action::Delete { clip_id, .. } => clip_id.as_str(),
Action::Reformat { clip, .. } => clip.id.as_str(),
Action::Rename { to, .. } => to.as_str(),
Action::WriteArtifact { owner_id, .. }
| Action::DeleteArtifact { owner_id, .. } => owner_id.as_str(),
})
.collect();
assert_eq!(ids, ["a", "b", "c", "z"]);
}
#[test]
fn empty_inputs_do_not_panic() {
let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
assert!(plan.is_empty());
assert_eq!(plan.len(), 0);
}
#[test]
fn empty_desired_with_full_manifest_deletes_all() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
assert_eq!(plan.deletes(), 2);
}
#[test]
fn full_desired_with_empty_manifest_downloads_all() {
let d = vec![
desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
];
let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
assert_eq!(plan.downloads(), 2);
}
#[test]
fn plan_counts_sum_to_len() {
let mut manifest = Manifest::new();
manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
manifest.insert(
"retag",
entry("retag.flac", AudioFormat::Flac, "old", "art"),
);
manifest.insert(
"reformat",
entry("reformat.flac", AudioFormat::Flac, "m", "art"),
);
manifest.insert(
"rename",
entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
);
manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
.iter()
.map(|id| (id.to_string(), present(100)))
.collect();
let d = vec![
desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
];
let plan = reconcile(&manifest, &d, &local, &mirror_ok());
let summed = plan.downloads()
+ plan.reformats()
+ plan.retags()
+ plan.renames()
+ plan.deletes()
+ plan.skips();
assert_eq!(summed, plan.len());
assert_eq!(plan.downloads(), 1);
assert_eq!(plan.reformats(), 1);
assert_eq!(plan.retags(), 1);
assert_eq!(plan.renames(), 1);
assert_eq!(plan.deletes(), 1);
assert_eq!(plan.skips(), 1);
}
fn cover(path: &str, hash: &str) -> ArtifactState {
ArtifactState {
path: path.to_string(),
hash: hash.to_string(),
}
}
fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
DesiredArtifact {
kind,
path: path.to_string(),
source_url: url.to_string(),
hash: hash.to_string(),
}
}
fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
Desired {
artifacts: arts,
..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
}
}
fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
ManifestEntry {
cover_jpg: Some(cover(cover_path, cover_hash)),
..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
}
}
fn write_artifacts(plan: &Plan) -> Vec<&Action> {
plan.actions
.iter()
.filter(|a| matches!(a, Action::WriteArtifact { .. }))
.collect()
}
#[test]
fn write_artifact_emitted_when_manifest_lacks_it() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let d = vec![desired_arts(
"a",
vec![art(
ArtifactKind::CoverJpg,
"a/cover.jpg",
"https://art/a",
"h1",
)],
)];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(plan.artifact_writes(), 1);
assert_eq!(plan.artifact_deletes(), 0);
assert_eq!(plan.skips(), 1);
assert_eq!(
write_artifacts(&plan)[0],
&Action::WriteArtifact {
kind: ArtifactKind::CoverJpg,
path: "a/cover.jpg".to_string(),
source_url: "https://art/a".to_string(),
hash: "h1".to_string(),
owner_id: "a".to_string(),
content: None,
}
);
}
#[test]
fn write_artifact_emitted_when_hash_differs() {
let mut manifest = Manifest::new();
manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
let d = vec![desired_arts(
"a",
vec![art(
ArtifactKind::CoverJpg,
"a/cover.jpg",
"https://art/a",
"new",
)],
)];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(plan.artifact_writes(), 1);
assert_eq!(plan.artifact_deletes(), 0);
if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
assert_eq!(hash, "new");
} else {
panic!("expected a WriteArtifact");
}
}
#[test]
fn write_artifact_skipped_when_hash_matches() {
let mut manifest = Manifest::new();
manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
let d = vec![desired_arts(
"a",
vec![art(
ArtifactKind::CoverJpg,
"a/cover.jpg",
"https://art/a",
"h1",
)],
)];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(plan.artifact_writes(), 0);
assert_eq!(plan.artifact_deletes(), 0);
assert_eq!(
plan.actions,
vec![Action::Skip {
clip_id: "a".to_string()
}]
);
}
#[test]
fn removed_kind_cover_is_kept_not_deleted() {
let mut manifest = Manifest::new();
manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
let d = vec![desired_arts("a", vec![])];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(plan.artifact_deletes(), 0);
assert_eq!(plan.artifact_writes(), 0);
assert_eq!(plan.deletes(), 0);
assert_eq!(
plan.actions,
vec![Action::Skip {
clip_id: "a".to_string()
}]
);
assert!(!plan.actions.iter().any(|a| matches!(
a,
Action::DeleteArtifact {
kind: ArtifactKind::CoverJpg,
..
}
)));
}
#[test]
fn delete_artifact_never_on_incomplete_listing() {
let mut manifest = Manifest::new();
manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
let sources = vec![SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: false,
}];
let local: HashMap<String, LocalFile> = [
("a".to_string(), present(100)),
("b".to_string(), present(100)),
]
.into_iter()
.collect();
let plan = reconcile(&manifest, &d, &local, &sources);
assert_eq!(plan.artifact_deletes(), 0);
assert_eq!(plan.deletes(), 0);
}
#[test]
fn delete_artifact_never_when_entry_preserved() {
let mut manifest = Manifest::new();
let preserved = ManifestEntry {
preserve: true,
..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
};
manifest.insert("a", preserved);
let d = vec![desired_arts("a", vec![])];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(plan.artifact_deletes(), 0);
}
#[test]
fn co_delete_never_when_path_empty() {
let mut manifest = Manifest::new();
manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
assert_eq!(plan.deletes(), 1);
assert_eq!(plan.artifact_deletes(), 0);
}
#[test]
fn co_delete_absent_clip_deletes_audio_and_cover() {
let mut manifest = Manifest::new();
manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
assert_eq!(plan.deletes(), 1);
assert_eq!(plan.artifact_deletes(), 1);
assert!(plan.actions.contains(&Action::Delete {
path: "gone.flac".to_string(),
clip_id: "gone".to_string(),
}));
assert!(plan.actions.contains(&Action::DeleteArtifact {
kind: ArtifactKind::CoverJpg,
path: "gone/cover.jpg".to_string(),
owner_id: "gone".to_string(),
}));
}
#[test]
fn co_delete_absent_clip_suppressed_when_not_enumerated() {
let mut manifest = Manifest::new();
manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
let sources = vec![SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: false,
}];
let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.artifact_deletes(), 0);
}
#[test]
fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
let mut manifest = Manifest::new();
manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
let mut d = desired_arts("a", vec![]);
d.trashed = true;
let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
assert_eq!(plan.deletes(), 1);
assert_eq!(plan.artifact_deletes(), 1);
}
#[test]
fn co_delete_trashed_suppressed_when_not_enumerated() {
let mut manifest = Manifest::new();
manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
let mut d = desired_arts("a", vec![]);
d.trashed = true;
let sources = vec![SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: false,
}];
let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.artifact_deletes(), 0);
assert_eq!(plan.skips(), 1);
}
#[test]
fn co_delete_trashed_suppressed_when_preserved() {
let mut manifest = Manifest::new();
let preserved = ManifestEntry {
preserve: true,
..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
};
manifest.insert("a", preserved);
let mut d = desired_arts("a", vec![]);
d.trashed = true;
let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.artifact_deletes(), 0);
}
#[test]
fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
let d = vec![desired_arts(
"a",
vec![art(
ArtifactKind::CoverJpg,
"shared/cover.jpg",
"https://art/a",
"h2",
)],
)];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(plan.artifact_writes(), 1);
assert!(!plan.actions.iter().any(
|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
));
assert!(plan.actions.contains(&Action::Delete {
path: "b.flac".to_string(),
clip_id: "b".to_string(),
}));
}
#[test]
fn suppress_downgrades_delete_artifact_colliding_with_download() {
let mut manifest = Manifest::new();
manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
assert_eq!(plan.downloads(), 1);
assert!(
!plan
.actions
.iter()
.any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
);
}
#[test]
fn adding_artifacts_leaves_the_audio_plan_unchanged() {
let build = |with_art: bool| {
let mut manifest = Manifest::new();
manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
manifest.insert(
"trash",
entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
);
let keep = if with_art {
desired_arts(
"keep",
vec![art(
ArtifactKind::CoverJpg,
"keep/cover.jpg",
"https://art/keep",
"h1",
)],
)
} else {
desired_arts("keep", vec![])
};
let mut trash = desired_arts("trash", vec![]);
trash.trashed = true;
let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
.iter()
.map(|id| (id.to_string(), present(100)))
.collect();
reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
};
let with = build(true);
let without = build(false);
let audio = |plan: &Plan| -> Vec<Action> {
plan.actions
.iter()
.filter(|a| {
!matches!(
a,
Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
)
})
.cloned()
.collect()
};
assert_eq!(audio(&with), audio(&without));
assert_eq!(with.deletes(), without.deletes());
assert_eq!(with.deletes(), 2);
assert_eq!(with.artifact_deletes(), 2);
assert_eq!(with.artifact_writes(), 0);
}
#[test]
fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
let mut manifest = Manifest::new();
manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
assert!(!manifest.get("a").unwrap().preserve);
let private = Desired {
private: true,
..desired_arts("a", vec![])
};
let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
assert_eq!(plan.artifact_deletes(), 0);
let copy_held = Desired {
modes: vec![SourceMode::Copy],
..desired_arts("a", vec![])
};
let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
assert_eq!(plan.artifact_deletes(), 0);
}
#[test]
fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
let mut manifest = Manifest::new();
manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
let d = vec![desired_arts(
"a",
vec![art(
ArtifactKind::CoverJpg,
"new/cover.jpg",
"https://art/a",
"h1",
)],
)];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(plan.artifact_writes(), 1);
assert_eq!(plan.artifact_deletes(), 0);
if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
assert_eq!(path, "new/cover.jpg");
} else {
panic!("expected a WriteArtifact");
}
}
#[test]
fn per_clip_reconcile_ignores_album_and_library_kinds() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let d = vec![desired_arts(
"a",
vec![
art(
ArtifactKind::FolderJpg,
"a/folder.jpg",
"https://art/folder",
"hf",
),
art(
ArtifactKind::Playlist,
"a/list.m3u",
"https://art/list",
"hp",
),
art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
],
)];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(plan.artifact_writes(), 1);
let paths: Vec<&str> = plan
.actions
.iter()
.filter_map(|a| match a {
Action::WriteArtifact { path, .. } => Some(path.as_str()),
_ => None,
})
.collect();
assert_eq!(paths, vec!["a/cover.jpg"]);
}
#[test]
fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
let d = vec![desired_arts(
"a",
vec![art(
ArtifactKind::FolderWebp,
"a/folder.webp",
"https://art/folder",
"hf",
)],
)];
let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
assert_eq!(plan.artifact_writes(), 0);
assert_eq!(plan.artifact_deletes(), 0);
}
fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
Clip {
id: id.to_string(),
title: "Song".to_string(),
image_large_url: image.to_string(),
video_cover_url: video.to_string(),
play_count,
created_at: created_at.to_string(),
..Default::default()
}
}
fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
let mut lineage = LineageContext::own_root(&clip);
lineage.root_id = root_id.to_string();
Desired {
clip,
lineage,
path: path.to_string(),
format: AudioFormat::Flac,
meta_hash: "m".to_string(),
art_hash: "a".to_string(),
modes: vec![SourceMode::Mirror],
trashed: false,
private: false,
artifacts: Vec::new(),
}
}
fn stored(path: &str, hash: &str) -> ArtifactState {
ArtifactState {
path: path.to_string(),
hash: hash.to_string(),
}
}
#[test]
fn folder_jpg_source_is_most_played() {
let members = vec![
album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
];
let albums = album_desired(&members, false);
assert_eq!(albums.len(), 1);
let jpg = albums[0].folder_jpg.as_ref().unwrap();
assert_eq!(jpg.hash, art_url_hash("art-b"));
assert_eq!(jpg.source_url, "art-b");
assert_eq!(jpg.path, "c/al/folder.jpg");
assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
}
#[test]
fn folder_jpg_tie_breaks_earliest_then_lex_id() {
let by_time = vec![
album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
];
let jpg = album_desired(&by_time, false)[0]
.folder_jpg
.clone()
.unwrap();
assert_eq!(jpg.source_url, "art-y");
let by_id = vec![
album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
];
let jpg = album_desired(&by_id, false)[0].folder_jpg.clone().unwrap();
assert_eq!(jpg.source_url, "art-g");
}
#[test]
fn folder_webp_source_is_first_created_animated() {
let members = vec![
album_member(
album_clip("a", 9, "t2", "art-a", "vid-a"),
"root",
"c/al/a.flac",
),
album_member(
album_clip("b", 1, "t0", "art-b", "vid-b"),
"root",
"c/al/b.flac",
),
album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
];
let webp = album_desired(&members, true)[0]
.folder_webp
.clone()
.unwrap();
assert_eq!(webp.source_url, "vid-b");
assert_eq!(webp.hash, art_url_hash("vid-b"));
assert_eq!(webp.path, "c/al/cover.webp");
assert_eq!(webp.kind, ArtifactKind::FolderWebp);
}
#[test]
fn animated_covers_off_yields_no_folder_webp() {
let members = vec![album_member(
album_clip("a", 1, "t0", "art-a", "vid-a"),
"root",
"c/al/a.flac",
)];
let off = album_desired(&members, false);
assert!(off[0].folder_webp.is_none());
let on = album_desired(&members, true);
assert!(on[0].folder_webp.is_some());
}
#[test]
fn album_with_no_art_yields_no_folder_jpg() {
let members = vec![album_member(
album_clip("a", 3, "t0", "", ""),
"root",
"c/al/a.flac",
)];
let albums = album_desired(&members, true);
assert!(albums[0].folder_jpg.is_none());
assert!(albums[0].folder_webp.is_none());
}
#[test]
fn album_desired_groups_by_root_id() {
let members = vec![
album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
];
let albums = album_desired(&members, false);
assert_eq!(albums.len(), 2);
assert_eq!(albums[0].root_id, "r1");
assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
assert_eq!(
albums[0].folder_jpg.as_ref().unwrap().path,
"c/al1/folder.jpg"
);
assert_eq!(albums[1].root_id, "r2");
assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
assert_eq!(
albums[1].folder_jpg.as_ref().unwrap().path,
"c/al2/folder.jpg"
);
}
#[test]
fn plan_writes_folder_art_when_store_empty() {
let members = vec![album_member(
album_clip("a", 1, "t0", "art-a", "vid-a"),
"root",
"c/al/a.flac",
)];
let desired = album_desired(&members, true);
let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
assert_eq!(
actions,
vec![
Action::WriteArtifact {
kind: ArtifactKind::FolderJpg,
path: "c/al/folder.jpg".to_string(),
source_url: "art-a".to_string(),
hash: art_url_hash("art-a"),
owner_id: "root".to_string(),
content: None,
},
Action::WriteArtifact {
kind: ArtifactKind::FolderWebp,
path: "c/al/cover.webp".to_string(),
source_url: "vid-a".to_string(),
hash: art_url_hash("vid-a"),
owner_id: "root".to_string(),
content: None,
},
]
);
}
#[test]
fn plan_skips_when_hash_and_path_match() {
let members = vec![album_member(
album_clip("a", 1, "t0", "art-a", ""),
"root",
"c/al/a.flac",
)];
let desired = album_desired(&members, false);
let mut albums = BTreeMap::new();
albums.insert(
"root".to_string(),
AlbumArt {
folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
folder_webp: None,
},
);
assert!(plan_album_artifacts(&desired, &albums, true).is_empty());
}
#[test]
fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
let members = vec![album_member(
album_clip("a", 1, "t0", "art-a", ""),
"root",
"c/al/a.flac",
)];
let desired = album_desired(&members, false);
let mut albums = BTreeMap::new();
albums.insert(
"root".to_string(),
AlbumArt {
folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
folder_webp: None,
},
);
let actions = plan_album_artifacts(&desired, &albums, true);
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
));
}
#[test]
fn h1_most_played_flip_to_same_art_writes_nothing() {
let run1 = vec![
album_member(
album_clip("a", 9, "t0", "same-art", ""),
"root",
"c/al/a.flac",
),
album_member(
album_clip("b", 1, "t1", "same-art", ""),
"root",
"c/al/b.flac",
),
];
let desired1 = album_desired(&run1, false);
let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true);
assert_eq!(write1.len(), 1);
let mut albums = BTreeMap::new();
if let Action::WriteArtifact {
path,
hash,
owner_id,
..
} = &write1[0]
{
albums.insert(
owner_id.clone(),
AlbumArt {
folder_jpg: Some(stored(path, hash)),
folder_webp: None,
},
);
}
let run2 = vec![
album_member(
album_clip("a", 1, "t0", "same-art", ""),
"root",
"c/al/a.flac",
),
album_member(
album_clip("b", 9, "t1", "same-art", ""),
"root",
"c/al/b.flac",
),
];
let desired2 = album_desired(&run2, false);
assert!(plan_album_artifacts(&desired2, &albums, true).is_empty());
}
#[test]
fn h1_flip_to_different_art_writes_exactly_one() {
let mut albums = BTreeMap::new();
albums.insert(
"root".to_string(),
AlbumArt {
folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
folder_webp: None,
},
);
let members = vec![
album_member(
album_clip("a", 1, "t0", "old-art", ""),
"root",
"c/al/a.flac",
),
album_member(
album_clip("b", 9, "t1", "new-art", ""),
"root",
"c/al/b.flac",
),
];
let desired = album_desired(&members, false);
let actions = plan_album_artifacts(&desired, &albums, true);
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
));
}
#[test]
fn one_write_per_album_regardless_of_clip_count() {
let members: Vec<Desired> = (0..200)
.map(|i| {
album_member(
album_clip(
&format!("clip-{i:03}"),
i as u64,
&format!("t{i:03}"),
&format!("art-{i:03}"),
&format!("vid-{i:03}"),
),
"root",
&format!("c/al/clip-{i:03}.flac"),
)
})
.collect();
let desired = album_desired(&members, true);
assert_eq!(desired.len(), 1);
let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
assert_eq!(actions.len(), 2);
assert_eq!(
actions
.iter()
.filter(|a| matches!(a, Action::WriteArtifact { .. }))
.count(),
2
);
}
#[test]
fn emptied_album_deletes_only_when_can_delete() {
let mut albums = BTreeMap::new();
albums.insert(
"root".to_string(),
AlbumArt {
folder_jpg: Some(stored("c/al/folder.jpg", "h")),
folder_webp: Some(stored("c/al/cover.webp", "hw")),
},
);
let desired: Vec<AlbumDesired> = Vec::new();
assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
let actions = plan_album_artifacts(&desired, &albums, true);
assert_eq!(
actions,
vec![
Action::DeleteArtifact {
kind: ArtifactKind::FolderJpg,
path: "c/al/folder.jpg".to_string(),
owner_id: "root".to_string(),
},
Action::DeleteArtifact {
kind: ArtifactKind::FolderWebp,
path: "c/al/cover.webp".to_string(),
owner_id: "root".to_string(),
},
]
);
}
#[test]
fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
let mut albums = BTreeMap::new();
albums.insert(
"root".to_string(),
AlbumArt {
folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
},
);
let members = vec![album_member(
album_clip("a", 1, "t0", "art-a", "vid-a"),
"root",
"c/al/a.flac",
)];
let desired = album_desired(&members, false);
assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
let actions = plan_album_artifacts(&desired, &albums, true);
assert_eq!(
actions,
vec![Action::DeleteArtifact {
kind: ArtifactKind::FolderWebp,
path: "c/al/cover.webp".to_string(),
owner_id: "root".to_string(),
}]
);
}
#[test]
fn plan_album_artifacts_is_deterministically_ordered() {
let members = vec![
album_member(
album_clip("a", 1, "t0", "art-a", "vid-a"),
"r2",
"c/al2/a.flac",
),
album_member(
album_clip("b", 1, "t0", "art-b", "vid-b"),
"r1",
"c/al1/b.flac",
),
];
let desired = album_desired(&members, true);
let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
let keys: Vec<(&str, ArtifactKind)> = actions
.iter()
.map(|a| match a {
Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
_ => unreachable!(),
})
.collect();
assert_eq!(
keys,
vec![
("r1", ArtifactKind::FolderJpg),
("r1", ArtifactKind::FolderWebp),
("r2", ArtifactKind::FolderJpg),
("r2", ArtifactKind::FolderWebp),
]
);
}
fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
PlaylistDesired {
id: id.to_owned(),
name: name.to_owned(),
path: path.to_owned(),
content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
hash: hash.to_owned(),
}
}
fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
PlaylistState {
name: name.to_owned(),
path: path.to_owned(),
hash: hash.to_owned(),
}
}
fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
entries
.iter()
.map(|(id, state)| ((*id).to_owned(), state.clone()))
.collect()
}
#[test]
fn playlist_write_emitted_for_a_new_playlist() {
let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
let actions = plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true);
assert_eq!(
actions,
vec![Action::WriteArtifact {
kind: ArtifactKind::Playlist,
path: "Road Trip.m3u8".to_owned(),
source_url: String::new(),
hash: "h1".to_owned(),
owner_id: "pl1".to_owned(),
content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
}]
);
}
#[test]
fn playlist_write_emitted_when_hash_changes() {
let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
let actions = plan_playlist_artifacts(&desired, &stored, true, true);
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
));
}
#[test]
fn playlist_unchanged_is_idempotent() {
let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
let actions = plan_playlist_artifacts(&desired, &stored, true, true);
assert!(actions.is_empty(), "an unchanged playlist plans nothing");
}
#[test]
fn playlist_rename_writes_new_and_deletes_old_path() {
let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
let actions = plan_playlist_artifacts(&desired, &stored, true, true);
assert_eq!(
actions,
vec![
Action::WriteArtifact {
kind: ArtifactKind::Playlist,
path: "Summer.m3u8".to_owned(),
source_url: String::new(),
hash: "h2".to_owned(),
owner_id: "pl1".to_owned(),
content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
},
Action::DeleteArtifact {
kind: ArtifactKind::Playlist,
path: "Spring.m3u8".to_owned(),
owner_id: "pl1".to_owned(),
},
]
);
}
#[test]
fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
let actions = plan_playlist_artifacts(&desired, &stored, false, true);
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
));
assert!(
!actions
.iter()
.any(|a| matches!(a, Action::DeleteArtifact { .. })),
"old path must not be deleted when deletes are disallowed"
);
}
#[test]
fn playlist_stale_removed_only_under_full_gate() {
let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
let deleted = plan_playlist_artifacts(&[], &stored, true, true);
assert_eq!(
deleted,
vec![Action::DeleteArtifact {
kind: ArtifactKind::Playlist,
path: "Gone.m3u8".to_owned(),
owner_id: "gone".to_owned(),
}]
);
assert!(plan_playlist_artifacts(&[], &stored, false, true).is_empty());
assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
assert!(plan_playlist_artifacts(&[], &stored, false, false).is_empty());
}
#[test]
fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
let stored = pl_store(&[
("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
]);
let actions = plan_playlist_artifacts(&[], &stored, true, false);
assert!(
actions.is_empty(),
"a failed playlist listing must plan zero actions, got {actions:?}"
);
}
#[test]
fn b2_empty_list_deletes_only_when_fully_enumerated() {
let stored = pl_store(&[
("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
]);
assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
let wiped = plan_playlist_artifacts(&[], &stored, true, true);
assert_eq!(
wiped
.iter()
.filter(|a| matches!(a, Action::DeleteArtifact { .. }))
.count(),
2
);
}
#[test]
fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
let actions = plan_playlist_artifacts(&desired, &stored, true, true);
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
));
assert!(
!actions.iter().any(|a| match a {
Action::WriteArtifact { owner_id, .. }
| Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
_ => false,
}),
"a protected (failed-member) playlist must have no action"
);
}
#[test]
fn playlist_rename_collision_downgrades_the_delete() {
let desired = vec![
pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
];
let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
let actions = plan_playlist_artifacts(&desired, &stored, true, true);
let write_paths: BTreeSet<&str> = actions
.iter()
.filter_map(|a| match a {
Action::WriteArtifact { path, .. } => Some(path.as_str()),
_ => None,
})
.collect();
for a in &actions {
if let Action::DeleteArtifact { path, .. } = a {
assert!(
!write_paths.contains(path.as_str()),
"a playlist delete aliases a write target: {path}"
);
}
}
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::collection::{btree_map, hash_map, vec};
use proptest::prelude::*;
use std::collections::BTreeSet;
type DesiredFields = (
String,
AudioFormat,
String,
String,
Vec<SourceMode>,
bool,
bool,
);
fn audio_format() -> impl Strategy<Value = AudioFormat> {
prop_oneof![
Just(AudioFormat::Mp3),
Just(AudioFormat::Flac),
Just(AudioFormat::Wav),
]
}
fn source_mode() -> impl Strategy<Value = SourceMode> {
prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
}
fn clip_id() -> impl Strategy<Value = String> {
(0u8..8).prop_map(|n| format!("c{n}"))
}
fn small_path() -> impl Strategy<Value = String> {
(0u8..6).prop_map(|n| format!("path{n}"))
}
fn manifest_path() -> impl Strategy<Value = String> {
prop_oneof![
1 => Just(String::new()),
6 => small_path(),
]
}
fn small_hash() -> impl Strategy<Value = String> {
(0u8..4).prop_map(|n| format!("h{n}"))
}
fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
(
manifest_path(),
audio_format(),
small_hash(),
small_hash(),
0u64..4,
any::<bool>(),
)
.prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
ManifestEntry {
path,
format,
meta_hash,
art_hash,
size,
preserve,
..Default::default()
}
})
}
fn manifest_strategy() -> impl Strategy<Value = Manifest> {
btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
}
fn local_file() -> impl Strategy<Value = LocalFile> {
(any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
}
fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
hash_map(clip_id(), local_file(), 0..8)
}
fn source_status() -> impl Strategy<Value = SourceStatus> {
(source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
mode,
fully_enumerated,
})
}
fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
vec(source_status(), 0..5)
}
fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
vec(
any::<bool>().prop_map(|fully_enumerated| SourceStatus {
mode: SourceMode::Copy,
fully_enumerated,
}),
1..5,
)
}
fn desired_fields() -> impl Strategy<Value = DesiredFields> {
(
small_path(),
audio_format(),
small_hash(),
small_hash(),
vec(source_mode(), 1..3),
any::<bool>(),
any::<bool>(),
)
}
fn build_desired(id: String, fields: DesiredFields) -> Desired {
let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
let clip = Clip {
id,
title: "t".to_string(),
..Default::default()
};
Desired {
lineage: LineageContext::own_root(&clip),
clip,
path,
format,
meta_hash,
art_hash,
modes,
trashed,
private,
artifacts: Vec::new(),
}
}
fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
items
.into_iter()
.map(|(id, fields)| build_desired(id, fields))
.collect()
})
}
fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
desired.iter().map(|d| d.clip.id.as_str()).collect()
}
fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
desired
.iter()
.filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
.map(|d| d.clip.id.as_str())
.collect()
}
fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
desired
.iter()
.filter(|d| !d.trashed)
.map(|d| d.clip.id.as_str())
.collect()
}
fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
plan.actions
.iter()
.filter_map(|a| match a {
Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
_ => None,
})
.collect()
}
fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
plan.actions
.iter()
.filter_map(|a| match a {
Action::Download { path, .. } | Action::Reformat { path, .. } => {
Some(path.as_str())
}
Action::Rename { to, .. } => Some(to.as_str()),
_ => None,
})
.collect()
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 256,
failure_persistence: None,
..ProptestConfig::default()
})]
#[test]
fn inv1_desired_clip_deleted_only_when_fully_trashed(
manifest in manifest_strategy(),
desired in desired_strategy(),
local in local_strategy(),
sources in sources_strategy(),
) {
let plan = reconcile(&manifest, &desired, &local, &sources);
let present = desired_ids(&desired);
let live = non_trashed_ids(&desired);
for id in delete_clip_ids(&plan) {
prop_assert!(
!(present.contains(id) && live.contains(id)),
"deleted a desired clip with a non-trashed duplicate: {id}"
);
}
}
#[test]
fn inv2_no_delete_when_any_mirror_unenumerated(
manifest in manifest_strategy(),
desired in desired_strategy(),
local in local_strategy(),
mut sources in sources_strategy(),
) {
sources.push(SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: false,
});
let plan = reconcile(&manifest, &desired, &local, &sources);
prop_assert_eq!(plan.deletes(), 0);
}
#[test]
fn inv3_all_copy_sources_means_no_deletes(
manifest in manifest_strategy(),
desired in desired_strategy(),
local in local_strategy(),
sources in copy_sources_strategy(),
) {
let plan = reconcile(&manifest, &desired, &local, &sources);
prop_assert_eq!(plan.deletes(), 0);
}
#[test]
fn inv4_plan_is_deterministic(
manifest in manifest_strategy(),
desired in desired_strategy(),
local in local_strategy(),
sources in sources_strategy(),
) {
let plan = reconcile(&manifest, &desired, &local, &sources);
let again = reconcile(&manifest, &desired, &local, &sources);
prop_assert_eq!(&plan, &again);
let mut desired_rev = desired.clone();
desired_rev.reverse();
let mut sources_rev = sources.clone();
sources_rev.reverse();
let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
prop_assert_eq!(&plan, &shuffled);
}
#[test]
fn inv5_every_delete_is_in_the_manifest(
manifest in manifest_strategy(),
desired in desired_strategy(),
local in local_strategy(),
sources in sources_strategy(),
) {
let plan = reconcile(&manifest, &desired, &local, &sources);
for id in delete_clip_ids(&plan) {
prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
}
}
#[test]
fn inv6_never_deletes_protected_clip(
manifest in manifest_strategy(),
desired in desired_strategy(),
local in local_strategy(),
sources in sources_strategy(),
) {
let plan = reconcile(&manifest, &desired, &local, &sources);
let protected = protected_ids(&desired);
for id in delete_clip_ids(&plan) {
prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
}
}
#[test]
fn inv7_no_delete_unless_deletion_allowed(
manifest in manifest_strategy(),
desired in desired_strategy(),
local in local_strategy(),
sources in sources_strategy(),
) {
let plan = reconcile(&manifest, &desired, &local, &sources);
if !deletion_allowed(&sources) {
prop_assert_eq!(plan.deletes(), 0);
}
}
#[test]
fn inv8_at_most_one_delete_per_clip(
manifest in manifest_strategy(),
desired in desired_strategy(),
local in local_strategy(),
sources in sources_strategy(),
) {
let plan = reconcile(&manifest, &desired, &local, &sources);
let ids = delete_clip_ids(&plan);
let unique: BTreeSet<&str> = ids.iter().copied().collect();
prop_assert_eq!(ids.len(), unique.len());
}
#[test]
fn inv9_no_delete_with_empty_path(
manifest in manifest_strategy(),
desired in desired_strategy(),
local in local_strategy(),
sources in sources_strategy(),
) {
let plan = reconcile(&manifest, &desired, &local, &sources);
for action in &plan.actions {
if let Action::Delete { path, .. } = action {
prop_assert!(!path.is_empty(), "delete with an empty path");
}
}
}
#[test]
fn inv10_no_delete_aliases_a_write_target(
manifest in manifest_strategy(),
desired in desired_strategy(),
local in local_strategy(),
sources in sources_strategy(),
) {
let plan = reconcile(&manifest, &desired, &local, &sources);
let targets = write_target_paths(&plan);
for action in &plan.actions {
if let Action::Delete { path, .. } = action {
prop_assert!(
!targets.contains(path.as_str()),
"delete path {path} aliases a write target"
);
}
}
}
}
}