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, local, 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(
738 d: &Desired,
739 manifest: &Manifest,
740 local: &HashMap<String, LocalFile>,
741 can_delete: bool,
742 out: &mut Vec<Action>,
743) {
744 let owner_id = d.clip.id.as_str();
745 let entry = manifest.get(owner_id);
746
747 for artifact in &d.artifacts {
748 if !is_per_clip_kind(artifact.kind) {
753 continue;
754 }
755 let needs_write = match entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind)) {
761 None => true,
762 Some(state) => {
763 state.hash != artifact.hash
764 || state.path != artifact.path
765 || local
766 .get(&state.path)
767 .is_some_and(|f| !f.exists || f.size == 0)
768 }
769 };
770 if needs_write {
771 out.push(Action::WriteArtifact {
772 kind: artifact.kind,
773 path: artifact.path.clone(),
774 source_url: artifact.source_url.clone(),
775 hash: artifact.hash.clone(),
776 owner_id: owner_id.to_string(),
777 content: artifact.content.clone(),
778 });
779 }
780 }
781
782 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
787 if !protected_now && let Some(entry) = entry {
788 let desired_kinds: BTreeSet<ArtifactKind> = d
789 .artifacts
790 .iter()
791 .filter(|a| is_per_clip_kind(a.kind))
792 .map(|a| a.kind)
793 .collect();
794 for (kind, state) in manifest_artifacts(entry) {
795 if removed_kind_delete_eligible(kind)
801 && !desired_kinds.contains(&kind)
802 && let Some(action) =
803 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
804 {
805 out.push(action);
806 }
807 }
808 }
809}
810
811fn co_delete_artifacts(
817 owner_id: &str,
818 manifest: &Manifest,
819 can_delete: bool,
820 out: &mut Vec<Action>,
821) {
822 let Some(entry) = manifest.get(owner_id) else {
823 return;
824 };
825 for (kind, state) in manifest_artifacts(entry) {
826 if let Some(action) =
827 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
828 {
829 out.push(action);
830 }
831 }
832}
833
834fn delete_stem_action(
843 clip_id: &str,
844 key: &str,
845 path: &str,
846 manifest: &Manifest,
847 can_delete: bool,
848) -> Option<Action> {
849 if !can_delete {
850 return None;
851 }
852 let entry = manifest.get(clip_id)?;
853 if path.is_empty() || entry.preserve {
854 return None;
855 }
856 Some(Action::DeleteStem {
857 clip_id: clip_id.to_string(),
858 key: key.to_string(),
859 path: path.to_string(),
860 })
861}
862
863fn plan_clip_stems(d: &Desired, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
879 let Some(desired_stems) = &d.stems else {
880 return;
881 };
882 let clip_id = d.clip.id.as_str();
883 let entry = manifest.get(clip_id);
884
885 for stem in desired_stems {
886 let needs_write = match entry.and_then(|e| e.stems.get(&stem.key)) {
887 None => true,
888 Some(state) => state.hash != stem.hash || state.path != stem.path,
889 };
890 if needs_write {
891 out.push(Action::WriteStem {
892 clip_id: clip_id.to_string(),
893 key: stem.key.clone(),
894 stem_id: stem.stem_id.clone(),
895 path: stem.path.clone(),
896 source_url: stem.source_url.clone(),
897 format: stem.format,
898 hash: stem.hash.clone(),
899 });
900 }
901 }
902
903 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
904 if !protected_now && let Some(entry) = entry {
905 let desired_keys: BTreeSet<&str> = desired_stems.iter().map(|s| s.key.as_str()).collect();
906 for (key, state) in &entry.stems {
907 if !desired_keys.contains(key.as_str())
913 && let Some(action) =
914 delete_stem_action(clip_id, key, &state.path, manifest, can_delete)
915 {
916 out.push(action);
917 }
918 }
919 }
920}
921
922fn co_delete_stems(clip_id: &str, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
930 let Some(entry) = manifest.get(clip_id) else {
931 return;
932 };
933 for (key, state) in &entry.stems {
934 if let Some(action) = delete_stem_action(clip_id, key, &state.path, manifest, can_delete) {
935 out.push(action);
936 }
937 }
938}
939
940fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
947 let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
948 for d in desired {
949 match by_id.get_mut(d.clip.id.as_str()) {
950 None => {
951 by_id.insert(d.clip.id.as_str(), d.clone());
952 }
953 Some(acc) => {
954 let take = rep_key(d) < rep_key(acc);
955 acc.private = acc.private || d.private;
956 acc.trashed = acc.trashed && d.trashed;
957 for mode in &d.modes {
958 if !acc.modes.contains(mode) {
959 acc.modes.push(*mode);
960 }
961 }
962 if take {
963 acc.clip = d.clip.clone();
964 acc.path = d.path.clone();
965 acc.format = d.format;
966 acc.meta_hash = d.meta_hash.clone();
967 acc.art_hash = d.art_hash.clone();
968 acc.artifacts = d.artifacts.clone();
969 acc.stems = d.stems.clone();
970 }
971 }
972 }
973 }
974 let mut out: Vec<Desired> = by_id.into_values().collect();
975 for d in &mut out {
976 let has_mirror = d.modes.contains(&SourceMode::Mirror);
978 let has_copy = d.modes.contains(&SourceMode::Copy);
979 d.modes.clear();
980 if has_mirror {
981 d.modes.push(SourceMode::Mirror);
982 }
983 if has_copy {
984 d.modes.push(SourceMode::Copy);
985 }
986 }
987 out
988}
989
990fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
993 let format = match d.format {
994 AudioFormat::Mp3 => 0,
995 AudioFormat::Flac => 1,
996 AudioFormat::Wav => 2,
997 };
998 (
999 d.path.as_str(),
1000 d.meta_hash.as_str(),
1001 d.art_hash.as_str(),
1002 format,
1003 )
1004}
1005
1006fn suppress_path_aliasing(actions: &mut [Action]) {
1012 let targets: BTreeSet<String> = actions
1013 .iter()
1014 .filter_map(|a| match a {
1015 Action::Download { path, .. }
1016 | Action::Reformat { path, .. }
1017 | Action::WriteArtifact { path, .. }
1018 | Action::WriteStem { path, .. } => Some(path.clone()),
1019 Action::Rename { to, .. } => Some(to.clone()),
1020 _ => None,
1021 })
1022 .collect();
1023 for a in actions.iter_mut() {
1024 if let Action::Delete { path, clip_id } = a
1025 && targets.contains(path.as_str())
1026 {
1027 *a = Action::Skip {
1028 clip_id: clip_id.clone(),
1029 };
1030 }
1031 if let Action::DeleteArtifact { path, owner_id, .. } = a
1032 && targets.contains(path.as_str())
1033 {
1034 *a = Action::Skip {
1035 clip_id: owner_id.clone(),
1036 };
1037 }
1038 if let Action::DeleteStem { path, clip_id, .. } = a
1039 && targets.contains(path.as_str())
1040 {
1041 *a = Action::Skip {
1042 clip_id: clip_id.clone(),
1043 };
1044 }
1045 }
1046}
1047
1048fn plan_desired(
1050 d: &Desired,
1051 manifest: &Manifest,
1052 local: &HashMap<String, LocalFile>,
1053 can_delete: bool,
1054 out: &mut Vec<Action>,
1055) {
1056 let clip_id = d.clip.id.as_str();
1057 let copy_held = d.modes.contains(&SourceMode::Copy);
1058
1059 if d.trashed && !d.private && !copy_held {
1065 match delete_action(clip_id, manifest, can_delete) {
1066 Some(action) => out.push(action),
1067 None => out.push(Action::Skip {
1068 clip_id: clip_id.to_string(),
1069 }),
1070 }
1071 return;
1072 }
1073
1074 let Some(entry) = manifest.get(clip_id) else {
1075 out.push(Action::Download {
1077 clip: d.clip.clone(),
1078 lineage: d.lineage.clone(),
1079 path: d.path.clone(),
1080 format: d.format,
1081 });
1082 return;
1083 };
1084
1085 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
1088 if missing {
1089 out.push(Action::Download {
1090 clip: d.clip.clone(),
1091 lineage: d.lineage.clone(),
1092 path: d.path.clone(),
1093 format: d.format,
1094 });
1095 return;
1096 }
1097
1098 if d.format != entry.format {
1099 out.push(Action::Reformat {
1102 clip: d.clip.clone(),
1103 path: d.path.clone(),
1104 from_path: entry.path.clone(),
1105 from: entry.format,
1106 to: d.format,
1107 });
1108 return;
1109 }
1110
1111 if d.path != entry.path {
1112 out.push(Action::Rename {
1113 from: entry.path.clone(),
1114 to: d.path.clone(),
1115 });
1116 if meta_or_art_changed(d, entry) {
1118 out.push(Action::Retag {
1119 clip: d.clip.clone(),
1120 lineage: d.lineage.clone(),
1121 path: d.path.clone(),
1122 });
1123 }
1124 return;
1125 }
1126
1127 if meta_or_art_changed(d, entry) {
1128 out.push(Action::Retag {
1129 clip: d.clip.clone(),
1130 lineage: d.lineage.clone(),
1131 path: entry.path.clone(),
1132 });
1133 return;
1134 }
1135
1136 out.push(Action::Skip {
1137 clip_id: clip_id.to_string(),
1138 });
1139}
1140
1141fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
1143 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
1144}
1145
1146pub fn album_desired(
1170 desired: &[Desired],
1171 animated_covers: bool,
1172 raw_cover: bool,
1173) -> Vec<AlbumDesired> {
1174 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
1175 for d in desired {
1176 groups
1177 .entry(d.lineage.root_id.as_str())
1178 .or_default()
1179 .push(d);
1180 }
1181
1182 groups
1183 .into_iter()
1184 .map(|(root_id, members)| {
1185 let album_dir = album_dir_of(&members);
1186 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
1187 kind: ArtifactKind::FolderJpg,
1188 path: album_child(&album_dir, "folder.jpg"),
1189 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
1190 hash: art_hash(&source.clip),
1191 content: None,
1192 });
1193 let folder_webp = animated_covers
1194 .then(|| folder_webp_source(&members))
1195 .flatten()
1196 .map(|source| DesiredArtifact {
1197 kind: ArtifactKind::FolderWebp,
1198 path: album_child(&album_dir, "cover.webp"),
1199 source_url: source.clip.video_cover_url.clone(),
1200 hash: art_url_hash(&source.clip.video_cover_url),
1201 content: None,
1202 });
1203 let folder_mp4 = raw_cover
1204 .then(|| folder_webp_source(&members))
1205 .flatten()
1206 .map(|source| DesiredArtifact {
1207 kind: ArtifactKind::FolderMp4,
1208 path: album_child(&album_dir, "cover.mp4"),
1209 source_url: source.clip.video_cover_url.clone(),
1210 hash: art_url_hash(&source.clip.video_cover_url),
1211 content: None,
1212 });
1213 AlbumDesired {
1214 root_id: root_id.to_owned(),
1215 folder_jpg,
1216 folder_webp,
1217 folder_mp4,
1218 }
1219 })
1220 .collect()
1221}
1222
1223fn album_dir_of(members: &[&Desired]) -> String {
1228 members
1229 .iter()
1230 .map(|d| parent_dir(&d.path))
1231 .min()
1232 .unwrap_or("")
1233 .to_owned()
1234}
1235
1236fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1242 members
1243 .iter()
1244 .copied()
1245 .filter(|d| {
1246 d.clip
1247 .selected_image_url()
1248 .is_some_and(|url| !url.is_empty())
1249 })
1250 .min_by(|a, b| {
1251 b.clip
1252 .play_count
1253 .cmp(&a.clip.play_count)
1254 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
1255 .then_with(|| a.clip.id.cmp(&b.clip.id))
1256 })
1257}
1258
1259fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1264 members
1265 .iter()
1266 .copied()
1267 .filter(|d| !d.clip.video_cover_url.is_empty())
1268 .min_by(|a, b| {
1269 a.clip
1270 .created_at
1271 .cmp(&b.clip.created_at)
1272 .then_with(|| a.clip.id.cmp(&b.clip.id))
1273 })
1274}
1275
1276fn parent_dir(path: &str) -> &str {
1278 match path.rsplit_once('/') {
1279 Some((dir, _)) => dir,
1280 None => "",
1281 }
1282}
1283
1284fn album_child(album_dir: &str, name: &str) -> String {
1287 if album_dir.is_empty() {
1288 name.to_owned()
1289 } else {
1290 format!("{album_dir}/{name}")
1291 }
1292}
1293
1294pub fn plan_album_artifacts(
1318 desired: &[AlbumDesired],
1319 albums: &BTreeMap<String, AlbumArt>,
1320 can_delete: bool,
1321 local: &HashMap<String, LocalFile>,
1322) -> Vec<Action> {
1323 let mut actions: Vec<Action> = Vec::new();
1324 let by_root: BTreeMap<&str, &AlbumDesired> =
1325 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1326
1327 for d in desired {
1328 let stored = albums.get(&d.root_id);
1329 for artifact in [
1330 d.folder_jpg.as_ref(),
1331 d.folder_webp.as_ref(),
1332 d.folder_mp4.as_ref(),
1333 ]
1334 .into_iter()
1335 .flatten()
1336 {
1337 let needs_write = match stored.and_then(|a| a.artifact(artifact.kind)) {
1338 None => true,
1339 Some(state) => {
1340 state.hash != artifact.hash
1341 || state.path != artifact.path
1342 || local
1343 .get(&state.path)
1344 .is_some_and(|f| !f.exists || f.size == 0)
1345 }
1346 };
1347 if needs_write {
1348 actions.push(Action::WriteArtifact {
1349 kind: artifact.kind,
1350 path: artifact.path.clone(),
1351 source_url: artifact.source_url.clone(),
1352 hash: artifact.hash.clone(),
1353 owner_id: d.root_id.clone(),
1354 content: None,
1355 });
1356 }
1357 }
1358 }
1359
1360 if can_delete {
1362 for (root_id, art) in albums {
1363 for (kind, state) in album_artifacts(art) {
1364 let desired_here = by_root
1365 .get(root_id.as_str())
1366 .is_some_and(|d| album_desires_kind(d, kind));
1367 if !desired_here && !state.path.is_empty() {
1368 actions.push(Action::DeleteArtifact {
1369 kind,
1370 path: state.path.clone(),
1371 owner_id: root_id.clone(),
1372 });
1373 }
1374 }
1375 }
1376 }
1377
1378 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1379 actions
1380}
1381
1382fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1385 let mut out = Vec::new();
1386 if let Some(state) = &art.folder_jpg {
1387 out.push((ArtifactKind::FolderJpg, state));
1388 }
1389 if let Some(state) = &art.folder_webp {
1390 out.push((ArtifactKind::FolderWebp, state));
1391 }
1392 if let Some(state) = &art.folder_mp4 {
1393 out.push((ArtifactKind::FolderMp4, state));
1394 }
1395 out
1396}
1397
1398fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1400 match kind {
1401 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1402 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1403 ArtifactKind::FolderMp4 => d.folder_mp4.is_some(),
1404 ArtifactKind::CoverJpg
1405 | ArtifactKind::CoverWebp
1406 | ArtifactKind::DetailsTxt
1407 | ArtifactKind::LyricsTxt
1408 | ArtifactKind::Lrc
1409 | ArtifactKind::VideoMp4
1410 | ArtifactKind::Playlist => false,
1411 }
1412}
1413
1414fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1416 match action {
1417 Action::WriteArtifact { owner_id, kind, .. }
1418 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1419 _ => ("", ArtifactKind::CoverJpg),
1420 }
1421}
1422
1423pub fn plan_playlist_artifacts(
1461 desired: &[PlaylistDesired],
1462 stored: &BTreeMap<String, PlaylistState>,
1463 can_delete: bool,
1464 list_fully_enumerated: bool,
1465 local: &HashMap<String, LocalFile>,
1466) -> Vec<Action> {
1467 let mut actions: Vec<Action> = Vec::new();
1468 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1469 let deletes_allowed = can_delete && list_fully_enumerated;
1472
1473 for d in desired {
1474 let stored_here = stored.get(&d.id);
1475 let needs_write = match stored_here {
1476 None => true,
1477 Some(state) => {
1478 state.hash != d.hash
1479 || state.path != d.path
1480 || local
1481 .get(&state.path)
1482 .is_some_and(|f| !f.exists || f.size == 0)
1483 }
1484 };
1485 if needs_write {
1486 actions.push(Action::WriteArtifact {
1487 kind: ArtifactKind::Playlist,
1488 path: d.path.clone(),
1489 source_url: String::new(),
1490 hash: d.hash.clone(),
1491 owner_id: d.id.clone(),
1492 content: Some(d.content.clone()),
1493 });
1494 }
1495 if deletes_allowed
1497 && let Some(state) = stored_here
1498 && !state.path.is_empty()
1499 && state.path != d.path
1500 {
1501 actions.push(Action::DeleteArtifact {
1502 kind: ArtifactKind::Playlist,
1503 path: state.path.clone(),
1504 owner_id: d.id.clone(),
1505 });
1506 }
1507 }
1508
1509 if deletes_allowed {
1512 for (id, state) in stored {
1513 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1514 actions.push(Action::DeleteArtifact {
1515 kind: ArtifactKind::Playlist,
1516 path: state.path.clone(),
1517 owner_id: id.clone(),
1518 });
1519 }
1520 }
1521 }
1522
1523 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1524 suppress_path_aliasing(&mut actions);
1527 actions
1528}
1529
1530fn playlist_action_key(action: &Action) -> (&str, u8) {
1533 match action {
1534 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1535 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1536 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1537 _ => ("", 3),
1538 }
1539}
1540
1541#[cfg(test)]
1542mod tests {
1543 use super::*;
1544 use crate::hash::content_hash;
1545
1546 fn clip(id: &str) -> Clip {
1547 Clip {
1548 id: id.to_string(),
1549 title: "Song".to_string(),
1550 ..Default::default()
1551 }
1552 }
1553
1554 fn lineage(id: &str) -> LineageContext {
1555 LineageContext::own_root(&clip(id))
1556 }
1557
1558 fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1559 ManifestEntry {
1560 path: path.to_string(),
1561 format,
1562 meta_hash: meta.to_string(),
1563 art_hash: art.to_string(),
1564 size: 100,
1565 preserve: false,
1566 ..Default::default()
1567 }
1568 }
1569
1570 fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1571 ManifestEntry {
1572 preserve: true,
1573 ..entry(path, format, meta, art)
1574 }
1575 }
1576
1577 fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1578 Desired {
1579 clip: clip(id),
1580 lineage: lineage(id),
1581 path: path.to_string(),
1582 format,
1583 meta_hash: meta.to_string(),
1584 art_hash: art.to_string(),
1585 modes: vec![SourceMode::Mirror],
1586 trashed: false,
1587 private: false,
1588 artifacts: Vec::new(),
1589 stems: None,
1590 }
1591 }
1592
1593 fn present(size: u64) -> LocalFile {
1594 LocalFile { exists: true, size }
1595 }
1596
1597 fn local_present(id: &str) -> HashMap<String, LocalFile> {
1598 [(id.to_string(), present(100))].into_iter().collect()
1599 }
1600
1601 fn mirror_ok() -> Vec<SourceStatus> {
1602 vec![SourceStatus {
1603 mode: SourceMode::Mirror,
1604 fully_enumerated: true,
1605 }]
1606 }
1607
1608 #[test]
1611 fn not_in_manifest_downloads() {
1612 let manifest = Manifest::new();
1613 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1614 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1615 assert_eq!(
1616 plan.actions,
1617 vec![Action::Download {
1618 clip: clip("a"),
1619 lineage: lineage("a"),
1620 path: "a.flac".to_string(),
1621 format: AudioFormat::Flac,
1622 }]
1623 );
1624 }
1625
1626 #[test]
1627 fn unchanged_clip_skips() {
1628 let mut manifest = Manifest::new();
1629 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1630 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1631 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1632 assert_eq!(
1633 plan.actions,
1634 vec![Action::Skip {
1635 clip_id: "a".to_string()
1636 }]
1637 );
1638 }
1639
1640 #[test]
1641 fn meta_change_retags_in_place() {
1642 let mut manifest = Manifest::new();
1643 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1644 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1645 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1646 assert_eq!(
1647 plan.actions,
1648 vec![Action::Retag {
1649 clip: clip("a"),
1650 lineage: lineage("a"),
1651 path: "a.flac".to_string(),
1652 }]
1653 );
1654 }
1655
1656 #[test]
1657 fn art_change_retags_in_place() {
1658 let mut manifest = Manifest::new();
1659 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1660 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1661 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1662 assert_eq!(
1663 plan.actions,
1664 vec![Action::Retag {
1665 clip: clip("a"),
1666 lineage: lineage("a"),
1667 path: "a.flac".to_string(),
1668 }]
1669 );
1670 }
1671
1672 #[test]
1673 fn rename_when_path_changes() {
1674 let mut manifest = Manifest::new();
1675 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1676 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1677 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1678 assert_eq!(
1679 plan.actions,
1680 vec![Action::Rename {
1681 from: "old/a.flac".to_string(),
1682 to: "new/a.flac".to_string(),
1683 }]
1684 );
1685 }
1686
1687 #[test]
1688 fn rename_with_meta_change_also_retags() {
1689 let mut manifest = Manifest::new();
1690 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1691 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1692 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1693 assert_eq!(
1694 plan.actions,
1695 vec![
1696 Action::Rename {
1697 from: "old/a.flac".to_string(),
1698 to: "new/a.flac".to_string(),
1699 },
1700 Action::Retag {
1701 clip: clip("a"),
1702 lineage: lineage("a"),
1703 path: "new/a.flac".to_string(),
1704 },
1705 ]
1706 );
1707 }
1708
1709 #[test]
1710 fn rename_without_meta_change_does_not_retag() {
1711 let mut manifest = Manifest::new();
1712 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1713 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1714 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1715 assert_eq!(plan.renames(), 1);
1716 assert_eq!(plan.retags(), 0);
1717 }
1718
1719 #[test]
1720 fn bulk_album_rename_moves_and_retags_without_redownload() {
1721 let mut manifest = Manifest::new();
1726 for id in ["a", "b", "c"] {
1727 manifest.insert(
1728 id,
1729 entry(
1730 &format!("Creator/Old Album/{id}.flac"),
1731 AudioFormat::Flac,
1732 "old-meta",
1733 "art",
1734 ),
1735 );
1736 }
1737 let d: Vec<Desired> = ["a", "b", "c"]
1738 .iter()
1739 .map(|id| {
1740 desired(
1741 id,
1742 &format!("Creator/New Album/{id}.flac"),
1743 AudioFormat::Flac,
1744 "new-meta",
1745 "art",
1746 )
1747 })
1748 .collect();
1749 let local: HashMap<String, LocalFile> = ["a", "b", "c"]
1750 .iter()
1751 .map(|id| (id.to_string(), present(100)))
1752 .collect();
1753
1754 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1755
1756 assert_eq!(plan.renames(), 3, "every member folder move is a rename");
1757 assert_eq!(
1758 plan.retags(),
1759 3,
1760 "the album tag change retags each in place"
1761 );
1762 assert_eq!(
1763 plan.downloads(),
1764 0,
1765 "an album rename must never re-download"
1766 );
1767 assert_eq!(
1768 plan.deletes(),
1769 0,
1770 "deletion safety: a rename deletes nothing"
1771 );
1772 for id in ["a", "b", "c"] {
1773 assert!(plan.actions.contains(&Action::Rename {
1774 from: format!("Creator/Old Album/{id}.flac"),
1775 to: format!("Creator/New Album/{id}.flac"),
1776 }));
1777 }
1778 }
1779
1780 #[test]
1781 fn format_change_reformats() {
1782 let mut manifest = Manifest::new();
1783 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1784 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1785 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1786 assert_eq!(
1787 plan.actions,
1788 vec![Action::Reformat {
1789 clip: clip("a"),
1790 path: "a.mp3".to_string(),
1791 from_path: "a.flac".to_string(),
1792 from: AudioFormat::Flac,
1793 to: AudioFormat::Mp3,
1794 }]
1795 );
1796 }
1797
1798 #[test]
1799 fn format_change_takes_precedence_over_rename_and_retag() {
1800 let mut manifest = Manifest::new();
1803 manifest.insert(
1804 "a",
1805 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1806 );
1807 let d = vec![desired(
1808 "a",
1809 "new/a.mp3",
1810 AudioFormat::Mp3,
1811 "new",
1812 "new-art",
1813 )];
1814 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1815 assert_eq!(plan.reformats(), 1);
1816 assert_eq!(plan.renames(), 0);
1817 assert_eq!(plan.retags(), 0);
1818 }
1819
1820 #[test]
1823 fn zero_length_file_downloads_even_when_hashes_match() {
1824 let mut manifest = Manifest::new();
1825 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1826 let local: HashMap<String, LocalFile> = [(
1827 "a".to_string(),
1828 LocalFile {
1829 exists: true,
1830 size: 0,
1831 },
1832 )]
1833 .into_iter()
1834 .collect();
1835 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1836 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1837 assert_eq!(plan.downloads(), 1);
1838 assert_eq!(plan.skips(), 0);
1839 }
1840
1841 #[test]
1842 fn missing_file_downloads_even_when_hashes_match() {
1843 let mut manifest = Manifest::new();
1844 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1845 let local: HashMap<String, LocalFile> = [(
1846 "a".to_string(),
1847 LocalFile {
1848 exists: false,
1849 size: 0,
1850 },
1851 )]
1852 .into_iter()
1853 .collect();
1854 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1855 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1856 assert_eq!(plan.downloads(), 1);
1857 }
1858
1859 #[test]
1860 fn absent_local_probe_treated_as_missing() {
1861 let mut manifest = Manifest::new();
1863 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1864 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1865 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1866 assert_eq!(plan.downloads(), 1);
1867 }
1868
1869 #[test]
1870 fn missing_file_download_wins_over_format_difference() {
1871 let mut manifest = Manifest::new();
1874 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1875 let local: HashMap<String, LocalFile> = [(
1876 "a".to_string(),
1877 LocalFile {
1878 exists: false,
1879 size: 0,
1880 },
1881 )]
1882 .into_iter()
1883 .collect();
1884 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1885 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1886 assert_eq!(plan.downloads(), 1);
1887 assert_eq!(plan.reformats(), 0);
1888 }
1889
1890 #[test]
1893 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
1894 let mut trashed = clip("a");
1899 trashed.status = "complete".to_string();
1900 trashed.is_trashed = true;
1901 assert!(crate::is_downloadable(&trashed));
1902
1903 let mut manifest = Manifest::new();
1904 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1905 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1906 d.clip = trashed;
1907 d.trashed = true;
1908 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1909 assert_eq!(
1910 plan.actions,
1911 vec![Action::Delete {
1912 path: "a.flac".to_string(),
1913 clip_id: "a".to_string(),
1914 }]
1915 );
1916 }
1917
1918 #[test]
1919 fn trashed_clip_deletes_local_file() {
1920 let mut manifest = Manifest::new();
1921 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1922 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1923 d.trashed = true;
1924 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1925 assert_eq!(
1926 plan.actions,
1927 vec![Action::Delete {
1928 path: "a.flac".to_string(),
1929 clip_id: "a".to_string(),
1930 }]
1931 );
1932 }
1933
1934 #[test]
1935 fn trashed_clip_not_in_manifest_skips() {
1936 let manifest = Manifest::new();
1938 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1939 d.trashed = true;
1940 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
1941 assert_eq!(
1942 plan.actions,
1943 vec![Action::Skip {
1944 clip_id: "a".to_string()
1945 }]
1946 );
1947 }
1948
1949 #[test]
1950 fn private_clip_is_kept() {
1951 let mut manifest = Manifest::new();
1952 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1953 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1954 d.private = true;
1955 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1956 assert_eq!(
1957 plan.actions,
1958 vec![Action::Skip {
1959 clip_id: "a".to_string()
1960 }]
1961 );
1962 }
1963
1964 #[test]
1965 fn private_beats_trashed_never_deletes() {
1966 let mut manifest = Manifest::new();
1968 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1969 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1970 d.trashed = true;
1971 d.private = true;
1972 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1973 assert_eq!(plan.deletes(), 0);
1974 assert_eq!(plan.skips(), 1);
1975 }
1976
1977 #[test]
1978 fn copy_held_trashed_clip_is_not_deleted() {
1979 let mut manifest = Manifest::new();
1982 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1983 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
1984 d.modes = vec![SourceMode::Copy];
1985 d.trashed = true;
1986 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
1987 assert_eq!(plan.deletes(), 0);
1988 assert_eq!(
1989 plan.actions,
1990 vec![Action::Skip {
1991 clip_id: "a".to_string()
1992 }]
1993 );
1994 }
1995
1996 #[test]
1999 fn absent_clip_deleted_when_all_mirrors_enumerated() {
2000 let mut manifest = Manifest::new();
2001 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2002 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2003 assert_eq!(
2004 plan.actions,
2005 vec![Action::Delete {
2006 path: "gone.flac".to_string(),
2007 clip_id: "gone".to_string(),
2008 }]
2009 );
2010 }
2011
2012 #[test]
2013 fn absent_clip_kept_when_any_mirror_not_enumerated() {
2014 let mut manifest = Manifest::new();
2015 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2016 let sources = vec![
2017 SourceStatus {
2018 mode: SourceMode::Mirror,
2019 fully_enumerated: true,
2020 },
2021 SourceStatus {
2022 mode: SourceMode::Mirror,
2023 fully_enumerated: false,
2024 },
2025 ];
2026 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2027 assert_eq!(plan.deletes(), 0);
2028 assert_eq!(
2029 plan.actions,
2030 vec![Action::Skip {
2031 clip_id: "gone".to_string()
2032 }]
2033 );
2034 }
2035
2036 #[test]
2037 fn empty_listing_cannot_cause_deletion() {
2038 let mut manifest = Manifest::new();
2041 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2042 let sources = vec![SourceStatus {
2043 mode: SourceMode::Mirror,
2044 fully_enumerated: false,
2045 }];
2046 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2047 assert_eq!(plan.deletes(), 0);
2048 assert_eq!(plan.skips(), 1);
2049 }
2050
2051 #[test]
2052 fn no_mirror_sources_means_no_deletion() {
2053 let mut manifest = Manifest::new();
2055 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2056 let copy_only = vec![SourceStatus {
2057 mode: SourceMode::Copy,
2058 fully_enumerated: true,
2059 }];
2060 assert_eq!(
2061 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
2062 0
2063 );
2064 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
2065 }
2066
2067 #[test]
2068 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
2069 let mut manifest = Manifest::new();
2070 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2071 let sources = vec![
2072 SourceStatus {
2073 mode: SourceMode::Copy,
2074 fully_enumerated: true,
2075 },
2076 SourceStatus {
2077 mode: SourceMode::Mirror,
2078 fully_enumerated: false,
2079 },
2080 ];
2081 assert_eq!(
2082 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
2083 0
2084 );
2085 }
2086
2087 #[test]
2088 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
2089 let mut manifest = Manifest::new();
2093 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
2094 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2095 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
2096 held.modes = vec![SourceMode::Copy];
2097 let local: HashMap<String, LocalFile> = [
2098 ("keep".to_string(), present(100)),
2099 ("gone".to_string(), present(100)),
2100 ]
2101 .into_iter()
2102 .collect();
2103 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
2104 assert!(plan.actions.contains(&Action::Skip {
2105 clip_id: "keep".to_string()
2106 }));
2107 assert!(plan.actions.contains(&Action::Delete {
2108 path: "gone.flac".to_string(),
2109 clip_id: "gone".to_string(),
2110 }));
2111 assert!(
2113 !plan
2114 .actions
2115 .iter()
2116 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
2117 );
2118 }
2119
2120 #[test]
2123 fn orphan_with_preserve_marker_is_kept() {
2124 let mut manifest = Manifest::new();
2127 manifest.insert(
2128 "gone",
2129 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
2130 );
2131 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2132 assert_eq!(plan.deletes(), 0);
2133 assert_eq!(
2134 plan.actions,
2135 vec![Action::Skip {
2136 clip_id: "gone".to_string()
2137 }]
2138 );
2139 }
2140
2141 #[test]
2142 fn trashed_clip_with_preserve_marker_is_kept() {
2143 let mut manifest = Manifest::new();
2146 manifest.insert(
2147 "a",
2148 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2149 );
2150 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2151 d.trashed = true;
2152 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2153 assert_eq!(plan.deletes(), 0);
2154 assert_eq!(plan.skips(), 1);
2155 }
2156
2157 #[test]
2160 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
2161 let mut manifest = Manifest::new();
2163 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2164 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2165 d.trashed = true;
2166 let sources = vec![SourceStatus {
2167 mode: SourceMode::Mirror,
2168 fully_enumerated: false,
2169 }];
2170 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2171 assert_eq!(plan.deletes(), 0);
2172 assert_eq!(plan.skips(), 1);
2173 }
2174
2175 #[test]
2176 fn trashed_clip_kept_when_sources_empty() {
2177 let mut manifest = Manifest::new();
2180 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2181 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2182 d.trashed = true;
2183 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
2184 assert_eq!(plan.deletes(), 0);
2185 assert_eq!(plan.skips(), 1);
2186 }
2187
2188 #[test]
2189 fn failed_copy_listing_suppresses_orphan_deletion() {
2190 let mut manifest = Manifest::new();
2193 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2194 let sources = vec![
2195 SourceStatus {
2196 mode: SourceMode::Mirror,
2197 fully_enumerated: true,
2198 },
2199 SourceStatus {
2200 mode: SourceMode::Copy,
2201 fully_enumerated: false,
2202 },
2203 ];
2204 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2205 assert_eq!(plan.deletes(), 0);
2206 }
2207
2208 #[test]
2209 fn failed_copy_listing_suppresses_trashed_deletion() {
2210 let mut manifest = Manifest::new();
2211 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2212 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2213 d.trashed = true;
2214 let sources = vec![
2215 SourceStatus {
2216 mode: SourceMode::Mirror,
2217 fully_enumerated: true,
2218 },
2219 SourceStatus {
2220 mode: SourceMode::Copy,
2221 fully_enumerated: false,
2222 },
2223 ];
2224 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2225 assert_eq!(plan.deletes(), 0);
2226 assert_eq!(plan.skips(), 1);
2227 }
2228
2229 #[test]
2230 fn empty_path_entry_never_deletes() {
2231 let mut manifest = Manifest::new();
2234 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
2235 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2236 assert_eq!(plan.deletes(), 0);
2237 assert_eq!(
2238 plan.actions,
2239 vec![Action::Skip {
2240 clip_id: "gone".to_string()
2241 }]
2242 );
2243 }
2244
2245 #[test]
2248 fn delete_suppressed_when_path_aliases_rename_target() {
2249 let mut manifest = Manifest::new();
2252 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
2253 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
2254 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
2255 let local: HashMap<String, LocalFile> = [
2256 ("a".to_string(), present(100)),
2257 ("b".to_string(), present(100)),
2258 ]
2259 .into_iter()
2260 .collect();
2261 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2262 assert!(plan.actions.contains(&Action::Rename {
2263 from: "old/a.flac".to_string(),
2264 to: "new/a.flac".to_string(),
2265 }));
2266 assert!(
2268 !plan
2269 .actions
2270 .iter()
2271 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
2272 );
2273 assert!(plan.actions.contains(&Action::Skip {
2274 clip_id: "b".to_string()
2275 }));
2276 }
2277
2278 #[test]
2279 fn delete_suppressed_when_path_aliases_download_target() {
2280 let mut manifest = Manifest::new();
2282 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
2283 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
2284 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2285 assert!(
2286 !plan
2287 .actions
2288 .iter()
2289 .any(|a| matches!(a, Action::Delete { .. }))
2290 );
2291 assert_eq!(plan.downloads(), 1);
2292 }
2293
2294 #[test]
2295 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
2296 let mut actions = vec![
2301 Action::Rename {
2302 from: "old/song.flac".to_string(),
2303 to: "new/cover.jpg".to_string(),
2304 },
2305 Action::DeleteArtifact {
2306 kind: ArtifactKind::CoverJpg,
2307 path: "new/cover.jpg".to_string(),
2308 owner_id: "a".to_string(),
2309 },
2310 ];
2311 suppress_path_aliasing(&mut actions);
2312 assert!(
2314 !actions
2315 .iter()
2316 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
2317 "a sidecar delete must not alias a rename target"
2318 );
2319 assert!(actions.contains(&Action::Skip {
2320 clip_id: "a".to_string()
2321 }));
2322 assert!(actions.contains(&Action::Rename {
2324 from: "old/song.flac".to_string(),
2325 to: "new/cover.jpg".to_string(),
2326 }));
2327 }
2328
2329 #[test]
2330 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
2331 let mut actions = vec![
2334 Action::WriteArtifact {
2335 kind: ArtifactKind::FolderJpg,
2336 path: "creator/album/folder.jpg".to_string(),
2337 source_url: "https://art/large.jpg".to_string(),
2338 hash: "h".to_string(),
2339 owner_id: "root".to_string(),
2340 content: None,
2341 },
2342 Action::DeleteArtifact {
2343 kind: ArtifactKind::FolderJpg,
2344 path: "creator/album/folder.jpg".to_string(),
2345 owner_id: "root-old".to_string(),
2346 },
2347 ];
2348 suppress_path_aliasing(&mut actions);
2349 assert!(
2350 !actions
2351 .iter()
2352 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
2353 );
2354 assert!(actions.contains(&Action::Skip {
2355 clip_id: "root-old".to_string()
2356 }));
2357 }
2358
2359 #[test]
2362 fn duplicate_trashed_does_not_defeat_copy_sibling() {
2363 let mut manifest = Manifest::new();
2366 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2367 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2368 copy_entry.modes = vec![SourceMode::Copy];
2369 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2370 trashed_entry.modes = vec![SourceMode::Mirror];
2371 trashed_entry.trashed = true;
2372 let plan = reconcile(
2373 &manifest,
2374 &[copy_entry, trashed_entry],
2375 &local_present("a"),
2376 &mirror_ok(),
2377 );
2378 assert_eq!(plan.deletes(), 0);
2379 assert_eq!(plan.skips(), 1);
2380 }
2381
2382 #[test]
2383 fn duplicate_trashed_does_not_defeat_private_sibling() {
2384 let mut manifest = Manifest::new();
2385 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2386 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2387 private_entry.private = true;
2388 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2389 trashed_entry.trashed = true;
2390 let plan = reconcile(
2391 &manifest,
2392 &[private_entry, trashed_entry],
2393 &local_present("a"),
2394 &mirror_ok(),
2395 );
2396 assert_eq!(plan.deletes(), 0);
2397 assert_eq!(plan.skips(), 1);
2398 }
2399
2400 #[test]
2401 fn duplicate_trashed_deletes_only_when_all_trashed() {
2402 let mut manifest = Manifest::new();
2404 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2405 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2406 first.trashed = true;
2407 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2408 second.trashed = true;
2409 let plan = reconcile(
2410 &manifest,
2411 &[first, second],
2412 &local_present("a"),
2413 &mirror_ok(),
2414 );
2415 assert_eq!(plan.deletes(), 1);
2416 }
2417
2418 #[test]
2419 fn duplicate_desired_unions_modes() {
2420 let mut manifest = Manifest::new();
2422 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2423 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2424 mirror_entry.modes = vec![SourceMode::Mirror];
2425 mirror_entry.trashed = true;
2426 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2427 copy_entry.modes = vec![SourceMode::Copy];
2428 let plan = reconcile(
2429 &manifest,
2430 &[mirror_entry, copy_entry],
2431 &local_present("a"),
2432 &mirror_ok(),
2433 );
2434 assert_eq!(plan.deletes(), 0);
2436 }
2437
2438 #[test]
2441 fn private_new_clip_downloads() {
2442 let manifest = Manifest::new();
2445 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2446 d.private = true;
2447 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2448 assert_eq!(plan.downloads(), 1);
2449 }
2450
2451 #[test]
2452 fn private_zero_length_file_redownloads() {
2453 let mut manifest = Manifest::new();
2454 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2455 let local: HashMap<String, LocalFile> = [(
2456 "a".to_string(),
2457 LocalFile {
2458 exists: true,
2459 size: 0,
2460 },
2461 )]
2462 .into_iter()
2463 .collect();
2464 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2465 d.private = true;
2466 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2467 assert_eq!(plan.downloads(), 1);
2468 }
2469
2470 #[test]
2471 fn private_meta_change_retags() {
2472 let mut manifest = Manifest::new();
2473 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2474 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2475 d.private = true;
2476 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2477 assert_eq!(plan.retags(), 1);
2478 assert_eq!(plan.deletes(), 0);
2479 }
2480
2481 #[test]
2482 fn absent_private_clip_protected_by_preserve_marker() {
2483 let mut manifest = Manifest::new();
2486 manifest.insert(
2487 "a",
2488 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2489 );
2490 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2491 assert_eq!(plan.deletes(), 0);
2492 assert_eq!(plan.skips(), 1);
2493 }
2494
2495 #[test]
2498 fn output_is_deterministic_regardless_of_input_order() {
2499 let mut manifest = Manifest::new();
2500 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2501 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2502 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2503 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2504 .iter()
2505 .map(|id| (id.to_string(), present(100)))
2506 .collect();
2507
2508 let forward = vec![
2509 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2510 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2511 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2512 ];
2513 let mut reversed = forward.clone();
2514 reversed.reverse();
2515
2516 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2517 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2518 assert_eq!(p1.actions, p2.actions);
2519
2520 let ids: Vec<&str> = p1
2523 .actions
2524 .iter()
2525 .map(|a| match a {
2526 Action::Skip { clip_id } => clip_id.as_str(),
2527 Action::Retag { clip, .. } => clip.id.as_str(),
2528 Action::Download { clip, .. } => clip.id.as_str(),
2529 Action::Delete { clip_id, .. } => clip_id.as_str(),
2530 Action::Reformat { clip, .. } => clip.id.as_str(),
2531 Action::Rename { to, .. } => to.as_str(),
2532 Action::WriteArtifact { owner_id, .. }
2533 | Action::DeleteArtifact { owner_id, .. } => owner_id.as_str(),
2534 Action::WriteStem { clip_id, .. } | Action::DeleteStem { clip_id, .. } => {
2535 clip_id.as_str()
2536 }
2537 })
2538 .collect();
2539 assert_eq!(ids, ["a", "b", "c", "z"]);
2540 }
2541
2542 #[test]
2543 fn empty_inputs_do_not_panic() {
2544 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2545 assert!(plan.is_empty());
2546 assert_eq!(plan.len(), 0);
2547 }
2548
2549 #[test]
2550 fn empty_desired_with_full_manifest_deletes_all() {
2551 let mut manifest = Manifest::new();
2552 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2553 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2554 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2555 assert_eq!(plan.deletes(), 2);
2556 }
2557
2558 #[test]
2559 fn full_desired_with_empty_manifest_downloads_all() {
2560 let d = vec![
2561 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2562 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2563 ];
2564 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2565 assert_eq!(plan.downloads(), 2);
2566 }
2567
2568 #[test]
2569 fn plan_counts_sum_to_len() {
2570 let mut manifest = Manifest::new();
2571 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2572 manifest.insert(
2573 "retag",
2574 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2575 );
2576 manifest.insert(
2577 "reformat",
2578 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2579 );
2580 manifest.insert(
2581 "rename",
2582 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2583 );
2584 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2585 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2586 .iter()
2587 .map(|id| (id.to_string(), present(100)))
2588 .collect();
2589 let d = vec![
2590 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2591 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2592 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2593 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2594 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2595 ];
2596 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2597 let summed = plan.downloads()
2598 + plan.reformats()
2599 + plan.retags()
2600 + plan.renames()
2601 + plan.deletes()
2602 + plan.skips();
2603 assert_eq!(summed, plan.len());
2604 assert_eq!(plan.downloads(), 1);
2605 assert_eq!(plan.reformats(), 1);
2606 assert_eq!(plan.retags(), 1);
2607 assert_eq!(plan.renames(), 1);
2608 assert_eq!(plan.deletes(), 1);
2609 assert_eq!(plan.skips(), 1);
2610 }
2611
2612 fn cover(path: &str, hash: &str) -> ArtifactState {
2615 ArtifactState {
2616 path: path.to_string(),
2617 hash: hash.to_string(),
2618 }
2619 }
2620
2621 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2622 DesiredArtifact {
2623 kind,
2624 path: path.to_string(),
2625 source_url: url.to_string(),
2626 hash: hash.to_string(),
2627 content: None,
2628 }
2629 }
2630
2631 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2633 DesiredArtifact {
2634 kind,
2635 path: path.to_string(),
2636 source_url: String::new(),
2637 hash: content_hash(body),
2638 content: Some(body.to_string()),
2639 }
2640 }
2641
2642 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2644 Desired {
2645 artifacts: arts,
2646 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2647 }
2648 }
2649
2650 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2652 ManifestEntry {
2653 cover_jpg: Some(cover(cover_path, cover_hash)),
2654 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2655 }
2656 }
2657
2658 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2659 plan.actions
2660 .iter()
2661 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2662 .collect()
2663 }
2664
2665 #[test]
2666 fn write_artifact_emitted_when_manifest_lacks_it() {
2667 let mut manifest = Manifest::new();
2670 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2671 let d = vec![desired_arts(
2672 "a",
2673 vec![art(
2674 ArtifactKind::CoverJpg,
2675 "a/cover.jpg",
2676 "https://art/a",
2677 "h1",
2678 )],
2679 )];
2680 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2681 assert_eq!(plan.artifact_writes(), 1);
2682 assert_eq!(plan.artifact_deletes(), 0);
2683 assert_eq!(plan.skips(), 1);
2684 assert_eq!(
2685 write_artifacts(&plan)[0],
2686 &Action::WriteArtifact {
2687 kind: ArtifactKind::CoverJpg,
2688 path: "a/cover.jpg".to_string(),
2689 source_url: "https://art/a".to_string(),
2690 hash: "h1".to_string(),
2691 owner_id: "a".to_string(),
2692 content: None,
2693 }
2694 );
2695 }
2696
2697 #[test]
2698 fn write_artifact_emitted_when_hash_differs() {
2699 let mut manifest = Manifest::new();
2702 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2703 let d = vec![desired_arts(
2704 "a",
2705 vec![art(
2706 ArtifactKind::CoverJpg,
2707 "a/cover.jpg",
2708 "https://art/a",
2709 "new",
2710 )],
2711 )];
2712 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2713 assert_eq!(plan.artifact_writes(), 1);
2714 assert_eq!(plan.artifact_deletes(), 0);
2715 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2716 assert_eq!(hash, "new");
2717 } else {
2718 panic!("expected a WriteArtifact");
2719 }
2720 }
2721
2722 #[test]
2723 fn write_artifact_skipped_when_hash_matches() {
2724 let mut manifest = Manifest::new();
2726 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2727 let d = vec![desired_arts(
2728 "a",
2729 vec![art(
2730 ArtifactKind::CoverJpg,
2731 "a/cover.jpg",
2732 "https://art/a",
2733 "h1",
2734 )],
2735 )];
2736 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2737 assert_eq!(plan.artifact_writes(), 0);
2738 assert_eq!(plan.artifact_deletes(), 0);
2739 assert_eq!(
2740 plan.actions,
2741 vec![Action::Skip {
2742 clip_id: "a".to_string()
2743 }]
2744 );
2745 }
2746
2747 #[test]
2748 fn removed_kind_cover_is_kept_not_deleted() {
2749 let mut manifest = Manifest::new();
2754 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2755 let d = vec![desired_arts("a", vec![])];
2756 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2757 assert_eq!(plan.artifact_deletes(), 0);
2758 assert_eq!(plan.artifact_writes(), 0);
2759 assert_eq!(plan.deletes(), 0);
2761 assert_eq!(
2762 plan.actions,
2763 vec![Action::Skip {
2764 clip_id: "a".to_string()
2765 }]
2766 );
2767 assert!(!plan.actions.iter().any(|a| matches!(
2768 a,
2769 Action::DeleteArtifact {
2770 kind: ArtifactKind::CoverJpg,
2771 ..
2772 }
2773 )));
2774 }
2775
2776 #[test]
2777 fn delete_artifact_never_on_incomplete_listing() {
2778 let mut manifest = Manifest::new();
2783 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2784 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2785 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2786 let sources = vec![SourceStatus {
2787 mode: SourceMode::Mirror,
2788 fully_enumerated: false,
2789 }];
2790 let local: HashMap<String, LocalFile> = [
2791 ("a".to_string(), present(100)),
2792 ("b".to_string(), present(100)),
2793 ]
2794 .into_iter()
2795 .collect();
2796 let plan = reconcile(&manifest, &d, &local, &sources);
2797 assert_eq!(plan.artifact_deletes(), 0);
2798 assert_eq!(plan.deletes(), 0);
2799 }
2800
2801 #[test]
2802 fn delete_artifact_never_when_entry_preserved() {
2803 let mut manifest = Manifest::new();
2806 let preserved = ManifestEntry {
2807 preserve: true,
2808 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2809 };
2810 manifest.insert("a", preserved);
2811 let d = vec![desired_arts("a", vec![])];
2812 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2813 assert_eq!(plan.artifact_deletes(), 0);
2814 }
2815
2816 #[test]
2817 fn co_delete_never_when_path_empty() {
2818 let mut manifest = Manifest::new();
2822 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2823 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2824 assert_eq!(plan.deletes(), 1);
2825 assert_eq!(plan.artifact_deletes(), 0);
2826 }
2827
2828 #[test]
2829 fn co_delete_absent_clip_deletes_audio_and_cover() {
2830 let mut manifest = Manifest::new();
2833 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2834 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2835 assert_eq!(plan.deletes(), 1);
2836 assert_eq!(plan.artifact_deletes(), 1);
2837 assert!(plan.actions.contains(&Action::Delete {
2838 path: "gone.flac".to_string(),
2839 clip_id: "gone".to_string(),
2840 }));
2841 assert!(plan.actions.contains(&Action::DeleteArtifact {
2842 kind: ArtifactKind::CoverJpg,
2843 path: "gone/cover.jpg".to_string(),
2844 owner_id: "gone".to_string(),
2845 }));
2846 }
2847
2848 #[test]
2849 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2850 let mut manifest = Manifest::new();
2852 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2853 let sources = vec![SourceStatus {
2854 mode: SourceMode::Mirror,
2855 fully_enumerated: false,
2856 }];
2857 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2858 assert_eq!(plan.deletes(), 0);
2859 assert_eq!(plan.artifact_deletes(), 0);
2860 }
2861
2862 #[test]
2863 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2864 let mut manifest = Manifest::new();
2866 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2867 let mut d = desired_arts("a", vec![]);
2868 d.trashed = true;
2869 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2870 assert_eq!(plan.deletes(), 1);
2871 assert_eq!(plan.artifact_deletes(), 1);
2872 }
2873
2874 #[test]
2875 fn co_delete_trashed_suppressed_when_not_enumerated() {
2876 let mut manifest = Manifest::new();
2878 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2879 let mut d = desired_arts("a", vec![]);
2880 d.trashed = true;
2881 let sources = vec![SourceStatus {
2882 mode: SourceMode::Mirror,
2883 fully_enumerated: false,
2884 }];
2885 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2886 assert_eq!(plan.deletes(), 0);
2887 assert_eq!(plan.artifact_deletes(), 0);
2888 assert_eq!(plan.skips(), 1);
2889 }
2890
2891 #[test]
2892 fn co_delete_trashed_suppressed_when_preserved() {
2893 let mut manifest = Manifest::new();
2895 let preserved = ManifestEntry {
2896 preserve: true,
2897 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2898 };
2899 manifest.insert("a", preserved);
2900 let mut d = desired_arts("a", vec![]);
2901 d.trashed = true;
2902 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2903 assert_eq!(plan.deletes(), 0);
2904 assert_eq!(plan.artifact_deletes(), 0);
2905 }
2906
2907 #[test]
2910 fn details_sidecar_written_with_inline_content_when_slot_absent() {
2911 let mut manifest = Manifest::new();
2914 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2915 let d = vec![desired_arts(
2916 "a",
2917 vec![text_art(
2918 ArtifactKind::DetailsTxt,
2919 "a.details.txt",
2920 "Title: A\n",
2921 )],
2922 )];
2923 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2924 assert_eq!(plan.artifact_writes(), 1);
2925 assert_eq!(plan.artifact_deletes(), 0);
2926 assert_eq!(
2927 write_artifacts(&plan)[0],
2928 &Action::WriteArtifact {
2929 kind: ArtifactKind::DetailsTxt,
2930 path: "a.details.txt".to_string(),
2931 source_url: String::new(),
2932 hash: content_hash("Title: A\n"),
2933 owner_id: "a".to_string(),
2934 content: Some("Title: A\n".to_string()),
2935 }
2936 );
2937 }
2938
2939 #[test]
2940 fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
2941 let mut manifest = Manifest::new();
2946 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2947 let body = "[re:rs-suno]\nla la\n";
2948 let d = vec![desired_arts(
2949 "a",
2950 vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
2951 )];
2952 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2953 assert_eq!(plan.artifact_writes(), 1);
2954 assert_eq!(plan.artifact_deletes(), 0);
2955 assert_eq!(
2956 write_artifacts(&plan)[0],
2957 &Action::WriteArtifact {
2958 kind: ArtifactKind::Lrc,
2959 path: "a.lrc".to_string(),
2960 source_url: String::new(),
2961 hash: content_hash(body),
2962 owner_id: "a".to_string(),
2963 content: Some(body.to_string()),
2964 }
2965 );
2966 }
2967
2968 #[test]
2969 fn text_sidecars_skipped_when_hash_and_path_match() {
2970 let mut manifest = Manifest::new();
2972 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2973 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
2974 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
2975 manifest.insert("a", e);
2976 let d = vec![desired_arts(
2977 "a",
2978 vec![
2979 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
2980 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
2981 ],
2982 )];
2983 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2984 assert_eq!(plan.artifact_writes(), 0);
2985 assert_eq!(plan.artifact_deletes(), 0);
2986 }
2987
2988 #[test]
2989 fn details_rewritten_when_content_hash_differs() {
2990 let mut manifest = Manifest::new();
2993 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
2994 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
2995 manifest.insert("a", e);
2996 let d = vec![desired_arts(
2997 "a",
2998 vec![text_art(
2999 ArtifactKind::DetailsTxt,
3000 "a.details.txt",
3001 "Title: New\n",
3002 )],
3003 )];
3004 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3005 assert_eq!(plan.artifact_writes(), 1);
3006 assert_eq!(plan.artifact_deletes(), 0);
3007 }
3008
3009 #[test]
3010 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
3011 let mut manifest = Manifest::new();
3015 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3016 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
3017 manifest.insert("a", e);
3018 let d = vec![desired_arts(
3019 "a",
3020 vec![text_art(
3021 ArtifactKind::LyricsTxt,
3022 "a.lyrics.txt",
3023 "new words\n",
3024 )],
3025 )];
3026 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3027 assert_eq!(plan.artifact_writes(), 1);
3029 assert_eq!(plan.retags(), 0);
3030 }
3031
3032 #[test]
3033 fn text_sidecar_relocated_when_path_differs() {
3034 let mut manifest = Manifest::new();
3037 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3038 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
3039 manifest.insert("a", e);
3040 let d = vec![desired_arts(
3041 "a",
3042 vec![text_art(
3043 ArtifactKind::DetailsTxt,
3044 "new/a.details.txt",
3045 "Title: A\n",
3046 )],
3047 )];
3048 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3049 assert_eq!(plan.artifact_writes(), 1);
3050 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3051 assert_eq!(path, "new/a.details.txt");
3052 } else {
3053 panic!("expected a WriteArtifact");
3054 }
3055 }
3056
3057 #[test]
3058 fn details_removed_kind_is_deleted_when_feature_off() {
3059 let mut manifest = Manifest::new();
3062 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3063 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3064 manifest.insert("a", e);
3065 let d = vec![desired_arts("a", vec![])];
3066 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3067 assert_eq!(plan.artifact_deletes(), 1);
3068 assert!(plan.actions.contains(&Action::DeleteArtifact {
3069 kind: ArtifactKind::DetailsTxt,
3070 path: "a.details.txt".to_string(),
3071 owner_id: "a".to_string(),
3072 }));
3073 }
3074
3075 #[test]
3076 fn lyrics_removed_kind_is_kept_not_deleted() {
3077 let mut manifest = Manifest::new();
3081 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3082 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3083 manifest.insert("a", e);
3084 let d = vec![desired_arts("a", vec![])];
3085 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3086 assert_eq!(plan.artifact_deletes(), 0);
3087 assert_eq!(plan.deletes(), 0);
3088 }
3089
3090 #[test]
3091 fn lrc_removed_kind_is_kept_not_deleted() {
3092 let mut manifest = Manifest::new();
3095 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3096 e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3097 manifest.insert("a", e);
3098 let d = vec![desired_arts("a", vec![])];
3099 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3100 assert_eq!(plan.artifact_deletes(), 0);
3101 assert_eq!(plan.deletes(), 0);
3102 }
3103
3104 #[test]
3105 fn video_mp4_removed_kind_is_kept_not_deleted() {
3106 let mut manifest = Manifest::new();
3110 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3111 e.video_mp4 = Some(cover("a.mp4", "vid-hash"));
3112 manifest.insert("a", e);
3113 let d = vec![desired_arts("a", vec![])];
3114 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3115 assert_eq!(plan.artifact_deletes(), 0);
3116 assert_eq!(plan.deletes(), 0);
3117 }
3118
3119 #[test]
3120 fn video_mp4_written_when_manifest_lacks_it() {
3121 let mut manifest = Manifest::new();
3124 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3125 let d = vec![desired_arts(
3126 "a",
3127 vec![art(
3128 ArtifactKind::VideoMp4,
3129 "a/song.mp4",
3130 "https://cdn/a/video.mp4",
3131 "vid-hash",
3132 )],
3133 )];
3134 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3135 assert_eq!(plan.artifact_writes(), 1);
3136 assert_eq!(
3137 write_artifacts(&plan)[0],
3138 &Action::WriteArtifact {
3139 kind: ArtifactKind::VideoMp4,
3140 path: "a/song.mp4".to_string(),
3141 source_url: "https://cdn/a/video.mp4".to_string(),
3142 hash: "vid-hash".to_string(),
3143 owner_id: "a".to_string(),
3144 content: None,
3145 }
3146 );
3147 }
3148
3149 #[test]
3150 fn details_removed_kind_not_deleted_on_incomplete_listing() {
3151 let mut manifest = Manifest::new();
3154 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3155 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3156 manifest.insert("a", e);
3157 let d = vec![desired_arts("a", vec![])];
3158 let sources = vec![SourceStatus {
3159 mode: SourceMode::Mirror,
3160 fully_enumerated: false,
3161 }];
3162 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
3163 assert_eq!(plan.artifact_deletes(), 0);
3164 }
3165
3166 #[test]
3167 fn details_removed_kind_not_deleted_when_preserved() {
3168 let mut manifest = Manifest::new();
3171 let mut e = ManifestEntry {
3172 preserve: true,
3173 ..entry("a.flac", AudioFormat::Flac, "m", "art")
3174 };
3175 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3176 manifest.insert("a", e);
3177 let d = vec![desired_arts("a", vec![])];
3178 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3179 assert_eq!(plan.artifact_deletes(), 0);
3180 }
3181
3182 #[test]
3183 fn co_delete_orphan_removes_every_text_sidecar() {
3184 let mut manifest = Manifest::new();
3188 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
3189 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
3190 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
3191 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
3192 e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3193 e.video_mp4 = Some(cover("gone/song.mp4", "vid-hash"));
3194 manifest.insert("gone", e);
3195 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3196 assert_eq!(plan.deletes(), 1);
3197 assert_eq!(plan.artifact_deletes(), 5);
3198 for (kind, path) in [
3199 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
3200 (ArtifactKind::DetailsTxt, "gone.details.txt"),
3201 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
3202 (ArtifactKind::Lrc, "gone.lrc"),
3203 (ArtifactKind::VideoMp4, "gone/song.mp4"),
3204 ] {
3205 assert!(
3206 plan.actions.contains(&Action::DeleteArtifact {
3207 kind,
3208 path: path.to_string(),
3209 owner_id: "gone".to_string(),
3210 }),
3211 "missing co-delete for {kind:?}"
3212 );
3213 }
3214 }
3215
3216 #[test]
3217 fn co_delete_trashed_removes_every_text_sidecar() {
3218 let mut manifest = Manifest::new();
3220 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3221 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3222 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3223 manifest.insert("a", e);
3224 let mut d = desired_arts("a", vec![]);
3225 d.trashed = true;
3226 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3227 assert_eq!(plan.deletes(), 1);
3228 assert_eq!(plan.artifact_deletes(), 2);
3229 }
3230
3231 #[test]
3232 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
3233 let mut manifest = Manifest::new();
3236 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3237 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
3238 let d = vec![desired_arts(
3241 "a",
3242 vec![art(
3243 ArtifactKind::CoverJpg,
3244 "shared/cover.jpg",
3245 "https://art/a",
3246 "h2",
3247 )],
3248 )];
3249 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3250 assert_eq!(plan.artifact_writes(), 1);
3251 assert!(!plan.actions.iter().any(
3253 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
3254 ));
3255 assert!(plan.actions.contains(&Action::Delete {
3257 path: "b.flac".to_string(),
3258 clip_id: "b".to_string(),
3259 }));
3260 }
3261
3262 #[test]
3263 fn suppress_downgrades_delete_artifact_colliding_with_download() {
3264 let mut manifest = Manifest::new();
3266 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
3267 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
3268 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
3269 assert_eq!(plan.downloads(), 1);
3270 assert!(
3271 !plan
3272 .actions
3273 .iter()
3274 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
3275 );
3276 }
3277
3278 #[test]
3279 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
3280 let build = |with_art: bool| {
3284 let mut manifest = Manifest::new();
3285 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
3286 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3287 manifest.insert(
3288 "trash",
3289 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
3290 );
3291 let keep = if with_art {
3292 desired_arts(
3293 "keep",
3294 vec![art(
3295 ArtifactKind::CoverJpg,
3296 "keep/cover.jpg",
3297 "https://art/keep",
3298 "h1",
3299 )],
3300 )
3301 } else {
3302 desired_arts("keep", vec![])
3303 };
3304 let mut trash = desired_arts("trash", vec![]);
3305 trash.trashed = true;
3306 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
3307 .iter()
3308 .map(|id| (id.to_string(), present(100)))
3309 .collect();
3310 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
3311 };
3312
3313 let with = build(true);
3314 let without = build(false);
3315
3316 let audio = |plan: &Plan| -> Vec<Action> {
3318 plan.actions
3319 .iter()
3320 .filter(|a| {
3321 !matches!(
3322 a,
3323 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
3324 )
3325 })
3326 .cloned()
3327 .collect()
3328 };
3329 assert_eq!(audio(&with), audio(&without));
3330 assert_eq!(with.deletes(), without.deletes());
3331 assert_eq!(with.deletes(), 2);
3333 assert_eq!(with.artifact_deletes(), 2);
3337 assert_eq!(with.artifact_writes(), 0);
3338 }
3339
3340 #[test]
3343 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
3344 let mut manifest = Manifest::new();
3350 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3351 assert!(!manifest.get("a").unwrap().preserve);
3352
3353 let private = Desired {
3355 private: true,
3356 ..desired_arts("a", vec![])
3357 };
3358 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
3359 assert_eq!(plan.artifact_deletes(), 0);
3360
3361 let copy_held = Desired {
3363 modes: vec![SourceMode::Copy],
3364 ..desired_arts("a", vec![])
3365 };
3366 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
3367 assert_eq!(plan.artifact_deletes(), 0);
3368 }
3369
3370 #[test]
3371 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
3372 let mut manifest = Manifest::new();
3378 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
3379 let d = vec![desired_arts(
3380 "a",
3381 vec![art(
3382 ArtifactKind::CoverJpg,
3383 "new/cover.jpg",
3384 "https://art/a",
3385 "h1",
3386 )],
3387 )];
3388 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3389 assert_eq!(plan.artifact_writes(), 1);
3390 assert_eq!(plan.artifact_deletes(), 0);
3391 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3392 assert_eq!(path, "new/cover.jpg");
3393 } else {
3394 panic!("expected a WriteArtifact");
3395 }
3396 }
3397
3398 #[test]
3399 fn per_clip_reconcile_ignores_album_and_library_kinds() {
3400 let mut manifest = Manifest::new();
3404 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3405 let d = vec![desired_arts(
3406 "a",
3407 vec![
3408 art(
3409 ArtifactKind::FolderJpg,
3410 "a/folder.jpg",
3411 "https://art/folder",
3412 "hf",
3413 ),
3414 art(
3415 ArtifactKind::Playlist,
3416 "a/list.m3u",
3417 "https://art/list",
3418 "hp",
3419 ),
3420 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
3421 ],
3422 )];
3423 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3424 assert_eq!(plan.artifact_writes(), 1);
3425 let paths: Vec<&str> = plan
3426 .actions
3427 .iter()
3428 .filter_map(|a| match a {
3429 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3430 _ => None,
3431 })
3432 .collect();
3433 assert_eq!(paths, vec!["a/cover.jpg"]);
3434 }
3435
3436 #[test]
3437 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3438 let mut manifest = Manifest::new();
3439 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3440 let d = vec![desired_arts(
3441 "a",
3442 vec![art(
3443 ArtifactKind::FolderWebp,
3444 "a/folder.webp",
3445 "https://art/folder",
3446 "hf",
3447 )],
3448 )];
3449 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3450 assert_eq!(plan.artifact_writes(), 0);
3451 assert_eq!(plan.artifact_deletes(), 0);
3452 }
3453
3454 fn local_with_missing(audio_id: &str, missing_path: &str) -> HashMap<String, LocalFile> {
3458 let mut m = local_present(audio_id);
3459 m.insert(missing_path.to_owned(), LocalFile::default());
3460 m
3461 }
3462
3463 fn local_with_present_artifact(
3465 audio_id: &str,
3466 artifact_path: &str,
3467 ) -> HashMap<String, LocalFile> {
3468 let mut m = local_present(audio_id);
3469 m.insert(artifact_path.to_owned(), present(50));
3470 m
3471 }
3472
3473 #[test]
3474 fn sidecar_missing_on_disk_forces_rewrite() {
3475 let mut manifest = Manifest::new();
3479 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3480 let d = vec![desired_arts(
3481 "a",
3482 vec![art(
3483 ArtifactKind::CoverJpg,
3484 "a/cover.jpg",
3485 "https://art/a",
3486 "h1",
3487 )],
3488 )];
3489 let local = local_with_missing("a", "a/cover.jpg");
3490 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3491 assert_eq!(
3492 plan.artifact_writes(),
3493 1,
3494 "missing sidecar must be rewritten"
3495 );
3496 assert_eq!(plan.artifact_deletes(), 0);
3497 }
3498
3499 #[test]
3500 fn sidecar_present_on_disk_with_matching_hash_no_churn() {
3501 let mut manifest = Manifest::new();
3503 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3504 let d = vec![desired_arts(
3505 "a",
3506 vec![art(
3507 ArtifactKind::CoverJpg,
3508 "a/cover.jpg",
3509 "https://art/a",
3510 "h1",
3511 )],
3512 )];
3513 let local = local_with_present_artifact("a", "a/cover.jpg");
3514 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3515 assert_eq!(plan.artifact_writes(), 0, "present sidecar must not churn");
3516 assert_eq!(plan.artifact_deletes(), 0);
3517 }
3518
3519 #[test]
3520 fn sidecar_probe_absent_falls_back_to_hash_comparison_no_write() {
3521 let mut manifest = Manifest::new();
3525 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3526 let d = vec![desired_arts(
3527 "a",
3528 vec![art(
3529 ArtifactKind::CoverJpg,
3530 "a/cover.jpg",
3531 "https://art/a",
3532 "h1",
3533 )],
3534 )];
3535 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3537 assert_eq!(
3538 plan.artifact_writes(),
3539 0,
3540 "no write when probe unavailable and hash matches"
3541 );
3542 assert_eq!(
3543 plan.artifact_deletes(),
3544 0,
3545 "missing probe must never trigger a delete"
3546 );
3547 }
3548
3549 #[test]
3550 fn folder_art_missing_on_disk_forces_rewrite() {
3551 let members = vec![album_member(
3554 album_clip("a", 1, "t0", "art-a", ""),
3555 "root",
3556 "c/al/a.flac",
3557 )];
3558 let desired = album_desired(&members, false, false);
3559 let mut albums = BTreeMap::new();
3560 albums.insert(
3561 "root".to_string(),
3562 AlbumArt {
3563 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3564 folder_webp: None,
3565 folder_mp4: None,
3566 },
3567 );
3568 let mut local: HashMap<String, LocalFile> = HashMap::new();
3569 local.insert("c/al/folder.jpg".to_owned(), LocalFile::default());
3570 let actions = plan_album_artifacts(&desired, &albums, true, &local);
3571 assert_eq!(actions.len(), 1, "missing folder art must be rewritten");
3572 assert!(matches!(
3573 &actions[0],
3574 Action::WriteArtifact {
3575 kind: ArtifactKind::FolderJpg,
3576 ..
3577 }
3578 ));
3579 }
3580
3581 #[test]
3582 fn folder_art_present_on_disk_no_churn() {
3583 let members = vec![album_member(
3585 album_clip("a", 1, "t0", "art-a", ""),
3586 "root",
3587 "c/al/a.flac",
3588 )];
3589 let desired = album_desired(&members, false, false);
3590 let mut albums = BTreeMap::new();
3591 albums.insert(
3592 "root".to_string(),
3593 AlbumArt {
3594 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3595 folder_webp: None,
3596 folder_mp4: None,
3597 },
3598 );
3599 let mut local: HashMap<String, LocalFile> = HashMap::new();
3600 local.insert("c/al/folder.jpg".to_owned(), present(5000));
3601 let actions = plan_album_artifacts(&desired, &albums, true, &local);
3602 assert!(
3603 actions.is_empty(),
3604 "present folder art with matching hash must not churn"
3605 );
3606 }
3607
3608 #[test]
3609 fn playlist_missing_on_disk_forces_rewrite() {
3610 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3613 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3614 let mut local: HashMap<String, LocalFile> = HashMap::new();
3615 local.insert("Mix.m3u8".to_owned(), LocalFile::default());
3616 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
3617 assert_eq!(actions.len(), 1, "missing playlist file must be rewritten");
3618 assert!(matches!(
3619 &actions[0],
3620 Action::WriteArtifact {
3621 kind: ArtifactKind::Playlist,
3622 ..
3623 }
3624 ));
3625 }
3626
3627 #[test]
3628 fn playlist_present_on_disk_no_churn() {
3629 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3631 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3632 let mut local: HashMap<String, LocalFile> = HashMap::new();
3633 local.insert("Mix.m3u8".to_owned(), present(200));
3634 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
3635 assert!(
3636 actions.is_empty(),
3637 "present playlist with matching hash must not churn"
3638 );
3639 }
3640
3641 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
3644 Clip {
3645 id: id.to_string(),
3646 title: "Song".to_string(),
3647 image_large_url: image.to_string(),
3648 video_cover_url: video.to_string(),
3649 play_count,
3650 created_at: created_at.to_string(),
3651 ..Default::default()
3652 }
3653 }
3654
3655 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
3656 let mut lineage = LineageContext::own_root(&clip);
3657 lineage.root_id = root_id.to_string();
3658 Desired {
3659 clip,
3660 lineage,
3661 path: path.to_string(),
3662 format: AudioFormat::Flac,
3663 meta_hash: "m".to_string(),
3664 art_hash: "a".to_string(),
3665 modes: vec![SourceMode::Mirror],
3666 trashed: false,
3667 private: false,
3668 artifacts: Vec::new(),
3669 stems: None,
3670 }
3671 }
3672
3673 fn stored(path: &str, hash: &str) -> ArtifactState {
3674 ArtifactState {
3675 path: path.to_string(),
3676 hash: hash.to_string(),
3677 }
3678 }
3679
3680 #[test]
3681 fn folder_jpg_source_is_most_played() {
3682 let members = vec![
3683 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
3684 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
3685 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
3686 ];
3687 let albums = album_desired(&members, false, false);
3688 assert_eq!(albums.len(), 1);
3689 let jpg = albums[0].folder_jpg.as_ref().unwrap();
3690 assert_eq!(jpg.hash, art_url_hash("art-b"));
3692 assert_eq!(jpg.source_url, "art-b");
3693 assert_eq!(jpg.path, "c/al/folder.jpg");
3694 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
3695 }
3696
3697 #[test]
3698 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
3699 let by_time = vec![
3701 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
3702 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
3703 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
3704 ];
3705 let jpg = album_desired(&by_time, false, false)[0]
3706 .folder_jpg
3707 .clone()
3708 .unwrap();
3709 assert_eq!(jpg.source_url, "art-y");
3710
3711 let by_id = vec![
3713 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
3714 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
3715 ];
3716 let jpg = album_desired(&by_id, false, false)[0]
3717 .folder_jpg
3718 .clone()
3719 .unwrap();
3720 assert_eq!(jpg.source_url, "art-g");
3721 }
3722
3723 #[test]
3724 fn folder_webp_source_is_first_created_animated() {
3725 let members = vec![
3726 album_member(
3727 album_clip("a", 9, "t2", "art-a", "vid-a"),
3728 "root",
3729 "c/al/a.flac",
3730 ),
3731 album_member(
3732 album_clip("b", 1, "t0", "art-b", "vid-b"),
3733 "root",
3734 "c/al/b.flac",
3735 ),
3736 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
3737 ];
3738 let webp = album_desired(&members, true, false)[0]
3739 .folder_webp
3740 .clone()
3741 .unwrap();
3742 assert_eq!(webp.source_url, "vid-b");
3744 assert_eq!(webp.hash, art_url_hash("vid-b"));
3745 assert_eq!(webp.path, "c/al/cover.webp");
3746 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
3747 }
3748
3749 #[test]
3750 fn animated_covers_off_yields_no_folder_webp() {
3751 let members = vec![album_member(
3752 album_clip("a", 1, "t0", "art-a", "vid-a"),
3753 "root",
3754 "c/al/a.flac",
3755 )];
3756 let off = album_desired(&members, false, false);
3757 assert!(off[0].folder_webp.is_none());
3758 let on = album_desired(&members, true, false);
3759 assert!(on[0].folder_webp.is_some());
3760 }
3761
3762 #[test]
3763 fn raw_cover_yields_folder_mp4_from_the_webp_source_verbatim() {
3764 let members = vec![
3765 album_member(
3766 album_clip("a", 9, "t2", "art-a", "vid-a"),
3767 "root",
3768 "c/al/a.flac",
3769 ),
3770 album_member(
3771 album_clip("b", 1, "t0", "art-b", "vid-b"),
3772 "root",
3773 "c/al/b.flac",
3774 ),
3775 ];
3776 let album = album_desired(&members, true, true).remove(0);
3780 let webp = album.folder_webp.unwrap();
3781 let mp4 = album.folder_mp4.unwrap();
3782 assert_eq!(mp4.kind, ArtifactKind::FolderMp4);
3783 assert_eq!(mp4.path, "c/al/cover.mp4");
3784 assert_eq!(mp4.source_url, "vid-b");
3785 assert_eq!(mp4.hash, art_url_hash("vid-b"));
3786 assert_eq!(mp4.source_url, webp.source_url, "same variant feeds both");
3787 }
3788
3789 #[test]
3790 fn raw_cover_and_webp_are_independent_toggles() {
3791 let members = vec![album_member(
3792 album_clip("a", 1, "t0", "art-a", "vid-a"),
3793 "root",
3794 "c/al/a.flac",
3795 )];
3796 let webp_only = album_desired(&members, true, false).remove(0);
3798 assert!(webp_only.folder_webp.is_some());
3799 assert!(webp_only.folder_mp4.is_none());
3800 let mp4_only = album_desired(&members, false, true).remove(0);
3802 assert!(mp4_only.folder_webp.is_none());
3803 assert!(mp4_only.folder_mp4.is_some());
3804 }
3805
3806 #[test]
3807 fn raw_cover_needs_an_animated_source() {
3808 let members = vec![album_member(
3810 album_clip("a", 3, "t0", "art-a", ""),
3811 "root",
3812 "c/al/a.flac",
3813 )];
3814 let album = album_desired(&members, true, true).remove(0);
3815 assert!(album.folder_mp4.is_none());
3816 assert!(album.folder_webp.is_none());
3817 }
3818
3819 #[test]
3820 fn album_with_no_art_yields_no_folder_jpg() {
3821 let members = vec![album_member(
3822 album_clip("a", 3, "t0", "", ""),
3823 "root",
3824 "c/al/a.flac",
3825 )];
3826 let albums = album_desired(&members, true, false);
3827 assert!(albums[0].folder_jpg.is_none());
3828 assert!(albums[0].folder_webp.is_none());
3829 }
3830
3831 #[test]
3832 fn album_desired_groups_by_root_id() {
3833 let members = vec![
3834 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
3835 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
3836 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
3837 ];
3838 let albums = album_desired(&members, false, false);
3839 assert_eq!(albums.len(), 2);
3840 assert_eq!(albums[0].root_id, "r1");
3841 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
3842 assert_eq!(
3843 albums[0].folder_jpg.as_ref().unwrap().path,
3844 "c/al1/folder.jpg"
3845 );
3846 assert_eq!(albums[1].root_id, "r2");
3847 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
3848 assert_eq!(
3849 albums[1].folder_jpg.as_ref().unwrap().path,
3850 "c/al2/folder.jpg"
3851 );
3852 }
3853
3854 #[test]
3855 fn plan_writes_folder_art_when_store_empty() {
3856 let members = vec![album_member(
3857 album_clip("a", 1, "t0", "art-a", "vid-a"),
3858 "root",
3859 "c/al/a.flac",
3860 )];
3861 let desired = album_desired(&members, true, false);
3862 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
3863 assert_eq!(
3864 actions,
3865 vec![
3866 Action::WriteArtifact {
3867 kind: ArtifactKind::FolderJpg,
3868 path: "c/al/folder.jpg".to_string(),
3869 source_url: "art-a".to_string(),
3870 hash: art_url_hash("art-a"),
3871 owner_id: "root".to_string(),
3872 content: None,
3873 },
3874 Action::WriteArtifact {
3875 kind: ArtifactKind::FolderWebp,
3876 path: "c/al/cover.webp".to_string(),
3877 source_url: "vid-a".to_string(),
3878 hash: art_url_hash("vid-a"),
3879 owner_id: "root".to_string(),
3880 content: None,
3881 },
3882 ]
3883 );
3884 }
3885
3886 #[test]
3887 fn plan_skips_when_hash_and_path_match() {
3888 let members = vec![album_member(
3889 album_clip("a", 1, "t0", "art-a", ""),
3890 "root",
3891 "c/al/a.flac",
3892 )];
3893 let desired = album_desired(&members, false, false);
3894 let mut albums = BTreeMap::new();
3895 albums.insert(
3896 "root".to_string(),
3897 AlbumArt {
3898 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3899 folder_webp: None,
3900 folder_mp4: None,
3901 },
3902 );
3903 assert!(plan_album_artifacts(&desired, &albums, true, &HashMap::new()).is_empty());
3904 }
3905
3906 #[test]
3907 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
3908 let members = vec![album_member(
3909 album_clip("a", 1, "t0", "art-a", ""),
3910 "root",
3911 "c/al/a.flac",
3912 )];
3913 let desired = album_desired(&members, false, false);
3914 let mut albums = BTreeMap::new();
3915 albums.insert(
3916 "root".to_string(),
3917 AlbumArt {
3918 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
3919 folder_webp: None,
3920 folder_mp4: None,
3921 },
3922 );
3923 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
3924 assert_eq!(actions.len(), 1);
3925 assert!(matches!(
3926 &actions[0],
3927 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
3928 ));
3929 }
3930
3931 #[test]
3932 fn h1_most_played_flip_to_same_art_writes_nothing() {
3933 let run1 = vec![
3935 album_member(
3936 album_clip("a", 9, "t0", "same-art", ""),
3937 "root",
3938 "c/al/a.flac",
3939 ),
3940 album_member(
3941 album_clip("b", 1, "t1", "same-art", ""),
3942 "root",
3943 "c/al/b.flac",
3944 ),
3945 ];
3946 let desired1 = album_desired(&run1, false, false);
3947 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true, &HashMap::new());
3948 assert_eq!(write1.len(), 1);
3949
3950 let mut albums = BTreeMap::new();
3952 if let Action::WriteArtifact {
3953 path,
3954 hash,
3955 owner_id,
3956 ..
3957 } = &write1[0]
3958 {
3959 albums.insert(
3960 owner_id.clone(),
3961 AlbumArt {
3962 folder_jpg: Some(stored(path, hash)),
3963 folder_webp: None,
3964 folder_mp4: None,
3965 },
3966 );
3967 }
3968
3969 let run2 = vec![
3971 album_member(
3972 album_clip("a", 1, "t0", "same-art", ""),
3973 "root",
3974 "c/al/a.flac",
3975 ),
3976 album_member(
3977 album_clip("b", 9, "t1", "same-art", ""),
3978 "root",
3979 "c/al/b.flac",
3980 ),
3981 ];
3982 let desired2 = album_desired(&run2, false, false);
3983 assert!(plan_album_artifacts(&desired2, &albums, true, &HashMap::new()).is_empty());
3985 }
3986
3987 #[test]
3988 fn h1_flip_to_different_art_writes_exactly_one() {
3989 let mut albums = BTreeMap::new();
3990 albums.insert(
3991 "root".to_string(),
3992 AlbumArt {
3993 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
3994 folder_webp: None,
3995 folder_mp4: None,
3996 },
3997 );
3998 let members = vec![
4000 album_member(
4001 album_clip("a", 1, "t0", "old-art", ""),
4002 "root",
4003 "c/al/a.flac",
4004 ),
4005 album_member(
4006 album_clip("b", 9, "t1", "new-art", ""),
4007 "root",
4008 "c/al/b.flac",
4009 ),
4010 ];
4011 let desired = album_desired(&members, false, false);
4012 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4013 assert_eq!(actions.len(), 1);
4014 assert!(matches!(
4015 &actions[0],
4016 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
4017 ));
4018 }
4019
4020 #[test]
4021 fn one_write_per_album_regardless_of_clip_count() {
4022 let members: Vec<Desired> = (0..200)
4023 .map(|i| {
4024 album_member(
4025 album_clip(
4026 &format!("clip-{i:03}"),
4027 i as u64,
4028 &format!("t{i:03}"),
4029 &format!("art-{i:03}"),
4030 &format!("vid-{i:03}"),
4031 ),
4032 "root",
4033 &format!("c/al/clip-{i:03}.flac"),
4034 )
4035 })
4036 .collect();
4037 let desired = album_desired(&members, true, false);
4038 assert_eq!(desired.len(), 1);
4039 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4040 assert_eq!(actions.len(), 2);
4042 assert_eq!(
4043 actions
4044 .iter()
4045 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
4046 .count(),
4047 2
4048 );
4049 }
4050
4051 #[test]
4052 fn emptied_album_deletes_only_when_can_delete() {
4053 let mut albums = BTreeMap::new();
4054 albums.insert(
4055 "root".to_string(),
4056 AlbumArt {
4057 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
4058 folder_webp: Some(stored("c/al/cover.webp", "hw")),
4059 folder_mp4: Some(stored("c/al/cover.mp4", "hm")),
4060 },
4061 );
4062 let desired: Vec<AlbumDesired> = Vec::new();
4064
4065 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4067
4068 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4070 assert_eq!(
4071 actions,
4072 vec![
4073 Action::DeleteArtifact {
4074 kind: ArtifactKind::FolderJpg,
4075 path: "c/al/folder.jpg".to_string(),
4076 owner_id: "root".to_string(),
4077 },
4078 Action::DeleteArtifact {
4079 kind: ArtifactKind::FolderWebp,
4080 path: "c/al/cover.webp".to_string(),
4081 owner_id: "root".to_string(),
4082 },
4083 Action::DeleteArtifact {
4084 kind: ArtifactKind::FolderMp4,
4085 path: "c/al/cover.mp4".to_string(),
4086 owner_id: "root".to_string(),
4087 },
4088 ]
4089 );
4090 }
4091
4092 #[test]
4093 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
4094 let mut albums = BTreeMap::new();
4095 albums.insert(
4096 "root".to_string(),
4097 AlbumArt {
4098 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4099 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4100 folder_mp4: None,
4101 },
4102 );
4103 let members = vec![album_member(
4106 album_clip("a", 1, "t0", "art-a", "vid-a"),
4107 "root",
4108 "c/al/a.flac",
4109 )];
4110 let desired = album_desired(&members, false, false);
4111
4112 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4113
4114 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4115 assert_eq!(
4116 actions,
4117 vec![Action::DeleteArtifact {
4118 kind: ArtifactKind::FolderWebp,
4119 path: "c/al/cover.webp".to_string(),
4120 owner_id: "root".to_string(),
4121 }]
4122 );
4123 }
4124
4125 #[test]
4126 fn disappeared_raw_cover_deletes_only_that_kind_when_gated() {
4127 let mut albums = BTreeMap::new();
4128 albums.insert(
4129 "root".to_string(),
4130 AlbumArt {
4131 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4132 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4133 folder_mp4: Some(stored("c/al/cover.mp4", &art_url_hash("vid-a"))),
4134 },
4135 );
4136 let members = vec![album_member(
4139 album_clip("a", 1, "t0", "art-a", "vid-a"),
4140 "root",
4141 "c/al/a.flac",
4142 )];
4143 let desired = album_desired(&members, true, false);
4144
4145 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4147
4148 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4150 assert_eq!(
4151 actions,
4152 vec![Action::DeleteArtifact {
4153 kind: ArtifactKind::FolderMp4,
4154 path: "c/al/cover.mp4".to_string(),
4155 owner_id: "root".to_string(),
4156 }]
4157 );
4158 }
4159
4160 #[test]
4161 fn plan_album_artifacts_is_deterministically_ordered() {
4162 let members = vec![
4163 album_member(
4164 album_clip("a", 1, "t0", "art-a", "vid-a"),
4165 "r2",
4166 "c/al2/a.flac",
4167 ),
4168 album_member(
4169 album_clip("b", 1, "t0", "art-b", "vid-b"),
4170 "r1",
4171 "c/al1/b.flac",
4172 ),
4173 ];
4174 let desired = album_desired(&members, true, true);
4175 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4176 let keys: Vec<(&str, ArtifactKind)> = actions
4177 .iter()
4178 .map(|a| match a {
4179 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
4180 _ => unreachable!(),
4181 })
4182 .collect();
4183 assert_eq!(
4184 keys,
4185 vec![
4186 ("r1", ArtifactKind::FolderJpg),
4187 ("r1", ArtifactKind::FolderWebp),
4188 ("r1", ArtifactKind::FolderMp4),
4189 ("r2", ArtifactKind::FolderJpg),
4190 ("r2", ArtifactKind::FolderWebp),
4191 ("r2", ArtifactKind::FolderMp4),
4192 ]
4193 );
4194 }
4195
4196 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
4199 PlaylistDesired {
4200 id: id.to_owned(),
4201 name: name.to_owned(),
4202 path: path.to_owned(),
4203 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
4204 hash: hash.to_owned(),
4205 }
4206 }
4207
4208 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
4209 PlaylistState {
4210 name: name.to_owned(),
4211 path: path.to_owned(),
4212 hash: hash.to_owned(),
4213 }
4214 }
4215
4216 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
4217 entries
4218 .iter()
4219 .map(|(id, state)| ((*id).to_owned(), state.clone()))
4220 .collect()
4221 }
4222
4223 #[test]
4224 fn playlist_write_emitted_for_a_new_playlist() {
4225 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
4226 let actions =
4227 plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true, &HashMap::new());
4228 assert_eq!(
4229 actions,
4230 vec![Action::WriteArtifact {
4231 kind: ArtifactKind::Playlist,
4232 path: "Road Trip.m3u8".to_owned(),
4233 source_url: String::new(),
4234 hash: "h1".to_owned(),
4235 owner_id: "pl1".to_owned(),
4236 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
4237 }]
4238 );
4239 }
4240
4241 #[test]
4242 fn playlist_write_emitted_when_hash_changes() {
4243 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
4246 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4247 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4248 assert_eq!(actions.len(), 1);
4249 assert!(matches!(
4250 &actions[0],
4251 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
4252 ));
4253 }
4254
4255 #[test]
4256 fn playlist_unchanged_is_idempotent() {
4257 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4258 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4259 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4260 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
4261 }
4262
4263 #[test]
4264 fn playlist_rename_writes_new_and_deletes_old_path() {
4265 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4268 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4269 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4270 assert_eq!(
4271 actions,
4272 vec![
4273 Action::WriteArtifact {
4274 kind: ArtifactKind::Playlist,
4275 path: "Summer.m3u8".to_owned(),
4276 source_url: String::new(),
4277 hash: "h2".to_owned(),
4278 owner_id: "pl1".to_owned(),
4279 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
4280 },
4281 Action::DeleteArtifact {
4282 kind: ArtifactKind::Playlist,
4283 path: "Spring.m3u8".to_owned(),
4284 owner_id: "pl1".to_owned(),
4285 },
4286 ]
4287 );
4288 }
4289
4290 #[test]
4291 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
4292 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4295 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4296 let actions = plan_playlist_artifacts(&desired, &stored, false, true, &HashMap::new());
4297 assert_eq!(actions.len(), 1);
4298 assert!(matches!(
4299 &actions[0],
4300 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
4301 ));
4302 assert!(
4303 !actions
4304 .iter()
4305 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
4306 "old path must not be deleted when deletes are disallowed"
4307 );
4308 }
4309
4310 #[test]
4311 fn playlist_stale_removed_only_under_full_gate() {
4312 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
4315
4316 let deleted = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4317 assert_eq!(
4318 deleted,
4319 vec![Action::DeleteArtifact {
4320 kind: ArtifactKind::Playlist,
4321 path: "Gone.m3u8".to_owned(),
4322 owner_id: "gone".to_owned(),
4323 }]
4324 );
4325
4326 assert!(plan_playlist_artifacts(&[], &stored, false, true, &HashMap::new()).is_empty());
4328 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4329 assert!(plan_playlist_artifacts(&[], &stored, false, false, &HashMap::new()).is_empty());
4330 }
4331
4332 #[test]
4333 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
4334 let stored = pl_store(&[
4339 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4340 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4341 ]);
4342 let actions = plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new());
4343 assert!(
4344 actions.is_empty(),
4345 "a failed playlist listing must plan zero actions, got {actions:?}"
4346 );
4347 }
4348
4349 #[test]
4350 fn b2_empty_list_deletes_only_when_fully_enumerated() {
4351 let stored = pl_store(&[
4356 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4357 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4358 ]);
4359
4360 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4362
4363 let wiped = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4366 assert_eq!(
4367 wiped
4368 .iter()
4369 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
4370 .count(),
4371 2
4372 );
4373 }
4374
4375 #[test]
4376 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
4377 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
4382 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
4383 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4384 assert_eq!(actions.len(), 1);
4386 assert!(matches!(
4387 &actions[0],
4388 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
4389 ));
4390 assert!(
4391 !actions.iter().any(|a| match a {
4392 Action::WriteArtifact { owner_id, .. }
4393 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
4394 _ => false,
4395 }),
4396 "a protected (failed-member) playlist must have no action"
4397 );
4398 }
4399
4400 #[test]
4401 fn playlist_rename_collision_downgrades_the_delete() {
4402 let desired = vec![
4408 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
4409 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
4410 ];
4411 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
4412 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4413 let write_paths: BTreeSet<&str> = actions
4415 .iter()
4416 .filter_map(|a| match a {
4417 Action::WriteArtifact { path, .. } => Some(path.as_str()),
4418 _ => None,
4419 })
4420 .collect();
4421 for a in &actions {
4422 if let Action::DeleteArtifact { path, .. } = a {
4423 assert!(
4424 !write_paths.contains(path.as_str()),
4425 "a playlist delete aliases a write target: {path}"
4426 );
4427 }
4428 }
4429 }
4430
4431 fn dstem(key: &str, path: &str, hash: &str) -> DesiredStem {
4434 DesiredStem {
4435 key: key.to_string(),
4436 stem_id: key.to_string(),
4437 path: path.to_string(),
4438 source_url: format!("https://cdn1.suno.ai/{key}.mp3"),
4439 format: StemFormat::Mp3,
4440 hash: hash.to_string(),
4441 }
4442 }
4443
4444 fn stem_desired(id: &str, stems: Option<Vec<DesiredStem>>) -> Desired {
4446 Desired {
4447 stems,
4448 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
4449 }
4450 }
4451
4452 fn entry_with_stems(id: &str, stems: &[(&str, &str, &str)]) -> ManifestEntry {
4454 let mut e = entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art");
4455 for (key, path, hash) in stems {
4456 e.stems.insert(
4457 key.to_string(),
4458 ArtifactState {
4459 path: path.to_string(),
4460 hash: hash.to_string(),
4461 },
4462 );
4463 }
4464 e
4465 }
4466
4467 fn stem_writes(plan: &Plan) -> Vec<(&str, &str)> {
4468 plan.actions
4469 .iter()
4470 .filter_map(|a| match a {
4471 Action::WriteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4472 _ => None,
4473 })
4474 .collect()
4475 }
4476
4477 fn stem_deletes(plan: &Plan) -> Vec<(&str, &str)> {
4478 plan.actions
4479 .iter()
4480 .filter_map(|a| match a {
4481 Action::DeleteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4482 _ => None,
4483 })
4484 .collect()
4485 }
4486
4487 #[test]
4488 fn stems_none_keeps_every_existing_stem() {
4489 let mut manifest = Manifest::new();
4492 manifest.insert(
4493 "a",
4494 entry_with_stems(
4495 "a",
4496 &[
4497 ("voc", "a.stems/voc.mp3", "h1"),
4498 ("drm", "a.stems/drm.mp3", "h2"),
4499 ],
4500 ),
4501 );
4502 let d = vec![stem_desired("a", None)];
4503 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4504 assert_eq!(plan.stem_writes(), 0);
4505 assert_eq!(plan.stem_deletes(), 0);
4506 }
4507
4508 #[test]
4509 fn stems_authoritative_writes_missing_stems() {
4510 let mut manifest = Manifest::new();
4511 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
4512 let d = vec![stem_desired(
4513 "a",
4514 Some(vec![
4515 dstem("voc", "a.stems/voc.mp3", "h1"),
4516 dstem("drm", "a.stems/drm.mp3", "h2"),
4517 ]),
4518 )];
4519 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4520 assert_eq!(
4521 stem_writes(&plan),
4522 vec![("voc", "a.stems/voc.mp3"), ("drm", "a.stems/drm.mp3")]
4523 );
4524 assert_eq!(plan.stem_deletes(), 0);
4525 }
4526
4527 #[test]
4528 fn stems_authoritative_rewrites_only_on_hash_or_path_drift() {
4529 let mut manifest = Manifest::new();
4530 manifest.insert(
4532 "a",
4533 entry_with_stems(
4534 "a",
4535 &[
4536 ("voc", "a.stems/voc.mp3", "h1"),
4537 ("drm", "a.stems/drm.mp3", "h2"),
4538 ("bas", "old.stems/bas.mp3", "h3"),
4539 ],
4540 ),
4541 );
4542 let d = vec![stem_desired(
4543 "a",
4544 Some(vec![
4545 dstem("voc", "a.stems/voc.mp3", "h1"), dstem("drm", "a.stems/drm.mp3", "h2-new"), dstem("bas", "a.stems/bas.mp3", "h3"), ]),
4549 )];
4550 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4551 assert_eq!(
4552 stem_writes(&plan),
4553 vec![("drm", "a.stems/drm.mp3"), ("bas", "a.stems/bas.mp3")]
4554 );
4555 assert_eq!(plan.stem_deletes(), 0);
4556 }
4557
4558 #[test]
4559 fn stems_authoritative_removes_a_stem_absent_from_the_set() {
4560 let mut manifest = Manifest::new();
4563 manifest.insert(
4564 "a",
4565 entry_with_stems(
4566 "a",
4567 &[
4568 ("voc", "a.stems/voc.mp3", "h1"),
4569 ("drm", "a.stems/drm.mp3", "h2"),
4570 ],
4571 ),
4572 );
4573 let d = vec![stem_desired(
4574 "a",
4575 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
4576 )];
4577 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4578 assert_eq!(plan.stem_writes(), 0);
4579 assert_eq!(stem_deletes(&plan), vec![("drm", "a.stems/drm.mp3")]);
4580 }
4581
4582 #[test]
4583 fn stems_removal_needs_deletion_allowed() {
4584 let mut manifest = Manifest::new();
4587 manifest.insert(
4588 "a",
4589 entry_with_stems(
4590 "a",
4591 &[
4592 ("voc", "a.stems/voc.mp3", "h1"),
4593 ("drm", "a.stems/drm.mp3", "h2"),
4594 ],
4595 ),
4596 );
4597 let d = vec![stem_desired(
4598 "a",
4599 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
4600 )];
4601
4602 let incomplete = vec![SourceStatus {
4603 mode: SourceMode::Mirror,
4604 fully_enumerated: false,
4605 }];
4606 assert_eq!(
4607 reconcile(&manifest, &d, &local_present("a"), &incomplete).stem_deletes(),
4608 0
4609 );
4610
4611 let copy_only = vec![SourceStatus {
4612 mode: SourceMode::Copy,
4613 fully_enumerated: true,
4614 }];
4615 assert_eq!(
4616 reconcile(&manifest, &d, &local_present("a"), ©_only).stem_deletes(),
4617 0
4618 );
4619 }
4620
4621 #[test]
4622 fn stems_removal_skipped_for_preserved_or_protected_clip() {
4623 let mut manifest = Manifest::new();
4624 let mut e = entry_with_stems(
4625 "a",
4626 &[
4627 ("voc", "a.stems/voc.mp3", "h1"),
4628 ("drm", "a.stems/drm.mp3", "h2"),
4629 ],
4630 );
4631 e.preserve = true;
4632 manifest.insert("a", e);
4633 let authoritative = Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]);
4634
4635 let d = vec![stem_desired("a", authoritative.clone())];
4637 assert_eq!(
4638 reconcile(&manifest, &d, &local_present("a"), &mirror_ok()).stem_deletes(),
4639 0
4640 );
4641
4642 let mut manifest2 = Manifest::new();
4644 manifest2.insert(
4645 "a",
4646 entry_with_stems(
4647 "a",
4648 &[
4649 ("voc", "a.stems/voc.mp3", "h1"),
4650 ("drm", "a.stems/drm.mp3", "h2"),
4651 ],
4652 ),
4653 );
4654 let held = Desired {
4655 modes: vec![SourceMode::Mirror, SourceMode::Copy],
4656 stems: authoritative,
4657 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
4658 };
4659 assert_eq!(
4660 reconcile(&manifest2, &[held], &local_present("a"), &mirror_ok()).stem_deletes(),
4661 0
4662 );
4663 }
4664
4665 #[test]
4666 fn stems_are_co_deleted_when_the_song_is_trashed() {
4667 let mut manifest = Manifest::new();
4670 manifest.insert(
4671 "a",
4672 entry_with_stems(
4673 "a",
4674 &[
4675 ("voc", "a.stems/voc.mp3", "h1"),
4676 ("drm", "a.stems/drm.mp3", "h2"),
4677 ],
4678 ),
4679 );
4680 let trashed = Desired {
4681 trashed: true,
4682 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
4683 };
4684 let plan = reconcile(&manifest, &[trashed], &local_present("a"), &mirror_ok());
4685 assert_eq!(plan.deletes(), 1, "the trashed audio is deleted");
4686 let mut deleted: Vec<&str> = stem_deletes(&plan).into_iter().map(|(k, _)| k).collect();
4687 deleted.sort_unstable();
4688 assert_eq!(deleted, vec!["drm", "voc"], "both stems co-deleted");
4689 }
4690
4691 #[test]
4692 fn stems_are_co_deleted_for_an_absent_clip() {
4693 let mut manifest = Manifest::new();
4694 manifest.insert(
4695 "a",
4696 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
4697 );
4698 let plan = reconcile(&manifest, &[], &local_present("a"), &mirror_ok());
4700 assert_eq!(plan.deletes(), 1);
4701 assert_eq!(stem_deletes(&plan), vec![("voc", "a.stems/voc.mp3")]);
4702 }
4703
4704 #[test]
4705 fn stems_are_kept_when_absent_clip_listing_is_incomplete() {
4706 let mut manifest = Manifest::new();
4708 manifest.insert(
4709 "a",
4710 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
4711 );
4712 let incomplete = vec![SourceStatus {
4713 mode: SourceMode::Mirror,
4714 fully_enumerated: false,
4715 }];
4716 let plan = reconcile(&manifest, &[], &HashMap::new(), &incomplete);
4717 assert_eq!(plan.deletes(), 0);
4718 assert_eq!(plan.stem_deletes(), 0);
4719 }
4720
4721 #[test]
4722 fn stem_delete_is_suppressed_when_it_aliases_a_stem_write() {
4723 let mut manifest = Manifest::new();
4727 manifest.insert(
4728 "a",
4729 entry_with_stems("a", &[("old", "a.stems/mix.mp3", "h1")]),
4730 );
4731 let d = vec![stem_desired(
4732 "a",
4733 Some(vec![dstem("new", "a.stems/mix.mp3", "h2")]),
4734 )];
4735 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4736 assert_eq!(stem_writes(&plan), vec![("new", "a.stems/mix.mp3")]);
4739 assert!(
4740 !plan.actions.iter().any(|a| matches!(
4741 a,
4742 Action::DeleteStem { path, .. } if path == "a.stems/mix.mp3"
4743 )),
4744 "a stem delete must never alias a stem write target"
4745 );
4746 }
4747}
4748
4749#[cfg(test)]
4762mod proptests {
4763 use super::*;
4764 use proptest::collection::{btree_map, hash_map, vec};
4765 use proptest::prelude::*;
4766 use std::collections::BTreeSet;
4767
4768 type DesiredFields = (
4769 String,
4770 AudioFormat,
4771 String,
4772 String,
4773 Vec<SourceMode>,
4774 bool,
4775 bool,
4776 );
4777
4778 fn audio_format() -> impl Strategy<Value = AudioFormat> {
4779 prop_oneof![
4780 Just(AudioFormat::Mp3),
4781 Just(AudioFormat::Flac),
4782 Just(AudioFormat::Wav),
4783 ]
4784 }
4785
4786 fn source_mode() -> impl Strategy<Value = SourceMode> {
4787 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
4788 }
4789
4790 fn clip_id() -> impl Strategy<Value = String> {
4793 (0u8..8).prop_map(|n| format!("c{n}"))
4794 }
4795
4796 fn small_path() -> impl Strategy<Value = String> {
4797 (0u8..6).prop_map(|n| format!("path{n}"))
4798 }
4799
4800 fn manifest_path() -> impl Strategy<Value = String> {
4803 prop_oneof![
4804 1 => Just(String::new()),
4805 6 => small_path(),
4806 ]
4807 }
4808
4809 fn small_hash() -> impl Strategy<Value = String> {
4810 (0u8..4).prop_map(|n| format!("h{n}"))
4811 }
4812
4813 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
4814 (
4815 manifest_path(),
4816 audio_format(),
4817 small_hash(),
4818 small_hash(),
4819 0u64..4,
4820 any::<bool>(),
4821 )
4822 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
4823 ManifestEntry {
4824 path,
4825 format,
4826 meta_hash,
4827 art_hash,
4828 size,
4829 preserve,
4830 ..Default::default()
4831 }
4832 })
4833 }
4834
4835 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
4836 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
4837 }
4838
4839 fn local_file() -> impl Strategy<Value = LocalFile> {
4840 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
4841 }
4842
4843 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
4844 hash_map(clip_id(), local_file(), 0..8)
4845 }
4846
4847 fn source_status() -> impl Strategy<Value = SourceStatus> {
4848 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
4849 mode,
4850 fully_enumerated,
4851 })
4852 }
4853
4854 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
4855 vec(source_status(), 0..5)
4856 }
4857
4858 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
4859 vec(
4860 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
4861 mode: SourceMode::Copy,
4862 fully_enumerated,
4863 }),
4864 1..5,
4865 )
4866 }
4867
4868 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
4869 (
4870 small_path(),
4871 audio_format(),
4872 small_hash(),
4873 small_hash(),
4874 vec(source_mode(), 1..3),
4875 any::<bool>(),
4876 any::<bool>(),
4877 )
4878 }
4879
4880 fn build_desired(id: String, fields: DesiredFields) -> Desired {
4881 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
4882 let clip = Clip {
4883 id,
4884 title: "t".to_string(),
4885 ..Default::default()
4886 };
4887 Desired {
4888 lineage: LineageContext::own_root(&clip),
4889 clip,
4890 path,
4891 format,
4892 meta_hash,
4893 art_hash,
4894 modes,
4895 trashed,
4896 private,
4897 artifacts: Vec::new(),
4898 stems: None,
4899 }
4900 }
4901
4902 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
4905 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
4906 items
4907 .into_iter()
4908 .map(|(id, fields)| build_desired(id, fields))
4909 .collect()
4910 })
4911 }
4912
4913 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
4914 desired.iter().map(|d| d.clip.id.as_str()).collect()
4915 }
4916
4917 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
4920 desired
4921 .iter()
4922 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
4923 .map(|d| d.clip.id.as_str())
4924 .collect()
4925 }
4926
4927 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
4930 desired
4931 .iter()
4932 .filter(|d| !d.trashed)
4933 .map(|d| d.clip.id.as_str())
4934 .collect()
4935 }
4936
4937 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
4938 plan.actions
4939 .iter()
4940 .filter_map(|a| match a {
4941 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
4942 _ => None,
4943 })
4944 .collect()
4945 }
4946
4947 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
4948 plan.actions
4949 .iter()
4950 .filter_map(|a| match a {
4951 Action::Download { path, .. } | Action::Reformat { path, .. } => {
4952 Some(path.as_str())
4953 }
4954 Action::Rename { to, .. } => Some(to.as_str()),
4955 _ => None,
4956 })
4957 .collect()
4958 }
4959
4960 proptest! {
4961 #![proptest_config(ProptestConfig {
4962 cases: 256,
4963 failure_persistence: None,
4964 ..ProptestConfig::default()
4965 })]
4966
4967 #[test]
4970 fn inv1_desired_clip_deleted_only_when_fully_trashed(
4971 manifest in manifest_strategy(),
4972 desired in desired_strategy(),
4973 local in local_strategy(),
4974 sources in sources_strategy(),
4975 ) {
4976 let plan = reconcile(&manifest, &desired, &local, &sources);
4977 let present = desired_ids(&desired);
4978 let live = non_trashed_ids(&desired);
4979 for id in delete_clip_ids(&plan) {
4980 prop_assert!(
4981 !(present.contains(id) && live.contains(id)),
4982 "deleted a desired clip with a non-trashed duplicate: {id}"
4983 );
4984 }
4985 }
4986
4987 #[test]
4991 fn inv2_no_delete_when_any_mirror_unenumerated(
4992 manifest in manifest_strategy(),
4993 desired in desired_strategy(),
4994 local in local_strategy(),
4995 mut sources in sources_strategy(),
4996 ) {
4997 sources.push(SourceStatus {
4998 mode: SourceMode::Mirror,
4999 fully_enumerated: false,
5000 });
5001 let plan = reconcile(&manifest, &desired, &local, &sources);
5002 prop_assert_eq!(plan.deletes(), 0);
5003 }
5004
5005 #[test]
5007 fn inv3_all_copy_sources_means_no_deletes(
5008 manifest in manifest_strategy(),
5009 desired in desired_strategy(),
5010 local in local_strategy(),
5011 sources in copy_sources_strategy(),
5012 ) {
5013 let plan = reconcile(&manifest, &desired, &local, &sources);
5014 prop_assert_eq!(plan.deletes(), 0);
5015 }
5016
5017 #[test]
5020 fn inv4_plan_is_deterministic(
5021 manifest in manifest_strategy(),
5022 desired in desired_strategy(),
5023 local in local_strategy(),
5024 sources in sources_strategy(),
5025 ) {
5026 let plan = reconcile(&manifest, &desired, &local, &sources);
5027
5028 let again = reconcile(&manifest, &desired, &local, &sources);
5029 prop_assert_eq!(&plan, &again);
5030
5031 let mut desired_rev = desired.clone();
5032 desired_rev.reverse();
5033 let mut sources_rev = sources.clone();
5034 sources_rev.reverse();
5035 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
5036 prop_assert_eq!(&plan, &shuffled);
5037 }
5038
5039 #[test]
5041 fn inv5_every_delete_is_in_the_manifest(
5042 manifest in manifest_strategy(),
5043 desired in desired_strategy(),
5044 local in local_strategy(),
5045 sources in sources_strategy(),
5046 ) {
5047 let plan = reconcile(&manifest, &desired, &local, &sources);
5048 for id in delete_clip_ids(&plan) {
5049 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
5050 }
5051 }
5052
5053 #[test]
5056 fn inv6_never_deletes_protected_clip(
5057 manifest in manifest_strategy(),
5058 desired in desired_strategy(),
5059 local in local_strategy(),
5060 sources in sources_strategy(),
5061 ) {
5062 let plan = reconcile(&manifest, &desired, &local, &sources);
5063 let protected = protected_ids(&desired);
5064 for id in delete_clip_ids(&plan) {
5065 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
5066 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
5067 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
5068 }
5069 }
5070
5071 #[test]
5074 fn inv7_no_delete_unless_deletion_allowed(
5075 manifest in manifest_strategy(),
5076 desired in desired_strategy(),
5077 local in local_strategy(),
5078 sources in sources_strategy(),
5079 ) {
5080 let plan = reconcile(&manifest, &desired, &local, &sources);
5081 if !deletion_allowed(&sources) {
5082 prop_assert_eq!(plan.deletes(), 0);
5083 }
5084 }
5085
5086 #[test]
5088 fn inv8_at_most_one_delete_per_clip(
5089 manifest in manifest_strategy(),
5090 desired in desired_strategy(),
5091 local in local_strategy(),
5092 sources in sources_strategy(),
5093 ) {
5094 let plan = reconcile(&manifest, &desired, &local, &sources);
5095 let ids = delete_clip_ids(&plan);
5096 let unique: BTreeSet<&str> = ids.iter().copied().collect();
5097 prop_assert_eq!(ids.len(), unique.len());
5098 }
5099
5100 #[test]
5102 fn inv9_no_delete_with_empty_path(
5103 manifest in manifest_strategy(),
5104 desired in desired_strategy(),
5105 local in local_strategy(),
5106 sources in sources_strategy(),
5107 ) {
5108 let plan = reconcile(&manifest, &desired, &local, &sources);
5109 for action in &plan.actions {
5110 if let Action::Delete { path, .. } = action {
5111 prop_assert!(!path.is_empty(), "delete with an empty path");
5112 }
5113 }
5114 }
5115
5116 #[test]
5119 fn inv10_no_delete_aliases_a_write_target(
5120 manifest in manifest_strategy(),
5121 desired in desired_strategy(),
5122 local in local_strategy(),
5123 sources in sources_strategy(),
5124 ) {
5125 let plan = reconcile(&manifest, &desired, &local, &sources);
5126 let targets = write_target_paths(&plan);
5127 for action in &plan.actions {
5128 if let Action::Delete { path, .. } = action {
5129 prop_assert!(
5130 !targets.contains(path.as_str()),
5131 "delete path {path} aliases a write target"
5132 );
5133 }
5134 }
5135 }
5136 }
5137}