use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use crate::album_art::{AlbumArt, PlaylistState};
use crate::lineage::LineageContext;
use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
use crate::model::Clip;
use crate::pathkey::{canonical_path_key, same_fs_path};
use crate::vocab::{ArtifactKind, AudioFormat, SourceMode, StemFormat};
mod album;
mod playlist;
mod types;
pub use album::plan_album_artifacts;
pub use playlist::plan_playlist_artifacts;
pub use types::*;
pub fn reconcile(
manifest: &Manifest,
desired: &[Desired],
local: &HashMap<String, LocalFile>,
sources: &[SourceStatus],
) -> Plan {
let merged: Vec<Desired>;
let ordered: Vec<&Desired> = if needs_aggregation(desired) {
merged = aggregate_desired(desired);
merged.iter().collect()
} else {
let mut refs: Vec<&Desired> = desired.iter().collect();
refs.sort_unstable_by(|a, b| a.clip.id.cmp(&b.clip.id));
refs
};
let desired_ids: HashSet<&str> = ordered.iter().map(|d| d.clip.id.as_str()).collect();
let mut actions: Vec<Action> = Vec::with_capacity(ordered.len() + manifest.len());
let can_delete = deletion_allowed(sources);
for &d in &ordered {
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);
co_delete_stems(d.clip.id.as_str(), manifest, can_delete, &mut actions);
} else {
plan_clip_artifacts(d, manifest, local, can_delete, &mut actions);
plan_clip_stems(d, manifest, local, 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);
co_delete_stems(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
}
pub fn area_authoritative(complete: bool, any_filtered: bool, narrowed: bool) -> bool {
complete && !any_filtered && !narrowed
}
pub fn area_fully_enumerated(authoritative: bool, clips_empty: bool, mode: SourceMode) -> bool {
authoritative && !(clips_empty && mode == SourceMode::Mirror)
}
pub fn narrows_downloads(can_delete: bool, library_authoritative: bool) -> bool {
!can_delete && !library_authoritative
}
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
| ArtifactKind::DetailsTxt
| ArtifactKind::LyricsTxt
| ArtifactKind::Lrc
| ArtifactKind::VideoMp4
)
}
fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
match kind {
ArtifactKind::CoverJpg
| ArtifactKind::LyricsTxt
| ArtifactKind::Lrc
| ArtifactKind::VideoMp4 => false,
ArtifactKind::CoverWebp
| ArtifactKind::DetailsTxt
| ArtifactKind::FolderJpg
| ArtifactKind::FolderWebp
| ArtifactKind::FolderMp4
| 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::DetailsTxt => entry.details_txt.as_ref(),
ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
ArtifactKind::Lrc => entry.lrc.as_ref(),
ArtifactKind::VideoMp4 => entry.video_mp4.as_ref(),
ArtifactKind::FolderJpg
| ArtifactKind::FolderWebp
| ArtifactKind::FolderMp4
| 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));
}
if let Some(state) = &entry.details_txt {
out.push((ArtifactKind::DetailsTxt, state));
}
if let Some(state) = &entry.lyrics_txt {
out.push((ArtifactKind::LyricsTxt, state));
}
if let Some(state) = &entry.lrc {
out.push((ArtifactKind::Lrc, state));
}
if let Some(state) = &entry.video_mp4 {
out.push((ArtifactKind::VideoMp4, 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::DetailsTxt => entry.details_txt = state,
ArtifactKind::LyricsTxt => entry.lyrics_txt = state,
ArtifactKind::Lrc => entry.lrc = state,
ArtifactKind::VideoMp4 => entry.video_mp4 = state,
ArtifactKind::FolderJpg
| ArtifactKind::FolderWebp
| ArtifactKind::FolderMp4
| ArtifactKind::Playlist => {}
}
}
pub(crate) fn set_manifest_stem(
entry: &mut ManifestEntry,
key: &str,
state: Option<ArtifactState>,
) {
match state {
Some(state) => {
entry.stems.insert(key.to_string(), state);
}
None => {
entry.stems.remove(key);
}
}
}
fn needs_write_drift(
stored: Option<(&str, &str)>,
want_hash: &str,
want_path: &str,
local: &HashMap<String, LocalFile>,
) -> bool {
match stored {
None => true,
Some((stored_hash, stored_path)) => {
stored_hash != want_hash
|| !same_fs_path(stored_path, want_path)
|| local
.get(stored_path)
.is_some_and(|f| !f.exists || f.size == 0)
}
}
}
fn plan_clip_artifacts(
d: &Desired,
manifest: &Manifest,
local: &HashMap<String, LocalFile>,
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 state = entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind));
let needs_write = needs_write_drift(
state.map(|state| (state.hash.as_str(), state.path.as_str())),
artifact.hash.as_str(),
artifact.path.as_str(),
local,
);
if needs_write {
if let Some(state) = state
&& state.hash == artifact.hash
&& !same_fs_path(&state.path, &artifact.path)
&& artifact.content.is_none()
&& local
.get(&state.path)
.is_some_and(|f| f.exists && f.size > 0)
{
out.push(Action::MoveArtifact {
kind: artifact.kind,
from: state.path.clone(),
to: artifact.path.clone(),
source_url: artifact.source_url.clone(),
hash: artifact.hash.clone(),
owner_id: owner_id.to_string(),
});
} else {
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: artifact.content.clone(),
});
}
}
}
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 delete_stem_action(
clip_id: &str,
key: &str,
path: &str,
manifest: &Manifest,
can_delete: bool,
) -> Option<Action> {
if !can_delete {
return None;
}
let entry = manifest.get(clip_id)?;
if path.is_empty() || entry.preserve {
return None;
}
Some(Action::DeleteStem {
clip_id: clip_id.to_string(),
key: key.to_string(),
path: path.to_string(),
})
}
fn plan_clip_stems(
d: &Desired,
manifest: &Manifest,
local: &HashMap<String, LocalFile>,
can_delete: bool,
out: &mut Vec<Action>,
) {
let Some(desired_stems) = &d.stems else {
return;
};
let clip_id = d.clip.id.as_str();
let entry = manifest.get(clip_id);
for stem in desired_stems {
let state = entry.and_then(|e| e.stems.get(&stem.key));
let needs_write = match state {
None => true,
Some(state) => state.hash != stem.hash || !same_fs_path(&state.path, &stem.path),
};
if needs_write {
if let Some(state) = state
&& state.hash == stem.hash
&& !same_fs_path(&state.path, &stem.path)
&& local
.get(&state.path)
.is_some_and(|f| f.exists && f.size > 0)
{
out.push(Action::MoveStem {
clip_id: clip_id.to_string(),
key: stem.key.clone(),
stem_id: stem.stem_id.clone(),
from: state.path.clone(),
to: stem.path.clone(),
source_url: stem.source_url.clone(),
format: stem.format,
hash: stem.hash.clone(),
});
} else {
out.push(Action::WriteStem {
clip_id: clip_id.to_string(),
key: stem.key.clone(),
stem_id: stem.stem_id.clone(),
path: stem.path.clone(),
source_url: stem.source_url.clone(),
format: stem.format,
hash: stem.hash.clone(),
});
}
}
}
let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
if !protected_now && let Some(entry) = entry {
let desired_keys: BTreeSet<&str> = desired_stems.iter().map(|s| s.key.as_str()).collect();
for (key, state) in &entry.stems {
if !desired_keys.contains(key.as_str())
&& let Some(action) =
delete_stem_action(clip_id, key, &state.path, manifest, can_delete)
{
out.push(action);
}
}
}
}
fn co_delete_stems(clip_id: &str, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
let Some(entry) = manifest.get(clip_id) else {
return;
};
for (key, state) in &entry.stems {
if let Some(action) = delete_stem_action(clip_id, key, &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();
acc.stems = d.stems.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 needs_aggregation(desired: &[Desired]) -> bool {
let mut seen: HashSet<&str> = HashSet::with_capacity(desired.len());
desired
.iter()
.any(|d| !seen.insert(d.clip.id.as_str()) || !modes_are_canonical(&d.modes))
}
fn modes_are_canonical(modes: &[SourceMode]) -> bool {
matches!(
modes,
[] | [SourceMode::Mirror] | [SourceMode::Copy] | [SourceMode::Mirror, SourceMode::Copy]
)
}
fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
let format = match d.format {
AudioFormat::Mp3 => 0,
AudioFormat::Flac => 1,
AudioFormat::Wav => 2,
AudioFormat::Alac => 3,
};
(
d.path.as_str(),
d.meta_hash.as_str(),
d.art_hash.as_str(),
format,
)
}
fn suppress_path_aliasing(actions: &mut [Action]) {
let aliased: Vec<usize> = {
let targets: BTreeSet<String> = actions
.iter()
.filter_map(|a| match a {
Action::Download { path, .. }
| Action::Reformat { path, .. }
| Action::WriteArtifact { path, .. }
| Action::WriteStem { path, .. } => Some(path.as_str()),
Action::Rename { to, .. }
| Action::MoveArtifact { to, .. }
| Action::MoveStem { to, .. } => Some(to.as_str()),
_ => None,
})
.map(canonical_path_key)
.collect();
actions
.iter()
.enumerate()
.filter_map(|(index, a)| match a {
Action::Delete { path, .. }
| Action::DeleteArtifact { path, .. }
| Action::DeleteStem { path, .. } => {
targets.contains(&canonical_path_key(path)).then_some(index)
}
_ => None,
})
.collect()
};
for index in aliased {
actions[index] = match &actions[index] {
Action::Delete { clip_id, .. } | Action::DeleteStem { clip_id, .. } => Action::Skip {
clip_id: clip_id.clone(),
},
Action::DeleteArtifact { owner_id, .. } => Action::Skip {
clip_id: owner_id.clone(),
},
_ => unreachable!("only delete actions are collected as aliased"),
};
}
}
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 !same_fs_path(&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
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod proptests;