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