1use std::collections::BTreeMap;
34use std::collections::BTreeSet;
35use std::collections::HashMap;
36use std::collections::HashSet;
37
38use crate::config::{AudioFormat, StemFormat};
39use crate::graph::{AlbumArt, PlaylistState};
40use crate::hash::{art_hash, art_url_hash};
41use crate::lineage::LineageContext;
42use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
43use crate::model::Clip;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub enum ArtifactKind {
58 CoverJpg,
60 CoverWebp,
62 DetailsTxt,
64 LyricsTxt,
66 Lrc,
68 VideoMp4,
71 FolderJpg,
73 FolderWebp,
75 FolderMp4,
79 Playlist,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
85#[serde(rename_all = "lowercase")]
86pub enum SourceMode {
87 Mirror,
89 Copy,
91}
92
93#[derive(Debug, Clone, PartialEq)]
100pub struct Desired {
101 pub clip: Clip,
103 pub lineage: LineageContext,
106 pub path: String,
108 pub format: AudioFormat,
110 pub meta_hash: String,
112 pub art_hash: String,
114 pub modes: Vec<SourceMode>,
116 pub trashed: bool,
118 pub private: bool,
120 pub artifacts: Vec<DesiredArtifact>,
128 pub stems: Option<Vec<DesiredStem>>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct DesiredStem {
151 pub key: String,
155 pub stem_id: String,
159 pub path: String,
162 pub source_url: String,
166 pub format: StemFormat,
169 pub hash: String,
171}
172
173#[derive(Debug, Clone, PartialEq)]
178pub struct DesiredArtifact {
179 pub kind: ArtifactKind,
181 pub path: String,
183 pub source_url: String,
186 pub hash: String,
188 pub content: Option<String>,
192}
193
194#[derive(Debug, Clone, PartialEq)]
205pub struct AlbumDesired {
206 pub root_id: String,
208 pub folder_jpg: Option<DesiredArtifact>,
210 pub folder_webp: Option<DesiredArtifact>,
212 pub folder_mp4: Option<DesiredArtifact>,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct PlaylistDesired {
229 pub id: String,
232 pub name: String,
234 pub path: String,
236 pub content: String,
238 pub hash: String,
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
244pub struct LocalFile {
245 pub exists: bool,
247 pub size: u64,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub struct SourceStatus {
254 pub mode: SourceMode,
256 pub fully_enumerated: bool,
258}
259
260#[derive(Debug, Clone, PartialEq)]
262pub enum Action {
263 Download {
265 clip: Clip,
266 lineage: LineageContext,
267 path: String,
268 format: AudioFormat,
269 },
270 Reformat {
276 clip: Clip,
277 path: String,
278 from_path: String,
279 from: AudioFormat,
280 to: AudioFormat,
281 },
282 Retag {
284 clip: Clip,
285 lineage: LineageContext,
286 path: String,
287 },
288 Rename { from: String, to: String },
290 Delete { path: String, clip_id: String },
292 Skip { clip_id: String },
294 WriteArtifact {
306 kind: ArtifactKind,
307 path: String,
308 source_url: String,
309 hash: String,
310 owner_id: String,
311 content: Option<String>,
312 },
313 MoveArtifact {
323 kind: ArtifactKind,
324 from: String,
325 to: String,
326 source_url: String,
327 hash: String,
328 owner_id: String,
329 },
330 DeleteArtifact {
337 kind: ArtifactKind,
338 path: String,
339 owner_id: String,
340 },
341 WriteStem {
351 clip_id: String,
352 key: String,
353 stem_id: String,
354 path: String,
355 source_url: String,
356 format: StemFormat,
357 hash: String,
358 },
359 MoveStem {
367 clip_id: String,
368 key: String,
369 stem_id: String,
370 from: String,
371 to: String,
372 source_url: String,
373 format: StemFormat,
374 hash: String,
375 },
376 DeleteStem {
385 clip_id: String,
386 key: String,
387 path: String,
388 },
389}
390
391#[derive(Debug, Clone, Default, PartialEq)]
396pub struct Plan {
397 pub actions: Vec<Action>,
399}
400
401impl Plan {
402 pub fn len(&self) -> usize {
404 self.actions.len()
405 }
406
407 pub fn is_empty(&self) -> bool {
409 self.actions.is_empty()
410 }
411
412 pub fn downloads(&self) -> usize {
414 self.count(|a| matches!(a, Action::Download { .. }))
415 }
416
417 pub fn reformats(&self) -> usize {
419 self.count(|a| matches!(a, Action::Reformat { .. }))
420 }
421
422 pub fn retags(&self) -> usize {
424 self.count(|a| matches!(a, Action::Retag { .. }))
425 }
426
427 pub fn renames(&self) -> usize {
429 self.count(|a| matches!(a, Action::Rename { .. }))
430 }
431
432 pub fn deletes(&self) -> usize {
434 self.count(|a| matches!(a, Action::Delete { .. }))
435 }
436
437 pub fn skips(&self) -> usize {
439 self.count(|a| matches!(a, Action::Skip { .. }))
440 }
441
442 pub fn artifact_writes(&self) -> usize {
444 self.count(|a| matches!(a, Action::WriteArtifact { .. }))
445 }
446
447 pub fn artifact_deletes(&self) -> usize {
449 self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
450 }
451
452 pub fn stem_writes(&self) -> usize {
454 self.count(|a| matches!(a, Action::WriteStem { .. }))
455 }
456
457 pub fn artifact_moves(&self) -> usize {
460 self.count(|a| matches!(a, Action::MoveArtifact { .. }))
461 }
462
463 pub fn stem_moves(&self) -> usize {
466 self.count(|a| matches!(a, Action::MoveStem { .. }))
467 }
468
469 pub fn stem_deletes(&self) -> usize {
471 self.count(|a| matches!(a, Action::DeleteStem { .. }))
472 }
473
474 fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
475 self.actions.iter().filter(|a| pred(a)).count()
476 }
477}
478
479pub fn reconcile(
494 manifest: &Manifest,
495 desired: &[Desired],
496 local: &HashMap<String, LocalFile>,
497 sources: &[SourceStatus],
498) -> Plan {
499 let merged: Vec<Desired>;
504 let ordered: Vec<&Desired> = if needs_aggregation(desired) {
505 merged = aggregate_desired(desired);
506 merged.iter().collect()
507 } else {
508 let mut refs: Vec<&Desired> = desired.iter().collect();
509 refs.sort_unstable_by(|a, b| a.clip.id.cmp(&b.clip.id));
510 refs
511 };
512 let desired_ids: HashSet<&str> = ordered.iter().map(|d| d.clip.id.as_str()).collect();
513 let mut actions: Vec<Action> = Vec::with_capacity(ordered.len() + manifest.len());
516
517 let can_delete = deletion_allowed(sources);
518
519 for &d in &ordered {
520 let before = actions.len();
525 plan_desired(d, manifest, local, can_delete, &mut actions);
526 let audio_deleted = actions[before..]
527 .iter()
528 .any(|a| matches!(a, Action::Delete { .. }));
529 if audio_deleted {
530 co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
531 co_delete_stems(d.clip.id.as_str(), manifest, can_delete, &mut actions);
532 } else {
533 plan_clip_artifacts(d, manifest, local, can_delete, &mut actions);
534 plan_clip_stems(d, manifest, local, can_delete, &mut actions);
535 }
536 }
537
538 for (clip_id, _entry) in manifest.iter() {
540 if desired_ids.contains(clip_id.as_str()) {
541 continue;
542 }
543 match delete_action(clip_id, manifest, can_delete) {
544 Some(action) => {
545 actions.push(action);
546 co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
549 co_delete_stems(clip_id, manifest, can_delete, &mut actions);
550 }
551 None => actions.push(Action::Skip {
554 clip_id: clip_id.clone(),
555 }),
556 }
557 }
558
559 suppress_path_aliasing(&mut actions);
560 Plan { actions }
561}
562
563pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
574 let mut saw_mirror = false;
575 for status in sources {
576 if !status.fully_enumerated {
577 return false;
578 }
579 if status.mode == SourceMode::Mirror {
580 saw_mirror = true;
581 }
582 }
583 saw_mirror
584}
585
586pub fn playlist_authoritative(complete: bool, any_filtered: bool, narrowed: bool) -> bool {
596 complete && !any_filtered && !narrowed
597}
598
599pub fn area_fully_enumerated(authoritative: bool, clips_empty: bool, mode: SourceMode) -> bool {
612 authoritative && !(clips_empty && mode == SourceMode::Mirror)
613}
614
615pub fn narrows_downloads(can_delete: bool, library_authoritative: bool) -> bool {
623 !can_delete && !library_authoritative
624}
625
626fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
632 if !can_delete {
633 return None;
634 }
635 let entry = manifest.get(clip_id)?;
636 if entry.path.is_empty() || entry.preserve {
637 return None;
638 }
639 Some(Action::Delete {
640 path: entry.path.clone(),
641 clip_id: clip_id.to_string(),
642 })
643}
644
645fn delete_artifact_action(
655 owner_id: &str,
656 kind: ArtifactKind,
657 path: &str,
658 manifest: &Manifest,
659 can_delete: bool,
660) -> Option<Action> {
661 if !can_delete {
662 return None;
663 }
664 let entry = manifest.get(owner_id)?;
665 if path.is_empty() || entry.preserve {
666 return None;
667 }
668 Some(Action::DeleteArtifact {
669 kind,
670 path: path.to_string(),
671 owner_id: owner_id.to_string(),
672 })
673}
674
675fn is_per_clip_kind(kind: ArtifactKind) -> bool {
681 matches!(
682 kind,
683 ArtifactKind::CoverJpg
684 | ArtifactKind::CoverWebp
685 | ArtifactKind::DetailsTxt
686 | ArtifactKind::LyricsTxt
687 | ArtifactKind::Lrc
688 | ArtifactKind::VideoMp4
689 )
690}
691
692fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
718 match kind {
719 ArtifactKind::CoverJpg
720 | ArtifactKind::CoverWebp
721 | ArtifactKind::LyricsTxt
722 | ArtifactKind::Lrc
723 | ArtifactKind::VideoMp4 => false,
724 ArtifactKind::DetailsTxt
725 | ArtifactKind::FolderJpg
726 | ArtifactKind::FolderWebp
727 | ArtifactKind::FolderMp4
728 | ArtifactKind::Playlist => true,
729 }
730}
731
732fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
737 match kind {
738 ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
739 ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
740 ArtifactKind::DetailsTxt => entry.details_txt.as_ref(),
741 ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
742 ArtifactKind::Lrc => entry.lrc.as_ref(),
743 ArtifactKind::VideoMp4 => entry.video_mp4.as_ref(),
744 ArtifactKind::FolderJpg
745 | ArtifactKind::FolderWebp
746 | ArtifactKind::FolderMp4
747 | ArtifactKind::Playlist => None,
748 }
749}
750
751fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
754 let mut out = Vec::new();
755 if let Some(state) = &entry.cover_jpg {
756 out.push((ArtifactKind::CoverJpg, state));
757 }
758 if let Some(state) = &entry.cover_webp {
759 out.push((ArtifactKind::CoverWebp, state));
760 }
761 if let Some(state) = &entry.details_txt {
762 out.push((ArtifactKind::DetailsTxt, state));
763 }
764 if let Some(state) = &entry.lyrics_txt {
765 out.push((ArtifactKind::LyricsTxt, state));
766 }
767 if let Some(state) = &entry.lrc {
768 out.push((ArtifactKind::Lrc, state));
769 }
770 if let Some(state) = &entry.video_mp4 {
771 out.push((ArtifactKind::VideoMp4, state));
772 }
773 out
774}
775
776pub(crate) fn set_manifest_artifact(
783 entry: &mut ManifestEntry,
784 kind: ArtifactKind,
785 state: Option<ArtifactState>,
786) {
787 match kind {
788 ArtifactKind::CoverJpg => entry.cover_jpg = state,
789 ArtifactKind::CoverWebp => entry.cover_webp = state,
790 ArtifactKind::DetailsTxt => entry.details_txt = state,
791 ArtifactKind::LyricsTxt => entry.lyrics_txt = state,
792 ArtifactKind::Lrc => entry.lrc = state,
793 ArtifactKind::VideoMp4 => entry.video_mp4 = state,
794 ArtifactKind::FolderJpg
795 | ArtifactKind::FolderWebp
796 | ArtifactKind::FolderMp4
797 | ArtifactKind::Playlist => {}
798 }
799}
800
801pub(crate) fn set_manifest_stem(
807 entry: &mut ManifestEntry,
808 key: &str,
809 state: Option<ArtifactState>,
810) {
811 match state {
812 Some(state) => {
813 entry.stems.insert(key.to_string(), state);
814 }
815 None => {
816 entry.stems.remove(key);
817 }
818 }
819}
820
821fn needs_write_drift(
822 stored: Option<(&str, &str)>,
823 want_hash: &str,
824 want_path: &str,
825 local: &HashMap<String, LocalFile>,
826) -> bool {
827 match stored {
828 None => true,
829 Some((stored_hash, stored_path)) => {
830 stored_hash != want_hash
831 || stored_path != want_path
832 || local
833 .get(stored_path)
834 .is_some_and(|f| !f.exists || f.size == 0)
835 }
836 }
837}
838
839fn plan_clip_artifacts(
855 d: &Desired,
856 manifest: &Manifest,
857 local: &HashMap<String, LocalFile>,
858 can_delete: bool,
859 out: &mut Vec<Action>,
860) {
861 let owner_id = d.clip.id.as_str();
862 let entry = manifest.get(owner_id);
863
864 for artifact in &d.artifacts {
865 if !is_per_clip_kind(artifact.kind) {
870 continue;
871 }
872 let state = entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind));
878 let needs_write = needs_write_drift(
879 state.map(|state| (state.hash.as_str(), state.path.as_str())),
880 artifact.hash.as_str(),
881 artifact.path.as_str(),
882 local,
883 );
884 if needs_write {
885 if let Some(state) = state
891 && state.hash == artifact.hash
892 && state.path != artifact.path
893 && artifact.content.is_none()
894 && local
895 .get(&state.path)
896 .is_some_and(|f| f.exists && f.size > 0)
897 {
898 out.push(Action::MoveArtifact {
899 kind: artifact.kind,
900 from: state.path.clone(),
901 to: artifact.path.clone(),
902 source_url: artifact.source_url.clone(),
903 hash: artifact.hash.clone(),
904 owner_id: owner_id.to_string(),
905 });
906 } else {
907 out.push(Action::WriteArtifact {
908 kind: artifact.kind,
909 path: artifact.path.clone(),
910 source_url: artifact.source_url.clone(),
911 hash: artifact.hash.clone(),
912 owner_id: owner_id.to_string(),
913 content: artifact.content.clone(),
914 });
915 }
916 }
917 }
918
919 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
924 if !protected_now && let Some(entry) = entry {
925 let desired_kinds: BTreeSet<ArtifactKind> = d
926 .artifacts
927 .iter()
928 .filter(|a| is_per_clip_kind(a.kind))
929 .map(|a| a.kind)
930 .collect();
931 for (kind, state) in manifest_artifacts(entry) {
932 if removed_kind_delete_eligible(kind)
938 && !desired_kinds.contains(&kind)
939 && let Some(action) =
940 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
941 {
942 out.push(action);
943 }
944 }
945 }
946}
947
948fn co_delete_artifacts(
954 owner_id: &str,
955 manifest: &Manifest,
956 can_delete: bool,
957 out: &mut Vec<Action>,
958) {
959 let Some(entry) = manifest.get(owner_id) else {
960 return;
961 };
962 for (kind, state) in manifest_artifacts(entry) {
963 if let Some(action) =
964 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
965 {
966 out.push(action);
967 }
968 }
969}
970
971fn delete_stem_action(
980 clip_id: &str,
981 key: &str,
982 path: &str,
983 manifest: &Manifest,
984 can_delete: bool,
985) -> Option<Action> {
986 if !can_delete {
987 return None;
988 }
989 let entry = manifest.get(clip_id)?;
990 if path.is_empty() || entry.preserve {
991 return None;
992 }
993 Some(Action::DeleteStem {
994 clip_id: clip_id.to_string(),
995 key: key.to_string(),
996 path: path.to_string(),
997 })
998}
999
1000fn plan_clip_stems(
1017 d: &Desired,
1018 manifest: &Manifest,
1019 local: &HashMap<String, LocalFile>,
1020 can_delete: bool,
1021 out: &mut Vec<Action>,
1022) {
1023 let Some(desired_stems) = &d.stems else {
1024 return;
1025 };
1026 let clip_id = d.clip.id.as_str();
1027 let entry = manifest.get(clip_id);
1028
1029 for stem in desired_stems {
1030 let state = entry.and_then(|e| e.stems.get(&stem.key));
1031 let needs_write = match state {
1032 None => true,
1033 Some(state) => state.hash != stem.hash || state.path != stem.path,
1034 };
1035 if needs_write {
1036 if let Some(state) = state
1041 && state.hash == stem.hash
1042 && state.path != stem.path
1043 && local
1044 .get(&state.path)
1045 .is_some_and(|f| f.exists && f.size > 0)
1046 {
1047 out.push(Action::MoveStem {
1048 clip_id: clip_id.to_string(),
1049 key: stem.key.clone(),
1050 stem_id: stem.stem_id.clone(),
1051 from: state.path.clone(),
1052 to: stem.path.clone(),
1053 source_url: stem.source_url.clone(),
1054 format: stem.format,
1055 hash: stem.hash.clone(),
1056 });
1057 } else {
1058 out.push(Action::WriteStem {
1059 clip_id: clip_id.to_string(),
1060 key: stem.key.clone(),
1061 stem_id: stem.stem_id.clone(),
1062 path: stem.path.clone(),
1063 source_url: stem.source_url.clone(),
1064 format: stem.format,
1065 hash: stem.hash.clone(),
1066 });
1067 }
1068 }
1069 }
1070
1071 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
1072 if !protected_now && let Some(entry) = entry {
1073 let desired_keys: BTreeSet<&str> = desired_stems.iter().map(|s| s.key.as_str()).collect();
1074 for (key, state) in &entry.stems {
1075 if !desired_keys.contains(key.as_str())
1081 && let Some(action) =
1082 delete_stem_action(clip_id, key, &state.path, manifest, can_delete)
1083 {
1084 out.push(action);
1085 }
1086 }
1087 }
1088}
1089
1090fn co_delete_stems(clip_id: &str, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
1098 let Some(entry) = manifest.get(clip_id) else {
1099 return;
1100 };
1101 for (key, state) in &entry.stems {
1102 if let Some(action) = delete_stem_action(clip_id, key, &state.path, manifest, can_delete) {
1103 out.push(action);
1104 }
1105 }
1106}
1107
1108fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
1115 let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
1116 for d in desired {
1117 match by_id.get_mut(d.clip.id.as_str()) {
1118 None => {
1119 by_id.insert(d.clip.id.as_str(), d.clone());
1120 }
1121 Some(acc) => {
1122 let take = rep_key(d) < rep_key(acc);
1123 acc.private = acc.private || d.private;
1124 acc.trashed = acc.trashed && d.trashed;
1125 for mode in &d.modes {
1126 if !acc.modes.contains(mode) {
1127 acc.modes.push(*mode);
1128 }
1129 }
1130 if take {
1131 acc.clip = d.clip.clone();
1132 acc.path = d.path.clone();
1133 acc.format = d.format;
1134 acc.meta_hash = d.meta_hash.clone();
1135 acc.art_hash = d.art_hash.clone();
1136 acc.artifacts = d.artifacts.clone();
1137 acc.stems = d.stems.clone();
1138 }
1139 }
1140 }
1141 }
1142 let mut out: Vec<Desired> = by_id.into_values().collect();
1143 for d in &mut out {
1144 let has_mirror = d.modes.contains(&SourceMode::Mirror);
1146 let has_copy = d.modes.contains(&SourceMode::Copy);
1147 d.modes.clear();
1148 if has_mirror {
1149 d.modes.push(SourceMode::Mirror);
1150 }
1151 if has_copy {
1152 d.modes.push(SourceMode::Copy);
1153 }
1154 }
1155 out
1156}
1157
1158fn needs_aggregation(desired: &[Desired]) -> bool {
1163 let mut seen: HashSet<&str> = HashSet::with_capacity(desired.len());
1164 desired
1165 .iter()
1166 .any(|d| !seen.insert(d.clip.id.as_str()) || !modes_are_canonical(&d.modes))
1167}
1168
1169fn modes_are_canonical(modes: &[SourceMode]) -> bool {
1172 matches!(
1173 modes,
1174 [] | [SourceMode::Mirror] | [SourceMode::Copy] | [SourceMode::Mirror, SourceMode::Copy]
1175 )
1176}
1177
1178fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
1181 let format = match d.format {
1182 AudioFormat::Mp3 => 0,
1183 AudioFormat::Flac => 1,
1184 AudioFormat::Wav => 2,
1185 AudioFormat::Alac => 3,
1186 };
1187 (
1188 d.path.as_str(),
1189 d.meta_hash.as_str(),
1190 d.art_hash.as_str(),
1191 format,
1192 )
1193}
1194
1195fn suppress_path_aliasing(actions: &mut [Action]) {
1202 let aliased: Vec<usize> = {
1206 let targets: BTreeSet<&str> = actions
1207 .iter()
1208 .filter_map(|a| match a {
1209 Action::Download { path, .. }
1210 | Action::Reformat { path, .. }
1211 | Action::WriteArtifact { path, .. }
1212 | Action::WriteStem { path, .. } => Some(path.as_str()),
1213 Action::Rename { to, .. }
1214 | Action::MoveArtifact { to, .. }
1215 | Action::MoveStem { to, .. } => Some(to.as_str()),
1216 _ => None,
1217 })
1218 .collect();
1219 actions
1220 .iter()
1221 .enumerate()
1222 .filter_map(|(index, a)| match a {
1223 Action::Delete { path, .. }
1224 | Action::DeleteArtifact { path, .. }
1225 | Action::DeleteStem { path, .. } => {
1226 targets.contains(path.as_str()).then_some(index)
1227 }
1228 _ => None,
1229 })
1230 .collect()
1231 };
1232 for index in aliased {
1233 actions[index] = match &actions[index] {
1234 Action::Delete { clip_id, .. } | Action::DeleteStem { clip_id, .. } => Action::Skip {
1235 clip_id: clip_id.clone(),
1236 },
1237 Action::DeleteArtifact { owner_id, .. } => Action::Skip {
1238 clip_id: owner_id.clone(),
1239 },
1240 _ => unreachable!("only delete actions are collected as aliased"),
1241 };
1242 }
1243}
1244
1245fn plan_desired(
1247 d: &Desired,
1248 manifest: &Manifest,
1249 local: &HashMap<String, LocalFile>,
1250 can_delete: bool,
1251 out: &mut Vec<Action>,
1252) {
1253 let clip_id = d.clip.id.as_str();
1254 let copy_held = d.modes.contains(&SourceMode::Copy);
1255
1256 if d.trashed && !d.private && !copy_held {
1262 match delete_action(clip_id, manifest, can_delete) {
1263 Some(action) => out.push(action),
1264 None => out.push(Action::Skip {
1265 clip_id: clip_id.to_string(),
1266 }),
1267 }
1268 return;
1269 }
1270
1271 let Some(entry) = manifest.get(clip_id) else {
1272 out.push(Action::Download {
1274 clip: d.clip.clone(),
1275 lineage: d.lineage.clone(),
1276 path: d.path.clone(),
1277 format: d.format,
1278 });
1279 return;
1280 };
1281
1282 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
1285 if missing {
1286 out.push(Action::Download {
1287 clip: d.clip.clone(),
1288 lineage: d.lineage.clone(),
1289 path: d.path.clone(),
1290 format: d.format,
1291 });
1292 return;
1293 }
1294
1295 if d.format != entry.format {
1296 out.push(Action::Reformat {
1299 clip: d.clip.clone(),
1300 path: d.path.clone(),
1301 from_path: entry.path.clone(),
1302 from: entry.format,
1303 to: d.format,
1304 });
1305 return;
1306 }
1307
1308 if d.path != entry.path {
1309 out.push(Action::Rename {
1310 from: entry.path.clone(),
1311 to: d.path.clone(),
1312 });
1313 if meta_or_art_changed(d, entry) {
1315 out.push(Action::Retag {
1316 clip: d.clip.clone(),
1317 lineage: d.lineage.clone(),
1318 path: d.path.clone(),
1319 });
1320 }
1321 return;
1322 }
1323
1324 if meta_or_art_changed(d, entry) {
1325 out.push(Action::Retag {
1326 clip: d.clip.clone(),
1327 lineage: d.lineage.clone(),
1328 path: entry.path.clone(),
1329 });
1330 return;
1331 }
1332
1333 out.push(Action::Skip {
1334 clip_id: clip_id.to_string(),
1335 });
1336}
1337
1338fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
1340 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
1341}
1342
1343pub fn album_desired(
1367 desired: &[Desired],
1368 animated_covers: bool,
1369 raw_cover: bool,
1370) -> Vec<AlbumDesired> {
1371 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
1372 for d in desired {
1373 groups
1374 .entry(d.lineage.root_id.as_str())
1375 .or_default()
1376 .push(d);
1377 }
1378
1379 groups
1380 .into_iter()
1381 .map(|(root_id, members)| {
1382 let album_dir = album_dir_of(&members);
1383 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
1384 kind: ArtifactKind::FolderJpg,
1385 path: album_child(&album_dir, "folder.jpg"),
1386 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
1387 hash: art_hash(&source.clip),
1388 content: None,
1389 });
1390 let folder_webp = animated_covers
1391 .then(|| folder_webp_source(&members))
1392 .flatten()
1393 .map(|source| DesiredArtifact {
1394 kind: ArtifactKind::FolderWebp,
1395 path: album_child(&album_dir, "cover.webp"),
1396 source_url: source.clip.video_cover_url.clone(),
1397 hash: art_url_hash(&source.clip.video_cover_url),
1398 content: None,
1399 });
1400 let folder_mp4 = raw_cover
1401 .then(|| folder_webp_source(&members))
1402 .flatten()
1403 .map(|source| DesiredArtifact {
1404 kind: ArtifactKind::FolderMp4,
1405 path: album_child(&album_dir, "cover.mp4"),
1406 source_url: source.clip.video_cover_url.clone(),
1407 hash: art_url_hash(&source.clip.video_cover_url),
1408 content: None,
1409 });
1410 AlbumDesired {
1411 root_id: root_id.to_owned(),
1412 folder_jpg,
1413 folder_webp,
1414 folder_mp4,
1415 }
1416 })
1417 .collect()
1418}
1419
1420fn album_dir_of(members: &[&Desired]) -> String {
1425 members
1426 .iter()
1427 .map(|d| parent_dir(&d.path))
1428 .min()
1429 .unwrap_or("")
1430 .to_owned()
1431}
1432
1433fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1439 members
1440 .iter()
1441 .copied()
1442 .filter(|d| {
1443 d.clip
1444 .selected_image_url()
1445 .is_some_and(|url| !url.is_empty())
1446 })
1447 .min_by(|a, b| {
1448 b.clip
1449 .play_count
1450 .cmp(&a.clip.play_count)
1451 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
1452 .then_with(|| a.clip.id.cmp(&b.clip.id))
1453 })
1454}
1455
1456fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1461 members
1462 .iter()
1463 .copied()
1464 .filter(|d| !d.clip.video_cover_url.is_empty())
1465 .min_by(|a, b| {
1466 a.clip
1467 .created_at
1468 .cmp(&b.clip.created_at)
1469 .then_with(|| a.clip.id.cmp(&b.clip.id))
1470 })
1471}
1472
1473fn parent_dir(path: &str) -> &str {
1475 match path.rsplit_once('/') {
1476 Some((dir, _)) => dir,
1477 None => "",
1478 }
1479}
1480
1481fn album_child(album_dir: &str, name: &str) -> String {
1484 if album_dir.is_empty() {
1485 name.to_owned()
1486 } else {
1487 format!("{album_dir}/{name}")
1488 }
1489}
1490
1491pub fn plan_album_artifacts(
1515 desired: &[AlbumDesired],
1516 albums: &BTreeMap<String, AlbumArt>,
1517 can_delete: bool,
1518 local: &HashMap<String, LocalFile>,
1519) -> Vec<Action> {
1520 let mut actions: Vec<Action> = Vec::new();
1521 let by_root: BTreeMap<&str, &AlbumDesired> =
1522 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1523
1524 for d in desired {
1525 let stored = albums.get(&d.root_id);
1526 for artifact in [
1527 d.folder_jpg.as_ref(),
1528 d.folder_webp.as_ref(),
1529 d.folder_mp4.as_ref(),
1530 ]
1531 .into_iter()
1532 .flatten()
1533 {
1534 let needs_write = needs_write_drift(
1535 stored
1536 .and_then(|a| a.artifact(artifact.kind))
1537 .map(|state| (state.hash.as_str(), state.path.as_str())),
1538 artifact.hash.as_str(),
1539 artifact.path.as_str(),
1540 local,
1541 );
1542 if needs_write {
1543 actions.push(Action::WriteArtifact {
1544 kind: artifact.kind,
1545 path: artifact.path.clone(),
1546 source_url: artifact.source_url.clone(),
1547 hash: artifact.hash.clone(),
1548 owner_id: d.root_id.clone(),
1549 content: None,
1550 });
1551 }
1552 }
1553 }
1554
1555 if can_delete {
1557 for (root_id, art) in albums {
1558 for (kind, state) in album_artifacts(art) {
1559 let desired_here = by_root
1560 .get(root_id.as_str())
1561 .is_some_and(|d| album_desires_kind(d, kind));
1562 if !desired_here && !state.path.is_empty() {
1563 actions.push(Action::DeleteArtifact {
1564 kind,
1565 path: state.path.clone(),
1566 owner_id: root_id.clone(),
1567 });
1568 }
1569 }
1570 }
1571 }
1572
1573 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1574 actions
1575}
1576
1577fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1580 let mut out = Vec::new();
1581 if let Some(state) = &art.folder_jpg {
1582 out.push((ArtifactKind::FolderJpg, state));
1583 }
1584 if let Some(state) = &art.folder_webp {
1585 out.push((ArtifactKind::FolderWebp, state));
1586 }
1587 if let Some(state) = &art.folder_mp4 {
1588 out.push((ArtifactKind::FolderMp4, state));
1589 }
1590 out
1591}
1592
1593fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1595 match kind {
1596 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1597 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1598 ArtifactKind::FolderMp4 => d.folder_mp4.is_some(),
1599 ArtifactKind::CoverJpg
1600 | ArtifactKind::CoverWebp
1601 | ArtifactKind::DetailsTxt
1602 | ArtifactKind::LyricsTxt
1603 | ArtifactKind::Lrc
1604 | ArtifactKind::VideoMp4
1605 | ArtifactKind::Playlist => false,
1606 }
1607}
1608
1609fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1611 match action {
1612 Action::WriteArtifact { owner_id, kind, .. }
1613 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1614 _ => ("", ArtifactKind::CoverJpg),
1615 }
1616}
1617
1618pub fn plan_playlist_artifacts(
1656 desired: &[PlaylistDesired],
1657 stored: &BTreeMap<String, PlaylistState>,
1658 can_delete: bool,
1659 list_fully_enumerated: bool,
1660 local: &HashMap<String, LocalFile>,
1661) -> Vec<Action> {
1662 let mut actions: Vec<Action> = Vec::new();
1663 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1664 let deletes_allowed = can_delete && list_fully_enumerated;
1667
1668 for d in desired {
1669 let stored_here = stored.get(&d.id);
1670 let needs_write = needs_write_drift(
1671 stored_here.map(|state| (state.hash.as_str(), state.path.as_str())),
1672 d.hash.as_str(),
1673 d.path.as_str(),
1674 local,
1675 );
1676 if needs_write {
1677 actions.push(Action::WriteArtifact {
1678 kind: ArtifactKind::Playlist,
1679 path: d.path.clone(),
1680 source_url: String::new(),
1681 hash: d.hash.clone(),
1682 owner_id: d.id.clone(),
1683 content: Some(d.content.clone()),
1684 });
1685 }
1686 if deletes_allowed
1688 && let Some(state) = stored_here
1689 && !state.path.is_empty()
1690 && state.path != d.path
1691 {
1692 actions.push(Action::DeleteArtifact {
1693 kind: ArtifactKind::Playlist,
1694 path: state.path.clone(),
1695 owner_id: d.id.clone(),
1696 });
1697 }
1698 }
1699
1700 if deletes_allowed {
1703 for (id, state) in stored {
1704 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1705 actions.push(Action::DeleteArtifact {
1706 kind: ArtifactKind::Playlist,
1707 path: state.path.clone(),
1708 owner_id: id.clone(),
1709 });
1710 }
1711 }
1712 }
1713
1714 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1715 suppress_path_aliasing(&mut actions);
1718 actions
1719}
1720
1721fn playlist_action_key(action: &Action) -> (&str, u8) {
1724 match action {
1725 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1726 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1727 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1728 _ => ("", 3),
1729 }
1730}
1731
1732#[cfg(test)]
1733mod tests {
1734 use super::*;
1735 use crate::hash::content_hash;
1736
1737 fn clip(id: &str) -> Clip {
1738 Clip {
1739 id: id.to_string(),
1740 title: "Song".to_string(),
1741 ..Default::default()
1742 }
1743 }
1744
1745 fn lineage(id: &str) -> LineageContext {
1746 LineageContext::own_root(&clip(id))
1747 }
1748
1749 fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1750 ManifestEntry {
1751 path: path.to_string(),
1752 format,
1753 meta_hash: meta.to_string(),
1754 art_hash: art.to_string(),
1755 size: 100,
1756 preserve: false,
1757 ..Default::default()
1758 }
1759 }
1760
1761 fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1762 ManifestEntry {
1763 preserve: true,
1764 ..entry(path, format, meta, art)
1765 }
1766 }
1767
1768 fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1769 Desired {
1770 clip: clip(id),
1771 lineage: lineage(id),
1772 path: path.to_string(),
1773 format,
1774 meta_hash: meta.to_string(),
1775 art_hash: art.to_string(),
1776 modes: vec![SourceMode::Mirror],
1777 trashed: false,
1778 private: false,
1779 artifacts: Vec::new(),
1780 stems: None,
1781 }
1782 }
1783
1784 fn present(size: u64) -> LocalFile {
1785 LocalFile { exists: true, size }
1786 }
1787
1788 fn local_present(id: &str) -> HashMap<String, LocalFile> {
1789 [(id.to_string(), present(100))].into_iter().collect()
1790 }
1791
1792 fn mirror_ok() -> Vec<SourceStatus> {
1793 vec![SourceStatus {
1794 mode: SourceMode::Mirror,
1795 fully_enumerated: true,
1796 }]
1797 }
1798
1799 #[test]
1802 fn not_in_manifest_downloads() {
1803 let manifest = Manifest::new();
1804 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1805 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1806 assert_eq!(
1807 plan.actions,
1808 vec![Action::Download {
1809 clip: clip("a"),
1810 lineage: lineage("a"),
1811 path: "a.flac".to_string(),
1812 format: AudioFormat::Flac,
1813 }]
1814 );
1815 }
1816
1817 #[test]
1818 fn unchanged_clip_skips() {
1819 let mut manifest = Manifest::new();
1820 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1821 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1822 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1823 assert_eq!(
1824 plan.actions,
1825 vec![Action::Skip {
1826 clip_id: "a".to_string()
1827 }]
1828 );
1829 }
1830
1831 #[test]
1832 fn meta_change_retags_in_place() {
1833 let mut manifest = Manifest::new();
1834 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1835 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1836 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1837 assert_eq!(
1838 plan.actions,
1839 vec![Action::Retag {
1840 clip: clip("a"),
1841 lineage: lineage("a"),
1842 path: "a.flac".to_string(),
1843 }]
1844 );
1845 }
1846
1847 #[test]
1848 fn art_change_retags_in_place() {
1849 let mut manifest = Manifest::new();
1850 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1851 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1852 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1853 assert_eq!(
1854 plan.actions,
1855 vec![Action::Retag {
1856 clip: clip("a"),
1857 lineage: lineage("a"),
1858 path: "a.flac".to_string(),
1859 }]
1860 );
1861 }
1862
1863 #[test]
1864 fn rename_when_path_changes() {
1865 let mut manifest = Manifest::new();
1866 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1867 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1868 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1869 assert_eq!(
1870 plan.actions,
1871 vec![Action::Rename {
1872 from: "old/a.flac".to_string(),
1873 to: "new/a.flac".to_string(),
1874 }]
1875 );
1876 }
1877
1878 #[test]
1879 fn rename_with_meta_change_also_retags() {
1880 let mut manifest = Manifest::new();
1881 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1882 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1883 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1884 assert_eq!(
1885 plan.actions,
1886 vec![
1887 Action::Rename {
1888 from: "old/a.flac".to_string(),
1889 to: "new/a.flac".to_string(),
1890 },
1891 Action::Retag {
1892 clip: clip("a"),
1893 lineage: lineage("a"),
1894 path: "new/a.flac".to_string(),
1895 },
1896 ]
1897 );
1898 }
1899
1900 #[test]
1901 fn bulk_album_rename_moves_and_retags_without_redownload() {
1902 let mut manifest = Manifest::new();
1907 for id in ["a", "b", "c"] {
1908 manifest.insert(
1909 id,
1910 entry(
1911 &format!("Creator/Old Album/{id}.flac"),
1912 AudioFormat::Flac,
1913 "old-meta",
1914 "art",
1915 ),
1916 );
1917 }
1918 let d: Vec<Desired> = ["a", "b", "c"]
1919 .iter()
1920 .map(|id| {
1921 desired(
1922 id,
1923 &format!("Creator/New Album/{id}.flac"),
1924 AudioFormat::Flac,
1925 "new-meta",
1926 "art",
1927 )
1928 })
1929 .collect();
1930 let local: HashMap<String, LocalFile> = ["a", "b", "c"]
1931 .iter()
1932 .map(|id| (id.to_string(), present(100)))
1933 .collect();
1934
1935 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1936
1937 assert_eq!(plan.renames(), 3, "every member folder move is a rename");
1938 assert_eq!(
1939 plan.retags(),
1940 3,
1941 "the album tag change retags each in place"
1942 );
1943 assert_eq!(
1944 plan.downloads(),
1945 0,
1946 "an album rename must never re-download"
1947 );
1948 assert_eq!(
1949 plan.deletes(),
1950 0,
1951 "deletion safety: a rename deletes nothing"
1952 );
1953 for id in ["a", "b", "c"] {
1954 assert!(plan.actions.contains(&Action::Rename {
1955 from: format!("Creator/Old Album/{id}.flac"),
1956 to: format!("Creator/New Album/{id}.flac"),
1957 }));
1958 }
1959 }
1960
1961 #[test]
1962 fn mis_rooted_clip_moves_never_deletes_even_when_deletion_is_armed() {
1963 let mut manifest = Manifest::new();
1969 manifest.insert(
1970 "child",
1971 entry("Creator/Root A/child.flac", AudioFormat::Flac, "m", "art"),
1972 );
1973 let d = vec![desired(
1974 "child",
1975 "Creator/Root B/child.flac",
1976 AudioFormat::Flac,
1977 "m",
1978 "art",
1979 )];
1980 let plan = reconcile(&manifest, &d, &local_present("child"), &mirror_ok());
1981
1982 assert_eq!(
1983 plan.actions,
1984 vec![Action::Rename {
1985 from: "Creator/Root A/child.flac".to_string(),
1986 to: "Creator/Root B/child.flac".to_string(),
1987 }],
1988 "a mis-rooted clip is moved, not deleted or re-downloaded"
1989 );
1990 assert_eq!(
1991 plan.deletes(),
1992 0,
1993 "deletion safety: a re-root deletes nothing"
1994 );
1995 assert_eq!(plan.downloads(), 0, "a re-root never re-fetches audio");
1996 }
1997
1998 #[test]
1999 fn format_change_reformats() {
2000 let mut manifest = Manifest::new();
2001 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2002 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
2003 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2004 assert_eq!(
2005 plan.actions,
2006 vec![Action::Reformat {
2007 clip: clip("a"),
2008 path: "a.mp3".to_string(),
2009 from_path: "a.flac".to_string(),
2010 from: AudioFormat::Flac,
2011 to: AudioFormat::Mp3,
2012 }]
2013 );
2014 }
2015
2016 #[test]
2017 fn format_change_takes_precedence_over_rename_and_retag() {
2018 let mut manifest = Manifest::new();
2021 manifest.insert(
2022 "a",
2023 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
2024 );
2025 let d = vec![desired(
2026 "a",
2027 "new/a.mp3",
2028 AudioFormat::Mp3,
2029 "new",
2030 "new-art",
2031 )];
2032 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2033 assert_eq!(plan.reformats(), 1);
2034 assert_eq!(plan.renames(), 0);
2035 assert_eq!(plan.retags(), 0);
2036 }
2037
2038 #[test]
2041 fn zero_length_file_downloads_even_when_hashes_match() {
2042 let mut manifest = Manifest::new();
2043 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2044 let local: HashMap<String, LocalFile> = [(
2045 "a".to_string(),
2046 LocalFile {
2047 exists: true,
2048 size: 0,
2049 },
2050 )]
2051 .into_iter()
2052 .collect();
2053 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2054 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2055 assert_eq!(plan.downloads(), 1);
2056 assert_eq!(plan.skips(), 0);
2057 }
2058
2059 #[test]
2060 fn missing_file_downloads_even_when_hashes_match() {
2061 let mut manifest = Manifest::new();
2062 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2063 let local: HashMap<String, LocalFile> = [(
2064 "a".to_string(),
2065 LocalFile {
2066 exists: false,
2067 size: 0,
2068 },
2069 )]
2070 .into_iter()
2071 .collect();
2072 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2073 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2074 assert_eq!(plan.downloads(), 1);
2075 }
2076
2077 #[test]
2078 fn absent_local_probe_treated_as_missing() {
2079 let mut manifest = Manifest::new();
2081 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2082 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2083 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2084 assert_eq!(plan.downloads(), 1);
2085 }
2086
2087 #[test]
2088 fn missing_file_download_wins_over_format_difference() {
2089 let mut manifest = Manifest::new();
2092 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2093 let local: HashMap<String, LocalFile> = [(
2094 "a".to_string(),
2095 LocalFile {
2096 exists: false,
2097 size: 0,
2098 },
2099 )]
2100 .into_iter()
2101 .collect();
2102 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
2103 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2104 assert_eq!(plan.downloads(), 1);
2105 assert_eq!(plan.reformats(), 0);
2106 }
2107
2108 #[test]
2111 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
2112 let mut trashed = clip("a");
2117 trashed.status = "complete".to_string();
2118 trashed.is_trashed = true;
2119 assert!(crate::is_downloadable(&trashed));
2120
2121 let mut manifest = Manifest::new();
2122 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2123 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2124 d.clip = trashed;
2125 d.trashed = true;
2126 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2127 assert_eq!(
2128 plan.actions,
2129 vec![Action::Delete {
2130 path: "a.flac".to_string(),
2131 clip_id: "a".to_string(),
2132 }]
2133 );
2134 }
2135
2136 #[test]
2137 fn trashed_clip_deletes_local_file() {
2138 let mut manifest = Manifest::new();
2139 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2140 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2141 d.trashed = true;
2142 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2143 assert_eq!(
2144 plan.actions,
2145 vec![Action::Delete {
2146 path: "a.flac".to_string(),
2147 clip_id: "a".to_string(),
2148 }]
2149 );
2150 }
2151
2152 #[test]
2153 fn trashed_clip_not_in_manifest_skips() {
2154 let manifest = Manifest::new();
2156 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2157 d.trashed = true;
2158 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2159 assert_eq!(
2160 plan.actions,
2161 vec![Action::Skip {
2162 clip_id: "a".to_string()
2163 }]
2164 );
2165 }
2166
2167 #[test]
2168 fn private_clip_is_kept() {
2169 let mut manifest = Manifest::new();
2170 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2171 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2172 d.private = true;
2173 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2174 assert_eq!(
2175 plan.actions,
2176 vec![Action::Skip {
2177 clip_id: "a".to_string()
2178 }]
2179 );
2180 }
2181
2182 #[test]
2183 fn private_beats_trashed_never_deletes() {
2184 let mut manifest = Manifest::new();
2186 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2187 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2188 d.trashed = true;
2189 d.private = true;
2190 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2191 assert_eq!(plan.deletes(), 0);
2192 assert_eq!(plan.skips(), 1);
2193 }
2194
2195 #[test]
2196 fn copy_held_trashed_clip_is_not_deleted() {
2197 let mut manifest = Manifest::new();
2200 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2201 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2202 d.modes = vec![SourceMode::Copy];
2203 d.trashed = true;
2204 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2205 assert_eq!(plan.deletes(), 0);
2206 assert_eq!(
2207 plan.actions,
2208 vec![Action::Skip {
2209 clip_id: "a".to_string()
2210 }]
2211 );
2212 }
2213
2214 #[test]
2217 fn absent_clip_deleted_when_all_mirrors_enumerated() {
2218 let mut manifest = Manifest::new();
2219 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2220 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2221 assert_eq!(
2222 plan.actions,
2223 vec![Action::Delete {
2224 path: "gone.flac".to_string(),
2225 clip_id: "gone".to_string(),
2226 }]
2227 );
2228 }
2229
2230 #[test]
2231 fn absent_clip_kept_when_any_mirror_not_enumerated() {
2232 let mut manifest = Manifest::new();
2233 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2234 let sources = vec![
2235 SourceStatus {
2236 mode: SourceMode::Mirror,
2237 fully_enumerated: true,
2238 },
2239 SourceStatus {
2240 mode: SourceMode::Mirror,
2241 fully_enumerated: false,
2242 },
2243 ];
2244 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2245 assert_eq!(plan.deletes(), 0);
2246 assert_eq!(
2247 plan.actions,
2248 vec![Action::Skip {
2249 clip_id: "gone".to_string()
2250 }]
2251 );
2252 }
2253
2254 #[test]
2255 fn empty_listing_cannot_cause_deletion() {
2256 let mut manifest = Manifest::new();
2259 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2260 let sources = vec![SourceStatus {
2261 mode: SourceMode::Mirror,
2262 fully_enumerated: false,
2263 }];
2264 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2265 assert_eq!(plan.deletes(), 0);
2266 assert_eq!(plan.skips(), 1);
2267 }
2268
2269 #[test]
2270 fn no_mirror_sources_means_no_deletion() {
2271 let mut manifest = Manifest::new();
2273 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2274 let copy_only = vec![SourceStatus {
2275 mode: SourceMode::Copy,
2276 fully_enumerated: true,
2277 }];
2278 assert_eq!(
2279 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
2280 0
2281 );
2282 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
2283 }
2284
2285 #[test]
2286 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
2287 let mut manifest = Manifest::new();
2288 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2289 let sources = vec![
2290 SourceStatus {
2291 mode: SourceMode::Copy,
2292 fully_enumerated: true,
2293 },
2294 SourceStatus {
2295 mode: SourceMode::Mirror,
2296 fully_enumerated: false,
2297 },
2298 ];
2299 assert_eq!(
2300 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
2301 0
2302 );
2303 }
2304
2305 #[test]
2306 fn playlist_authoritative_requires_all_conditions() {
2307 assert!(playlist_authoritative(true, false, false));
2309 assert!(!playlist_authoritative(false, false, false));
2311 assert!(!playlist_authoritative(true, true, false));
2313 assert!(!playlist_authoritative(true, false, true));
2315 assert!(!playlist_authoritative(false, true, true));
2317 }
2318
2319 #[test]
2320 fn area_fully_enumerated_applies_empty_mirror_guard() {
2321 assert!(area_fully_enumerated(true, false, SourceMode::Mirror));
2323 assert!(!area_fully_enumerated(true, true, SourceMode::Mirror));
2325 assert!(area_fully_enumerated(true, true, SourceMode::Copy));
2327 assert!(area_fully_enumerated(true, false, SourceMode::Copy));
2329 assert!(!area_fully_enumerated(false, false, SourceMode::Mirror));
2331 assert!(!area_fully_enumerated(false, true, SourceMode::Copy));
2332 }
2333
2334 #[test]
2335 fn narrows_downloads_only_when_no_deletion_and_no_full_library() {
2336 assert!(narrows_downloads(false, false));
2338 assert!(!narrows_downloads(true, false));
2340 assert!(!narrows_downloads(false, true));
2342 assert!(!narrows_downloads(true, true));
2344 }
2345
2346 #[test]
2347 fn narrowing_never_coexists_with_deletion() {
2348 for can_delete in [false, true] {
2349 for lib_auth in [false, true] {
2350 assert!(
2351 !(narrows_downloads(can_delete, lib_auth) && can_delete),
2352 "truncate must imply !can_delete"
2353 );
2354 }
2355 }
2356 }
2357
2358 #[test]
2359 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
2360 let mut manifest = Manifest::new();
2364 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
2365 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2366 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
2367 held.modes = vec![SourceMode::Copy];
2368 let local: HashMap<String, LocalFile> = [
2369 ("keep".to_string(), present(100)),
2370 ("gone".to_string(), present(100)),
2371 ]
2372 .into_iter()
2373 .collect();
2374 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
2375 assert!(plan.actions.contains(&Action::Skip {
2376 clip_id: "keep".to_string()
2377 }));
2378 assert!(plan.actions.contains(&Action::Delete {
2379 path: "gone.flac".to_string(),
2380 clip_id: "gone".to_string(),
2381 }));
2382 assert!(
2384 !plan
2385 .actions
2386 .iter()
2387 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
2388 );
2389 }
2390
2391 #[test]
2394 fn orphan_with_preserve_marker_is_kept() {
2395 let mut manifest = Manifest::new();
2398 manifest.insert(
2399 "gone",
2400 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
2401 );
2402 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2403 assert_eq!(plan.deletes(), 0);
2404 assert_eq!(
2405 plan.actions,
2406 vec![Action::Skip {
2407 clip_id: "gone".to_string()
2408 }]
2409 );
2410 }
2411
2412 #[test]
2413 fn trashed_clip_with_preserve_marker_is_kept() {
2414 let mut manifest = Manifest::new();
2417 manifest.insert(
2418 "a",
2419 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2420 );
2421 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2422 d.trashed = true;
2423 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2424 assert_eq!(plan.deletes(), 0);
2425 assert_eq!(plan.skips(), 1);
2426 }
2427
2428 #[test]
2431 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
2432 let mut manifest = Manifest::new();
2434 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2435 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2436 d.trashed = true;
2437 let sources = vec![SourceStatus {
2438 mode: SourceMode::Mirror,
2439 fully_enumerated: false,
2440 }];
2441 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2442 assert_eq!(plan.deletes(), 0);
2443 assert_eq!(plan.skips(), 1);
2444 }
2445
2446 #[test]
2447 fn trashed_clip_kept_when_sources_empty() {
2448 let mut manifest = Manifest::new();
2451 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2452 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2453 d.trashed = true;
2454 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
2455 assert_eq!(plan.deletes(), 0);
2456 assert_eq!(plan.skips(), 1);
2457 }
2458
2459 #[test]
2460 fn failed_copy_listing_suppresses_orphan_deletion() {
2461 let mut manifest = Manifest::new();
2464 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2465 let sources = vec![
2466 SourceStatus {
2467 mode: SourceMode::Mirror,
2468 fully_enumerated: true,
2469 },
2470 SourceStatus {
2471 mode: SourceMode::Copy,
2472 fully_enumerated: false,
2473 },
2474 ];
2475 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2476 assert_eq!(plan.deletes(), 0);
2477 }
2478
2479 #[test]
2480 fn failed_copy_listing_suppresses_trashed_deletion() {
2481 let mut manifest = Manifest::new();
2482 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2483 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2484 d.trashed = true;
2485 let sources = vec![
2486 SourceStatus {
2487 mode: SourceMode::Mirror,
2488 fully_enumerated: true,
2489 },
2490 SourceStatus {
2491 mode: SourceMode::Copy,
2492 fully_enumerated: false,
2493 },
2494 ];
2495 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2496 assert_eq!(plan.deletes(), 0);
2497 assert_eq!(plan.skips(), 1);
2498 }
2499
2500 #[test]
2501 fn empty_path_entry_never_deletes() {
2502 let mut manifest = Manifest::new();
2505 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
2506 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2507 assert_eq!(plan.deletes(), 0);
2508 assert_eq!(
2509 plan.actions,
2510 vec![Action::Skip {
2511 clip_id: "gone".to_string()
2512 }]
2513 );
2514 }
2515
2516 #[test]
2519 fn delete_suppressed_when_path_aliases_rename_target() {
2520 let mut manifest = Manifest::new();
2523 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
2524 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
2525 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
2526 let local: HashMap<String, LocalFile> = [
2527 ("a".to_string(), present(100)),
2528 ("b".to_string(), present(100)),
2529 ]
2530 .into_iter()
2531 .collect();
2532 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2533 assert!(plan.actions.contains(&Action::Rename {
2534 from: "old/a.flac".to_string(),
2535 to: "new/a.flac".to_string(),
2536 }));
2537 assert!(
2539 !plan
2540 .actions
2541 .iter()
2542 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
2543 );
2544 assert!(plan.actions.contains(&Action::Skip {
2545 clip_id: "b".to_string()
2546 }));
2547 }
2548
2549 #[test]
2550 fn delete_suppressed_when_path_aliases_download_target() {
2551 let mut manifest = Manifest::new();
2553 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
2554 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
2555 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2556 assert!(
2557 !plan
2558 .actions
2559 .iter()
2560 .any(|a| matches!(a, Action::Delete { .. }))
2561 );
2562 assert_eq!(plan.downloads(), 1);
2563 }
2564
2565 #[test]
2566 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
2567 let mut actions = vec![
2572 Action::Rename {
2573 from: "old/song.flac".to_string(),
2574 to: "new/cover.jpg".to_string(),
2575 },
2576 Action::DeleteArtifact {
2577 kind: ArtifactKind::CoverJpg,
2578 path: "new/cover.jpg".to_string(),
2579 owner_id: "a".to_string(),
2580 },
2581 ];
2582 suppress_path_aliasing(&mut actions);
2583 assert!(
2585 !actions
2586 .iter()
2587 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
2588 "a sidecar delete must not alias a rename target"
2589 );
2590 assert!(actions.contains(&Action::Skip {
2591 clip_id: "a".to_string()
2592 }));
2593 assert!(actions.contains(&Action::Rename {
2595 from: "old/song.flac".to_string(),
2596 to: "new/cover.jpg".to_string(),
2597 }));
2598 }
2599
2600 #[test]
2601 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
2602 let mut actions = vec![
2605 Action::WriteArtifact {
2606 kind: ArtifactKind::FolderJpg,
2607 path: "creator/album/folder.jpg".to_string(),
2608 source_url: "https://art/large.jpg".to_string(),
2609 hash: "h".to_string(),
2610 owner_id: "root".to_string(),
2611 content: None,
2612 },
2613 Action::DeleteArtifact {
2614 kind: ArtifactKind::FolderJpg,
2615 path: "creator/album/folder.jpg".to_string(),
2616 owner_id: "root-old".to_string(),
2617 },
2618 ];
2619 suppress_path_aliasing(&mut actions);
2620 assert!(
2621 !actions
2622 .iter()
2623 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
2624 );
2625 assert!(actions.contains(&Action::Skip {
2626 clip_id: "root-old".to_string()
2627 }));
2628 }
2629
2630 #[test]
2633 fn duplicate_trashed_does_not_defeat_copy_sibling() {
2634 let mut manifest = Manifest::new();
2637 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2638 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2639 copy_entry.modes = vec![SourceMode::Copy];
2640 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2641 trashed_entry.modes = vec![SourceMode::Mirror];
2642 trashed_entry.trashed = true;
2643 let plan = reconcile(
2644 &manifest,
2645 &[copy_entry, trashed_entry],
2646 &local_present("a"),
2647 &mirror_ok(),
2648 );
2649 assert_eq!(plan.deletes(), 0);
2650 assert_eq!(plan.skips(), 1);
2651 }
2652
2653 #[test]
2654 fn duplicate_trashed_does_not_defeat_private_sibling() {
2655 let mut manifest = Manifest::new();
2656 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2657 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2658 private_entry.private = true;
2659 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2660 trashed_entry.trashed = true;
2661 let plan = reconcile(
2662 &manifest,
2663 &[private_entry, trashed_entry],
2664 &local_present("a"),
2665 &mirror_ok(),
2666 );
2667 assert_eq!(plan.deletes(), 0);
2668 assert_eq!(plan.skips(), 1);
2669 }
2670
2671 #[test]
2672 fn duplicate_trashed_deletes_only_when_all_trashed() {
2673 let mut manifest = Manifest::new();
2675 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2676 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2677 first.trashed = true;
2678 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2679 second.trashed = true;
2680 let plan = reconcile(
2681 &manifest,
2682 &[first, second],
2683 &local_present("a"),
2684 &mirror_ok(),
2685 );
2686 assert_eq!(plan.deletes(), 1);
2687 }
2688
2689 #[test]
2690 fn duplicate_desired_unions_modes() {
2691 let mut manifest = Manifest::new();
2693 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2694 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2695 mirror_entry.modes = vec![SourceMode::Mirror];
2696 mirror_entry.trashed = true;
2697 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2698 copy_entry.modes = vec![SourceMode::Copy];
2699 let plan = reconcile(
2700 &manifest,
2701 &[mirror_entry, copy_entry],
2702 &local_present("a"),
2703 &mirror_ok(),
2704 );
2705 assert_eq!(plan.deletes(), 0);
2707 }
2708
2709 #[test]
2712 fn private_new_clip_downloads() {
2713 let manifest = Manifest::new();
2716 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2717 d.private = true;
2718 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2719 assert_eq!(plan.downloads(), 1);
2720 }
2721
2722 #[test]
2723 fn private_zero_length_file_redownloads() {
2724 let mut manifest = Manifest::new();
2725 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2726 let local: HashMap<String, LocalFile> = [(
2727 "a".to_string(),
2728 LocalFile {
2729 exists: true,
2730 size: 0,
2731 },
2732 )]
2733 .into_iter()
2734 .collect();
2735 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2736 d.private = true;
2737 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2738 assert_eq!(plan.downloads(), 1);
2739 }
2740
2741 #[test]
2742 fn private_meta_change_retags() {
2743 let mut manifest = Manifest::new();
2744 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2745 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2746 d.private = true;
2747 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2748 assert_eq!(plan.retags(), 1);
2749 assert_eq!(plan.deletes(), 0);
2750 }
2751
2752 #[test]
2753 fn absent_private_clip_protected_by_preserve_marker() {
2754 let mut manifest = Manifest::new();
2757 manifest.insert(
2758 "a",
2759 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2760 );
2761 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2762 assert_eq!(plan.deletes(), 0);
2763 assert_eq!(plan.skips(), 1);
2764 }
2765
2766 #[test]
2769 fn output_is_deterministic_regardless_of_input_order() {
2770 let mut manifest = Manifest::new();
2771 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2772 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2773 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2774 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2775 .iter()
2776 .map(|id| (id.to_string(), present(100)))
2777 .collect();
2778
2779 let forward = vec![
2780 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2781 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2782 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2783 ];
2784 let mut reversed = forward.clone();
2785 reversed.reverse();
2786
2787 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2788 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2789 assert_eq!(p1.actions, p2.actions);
2790
2791 let ids: Vec<&str> = p1
2794 .actions
2795 .iter()
2796 .map(|a| match a {
2797 Action::Skip { clip_id } => clip_id.as_str(),
2798 Action::Retag { clip, .. } => clip.id.as_str(),
2799 Action::Download { clip, .. } => clip.id.as_str(),
2800 Action::Delete { clip_id, .. } => clip_id.as_str(),
2801 Action::Reformat { clip, .. } => clip.id.as_str(),
2802 Action::Rename { to, .. } => to.as_str(),
2803 Action::WriteArtifact { owner_id, .. }
2804 | Action::DeleteArtifact { owner_id, .. }
2805 | Action::MoveArtifact { owner_id, .. } => owner_id.as_str(),
2806 Action::WriteStem { clip_id, .. }
2807 | Action::DeleteStem { clip_id, .. }
2808 | Action::MoveStem { clip_id, .. } => clip_id.as_str(),
2809 })
2810 .collect();
2811 assert_eq!(ids, ["a", "b", "c", "z"]);
2812 }
2813
2814 #[test]
2815 fn empty_inputs_do_not_panic() {
2816 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2817 assert!(plan.is_empty());
2818 assert_eq!(plan.len(), 0);
2819 }
2820
2821 #[test]
2822 fn empty_desired_with_full_manifest_deletes_all() {
2823 let mut manifest = Manifest::new();
2824 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2825 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2826 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2827 assert_eq!(plan.deletes(), 2);
2828 }
2829
2830 #[test]
2831 fn full_desired_with_empty_manifest_downloads_all() {
2832 let d = vec![
2833 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2834 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2835 ];
2836 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2837 assert_eq!(plan.downloads(), 2);
2838 }
2839
2840 #[test]
2841 fn plan_counts_sum_to_len() {
2842 let mut manifest = Manifest::new();
2843 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2844 manifest.insert(
2845 "retag",
2846 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2847 );
2848 manifest.insert(
2849 "reformat",
2850 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2851 );
2852 manifest.insert(
2853 "rename",
2854 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2855 );
2856 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2857 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2858 .iter()
2859 .map(|id| (id.to_string(), present(100)))
2860 .collect();
2861 let d = vec![
2862 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2863 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2864 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2865 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2866 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2867 ];
2868 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2869 let summed = plan.downloads()
2870 + plan.reformats()
2871 + plan.retags()
2872 + plan.renames()
2873 + plan.deletes()
2874 + plan.skips();
2875 assert_eq!(summed, plan.len());
2876 assert_eq!(plan.downloads(), 1);
2877 assert_eq!(plan.reformats(), 1);
2878 assert_eq!(plan.retags(), 1);
2879 assert_eq!(plan.renames(), 1);
2880 assert_eq!(plan.deletes(), 1);
2881 assert_eq!(plan.skips(), 1);
2882 }
2883
2884 fn cover(path: &str, hash: &str) -> ArtifactState {
2887 ArtifactState {
2888 path: path.to_string(),
2889 hash: hash.to_string(),
2890 }
2891 }
2892
2893 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2894 DesiredArtifact {
2895 kind,
2896 path: path.to_string(),
2897 source_url: url.to_string(),
2898 hash: hash.to_string(),
2899 content: None,
2900 }
2901 }
2902
2903 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2905 DesiredArtifact {
2906 kind,
2907 path: path.to_string(),
2908 source_url: String::new(),
2909 hash: content_hash(body),
2910 content: Some(body.to_string()),
2911 }
2912 }
2913
2914 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2916 Desired {
2917 artifacts: arts,
2918 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2919 }
2920 }
2921
2922 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2924 ManifestEntry {
2925 cover_jpg: Some(cover(cover_path, cover_hash)),
2926 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2927 }
2928 }
2929
2930 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2931 plan.actions
2932 .iter()
2933 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2934 .collect()
2935 }
2936
2937 #[test]
2938 fn write_artifact_emitted_when_manifest_lacks_it() {
2939 let mut manifest = Manifest::new();
2942 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2943 let d = vec![desired_arts(
2944 "a",
2945 vec![art(
2946 ArtifactKind::CoverJpg,
2947 "a/cover.jpg",
2948 "https://art/a",
2949 "h1",
2950 )],
2951 )];
2952 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2953 assert_eq!(plan.artifact_writes(), 1);
2954 assert_eq!(plan.artifact_deletes(), 0);
2955 assert_eq!(plan.skips(), 1);
2956 assert_eq!(
2957 write_artifacts(&plan)[0],
2958 &Action::WriteArtifact {
2959 kind: ArtifactKind::CoverJpg,
2960 path: "a/cover.jpg".to_string(),
2961 source_url: "https://art/a".to_string(),
2962 hash: "h1".to_string(),
2963 owner_id: "a".to_string(),
2964 content: None,
2965 }
2966 );
2967 }
2968
2969 #[test]
2970 fn write_artifact_emitted_when_hash_differs() {
2971 let mut manifest = Manifest::new();
2974 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2975 let d = vec![desired_arts(
2976 "a",
2977 vec![art(
2978 ArtifactKind::CoverJpg,
2979 "a/cover.jpg",
2980 "https://art/a",
2981 "new",
2982 )],
2983 )];
2984 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2985 assert_eq!(plan.artifact_writes(), 1);
2986 assert_eq!(plan.artifact_deletes(), 0);
2987 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2988 assert_eq!(hash, "new");
2989 } else {
2990 panic!("expected a WriteArtifact");
2991 }
2992 }
2993
2994 #[test]
2995 fn write_artifact_skipped_when_hash_matches() {
2996 let mut manifest = Manifest::new();
2998 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2999 let d = vec![desired_arts(
3000 "a",
3001 vec![art(
3002 ArtifactKind::CoverJpg,
3003 "a/cover.jpg",
3004 "https://art/a",
3005 "h1",
3006 )],
3007 )];
3008 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3009 assert_eq!(plan.artifact_writes(), 0);
3010 assert_eq!(plan.artifact_deletes(), 0);
3011 assert_eq!(
3012 plan.actions,
3013 vec![Action::Skip {
3014 clip_id: "a".to_string()
3015 }]
3016 );
3017 }
3018
3019 #[test]
3020 fn removed_kind_cover_is_kept_not_deleted() {
3021 let mut manifest = Manifest::new();
3026 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3027 let d = vec![desired_arts("a", vec![])];
3028 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3029 assert_eq!(plan.artifact_deletes(), 0);
3030 assert_eq!(plan.artifact_writes(), 0);
3031 assert_eq!(plan.deletes(), 0);
3033 assert_eq!(
3034 plan.actions,
3035 vec![Action::Skip {
3036 clip_id: "a".to_string()
3037 }]
3038 );
3039 assert!(!plan.actions.iter().any(|a| matches!(
3040 a,
3041 Action::DeleteArtifact {
3042 kind: ArtifactKind::CoverJpg,
3043 ..
3044 }
3045 )));
3046 }
3047
3048 #[test]
3049 fn delete_artifact_never_on_incomplete_listing() {
3050 let mut manifest = Manifest::new();
3055 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3056 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
3057 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
3058 let sources = vec![SourceStatus {
3059 mode: SourceMode::Mirror,
3060 fully_enumerated: false,
3061 }];
3062 let local: HashMap<String, LocalFile> = [
3063 ("a".to_string(), present(100)),
3064 ("b".to_string(), present(100)),
3065 ]
3066 .into_iter()
3067 .collect();
3068 let plan = reconcile(&manifest, &d, &local, &sources);
3069 assert_eq!(plan.artifact_deletes(), 0);
3070 assert_eq!(plan.deletes(), 0);
3071 }
3072
3073 #[test]
3074 fn delete_artifact_never_when_entry_preserved() {
3075 let mut manifest = Manifest::new();
3078 let preserved = ManifestEntry {
3079 preserve: true,
3080 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
3081 };
3082 manifest.insert("a", preserved);
3083 let d = vec![desired_arts("a", vec![])];
3084 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3085 assert_eq!(plan.artifact_deletes(), 0);
3086 }
3087
3088 #[test]
3089 fn co_delete_never_when_path_empty() {
3090 let mut manifest = Manifest::new();
3094 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
3095 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3096 assert_eq!(plan.deletes(), 1);
3097 assert_eq!(plan.artifact_deletes(), 0);
3098 }
3099
3100 #[test]
3101 fn co_delete_absent_clip_deletes_audio_and_cover() {
3102 let mut manifest = Manifest::new();
3105 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3106 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3107 assert_eq!(plan.deletes(), 1);
3108 assert_eq!(plan.artifact_deletes(), 1);
3109 assert!(plan.actions.contains(&Action::Delete {
3110 path: "gone.flac".to_string(),
3111 clip_id: "gone".to_string(),
3112 }));
3113 assert!(plan.actions.contains(&Action::DeleteArtifact {
3114 kind: ArtifactKind::CoverJpg,
3115 path: "gone/cover.jpg".to_string(),
3116 owner_id: "gone".to_string(),
3117 }));
3118 }
3119
3120 #[test]
3121 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
3122 let mut manifest = Manifest::new();
3124 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3125 let sources = vec![SourceStatus {
3126 mode: SourceMode::Mirror,
3127 fully_enumerated: false,
3128 }];
3129 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
3130 assert_eq!(plan.deletes(), 0);
3131 assert_eq!(plan.artifact_deletes(), 0);
3132 }
3133
3134 #[test]
3135 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
3136 let mut manifest = Manifest::new();
3138 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3139 let mut d = desired_arts("a", vec![]);
3140 d.trashed = true;
3141 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3142 assert_eq!(plan.deletes(), 1);
3143 assert_eq!(plan.artifact_deletes(), 1);
3144 }
3145
3146 #[test]
3147 fn co_delete_trashed_suppressed_when_not_enumerated() {
3148 let mut manifest = Manifest::new();
3150 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3151 let mut d = desired_arts("a", vec![]);
3152 d.trashed = true;
3153 let sources = vec![SourceStatus {
3154 mode: SourceMode::Mirror,
3155 fully_enumerated: false,
3156 }];
3157 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
3158 assert_eq!(plan.deletes(), 0);
3159 assert_eq!(plan.artifact_deletes(), 0);
3160 assert_eq!(plan.skips(), 1);
3161 }
3162
3163 #[test]
3164 fn co_delete_trashed_suppressed_when_preserved() {
3165 let mut manifest = Manifest::new();
3167 let preserved = ManifestEntry {
3168 preserve: true,
3169 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
3170 };
3171 manifest.insert("a", preserved);
3172 let mut d = desired_arts("a", vec![]);
3173 d.trashed = true;
3174 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3175 assert_eq!(plan.deletes(), 0);
3176 assert_eq!(plan.artifact_deletes(), 0);
3177 }
3178
3179 #[test]
3182 fn details_sidecar_written_with_inline_content_when_slot_absent() {
3183 let mut manifest = Manifest::new();
3186 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3187 let d = vec![desired_arts(
3188 "a",
3189 vec![text_art(
3190 ArtifactKind::DetailsTxt,
3191 "a.details.txt",
3192 "Title: A\n",
3193 )],
3194 )];
3195 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3196 assert_eq!(plan.artifact_writes(), 1);
3197 assert_eq!(plan.artifact_deletes(), 0);
3198 assert_eq!(
3199 write_artifacts(&plan)[0],
3200 &Action::WriteArtifact {
3201 kind: ArtifactKind::DetailsTxt,
3202 path: "a.details.txt".to_string(),
3203 source_url: String::new(),
3204 hash: content_hash("Title: A\n"),
3205 owner_id: "a".to_string(),
3206 content: Some("Title: A\n".to_string()),
3207 }
3208 );
3209 }
3210
3211 #[test]
3212 fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
3213 let mut manifest = Manifest::new();
3218 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3219 let body = "[re:rs-suno]\nla la\n";
3220 let d = vec![desired_arts(
3221 "a",
3222 vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
3223 )];
3224 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3225 assert_eq!(plan.artifact_writes(), 1);
3226 assert_eq!(plan.artifact_deletes(), 0);
3227 assert_eq!(
3228 write_artifacts(&plan)[0],
3229 &Action::WriteArtifact {
3230 kind: ArtifactKind::Lrc,
3231 path: "a.lrc".to_string(),
3232 source_url: String::new(),
3233 hash: content_hash(body),
3234 owner_id: "a".to_string(),
3235 content: Some(body.to_string()),
3236 }
3237 );
3238 }
3239
3240 #[test]
3241 fn text_sidecars_skipped_when_hash_and_path_match() {
3242 let mut manifest = Manifest::new();
3244 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3245 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3246 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
3247 manifest.insert("a", e);
3248 let d = vec![desired_arts(
3249 "a",
3250 vec![
3251 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
3252 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
3253 ],
3254 )];
3255 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3256 assert_eq!(plan.artifact_writes(), 0);
3257 assert_eq!(plan.artifact_deletes(), 0);
3258 }
3259
3260 #[test]
3261 fn details_rewritten_when_content_hash_differs() {
3262 let mut manifest = Manifest::new();
3265 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3266 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
3267 manifest.insert("a", e);
3268 let d = vec![desired_arts(
3269 "a",
3270 vec![text_art(
3271 ArtifactKind::DetailsTxt,
3272 "a.details.txt",
3273 "Title: New\n",
3274 )],
3275 )];
3276 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3277 assert_eq!(plan.artifact_writes(), 1);
3278 assert_eq!(plan.artifact_deletes(), 0);
3279 }
3280
3281 #[test]
3282 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
3283 let mut manifest = Manifest::new();
3287 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3288 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
3289 manifest.insert("a", e);
3290 let d = vec![desired_arts(
3291 "a",
3292 vec![text_art(
3293 ArtifactKind::LyricsTxt,
3294 "a.lyrics.txt",
3295 "new words\n",
3296 )],
3297 )];
3298 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3299 assert_eq!(plan.artifact_writes(), 1);
3301 assert_eq!(plan.retags(), 0);
3302 }
3303
3304 #[test]
3305 fn text_sidecar_relocated_when_path_differs() {
3306 let mut manifest = Manifest::new();
3309 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3310 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
3311 manifest.insert("a", e);
3312 let d = vec![desired_arts(
3313 "a",
3314 vec![text_art(
3315 ArtifactKind::DetailsTxt,
3316 "new/a.details.txt",
3317 "Title: A\n",
3318 )],
3319 )];
3320 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3321 assert_eq!(plan.artifact_writes(), 1);
3322 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3323 assert_eq!(path, "new/a.details.txt");
3324 } else {
3325 panic!("expected a WriteArtifact");
3326 }
3327 }
3328
3329 #[test]
3330 fn fetched_sidecar_path_drift_emits_move() {
3331 let mut manifest = Manifest::new();
3334 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3335 e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3336 manifest.insert("a", e);
3337 let d = vec![desired_arts(
3338 "a",
3339 vec![art(
3340 ArtifactKind::CoverJpg,
3341 "new/cover.jpg",
3342 "https://art/large.jpg",
3343 "arthash",
3344 )],
3345 )];
3346 let local: HashMap<String, LocalFile> = [
3347 ("a".to_string(), present(100)),
3348 ("old/cover.jpg".to_string(), present(50)),
3349 ]
3350 .into_iter()
3351 .collect();
3352 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3353 assert_eq!(plan.artifact_moves(), 1);
3354 assert_eq!(plan.artifact_writes(), 0);
3355 assert!(plan.actions.contains(&Action::MoveArtifact {
3356 kind: ArtifactKind::CoverJpg,
3357 from: "old/cover.jpg".to_string(),
3358 to: "new/cover.jpg".to_string(),
3359 source_url: "https://art/large.jpg".to_string(),
3360 hash: "arthash".to_string(),
3361 owner_id: "a".to_string(),
3362 }));
3363 }
3364
3365 #[test]
3366 fn sidecar_hash_drift_emits_write_not_move() {
3367 let mut manifest = Manifest::new();
3369 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3370 e.cover_jpg = Some(cover("old/cover.jpg", "oldhash"));
3371 manifest.insert("a", e);
3372 let d = vec![desired_arts(
3373 "a",
3374 vec![art(
3375 ArtifactKind::CoverJpg,
3376 "new/cover.jpg",
3377 "https://art/large.jpg",
3378 "newhash",
3379 )],
3380 )];
3381 let local: HashMap<String, LocalFile> = [
3382 ("a".to_string(), present(100)),
3383 ("old/cover.jpg".to_string(), present(50)),
3384 ]
3385 .into_iter()
3386 .collect();
3387 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3388 assert_eq!(plan.artifact_moves(), 0);
3389 assert_eq!(plan.artifact_writes(), 1);
3390 }
3391
3392 #[test]
3393 fn inline_sidecar_path_drift_stays_a_write() {
3394 let mut manifest = Manifest::new();
3397 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3398 e.lyrics_txt = Some(cover("old/a.lyrics.txt", &content_hash("words\n")));
3399 manifest.insert("a", e);
3400 let d = vec![desired_arts(
3401 "a",
3402 vec![text_art(
3403 ArtifactKind::LyricsTxt,
3404 "new/a.lyrics.txt",
3405 "words\n",
3406 )],
3407 )];
3408 let local: HashMap<String, LocalFile> = [
3409 ("a".to_string(), present(100)),
3410 ("old/a.lyrics.txt".to_string(), present(50)),
3411 ]
3412 .into_iter()
3413 .collect();
3414 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3415 assert_eq!(plan.artifact_moves(), 0);
3416 assert_eq!(plan.artifact_writes(), 1);
3417 }
3418
3419 #[test]
3420 fn sidecar_move_downgrades_to_write_when_old_file_absent() {
3421 let mut manifest = Manifest::new();
3424 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3425 e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3426 manifest.insert("a", e);
3427 let d = vec![desired_arts(
3428 "a",
3429 vec![art(
3430 ArtifactKind::CoverJpg,
3431 "new/cover.jpg",
3432 "https://art/large.jpg",
3433 "arthash",
3434 )],
3435 )];
3436 let local: HashMap<String, LocalFile> = [
3437 ("a".to_string(), present(100)),
3438 (
3439 "old/cover.jpg".to_string(),
3440 LocalFile {
3441 exists: false,
3442 size: 0,
3443 },
3444 ),
3445 ]
3446 .into_iter()
3447 .collect();
3448 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3449 assert_eq!(plan.artifact_moves(), 0);
3450 assert_eq!(plan.artifact_writes(), 1);
3451 }
3452
3453 #[test]
3454 fn move_target_suppresses_a_colliding_delete() {
3455 let mut manifest = Manifest::new();
3458 let mut a = entry("a.flac", AudioFormat::Flac, "m", "art");
3459 a.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3460 manifest.insert("a", a);
3461 let mut b = entry("b.flac", AudioFormat::Flac, "m", "art");
3464 b.details_txt = Some(cover("new/cover.jpg", "bh"));
3465 manifest.insert("b", b);
3466 let d = vec![
3467 desired_arts(
3468 "a",
3469 vec![art(
3470 ArtifactKind::CoverJpg,
3471 "new/cover.jpg",
3472 "https://art/large.jpg",
3473 "arthash",
3474 )],
3475 ),
3476 desired_arts("b", vec![]),
3477 ];
3478 let local: HashMap<String, LocalFile> = [
3479 ("a".to_string(), present(100)),
3480 ("b".to_string(), present(100)),
3481 ("old/cover.jpg".to_string(), present(50)),
3482 ("new/cover.jpg".to_string(), present(50)),
3483 ]
3484 .into_iter()
3485 .collect();
3486 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3487 assert_eq!(plan.artifact_moves(), 1);
3488 assert!(!plan.actions.iter().any(|a| matches!(
3490 a,
3491 Action::DeleteArtifact { path, .. } if path == "new/cover.jpg"
3492 )));
3493 }
3494
3495 #[test]
3496 fn stem_path_drift_emits_move() {
3497 let mut manifest = Manifest::new();
3500 manifest.insert(
3501 "a",
3502 entry_with_stems("a", &[("voc", "old.stems/voc.mp3", "h1")]),
3503 );
3504 let d = vec![stem_desired(
3505 "a",
3506 Some(vec![dstem("voc", "new.stems/voc.mp3", "h1")]),
3507 )];
3508 let local: HashMap<String, LocalFile> = [
3509 ("a".to_string(), present(100)),
3510 ("old.stems/voc.mp3".to_string(), present(50)),
3511 ]
3512 .into_iter()
3513 .collect();
3514 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3515 assert_eq!(plan.stem_moves(), 1);
3516 assert_eq!(plan.stem_writes(), 0);
3517 assert!(plan.actions.contains(&Action::MoveStem {
3518 clip_id: "a".to_string(),
3519 key: "voc".to_string(),
3520 stem_id: "voc".to_string(),
3521 from: "old.stems/voc.mp3".to_string(),
3522 to: "new.stems/voc.mp3".to_string(),
3523 source_url: "https://cdn1.suno.ai/voc.mp3".to_string(),
3524 format: StemFormat::Mp3,
3525 hash: "h1".to_string(),
3526 }));
3527 }
3528
3529 #[test]
3530 fn details_removed_kind_is_deleted_when_feature_off() {
3531 let mut manifest = Manifest::new();
3534 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3535 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3536 manifest.insert("a", e);
3537 let d = vec![desired_arts("a", vec![])];
3538 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3539 assert_eq!(plan.artifact_deletes(), 1);
3540 assert!(plan.actions.contains(&Action::DeleteArtifact {
3541 kind: ArtifactKind::DetailsTxt,
3542 path: "a.details.txt".to_string(),
3543 owner_id: "a".to_string(),
3544 }));
3545 }
3546
3547 #[test]
3548 fn lyrics_removed_kind_is_kept_not_deleted() {
3549 let mut manifest = Manifest::new();
3553 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3554 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3555 manifest.insert("a", e);
3556 let d = vec![desired_arts("a", vec![])];
3557 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3558 assert_eq!(plan.artifact_deletes(), 0);
3559 assert_eq!(plan.deletes(), 0);
3560 }
3561
3562 #[test]
3563 fn lrc_removed_kind_is_kept_not_deleted() {
3564 let mut manifest = Manifest::new();
3567 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3568 e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3569 manifest.insert("a", e);
3570 let d = vec![desired_arts("a", vec![])];
3571 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3572 assert_eq!(plan.artifact_deletes(), 0);
3573 assert_eq!(plan.deletes(), 0);
3574 }
3575
3576 #[test]
3577 fn video_mp4_removed_kind_is_kept_not_deleted() {
3578 let mut manifest = Manifest::new();
3582 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3583 e.video_mp4 = Some(cover("a.mp4", "vid-hash"));
3584 manifest.insert("a", e);
3585 let d = vec![desired_arts("a", vec![])];
3586 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3587 assert_eq!(plan.artifact_deletes(), 0);
3588 assert_eq!(plan.deletes(), 0);
3589 }
3590
3591 #[test]
3592 fn video_mp4_written_when_manifest_lacks_it() {
3593 let mut manifest = Manifest::new();
3596 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3597 let d = vec![desired_arts(
3598 "a",
3599 vec![art(
3600 ArtifactKind::VideoMp4,
3601 "a/song.mp4",
3602 "https://cdn/a/video.mp4",
3603 "vid-hash",
3604 )],
3605 )];
3606 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3607 assert_eq!(plan.artifact_writes(), 1);
3608 assert_eq!(
3609 write_artifacts(&plan)[0],
3610 &Action::WriteArtifact {
3611 kind: ArtifactKind::VideoMp4,
3612 path: "a/song.mp4".to_string(),
3613 source_url: "https://cdn/a/video.mp4".to_string(),
3614 hash: "vid-hash".to_string(),
3615 owner_id: "a".to_string(),
3616 content: None,
3617 }
3618 );
3619 }
3620
3621 #[test]
3622 fn details_removed_kind_not_deleted_on_incomplete_listing() {
3623 let mut manifest = Manifest::new();
3626 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3627 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3628 manifest.insert("a", e);
3629 let d = vec![desired_arts("a", vec![])];
3630 let sources = vec![SourceStatus {
3631 mode: SourceMode::Mirror,
3632 fully_enumerated: false,
3633 }];
3634 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
3635 assert_eq!(plan.artifact_deletes(), 0);
3636 }
3637
3638 #[test]
3639 fn details_removed_kind_not_deleted_when_preserved() {
3640 let mut manifest = Manifest::new();
3643 let mut e = ManifestEntry {
3644 preserve: true,
3645 ..entry("a.flac", AudioFormat::Flac, "m", "art")
3646 };
3647 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3648 manifest.insert("a", e);
3649 let d = vec![desired_arts("a", vec![])];
3650 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3651 assert_eq!(plan.artifact_deletes(), 0);
3652 }
3653
3654 #[test]
3655 fn co_delete_orphan_removes_every_text_sidecar() {
3656 let mut manifest = Manifest::new();
3660 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
3661 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
3662 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
3663 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
3664 e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3665 e.video_mp4 = Some(cover("gone/song.mp4", "vid-hash"));
3666 manifest.insert("gone", e);
3667 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3668 assert_eq!(plan.deletes(), 1);
3669 assert_eq!(plan.artifact_deletes(), 5);
3670 for (kind, path) in [
3671 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
3672 (ArtifactKind::DetailsTxt, "gone.details.txt"),
3673 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
3674 (ArtifactKind::Lrc, "gone.lrc"),
3675 (ArtifactKind::VideoMp4, "gone/song.mp4"),
3676 ] {
3677 assert!(
3678 plan.actions.contains(&Action::DeleteArtifact {
3679 kind,
3680 path: path.to_string(),
3681 owner_id: "gone".to_string(),
3682 }),
3683 "missing co-delete for {kind:?}"
3684 );
3685 }
3686 }
3687
3688 #[test]
3689 fn co_delete_trashed_removes_every_text_sidecar() {
3690 let mut manifest = Manifest::new();
3692 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3693 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3694 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3695 manifest.insert("a", e);
3696 let mut d = desired_arts("a", vec![]);
3697 d.trashed = true;
3698 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3699 assert_eq!(plan.deletes(), 1);
3700 assert_eq!(plan.artifact_deletes(), 2);
3701 }
3702
3703 #[test]
3704 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
3705 let mut manifest = Manifest::new();
3708 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3709 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
3710 let d = vec![desired_arts(
3713 "a",
3714 vec![art(
3715 ArtifactKind::CoverJpg,
3716 "shared/cover.jpg",
3717 "https://art/a",
3718 "h2",
3719 )],
3720 )];
3721 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3722 assert_eq!(plan.artifact_writes(), 1);
3723 assert!(!plan.actions.iter().any(
3725 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
3726 ));
3727 assert!(plan.actions.contains(&Action::Delete {
3729 path: "b.flac".to_string(),
3730 clip_id: "b".to_string(),
3731 }));
3732 }
3733
3734 #[test]
3735 fn suppress_downgrades_delete_artifact_colliding_with_download() {
3736 let mut manifest = Manifest::new();
3738 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
3739 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
3740 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
3741 assert_eq!(plan.downloads(), 1);
3742 assert!(
3743 !plan
3744 .actions
3745 .iter()
3746 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
3747 );
3748 }
3749
3750 #[test]
3751 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
3752 let build = |with_art: bool| {
3756 let mut manifest = Manifest::new();
3757 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
3758 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3759 manifest.insert(
3760 "trash",
3761 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
3762 );
3763 let keep = if with_art {
3764 desired_arts(
3765 "keep",
3766 vec![art(
3767 ArtifactKind::CoverJpg,
3768 "keep/cover.jpg",
3769 "https://art/keep",
3770 "h1",
3771 )],
3772 )
3773 } else {
3774 desired_arts("keep", vec![])
3775 };
3776 let mut trash = desired_arts("trash", vec![]);
3777 trash.trashed = true;
3778 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
3779 .iter()
3780 .map(|id| (id.to_string(), present(100)))
3781 .collect();
3782 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
3783 };
3784
3785 let with = build(true);
3786 let without = build(false);
3787
3788 let audio = |plan: &Plan| -> Vec<Action> {
3790 plan.actions
3791 .iter()
3792 .filter(|a| {
3793 !matches!(
3794 a,
3795 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
3796 )
3797 })
3798 .cloned()
3799 .collect()
3800 };
3801 assert_eq!(audio(&with), audio(&without));
3802 assert_eq!(with.deletes(), without.deletes());
3803 assert_eq!(with.deletes(), 2);
3805 assert_eq!(with.artifact_deletes(), 2);
3809 assert_eq!(with.artifact_writes(), 0);
3810 }
3811
3812 #[test]
3815 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
3816 let mut manifest = Manifest::new();
3822 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3823 assert!(!manifest.get("a").unwrap().preserve);
3824
3825 let private = Desired {
3827 private: true,
3828 ..desired_arts("a", vec![])
3829 };
3830 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
3831 assert_eq!(plan.artifact_deletes(), 0);
3832
3833 let copy_held = Desired {
3835 modes: vec![SourceMode::Copy],
3836 ..desired_arts("a", vec![])
3837 };
3838 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
3839 assert_eq!(plan.artifact_deletes(), 0);
3840 }
3841
3842 #[test]
3843 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
3844 let mut manifest = Manifest::new();
3850 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
3851 let d = vec![desired_arts(
3852 "a",
3853 vec![art(
3854 ArtifactKind::CoverJpg,
3855 "new/cover.jpg",
3856 "https://art/a",
3857 "h1",
3858 )],
3859 )];
3860 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3861 assert_eq!(plan.artifact_writes(), 1);
3862 assert_eq!(plan.artifact_deletes(), 0);
3863 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3864 assert_eq!(path, "new/cover.jpg");
3865 } else {
3866 panic!("expected a WriteArtifact");
3867 }
3868 }
3869
3870 #[test]
3871 fn needs_write_drift_applies_hash_path_and_probe_rules() {
3872 let local: HashMap<String, LocalFile> = [
3873 ("ok".to_string(), present(10)),
3874 ("missing".to_string(), LocalFile::default()),
3875 ("empty".to_string(), present(0)),
3876 ]
3877 .into_iter()
3878 .collect();
3879
3880 assert!(needs_write_drift(None, "h1", "ok", &local));
3881 assert!(!needs_write_drift(Some(("h1", "ok")), "h1", "ok", &local));
3882 assert!(needs_write_drift(Some(("h0", "ok")), "h1", "ok", &local));
3883 assert!(needs_write_drift(
3884 Some(("h1", "missing")),
3885 "h1",
3886 "missing",
3887 &local
3888 ));
3889 assert!(needs_write_drift(
3890 Some(("h1", "empty")),
3891 "h1",
3892 "empty",
3893 &local
3894 ));
3895 assert!(!needs_write_drift(
3896 Some(("h1", "unprobed")),
3897 "h1",
3898 "unprobed",
3899 &local
3900 ));
3901 }
3902
3903 #[test]
3904 fn per_clip_reconcile_ignores_album_and_library_kinds() {
3905 let mut manifest = Manifest::new();
3909 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3910 let d = vec![desired_arts(
3911 "a",
3912 vec![
3913 art(
3914 ArtifactKind::FolderJpg,
3915 "a/folder.jpg",
3916 "https://art/folder",
3917 "hf",
3918 ),
3919 art(
3920 ArtifactKind::Playlist,
3921 "a/list.m3u",
3922 "https://art/list",
3923 "hp",
3924 ),
3925 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
3926 ],
3927 )];
3928 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3929 assert_eq!(plan.artifact_writes(), 1);
3930 let paths: Vec<&str> = plan
3931 .actions
3932 .iter()
3933 .filter_map(|a| match a {
3934 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3935 _ => None,
3936 })
3937 .collect();
3938 assert_eq!(paths, vec!["a/cover.jpg"]);
3939 }
3940
3941 #[test]
3942 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3943 let mut manifest = Manifest::new();
3944 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3945 let d = vec![desired_arts(
3946 "a",
3947 vec![art(
3948 ArtifactKind::FolderWebp,
3949 "a/folder.webp",
3950 "https://art/folder",
3951 "hf",
3952 )],
3953 )];
3954 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3955 assert_eq!(plan.artifact_writes(), 0);
3956 assert_eq!(plan.artifact_deletes(), 0);
3957 }
3958
3959 fn local_with_missing(audio_id: &str, missing_path: &str) -> HashMap<String, LocalFile> {
3963 let mut m = local_present(audio_id);
3964 m.insert(missing_path.to_owned(), LocalFile::default());
3965 m
3966 }
3967
3968 fn local_with_present_artifact(
3970 audio_id: &str,
3971 artifact_path: &str,
3972 ) -> HashMap<String, LocalFile> {
3973 let mut m = local_present(audio_id);
3974 m.insert(artifact_path.to_owned(), present(50));
3975 m
3976 }
3977
3978 #[test]
3979 fn sidecar_missing_on_disk_forces_rewrite() {
3980 let mut manifest = Manifest::new();
3984 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3985 let d = vec![desired_arts(
3986 "a",
3987 vec![art(
3988 ArtifactKind::CoverJpg,
3989 "a/cover.jpg",
3990 "https://art/a",
3991 "h1",
3992 )],
3993 )];
3994 let local = local_with_missing("a", "a/cover.jpg");
3995 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3996 assert_eq!(
3997 plan.artifact_writes(),
3998 1,
3999 "missing sidecar must be rewritten"
4000 );
4001 assert_eq!(plan.artifact_deletes(), 0);
4002 }
4003
4004 #[test]
4005 fn sidecar_present_on_disk_with_matching_hash_no_churn() {
4006 let mut manifest = Manifest::new();
4008 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
4009 let d = vec![desired_arts(
4010 "a",
4011 vec![art(
4012 ArtifactKind::CoverJpg,
4013 "a/cover.jpg",
4014 "https://art/a",
4015 "h1",
4016 )],
4017 )];
4018 let local = local_with_present_artifact("a", "a/cover.jpg");
4019 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
4020 assert_eq!(plan.artifact_writes(), 0, "present sidecar must not churn");
4021 assert_eq!(plan.artifact_deletes(), 0);
4022 }
4023
4024 #[test]
4025 fn sidecar_probe_absent_falls_back_to_hash_comparison_no_write() {
4026 let mut manifest = Manifest::new();
4030 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
4031 let d = vec![desired_arts(
4032 "a",
4033 vec![art(
4034 ArtifactKind::CoverJpg,
4035 "a/cover.jpg",
4036 "https://art/a",
4037 "h1",
4038 )],
4039 )];
4040 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4042 assert_eq!(
4043 plan.artifact_writes(),
4044 0,
4045 "no write when probe unavailable and hash matches"
4046 );
4047 assert_eq!(
4048 plan.artifact_deletes(),
4049 0,
4050 "missing probe must never trigger a delete"
4051 );
4052 }
4053
4054 #[test]
4055 fn folder_art_missing_on_disk_forces_rewrite() {
4056 let members = vec![album_member(
4059 album_clip("a", 1, "t0", "art-a", ""),
4060 "root",
4061 "c/al/a.flac",
4062 )];
4063 let desired = album_desired(&members, false, false);
4064 let mut albums = BTreeMap::new();
4065 albums.insert(
4066 "root".to_string(),
4067 AlbumArt {
4068 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4069 folder_webp: None,
4070 folder_mp4: None,
4071 },
4072 );
4073 let mut local: HashMap<String, LocalFile> = HashMap::new();
4074 local.insert("c/al/folder.jpg".to_owned(), LocalFile::default());
4075 let actions = plan_album_artifacts(&desired, &albums, true, &local);
4076 assert_eq!(actions.len(), 1, "missing folder art must be rewritten");
4077 assert!(matches!(
4078 &actions[0],
4079 Action::WriteArtifact {
4080 kind: ArtifactKind::FolderJpg,
4081 ..
4082 }
4083 ));
4084 }
4085
4086 #[test]
4087 fn folder_art_present_on_disk_no_churn() {
4088 let members = vec![album_member(
4090 album_clip("a", 1, "t0", "art-a", ""),
4091 "root",
4092 "c/al/a.flac",
4093 )];
4094 let desired = album_desired(&members, false, false);
4095 let mut albums = BTreeMap::new();
4096 albums.insert(
4097 "root".to_string(),
4098 AlbumArt {
4099 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4100 folder_webp: None,
4101 folder_mp4: None,
4102 },
4103 );
4104 let mut local: HashMap<String, LocalFile> = HashMap::new();
4105 local.insert("c/al/folder.jpg".to_owned(), present(5000));
4106 let actions = plan_album_artifacts(&desired, &albums, true, &local);
4107 assert!(
4108 actions.is_empty(),
4109 "present folder art with matching hash must not churn"
4110 );
4111 }
4112
4113 #[test]
4114 fn playlist_missing_on_disk_forces_rewrite() {
4115 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4118 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4119 let mut local: HashMap<String, LocalFile> = HashMap::new();
4120 local.insert("Mix.m3u8".to_owned(), LocalFile::default());
4121 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
4122 assert_eq!(actions.len(), 1, "missing playlist file must be rewritten");
4123 assert!(matches!(
4124 &actions[0],
4125 Action::WriteArtifact {
4126 kind: ArtifactKind::Playlist,
4127 ..
4128 }
4129 ));
4130 }
4131
4132 #[test]
4133 fn playlist_present_on_disk_no_churn() {
4134 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4136 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4137 let mut local: HashMap<String, LocalFile> = HashMap::new();
4138 local.insert("Mix.m3u8".to_owned(), present(200));
4139 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
4140 assert!(
4141 actions.is_empty(),
4142 "present playlist with matching hash must not churn"
4143 );
4144 }
4145
4146 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
4149 Clip {
4150 id: id.to_string(),
4151 title: "Song".to_string(),
4152 image_large_url: image.to_string(),
4153 video_cover_url: video.to_string(),
4154 play_count,
4155 created_at: created_at.to_string(),
4156 ..Default::default()
4157 }
4158 }
4159
4160 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
4161 let mut lineage = LineageContext::own_root(&clip);
4162 lineage.root_id = root_id.to_string();
4163 Desired {
4164 clip,
4165 lineage,
4166 path: path.to_string(),
4167 format: AudioFormat::Flac,
4168 meta_hash: "m".to_string(),
4169 art_hash: "a".to_string(),
4170 modes: vec![SourceMode::Mirror],
4171 trashed: false,
4172 private: false,
4173 artifacts: Vec::new(),
4174 stems: None,
4175 }
4176 }
4177
4178 fn stored(path: &str, hash: &str) -> ArtifactState {
4179 ArtifactState {
4180 path: path.to_string(),
4181 hash: hash.to_string(),
4182 }
4183 }
4184
4185 #[test]
4186 fn folder_jpg_source_is_most_played() {
4187 let members = vec![
4188 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
4189 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
4190 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
4191 ];
4192 let albums = album_desired(&members, false, false);
4193 assert_eq!(albums.len(), 1);
4194 let jpg = albums[0].folder_jpg.as_ref().unwrap();
4195 assert_eq!(jpg.hash, art_url_hash("art-b"));
4197 assert_eq!(jpg.source_url, "art-b");
4198 assert_eq!(jpg.path, "c/al/folder.jpg");
4199 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
4200 }
4201
4202 #[test]
4203 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
4204 let by_time = vec![
4206 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
4207 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
4208 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
4209 ];
4210 let jpg = album_desired(&by_time, false, false)[0]
4211 .folder_jpg
4212 .clone()
4213 .unwrap();
4214 assert_eq!(jpg.source_url, "art-y");
4215
4216 let by_id = vec![
4218 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
4219 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
4220 ];
4221 let jpg = album_desired(&by_id, false, false)[0]
4222 .folder_jpg
4223 .clone()
4224 .unwrap();
4225 assert_eq!(jpg.source_url, "art-g");
4226 }
4227
4228 #[test]
4229 fn folder_webp_source_is_first_created_animated() {
4230 let members = vec![
4231 album_member(
4232 album_clip("a", 9, "t2", "art-a", "vid-a"),
4233 "root",
4234 "c/al/a.flac",
4235 ),
4236 album_member(
4237 album_clip("b", 1, "t0", "art-b", "vid-b"),
4238 "root",
4239 "c/al/b.flac",
4240 ),
4241 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
4242 ];
4243 let webp = album_desired(&members, true, false)[0]
4244 .folder_webp
4245 .clone()
4246 .unwrap();
4247 assert_eq!(webp.source_url, "vid-b");
4249 assert_eq!(webp.hash, art_url_hash("vid-b"));
4250 assert_eq!(webp.path, "c/al/cover.webp");
4251 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
4252 }
4253
4254 #[test]
4255 fn animated_covers_off_yields_no_folder_webp() {
4256 let members = vec![album_member(
4257 album_clip("a", 1, "t0", "art-a", "vid-a"),
4258 "root",
4259 "c/al/a.flac",
4260 )];
4261 let off = album_desired(&members, false, false);
4262 assert!(off[0].folder_webp.is_none());
4263 let on = album_desired(&members, true, false);
4264 assert!(on[0].folder_webp.is_some());
4265 }
4266
4267 #[test]
4268 fn raw_cover_yields_folder_mp4_from_the_webp_source_verbatim() {
4269 let members = vec![
4270 album_member(
4271 album_clip("a", 9, "t2", "art-a", "vid-a"),
4272 "root",
4273 "c/al/a.flac",
4274 ),
4275 album_member(
4276 album_clip("b", 1, "t0", "art-b", "vid-b"),
4277 "root",
4278 "c/al/b.flac",
4279 ),
4280 ];
4281 let album = album_desired(&members, true, true).remove(0);
4285 let webp = album.folder_webp.unwrap();
4286 let mp4 = album.folder_mp4.unwrap();
4287 assert_eq!(mp4.kind, ArtifactKind::FolderMp4);
4288 assert_eq!(mp4.path, "c/al/cover.mp4");
4289 assert_eq!(mp4.source_url, "vid-b");
4290 assert_eq!(mp4.hash, art_url_hash("vid-b"));
4291 assert_eq!(mp4.source_url, webp.source_url, "same variant feeds both");
4292 }
4293
4294 #[test]
4295 fn raw_cover_and_webp_are_independent_toggles() {
4296 let members = vec![album_member(
4297 album_clip("a", 1, "t0", "art-a", "vid-a"),
4298 "root",
4299 "c/al/a.flac",
4300 )];
4301 let webp_only = album_desired(&members, true, false).remove(0);
4303 assert!(webp_only.folder_webp.is_some());
4304 assert!(webp_only.folder_mp4.is_none());
4305 let mp4_only = album_desired(&members, false, true).remove(0);
4307 assert!(mp4_only.folder_webp.is_none());
4308 assert!(mp4_only.folder_mp4.is_some());
4309 }
4310
4311 #[test]
4312 fn raw_cover_needs_an_animated_source() {
4313 let members = vec![album_member(
4315 album_clip("a", 3, "t0", "art-a", ""),
4316 "root",
4317 "c/al/a.flac",
4318 )];
4319 let album = album_desired(&members, true, true).remove(0);
4320 assert!(album.folder_mp4.is_none());
4321 assert!(album.folder_webp.is_none());
4322 }
4323
4324 #[test]
4325 fn album_with_no_art_yields_no_folder_jpg() {
4326 let members = vec![album_member(
4327 album_clip("a", 3, "t0", "", ""),
4328 "root",
4329 "c/al/a.flac",
4330 )];
4331 let albums = album_desired(&members, true, false);
4332 assert!(albums[0].folder_jpg.is_none());
4333 assert!(albums[0].folder_webp.is_none());
4334 }
4335
4336 #[test]
4337 fn album_desired_groups_by_root_id() {
4338 let members = vec![
4339 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
4340 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
4341 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
4342 ];
4343 let albums = album_desired(&members, false, false);
4344 assert_eq!(albums.len(), 2);
4345 assert_eq!(albums[0].root_id, "r1");
4346 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
4347 assert_eq!(
4348 albums[0].folder_jpg.as_ref().unwrap().path,
4349 "c/al1/folder.jpg"
4350 );
4351 assert_eq!(albums[1].root_id, "r2");
4352 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
4353 assert_eq!(
4354 albums[1].folder_jpg.as_ref().unwrap().path,
4355 "c/al2/folder.jpg"
4356 );
4357 }
4358
4359 #[test]
4360 fn plan_writes_folder_art_when_store_empty() {
4361 let members = vec![album_member(
4362 album_clip("a", 1, "t0", "art-a", "vid-a"),
4363 "root",
4364 "c/al/a.flac",
4365 )];
4366 let desired = album_desired(&members, true, false);
4367 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4368 assert_eq!(
4369 actions,
4370 vec![
4371 Action::WriteArtifact {
4372 kind: ArtifactKind::FolderJpg,
4373 path: "c/al/folder.jpg".to_string(),
4374 source_url: "art-a".to_string(),
4375 hash: art_url_hash("art-a"),
4376 owner_id: "root".to_string(),
4377 content: None,
4378 },
4379 Action::WriteArtifact {
4380 kind: ArtifactKind::FolderWebp,
4381 path: "c/al/cover.webp".to_string(),
4382 source_url: "vid-a".to_string(),
4383 hash: art_url_hash("vid-a"),
4384 owner_id: "root".to_string(),
4385 content: None,
4386 },
4387 ]
4388 );
4389 }
4390
4391 #[test]
4392 fn plan_skips_when_hash_and_path_match() {
4393 let members = vec![album_member(
4394 album_clip("a", 1, "t0", "art-a", ""),
4395 "root",
4396 "c/al/a.flac",
4397 )];
4398 let desired = album_desired(&members, false, false);
4399 let mut albums = BTreeMap::new();
4400 albums.insert(
4401 "root".to_string(),
4402 AlbumArt {
4403 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4404 folder_webp: None,
4405 folder_mp4: None,
4406 },
4407 );
4408 assert!(plan_album_artifacts(&desired, &albums, true, &HashMap::new()).is_empty());
4409 }
4410
4411 #[test]
4412 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
4413 let members = vec![album_member(
4414 album_clip("a", 1, "t0", "art-a", ""),
4415 "root",
4416 "c/al/a.flac",
4417 )];
4418 let desired = album_desired(&members, false, false);
4419 let mut albums = BTreeMap::new();
4420 albums.insert(
4421 "root".to_string(),
4422 AlbumArt {
4423 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
4424 folder_webp: None,
4425 folder_mp4: None,
4426 },
4427 );
4428 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4429 assert_eq!(actions.len(), 1);
4430 assert!(matches!(
4431 &actions[0],
4432 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
4433 ));
4434 }
4435
4436 #[test]
4437 fn h1_most_played_flip_to_same_art_writes_nothing() {
4438 let run1 = vec![
4440 album_member(
4441 album_clip("a", 9, "t0", "same-art", ""),
4442 "root",
4443 "c/al/a.flac",
4444 ),
4445 album_member(
4446 album_clip("b", 1, "t1", "same-art", ""),
4447 "root",
4448 "c/al/b.flac",
4449 ),
4450 ];
4451 let desired1 = album_desired(&run1, false, false);
4452 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true, &HashMap::new());
4453 assert_eq!(write1.len(), 1);
4454
4455 let mut albums = BTreeMap::new();
4457 if let Action::WriteArtifact {
4458 path,
4459 hash,
4460 owner_id,
4461 ..
4462 } = &write1[0]
4463 {
4464 albums.insert(
4465 owner_id.clone(),
4466 AlbumArt {
4467 folder_jpg: Some(stored(path, hash)),
4468 folder_webp: None,
4469 folder_mp4: None,
4470 },
4471 );
4472 }
4473
4474 let run2 = vec![
4476 album_member(
4477 album_clip("a", 1, "t0", "same-art", ""),
4478 "root",
4479 "c/al/a.flac",
4480 ),
4481 album_member(
4482 album_clip("b", 9, "t1", "same-art", ""),
4483 "root",
4484 "c/al/b.flac",
4485 ),
4486 ];
4487 let desired2 = album_desired(&run2, false, false);
4488 assert!(plan_album_artifacts(&desired2, &albums, true, &HashMap::new()).is_empty());
4490 }
4491
4492 #[test]
4493 fn h1_flip_to_different_art_writes_exactly_one() {
4494 let mut albums = BTreeMap::new();
4495 albums.insert(
4496 "root".to_string(),
4497 AlbumArt {
4498 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
4499 folder_webp: None,
4500 folder_mp4: None,
4501 },
4502 );
4503 let members = vec![
4505 album_member(
4506 album_clip("a", 1, "t0", "old-art", ""),
4507 "root",
4508 "c/al/a.flac",
4509 ),
4510 album_member(
4511 album_clip("b", 9, "t1", "new-art", ""),
4512 "root",
4513 "c/al/b.flac",
4514 ),
4515 ];
4516 let desired = album_desired(&members, false, false);
4517 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4518 assert_eq!(actions.len(), 1);
4519 assert!(matches!(
4520 &actions[0],
4521 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
4522 ));
4523 }
4524
4525 #[test]
4526 fn one_write_per_album_regardless_of_clip_count() {
4527 let members: Vec<Desired> = (0..200)
4528 .map(|i| {
4529 album_member(
4530 album_clip(
4531 &format!("clip-{i:03}"),
4532 i as u64,
4533 &format!("t{i:03}"),
4534 &format!("art-{i:03}"),
4535 &format!("vid-{i:03}"),
4536 ),
4537 "root",
4538 &format!("c/al/clip-{i:03}.flac"),
4539 )
4540 })
4541 .collect();
4542 let desired = album_desired(&members, true, false);
4543 assert_eq!(desired.len(), 1);
4544 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4545 assert_eq!(actions.len(), 2);
4547 assert_eq!(
4548 actions
4549 .iter()
4550 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
4551 .count(),
4552 2
4553 );
4554 }
4555
4556 #[test]
4557 fn emptied_album_deletes_only_when_can_delete() {
4558 let mut albums = BTreeMap::new();
4559 albums.insert(
4560 "root".to_string(),
4561 AlbumArt {
4562 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
4563 folder_webp: Some(stored("c/al/cover.webp", "hw")),
4564 folder_mp4: Some(stored("c/al/cover.mp4", "hm")),
4565 },
4566 );
4567 let desired: Vec<AlbumDesired> = Vec::new();
4569
4570 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4572
4573 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4575 assert_eq!(
4576 actions,
4577 vec![
4578 Action::DeleteArtifact {
4579 kind: ArtifactKind::FolderJpg,
4580 path: "c/al/folder.jpg".to_string(),
4581 owner_id: "root".to_string(),
4582 },
4583 Action::DeleteArtifact {
4584 kind: ArtifactKind::FolderWebp,
4585 path: "c/al/cover.webp".to_string(),
4586 owner_id: "root".to_string(),
4587 },
4588 Action::DeleteArtifact {
4589 kind: ArtifactKind::FolderMp4,
4590 path: "c/al/cover.mp4".to_string(),
4591 owner_id: "root".to_string(),
4592 },
4593 ]
4594 );
4595 }
4596
4597 #[test]
4598 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
4599 let mut albums = BTreeMap::new();
4600 albums.insert(
4601 "root".to_string(),
4602 AlbumArt {
4603 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4604 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4605 folder_mp4: None,
4606 },
4607 );
4608 let members = vec![album_member(
4611 album_clip("a", 1, "t0", "art-a", "vid-a"),
4612 "root",
4613 "c/al/a.flac",
4614 )];
4615 let desired = album_desired(&members, false, false);
4616
4617 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4618
4619 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4620 assert_eq!(
4621 actions,
4622 vec![Action::DeleteArtifact {
4623 kind: ArtifactKind::FolderWebp,
4624 path: "c/al/cover.webp".to_string(),
4625 owner_id: "root".to_string(),
4626 }]
4627 );
4628 }
4629
4630 #[test]
4631 fn disappeared_raw_cover_deletes_only_that_kind_when_gated() {
4632 let mut albums = BTreeMap::new();
4633 albums.insert(
4634 "root".to_string(),
4635 AlbumArt {
4636 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4637 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4638 folder_mp4: Some(stored("c/al/cover.mp4", &art_url_hash("vid-a"))),
4639 },
4640 );
4641 let members = vec![album_member(
4644 album_clip("a", 1, "t0", "art-a", "vid-a"),
4645 "root",
4646 "c/al/a.flac",
4647 )];
4648 let desired = album_desired(&members, true, false);
4649
4650 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4652
4653 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4655 assert_eq!(
4656 actions,
4657 vec![Action::DeleteArtifact {
4658 kind: ArtifactKind::FolderMp4,
4659 path: "c/al/cover.mp4".to_string(),
4660 owner_id: "root".to_string(),
4661 }]
4662 );
4663 }
4664
4665 #[test]
4666 fn plan_album_artifacts_is_deterministically_ordered() {
4667 let members = vec![
4668 album_member(
4669 album_clip("a", 1, "t0", "art-a", "vid-a"),
4670 "r2",
4671 "c/al2/a.flac",
4672 ),
4673 album_member(
4674 album_clip("b", 1, "t0", "art-b", "vid-b"),
4675 "r1",
4676 "c/al1/b.flac",
4677 ),
4678 ];
4679 let desired = album_desired(&members, true, true);
4680 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4681 let keys: Vec<(&str, ArtifactKind)> = actions
4682 .iter()
4683 .map(|a| match a {
4684 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
4685 _ => unreachable!(),
4686 })
4687 .collect();
4688 assert_eq!(
4689 keys,
4690 vec![
4691 ("r1", ArtifactKind::FolderJpg),
4692 ("r1", ArtifactKind::FolderWebp),
4693 ("r1", ArtifactKind::FolderMp4),
4694 ("r2", ArtifactKind::FolderJpg),
4695 ("r2", ArtifactKind::FolderWebp),
4696 ("r2", ArtifactKind::FolderMp4),
4697 ]
4698 );
4699 }
4700
4701 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
4704 PlaylistDesired {
4705 id: id.to_owned(),
4706 name: name.to_owned(),
4707 path: path.to_owned(),
4708 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
4709 hash: hash.to_owned(),
4710 }
4711 }
4712
4713 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
4714 PlaylistState {
4715 name: name.to_owned(),
4716 path: path.to_owned(),
4717 hash: hash.to_owned(),
4718 }
4719 }
4720
4721 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
4722 entries
4723 .iter()
4724 .map(|(id, state)| ((*id).to_owned(), state.clone()))
4725 .collect()
4726 }
4727
4728 #[test]
4729 fn playlist_write_emitted_for_a_new_playlist() {
4730 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
4731 let actions =
4732 plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true, &HashMap::new());
4733 assert_eq!(
4734 actions,
4735 vec![Action::WriteArtifact {
4736 kind: ArtifactKind::Playlist,
4737 path: "Road Trip.m3u8".to_owned(),
4738 source_url: String::new(),
4739 hash: "h1".to_owned(),
4740 owner_id: "pl1".to_owned(),
4741 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
4742 }]
4743 );
4744 }
4745
4746 #[test]
4747 fn playlist_write_emitted_when_hash_changes() {
4748 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
4751 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4752 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4753 assert_eq!(actions.len(), 1);
4754 assert!(matches!(
4755 &actions[0],
4756 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
4757 ));
4758 }
4759
4760 #[test]
4761 fn playlist_unchanged_is_idempotent() {
4762 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4763 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4764 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4765 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
4766 }
4767
4768 #[test]
4769 fn playlist_rename_writes_new_and_deletes_old_path() {
4770 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4773 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4774 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4775 assert_eq!(
4776 actions,
4777 vec![
4778 Action::WriteArtifact {
4779 kind: ArtifactKind::Playlist,
4780 path: "Summer.m3u8".to_owned(),
4781 source_url: String::new(),
4782 hash: "h2".to_owned(),
4783 owner_id: "pl1".to_owned(),
4784 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
4785 },
4786 Action::DeleteArtifact {
4787 kind: ArtifactKind::Playlist,
4788 path: "Spring.m3u8".to_owned(),
4789 owner_id: "pl1".to_owned(),
4790 },
4791 ]
4792 );
4793 }
4794
4795 #[test]
4796 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
4797 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4800 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4801 let actions = plan_playlist_artifacts(&desired, &stored, false, true, &HashMap::new());
4802 assert_eq!(actions.len(), 1);
4803 assert!(matches!(
4804 &actions[0],
4805 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
4806 ));
4807 assert!(
4808 !actions
4809 .iter()
4810 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
4811 "old path must not be deleted when deletes are disallowed"
4812 );
4813 }
4814
4815 #[test]
4816 fn playlist_stale_removed_only_under_full_gate() {
4817 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
4820
4821 let deleted = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4822 assert_eq!(
4823 deleted,
4824 vec![Action::DeleteArtifact {
4825 kind: ArtifactKind::Playlist,
4826 path: "Gone.m3u8".to_owned(),
4827 owner_id: "gone".to_owned(),
4828 }]
4829 );
4830
4831 assert!(plan_playlist_artifacts(&[], &stored, false, true, &HashMap::new()).is_empty());
4833 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4834 assert!(plan_playlist_artifacts(&[], &stored, false, false, &HashMap::new()).is_empty());
4835 }
4836
4837 #[test]
4838 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
4839 let stored = pl_store(&[
4844 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4845 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4846 ]);
4847 let actions = plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new());
4848 assert!(
4849 actions.is_empty(),
4850 "a failed playlist listing must plan zero actions, got {actions:?}"
4851 );
4852 }
4853
4854 #[test]
4855 fn b2_empty_list_deletes_only_when_fully_enumerated() {
4856 let stored = pl_store(&[
4861 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4862 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4863 ]);
4864
4865 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4867
4868 let wiped = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4871 assert_eq!(
4872 wiped
4873 .iter()
4874 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
4875 .count(),
4876 2
4877 );
4878 }
4879
4880 #[test]
4881 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
4882 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
4887 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
4888 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4889 assert_eq!(actions.len(), 1);
4891 assert!(matches!(
4892 &actions[0],
4893 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
4894 ));
4895 assert!(
4896 !actions.iter().any(|a| match a {
4897 Action::WriteArtifact { owner_id, .. }
4898 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
4899 _ => false,
4900 }),
4901 "a protected (failed-member) playlist must have no action"
4902 );
4903 }
4904
4905 #[test]
4906 fn playlist_rename_collision_downgrades_the_delete() {
4907 let desired = vec![
4913 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
4914 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
4915 ];
4916 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
4917 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4918 let write_paths: BTreeSet<&str> = actions
4920 .iter()
4921 .filter_map(|a| match a {
4922 Action::WriteArtifact { path, .. } => Some(path.as_str()),
4923 _ => None,
4924 })
4925 .collect();
4926 for a in &actions {
4927 if let Action::DeleteArtifact { path, .. } = a {
4928 assert!(
4929 !write_paths.contains(path.as_str()),
4930 "a playlist delete aliases a write target: {path}"
4931 );
4932 }
4933 }
4934 }
4935
4936 fn dstem(key: &str, path: &str, hash: &str) -> DesiredStem {
4939 DesiredStem {
4940 key: key.to_string(),
4941 stem_id: key.to_string(),
4942 path: path.to_string(),
4943 source_url: format!("https://cdn1.suno.ai/{key}.mp3"),
4944 format: StemFormat::Mp3,
4945 hash: hash.to_string(),
4946 }
4947 }
4948
4949 fn stem_desired(id: &str, stems: Option<Vec<DesiredStem>>) -> Desired {
4951 Desired {
4952 stems,
4953 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
4954 }
4955 }
4956
4957 fn entry_with_stems(id: &str, stems: &[(&str, &str, &str)]) -> ManifestEntry {
4959 let mut e = entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art");
4960 for (key, path, hash) in stems {
4961 e.stems.insert(
4962 key.to_string(),
4963 ArtifactState {
4964 path: path.to_string(),
4965 hash: hash.to_string(),
4966 },
4967 );
4968 }
4969 e
4970 }
4971
4972 fn stem_writes(plan: &Plan) -> Vec<(&str, &str)> {
4973 plan.actions
4974 .iter()
4975 .filter_map(|a| match a {
4976 Action::WriteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4977 _ => None,
4978 })
4979 .collect()
4980 }
4981
4982 fn stem_deletes(plan: &Plan) -> Vec<(&str, &str)> {
4983 plan.actions
4984 .iter()
4985 .filter_map(|a| match a {
4986 Action::DeleteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4987 _ => None,
4988 })
4989 .collect()
4990 }
4991
4992 #[test]
4993 fn stems_none_keeps_every_existing_stem() {
4994 let mut manifest = Manifest::new();
4997 manifest.insert(
4998 "a",
4999 entry_with_stems(
5000 "a",
5001 &[
5002 ("voc", "a.stems/voc.mp3", "h1"),
5003 ("drm", "a.stems/drm.mp3", "h2"),
5004 ],
5005 ),
5006 );
5007 let d = vec![stem_desired("a", None)];
5008 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5009 assert_eq!(plan.stem_writes(), 0);
5010 assert_eq!(plan.stem_deletes(), 0);
5011 }
5012
5013 #[test]
5014 fn stems_authoritative_writes_missing_stems() {
5015 let mut manifest = Manifest::new();
5016 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
5017 let d = vec![stem_desired(
5018 "a",
5019 Some(vec![
5020 dstem("voc", "a.stems/voc.mp3", "h1"),
5021 dstem("drm", "a.stems/drm.mp3", "h2"),
5022 ]),
5023 )];
5024 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5025 assert_eq!(
5026 stem_writes(&plan),
5027 vec![("voc", "a.stems/voc.mp3"), ("drm", "a.stems/drm.mp3")]
5028 );
5029 assert_eq!(plan.stem_deletes(), 0);
5030 }
5031
5032 #[test]
5033 fn stems_authoritative_rewrites_only_on_hash_or_path_drift() {
5034 let mut manifest = Manifest::new();
5035 manifest.insert(
5037 "a",
5038 entry_with_stems(
5039 "a",
5040 &[
5041 ("voc", "a.stems/voc.mp3", "h1"),
5042 ("drm", "a.stems/drm.mp3", "h2"),
5043 ("bas", "old.stems/bas.mp3", "h3"),
5044 ],
5045 ),
5046 );
5047 let d = vec![stem_desired(
5048 "a",
5049 Some(vec![
5050 dstem("voc", "a.stems/voc.mp3", "h1"), dstem("drm", "a.stems/drm.mp3", "h2-new"), dstem("bas", "a.stems/bas.mp3", "h3"), ]),
5054 )];
5055 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5056 assert_eq!(
5057 stem_writes(&plan),
5058 vec![("drm", "a.stems/drm.mp3"), ("bas", "a.stems/bas.mp3")]
5059 );
5060 assert_eq!(plan.stem_deletes(), 0);
5061 }
5062
5063 #[test]
5064 fn stems_authoritative_removes_a_stem_absent_from_the_set() {
5065 let mut manifest = Manifest::new();
5068 manifest.insert(
5069 "a",
5070 entry_with_stems(
5071 "a",
5072 &[
5073 ("voc", "a.stems/voc.mp3", "h1"),
5074 ("drm", "a.stems/drm.mp3", "h2"),
5075 ],
5076 ),
5077 );
5078 let d = vec![stem_desired(
5079 "a",
5080 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
5081 )];
5082 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5083 assert_eq!(plan.stem_writes(), 0);
5084 assert_eq!(stem_deletes(&plan), vec![("drm", "a.stems/drm.mp3")]);
5085 }
5086
5087 #[test]
5088 fn stems_removal_needs_deletion_allowed() {
5089 let mut manifest = Manifest::new();
5092 manifest.insert(
5093 "a",
5094 entry_with_stems(
5095 "a",
5096 &[
5097 ("voc", "a.stems/voc.mp3", "h1"),
5098 ("drm", "a.stems/drm.mp3", "h2"),
5099 ],
5100 ),
5101 );
5102 let d = vec![stem_desired(
5103 "a",
5104 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
5105 )];
5106
5107 let incomplete = vec![SourceStatus {
5108 mode: SourceMode::Mirror,
5109 fully_enumerated: false,
5110 }];
5111 assert_eq!(
5112 reconcile(&manifest, &d, &local_present("a"), &incomplete).stem_deletes(),
5113 0
5114 );
5115
5116 let copy_only = vec![SourceStatus {
5117 mode: SourceMode::Copy,
5118 fully_enumerated: true,
5119 }];
5120 assert_eq!(
5121 reconcile(&manifest, &d, &local_present("a"), ©_only).stem_deletes(),
5122 0
5123 );
5124 }
5125
5126 #[test]
5127 fn stems_removal_skipped_for_preserved_or_protected_clip() {
5128 let mut manifest = Manifest::new();
5129 let mut e = entry_with_stems(
5130 "a",
5131 &[
5132 ("voc", "a.stems/voc.mp3", "h1"),
5133 ("drm", "a.stems/drm.mp3", "h2"),
5134 ],
5135 );
5136 e.preserve = true;
5137 manifest.insert("a", e);
5138 let authoritative = Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]);
5139
5140 let d = vec![stem_desired("a", authoritative.clone())];
5142 assert_eq!(
5143 reconcile(&manifest, &d, &local_present("a"), &mirror_ok()).stem_deletes(),
5144 0
5145 );
5146
5147 let mut manifest2 = Manifest::new();
5149 manifest2.insert(
5150 "a",
5151 entry_with_stems(
5152 "a",
5153 &[
5154 ("voc", "a.stems/voc.mp3", "h1"),
5155 ("drm", "a.stems/drm.mp3", "h2"),
5156 ],
5157 ),
5158 );
5159 let held = Desired {
5160 modes: vec![SourceMode::Mirror, SourceMode::Copy],
5161 stems: authoritative,
5162 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
5163 };
5164 assert_eq!(
5165 reconcile(&manifest2, &[held], &local_present("a"), &mirror_ok()).stem_deletes(),
5166 0
5167 );
5168 }
5169
5170 #[test]
5171 fn stems_are_co_deleted_when_the_song_is_trashed() {
5172 let mut manifest = Manifest::new();
5175 manifest.insert(
5176 "a",
5177 entry_with_stems(
5178 "a",
5179 &[
5180 ("voc", "a.stems/voc.mp3", "h1"),
5181 ("drm", "a.stems/drm.mp3", "h2"),
5182 ],
5183 ),
5184 );
5185 let trashed = Desired {
5186 trashed: true,
5187 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
5188 };
5189 let plan = reconcile(&manifest, &[trashed], &local_present("a"), &mirror_ok());
5190 assert_eq!(plan.deletes(), 1, "the trashed audio is deleted");
5191 let mut deleted: Vec<&str> = stem_deletes(&plan).into_iter().map(|(k, _)| k).collect();
5192 deleted.sort_unstable();
5193 assert_eq!(deleted, vec!["drm", "voc"], "both stems co-deleted");
5194 }
5195
5196 #[test]
5197 fn stems_are_co_deleted_for_an_absent_clip() {
5198 let mut manifest = Manifest::new();
5199 manifest.insert(
5200 "a",
5201 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5202 );
5203 let plan = reconcile(&manifest, &[], &local_present("a"), &mirror_ok());
5205 assert_eq!(plan.deletes(), 1);
5206 assert_eq!(stem_deletes(&plan), vec![("voc", "a.stems/voc.mp3")]);
5207 }
5208
5209 #[test]
5210 fn stems_are_kept_when_absent_clip_listing_is_incomplete() {
5211 let mut manifest = Manifest::new();
5213 manifest.insert(
5214 "a",
5215 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5216 );
5217 let incomplete = vec![SourceStatus {
5218 mode: SourceMode::Mirror,
5219 fully_enumerated: false,
5220 }];
5221 let plan = reconcile(&manifest, &[], &HashMap::new(), &incomplete);
5222 assert_eq!(plan.deletes(), 0);
5223 assert_eq!(plan.stem_deletes(), 0);
5224 }
5225
5226 #[test]
5227 fn stem_delete_is_suppressed_when_it_aliases_a_stem_write() {
5228 let mut manifest = Manifest::new();
5232 manifest.insert(
5233 "a",
5234 entry_with_stems("a", &[("old", "a.stems/mix.mp3", "h1")]),
5235 );
5236 let d = vec![stem_desired(
5237 "a",
5238 Some(vec![dstem("new", "a.stems/mix.mp3", "h2")]),
5239 )];
5240 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5241 assert_eq!(stem_writes(&plan), vec![("new", "a.stems/mix.mp3")]);
5244 assert!(
5245 !plan.actions.iter().any(|a| matches!(
5246 a,
5247 Action::DeleteStem { path, .. } if path == "a.stems/mix.mp3"
5248 )),
5249 "a stem delete must never alias a stem write target"
5250 );
5251 }
5252}
5253
5254#[cfg(test)]
5267mod proptests {
5268 use super::*;
5269 use proptest::collection::{btree_map, hash_map, vec};
5270 use proptest::prelude::*;
5271 use std::collections::BTreeSet;
5272
5273 type DesiredFields = (
5274 String,
5275 AudioFormat,
5276 String,
5277 String,
5278 Vec<SourceMode>,
5279 bool,
5280 bool,
5281 );
5282
5283 fn audio_format() -> impl Strategy<Value = AudioFormat> {
5284 prop_oneof![
5285 Just(AudioFormat::Mp3),
5286 Just(AudioFormat::Flac),
5287 Just(AudioFormat::Wav),
5288 ]
5289 }
5290
5291 fn source_mode() -> impl Strategy<Value = SourceMode> {
5292 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
5293 }
5294
5295 fn clip_id() -> impl Strategy<Value = String> {
5298 (0u8..8).prop_map(|n| format!("c{n}"))
5299 }
5300
5301 fn small_path() -> impl Strategy<Value = String> {
5302 (0u8..6).prop_map(|n| format!("path{n}"))
5303 }
5304
5305 fn manifest_path() -> impl Strategy<Value = String> {
5308 prop_oneof![
5309 1 => Just(String::new()),
5310 6 => small_path(),
5311 ]
5312 }
5313
5314 fn small_hash() -> impl Strategy<Value = String> {
5315 (0u8..4).prop_map(|n| format!("h{n}"))
5316 }
5317
5318 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
5319 (
5320 manifest_path(),
5321 audio_format(),
5322 small_hash(),
5323 small_hash(),
5324 0u64..4,
5325 any::<bool>(),
5326 )
5327 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
5328 ManifestEntry {
5329 path,
5330 format,
5331 meta_hash,
5332 art_hash,
5333 size,
5334 preserve,
5335 ..Default::default()
5336 }
5337 })
5338 }
5339
5340 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
5341 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
5342 }
5343
5344 fn local_file() -> impl Strategy<Value = LocalFile> {
5345 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
5346 }
5347
5348 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
5349 hash_map(clip_id(), local_file(), 0..8)
5350 }
5351
5352 fn source_status() -> impl Strategy<Value = SourceStatus> {
5353 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
5354 mode,
5355 fully_enumerated,
5356 })
5357 }
5358
5359 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5360 vec(source_status(), 0..5)
5361 }
5362
5363 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5364 vec(
5365 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
5366 mode: SourceMode::Copy,
5367 fully_enumerated,
5368 }),
5369 1..5,
5370 )
5371 }
5372
5373 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
5374 (
5375 small_path(),
5376 audio_format(),
5377 small_hash(),
5378 small_hash(),
5379 vec(source_mode(), 1..3),
5380 any::<bool>(),
5381 any::<bool>(),
5382 )
5383 }
5384
5385 fn build_desired(id: String, fields: DesiredFields) -> Desired {
5386 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
5387 let clip = Clip {
5388 id,
5389 title: "t".to_string(),
5390 ..Default::default()
5391 };
5392 Desired {
5393 lineage: LineageContext::own_root(&clip),
5394 clip,
5395 path,
5396 format,
5397 meta_hash,
5398 art_hash,
5399 modes,
5400 trashed,
5401 private,
5402 artifacts: Vec::new(),
5403 stems: None,
5404 }
5405 }
5406
5407 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
5410 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
5411 items
5412 .into_iter()
5413 .map(|(id, fields)| build_desired(id, fields))
5414 .collect()
5415 })
5416 }
5417
5418 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
5419 desired.iter().map(|d| d.clip.id.as_str()).collect()
5420 }
5421
5422 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
5425 desired
5426 .iter()
5427 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
5428 .map(|d| d.clip.id.as_str())
5429 .collect()
5430 }
5431
5432 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
5435 desired
5436 .iter()
5437 .filter(|d| !d.trashed)
5438 .map(|d| d.clip.id.as_str())
5439 .collect()
5440 }
5441
5442 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
5443 plan.actions
5444 .iter()
5445 .filter_map(|a| match a {
5446 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
5447 _ => None,
5448 })
5449 .collect()
5450 }
5451
5452 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
5453 plan.actions
5454 .iter()
5455 .filter_map(|a| match a {
5456 Action::Download { path, .. } | Action::Reformat { path, .. } => {
5457 Some(path.as_str())
5458 }
5459 Action::Rename { to, .. } => Some(to.as_str()),
5460 _ => None,
5461 })
5462 .collect()
5463 }
5464
5465 proptest! {
5466 #![proptest_config(ProptestConfig {
5467 cases: 256,
5468 failure_persistence: None,
5469 ..ProptestConfig::default()
5470 })]
5471
5472 #[test]
5475 fn inv1_desired_clip_deleted_only_when_fully_trashed(
5476 manifest in manifest_strategy(),
5477 desired in desired_strategy(),
5478 local in local_strategy(),
5479 sources in sources_strategy(),
5480 ) {
5481 let plan = reconcile(&manifest, &desired, &local, &sources);
5482 let present = desired_ids(&desired);
5483 let live = non_trashed_ids(&desired);
5484 for id in delete_clip_ids(&plan) {
5485 prop_assert!(
5486 !(present.contains(id) && live.contains(id)),
5487 "deleted a desired clip with a non-trashed duplicate: {id}"
5488 );
5489 }
5490 }
5491
5492 #[test]
5496 fn inv2_no_delete_when_any_mirror_unenumerated(
5497 manifest in manifest_strategy(),
5498 desired in desired_strategy(),
5499 local in local_strategy(),
5500 mut sources in sources_strategy(),
5501 ) {
5502 sources.push(SourceStatus {
5503 mode: SourceMode::Mirror,
5504 fully_enumerated: false,
5505 });
5506 let plan = reconcile(&manifest, &desired, &local, &sources);
5507 prop_assert_eq!(plan.deletes(), 0);
5508 }
5509
5510 #[test]
5512 fn inv3_all_copy_sources_means_no_deletes(
5513 manifest in manifest_strategy(),
5514 desired in desired_strategy(),
5515 local in local_strategy(),
5516 sources in copy_sources_strategy(),
5517 ) {
5518 let plan = reconcile(&manifest, &desired, &local, &sources);
5519 prop_assert_eq!(plan.deletes(), 0);
5520 }
5521
5522 #[test]
5525 fn inv4_plan_is_deterministic(
5526 manifest in manifest_strategy(),
5527 desired in desired_strategy(),
5528 local in local_strategy(),
5529 sources in sources_strategy(),
5530 ) {
5531 let plan = reconcile(&manifest, &desired, &local, &sources);
5532
5533 let again = reconcile(&manifest, &desired, &local, &sources);
5534 prop_assert_eq!(&plan, &again);
5535
5536 let mut desired_rev = desired.clone();
5537 desired_rev.reverse();
5538 let mut sources_rev = sources.clone();
5539 sources_rev.reverse();
5540 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
5541 prop_assert_eq!(&plan, &shuffled);
5542 }
5543
5544 #[test]
5546 fn inv5_every_delete_is_in_the_manifest(
5547 manifest in manifest_strategy(),
5548 desired in desired_strategy(),
5549 local in local_strategy(),
5550 sources in sources_strategy(),
5551 ) {
5552 let plan = reconcile(&manifest, &desired, &local, &sources);
5553 for id in delete_clip_ids(&plan) {
5554 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
5555 }
5556 }
5557
5558 #[test]
5561 fn inv6_never_deletes_protected_clip(
5562 manifest in manifest_strategy(),
5563 desired in desired_strategy(),
5564 local in local_strategy(),
5565 sources in sources_strategy(),
5566 ) {
5567 let plan = reconcile(&manifest, &desired, &local, &sources);
5568 let protected = protected_ids(&desired);
5569 for id in delete_clip_ids(&plan) {
5570 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
5571 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
5572 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
5573 }
5574 }
5575
5576 #[test]
5579 fn inv7_no_delete_unless_deletion_allowed(
5580 manifest in manifest_strategy(),
5581 desired in desired_strategy(),
5582 local in local_strategy(),
5583 sources in sources_strategy(),
5584 ) {
5585 let plan = reconcile(&manifest, &desired, &local, &sources);
5586 if !deletion_allowed(&sources) {
5587 prop_assert_eq!(plan.deletes(), 0);
5588 }
5589 }
5590
5591 #[test]
5593 fn inv8_at_most_one_delete_per_clip(
5594 manifest in manifest_strategy(),
5595 desired in desired_strategy(),
5596 local in local_strategy(),
5597 sources in sources_strategy(),
5598 ) {
5599 let plan = reconcile(&manifest, &desired, &local, &sources);
5600 let ids = delete_clip_ids(&plan);
5601 let unique: BTreeSet<&str> = ids.iter().copied().collect();
5602 prop_assert_eq!(ids.len(), unique.len());
5603 }
5604
5605 #[test]
5607 fn inv9_no_delete_with_empty_path(
5608 manifest in manifest_strategy(),
5609 desired in desired_strategy(),
5610 local in local_strategy(),
5611 sources in sources_strategy(),
5612 ) {
5613 let plan = reconcile(&manifest, &desired, &local, &sources);
5614 for action in &plan.actions {
5615 if let Action::Delete { path, .. } = action {
5616 prop_assert!(!path.is_empty(), "delete with an empty path");
5617 }
5618 }
5619 }
5620
5621 #[test]
5624 fn inv10_no_delete_aliases_a_write_target(
5625 manifest in manifest_strategy(),
5626 desired in desired_strategy(),
5627 local in local_strategy(),
5628 sources in sources_strategy(),
5629 ) {
5630 let plan = reconcile(&manifest, &desired, &local, &sources);
5631 let targets = write_target_paths(&plan);
5632 for action in &plan.actions {
5633 if let Action::Delete { path, .. } = action {
5634 prop_assert!(
5635 !targets.contains(path.as_str()),
5636 "delete path {path} aliases a write target"
5637 );
5638 }
5639 }
5640 }
5641 }
5642}