use std::collections::{BTreeSet, HashMap, HashSet};
use crate::{
Clip, Desired, LIKED_PLAYLIST_ID, LineageStore, PlaylistDesired, PlaylistInput, SourceMode,
SourceStatus, area_authoritative, area_fully_enumerated, build_playlist_desired,
deletion_allowed,
};
pub struct AreaListing {
kind: AreaKind,
mode: SourceMode,
clips: Vec<Clip>,
authoritative_ignoring_empty: bool,
}
pub enum AreaKind {
Library,
Liked,
Playlist { id: String, name: String },
}
impl AreaListing {
pub fn listed(
kind: AreaKind,
mode: SourceMode,
clips: Vec<Clip>,
complete: bool,
any_filtered: bool,
narrowed: bool,
) -> Self {
Self {
kind,
mode,
clips,
authoritative_ignoring_empty: area_authoritative(complete, any_filtered, narrowed),
}
}
pub fn failed(kind: AreaKind, mode: SourceMode) -> Self {
Self {
kind,
mode,
clips: Vec::new(),
authoritative_ignoring_empty: false,
}
}
pub fn unresolved_playlist(mode: SourceMode) -> Self {
Self::failed(
AreaKind::Playlist {
id: String::new(),
name: String::new(),
},
mode,
)
}
pub fn clips(&self) -> &[Clip] {
&self.clips
}
}
pub fn area_mode(area: &AreaListing, force_copy: bool) -> SourceMode {
if force_copy {
SourceMode::Copy
} else {
area.mode
}
}
#[must_use]
pub fn area_enumerated(area: &AreaListing, force_copy: bool) -> bool {
area_fully_enumerated(
area.authoritative_ignoring_empty,
area.clips.is_empty(),
area_mode(area, force_copy),
)
}
#[must_use]
pub fn library_authoritative(areas: &[AreaListing], force_copy: bool) -> bool {
areas
.iter()
.any(|a| matches!(a.kind, AreaKind::Library) && area_enumerated(a, force_copy))
}
#[must_use]
pub fn source_statuses(areas: &[AreaListing], force_copy: bool) -> Vec<SourceStatus> {
areas
.iter()
.map(|area| SourceStatus {
mode: area_mode(area, force_copy),
fully_enumerated: area_enumerated(area, force_copy),
})
.collect()
}
#[must_use]
pub fn adoption_enumerated(areas: &[AreaListing], force_copy: bool) -> bool {
library_authoritative(areas, force_copy)
|| deletion_allowed(&source_statuses(areas, force_copy))
}
pub fn union_clips(areas: &[AreaListing]) -> Vec<Clip> {
let mut seen: HashSet<String> = HashSet::new();
let mut union: Vec<Clip> = Vec::new();
for area in areas {
for clip in &area.clips {
if seen.insert(clip.id.clone()) {
union.push(clip.clone());
}
}
}
union
}
pub fn build_scoped_playlist_desired(
areas: &[AreaListing],
desired: &[Desired],
store: &LineageStore,
protected: &mut BTreeSet<String>,
force_copy: bool,
members_intact: bool,
) -> (Vec<PlaylistDesired>, bool) {
let mut owned: Vec<(String, String, Vec<Clip>)> = Vec::new();
for area in areas {
match &area.kind {
AreaKind::Playlist { id, name } => {
if members_intact && !id.is_empty() && area_enumerated(area, force_copy) {
owned.push((id.clone(), name.clone(), area.clips.clone()));
} else if !id.is_empty() {
protected.insert(id.clone());
}
}
AreaKind::Liked => {
if members_intact && area_enumerated(area, force_copy) {
owned.push((
LIKED_PLAYLIST_ID.to_owned(),
"Liked Songs".to_owned(),
area.clips.clone(),
));
} else {
protected.insert(LIKED_PLAYLIST_ID.to_owned());
}
}
AreaKind::Library => {}
}
}
let rendered: BTreeSet<&str> = owned.iter().map(|(id, _, _)| id.as_str()).collect();
for id in store.playlists.keys() {
if !rendered.contains(id.as_str()) {
protected.insert(id.clone());
}
}
let inputs: Vec<PlaylistInput<'_>> = owned
.iter()
.map(|(id, name, members)| PlaylistInput {
id: id.as_str(),
name: name.as_str(),
members: members.as_slice(),
})
.collect();
(build_playlist_desired(&inputs, desired), true)
}
pub fn build_modes_by_id(areas: &[(SourceMode, Vec<String>)]) -> HashMap<String, Vec<SourceMode>> {
let mut map: HashMap<String, (bool, bool)> = HashMap::new();
for (mode, ids) in areas {
for id in ids {
let entry = map.entry(id.clone()).or_insert((false, false));
match mode {
SourceMode::Mirror => entry.0 = true,
SourceMode::Copy => entry.1 = true,
}
}
}
map.into_iter()
.map(|(id, (mirror, copy))| {
let mut modes = Vec::new();
if mirror {
modes.push(SourceMode::Mirror);
}
if copy {
modes.push(SourceMode::Copy);
}
(id, modes)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
Action, ArtifactToggles, AudioFormat, LocalFile, Manifest, ManifestEntry, NamingConfig,
build_desired, narrows_downloads, reconcile,
};
fn tclip(id: &str) -> Clip {
Clip {
id: id.to_owned(),
title: "Song".to_owned(),
handle: "alice".to_owned(),
..Default::default()
}
}
fn area(kind: AreaKind, mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
AreaListing {
kind,
mode,
clips: ids.iter().map(|id| tclip(id)).collect(),
authoritative_ignoring_empty: authoritative,
}
}
#[test]
fn empty_mirror_area_is_not_enumerated() {
let mirror = area(AreaKind::Liked, SourceMode::Mirror, &[], true);
assert!(!area_enumerated(&mirror, false));
let copy = area(AreaKind::Liked, SourceMode::Copy, &[], true);
assert!(area_enumerated(©, false));
let full = area(AreaKind::Liked, SourceMode::Mirror, &["x"], true);
assert!(area_enumerated(&full, false));
}
#[test]
fn adoption_enumerated_covers_a_mirror_playlist_under_library_off() {
let playlist = |mode, ids: &[&str], auth| {
area(
AreaKind::Playlist {
id: "p".into(),
name: "P".into(),
},
mode,
ids,
auth,
)
};
assert!(adoption_enumerated(
&[playlist(SourceMode::Mirror, &["pl"], true)],
false
));
assert!(!adoption_enumerated(
&[playlist(SourceMode::Copy, &["pl"], true)],
false
));
assert!(!adoption_enumerated(
&[playlist(SourceMode::Mirror, &[], true)],
false
));
assert!(!adoption_enumerated(
&[playlist(SourceMode::Mirror, &["pl"], false)],
false
));
assert!(!adoption_enumerated(
&[playlist(SourceMode::Mirror, &["pl"], true)],
true
));
assert!(adoption_enumerated(
&[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
false,
));
}
#[test]
fn library_authoritative_counts_protector_not_off() {
let with_protector = vec![
area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
area(
AreaKind::Playlist {
id: "p".into(),
name: "P".into(),
},
SourceMode::Mirror,
&["pl"],
true,
),
];
assert!(library_authoritative(&with_protector, false));
let off = vec![area(
AreaKind::Playlist {
id: "p".into(),
name: "P".into(),
},
SourceMode::Mirror,
&["pl"],
true,
)];
assert!(!library_authoritative(&off, false));
}
fn verdict(areas: &[AreaListing]) -> (bool, bool, bool) {
let can_delete = deletion_allowed(&source_statuses(areas, false));
let lib_auth = library_authoritative(areas, false);
(
can_delete,
lib_auth,
narrows_downloads(can_delete, lib_auth),
)
}
fn pl_area(mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
area(
AreaKind::Playlist {
id: "p".into(),
name: "P".into(),
},
mode,
ids,
authoritative,
)
}
#[test]
fn narrowed_playlist_mirror_disarms_deletion() {
let narrowed = pl_area(
SourceMode::Mirror,
&["a"],
area_authoritative(true, false, true),
);
assert!(!area_enumerated(&narrowed, false));
assert!(!deletion_allowed(&source_statuses(&[narrowed], false)));
let full = pl_area(
SourceMode::Mirror,
&["a"],
area_authoritative(true, false, false),
);
assert!(area_enumerated(&full, false));
assert!(deletion_allowed(&source_statuses(&[full], false)));
}
#[test]
fn narrowed_playlist_with_protector_neither_deletes_nor_narrows() {
let areas = vec![
area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
pl_area(
SourceMode::Mirror,
&["pl"],
area_authoritative(true, false, true),
),
];
let (can_delete, lib_auth, truncate) = verdict(&areas);
assert!(!can_delete, "narrowed playlist mirror is disarmed");
assert!(lib_auth, "the protector is an authoritative library");
assert!(
!truncate,
"the full library is listed, so downloads are not narrowed"
);
}
#[test]
fn narrowed_playlist_off_disarms_and_narrows() {
let areas = vec![pl_area(
SourceMode::Mirror,
&["pl"],
area_authoritative(true, false, true),
)];
let (can_delete, lib_auth, truncate) = verdict(&areas);
assert!(!can_delete, "narrowed playlist mirror is disarmed");
assert!(!lib_auth, "library=off leaves no library area");
assert!(
truncate,
"no armed deletion and no full library, so downloads narrow"
);
}
#[test]
fn configured_full_library_mirror_still_deletes_when_narrowed() {
let areas = vec![area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)];
let (can_delete, lib_auth, truncate) = verdict(&areas);
assert!(
can_delete,
"the configured full-library mirror still deletes"
);
assert!(lib_auth);
assert!(
!truncate,
"the full library is listed, so downloads are not narrowed"
);
}
#[test]
fn adoption_skips_pin_on_a_narrowed_library_off_playlist() {
let areas = vec![pl_area(
SourceMode::Mirror,
&["pl"],
area_authoritative(true, false, true),
)];
assert!(!adoption_enumerated(&areas, false));
}
#[test]
fn union_keeps_first_area_payload() {
let mut lib = tclip("shared");
lib.title = "Library".to_owned();
let mut pl = tclip("shared");
pl.title = "Playlist".to_owned();
let areas = vec![
AreaListing {
kind: AreaKind::Library,
mode: SourceMode::Copy,
clips: vec![lib, tclip("lib-only")],
authoritative_ignoring_empty: true,
},
AreaListing {
kind: AreaKind::Playlist {
id: "p".into(),
name: "P".into(),
},
mode: SourceMode::Mirror,
clips: vec![pl],
authoritative_ignoring_empty: true,
},
];
let union = union_clips(&areas);
assert_eq!(union.len(), 2);
assert_eq!(union[0].id, "shared");
assert_eq!(union[0].title, "Library");
assert_eq!(union[1].id, "lib-only");
}
#[test]
fn a_failed_area_suppresses_deletion_for_the_run() {
let areas = [
area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
area(
AreaKind::Playlist {
id: "p".into(),
name: "P".into(),
},
SourceMode::Mirror,
&[],
false,
),
];
let sources: Vec<SourceStatus> = areas
.iter()
.map(|a| SourceStatus {
mode: area_mode(a, false),
fully_enumerated: area_enumerated(a, false),
})
.collect();
assert!(!deletion_allowed(&sources));
}
#[test]
fn mixed_mode_deletes_only_mirror_exclusive_orphans() {
let areas = vec![
area(AreaKind::Liked, SourceMode::Mirror, &["m-live"], true),
area(
AreaKind::Playlist {
id: "p".into(),
name: "P".into(),
},
SourceMode::Copy,
&["c-live"],
true,
),
];
let sources: Vec<SourceStatus> = areas
.iter()
.map(|a| SourceStatus {
mode: area_mode(a, false),
fully_enumerated: area_enumerated(a, false),
})
.collect();
assert!(deletion_allowed(&sources));
let area_modes: Vec<(SourceMode, Vec<String>)> = areas
.iter()
.map(|a| {
(
area_mode(a, false),
a.clips.iter().map(|c| c.id.clone()).collect(),
)
})
.collect();
let modes = build_modes_by_id(&area_modes);
let union = union_clips(&areas);
let desired = build_desired(
&union.iter().collect::<Vec<_>>(),
AudioFormat::Flac,
&modes,
&HashMap::new(),
&BTreeSet::new(),
ArtifactToggles::default(),
&NamingConfig::default(),
);
let mut manifest = Manifest::new();
for id in ["m-live", "c-live", "m-orphan", "c-orphan"] {
manifest.insert(
id,
ManifestEntry {
path: format!("{id}.flac"),
format: AudioFormat::Flac,
size: 100,
preserve: id == "c-orphan",
..Default::default()
},
);
}
let local: HashMap<String, LocalFile> = manifest
.iter()
.map(|(id, _)| {
(
id.clone(),
LocalFile {
exists: true,
size: 100,
},
)
})
.collect();
let plan = reconcile(&manifest, &desired, &local, &sources);
let deleted: Vec<&str> = plan
.actions
.iter()
.filter_map(|a| match a {
Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
_ => None,
})
.collect();
assert_eq!(deleted, vec!["m-orphan"]);
}
#[test]
fn build_modes_by_id_copy_wins_and_dedups() {
let map = build_modes_by_id(&[
(SourceMode::Mirror, vec!["a".to_owned(), "b".to_owned()]),
(SourceMode::Copy, vec!["b".to_owned(), "c".to_owned()]),
]);
assert_eq!(map["a"], vec![SourceMode::Mirror]);
assert_eq!(map["b"], vec![SourceMode::Mirror, SourceMode::Copy]);
assert_eq!(map["c"], vec![SourceMode::Copy]);
}
}