1use std::collections::BTreeMap;
34use std::collections::BTreeSet;
35use std::collections::HashMap;
36
37use crate::config::{AudioFormat, StemFormat};
38use crate::graph::{AlbumArt, PlaylistState};
39use crate::hash::{art_hash, art_url_hash};
40use crate::lineage::LineageContext;
41use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
42use crate::model::Clip;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
56pub enum ArtifactKind {
57 CoverJpg,
59 CoverWebp,
61 DetailsTxt,
63 LyricsTxt,
65 Lrc,
67 VideoMp4,
70 FolderJpg,
72 FolderWebp,
74 FolderMp4,
78 Playlist,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum SourceMode {
86 Mirror,
88 Copy,
90}
91
92#[derive(Debug, Clone, PartialEq)]
99pub struct Desired {
100 pub clip: Clip,
102 pub lineage: LineageContext,
105 pub path: String,
107 pub format: AudioFormat,
109 pub meta_hash: String,
111 pub art_hash: String,
113 pub modes: Vec<SourceMode>,
115 pub trashed: bool,
117 pub private: bool,
119 pub artifacts: Vec<DesiredArtifact>,
127 pub stems: Option<Vec<DesiredStem>>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct DesiredStem {
150 pub key: String,
154 pub stem_id: String,
158 pub path: String,
161 pub source_url: String,
165 pub format: StemFormat,
168 pub hash: String,
170}
171
172#[derive(Debug, Clone, PartialEq)]
177pub struct DesiredArtifact {
178 pub kind: ArtifactKind,
180 pub path: String,
182 pub source_url: String,
185 pub hash: String,
187 pub content: Option<String>,
191}
192
193#[derive(Debug, Clone, PartialEq)]
204pub struct AlbumDesired {
205 pub root_id: String,
207 pub folder_jpg: Option<DesiredArtifact>,
209 pub folder_webp: Option<DesiredArtifact>,
211 pub folder_mp4: Option<DesiredArtifact>,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct PlaylistDesired {
228 pub id: String,
231 pub name: String,
233 pub path: String,
235 pub content: String,
237 pub hash: String,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
243pub struct LocalFile {
244 pub exists: bool,
246 pub size: u64,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub struct SourceStatus {
253 pub mode: SourceMode,
255 pub fully_enumerated: bool,
257}
258
259#[derive(Debug, Clone, PartialEq)]
261pub enum Action {
262 Download {
264 clip: Clip,
265 lineage: LineageContext,
266 path: String,
267 format: AudioFormat,
268 },
269 Reformat {
275 clip: Clip,
276 path: String,
277 from_path: String,
278 from: AudioFormat,
279 to: AudioFormat,
280 },
281 Retag {
283 clip: Clip,
284 lineage: LineageContext,
285 path: String,
286 },
287 Rename { from: String, to: String },
289 Delete { path: String, clip_id: String },
291 Skip { clip_id: String },
293 WriteArtifact {
305 kind: ArtifactKind,
306 path: String,
307 source_url: String,
308 hash: String,
309 owner_id: String,
310 content: Option<String>,
311 },
312 MoveArtifact {
322 kind: ArtifactKind,
323 from: String,
324 to: String,
325 source_url: String,
326 hash: String,
327 owner_id: String,
328 },
329 DeleteArtifact {
336 kind: ArtifactKind,
337 path: String,
338 owner_id: String,
339 },
340 WriteStem {
350 clip_id: String,
351 key: String,
352 stem_id: String,
353 path: String,
354 source_url: String,
355 format: StemFormat,
356 hash: String,
357 },
358 MoveStem {
366 clip_id: String,
367 key: String,
368 stem_id: String,
369 from: String,
370 to: String,
371 source_url: String,
372 format: StemFormat,
373 hash: String,
374 },
375 DeleteStem {
384 clip_id: String,
385 key: String,
386 path: String,
387 },
388}
389
390#[derive(Debug, Clone, Default, PartialEq)]
395pub struct Plan {
396 pub actions: Vec<Action>,
398}
399
400impl Plan {
401 pub fn len(&self) -> usize {
403 self.actions.len()
404 }
405
406 pub fn is_empty(&self) -> bool {
408 self.actions.is_empty()
409 }
410
411 pub fn downloads(&self) -> usize {
413 self.count(|a| matches!(a, Action::Download { .. }))
414 }
415
416 pub fn reformats(&self) -> usize {
418 self.count(|a| matches!(a, Action::Reformat { .. }))
419 }
420
421 pub fn retags(&self) -> usize {
423 self.count(|a| matches!(a, Action::Retag { .. }))
424 }
425
426 pub fn renames(&self) -> usize {
428 self.count(|a| matches!(a, Action::Rename { .. }))
429 }
430
431 pub fn deletes(&self) -> usize {
433 self.count(|a| matches!(a, Action::Delete { .. }))
434 }
435
436 pub fn skips(&self) -> usize {
438 self.count(|a| matches!(a, Action::Skip { .. }))
439 }
440
441 pub fn artifact_writes(&self) -> usize {
443 self.count(|a| matches!(a, Action::WriteArtifact { .. }))
444 }
445
446 pub fn artifact_deletes(&self) -> usize {
448 self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
449 }
450
451 pub fn stem_writes(&self) -> usize {
453 self.count(|a| matches!(a, Action::WriteStem { .. }))
454 }
455
456 pub fn artifact_moves(&self) -> usize {
459 self.count(|a| matches!(a, Action::MoveArtifact { .. }))
460 }
461
462 pub fn stem_moves(&self) -> usize {
465 self.count(|a| matches!(a, Action::MoveStem { .. }))
466 }
467
468 pub fn stem_deletes(&self) -> usize {
470 self.count(|a| matches!(a, Action::DeleteStem { .. }))
471 }
472
473 fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
474 self.actions.iter().filter(|a| pred(a)).count()
475 }
476}
477
478pub fn reconcile(
493 manifest: &Manifest,
494 desired: &[Desired],
495 local: &HashMap<String, LocalFile>,
496 sources: &[SourceStatus],
497) -> Plan {
498 let mut actions: Vec<Action> = Vec::new();
499
500 let desired = aggregate_desired(desired);
502 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.clip.id.as_str()).collect();
503
504 let can_delete = deletion_allowed(sources);
505
506 for d in &desired {
507 let before = actions.len();
512 plan_desired(d, manifest, local, can_delete, &mut actions);
513 let audio_deleted = actions[before..]
514 .iter()
515 .any(|a| matches!(a, Action::Delete { .. }));
516 if audio_deleted {
517 co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
518 co_delete_stems(d.clip.id.as_str(), manifest, can_delete, &mut actions);
519 } else {
520 plan_clip_artifacts(d, manifest, local, can_delete, &mut actions);
521 plan_clip_stems(d, manifest, local, can_delete, &mut actions);
522 }
523 }
524
525 for (clip_id, _entry) in manifest.iter() {
527 if desired_ids.contains(clip_id.as_str()) {
528 continue;
529 }
530 match delete_action(clip_id, manifest, can_delete) {
531 Some(action) => {
532 actions.push(action);
533 co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
536 co_delete_stems(clip_id, manifest, can_delete, &mut actions);
537 }
538 None => actions.push(Action::Skip {
541 clip_id: clip_id.clone(),
542 }),
543 }
544 }
545
546 suppress_path_aliasing(&mut actions);
547 Plan { actions }
548}
549
550pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
561 let mut saw_mirror = false;
562 for status in sources {
563 if !status.fully_enumerated {
564 return false;
565 }
566 if status.mode == SourceMode::Mirror {
567 saw_mirror = true;
568 }
569 }
570 saw_mirror
571}
572
573fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
579 if !can_delete {
580 return None;
581 }
582 let entry = manifest.get(clip_id)?;
583 if entry.path.is_empty() || entry.preserve {
584 return None;
585 }
586 Some(Action::Delete {
587 path: entry.path.clone(),
588 clip_id: clip_id.to_string(),
589 })
590}
591
592fn delete_artifact_action(
602 owner_id: &str,
603 kind: ArtifactKind,
604 path: &str,
605 manifest: &Manifest,
606 can_delete: bool,
607) -> Option<Action> {
608 if !can_delete {
609 return None;
610 }
611 let entry = manifest.get(owner_id)?;
612 if path.is_empty() || entry.preserve {
613 return None;
614 }
615 Some(Action::DeleteArtifact {
616 kind,
617 path: path.to_string(),
618 owner_id: owner_id.to_string(),
619 })
620}
621
622fn is_per_clip_kind(kind: ArtifactKind) -> bool {
628 matches!(
629 kind,
630 ArtifactKind::CoverJpg
631 | ArtifactKind::CoverWebp
632 | ArtifactKind::DetailsTxt
633 | ArtifactKind::LyricsTxt
634 | ArtifactKind::Lrc
635 | ArtifactKind::VideoMp4
636 )
637}
638
639fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
665 match kind {
666 ArtifactKind::CoverJpg
667 | ArtifactKind::CoverWebp
668 | ArtifactKind::LyricsTxt
669 | ArtifactKind::Lrc
670 | ArtifactKind::VideoMp4 => false,
671 ArtifactKind::DetailsTxt
672 | ArtifactKind::FolderJpg
673 | ArtifactKind::FolderWebp
674 | ArtifactKind::FolderMp4
675 | ArtifactKind::Playlist => true,
676 }
677}
678
679fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
684 match kind {
685 ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
686 ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
687 ArtifactKind::DetailsTxt => entry.details_txt.as_ref(),
688 ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
689 ArtifactKind::Lrc => entry.lrc.as_ref(),
690 ArtifactKind::VideoMp4 => entry.video_mp4.as_ref(),
691 ArtifactKind::FolderJpg
692 | ArtifactKind::FolderWebp
693 | ArtifactKind::FolderMp4
694 | ArtifactKind::Playlist => None,
695 }
696}
697
698fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
701 let mut out = Vec::new();
702 if let Some(state) = &entry.cover_jpg {
703 out.push((ArtifactKind::CoverJpg, state));
704 }
705 if let Some(state) = &entry.cover_webp {
706 out.push((ArtifactKind::CoverWebp, state));
707 }
708 if let Some(state) = &entry.details_txt {
709 out.push((ArtifactKind::DetailsTxt, state));
710 }
711 if let Some(state) = &entry.lyrics_txt {
712 out.push((ArtifactKind::LyricsTxt, state));
713 }
714 if let Some(state) = &entry.lrc {
715 out.push((ArtifactKind::Lrc, state));
716 }
717 if let Some(state) = &entry.video_mp4 {
718 out.push((ArtifactKind::VideoMp4, state));
719 }
720 out
721}
722
723pub(crate) fn set_manifest_artifact(
730 entry: &mut ManifestEntry,
731 kind: ArtifactKind,
732 state: Option<ArtifactState>,
733) {
734 match kind {
735 ArtifactKind::CoverJpg => entry.cover_jpg = state,
736 ArtifactKind::CoverWebp => entry.cover_webp = state,
737 ArtifactKind::DetailsTxt => entry.details_txt = state,
738 ArtifactKind::LyricsTxt => entry.lyrics_txt = state,
739 ArtifactKind::Lrc => entry.lrc = state,
740 ArtifactKind::VideoMp4 => entry.video_mp4 = state,
741 ArtifactKind::FolderJpg
742 | ArtifactKind::FolderWebp
743 | ArtifactKind::FolderMp4
744 | ArtifactKind::Playlist => {}
745 }
746}
747
748pub(crate) fn set_manifest_stem(
754 entry: &mut ManifestEntry,
755 key: &str,
756 state: Option<ArtifactState>,
757) {
758 match state {
759 Some(state) => {
760 entry.stems.insert(key.to_string(), state);
761 }
762 None => {
763 entry.stems.remove(key);
764 }
765 }
766}
767
768fn plan_clip_artifacts(
784 d: &Desired,
785 manifest: &Manifest,
786 local: &HashMap<String, LocalFile>,
787 can_delete: bool,
788 out: &mut Vec<Action>,
789) {
790 let owner_id = d.clip.id.as_str();
791 let entry = manifest.get(owner_id);
792
793 for artifact in &d.artifacts {
794 if !is_per_clip_kind(artifact.kind) {
799 continue;
800 }
801 let state = entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind));
807 let needs_write = match state {
808 None => true,
809 Some(state) => {
810 state.hash != artifact.hash
811 || state.path != artifact.path
812 || local
813 .get(&state.path)
814 .is_some_and(|f| !f.exists || f.size == 0)
815 }
816 };
817 if needs_write {
818 if let Some(state) = state
824 && state.hash == artifact.hash
825 && state.path != artifact.path
826 && artifact.content.is_none()
827 && local
828 .get(&state.path)
829 .is_some_and(|f| f.exists && f.size > 0)
830 {
831 out.push(Action::MoveArtifact {
832 kind: artifact.kind,
833 from: state.path.clone(),
834 to: artifact.path.clone(),
835 source_url: artifact.source_url.clone(),
836 hash: artifact.hash.clone(),
837 owner_id: owner_id.to_string(),
838 });
839 } else {
840 out.push(Action::WriteArtifact {
841 kind: artifact.kind,
842 path: artifact.path.clone(),
843 source_url: artifact.source_url.clone(),
844 hash: artifact.hash.clone(),
845 owner_id: owner_id.to_string(),
846 content: artifact.content.clone(),
847 });
848 }
849 }
850 }
851
852 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
857 if !protected_now && let Some(entry) = entry {
858 let desired_kinds: BTreeSet<ArtifactKind> = d
859 .artifacts
860 .iter()
861 .filter(|a| is_per_clip_kind(a.kind))
862 .map(|a| a.kind)
863 .collect();
864 for (kind, state) in manifest_artifacts(entry) {
865 if removed_kind_delete_eligible(kind)
871 && !desired_kinds.contains(&kind)
872 && let Some(action) =
873 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
874 {
875 out.push(action);
876 }
877 }
878 }
879}
880
881fn co_delete_artifacts(
887 owner_id: &str,
888 manifest: &Manifest,
889 can_delete: bool,
890 out: &mut Vec<Action>,
891) {
892 let Some(entry) = manifest.get(owner_id) else {
893 return;
894 };
895 for (kind, state) in manifest_artifacts(entry) {
896 if let Some(action) =
897 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
898 {
899 out.push(action);
900 }
901 }
902}
903
904fn delete_stem_action(
913 clip_id: &str,
914 key: &str,
915 path: &str,
916 manifest: &Manifest,
917 can_delete: bool,
918) -> Option<Action> {
919 if !can_delete {
920 return None;
921 }
922 let entry = manifest.get(clip_id)?;
923 if path.is_empty() || entry.preserve {
924 return None;
925 }
926 Some(Action::DeleteStem {
927 clip_id: clip_id.to_string(),
928 key: key.to_string(),
929 path: path.to_string(),
930 })
931}
932
933fn plan_clip_stems(
950 d: &Desired,
951 manifest: &Manifest,
952 local: &HashMap<String, LocalFile>,
953 can_delete: bool,
954 out: &mut Vec<Action>,
955) {
956 let Some(desired_stems) = &d.stems else {
957 return;
958 };
959 let clip_id = d.clip.id.as_str();
960 let entry = manifest.get(clip_id);
961
962 for stem in desired_stems {
963 let state = entry.and_then(|e| e.stems.get(&stem.key));
964 let needs_write = match state {
965 None => true,
966 Some(state) => state.hash != stem.hash || state.path != stem.path,
967 };
968 if needs_write {
969 if let Some(state) = state
974 && state.hash == stem.hash
975 && state.path != stem.path
976 && local
977 .get(&state.path)
978 .is_some_and(|f| f.exists && f.size > 0)
979 {
980 out.push(Action::MoveStem {
981 clip_id: clip_id.to_string(),
982 key: stem.key.clone(),
983 stem_id: stem.stem_id.clone(),
984 from: state.path.clone(),
985 to: stem.path.clone(),
986 source_url: stem.source_url.clone(),
987 format: stem.format,
988 hash: stem.hash.clone(),
989 });
990 } else {
991 out.push(Action::WriteStem {
992 clip_id: clip_id.to_string(),
993 key: stem.key.clone(),
994 stem_id: stem.stem_id.clone(),
995 path: stem.path.clone(),
996 source_url: stem.source_url.clone(),
997 format: stem.format,
998 hash: stem.hash.clone(),
999 });
1000 }
1001 }
1002 }
1003
1004 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
1005 if !protected_now && let Some(entry) = entry {
1006 let desired_keys: BTreeSet<&str> = desired_stems.iter().map(|s| s.key.as_str()).collect();
1007 for (key, state) in &entry.stems {
1008 if !desired_keys.contains(key.as_str())
1014 && let Some(action) =
1015 delete_stem_action(clip_id, key, &state.path, manifest, can_delete)
1016 {
1017 out.push(action);
1018 }
1019 }
1020 }
1021}
1022
1023fn co_delete_stems(clip_id: &str, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
1031 let Some(entry) = manifest.get(clip_id) else {
1032 return;
1033 };
1034 for (key, state) in &entry.stems {
1035 if let Some(action) = delete_stem_action(clip_id, key, &state.path, manifest, can_delete) {
1036 out.push(action);
1037 }
1038 }
1039}
1040
1041fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
1048 let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
1049 for d in desired {
1050 match by_id.get_mut(d.clip.id.as_str()) {
1051 None => {
1052 by_id.insert(d.clip.id.as_str(), d.clone());
1053 }
1054 Some(acc) => {
1055 let take = rep_key(d) < rep_key(acc);
1056 acc.private = acc.private || d.private;
1057 acc.trashed = acc.trashed && d.trashed;
1058 for mode in &d.modes {
1059 if !acc.modes.contains(mode) {
1060 acc.modes.push(*mode);
1061 }
1062 }
1063 if take {
1064 acc.clip = d.clip.clone();
1065 acc.path = d.path.clone();
1066 acc.format = d.format;
1067 acc.meta_hash = d.meta_hash.clone();
1068 acc.art_hash = d.art_hash.clone();
1069 acc.artifacts = d.artifacts.clone();
1070 acc.stems = d.stems.clone();
1071 }
1072 }
1073 }
1074 }
1075 let mut out: Vec<Desired> = by_id.into_values().collect();
1076 for d in &mut out {
1077 let has_mirror = d.modes.contains(&SourceMode::Mirror);
1079 let has_copy = d.modes.contains(&SourceMode::Copy);
1080 d.modes.clear();
1081 if has_mirror {
1082 d.modes.push(SourceMode::Mirror);
1083 }
1084 if has_copy {
1085 d.modes.push(SourceMode::Copy);
1086 }
1087 }
1088 out
1089}
1090
1091fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
1094 let format = match d.format {
1095 AudioFormat::Mp3 => 0,
1096 AudioFormat::Flac => 1,
1097 AudioFormat::Wav => 2,
1098 };
1099 (
1100 d.path.as_str(),
1101 d.meta_hash.as_str(),
1102 d.art_hash.as_str(),
1103 format,
1104 )
1105}
1106
1107fn suppress_path_aliasing(actions: &mut [Action]) {
1114 let targets: BTreeSet<String> = actions
1115 .iter()
1116 .filter_map(|a| match a {
1117 Action::Download { path, .. }
1118 | Action::Reformat { path, .. }
1119 | Action::WriteArtifact { path, .. }
1120 | Action::WriteStem { path, .. } => Some(path.clone()),
1121 Action::Rename { to, .. }
1122 | Action::MoveArtifact { to, .. }
1123 | Action::MoveStem { to, .. } => Some(to.clone()),
1124 _ => None,
1125 })
1126 .collect();
1127 for a in actions.iter_mut() {
1128 if let Action::Delete { path, clip_id } = a
1129 && targets.contains(path.as_str())
1130 {
1131 *a = Action::Skip {
1132 clip_id: clip_id.clone(),
1133 };
1134 }
1135 if let Action::DeleteArtifact { path, owner_id, .. } = a
1136 && targets.contains(path.as_str())
1137 {
1138 *a = Action::Skip {
1139 clip_id: owner_id.clone(),
1140 };
1141 }
1142 if let Action::DeleteStem { path, clip_id, .. } = a
1143 && targets.contains(path.as_str())
1144 {
1145 *a = Action::Skip {
1146 clip_id: clip_id.clone(),
1147 };
1148 }
1149 }
1150}
1151
1152fn plan_desired(
1154 d: &Desired,
1155 manifest: &Manifest,
1156 local: &HashMap<String, LocalFile>,
1157 can_delete: bool,
1158 out: &mut Vec<Action>,
1159) {
1160 let clip_id = d.clip.id.as_str();
1161 let copy_held = d.modes.contains(&SourceMode::Copy);
1162
1163 if d.trashed && !d.private && !copy_held {
1169 match delete_action(clip_id, manifest, can_delete) {
1170 Some(action) => out.push(action),
1171 None => out.push(Action::Skip {
1172 clip_id: clip_id.to_string(),
1173 }),
1174 }
1175 return;
1176 }
1177
1178 let Some(entry) = manifest.get(clip_id) else {
1179 out.push(Action::Download {
1181 clip: d.clip.clone(),
1182 lineage: d.lineage.clone(),
1183 path: d.path.clone(),
1184 format: d.format,
1185 });
1186 return;
1187 };
1188
1189 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
1192 if missing {
1193 out.push(Action::Download {
1194 clip: d.clip.clone(),
1195 lineage: d.lineage.clone(),
1196 path: d.path.clone(),
1197 format: d.format,
1198 });
1199 return;
1200 }
1201
1202 if d.format != entry.format {
1203 out.push(Action::Reformat {
1206 clip: d.clip.clone(),
1207 path: d.path.clone(),
1208 from_path: entry.path.clone(),
1209 from: entry.format,
1210 to: d.format,
1211 });
1212 return;
1213 }
1214
1215 if d.path != entry.path {
1216 out.push(Action::Rename {
1217 from: entry.path.clone(),
1218 to: d.path.clone(),
1219 });
1220 if meta_or_art_changed(d, entry) {
1222 out.push(Action::Retag {
1223 clip: d.clip.clone(),
1224 lineage: d.lineage.clone(),
1225 path: d.path.clone(),
1226 });
1227 }
1228 return;
1229 }
1230
1231 if meta_or_art_changed(d, entry) {
1232 out.push(Action::Retag {
1233 clip: d.clip.clone(),
1234 lineage: d.lineage.clone(),
1235 path: entry.path.clone(),
1236 });
1237 return;
1238 }
1239
1240 out.push(Action::Skip {
1241 clip_id: clip_id.to_string(),
1242 });
1243}
1244
1245fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
1247 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
1248}
1249
1250pub fn album_desired(
1274 desired: &[Desired],
1275 animated_covers: bool,
1276 raw_cover: bool,
1277) -> Vec<AlbumDesired> {
1278 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
1279 for d in desired {
1280 groups
1281 .entry(d.lineage.root_id.as_str())
1282 .or_default()
1283 .push(d);
1284 }
1285
1286 groups
1287 .into_iter()
1288 .map(|(root_id, members)| {
1289 let album_dir = album_dir_of(&members);
1290 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
1291 kind: ArtifactKind::FolderJpg,
1292 path: album_child(&album_dir, "folder.jpg"),
1293 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
1294 hash: art_hash(&source.clip),
1295 content: None,
1296 });
1297 let folder_webp = animated_covers
1298 .then(|| folder_webp_source(&members))
1299 .flatten()
1300 .map(|source| DesiredArtifact {
1301 kind: ArtifactKind::FolderWebp,
1302 path: album_child(&album_dir, "cover.webp"),
1303 source_url: source.clip.video_cover_url.clone(),
1304 hash: art_url_hash(&source.clip.video_cover_url),
1305 content: None,
1306 });
1307 let folder_mp4 = raw_cover
1308 .then(|| folder_webp_source(&members))
1309 .flatten()
1310 .map(|source| DesiredArtifact {
1311 kind: ArtifactKind::FolderMp4,
1312 path: album_child(&album_dir, "cover.mp4"),
1313 source_url: source.clip.video_cover_url.clone(),
1314 hash: art_url_hash(&source.clip.video_cover_url),
1315 content: None,
1316 });
1317 AlbumDesired {
1318 root_id: root_id.to_owned(),
1319 folder_jpg,
1320 folder_webp,
1321 folder_mp4,
1322 }
1323 })
1324 .collect()
1325}
1326
1327fn album_dir_of(members: &[&Desired]) -> String {
1332 members
1333 .iter()
1334 .map(|d| parent_dir(&d.path))
1335 .min()
1336 .unwrap_or("")
1337 .to_owned()
1338}
1339
1340fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1346 members
1347 .iter()
1348 .copied()
1349 .filter(|d| {
1350 d.clip
1351 .selected_image_url()
1352 .is_some_and(|url| !url.is_empty())
1353 })
1354 .min_by(|a, b| {
1355 b.clip
1356 .play_count
1357 .cmp(&a.clip.play_count)
1358 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
1359 .then_with(|| a.clip.id.cmp(&b.clip.id))
1360 })
1361}
1362
1363fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1368 members
1369 .iter()
1370 .copied()
1371 .filter(|d| !d.clip.video_cover_url.is_empty())
1372 .min_by(|a, b| {
1373 a.clip
1374 .created_at
1375 .cmp(&b.clip.created_at)
1376 .then_with(|| a.clip.id.cmp(&b.clip.id))
1377 })
1378}
1379
1380fn parent_dir(path: &str) -> &str {
1382 match path.rsplit_once('/') {
1383 Some((dir, _)) => dir,
1384 None => "",
1385 }
1386}
1387
1388fn album_child(album_dir: &str, name: &str) -> String {
1391 if album_dir.is_empty() {
1392 name.to_owned()
1393 } else {
1394 format!("{album_dir}/{name}")
1395 }
1396}
1397
1398pub fn plan_album_artifacts(
1422 desired: &[AlbumDesired],
1423 albums: &BTreeMap<String, AlbumArt>,
1424 can_delete: bool,
1425 local: &HashMap<String, LocalFile>,
1426) -> Vec<Action> {
1427 let mut actions: Vec<Action> = Vec::new();
1428 let by_root: BTreeMap<&str, &AlbumDesired> =
1429 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1430
1431 for d in desired {
1432 let stored = albums.get(&d.root_id);
1433 for artifact in [
1434 d.folder_jpg.as_ref(),
1435 d.folder_webp.as_ref(),
1436 d.folder_mp4.as_ref(),
1437 ]
1438 .into_iter()
1439 .flatten()
1440 {
1441 let needs_write = match stored.and_then(|a| a.artifact(artifact.kind)) {
1442 None => true,
1443 Some(state) => {
1444 state.hash != artifact.hash
1445 || state.path != artifact.path
1446 || local
1447 .get(&state.path)
1448 .is_some_and(|f| !f.exists || f.size == 0)
1449 }
1450 };
1451 if needs_write {
1452 actions.push(Action::WriteArtifact {
1453 kind: artifact.kind,
1454 path: artifact.path.clone(),
1455 source_url: artifact.source_url.clone(),
1456 hash: artifact.hash.clone(),
1457 owner_id: d.root_id.clone(),
1458 content: None,
1459 });
1460 }
1461 }
1462 }
1463
1464 if can_delete {
1466 for (root_id, art) in albums {
1467 for (kind, state) in album_artifacts(art) {
1468 let desired_here = by_root
1469 .get(root_id.as_str())
1470 .is_some_and(|d| album_desires_kind(d, kind));
1471 if !desired_here && !state.path.is_empty() {
1472 actions.push(Action::DeleteArtifact {
1473 kind,
1474 path: state.path.clone(),
1475 owner_id: root_id.clone(),
1476 });
1477 }
1478 }
1479 }
1480 }
1481
1482 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1483 actions
1484}
1485
1486fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1489 let mut out = Vec::new();
1490 if let Some(state) = &art.folder_jpg {
1491 out.push((ArtifactKind::FolderJpg, state));
1492 }
1493 if let Some(state) = &art.folder_webp {
1494 out.push((ArtifactKind::FolderWebp, state));
1495 }
1496 if let Some(state) = &art.folder_mp4 {
1497 out.push((ArtifactKind::FolderMp4, state));
1498 }
1499 out
1500}
1501
1502fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1504 match kind {
1505 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1506 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1507 ArtifactKind::FolderMp4 => d.folder_mp4.is_some(),
1508 ArtifactKind::CoverJpg
1509 | ArtifactKind::CoverWebp
1510 | ArtifactKind::DetailsTxt
1511 | ArtifactKind::LyricsTxt
1512 | ArtifactKind::Lrc
1513 | ArtifactKind::VideoMp4
1514 | ArtifactKind::Playlist => false,
1515 }
1516}
1517
1518fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1520 match action {
1521 Action::WriteArtifact { owner_id, kind, .. }
1522 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1523 _ => ("", ArtifactKind::CoverJpg),
1524 }
1525}
1526
1527pub fn plan_playlist_artifacts(
1565 desired: &[PlaylistDesired],
1566 stored: &BTreeMap<String, PlaylistState>,
1567 can_delete: bool,
1568 list_fully_enumerated: bool,
1569 local: &HashMap<String, LocalFile>,
1570) -> Vec<Action> {
1571 let mut actions: Vec<Action> = Vec::new();
1572 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1573 let deletes_allowed = can_delete && list_fully_enumerated;
1576
1577 for d in desired {
1578 let stored_here = stored.get(&d.id);
1579 let needs_write = match stored_here {
1580 None => true,
1581 Some(state) => {
1582 state.hash != d.hash
1583 || state.path != d.path
1584 || local
1585 .get(&state.path)
1586 .is_some_and(|f| !f.exists || f.size == 0)
1587 }
1588 };
1589 if needs_write {
1590 actions.push(Action::WriteArtifact {
1591 kind: ArtifactKind::Playlist,
1592 path: d.path.clone(),
1593 source_url: String::new(),
1594 hash: d.hash.clone(),
1595 owner_id: d.id.clone(),
1596 content: Some(d.content.clone()),
1597 });
1598 }
1599 if deletes_allowed
1601 && let Some(state) = stored_here
1602 && !state.path.is_empty()
1603 && state.path != d.path
1604 {
1605 actions.push(Action::DeleteArtifact {
1606 kind: ArtifactKind::Playlist,
1607 path: state.path.clone(),
1608 owner_id: d.id.clone(),
1609 });
1610 }
1611 }
1612
1613 if deletes_allowed {
1616 for (id, state) in stored {
1617 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1618 actions.push(Action::DeleteArtifact {
1619 kind: ArtifactKind::Playlist,
1620 path: state.path.clone(),
1621 owner_id: id.clone(),
1622 });
1623 }
1624 }
1625 }
1626
1627 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1628 suppress_path_aliasing(&mut actions);
1631 actions
1632}
1633
1634fn playlist_action_key(action: &Action) -> (&str, u8) {
1637 match action {
1638 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1639 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1640 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1641 _ => ("", 3),
1642 }
1643}
1644
1645#[cfg(test)]
1646mod tests {
1647 use super::*;
1648 use crate::hash::content_hash;
1649
1650 fn clip(id: &str) -> Clip {
1651 Clip {
1652 id: id.to_string(),
1653 title: "Song".to_string(),
1654 ..Default::default()
1655 }
1656 }
1657
1658 fn lineage(id: &str) -> LineageContext {
1659 LineageContext::own_root(&clip(id))
1660 }
1661
1662 fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1663 ManifestEntry {
1664 path: path.to_string(),
1665 format,
1666 meta_hash: meta.to_string(),
1667 art_hash: art.to_string(),
1668 size: 100,
1669 preserve: false,
1670 ..Default::default()
1671 }
1672 }
1673
1674 fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1675 ManifestEntry {
1676 preserve: true,
1677 ..entry(path, format, meta, art)
1678 }
1679 }
1680
1681 fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1682 Desired {
1683 clip: clip(id),
1684 lineage: lineage(id),
1685 path: path.to_string(),
1686 format,
1687 meta_hash: meta.to_string(),
1688 art_hash: art.to_string(),
1689 modes: vec![SourceMode::Mirror],
1690 trashed: false,
1691 private: false,
1692 artifacts: Vec::new(),
1693 stems: None,
1694 }
1695 }
1696
1697 fn present(size: u64) -> LocalFile {
1698 LocalFile { exists: true, size }
1699 }
1700
1701 fn local_present(id: &str) -> HashMap<String, LocalFile> {
1702 [(id.to_string(), present(100))].into_iter().collect()
1703 }
1704
1705 fn mirror_ok() -> Vec<SourceStatus> {
1706 vec![SourceStatus {
1707 mode: SourceMode::Mirror,
1708 fully_enumerated: true,
1709 }]
1710 }
1711
1712 #[test]
1715 fn not_in_manifest_downloads() {
1716 let manifest = Manifest::new();
1717 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1718 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1719 assert_eq!(
1720 plan.actions,
1721 vec![Action::Download {
1722 clip: clip("a"),
1723 lineage: lineage("a"),
1724 path: "a.flac".to_string(),
1725 format: AudioFormat::Flac,
1726 }]
1727 );
1728 }
1729
1730 #[test]
1731 fn unchanged_clip_skips() {
1732 let mut manifest = Manifest::new();
1733 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1734 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1735 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1736 assert_eq!(
1737 plan.actions,
1738 vec![Action::Skip {
1739 clip_id: "a".to_string()
1740 }]
1741 );
1742 }
1743
1744 #[test]
1745 fn meta_change_retags_in_place() {
1746 let mut manifest = Manifest::new();
1747 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1748 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1749 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1750 assert_eq!(
1751 plan.actions,
1752 vec![Action::Retag {
1753 clip: clip("a"),
1754 lineage: lineage("a"),
1755 path: "a.flac".to_string(),
1756 }]
1757 );
1758 }
1759
1760 #[test]
1761 fn art_change_retags_in_place() {
1762 let mut manifest = Manifest::new();
1763 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1764 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1765 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1766 assert_eq!(
1767 plan.actions,
1768 vec![Action::Retag {
1769 clip: clip("a"),
1770 lineage: lineage("a"),
1771 path: "a.flac".to_string(),
1772 }]
1773 );
1774 }
1775
1776 #[test]
1777 fn rename_when_path_changes() {
1778 let mut manifest = Manifest::new();
1779 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1780 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1781 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1782 assert_eq!(
1783 plan.actions,
1784 vec![Action::Rename {
1785 from: "old/a.flac".to_string(),
1786 to: "new/a.flac".to_string(),
1787 }]
1788 );
1789 }
1790
1791 #[test]
1792 fn rename_with_meta_change_also_retags() {
1793 let mut manifest = Manifest::new();
1794 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1795 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1796 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1797 assert_eq!(
1798 plan.actions,
1799 vec![
1800 Action::Rename {
1801 from: "old/a.flac".to_string(),
1802 to: "new/a.flac".to_string(),
1803 },
1804 Action::Retag {
1805 clip: clip("a"),
1806 lineage: lineage("a"),
1807 path: "new/a.flac".to_string(),
1808 },
1809 ]
1810 );
1811 }
1812
1813 #[test]
1814 fn rename_without_meta_change_does_not_retag() {
1815 let mut manifest = Manifest::new();
1816 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1817 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1818 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1819 assert_eq!(plan.renames(), 1);
1820 assert_eq!(plan.retags(), 0);
1821 }
1822
1823 #[test]
1824 fn bulk_album_rename_moves_and_retags_without_redownload() {
1825 let mut manifest = Manifest::new();
1830 for id in ["a", "b", "c"] {
1831 manifest.insert(
1832 id,
1833 entry(
1834 &format!("Creator/Old Album/{id}.flac"),
1835 AudioFormat::Flac,
1836 "old-meta",
1837 "art",
1838 ),
1839 );
1840 }
1841 let d: Vec<Desired> = ["a", "b", "c"]
1842 .iter()
1843 .map(|id| {
1844 desired(
1845 id,
1846 &format!("Creator/New Album/{id}.flac"),
1847 AudioFormat::Flac,
1848 "new-meta",
1849 "art",
1850 )
1851 })
1852 .collect();
1853 let local: HashMap<String, LocalFile> = ["a", "b", "c"]
1854 .iter()
1855 .map(|id| (id.to_string(), present(100)))
1856 .collect();
1857
1858 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1859
1860 assert_eq!(plan.renames(), 3, "every member folder move is a rename");
1861 assert_eq!(
1862 plan.retags(),
1863 3,
1864 "the album tag change retags each in place"
1865 );
1866 assert_eq!(
1867 plan.downloads(),
1868 0,
1869 "an album rename must never re-download"
1870 );
1871 assert_eq!(
1872 plan.deletes(),
1873 0,
1874 "deletion safety: a rename deletes nothing"
1875 );
1876 for id in ["a", "b", "c"] {
1877 assert!(plan.actions.contains(&Action::Rename {
1878 from: format!("Creator/Old Album/{id}.flac"),
1879 to: format!("Creator/New Album/{id}.flac"),
1880 }));
1881 }
1882 }
1883
1884 #[test]
1885 fn format_change_reformats() {
1886 let mut manifest = Manifest::new();
1887 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1888 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1889 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1890 assert_eq!(
1891 plan.actions,
1892 vec![Action::Reformat {
1893 clip: clip("a"),
1894 path: "a.mp3".to_string(),
1895 from_path: "a.flac".to_string(),
1896 from: AudioFormat::Flac,
1897 to: AudioFormat::Mp3,
1898 }]
1899 );
1900 }
1901
1902 #[test]
1903 fn format_change_takes_precedence_over_rename_and_retag() {
1904 let mut manifest = Manifest::new();
1907 manifest.insert(
1908 "a",
1909 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1910 );
1911 let d = vec![desired(
1912 "a",
1913 "new/a.mp3",
1914 AudioFormat::Mp3,
1915 "new",
1916 "new-art",
1917 )];
1918 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1919 assert_eq!(plan.reformats(), 1);
1920 assert_eq!(plan.renames(), 0);
1921 assert_eq!(plan.retags(), 0);
1922 }
1923
1924 #[test]
1927 fn zero_length_file_downloads_even_when_hashes_match() {
1928 let mut manifest = Manifest::new();
1929 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1930 let local: HashMap<String, LocalFile> = [(
1931 "a".to_string(),
1932 LocalFile {
1933 exists: true,
1934 size: 0,
1935 },
1936 )]
1937 .into_iter()
1938 .collect();
1939 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1940 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1941 assert_eq!(plan.downloads(), 1);
1942 assert_eq!(plan.skips(), 0);
1943 }
1944
1945 #[test]
1946 fn missing_file_downloads_even_when_hashes_match() {
1947 let mut manifest = Manifest::new();
1948 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1949 let local: HashMap<String, LocalFile> = [(
1950 "a".to_string(),
1951 LocalFile {
1952 exists: false,
1953 size: 0,
1954 },
1955 )]
1956 .into_iter()
1957 .collect();
1958 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1959 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1960 assert_eq!(plan.downloads(), 1);
1961 }
1962
1963 #[test]
1964 fn absent_local_probe_treated_as_missing() {
1965 let mut manifest = Manifest::new();
1967 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1968 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1969 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1970 assert_eq!(plan.downloads(), 1);
1971 }
1972
1973 #[test]
1974 fn missing_file_download_wins_over_format_difference() {
1975 let mut manifest = Manifest::new();
1978 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1979 let local: HashMap<String, LocalFile> = [(
1980 "a".to_string(),
1981 LocalFile {
1982 exists: false,
1983 size: 0,
1984 },
1985 )]
1986 .into_iter()
1987 .collect();
1988 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1989 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1990 assert_eq!(plan.downloads(), 1);
1991 assert_eq!(plan.reformats(), 0);
1992 }
1993
1994 #[test]
1997 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
1998 let mut trashed = clip("a");
2003 trashed.status = "complete".to_string();
2004 trashed.is_trashed = true;
2005 assert!(crate::is_downloadable(&trashed));
2006
2007 let mut manifest = Manifest::new();
2008 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2009 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2010 d.clip = trashed;
2011 d.trashed = true;
2012 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2013 assert_eq!(
2014 plan.actions,
2015 vec![Action::Delete {
2016 path: "a.flac".to_string(),
2017 clip_id: "a".to_string(),
2018 }]
2019 );
2020 }
2021
2022 #[test]
2023 fn trashed_clip_deletes_local_file() {
2024 let mut manifest = Manifest::new();
2025 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2026 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2027 d.trashed = true;
2028 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2029 assert_eq!(
2030 plan.actions,
2031 vec![Action::Delete {
2032 path: "a.flac".to_string(),
2033 clip_id: "a".to_string(),
2034 }]
2035 );
2036 }
2037
2038 #[test]
2039 fn trashed_clip_not_in_manifest_skips() {
2040 let manifest = Manifest::new();
2042 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2043 d.trashed = true;
2044 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2045 assert_eq!(
2046 plan.actions,
2047 vec![Action::Skip {
2048 clip_id: "a".to_string()
2049 }]
2050 );
2051 }
2052
2053 #[test]
2054 fn private_clip_is_kept() {
2055 let mut manifest = Manifest::new();
2056 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2057 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2058 d.private = true;
2059 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2060 assert_eq!(
2061 plan.actions,
2062 vec![Action::Skip {
2063 clip_id: "a".to_string()
2064 }]
2065 );
2066 }
2067
2068 #[test]
2069 fn private_beats_trashed_never_deletes() {
2070 let mut manifest = Manifest::new();
2072 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2073 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2074 d.trashed = true;
2075 d.private = true;
2076 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2077 assert_eq!(plan.deletes(), 0);
2078 assert_eq!(plan.skips(), 1);
2079 }
2080
2081 #[test]
2082 fn copy_held_trashed_clip_is_not_deleted() {
2083 let mut manifest = Manifest::new();
2086 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2087 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2088 d.modes = vec![SourceMode::Copy];
2089 d.trashed = true;
2090 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2091 assert_eq!(plan.deletes(), 0);
2092 assert_eq!(
2093 plan.actions,
2094 vec![Action::Skip {
2095 clip_id: "a".to_string()
2096 }]
2097 );
2098 }
2099
2100 #[test]
2103 fn absent_clip_deleted_when_all_mirrors_enumerated() {
2104 let mut manifest = Manifest::new();
2105 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2106 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2107 assert_eq!(
2108 plan.actions,
2109 vec![Action::Delete {
2110 path: "gone.flac".to_string(),
2111 clip_id: "gone".to_string(),
2112 }]
2113 );
2114 }
2115
2116 #[test]
2117 fn absent_clip_kept_when_any_mirror_not_enumerated() {
2118 let mut manifest = Manifest::new();
2119 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2120 let sources = vec![
2121 SourceStatus {
2122 mode: SourceMode::Mirror,
2123 fully_enumerated: true,
2124 },
2125 SourceStatus {
2126 mode: SourceMode::Mirror,
2127 fully_enumerated: false,
2128 },
2129 ];
2130 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2131 assert_eq!(plan.deletes(), 0);
2132 assert_eq!(
2133 plan.actions,
2134 vec![Action::Skip {
2135 clip_id: "gone".to_string()
2136 }]
2137 );
2138 }
2139
2140 #[test]
2141 fn empty_listing_cannot_cause_deletion() {
2142 let mut manifest = Manifest::new();
2145 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2146 let sources = vec![SourceStatus {
2147 mode: SourceMode::Mirror,
2148 fully_enumerated: false,
2149 }];
2150 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2151 assert_eq!(plan.deletes(), 0);
2152 assert_eq!(plan.skips(), 1);
2153 }
2154
2155 #[test]
2156 fn no_mirror_sources_means_no_deletion() {
2157 let mut manifest = Manifest::new();
2159 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2160 let copy_only = vec![SourceStatus {
2161 mode: SourceMode::Copy,
2162 fully_enumerated: true,
2163 }];
2164 assert_eq!(
2165 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
2166 0
2167 );
2168 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
2169 }
2170
2171 #[test]
2172 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
2173 let mut manifest = Manifest::new();
2174 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2175 let sources = vec![
2176 SourceStatus {
2177 mode: SourceMode::Copy,
2178 fully_enumerated: true,
2179 },
2180 SourceStatus {
2181 mode: SourceMode::Mirror,
2182 fully_enumerated: false,
2183 },
2184 ];
2185 assert_eq!(
2186 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
2187 0
2188 );
2189 }
2190
2191 #[test]
2192 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
2193 let mut manifest = Manifest::new();
2197 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
2198 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2199 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
2200 held.modes = vec![SourceMode::Copy];
2201 let local: HashMap<String, LocalFile> = [
2202 ("keep".to_string(), present(100)),
2203 ("gone".to_string(), present(100)),
2204 ]
2205 .into_iter()
2206 .collect();
2207 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
2208 assert!(plan.actions.contains(&Action::Skip {
2209 clip_id: "keep".to_string()
2210 }));
2211 assert!(plan.actions.contains(&Action::Delete {
2212 path: "gone.flac".to_string(),
2213 clip_id: "gone".to_string(),
2214 }));
2215 assert!(
2217 !plan
2218 .actions
2219 .iter()
2220 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
2221 );
2222 }
2223
2224 #[test]
2227 fn orphan_with_preserve_marker_is_kept() {
2228 let mut manifest = Manifest::new();
2231 manifest.insert(
2232 "gone",
2233 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
2234 );
2235 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2236 assert_eq!(plan.deletes(), 0);
2237 assert_eq!(
2238 plan.actions,
2239 vec![Action::Skip {
2240 clip_id: "gone".to_string()
2241 }]
2242 );
2243 }
2244
2245 #[test]
2246 fn trashed_clip_with_preserve_marker_is_kept() {
2247 let mut manifest = Manifest::new();
2250 manifest.insert(
2251 "a",
2252 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2253 );
2254 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2255 d.trashed = true;
2256 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2257 assert_eq!(plan.deletes(), 0);
2258 assert_eq!(plan.skips(), 1);
2259 }
2260
2261 #[test]
2264 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
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 let sources = vec![SourceStatus {
2271 mode: SourceMode::Mirror,
2272 fully_enumerated: false,
2273 }];
2274 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2275 assert_eq!(plan.deletes(), 0);
2276 assert_eq!(plan.skips(), 1);
2277 }
2278
2279 #[test]
2280 fn trashed_clip_kept_when_sources_empty() {
2281 let mut manifest = Manifest::new();
2284 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2285 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2286 d.trashed = true;
2287 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
2288 assert_eq!(plan.deletes(), 0);
2289 assert_eq!(plan.skips(), 1);
2290 }
2291
2292 #[test]
2293 fn failed_copy_listing_suppresses_orphan_deletion() {
2294 let mut manifest = Manifest::new();
2297 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2298 let sources = vec![
2299 SourceStatus {
2300 mode: SourceMode::Mirror,
2301 fully_enumerated: true,
2302 },
2303 SourceStatus {
2304 mode: SourceMode::Copy,
2305 fully_enumerated: false,
2306 },
2307 ];
2308 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2309 assert_eq!(plan.deletes(), 0);
2310 }
2311
2312 #[test]
2313 fn failed_copy_listing_suppresses_trashed_deletion() {
2314 let mut manifest = Manifest::new();
2315 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2316 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2317 d.trashed = true;
2318 let sources = vec![
2319 SourceStatus {
2320 mode: SourceMode::Mirror,
2321 fully_enumerated: true,
2322 },
2323 SourceStatus {
2324 mode: SourceMode::Copy,
2325 fully_enumerated: false,
2326 },
2327 ];
2328 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2329 assert_eq!(plan.deletes(), 0);
2330 assert_eq!(plan.skips(), 1);
2331 }
2332
2333 #[test]
2334 fn empty_path_entry_never_deletes() {
2335 let mut manifest = Manifest::new();
2338 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
2339 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2340 assert_eq!(plan.deletes(), 0);
2341 assert_eq!(
2342 plan.actions,
2343 vec![Action::Skip {
2344 clip_id: "gone".to_string()
2345 }]
2346 );
2347 }
2348
2349 #[test]
2352 fn delete_suppressed_when_path_aliases_rename_target() {
2353 let mut manifest = Manifest::new();
2356 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
2357 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
2358 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
2359 let local: HashMap<String, LocalFile> = [
2360 ("a".to_string(), present(100)),
2361 ("b".to_string(), present(100)),
2362 ]
2363 .into_iter()
2364 .collect();
2365 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2366 assert!(plan.actions.contains(&Action::Rename {
2367 from: "old/a.flac".to_string(),
2368 to: "new/a.flac".to_string(),
2369 }));
2370 assert!(
2372 !plan
2373 .actions
2374 .iter()
2375 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
2376 );
2377 assert!(plan.actions.contains(&Action::Skip {
2378 clip_id: "b".to_string()
2379 }));
2380 }
2381
2382 #[test]
2383 fn delete_suppressed_when_path_aliases_download_target() {
2384 let mut manifest = Manifest::new();
2386 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
2387 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
2388 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2389 assert!(
2390 !plan
2391 .actions
2392 .iter()
2393 .any(|a| matches!(a, Action::Delete { .. }))
2394 );
2395 assert_eq!(plan.downloads(), 1);
2396 }
2397
2398 #[test]
2399 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
2400 let mut actions = vec![
2405 Action::Rename {
2406 from: "old/song.flac".to_string(),
2407 to: "new/cover.jpg".to_string(),
2408 },
2409 Action::DeleteArtifact {
2410 kind: ArtifactKind::CoverJpg,
2411 path: "new/cover.jpg".to_string(),
2412 owner_id: "a".to_string(),
2413 },
2414 ];
2415 suppress_path_aliasing(&mut actions);
2416 assert!(
2418 !actions
2419 .iter()
2420 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
2421 "a sidecar delete must not alias a rename target"
2422 );
2423 assert!(actions.contains(&Action::Skip {
2424 clip_id: "a".to_string()
2425 }));
2426 assert!(actions.contains(&Action::Rename {
2428 from: "old/song.flac".to_string(),
2429 to: "new/cover.jpg".to_string(),
2430 }));
2431 }
2432
2433 #[test]
2434 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
2435 let mut actions = vec![
2438 Action::WriteArtifact {
2439 kind: ArtifactKind::FolderJpg,
2440 path: "creator/album/folder.jpg".to_string(),
2441 source_url: "https://art/large.jpg".to_string(),
2442 hash: "h".to_string(),
2443 owner_id: "root".to_string(),
2444 content: None,
2445 },
2446 Action::DeleteArtifact {
2447 kind: ArtifactKind::FolderJpg,
2448 path: "creator/album/folder.jpg".to_string(),
2449 owner_id: "root-old".to_string(),
2450 },
2451 ];
2452 suppress_path_aliasing(&mut actions);
2453 assert!(
2454 !actions
2455 .iter()
2456 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
2457 );
2458 assert!(actions.contains(&Action::Skip {
2459 clip_id: "root-old".to_string()
2460 }));
2461 }
2462
2463 #[test]
2466 fn duplicate_trashed_does_not_defeat_copy_sibling() {
2467 let mut manifest = Manifest::new();
2470 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2471 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2472 copy_entry.modes = vec![SourceMode::Copy];
2473 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2474 trashed_entry.modes = vec![SourceMode::Mirror];
2475 trashed_entry.trashed = true;
2476 let plan = reconcile(
2477 &manifest,
2478 &[copy_entry, trashed_entry],
2479 &local_present("a"),
2480 &mirror_ok(),
2481 );
2482 assert_eq!(plan.deletes(), 0);
2483 assert_eq!(plan.skips(), 1);
2484 }
2485
2486 #[test]
2487 fn duplicate_trashed_does_not_defeat_private_sibling() {
2488 let mut manifest = Manifest::new();
2489 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2490 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2491 private_entry.private = true;
2492 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2493 trashed_entry.trashed = true;
2494 let plan = reconcile(
2495 &manifest,
2496 &[private_entry, trashed_entry],
2497 &local_present("a"),
2498 &mirror_ok(),
2499 );
2500 assert_eq!(plan.deletes(), 0);
2501 assert_eq!(plan.skips(), 1);
2502 }
2503
2504 #[test]
2505 fn duplicate_trashed_deletes_only_when_all_trashed() {
2506 let mut manifest = Manifest::new();
2508 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2509 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2510 first.trashed = true;
2511 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2512 second.trashed = true;
2513 let plan = reconcile(
2514 &manifest,
2515 &[first, second],
2516 &local_present("a"),
2517 &mirror_ok(),
2518 );
2519 assert_eq!(plan.deletes(), 1);
2520 }
2521
2522 #[test]
2523 fn duplicate_desired_unions_modes() {
2524 let mut manifest = Manifest::new();
2526 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2527 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2528 mirror_entry.modes = vec![SourceMode::Mirror];
2529 mirror_entry.trashed = true;
2530 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2531 copy_entry.modes = vec![SourceMode::Copy];
2532 let plan = reconcile(
2533 &manifest,
2534 &[mirror_entry, copy_entry],
2535 &local_present("a"),
2536 &mirror_ok(),
2537 );
2538 assert_eq!(plan.deletes(), 0);
2540 }
2541
2542 #[test]
2545 fn private_new_clip_downloads() {
2546 let manifest = Manifest::new();
2549 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2550 d.private = true;
2551 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2552 assert_eq!(plan.downloads(), 1);
2553 }
2554
2555 #[test]
2556 fn private_zero_length_file_redownloads() {
2557 let mut manifest = Manifest::new();
2558 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2559 let local: HashMap<String, LocalFile> = [(
2560 "a".to_string(),
2561 LocalFile {
2562 exists: true,
2563 size: 0,
2564 },
2565 )]
2566 .into_iter()
2567 .collect();
2568 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2569 d.private = true;
2570 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2571 assert_eq!(plan.downloads(), 1);
2572 }
2573
2574 #[test]
2575 fn private_meta_change_retags() {
2576 let mut manifest = Manifest::new();
2577 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2578 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2579 d.private = true;
2580 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2581 assert_eq!(plan.retags(), 1);
2582 assert_eq!(plan.deletes(), 0);
2583 }
2584
2585 #[test]
2586 fn absent_private_clip_protected_by_preserve_marker() {
2587 let mut manifest = Manifest::new();
2590 manifest.insert(
2591 "a",
2592 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2593 );
2594 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2595 assert_eq!(plan.deletes(), 0);
2596 assert_eq!(plan.skips(), 1);
2597 }
2598
2599 #[test]
2602 fn output_is_deterministic_regardless_of_input_order() {
2603 let mut manifest = Manifest::new();
2604 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2605 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2606 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2607 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2608 .iter()
2609 .map(|id| (id.to_string(), present(100)))
2610 .collect();
2611
2612 let forward = vec![
2613 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2614 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2615 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2616 ];
2617 let mut reversed = forward.clone();
2618 reversed.reverse();
2619
2620 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2621 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2622 assert_eq!(p1.actions, p2.actions);
2623
2624 let ids: Vec<&str> = p1
2627 .actions
2628 .iter()
2629 .map(|a| match a {
2630 Action::Skip { clip_id } => clip_id.as_str(),
2631 Action::Retag { clip, .. } => clip.id.as_str(),
2632 Action::Download { clip, .. } => clip.id.as_str(),
2633 Action::Delete { clip_id, .. } => clip_id.as_str(),
2634 Action::Reformat { clip, .. } => clip.id.as_str(),
2635 Action::Rename { to, .. } => to.as_str(),
2636 Action::WriteArtifact { owner_id, .. }
2637 | Action::DeleteArtifact { owner_id, .. }
2638 | Action::MoveArtifact { owner_id, .. } => owner_id.as_str(),
2639 Action::WriteStem { clip_id, .. }
2640 | Action::DeleteStem { clip_id, .. }
2641 | Action::MoveStem { clip_id, .. } => clip_id.as_str(),
2642 })
2643 .collect();
2644 assert_eq!(ids, ["a", "b", "c", "z"]);
2645 }
2646
2647 #[test]
2648 fn empty_inputs_do_not_panic() {
2649 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2650 assert!(plan.is_empty());
2651 assert_eq!(plan.len(), 0);
2652 }
2653
2654 #[test]
2655 fn empty_desired_with_full_manifest_deletes_all() {
2656 let mut manifest = Manifest::new();
2657 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2658 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2659 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2660 assert_eq!(plan.deletes(), 2);
2661 }
2662
2663 #[test]
2664 fn full_desired_with_empty_manifest_downloads_all() {
2665 let d = vec![
2666 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2667 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2668 ];
2669 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2670 assert_eq!(plan.downloads(), 2);
2671 }
2672
2673 #[test]
2674 fn plan_counts_sum_to_len() {
2675 let mut manifest = Manifest::new();
2676 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2677 manifest.insert(
2678 "retag",
2679 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2680 );
2681 manifest.insert(
2682 "reformat",
2683 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2684 );
2685 manifest.insert(
2686 "rename",
2687 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2688 );
2689 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2690 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2691 .iter()
2692 .map(|id| (id.to_string(), present(100)))
2693 .collect();
2694 let d = vec![
2695 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2696 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2697 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2698 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2699 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2700 ];
2701 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2702 let summed = plan.downloads()
2703 + plan.reformats()
2704 + plan.retags()
2705 + plan.renames()
2706 + plan.deletes()
2707 + plan.skips();
2708 assert_eq!(summed, plan.len());
2709 assert_eq!(plan.downloads(), 1);
2710 assert_eq!(plan.reformats(), 1);
2711 assert_eq!(plan.retags(), 1);
2712 assert_eq!(plan.renames(), 1);
2713 assert_eq!(plan.deletes(), 1);
2714 assert_eq!(plan.skips(), 1);
2715 }
2716
2717 fn cover(path: &str, hash: &str) -> ArtifactState {
2720 ArtifactState {
2721 path: path.to_string(),
2722 hash: hash.to_string(),
2723 }
2724 }
2725
2726 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2727 DesiredArtifact {
2728 kind,
2729 path: path.to_string(),
2730 source_url: url.to_string(),
2731 hash: hash.to_string(),
2732 content: None,
2733 }
2734 }
2735
2736 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2738 DesiredArtifact {
2739 kind,
2740 path: path.to_string(),
2741 source_url: String::new(),
2742 hash: content_hash(body),
2743 content: Some(body.to_string()),
2744 }
2745 }
2746
2747 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2749 Desired {
2750 artifacts: arts,
2751 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2752 }
2753 }
2754
2755 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2757 ManifestEntry {
2758 cover_jpg: Some(cover(cover_path, cover_hash)),
2759 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2760 }
2761 }
2762
2763 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2764 plan.actions
2765 .iter()
2766 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2767 .collect()
2768 }
2769
2770 #[test]
2771 fn write_artifact_emitted_when_manifest_lacks_it() {
2772 let mut manifest = Manifest::new();
2775 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2776 let d = vec![desired_arts(
2777 "a",
2778 vec![art(
2779 ArtifactKind::CoverJpg,
2780 "a/cover.jpg",
2781 "https://art/a",
2782 "h1",
2783 )],
2784 )];
2785 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2786 assert_eq!(plan.artifact_writes(), 1);
2787 assert_eq!(plan.artifact_deletes(), 0);
2788 assert_eq!(plan.skips(), 1);
2789 assert_eq!(
2790 write_artifacts(&plan)[0],
2791 &Action::WriteArtifact {
2792 kind: ArtifactKind::CoverJpg,
2793 path: "a/cover.jpg".to_string(),
2794 source_url: "https://art/a".to_string(),
2795 hash: "h1".to_string(),
2796 owner_id: "a".to_string(),
2797 content: None,
2798 }
2799 );
2800 }
2801
2802 #[test]
2803 fn write_artifact_emitted_when_hash_differs() {
2804 let mut manifest = Manifest::new();
2807 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2808 let d = vec![desired_arts(
2809 "a",
2810 vec![art(
2811 ArtifactKind::CoverJpg,
2812 "a/cover.jpg",
2813 "https://art/a",
2814 "new",
2815 )],
2816 )];
2817 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2818 assert_eq!(plan.artifact_writes(), 1);
2819 assert_eq!(plan.artifact_deletes(), 0);
2820 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2821 assert_eq!(hash, "new");
2822 } else {
2823 panic!("expected a WriteArtifact");
2824 }
2825 }
2826
2827 #[test]
2828 fn write_artifact_skipped_when_hash_matches() {
2829 let mut manifest = Manifest::new();
2831 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2832 let d = vec![desired_arts(
2833 "a",
2834 vec![art(
2835 ArtifactKind::CoverJpg,
2836 "a/cover.jpg",
2837 "https://art/a",
2838 "h1",
2839 )],
2840 )];
2841 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2842 assert_eq!(plan.artifact_writes(), 0);
2843 assert_eq!(plan.artifact_deletes(), 0);
2844 assert_eq!(
2845 plan.actions,
2846 vec![Action::Skip {
2847 clip_id: "a".to_string()
2848 }]
2849 );
2850 }
2851
2852 #[test]
2853 fn removed_kind_cover_is_kept_not_deleted() {
2854 let mut manifest = Manifest::new();
2859 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2860 let d = vec![desired_arts("a", vec![])];
2861 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2862 assert_eq!(plan.artifact_deletes(), 0);
2863 assert_eq!(plan.artifact_writes(), 0);
2864 assert_eq!(plan.deletes(), 0);
2866 assert_eq!(
2867 plan.actions,
2868 vec![Action::Skip {
2869 clip_id: "a".to_string()
2870 }]
2871 );
2872 assert!(!plan.actions.iter().any(|a| matches!(
2873 a,
2874 Action::DeleteArtifact {
2875 kind: ArtifactKind::CoverJpg,
2876 ..
2877 }
2878 )));
2879 }
2880
2881 #[test]
2882 fn delete_artifact_never_on_incomplete_listing() {
2883 let mut manifest = Manifest::new();
2888 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2889 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2890 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2891 let sources = vec![SourceStatus {
2892 mode: SourceMode::Mirror,
2893 fully_enumerated: false,
2894 }];
2895 let local: HashMap<String, LocalFile> = [
2896 ("a".to_string(), present(100)),
2897 ("b".to_string(), present(100)),
2898 ]
2899 .into_iter()
2900 .collect();
2901 let plan = reconcile(&manifest, &d, &local, &sources);
2902 assert_eq!(plan.artifact_deletes(), 0);
2903 assert_eq!(plan.deletes(), 0);
2904 }
2905
2906 #[test]
2907 fn delete_artifact_never_when_entry_preserved() {
2908 let mut manifest = Manifest::new();
2911 let preserved = ManifestEntry {
2912 preserve: true,
2913 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2914 };
2915 manifest.insert("a", preserved);
2916 let d = vec![desired_arts("a", vec![])];
2917 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2918 assert_eq!(plan.artifact_deletes(), 0);
2919 }
2920
2921 #[test]
2922 fn co_delete_never_when_path_empty() {
2923 let mut manifest = Manifest::new();
2927 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2928 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2929 assert_eq!(plan.deletes(), 1);
2930 assert_eq!(plan.artifact_deletes(), 0);
2931 }
2932
2933 #[test]
2934 fn co_delete_absent_clip_deletes_audio_and_cover() {
2935 let mut manifest = Manifest::new();
2938 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2939 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2940 assert_eq!(plan.deletes(), 1);
2941 assert_eq!(plan.artifact_deletes(), 1);
2942 assert!(plan.actions.contains(&Action::Delete {
2943 path: "gone.flac".to_string(),
2944 clip_id: "gone".to_string(),
2945 }));
2946 assert!(plan.actions.contains(&Action::DeleteArtifact {
2947 kind: ArtifactKind::CoverJpg,
2948 path: "gone/cover.jpg".to_string(),
2949 owner_id: "gone".to_string(),
2950 }));
2951 }
2952
2953 #[test]
2954 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2955 let mut manifest = Manifest::new();
2957 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2958 let sources = vec![SourceStatus {
2959 mode: SourceMode::Mirror,
2960 fully_enumerated: false,
2961 }];
2962 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2963 assert_eq!(plan.deletes(), 0);
2964 assert_eq!(plan.artifact_deletes(), 0);
2965 }
2966
2967 #[test]
2968 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2969 let mut manifest = Manifest::new();
2971 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2972 let mut d = desired_arts("a", vec![]);
2973 d.trashed = true;
2974 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2975 assert_eq!(plan.deletes(), 1);
2976 assert_eq!(plan.artifact_deletes(), 1);
2977 }
2978
2979 #[test]
2980 fn co_delete_trashed_suppressed_when_not_enumerated() {
2981 let mut manifest = Manifest::new();
2983 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2984 let mut d = desired_arts("a", vec![]);
2985 d.trashed = true;
2986 let sources = vec![SourceStatus {
2987 mode: SourceMode::Mirror,
2988 fully_enumerated: false,
2989 }];
2990 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2991 assert_eq!(plan.deletes(), 0);
2992 assert_eq!(plan.artifact_deletes(), 0);
2993 assert_eq!(plan.skips(), 1);
2994 }
2995
2996 #[test]
2997 fn co_delete_trashed_suppressed_when_preserved() {
2998 let mut manifest = Manifest::new();
3000 let preserved = ManifestEntry {
3001 preserve: true,
3002 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
3003 };
3004 manifest.insert("a", preserved);
3005 let mut d = desired_arts("a", vec![]);
3006 d.trashed = true;
3007 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3008 assert_eq!(plan.deletes(), 0);
3009 assert_eq!(plan.artifact_deletes(), 0);
3010 }
3011
3012 #[test]
3015 fn details_sidecar_written_with_inline_content_when_slot_absent() {
3016 let mut manifest = Manifest::new();
3019 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3020 let d = vec![desired_arts(
3021 "a",
3022 vec![text_art(
3023 ArtifactKind::DetailsTxt,
3024 "a.details.txt",
3025 "Title: A\n",
3026 )],
3027 )];
3028 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3029 assert_eq!(plan.artifact_writes(), 1);
3030 assert_eq!(plan.artifact_deletes(), 0);
3031 assert_eq!(
3032 write_artifacts(&plan)[0],
3033 &Action::WriteArtifact {
3034 kind: ArtifactKind::DetailsTxt,
3035 path: "a.details.txt".to_string(),
3036 source_url: String::new(),
3037 hash: content_hash("Title: A\n"),
3038 owner_id: "a".to_string(),
3039 content: Some("Title: A\n".to_string()),
3040 }
3041 );
3042 }
3043
3044 #[test]
3045 fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
3046 let mut manifest = Manifest::new();
3051 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3052 let body = "[re:rs-suno]\nla la\n";
3053 let d = vec![desired_arts(
3054 "a",
3055 vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
3056 )];
3057 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3058 assert_eq!(plan.artifact_writes(), 1);
3059 assert_eq!(plan.artifact_deletes(), 0);
3060 assert_eq!(
3061 write_artifacts(&plan)[0],
3062 &Action::WriteArtifact {
3063 kind: ArtifactKind::Lrc,
3064 path: "a.lrc".to_string(),
3065 source_url: String::new(),
3066 hash: content_hash(body),
3067 owner_id: "a".to_string(),
3068 content: Some(body.to_string()),
3069 }
3070 );
3071 }
3072
3073 #[test]
3074 fn text_sidecars_skipped_when_hash_and_path_match() {
3075 let mut manifest = Manifest::new();
3077 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3078 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3079 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
3080 manifest.insert("a", e);
3081 let d = vec![desired_arts(
3082 "a",
3083 vec![
3084 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
3085 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
3086 ],
3087 )];
3088 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3089 assert_eq!(plan.artifact_writes(), 0);
3090 assert_eq!(plan.artifact_deletes(), 0);
3091 }
3092
3093 #[test]
3094 fn details_rewritten_when_content_hash_differs() {
3095 let mut manifest = Manifest::new();
3098 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3099 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
3100 manifest.insert("a", e);
3101 let d = vec![desired_arts(
3102 "a",
3103 vec![text_art(
3104 ArtifactKind::DetailsTxt,
3105 "a.details.txt",
3106 "Title: New\n",
3107 )],
3108 )];
3109 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3110 assert_eq!(plan.artifact_writes(), 1);
3111 assert_eq!(plan.artifact_deletes(), 0);
3112 }
3113
3114 #[test]
3115 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
3116 let mut manifest = Manifest::new();
3120 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3121 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
3122 manifest.insert("a", e);
3123 let d = vec![desired_arts(
3124 "a",
3125 vec![text_art(
3126 ArtifactKind::LyricsTxt,
3127 "a.lyrics.txt",
3128 "new words\n",
3129 )],
3130 )];
3131 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3132 assert_eq!(plan.artifact_writes(), 1);
3134 assert_eq!(plan.retags(), 0);
3135 }
3136
3137 #[test]
3138 fn text_sidecar_relocated_when_path_differs() {
3139 let mut manifest = Manifest::new();
3142 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3143 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
3144 manifest.insert("a", e);
3145 let d = vec![desired_arts(
3146 "a",
3147 vec![text_art(
3148 ArtifactKind::DetailsTxt,
3149 "new/a.details.txt",
3150 "Title: A\n",
3151 )],
3152 )];
3153 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3154 assert_eq!(plan.artifact_writes(), 1);
3155 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3156 assert_eq!(path, "new/a.details.txt");
3157 } else {
3158 panic!("expected a WriteArtifact");
3159 }
3160 }
3161
3162 #[test]
3163 fn fetched_sidecar_path_drift_emits_move() {
3164 let mut manifest = Manifest::new();
3167 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3168 e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3169 manifest.insert("a", e);
3170 let d = vec![desired_arts(
3171 "a",
3172 vec![art(
3173 ArtifactKind::CoverJpg,
3174 "new/cover.jpg",
3175 "https://art/large.jpg",
3176 "arthash",
3177 )],
3178 )];
3179 let local: HashMap<String, LocalFile> = [
3180 ("a".to_string(), present(100)),
3181 ("old/cover.jpg".to_string(), present(50)),
3182 ]
3183 .into_iter()
3184 .collect();
3185 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3186 assert_eq!(plan.artifact_moves(), 1);
3187 assert_eq!(plan.artifact_writes(), 0);
3188 assert!(plan.actions.contains(&Action::MoveArtifact {
3189 kind: ArtifactKind::CoverJpg,
3190 from: "old/cover.jpg".to_string(),
3191 to: "new/cover.jpg".to_string(),
3192 source_url: "https://art/large.jpg".to_string(),
3193 hash: "arthash".to_string(),
3194 owner_id: "a".to_string(),
3195 }));
3196 }
3197
3198 #[test]
3199 fn sidecar_hash_drift_emits_write_not_move() {
3200 let mut manifest = Manifest::new();
3202 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3203 e.cover_jpg = Some(cover("old/cover.jpg", "oldhash"));
3204 manifest.insert("a", e);
3205 let d = vec![desired_arts(
3206 "a",
3207 vec![art(
3208 ArtifactKind::CoverJpg,
3209 "new/cover.jpg",
3210 "https://art/large.jpg",
3211 "newhash",
3212 )],
3213 )];
3214 let local: HashMap<String, LocalFile> = [
3215 ("a".to_string(), present(100)),
3216 ("old/cover.jpg".to_string(), present(50)),
3217 ]
3218 .into_iter()
3219 .collect();
3220 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3221 assert_eq!(plan.artifact_moves(), 0);
3222 assert_eq!(plan.artifact_writes(), 1);
3223 }
3224
3225 #[test]
3226 fn inline_sidecar_path_drift_stays_a_write() {
3227 let mut manifest = Manifest::new();
3230 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3231 e.lyrics_txt = Some(cover("old/a.lyrics.txt", &content_hash("words\n")));
3232 manifest.insert("a", e);
3233 let d = vec![desired_arts(
3234 "a",
3235 vec![text_art(
3236 ArtifactKind::LyricsTxt,
3237 "new/a.lyrics.txt",
3238 "words\n",
3239 )],
3240 )];
3241 let local: HashMap<String, LocalFile> = [
3242 ("a".to_string(), present(100)),
3243 ("old/a.lyrics.txt".to_string(), present(50)),
3244 ]
3245 .into_iter()
3246 .collect();
3247 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3248 assert_eq!(plan.artifact_moves(), 0);
3249 assert_eq!(plan.artifact_writes(), 1);
3250 }
3251
3252 #[test]
3253 fn sidecar_move_downgrades_to_write_when_old_file_absent() {
3254 let mut manifest = Manifest::new();
3257 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3258 e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3259 manifest.insert("a", e);
3260 let d = vec![desired_arts(
3261 "a",
3262 vec![art(
3263 ArtifactKind::CoverJpg,
3264 "new/cover.jpg",
3265 "https://art/large.jpg",
3266 "arthash",
3267 )],
3268 )];
3269 let local: HashMap<String, LocalFile> = [
3270 ("a".to_string(), present(100)),
3271 (
3272 "old/cover.jpg".to_string(),
3273 LocalFile {
3274 exists: false,
3275 size: 0,
3276 },
3277 ),
3278 ]
3279 .into_iter()
3280 .collect();
3281 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3282 assert_eq!(plan.artifact_moves(), 0);
3283 assert_eq!(plan.artifact_writes(), 1);
3284 }
3285
3286 #[test]
3287 fn move_target_suppresses_a_colliding_delete() {
3288 let mut manifest = Manifest::new();
3291 let mut a = entry("a.flac", AudioFormat::Flac, "m", "art");
3292 a.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3293 manifest.insert("a", a);
3294 let mut b = entry("b.flac", AudioFormat::Flac, "m", "art");
3297 b.details_txt = Some(cover("new/cover.jpg", "bh"));
3298 manifest.insert("b", b);
3299 let d = vec![
3300 desired_arts(
3301 "a",
3302 vec![art(
3303 ArtifactKind::CoverJpg,
3304 "new/cover.jpg",
3305 "https://art/large.jpg",
3306 "arthash",
3307 )],
3308 ),
3309 desired_arts("b", vec![]),
3310 ];
3311 let local: HashMap<String, LocalFile> = [
3312 ("a".to_string(), present(100)),
3313 ("b".to_string(), present(100)),
3314 ("old/cover.jpg".to_string(), present(50)),
3315 ("new/cover.jpg".to_string(), present(50)),
3316 ]
3317 .into_iter()
3318 .collect();
3319 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3320 assert_eq!(plan.artifact_moves(), 1);
3321 assert!(!plan.actions.iter().any(|a| matches!(
3323 a,
3324 Action::DeleteArtifact { path, .. } if path == "new/cover.jpg"
3325 )));
3326 }
3327
3328 #[test]
3329 fn stem_path_drift_emits_move() {
3330 let mut manifest = Manifest::new();
3333 manifest.insert(
3334 "a",
3335 entry_with_stems("a", &[("voc", "old.stems/voc.mp3", "h1")]),
3336 );
3337 let d = vec![stem_desired(
3338 "a",
3339 Some(vec![dstem("voc", "new.stems/voc.mp3", "h1")]),
3340 )];
3341 let local: HashMap<String, LocalFile> = [
3342 ("a".to_string(), present(100)),
3343 ("old.stems/voc.mp3".to_string(), present(50)),
3344 ]
3345 .into_iter()
3346 .collect();
3347 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3348 assert_eq!(plan.stem_moves(), 1);
3349 assert_eq!(plan.stem_writes(), 0);
3350 assert!(plan.actions.contains(&Action::MoveStem {
3351 clip_id: "a".to_string(),
3352 key: "voc".to_string(),
3353 stem_id: "voc".to_string(),
3354 from: "old.stems/voc.mp3".to_string(),
3355 to: "new.stems/voc.mp3".to_string(),
3356 source_url: "https://cdn1.suno.ai/voc.mp3".to_string(),
3357 format: StemFormat::Mp3,
3358 hash: "h1".to_string(),
3359 }));
3360 }
3361
3362 #[test]
3363 fn details_removed_kind_is_deleted_when_feature_off() {
3364 let mut manifest = Manifest::new();
3367 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3368 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3369 manifest.insert("a", e);
3370 let d = vec![desired_arts("a", vec![])];
3371 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3372 assert_eq!(plan.artifact_deletes(), 1);
3373 assert!(plan.actions.contains(&Action::DeleteArtifact {
3374 kind: ArtifactKind::DetailsTxt,
3375 path: "a.details.txt".to_string(),
3376 owner_id: "a".to_string(),
3377 }));
3378 }
3379
3380 #[test]
3381 fn lyrics_removed_kind_is_kept_not_deleted() {
3382 let mut manifest = Manifest::new();
3386 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3387 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3388 manifest.insert("a", e);
3389 let d = vec![desired_arts("a", vec![])];
3390 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3391 assert_eq!(plan.artifact_deletes(), 0);
3392 assert_eq!(plan.deletes(), 0);
3393 }
3394
3395 #[test]
3396 fn lrc_removed_kind_is_kept_not_deleted() {
3397 let mut manifest = Manifest::new();
3400 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3401 e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3402 manifest.insert("a", e);
3403 let d = vec![desired_arts("a", vec![])];
3404 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3405 assert_eq!(plan.artifact_deletes(), 0);
3406 assert_eq!(plan.deletes(), 0);
3407 }
3408
3409 #[test]
3410 fn video_mp4_removed_kind_is_kept_not_deleted() {
3411 let mut manifest = Manifest::new();
3415 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3416 e.video_mp4 = Some(cover("a.mp4", "vid-hash"));
3417 manifest.insert("a", e);
3418 let d = vec![desired_arts("a", vec![])];
3419 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3420 assert_eq!(plan.artifact_deletes(), 0);
3421 assert_eq!(plan.deletes(), 0);
3422 }
3423
3424 #[test]
3425 fn video_mp4_written_when_manifest_lacks_it() {
3426 let mut manifest = Manifest::new();
3429 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3430 let d = vec![desired_arts(
3431 "a",
3432 vec![art(
3433 ArtifactKind::VideoMp4,
3434 "a/song.mp4",
3435 "https://cdn/a/video.mp4",
3436 "vid-hash",
3437 )],
3438 )];
3439 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3440 assert_eq!(plan.artifact_writes(), 1);
3441 assert_eq!(
3442 write_artifacts(&plan)[0],
3443 &Action::WriteArtifact {
3444 kind: ArtifactKind::VideoMp4,
3445 path: "a/song.mp4".to_string(),
3446 source_url: "https://cdn/a/video.mp4".to_string(),
3447 hash: "vid-hash".to_string(),
3448 owner_id: "a".to_string(),
3449 content: None,
3450 }
3451 );
3452 }
3453
3454 #[test]
3455 fn details_removed_kind_not_deleted_on_incomplete_listing() {
3456 let mut manifest = Manifest::new();
3459 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3460 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3461 manifest.insert("a", e);
3462 let d = vec![desired_arts("a", vec![])];
3463 let sources = vec![SourceStatus {
3464 mode: SourceMode::Mirror,
3465 fully_enumerated: false,
3466 }];
3467 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
3468 assert_eq!(plan.artifact_deletes(), 0);
3469 }
3470
3471 #[test]
3472 fn details_removed_kind_not_deleted_when_preserved() {
3473 let mut manifest = Manifest::new();
3476 let mut e = ManifestEntry {
3477 preserve: true,
3478 ..entry("a.flac", AudioFormat::Flac, "m", "art")
3479 };
3480 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3481 manifest.insert("a", e);
3482 let d = vec![desired_arts("a", vec![])];
3483 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3484 assert_eq!(plan.artifact_deletes(), 0);
3485 }
3486
3487 #[test]
3488 fn co_delete_orphan_removes_every_text_sidecar() {
3489 let mut manifest = Manifest::new();
3493 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
3494 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
3495 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
3496 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
3497 e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3498 e.video_mp4 = Some(cover("gone/song.mp4", "vid-hash"));
3499 manifest.insert("gone", e);
3500 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3501 assert_eq!(plan.deletes(), 1);
3502 assert_eq!(plan.artifact_deletes(), 5);
3503 for (kind, path) in [
3504 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
3505 (ArtifactKind::DetailsTxt, "gone.details.txt"),
3506 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
3507 (ArtifactKind::Lrc, "gone.lrc"),
3508 (ArtifactKind::VideoMp4, "gone/song.mp4"),
3509 ] {
3510 assert!(
3511 plan.actions.contains(&Action::DeleteArtifact {
3512 kind,
3513 path: path.to_string(),
3514 owner_id: "gone".to_string(),
3515 }),
3516 "missing co-delete for {kind:?}"
3517 );
3518 }
3519 }
3520
3521 #[test]
3522 fn co_delete_trashed_removes_every_text_sidecar() {
3523 let mut manifest = Manifest::new();
3525 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3526 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3527 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3528 manifest.insert("a", e);
3529 let mut d = desired_arts("a", vec![]);
3530 d.trashed = true;
3531 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3532 assert_eq!(plan.deletes(), 1);
3533 assert_eq!(plan.artifact_deletes(), 2);
3534 }
3535
3536 #[test]
3537 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
3538 let mut manifest = Manifest::new();
3541 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3542 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
3543 let d = vec![desired_arts(
3546 "a",
3547 vec![art(
3548 ArtifactKind::CoverJpg,
3549 "shared/cover.jpg",
3550 "https://art/a",
3551 "h2",
3552 )],
3553 )];
3554 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3555 assert_eq!(plan.artifact_writes(), 1);
3556 assert!(!plan.actions.iter().any(
3558 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
3559 ));
3560 assert!(plan.actions.contains(&Action::Delete {
3562 path: "b.flac".to_string(),
3563 clip_id: "b".to_string(),
3564 }));
3565 }
3566
3567 #[test]
3568 fn suppress_downgrades_delete_artifact_colliding_with_download() {
3569 let mut manifest = Manifest::new();
3571 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
3572 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
3573 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
3574 assert_eq!(plan.downloads(), 1);
3575 assert!(
3576 !plan
3577 .actions
3578 .iter()
3579 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
3580 );
3581 }
3582
3583 #[test]
3584 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
3585 let build = |with_art: bool| {
3589 let mut manifest = Manifest::new();
3590 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
3591 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3592 manifest.insert(
3593 "trash",
3594 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
3595 );
3596 let keep = if with_art {
3597 desired_arts(
3598 "keep",
3599 vec![art(
3600 ArtifactKind::CoverJpg,
3601 "keep/cover.jpg",
3602 "https://art/keep",
3603 "h1",
3604 )],
3605 )
3606 } else {
3607 desired_arts("keep", vec![])
3608 };
3609 let mut trash = desired_arts("trash", vec![]);
3610 trash.trashed = true;
3611 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
3612 .iter()
3613 .map(|id| (id.to_string(), present(100)))
3614 .collect();
3615 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
3616 };
3617
3618 let with = build(true);
3619 let without = build(false);
3620
3621 let audio = |plan: &Plan| -> Vec<Action> {
3623 plan.actions
3624 .iter()
3625 .filter(|a| {
3626 !matches!(
3627 a,
3628 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
3629 )
3630 })
3631 .cloned()
3632 .collect()
3633 };
3634 assert_eq!(audio(&with), audio(&without));
3635 assert_eq!(with.deletes(), without.deletes());
3636 assert_eq!(with.deletes(), 2);
3638 assert_eq!(with.artifact_deletes(), 2);
3642 assert_eq!(with.artifact_writes(), 0);
3643 }
3644
3645 #[test]
3648 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
3649 let mut manifest = Manifest::new();
3655 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3656 assert!(!manifest.get("a").unwrap().preserve);
3657
3658 let private = Desired {
3660 private: true,
3661 ..desired_arts("a", vec![])
3662 };
3663 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
3664 assert_eq!(plan.artifact_deletes(), 0);
3665
3666 let copy_held = Desired {
3668 modes: vec![SourceMode::Copy],
3669 ..desired_arts("a", vec![])
3670 };
3671 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
3672 assert_eq!(plan.artifact_deletes(), 0);
3673 }
3674
3675 #[test]
3676 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
3677 let mut manifest = Manifest::new();
3683 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
3684 let d = vec![desired_arts(
3685 "a",
3686 vec![art(
3687 ArtifactKind::CoverJpg,
3688 "new/cover.jpg",
3689 "https://art/a",
3690 "h1",
3691 )],
3692 )];
3693 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3694 assert_eq!(plan.artifact_writes(), 1);
3695 assert_eq!(plan.artifact_deletes(), 0);
3696 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3697 assert_eq!(path, "new/cover.jpg");
3698 } else {
3699 panic!("expected a WriteArtifact");
3700 }
3701 }
3702
3703 #[test]
3704 fn per_clip_reconcile_ignores_album_and_library_kinds() {
3705 let mut manifest = Manifest::new();
3709 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3710 let d = vec![desired_arts(
3711 "a",
3712 vec![
3713 art(
3714 ArtifactKind::FolderJpg,
3715 "a/folder.jpg",
3716 "https://art/folder",
3717 "hf",
3718 ),
3719 art(
3720 ArtifactKind::Playlist,
3721 "a/list.m3u",
3722 "https://art/list",
3723 "hp",
3724 ),
3725 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
3726 ],
3727 )];
3728 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3729 assert_eq!(plan.artifact_writes(), 1);
3730 let paths: Vec<&str> = plan
3731 .actions
3732 .iter()
3733 .filter_map(|a| match a {
3734 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3735 _ => None,
3736 })
3737 .collect();
3738 assert_eq!(paths, vec!["a/cover.jpg"]);
3739 }
3740
3741 #[test]
3742 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3743 let mut manifest = Manifest::new();
3744 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3745 let d = vec![desired_arts(
3746 "a",
3747 vec![art(
3748 ArtifactKind::FolderWebp,
3749 "a/folder.webp",
3750 "https://art/folder",
3751 "hf",
3752 )],
3753 )];
3754 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3755 assert_eq!(plan.artifact_writes(), 0);
3756 assert_eq!(plan.artifact_deletes(), 0);
3757 }
3758
3759 fn local_with_missing(audio_id: &str, missing_path: &str) -> HashMap<String, LocalFile> {
3763 let mut m = local_present(audio_id);
3764 m.insert(missing_path.to_owned(), LocalFile::default());
3765 m
3766 }
3767
3768 fn local_with_present_artifact(
3770 audio_id: &str,
3771 artifact_path: &str,
3772 ) -> HashMap<String, LocalFile> {
3773 let mut m = local_present(audio_id);
3774 m.insert(artifact_path.to_owned(), present(50));
3775 m
3776 }
3777
3778 #[test]
3779 fn sidecar_missing_on_disk_forces_rewrite() {
3780 let mut manifest = Manifest::new();
3784 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3785 let d = vec![desired_arts(
3786 "a",
3787 vec![art(
3788 ArtifactKind::CoverJpg,
3789 "a/cover.jpg",
3790 "https://art/a",
3791 "h1",
3792 )],
3793 )];
3794 let local = local_with_missing("a", "a/cover.jpg");
3795 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3796 assert_eq!(
3797 plan.artifact_writes(),
3798 1,
3799 "missing sidecar must be rewritten"
3800 );
3801 assert_eq!(plan.artifact_deletes(), 0);
3802 }
3803
3804 #[test]
3805 fn sidecar_present_on_disk_with_matching_hash_no_churn() {
3806 let mut manifest = Manifest::new();
3808 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3809 let d = vec![desired_arts(
3810 "a",
3811 vec![art(
3812 ArtifactKind::CoverJpg,
3813 "a/cover.jpg",
3814 "https://art/a",
3815 "h1",
3816 )],
3817 )];
3818 let local = local_with_present_artifact("a", "a/cover.jpg");
3819 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3820 assert_eq!(plan.artifact_writes(), 0, "present sidecar must not churn");
3821 assert_eq!(plan.artifact_deletes(), 0);
3822 }
3823
3824 #[test]
3825 fn sidecar_probe_absent_falls_back_to_hash_comparison_no_write() {
3826 let mut manifest = Manifest::new();
3830 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3831 let d = vec![desired_arts(
3832 "a",
3833 vec![art(
3834 ArtifactKind::CoverJpg,
3835 "a/cover.jpg",
3836 "https://art/a",
3837 "h1",
3838 )],
3839 )];
3840 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3842 assert_eq!(
3843 plan.artifact_writes(),
3844 0,
3845 "no write when probe unavailable and hash matches"
3846 );
3847 assert_eq!(
3848 plan.artifact_deletes(),
3849 0,
3850 "missing probe must never trigger a delete"
3851 );
3852 }
3853
3854 #[test]
3855 fn folder_art_missing_on_disk_forces_rewrite() {
3856 let members = vec![album_member(
3859 album_clip("a", 1, "t0", "art-a", ""),
3860 "root",
3861 "c/al/a.flac",
3862 )];
3863 let desired = album_desired(&members, false, false);
3864 let mut albums = BTreeMap::new();
3865 albums.insert(
3866 "root".to_string(),
3867 AlbumArt {
3868 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3869 folder_webp: None,
3870 folder_mp4: None,
3871 },
3872 );
3873 let mut local: HashMap<String, LocalFile> = HashMap::new();
3874 local.insert("c/al/folder.jpg".to_owned(), LocalFile::default());
3875 let actions = plan_album_artifacts(&desired, &albums, true, &local);
3876 assert_eq!(actions.len(), 1, "missing folder art must be rewritten");
3877 assert!(matches!(
3878 &actions[0],
3879 Action::WriteArtifact {
3880 kind: ArtifactKind::FolderJpg,
3881 ..
3882 }
3883 ));
3884 }
3885
3886 #[test]
3887 fn folder_art_present_on_disk_no_churn() {
3888 let members = vec![album_member(
3890 album_clip("a", 1, "t0", "art-a", ""),
3891 "root",
3892 "c/al/a.flac",
3893 )];
3894 let desired = album_desired(&members, false, false);
3895 let mut albums = BTreeMap::new();
3896 albums.insert(
3897 "root".to_string(),
3898 AlbumArt {
3899 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3900 folder_webp: None,
3901 folder_mp4: None,
3902 },
3903 );
3904 let mut local: HashMap<String, LocalFile> = HashMap::new();
3905 local.insert("c/al/folder.jpg".to_owned(), present(5000));
3906 let actions = plan_album_artifacts(&desired, &albums, true, &local);
3907 assert!(
3908 actions.is_empty(),
3909 "present folder art with matching hash must not churn"
3910 );
3911 }
3912
3913 #[test]
3914 fn playlist_missing_on_disk_forces_rewrite() {
3915 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3918 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3919 let mut local: HashMap<String, LocalFile> = HashMap::new();
3920 local.insert("Mix.m3u8".to_owned(), LocalFile::default());
3921 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
3922 assert_eq!(actions.len(), 1, "missing playlist file must be rewritten");
3923 assert!(matches!(
3924 &actions[0],
3925 Action::WriteArtifact {
3926 kind: ArtifactKind::Playlist,
3927 ..
3928 }
3929 ));
3930 }
3931
3932 #[test]
3933 fn playlist_present_on_disk_no_churn() {
3934 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3936 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3937 let mut local: HashMap<String, LocalFile> = HashMap::new();
3938 local.insert("Mix.m3u8".to_owned(), present(200));
3939 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
3940 assert!(
3941 actions.is_empty(),
3942 "present playlist with matching hash must not churn"
3943 );
3944 }
3945
3946 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
3949 Clip {
3950 id: id.to_string(),
3951 title: "Song".to_string(),
3952 image_large_url: image.to_string(),
3953 video_cover_url: video.to_string(),
3954 play_count,
3955 created_at: created_at.to_string(),
3956 ..Default::default()
3957 }
3958 }
3959
3960 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
3961 let mut lineage = LineageContext::own_root(&clip);
3962 lineage.root_id = root_id.to_string();
3963 Desired {
3964 clip,
3965 lineage,
3966 path: path.to_string(),
3967 format: AudioFormat::Flac,
3968 meta_hash: "m".to_string(),
3969 art_hash: "a".to_string(),
3970 modes: vec![SourceMode::Mirror],
3971 trashed: false,
3972 private: false,
3973 artifacts: Vec::new(),
3974 stems: None,
3975 }
3976 }
3977
3978 fn stored(path: &str, hash: &str) -> ArtifactState {
3979 ArtifactState {
3980 path: path.to_string(),
3981 hash: hash.to_string(),
3982 }
3983 }
3984
3985 #[test]
3986 fn folder_jpg_source_is_most_played() {
3987 let members = vec![
3988 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
3989 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
3990 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
3991 ];
3992 let albums = album_desired(&members, false, false);
3993 assert_eq!(albums.len(), 1);
3994 let jpg = albums[0].folder_jpg.as_ref().unwrap();
3995 assert_eq!(jpg.hash, art_url_hash("art-b"));
3997 assert_eq!(jpg.source_url, "art-b");
3998 assert_eq!(jpg.path, "c/al/folder.jpg");
3999 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
4000 }
4001
4002 #[test]
4003 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
4004 let by_time = vec![
4006 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
4007 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
4008 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
4009 ];
4010 let jpg = album_desired(&by_time, false, false)[0]
4011 .folder_jpg
4012 .clone()
4013 .unwrap();
4014 assert_eq!(jpg.source_url, "art-y");
4015
4016 let by_id = vec![
4018 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
4019 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
4020 ];
4021 let jpg = album_desired(&by_id, false, false)[0]
4022 .folder_jpg
4023 .clone()
4024 .unwrap();
4025 assert_eq!(jpg.source_url, "art-g");
4026 }
4027
4028 #[test]
4029 fn folder_webp_source_is_first_created_animated() {
4030 let members = vec![
4031 album_member(
4032 album_clip("a", 9, "t2", "art-a", "vid-a"),
4033 "root",
4034 "c/al/a.flac",
4035 ),
4036 album_member(
4037 album_clip("b", 1, "t0", "art-b", "vid-b"),
4038 "root",
4039 "c/al/b.flac",
4040 ),
4041 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
4042 ];
4043 let webp = album_desired(&members, true, false)[0]
4044 .folder_webp
4045 .clone()
4046 .unwrap();
4047 assert_eq!(webp.source_url, "vid-b");
4049 assert_eq!(webp.hash, art_url_hash("vid-b"));
4050 assert_eq!(webp.path, "c/al/cover.webp");
4051 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
4052 }
4053
4054 #[test]
4055 fn animated_covers_off_yields_no_folder_webp() {
4056 let members = vec![album_member(
4057 album_clip("a", 1, "t0", "art-a", "vid-a"),
4058 "root",
4059 "c/al/a.flac",
4060 )];
4061 let off = album_desired(&members, false, false);
4062 assert!(off[0].folder_webp.is_none());
4063 let on = album_desired(&members, true, false);
4064 assert!(on[0].folder_webp.is_some());
4065 }
4066
4067 #[test]
4068 fn raw_cover_yields_folder_mp4_from_the_webp_source_verbatim() {
4069 let members = vec![
4070 album_member(
4071 album_clip("a", 9, "t2", "art-a", "vid-a"),
4072 "root",
4073 "c/al/a.flac",
4074 ),
4075 album_member(
4076 album_clip("b", 1, "t0", "art-b", "vid-b"),
4077 "root",
4078 "c/al/b.flac",
4079 ),
4080 ];
4081 let album = album_desired(&members, true, true).remove(0);
4085 let webp = album.folder_webp.unwrap();
4086 let mp4 = album.folder_mp4.unwrap();
4087 assert_eq!(mp4.kind, ArtifactKind::FolderMp4);
4088 assert_eq!(mp4.path, "c/al/cover.mp4");
4089 assert_eq!(mp4.source_url, "vid-b");
4090 assert_eq!(mp4.hash, art_url_hash("vid-b"));
4091 assert_eq!(mp4.source_url, webp.source_url, "same variant feeds both");
4092 }
4093
4094 #[test]
4095 fn raw_cover_and_webp_are_independent_toggles() {
4096 let members = vec![album_member(
4097 album_clip("a", 1, "t0", "art-a", "vid-a"),
4098 "root",
4099 "c/al/a.flac",
4100 )];
4101 let webp_only = album_desired(&members, true, false).remove(0);
4103 assert!(webp_only.folder_webp.is_some());
4104 assert!(webp_only.folder_mp4.is_none());
4105 let mp4_only = album_desired(&members, false, true).remove(0);
4107 assert!(mp4_only.folder_webp.is_none());
4108 assert!(mp4_only.folder_mp4.is_some());
4109 }
4110
4111 #[test]
4112 fn raw_cover_needs_an_animated_source() {
4113 let members = vec![album_member(
4115 album_clip("a", 3, "t0", "art-a", ""),
4116 "root",
4117 "c/al/a.flac",
4118 )];
4119 let album = album_desired(&members, true, true).remove(0);
4120 assert!(album.folder_mp4.is_none());
4121 assert!(album.folder_webp.is_none());
4122 }
4123
4124 #[test]
4125 fn album_with_no_art_yields_no_folder_jpg() {
4126 let members = vec![album_member(
4127 album_clip("a", 3, "t0", "", ""),
4128 "root",
4129 "c/al/a.flac",
4130 )];
4131 let albums = album_desired(&members, true, false);
4132 assert!(albums[0].folder_jpg.is_none());
4133 assert!(albums[0].folder_webp.is_none());
4134 }
4135
4136 #[test]
4137 fn album_desired_groups_by_root_id() {
4138 let members = vec![
4139 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
4140 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
4141 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
4142 ];
4143 let albums = album_desired(&members, false, false);
4144 assert_eq!(albums.len(), 2);
4145 assert_eq!(albums[0].root_id, "r1");
4146 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
4147 assert_eq!(
4148 albums[0].folder_jpg.as_ref().unwrap().path,
4149 "c/al1/folder.jpg"
4150 );
4151 assert_eq!(albums[1].root_id, "r2");
4152 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
4153 assert_eq!(
4154 albums[1].folder_jpg.as_ref().unwrap().path,
4155 "c/al2/folder.jpg"
4156 );
4157 }
4158
4159 #[test]
4160 fn plan_writes_folder_art_when_store_empty() {
4161 let members = vec![album_member(
4162 album_clip("a", 1, "t0", "art-a", "vid-a"),
4163 "root",
4164 "c/al/a.flac",
4165 )];
4166 let desired = album_desired(&members, true, false);
4167 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4168 assert_eq!(
4169 actions,
4170 vec![
4171 Action::WriteArtifact {
4172 kind: ArtifactKind::FolderJpg,
4173 path: "c/al/folder.jpg".to_string(),
4174 source_url: "art-a".to_string(),
4175 hash: art_url_hash("art-a"),
4176 owner_id: "root".to_string(),
4177 content: None,
4178 },
4179 Action::WriteArtifact {
4180 kind: ArtifactKind::FolderWebp,
4181 path: "c/al/cover.webp".to_string(),
4182 source_url: "vid-a".to_string(),
4183 hash: art_url_hash("vid-a"),
4184 owner_id: "root".to_string(),
4185 content: None,
4186 },
4187 ]
4188 );
4189 }
4190
4191 #[test]
4192 fn plan_skips_when_hash_and_path_match() {
4193 let members = vec![album_member(
4194 album_clip("a", 1, "t0", "art-a", ""),
4195 "root",
4196 "c/al/a.flac",
4197 )];
4198 let desired = album_desired(&members, false, false);
4199 let mut albums = BTreeMap::new();
4200 albums.insert(
4201 "root".to_string(),
4202 AlbumArt {
4203 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4204 folder_webp: None,
4205 folder_mp4: None,
4206 },
4207 );
4208 assert!(plan_album_artifacts(&desired, &albums, true, &HashMap::new()).is_empty());
4209 }
4210
4211 #[test]
4212 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
4213 let members = vec![album_member(
4214 album_clip("a", 1, "t0", "art-a", ""),
4215 "root",
4216 "c/al/a.flac",
4217 )];
4218 let desired = album_desired(&members, false, false);
4219 let mut albums = BTreeMap::new();
4220 albums.insert(
4221 "root".to_string(),
4222 AlbumArt {
4223 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
4224 folder_webp: None,
4225 folder_mp4: None,
4226 },
4227 );
4228 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4229 assert_eq!(actions.len(), 1);
4230 assert!(matches!(
4231 &actions[0],
4232 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
4233 ));
4234 }
4235
4236 #[test]
4237 fn h1_most_played_flip_to_same_art_writes_nothing() {
4238 let run1 = vec![
4240 album_member(
4241 album_clip("a", 9, "t0", "same-art", ""),
4242 "root",
4243 "c/al/a.flac",
4244 ),
4245 album_member(
4246 album_clip("b", 1, "t1", "same-art", ""),
4247 "root",
4248 "c/al/b.flac",
4249 ),
4250 ];
4251 let desired1 = album_desired(&run1, false, false);
4252 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true, &HashMap::new());
4253 assert_eq!(write1.len(), 1);
4254
4255 let mut albums = BTreeMap::new();
4257 if let Action::WriteArtifact {
4258 path,
4259 hash,
4260 owner_id,
4261 ..
4262 } = &write1[0]
4263 {
4264 albums.insert(
4265 owner_id.clone(),
4266 AlbumArt {
4267 folder_jpg: Some(stored(path, hash)),
4268 folder_webp: None,
4269 folder_mp4: None,
4270 },
4271 );
4272 }
4273
4274 let run2 = vec![
4276 album_member(
4277 album_clip("a", 1, "t0", "same-art", ""),
4278 "root",
4279 "c/al/a.flac",
4280 ),
4281 album_member(
4282 album_clip("b", 9, "t1", "same-art", ""),
4283 "root",
4284 "c/al/b.flac",
4285 ),
4286 ];
4287 let desired2 = album_desired(&run2, false, false);
4288 assert!(plan_album_artifacts(&desired2, &albums, true, &HashMap::new()).is_empty());
4290 }
4291
4292 #[test]
4293 fn h1_flip_to_different_art_writes_exactly_one() {
4294 let mut albums = BTreeMap::new();
4295 albums.insert(
4296 "root".to_string(),
4297 AlbumArt {
4298 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
4299 folder_webp: None,
4300 folder_mp4: None,
4301 },
4302 );
4303 let members = vec![
4305 album_member(
4306 album_clip("a", 1, "t0", "old-art", ""),
4307 "root",
4308 "c/al/a.flac",
4309 ),
4310 album_member(
4311 album_clip("b", 9, "t1", "new-art", ""),
4312 "root",
4313 "c/al/b.flac",
4314 ),
4315 ];
4316 let desired = album_desired(&members, false, false);
4317 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4318 assert_eq!(actions.len(), 1);
4319 assert!(matches!(
4320 &actions[0],
4321 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
4322 ));
4323 }
4324
4325 #[test]
4326 fn one_write_per_album_regardless_of_clip_count() {
4327 let members: Vec<Desired> = (0..200)
4328 .map(|i| {
4329 album_member(
4330 album_clip(
4331 &format!("clip-{i:03}"),
4332 i as u64,
4333 &format!("t{i:03}"),
4334 &format!("art-{i:03}"),
4335 &format!("vid-{i:03}"),
4336 ),
4337 "root",
4338 &format!("c/al/clip-{i:03}.flac"),
4339 )
4340 })
4341 .collect();
4342 let desired = album_desired(&members, true, false);
4343 assert_eq!(desired.len(), 1);
4344 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4345 assert_eq!(actions.len(), 2);
4347 assert_eq!(
4348 actions
4349 .iter()
4350 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
4351 .count(),
4352 2
4353 );
4354 }
4355
4356 #[test]
4357 fn emptied_album_deletes_only_when_can_delete() {
4358 let mut albums = BTreeMap::new();
4359 albums.insert(
4360 "root".to_string(),
4361 AlbumArt {
4362 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
4363 folder_webp: Some(stored("c/al/cover.webp", "hw")),
4364 folder_mp4: Some(stored("c/al/cover.mp4", "hm")),
4365 },
4366 );
4367 let desired: Vec<AlbumDesired> = Vec::new();
4369
4370 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4372
4373 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4375 assert_eq!(
4376 actions,
4377 vec![
4378 Action::DeleteArtifact {
4379 kind: ArtifactKind::FolderJpg,
4380 path: "c/al/folder.jpg".to_string(),
4381 owner_id: "root".to_string(),
4382 },
4383 Action::DeleteArtifact {
4384 kind: ArtifactKind::FolderWebp,
4385 path: "c/al/cover.webp".to_string(),
4386 owner_id: "root".to_string(),
4387 },
4388 Action::DeleteArtifact {
4389 kind: ArtifactKind::FolderMp4,
4390 path: "c/al/cover.mp4".to_string(),
4391 owner_id: "root".to_string(),
4392 },
4393 ]
4394 );
4395 }
4396
4397 #[test]
4398 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
4399 let mut albums = BTreeMap::new();
4400 albums.insert(
4401 "root".to_string(),
4402 AlbumArt {
4403 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4404 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4405 folder_mp4: None,
4406 },
4407 );
4408 let members = vec![album_member(
4411 album_clip("a", 1, "t0", "art-a", "vid-a"),
4412 "root",
4413 "c/al/a.flac",
4414 )];
4415 let desired = album_desired(&members, false, false);
4416
4417 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4418
4419 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4420 assert_eq!(
4421 actions,
4422 vec![Action::DeleteArtifact {
4423 kind: ArtifactKind::FolderWebp,
4424 path: "c/al/cover.webp".to_string(),
4425 owner_id: "root".to_string(),
4426 }]
4427 );
4428 }
4429
4430 #[test]
4431 fn disappeared_raw_cover_deletes_only_that_kind_when_gated() {
4432 let mut albums = BTreeMap::new();
4433 albums.insert(
4434 "root".to_string(),
4435 AlbumArt {
4436 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4437 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4438 folder_mp4: Some(stored("c/al/cover.mp4", &art_url_hash("vid-a"))),
4439 },
4440 );
4441 let members = vec![album_member(
4444 album_clip("a", 1, "t0", "art-a", "vid-a"),
4445 "root",
4446 "c/al/a.flac",
4447 )];
4448 let desired = album_desired(&members, true, false);
4449
4450 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4452
4453 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4455 assert_eq!(
4456 actions,
4457 vec![Action::DeleteArtifact {
4458 kind: ArtifactKind::FolderMp4,
4459 path: "c/al/cover.mp4".to_string(),
4460 owner_id: "root".to_string(),
4461 }]
4462 );
4463 }
4464
4465 #[test]
4466 fn plan_album_artifacts_is_deterministically_ordered() {
4467 let members = vec![
4468 album_member(
4469 album_clip("a", 1, "t0", "art-a", "vid-a"),
4470 "r2",
4471 "c/al2/a.flac",
4472 ),
4473 album_member(
4474 album_clip("b", 1, "t0", "art-b", "vid-b"),
4475 "r1",
4476 "c/al1/b.flac",
4477 ),
4478 ];
4479 let desired = album_desired(&members, true, true);
4480 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4481 let keys: Vec<(&str, ArtifactKind)> = actions
4482 .iter()
4483 .map(|a| match a {
4484 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
4485 _ => unreachable!(),
4486 })
4487 .collect();
4488 assert_eq!(
4489 keys,
4490 vec![
4491 ("r1", ArtifactKind::FolderJpg),
4492 ("r1", ArtifactKind::FolderWebp),
4493 ("r1", ArtifactKind::FolderMp4),
4494 ("r2", ArtifactKind::FolderJpg),
4495 ("r2", ArtifactKind::FolderWebp),
4496 ("r2", ArtifactKind::FolderMp4),
4497 ]
4498 );
4499 }
4500
4501 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
4504 PlaylistDesired {
4505 id: id.to_owned(),
4506 name: name.to_owned(),
4507 path: path.to_owned(),
4508 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
4509 hash: hash.to_owned(),
4510 }
4511 }
4512
4513 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
4514 PlaylistState {
4515 name: name.to_owned(),
4516 path: path.to_owned(),
4517 hash: hash.to_owned(),
4518 }
4519 }
4520
4521 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
4522 entries
4523 .iter()
4524 .map(|(id, state)| ((*id).to_owned(), state.clone()))
4525 .collect()
4526 }
4527
4528 #[test]
4529 fn playlist_write_emitted_for_a_new_playlist() {
4530 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
4531 let actions =
4532 plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true, &HashMap::new());
4533 assert_eq!(
4534 actions,
4535 vec![Action::WriteArtifact {
4536 kind: ArtifactKind::Playlist,
4537 path: "Road Trip.m3u8".to_owned(),
4538 source_url: String::new(),
4539 hash: "h1".to_owned(),
4540 owner_id: "pl1".to_owned(),
4541 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
4542 }]
4543 );
4544 }
4545
4546 #[test]
4547 fn playlist_write_emitted_when_hash_changes() {
4548 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
4551 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4552 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4553 assert_eq!(actions.len(), 1);
4554 assert!(matches!(
4555 &actions[0],
4556 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
4557 ));
4558 }
4559
4560 #[test]
4561 fn playlist_unchanged_is_idempotent() {
4562 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4563 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4564 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4565 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
4566 }
4567
4568 #[test]
4569 fn playlist_rename_writes_new_and_deletes_old_path() {
4570 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4573 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4574 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4575 assert_eq!(
4576 actions,
4577 vec![
4578 Action::WriteArtifact {
4579 kind: ArtifactKind::Playlist,
4580 path: "Summer.m3u8".to_owned(),
4581 source_url: String::new(),
4582 hash: "h2".to_owned(),
4583 owner_id: "pl1".to_owned(),
4584 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
4585 },
4586 Action::DeleteArtifact {
4587 kind: ArtifactKind::Playlist,
4588 path: "Spring.m3u8".to_owned(),
4589 owner_id: "pl1".to_owned(),
4590 },
4591 ]
4592 );
4593 }
4594
4595 #[test]
4596 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
4597 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4600 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4601 let actions = plan_playlist_artifacts(&desired, &stored, false, true, &HashMap::new());
4602 assert_eq!(actions.len(), 1);
4603 assert!(matches!(
4604 &actions[0],
4605 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
4606 ));
4607 assert!(
4608 !actions
4609 .iter()
4610 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
4611 "old path must not be deleted when deletes are disallowed"
4612 );
4613 }
4614
4615 #[test]
4616 fn playlist_stale_removed_only_under_full_gate() {
4617 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
4620
4621 let deleted = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4622 assert_eq!(
4623 deleted,
4624 vec![Action::DeleteArtifact {
4625 kind: ArtifactKind::Playlist,
4626 path: "Gone.m3u8".to_owned(),
4627 owner_id: "gone".to_owned(),
4628 }]
4629 );
4630
4631 assert!(plan_playlist_artifacts(&[], &stored, false, true, &HashMap::new()).is_empty());
4633 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4634 assert!(plan_playlist_artifacts(&[], &stored, false, false, &HashMap::new()).is_empty());
4635 }
4636
4637 #[test]
4638 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
4639 let stored = pl_store(&[
4644 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4645 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4646 ]);
4647 let actions = plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new());
4648 assert!(
4649 actions.is_empty(),
4650 "a failed playlist listing must plan zero actions, got {actions:?}"
4651 );
4652 }
4653
4654 #[test]
4655 fn b2_empty_list_deletes_only_when_fully_enumerated() {
4656 let stored = pl_store(&[
4661 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4662 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4663 ]);
4664
4665 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4667
4668 let wiped = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4671 assert_eq!(
4672 wiped
4673 .iter()
4674 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
4675 .count(),
4676 2
4677 );
4678 }
4679
4680 #[test]
4681 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
4682 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
4687 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
4688 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4689 assert_eq!(actions.len(), 1);
4691 assert!(matches!(
4692 &actions[0],
4693 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
4694 ));
4695 assert!(
4696 !actions.iter().any(|a| match a {
4697 Action::WriteArtifact { owner_id, .. }
4698 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
4699 _ => false,
4700 }),
4701 "a protected (failed-member) playlist must have no action"
4702 );
4703 }
4704
4705 #[test]
4706 fn playlist_rename_collision_downgrades_the_delete() {
4707 let desired = vec![
4713 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
4714 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
4715 ];
4716 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
4717 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4718 let write_paths: BTreeSet<&str> = actions
4720 .iter()
4721 .filter_map(|a| match a {
4722 Action::WriteArtifact { path, .. } => Some(path.as_str()),
4723 _ => None,
4724 })
4725 .collect();
4726 for a in &actions {
4727 if let Action::DeleteArtifact { path, .. } = a {
4728 assert!(
4729 !write_paths.contains(path.as_str()),
4730 "a playlist delete aliases a write target: {path}"
4731 );
4732 }
4733 }
4734 }
4735
4736 fn dstem(key: &str, path: &str, hash: &str) -> DesiredStem {
4739 DesiredStem {
4740 key: key.to_string(),
4741 stem_id: key.to_string(),
4742 path: path.to_string(),
4743 source_url: format!("https://cdn1.suno.ai/{key}.mp3"),
4744 format: StemFormat::Mp3,
4745 hash: hash.to_string(),
4746 }
4747 }
4748
4749 fn stem_desired(id: &str, stems: Option<Vec<DesiredStem>>) -> Desired {
4751 Desired {
4752 stems,
4753 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
4754 }
4755 }
4756
4757 fn entry_with_stems(id: &str, stems: &[(&str, &str, &str)]) -> ManifestEntry {
4759 let mut e = entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art");
4760 for (key, path, hash) in stems {
4761 e.stems.insert(
4762 key.to_string(),
4763 ArtifactState {
4764 path: path.to_string(),
4765 hash: hash.to_string(),
4766 },
4767 );
4768 }
4769 e
4770 }
4771
4772 fn stem_writes(plan: &Plan) -> Vec<(&str, &str)> {
4773 plan.actions
4774 .iter()
4775 .filter_map(|a| match a {
4776 Action::WriteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4777 _ => None,
4778 })
4779 .collect()
4780 }
4781
4782 fn stem_deletes(plan: &Plan) -> Vec<(&str, &str)> {
4783 plan.actions
4784 .iter()
4785 .filter_map(|a| match a {
4786 Action::DeleteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4787 _ => None,
4788 })
4789 .collect()
4790 }
4791
4792 #[test]
4793 fn stems_none_keeps_every_existing_stem() {
4794 let mut manifest = Manifest::new();
4797 manifest.insert(
4798 "a",
4799 entry_with_stems(
4800 "a",
4801 &[
4802 ("voc", "a.stems/voc.mp3", "h1"),
4803 ("drm", "a.stems/drm.mp3", "h2"),
4804 ],
4805 ),
4806 );
4807 let d = vec![stem_desired("a", None)];
4808 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4809 assert_eq!(plan.stem_writes(), 0);
4810 assert_eq!(plan.stem_deletes(), 0);
4811 }
4812
4813 #[test]
4814 fn stems_authoritative_writes_missing_stems() {
4815 let mut manifest = Manifest::new();
4816 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
4817 let d = vec![stem_desired(
4818 "a",
4819 Some(vec![
4820 dstem("voc", "a.stems/voc.mp3", "h1"),
4821 dstem("drm", "a.stems/drm.mp3", "h2"),
4822 ]),
4823 )];
4824 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4825 assert_eq!(
4826 stem_writes(&plan),
4827 vec![("voc", "a.stems/voc.mp3"), ("drm", "a.stems/drm.mp3")]
4828 );
4829 assert_eq!(plan.stem_deletes(), 0);
4830 }
4831
4832 #[test]
4833 fn stems_authoritative_rewrites_only_on_hash_or_path_drift() {
4834 let mut manifest = Manifest::new();
4835 manifest.insert(
4837 "a",
4838 entry_with_stems(
4839 "a",
4840 &[
4841 ("voc", "a.stems/voc.mp3", "h1"),
4842 ("drm", "a.stems/drm.mp3", "h2"),
4843 ("bas", "old.stems/bas.mp3", "h3"),
4844 ],
4845 ),
4846 );
4847 let d = vec![stem_desired(
4848 "a",
4849 Some(vec![
4850 dstem("voc", "a.stems/voc.mp3", "h1"), dstem("drm", "a.stems/drm.mp3", "h2-new"), dstem("bas", "a.stems/bas.mp3", "h3"), ]),
4854 )];
4855 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4856 assert_eq!(
4857 stem_writes(&plan),
4858 vec![("drm", "a.stems/drm.mp3"), ("bas", "a.stems/bas.mp3")]
4859 );
4860 assert_eq!(plan.stem_deletes(), 0);
4861 }
4862
4863 #[test]
4864 fn stems_authoritative_removes_a_stem_absent_from_the_set() {
4865 let mut manifest = Manifest::new();
4868 manifest.insert(
4869 "a",
4870 entry_with_stems(
4871 "a",
4872 &[
4873 ("voc", "a.stems/voc.mp3", "h1"),
4874 ("drm", "a.stems/drm.mp3", "h2"),
4875 ],
4876 ),
4877 );
4878 let d = vec![stem_desired(
4879 "a",
4880 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
4881 )];
4882 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4883 assert_eq!(plan.stem_writes(), 0);
4884 assert_eq!(stem_deletes(&plan), vec![("drm", "a.stems/drm.mp3")]);
4885 }
4886
4887 #[test]
4888 fn stems_removal_needs_deletion_allowed() {
4889 let mut manifest = Manifest::new();
4892 manifest.insert(
4893 "a",
4894 entry_with_stems(
4895 "a",
4896 &[
4897 ("voc", "a.stems/voc.mp3", "h1"),
4898 ("drm", "a.stems/drm.mp3", "h2"),
4899 ],
4900 ),
4901 );
4902 let d = vec![stem_desired(
4903 "a",
4904 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
4905 )];
4906
4907 let incomplete = vec![SourceStatus {
4908 mode: SourceMode::Mirror,
4909 fully_enumerated: false,
4910 }];
4911 assert_eq!(
4912 reconcile(&manifest, &d, &local_present("a"), &incomplete).stem_deletes(),
4913 0
4914 );
4915
4916 let copy_only = vec![SourceStatus {
4917 mode: SourceMode::Copy,
4918 fully_enumerated: true,
4919 }];
4920 assert_eq!(
4921 reconcile(&manifest, &d, &local_present("a"), ©_only).stem_deletes(),
4922 0
4923 );
4924 }
4925
4926 #[test]
4927 fn stems_removal_skipped_for_preserved_or_protected_clip() {
4928 let mut manifest = Manifest::new();
4929 let mut e = entry_with_stems(
4930 "a",
4931 &[
4932 ("voc", "a.stems/voc.mp3", "h1"),
4933 ("drm", "a.stems/drm.mp3", "h2"),
4934 ],
4935 );
4936 e.preserve = true;
4937 manifest.insert("a", e);
4938 let authoritative = Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]);
4939
4940 let d = vec![stem_desired("a", authoritative.clone())];
4942 assert_eq!(
4943 reconcile(&manifest, &d, &local_present("a"), &mirror_ok()).stem_deletes(),
4944 0
4945 );
4946
4947 let mut manifest2 = Manifest::new();
4949 manifest2.insert(
4950 "a",
4951 entry_with_stems(
4952 "a",
4953 &[
4954 ("voc", "a.stems/voc.mp3", "h1"),
4955 ("drm", "a.stems/drm.mp3", "h2"),
4956 ],
4957 ),
4958 );
4959 let held = Desired {
4960 modes: vec![SourceMode::Mirror, SourceMode::Copy],
4961 stems: authoritative,
4962 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
4963 };
4964 assert_eq!(
4965 reconcile(&manifest2, &[held], &local_present("a"), &mirror_ok()).stem_deletes(),
4966 0
4967 );
4968 }
4969
4970 #[test]
4971 fn stems_are_co_deleted_when_the_song_is_trashed() {
4972 let mut manifest = Manifest::new();
4975 manifest.insert(
4976 "a",
4977 entry_with_stems(
4978 "a",
4979 &[
4980 ("voc", "a.stems/voc.mp3", "h1"),
4981 ("drm", "a.stems/drm.mp3", "h2"),
4982 ],
4983 ),
4984 );
4985 let trashed = Desired {
4986 trashed: true,
4987 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
4988 };
4989 let plan = reconcile(&manifest, &[trashed], &local_present("a"), &mirror_ok());
4990 assert_eq!(plan.deletes(), 1, "the trashed audio is deleted");
4991 let mut deleted: Vec<&str> = stem_deletes(&plan).into_iter().map(|(k, _)| k).collect();
4992 deleted.sort_unstable();
4993 assert_eq!(deleted, vec!["drm", "voc"], "both stems co-deleted");
4994 }
4995
4996 #[test]
4997 fn stems_are_co_deleted_for_an_absent_clip() {
4998 let mut manifest = Manifest::new();
4999 manifest.insert(
5000 "a",
5001 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5002 );
5003 let plan = reconcile(&manifest, &[], &local_present("a"), &mirror_ok());
5005 assert_eq!(plan.deletes(), 1);
5006 assert_eq!(stem_deletes(&plan), vec![("voc", "a.stems/voc.mp3")]);
5007 }
5008
5009 #[test]
5010 fn stems_are_kept_when_absent_clip_listing_is_incomplete() {
5011 let mut manifest = Manifest::new();
5013 manifest.insert(
5014 "a",
5015 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5016 );
5017 let incomplete = vec![SourceStatus {
5018 mode: SourceMode::Mirror,
5019 fully_enumerated: false,
5020 }];
5021 let plan = reconcile(&manifest, &[], &HashMap::new(), &incomplete);
5022 assert_eq!(plan.deletes(), 0);
5023 assert_eq!(plan.stem_deletes(), 0);
5024 }
5025
5026 #[test]
5027 fn stem_delete_is_suppressed_when_it_aliases_a_stem_write() {
5028 let mut manifest = Manifest::new();
5032 manifest.insert(
5033 "a",
5034 entry_with_stems("a", &[("old", "a.stems/mix.mp3", "h1")]),
5035 );
5036 let d = vec![stem_desired(
5037 "a",
5038 Some(vec![dstem("new", "a.stems/mix.mp3", "h2")]),
5039 )];
5040 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5041 assert_eq!(stem_writes(&plan), vec![("new", "a.stems/mix.mp3")]);
5044 assert!(
5045 !plan.actions.iter().any(|a| matches!(
5046 a,
5047 Action::DeleteStem { path, .. } if path == "a.stems/mix.mp3"
5048 )),
5049 "a stem delete must never alias a stem write target"
5050 );
5051 }
5052}
5053
5054#[cfg(test)]
5067mod proptests {
5068 use super::*;
5069 use proptest::collection::{btree_map, hash_map, vec};
5070 use proptest::prelude::*;
5071 use std::collections::BTreeSet;
5072
5073 type DesiredFields = (
5074 String,
5075 AudioFormat,
5076 String,
5077 String,
5078 Vec<SourceMode>,
5079 bool,
5080 bool,
5081 );
5082
5083 fn audio_format() -> impl Strategy<Value = AudioFormat> {
5084 prop_oneof![
5085 Just(AudioFormat::Mp3),
5086 Just(AudioFormat::Flac),
5087 Just(AudioFormat::Wav),
5088 ]
5089 }
5090
5091 fn source_mode() -> impl Strategy<Value = SourceMode> {
5092 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
5093 }
5094
5095 fn clip_id() -> impl Strategy<Value = String> {
5098 (0u8..8).prop_map(|n| format!("c{n}"))
5099 }
5100
5101 fn small_path() -> impl Strategy<Value = String> {
5102 (0u8..6).prop_map(|n| format!("path{n}"))
5103 }
5104
5105 fn manifest_path() -> impl Strategy<Value = String> {
5108 prop_oneof![
5109 1 => Just(String::new()),
5110 6 => small_path(),
5111 ]
5112 }
5113
5114 fn small_hash() -> impl Strategy<Value = String> {
5115 (0u8..4).prop_map(|n| format!("h{n}"))
5116 }
5117
5118 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
5119 (
5120 manifest_path(),
5121 audio_format(),
5122 small_hash(),
5123 small_hash(),
5124 0u64..4,
5125 any::<bool>(),
5126 )
5127 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
5128 ManifestEntry {
5129 path,
5130 format,
5131 meta_hash,
5132 art_hash,
5133 size,
5134 preserve,
5135 ..Default::default()
5136 }
5137 })
5138 }
5139
5140 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
5141 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
5142 }
5143
5144 fn local_file() -> impl Strategy<Value = LocalFile> {
5145 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
5146 }
5147
5148 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
5149 hash_map(clip_id(), local_file(), 0..8)
5150 }
5151
5152 fn source_status() -> impl Strategy<Value = SourceStatus> {
5153 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
5154 mode,
5155 fully_enumerated,
5156 })
5157 }
5158
5159 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5160 vec(source_status(), 0..5)
5161 }
5162
5163 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5164 vec(
5165 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
5166 mode: SourceMode::Copy,
5167 fully_enumerated,
5168 }),
5169 1..5,
5170 )
5171 }
5172
5173 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
5174 (
5175 small_path(),
5176 audio_format(),
5177 small_hash(),
5178 small_hash(),
5179 vec(source_mode(), 1..3),
5180 any::<bool>(),
5181 any::<bool>(),
5182 )
5183 }
5184
5185 fn build_desired(id: String, fields: DesiredFields) -> Desired {
5186 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
5187 let clip = Clip {
5188 id,
5189 title: "t".to_string(),
5190 ..Default::default()
5191 };
5192 Desired {
5193 lineage: LineageContext::own_root(&clip),
5194 clip,
5195 path,
5196 format,
5197 meta_hash,
5198 art_hash,
5199 modes,
5200 trashed,
5201 private,
5202 artifacts: Vec::new(),
5203 stems: None,
5204 }
5205 }
5206
5207 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
5210 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
5211 items
5212 .into_iter()
5213 .map(|(id, fields)| build_desired(id, fields))
5214 .collect()
5215 })
5216 }
5217
5218 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
5219 desired.iter().map(|d| d.clip.id.as_str()).collect()
5220 }
5221
5222 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
5225 desired
5226 .iter()
5227 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
5228 .map(|d| d.clip.id.as_str())
5229 .collect()
5230 }
5231
5232 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
5235 desired
5236 .iter()
5237 .filter(|d| !d.trashed)
5238 .map(|d| d.clip.id.as_str())
5239 .collect()
5240 }
5241
5242 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
5243 plan.actions
5244 .iter()
5245 .filter_map(|a| match a {
5246 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
5247 _ => None,
5248 })
5249 .collect()
5250 }
5251
5252 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
5253 plan.actions
5254 .iter()
5255 .filter_map(|a| match a {
5256 Action::Download { path, .. } | Action::Reformat { path, .. } => {
5257 Some(path.as_str())
5258 }
5259 Action::Rename { to, .. } => Some(to.as_str()),
5260 _ => None,
5261 })
5262 .collect()
5263 }
5264
5265 proptest! {
5266 #![proptest_config(ProptestConfig {
5267 cases: 256,
5268 failure_persistence: None,
5269 ..ProptestConfig::default()
5270 })]
5271
5272 #[test]
5275 fn inv1_desired_clip_deleted_only_when_fully_trashed(
5276 manifest in manifest_strategy(),
5277 desired in desired_strategy(),
5278 local in local_strategy(),
5279 sources in sources_strategy(),
5280 ) {
5281 let plan = reconcile(&manifest, &desired, &local, &sources);
5282 let present = desired_ids(&desired);
5283 let live = non_trashed_ids(&desired);
5284 for id in delete_clip_ids(&plan) {
5285 prop_assert!(
5286 !(present.contains(id) && live.contains(id)),
5287 "deleted a desired clip with a non-trashed duplicate: {id}"
5288 );
5289 }
5290 }
5291
5292 #[test]
5296 fn inv2_no_delete_when_any_mirror_unenumerated(
5297 manifest in manifest_strategy(),
5298 desired in desired_strategy(),
5299 local in local_strategy(),
5300 mut sources in sources_strategy(),
5301 ) {
5302 sources.push(SourceStatus {
5303 mode: SourceMode::Mirror,
5304 fully_enumerated: false,
5305 });
5306 let plan = reconcile(&manifest, &desired, &local, &sources);
5307 prop_assert_eq!(plan.deletes(), 0);
5308 }
5309
5310 #[test]
5312 fn inv3_all_copy_sources_means_no_deletes(
5313 manifest in manifest_strategy(),
5314 desired in desired_strategy(),
5315 local in local_strategy(),
5316 sources in copy_sources_strategy(),
5317 ) {
5318 let plan = reconcile(&manifest, &desired, &local, &sources);
5319 prop_assert_eq!(plan.deletes(), 0);
5320 }
5321
5322 #[test]
5325 fn inv4_plan_is_deterministic(
5326 manifest in manifest_strategy(),
5327 desired in desired_strategy(),
5328 local in local_strategy(),
5329 sources in sources_strategy(),
5330 ) {
5331 let plan = reconcile(&manifest, &desired, &local, &sources);
5332
5333 let again = reconcile(&manifest, &desired, &local, &sources);
5334 prop_assert_eq!(&plan, &again);
5335
5336 let mut desired_rev = desired.clone();
5337 desired_rev.reverse();
5338 let mut sources_rev = sources.clone();
5339 sources_rev.reverse();
5340 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
5341 prop_assert_eq!(&plan, &shuffled);
5342 }
5343
5344 #[test]
5346 fn inv5_every_delete_is_in_the_manifest(
5347 manifest in manifest_strategy(),
5348 desired in desired_strategy(),
5349 local in local_strategy(),
5350 sources in sources_strategy(),
5351 ) {
5352 let plan = reconcile(&manifest, &desired, &local, &sources);
5353 for id in delete_clip_ids(&plan) {
5354 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
5355 }
5356 }
5357
5358 #[test]
5361 fn inv6_never_deletes_protected_clip(
5362 manifest in manifest_strategy(),
5363 desired in desired_strategy(),
5364 local in local_strategy(),
5365 sources in sources_strategy(),
5366 ) {
5367 let plan = reconcile(&manifest, &desired, &local, &sources);
5368 let protected = protected_ids(&desired);
5369 for id in delete_clip_ids(&plan) {
5370 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
5371 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
5372 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
5373 }
5374 }
5375
5376 #[test]
5379 fn inv7_no_delete_unless_deletion_allowed(
5380 manifest in manifest_strategy(),
5381 desired in desired_strategy(),
5382 local in local_strategy(),
5383 sources in sources_strategy(),
5384 ) {
5385 let plan = reconcile(&manifest, &desired, &local, &sources);
5386 if !deletion_allowed(&sources) {
5387 prop_assert_eq!(plan.deletes(), 0);
5388 }
5389 }
5390
5391 #[test]
5393 fn inv8_at_most_one_delete_per_clip(
5394 manifest in manifest_strategy(),
5395 desired in desired_strategy(),
5396 local in local_strategy(),
5397 sources in sources_strategy(),
5398 ) {
5399 let plan = reconcile(&manifest, &desired, &local, &sources);
5400 let ids = delete_clip_ids(&plan);
5401 let unique: BTreeSet<&str> = ids.iter().copied().collect();
5402 prop_assert_eq!(ids.len(), unique.len());
5403 }
5404
5405 #[test]
5407 fn inv9_no_delete_with_empty_path(
5408 manifest in manifest_strategy(),
5409 desired in desired_strategy(),
5410 local in local_strategy(),
5411 sources in sources_strategy(),
5412 ) {
5413 let plan = reconcile(&manifest, &desired, &local, &sources);
5414 for action in &plan.actions {
5415 if let Action::Delete { path, .. } = action {
5416 prop_assert!(!path.is_empty(), "delete with an empty path");
5417 }
5418 }
5419 }
5420
5421 #[test]
5424 fn inv10_no_delete_aliases_a_write_target(
5425 manifest in manifest_strategy(),
5426 desired in desired_strategy(),
5427 local in local_strategy(),
5428 sources in sources_strategy(),
5429 ) {
5430 let plan = reconcile(&manifest, &desired, &local, &sources);
5431 let targets = write_target_paths(&plan);
5432 for action in &plan.actions {
5433 if let Action::Delete { path, .. } = action {
5434 prop_assert!(
5435 !targets.contains(path.as_str()),
5436 "delete path {path} aliases a write target"
5437 );
5438 }
5439 }
5440 }
5441 }
5442}