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 mis_rooted_clip_moves_never_deletes_even_when_deletion_is_armed() {
1962 let mut manifest = Manifest::new();
1968 manifest.insert(
1969 "child",
1970 entry("Creator/Root A/child.flac", AudioFormat::Flac, "m", "art"),
1971 );
1972 let d = vec![desired(
1973 "child",
1974 "Creator/Root B/child.flac",
1975 AudioFormat::Flac,
1976 "m",
1977 "art",
1978 )];
1979 let plan = reconcile(&manifest, &d, &local_present("child"), &mirror_ok());
1980
1981 assert_eq!(
1982 plan.actions,
1983 vec![Action::Rename {
1984 from: "Creator/Root A/child.flac".to_string(),
1985 to: "Creator/Root B/child.flac".to_string(),
1986 }],
1987 "a mis-rooted clip is moved, not deleted or re-downloaded"
1988 );
1989 assert_eq!(
1990 plan.deletes(),
1991 0,
1992 "deletion safety: a re-root deletes nothing"
1993 );
1994 assert_eq!(plan.downloads(), 0, "a re-root never re-fetches audio");
1995 }
1996
1997 #[test]
1998 fn format_change_reformats() {
1999 let mut manifest = Manifest::new();
2000 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2001 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
2002 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2003 assert_eq!(
2004 plan.actions,
2005 vec![Action::Reformat {
2006 clip: clip("a"),
2007 path: "a.mp3".to_string(),
2008 from_path: "a.flac".to_string(),
2009 from: AudioFormat::Flac,
2010 to: AudioFormat::Mp3,
2011 }]
2012 );
2013 }
2014
2015 #[test]
2016 fn format_change_takes_precedence_over_rename_and_retag() {
2017 let mut manifest = Manifest::new();
2020 manifest.insert(
2021 "a",
2022 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
2023 );
2024 let d = vec![desired(
2025 "a",
2026 "new/a.mp3",
2027 AudioFormat::Mp3,
2028 "new",
2029 "new-art",
2030 )];
2031 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2032 assert_eq!(plan.reformats(), 1);
2033 assert_eq!(plan.renames(), 0);
2034 assert_eq!(plan.retags(), 0);
2035 }
2036
2037 #[test]
2040 fn zero_length_file_downloads_even_when_hashes_match() {
2041 let mut manifest = Manifest::new();
2042 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2043 let local: HashMap<String, LocalFile> = [(
2044 "a".to_string(),
2045 LocalFile {
2046 exists: true,
2047 size: 0,
2048 },
2049 )]
2050 .into_iter()
2051 .collect();
2052 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2053 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2054 assert_eq!(plan.downloads(), 1);
2055 assert_eq!(plan.skips(), 0);
2056 }
2057
2058 #[test]
2059 fn missing_file_downloads_even_when_hashes_match() {
2060 let mut manifest = Manifest::new();
2061 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2062 let local: HashMap<String, LocalFile> = [(
2063 "a".to_string(),
2064 LocalFile {
2065 exists: false,
2066 size: 0,
2067 },
2068 )]
2069 .into_iter()
2070 .collect();
2071 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2072 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2073 assert_eq!(plan.downloads(), 1);
2074 }
2075
2076 #[test]
2077 fn absent_local_probe_treated_as_missing() {
2078 let mut manifest = Manifest::new();
2080 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2081 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2082 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2083 assert_eq!(plan.downloads(), 1);
2084 }
2085
2086 #[test]
2087 fn missing_file_download_wins_over_format_difference() {
2088 let mut manifest = Manifest::new();
2091 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2092 let local: HashMap<String, LocalFile> = [(
2093 "a".to_string(),
2094 LocalFile {
2095 exists: false,
2096 size: 0,
2097 },
2098 )]
2099 .into_iter()
2100 .collect();
2101 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
2102 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2103 assert_eq!(plan.downloads(), 1);
2104 assert_eq!(plan.reformats(), 0);
2105 }
2106
2107 #[test]
2110 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
2111 let mut trashed = clip("a");
2116 trashed.status = "complete".to_string();
2117 trashed.is_trashed = true;
2118 assert!(crate::is_downloadable(&trashed));
2119
2120 let mut manifest = Manifest::new();
2121 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2122 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2123 d.clip = trashed;
2124 d.trashed = true;
2125 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2126 assert_eq!(
2127 plan.actions,
2128 vec![Action::Delete {
2129 path: "a.flac".to_string(),
2130 clip_id: "a".to_string(),
2131 }]
2132 );
2133 }
2134
2135 #[test]
2136 fn trashed_clip_deletes_local_file() {
2137 let mut manifest = Manifest::new();
2138 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2139 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2140 d.trashed = true;
2141 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2142 assert_eq!(
2143 plan.actions,
2144 vec![Action::Delete {
2145 path: "a.flac".to_string(),
2146 clip_id: "a".to_string(),
2147 }]
2148 );
2149 }
2150
2151 #[test]
2152 fn trashed_clip_not_in_manifest_skips() {
2153 let manifest = Manifest::new();
2155 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2156 d.trashed = true;
2157 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2158 assert_eq!(
2159 plan.actions,
2160 vec![Action::Skip {
2161 clip_id: "a".to_string()
2162 }]
2163 );
2164 }
2165
2166 #[test]
2167 fn private_clip_is_kept() {
2168 let mut manifest = Manifest::new();
2169 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2170 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2171 d.private = true;
2172 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2173 assert_eq!(
2174 plan.actions,
2175 vec![Action::Skip {
2176 clip_id: "a".to_string()
2177 }]
2178 );
2179 }
2180
2181 #[test]
2182 fn private_beats_trashed_never_deletes() {
2183 let mut manifest = Manifest::new();
2185 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2186 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2187 d.trashed = true;
2188 d.private = true;
2189 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2190 assert_eq!(plan.deletes(), 0);
2191 assert_eq!(plan.skips(), 1);
2192 }
2193
2194 #[test]
2195 fn copy_held_trashed_clip_is_not_deleted() {
2196 let mut manifest = Manifest::new();
2199 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2200 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2201 d.modes = vec![SourceMode::Copy];
2202 d.trashed = true;
2203 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2204 assert_eq!(plan.deletes(), 0);
2205 assert_eq!(
2206 plan.actions,
2207 vec![Action::Skip {
2208 clip_id: "a".to_string()
2209 }]
2210 );
2211 }
2212
2213 #[test]
2216 fn absent_clip_deleted_when_all_mirrors_enumerated() {
2217 let mut manifest = Manifest::new();
2218 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2219 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2220 assert_eq!(
2221 plan.actions,
2222 vec![Action::Delete {
2223 path: "gone.flac".to_string(),
2224 clip_id: "gone".to_string(),
2225 }]
2226 );
2227 }
2228
2229 #[test]
2230 fn absent_clip_kept_when_any_mirror_not_enumerated() {
2231 let mut manifest = Manifest::new();
2232 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2233 let sources = vec![
2234 SourceStatus {
2235 mode: SourceMode::Mirror,
2236 fully_enumerated: true,
2237 },
2238 SourceStatus {
2239 mode: SourceMode::Mirror,
2240 fully_enumerated: false,
2241 },
2242 ];
2243 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2244 assert_eq!(plan.deletes(), 0);
2245 assert_eq!(
2246 plan.actions,
2247 vec![Action::Skip {
2248 clip_id: "gone".to_string()
2249 }]
2250 );
2251 }
2252
2253 #[test]
2254 fn empty_listing_cannot_cause_deletion() {
2255 let mut manifest = Manifest::new();
2258 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2259 let sources = vec![SourceStatus {
2260 mode: SourceMode::Mirror,
2261 fully_enumerated: false,
2262 }];
2263 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2264 assert_eq!(plan.deletes(), 0);
2265 assert_eq!(plan.skips(), 1);
2266 }
2267
2268 #[test]
2269 fn no_mirror_sources_means_no_deletion() {
2270 let mut manifest = Manifest::new();
2272 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2273 let copy_only = vec![SourceStatus {
2274 mode: SourceMode::Copy,
2275 fully_enumerated: true,
2276 }];
2277 assert_eq!(
2278 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
2279 0
2280 );
2281 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
2282 }
2283
2284 #[test]
2285 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
2286 let mut manifest = Manifest::new();
2287 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2288 let sources = vec![
2289 SourceStatus {
2290 mode: SourceMode::Copy,
2291 fully_enumerated: true,
2292 },
2293 SourceStatus {
2294 mode: SourceMode::Mirror,
2295 fully_enumerated: false,
2296 },
2297 ];
2298 assert_eq!(
2299 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
2300 0
2301 );
2302 }
2303
2304 #[test]
2305 fn playlist_authoritative_requires_all_conditions() {
2306 assert!(playlist_authoritative(true, false, false));
2308 assert!(!playlist_authoritative(false, false, false));
2310 assert!(!playlist_authoritative(true, true, false));
2312 assert!(!playlist_authoritative(true, false, true));
2314 assert!(!playlist_authoritative(false, true, true));
2316 }
2317
2318 #[test]
2319 fn area_fully_enumerated_applies_empty_mirror_guard() {
2320 assert!(area_fully_enumerated(true, false, SourceMode::Mirror));
2322 assert!(!area_fully_enumerated(true, true, SourceMode::Mirror));
2324 assert!(area_fully_enumerated(true, true, SourceMode::Copy));
2326 assert!(area_fully_enumerated(true, false, SourceMode::Copy));
2328 assert!(!area_fully_enumerated(false, false, SourceMode::Mirror));
2330 assert!(!area_fully_enumerated(false, true, SourceMode::Copy));
2331 }
2332
2333 #[test]
2334 fn narrows_downloads_only_when_no_deletion_and_no_full_library() {
2335 assert!(narrows_downloads(false, false));
2337 assert!(!narrows_downloads(true, false));
2339 assert!(!narrows_downloads(false, true));
2341 assert!(!narrows_downloads(true, true));
2343 }
2344
2345 #[test]
2346 fn narrowing_never_coexists_with_deletion() {
2347 for can_delete in [false, true] {
2348 for lib_auth in [false, true] {
2349 assert!(
2350 !(narrows_downloads(can_delete, lib_auth) && can_delete),
2351 "truncate must imply !can_delete"
2352 );
2353 }
2354 }
2355 }
2356
2357 #[test]
2358 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
2359 let mut manifest = Manifest::new();
2363 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
2364 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2365 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
2366 held.modes = vec![SourceMode::Copy];
2367 let local: HashMap<String, LocalFile> = [
2368 ("keep".to_string(), present(100)),
2369 ("gone".to_string(), present(100)),
2370 ]
2371 .into_iter()
2372 .collect();
2373 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
2374 assert!(plan.actions.contains(&Action::Skip {
2375 clip_id: "keep".to_string()
2376 }));
2377 assert!(plan.actions.contains(&Action::Delete {
2378 path: "gone.flac".to_string(),
2379 clip_id: "gone".to_string(),
2380 }));
2381 assert!(
2383 !plan
2384 .actions
2385 .iter()
2386 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
2387 );
2388 }
2389
2390 #[test]
2393 fn orphan_with_preserve_marker_is_kept() {
2394 let mut manifest = Manifest::new();
2397 manifest.insert(
2398 "gone",
2399 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
2400 );
2401 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2402 assert_eq!(plan.deletes(), 0);
2403 assert_eq!(
2404 plan.actions,
2405 vec![Action::Skip {
2406 clip_id: "gone".to_string()
2407 }]
2408 );
2409 }
2410
2411 #[test]
2412 fn trashed_clip_with_preserve_marker_is_kept() {
2413 let mut manifest = Manifest::new();
2416 manifest.insert(
2417 "a",
2418 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2419 );
2420 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2421 d.trashed = true;
2422 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2423 assert_eq!(plan.deletes(), 0);
2424 assert_eq!(plan.skips(), 1);
2425 }
2426
2427 #[test]
2430 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
2431 let mut manifest = Manifest::new();
2433 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2434 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2435 d.trashed = true;
2436 let sources = vec![SourceStatus {
2437 mode: SourceMode::Mirror,
2438 fully_enumerated: false,
2439 }];
2440 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2441 assert_eq!(plan.deletes(), 0);
2442 assert_eq!(plan.skips(), 1);
2443 }
2444
2445 #[test]
2446 fn trashed_clip_kept_when_sources_empty() {
2447 let mut manifest = Manifest::new();
2450 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2451 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2452 d.trashed = true;
2453 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
2454 assert_eq!(plan.deletes(), 0);
2455 assert_eq!(plan.skips(), 1);
2456 }
2457
2458 #[test]
2459 fn failed_copy_listing_suppresses_orphan_deletion() {
2460 let mut manifest = Manifest::new();
2463 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2464 let sources = vec![
2465 SourceStatus {
2466 mode: SourceMode::Mirror,
2467 fully_enumerated: true,
2468 },
2469 SourceStatus {
2470 mode: SourceMode::Copy,
2471 fully_enumerated: false,
2472 },
2473 ];
2474 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2475 assert_eq!(plan.deletes(), 0);
2476 }
2477
2478 #[test]
2479 fn failed_copy_listing_suppresses_trashed_deletion() {
2480 let mut manifest = Manifest::new();
2481 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2482 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2483 d.trashed = true;
2484 let sources = vec![
2485 SourceStatus {
2486 mode: SourceMode::Mirror,
2487 fully_enumerated: true,
2488 },
2489 SourceStatus {
2490 mode: SourceMode::Copy,
2491 fully_enumerated: false,
2492 },
2493 ];
2494 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2495 assert_eq!(plan.deletes(), 0);
2496 assert_eq!(plan.skips(), 1);
2497 }
2498
2499 #[test]
2500 fn empty_path_entry_never_deletes() {
2501 let mut manifest = Manifest::new();
2504 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
2505 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2506 assert_eq!(plan.deletes(), 0);
2507 assert_eq!(
2508 plan.actions,
2509 vec![Action::Skip {
2510 clip_id: "gone".to_string()
2511 }]
2512 );
2513 }
2514
2515 #[test]
2518 fn delete_suppressed_when_path_aliases_rename_target() {
2519 let mut manifest = Manifest::new();
2522 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
2523 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
2524 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
2525 let local: HashMap<String, LocalFile> = [
2526 ("a".to_string(), present(100)),
2527 ("b".to_string(), present(100)),
2528 ]
2529 .into_iter()
2530 .collect();
2531 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2532 assert!(plan.actions.contains(&Action::Rename {
2533 from: "old/a.flac".to_string(),
2534 to: "new/a.flac".to_string(),
2535 }));
2536 assert!(
2538 !plan
2539 .actions
2540 .iter()
2541 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
2542 );
2543 assert!(plan.actions.contains(&Action::Skip {
2544 clip_id: "b".to_string()
2545 }));
2546 }
2547
2548 #[test]
2549 fn delete_suppressed_when_path_aliases_download_target() {
2550 let mut manifest = Manifest::new();
2552 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
2553 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
2554 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2555 assert!(
2556 !plan
2557 .actions
2558 .iter()
2559 .any(|a| matches!(a, Action::Delete { .. }))
2560 );
2561 assert_eq!(plan.downloads(), 1);
2562 }
2563
2564 #[test]
2565 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
2566 let mut actions = vec![
2571 Action::Rename {
2572 from: "old/song.flac".to_string(),
2573 to: "new/cover.jpg".to_string(),
2574 },
2575 Action::DeleteArtifact {
2576 kind: ArtifactKind::CoverJpg,
2577 path: "new/cover.jpg".to_string(),
2578 owner_id: "a".to_string(),
2579 },
2580 ];
2581 suppress_path_aliasing(&mut actions);
2582 assert!(
2584 !actions
2585 .iter()
2586 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
2587 "a sidecar delete must not alias a rename target"
2588 );
2589 assert!(actions.contains(&Action::Skip {
2590 clip_id: "a".to_string()
2591 }));
2592 assert!(actions.contains(&Action::Rename {
2594 from: "old/song.flac".to_string(),
2595 to: "new/cover.jpg".to_string(),
2596 }));
2597 }
2598
2599 #[test]
2600 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
2601 let mut actions = vec![
2604 Action::WriteArtifact {
2605 kind: ArtifactKind::FolderJpg,
2606 path: "creator/album/folder.jpg".to_string(),
2607 source_url: "https://art/large.jpg".to_string(),
2608 hash: "h".to_string(),
2609 owner_id: "root".to_string(),
2610 content: None,
2611 },
2612 Action::DeleteArtifact {
2613 kind: ArtifactKind::FolderJpg,
2614 path: "creator/album/folder.jpg".to_string(),
2615 owner_id: "root-old".to_string(),
2616 },
2617 ];
2618 suppress_path_aliasing(&mut actions);
2619 assert!(
2620 !actions
2621 .iter()
2622 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
2623 );
2624 assert!(actions.contains(&Action::Skip {
2625 clip_id: "root-old".to_string()
2626 }));
2627 }
2628
2629 #[test]
2632 fn duplicate_trashed_does_not_defeat_copy_sibling() {
2633 let mut manifest = Manifest::new();
2636 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2637 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2638 copy_entry.modes = vec![SourceMode::Copy];
2639 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2640 trashed_entry.modes = vec![SourceMode::Mirror];
2641 trashed_entry.trashed = true;
2642 let plan = reconcile(
2643 &manifest,
2644 &[copy_entry, trashed_entry],
2645 &local_present("a"),
2646 &mirror_ok(),
2647 );
2648 assert_eq!(plan.deletes(), 0);
2649 assert_eq!(plan.skips(), 1);
2650 }
2651
2652 #[test]
2653 fn duplicate_trashed_does_not_defeat_private_sibling() {
2654 let mut manifest = Manifest::new();
2655 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2656 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2657 private_entry.private = true;
2658 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2659 trashed_entry.trashed = true;
2660 let plan = reconcile(
2661 &manifest,
2662 &[private_entry, trashed_entry],
2663 &local_present("a"),
2664 &mirror_ok(),
2665 );
2666 assert_eq!(plan.deletes(), 0);
2667 assert_eq!(plan.skips(), 1);
2668 }
2669
2670 #[test]
2671 fn duplicate_trashed_deletes_only_when_all_trashed() {
2672 let mut manifest = Manifest::new();
2674 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2675 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2676 first.trashed = true;
2677 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2678 second.trashed = true;
2679 let plan = reconcile(
2680 &manifest,
2681 &[first, second],
2682 &local_present("a"),
2683 &mirror_ok(),
2684 );
2685 assert_eq!(plan.deletes(), 1);
2686 }
2687
2688 #[test]
2689 fn duplicate_desired_unions_modes() {
2690 let mut manifest = Manifest::new();
2692 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2693 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2694 mirror_entry.modes = vec![SourceMode::Mirror];
2695 mirror_entry.trashed = true;
2696 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2697 copy_entry.modes = vec![SourceMode::Copy];
2698 let plan = reconcile(
2699 &manifest,
2700 &[mirror_entry, copy_entry],
2701 &local_present("a"),
2702 &mirror_ok(),
2703 );
2704 assert_eq!(plan.deletes(), 0);
2706 }
2707
2708 #[test]
2711 fn private_new_clip_downloads() {
2712 let manifest = Manifest::new();
2715 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2716 d.private = true;
2717 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2718 assert_eq!(plan.downloads(), 1);
2719 }
2720
2721 #[test]
2722 fn private_zero_length_file_redownloads() {
2723 let mut manifest = Manifest::new();
2724 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2725 let local: HashMap<String, LocalFile> = [(
2726 "a".to_string(),
2727 LocalFile {
2728 exists: true,
2729 size: 0,
2730 },
2731 )]
2732 .into_iter()
2733 .collect();
2734 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2735 d.private = true;
2736 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2737 assert_eq!(plan.downloads(), 1);
2738 }
2739
2740 #[test]
2741 fn private_meta_change_retags() {
2742 let mut manifest = Manifest::new();
2743 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2744 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2745 d.private = true;
2746 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2747 assert_eq!(plan.retags(), 1);
2748 assert_eq!(plan.deletes(), 0);
2749 }
2750
2751 #[test]
2752 fn absent_private_clip_protected_by_preserve_marker() {
2753 let mut manifest = Manifest::new();
2756 manifest.insert(
2757 "a",
2758 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2759 );
2760 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2761 assert_eq!(plan.deletes(), 0);
2762 assert_eq!(plan.skips(), 1);
2763 }
2764
2765 #[test]
2768 fn output_is_deterministic_regardless_of_input_order() {
2769 let mut manifest = Manifest::new();
2770 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2771 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2772 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2773 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2774 .iter()
2775 .map(|id| (id.to_string(), present(100)))
2776 .collect();
2777
2778 let forward = vec![
2779 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2780 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2781 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2782 ];
2783 let mut reversed = forward.clone();
2784 reversed.reverse();
2785
2786 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2787 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2788 assert_eq!(p1.actions, p2.actions);
2789
2790 let ids: Vec<&str> = p1
2793 .actions
2794 .iter()
2795 .map(|a| match a {
2796 Action::Skip { clip_id } => clip_id.as_str(),
2797 Action::Retag { clip, .. } => clip.id.as_str(),
2798 Action::Download { clip, .. } => clip.id.as_str(),
2799 Action::Delete { clip_id, .. } => clip_id.as_str(),
2800 Action::Reformat { clip, .. } => clip.id.as_str(),
2801 Action::Rename { to, .. } => to.as_str(),
2802 Action::WriteArtifact { owner_id, .. }
2803 | Action::DeleteArtifact { owner_id, .. }
2804 | Action::MoveArtifact { owner_id, .. } => owner_id.as_str(),
2805 Action::WriteStem { clip_id, .. }
2806 | Action::DeleteStem { clip_id, .. }
2807 | Action::MoveStem { clip_id, .. } => clip_id.as_str(),
2808 })
2809 .collect();
2810 assert_eq!(ids, ["a", "b", "c", "z"]);
2811 }
2812
2813 #[test]
2814 fn empty_inputs_do_not_panic() {
2815 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2816 assert!(plan.is_empty());
2817 assert_eq!(plan.len(), 0);
2818 }
2819
2820 #[test]
2821 fn empty_desired_with_full_manifest_deletes_all() {
2822 let mut manifest = Manifest::new();
2823 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2824 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2825 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2826 assert_eq!(plan.deletes(), 2);
2827 }
2828
2829 #[test]
2830 fn full_desired_with_empty_manifest_downloads_all() {
2831 let d = vec![
2832 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2833 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2834 ];
2835 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2836 assert_eq!(plan.downloads(), 2);
2837 }
2838
2839 #[test]
2840 fn plan_counts_sum_to_len() {
2841 let mut manifest = Manifest::new();
2842 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2843 manifest.insert(
2844 "retag",
2845 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2846 );
2847 manifest.insert(
2848 "reformat",
2849 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2850 );
2851 manifest.insert(
2852 "rename",
2853 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2854 );
2855 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2856 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2857 .iter()
2858 .map(|id| (id.to_string(), present(100)))
2859 .collect();
2860 let d = vec![
2861 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2862 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2863 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2864 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2865 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2866 ];
2867 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2868 let summed = plan.downloads()
2869 + plan.reformats()
2870 + plan.retags()
2871 + plan.renames()
2872 + plan.deletes()
2873 + plan.skips();
2874 assert_eq!(summed, plan.len());
2875 assert_eq!(plan.downloads(), 1);
2876 assert_eq!(plan.reformats(), 1);
2877 assert_eq!(plan.retags(), 1);
2878 assert_eq!(plan.renames(), 1);
2879 assert_eq!(plan.deletes(), 1);
2880 assert_eq!(plan.skips(), 1);
2881 }
2882
2883 fn cover(path: &str, hash: &str) -> ArtifactState {
2886 ArtifactState {
2887 path: path.to_string(),
2888 hash: hash.to_string(),
2889 }
2890 }
2891
2892 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2893 DesiredArtifact {
2894 kind,
2895 path: path.to_string(),
2896 source_url: url.to_string(),
2897 hash: hash.to_string(),
2898 content: None,
2899 }
2900 }
2901
2902 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2904 DesiredArtifact {
2905 kind,
2906 path: path.to_string(),
2907 source_url: String::new(),
2908 hash: content_hash(body),
2909 content: Some(body.to_string()),
2910 }
2911 }
2912
2913 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2915 Desired {
2916 artifacts: arts,
2917 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2918 }
2919 }
2920
2921 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2923 ManifestEntry {
2924 cover_jpg: Some(cover(cover_path, cover_hash)),
2925 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2926 }
2927 }
2928
2929 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2930 plan.actions
2931 .iter()
2932 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2933 .collect()
2934 }
2935
2936 #[test]
2937 fn write_artifact_emitted_when_manifest_lacks_it() {
2938 let mut manifest = Manifest::new();
2941 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2942 let d = vec![desired_arts(
2943 "a",
2944 vec![art(
2945 ArtifactKind::CoverJpg,
2946 "a/cover.jpg",
2947 "https://art/a",
2948 "h1",
2949 )],
2950 )];
2951 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2952 assert_eq!(plan.artifact_writes(), 1);
2953 assert_eq!(plan.artifact_deletes(), 0);
2954 assert_eq!(plan.skips(), 1);
2955 assert_eq!(
2956 write_artifacts(&plan)[0],
2957 &Action::WriteArtifact {
2958 kind: ArtifactKind::CoverJpg,
2959 path: "a/cover.jpg".to_string(),
2960 source_url: "https://art/a".to_string(),
2961 hash: "h1".to_string(),
2962 owner_id: "a".to_string(),
2963 content: None,
2964 }
2965 );
2966 }
2967
2968 #[test]
2969 fn write_artifact_emitted_when_hash_differs() {
2970 let mut manifest = Manifest::new();
2973 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2974 let d = vec![desired_arts(
2975 "a",
2976 vec![art(
2977 ArtifactKind::CoverJpg,
2978 "a/cover.jpg",
2979 "https://art/a",
2980 "new",
2981 )],
2982 )];
2983 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2984 assert_eq!(plan.artifact_writes(), 1);
2985 assert_eq!(plan.artifact_deletes(), 0);
2986 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2987 assert_eq!(hash, "new");
2988 } else {
2989 panic!("expected a WriteArtifact");
2990 }
2991 }
2992
2993 #[test]
2994 fn write_artifact_skipped_when_hash_matches() {
2995 let mut manifest = Manifest::new();
2997 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2998 let d = vec![desired_arts(
2999 "a",
3000 vec![art(
3001 ArtifactKind::CoverJpg,
3002 "a/cover.jpg",
3003 "https://art/a",
3004 "h1",
3005 )],
3006 )];
3007 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3008 assert_eq!(plan.artifact_writes(), 0);
3009 assert_eq!(plan.artifact_deletes(), 0);
3010 assert_eq!(
3011 plan.actions,
3012 vec![Action::Skip {
3013 clip_id: "a".to_string()
3014 }]
3015 );
3016 }
3017
3018 #[test]
3019 fn removed_kind_cover_is_kept_not_deleted() {
3020 let mut manifest = Manifest::new();
3025 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3026 let d = vec![desired_arts("a", vec![])];
3027 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3028 assert_eq!(plan.artifact_deletes(), 0);
3029 assert_eq!(plan.artifact_writes(), 0);
3030 assert_eq!(plan.deletes(), 0);
3032 assert_eq!(
3033 plan.actions,
3034 vec![Action::Skip {
3035 clip_id: "a".to_string()
3036 }]
3037 );
3038 assert!(!plan.actions.iter().any(|a| matches!(
3039 a,
3040 Action::DeleteArtifact {
3041 kind: ArtifactKind::CoverJpg,
3042 ..
3043 }
3044 )));
3045 }
3046
3047 #[test]
3048 fn delete_artifact_never_on_incomplete_listing() {
3049 let mut manifest = Manifest::new();
3054 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3055 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
3056 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
3057 let sources = vec![SourceStatus {
3058 mode: SourceMode::Mirror,
3059 fully_enumerated: false,
3060 }];
3061 let local: HashMap<String, LocalFile> = [
3062 ("a".to_string(), present(100)),
3063 ("b".to_string(), present(100)),
3064 ]
3065 .into_iter()
3066 .collect();
3067 let plan = reconcile(&manifest, &d, &local, &sources);
3068 assert_eq!(plan.artifact_deletes(), 0);
3069 assert_eq!(plan.deletes(), 0);
3070 }
3071
3072 #[test]
3073 fn delete_artifact_never_when_entry_preserved() {
3074 let mut manifest = Manifest::new();
3077 let preserved = ManifestEntry {
3078 preserve: true,
3079 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
3080 };
3081 manifest.insert("a", preserved);
3082 let d = vec![desired_arts("a", vec![])];
3083 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3084 assert_eq!(plan.artifact_deletes(), 0);
3085 }
3086
3087 #[test]
3088 fn co_delete_never_when_path_empty() {
3089 let mut manifest = Manifest::new();
3093 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
3094 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3095 assert_eq!(plan.deletes(), 1);
3096 assert_eq!(plan.artifact_deletes(), 0);
3097 }
3098
3099 #[test]
3100 fn co_delete_absent_clip_deletes_audio_and_cover() {
3101 let mut manifest = Manifest::new();
3104 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3105 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3106 assert_eq!(plan.deletes(), 1);
3107 assert_eq!(plan.artifact_deletes(), 1);
3108 assert!(plan.actions.contains(&Action::Delete {
3109 path: "gone.flac".to_string(),
3110 clip_id: "gone".to_string(),
3111 }));
3112 assert!(plan.actions.contains(&Action::DeleteArtifact {
3113 kind: ArtifactKind::CoverJpg,
3114 path: "gone/cover.jpg".to_string(),
3115 owner_id: "gone".to_string(),
3116 }));
3117 }
3118
3119 #[test]
3120 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
3121 let mut manifest = Manifest::new();
3123 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3124 let sources = vec![SourceStatus {
3125 mode: SourceMode::Mirror,
3126 fully_enumerated: false,
3127 }];
3128 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
3129 assert_eq!(plan.deletes(), 0);
3130 assert_eq!(plan.artifact_deletes(), 0);
3131 }
3132
3133 #[test]
3134 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
3135 let mut manifest = Manifest::new();
3137 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3138 let mut d = desired_arts("a", vec![]);
3139 d.trashed = true;
3140 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3141 assert_eq!(plan.deletes(), 1);
3142 assert_eq!(plan.artifact_deletes(), 1);
3143 }
3144
3145 #[test]
3146 fn co_delete_trashed_suppressed_when_not_enumerated() {
3147 let mut manifest = Manifest::new();
3149 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3150 let mut d = desired_arts("a", vec![]);
3151 d.trashed = true;
3152 let sources = vec![SourceStatus {
3153 mode: SourceMode::Mirror,
3154 fully_enumerated: false,
3155 }];
3156 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
3157 assert_eq!(plan.deletes(), 0);
3158 assert_eq!(plan.artifact_deletes(), 0);
3159 assert_eq!(plan.skips(), 1);
3160 }
3161
3162 #[test]
3163 fn co_delete_trashed_suppressed_when_preserved() {
3164 let mut manifest = Manifest::new();
3166 let preserved = ManifestEntry {
3167 preserve: true,
3168 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
3169 };
3170 manifest.insert("a", preserved);
3171 let mut d = desired_arts("a", vec![]);
3172 d.trashed = true;
3173 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3174 assert_eq!(plan.deletes(), 0);
3175 assert_eq!(plan.artifact_deletes(), 0);
3176 }
3177
3178 #[test]
3181 fn details_sidecar_written_with_inline_content_when_slot_absent() {
3182 let mut manifest = Manifest::new();
3185 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3186 let d = vec![desired_arts(
3187 "a",
3188 vec![text_art(
3189 ArtifactKind::DetailsTxt,
3190 "a.details.txt",
3191 "Title: A\n",
3192 )],
3193 )];
3194 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3195 assert_eq!(plan.artifact_writes(), 1);
3196 assert_eq!(plan.artifact_deletes(), 0);
3197 assert_eq!(
3198 write_artifacts(&plan)[0],
3199 &Action::WriteArtifact {
3200 kind: ArtifactKind::DetailsTxt,
3201 path: "a.details.txt".to_string(),
3202 source_url: String::new(),
3203 hash: content_hash("Title: A\n"),
3204 owner_id: "a".to_string(),
3205 content: Some("Title: A\n".to_string()),
3206 }
3207 );
3208 }
3209
3210 #[test]
3211 fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
3212 let mut manifest = Manifest::new();
3217 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3218 let body = "[re:rs-suno]\nla la\n";
3219 let d = vec![desired_arts(
3220 "a",
3221 vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
3222 )];
3223 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3224 assert_eq!(plan.artifact_writes(), 1);
3225 assert_eq!(plan.artifact_deletes(), 0);
3226 assert_eq!(
3227 write_artifacts(&plan)[0],
3228 &Action::WriteArtifact {
3229 kind: ArtifactKind::Lrc,
3230 path: "a.lrc".to_string(),
3231 source_url: String::new(),
3232 hash: content_hash(body),
3233 owner_id: "a".to_string(),
3234 content: Some(body.to_string()),
3235 }
3236 );
3237 }
3238
3239 #[test]
3240 fn text_sidecars_skipped_when_hash_and_path_match() {
3241 let mut manifest = Manifest::new();
3243 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3244 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3245 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
3246 manifest.insert("a", e);
3247 let d = vec![desired_arts(
3248 "a",
3249 vec![
3250 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
3251 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
3252 ],
3253 )];
3254 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3255 assert_eq!(plan.artifact_writes(), 0);
3256 assert_eq!(plan.artifact_deletes(), 0);
3257 }
3258
3259 #[test]
3260 fn details_rewritten_when_content_hash_differs() {
3261 let mut manifest = Manifest::new();
3264 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3265 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
3266 manifest.insert("a", e);
3267 let d = vec![desired_arts(
3268 "a",
3269 vec![text_art(
3270 ArtifactKind::DetailsTxt,
3271 "a.details.txt",
3272 "Title: New\n",
3273 )],
3274 )];
3275 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3276 assert_eq!(plan.artifact_writes(), 1);
3277 assert_eq!(plan.artifact_deletes(), 0);
3278 }
3279
3280 #[test]
3281 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
3282 let mut manifest = Manifest::new();
3286 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3287 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
3288 manifest.insert("a", e);
3289 let d = vec![desired_arts(
3290 "a",
3291 vec![text_art(
3292 ArtifactKind::LyricsTxt,
3293 "a.lyrics.txt",
3294 "new words\n",
3295 )],
3296 )];
3297 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3298 assert_eq!(plan.artifact_writes(), 1);
3300 assert_eq!(plan.retags(), 0);
3301 }
3302
3303 #[test]
3304 fn text_sidecar_relocated_when_path_differs() {
3305 let mut manifest = Manifest::new();
3308 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3309 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
3310 manifest.insert("a", e);
3311 let d = vec![desired_arts(
3312 "a",
3313 vec![text_art(
3314 ArtifactKind::DetailsTxt,
3315 "new/a.details.txt",
3316 "Title: A\n",
3317 )],
3318 )];
3319 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3320 assert_eq!(plan.artifact_writes(), 1);
3321 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3322 assert_eq!(path, "new/a.details.txt");
3323 } else {
3324 panic!("expected a WriteArtifact");
3325 }
3326 }
3327
3328 #[test]
3329 fn fetched_sidecar_path_drift_emits_move() {
3330 let mut manifest = Manifest::new();
3333 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3334 e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3335 manifest.insert("a", e);
3336 let d = vec![desired_arts(
3337 "a",
3338 vec![art(
3339 ArtifactKind::CoverJpg,
3340 "new/cover.jpg",
3341 "https://art/large.jpg",
3342 "arthash",
3343 )],
3344 )];
3345 let local: HashMap<String, LocalFile> = [
3346 ("a".to_string(), present(100)),
3347 ("old/cover.jpg".to_string(), present(50)),
3348 ]
3349 .into_iter()
3350 .collect();
3351 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3352 assert_eq!(plan.artifact_moves(), 1);
3353 assert_eq!(plan.artifact_writes(), 0);
3354 assert!(plan.actions.contains(&Action::MoveArtifact {
3355 kind: ArtifactKind::CoverJpg,
3356 from: "old/cover.jpg".to_string(),
3357 to: "new/cover.jpg".to_string(),
3358 source_url: "https://art/large.jpg".to_string(),
3359 hash: "arthash".to_string(),
3360 owner_id: "a".to_string(),
3361 }));
3362 }
3363
3364 #[test]
3365 fn sidecar_hash_drift_emits_write_not_move() {
3366 let mut manifest = Manifest::new();
3368 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3369 e.cover_jpg = Some(cover("old/cover.jpg", "oldhash"));
3370 manifest.insert("a", e);
3371 let d = vec![desired_arts(
3372 "a",
3373 vec![art(
3374 ArtifactKind::CoverJpg,
3375 "new/cover.jpg",
3376 "https://art/large.jpg",
3377 "newhash",
3378 )],
3379 )];
3380 let local: HashMap<String, LocalFile> = [
3381 ("a".to_string(), present(100)),
3382 ("old/cover.jpg".to_string(), present(50)),
3383 ]
3384 .into_iter()
3385 .collect();
3386 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3387 assert_eq!(plan.artifact_moves(), 0);
3388 assert_eq!(plan.artifact_writes(), 1);
3389 }
3390
3391 #[test]
3392 fn inline_sidecar_path_drift_stays_a_write() {
3393 let mut manifest = Manifest::new();
3396 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3397 e.lyrics_txt = Some(cover("old/a.lyrics.txt", &content_hash("words\n")));
3398 manifest.insert("a", e);
3399 let d = vec![desired_arts(
3400 "a",
3401 vec![text_art(
3402 ArtifactKind::LyricsTxt,
3403 "new/a.lyrics.txt",
3404 "words\n",
3405 )],
3406 )];
3407 let local: HashMap<String, LocalFile> = [
3408 ("a".to_string(), present(100)),
3409 ("old/a.lyrics.txt".to_string(), present(50)),
3410 ]
3411 .into_iter()
3412 .collect();
3413 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3414 assert_eq!(plan.artifact_moves(), 0);
3415 assert_eq!(plan.artifact_writes(), 1);
3416 }
3417
3418 #[test]
3419 fn sidecar_move_downgrades_to_write_when_old_file_absent() {
3420 let mut manifest = Manifest::new();
3423 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3424 e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3425 manifest.insert("a", e);
3426 let d = vec![desired_arts(
3427 "a",
3428 vec![art(
3429 ArtifactKind::CoverJpg,
3430 "new/cover.jpg",
3431 "https://art/large.jpg",
3432 "arthash",
3433 )],
3434 )];
3435 let local: HashMap<String, LocalFile> = [
3436 ("a".to_string(), present(100)),
3437 (
3438 "old/cover.jpg".to_string(),
3439 LocalFile {
3440 exists: false,
3441 size: 0,
3442 },
3443 ),
3444 ]
3445 .into_iter()
3446 .collect();
3447 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3448 assert_eq!(plan.artifact_moves(), 0);
3449 assert_eq!(plan.artifact_writes(), 1);
3450 }
3451
3452 #[test]
3453 fn move_target_suppresses_a_colliding_delete() {
3454 let mut manifest = Manifest::new();
3457 let mut a = entry("a.flac", AudioFormat::Flac, "m", "art");
3458 a.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3459 manifest.insert("a", a);
3460 let mut b = entry("b.flac", AudioFormat::Flac, "m", "art");
3463 b.details_txt = Some(cover("new/cover.jpg", "bh"));
3464 manifest.insert("b", b);
3465 let d = vec![
3466 desired_arts(
3467 "a",
3468 vec![art(
3469 ArtifactKind::CoverJpg,
3470 "new/cover.jpg",
3471 "https://art/large.jpg",
3472 "arthash",
3473 )],
3474 ),
3475 desired_arts("b", vec![]),
3476 ];
3477 let local: HashMap<String, LocalFile> = [
3478 ("a".to_string(), present(100)),
3479 ("b".to_string(), present(100)),
3480 ("old/cover.jpg".to_string(), present(50)),
3481 ("new/cover.jpg".to_string(), present(50)),
3482 ]
3483 .into_iter()
3484 .collect();
3485 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3486 assert_eq!(plan.artifact_moves(), 1);
3487 assert!(!plan.actions.iter().any(|a| matches!(
3489 a,
3490 Action::DeleteArtifact { path, .. } if path == "new/cover.jpg"
3491 )));
3492 }
3493
3494 #[test]
3495 fn stem_path_drift_emits_move() {
3496 let mut manifest = Manifest::new();
3499 manifest.insert(
3500 "a",
3501 entry_with_stems("a", &[("voc", "old.stems/voc.mp3", "h1")]),
3502 );
3503 let d = vec![stem_desired(
3504 "a",
3505 Some(vec![dstem("voc", "new.stems/voc.mp3", "h1")]),
3506 )];
3507 let local: HashMap<String, LocalFile> = [
3508 ("a".to_string(), present(100)),
3509 ("old.stems/voc.mp3".to_string(), present(50)),
3510 ]
3511 .into_iter()
3512 .collect();
3513 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3514 assert_eq!(plan.stem_moves(), 1);
3515 assert_eq!(plan.stem_writes(), 0);
3516 assert!(plan.actions.contains(&Action::MoveStem {
3517 clip_id: "a".to_string(),
3518 key: "voc".to_string(),
3519 stem_id: "voc".to_string(),
3520 from: "old.stems/voc.mp3".to_string(),
3521 to: "new.stems/voc.mp3".to_string(),
3522 source_url: "https://cdn1.suno.ai/voc.mp3".to_string(),
3523 format: StemFormat::Mp3,
3524 hash: "h1".to_string(),
3525 }));
3526 }
3527
3528 #[test]
3529 fn details_removed_kind_is_deleted_when_feature_off() {
3530 let mut manifest = Manifest::new();
3533 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3534 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3535 manifest.insert("a", e);
3536 let d = vec![desired_arts("a", vec![])];
3537 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3538 assert_eq!(plan.artifact_deletes(), 1);
3539 assert!(plan.actions.contains(&Action::DeleteArtifact {
3540 kind: ArtifactKind::DetailsTxt,
3541 path: "a.details.txt".to_string(),
3542 owner_id: "a".to_string(),
3543 }));
3544 }
3545
3546 #[test]
3547 fn lyrics_removed_kind_is_kept_not_deleted() {
3548 let mut manifest = Manifest::new();
3552 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3553 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3554 manifest.insert("a", e);
3555 let d = vec![desired_arts("a", vec![])];
3556 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3557 assert_eq!(plan.artifact_deletes(), 0);
3558 assert_eq!(plan.deletes(), 0);
3559 }
3560
3561 #[test]
3562 fn lrc_removed_kind_is_kept_not_deleted() {
3563 let mut manifest = Manifest::new();
3566 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3567 e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3568 manifest.insert("a", e);
3569 let d = vec![desired_arts("a", vec![])];
3570 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3571 assert_eq!(plan.artifact_deletes(), 0);
3572 assert_eq!(plan.deletes(), 0);
3573 }
3574
3575 #[test]
3576 fn video_mp4_removed_kind_is_kept_not_deleted() {
3577 let mut manifest = Manifest::new();
3581 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3582 e.video_mp4 = Some(cover("a.mp4", "vid-hash"));
3583 manifest.insert("a", e);
3584 let d = vec![desired_arts("a", vec![])];
3585 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3586 assert_eq!(plan.artifact_deletes(), 0);
3587 assert_eq!(plan.deletes(), 0);
3588 }
3589
3590 #[test]
3591 fn video_mp4_written_when_manifest_lacks_it() {
3592 let mut manifest = Manifest::new();
3595 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3596 let d = vec![desired_arts(
3597 "a",
3598 vec![art(
3599 ArtifactKind::VideoMp4,
3600 "a/song.mp4",
3601 "https://cdn/a/video.mp4",
3602 "vid-hash",
3603 )],
3604 )];
3605 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3606 assert_eq!(plan.artifact_writes(), 1);
3607 assert_eq!(
3608 write_artifacts(&plan)[0],
3609 &Action::WriteArtifact {
3610 kind: ArtifactKind::VideoMp4,
3611 path: "a/song.mp4".to_string(),
3612 source_url: "https://cdn/a/video.mp4".to_string(),
3613 hash: "vid-hash".to_string(),
3614 owner_id: "a".to_string(),
3615 content: None,
3616 }
3617 );
3618 }
3619
3620 #[test]
3621 fn details_removed_kind_not_deleted_on_incomplete_listing() {
3622 let mut manifest = Manifest::new();
3625 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3626 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3627 manifest.insert("a", e);
3628 let d = vec![desired_arts("a", vec![])];
3629 let sources = vec![SourceStatus {
3630 mode: SourceMode::Mirror,
3631 fully_enumerated: false,
3632 }];
3633 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
3634 assert_eq!(plan.artifact_deletes(), 0);
3635 }
3636
3637 #[test]
3638 fn details_removed_kind_not_deleted_when_preserved() {
3639 let mut manifest = Manifest::new();
3642 let mut e = ManifestEntry {
3643 preserve: true,
3644 ..entry("a.flac", AudioFormat::Flac, "m", "art")
3645 };
3646 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3647 manifest.insert("a", e);
3648 let d = vec![desired_arts("a", vec![])];
3649 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3650 assert_eq!(plan.artifact_deletes(), 0);
3651 }
3652
3653 #[test]
3654 fn co_delete_orphan_removes_every_text_sidecar() {
3655 let mut manifest = Manifest::new();
3659 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
3660 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
3661 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
3662 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
3663 e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3664 e.video_mp4 = Some(cover("gone/song.mp4", "vid-hash"));
3665 manifest.insert("gone", e);
3666 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3667 assert_eq!(plan.deletes(), 1);
3668 assert_eq!(plan.artifact_deletes(), 5);
3669 for (kind, path) in [
3670 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
3671 (ArtifactKind::DetailsTxt, "gone.details.txt"),
3672 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
3673 (ArtifactKind::Lrc, "gone.lrc"),
3674 (ArtifactKind::VideoMp4, "gone/song.mp4"),
3675 ] {
3676 assert!(
3677 plan.actions.contains(&Action::DeleteArtifact {
3678 kind,
3679 path: path.to_string(),
3680 owner_id: "gone".to_string(),
3681 }),
3682 "missing co-delete for {kind:?}"
3683 );
3684 }
3685 }
3686
3687 #[test]
3688 fn co_delete_trashed_removes_every_text_sidecar() {
3689 let mut manifest = Manifest::new();
3691 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3692 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3693 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3694 manifest.insert("a", e);
3695 let mut d = desired_arts("a", vec![]);
3696 d.trashed = true;
3697 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3698 assert_eq!(plan.deletes(), 1);
3699 assert_eq!(plan.artifact_deletes(), 2);
3700 }
3701
3702 #[test]
3703 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
3704 let mut manifest = Manifest::new();
3707 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3708 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
3709 let d = vec![desired_arts(
3712 "a",
3713 vec![art(
3714 ArtifactKind::CoverJpg,
3715 "shared/cover.jpg",
3716 "https://art/a",
3717 "h2",
3718 )],
3719 )];
3720 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3721 assert_eq!(plan.artifact_writes(), 1);
3722 assert!(!plan.actions.iter().any(
3724 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
3725 ));
3726 assert!(plan.actions.contains(&Action::Delete {
3728 path: "b.flac".to_string(),
3729 clip_id: "b".to_string(),
3730 }));
3731 }
3732
3733 #[test]
3734 fn suppress_downgrades_delete_artifact_colliding_with_download() {
3735 let mut manifest = Manifest::new();
3737 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
3738 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
3739 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
3740 assert_eq!(plan.downloads(), 1);
3741 assert!(
3742 !plan
3743 .actions
3744 .iter()
3745 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
3746 );
3747 }
3748
3749 #[test]
3750 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
3751 let build = |with_art: bool| {
3755 let mut manifest = Manifest::new();
3756 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
3757 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3758 manifest.insert(
3759 "trash",
3760 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
3761 );
3762 let keep = if with_art {
3763 desired_arts(
3764 "keep",
3765 vec![art(
3766 ArtifactKind::CoverJpg,
3767 "keep/cover.jpg",
3768 "https://art/keep",
3769 "h1",
3770 )],
3771 )
3772 } else {
3773 desired_arts("keep", vec![])
3774 };
3775 let mut trash = desired_arts("trash", vec![]);
3776 trash.trashed = true;
3777 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
3778 .iter()
3779 .map(|id| (id.to_string(), present(100)))
3780 .collect();
3781 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
3782 };
3783
3784 let with = build(true);
3785 let without = build(false);
3786
3787 let audio = |plan: &Plan| -> Vec<Action> {
3789 plan.actions
3790 .iter()
3791 .filter(|a| {
3792 !matches!(
3793 a,
3794 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
3795 )
3796 })
3797 .cloned()
3798 .collect()
3799 };
3800 assert_eq!(audio(&with), audio(&without));
3801 assert_eq!(with.deletes(), without.deletes());
3802 assert_eq!(with.deletes(), 2);
3804 assert_eq!(with.artifact_deletes(), 2);
3808 assert_eq!(with.artifact_writes(), 0);
3809 }
3810
3811 #[test]
3814 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
3815 let mut manifest = Manifest::new();
3821 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3822 assert!(!manifest.get("a").unwrap().preserve);
3823
3824 let private = Desired {
3826 private: true,
3827 ..desired_arts("a", vec![])
3828 };
3829 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
3830 assert_eq!(plan.artifact_deletes(), 0);
3831
3832 let copy_held = Desired {
3834 modes: vec![SourceMode::Copy],
3835 ..desired_arts("a", vec![])
3836 };
3837 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
3838 assert_eq!(plan.artifact_deletes(), 0);
3839 }
3840
3841 #[test]
3842 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
3843 let mut manifest = Manifest::new();
3849 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
3850 let d = vec![desired_arts(
3851 "a",
3852 vec![art(
3853 ArtifactKind::CoverJpg,
3854 "new/cover.jpg",
3855 "https://art/a",
3856 "h1",
3857 )],
3858 )];
3859 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3860 assert_eq!(plan.artifact_writes(), 1);
3861 assert_eq!(plan.artifact_deletes(), 0);
3862 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3863 assert_eq!(path, "new/cover.jpg");
3864 } else {
3865 panic!("expected a WriteArtifact");
3866 }
3867 }
3868
3869 #[test]
3870 fn needs_write_drift_applies_hash_path_and_probe_rules() {
3871 let local: HashMap<String, LocalFile> = [
3872 ("ok".to_string(), present(10)),
3873 ("missing".to_string(), LocalFile::default()),
3874 ("empty".to_string(), present(0)),
3875 ]
3876 .into_iter()
3877 .collect();
3878
3879 assert!(needs_write_drift(None, "h1", "ok", &local));
3880 assert!(!needs_write_drift(Some(("h1", "ok")), "h1", "ok", &local));
3881 assert!(needs_write_drift(Some(("h0", "ok")), "h1", "ok", &local));
3882 assert!(needs_write_drift(
3883 Some(("h1", "missing")),
3884 "h1",
3885 "missing",
3886 &local
3887 ));
3888 assert!(needs_write_drift(
3889 Some(("h1", "empty")),
3890 "h1",
3891 "empty",
3892 &local
3893 ));
3894 assert!(!needs_write_drift(
3895 Some(("h1", "unprobed")),
3896 "h1",
3897 "unprobed",
3898 &local
3899 ));
3900 }
3901
3902 #[test]
3903 fn per_clip_reconcile_ignores_album_and_library_kinds() {
3904 let mut manifest = Manifest::new();
3908 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3909 let d = vec![desired_arts(
3910 "a",
3911 vec![
3912 art(
3913 ArtifactKind::FolderJpg,
3914 "a/folder.jpg",
3915 "https://art/folder",
3916 "hf",
3917 ),
3918 art(
3919 ArtifactKind::Playlist,
3920 "a/list.m3u",
3921 "https://art/list",
3922 "hp",
3923 ),
3924 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
3925 ],
3926 )];
3927 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3928 assert_eq!(plan.artifact_writes(), 1);
3929 let paths: Vec<&str> = plan
3930 .actions
3931 .iter()
3932 .filter_map(|a| match a {
3933 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3934 _ => None,
3935 })
3936 .collect();
3937 assert_eq!(paths, vec!["a/cover.jpg"]);
3938 }
3939
3940 #[test]
3941 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3942 let mut manifest = Manifest::new();
3943 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3944 let d = vec![desired_arts(
3945 "a",
3946 vec![art(
3947 ArtifactKind::FolderWebp,
3948 "a/folder.webp",
3949 "https://art/folder",
3950 "hf",
3951 )],
3952 )];
3953 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3954 assert_eq!(plan.artifact_writes(), 0);
3955 assert_eq!(plan.artifact_deletes(), 0);
3956 }
3957
3958 fn local_with_missing(audio_id: &str, missing_path: &str) -> HashMap<String, LocalFile> {
3962 let mut m = local_present(audio_id);
3963 m.insert(missing_path.to_owned(), LocalFile::default());
3964 m
3965 }
3966
3967 fn local_with_present_artifact(
3969 audio_id: &str,
3970 artifact_path: &str,
3971 ) -> HashMap<String, LocalFile> {
3972 let mut m = local_present(audio_id);
3973 m.insert(artifact_path.to_owned(), present(50));
3974 m
3975 }
3976
3977 #[test]
3978 fn sidecar_missing_on_disk_forces_rewrite() {
3979 let mut manifest = Manifest::new();
3983 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3984 let d = vec![desired_arts(
3985 "a",
3986 vec![art(
3987 ArtifactKind::CoverJpg,
3988 "a/cover.jpg",
3989 "https://art/a",
3990 "h1",
3991 )],
3992 )];
3993 let local = local_with_missing("a", "a/cover.jpg");
3994 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3995 assert_eq!(
3996 plan.artifact_writes(),
3997 1,
3998 "missing sidecar must be rewritten"
3999 );
4000 assert_eq!(plan.artifact_deletes(), 0);
4001 }
4002
4003 #[test]
4004 fn sidecar_present_on_disk_with_matching_hash_no_churn() {
4005 let mut manifest = Manifest::new();
4007 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
4008 let d = vec![desired_arts(
4009 "a",
4010 vec![art(
4011 ArtifactKind::CoverJpg,
4012 "a/cover.jpg",
4013 "https://art/a",
4014 "h1",
4015 )],
4016 )];
4017 let local = local_with_present_artifact("a", "a/cover.jpg");
4018 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
4019 assert_eq!(plan.artifact_writes(), 0, "present sidecar must not churn");
4020 assert_eq!(plan.artifact_deletes(), 0);
4021 }
4022
4023 #[test]
4024 fn sidecar_probe_absent_falls_back_to_hash_comparison_no_write() {
4025 let mut manifest = Manifest::new();
4029 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
4030 let d = vec![desired_arts(
4031 "a",
4032 vec![art(
4033 ArtifactKind::CoverJpg,
4034 "a/cover.jpg",
4035 "https://art/a",
4036 "h1",
4037 )],
4038 )];
4039 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4041 assert_eq!(
4042 plan.artifact_writes(),
4043 0,
4044 "no write when probe unavailable and hash matches"
4045 );
4046 assert_eq!(
4047 plan.artifact_deletes(),
4048 0,
4049 "missing probe must never trigger a delete"
4050 );
4051 }
4052
4053 #[test]
4054 fn folder_art_missing_on_disk_forces_rewrite() {
4055 let members = vec![album_member(
4058 album_clip("a", 1, "t0", "art-a", ""),
4059 "root",
4060 "c/al/a.flac",
4061 )];
4062 let desired = album_desired(&members, false, false);
4063 let mut albums = BTreeMap::new();
4064 albums.insert(
4065 "root".to_string(),
4066 AlbumArt {
4067 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4068 folder_webp: None,
4069 folder_mp4: None,
4070 },
4071 );
4072 let mut local: HashMap<String, LocalFile> = HashMap::new();
4073 local.insert("c/al/folder.jpg".to_owned(), LocalFile::default());
4074 let actions = plan_album_artifacts(&desired, &albums, true, &local);
4075 assert_eq!(actions.len(), 1, "missing folder art must be rewritten");
4076 assert!(matches!(
4077 &actions[0],
4078 Action::WriteArtifact {
4079 kind: ArtifactKind::FolderJpg,
4080 ..
4081 }
4082 ));
4083 }
4084
4085 #[test]
4086 fn folder_art_present_on_disk_no_churn() {
4087 let members = vec![album_member(
4089 album_clip("a", 1, "t0", "art-a", ""),
4090 "root",
4091 "c/al/a.flac",
4092 )];
4093 let desired = album_desired(&members, false, false);
4094 let mut albums = BTreeMap::new();
4095 albums.insert(
4096 "root".to_string(),
4097 AlbumArt {
4098 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4099 folder_webp: None,
4100 folder_mp4: None,
4101 },
4102 );
4103 let mut local: HashMap<String, LocalFile> = HashMap::new();
4104 local.insert("c/al/folder.jpg".to_owned(), present(5000));
4105 let actions = plan_album_artifacts(&desired, &albums, true, &local);
4106 assert!(
4107 actions.is_empty(),
4108 "present folder art with matching hash must not churn"
4109 );
4110 }
4111
4112 #[test]
4113 fn playlist_missing_on_disk_forces_rewrite() {
4114 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4117 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4118 let mut local: HashMap<String, LocalFile> = HashMap::new();
4119 local.insert("Mix.m3u8".to_owned(), LocalFile::default());
4120 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
4121 assert_eq!(actions.len(), 1, "missing playlist file must be rewritten");
4122 assert!(matches!(
4123 &actions[0],
4124 Action::WriteArtifact {
4125 kind: ArtifactKind::Playlist,
4126 ..
4127 }
4128 ));
4129 }
4130
4131 #[test]
4132 fn playlist_present_on_disk_no_churn() {
4133 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4135 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4136 let mut local: HashMap<String, LocalFile> = HashMap::new();
4137 local.insert("Mix.m3u8".to_owned(), present(200));
4138 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
4139 assert!(
4140 actions.is_empty(),
4141 "present playlist with matching hash must not churn"
4142 );
4143 }
4144
4145 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
4148 Clip {
4149 id: id.to_string(),
4150 title: "Song".to_string(),
4151 image_large_url: image.to_string(),
4152 video_cover_url: video.to_string(),
4153 play_count,
4154 created_at: created_at.to_string(),
4155 ..Default::default()
4156 }
4157 }
4158
4159 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
4160 let mut lineage = LineageContext::own_root(&clip);
4161 lineage.root_id = root_id.to_string();
4162 Desired {
4163 clip,
4164 lineage,
4165 path: path.to_string(),
4166 format: AudioFormat::Flac,
4167 meta_hash: "m".to_string(),
4168 art_hash: "a".to_string(),
4169 modes: vec![SourceMode::Mirror],
4170 trashed: false,
4171 private: false,
4172 artifacts: Vec::new(),
4173 stems: None,
4174 }
4175 }
4176
4177 fn stored(path: &str, hash: &str) -> ArtifactState {
4178 ArtifactState {
4179 path: path.to_string(),
4180 hash: hash.to_string(),
4181 }
4182 }
4183
4184 #[test]
4185 fn folder_jpg_source_is_most_played() {
4186 let members = vec![
4187 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
4188 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
4189 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
4190 ];
4191 let albums = album_desired(&members, false, false);
4192 assert_eq!(albums.len(), 1);
4193 let jpg = albums[0].folder_jpg.as_ref().unwrap();
4194 assert_eq!(jpg.hash, art_url_hash("art-b"));
4196 assert_eq!(jpg.source_url, "art-b");
4197 assert_eq!(jpg.path, "c/al/folder.jpg");
4198 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
4199 }
4200
4201 #[test]
4202 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
4203 let by_time = vec![
4205 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
4206 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
4207 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
4208 ];
4209 let jpg = album_desired(&by_time, false, false)[0]
4210 .folder_jpg
4211 .clone()
4212 .unwrap();
4213 assert_eq!(jpg.source_url, "art-y");
4214
4215 let by_id = vec![
4217 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
4218 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
4219 ];
4220 let jpg = album_desired(&by_id, false, false)[0]
4221 .folder_jpg
4222 .clone()
4223 .unwrap();
4224 assert_eq!(jpg.source_url, "art-g");
4225 }
4226
4227 #[test]
4228 fn folder_webp_source_is_first_created_animated() {
4229 let members = vec![
4230 album_member(
4231 album_clip("a", 9, "t2", "art-a", "vid-a"),
4232 "root",
4233 "c/al/a.flac",
4234 ),
4235 album_member(
4236 album_clip("b", 1, "t0", "art-b", "vid-b"),
4237 "root",
4238 "c/al/b.flac",
4239 ),
4240 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
4241 ];
4242 let webp = album_desired(&members, true, false)[0]
4243 .folder_webp
4244 .clone()
4245 .unwrap();
4246 assert_eq!(webp.source_url, "vid-b");
4248 assert_eq!(webp.hash, art_url_hash("vid-b"));
4249 assert_eq!(webp.path, "c/al/cover.webp");
4250 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
4251 }
4252
4253 #[test]
4254 fn animated_covers_off_yields_no_folder_webp() {
4255 let members = vec![album_member(
4256 album_clip("a", 1, "t0", "art-a", "vid-a"),
4257 "root",
4258 "c/al/a.flac",
4259 )];
4260 let off = album_desired(&members, false, false);
4261 assert!(off[0].folder_webp.is_none());
4262 let on = album_desired(&members, true, false);
4263 assert!(on[0].folder_webp.is_some());
4264 }
4265
4266 #[test]
4267 fn raw_cover_yields_folder_mp4_from_the_webp_source_verbatim() {
4268 let members = vec![
4269 album_member(
4270 album_clip("a", 9, "t2", "art-a", "vid-a"),
4271 "root",
4272 "c/al/a.flac",
4273 ),
4274 album_member(
4275 album_clip("b", 1, "t0", "art-b", "vid-b"),
4276 "root",
4277 "c/al/b.flac",
4278 ),
4279 ];
4280 let album = album_desired(&members, true, true).remove(0);
4284 let webp = album.folder_webp.unwrap();
4285 let mp4 = album.folder_mp4.unwrap();
4286 assert_eq!(mp4.kind, ArtifactKind::FolderMp4);
4287 assert_eq!(mp4.path, "c/al/cover.mp4");
4288 assert_eq!(mp4.source_url, "vid-b");
4289 assert_eq!(mp4.hash, art_url_hash("vid-b"));
4290 assert_eq!(mp4.source_url, webp.source_url, "same variant feeds both");
4291 }
4292
4293 #[test]
4294 fn raw_cover_and_webp_are_independent_toggles() {
4295 let members = vec![album_member(
4296 album_clip("a", 1, "t0", "art-a", "vid-a"),
4297 "root",
4298 "c/al/a.flac",
4299 )];
4300 let webp_only = album_desired(&members, true, false).remove(0);
4302 assert!(webp_only.folder_webp.is_some());
4303 assert!(webp_only.folder_mp4.is_none());
4304 let mp4_only = album_desired(&members, false, true).remove(0);
4306 assert!(mp4_only.folder_webp.is_none());
4307 assert!(mp4_only.folder_mp4.is_some());
4308 }
4309
4310 #[test]
4311 fn raw_cover_needs_an_animated_source() {
4312 let members = vec![album_member(
4314 album_clip("a", 3, "t0", "art-a", ""),
4315 "root",
4316 "c/al/a.flac",
4317 )];
4318 let album = album_desired(&members, true, true).remove(0);
4319 assert!(album.folder_mp4.is_none());
4320 assert!(album.folder_webp.is_none());
4321 }
4322
4323 #[test]
4324 fn album_with_no_art_yields_no_folder_jpg() {
4325 let members = vec![album_member(
4326 album_clip("a", 3, "t0", "", ""),
4327 "root",
4328 "c/al/a.flac",
4329 )];
4330 let albums = album_desired(&members, true, false);
4331 assert!(albums[0].folder_jpg.is_none());
4332 assert!(albums[0].folder_webp.is_none());
4333 }
4334
4335 #[test]
4336 fn album_desired_groups_by_root_id() {
4337 let members = vec![
4338 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
4339 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
4340 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
4341 ];
4342 let albums = album_desired(&members, false, false);
4343 assert_eq!(albums.len(), 2);
4344 assert_eq!(albums[0].root_id, "r1");
4345 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
4346 assert_eq!(
4347 albums[0].folder_jpg.as_ref().unwrap().path,
4348 "c/al1/folder.jpg"
4349 );
4350 assert_eq!(albums[1].root_id, "r2");
4351 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
4352 assert_eq!(
4353 albums[1].folder_jpg.as_ref().unwrap().path,
4354 "c/al2/folder.jpg"
4355 );
4356 }
4357
4358 #[test]
4359 fn plan_writes_folder_art_when_store_empty() {
4360 let members = vec![album_member(
4361 album_clip("a", 1, "t0", "art-a", "vid-a"),
4362 "root",
4363 "c/al/a.flac",
4364 )];
4365 let desired = album_desired(&members, true, false);
4366 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4367 assert_eq!(
4368 actions,
4369 vec![
4370 Action::WriteArtifact {
4371 kind: ArtifactKind::FolderJpg,
4372 path: "c/al/folder.jpg".to_string(),
4373 source_url: "art-a".to_string(),
4374 hash: art_url_hash("art-a"),
4375 owner_id: "root".to_string(),
4376 content: None,
4377 },
4378 Action::WriteArtifact {
4379 kind: ArtifactKind::FolderWebp,
4380 path: "c/al/cover.webp".to_string(),
4381 source_url: "vid-a".to_string(),
4382 hash: art_url_hash("vid-a"),
4383 owner_id: "root".to_string(),
4384 content: None,
4385 },
4386 ]
4387 );
4388 }
4389
4390 #[test]
4391 fn plan_skips_when_hash_and_path_match() {
4392 let members = vec![album_member(
4393 album_clip("a", 1, "t0", "art-a", ""),
4394 "root",
4395 "c/al/a.flac",
4396 )];
4397 let desired = album_desired(&members, false, false);
4398 let mut albums = BTreeMap::new();
4399 albums.insert(
4400 "root".to_string(),
4401 AlbumArt {
4402 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4403 folder_webp: None,
4404 folder_mp4: None,
4405 },
4406 );
4407 assert!(plan_album_artifacts(&desired, &albums, true, &HashMap::new()).is_empty());
4408 }
4409
4410 #[test]
4411 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
4412 let members = vec![album_member(
4413 album_clip("a", 1, "t0", "art-a", ""),
4414 "root",
4415 "c/al/a.flac",
4416 )];
4417 let desired = album_desired(&members, false, false);
4418 let mut albums = BTreeMap::new();
4419 albums.insert(
4420 "root".to_string(),
4421 AlbumArt {
4422 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
4423 folder_webp: None,
4424 folder_mp4: None,
4425 },
4426 );
4427 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4428 assert_eq!(actions.len(), 1);
4429 assert!(matches!(
4430 &actions[0],
4431 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
4432 ));
4433 }
4434
4435 #[test]
4436 fn h1_most_played_flip_to_same_art_writes_nothing() {
4437 let run1 = vec![
4439 album_member(
4440 album_clip("a", 9, "t0", "same-art", ""),
4441 "root",
4442 "c/al/a.flac",
4443 ),
4444 album_member(
4445 album_clip("b", 1, "t1", "same-art", ""),
4446 "root",
4447 "c/al/b.flac",
4448 ),
4449 ];
4450 let desired1 = album_desired(&run1, false, false);
4451 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true, &HashMap::new());
4452 assert_eq!(write1.len(), 1);
4453
4454 let mut albums = BTreeMap::new();
4456 if let Action::WriteArtifact {
4457 path,
4458 hash,
4459 owner_id,
4460 ..
4461 } = &write1[0]
4462 {
4463 albums.insert(
4464 owner_id.clone(),
4465 AlbumArt {
4466 folder_jpg: Some(stored(path, hash)),
4467 folder_webp: None,
4468 folder_mp4: None,
4469 },
4470 );
4471 }
4472
4473 let run2 = vec![
4475 album_member(
4476 album_clip("a", 1, "t0", "same-art", ""),
4477 "root",
4478 "c/al/a.flac",
4479 ),
4480 album_member(
4481 album_clip("b", 9, "t1", "same-art", ""),
4482 "root",
4483 "c/al/b.flac",
4484 ),
4485 ];
4486 let desired2 = album_desired(&run2, false, false);
4487 assert!(plan_album_artifacts(&desired2, &albums, true, &HashMap::new()).is_empty());
4489 }
4490
4491 #[test]
4492 fn h1_flip_to_different_art_writes_exactly_one() {
4493 let mut albums = BTreeMap::new();
4494 albums.insert(
4495 "root".to_string(),
4496 AlbumArt {
4497 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
4498 folder_webp: None,
4499 folder_mp4: None,
4500 },
4501 );
4502 let members = vec![
4504 album_member(
4505 album_clip("a", 1, "t0", "old-art", ""),
4506 "root",
4507 "c/al/a.flac",
4508 ),
4509 album_member(
4510 album_clip("b", 9, "t1", "new-art", ""),
4511 "root",
4512 "c/al/b.flac",
4513 ),
4514 ];
4515 let desired = album_desired(&members, false, false);
4516 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4517 assert_eq!(actions.len(), 1);
4518 assert!(matches!(
4519 &actions[0],
4520 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
4521 ));
4522 }
4523
4524 #[test]
4525 fn one_write_per_album_regardless_of_clip_count() {
4526 let members: Vec<Desired> = (0..200)
4527 .map(|i| {
4528 album_member(
4529 album_clip(
4530 &format!("clip-{i:03}"),
4531 i as u64,
4532 &format!("t{i:03}"),
4533 &format!("art-{i:03}"),
4534 &format!("vid-{i:03}"),
4535 ),
4536 "root",
4537 &format!("c/al/clip-{i:03}.flac"),
4538 )
4539 })
4540 .collect();
4541 let desired = album_desired(&members, true, false);
4542 assert_eq!(desired.len(), 1);
4543 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4544 assert_eq!(actions.len(), 2);
4546 assert_eq!(
4547 actions
4548 .iter()
4549 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
4550 .count(),
4551 2
4552 );
4553 }
4554
4555 #[test]
4556 fn emptied_album_deletes_only_when_can_delete() {
4557 let mut albums = BTreeMap::new();
4558 albums.insert(
4559 "root".to_string(),
4560 AlbumArt {
4561 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
4562 folder_webp: Some(stored("c/al/cover.webp", "hw")),
4563 folder_mp4: Some(stored("c/al/cover.mp4", "hm")),
4564 },
4565 );
4566 let desired: Vec<AlbumDesired> = Vec::new();
4568
4569 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4571
4572 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4574 assert_eq!(
4575 actions,
4576 vec![
4577 Action::DeleteArtifact {
4578 kind: ArtifactKind::FolderJpg,
4579 path: "c/al/folder.jpg".to_string(),
4580 owner_id: "root".to_string(),
4581 },
4582 Action::DeleteArtifact {
4583 kind: ArtifactKind::FolderWebp,
4584 path: "c/al/cover.webp".to_string(),
4585 owner_id: "root".to_string(),
4586 },
4587 Action::DeleteArtifact {
4588 kind: ArtifactKind::FolderMp4,
4589 path: "c/al/cover.mp4".to_string(),
4590 owner_id: "root".to_string(),
4591 },
4592 ]
4593 );
4594 }
4595
4596 #[test]
4597 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
4598 let mut albums = BTreeMap::new();
4599 albums.insert(
4600 "root".to_string(),
4601 AlbumArt {
4602 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4603 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4604 folder_mp4: None,
4605 },
4606 );
4607 let members = vec![album_member(
4610 album_clip("a", 1, "t0", "art-a", "vid-a"),
4611 "root",
4612 "c/al/a.flac",
4613 )];
4614 let desired = album_desired(&members, false, false);
4615
4616 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4617
4618 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4619 assert_eq!(
4620 actions,
4621 vec![Action::DeleteArtifact {
4622 kind: ArtifactKind::FolderWebp,
4623 path: "c/al/cover.webp".to_string(),
4624 owner_id: "root".to_string(),
4625 }]
4626 );
4627 }
4628
4629 #[test]
4630 fn disappeared_raw_cover_deletes_only_that_kind_when_gated() {
4631 let mut albums = BTreeMap::new();
4632 albums.insert(
4633 "root".to_string(),
4634 AlbumArt {
4635 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4636 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4637 folder_mp4: Some(stored("c/al/cover.mp4", &art_url_hash("vid-a"))),
4638 },
4639 );
4640 let members = vec![album_member(
4643 album_clip("a", 1, "t0", "art-a", "vid-a"),
4644 "root",
4645 "c/al/a.flac",
4646 )];
4647 let desired = album_desired(&members, true, false);
4648
4649 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4651
4652 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4654 assert_eq!(
4655 actions,
4656 vec![Action::DeleteArtifact {
4657 kind: ArtifactKind::FolderMp4,
4658 path: "c/al/cover.mp4".to_string(),
4659 owner_id: "root".to_string(),
4660 }]
4661 );
4662 }
4663
4664 #[test]
4665 fn plan_album_artifacts_is_deterministically_ordered() {
4666 let members = vec![
4667 album_member(
4668 album_clip("a", 1, "t0", "art-a", "vid-a"),
4669 "r2",
4670 "c/al2/a.flac",
4671 ),
4672 album_member(
4673 album_clip("b", 1, "t0", "art-b", "vid-b"),
4674 "r1",
4675 "c/al1/b.flac",
4676 ),
4677 ];
4678 let desired = album_desired(&members, true, true);
4679 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4680 let keys: Vec<(&str, ArtifactKind)> = actions
4681 .iter()
4682 .map(|a| match a {
4683 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
4684 _ => unreachable!(),
4685 })
4686 .collect();
4687 assert_eq!(
4688 keys,
4689 vec![
4690 ("r1", ArtifactKind::FolderJpg),
4691 ("r1", ArtifactKind::FolderWebp),
4692 ("r1", ArtifactKind::FolderMp4),
4693 ("r2", ArtifactKind::FolderJpg),
4694 ("r2", ArtifactKind::FolderWebp),
4695 ("r2", ArtifactKind::FolderMp4),
4696 ]
4697 );
4698 }
4699
4700 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
4703 PlaylistDesired {
4704 id: id.to_owned(),
4705 name: name.to_owned(),
4706 path: path.to_owned(),
4707 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
4708 hash: hash.to_owned(),
4709 }
4710 }
4711
4712 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
4713 PlaylistState {
4714 name: name.to_owned(),
4715 path: path.to_owned(),
4716 hash: hash.to_owned(),
4717 }
4718 }
4719
4720 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
4721 entries
4722 .iter()
4723 .map(|(id, state)| ((*id).to_owned(), state.clone()))
4724 .collect()
4725 }
4726
4727 #[test]
4728 fn playlist_write_emitted_for_a_new_playlist() {
4729 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
4730 let actions =
4731 plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true, &HashMap::new());
4732 assert_eq!(
4733 actions,
4734 vec![Action::WriteArtifact {
4735 kind: ArtifactKind::Playlist,
4736 path: "Road Trip.m3u8".to_owned(),
4737 source_url: String::new(),
4738 hash: "h1".to_owned(),
4739 owner_id: "pl1".to_owned(),
4740 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
4741 }]
4742 );
4743 }
4744
4745 #[test]
4746 fn playlist_write_emitted_when_hash_changes() {
4747 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
4750 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4751 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4752 assert_eq!(actions.len(), 1);
4753 assert!(matches!(
4754 &actions[0],
4755 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
4756 ));
4757 }
4758
4759 #[test]
4760 fn playlist_unchanged_is_idempotent() {
4761 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4762 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4763 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4764 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
4765 }
4766
4767 #[test]
4768 fn playlist_rename_writes_new_and_deletes_old_path() {
4769 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4772 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4773 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4774 assert_eq!(
4775 actions,
4776 vec![
4777 Action::WriteArtifact {
4778 kind: ArtifactKind::Playlist,
4779 path: "Summer.m3u8".to_owned(),
4780 source_url: String::new(),
4781 hash: "h2".to_owned(),
4782 owner_id: "pl1".to_owned(),
4783 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
4784 },
4785 Action::DeleteArtifact {
4786 kind: ArtifactKind::Playlist,
4787 path: "Spring.m3u8".to_owned(),
4788 owner_id: "pl1".to_owned(),
4789 },
4790 ]
4791 );
4792 }
4793
4794 #[test]
4795 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
4796 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4799 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4800 let actions = plan_playlist_artifacts(&desired, &stored, false, true, &HashMap::new());
4801 assert_eq!(actions.len(), 1);
4802 assert!(matches!(
4803 &actions[0],
4804 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
4805 ));
4806 assert!(
4807 !actions
4808 .iter()
4809 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
4810 "old path must not be deleted when deletes are disallowed"
4811 );
4812 }
4813
4814 #[test]
4815 fn playlist_stale_removed_only_under_full_gate() {
4816 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
4819
4820 let deleted = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4821 assert_eq!(
4822 deleted,
4823 vec![Action::DeleteArtifact {
4824 kind: ArtifactKind::Playlist,
4825 path: "Gone.m3u8".to_owned(),
4826 owner_id: "gone".to_owned(),
4827 }]
4828 );
4829
4830 assert!(plan_playlist_artifacts(&[], &stored, false, true, &HashMap::new()).is_empty());
4832 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4833 assert!(plan_playlist_artifacts(&[], &stored, false, false, &HashMap::new()).is_empty());
4834 }
4835
4836 #[test]
4837 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
4838 let stored = pl_store(&[
4843 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4844 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4845 ]);
4846 let actions = plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new());
4847 assert!(
4848 actions.is_empty(),
4849 "a failed playlist listing must plan zero actions, got {actions:?}"
4850 );
4851 }
4852
4853 #[test]
4854 fn b2_empty_list_deletes_only_when_fully_enumerated() {
4855 let stored = pl_store(&[
4860 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4861 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4862 ]);
4863
4864 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4866
4867 let wiped = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4870 assert_eq!(
4871 wiped
4872 .iter()
4873 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
4874 .count(),
4875 2
4876 );
4877 }
4878
4879 #[test]
4880 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
4881 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
4886 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
4887 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4888 assert_eq!(actions.len(), 1);
4890 assert!(matches!(
4891 &actions[0],
4892 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
4893 ));
4894 assert!(
4895 !actions.iter().any(|a| match a {
4896 Action::WriteArtifact { owner_id, .. }
4897 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
4898 _ => false,
4899 }),
4900 "a protected (failed-member) playlist must have no action"
4901 );
4902 }
4903
4904 #[test]
4905 fn playlist_rename_collision_downgrades_the_delete() {
4906 let desired = vec![
4912 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
4913 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
4914 ];
4915 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
4916 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4917 let write_paths: BTreeSet<&str> = actions
4919 .iter()
4920 .filter_map(|a| match a {
4921 Action::WriteArtifact { path, .. } => Some(path.as_str()),
4922 _ => None,
4923 })
4924 .collect();
4925 for a in &actions {
4926 if let Action::DeleteArtifact { path, .. } = a {
4927 assert!(
4928 !write_paths.contains(path.as_str()),
4929 "a playlist delete aliases a write target: {path}"
4930 );
4931 }
4932 }
4933 }
4934
4935 fn dstem(key: &str, path: &str, hash: &str) -> DesiredStem {
4938 DesiredStem {
4939 key: key.to_string(),
4940 stem_id: key.to_string(),
4941 path: path.to_string(),
4942 source_url: format!("https://cdn1.suno.ai/{key}.mp3"),
4943 format: StemFormat::Mp3,
4944 hash: hash.to_string(),
4945 }
4946 }
4947
4948 fn stem_desired(id: &str, stems: Option<Vec<DesiredStem>>) -> Desired {
4950 Desired {
4951 stems,
4952 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
4953 }
4954 }
4955
4956 fn entry_with_stems(id: &str, stems: &[(&str, &str, &str)]) -> ManifestEntry {
4958 let mut e = entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art");
4959 for (key, path, hash) in stems {
4960 e.stems.insert(
4961 key.to_string(),
4962 ArtifactState {
4963 path: path.to_string(),
4964 hash: hash.to_string(),
4965 },
4966 );
4967 }
4968 e
4969 }
4970
4971 fn stem_writes(plan: &Plan) -> Vec<(&str, &str)> {
4972 plan.actions
4973 .iter()
4974 .filter_map(|a| match a {
4975 Action::WriteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4976 _ => None,
4977 })
4978 .collect()
4979 }
4980
4981 fn stem_deletes(plan: &Plan) -> Vec<(&str, &str)> {
4982 plan.actions
4983 .iter()
4984 .filter_map(|a| match a {
4985 Action::DeleteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4986 _ => None,
4987 })
4988 .collect()
4989 }
4990
4991 #[test]
4992 fn stems_none_keeps_every_existing_stem() {
4993 let mut manifest = Manifest::new();
4996 manifest.insert(
4997 "a",
4998 entry_with_stems(
4999 "a",
5000 &[
5001 ("voc", "a.stems/voc.mp3", "h1"),
5002 ("drm", "a.stems/drm.mp3", "h2"),
5003 ],
5004 ),
5005 );
5006 let d = vec![stem_desired("a", None)];
5007 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5008 assert_eq!(plan.stem_writes(), 0);
5009 assert_eq!(plan.stem_deletes(), 0);
5010 }
5011
5012 #[test]
5013 fn stems_authoritative_writes_missing_stems() {
5014 let mut manifest = Manifest::new();
5015 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
5016 let d = vec![stem_desired(
5017 "a",
5018 Some(vec![
5019 dstem("voc", "a.stems/voc.mp3", "h1"),
5020 dstem("drm", "a.stems/drm.mp3", "h2"),
5021 ]),
5022 )];
5023 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5024 assert_eq!(
5025 stem_writes(&plan),
5026 vec![("voc", "a.stems/voc.mp3"), ("drm", "a.stems/drm.mp3")]
5027 );
5028 assert_eq!(plan.stem_deletes(), 0);
5029 }
5030
5031 #[test]
5032 fn stems_authoritative_rewrites_only_on_hash_or_path_drift() {
5033 let mut manifest = Manifest::new();
5034 manifest.insert(
5036 "a",
5037 entry_with_stems(
5038 "a",
5039 &[
5040 ("voc", "a.stems/voc.mp3", "h1"),
5041 ("drm", "a.stems/drm.mp3", "h2"),
5042 ("bas", "old.stems/bas.mp3", "h3"),
5043 ],
5044 ),
5045 );
5046 let d = vec![stem_desired(
5047 "a",
5048 Some(vec![
5049 dstem("voc", "a.stems/voc.mp3", "h1"), dstem("drm", "a.stems/drm.mp3", "h2-new"), dstem("bas", "a.stems/bas.mp3", "h3"), ]),
5053 )];
5054 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5055 assert_eq!(
5056 stem_writes(&plan),
5057 vec![("drm", "a.stems/drm.mp3"), ("bas", "a.stems/bas.mp3")]
5058 );
5059 assert_eq!(plan.stem_deletes(), 0);
5060 }
5061
5062 #[test]
5063 fn stems_authoritative_removes_a_stem_absent_from_the_set() {
5064 let mut manifest = Manifest::new();
5067 manifest.insert(
5068 "a",
5069 entry_with_stems(
5070 "a",
5071 &[
5072 ("voc", "a.stems/voc.mp3", "h1"),
5073 ("drm", "a.stems/drm.mp3", "h2"),
5074 ],
5075 ),
5076 );
5077 let d = vec![stem_desired(
5078 "a",
5079 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
5080 )];
5081 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5082 assert_eq!(plan.stem_writes(), 0);
5083 assert_eq!(stem_deletes(&plan), vec![("drm", "a.stems/drm.mp3")]);
5084 }
5085
5086 #[test]
5087 fn stems_removal_needs_deletion_allowed() {
5088 let mut manifest = Manifest::new();
5091 manifest.insert(
5092 "a",
5093 entry_with_stems(
5094 "a",
5095 &[
5096 ("voc", "a.stems/voc.mp3", "h1"),
5097 ("drm", "a.stems/drm.mp3", "h2"),
5098 ],
5099 ),
5100 );
5101 let d = vec![stem_desired(
5102 "a",
5103 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
5104 )];
5105
5106 let incomplete = vec![SourceStatus {
5107 mode: SourceMode::Mirror,
5108 fully_enumerated: false,
5109 }];
5110 assert_eq!(
5111 reconcile(&manifest, &d, &local_present("a"), &incomplete).stem_deletes(),
5112 0
5113 );
5114
5115 let copy_only = vec![SourceStatus {
5116 mode: SourceMode::Copy,
5117 fully_enumerated: true,
5118 }];
5119 assert_eq!(
5120 reconcile(&manifest, &d, &local_present("a"), ©_only).stem_deletes(),
5121 0
5122 );
5123 }
5124
5125 #[test]
5126 fn stems_removal_skipped_for_preserved_or_protected_clip() {
5127 let mut manifest = Manifest::new();
5128 let mut e = entry_with_stems(
5129 "a",
5130 &[
5131 ("voc", "a.stems/voc.mp3", "h1"),
5132 ("drm", "a.stems/drm.mp3", "h2"),
5133 ],
5134 );
5135 e.preserve = true;
5136 manifest.insert("a", e);
5137 let authoritative = Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]);
5138
5139 let d = vec![stem_desired("a", authoritative.clone())];
5141 assert_eq!(
5142 reconcile(&manifest, &d, &local_present("a"), &mirror_ok()).stem_deletes(),
5143 0
5144 );
5145
5146 let mut manifest2 = Manifest::new();
5148 manifest2.insert(
5149 "a",
5150 entry_with_stems(
5151 "a",
5152 &[
5153 ("voc", "a.stems/voc.mp3", "h1"),
5154 ("drm", "a.stems/drm.mp3", "h2"),
5155 ],
5156 ),
5157 );
5158 let held = Desired {
5159 modes: vec![SourceMode::Mirror, SourceMode::Copy],
5160 stems: authoritative,
5161 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
5162 };
5163 assert_eq!(
5164 reconcile(&manifest2, &[held], &local_present("a"), &mirror_ok()).stem_deletes(),
5165 0
5166 );
5167 }
5168
5169 #[test]
5170 fn stems_are_co_deleted_when_the_song_is_trashed() {
5171 let mut manifest = Manifest::new();
5174 manifest.insert(
5175 "a",
5176 entry_with_stems(
5177 "a",
5178 &[
5179 ("voc", "a.stems/voc.mp3", "h1"),
5180 ("drm", "a.stems/drm.mp3", "h2"),
5181 ],
5182 ),
5183 );
5184 let trashed = Desired {
5185 trashed: true,
5186 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
5187 };
5188 let plan = reconcile(&manifest, &[trashed], &local_present("a"), &mirror_ok());
5189 assert_eq!(plan.deletes(), 1, "the trashed audio is deleted");
5190 let mut deleted: Vec<&str> = stem_deletes(&plan).into_iter().map(|(k, _)| k).collect();
5191 deleted.sort_unstable();
5192 assert_eq!(deleted, vec!["drm", "voc"], "both stems co-deleted");
5193 }
5194
5195 #[test]
5196 fn stems_are_co_deleted_for_an_absent_clip() {
5197 let mut manifest = Manifest::new();
5198 manifest.insert(
5199 "a",
5200 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5201 );
5202 let plan = reconcile(&manifest, &[], &local_present("a"), &mirror_ok());
5204 assert_eq!(plan.deletes(), 1);
5205 assert_eq!(stem_deletes(&plan), vec![("voc", "a.stems/voc.mp3")]);
5206 }
5207
5208 #[test]
5209 fn stems_are_kept_when_absent_clip_listing_is_incomplete() {
5210 let mut manifest = Manifest::new();
5212 manifest.insert(
5213 "a",
5214 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5215 );
5216 let incomplete = vec![SourceStatus {
5217 mode: SourceMode::Mirror,
5218 fully_enumerated: false,
5219 }];
5220 let plan = reconcile(&manifest, &[], &HashMap::new(), &incomplete);
5221 assert_eq!(plan.deletes(), 0);
5222 assert_eq!(plan.stem_deletes(), 0);
5223 }
5224
5225 #[test]
5226 fn stem_delete_is_suppressed_when_it_aliases_a_stem_write() {
5227 let mut manifest = Manifest::new();
5231 manifest.insert(
5232 "a",
5233 entry_with_stems("a", &[("old", "a.stems/mix.mp3", "h1")]),
5234 );
5235 let d = vec![stem_desired(
5236 "a",
5237 Some(vec![dstem("new", "a.stems/mix.mp3", "h2")]),
5238 )];
5239 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5240 assert_eq!(stem_writes(&plan), vec![("new", "a.stems/mix.mp3")]);
5243 assert!(
5244 !plan.actions.iter().any(|a| matches!(
5245 a,
5246 Action::DeleteStem { path, .. } if path == "a.stems/mix.mp3"
5247 )),
5248 "a stem delete must never alias a stem write target"
5249 );
5250 }
5251}
5252
5253#[cfg(test)]
5266mod proptests {
5267 use super::*;
5268 use proptest::collection::{btree_map, hash_map, vec};
5269 use proptest::prelude::*;
5270 use std::collections::BTreeSet;
5271
5272 type DesiredFields = (
5273 String,
5274 AudioFormat,
5275 String,
5276 String,
5277 Vec<SourceMode>,
5278 bool,
5279 bool,
5280 );
5281
5282 fn audio_format() -> impl Strategy<Value = AudioFormat> {
5283 prop_oneof![
5284 Just(AudioFormat::Mp3),
5285 Just(AudioFormat::Flac),
5286 Just(AudioFormat::Wav),
5287 ]
5288 }
5289
5290 fn source_mode() -> impl Strategy<Value = SourceMode> {
5291 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
5292 }
5293
5294 fn clip_id() -> impl Strategy<Value = String> {
5297 (0u8..8).prop_map(|n| format!("c{n}"))
5298 }
5299
5300 fn small_path() -> impl Strategy<Value = String> {
5301 (0u8..6).prop_map(|n| format!("path{n}"))
5302 }
5303
5304 fn manifest_path() -> impl Strategy<Value = String> {
5307 prop_oneof![
5308 1 => Just(String::new()),
5309 6 => small_path(),
5310 ]
5311 }
5312
5313 fn small_hash() -> impl Strategy<Value = String> {
5314 (0u8..4).prop_map(|n| format!("h{n}"))
5315 }
5316
5317 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
5318 (
5319 manifest_path(),
5320 audio_format(),
5321 small_hash(),
5322 small_hash(),
5323 0u64..4,
5324 any::<bool>(),
5325 )
5326 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
5327 ManifestEntry {
5328 path,
5329 format,
5330 meta_hash,
5331 art_hash,
5332 size,
5333 preserve,
5334 ..Default::default()
5335 }
5336 })
5337 }
5338
5339 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
5340 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
5341 }
5342
5343 fn local_file() -> impl Strategy<Value = LocalFile> {
5344 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
5345 }
5346
5347 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
5348 hash_map(clip_id(), local_file(), 0..8)
5349 }
5350
5351 fn source_status() -> impl Strategy<Value = SourceStatus> {
5352 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
5353 mode,
5354 fully_enumerated,
5355 })
5356 }
5357
5358 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5359 vec(source_status(), 0..5)
5360 }
5361
5362 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5363 vec(
5364 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
5365 mode: SourceMode::Copy,
5366 fully_enumerated,
5367 }),
5368 1..5,
5369 )
5370 }
5371
5372 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
5373 (
5374 small_path(),
5375 audio_format(),
5376 small_hash(),
5377 small_hash(),
5378 vec(source_mode(), 1..3),
5379 any::<bool>(),
5380 any::<bool>(),
5381 )
5382 }
5383
5384 fn build_desired(id: String, fields: DesiredFields) -> Desired {
5385 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
5386 let clip = Clip {
5387 id,
5388 title: "t".to_string(),
5389 ..Default::default()
5390 };
5391 Desired {
5392 lineage: LineageContext::own_root(&clip),
5393 clip,
5394 path,
5395 format,
5396 meta_hash,
5397 art_hash,
5398 modes,
5399 trashed,
5400 private,
5401 artifacts: Vec::new(),
5402 stems: None,
5403 }
5404 }
5405
5406 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
5409 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
5410 items
5411 .into_iter()
5412 .map(|(id, fields)| build_desired(id, fields))
5413 .collect()
5414 })
5415 }
5416
5417 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
5418 desired.iter().map(|d| d.clip.id.as_str()).collect()
5419 }
5420
5421 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
5424 desired
5425 .iter()
5426 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
5427 .map(|d| d.clip.id.as_str())
5428 .collect()
5429 }
5430
5431 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
5434 desired
5435 .iter()
5436 .filter(|d| !d.trashed)
5437 .map(|d| d.clip.id.as_str())
5438 .collect()
5439 }
5440
5441 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
5442 plan.actions
5443 .iter()
5444 .filter_map(|a| match a {
5445 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
5446 _ => None,
5447 })
5448 .collect()
5449 }
5450
5451 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
5452 plan.actions
5453 .iter()
5454 .filter_map(|a| match a {
5455 Action::Download { path, .. } | Action::Reformat { path, .. } => {
5456 Some(path.as_str())
5457 }
5458 Action::Rename { to, .. } => Some(to.as_str()),
5459 _ => None,
5460 })
5461 .collect()
5462 }
5463
5464 proptest! {
5465 #![proptest_config(ProptestConfig {
5466 cases: 256,
5467 failure_persistence: None,
5468 ..ProptestConfig::default()
5469 })]
5470
5471 #[test]
5474 fn inv1_desired_clip_deleted_only_when_fully_trashed(
5475 manifest in manifest_strategy(),
5476 desired in desired_strategy(),
5477 local in local_strategy(),
5478 sources in sources_strategy(),
5479 ) {
5480 let plan = reconcile(&manifest, &desired, &local, &sources);
5481 let present = desired_ids(&desired);
5482 let live = non_trashed_ids(&desired);
5483 for id in delete_clip_ids(&plan) {
5484 prop_assert!(
5485 !(present.contains(id) && live.contains(id)),
5486 "deleted a desired clip with a non-trashed duplicate: {id}"
5487 );
5488 }
5489 }
5490
5491 #[test]
5495 fn inv2_no_delete_when_any_mirror_unenumerated(
5496 manifest in manifest_strategy(),
5497 desired in desired_strategy(),
5498 local in local_strategy(),
5499 mut sources in sources_strategy(),
5500 ) {
5501 sources.push(SourceStatus {
5502 mode: SourceMode::Mirror,
5503 fully_enumerated: false,
5504 });
5505 let plan = reconcile(&manifest, &desired, &local, &sources);
5506 prop_assert_eq!(plan.deletes(), 0);
5507 }
5508
5509 #[test]
5511 fn inv3_all_copy_sources_means_no_deletes(
5512 manifest in manifest_strategy(),
5513 desired in desired_strategy(),
5514 local in local_strategy(),
5515 sources in copy_sources_strategy(),
5516 ) {
5517 let plan = reconcile(&manifest, &desired, &local, &sources);
5518 prop_assert_eq!(plan.deletes(), 0);
5519 }
5520
5521 #[test]
5524 fn inv4_plan_is_deterministic(
5525 manifest in manifest_strategy(),
5526 desired in desired_strategy(),
5527 local in local_strategy(),
5528 sources in sources_strategy(),
5529 ) {
5530 let plan = reconcile(&manifest, &desired, &local, &sources);
5531
5532 let again = reconcile(&manifest, &desired, &local, &sources);
5533 prop_assert_eq!(&plan, &again);
5534
5535 let mut desired_rev = desired.clone();
5536 desired_rev.reverse();
5537 let mut sources_rev = sources.clone();
5538 sources_rev.reverse();
5539 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
5540 prop_assert_eq!(&plan, &shuffled);
5541 }
5542
5543 #[test]
5545 fn inv5_every_delete_is_in_the_manifest(
5546 manifest in manifest_strategy(),
5547 desired in desired_strategy(),
5548 local in local_strategy(),
5549 sources in sources_strategy(),
5550 ) {
5551 let plan = reconcile(&manifest, &desired, &local, &sources);
5552 for id in delete_clip_ids(&plan) {
5553 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
5554 }
5555 }
5556
5557 #[test]
5560 fn inv6_never_deletes_protected_clip(
5561 manifest in manifest_strategy(),
5562 desired in desired_strategy(),
5563 local in local_strategy(),
5564 sources in sources_strategy(),
5565 ) {
5566 let plan = reconcile(&manifest, &desired, &local, &sources);
5567 let protected = protected_ids(&desired);
5568 for id in delete_clip_ids(&plan) {
5569 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
5570 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
5571 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
5572 }
5573 }
5574
5575 #[test]
5578 fn inv7_no_delete_unless_deletion_allowed(
5579 manifest in manifest_strategy(),
5580 desired in desired_strategy(),
5581 local in local_strategy(),
5582 sources in sources_strategy(),
5583 ) {
5584 let plan = reconcile(&manifest, &desired, &local, &sources);
5585 if !deletion_allowed(&sources) {
5586 prop_assert_eq!(plan.deletes(), 0);
5587 }
5588 }
5589
5590 #[test]
5592 fn inv8_at_most_one_delete_per_clip(
5593 manifest in manifest_strategy(),
5594 desired in desired_strategy(),
5595 local in local_strategy(),
5596 sources in sources_strategy(),
5597 ) {
5598 let plan = reconcile(&manifest, &desired, &local, &sources);
5599 let ids = delete_clip_ids(&plan);
5600 let unique: BTreeSet<&str> = ids.iter().copied().collect();
5601 prop_assert_eq!(ids.len(), unique.len());
5602 }
5603
5604 #[test]
5606 fn inv9_no_delete_with_empty_path(
5607 manifest in manifest_strategy(),
5608 desired in desired_strategy(),
5609 local in local_strategy(),
5610 sources in sources_strategy(),
5611 ) {
5612 let plan = reconcile(&manifest, &desired, &local, &sources);
5613 for action in &plan.actions {
5614 if let Action::Delete { path, .. } = action {
5615 prop_assert!(!path.is_empty(), "delete with an empty path");
5616 }
5617 }
5618 }
5619
5620 #[test]
5623 fn inv10_no_delete_aliases_a_write_target(
5624 manifest in manifest_strategy(),
5625 desired in desired_strategy(),
5626 local in local_strategy(),
5627 sources in sources_strategy(),
5628 ) {
5629 let plan = reconcile(&manifest, &desired, &local, &sources);
5630 let targets = write_target_paths(&plan);
5631 for action in &plan.actions {
5632 if let Action::Delete { path, .. } = action {
5633 prop_assert!(
5634 !targets.contains(path.as_str()),
5635 "delete path {path} aliases a write target"
5636 );
5637 }
5638 }
5639 }
5640 }
5641}