1use std::collections::BTreeMap;
34use std::collections::BTreeSet;
35use std::collections::HashMap;
36
37use crate::config::{AudioFormat, StemFormat};
38use crate::graph::{AlbumArt, PlaylistState};
39use crate::hash::{art_hash, art_url_hash};
40use crate::lineage::LineageContext;
41use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
42use crate::model::Clip;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
56pub enum ArtifactKind {
57 CoverJpg,
59 CoverWebp,
61 DetailsTxt,
63 LyricsTxt,
65 Lrc,
67 VideoMp4,
70 FolderJpg,
72 FolderWebp,
74 FolderMp4,
78 Playlist,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum SourceMode {
86 Mirror,
88 Copy,
90}
91
92#[derive(Debug, Clone, PartialEq)]
99pub struct Desired {
100 pub clip: Clip,
102 pub lineage: LineageContext,
105 pub path: String,
107 pub format: AudioFormat,
109 pub meta_hash: String,
111 pub art_hash: String,
113 pub modes: Vec<SourceMode>,
115 pub trashed: bool,
117 pub private: bool,
119 pub artifacts: Vec<DesiredArtifact>,
127 pub stems: Option<Vec<DesiredStem>>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct DesiredStem {
150 pub key: String,
154 pub stem_id: String,
158 pub path: String,
161 pub source_url: String,
165 pub format: StemFormat,
168 pub hash: String,
170}
171
172#[derive(Debug, Clone, PartialEq)]
177pub struct DesiredArtifact {
178 pub kind: ArtifactKind,
180 pub path: String,
182 pub source_url: String,
185 pub hash: String,
187 pub content: Option<String>,
191}
192
193#[derive(Debug, Clone, PartialEq)]
204pub struct AlbumDesired {
205 pub root_id: String,
207 pub folder_jpg: Option<DesiredArtifact>,
209 pub folder_webp: Option<DesiredArtifact>,
211 pub folder_mp4: Option<DesiredArtifact>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct PlaylistDesired {
228 pub id: String,
231 pub name: String,
233 pub path: String,
235 pub content: String,
237 pub hash: String,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
243pub struct LocalFile {
244 pub exists: bool,
246 pub size: u64,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub struct SourceStatus {
253 pub mode: SourceMode,
255 pub fully_enumerated: bool,
257}
258
259#[derive(Debug, Clone, PartialEq)]
261pub enum Action {
262 Download {
264 clip: Clip,
265 lineage: LineageContext,
266 path: String,
267 format: AudioFormat,
268 },
269 Reformat {
275 clip: Clip,
276 path: String,
277 from_path: String,
278 from: AudioFormat,
279 to: AudioFormat,
280 },
281 Retag {
283 clip: Clip,
284 lineage: LineageContext,
285 path: String,
286 },
287 Rename { from: String, to: String },
289 Delete { path: String, clip_id: String },
291 Skip { clip_id: String },
293 WriteArtifact {
305 kind: ArtifactKind,
306 path: String,
307 source_url: String,
308 hash: String,
309 owner_id: String,
310 content: Option<String>,
311 },
312 DeleteArtifact {
319 kind: ArtifactKind,
320 path: String,
321 owner_id: String,
322 },
323 WriteStem {
333 clip_id: String,
334 key: String,
335 stem_id: String,
336 path: String,
337 source_url: String,
338 format: StemFormat,
339 hash: String,
340 },
341 DeleteStem {
350 clip_id: String,
351 key: String,
352 path: String,
353 },
354}
355
356#[derive(Debug, Clone, Default, PartialEq)]
361pub struct Plan {
362 pub actions: Vec<Action>,
364}
365
366impl Plan {
367 pub fn len(&self) -> usize {
369 self.actions.len()
370 }
371
372 pub fn is_empty(&self) -> bool {
374 self.actions.is_empty()
375 }
376
377 pub fn downloads(&self) -> usize {
379 self.count(|a| matches!(a, Action::Download { .. }))
380 }
381
382 pub fn reformats(&self) -> usize {
384 self.count(|a| matches!(a, Action::Reformat { .. }))
385 }
386
387 pub fn retags(&self) -> usize {
389 self.count(|a| matches!(a, Action::Retag { .. }))
390 }
391
392 pub fn renames(&self) -> usize {
394 self.count(|a| matches!(a, Action::Rename { .. }))
395 }
396
397 pub fn deletes(&self) -> usize {
399 self.count(|a| matches!(a, Action::Delete { .. }))
400 }
401
402 pub fn skips(&self) -> usize {
404 self.count(|a| matches!(a, Action::Skip { .. }))
405 }
406
407 pub fn artifact_writes(&self) -> usize {
409 self.count(|a| matches!(a, Action::WriteArtifact { .. }))
410 }
411
412 pub fn artifact_deletes(&self) -> usize {
414 self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
415 }
416
417 pub fn stem_writes(&self) -> usize {
419 self.count(|a| matches!(a, Action::WriteStem { .. }))
420 }
421
422 pub fn stem_deletes(&self) -> usize {
424 self.count(|a| matches!(a, Action::DeleteStem { .. }))
425 }
426
427 fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
428 self.actions.iter().filter(|a| pred(a)).count()
429 }
430}
431
432pub fn reconcile(
447 manifest: &Manifest,
448 desired: &[Desired],
449 local: &HashMap<String, LocalFile>,
450 sources: &[SourceStatus],
451) -> Plan {
452 let mut actions: Vec<Action> = Vec::new();
453
454 let desired = aggregate_desired(desired);
456 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.clip.id.as_str()).collect();
457
458 let can_delete = deletion_allowed(sources);
459
460 for d in &desired {
461 let before = actions.len();
466 plan_desired(d, manifest, local, can_delete, &mut actions);
467 let audio_deleted = actions[before..]
468 .iter()
469 .any(|a| matches!(a, Action::Delete { .. }));
470 if audio_deleted {
471 co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
472 co_delete_stems(d.clip.id.as_str(), manifest, can_delete, &mut actions);
473 } else {
474 plan_clip_artifacts(d, manifest, can_delete, &mut actions);
475 plan_clip_stems(d, manifest, can_delete, &mut actions);
476 }
477 }
478
479 for (clip_id, _entry) in manifest.iter() {
481 if desired_ids.contains(clip_id.as_str()) {
482 continue;
483 }
484 match delete_action(clip_id, manifest, can_delete) {
485 Some(action) => {
486 actions.push(action);
487 co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
490 co_delete_stems(clip_id, manifest, can_delete, &mut actions);
491 }
492 None => actions.push(Action::Skip {
495 clip_id: clip_id.clone(),
496 }),
497 }
498 }
499
500 suppress_path_aliasing(&mut actions);
501 Plan { actions }
502}
503
504pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
515 let mut saw_mirror = false;
516 for status in sources {
517 if !status.fully_enumerated {
518 return false;
519 }
520 if status.mode == SourceMode::Mirror {
521 saw_mirror = true;
522 }
523 }
524 saw_mirror
525}
526
527fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
533 if !can_delete {
534 return None;
535 }
536 let entry = manifest.get(clip_id)?;
537 if entry.path.is_empty() || entry.preserve {
538 return None;
539 }
540 Some(Action::Delete {
541 path: entry.path.clone(),
542 clip_id: clip_id.to_string(),
543 })
544}
545
546fn delete_artifact_action(
556 owner_id: &str,
557 kind: ArtifactKind,
558 path: &str,
559 manifest: &Manifest,
560 can_delete: bool,
561) -> Option<Action> {
562 if !can_delete {
563 return None;
564 }
565 let entry = manifest.get(owner_id)?;
566 if path.is_empty() || entry.preserve {
567 return None;
568 }
569 Some(Action::DeleteArtifact {
570 kind,
571 path: path.to_string(),
572 owner_id: owner_id.to_string(),
573 })
574}
575
576fn is_per_clip_kind(kind: ArtifactKind) -> bool {
582 matches!(
583 kind,
584 ArtifactKind::CoverJpg
585 | ArtifactKind::CoverWebp
586 | ArtifactKind::DetailsTxt
587 | ArtifactKind::LyricsTxt
588 | ArtifactKind::Lrc
589 | ArtifactKind::VideoMp4
590 )
591}
592
593fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
619 match kind {
620 ArtifactKind::CoverJpg
621 | ArtifactKind::CoverWebp
622 | ArtifactKind::LyricsTxt
623 | ArtifactKind::Lrc
624 | ArtifactKind::VideoMp4 => false,
625 ArtifactKind::DetailsTxt
626 | ArtifactKind::FolderJpg
627 | ArtifactKind::FolderWebp
628 | ArtifactKind::FolderMp4
629 | ArtifactKind::Playlist => true,
630 }
631}
632
633fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
638 match kind {
639 ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
640 ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
641 ArtifactKind::DetailsTxt => entry.details_txt.as_ref(),
642 ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
643 ArtifactKind::Lrc => entry.lrc.as_ref(),
644 ArtifactKind::VideoMp4 => entry.video_mp4.as_ref(),
645 ArtifactKind::FolderJpg
646 | ArtifactKind::FolderWebp
647 | ArtifactKind::FolderMp4
648 | ArtifactKind::Playlist => None,
649 }
650}
651
652fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
655 let mut out = Vec::new();
656 if let Some(state) = &entry.cover_jpg {
657 out.push((ArtifactKind::CoverJpg, state));
658 }
659 if let Some(state) = &entry.cover_webp {
660 out.push((ArtifactKind::CoverWebp, state));
661 }
662 if let Some(state) = &entry.details_txt {
663 out.push((ArtifactKind::DetailsTxt, state));
664 }
665 if let Some(state) = &entry.lyrics_txt {
666 out.push((ArtifactKind::LyricsTxt, state));
667 }
668 if let Some(state) = &entry.lrc {
669 out.push((ArtifactKind::Lrc, state));
670 }
671 if let Some(state) = &entry.video_mp4 {
672 out.push((ArtifactKind::VideoMp4, state));
673 }
674 out
675}
676
677pub(crate) fn set_manifest_artifact(
684 entry: &mut ManifestEntry,
685 kind: ArtifactKind,
686 state: Option<ArtifactState>,
687) {
688 match kind {
689 ArtifactKind::CoverJpg => entry.cover_jpg = state,
690 ArtifactKind::CoverWebp => entry.cover_webp = state,
691 ArtifactKind::DetailsTxt => entry.details_txt = state,
692 ArtifactKind::LyricsTxt => entry.lyrics_txt = state,
693 ArtifactKind::Lrc => entry.lrc = state,
694 ArtifactKind::VideoMp4 => entry.video_mp4 = state,
695 ArtifactKind::FolderJpg
696 | ArtifactKind::FolderWebp
697 | ArtifactKind::FolderMp4
698 | ArtifactKind::Playlist => {}
699 }
700}
701
702pub(crate) fn set_manifest_stem(
708 entry: &mut ManifestEntry,
709 key: &str,
710 state: Option<ArtifactState>,
711) {
712 match state {
713 Some(state) => {
714 entry.stems.insert(key.to_string(), state);
715 }
716 None => {
717 entry.stems.remove(key);
718 }
719 }
720}
721
722fn plan_clip_artifacts(d: &Desired, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
732 let owner_id = d.clip.id.as_str();
733 let entry = manifest.get(owner_id);
734
735 for artifact in &d.artifacts {
736 if !is_per_clip_kind(artifact.kind) {
740 continue;
741 }
742 let needs_write = match entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind)) {
751 None => true,
752 Some(state) => state.hash != artifact.hash || state.path != artifact.path,
753 };
754 if needs_write {
755 out.push(Action::WriteArtifact {
756 kind: artifact.kind,
757 path: artifact.path.clone(),
758 source_url: artifact.source_url.clone(),
759 hash: artifact.hash.clone(),
760 owner_id: owner_id.to_string(),
761 content: artifact.content.clone(),
762 });
763 }
764 }
765
766 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
771 if !protected_now && let Some(entry) = entry {
772 let desired_kinds: BTreeSet<ArtifactKind> = d
773 .artifacts
774 .iter()
775 .filter(|a| is_per_clip_kind(a.kind))
776 .map(|a| a.kind)
777 .collect();
778 for (kind, state) in manifest_artifacts(entry) {
779 if removed_kind_delete_eligible(kind)
785 && !desired_kinds.contains(&kind)
786 && let Some(action) =
787 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
788 {
789 out.push(action);
790 }
791 }
792 }
793}
794
795fn co_delete_artifacts(
801 owner_id: &str,
802 manifest: &Manifest,
803 can_delete: bool,
804 out: &mut Vec<Action>,
805) {
806 let Some(entry) = manifest.get(owner_id) else {
807 return;
808 };
809 for (kind, state) in manifest_artifacts(entry) {
810 if let Some(action) =
811 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
812 {
813 out.push(action);
814 }
815 }
816}
817
818fn delete_stem_action(
827 clip_id: &str,
828 key: &str,
829 path: &str,
830 manifest: &Manifest,
831 can_delete: bool,
832) -> Option<Action> {
833 if !can_delete {
834 return None;
835 }
836 let entry = manifest.get(clip_id)?;
837 if path.is_empty() || entry.preserve {
838 return None;
839 }
840 Some(Action::DeleteStem {
841 clip_id: clip_id.to_string(),
842 key: key.to_string(),
843 path: path.to_string(),
844 })
845}
846
847fn plan_clip_stems(d: &Desired, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
863 let Some(desired_stems) = &d.stems else {
864 return;
865 };
866 let clip_id = d.clip.id.as_str();
867 let entry = manifest.get(clip_id);
868
869 for stem in desired_stems {
870 let needs_write = match entry.and_then(|e| e.stems.get(&stem.key)) {
871 None => true,
872 Some(state) => state.hash != stem.hash || state.path != stem.path,
873 };
874 if needs_write {
875 out.push(Action::WriteStem {
876 clip_id: clip_id.to_string(),
877 key: stem.key.clone(),
878 stem_id: stem.stem_id.clone(),
879 path: stem.path.clone(),
880 source_url: stem.source_url.clone(),
881 format: stem.format,
882 hash: stem.hash.clone(),
883 });
884 }
885 }
886
887 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
888 if !protected_now && let Some(entry) = entry {
889 let desired_keys: BTreeSet<&str> = desired_stems.iter().map(|s| s.key.as_str()).collect();
890 for (key, state) in &entry.stems {
891 if !desired_keys.contains(key.as_str())
897 && let Some(action) =
898 delete_stem_action(clip_id, key, &state.path, manifest, can_delete)
899 {
900 out.push(action);
901 }
902 }
903 }
904}
905
906fn co_delete_stems(clip_id: &str, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
914 let Some(entry) = manifest.get(clip_id) else {
915 return;
916 };
917 for (key, state) in &entry.stems {
918 if let Some(action) = delete_stem_action(clip_id, key, &state.path, manifest, can_delete) {
919 out.push(action);
920 }
921 }
922}
923
924fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
931 let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
932 for d in desired {
933 match by_id.get_mut(d.clip.id.as_str()) {
934 None => {
935 by_id.insert(d.clip.id.as_str(), d.clone());
936 }
937 Some(acc) => {
938 let take = rep_key(d) < rep_key(acc);
939 acc.private = acc.private || d.private;
940 acc.trashed = acc.trashed && d.trashed;
941 for mode in &d.modes {
942 if !acc.modes.contains(mode) {
943 acc.modes.push(*mode);
944 }
945 }
946 if take {
947 acc.clip = d.clip.clone();
948 acc.path = d.path.clone();
949 acc.format = d.format;
950 acc.meta_hash = d.meta_hash.clone();
951 acc.art_hash = d.art_hash.clone();
952 acc.artifacts = d.artifacts.clone();
953 acc.stems = d.stems.clone();
954 }
955 }
956 }
957 }
958 let mut out: Vec<Desired> = by_id.into_values().collect();
959 for d in &mut out {
960 let has_mirror = d.modes.contains(&SourceMode::Mirror);
962 let has_copy = d.modes.contains(&SourceMode::Copy);
963 d.modes.clear();
964 if has_mirror {
965 d.modes.push(SourceMode::Mirror);
966 }
967 if has_copy {
968 d.modes.push(SourceMode::Copy);
969 }
970 }
971 out
972}
973
974fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
977 let format = match d.format {
978 AudioFormat::Mp3 => 0,
979 AudioFormat::Flac => 1,
980 AudioFormat::Wav => 2,
981 };
982 (
983 d.path.as_str(),
984 d.meta_hash.as_str(),
985 d.art_hash.as_str(),
986 format,
987 )
988}
989
990fn suppress_path_aliasing(actions: &mut [Action]) {
996 let targets: BTreeSet<String> = actions
997 .iter()
998 .filter_map(|a| match a {
999 Action::Download { path, .. }
1000 | Action::Reformat { path, .. }
1001 | Action::WriteArtifact { path, .. }
1002 | Action::WriteStem { path, .. } => Some(path.clone()),
1003 Action::Rename { to, .. } => Some(to.clone()),
1004 _ => None,
1005 })
1006 .collect();
1007 for a in actions.iter_mut() {
1008 if let Action::Delete { path, clip_id } = a
1009 && targets.contains(path.as_str())
1010 {
1011 *a = Action::Skip {
1012 clip_id: clip_id.clone(),
1013 };
1014 }
1015 if let Action::DeleteArtifact { path, owner_id, .. } = a
1016 && targets.contains(path.as_str())
1017 {
1018 *a = Action::Skip {
1019 clip_id: owner_id.clone(),
1020 };
1021 }
1022 if let Action::DeleteStem { path, clip_id, .. } = a
1023 && targets.contains(path.as_str())
1024 {
1025 *a = Action::Skip {
1026 clip_id: clip_id.clone(),
1027 };
1028 }
1029 }
1030}
1031
1032fn plan_desired(
1034 d: &Desired,
1035 manifest: &Manifest,
1036 local: &HashMap<String, LocalFile>,
1037 can_delete: bool,
1038 out: &mut Vec<Action>,
1039) {
1040 let clip_id = d.clip.id.as_str();
1041 let copy_held = d.modes.contains(&SourceMode::Copy);
1042
1043 if d.trashed && !d.private && !copy_held {
1049 match delete_action(clip_id, manifest, can_delete) {
1050 Some(action) => out.push(action),
1051 None => out.push(Action::Skip {
1052 clip_id: clip_id.to_string(),
1053 }),
1054 }
1055 return;
1056 }
1057
1058 let Some(entry) = manifest.get(clip_id) else {
1059 out.push(Action::Download {
1061 clip: d.clip.clone(),
1062 lineage: d.lineage.clone(),
1063 path: d.path.clone(),
1064 format: d.format,
1065 });
1066 return;
1067 };
1068
1069 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
1072 if missing {
1073 out.push(Action::Download {
1074 clip: d.clip.clone(),
1075 lineage: d.lineage.clone(),
1076 path: d.path.clone(),
1077 format: d.format,
1078 });
1079 return;
1080 }
1081
1082 if d.format != entry.format {
1083 out.push(Action::Reformat {
1086 clip: d.clip.clone(),
1087 path: d.path.clone(),
1088 from_path: entry.path.clone(),
1089 from: entry.format,
1090 to: d.format,
1091 });
1092 return;
1093 }
1094
1095 if d.path != entry.path {
1096 out.push(Action::Rename {
1097 from: entry.path.clone(),
1098 to: d.path.clone(),
1099 });
1100 if meta_or_art_changed(d, entry) {
1102 out.push(Action::Retag {
1103 clip: d.clip.clone(),
1104 lineage: d.lineage.clone(),
1105 path: d.path.clone(),
1106 });
1107 }
1108 return;
1109 }
1110
1111 if meta_or_art_changed(d, entry) {
1112 out.push(Action::Retag {
1113 clip: d.clip.clone(),
1114 lineage: d.lineage.clone(),
1115 path: entry.path.clone(),
1116 });
1117 return;
1118 }
1119
1120 out.push(Action::Skip {
1121 clip_id: clip_id.to_string(),
1122 });
1123}
1124
1125fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
1127 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
1128}
1129
1130pub fn album_desired(
1154 desired: &[Desired],
1155 animated_covers: bool,
1156 raw_cover: bool,
1157) -> Vec<AlbumDesired> {
1158 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
1159 for d in desired {
1160 groups
1161 .entry(d.lineage.root_id.as_str())
1162 .or_default()
1163 .push(d);
1164 }
1165
1166 groups
1167 .into_iter()
1168 .map(|(root_id, members)| {
1169 let album_dir = album_dir_of(&members);
1170 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
1171 kind: ArtifactKind::FolderJpg,
1172 path: album_child(&album_dir, "folder.jpg"),
1173 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
1174 hash: art_hash(&source.clip),
1175 content: None,
1176 });
1177 let folder_webp = animated_covers
1178 .then(|| folder_webp_source(&members))
1179 .flatten()
1180 .map(|source| DesiredArtifact {
1181 kind: ArtifactKind::FolderWebp,
1182 path: album_child(&album_dir, "cover.webp"),
1183 source_url: source.clip.video_cover_url.clone(),
1184 hash: art_url_hash(&source.clip.video_cover_url),
1185 content: None,
1186 });
1187 let folder_mp4 = raw_cover
1188 .then(|| folder_webp_source(&members))
1189 .flatten()
1190 .map(|source| DesiredArtifact {
1191 kind: ArtifactKind::FolderMp4,
1192 path: album_child(&album_dir, "cover.mp4"),
1193 source_url: source.clip.video_cover_url.clone(),
1194 hash: art_url_hash(&source.clip.video_cover_url),
1195 content: None,
1196 });
1197 AlbumDesired {
1198 root_id: root_id.to_owned(),
1199 folder_jpg,
1200 folder_webp,
1201 folder_mp4,
1202 }
1203 })
1204 .collect()
1205}
1206
1207fn album_dir_of(members: &[&Desired]) -> String {
1212 members
1213 .iter()
1214 .map(|d| parent_dir(&d.path))
1215 .min()
1216 .unwrap_or("")
1217 .to_owned()
1218}
1219
1220fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1226 members
1227 .iter()
1228 .copied()
1229 .filter(|d| {
1230 d.clip
1231 .selected_image_url()
1232 .is_some_and(|url| !url.is_empty())
1233 })
1234 .min_by(|a, b| {
1235 b.clip
1236 .play_count
1237 .cmp(&a.clip.play_count)
1238 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
1239 .then_with(|| a.clip.id.cmp(&b.clip.id))
1240 })
1241}
1242
1243fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1248 members
1249 .iter()
1250 .copied()
1251 .filter(|d| !d.clip.video_cover_url.is_empty())
1252 .min_by(|a, b| {
1253 a.clip
1254 .created_at
1255 .cmp(&b.clip.created_at)
1256 .then_with(|| a.clip.id.cmp(&b.clip.id))
1257 })
1258}
1259
1260fn parent_dir(path: &str) -> &str {
1262 match path.rsplit_once('/') {
1263 Some((dir, _)) => dir,
1264 None => "",
1265 }
1266}
1267
1268fn album_child(album_dir: &str, name: &str) -> String {
1271 if album_dir.is_empty() {
1272 name.to_owned()
1273 } else {
1274 format!("{album_dir}/{name}")
1275 }
1276}
1277
1278pub fn plan_album_artifacts(
1297 desired: &[AlbumDesired],
1298 albums: &BTreeMap<String, AlbumArt>,
1299 can_delete: bool,
1300) -> Vec<Action> {
1301 let mut actions: Vec<Action> = Vec::new();
1302 let by_root: BTreeMap<&str, &AlbumDesired> =
1303 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1304
1305 for d in desired {
1306 let stored = albums.get(&d.root_id);
1307 for artifact in [
1308 d.folder_jpg.as_ref(),
1309 d.folder_webp.as_ref(),
1310 d.folder_mp4.as_ref(),
1311 ]
1312 .into_iter()
1313 .flatten()
1314 {
1315 let needs_write = match stored.and_then(|a| a.artifact(artifact.kind)) {
1316 None => true,
1317 Some(state) => state.hash != artifact.hash || state.path != artifact.path,
1318 };
1319 if needs_write {
1320 actions.push(Action::WriteArtifact {
1321 kind: artifact.kind,
1322 path: artifact.path.clone(),
1323 source_url: artifact.source_url.clone(),
1324 hash: artifact.hash.clone(),
1325 owner_id: d.root_id.clone(),
1326 content: None,
1327 });
1328 }
1329 }
1330 }
1331
1332 if can_delete {
1334 for (root_id, art) in albums {
1335 for (kind, state) in album_artifacts(art) {
1336 let desired_here = by_root
1337 .get(root_id.as_str())
1338 .is_some_and(|d| album_desires_kind(d, kind));
1339 if !desired_here && !state.path.is_empty() {
1340 actions.push(Action::DeleteArtifact {
1341 kind,
1342 path: state.path.clone(),
1343 owner_id: root_id.clone(),
1344 });
1345 }
1346 }
1347 }
1348 }
1349
1350 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1351 actions
1352}
1353
1354fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1357 let mut out = Vec::new();
1358 if let Some(state) = &art.folder_jpg {
1359 out.push((ArtifactKind::FolderJpg, state));
1360 }
1361 if let Some(state) = &art.folder_webp {
1362 out.push((ArtifactKind::FolderWebp, state));
1363 }
1364 if let Some(state) = &art.folder_mp4 {
1365 out.push((ArtifactKind::FolderMp4, state));
1366 }
1367 out
1368}
1369
1370fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1372 match kind {
1373 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1374 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1375 ArtifactKind::FolderMp4 => d.folder_mp4.is_some(),
1376 ArtifactKind::CoverJpg
1377 | ArtifactKind::CoverWebp
1378 | ArtifactKind::DetailsTxt
1379 | ArtifactKind::LyricsTxt
1380 | ArtifactKind::Lrc
1381 | ArtifactKind::VideoMp4
1382 | ArtifactKind::Playlist => false,
1383 }
1384}
1385
1386fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1388 match action {
1389 Action::WriteArtifact { owner_id, kind, .. }
1390 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1391 _ => ("", ArtifactKind::CoverJpg),
1392 }
1393}
1394
1395pub fn plan_playlist_artifacts(
1428 desired: &[PlaylistDesired],
1429 stored: &BTreeMap<String, PlaylistState>,
1430 can_delete: bool,
1431 list_fully_enumerated: bool,
1432) -> Vec<Action> {
1433 let mut actions: Vec<Action> = Vec::new();
1434 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1435 let deletes_allowed = can_delete && list_fully_enumerated;
1438
1439 for d in desired {
1440 let stored_here = stored.get(&d.id);
1441 let needs_write = match stored_here {
1442 None => true,
1443 Some(state) => state.hash != d.hash || state.path != d.path,
1444 };
1445 if needs_write {
1446 actions.push(Action::WriteArtifact {
1447 kind: ArtifactKind::Playlist,
1448 path: d.path.clone(),
1449 source_url: String::new(),
1450 hash: d.hash.clone(),
1451 owner_id: d.id.clone(),
1452 content: Some(d.content.clone()),
1453 });
1454 }
1455 if deletes_allowed
1457 && let Some(state) = stored_here
1458 && !state.path.is_empty()
1459 && state.path != d.path
1460 {
1461 actions.push(Action::DeleteArtifact {
1462 kind: ArtifactKind::Playlist,
1463 path: state.path.clone(),
1464 owner_id: d.id.clone(),
1465 });
1466 }
1467 }
1468
1469 if deletes_allowed {
1472 for (id, state) in stored {
1473 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1474 actions.push(Action::DeleteArtifact {
1475 kind: ArtifactKind::Playlist,
1476 path: state.path.clone(),
1477 owner_id: id.clone(),
1478 });
1479 }
1480 }
1481 }
1482
1483 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1484 suppress_path_aliasing(&mut actions);
1487 actions
1488}
1489
1490fn playlist_action_key(action: &Action) -> (&str, u8) {
1493 match action {
1494 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1495 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1496 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1497 _ => ("", 3),
1498 }
1499}
1500
1501#[cfg(test)]
1502mod tests {
1503 use super::*;
1504 use crate::hash::content_hash;
1505
1506 fn clip(id: &str) -> Clip {
1507 Clip {
1508 id: id.to_string(),
1509 title: "Song".to_string(),
1510 ..Default::default()
1511 }
1512 }
1513
1514 fn lineage(id: &str) -> LineageContext {
1515 LineageContext::own_root(&clip(id))
1516 }
1517
1518 fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1519 ManifestEntry {
1520 path: path.to_string(),
1521 format,
1522 meta_hash: meta.to_string(),
1523 art_hash: art.to_string(),
1524 size: 100,
1525 preserve: false,
1526 ..Default::default()
1527 }
1528 }
1529
1530 fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1531 ManifestEntry {
1532 preserve: true,
1533 ..entry(path, format, meta, art)
1534 }
1535 }
1536
1537 fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1538 Desired {
1539 clip: clip(id),
1540 lineage: lineage(id),
1541 path: path.to_string(),
1542 format,
1543 meta_hash: meta.to_string(),
1544 art_hash: art.to_string(),
1545 modes: vec![SourceMode::Mirror],
1546 trashed: false,
1547 private: false,
1548 artifacts: Vec::new(),
1549 stems: None,
1550 }
1551 }
1552
1553 fn present(size: u64) -> LocalFile {
1554 LocalFile { exists: true, size }
1555 }
1556
1557 fn local_present(id: &str) -> HashMap<String, LocalFile> {
1558 [(id.to_string(), present(100))].into_iter().collect()
1559 }
1560
1561 fn mirror_ok() -> Vec<SourceStatus> {
1562 vec![SourceStatus {
1563 mode: SourceMode::Mirror,
1564 fully_enumerated: true,
1565 }]
1566 }
1567
1568 #[test]
1571 fn not_in_manifest_downloads() {
1572 let manifest = Manifest::new();
1573 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1574 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1575 assert_eq!(
1576 plan.actions,
1577 vec![Action::Download {
1578 clip: clip("a"),
1579 lineage: lineage("a"),
1580 path: "a.flac".to_string(),
1581 format: AudioFormat::Flac,
1582 }]
1583 );
1584 }
1585
1586 #[test]
1587 fn unchanged_clip_skips() {
1588 let mut manifest = Manifest::new();
1589 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1590 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1591 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1592 assert_eq!(
1593 plan.actions,
1594 vec![Action::Skip {
1595 clip_id: "a".to_string()
1596 }]
1597 );
1598 }
1599
1600 #[test]
1601 fn meta_change_retags_in_place() {
1602 let mut manifest = Manifest::new();
1603 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1604 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1605 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1606 assert_eq!(
1607 plan.actions,
1608 vec![Action::Retag {
1609 clip: clip("a"),
1610 lineage: lineage("a"),
1611 path: "a.flac".to_string(),
1612 }]
1613 );
1614 }
1615
1616 #[test]
1617 fn art_change_retags_in_place() {
1618 let mut manifest = Manifest::new();
1619 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1620 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1621 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1622 assert_eq!(
1623 plan.actions,
1624 vec![Action::Retag {
1625 clip: clip("a"),
1626 lineage: lineage("a"),
1627 path: "a.flac".to_string(),
1628 }]
1629 );
1630 }
1631
1632 #[test]
1633 fn rename_when_path_changes() {
1634 let mut manifest = Manifest::new();
1635 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1636 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1637 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1638 assert_eq!(
1639 plan.actions,
1640 vec![Action::Rename {
1641 from: "old/a.flac".to_string(),
1642 to: "new/a.flac".to_string(),
1643 }]
1644 );
1645 }
1646
1647 #[test]
1648 fn rename_with_meta_change_also_retags() {
1649 let mut manifest = Manifest::new();
1650 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1651 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1652 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1653 assert_eq!(
1654 plan.actions,
1655 vec![
1656 Action::Rename {
1657 from: "old/a.flac".to_string(),
1658 to: "new/a.flac".to_string(),
1659 },
1660 Action::Retag {
1661 clip: clip("a"),
1662 lineage: lineage("a"),
1663 path: "new/a.flac".to_string(),
1664 },
1665 ]
1666 );
1667 }
1668
1669 #[test]
1670 fn rename_without_meta_change_does_not_retag() {
1671 let mut manifest = Manifest::new();
1672 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1673 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1674 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1675 assert_eq!(plan.renames(), 1);
1676 assert_eq!(plan.retags(), 0);
1677 }
1678
1679 #[test]
1680 fn bulk_album_rename_moves_and_retags_without_redownload() {
1681 let mut manifest = Manifest::new();
1686 for id in ["a", "b", "c"] {
1687 manifest.insert(
1688 id,
1689 entry(
1690 &format!("Creator/Old Album/{id}.flac"),
1691 AudioFormat::Flac,
1692 "old-meta",
1693 "art",
1694 ),
1695 );
1696 }
1697 let d: Vec<Desired> = ["a", "b", "c"]
1698 .iter()
1699 .map(|id| {
1700 desired(
1701 id,
1702 &format!("Creator/New Album/{id}.flac"),
1703 AudioFormat::Flac,
1704 "new-meta",
1705 "art",
1706 )
1707 })
1708 .collect();
1709 let local: HashMap<String, LocalFile> = ["a", "b", "c"]
1710 .iter()
1711 .map(|id| (id.to_string(), present(100)))
1712 .collect();
1713
1714 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1715
1716 assert_eq!(plan.renames(), 3, "every member folder move is a rename");
1717 assert_eq!(
1718 plan.retags(),
1719 3,
1720 "the album tag change retags each in place"
1721 );
1722 assert_eq!(
1723 plan.downloads(),
1724 0,
1725 "an album rename must never re-download"
1726 );
1727 assert_eq!(
1728 plan.deletes(),
1729 0,
1730 "deletion safety: a rename deletes nothing"
1731 );
1732 for id in ["a", "b", "c"] {
1733 assert!(plan.actions.contains(&Action::Rename {
1734 from: format!("Creator/Old Album/{id}.flac"),
1735 to: format!("Creator/New Album/{id}.flac"),
1736 }));
1737 }
1738 }
1739
1740 #[test]
1741 fn format_change_reformats() {
1742 let mut manifest = Manifest::new();
1743 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1744 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1745 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1746 assert_eq!(
1747 plan.actions,
1748 vec![Action::Reformat {
1749 clip: clip("a"),
1750 path: "a.mp3".to_string(),
1751 from_path: "a.flac".to_string(),
1752 from: AudioFormat::Flac,
1753 to: AudioFormat::Mp3,
1754 }]
1755 );
1756 }
1757
1758 #[test]
1759 fn format_change_takes_precedence_over_rename_and_retag() {
1760 let mut manifest = Manifest::new();
1763 manifest.insert(
1764 "a",
1765 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1766 );
1767 let d = vec![desired(
1768 "a",
1769 "new/a.mp3",
1770 AudioFormat::Mp3,
1771 "new",
1772 "new-art",
1773 )];
1774 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1775 assert_eq!(plan.reformats(), 1);
1776 assert_eq!(plan.renames(), 0);
1777 assert_eq!(plan.retags(), 0);
1778 }
1779
1780 #[test]
1783 fn zero_length_file_downloads_even_when_hashes_match() {
1784 let mut manifest = Manifest::new();
1785 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1786 let local: HashMap<String, LocalFile> = [(
1787 "a".to_string(),
1788 LocalFile {
1789 exists: true,
1790 size: 0,
1791 },
1792 )]
1793 .into_iter()
1794 .collect();
1795 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1796 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1797 assert_eq!(plan.downloads(), 1);
1798 assert_eq!(plan.skips(), 0);
1799 }
1800
1801 #[test]
1802 fn missing_file_downloads_even_when_hashes_match() {
1803 let mut manifest = Manifest::new();
1804 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1805 let local: HashMap<String, LocalFile> = [(
1806 "a".to_string(),
1807 LocalFile {
1808 exists: false,
1809 size: 0,
1810 },
1811 )]
1812 .into_iter()
1813 .collect();
1814 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1815 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1816 assert_eq!(plan.downloads(), 1);
1817 }
1818
1819 #[test]
1820 fn absent_local_probe_treated_as_missing() {
1821 let mut manifest = Manifest::new();
1823 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1824 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1825 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1826 assert_eq!(plan.downloads(), 1);
1827 }
1828
1829 #[test]
1830 fn missing_file_download_wins_over_format_difference() {
1831 let mut manifest = Manifest::new();
1834 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1835 let local: HashMap<String, LocalFile> = [(
1836 "a".to_string(),
1837 LocalFile {
1838 exists: false,
1839 size: 0,
1840 },
1841 )]
1842 .into_iter()
1843 .collect();
1844 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1845 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1846 assert_eq!(plan.downloads(), 1);
1847 assert_eq!(plan.reformats(), 0);
1848 }
1849
1850 #[test]
1853 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
1854 let mut trashed = clip("a");
1859 trashed.status = "complete".to_string();
1860 trashed.is_trashed = true;
1861 assert!(crate::is_downloadable(&trashed));
1862
1863 let mut manifest = Manifest::new();
1864 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1865 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1866 d.clip = trashed;
1867 d.trashed = true;
1868 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1869 assert_eq!(
1870 plan.actions,
1871 vec![Action::Delete {
1872 path: "a.flac".to_string(),
1873 clip_id: "a".to_string(),
1874 }]
1875 );
1876 }
1877
1878 #[test]
1879 fn trashed_clip_deletes_local_file() {
1880 let mut manifest = Manifest::new();
1881 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1882 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1883 d.trashed = true;
1884 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1885 assert_eq!(
1886 plan.actions,
1887 vec![Action::Delete {
1888 path: "a.flac".to_string(),
1889 clip_id: "a".to_string(),
1890 }]
1891 );
1892 }
1893
1894 #[test]
1895 fn trashed_clip_not_in_manifest_skips() {
1896 let manifest = Manifest::new();
1898 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1899 d.trashed = true;
1900 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1901 assert_eq!(
1902 plan.actions,
1903 vec![Action::Skip {
1904 clip_id: "a".to_string()
1905 }]
1906 );
1907 }
1908
1909 #[test]
1910 fn private_clip_is_kept() {
1911 let mut manifest = Manifest::new();
1912 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1913 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1914 d.private = true;
1915 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1916 assert_eq!(
1917 plan.actions,
1918 vec![Action::Skip {
1919 clip_id: "a".to_string()
1920 }]
1921 );
1922 }
1923
1924 #[test]
1925 fn private_beats_trashed_never_deletes() {
1926 let mut manifest = Manifest::new();
1928 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1929 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1930 d.trashed = true;
1931 d.private = true;
1932 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1933 assert_eq!(plan.deletes(), 0);
1934 assert_eq!(plan.skips(), 1);
1935 }
1936
1937 #[test]
1938 fn copy_held_trashed_clip_is_not_deleted() {
1939 let mut manifest = Manifest::new();
1942 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1943 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1944 d.modes = vec![SourceMode::Copy];
1945 d.trashed = true;
1946 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1947 assert_eq!(plan.deletes(), 0);
1948 assert_eq!(
1949 plan.actions,
1950 vec![Action::Skip {
1951 clip_id: "a".to_string()
1952 }]
1953 );
1954 }
1955
1956 #[test]
1959 fn absent_clip_deleted_when_all_mirrors_enumerated() {
1960 let mut manifest = Manifest::new();
1961 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1962 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
1963 assert_eq!(
1964 plan.actions,
1965 vec![Action::Delete {
1966 path: "gone.flac".to_string(),
1967 clip_id: "gone".to_string(),
1968 }]
1969 );
1970 }
1971
1972 #[test]
1973 fn absent_clip_kept_when_any_mirror_not_enumerated() {
1974 let mut manifest = Manifest::new();
1975 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
1976 let sources = vec![
1977 SourceStatus {
1978 mode: SourceMode::Mirror,
1979 fully_enumerated: true,
1980 },
1981 SourceStatus {
1982 mode: SourceMode::Mirror,
1983 fully_enumerated: false,
1984 },
1985 ];
1986 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
1987 assert_eq!(plan.deletes(), 0);
1988 assert_eq!(
1989 plan.actions,
1990 vec![Action::Skip {
1991 clip_id: "gone".to_string()
1992 }]
1993 );
1994 }
1995
1996 #[test]
1997 fn empty_listing_cannot_cause_deletion() {
1998 let mut manifest = Manifest::new();
2001 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2002 let sources = vec![SourceStatus {
2003 mode: SourceMode::Mirror,
2004 fully_enumerated: false,
2005 }];
2006 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2007 assert_eq!(plan.deletes(), 0);
2008 assert_eq!(plan.skips(), 1);
2009 }
2010
2011 #[test]
2012 fn no_mirror_sources_means_no_deletion() {
2013 let mut manifest = Manifest::new();
2015 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2016 let copy_only = vec![SourceStatus {
2017 mode: SourceMode::Copy,
2018 fully_enumerated: true,
2019 }];
2020 assert_eq!(
2021 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
2022 0
2023 );
2024 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
2025 }
2026
2027 #[test]
2028 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
2029 let mut manifest = Manifest::new();
2030 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2031 let sources = vec![
2032 SourceStatus {
2033 mode: SourceMode::Copy,
2034 fully_enumerated: true,
2035 },
2036 SourceStatus {
2037 mode: SourceMode::Mirror,
2038 fully_enumerated: false,
2039 },
2040 ];
2041 assert_eq!(
2042 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
2043 0
2044 );
2045 }
2046
2047 #[test]
2048 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
2049 let mut manifest = Manifest::new();
2053 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
2054 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2055 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
2056 held.modes = vec![SourceMode::Copy];
2057 let local: HashMap<String, LocalFile> = [
2058 ("keep".to_string(), present(100)),
2059 ("gone".to_string(), present(100)),
2060 ]
2061 .into_iter()
2062 .collect();
2063 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
2064 assert!(plan.actions.contains(&Action::Skip {
2065 clip_id: "keep".to_string()
2066 }));
2067 assert!(plan.actions.contains(&Action::Delete {
2068 path: "gone.flac".to_string(),
2069 clip_id: "gone".to_string(),
2070 }));
2071 assert!(
2073 !plan
2074 .actions
2075 .iter()
2076 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
2077 );
2078 }
2079
2080 #[test]
2083 fn orphan_with_preserve_marker_is_kept() {
2084 let mut manifest = Manifest::new();
2087 manifest.insert(
2088 "gone",
2089 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
2090 );
2091 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2092 assert_eq!(plan.deletes(), 0);
2093 assert_eq!(
2094 plan.actions,
2095 vec![Action::Skip {
2096 clip_id: "gone".to_string()
2097 }]
2098 );
2099 }
2100
2101 #[test]
2102 fn trashed_clip_with_preserve_marker_is_kept() {
2103 let mut manifest = Manifest::new();
2106 manifest.insert(
2107 "a",
2108 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2109 );
2110 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2111 d.trashed = true;
2112 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2113 assert_eq!(plan.deletes(), 0);
2114 assert_eq!(plan.skips(), 1);
2115 }
2116
2117 #[test]
2120 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
2121 let mut manifest = Manifest::new();
2123 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2124 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2125 d.trashed = true;
2126 let sources = vec![SourceStatus {
2127 mode: SourceMode::Mirror,
2128 fully_enumerated: false,
2129 }];
2130 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2131 assert_eq!(plan.deletes(), 0);
2132 assert_eq!(plan.skips(), 1);
2133 }
2134
2135 #[test]
2136 fn trashed_clip_kept_when_sources_empty() {
2137 let mut manifest = Manifest::new();
2140 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2141 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2142 d.trashed = true;
2143 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
2144 assert_eq!(plan.deletes(), 0);
2145 assert_eq!(plan.skips(), 1);
2146 }
2147
2148 #[test]
2149 fn failed_copy_listing_suppresses_orphan_deletion() {
2150 let mut manifest = Manifest::new();
2153 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2154 let sources = vec![
2155 SourceStatus {
2156 mode: SourceMode::Mirror,
2157 fully_enumerated: true,
2158 },
2159 SourceStatus {
2160 mode: SourceMode::Copy,
2161 fully_enumerated: false,
2162 },
2163 ];
2164 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2165 assert_eq!(plan.deletes(), 0);
2166 }
2167
2168 #[test]
2169 fn failed_copy_listing_suppresses_trashed_deletion() {
2170 let mut manifest = Manifest::new();
2171 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2172 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2173 d.trashed = true;
2174 let sources = vec![
2175 SourceStatus {
2176 mode: SourceMode::Mirror,
2177 fully_enumerated: true,
2178 },
2179 SourceStatus {
2180 mode: SourceMode::Copy,
2181 fully_enumerated: false,
2182 },
2183 ];
2184 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2185 assert_eq!(plan.deletes(), 0);
2186 assert_eq!(plan.skips(), 1);
2187 }
2188
2189 #[test]
2190 fn empty_path_entry_never_deletes() {
2191 let mut manifest = Manifest::new();
2194 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
2195 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2196 assert_eq!(plan.deletes(), 0);
2197 assert_eq!(
2198 plan.actions,
2199 vec![Action::Skip {
2200 clip_id: "gone".to_string()
2201 }]
2202 );
2203 }
2204
2205 #[test]
2208 fn delete_suppressed_when_path_aliases_rename_target() {
2209 let mut manifest = Manifest::new();
2212 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
2213 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
2214 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
2215 let local: HashMap<String, LocalFile> = [
2216 ("a".to_string(), present(100)),
2217 ("b".to_string(), present(100)),
2218 ]
2219 .into_iter()
2220 .collect();
2221 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2222 assert!(plan.actions.contains(&Action::Rename {
2223 from: "old/a.flac".to_string(),
2224 to: "new/a.flac".to_string(),
2225 }));
2226 assert!(
2228 !plan
2229 .actions
2230 .iter()
2231 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
2232 );
2233 assert!(plan.actions.contains(&Action::Skip {
2234 clip_id: "b".to_string()
2235 }));
2236 }
2237
2238 #[test]
2239 fn delete_suppressed_when_path_aliases_download_target() {
2240 let mut manifest = Manifest::new();
2242 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
2243 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
2244 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2245 assert!(
2246 !plan
2247 .actions
2248 .iter()
2249 .any(|a| matches!(a, Action::Delete { .. }))
2250 );
2251 assert_eq!(plan.downloads(), 1);
2252 }
2253
2254 #[test]
2255 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
2256 let mut actions = vec![
2261 Action::Rename {
2262 from: "old/song.flac".to_string(),
2263 to: "new/cover.jpg".to_string(),
2264 },
2265 Action::DeleteArtifact {
2266 kind: ArtifactKind::CoverJpg,
2267 path: "new/cover.jpg".to_string(),
2268 owner_id: "a".to_string(),
2269 },
2270 ];
2271 suppress_path_aliasing(&mut actions);
2272 assert!(
2274 !actions
2275 .iter()
2276 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
2277 "a sidecar delete must not alias a rename target"
2278 );
2279 assert!(actions.contains(&Action::Skip {
2280 clip_id: "a".to_string()
2281 }));
2282 assert!(actions.contains(&Action::Rename {
2284 from: "old/song.flac".to_string(),
2285 to: "new/cover.jpg".to_string(),
2286 }));
2287 }
2288
2289 #[test]
2290 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
2291 let mut actions = vec![
2294 Action::WriteArtifact {
2295 kind: ArtifactKind::FolderJpg,
2296 path: "creator/album/folder.jpg".to_string(),
2297 source_url: "https://art/large.jpg".to_string(),
2298 hash: "h".to_string(),
2299 owner_id: "root".to_string(),
2300 content: None,
2301 },
2302 Action::DeleteArtifact {
2303 kind: ArtifactKind::FolderJpg,
2304 path: "creator/album/folder.jpg".to_string(),
2305 owner_id: "root-old".to_string(),
2306 },
2307 ];
2308 suppress_path_aliasing(&mut actions);
2309 assert!(
2310 !actions
2311 .iter()
2312 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
2313 );
2314 assert!(actions.contains(&Action::Skip {
2315 clip_id: "root-old".to_string()
2316 }));
2317 }
2318
2319 #[test]
2322 fn duplicate_trashed_does_not_defeat_copy_sibling() {
2323 let mut manifest = Manifest::new();
2326 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2327 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2328 copy_entry.modes = vec![SourceMode::Copy];
2329 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2330 trashed_entry.modes = vec![SourceMode::Mirror];
2331 trashed_entry.trashed = true;
2332 let plan = reconcile(
2333 &manifest,
2334 &[copy_entry, trashed_entry],
2335 &local_present("a"),
2336 &mirror_ok(),
2337 );
2338 assert_eq!(plan.deletes(), 0);
2339 assert_eq!(plan.skips(), 1);
2340 }
2341
2342 #[test]
2343 fn duplicate_trashed_does_not_defeat_private_sibling() {
2344 let mut manifest = Manifest::new();
2345 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2346 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2347 private_entry.private = true;
2348 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2349 trashed_entry.trashed = true;
2350 let plan = reconcile(
2351 &manifest,
2352 &[private_entry, trashed_entry],
2353 &local_present("a"),
2354 &mirror_ok(),
2355 );
2356 assert_eq!(plan.deletes(), 0);
2357 assert_eq!(plan.skips(), 1);
2358 }
2359
2360 #[test]
2361 fn duplicate_trashed_deletes_only_when_all_trashed() {
2362 let mut manifest = Manifest::new();
2364 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2365 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2366 first.trashed = true;
2367 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2368 second.trashed = true;
2369 let plan = reconcile(
2370 &manifest,
2371 &[first, second],
2372 &local_present("a"),
2373 &mirror_ok(),
2374 );
2375 assert_eq!(plan.deletes(), 1);
2376 }
2377
2378 #[test]
2379 fn duplicate_desired_unions_modes() {
2380 let mut manifest = Manifest::new();
2382 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2383 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2384 mirror_entry.modes = vec![SourceMode::Mirror];
2385 mirror_entry.trashed = true;
2386 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2387 copy_entry.modes = vec![SourceMode::Copy];
2388 let plan = reconcile(
2389 &manifest,
2390 &[mirror_entry, copy_entry],
2391 &local_present("a"),
2392 &mirror_ok(),
2393 );
2394 assert_eq!(plan.deletes(), 0);
2396 }
2397
2398 #[test]
2401 fn private_new_clip_downloads() {
2402 let manifest = Manifest::new();
2405 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2406 d.private = true;
2407 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2408 assert_eq!(plan.downloads(), 1);
2409 }
2410
2411 #[test]
2412 fn private_zero_length_file_redownloads() {
2413 let mut manifest = Manifest::new();
2414 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2415 let local: HashMap<String, LocalFile> = [(
2416 "a".to_string(),
2417 LocalFile {
2418 exists: true,
2419 size: 0,
2420 },
2421 )]
2422 .into_iter()
2423 .collect();
2424 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2425 d.private = true;
2426 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2427 assert_eq!(plan.downloads(), 1);
2428 }
2429
2430 #[test]
2431 fn private_meta_change_retags() {
2432 let mut manifest = Manifest::new();
2433 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2434 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2435 d.private = true;
2436 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2437 assert_eq!(plan.retags(), 1);
2438 assert_eq!(plan.deletes(), 0);
2439 }
2440
2441 #[test]
2442 fn absent_private_clip_protected_by_preserve_marker() {
2443 let mut manifest = Manifest::new();
2446 manifest.insert(
2447 "a",
2448 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2449 );
2450 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2451 assert_eq!(plan.deletes(), 0);
2452 assert_eq!(plan.skips(), 1);
2453 }
2454
2455 #[test]
2458 fn output_is_deterministic_regardless_of_input_order() {
2459 let mut manifest = Manifest::new();
2460 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2461 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2462 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2463 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2464 .iter()
2465 .map(|id| (id.to_string(), present(100)))
2466 .collect();
2467
2468 let forward = vec![
2469 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2470 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2471 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2472 ];
2473 let mut reversed = forward.clone();
2474 reversed.reverse();
2475
2476 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2477 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2478 assert_eq!(p1.actions, p2.actions);
2479
2480 let ids: Vec<&str> = p1
2483 .actions
2484 .iter()
2485 .map(|a| match a {
2486 Action::Skip { clip_id } => clip_id.as_str(),
2487 Action::Retag { clip, .. } => clip.id.as_str(),
2488 Action::Download { clip, .. } => clip.id.as_str(),
2489 Action::Delete { clip_id, .. } => clip_id.as_str(),
2490 Action::Reformat { clip, .. } => clip.id.as_str(),
2491 Action::Rename { to, .. } => to.as_str(),
2492 Action::WriteArtifact { owner_id, .. }
2493 | Action::DeleteArtifact { owner_id, .. } => owner_id.as_str(),
2494 Action::WriteStem { clip_id, .. } | Action::DeleteStem { clip_id, .. } => {
2495 clip_id.as_str()
2496 }
2497 })
2498 .collect();
2499 assert_eq!(ids, ["a", "b", "c", "z"]);
2500 }
2501
2502 #[test]
2503 fn empty_inputs_do_not_panic() {
2504 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2505 assert!(plan.is_empty());
2506 assert_eq!(plan.len(), 0);
2507 }
2508
2509 #[test]
2510 fn empty_desired_with_full_manifest_deletes_all() {
2511 let mut manifest = Manifest::new();
2512 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2513 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2514 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2515 assert_eq!(plan.deletes(), 2);
2516 }
2517
2518 #[test]
2519 fn full_desired_with_empty_manifest_downloads_all() {
2520 let d = vec![
2521 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2522 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2523 ];
2524 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2525 assert_eq!(plan.downloads(), 2);
2526 }
2527
2528 #[test]
2529 fn plan_counts_sum_to_len() {
2530 let mut manifest = Manifest::new();
2531 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2532 manifest.insert(
2533 "retag",
2534 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2535 );
2536 manifest.insert(
2537 "reformat",
2538 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2539 );
2540 manifest.insert(
2541 "rename",
2542 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2543 );
2544 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2545 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2546 .iter()
2547 .map(|id| (id.to_string(), present(100)))
2548 .collect();
2549 let d = vec![
2550 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2551 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2552 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2553 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2554 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2555 ];
2556 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2557 let summed = plan.downloads()
2558 + plan.reformats()
2559 + plan.retags()
2560 + plan.renames()
2561 + plan.deletes()
2562 + plan.skips();
2563 assert_eq!(summed, plan.len());
2564 assert_eq!(plan.downloads(), 1);
2565 assert_eq!(plan.reformats(), 1);
2566 assert_eq!(plan.retags(), 1);
2567 assert_eq!(plan.renames(), 1);
2568 assert_eq!(plan.deletes(), 1);
2569 assert_eq!(plan.skips(), 1);
2570 }
2571
2572 fn cover(path: &str, hash: &str) -> ArtifactState {
2575 ArtifactState {
2576 path: path.to_string(),
2577 hash: hash.to_string(),
2578 }
2579 }
2580
2581 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2582 DesiredArtifact {
2583 kind,
2584 path: path.to_string(),
2585 source_url: url.to_string(),
2586 hash: hash.to_string(),
2587 content: None,
2588 }
2589 }
2590
2591 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2593 DesiredArtifact {
2594 kind,
2595 path: path.to_string(),
2596 source_url: String::new(),
2597 hash: content_hash(body),
2598 content: Some(body.to_string()),
2599 }
2600 }
2601
2602 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2604 Desired {
2605 artifacts: arts,
2606 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2607 }
2608 }
2609
2610 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2612 ManifestEntry {
2613 cover_jpg: Some(cover(cover_path, cover_hash)),
2614 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2615 }
2616 }
2617
2618 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2619 plan.actions
2620 .iter()
2621 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2622 .collect()
2623 }
2624
2625 #[test]
2626 fn write_artifact_emitted_when_manifest_lacks_it() {
2627 let mut manifest = Manifest::new();
2630 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2631 let d = vec![desired_arts(
2632 "a",
2633 vec![art(
2634 ArtifactKind::CoverJpg,
2635 "a/cover.jpg",
2636 "https://art/a",
2637 "h1",
2638 )],
2639 )];
2640 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2641 assert_eq!(plan.artifact_writes(), 1);
2642 assert_eq!(plan.artifact_deletes(), 0);
2643 assert_eq!(plan.skips(), 1);
2644 assert_eq!(
2645 write_artifacts(&plan)[0],
2646 &Action::WriteArtifact {
2647 kind: ArtifactKind::CoverJpg,
2648 path: "a/cover.jpg".to_string(),
2649 source_url: "https://art/a".to_string(),
2650 hash: "h1".to_string(),
2651 owner_id: "a".to_string(),
2652 content: None,
2653 }
2654 );
2655 }
2656
2657 #[test]
2658 fn write_artifact_emitted_when_hash_differs() {
2659 let mut manifest = Manifest::new();
2662 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2663 let d = vec![desired_arts(
2664 "a",
2665 vec![art(
2666 ArtifactKind::CoverJpg,
2667 "a/cover.jpg",
2668 "https://art/a",
2669 "new",
2670 )],
2671 )];
2672 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2673 assert_eq!(plan.artifact_writes(), 1);
2674 assert_eq!(plan.artifact_deletes(), 0);
2675 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2676 assert_eq!(hash, "new");
2677 } else {
2678 panic!("expected a WriteArtifact");
2679 }
2680 }
2681
2682 #[test]
2683 fn write_artifact_skipped_when_hash_matches() {
2684 let mut manifest = Manifest::new();
2686 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2687 let d = vec![desired_arts(
2688 "a",
2689 vec![art(
2690 ArtifactKind::CoverJpg,
2691 "a/cover.jpg",
2692 "https://art/a",
2693 "h1",
2694 )],
2695 )];
2696 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2697 assert_eq!(plan.artifact_writes(), 0);
2698 assert_eq!(plan.artifact_deletes(), 0);
2699 assert_eq!(
2700 plan.actions,
2701 vec![Action::Skip {
2702 clip_id: "a".to_string()
2703 }]
2704 );
2705 }
2706
2707 #[test]
2708 fn removed_kind_cover_is_kept_not_deleted() {
2709 let mut manifest = Manifest::new();
2714 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2715 let d = vec![desired_arts("a", vec![])];
2716 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2717 assert_eq!(plan.artifact_deletes(), 0);
2718 assert_eq!(plan.artifact_writes(), 0);
2719 assert_eq!(plan.deletes(), 0);
2721 assert_eq!(
2722 plan.actions,
2723 vec![Action::Skip {
2724 clip_id: "a".to_string()
2725 }]
2726 );
2727 assert!(!plan.actions.iter().any(|a| matches!(
2728 a,
2729 Action::DeleteArtifact {
2730 kind: ArtifactKind::CoverJpg,
2731 ..
2732 }
2733 )));
2734 }
2735
2736 #[test]
2737 fn delete_artifact_never_on_incomplete_listing() {
2738 let mut manifest = Manifest::new();
2743 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2744 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2745 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2746 let sources = vec![SourceStatus {
2747 mode: SourceMode::Mirror,
2748 fully_enumerated: false,
2749 }];
2750 let local: HashMap<String, LocalFile> = [
2751 ("a".to_string(), present(100)),
2752 ("b".to_string(), present(100)),
2753 ]
2754 .into_iter()
2755 .collect();
2756 let plan = reconcile(&manifest, &d, &local, &sources);
2757 assert_eq!(plan.artifact_deletes(), 0);
2758 assert_eq!(plan.deletes(), 0);
2759 }
2760
2761 #[test]
2762 fn delete_artifact_never_when_entry_preserved() {
2763 let mut manifest = Manifest::new();
2766 let preserved = ManifestEntry {
2767 preserve: true,
2768 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2769 };
2770 manifest.insert("a", preserved);
2771 let d = vec![desired_arts("a", vec![])];
2772 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2773 assert_eq!(plan.artifact_deletes(), 0);
2774 }
2775
2776 #[test]
2777 fn co_delete_never_when_path_empty() {
2778 let mut manifest = Manifest::new();
2782 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2783 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2784 assert_eq!(plan.deletes(), 1);
2785 assert_eq!(plan.artifact_deletes(), 0);
2786 }
2787
2788 #[test]
2789 fn co_delete_absent_clip_deletes_audio_and_cover() {
2790 let mut manifest = Manifest::new();
2793 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2794 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2795 assert_eq!(plan.deletes(), 1);
2796 assert_eq!(plan.artifact_deletes(), 1);
2797 assert!(plan.actions.contains(&Action::Delete {
2798 path: "gone.flac".to_string(),
2799 clip_id: "gone".to_string(),
2800 }));
2801 assert!(plan.actions.contains(&Action::DeleteArtifact {
2802 kind: ArtifactKind::CoverJpg,
2803 path: "gone/cover.jpg".to_string(),
2804 owner_id: "gone".to_string(),
2805 }));
2806 }
2807
2808 #[test]
2809 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2810 let mut manifest = Manifest::new();
2812 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2813 let sources = vec![SourceStatus {
2814 mode: SourceMode::Mirror,
2815 fully_enumerated: false,
2816 }];
2817 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2818 assert_eq!(plan.deletes(), 0);
2819 assert_eq!(plan.artifact_deletes(), 0);
2820 }
2821
2822 #[test]
2823 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2824 let mut manifest = Manifest::new();
2826 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2827 let mut d = desired_arts("a", vec![]);
2828 d.trashed = true;
2829 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2830 assert_eq!(plan.deletes(), 1);
2831 assert_eq!(plan.artifact_deletes(), 1);
2832 }
2833
2834 #[test]
2835 fn co_delete_trashed_suppressed_when_not_enumerated() {
2836 let mut manifest = Manifest::new();
2838 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2839 let mut d = desired_arts("a", vec![]);
2840 d.trashed = true;
2841 let sources = vec![SourceStatus {
2842 mode: SourceMode::Mirror,
2843 fully_enumerated: false,
2844 }];
2845 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2846 assert_eq!(plan.deletes(), 0);
2847 assert_eq!(plan.artifact_deletes(), 0);
2848 assert_eq!(plan.skips(), 1);
2849 }
2850
2851 #[test]
2852 fn co_delete_trashed_suppressed_when_preserved() {
2853 let mut manifest = Manifest::new();
2855 let preserved = ManifestEntry {
2856 preserve: true,
2857 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2858 };
2859 manifest.insert("a", preserved);
2860 let mut d = desired_arts("a", vec![]);
2861 d.trashed = true;
2862 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2863 assert_eq!(plan.deletes(), 0);
2864 assert_eq!(plan.artifact_deletes(), 0);
2865 }
2866
2867 #[test]
2870 fn details_sidecar_written_with_inline_content_when_slot_absent() {
2871 let mut manifest = Manifest::new();
2874 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2875 let d = vec![desired_arts(
2876 "a",
2877 vec![text_art(
2878 ArtifactKind::DetailsTxt,
2879 "a.details.txt",
2880 "Title: A\n",
2881 )],
2882 )];
2883 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2884 assert_eq!(plan.artifact_writes(), 1);
2885 assert_eq!(plan.artifact_deletes(), 0);
2886 assert_eq!(
2887 write_artifacts(&plan)[0],
2888 &Action::WriteArtifact {
2889 kind: ArtifactKind::DetailsTxt,
2890 path: "a.details.txt".to_string(),
2891 source_url: String::new(),
2892 hash: content_hash("Title: A\n"),
2893 owner_id: "a".to_string(),
2894 content: Some("Title: A\n".to_string()),
2895 }
2896 );
2897 }
2898
2899 #[test]
2900 fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
2901 let mut manifest = Manifest::new();
2906 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2907 let body = "[re:rs-suno]\nla la\n";
2908 let d = vec![desired_arts(
2909 "a",
2910 vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
2911 )];
2912 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2913 assert_eq!(plan.artifact_writes(), 1);
2914 assert_eq!(plan.artifact_deletes(), 0);
2915 assert_eq!(
2916 write_artifacts(&plan)[0],
2917 &Action::WriteArtifact {
2918 kind: ArtifactKind::Lrc,
2919 path: "a.lrc".to_string(),
2920 source_url: String::new(),
2921 hash: content_hash(body),
2922 owner_id: "a".to_string(),
2923 content: Some(body.to_string()),
2924 }
2925 );
2926 }
2927
2928 #[test]
2929 fn text_sidecars_skipped_when_hash_and_path_match() {
2930 let mut manifest = Manifest::new();
2932 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2933 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2934 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
2935 manifest.insert("a", e);
2936 let d = vec![desired_arts(
2937 "a",
2938 vec![
2939 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
2940 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
2941 ],
2942 )];
2943 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2944 assert_eq!(plan.artifact_writes(), 0);
2945 assert_eq!(plan.artifact_deletes(), 0);
2946 }
2947
2948 #[test]
2949 fn details_rewritten_when_content_hash_differs() {
2950 let mut manifest = Manifest::new();
2953 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2954 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
2955 manifest.insert("a", e);
2956 let d = vec![desired_arts(
2957 "a",
2958 vec![text_art(
2959 ArtifactKind::DetailsTxt,
2960 "a.details.txt",
2961 "Title: New\n",
2962 )],
2963 )];
2964 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2965 assert_eq!(plan.artifact_writes(), 1);
2966 assert_eq!(plan.artifact_deletes(), 0);
2967 }
2968
2969 #[test]
2970 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
2971 let mut manifest = Manifest::new();
2975 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2976 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
2977 manifest.insert("a", e);
2978 let d = vec![desired_arts(
2979 "a",
2980 vec![text_art(
2981 ArtifactKind::LyricsTxt,
2982 "a.lyrics.txt",
2983 "new words\n",
2984 )],
2985 )];
2986 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2987 assert_eq!(plan.artifact_writes(), 1);
2989 assert_eq!(plan.retags(), 0);
2990 }
2991
2992 #[test]
2993 fn text_sidecar_relocated_when_path_differs() {
2994 let mut manifest = Manifest::new();
2997 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2998 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
2999 manifest.insert("a", e);
3000 let d = vec![desired_arts(
3001 "a",
3002 vec![text_art(
3003 ArtifactKind::DetailsTxt,
3004 "new/a.details.txt",
3005 "Title: A\n",
3006 )],
3007 )];
3008 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3009 assert_eq!(plan.artifact_writes(), 1);
3010 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3011 assert_eq!(path, "new/a.details.txt");
3012 } else {
3013 panic!("expected a WriteArtifact");
3014 }
3015 }
3016
3017 #[test]
3018 fn details_removed_kind_is_deleted_when_feature_off() {
3019 let mut manifest = Manifest::new();
3022 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3023 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3024 manifest.insert("a", e);
3025 let d = vec![desired_arts("a", vec![])];
3026 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3027 assert_eq!(plan.artifact_deletes(), 1);
3028 assert!(plan.actions.contains(&Action::DeleteArtifact {
3029 kind: ArtifactKind::DetailsTxt,
3030 path: "a.details.txt".to_string(),
3031 owner_id: "a".to_string(),
3032 }));
3033 }
3034
3035 #[test]
3036 fn lyrics_removed_kind_is_kept_not_deleted() {
3037 let mut manifest = Manifest::new();
3041 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3042 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3043 manifest.insert("a", e);
3044 let d = vec![desired_arts("a", vec![])];
3045 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3046 assert_eq!(plan.artifact_deletes(), 0);
3047 assert_eq!(plan.deletes(), 0);
3048 }
3049
3050 #[test]
3051 fn lrc_removed_kind_is_kept_not_deleted() {
3052 let mut manifest = Manifest::new();
3055 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3056 e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3057 manifest.insert("a", e);
3058 let d = vec![desired_arts("a", vec![])];
3059 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3060 assert_eq!(plan.artifact_deletes(), 0);
3061 assert_eq!(plan.deletes(), 0);
3062 }
3063
3064 #[test]
3065 fn video_mp4_removed_kind_is_kept_not_deleted() {
3066 let mut manifest = Manifest::new();
3070 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3071 e.video_mp4 = Some(cover("a.mp4", "vid-hash"));
3072 manifest.insert("a", e);
3073 let d = vec![desired_arts("a", vec![])];
3074 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3075 assert_eq!(plan.artifact_deletes(), 0);
3076 assert_eq!(plan.deletes(), 0);
3077 }
3078
3079 #[test]
3080 fn video_mp4_written_when_manifest_lacks_it() {
3081 let mut manifest = Manifest::new();
3084 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3085 let d = vec![desired_arts(
3086 "a",
3087 vec![art(
3088 ArtifactKind::VideoMp4,
3089 "a/song.mp4",
3090 "https://cdn/a/video.mp4",
3091 "vid-hash",
3092 )],
3093 )];
3094 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3095 assert_eq!(plan.artifact_writes(), 1);
3096 assert_eq!(
3097 write_artifacts(&plan)[0],
3098 &Action::WriteArtifact {
3099 kind: ArtifactKind::VideoMp4,
3100 path: "a/song.mp4".to_string(),
3101 source_url: "https://cdn/a/video.mp4".to_string(),
3102 hash: "vid-hash".to_string(),
3103 owner_id: "a".to_string(),
3104 content: None,
3105 }
3106 );
3107 }
3108
3109 #[test]
3110 fn details_removed_kind_not_deleted_on_incomplete_listing() {
3111 let mut manifest = Manifest::new();
3114 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3115 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3116 manifest.insert("a", e);
3117 let d = vec![desired_arts("a", vec![])];
3118 let sources = vec![SourceStatus {
3119 mode: SourceMode::Mirror,
3120 fully_enumerated: false,
3121 }];
3122 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
3123 assert_eq!(plan.artifact_deletes(), 0);
3124 }
3125
3126 #[test]
3127 fn details_removed_kind_not_deleted_when_preserved() {
3128 let mut manifest = Manifest::new();
3131 let mut e = ManifestEntry {
3132 preserve: true,
3133 ..entry("a.flac", AudioFormat::Flac, "m", "art")
3134 };
3135 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3136 manifest.insert("a", e);
3137 let d = vec![desired_arts("a", vec![])];
3138 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3139 assert_eq!(plan.artifact_deletes(), 0);
3140 }
3141
3142 #[test]
3143 fn co_delete_orphan_removes_every_text_sidecar() {
3144 let mut manifest = Manifest::new();
3148 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
3149 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
3150 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
3151 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
3152 e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3153 e.video_mp4 = Some(cover("gone/song.mp4", "vid-hash"));
3154 manifest.insert("gone", e);
3155 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3156 assert_eq!(plan.deletes(), 1);
3157 assert_eq!(plan.artifact_deletes(), 5);
3158 for (kind, path) in [
3159 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
3160 (ArtifactKind::DetailsTxt, "gone.details.txt"),
3161 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
3162 (ArtifactKind::Lrc, "gone.lrc"),
3163 (ArtifactKind::VideoMp4, "gone/song.mp4"),
3164 ] {
3165 assert!(
3166 plan.actions.contains(&Action::DeleteArtifact {
3167 kind,
3168 path: path.to_string(),
3169 owner_id: "gone".to_string(),
3170 }),
3171 "missing co-delete for {kind:?}"
3172 );
3173 }
3174 }
3175
3176 #[test]
3177 fn co_delete_trashed_removes_every_text_sidecar() {
3178 let mut manifest = Manifest::new();
3180 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3181 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3182 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3183 manifest.insert("a", e);
3184 let mut d = desired_arts("a", vec![]);
3185 d.trashed = true;
3186 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3187 assert_eq!(plan.deletes(), 1);
3188 assert_eq!(plan.artifact_deletes(), 2);
3189 }
3190
3191 #[test]
3192 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
3193 let mut manifest = Manifest::new();
3196 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3197 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
3198 let d = vec![desired_arts(
3201 "a",
3202 vec![art(
3203 ArtifactKind::CoverJpg,
3204 "shared/cover.jpg",
3205 "https://art/a",
3206 "h2",
3207 )],
3208 )];
3209 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3210 assert_eq!(plan.artifact_writes(), 1);
3211 assert!(!plan.actions.iter().any(
3213 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
3214 ));
3215 assert!(plan.actions.contains(&Action::Delete {
3217 path: "b.flac".to_string(),
3218 clip_id: "b".to_string(),
3219 }));
3220 }
3221
3222 #[test]
3223 fn suppress_downgrades_delete_artifact_colliding_with_download() {
3224 let mut manifest = Manifest::new();
3226 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
3227 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
3228 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
3229 assert_eq!(plan.downloads(), 1);
3230 assert!(
3231 !plan
3232 .actions
3233 .iter()
3234 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
3235 );
3236 }
3237
3238 #[test]
3239 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
3240 let build = |with_art: bool| {
3244 let mut manifest = Manifest::new();
3245 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
3246 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3247 manifest.insert(
3248 "trash",
3249 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
3250 );
3251 let keep = if with_art {
3252 desired_arts(
3253 "keep",
3254 vec![art(
3255 ArtifactKind::CoverJpg,
3256 "keep/cover.jpg",
3257 "https://art/keep",
3258 "h1",
3259 )],
3260 )
3261 } else {
3262 desired_arts("keep", vec![])
3263 };
3264 let mut trash = desired_arts("trash", vec![]);
3265 trash.trashed = true;
3266 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
3267 .iter()
3268 .map(|id| (id.to_string(), present(100)))
3269 .collect();
3270 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
3271 };
3272
3273 let with = build(true);
3274 let without = build(false);
3275
3276 let audio = |plan: &Plan| -> Vec<Action> {
3278 plan.actions
3279 .iter()
3280 .filter(|a| {
3281 !matches!(
3282 a,
3283 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
3284 )
3285 })
3286 .cloned()
3287 .collect()
3288 };
3289 assert_eq!(audio(&with), audio(&without));
3290 assert_eq!(with.deletes(), without.deletes());
3291 assert_eq!(with.deletes(), 2);
3293 assert_eq!(with.artifact_deletes(), 2);
3297 assert_eq!(with.artifact_writes(), 0);
3298 }
3299
3300 #[test]
3303 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
3304 let mut manifest = Manifest::new();
3310 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3311 assert!(!manifest.get("a").unwrap().preserve);
3312
3313 let private = Desired {
3315 private: true,
3316 ..desired_arts("a", vec![])
3317 };
3318 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
3319 assert_eq!(plan.artifact_deletes(), 0);
3320
3321 let copy_held = Desired {
3323 modes: vec![SourceMode::Copy],
3324 ..desired_arts("a", vec![])
3325 };
3326 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
3327 assert_eq!(plan.artifact_deletes(), 0);
3328 }
3329
3330 #[test]
3331 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
3332 let mut manifest = Manifest::new();
3338 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
3339 let d = vec![desired_arts(
3340 "a",
3341 vec![art(
3342 ArtifactKind::CoverJpg,
3343 "new/cover.jpg",
3344 "https://art/a",
3345 "h1",
3346 )],
3347 )];
3348 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3349 assert_eq!(plan.artifact_writes(), 1);
3350 assert_eq!(plan.artifact_deletes(), 0);
3351 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3352 assert_eq!(path, "new/cover.jpg");
3353 } else {
3354 panic!("expected a WriteArtifact");
3355 }
3356 }
3357
3358 #[test]
3359 fn per_clip_reconcile_ignores_album_and_library_kinds() {
3360 let mut manifest = Manifest::new();
3364 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3365 let d = vec![desired_arts(
3366 "a",
3367 vec![
3368 art(
3369 ArtifactKind::FolderJpg,
3370 "a/folder.jpg",
3371 "https://art/folder",
3372 "hf",
3373 ),
3374 art(
3375 ArtifactKind::Playlist,
3376 "a/list.m3u",
3377 "https://art/list",
3378 "hp",
3379 ),
3380 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
3381 ],
3382 )];
3383 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3384 assert_eq!(plan.artifact_writes(), 1);
3385 let paths: Vec<&str> = plan
3386 .actions
3387 .iter()
3388 .filter_map(|a| match a {
3389 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3390 _ => None,
3391 })
3392 .collect();
3393 assert_eq!(paths, vec!["a/cover.jpg"]);
3394 }
3395
3396 #[test]
3397 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3398 let mut manifest = Manifest::new();
3399 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3400 let d = vec![desired_arts(
3401 "a",
3402 vec![art(
3403 ArtifactKind::FolderWebp,
3404 "a/folder.webp",
3405 "https://art/folder",
3406 "hf",
3407 )],
3408 )];
3409 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3410 assert_eq!(plan.artifact_writes(), 0);
3411 assert_eq!(plan.artifact_deletes(), 0);
3412 }
3413
3414 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
3417 Clip {
3418 id: id.to_string(),
3419 title: "Song".to_string(),
3420 image_large_url: image.to_string(),
3421 video_cover_url: video.to_string(),
3422 play_count,
3423 created_at: created_at.to_string(),
3424 ..Default::default()
3425 }
3426 }
3427
3428 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
3429 let mut lineage = LineageContext::own_root(&clip);
3430 lineage.root_id = root_id.to_string();
3431 Desired {
3432 clip,
3433 lineage,
3434 path: path.to_string(),
3435 format: AudioFormat::Flac,
3436 meta_hash: "m".to_string(),
3437 art_hash: "a".to_string(),
3438 modes: vec![SourceMode::Mirror],
3439 trashed: false,
3440 private: false,
3441 artifacts: Vec::new(),
3442 stems: None,
3443 }
3444 }
3445
3446 fn stored(path: &str, hash: &str) -> ArtifactState {
3447 ArtifactState {
3448 path: path.to_string(),
3449 hash: hash.to_string(),
3450 }
3451 }
3452
3453 #[test]
3454 fn folder_jpg_source_is_most_played() {
3455 let members = vec![
3456 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
3457 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
3458 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
3459 ];
3460 let albums = album_desired(&members, false, false);
3461 assert_eq!(albums.len(), 1);
3462 let jpg = albums[0].folder_jpg.as_ref().unwrap();
3463 assert_eq!(jpg.hash, art_url_hash("art-b"));
3465 assert_eq!(jpg.source_url, "art-b");
3466 assert_eq!(jpg.path, "c/al/folder.jpg");
3467 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
3468 }
3469
3470 #[test]
3471 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
3472 let by_time = vec![
3474 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
3475 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
3476 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
3477 ];
3478 let jpg = album_desired(&by_time, false, false)[0]
3479 .folder_jpg
3480 .clone()
3481 .unwrap();
3482 assert_eq!(jpg.source_url, "art-y");
3483
3484 let by_id = vec![
3486 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
3487 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
3488 ];
3489 let jpg = album_desired(&by_id, false, false)[0]
3490 .folder_jpg
3491 .clone()
3492 .unwrap();
3493 assert_eq!(jpg.source_url, "art-g");
3494 }
3495
3496 #[test]
3497 fn folder_webp_source_is_first_created_animated() {
3498 let members = vec![
3499 album_member(
3500 album_clip("a", 9, "t2", "art-a", "vid-a"),
3501 "root",
3502 "c/al/a.flac",
3503 ),
3504 album_member(
3505 album_clip("b", 1, "t0", "art-b", "vid-b"),
3506 "root",
3507 "c/al/b.flac",
3508 ),
3509 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
3510 ];
3511 let webp = album_desired(&members, true, false)[0]
3512 .folder_webp
3513 .clone()
3514 .unwrap();
3515 assert_eq!(webp.source_url, "vid-b");
3517 assert_eq!(webp.hash, art_url_hash("vid-b"));
3518 assert_eq!(webp.path, "c/al/cover.webp");
3519 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
3520 }
3521
3522 #[test]
3523 fn animated_covers_off_yields_no_folder_webp() {
3524 let members = vec![album_member(
3525 album_clip("a", 1, "t0", "art-a", "vid-a"),
3526 "root",
3527 "c/al/a.flac",
3528 )];
3529 let off = album_desired(&members, false, false);
3530 assert!(off[0].folder_webp.is_none());
3531 let on = album_desired(&members, true, false);
3532 assert!(on[0].folder_webp.is_some());
3533 }
3534
3535 #[test]
3536 fn raw_cover_yields_folder_mp4_from_the_webp_source_verbatim() {
3537 let members = vec![
3538 album_member(
3539 album_clip("a", 9, "t2", "art-a", "vid-a"),
3540 "root",
3541 "c/al/a.flac",
3542 ),
3543 album_member(
3544 album_clip("b", 1, "t0", "art-b", "vid-b"),
3545 "root",
3546 "c/al/b.flac",
3547 ),
3548 ];
3549 let album = album_desired(&members, true, true).remove(0);
3553 let webp = album.folder_webp.unwrap();
3554 let mp4 = album.folder_mp4.unwrap();
3555 assert_eq!(mp4.kind, ArtifactKind::FolderMp4);
3556 assert_eq!(mp4.path, "c/al/cover.mp4");
3557 assert_eq!(mp4.source_url, "vid-b");
3558 assert_eq!(mp4.hash, art_url_hash("vid-b"));
3559 assert_eq!(mp4.source_url, webp.source_url, "same variant feeds both");
3560 }
3561
3562 #[test]
3563 fn raw_cover_and_webp_are_independent_toggles() {
3564 let members = vec![album_member(
3565 album_clip("a", 1, "t0", "art-a", "vid-a"),
3566 "root",
3567 "c/al/a.flac",
3568 )];
3569 let webp_only = album_desired(&members, true, false).remove(0);
3571 assert!(webp_only.folder_webp.is_some());
3572 assert!(webp_only.folder_mp4.is_none());
3573 let mp4_only = album_desired(&members, false, true).remove(0);
3575 assert!(mp4_only.folder_webp.is_none());
3576 assert!(mp4_only.folder_mp4.is_some());
3577 }
3578
3579 #[test]
3580 fn raw_cover_needs_an_animated_source() {
3581 let members = vec![album_member(
3583 album_clip("a", 3, "t0", "art-a", ""),
3584 "root",
3585 "c/al/a.flac",
3586 )];
3587 let album = album_desired(&members, true, true).remove(0);
3588 assert!(album.folder_mp4.is_none());
3589 assert!(album.folder_webp.is_none());
3590 }
3591
3592 #[test]
3593 fn album_with_no_art_yields_no_folder_jpg() {
3594 let members = vec![album_member(
3595 album_clip("a", 3, "t0", "", ""),
3596 "root",
3597 "c/al/a.flac",
3598 )];
3599 let albums = album_desired(&members, true, false);
3600 assert!(albums[0].folder_jpg.is_none());
3601 assert!(albums[0].folder_webp.is_none());
3602 }
3603
3604 #[test]
3605 fn album_desired_groups_by_root_id() {
3606 let members = vec![
3607 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
3608 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
3609 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
3610 ];
3611 let albums = album_desired(&members, false, false);
3612 assert_eq!(albums.len(), 2);
3613 assert_eq!(albums[0].root_id, "r1");
3614 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
3615 assert_eq!(
3616 albums[0].folder_jpg.as_ref().unwrap().path,
3617 "c/al1/folder.jpg"
3618 );
3619 assert_eq!(albums[1].root_id, "r2");
3620 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
3621 assert_eq!(
3622 albums[1].folder_jpg.as_ref().unwrap().path,
3623 "c/al2/folder.jpg"
3624 );
3625 }
3626
3627 #[test]
3628 fn plan_writes_folder_art_when_store_empty() {
3629 let members = vec![album_member(
3630 album_clip("a", 1, "t0", "art-a", "vid-a"),
3631 "root",
3632 "c/al/a.flac",
3633 )];
3634 let desired = album_desired(&members, true, false);
3635 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3636 assert_eq!(
3637 actions,
3638 vec![
3639 Action::WriteArtifact {
3640 kind: ArtifactKind::FolderJpg,
3641 path: "c/al/folder.jpg".to_string(),
3642 source_url: "art-a".to_string(),
3643 hash: art_url_hash("art-a"),
3644 owner_id: "root".to_string(),
3645 content: None,
3646 },
3647 Action::WriteArtifact {
3648 kind: ArtifactKind::FolderWebp,
3649 path: "c/al/cover.webp".to_string(),
3650 source_url: "vid-a".to_string(),
3651 hash: art_url_hash("vid-a"),
3652 owner_id: "root".to_string(),
3653 content: None,
3654 },
3655 ]
3656 );
3657 }
3658
3659 #[test]
3660 fn plan_skips_when_hash_and_path_match() {
3661 let members = vec![album_member(
3662 album_clip("a", 1, "t0", "art-a", ""),
3663 "root",
3664 "c/al/a.flac",
3665 )];
3666 let desired = album_desired(&members, false, false);
3667 let mut albums = BTreeMap::new();
3668 albums.insert(
3669 "root".to_string(),
3670 AlbumArt {
3671 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3672 folder_webp: None,
3673 folder_mp4: None,
3674 },
3675 );
3676 assert!(plan_album_artifacts(&desired, &albums, true).is_empty());
3677 }
3678
3679 #[test]
3680 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
3681 let members = vec![album_member(
3682 album_clip("a", 1, "t0", "art-a", ""),
3683 "root",
3684 "c/al/a.flac",
3685 )];
3686 let desired = album_desired(&members, false, false);
3687 let mut albums = BTreeMap::new();
3688 albums.insert(
3689 "root".to_string(),
3690 AlbumArt {
3691 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
3692 folder_webp: None,
3693 folder_mp4: None,
3694 },
3695 );
3696 let actions = plan_album_artifacts(&desired, &albums, true);
3697 assert_eq!(actions.len(), 1);
3698 assert!(matches!(
3699 &actions[0],
3700 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
3701 ));
3702 }
3703
3704 #[test]
3705 fn h1_most_played_flip_to_same_art_writes_nothing() {
3706 let run1 = vec![
3708 album_member(
3709 album_clip("a", 9, "t0", "same-art", ""),
3710 "root",
3711 "c/al/a.flac",
3712 ),
3713 album_member(
3714 album_clip("b", 1, "t1", "same-art", ""),
3715 "root",
3716 "c/al/b.flac",
3717 ),
3718 ];
3719 let desired1 = album_desired(&run1, false, false);
3720 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true);
3721 assert_eq!(write1.len(), 1);
3722
3723 let mut albums = BTreeMap::new();
3725 if let Action::WriteArtifact {
3726 path,
3727 hash,
3728 owner_id,
3729 ..
3730 } = &write1[0]
3731 {
3732 albums.insert(
3733 owner_id.clone(),
3734 AlbumArt {
3735 folder_jpg: Some(stored(path, hash)),
3736 folder_webp: None,
3737 folder_mp4: None,
3738 },
3739 );
3740 }
3741
3742 let run2 = vec![
3744 album_member(
3745 album_clip("a", 1, "t0", "same-art", ""),
3746 "root",
3747 "c/al/a.flac",
3748 ),
3749 album_member(
3750 album_clip("b", 9, "t1", "same-art", ""),
3751 "root",
3752 "c/al/b.flac",
3753 ),
3754 ];
3755 let desired2 = album_desired(&run2, false, false);
3756 assert!(plan_album_artifacts(&desired2, &albums, true).is_empty());
3758 }
3759
3760 #[test]
3761 fn h1_flip_to_different_art_writes_exactly_one() {
3762 let mut albums = BTreeMap::new();
3763 albums.insert(
3764 "root".to_string(),
3765 AlbumArt {
3766 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
3767 folder_webp: None,
3768 folder_mp4: None,
3769 },
3770 );
3771 let members = vec![
3773 album_member(
3774 album_clip("a", 1, "t0", "old-art", ""),
3775 "root",
3776 "c/al/a.flac",
3777 ),
3778 album_member(
3779 album_clip("b", 9, "t1", "new-art", ""),
3780 "root",
3781 "c/al/b.flac",
3782 ),
3783 ];
3784 let desired = album_desired(&members, false, false);
3785 let actions = plan_album_artifacts(&desired, &albums, true);
3786 assert_eq!(actions.len(), 1);
3787 assert!(matches!(
3788 &actions[0],
3789 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
3790 ));
3791 }
3792
3793 #[test]
3794 fn one_write_per_album_regardless_of_clip_count() {
3795 let members: Vec<Desired> = (0..200)
3796 .map(|i| {
3797 album_member(
3798 album_clip(
3799 &format!("clip-{i:03}"),
3800 i as u64,
3801 &format!("t{i:03}"),
3802 &format!("art-{i:03}"),
3803 &format!("vid-{i:03}"),
3804 ),
3805 "root",
3806 &format!("c/al/clip-{i:03}.flac"),
3807 )
3808 })
3809 .collect();
3810 let desired = album_desired(&members, true, false);
3811 assert_eq!(desired.len(), 1);
3812 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3813 assert_eq!(actions.len(), 2);
3815 assert_eq!(
3816 actions
3817 .iter()
3818 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
3819 .count(),
3820 2
3821 );
3822 }
3823
3824 #[test]
3825 fn emptied_album_deletes_only_when_can_delete() {
3826 let mut albums = BTreeMap::new();
3827 albums.insert(
3828 "root".to_string(),
3829 AlbumArt {
3830 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
3831 folder_webp: Some(stored("c/al/cover.webp", "hw")),
3832 folder_mp4: Some(stored("c/al/cover.mp4", "hm")),
3833 },
3834 );
3835 let desired: Vec<AlbumDesired> = Vec::new();
3837
3838 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3840
3841 let actions = plan_album_artifacts(&desired, &albums, true);
3843 assert_eq!(
3844 actions,
3845 vec![
3846 Action::DeleteArtifact {
3847 kind: ArtifactKind::FolderJpg,
3848 path: "c/al/folder.jpg".to_string(),
3849 owner_id: "root".to_string(),
3850 },
3851 Action::DeleteArtifact {
3852 kind: ArtifactKind::FolderWebp,
3853 path: "c/al/cover.webp".to_string(),
3854 owner_id: "root".to_string(),
3855 },
3856 Action::DeleteArtifact {
3857 kind: ArtifactKind::FolderMp4,
3858 path: "c/al/cover.mp4".to_string(),
3859 owner_id: "root".to_string(),
3860 },
3861 ]
3862 );
3863 }
3864
3865 #[test]
3866 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
3867 let mut albums = BTreeMap::new();
3868 albums.insert(
3869 "root".to_string(),
3870 AlbumArt {
3871 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3872 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
3873 folder_mp4: None,
3874 },
3875 );
3876 let members = vec![album_member(
3879 album_clip("a", 1, "t0", "art-a", "vid-a"),
3880 "root",
3881 "c/al/a.flac",
3882 )];
3883 let desired = album_desired(&members, false, false);
3884
3885 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3886
3887 let actions = plan_album_artifacts(&desired, &albums, true);
3888 assert_eq!(
3889 actions,
3890 vec![Action::DeleteArtifact {
3891 kind: ArtifactKind::FolderWebp,
3892 path: "c/al/cover.webp".to_string(),
3893 owner_id: "root".to_string(),
3894 }]
3895 );
3896 }
3897
3898 #[test]
3899 fn disappeared_raw_cover_deletes_only_that_kind_when_gated() {
3900 let mut albums = BTreeMap::new();
3901 albums.insert(
3902 "root".to_string(),
3903 AlbumArt {
3904 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3905 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
3906 folder_mp4: Some(stored("c/al/cover.mp4", &art_url_hash("vid-a"))),
3907 },
3908 );
3909 let members = vec![album_member(
3912 album_clip("a", 1, "t0", "art-a", "vid-a"),
3913 "root",
3914 "c/al/a.flac",
3915 )];
3916 let desired = album_desired(&members, true, false);
3917
3918 assert!(plan_album_artifacts(&desired, &albums, false).is_empty());
3920
3921 let actions = plan_album_artifacts(&desired, &albums, true);
3923 assert_eq!(
3924 actions,
3925 vec![Action::DeleteArtifact {
3926 kind: ArtifactKind::FolderMp4,
3927 path: "c/al/cover.mp4".to_string(),
3928 owner_id: "root".to_string(),
3929 }]
3930 );
3931 }
3932
3933 #[test]
3934 fn plan_album_artifacts_is_deterministically_ordered() {
3935 let members = vec![
3936 album_member(
3937 album_clip("a", 1, "t0", "art-a", "vid-a"),
3938 "r2",
3939 "c/al2/a.flac",
3940 ),
3941 album_member(
3942 album_clip("b", 1, "t0", "art-b", "vid-b"),
3943 "r1",
3944 "c/al1/b.flac",
3945 ),
3946 ];
3947 let desired = album_desired(&members, true, true);
3948 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true);
3949 let keys: Vec<(&str, ArtifactKind)> = actions
3950 .iter()
3951 .map(|a| match a {
3952 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
3953 _ => unreachable!(),
3954 })
3955 .collect();
3956 assert_eq!(
3957 keys,
3958 vec![
3959 ("r1", ArtifactKind::FolderJpg),
3960 ("r1", ArtifactKind::FolderWebp),
3961 ("r1", ArtifactKind::FolderMp4),
3962 ("r2", ArtifactKind::FolderJpg),
3963 ("r2", ArtifactKind::FolderWebp),
3964 ("r2", ArtifactKind::FolderMp4),
3965 ]
3966 );
3967 }
3968
3969 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
3972 PlaylistDesired {
3973 id: id.to_owned(),
3974 name: name.to_owned(),
3975 path: path.to_owned(),
3976 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
3977 hash: hash.to_owned(),
3978 }
3979 }
3980
3981 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
3982 PlaylistState {
3983 name: name.to_owned(),
3984 path: path.to_owned(),
3985 hash: hash.to_owned(),
3986 }
3987 }
3988
3989 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
3990 entries
3991 .iter()
3992 .map(|(id, state)| ((*id).to_owned(), state.clone()))
3993 .collect()
3994 }
3995
3996 #[test]
3997 fn playlist_write_emitted_for_a_new_playlist() {
3998 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
3999 let actions = plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true);
4000 assert_eq!(
4001 actions,
4002 vec![Action::WriteArtifact {
4003 kind: ArtifactKind::Playlist,
4004 path: "Road Trip.m3u8".to_owned(),
4005 source_url: String::new(),
4006 hash: "h1".to_owned(),
4007 owner_id: "pl1".to_owned(),
4008 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
4009 }]
4010 );
4011 }
4012
4013 #[test]
4014 fn playlist_write_emitted_when_hash_changes() {
4015 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
4018 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4019 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
4020 assert_eq!(actions.len(), 1);
4021 assert!(matches!(
4022 &actions[0],
4023 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
4024 ));
4025 }
4026
4027 #[test]
4028 fn playlist_unchanged_is_idempotent() {
4029 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4030 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4031 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
4032 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
4033 }
4034
4035 #[test]
4036 fn playlist_rename_writes_new_and_deletes_old_path() {
4037 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4040 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4041 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
4042 assert_eq!(
4043 actions,
4044 vec![
4045 Action::WriteArtifact {
4046 kind: ArtifactKind::Playlist,
4047 path: "Summer.m3u8".to_owned(),
4048 source_url: String::new(),
4049 hash: "h2".to_owned(),
4050 owner_id: "pl1".to_owned(),
4051 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
4052 },
4053 Action::DeleteArtifact {
4054 kind: ArtifactKind::Playlist,
4055 path: "Spring.m3u8".to_owned(),
4056 owner_id: "pl1".to_owned(),
4057 },
4058 ]
4059 );
4060 }
4061
4062 #[test]
4063 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
4064 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4067 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4068 let actions = plan_playlist_artifacts(&desired, &stored, false, true);
4069 assert_eq!(actions.len(), 1);
4070 assert!(matches!(
4071 &actions[0],
4072 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
4073 ));
4074 assert!(
4075 !actions
4076 .iter()
4077 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
4078 "old path must not be deleted when deletes are disallowed"
4079 );
4080 }
4081
4082 #[test]
4083 fn playlist_stale_removed_only_under_full_gate() {
4084 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
4087
4088 let deleted = plan_playlist_artifacts(&[], &stored, true, true);
4089 assert_eq!(
4090 deleted,
4091 vec![Action::DeleteArtifact {
4092 kind: ArtifactKind::Playlist,
4093 path: "Gone.m3u8".to_owned(),
4094 owner_id: "gone".to_owned(),
4095 }]
4096 );
4097
4098 assert!(plan_playlist_artifacts(&[], &stored, false, true).is_empty());
4100 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
4101 assert!(plan_playlist_artifacts(&[], &stored, false, false).is_empty());
4102 }
4103
4104 #[test]
4105 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
4106 let stored = pl_store(&[
4111 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4112 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4113 ]);
4114 let actions = plan_playlist_artifacts(&[], &stored, true, false);
4115 assert!(
4116 actions.is_empty(),
4117 "a failed playlist listing must plan zero actions, got {actions:?}"
4118 );
4119 }
4120
4121 #[test]
4122 fn b2_empty_list_deletes_only_when_fully_enumerated() {
4123 let stored = pl_store(&[
4128 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4129 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4130 ]);
4131
4132 assert!(plan_playlist_artifacts(&[], &stored, true, false).is_empty());
4134
4135 let wiped = plan_playlist_artifacts(&[], &stored, true, true);
4138 assert_eq!(
4139 wiped
4140 .iter()
4141 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
4142 .count(),
4143 2
4144 );
4145 }
4146
4147 #[test]
4148 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
4149 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
4154 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
4155 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
4156 assert_eq!(actions.len(), 1);
4158 assert!(matches!(
4159 &actions[0],
4160 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
4161 ));
4162 assert!(
4163 !actions.iter().any(|a| match a {
4164 Action::WriteArtifact { owner_id, .. }
4165 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
4166 _ => false,
4167 }),
4168 "a protected (failed-member) playlist must have no action"
4169 );
4170 }
4171
4172 #[test]
4173 fn playlist_rename_collision_downgrades_the_delete() {
4174 let desired = vec![
4180 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
4181 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
4182 ];
4183 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
4184 let actions = plan_playlist_artifacts(&desired, &stored, true, true);
4185 let write_paths: BTreeSet<&str> = actions
4187 .iter()
4188 .filter_map(|a| match a {
4189 Action::WriteArtifact { path, .. } => Some(path.as_str()),
4190 _ => None,
4191 })
4192 .collect();
4193 for a in &actions {
4194 if let Action::DeleteArtifact { path, .. } = a {
4195 assert!(
4196 !write_paths.contains(path.as_str()),
4197 "a playlist delete aliases a write target: {path}"
4198 );
4199 }
4200 }
4201 }
4202
4203 fn dstem(key: &str, path: &str, hash: &str) -> DesiredStem {
4206 DesiredStem {
4207 key: key.to_string(),
4208 stem_id: key.to_string(),
4209 path: path.to_string(),
4210 source_url: format!("https://cdn1.suno.ai/{key}.mp3"),
4211 format: StemFormat::Mp3,
4212 hash: hash.to_string(),
4213 }
4214 }
4215
4216 fn stem_desired(id: &str, stems: Option<Vec<DesiredStem>>) -> Desired {
4218 Desired {
4219 stems,
4220 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
4221 }
4222 }
4223
4224 fn entry_with_stems(id: &str, stems: &[(&str, &str, &str)]) -> ManifestEntry {
4226 let mut e = entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art");
4227 for (key, path, hash) in stems {
4228 e.stems.insert(
4229 key.to_string(),
4230 ArtifactState {
4231 path: path.to_string(),
4232 hash: hash.to_string(),
4233 },
4234 );
4235 }
4236 e
4237 }
4238
4239 fn stem_writes(plan: &Plan) -> Vec<(&str, &str)> {
4240 plan.actions
4241 .iter()
4242 .filter_map(|a| match a {
4243 Action::WriteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4244 _ => None,
4245 })
4246 .collect()
4247 }
4248
4249 fn stem_deletes(plan: &Plan) -> Vec<(&str, &str)> {
4250 plan.actions
4251 .iter()
4252 .filter_map(|a| match a {
4253 Action::DeleteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4254 _ => None,
4255 })
4256 .collect()
4257 }
4258
4259 #[test]
4260 fn stems_none_keeps_every_existing_stem() {
4261 let mut manifest = Manifest::new();
4264 manifest.insert(
4265 "a",
4266 entry_with_stems(
4267 "a",
4268 &[
4269 ("voc", "a.stems/voc.mp3", "h1"),
4270 ("drm", "a.stems/drm.mp3", "h2"),
4271 ],
4272 ),
4273 );
4274 let d = vec![stem_desired("a", None)];
4275 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4276 assert_eq!(plan.stem_writes(), 0);
4277 assert_eq!(plan.stem_deletes(), 0);
4278 }
4279
4280 #[test]
4281 fn stems_authoritative_writes_missing_stems() {
4282 let mut manifest = Manifest::new();
4283 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
4284 let d = vec![stem_desired(
4285 "a",
4286 Some(vec![
4287 dstem("voc", "a.stems/voc.mp3", "h1"),
4288 dstem("drm", "a.stems/drm.mp3", "h2"),
4289 ]),
4290 )];
4291 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4292 assert_eq!(
4293 stem_writes(&plan),
4294 vec![("voc", "a.stems/voc.mp3"), ("drm", "a.stems/drm.mp3")]
4295 );
4296 assert_eq!(plan.stem_deletes(), 0);
4297 }
4298
4299 #[test]
4300 fn stems_authoritative_rewrites_only_on_hash_or_path_drift() {
4301 let mut manifest = Manifest::new();
4302 manifest.insert(
4304 "a",
4305 entry_with_stems(
4306 "a",
4307 &[
4308 ("voc", "a.stems/voc.mp3", "h1"),
4309 ("drm", "a.stems/drm.mp3", "h2"),
4310 ("bas", "old.stems/bas.mp3", "h3"),
4311 ],
4312 ),
4313 );
4314 let d = vec![stem_desired(
4315 "a",
4316 Some(vec![
4317 dstem("voc", "a.stems/voc.mp3", "h1"), dstem("drm", "a.stems/drm.mp3", "h2-new"), dstem("bas", "a.stems/bas.mp3", "h3"), ]),
4321 )];
4322 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4323 assert_eq!(
4324 stem_writes(&plan),
4325 vec![("drm", "a.stems/drm.mp3"), ("bas", "a.stems/bas.mp3")]
4326 );
4327 assert_eq!(plan.stem_deletes(), 0);
4328 }
4329
4330 #[test]
4331 fn stems_authoritative_removes_a_stem_absent_from_the_set() {
4332 let mut manifest = Manifest::new();
4335 manifest.insert(
4336 "a",
4337 entry_with_stems(
4338 "a",
4339 &[
4340 ("voc", "a.stems/voc.mp3", "h1"),
4341 ("drm", "a.stems/drm.mp3", "h2"),
4342 ],
4343 ),
4344 );
4345 let d = vec![stem_desired(
4346 "a",
4347 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
4348 )];
4349 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4350 assert_eq!(plan.stem_writes(), 0);
4351 assert_eq!(stem_deletes(&plan), vec![("drm", "a.stems/drm.mp3")]);
4352 }
4353
4354 #[test]
4355 fn stems_removal_needs_deletion_allowed() {
4356 let mut manifest = Manifest::new();
4359 manifest.insert(
4360 "a",
4361 entry_with_stems(
4362 "a",
4363 &[
4364 ("voc", "a.stems/voc.mp3", "h1"),
4365 ("drm", "a.stems/drm.mp3", "h2"),
4366 ],
4367 ),
4368 );
4369 let d = vec![stem_desired(
4370 "a",
4371 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
4372 )];
4373
4374 let incomplete = vec![SourceStatus {
4375 mode: SourceMode::Mirror,
4376 fully_enumerated: false,
4377 }];
4378 assert_eq!(
4379 reconcile(&manifest, &d, &local_present("a"), &incomplete).stem_deletes(),
4380 0
4381 );
4382
4383 let copy_only = vec![SourceStatus {
4384 mode: SourceMode::Copy,
4385 fully_enumerated: true,
4386 }];
4387 assert_eq!(
4388 reconcile(&manifest, &d, &local_present("a"), ©_only).stem_deletes(),
4389 0
4390 );
4391 }
4392
4393 #[test]
4394 fn stems_removal_skipped_for_preserved_or_protected_clip() {
4395 let mut manifest = Manifest::new();
4396 let mut e = entry_with_stems(
4397 "a",
4398 &[
4399 ("voc", "a.stems/voc.mp3", "h1"),
4400 ("drm", "a.stems/drm.mp3", "h2"),
4401 ],
4402 );
4403 e.preserve = true;
4404 manifest.insert("a", e);
4405 let authoritative = Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]);
4406
4407 let d = vec![stem_desired("a", authoritative.clone())];
4409 assert_eq!(
4410 reconcile(&manifest, &d, &local_present("a"), &mirror_ok()).stem_deletes(),
4411 0
4412 );
4413
4414 let mut manifest2 = Manifest::new();
4416 manifest2.insert(
4417 "a",
4418 entry_with_stems(
4419 "a",
4420 &[
4421 ("voc", "a.stems/voc.mp3", "h1"),
4422 ("drm", "a.stems/drm.mp3", "h2"),
4423 ],
4424 ),
4425 );
4426 let held = Desired {
4427 modes: vec![SourceMode::Mirror, SourceMode::Copy],
4428 stems: authoritative,
4429 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
4430 };
4431 assert_eq!(
4432 reconcile(&manifest2, &[held], &local_present("a"), &mirror_ok()).stem_deletes(),
4433 0
4434 );
4435 }
4436
4437 #[test]
4438 fn stems_are_co_deleted_when_the_song_is_trashed() {
4439 let mut manifest = Manifest::new();
4442 manifest.insert(
4443 "a",
4444 entry_with_stems(
4445 "a",
4446 &[
4447 ("voc", "a.stems/voc.mp3", "h1"),
4448 ("drm", "a.stems/drm.mp3", "h2"),
4449 ],
4450 ),
4451 );
4452 let trashed = Desired {
4453 trashed: true,
4454 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
4455 };
4456 let plan = reconcile(&manifest, &[trashed], &local_present("a"), &mirror_ok());
4457 assert_eq!(plan.deletes(), 1, "the trashed audio is deleted");
4458 let mut deleted: Vec<&str> = stem_deletes(&plan).into_iter().map(|(k, _)| k).collect();
4459 deleted.sort_unstable();
4460 assert_eq!(deleted, vec!["drm", "voc"], "both stems co-deleted");
4461 }
4462
4463 #[test]
4464 fn stems_are_co_deleted_for_an_absent_clip() {
4465 let mut manifest = Manifest::new();
4466 manifest.insert(
4467 "a",
4468 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
4469 );
4470 let plan = reconcile(&manifest, &[], &local_present("a"), &mirror_ok());
4472 assert_eq!(plan.deletes(), 1);
4473 assert_eq!(stem_deletes(&plan), vec![("voc", "a.stems/voc.mp3")]);
4474 }
4475
4476 #[test]
4477 fn stems_are_kept_when_absent_clip_listing_is_incomplete() {
4478 let mut manifest = Manifest::new();
4480 manifest.insert(
4481 "a",
4482 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
4483 );
4484 let incomplete = vec![SourceStatus {
4485 mode: SourceMode::Mirror,
4486 fully_enumerated: false,
4487 }];
4488 let plan = reconcile(&manifest, &[], &HashMap::new(), &incomplete);
4489 assert_eq!(plan.deletes(), 0);
4490 assert_eq!(plan.stem_deletes(), 0);
4491 }
4492
4493 #[test]
4494 fn stem_delete_is_suppressed_when_it_aliases_a_stem_write() {
4495 let mut manifest = Manifest::new();
4499 manifest.insert(
4500 "a",
4501 entry_with_stems("a", &[("old", "a.stems/mix.mp3", "h1")]),
4502 );
4503 let d = vec![stem_desired(
4504 "a",
4505 Some(vec![dstem("new", "a.stems/mix.mp3", "h2")]),
4506 )];
4507 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4508 assert_eq!(stem_writes(&plan), vec![("new", "a.stems/mix.mp3")]);
4511 assert!(
4512 !plan.actions.iter().any(|a| matches!(
4513 a,
4514 Action::DeleteStem { path, .. } if path == "a.stems/mix.mp3"
4515 )),
4516 "a stem delete must never alias a stem write target"
4517 );
4518 }
4519}
4520
4521#[cfg(test)]
4534mod proptests {
4535 use super::*;
4536 use proptest::collection::{btree_map, hash_map, vec};
4537 use proptest::prelude::*;
4538 use std::collections::BTreeSet;
4539
4540 type DesiredFields = (
4541 String,
4542 AudioFormat,
4543 String,
4544 String,
4545 Vec<SourceMode>,
4546 bool,
4547 bool,
4548 );
4549
4550 fn audio_format() -> impl Strategy<Value = AudioFormat> {
4551 prop_oneof![
4552 Just(AudioFormat::Mp3),
4553 Just(AudioFormat::Flac),
4554 Just(AudioFormat::Wav),
4555 ]
4556 }
4557
4558 fn source_mode() -> impl Strategy<Value = SourceMode> {
4559 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
4560 }
4561
4562 fn clip_id() -> impl Strategy<Value = String> {
4565 (0u8..8).prop_map(|n| format!("c{n}"))
4566 }
4567
4568 fn small_path() -> impl Strategy<Value = String> {
4569 (0u8..6).prop_map(|n| format!("path{n}"))
4570 }
4571
4572 fn manifest_path() -> impl Strategy<Value = String> {
4575 prop_oneof![
4576 1 => Just(String::new()),
4577 6 => small_path(),
4578 ]
4579 }
4580
4581 fn small_hash() -> impl Strategy<Value = String> {
4582 (0u8..4).prop_map(|n| format!("h{n}"))
4583 }
4584
4585 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
4586 (
4587 manifest_path(),
4588 audio_format(),
4589 small_hash(),
4590 small_hash(),
4591 0u64..4,
4592 any::<bool>(),
4593 )
4594 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
4595 ManifestEntry {
4596 path,
4597 format,
4598 meta_hash,
4599 art_hash,
4600 size,
4601 preserve,
4602 ..Default::default()
4603 }
4604 })
4605 }
4606
4607 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
4608 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
4609 }
4610
4611 fn local_file() -> impl Strategy<Value = LocalFile> {
4612 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
4613 }
4614
4615 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
4616 hash_map(clip_id(), local_file(), 0..8)
4617 }
4618
4619 fn source_status() -> impl Strategy<Value = SourceStatus> {
4620 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
4621 mode,
4622 fully_enumerated,
4623 })
4624 }
4625
4626 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
4627 vec(source_status(), 0..5)
4628 }
4629
4630 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
4631 vec(
4632 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
4633 mode: SourceMode::Copy,
4634 fully_enumerated,
4635 }),
4636 1..5,
4637 )
4638 }
4639
4640 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
4641 (
4642 small_path(),
4643 audio_format(),
4644 small_hash(),
4645 small_hash(),
4646 vec(source_mode(), 1..3),
4647 any::<bool>(),
4648 any::<bool>(),
4649 )
4650 }
4651
4652 fn build_desired(id: String, fields: DesiredFields) -> Desired {
4653 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
4654 let clip = Clip {
4655 id,
4656 title: "t".to_string(),
4657 ..Default::default()
4658 };
4659 Desired {
4660 lineage: LineageContext::own_root(&clip),
4661 clip,
4662 path,
4663 format,
4664 meta_hash,
4665 art_hash,
4666 modes,
4667 trashed,
4668 private,
4669 artifacts: Vec::new(),
4670 stems: None,
4671 }
4672 }
4673
4674 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
4677 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
4678 items
4679 .into_iter()
4680 .map(|(id, fields)| build_desired(id, fields))
4681 .collect()
4682 })
4683 }
4684
4685 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
4686 desired.iter().map(|d| d.clip.id.as_str()).collect()
4687 }
4688
4689 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
4692 desired
4693 .iter()
4694 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
4695 .map(|d| d.clip.id.as_str())
4696 .collect()
4697 }
4698
4699 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
4702 desired
4703 .iter()
4704 .filter(|d| !d.trashed)
4705 .map(|d| d.clip.id.as_str())
4706 .collect()
4707 }
4708
4709 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
4710 plan.actions
4711 .iter()
4712 .filter_map(|a| match a {
4713 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
4714 _ => None,
4715 })
4716 .collect()
4717 }
4718
4719 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
4720 plan.actions
4721 .iter()
4722 .filter_map(|a| match a {
4723 Action::Download { path, .. } | Action::Reformat { path, .. } => {
4724 Some(path.as_str())
4725 }
4726 Action::Rename { to, .. } => Some(to.as_str()),
4727 _ => None,
4728 })
4729 .collect()
4730 }
4731
4732 proptest! {
4733 #![proptest_config(ProptestConfig {
4734 cases: 256,
4735 failure_persistence: None,
4736 ..ProptestConfig::default()
4737 })]
4738
4739 #[test]
4742 fn inv1_desired_clip_deleted_only_when_fully_trashed(
4743 manifest in manifest_strategy(),
4744 desired in desired_strategy(),
4745 local in local_strategy(),
4746 sources in sources_strategy(),
4747 ) {
4748 let plan = reconcile(&manifest, &desired, &local, &sources);
4749 let present = desired_ids(&desired);
4750 let live = non_trashed_ids(&desired);
4751 for id in delete_clip_ids(&plan) {
4752 prop_assert!(
4753 !(present.contains(id) && live.contains(id)),
4754 "deleted a desired clip with a non-trashed duplicate: {id}"
4755 );
4756 }
4757 }
4758
4759 #[test]
4763 fn inv2_no_delete_when_any_mirror_unenumerated(
4764 manifest in manifest_strategy(),
4765 desired in desired_strategy(),
4766 local in local_strategy(),
4767 mut sources in sources_strategy(),
4768 ) {
4769 sources.push(SourceStatus {
4770 mode: SourceMode::Mirror,
4771 fully_enumerated: false,
4772 });
4773 let plan = reconcile(&manifest, &desired, &local, &sources);
4774 prop_assert_eq!(plan.deletes(), 0);
4775 }
4776
4777 #[test]
4779 fn inv3_all_copy_sources_means_no_deletes(
4780 manifest in manifest_strategy(),
4781 desired in desired_strategy(),
4782 local in local_strategy(),
4783 sources in copy_sources_strategy(),
4784 ) {
4785 let plan = reconcile(&manifest, &desired, &local, &sources);
4786 prop_assert_eq!(plan.deletes(), 0);
4787 }
4788
4789 #[test]
4792 fn inv4_plan_is_deterministic(
4793 manifest in manifest_strategy(),
4794 desired in desired_strategy(),
4795 local in local_strategy(),
4796 sources in sources_strategy(),
4797 ) {
4798 let plan = reconcile(&manifest, &desired, &local, &sources);
4799
4800 let again = reconcile(&manifest, &desired, &local, &sources);
4801 prop_assert_eq!(&plan, &again);
4802
4803 let mut desired_rev = desired.clone();
4804 desired_rev.reverse();
4805 let mut sources_rev = sources.clone();
4806 sources_rev.reverse();
4807 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
4808 prop_assert_eq!(&plan, &shuffled);
4809 }
4810
4811 #[test]
4813 fn inv5_every_delete_is_in_the_manifest(
4814 manifest in manifest_strategy(),
4815 desired in desired_strategy(),
4816 local in local_strategy(),
4817 sources in sources_strategy(),
4818 ) {
4819 let plan = reconcile(&manifest, &desired, &local, &sources);
4820 for id in delete_clip_ids(&plan) {
4821 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
4822 }
4823 }
4824
4825 #[test]
4828 fn inv6_never_deletes_protected_clip(
4829 manifest in manifest_strategy(),
4830 desired in desired_strategy(),
4831 local in local_strategy(),
4832 sources in sources_strategy(),
4833 ) {
4834 let plan = reconcile(&manifest, &desired, &local, &sources);
4835 let protected = protected_ids(&desired);
4836 for id in delete_clip_ids(&plan) {
4837 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
4838 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
4839 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
4840 }
4841 }
4842
4843 #[test]
4846 fn inv7_no_delete_unless_deletion_allowed(
4847 manifest in manifest_strategy(),
4848 desired in desired_strategy(),
4849 local in local_strategy(),
4850 sources in sources_strategy(),
4851 ) {
4852 let plan = reconcile(&manifest, &desired, &local, &sources);
4853 if !deletion_allowed(&sources) {
4854 prop_assert_eq!(plan.deletes(), 0);
4855 }
4856 }
4857
4858 #[test]
4860 fn inv8_at_most_one_delete_per_clip(
4861 manifest in manifest_strategy(),
4862 desired in desired_strategy(),
4863 local in local_strategy(),
4864 sources in sources_strategy(),
4865 ) {
4866 let plan = reconcile(&manifest, &desired, &local, &sources);
4867 let ids = delete_clip_ids(&plan);
4868 let unique: BTreeSet<&str> = ids.iter().copied().collect();
4869 prop_assert_eq!(ids.len(), unique.len());
4870 }
4871
4872 #[test]
4874 fn inv9_no_delete_with_empty_path(
4875 manifest in manifest_strategy(),
4876 desired in desired_strategy(),
4877 local in local_strategy(),
4878 sources in sources_strategy(),
4879 ) {
4880 let plan = reconcile(&manifest, &desired, &local, &sources);
4881 for action in &plan.actions {
4882 if let Action::Delete { path, .. } = action {
4883 prop_assert!(!path.is_empty(), "delete with an empty path");
4884 }
4885 }
4886 }
4887
4888 #[test]
4891 fn inv10_no_delete_aliases_a_write_target(
4892 manifest in manifest_strategy(),
4893 desired in desired_strategy(),
4894 local in local_strategy(),
4895 sources in sources_strategy(),
4896 ) {
4897 let plan = reconcile(&manifest, &desired, &local, &sources);
4898 let targets = write_target_paths(&plan);
4899 for action in &plan.actions {
4900 if let Action::Delete { path, .. } = action {
4901 prop_assert!(
4902 !targets.contains(path.as_str()),
4903 "delete path {path} aliases a write target"
4904 );
4905 }
4906 }
4907 }
4908 }
4909}