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