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 };
1186 (
1187 d.path.as_str(),
1188 d.meta_hash.as_str(),
1189 d.art_hash.as_str(),
1190 format,
1191 )
1192}
1193
1194fn suppress_path_aliasing(actions: &mut [Action]) {
1201 let aliased: Vec<usize> = {
1205 let targets: BTreeSet<&str> = actions
1206 .iter()
1207 .filter_map(|a| match a {
1208 Action::Download { path, .. }
1209 | Action::Reformat { path, .. }
1210 | Action::WriteArtifact { path, .. }
1211 | Action::WriteStem { path, .. } => Some(path.as_str()),
1212 Action::Rename { to, .. }
1213 | Action::MoveArtifact { to, .. }
1214 | Action::MoveStem { to, .. } => Some(to.as_str()),
1215 _ => None,
1216 })
1217 .collect();
1218 actions
1219 .iter()
1220 .enumerate()
1221 .filter_map(|(index, a)| match a {
1222 Action::Delete { path, .. }
1223 | Action::DeleteArtifact { path, .. }
1224 | Action::DeleteStem { path, .. } => {
1225 targets.contains(path.as_str()).then_some(index)
1226 }
1227 _ => None,
1228 })
1229 .collect()
1230 };
1231 for index in aliased {
1232 actions[index] = match &actions[index] {
1233 Action::Delete { clip_id, .. } | Action::DeleteStem { clip_id, .. } => Action::Skip {
1234 clip_id: clip_id.clone(),
1235 },
1236 Action::DeleteArtifact { owner_id, .. } => Action::Skip {
1237 clip_id: owner_id.clone(),
1238 },
1239 _ => unreachable!("only delete actions are collected as aliased"),
1240 };
1241 }
1242}
1243
1244fn plan_desired(
1246 d: &Desired,
1247 manifest: &Manifest,
1248 local: &HashMap<String, LocalFile>,
1249 can_delete: bool,
1250 out: &mut Vec<Action>,
1251) {
1252 let clip_id = d.clip.id.as_str();
1253 let copy_held = d.modes.contains(&SourceMode::Copy);
1254
1255 if d.trashed && !d.private && !copy_held {
1261 match delete_action(clip_id, manifest, can_delete) {
1262 Some(action) => out.push(action),
1263 None => out.push(Action::Skip {
1264 clip_id: clip_id.to_string(),
1265 }),
1266 }
1267 return;
1268 }
1269
1270 let Some(entry) = manifest.get(clip_id) else {
1271 out.push(Action::Download {
1273 clip: d.clip.clone(),
1274 lineage: d.lineage.clone(),
1275 path: d.path.clone(),
1276 format: d.format,
1277 });
1278 return;
1279 };
1280
1281 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
1284 if missing {
1285 out.push(Action::Download {
1286 clip: d.clip.clone(),
1287 lineage: d.lineage.clone(),
1288 path: d.path.clone(),
1289 format: d.format,
1290 });
1291 return;
1292 }
1293
1294 if d.format != entry.format {
1295 out.push(Action::Reformat {
1298 clip: d.clip.clone(),
1299 path: d.path.clone(),
1300 from_path: entry.path.clone(),
1301 from: entry.format,
1302 to: d.format,
1303 });
1304 return;
1305 }
1306
1307 if d.path != entry.path {
1308 out.push(Action::Rename {
1309 from: entry.path.clone(),
1310 to: d.path.clone(),
1311 });
1312 if meta_or_art_changed(d, entry) {
1314 out.push(Action::Retag {
1315 clip: d.clip.clone(),
1316 lineage: d.lineage.clone(),
1317 path: d.path.clone(),
1318 });
1319 }
1320 return;
1321 }
1322
1323 if meta_or_art_changed(d, entry) {
1324 out.push(Action::Retag {
1325 clip: d.clip.clone(),
1326 lineage: d.lineage.clone(),
1327 path: entry.path.clone(),
1328 });
1329 return;
1330 }
1331
1332 out.push(Action::Skip {
1333 clip_id: clip_id.to_string(),
1334 });
1335}
1336
1337fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
1339 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
1340}
1341
1342pub fn album_desired(
1366 desired: &[Desired],
1367 animated_covers: bool,
1368 raw_cover: bool,
1369) -> Vec<AlbumDesired> {
1370 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
1371 for d in desired {
1372 groups
1373 .entry(d.lineage.root_id.as_str())
1374 .or_default()
1375 .push(d);
1376 }
1377
1378 groups
1379 .into_iter()
1380 .map(|(root_id, members)| {
1381 let album_dir = album_dir_of(&members);
1382 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
1383 kind: ArtifactKind::FolderJpg,
1384 path: album_child(&album_dir, "folder.jpg"),
1385 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
1386 hash: art_hash(&source.clip),
1387 content: None,
1388 });
1389 let folder_webp = animated_covers
1390 .then(|| folder_webp_source(&members))
1391 .flatten()
1392 .map(|source| DesiredArtifact {
1393 kind: ArtifactKind::FolderWebp,
1394 path: album_child(&album_dir, "cover.webp"),
1395 source_url: source.clip.video_cover_url.clone(),
1396 hash: art_url_hash(&source.clip.video_cover_url),
1397 content: None,
1398 });
1399 let folder_mp4 = raw_cover
1400 .then(|| folder_webp_source(&members))
1401 .flatten()
1402 .map(|source| DesiredArtifact {
1403 kind: ArtifactKind::FolderMp4,
1404 path: album_child(&album_dir, "cover.mp4"),
1405 source_url: source.clip.video_cover_url.clone(),
1406 hash: art_url_hash(&source.clip.video_cover_url),
1407 content: None,
1408 });
1409 AlbumDesired {
1410 root_id: root_id.to_owned(),
1411 folder_jpg,
1412 folder_webp,
1413 folder_mp4,
1414 }
1415 })
1416 .collect()
1417}
1418
1419fn album_dir_of(members: &[&Desired]) -> String {
1424 members
1425 .iter()
1426 .map(|d| parent_dir(&d.path))
1427 .min()
1428 .unwrap_or("")
1429 .to_owned()
1430}
1431
1432fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1438 members
1439 .iter()
1440 .copied()
1441 .filter(|d| {
1442 d.clip
1443 .selected_image_url()
1444 .is_some_and(|url| !url.is_empty())
1445 })
1446 .min_by(|a, b| {
1447 b.clip
1448 .play_count
1449 .cmp(&a.clip.play_count)
1450 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
1451 .then_with(|| a.clip.id.cmp(&b.clip.id))
1452 })
1453}
1454
1455fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1460 members
1461 .iter()
1462 .copied()
1463 .filter(|d| !d.clip.video_cover_url.is_empty())
1464 .min_by(|a, b| {
1465 a.clip
1466 .created_at
1467 .cmp(&b.clip.created_at)
1468 .then_with(|| a.clip.id.cmp(&b.clip.id))
1469 })
1470}
1471
1472fn parent_dir(path: &str) -> &str {
1474 match path.rsplit_once('/') {
1475 Some((dir, _)) => dir,
1476 None => "",
1477 }
1478}
1479
1480fn album_child(album_dir: &str, name: &str) -> String {
1483 if album_dir.is_empty() {
1484 name.to_owned()
1485 } else {
1486 format!("{album_dir}/{name}")
1487 }
1488}
1489
1490pub fn plan_album_artifacts(
1514 desired: &[AlbumDesired],
1515 albums: &BTreeMap<String, AlbumArt>,
1516 can_delete: bool,
1517 local: &HashMap<String, LocalFile>,
1518) -> Vec<Action> {
1519 let mut actions: Vec<Action> = Vec::new();
1520 let by_root: BTreeMap<&str, &AlbumDesired> =
1521 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1522
1523 for d in desired {
1524 let stored = albums.get(&d.root_id);
1525 for artifact in [
1526 d.folder_jpg.as_ref(),
1527 d.folder_webp.as_ref(),
1528 d.folder_mp4.as_ref(),
1529 ]
1530 .into_iter()
1531 .flatten()
1532 {
1533 let needs_write = needs_write_drift(
1534 stored
1535 .and_then(|a| a.artifact(artifact.kind))
1536 .map(|state| (state.hash.as_str(), state.path.as_str())),
1537 artifact.hash.as_str(),
1538 artifact.path.as_str(),
1539 local,
1540 );
1541 if needs_write {
1542 actions.push(Action::WriteArtifact {
1543 kind: artifact.kind,
1544 path: artifact.path.clone(),
1545 source_url: artifact.source_url.clone(),
1546 hash: artifact.hash.clone(),
1547 owner_id: d.root_id.clone(),
1548 content: None,
1549 });
1550 }
1551 }
1552 }
1553
1554 if can_delete {
1556 for (root_id, art) in albums {
1557 for (kind, state) in album_artifacts(art) {
1558 let desired_here = by_root
1559 .get(root_id.as_str())
1560 .is_some_and(|d| album_desires_kind(d, kind));
1561 if !desired_here && !state.path.is_empty() {
1562 actions.push(Action::DeleteArtifact {
1563 kind,
1564 path: state.path.clone(),
1565 owner_id: root_id.clone(),
1566 });
1567 }
1568 }
1569 }
1570 }
1571
1572 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1573 actions
1574}
1575
1576fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1579 let mut out = Vec::new();
1580 if let Some(state) = &art.folder_jpg {
1581 out.push((ArtifactKind::FolderJpg, state));
1582 }
1583 if let Some(state) = &art.folder_webp {
1584 out.push((ArtifactKind::FolderWebp, state));
1585 }
1586 if let Some(state) = &art.folder_mp4 {
1587 out.push((ArtifactKind::FolderMp4, state));
1588 }
1589 out
1590}
1591
1592fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1594 match kind {
1595 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1596 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1597 ArtifactKind::FolderMp4 => d.folder_mp4.is_some(),
1598 ArtifactKind::CoverJpg
1599 | ArtifactKind::CoverWebp
1600 | ArtifactKind::DetailsTxt
1601 | ArtifactKind::LyricsTxt
1602 | ArtifactKind::Lrc
1603 | ArtifactKind::VideoMp4
1604 | ArtifactKind::Playlist => false,
1605 }
1606}
1607
1608fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1610 match action {
1611 Action::WriteArtifact { owner_id, kind, .. }
1612 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1613 _ => ("", ArtifactKind::CoverJpg),
1614 }
1615}
1616
1617pub fn plan_playlist_artifacts(
1655 desired: &[PlaylistDesired],
1656 stored: &BTreeMap<String, PlaylistState>,
1657 can_delete: bool,
1658 list_fully_enumerated: bool,
1659 local: &HashMap<String, LocalFile>,
1660) -> Vec<Action> {
1661 let mut actions: Vec<Action> = Vec::new();
1662 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1663 let deletes_allowed = can_delete && list_fully_enumerated;
1666
1667 for d in desired {
1668 let stored_here = stored.get(&d.id);
1669 let needs_write = needs_write_drift(
1670 stored_here.map(|state| (state.hash.as_str(), state.path.as_str())),
1671 d.hash.as_str(),
1672 d.path.as_str(),
1673 local,
1674 );
1675 if needs_write {
1676 actions.push(Action::WriteArtifact {
1677 kind: ArtifactKind::Playlist,
1678 path: d.path.clone(),
1679 source_url: String::new(),
1680 hash: d.hash.clone(),
1681 owner_id: d.id.clone(),
1682 content: Some(d.content.clone()),
1683 });
1684 }
1685 if deletes_allowed
1687 && let Some(state) = stored_here
1688 && !state.path.is_empty()
1689 && state.path != d.path
1690 {
1691 actions.push(Action::DeleteArtifact {
1692 kind: ArtifactKind::Playlist,
1693 path: state.path.clone(),
1694 owner_id: d.id.clone(),
1695 });
1696 }
1697 }
1698
1699 if deletes_allowed {
1702 for (id, state) in stored {
1703 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1704 actions.push(Action::DeleteArtifact {
1705 kind: ArtifactKind::Playlist,
1706 path: state.path.clone(),
1707 owner_id: id.clone(),
1708 });
1709 }
1710 }
1711 }
1712
1713 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1714 suppress_path_aliasing(&mut actions);
1717 actions
1718}
1719
1720fn playlist_action_key(action: &Action) -> (&str, u8) {
1723 match action {
1724 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1725 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1726 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1727 _ => ("", 3),
1728 }
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733 use super::*;
1734 use crate::hash::content_hash;
1735
1736 fn clip(id: &str) -> Clip {
1737 Clip {
1738 id: id.to_string(),
1739 title: "Song".to_string(),
1740 ..Default::default()
1741 }
1742 }
1743
1744 fn lineage(id: &str) -> LineageContext {
1745 LineageContext::own_root(&clip(id))
1746 }
1747
1748 fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1749 ManifestEntry {
1750 path: path.to_string(),
1751 format,
1752 meta_hash: meta.to_string(),
1753 art_hash: art.to_string(),
1754 size: 100,
1755 preserve: false,
1756 ..Default::default()
1757 }
1758 }
1759
1760 fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1761 ManifestEntry {
1762 preserve: true,
1763 ..entry(path, format, meta, art)
1764 }
1765 }
1766
1767 fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1768 Desired {
1769 clip: clip(id),
1770 lineage: lineage(id),
1771 path: path.to_string(),
1772 format,
1773 meta_hash: meta.to_string(),
1774 art_hash: art.to_string(),
1775 modes: vec![SourceMode::Mirror],
1776 trashed: false,
1777 private: false,
1778 artifacts: Vec::new(),
1779 stems: None,
1780 }
1781 }
1782
1783 fn present(size: u64) -> LocalFile {
1784 LocalFile { exists: true, size }
1785 }
1786
1787 fn local_present(id: &str) -> HashMap<String, LocalFile> {
1788 [(id.to_string(), present(100))].into_iter().collect()
1789 }
1790
1791 fn mirror_ok() -> Vec<SourceStatus> {
1792 vec![SourceStatus {
1793 mode: SourceMode::Mirror,
1794 fully_enumerated: true,
1795 }]
1796 }
1797
1798 #[test]
1801 fn not_in_manifest_downloads() {
1802 let manifest = Manifest::new();
1803 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1804 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1805 assert_eq!(
1806 plan.actions,
1807 vec![Action::Download {
1808 clip: clip("a"),
1809 lineage: lineage("a"),
1810 path: "a.flac".to_string(),
1811 format: AudioFormat::Flac,
1812 }]
1813 );
1814 }
1815
1816 #[test]
1817 fn unchanged_clip_skips() {
1818 let mut manifest = Manifest::new();
1819 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1820 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1821 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1822 assert_eq!(
1823 plan.actions,
1824 vec![Action::Skip {
1825 clip_id: "a".to_string()
1826 }]
1827 );
1828 }
1829
1830 #[test]
1831 fn meta_change_retags_in_place() {
1832 let mut manifest = Manifest::new();
1833 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1834 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1835 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1836 assert_eq!(
1837 plan.actions,
1838 vec![Action::Retag {
1839 clip: clip("a"),
1840 lineage: lineage("a"),
1841 path: "a.flac".to_string(),
1842 }]
1843 );
1844 }
1845
1846 #[test]
1847 fn art_change_retags_in_place() {
1848 let mut manifest = Manifest::new();
1849 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1850 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1851 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1852 assert_eq!(
1853 plan.actions,
1854 vec![Action::Retag {
1855 clip: clip("a"),
1856 lineage: lineage("a"),
1857 path: "a.flac".to_string(),
1858 }]
1859 );
1860 }
1861
1862 #[test]
1863 fn rename_when_path_changes() {
1864 let mut manifest = Manifest::new();
1865 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1866 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1867 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1868 assert_eq!(
1869 plan.actions,
1870 vec![Action::Rename {
1871 from: "old/a.flac".to_string(),
1872 to: "new/a.flac".to_string(),
1873 }]
1874 );
1875 }
1876
1877 #[test]
1878 fn rename_with_meta_change_also_retags() {
1879 let mut manifest = Manifest::new();
1880 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1881 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1882 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1883 assert_eq!(
1884 plan.actions,
1885 vec![
1886 Action::Rename {
1887 from: "old/a.flac".to_string(),
1888 to: "new/a.flac".to_string(),
1889 },
1890 Action::Retag {
1891 clip: clip("a"),
1892 lineage: lineage("a"),
1893 path: "new/a.flac".to_string(),
1894 },
1895 ]
1896 );
1897 }
1898
1899 #[test]
1900 fn bulk_album_rename_moves_and_retags_without_redownload() {
1901 let mut manifest = Manifest::new();
1906 for id in ["a", "b", "c"] {
1907 manifest.insert(
1908 id,
1909 entry(
1910 &format!("Creator/Old Album/{id}.flac"),
1911 AudioFormat::Flac,
1912 "old-meta",
1913 "art",
1914 ),
1915 );
1916 }
1917 let d: Vec<Desired> = ["a", "b", "c"]
1918 .iter()
1919 .map(|id| {
1920 desired(
1921 id,
1922 &format!("Creator/New Album/{id}.flac"),
1923 AudioFormat::Flac,
1924 "new-meta",
1925 "art",
1926 )
1927 })
1928 .collect();
1929 let local: HashMap<String, LocalFile> = ["a", "b", "c"]
1930 .iter()
1931 .map(|id| (id.to_string(), present(100)))
1932 .collect();
1933
1934 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1935
1936 assert_eq!(plan.renames(), 3, "every member folder move is a rename");
1937 assert_eq!(
1938 plan.retags(),
1939 3,
1940 "the album tag change retags each in place"
1941 );
1942 assert_eq!(
1943 plan.downloads(),
1944 0,
1945 "an album rename must never re-download"
1946 );
1947 assert_eq!(
1948 plan.deletes(),
1949 0,
1950 "deletion safety: a rename deletes nothing"
1951 );
1952 for id in ["a", "b", "c"] {
1953 assert!(plan.actions.contains(&Action::Rename {
1954 from: format!("Creator/Old Album/{id}.flac"),
1955 to: format!("Creator/New Album/{id}.flac"),
1956 }));
1957 }
1958 }
1959
1960 #[test]
1961 fn format_change_reformats() {
1962 let mut manifest = Manifest::new();
1963 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1964 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1965 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1966 assert_eq!(
1967 plan.actions,
1968 vec![Action::Reformat {
1969 clip: clip("a"),
1970 path: "a.mp3".to_string(),
1971 from_path: "a.flac".to_string(),
1972 from: AudioFormat::Flac,
1973 to: AudioFormat::Mp3,
1974 }]
1975 );
1976 }
1977
1978 #[test]
1979 fn format_change_takes_precedence_over_rename_and_retag() {
1980 let mut manifest = Manifest::new();
1983 manifest.insert(
1984 "a",
1985 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1986 );
1987 let d = vec![desired(
1988 "a",
1989 "new/a.mp3",
1990 AudioFormat::Mp3,
1991 "new",
1992 "new-art",
1993 )];
1994 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1995 assert_eq!(plan.reformats(), 1);
1996 assert_eq!(plan.renames(), 0);
1997 assert_eq!(plan.retags(), 0);
1998 }
1999
2000 #[test]
2003 fn zero_length_file_downloads_even_when_hashes_match() {
2004 let mut manifest = Manifest::new();
2005 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2006 let local: HashMap<String, LocalFile> = [(
2007 "a".to_string(),
2008 LocalFile {
2009 exists: true,
2010 size: 0,
2011 },
2012 )]
2013 .into_iter()
2014 .collect();
2015 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2016 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2017 assert_eq!(plan.downloads(), 1);
2018 assert_eq!(plan.skips(), 0);
2019 }
2020
2021 #[test]
2022 fn missing_file_downloads_even_when_hashes_match() {
2023 let mut manifest = Manifest::new();
2024 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2025 let local: HashMap<String, LocalFile> = [(
2026 "a".to_string(),
2027 LocalFile {
2028 exists: false,
2029 size: 0,
2030 },
2031 )]
2032 .into_iter()
2033 .collect();
2034 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2035 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2036 assert_eq!(plan.downloads(), 1);
2037 }
2038
2039 #[test]
2040 fn absent_local_probe_treated_as_missing() {
2041 let mut manifest = Manifest::new();
2043 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2044 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2045 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2046 assert_eq!(plan.downloads(), 1);
2047 }
2048
2049 #[test]
2050 fn missing_file_download_wins_over_format_difference() {
2051 let mut manifest = Manifest::new();
2054 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2055 let local: HashMap<String, LocalFile> = [(
2056 "a".to_string(),
2057 LocalFile {
2058 exists: false,
2059 size: 0,
2060 },
2061 )]
2062 .into_iter()
2063 .collect();
2064 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
2065 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2066 assert_eq!(plan.downloads(), 1);
2067 assert_eq!(plan.reformats(), 0);
2068 }
2069
2070 #[test]
2073 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
2074 let mut trashed = clip("a");
2079 trashed.status = "complete".to_string();
2080 trashed.is_trashed = true;
2081 assert!(crate::is_downloadable(&trashed));
2082
2083 let mut manifest = Manifest::new();
2084 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2085 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2086 d.clip = trashed;
2087 d.trashed = true;
2088 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2089 assert_eq!(
2090 plan.actions,
2091 vec![Action::Delete {
2092 path: "a.flac".to_string(),
2093 clip_id: "a".to_string(),
2094 }]
2095 );
2096 }
2097
2098 #[test]
2099 fn trashed_clip_deletes_local_file() {
2100 let mut manifest = Manifest::new();
2101 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2102 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2103 d.trashed = true;
2104 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2105 assert_eq!(
2106 plan.actions,
2107 vec![Action::Delete {
2108 path: "a.flac".to_string(),
2109 clip_id: "a".to_string(),
2110 }]
2111 );
2112 }
2113
2114 #[test]
2115 fn trashed_clip_not_in_manifest_skips() {
2116 let manifest = Manifest::new();
2118 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2119 d.trashed = true;
2120 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2121 assert_eq!(
2122 plan.actions,
2123 vec![Action::Skip {
2124 clip_id: "a".to_string()
2125 }]
2126 );
2127 }
2128
2129 #[test]
2130 fn private_clip_is_kept() {
2131 let mut manifest = Manifest::new();
2132 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2133 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2134 d.private = true;
2135 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2136 assert_eq!(
2137 plan.actions,
2138 vec![Action::Skip {
2139 clip_id: "a".to_string()
2140 }]
2141 );
2142 }
2143
2144 #[test]
2145 fn private_beats_trashed_never_deletes() {
2146 let mut manifest = Manifest::new();
2148 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2149 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2150 d.trashed = true;
2151 d.private = true;
2152 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2153 assert_eq!(plan.deletes(), 0);
2154 assert_eq!(plan.skips(), 1);
2155 }
2156
2157 #[test]
2158 fn copy_held_trashed_clip_is_not_deleted() {
2159 let mut manifest = Manifest::new();
2162 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2163 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2164 d.modes = vec![SourceMode::Copy];
2165 d.trashed = true;
2166 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2167 assert_eq!(plan.deletes(), 0);
2168 assert_eq!(
2169 plan.actions,
2170 vec![Action::Skip {
2171 clip_id: "a".to_string()
2172 }]
2173 );
2174 }
2175
2176 #[test]
2179 fn absent_clip_deleted_when_all_mirrors_enumerated() {
2180 let mut manifest = Manifest::new();
2181 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2182 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2183 assert_eq!(
2184 plan.actions,
2185 vec![Action::Delete {
2186 path: "gone.flac".to_string(),
2187 clip_id: "gone".to_string(),
2188 }]
2189 );
2190 }
2191
2192 #[test]
2193 fn absent_clip_kept_when_any_mirror_not_enumerated() {
2194 let mut manifest = Manifest::new();
2195 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2196 let sources = vec![
2197 SourceStatus {
2198 mode: SourceMode::Mirror,
2199 fully_enumerated: true,
2200 },
2201 SourceStatus {
2202 mode: SourceMode::Mirror,
2203 fully_enumerated: false,
2204 },
2205 ];
2206 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2207 assert_eq!(plan.deletes(), 0);
2208 assert_eq!(
2209 plan.actions,
2210 vec![Action::Skip {
2211 clip_id: "gone".to_string()
2212 }]
2213 );
2214 }
2215
2216 #[test]
2217 fn empty_listing_cannot_cause_deletion() {
2218 let mut manifest = Manifest::new();
2221 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2222 let sources = vec![SourceStatus {
2223 mode: SourceMode::Mirror,
2224 fully_enumerated: false,
2225 }];
2226 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2227 assert_eq!(plan.deletes(), 0);
2228 assert_eq!(plan.skips(), 1);
2229 }
2230
2231 #[test]
2232 fn no_mirror_sources_means_no_deletion() {
2233 let mut manifest = Manifest::new();
2235 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2236 let copy_only = vec![SourceStatus {
2237 mode: SourceMode::Copy,
2238 fully_enumerated: true,
2239 }];
2240 assert_eq!(
2241 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
2242 0
2243 );
2244 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
2245 }
2246
2247 #[test]
2248 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
2249 let mut manifest = Manifest::new();
2250 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2251 let sources = vec![
2252 SourceStatus {
2253 mode: SourceMode::Copy,
2254 fully_enumerated: true,
2255 },
2256 SourceStatus {
2257 mode: SourceMode::Mirror,
2258 fully_enumerated: false,
2259 },
2260 ];
2261 assert_eq!(
2262 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
2263 0
2264 );
2265 }
2266
2267 #[test]
2268 fn playlist_authoritative_requires_all_conditions() {
2269 assert!(playlist_authoritative(true, false, false));
2271 assert!(!playlist_authoritative(false, false, false));
2273 assert!(!playlist_authoritative(true, true, false));
2275 assert!(!playlist_authoritative(true, false, true));
2277 assert!(!playlist_authoritative(false, true, true));
2279 }
2280
2281 #[test]
2282 fn area_fully_enumerated_applies_empty_mirror_guard() {
2283 assert!(area_fully_enumerated(true, false, SourceMode::Mirror));
2285 assert!(!area_fully_enumerated(true, true, SourceMode::Mirror));
2287 assert!(area_fully_enumerated(true, true, SourceMode::Copy));
2289 assert!(area_fully_enumerated(true, false, SourceMode::Copy));
2291 assert!(!area_fully_enumerated(false, false, SourceMode::Mirror));
2293 assert!(!area_fully_enumerated(false, true, SourceMode::Copy));
2294 }
2295
2296 #[test]
2297 fn narrows_downloads_only_when_no_deletion_and_no_full_library() {
2298 assert!(narrows_downloads(false, false));
2300 assert!(!narrows_downloads(true, false));
2302 assert!(!narrows_downloads(false, true));
2304 assert!(!narrows_downloads(true, true));
2306 }
2307
2308 #[test]
2309 fn narrowing_never_coexists_with_deletion() {
2310 for can_delete in [false, true] {
2311 for lib_auth in [false, true] {
2312 assert!(
2313 !(narrows_downloads(can_delete, lib_auth) && can_delete),
2314 "truncate must imply !can_delete"
2315 );
2316 }
2317 }
2318 }
2319
2320 #[test]
2321 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
2322 let mut manifest = Manifest::new();
2326 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
2327 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2328 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
2329 held.modes = vec![SourceMode::Copy];
2330 let local: HashMap<String, LocalFile> = [
2331 ("keep".to_string(), present(100)),
2332 ("gone".to_string(), present(100)),
2333 ]
2334 .into_iter()
2335 .collect();
2336 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
2337 assert!(plan.actions.contains(&Action::Skip {
2338 clip_id: "keep".to_string()
2339 }));
2340 assert!(plan.actions.contains(&Action::Delete {
2341 path: "gone.flac".to_string(),
2342 clip_id: "gone".to_string(),
2343 }));
2344 assert!(
2346 !plan
2347 .actions
2348 .iter()
2349 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
2350 );
2351 }
2352
2353 #[test]
2356 fn orphan_with_preserve_marker_is_kept() {
2357 let mut manifest = Manifest::new();
2360 manifest.insert(
2361 "gone",
2362 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
2363 );
2364 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2365 assert_eq!(plan.deletes(), 0);
2366 assert_eq!(
2367 plan.actions,
2368 vec![Action::Skip {
2369 clip_id: "gone".to_string()
2370 }]
2371 );
2372 }
2373
2374 #[test]
2375 fn trashed_clip_with_preserve_marker_is_kept() {
2376 let mut manifest = Manifest::new();
2379 manifest.insert(
2380 "a",
2381 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2382 );
2383 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2384 d.trashed = true;
2385 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2386 assert_eq!(plan.deletes(), 0);
2387 assert_eq!(plan.skips(), 1);
2388 }
2389
2390 #[test]
2393 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
2394 let mut manifest = Manifest::new();
2396 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2397 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2398 d.trashed = true;
2399 let sources = vec![SourceStatus {
2400 mode: SourceMode::Mirror,
2401 fully_enumerated: false,
2402 }];
2403 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2404 assert_eq!(plan.deletes(), 0);
2405 assert_eq!(plan.skips(), 1);
2406 }
2407
2408 #[test]
2409 fn trashed_clip_kept_when_sources_empty() {
2410 let mut manifest = Manifest::new();
2413 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2414 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2415 d.trashed = true;
2416 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
2417 assert_eq!(plan.deletes(), 0);
2418 assert_eq!(plan.skips(), 1);
2419 }
2420
2421 #[test]
2422 fn failed_copy_listing_suppresses_orphan_deletion() {
2423 let mut manifest = Manifest::new();
2426 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2427 let sources = vec![
2428 SourceStatus {
2429 mode: SourceMode::Mirror,
2430 fully_enumerated: true,
2431 },
2432 SourceStatus {
2433 mode: SourceMode::Copy,
2434 fully_enumerated: false,
2435 },
2436 ];
2437 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2438 assert_eq!(plan.deletes(), 0);
2439 }
2440
2441 #[test]
2442 fn failed_copy_listing_suppresses_trashed_deletion() {
2443 let mut manifest = Manifest::new();
2444 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2445 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2446 d.trashed = true;
2447 let sources = vec![
2448 SourceStatus {
2449 mode: SourceMode::Mirror,
2450 fully_enumerated: true,
2451 },
2452 SourceStatus {
2453 mode: SourceMode::Copy,
2454 fully_enumerated: false,
2455 },
2456 ];
2457 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2458 assert_eq!(plan.deletes(), 0);
2459 assert_eq!(plan.skips(), 1);
2460 }
2461
2462 #[test]
2463 fn empty_path_entry_never_deletes() {
2464 let mut manifest = Manifest::new();
2467 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
2468 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2469 assert_eq!(plan.deletes(), 0);
2470 assert_eq!(
2471 plan.actions,
2472 vec![Action::Skip {
2473 clip_id: "gone".to_string()
2474 }]
2475 );
2476 }
2477
2478 #[test]
2481 fn delete_suppressed_when_path_aliases_rename_target() {
2482 let mut manifest = Manifest::new();
2485 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
2486 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
2487 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
2488 let local: HashMap<String, LocalFile> = [
2489 ("a".to_string(), present(100)),
2490 ("b".to_string(), present(100)),
2491 ]
2492 .into_iter()
2493 .collect();
2494 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2495 assert!(plan.actions.contains(&Action::Rename {
2496 from: "old/a.flac".to_string(),
2497 to: "new/a.flac".to_string(),
2498 }));
2499 assert!(
2501 !plan
2502 .actions
2503 .iter()
2504 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
2505 );
2506 assert!(plan.actions.contains(&Action::Skip {
2507 clip_id: "b".to_string()
2508 }));
2509 }
2510
2511 #[test]
2512 fn delete_suppressed_when_path_aliases_download_target() {
2513 let mut manifest = Manifest::new();
2515 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
2516 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
2517 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2518 assert!(
2519 !plan
2520 .actions
2521 .iter()
2522 .any(|a| matches!(a, Action::Delete { .. }))
2523 );
2524 assert_eq!(plan.downloads(), 1);
2525 }
2526
2527 #[test]
2528 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
2529 let mut actions = vec![
2534 Action::Rename {
2535 from: "old/song.flac".to_string(),
2536 to: "new/cover.jpg".to_string(),
2537 },
2538 Action::DeleteArtifact {
2539 kind: ArtifactKind::CoverJpg,
2540 path: "new/cover.jpg".to_string(),
2541 owner_id: "a".to_string(),
2542 },
2543 ];
2544 suppress_path_aliasing(&mut actions);
2545 assert!(
2547 !actions
2548 .iter()
2549 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
2550 "a sidecar delete must not alias a rename target"
2551 );
2552 assert!(actions.contains(&Action::Skip {
2553 clip_id: "a".to_string()
2554 }));
2555 assert!(actions.contains(&Action::Rename {
2557 from: "old/song.flac".to_string(),
2558 to: "new/cover.jpg".to_string(),
2559 }));
2560 }
2561
2562 #[test]
2563 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
2564 let mut actions = vec![
2567 Action::WriteArtifact {
2568 kind: ArtifactKind::FolderJpg,
2569 path: "creator/album/folder.jpg".to_string(),
2570 source_url: "https://art/large.jpg".to_string(),
2571 hash: "h".to_string(),
2572 owner_id: "root".to_string(),
2573 content: None,
2574 },
2575 Action::DeleteArtifact {
2576 kind: ArtifactKind::FolderJpg,
2577 path: "creator/album/folder.jpg".to_string(),
2578 owner_id: "root-old".to_string(),
2579 },
2580 ];
2581 suppress_path_aliasing(&mut actions);
2582 assert!(
2583 !actions
2584 .iter()
2585 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
2586 );
2587 assert!(actions.contains(&Action::Skip {
2588 clip_id: "root-old".to_string()
2589 }));
2590 }
2591
2592 #[test]
2595 fn duplicate_trashed_does_not_defeat_copy_sibling() {
2596 let mut manifest = Manifest::new();
2599 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2600 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2601 copy_entry.modes = vec![SourceMode::Copy];
2602 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2603 trashed_entry.modes = vec![SourceMode::Mirror];
2604 trashed_entry.trashed = true;
2605 let plan = reconcile(
2606 &manifest,
2607 &[copy_entry, trashed_entry],
2608 &local_present("a"),
2609 &mirror_ok(),
2610 );
2611 assert_eq!(plan.deletes(), 0);
2612 assert_eq!(plan.skips(), 1);
2613 }
2614
2615 #[test]
2616 fn duplicate_trashed_does_not_defeat_private_sibling() {
2617 let mut manifest = Manifest::new();
2618 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2619 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2620 private_entry.private = true;
2621 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2622 trashed_entry.trashed = true;
2623 let plan = reconcile(
2624 &manifest,
2625 &[private_entry, trashed_entry],
2626 &local_present("a"),
2627 &mirror_ok(),
2628 );
2629 assert_eq!(plan.deletes(), 0);
2630 assert_eq!(plan.skips(), 1);
2631 }
2632
2633 #[test]
2634 fn duplicate_trashed_deletes_only_when_all_trashed() {
2635 let mut manifest = Manifest::new();
2637 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2638 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2639 first.trashed = true;
2640 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2641 second.trashed = true;
2642 let plan = reconcile(
2643 &manifest,
2644 &[first, second],
2645 &local_present("a"),
2646 &mirror_ok(),
2647 );
2648 assert_eq!(plan.deletes(), 1);
2649 }
2650
2651 #[test]
2652 fn duplicate_desired_unions_modes() {
2653 let mut manifest = Manifest::new();
2655 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2656 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2657 mirror_entry.modes = vec![SourceMode::Mirror];
2658 mirror_entry.trashed = true;
2659 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2660 copy_entry.modes = vec![SourceMode::Copy];
2661 let plan = reconcile(
2662 &manifest,
2663 &[mirror_entry, copy_entry],
2664 &local_present("a"),
2665 &mirror_ok(),
2666 );
2667 assert_eq!(plan.deletes(), 0);
2669 }
2670
2671 #[test]
2674 fn private_new_clip_downloads() {
2675 let manifest = Manifest::new();
2678 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2679 d.private = true;
2680 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2681 assert_eq!(plan.downloads(), 1);
2682 }
2683
2684 #[test]
2685 fn private_zero_length_file_redownloads() {
2686 let mut manifest = Manifest::new();
2687 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2688 let local: HashMap<String, LocalFile> = [(
2689 "a".to_string(),
2690 LocalFile {
2691 exists: true,
2692 size: 0,
2693 },
2694 )]
2695 .into_iter()
2696 .collect();
2697 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2698 d.private = true;
2699 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2700 assert_eq!(plan.downloads(), 1);
2701 }
2702
2703 #[test]
2704 fn private_meta_change_retags() {
2705 let mut manifest = Manifest::new();
2706 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2707 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2708 d.private = true;
2709 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2710 assert_eq!(plan.retags(), 1);
2711 assert_eq!(plan.deletes(), 0);
2712 }
2713
2714 #[test]
2715 fn absent_private_clip_protected_by_preserve_marker() {
2716 let mut manifest = Manifest::new();
2719 manifest.insert(
2720 "a",
2721 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2722 );
2723 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2724 assert_eq!(plan.deletes(), 0);
2725 assert_eq!(plan.skips(), 1);
2726 }
2727
2728 #[test]
2731 fn output_is_deterministic_regardless_of_input_order() {
2732 let mut manifest = Manifest::new();
2733 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2734 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2735 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2736 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2737 .iter()
2738 .map(|id| (id.to_string(), present(100)))
2739 .collect();
2740
2741 let forward = vec![
2742 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2743 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2744 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2745 ];
2746 let mut reversed = forward.clone();
2747 reversed.reverse();
2748
2749 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2750 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2751 assert_eq!(p1.actions, p2.actions);
2752
2753 let ids: Vec<&str> = p1
2756 .actions
2757 .iter()
2758 .map(|a| match a {
2759 Action::Skip { clip_id } => clip_id.as_str(),
2760 Action::Retag { clip, .. } => clip.id.as_str(),
2761 Action::Download { clip, .. } => clip.id.as_str(),
2762 Action::Delete { clip_id, .. } => clip_id.as_str(),
2763 Action::Reformat { clip, .. } => clip.id.as_str(),
2764 Action::Rename { to, .. } => to.as_str(),
2765 Action::WriteArtifact { owner_id, .. }
2766 | Action::DeleteArtifact { owner_id, .. }
2767 | Action::MoveArtifact { owner_id, .. } => owner_id.as_str(),
2768 Action::WriteStem { clip_id, .. }
2769 | Action::DeleteStem { clip_id, .. }
2770 | Action::MoveStem { clip_id, .. } => clip_id.as_str(),
2771 })
2772 .collect();
2773 assert_eq!(ids, ["a", "b", "c", "z"]);
2774 }
2775
2776 #[test]
2777 fn empty_inputs_do_not_panic() {
2778 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2779 assert!(plan.is_empty());
2780 assert_eq!(plan.len(), 0);
2781 }
2782
2783 #[test]
2784 fn empty_desired_with_full_manifest_deletes_all() {
2785 let mut manifest = Manifest::new();
2786 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2787 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2788 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2789 assert_eq!(plan.deletes(), 2);
2790 }
2791
2792 #[test]
2793 fn full_desired_with_empty_manifest_downloads_all() {
2794 let d = vec![
2795 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2796 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2797 ];
2798 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2799 assert_eq!(plan.downloads(), 2);
2800 }
2801
2802 #[test]
2803 fn plan_counts_sum_to_len() {
2804 let mut manifest = Manifest::new();
2805 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2806 manifest.insert(
2807 "retag",
2808 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2809 );
2810 manifest.insert(
2811 "reformat",
2812 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2813 );
2814 manifest.insert(
2815 "rename",
2816 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2817 );
2818 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2819 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2820 .iter()
2821 .map(|id| (id.to_string(), present(100)))
2822 .collect();
2823 let d = vec![
2824 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2825 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2826 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2827 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2828 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2829 ];
2830 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2831 let summed = plan.downloads()
2832 + plan.reformats()
2833 + plan.retags()
2834 + plan.renames()
2835 + plan.deletes()
2836 + plan.skips();
2837 assert_eq!(summed, plan.len());
2838 assert_eq!(plan.downloads(), 1);
2839 assert_eq!(plan.reformats(), 1);
2840 assert_eq!(plan.retags(), 1);
2841 assert_eq!(plan.renames(), 1);
2842 assert_eq!(plan.deletes(), 1);
2843 assert_eq!(plan.skips(), 1);
2844 }
2845
2846 fn cover(path: &str, hash: &str) -> ArtifactState {
2849 ArtifactState {
2850 path: path.to_string(),
2851 hash: hash.to_string(),
2852 }
2853 }
2854
2855 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2856 DesiredArtifact {
2857 kind,
2858 path: path.to_string(),
2859 source_url: url.to_string(),
2860 hash: hash.to_string(),
2861 content: None,
2862 }
2863 }
2864
2865 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2867 DesiredArtifact {
2868 kind,
2869 path: path.to_string(),
2870 source_url: String::new(),
2871 hash: content_hash(body),
2872 content: Some(body.to_string()),
2873 }
2874 }
2875
2876 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2878 Desired {
2879 artifacts: arts,
2880 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2881 }
2882 }
2883
2884 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2886 ManifestEntry {
2887 cover_jpg: Some(cover(cover_path, cover_hash)),
2888 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2889 }
2890 }
2891
2892 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2893 plan.actions
2894 .iter()
2895 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2896 .collect()
2897 }
2898
2899 #[test]
2900 fn write_artifact_emitted_when_manifest_lacks_it() {
2901 let mut manifest = Manifest::new();
2904 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2905 let d = vec![desired_arts(
2906 "a",
2907 vec![art(
2908 ArtifactKind::CoverJpg,
2909 "a/cover.jpg",
2910 "https://art/a",
2911 "h1",
2912 )],
2913 )];
2914 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2915 assert_eq!(plan.artifact_writes(), 1);
2916 assert_eq!(plan.artifact_deletes(), 0);
2917 assert_eq!(plan.skips(), 1);
2918 assert_eq!(
2919 write_artifacts(&plan)[0],
2920 &Action::WriteArtifact {
2921 kind: ArtifactKind::CoverJpg,
2922 path: "a/cover.jpg".to_string(),
2923 source_url: "https://art/a".to_string(),
2924 hash: "h1".to_string(),
2925 owner_id: "a".to_string(),
2926 content: None,
2927 }
2928 );
2929 }
2930
2931 #[test]
2932 fn write_artifact_emitted_when_hash_differs() {
2933 let mut manifest = Manifest::new();
2936 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2937 let d = vec![desired_arts(
2938 "a",
2939 vec![art(
2940 ArtifactKind::CoverJpg,
2941 "a/cover.jpg",
2942 "https://art/a",
2943 "new",
2944 )],
2945 )];
2946 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2947 assert_eq!(plan.artifact_writes(), 1);
2948 assert_eq!(plan.artifact_deletes(), 0);
2949 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2950 assert_eq!(hash, "new");
2951 } else {
2952 panic!("expected a WriteArtifact");
2953 }
2954 }
2955
2956 #[test]
2957 fn write_artifact_skipped_when_hash_matches() {
2958 let mut manifest = Manifest::new();
2960 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2961 let d = vec![desired_arts(
2962 "a",
2963 vec![art(
2964 ArtifactKind::CoverJpg,
2965 "a/cover.jpg",
2966 "https://art/a",
2967 "h1",
2968 )],
2969 )];
2970 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2971 assert_eq!(plan.artifact_writes(), 0);
2972 assert_eq!(plan.artifact_deletes(), 0);
2973 assert_eq!(
2974 plan.actions,
2975 vec![Action::Skip {
2976 clip_id: "a".to_string()
2977 }]
2978 );
2979 }
2980
2981 #[test]
2982 fn removed_kind_cover_is_kept_not_deleted() {
2983 let mut manifest = Manifest::new();
2988 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2989 let d = vec![desired_arts("a", vec![])];
2990 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2991 assert_eq!(plan.artifact_deletes(), 0);
2992 assert_eq!(plan.artifact_writes(), 0);
2993 assert_eq!(plan.deletes(), 0);
2995 assert_eq!(
2996 plan.actions,
2997 vec![Action::Skip {
2998 clip_id: "a".to_string()
2999 }]
3000 );
3001 assert!(!plan.actions.iter().any(|a| matches!(
3002 a,
3003 Action::DeleteArtifact {
3004 kind: ArtifactKind::CoverJpg,
3005 ..
3006 }
3007 )));
3008 }
3009
3010 #[test]
3011 fn delete_artifact_never_on_incomplete_listing() {
3012 let mut manifest = Manifest::new();
3017 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3018 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
3019 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
3020 let sources = vec![SourceStatus {
3021 mode: SourceMode::Mirror,
3022 fully_enumerated: false,
3023 }];
3024 let local: HashMap<String, LocalFile> = [
3025 ("a".to_string(), present(100)),
3026 ("b".to_string(), present(100)),
3027 ]
3028 .into_iter()
3029 .collect();
3030 let plan = reconcile(&manifest, &d, &local, &sources);
3031 assert_eq!(plan.artifact_deletes(), 0);
3032 assert_eq!(plan.deletes(), 0);
3033 }
3034
3035 #[test]
3036 fn delete_artifact_never_when_entry_preserved() {
3037 let mut manifest = Manifest::new();
3040 let preserved = ManifestEntry {
3041 preserve: true,
3042 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
3043 };
3044 manifest.insert("a", preserved);
3045 let d = vec![desired_arts("a", vec![])];
3046 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3047 assert_eq!(plan.artifact_deletes(), 0);
3048 }
3049
3050 #[test]
3051 fn co_delete_never_when_path_empty() {
3052 let mut manifest = Manifest::new();
3056 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
3057 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3058 assert_eq!(plan.deletes(), 1);
3059 assert_eq!(plan.artifact_deletes(), 0);
3060 }
3061
3062 #[test]
3063 fn co_delete_absent_clip_deletes_audio_and_cover() {
3064 let mut manifest = Manifest::new();
3067 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3068 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3069 assert_eq!(plan.deletes(), 1);
3070 assert_eq!(plan.artifact_deletes(), 1);
3071 assert!(plan.actions.contains(&Action::Delete {
3072 path: "gone.flac".to_string(),
3073 clip_id: "gone".to_string(),
3074 }));
3075 assert!(plan.actions.contains(&Action::DeleteArtifact {
3076 kind: ArtifactKind::CoverJpg,
3077 path: "gone/cover.jpg".to_string(),
3078 owner_id: "gone".to_string(),
3079 }));
3080 }
3081
3082 #[test]
3083 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
3084 let mut manifest = Manifest::new();
3086 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3087 let sources = vec![SourceStatus {
3088 mode: SourceMode::Mirror,
3089 fully_enumerated: false,
3090 }];
3091 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
3092 assert_eq!(plan.deletes(), 0);
3093 assert_eq!(plan.artifact_deletes(), 0);
3094 }
3095
3096 #[test]
3097 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
3098 let mut manifest = Manifest::new();
3100 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3101 let mut d = desired_arts("a", vec![]);
3102 d.trashed = true;
3103 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3104 assert_eq!(plan.deletes(), 1);
3105 assert_eq!(plan.artifact_deletes(), 1);
3106 }
3107
3108 #[test]
3109 fn co_delete_trashed_suppressed_when_not_enumerated() {
3110 let mut manifest = Manifest::new();
3112 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3113 let mut d = desired_arts("a", vec![]);
3114 d.trashed = true;
3115 let sources = vec![SourceStatus {
3116 mode: SourceMode::Mirror,
3117 fully_enumerated: false,
3118 }];
3119 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
3120 assert_eq!(plan.deletes(), 0);
3121 assert_eq!(plan.artifact_deletes(), 0);
3122 assert_eq!(plan.skips(), 1);
3123 }
3124
3125 #[test]
3126 fn co_delete_trashed_suppressed_when_preserved() {
3127 let mut manifest = Manifest::new();
3129 let preserved = ManifestEntry {
3130 preserve: true,
3131 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
3132 };
3133 manifest.insert("a", preserved);
3134 let mut d = desired_arts("a", vec![]);
3135 d.trashed = true;
3136 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3137 assert_eq!(plan.deletes(), 0);
3138 assert_eq!(plan.artifact_deletes(), 0);
3139 }
3140
3141 #[test]
3144 fn details_sidecar_written_with_inline_content_when_slot_absent() {
3145 let mut manifest = Manifest::new();
3148 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3149 let d = vec![desired_arts(
3150 "a",
3151 vec![text_art(
3152 ArtifactKind::DetailsTxt,
3153 "a.details.txt",
3154 "Title: A\n",
3155 )],
3156 )];
3157 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3158 assert_eq!(plan.artifact_writes(), 1);
3159 assert_eq!(plan.artifact_deletes(), 0);
3160 assert_eq!(
3161 write_artifacts(&plan)[0],
3162 &Action::WriteArtifact {
3163 kind: ArtifactKind::DetailsTxt,
3164 path: "a.details.txt".to_string(),
3165 source_url: String::new(),
3166 hash: content_hash("Title: A\n"),
3167 owner_id: "a".to_string(),
3168 content: Some("Title: A\n".to_string()),
3169 }
3170 );
3171 }
3172
3173 #[test]
3174 fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
3175 let mut manifest = Manifest::new();
3180 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3181 let body = "[re:rs-suno]\nla la\n";
3182 let d = vec![desired_arts(
3183 "a",
3184 vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
3185 )];
3186 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3187 assert_eq!(plan.artifact_writes(), 1);
3188 assert_eq!(plan.artifact_deletes(), 0);
3189 assert_eq!(
3190 write_artifacts(&plan)[0],
3191 &Action::WriteArtifact {
3192 kind: ArtifactKind::Lrc,
3193 path: "a.lrc".to_string(),
3194 source_url: String::new(),
3195 hash: content_hash(body),
3196 owner_id: "a".to_string(),
3197 content: Some(body.to_string()),
3198 }
3199 );
3200 }
3201
3202 #[test]
3203 fn text_sidecars_skipped_when_hash_and_path_match() {
3204 let mut manifest = Manifest::new();
3206 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3207 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3208 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
3209 manifest.insert("a", e);
3210 let d = vec![desired_arts(
3211 "a",
3212 vec![
3213 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
3214 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
3215 ],
3216 )];
3217 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3218 assert_eq!(plan.artifact_writes(), 0);
3219 assert_eq!(plan.artifact_deletes(), 0);
3220 }
3221
3222 #[test]
3223 fn details_rewritten_when_content_hash_differs() {
3224 let mut manifest = Manifest::new();
3227 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3228 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
3229 manifest.insert("a", e);
3230 let d = vec![desired_arts(
3231 "a",
3232 vec![text_art(
3233 ArtifactKind::DetailsTxt,
3234 "a.details.txt",
3235 "Title: New\n",
3236 )],
3237 )];
3238 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3239 assert_eq!(plan.artifact_writes(), 1);
3240 assert_eq!(plan.artifact_deletes(), 0);
3241 }
3242
3243 #[test]
3244 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
3245 let mut manifest = Manifest::new();
3249 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3250 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
3251 manifest.insert("a", e);
3252 let d = vec![desired_arts(
3253 "a",
3254 vec![text_art(
3255 ArtifactKind::LyricsTxt,
3256 "a.lyrics.txt",
3257 "new words\n",
3258 )],
3259 )];
3260 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3261 assert_eq!(plan.artifact_writes(), 1);
3263 assert_eq!(plan.retags(), 0);
3264 }
3265
3266 #[test]
3267 fn text_sidecar_relocated_when_path_differs() {
3268 let mut manifest = Manifest::new();
3271 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3272 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
3273 manifest.insert("a", e);
3274 let d = vec![desired_arts(
3275 "a",
3276 vec![text_art(
3277 ArtifactKind::DetailsTxt,
3278 "new/a.details.txt",
3279 "Title: A\n",
3280 )],
3281 )];
3282 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3283 assert_eq!(plan.artifact_writes(), 1);
3284 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3285 assert_eq!(path, "new/a.details.txt");
3286 } else {
3287 panic!("expected a WriteArtifact");
3288 }
3289 }
3290
3291 #[test]
3292 fn fetched_sidecar_path_drift_emits_move() {
3293 let mut manifest = Manifest::new();
3296 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3297 e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3298 manifest.insert("a", e);
3299 let d = vec![desired_arts(
3300 "a",
3301 vec![art(
3302 ArtifactKind::CoverJpg,
3303 "new/cover.jpg",
3304 "https://art/large.jpg",
3305 "arthash",
3306 )],
3307 )];
3308 let local: HashMap<String, LocalFile> = [
3309 ("a".to_string(), present(100)),
3310 ("old/cover.jpg".to_string(), present(50)),
3311 ]
3312 .into_iter()
3313 .collect();
3314 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3315 assert_eq!(plan.artifact_moves(), 1);
3316 assert_eq!(plan.artifact_writes(), 0);
3317 assert!(plan.actions.contains(&Action::MoveArtifact {
3318 kind: ArtifactKind::CoverJpg,
3319 from: "old/cover.jpg".to_string(),
3320 to: "new/cover.jpg".to_string(),
3321 source_url: "https://art/large.jpg".to_string(),
3322 hash: "arthash".to_string(),
3323 owner_id: "a".to_string(),
3324 }));
3325 }
3326
3327 #[test]
3328 fn sidecar_hash_drift_emits_write_not_move() {
3329 let mut manifest = Manifest::new();
3331 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3332 e.cover_jpg = Some(cover("old/cover.jpg", "oldhash"));
3333 manifest.insert("a", e);
3334 let d = vec![desired_arts(
3335 "a",
3336 vec![art(
3337 ArtifactKind::CoverJpg,
3338 "new/cover.jpg",
3339 "https://art/large.jpg",
3340 "newhash",
3341 )],
3342 )];
3343 let local: HashMap<String, LocalFile> = [
3344 ("a".to_string(), present(100)),
3345 ("old/cover.jpg".to_string(), present(50)),
3346 ]
3347 .into_iter()
3348 .collect();
3349 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3350 assert_eq!(plan.artifact_moves(), 0);
3351 assert_eq!(plan.artifact_writes(), 1);
3352 }
3353
3354 #[test]
3355 fn inline_sidecar_path_drift_stays_a_write() {
3356 let mut manifest = Manifest::new();
3359 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3360 e.lyrics_txt = Some(cover("old/a.lyrics.txt", &content_hash("words\n")));
3361 manifest.insert("a", e);
3362 let d = vec![desired_arts(
3363 "a",
3364 vec![text_art(
3365 ArtifactKind::LyricsTxt,
3366 "new/a.lyrics.txt",
3367 "words\n",
3368 )],
3369 )];
3370 let local: HashMap<String, LocalFile> = [
3371 ("a".to_string(), present(100)),
3372 ("old/a.lyrics.txt".to_string(), present(50)),
3373 ]
3374 .into_iter()
3375 .collect();
3376 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3377 assert_eq!(plan.artifact_moves(), 0);
3378 assert_eq!(plan.artifact_writes(), 1);
3379 }
3380
3381 #[test]
3382 fn sidecar_move_downgrades_to_write_when_old_file_absent() {
3383 let mut manifest = Manifest::new();
3386 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3387 e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3388 manifest.insert("a", e);
3389 let d = vec![desired_arts(
3390 "a",
3391 vec![art(
3392 ArtifactKind::CoverJpg,
3393 "new/cover.jpg",
3394 "https://art/large.jpg",
3395 "arthash",
3396 )],
3397 )];
3398 let local: HashMap<String, LocalFile> = [
3399 ("a".to_string(), present(100)),
3400 (
3401 "old/cover.jpg".to_string(),
3402 LocalFile {
3403 exists: false,
3404 size: 0,
3405 },
3406 ),
3407 ]
3408 .into_iter()
3409 .collect();
3410 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3411 assert_eq!(plan.artifact_moves(), 0);
3412 assert_eq!(plan.artifact_writes(), 1);
3413 }
3414
3415 #[test]
3416 fn move_target_suppresses_a_colliding_delete() {
3417 let mut manifest = Manifest::new();
3420 let mut a = entry("a.flac", AudioFormat::Flac, "m", "art");
3421 a.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3422 manifest.insert("a", a);
3423 let mut b = entry("b.flac", AudioFormat::Flac, "m", "art");
3426 b.details_txt = Some(cover("new/cover.jpg", "bh"));
3427 manifest.insert("b", b);
3428 let d = vec![
3429 desired_arts(
3430 "a",
3431 vec![art(
3432 ArtifactKind::CoverJpg,
3433 "new/cover.jpg",
3434 "https://art/large.jpg",
3435 "arthash",
3436 )],
3437 ),
3438 desired_arts("b", vec![]),
3439 ];
3440 let local: HashMap<String, LocalFile> = [
3441 ("a".to_string(), present(100)),
3442 ("b".to_string(), present(100)),
3443 ("old/cover.jpg".to_string(), present(50)),
3444 ("new/cover.jpg".to_string(), present(50)),
3445 ]
3446 .into_iter()
3447 .collect();
3448 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3449 assert_eq!(plan.artifact_moves(), 1);
3450 assert!(!plan.actions.iter().any(|a| matches!(
3452 a,
3453 Action::DeleteArtifact { path, .. } if path == "new/cover.jpg"
3454 )));
3455 }
3456
3457 #[test]
3458 fn stem_path_drift_emits_move() {
3459 let mut manifest = Manifest::new();
3462 manifest.insert(
3463 "a",
3464 entry_with_stems("a", &[("voc", "old.stems/voc.mp3", "h1")]),
3465 );
3466 let d = vec![stem_desired(
3467 "a",
3468 Some(vec![dstem("voc", "new.stems/voc.mp3", "h1")]),
3469 )];
3470 let local: HashMap<String, LocalFile> = [
3471 ("a".to_string(), present(100)),
3472 ("old.stems/voc.mp3".to_string(), present(50)),
3473 ]
3474 .into_iter()
3475 .collect();
3476 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3477 assert_eq!(plan.stem_moves(), 1);
3478 assert_eq!(plan.stem_writes(), 0);
3479 assert!(plan.actions.contains(&Action::MoveStem {
3480 clip_id: "a".to_string(),
3481 key: "voc".to_string(),
3482 stem_id: "voc".to_string(),
3483 from: "old.stems/voc.mp3".to_string(),
3484 to: "new.stems/voc.mp3".to_string(),
3485 source_url: "https://cdn1.suno.ai/voc.mp3".to_string(),
3486 format: StemFormat::Mp3,
3487 hash: "h1".to_string(),
3488 }));
3489 }
3490
3491 #[test]
3492 fn details_removed_kind_is_deleted_when_feature_off() {
3493 let mut manifest = Manifest::new();
3496 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3497 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3498 manifest.insert("a", e);
3499 let d = vec![desired_arts("a", vec![])];
3500 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3501 assert_eq!(plan.artifact_deletes(), 1);
3502 assert!(plan.actions.contains(&Action::DeleteArtifact {
3503 kind: ArtifactKind::DetailsTxt,
3504 path: "a.details.txt".to_string(),
3505 owner_id: "a".to_string(),
3506 }));
3507 }
3508
3509 #[test]
3510 fn lyrics_removed_kind_is_kept_not_deleted() {
3511 let mut manifest = Manifest::new();
3515 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3516 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3517 manifest.insert("a", e);
3518 let d = vec![desired_arts("a", vec![])];
3519 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3520 assert_eq!(plan.artifact_deletes(), 0);
3521 assert_eq!(plan.deletes(), 0);
3522 }
3523
3524 #[test]
3525 fn lrc_removed_kind_is_kept_not_deleted() {
3526 let mut manifest = Manifest::new();
3529 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3530 e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3531 manifest.insert("a", e);
3532 let d = vec![desired_arts("a", vec![])];
3533 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3534 assert_eq!(plan.artifact_deletes(), 0);
3535 assert_eq!(plan.deletes(), 0);
3536 }
3537
3538 #[test]
3539 fn video_mp4_removed_kind_is_kept_not_deleted() {
3540 let mut manifest = Manifest::new();
3544 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3545 e.video_mp4 = Some(cover("a.mp4", "vid-hash"));
3546 manifest.insert("a", e);
3547 let d = vec![desired_arts("a", vec![])];
3548 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3549 assert_eq!(plan.artifact_deletes(), 0);
3550 assert_eq!(plan.deletes(), 0);
3551 }
3552
3553 #[test]
3554 fn video_mp4_written_when_manifest_lacks_it() {
3555 let mut manifest = Manifest::new();
3558 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3559 let d = vec![desired_arts(
3560 "a",
3561 vec![art(
3562 ArtifactKind::VideoMp4,
3563 "a/song.mp4",
3564 "https://cdn/a/video.mp4",
3565 "vid-hash",
3566 )],
3567 )];
3568 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3569 assert_eq!(plan.artifact_writes(), 1);
3570 assert_eq!(
3571 write_artifacts(&plan)[0],
3572 &Action::WriteArtifact {
3573 kind: ArtifactKind::VideoMp4,
3574 path: "a/song.mp4".to_string(),
3575 source_url: "https://cdn/a/video.mp4".to_string(),
3576 hash: "vid-hash".to_string(),
3577 owner_id: "a".to_string(),
3578 content: None,
3579 }
3580 );
3581 }
3582
3583 #[test]
3584 fn details_removed_kind_not_deleted_on_incomplete_listing() {
3585 let mut manifest = Manifest::new();
3588 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3589 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3590 manifest.insert("a", e);
3591 let d = vec![desired_arts("a", vec![])];
3592 let sources = vec![SourceStatus {
3593 mode: SourceMode::Mirror,
3594 fully_enumerated: false,
3595 }];
3596 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
3597 assert_eq!(plan.artifact_deletes(), 0);
3598 }
3599
3600 #[test]
3601 fn details_removed_kind_not_deleted_when_preserved() {
3602 let mut manifest = Manifest::new();
3605 let mut e = ManifestEntry {
3606 preserve: true,
3607 ..entry("a.flac", AudioFormat::Flac, "m", "art")
3608 };
3609 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3610 manifest.insert("a", e);
3611 let d = vec![desired_arts("a", vec![])];
3612 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3613 assert_eq!(plan.artifact_deletes(), 0);
3614 }
3615
3616 #[test]
3617 fn co_delete_orphan_removes_every_text_sidecar() {
3618 let mut manifest = Manifest::new();
3622 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
3623 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
3624 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
3625 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
3626 e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3627 e.video_mp4 = Some(cover("gone/song.mp4", "vid-hash"));
3628 manifest.insert("gone", e);
3629 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3630 assert_eq!(plan.deletes(), 1);
3631 assert_eq!(plan.artifact_deletes(), 5);
3632 for (kind, path) in [
3633 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
3634 (ArtifactKind::DetailsTxt, "gone.details.txt"),
3635 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
3636 (ArtifactKind::Lrc, "gone.lrc"),
3637 (ArtifactKind::VideoMp4, "gone/song.mp4"),
3638 ] {
3639 assert!(
3640 plan.actions.contains(&Action::DeleteArtifact {
3641 kind,
3642 path: path.to_string(),
3643 owner_id: "gone".to_string(),
3644 }),
3645 "missing co-delete for {kind:?}"
3646 );
3647 }
3648 }
3649
3650 #[test]
3651 fn co_delete_trashed_removes_every_text_sidecar() {
3652 let mut manifest = Manifest::new();
3654 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3655 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3656 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3657 manifest.insert("a", e);
3658 let mut d = desired_arts("a", vec![]);
3659 d.trashed = true;
3660 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3661 assert_eq!(plan.deletes(), 1);
3662 assert_eq!(plan.artifact_deletes(), 2);
3663 }
3664
3665 #[test]
3666 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
3667 let mut manifest = Manifest::new();
3670 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3671 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
3672 let d = vec![desired_arts(
3675 "a",
3676 vec![art(
3677 ArtifactKind::CoverJpg,
3678 "shared/cover.jpg",
3679 "https://art/a",
3680 "h2",
3681 )],
3682 )];
3683 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3684 assert_eq!(plan.artifact_writes(), 1);
3685 assert!(!plan.actions.iter().any(
3687 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
3688 ));
3689 assert!(plan.actions.contains(&Action::Delete {
3691 path: "b.flac".to_string(),
3692 clip_id: "b".to_string(),
3693 }));
3694 }
3695
3696 #[test]
3697 fn suppress_downgrades_delete_artifact_colliding_with_download() {
3698 let mut manifest = Manifest::new();
3700 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
3701 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
3702 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
3703 assert_eq!(plan.downloads(), 1);
3704 assert!(
3705 !plan
3706 .actions
3707 .iter()
3708 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
3709 );
3710 }
3711
3712 #[test]
3713 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
3714 let build = |with_art: bool| {
3718 let mut manifest = Manifest::new();
3719 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
3720 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3721 manifest.insert(
3722 "trash",
3723 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
3724 );
3725 let keep = if with_art {
3726 desired_arts(
3727 "keep",
3728 vec![art(
3729 ArtifactKind::CoverJpg,
3730 "keep/cover.jpg",
3731 "https://art/keep",
3732 "h1",
3733 )],
3734 )
3735 } else {
3736 desired_arts("keep", vec![])
3737 };
3738 let mut trash = desired_arts("trash", vec![]);
3739 trash.trashed = true;
3740 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
3741 .iter()
3742 .map(|id| (id.to_string(), present(100)))
3743 .collect();
3744 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
3745 };
3746
3747 let with = build(true);
3748 let without = build(false);
3749
3750 let audio = |plan: &Plan| -> Vec<Action> {
3752 plan.actions
3753 .iter()
3754 .filter(|a| {
3755 !matches!(
3756 a,
3757 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
3758 )
3759 })
3760 .cloned()
3761 .collect()
3762 };
3763 assert_eq!(audio(&with), audio(&without));
3764 assert_eq!(with.deletes(), without.deletes());
3765 assert_eq!(with.deletes(), 2);
3767 assert_eq!(with.artifact_deletes(), 2);
3771 assert_eq!(with.artifact_writes(), 0);
3772 }
3773
3774 #[test]
3777 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
3778 let mut manifest = Manifest::new();
3784 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3785 assert!(!manifest.get("a").unwrap().preserve);
3786
3787 let private = Desired {
3789 private: true,
3790 ..desired_arts("a", vec![])
3791 };
3792 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
3793 assert_eq!(plan.artifact_deletes(), 0);
3794
3795 let copy_held = Desired {
3797 modes: vec![SourceMode::Copy],
3798 ..desired_arts("a", vec![])
3799 };
3800 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
3801 assert_eq!(plan.artifact_deletes(), 0);
3802 }
3803
3804 #[test]
3805 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
3806 let mut manifest = Manifest::new();
3812 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
3813 let d = vec![desired_arts(
3814 "a",
3815 vec![art(
3816 ArtifactKind::CoverJpg,
3817 "new/cover.jpg",
3818 "https://art/a",
3819 "h1",
3820 )],
3821 )];
3822 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3823 assert_eq!(plan.artifact_writes(), 1);
3824 assert_eq!(plan.artifact_deletes(), 0);
3825 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3826 assert_eq!(path, "new/cover.jpg");
3827 } else {
3828 panic!("expected a WriteArtifact");
3829 }
3830 }
3831
3832 #[test]
3833 fn needs_write_drift_applies_hash_path_and_probe_rules() {
3834 let local: HashMap<String, LocalFile> = [
3835 ("ok".to_string(), present(10)),
3836 ("missing".to_string(), LocalFile::default()),
3837 ("empty".to_string(), present(0)),
3838 ]
3839 .into_iter()
3840 .collect();
3841
3842 assert!(needs_write_drift(None, "h1", "ok", &local));
3843 assert!(!needs_write_drift(Some(("h1", "ok")), "h1", "ok", &local));
3844 assert!(needs_write_drift(Some(("h0", "ok")), "h1", "ok", &local));
3845 assert!(needs_write_drift(
3846 Some(("h1", "missing")),
3847 "h1",
3848 "missing",
3849 &local
3850 ));
3851 assert!(needs_write_drift(
3852 Some(("h1", "empty")),
3853 "h1",
3854 "empty",
3855 &local
3856 ));
3857 assert!(!needs_write_drift(
3858 Some(("h1", "unprobed")),
3859 "h1",
3860 "unprobed",
3861 &local
3862 ));
3863 }
3864
3865 #[test]
3866 fn per_clip_reconcile_ignores_album_and_library_kinds() {
3867 let mut manifest = Manifest::new();
3871 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3872 let d = vec![desired_arts(
3873 "a",
3874 vec![
3875 art(
3876 ArtifactKind::FolderJpg,
3877 "a/folder.jpg",
3878 "https://art/folder",
3879 "hf",
3880 ),
3881 art(
3882 ArtifactKind::Playlist,
3883 "a/list.m3u",
3884 "https://art/list",
3885 "hp",
3886 ),
3887 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
3888 ],
3889 )];
3890 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3891 assert_eq!(plan.artifact_writes(), 1);
3892 let paths: Vec<&str> = plan
3893 .actions
3894 .iter()
3895 .filter_map(|a| match a {
3896 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3897 _ => None,
3898 })
3899 .collect();
3900 assert_eq!(paths, vec!["a/cover.jpg"]);
3901 }
3902
3903 #[test]
3904 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3905 let mut manifest = Manifest::new();
3906 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3907 let d = vec![desired_arts(
3908 "a",
3909 vec![art(
3910 ArtifactKind::FolderWebp,
3911 "a/folder.webp",
3912 "https://art/folder",
3913 "hf",
3914 )],
3915 )];
3916 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3917 assert_eq!(plan.artifact_writes(), 0);
3918 assert_eq!(plan.artifact_deletes(), 0);
3919 }
3920
3921 fn local_with_missing(audio_id: &str, missing_path: &str) -> HashMap<String, LocalFile> {
3925 let mut m = local_present(audio_id);
3926 m.insert(missing_path.to_owned(), LocalFile::default());
3927 m
3928 }
3929
3930 fn local_with_present_artifact(
3932 audio_id: &str,
3933 artifact_path: &str,
3934 ) -> HashMap<String, LocalFile> {
3935 let mut m = local_present(audio_id);
3936 m.insert(artifact_path.to_owned(), present(50));
3937 m
3938 }
3939
3940 #[test]
3941 fn sidecar_missing_on_disk_forces_rewrite() {
3942 let mut manifest = Manifest::new();
3946 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3947 let d = vec![desired_arts(
3948 "a",
3949 vec![art(
3950 ArtifactKind::CoverJpg,
3951 "a/cover.jpg",
3952 "https://art/a",
3953 "h1",
3954 )],
3955 )];
3956 let local = local_with_missing("a", "a/cover.jpg");
3957 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3958 assert_eq!(
3959 plan.artifact_writes(),
3960 1,
3961 "missing sidecar must be rewritten"
3962 );
3963 assert_eq!(plan.artifact_deletes(), 0);
3964 }
3965
3966 #[test]
3967 fn sidecar_present_on_disk_with_matching_hash_no_churn() {
3968 let mut manifest = Manifest::new();
3970 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3971 let d = vec![desired_arts(
3972 "a",
3973 vec![art(
3974 ArtifactKind::CoverJpg,
3975 "a/cover.jpg",
3976 "https://art/a",
3977 "h1",
3978 )],
3979 )];
3980 let local = local_with_present_artifact("a", "a/cover.jpg");
3981 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3982 assert_eq!(plan.artifact_writes(), 0, "present sidecar must not churn");
3983 assert_eq!(plan.artifact_deletes(), 0);
3984 }
3985
3986 #[test]
3987 fn sidecar_probe_absent_falls_back_to_hash_comparison_no_write() {
3988 let mut manifest = Manifest::new();
3992 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3993 let d = vec![desired_arts(
3994 "a",
3995 vec![art(
3996 ArtifactKind::CoverJpg,
3997 "a/cover.jpg",
3998 "https://art/a",
3999 "h1",
4000 )],
4001 )];
4002 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4004 assert_eq!(
4005 plan.artifact_writes(),
4006 0,
4007 "no write when probe unavailable and hash matches"
4008 );
4009 assert_eq!(
4010 plan.artifact_deletes(),
4011 0,
4012 "missing probe must never trigger a delete"
4013 );
4014 }
4015
4016 #[test]
4017 fn folder_art_missing_on_disk_forces_rewrite() {
4018 let members = vec![album_member(
4021 album_clip("a", 1, "t0", "art-a", ""),
4022 "root",
4023 "c/al/a.flac",
4024 )];
4025 let desired = album_desired(&members, false, false);
4026 let mut albums = BTreeMap::new();
4027 albums.insert(
4028 "root".to_string(),
4029 AlbumArt {
4030 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4031 folder_webp: None,
4032 folder_mp4: None,
4033 },
4034 );
4035 let mut local: HashMap<String, LocalFile> = HashMap::new();
4036 local.insert("c/al/folder.jpg".to_owned(), LocalFile::default());
4037 let actions = plan_album_artifacts(&desired, &albums, true, &local);
4038 assert_eq!(actions.len(), 1, "missing folder art must be rewritten");
4039 assert!(matches!(
4040 &actions[0],
4041 Action::WriteArtifact {
4042 kind: ArtifactKind::FolderJpg,
4043 ..
4044 }
4045 ));
4046 }
4047
4048 #[test]
4049 fn folder_art_present_on_disk_no_churn() {
4050 let members = vec![album_member(
4052 album_clip("a", 1, "t0", "art-a", ""),
4053 "root",
4054 "c/al/a.flac",
4055 )];
4056 let desired = album_desired(&members, false, false);
4057 let mut albums = BTreeMap::new();
4058 albums.insert(
4059 "root".to_string(),
4060 AlbumArt {
4061 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4062 folder_webp: None,
4063 folder_mp4: None,
4064 },
4065 );
4066 let mut local: HashMap<String, LocalFile> = HashMap::new();
4067 local.insert("c/al/folder.jpg".to_owned(), present(5000));
4068 let actions = plan_album_artifacts(&desired, &albums, true, &local);
4069 assert!(
4070 actions.is_empty(),
4071 "present folder art with matching hash must not churn"
4072 );
4073 }
4074
4075 #[test]
4076 fn playlist_missing_on_disk_forces_rewrite() {
4077 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4080 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4081 let mut local: HashMap<String, LocalFile> = HashMap::new();
4082 local.insert("Mix.m3u8".to_owned(), LocalFile::default());
4083 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
4084 assert_eq!(actions.len(), 1, "missing playlist file must be rewritten");
4085 assert!(matches!(
4086 &actions[0],
4087 Action::WriteArtifact {
4088 kind: ArtifactKind::Playlist,
4089 ..
4090 }
4091 ));
4092 }
4093
4094 #[test]
4095 fn playlist_present_on_disk_no_churn() {
4096 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4098 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4099 let mut local: HashMap<String, LocalFile> = HashMap::new();
4100 local.insert("Mix.m3u8".to_owned(), present(200));
4101 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
4102 assert!(
4103 actions.is_empty(),
4104 "present playlist with matching hash must not churn"
4105 );
4106 }
4107
4108 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
4111 Clip {
4112 id: id.to_string(),
4113 title: "Song".to_string(),
4114 image_large_url: image.to_string(),
4115 video_cover_url: video.to_string(),
4116 play_count,
4117 created_at: created_at.to_string(),
4118 ..Default::default()
4119 }
4120 }
4121
4122 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
4123 let mut lineage = LineageContext::own_root(&clip);
4124 lineage.root_id = root_id.to_string();
4125 Desired {
4126 clip,
4127 lineage,
4128 path: path.to_string(),
4129 format: AudioFormat::Flac,
4130 meta_hash: "m".to_string(),
4131 art_hash: "a".to_string(),
4132 modes: vec![SourceMode::Mirror],
4133 trashed: false,
4134 private: false,
4135 artifacts: Vec::new(),
4136 stems: None,
4137 }
4138 }
4139
4140 fn stored(path: &str, hash: &str) -> ArtifactState {
4141 ArtifactState {
4142 path: path.to_string(),
4143 hash: hash.to_string(),
4144 }
4145 }
4146
4147 #[test]
4148 fn folder_jpg_source_is_most_played() {
4149 let members = vec![
4150 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
4151 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
4152 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
4153 ];
4154 let albums = album_desired(&members, false, false);
4155 assert_eq!(albums.len(), 1);
4156 let jpg = albums[0].folder_jpg.as_ref().unwrap();
4157 assert_eq!(jpg.hash, art_url_hash("art-b"));
4159 assert_eq!(jpg.source_url, "art-b");
4160 assert_eq!(jpg.path, "c/al/folder.jpg");
4161 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
4162 }
4163
4164 #[test]
4165 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
4166 let by_time = vec![
4168 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
4169 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
4170 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
4171 ];
4172 let jpg = album_desired(&by_time, false, false)[0]
4173 .folder_jpg
4174 .clone()
4175 .unwrap();
4176 assert_eq!(jpg.source_url, "art-y");
4177
4178 let by_id = vec![
4180 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
4181 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
4182 ];
4183 let jpg = album_desired(&by_id, false, false)[0]
4184 .folder_jpg
4185 .clone()
4186 .unwrap();
4187 assert_eq!(jpg.source_url, "art-g");
4188 }
4189
4190 #[test]
4191 fn folder_webp_source_is_first_created_animated() {
4192 let members = vec![
4193 album_member(
4194 album_clip("a", 9, "t2", "art-a", "vid-a"),
4195 "root",
4196 "c/al/a.flac",
4197 ),
4198 album_member(
4199 album_clip("b", 1, "t0", "art-b", "vid-b"),
4200 "root",
4201 "c/al/b.flac",
4202 ),
4203 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
4204 ];
4205 let webp = album_desired(&members, true, false)[0]
4206 .folder_webp
4207 .clone()
4208 .unwrap();
4209 assert_eq!(webp.source_url, "vid-b");
4211 assert_eq!(webp.hash, art_url_hash("vid-b"));
4212 assert_eq!(webp.path, "c/al/cover.webp");
4213 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
4214 }
4215
4216 #[test]
4217 fn animated_covers_off_yields_no_folder_webp() {
4218 let members = vec![album_member(
4219 album_clip("a", 1, "t0", "art-a", "vid-a"),
4220 "root",
4221 "c/al/a.flac",
4222 )];
4223 let off = album_desired(&members, false, false);
4224 assert!(off[0].folder_webp.is_none());
4225 let on = album_desired(&members, true, false);
4226 assert!(on[0].folder_webp.is_some());
4227 }
4228
4229 #[test]
4230 fn raw_cover_yields_folder_mp4_from_the_webp_source_verbatim() {
4231 let members = vec![
4232 album_member(
4233 album_clip("a", 9, "t2", "art-a", "vid-a"),
4234 "root",
4235 "c/al/a.flac",
4236 ),
4237 album_member(
4238 album_clip("b", 1, "t0", "art-b", "vid-b"),
4239 "root",
4240 "c/al/b.flac",
4241 ),
4242 ];
4243 let album = album_desired(&members, true, true).remove(0);
4247 let webp = album.folder_webp.unwrap();
4248 let mp4 = album.folder_mp4.unwrap();
4249 assert_eq!(mp4.kind, ArtifactKind::FolderMp4);
4250 assert_eq!(mp4.path, "c/al/cover.mp4");
4251 assert_eq!(mp4.source_url, "vid-b");
4252 assert_eq!(mp4.hash, art_url_hash("vid-b"));
4253 assert_eq!(mp4.source_url, webp.source_url, "same variant feeds both");
4254 }
4255
4256 #[test]
4257 fn raw_cover_and_webp_are_independent_toggles() {
4258 let members = vec![album_member(
4259 album_clip("a", 1, "t0", "art-a", "vid-a"),
4260 "root",
4261 "c/al/a.flac",
4262 )];
4263 let webp_only = album_desired(&members, true, false).remove(0);
4265 assert!(webp_only.folder_webp.is_some());
4266 assert!(webp_only.folder_mp4.is_none());
4267 let mp4_only = album_desired(&members, false, true).remove(0);
4269 assert!(mp4_only.folder_webp.is_none());
4270 assert!(mp4_only.folder_mp4.is_some());
4271 }
4272
4273 #[test]
4274 fn raw_cover_needs_an_animated_source() {
4275 let members = vec![album_member(
4277 album_clip("a", 3, "t0", "art-a", ""),
4278 "root",
4279 "c/al/a.flac",
4280 )];
4281 let album = album_desired(&members, true, true).remove(0);
4282 assert!(album.folder_mp4.is_none());
4283 assert!(album.folder_webp.is_none());
4284 }
4285
4286 #[test]
4287 fn album_with_no_art_yields_no_folder_jpg() {
4288 let members = vec![album_member(
4289 album_clip("a", 3, "t0", "", ""),
4290 "root",
4291 "c/al/a.flac",
4292 )];
4293 let albums = album_desired(&members, true, false);
4294 assert!(albums[0].folder_jpg.is_none());
4295 assert!(albums[0].folder_webp.is_none());
4296 }
4297
4298 #[test]
4299 fn album_desired_groups_by_root_id() {
4300 let members = vec![
4301 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
4302 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
4303 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
4304 ];
4305 let albums = album_desired(&members, false, false);
4306 assert_eq!(albums.len(), 2);
4307 assert_eq!(albums[0].root_id, "r1");
4308 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
4309 assert_eq!(
4310 albums[0].folder_jpg.as_ref().unwrap().path,
4311 "c/al1/folder.jpg"
4312 );
4313 assert_eq!(albums[1].root_id, "r2");
4314 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
4315 assert_eq!(
4316 albums[1].folder_jpg.as_ref().unwrap().path,
4317 "c/al2/folder.jpg"
4318 );
4319 }
4320
4321 #[test]
4322 fn plan_writes_folder_art_when_store_empty() {
4323 let members = vec![album_member(
4324 album_clip("a", 1, "t0", "art-a", "vid-a"),
4325 "root",
4326 "c/al/a.flac",
4327 )];
4328 let desired = album_desired(&members, true, false);
4329 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4330 assert_eq!(
4331 actions,
4332 vec![
4333 Action::WriteArtifact {
4334 kind: ArtifactKind::FolderJpg,
4335 path: "c/al/folder.jpg".to_string(),
4336 source_url: "art-a".to_string(),
4337 hash: art_url_hash("art-a"),
4338 owner_id: "root".to_string(),
4339 content: None,
4340 },
4341 Action::WriteArtifact {
4342 kind: ArtifactKind::FolderWebp,
4343 path: "c/al/cover.webp".to_string(),
4344 source_url: "vid-a".to_string(),
4345 hash: art_url_hash("vid-a"),
4346 owner_id: "root".to_string(),
4347 content: None,
4348 },
4349 ]
4350 );
4351 }
4352
4353 #[test]
4354 fn plan_skips_when_hash_and_path_match() {
4355 let members = vec![album_member(
4356 album_clip("a", 1, "t0", "art-a", ""),
4357 "root",
4358 "c/al/a.flac",
4359 )];
4360 let desired = album_desired(&members, false, false);
4361 let mut albums = BTreeMap::new();
4362 albums.insert(
4363 "root".to_string(),
4364 AlbumArt {
4365 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4366 folder_webp: None,
4367 folder_mp4: None,
4368 },
4369 );
4370 assert!(plan_album_artifacts(&desired, &albums, true, &HashMap::new()).is_empty());
4371 }
4372
4373 #[test]
4374 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
4375 let members = vec![album_member(
4376 album_clip("a", 1, "t0", "art-a", ""),
4377 "root",
4378 "c/al/a.flac",
4379 )];
4380 let desired = album_desired(&members, false, false);
4381 let mut albums = BTreeMap::new();
4382 albums.insert(
4383 "root".to_string(),
4384 AlbumArt {
4385 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
4386 folder_webp: None,
4387 folder_mp4: None,
4388 },
4389 );
4390 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4391 assert_eq!(actions.len(), 1);
4392 assert!(matches!(
4393 &actions[0],
4394 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
4395 ));
4396 }
4397
4398 #[test]
4399 fn h1_most_played_flip_to_same_art_writes_nothing() {
4400 let run1 = vec![
4402 album_member(
4403 album_clip("a", 9, "t0", "same-art", ""),
4404 "root",
4405 "c/al/a.flac",
4406 ),
4407 album_member(
4408 album_clip("b", 1, "t1", "same-art", ""),
4409 "root",
4410 "c/al/b.flac",
4411 ),
4412 ];
4413 let desired1 = album_desired(&run1, false, false);
4414 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true, &HashMap::new());
4415 assert_eq!(write1.len(), 1);
4416
4417 let mut albums = BTreeMap::new();
4419 if let Action::WriteArtifact {
4420 path,
4421 hash,
4422 owner_id,
4423 ..
4424 } = &write1[0]
4425 {
4426 albums.insert(
4427 owner_id.clone(),
4428 AlbumArt {
4429 folder_jpg: Some(stored(path, hash)),
4430 folder_webp: None,
4431 folder_mp4: None,
4432 },
4433 );
4434 }
4435
4436 let run2 = vec![
4438 album_member(
4439 album_clip("a", 1, "t0", "same-art", ""),
4440 "root",
4441 "c/al/a.flac",
4442 ),
4443 album_member(
4444 album_clip("b", 9, "t1", "same-art", ""),
4445 "root",
4446 "c/al/b.flac",
4447 ),
4448 ];
4449 let desired2 = album_desired(&run2, false, false);
4450 assert!(plan_album_artifacts(&desired2, &albums, true, &HashMap::new()).is_empty());
4452 }
4453
4454 #[test]
4455 fn h1_flip_to_different_art_writes_exactly_one() {
4456 let mut albums = BTreeMap::new();
4457 albums.insert(
4458 "root".to_string(),
4459 AlbumArt {
4460 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
4461 folder_webp: None,
4462 folder_mp4: None,
4463 },
4464 );
4465 let members = vec![
4467 album_member(
4468 album_clip("a", 1, "t0", "old-art", ""),
4469 "root",
4470 "c/al/a.flac",
4471 ),
4472 album_member(
4473 album_clip("b", 9, "t1", "new-art", ""),
4474 "root",
4475 "c/al/b.flac",
4476 ),
4477 ];
4478 let desired = album_desired(&members, false, false);
4479 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4480 assert_eq!(actions.len(), 1);
4481 assert!(matches!(
4482 &actions[0],
4483 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
4484 ));
4485 }
4486
4487 #[test]
4488 fn one_write_per_album_regardless_of_clip_count() {
4489 let members: Vec<Desired> = (0..200)
4490 .map(|i| {
4491 album_member(
4492 album_clip(
4493 &format!("clip-{i:03}"),
4494 i as u64,
4495 &format!("t{i:03}"),
4496 &format!("art-{i:03}"),
4497 &format!("vid-{i:03}"),
4498 ),
4499 "root",
4500 &format!("c/al/clip-{i:03}.flac"),
4501 )
4502 })
4503 .collect();
4504 let desired = album_desired(&members, true, false);
4505 assert_eq!(desired.len(), 1);
4506 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4507 assert_eq!(actions.len(), 2);
4509 assert_eq!(
4510 actions
4511 .iter()
4512 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
4513 .count(),
4514 2
4515 );
4516 }
4517
4518 #[test]
4519 fn emptied_album_deletes_only_when_can_delete() {
4520 let mut albums = BTreeMap::new();
4521 albums.insert(
4522 "root".to_string(),
4523 AlbumArt {
4524 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
4525 folder_webp: Some(stored("c/al/cover.webp", "hw")),
4526 folder_mp4: Some(stored("c/al/cover.mp4", "hm")),
4527 },
4528 );
4529 let desired: Vec<AlbumDesired> = Vec::new();
4531
4532 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4534
4535 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4537 assert_eq!(
4538 actions,
4539 vec![
4540 Action::DeleteArtifact {
4541 kind: ArtifactKind::FolderJpg,
4542 path: "c/al/folder.jpg".to_string(),
4543 owner_id: "root".to_string(),
4544 },
4545 Action::DeleteArtifact {
4546 kind: ArtifactKind::FolderWebp,
4547 path: "c/al/cover.webp".to_string(),
4548 owner_id: "root".to_string(),
4549 },
4550 Action::DeleteArtifact {
4551 kind: ArtifactKind::FolderMp4,
4552 path: "c/al/cover.mp4".to_string(),
4553 owner_id: "root".to_string(),
4554 },
4555 ]
4556 );
4557 }
4558
4559 #[test]
4560 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
4561 let mut albums = BTreeMap::new();
4562 albums.insert(
4563 "root".to_string(),
4564 AlbumArt {
4565 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4566 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4567 folder_mp4: None,
4568 },
4569 );
4570 let members = vec![album_member(
4573 album_clip("a", 1, "t0", "art-a", "vid-a"),
4574 "root",
4575 "c/al/a.flac",
4576 )];
4577 let desired = album_desired(&members, false, false);
4578
4579 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4580
4581 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4582 assert_eq!(
4583 actions,
4584 vec![Action::DeleteArtifact {
4585 kind: ArtifactKind::FolderWebp,
4586 path: "c/al/cover.webp".to_string(),
4587 owner_id: "root".to_string(),
4588 }]
4589 );
4590 }
4591
4592 #[test]
4593 fn disappeared_raw_cover_deletes_only_that_kind_when_gated() {
4594 let mut albums = BTreeMap::new();
4595 albums.insert(
4596 "root".to_string(),
4597 AlbumArt {
4598 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4599 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4600 folder_mp4: Some(stored("c/al/cover.mp4", &art_url_hash("vid-a"))),
4601 },
4602 );
4603 let members = vec![album_member(
4606 album_clip("a", 1, "t0", "art-a", "vid-a"),
4607 "root",
4608 "c/al/a.flac",
4609 )];
4610 let desired = album_desired(&members, true, false);
4611
4612 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4614
4615 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4617 assert_eq!(
4618 actions,
4619 vec![Action::DeleteArtifact {
4620 kind: ArtifactKind::FolderMp4,
4621 path: "c/al/cover.mp4".to_string(),
4622 owner_id: "root".to_string(),
4623 }]
4624 );
4625 }
4626
4627 #[test]
4628 fn plan_album_artifacts_is_deterministically_ordered() {
4629 let members = vec![
4630 album_member(
4631 album_clip("a", 1, "t0", "art-a", "vid-a"),
4632 "r2",
4633 "c/al2/a.flac",
4634 ),
4635 album_member(
4636 album_clip("b", 1, "t0", "art-b", "vid-b"),
4637 "r1",
4638 "c/al1/b.flac",
4639 ),
4640 ];
4641 let desired = album_desired(&members, true, true);
4642 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4643 let keys: Vec<(&str, ArtifactKind)> = actions
4644 .iter()
4645 .map(|a| match a {
4646 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
4647 _ => unreachable!(),
4648 })
4649 .collect();
4650 assert_eq!(
4651 keys,
4652 vec![
4653 ("r1", ArtifactKind::FolderJpg),
4654 ("r1", ArtifactKind::FolderWebp),
4655 ("r1", ArtifactKind::FolderMp4),
4656 ("r2", ArtifactKind::FolderJpg),
4657 ("r2", ArtifactKind::FolderWebp),
4658 ("r2", ArtifactKind::FolderMp4),
4659 ]
4660 );
4661 }
4662
4663 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
4666 PlaylistDesired {
4667 id: id.to_owned(),
4668 name: name.to_owned(),
4669 path: path.to_owned(),
4670 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
4671 hash: hash.to_owned(),
4672 }
4673 }
4674
4675 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
4676 PlaylistState {
4677 name: name.to_owned(),
4678 path: path.to_owned(),
4679 hash: hash.to_owned(),
4680 }
4681 }
4682
4683 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
4684 entries
4685 .iter()
4686 .map(|(id, state)| ((*id).to_owned(), state.clone()))
4687 .collect()
4688 }
4689
4690 #[test]
4691 fn playlist_write_emitted_for_a_new_playlist() {
4692 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
4693 let actions =
4694 plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true, &HashMap::new());
4695 assert_eq!(
4696 actions,
4697 vec![Action::WriteArtifact {
4698 kind: ArtifactKind::Playlist,
4699 path: "Road Trip.m3u8".to_owned(),
4700 source_url: String::new(),
4701 hash: "h1".to_owned(),
4702 owner_id: "pl1".to_owned(),
4703 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
4704 }]
4705 );
4706 }
4707
4708 #[test]
4709 fn playlist_write_emitted_when_hash_changes() {
4710 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
4713 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4714 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4715 assert_eq!(actions.len(), 1);
4716 assert!(matches!(
4717 &actions[0],
4718 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
4719 ));
4720 }
4721
4722 #[test]
4723 fn playlist_unchanged_is_idempotent() {
4724 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4725 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4726 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4727 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
4728 }
4729
4730 #[test]
4731 fn playlist_rename_writes_new_and_deletes_old_path() {
4732 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4735 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4736 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4737 assert_eq!(
4738 actions,
4739 vec![
4740 Action::WriteArtifact {
4741 kind: ArtifactKind::Playlist,
4742 path: "Summer.m3u8".to_owned(),
4743 source_url: String::new(),
4744 hash: "h2".to_owned(),
4745 owner_id: "pl1".to_owned(),
4746 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
4747 },
4748 Action::DeleteArtifact {
4749 kind: ArtifactKind::Playlist,
4750 path: "Spring.m3u8".to_owned(),
4751 owner_id: "pl1".to_owned(),
4752 },
4753 ]
4754 );
4755 }
4756
4757 #[test]
4758 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
4759 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4762 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4763 let actions = plan_playlist_artifacts(&desired, &stored, false, true, &HashMap::new());
4764 assert_eq!(actions.len(), 1);
4765 assert!(matches!(
4766 &actions[0],
4767 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
4768 ));
4769 assert!(
4770 !actions
4771 .iter()
4772 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
4773 "old path must not be deleted when deletes are disallowed"
4774 );
4775 }
4776
4777 #[test]
4778 fn playlist_stale_removed_only_under_full_gate() {
4779 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
4782
4783 let deleted = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4784 assert_eq!(
4785 deleted,
4786 vec![Action::DeleteArtifact {
4787 kind: ArtifactKind::Playlist,
4788 path: "Gone.m3u8".to_owned(),
4789 owner_id: "gone".to_owned(),
4790 }]
4791 );
4792
4793 assert!(plan_playlist_artifacts(&[], &stored, false, true, &HashMap::new()).is_empty());
4795 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4796 assert!(plan_playlist_artifacts(&[], &stored, false, false, &HashMap::new()).is_empty());
4797 }
4798
4799 #[test]
4800 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
4801 let stored = pl_store(&[
4806 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4807 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4808 ]);
4809 let actions = plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new());
4810 assert!(
4811 actions.is_empty(),
4812 "a failed playlist listing must plan zero actions, got {actions:?}"
4813 );
4814 }
4815
4816 #[test]
4817 fn b2_empty_list_deletes_only_when_fully_enumerated() {
4818 let stored = pl_store(&[
4823 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4824 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4825 ]);
4826
4827 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4829
4830 let wiped = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4833 assert_eq!(
4834 wiped
4835 .iter()
4836 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
4837 .count(),
4838 2
4839 );
4840 }
4841
4842 #[test]
4843 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
4844 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
4849 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
4850 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4851 assert_eq!(actions.len(), 1);
4853 assert!(matches!(
4854 &actions[0],
4855 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
4856 ));
4857 assert!(
4858 !actions.iter().any(|a| match a {
4859 Action::WriteArtifact { owner_id, .. }
4860 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
4861 _ => false,
4862 }),
4863 "a protected (failed-member) playlist must have no action"
4864 );
4865 }
4866
4867 #[test]
4868 fn playlist_rename_collision_downgrades_the_delete() {
4869 let desired = vec![
4875 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
4876 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
4877 ];
4878 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
4879 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4880 let write_paths: BTreeSet<&str> = actions
4882 .iter()
4883 .filter_map(|a| match a {
4884 Action::WriteArtifact { path, .. } => Some(path.as_str()),
4885 _ => None,
4886 })
4887 .collect();
4888 for a in &actions {
4889 if let Action::DeleteArtifact { path, .. } = a {
4890 assert!(
4891 !write_paths.contains(path.as_str()),
4892 "a playlist delete aliases a write target: {path}"
4893 );
4894 }
4895 }
4896 }
4897
4898 fn dstem(key: &str, path: &str, hash: &str) -> DesiredStem {
4901 DesiredStem {
4902 key: key.to_string(),
4903 stem_id: key.to_string(),
4904 path: path.to_string(),
4905 source_url: format!("https://cdn1.suno.ai/{key}.mp3"),
4906 format: StemFormat::Mp3,
4907 hash: hash.to_string(),
4908 }
4909 }
4910
4911 fn stem_desired(id: &str, stems: Option<Vec<DesiredStem>>) -> Desired {
4913 Desired {
4914 stems,
4915 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
4916 }
4917 }
4918
4919 fn entry_with_stems(id: &str, stems: &[(&str, &str, &str)]) -> ManifestEntry {
4921 let mut e = entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art");
4922 for (key, path, hash) in stems {
4923 e.stems.insert(
4924 key.to_string(),
4925 ArtifactState {
4926 path: path.to_string(),
4927 hash: hash.to_string(),
4928 },
4929 );
4930 }
4931 e
4932 }
4933
4934 fn stem_writes(plan: &Plan) -> Vec<(&str, &str)> {
4935 plan.actions
4936 .iter()
4937 .filter_map(|a| match a {
4938 Action::WriteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4939 _ => None,
4940 })
4941 .collect()
4942 }
4943
4944 fn stem_deletes(plan: &Plan) -> Vec<(&str, &str)> {
4945 plan.actions
4946 .iter()
4947 .filter_map(|a| match a {
4948 Action::DeleteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4949 _ => None,
4950 })
4951 .collect()
4952 }
4953
4954 #[test]
4955 fn stems_none_keeps_every_existing_stem() {
4956 let mut manifest = Manifest::new();
4959 manifest.insert(
4960 "a",
4961 entry_with_stems(
4962 "a",
4963 &[
4964 ("voc", "a.stems/voc.mp3", "h1"),
4965 ("drm", "a.stems/drm.mp3", "h2"),
4966 ],
4967 ),
4968 );
4969 let d = vec![stem_desired("a", None)];
4970 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4971 assert_eq!(plan.stem_writes(), 0);
4972 assert_eq!(plan.stem_deletes(), 0);
4973 }
4974
4975 #[test]
4976 fn stems_authoritative_writes_missing_stems() {
4977 let mut manifest = Manifest::new();
4978 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
4979 let d = vec![stem_desired(
4980 "a",
4981 Some(vec![
4982 dstem("voc", "a.stems/voc.mp3", "h1"),
4983 dstem("drm", "a.stems/drm.mp3", "h2"),
4984 ]),
4985 )];
4986 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4987 assert_eq!(
4988 stem_writes(&plan),
4989 vec![("voc", "a.stems/voc.mp3"), ("drm", "a.stems/drm.mp3")]
4990 );
4991 assert_eq!(plan.stem_deletes(), 0);
4992 }
4993
4994 #[test]
4995 fn stems_authoritative_rewrites_only_on_hash_or_path_drift() {
4996 let mut manifest = Manifest::new();
4997 manifest.insert(
4999 "a",
5000 entry_with_stems(
5001 "a",
5002 &[
5003 ("voc", "a.stems/voc.mp3", "h1"),
5004 ("drm", "a.stems/drm.mp3", "h2"),
5005 ("bas", "old.stems/bas.mp3", "h3"),
5006 ],
5007 ),
5008 );
5009 let d = vec![stem_desired(
5010 "a",
5011 Some(vec![
5012 dstem("voc", "a.stems/voc.mp3", "h1"), dstem("drm", "a.stems/drm.mp3", "h2-new"), dstem("bas", "a.stems/bas.mp3", "h3"), ]),
5016 )];
5017 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5018 assert_eq!(
5019 stem_writes(&plan),
5020 vec![("drm", "a.stems/drm.mp3"), ("bas", "a.stems/bas.mp3")]
5021 );
5022 assert_eq!(plan.stem_deletes(), 0);
5023 }
5024
5025 #[test]
5026 fn stems_authoritative_removes_a_stem_absent_from_the_set() {
5027 let mut manifest = Manifest::new();
5030 manifest.insert(
5031 "a",
5032 entry_with_stems(
5033 "a",
5034 &[
5035 ("voc", "a.stems/voc.mp3", "h1"),
5036 ("drm", "a.stems/drm.mp3", "h2"),
5037 ],
5038 ),
5039 );
5040 let d = vec![stem_desired(
5041 "a",
5042 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
5043 )];
5044 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5045 assert_eq!(plan.stem_writes(), 0);
5046 assert_eq!(stem_deletes(&plan), vec![("drm", "a.stems/drm.mp3")]);
5047 }
5048
5049 #[test]
5050 fn stems_removal_needs_deletion_allowed() {
5051 let mut manifest = Manifest::new();
5054 manifest.insert(
5055 "a",
5056 entry_with_stems(
5057 "a",
5058 &[
5059 ("voc", "a.stems/voc.mp3", "h1"),
5060 ("drm", "a.stems/drm.mp3", "h2"),
5061 ],
5062 ),
5063 );
5064 let d = vec![stem_desired(
5065 "a",
5066 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
5067 )];
5068
5069 let incomplete = vec![SourceStatus {
5070 mode: SourceMode::Mirror,
5071 fully_enumerated: false,
5072 }];
5073 assert_eq!(
5074 reconcile(&manifest, &d, &local_present("a"), &incomplete).stem_deletes(),
5075 0
5076 );
5077
5078 let copy_only = vec![SourceStatus {
5079 mode: SourceMode::Copy,
5080 fully_enumerated: true,
5081 }];
5082 assert_eq!(
5083 reconcile(&manifest, &d, &local_present("a"), ©_only).stem_deletes(),
5084 0
5085 );
5086 }
5087
5088 #[test]
5089 fn stems_removal_skipped_for_preserved_or_protected_clip() {
5090 let mut manifest = Manifest::new();
5091 let mut e = entry_with_stems(
5092 "a",
5093 &[
5094 ("voc", "a.stems/voc.mp3", "h1"),
5095 ("drm", "a.stems/drm.mp3", "h2"),
5096 ],
5097 );
5098 e.preserve = true;
5099 manifest.insert("a", e);
5100 let authoritative = Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]);
5101
5102 let d = vec![stem_desired("a", authoritative.clone())];
5104 assert_eq!(
5105 reconcile(&manifest, &d, &local_present("a"), &mirror_ok()).stem_deletes(),
5106 0
5107 );
5108
5109 let mut manifest2 = Manifest::new();
5111 manifest2.insert(
5112 "a",
5113 entry_with_stems(
5114 "a",
5115 &[
5116 ("voc", "a.stems/voc.mp3", "h1"),
5117 ("drm", "a.stems/drm.mp3", "h2"),
5118 ],
5119 ),
5120 );
5121 let held = Desired {
5122 modes: vec![SourceMode::Mirror, SourceMode::Copy],
5123 stems: authoritative,
5124 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
5125 };
5126 assert_eq!(
5127 reconcile(&manifest2, &[held], &local_present("a"), &mirror_ok()).stem_deletes(),
5128 0
5129 );
5130 }
5131
5132 #[test]
5133 fn stems_are_co_deleted_when_the_song_is_trashed() {
5134 let mut manifest = Manifest::new();
5137 manifest.insert(
5138 "a",
5139 entry_with_stems(
5140 "a",
5141 &[
5142 ("voc", "a.stems/voc.mp3", "h1"),
5143 ("drm", "a.stems/drm.mp3", "h2"),
5144 ],
5145 ),
5146 );
5147 let trashed = Desired {
5148 trashed: true,
5149 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
5150 };
5151 let plan = reconcile(&manifest, &[trashed], &local_present("a"), &mirror_ok());
5152 assert_eq!(plan.deletes(), 1, "the trashed audio is deleted");
5153 let mut deleted: Vec<&str> = stem_deletes(&plan).into_iter().map(|(k, _)| k).collect();
5154 deleted.sort_unstable();
5155 assert_eq!(deleted, vec!["drm", "voc"], "both stems co-deleted");
5156 }
5157
5158 #[test]
5159 fn stems_are_co_deleted_for_an_absent_clip() {
5160 let mut manifest = Manifest::new();
5161 manifest.insert(
5162 "a",
5163 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5164 );
5165 let plan = reconcile(&manifest, &[], &local_present("a"), &mirror_ok());
5167 assert_eq!(plan.deletes(), 1);
5168 assert_eq!(stem_deletes(&plan), vec![("voc", "a.stems/voc.mp3")]);
5169 }
5170
5171 #[test]
5172 fn stems_are_kept_when_absent_clip_listing_is_incomplete() {
5173 let mut manifest = Manifest::new();
5175 manifest.insert(
5176 "a",
5177 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5178 );
5179 let incomplete = vec![SourceStatus {
5180 mode: SourceMode::Mirror,
5181 fully_enumerated: false,
5182 }];
5183 let plan = reconcile(&manifest, &[], &HashMap::new(), &incomplete);
5184 assert_eq!(plan.deletes(), 0);
5185 assert_eq!(plan.stem_deletes(), 0);
5186 }
5187
5188 #[test]
5189 fn stem_delete_is_suppressed_when_it_aliases_a_stem_write() {
5190 let mut manifest = Manifest::new();
5194 manifest.insert(
5195 "a",
5196 entry_with_stems("a", &[("old", "a.stems/mix.mp3", "h1")]),
5197 );
5198 let d = vec![stem_desired(
5199 "a",
5200 Some(vec![dstem("new", "a.stems/mix.mp3", "h2")]),
5201 )];
5202 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5203 assert_eq!(stem_writes(&plan), vec![("new", "a.stems/mix.mp3")]);
5206 assert!(
5207 !plan.actions.iter().any(|a| matches!(
5208 a,
5209 Action::DeleteStem { path, .. } if path == "a.stems/mix.mp3"
5210 )),
5211 "a stem delete must never alias a stem write target"
5212 );
5213 }
5214}
5215
5216#[cfg(test)]
5229mod proptests {
5230 use super::*;
5231 use proptest::collection::{btree_map, hash_map, vec};
5232 use proptest::prelude::*;
5233 use std::collections::BTreeSet;
5234
5235 type DesiredFields = (
5236 String,
5237 AudioFormat,
5238 String,
5239 String,
5240 Vec<SourceMode>,
5241 bool,
5242 bool,
5243 );
5244
5245 fn audio_format() -> impl Strategy<Value = AudioFormat> {
5246 prop_oneof![
5247 Just(AudioFormat::Mp3),
5248 Just(AudioFormat::Flac),
5249 Just(AudioFormat::Wav),
5250 ]
5251 }
5252
5253 fn source_mode() -> impl Strategy<Value = SourceMode> {
5254 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
5255 }
5256
5257 fn clip_id() -> impl Strategy<Value = String> {
5260 (0u8..8).prop_map(|n| format!("c{n}"))
5261 }
5262
5263 fn small_path() -> impl Strategy<Value = String> {
5264 (0u8..6).prop_map(|n| format!("path{n}"))
5265 }
5266
5267 fn manifest_path() -> impl Strategy<Value = String> {
5270 prop_oneof![
5271 1 => Just(String::new()),
5272 6 => small_path(),
5273 ]
5274 }
5275
5276 fn small_hash() -> impl Strategy<Value = String> {
5277 (0u8..4).prop_map(|n| format!("h{n}"))
5278 }
5279
5280 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
5281 (
5282 manifest_path(),
5283 audio_format(),
5284 small_hash(),
5285 small_hash(),
5286 0u64..4,
5287 any::<bool>(),
5288 )
5289 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
5290 ManifestEntry {
5291 path,
5292 format,
5293 meta_hash,
5294 art_hash,
5295 size,
5296 preserve,
5297 ..Default::default()
5298 }
5299 })
5300 }
5301
5302 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
5303 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
5304 }
5305
5306 fn local_file() -> impl Strategy<Value = LocalFile> {
5307 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
5308 }
5309
5310 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
5311 hash_map(clip_id(), local_file(), 0..8)
5312 }
5313
5314 fn source_status() -> impl Strategy<Value = SourceStatus> {
5315 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
5316 mode,
5317 fully_enumerated,
5318 })
5319 }
5320
5321 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5322 vec(source_status(), 0..5)
5323 }
5324
5325 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5326 vec(
5327 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
5328 mode: SourceMode::Copy,
5329 fully_enumerated,
5330 }),
5331 1..5,
5332 )
5333 }
5334
5335 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
5336 (
5337 small_path(),
5338 audio_format(),
5339 small_hash(),
5340 small_hash(),
5341 vec(source_mode(), 1..3),
5342 any::<bool>(),
5343 any::<bool>(),
5344 )
5345 }
5346
5347 fn build_desired(id: String, fields: DesiredFields) -> Desired {
5348 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
5349 let clip = Clip {
5350 id,
5351 title: "t".to_string(),
5352 ..Default::default()
5353 };
5354 Desired {
5355 lineage: LineageContext::own_root(&clip),
5356 clip,
5357 path,
5358 format,
5359 meta_hash,
5360 art_hash,
5361 modes,
5362 trashed,
5363 private,
5364 artifacts: Vec::new(),
5365 stems: None,
5366 }
5367 }
5368
5369 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
5372 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
5373 items
5374 .into_iter()
5375 .map(|(id, fields)| build_desired(id, fields))
5376 .collect()
5377 })
5378 }
5379
5380 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
5381 desired.iter().map(|d| d.clip.id.as_str()).collect()
5382 }
5383
5384 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
5387 desired
5388 .iter()
5389 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
5390 .map(|d| d.clip.id.as_str())
5391 .collect()
5392 }
5393
5394 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
5397 desired
5398 .iter()
5399 .filter(|d| !d.trashed)
5400 .map(|d| d.clip.id.as_str())
5401 .collect()
5402 }
5403
5404 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
5405 plan.actions
5406 .iter()
5407 .filter_map(|a| match a {
5408 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
5409 _ => None,
5410 })
5411 .collect()
5412 }
5413
5414 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
5415 plan.actions
5416 .iter()
5417 .filter_map(|a| match a {
5418 Action::Download { path, .. } | Action::Reformat { path, .. } => {
5419 Some(path.as_str())
5420 }
5421 Action::Rename { to, .. } => Some(to.as_str()),
5422 _ => None,
5423 })
5424 .collect()
5425 }
5426
5427 proptest! {
5428 #![proptest_config(ProptestConfig {
5429 cases: 256,
5430 failure_persistence: None,
5431 ..ProptestConfig::default()
5432 })]
5433
5434 #[test]
5437 fn inv1_desired_clip_deleted_only_when_fully_trashed(
5438 manifest in manifest_strategy(),
5439 desired in desired_strategy(),
5440 local in local_strategy(),
5441 sources in sources_strategy(),
5442 ) {
5443 let plan = reconcile(&manifest, &desired, &local, &sources);
5444 let present = desired_ids(&desired);
5445 let live = non_trashed_ids(&desired);
5446 for id in delete_clip_ids(&plan) {
5447 prop_assert!(
5448 !(present.contains(id) && live.contains(id)),
5449 "deleted a desired clip with a non-trashed duplicate: {id}"
5450 );
5451 }
5452 }
5453
5454 #[test]
5458 fn inv2_no_delete_when_any_mirror_unenumerated(
5459 manifest in manifest_strategy(),
5460 desired in desired_strategy(),
5461 local in local_strategy(),
5462 mut sources in sources_strategy(),
5463 ) {
5464 sources.push(SourceStatus {
5465 mode: SourceMode::Mirror,
5466 fully_enumerated: false,
5467 });
5468 let plan = reconcile(&manifest, &desired, &local, &sources);
5469 prop_assert_eq!(plan.deletes(), 0);
5470 }
5471
5472 #[test]
5474 fn inv3_all_copy_sources_means_no_deletes(
5475 manifest in manifest_strategy(),
5476 desired in desired_strategy(),
5477 local in local_strategy(),
5478 sources in copy_sources_strategy(),
5479 ) {
5480 let plan = reconcile(&manifest, &desired, &local, &sources);
5481 prop_assert_eq!(plan.deletes(), 0);
5482 }
5483
5484 #[test]
5487 fn inv4_plan_is_deterministic(
5488 manifest in manifest_strategy(),
5489 desired in desired_strategy(),
5490 local in local_strategy(),
5491 sources in sources_strategy(),
5492 ) {
5493 let plan = reconcile(&manifest, &desired, &local, &sources);
5494
5495 let again = reconcile(&manifest, &desired, &local, &sources);
5496 prop_assert_eq!(&plan, &again);
5497
5498 let mut desired_rev = desired.clone();
5499 desired_rev.reverse();
5500 let mut sources_rev = sources.clone();
5501 sources_rev.reverse();
5502 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
5503 prop_assert_eq!(&plan, &shuffled);
5504 }
5505
5506 #[test]
5508 fn inv5_every_delete_is_in_the_manifest(
5509 manifest in manifest_strategy(),
5510 desired in desired_strategy(),
5511 local in local_strategy(),
5512 sources in sources_strategy(),
5513 ) {
5514 let plan = reconcile(&manifest, &desired, &local, &sources);
5515 for id in delete_clip_ids(&plan) {
5516 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
5517 }
5518 }
5519
5520 #[test]
5523 fn inv6_never_deletes_protected_clip(
5524 manifest in manifest_strategy(),
5525 desired in desired_strategy(),
5526 local in local_strategy(),
5527 sources in sources_strategy(),
5528 ) {
5529 let plan = reconcile(&manifest, &desired, &local, &sources);
5530 let protected = protected_ids(&desired);
5531 for id in delete_clip_ids(&plan) {
5532 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
5533 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
5534 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
5535 }
5536 }
5537
5538 #[test]
5541 fn inv7_no_delete_unless_deletion_allowed(
5542 manifest in manifest_strategy(),
5543 desired in desired_strategy(),
5544 local in local_strategy(),
5545 sources in sources_strategy(),
5546 ) {
5547 let plan = reconcile(&manifest, &desired, &local, &sources);
5548 if !deletion_allowed(&sources) {
5549 prop_assert_eq!(plan.deletes(), 0);
5550 }
5551 }
5552
5553 #[test]
5555 fn inv8_at_most_one_delete_per_clip(
5556 manifest in manifest_strategy(),
5557 desired in desired_strategy(),
5558 local in local_strategy(),
5559 sources in sources_strategy(),
5560 ) {
5561 let plan = reconcile(&manifest, &desired, &local, &sources);
5562 let ids = delete_clip_ids(&plan);
5563 let unique: BTreeSet<&str> = ids.iter().copied().collect();
5564 prop_assert_eq!(ids.len(), unique.len());
5565 }
5566
5567 #[test]
5569 fn inv9_no_delete_with_empty_path(
5570 manifest in manifest_strategy(),
5571 desired in desired_strategy(),
5572 local in local_strategy(),
5573 sources in sources_strategy(),
5574 ) {
5575 let plan = reconcile(&manifest, &desired, &local, &sources);
5576 for action in &plan.actions {
5577 if let Action::Delete { path, .. } = action {
5578 prop_assert!(!path.is_empty(), "delete with an empty path");
5579 }
5580 }
5581 }
5582
5583 #[test]
5586 fn inv10_no_delete_aliases_a_write_target(
5587 manifest in manifest_strategy(),
5588 desired in desired_strategy(),
5589 local in local_strategy(),
5590 sources in sources_strategy(),
5591 ) {
5592 let plan = reconcile(&manifest, &desired, &local, &sources);
5593 let targets = write_target_paths(&plan);
5594 for action in &plan.actions {
5595 if let Action::Delete { path, .. } = action {
5596 prop_assert!(
5597 !targets.contains(path.as_str()),
5598 "delete path {path} aliases a write target"
5599 );
5600 }
5601 }
5602 }
5603 }
5604}