use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use crate::album_art::{AlbumArt, PlaylistState};
use crate::hash::{art_hash, art_url_hash, webp_art_hash};
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, WebpEncodeSettings};
#[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>,
pub stems: Option<Vec<DesiredStem>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DesiredStem {
pub key: String,
pub stem_id: String,
pub path: String,
pub source_url: String,
pub format: StemFormat,
pub hash: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DesiredArtifact {
pub kind: ArtifactKind,
pub path: String,
pub source_url: String,
pub hash: String,
pub content: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AlbumDesired {
pub root_id: String,
pub folder_jpg: Option<DesiredArtifact>,
pub folder_webp: Option<DesiredArtifact>,
pub folder_mp4: 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>,
},
MoveArtifact {
kind: ArtifactKind,
from: String,
to: String,
source_url: String,
hash: String,
owner_id: String,
},
DeleteArtifact {
kind: ArtifactKind,
path: String,
owner_id: String,
},
WriteStem {
clip_id: String,
key: String,
stem_id: String,
path: String,
source_url: String,
format: StemFormat,
hash: String,
},
MoveStem {
clip_id: String,
key: String,
stem_id: String,
from: String,
to: String,
source_url: String,
format: StemFormat,
hash: String,
},
DeleteStem {
clip_id: String,
key: String,
path: 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 { .. }))
}
pub fn stem_writes(&self) -> usize {
self.count(|a| matches!(a, Action::WriteStem { .. }))
}
pub fn artifact_moves(&self) -> usize {
self.count(|a| matches!(a, Action::MoveArtifact { .. }))
}
pub fn stem_moves(&self) -> usize {
self.count(|a| matches!(a, Action::MoveStem { .. }))
}
pub fn stem_deletes(&self) -> usize {
self.count(|a| matches!(a, Action::DeleteStem { .. }))
}
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 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
}
pub fn album_desired(
desired: &[Desired],
animated_covers: bool,
raw_cover: bool,
webp: WebpEncodeSettings,
) -> 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),
content: None,
});
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: webp_art_hash(&source.clip.video_cover_url, &webp),
content: None,
});
let folder_mp4 = raw_cover
.then(|| folder_webp_source(&members))
.flatten()
.map(|source| DesiredArtifact {
kind: ArtifactKind::FolderMp4,
path: album_child(&album_dir, "cover.mp4"),
source_url: source.clip.video_cover_url.clone(),
hash: art_url_hash(&source.clip.video_cover_url),
content: None,
});
AlbumDesired {
root_id: root_id.to_owned(),
folder_jpg,
folder_webp,
folder_mp4,
}
})
.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,
local: &HashMap<String, LocalFile>,
) -> 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(),
d.folder_mp4.as_ref(),
]
.into_iter()
.flatten()
{
let needs_write = needs_write_drift(
stored
.and_then(|a| a.artifact(artifact.kind))
.map(|state| (state.hash.as_str(), state.path.as_str())),
artifact.hash.as_str(),
artifact.path.as_str(),
local,
);
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));
}
if let Some(state) = &art.folder_mp4 {
out.push((ArtifactKind::FolderMp4, 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::FolderMp4 => d.folder_mp4.is_some(),
ArtifactKind::CoverJpg
| ArtifactKind::CoverWebp
| ArtifactKind::DetailsTxt
| ArtifactKind::LyricsTxt
| ArtifactKind::Lrc
| ArtifactKind::VideoMp4
| 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,
local: &HashMap<String, LocalFile>,
) -> 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 = needs_write_drift(
stored_here.map(|state| (state.hash.as_str(), state.path.as_str())),
d.hash.as_str(),
d.path.as_str(),
local,
);
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;
#[cfg(test)]
mod proptests;