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