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 needs_write_drift(
769 stored: Option<(&str, &str)>,
770 want_hash: &str,
771 want_path: &str,
772 local: &HashMap<String, LocalFile>,
773) -> bool {
774 match stored {
775 None => true,
776 Some((stored_hash, stored_path)) => {
777 stored_hash != want_hash
778 || stored_path != want_path
779 || local
780 .get(stored_path)
781 .is_some_and(|f| !f.exists || f.size == 0)
782 }
783 }
784}
785
786fn plan_clip_artifacts(
802 d: &Desired,
803 manifest: &Manifest,
804 local: &HashMap<String, LocalFile>,
805 can_delete: bool,
806 out: &mut Vec<Action>,
807) {
808 let owner_id = d.clip.id.as_str();
809 let entry = manifest.get(owner_id);
810
811 for artifact in &d.artifacts {
812 if !is_per_clip_kind(artifact.kind) {
817 continue;
818 }
819 let state = entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind));
825 let needs_write = needs_write_drift(
826 state.map(|state| (state.hash.as_str(), state.path.as_str())),
827 artifact.hash.as_str(),
828 artifact.path.as_str(),
829 local,
830 );
831 if needs_write {
832 if let Some(state) = state
838 && state.hash == artifact.hash
839 && state.path != artifact.path
840 && artifact.content.is_none()
841 && local
842 .get(&state.path)
843 .is_some_and(|f| f.exists && f.size > 0)
844 {
845 out.push(Action::MoveArtifact {
846 kind: artifact.kind,
847 from: state.path.clone(),
848 to: artifact.path.clone(),
849 source_url: artifact.source_url.clone(),
850 hash: artifact.hash.clone(),
851 owner_id: owner_id.to_string(),
852 });
853 } else {
854 out.push(Action::WriteArtifact {
855 kind: artifact.kind,
856 path: artifact.path.clone(),
857 source_url: artifact.source_url.clone(),
858 hash: artifact.hash.clone(),
859 owner_id: owner_id.to_string(),
860 content: artifact.content.clone(),
861 });
862 }
863 }
864 }
865
866 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
871 if !protected_now && let Some(entry) = entry {
872 let desired_kinds: BTreeSet<ArtifactKind> = d
873 .artifacts
874 .iter()
875 .filter(|a| is_per_clip_kind(a.kind))
876 .map(|a| a.kind)
877 .collect();
878 for (kind, state) in manifest_artifacts(entry) {
879 if removed_kind_delete_eligible(kind)
885 && !desired_kinds.contains(&kind)
886 && let Some(action) =
887 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
888 {
889 out.push(action);
890 }
891 }
892 }
893}
894
895fn co_delete_artifacts(
901 owner_id: &str,
902 manifest: &Manifest,
903 can_delete: bool,
904 out: &mut Vec<Action>,
905) {
906 let Some(entry) = manifest.get(owner_id) else {
907 return;
908 };
909 for (kind, state) in manifest_artifacts(entry) {
910 if let Some(action) =
911 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
912 {
913 out.push(action);
914 }
915 }
916}
917
918fn delete_stem_action(
927 clip_id: &str,
928 key: &str,
929 path: &str,
930 manifest: &Manifest,
931 can_delete: bool,
932) -> Option<Action> {
933 if !can_delete {
934 return None;
935 }
936 let entry = manifest.get(clip_id)?;
937 if path.is_empty() || entry.preserve {
938 return None;
939 }
940 Some(Action::DeleteStem {
941 clip_id: clip_id.to_string(),
942 key: key.to_string(),
943 path: path.to_string(),
944 })
945}
946
947fn plan_clip_stems(
964 d: &Desired,
965 manifest: &Manifest,
966 local: &HashMap<String, LocalFile>,
967 can_delete: bool,
968 out: &mut Vec<Action>,
969) {
970 let Some(desired_stems) = &d.stems else {
971 return;
972 };
973 let clip_id = d.clip.id.as_str();
974 let entry = manifest.get(clip_id);
975
976 for stem in desired_stems {
977 let state = entry.and_then(|e| e.stems.get(&stem.key));
978 let needs_write = match state {
979 None => true,
980 Some(state) => state.hash != stem.hash || state.path != stem.path,
981 };
982 if needs_write {
983 if let Some(state) = state
988 && state.hash == stem.hash
989 && state.path != stem.path
990 && local
991 .get(&state.path)
992 .is_some_and(|f| f.exists && f.size > 0)
993 {
994 out.push(Action::MoveStem {
995 clip_id: clip_id.to_string(),
996 key: stem.key.clone(),
997 stem_id: stem.stem_id.clone(),
998 from: state.path.clone(),
999 to: stem.path.clone(),
1000 source_url: stem.source_url.clone(),
1001 format: stem.format,
1002 hash: stem.hash.clone(),
1003 });
1004 } else {
1005 out.push(Action::WriteStem {
1006 clip_id: clip_id.to_string(),
1007 key: stem.key.clone(),
1008 stem_id: stem.stem_id.clone(),
1009 path: stem.path.clone(),
1010 source_url: stem.source_url.clone(),
1011 format: stem.format,
1012 hash: stem.hash.clone(),
1013 });
1014 }
1015 }
1016 }
1017
1018 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
1019 if !protected_now && let Some(entry) = entry {
1020 let desired_keys: BTreeSet<&str> = desired_stems.iter().map(|s| s.key.as_str()).collect();
1021 for (key, state) in &entry.stems {
1022 if !desired_keys.contains(key.as_str())
1028 && let Some(action) =
1029 delete_stem_action(clip_id, key, &state.path, manifest, can_delete)
1030 {
1031 out.push(action);
1032 }
1033 }
1034 }
1035}
1036
1037fn co_delete_stems(clip_id: &str, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
1045 let Some(entry) = manifest.get(clip_id) else {
1046 return;
1047 };
1048 for (key, state) in &entry.stems {
1049 if let Some(action) = delete_stem_action(clip_id, key, &state.path, manifest, can_delete) {
1050 out.push(action);
1051 }
1052 }
1053}
1054
1055fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
1062 let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
1063 for d in desired {
1064 match by_id.get_mut(d.clip.id.as_str()) {
1065 None => {
1066 by_id.insert(d.clip.id.as_str(), d.clone());
1067 }
1068 Some(acc) => {
1069 let take = rep_key(d) < rep_key(acc);
1070 acc.private = acc.private || d.private;
1071 acc.trashed = acc.trashed && d.trashed;
1072 for mode in &d.modes {
1073 if !acc.modes.contains(mode) {
1074 acc.modes.push(*mode);
1075 }
1076 }
1077 if take {
1078 acc.clip = d.clip.clone();
1079 acc.path = d.path.clone();
1080 acc.format = d.format;
1081 acc.meta_hash = d.meta_hash.clone();
1082 acc.art_hash = d.art_hash.clone();
1083 acc.artifacts = d.artifacts.clone();
1084 acc.stems = d.stems.clone();
1085 }
1086 }
1087 }
1088 }
1089 let mut out: Vec<Desired> = by_id.into_values().collect();
1090 for d in &mut out {
1091 let has_mirror = d.modes.contains(&SourceMode::Mirror);
1093 let has_copy = d.modes.contains(&SourceMode::Copy);
1094 d.modes.clear();
1095 if has_mirror {
1096 d.modes.push(SourceMode::Mirror);
1097 }
1098 if has_copy {
1099 d.modes.push(SourceMode::Copy);
1100 }
1101 }
1102 out
1103}
1104
1105fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
1108 let format = match d.format {
1109 AudioFormat::Mp3 => 0,
1110 AudioFormat::Flac => 1,
1111 AudioFormat::Wav => 2,
1112 };
1113 (
1114 d.path.as_str(),
1115 d.meta_hash.as_str(),
1116 d.art_hash.as_str(),
1117 format,
1118 )
1119}
1120
1121fn suppress_path_aliasing(actions: &mut [Action]) {
1128 let targets: BTreeSet<String> = actions
1129 .iter()
1130 .filter_map(|a| match a {
1131 Action::Download { path, .. }
1132 | Action::Reformat { path, .. }
1133 | Action::WriteArtifact { path, .. }
1134 | Action::WriteStem { path, .. } => Some(path.clone()),
1135 Action::Rename { to, .. }
1136 | Action::MoveArtifact { to, .. }
1137 | Action::MoveStem { to, .. } => Some(to.clone()),
1138 _ => None,
1139 })
1140 .collect();
1141 for a in actions.iter_mut() {
1142 if let Action::Delete { path, clip_id } = a
1143 && targets.contains(path.as_str())
1144 {
1145 *a = Action::Skip {
1146 clip_id: clip_id.clone(),
1147 };
1148 }
1149 if let Action::DeleteArtifact { path, owner_id, .. } = a
1150 && targets.contains(path.as_str())
1151 {
1152 *a = Action::Skip {
1153 clip_id: owner_id.clone(),
1154 };
1155 }
1156 if let Action::DeleteStem { path, clip_id, .. } = a
1157 && targets.contains(path.as_str())
1158 {
1159 *a = Action::Skip {
1160 clip_id: clip_id.clone(),
1161 };
1162 }
1163 }
1164}
1165
1166fn plan_desired(
1168 d: &Desired,
1169 manifest: &Manifest,
1170 local: &HashMap<String, LocalFile>,
1171 can_delete: bool,
1172 out: &mut Vec<Action>,
1173) {
1174 let clip_id = d.clip.id.as_str();
1175 let copy_held = d.modes.contains(&SourceMode::Copy);
1176
1177 if d.trashed && !d.private && !copy_held {
1183 match delete_action(clip_id, manifest, can_delete) {
1184 Some(action) => out.push(action),
1185 None => out.push(Action::Skip {
1186 clip_id: clip_id.to_string(),
1187 }),
1188 }
1189 return;
1190 }
1191
1192 let Some(entry) = manifest.get(clip_id) else {
1193 out.push(Action::Download {
1195 clip: d.clip.clone(),
1196 lineage: d.lineage.clone(),
1197 path: d.path.clone(),
1198 format: d.format,
1199 });
1200 return;
1201 };
1202
1203 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
1206 if missing {
1207 out.push(Action::Download {
1208 clip: d.clip.clone(),
1209 lineage: d.lineage.clone(),
1210 path: d.path.clone(),
1211 format: d.format,
1212 });
1213 return;
1214 }
1215
1216 if d.format != entry.format {
1217 out.push(Action::Reformat {
1220 clip: d.clip.clone(),
1221 path: d.path.clone(),
1222 from_path: entry.path.clone(),
1223 from: entry.format,
1224 to: d.format,
1225 });
1226 return;
1227 }
1228
1229 if d.path != entry.path {
1230 out.push(Action::Rename {
1231 from: entry.path.clone(),
1232 to: d.path.clone(),
1233 });
1234 if meta_or_art_changed(d, entry) {
1236 out.push(Action::Retag {
1237 clip: d.clip.clone(),
1238 lineage: d.lineage.clone(),
1239 path: d.path.clone(),
1240 });
1241 }
1242 return;
1243 }
1244
1245 if meta_or_art_changed(d, entry) {
1246 out.push(Action::Retag {
1247 clip: d.clip.clone(),
1248 lineage: d.lineage.clone(),
1249 path: entry.path.clone(),
1250 });
1251 return;
1252 }
1253
1254 out.push(Action::Skip {
1255 clip_id: clip_id.to_string(),
1256 });
1257}
1258
1259fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
1261 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
1262}
1263
1264pub fn album_desired(
1288 desired: &[Desired],
1289 animated_covers: bool,
1290 raw_cover: bool,
1291) -> Vec<AlbumDesired> {
1292 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
1293 for d in desired {
1294 groups
1295 .entry(d.lineage.root_id.as_str())
1296 .or_default()
1297 .push(d);
1298 }
1299
1300 groups
1301 .into_iter()
1302 .map(|(root_id, members)| {
1303 let album_dir = album_dir_of(&members);
1304 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
1305 kind: ArtifactKind::FolderJpg,
1306 path: album_child(&album_dir, "folder.jpg"),
1307 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
1308 hash: art_hash(&source.clip),
1309 content: None,
1310 });
1311 let folder_webp = animated_covers
1312 .then(|| folder_webp_source(&members))
1313 .flatten()
1314 .map(|source| DesiredArtifact {
1315 kind: ArtifactKind::FolderWebp,
1316 path: album_child(&album_dir, "cover.webp"),
1317 source_url: source.clip.video_cover_url.clone(),
1318 hash: art_url_hash(&source.clip.video_cover_url),
1319 content: None,
1320 });
1321 let folder_mp4 = raw_cover
1322 .then(|| folder_webp_source(&members))
1323 .flatten()
1324 .map(|source| DesiredArtifact {
1325 kind: ArtifactKind::FolderMp4,
1326 path: album_child(&album_dir, "cover.mp4"),
1327 source_url: source.clip.video_cover_url.clone(),
1328 hash: art_url_hash(&source.clip.video_cover_url),
1329 content: None,
1330 });
1331 AlbumDesired {
1332 root_id: root_id.to_owned(),
1333 folder_jpg,
1334 folder_webp,
1335 folder_mp4,
1336 }
1337 })
1338 .collect()
1339}
1340
1341fn album_dir_of(members: &[&Desired]) -> String {
1346 members
1347 .iter()
1348 .map(|d| parent_dir(&d.path))
1349 .min()
1350 .unwrap_or("")
1351 .to_owned()
1352}
1353
1354fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1360 members
1361 .iter()
1362 .copied()
1363 .filter(|d| {
1364 d.clip
1365 .selected_image_url()
1366 .is_some_and(|url| !url.is_empty())
1367 })
1368 .min_by(|a, b| {
1369 b.clip
1370 .play_count
1371 .cmp(&a.clip.play_count)
1372 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
1373 .then_with(|| a.clip.id.cmp(&b.clip.id))
1374 })
1375}
1376
1377fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1382 members
1383 .iter()
1384 .copied()
1385 .filter(|d| !d.clip.video_cover_url.is_empty())
1386 .min_by(|a, b| {
1387 a.clip
1388 .created_at
1389 .cmp(&b.clip.created_at)
1390 .then_with(|| a.clip.id.cmp(&b.clip.id))
1391 })
1392}
1393
1394fn parent_dir(path: &str) -> &str {
1396 match path.rsplit_once('/') {
1397 Some((dir, _)) => dir,
1398 None => "",
1399 }
1400}
1401
1402fn album_child(album_dir: &str, name: &str) -> String {
1405 if album_dir.is_empty() {
1406 name.to_owned()
1407 } else {
1408 format!("{album_dir}/{name}")
1409 }
1410}
1411
1412pub fn plan_album_artifacts(
1436 desired: &[AlbumDesired],
1437 albums: &BTreeMap<String, AlbumArt>,
1438 can_delete: bool,
1439 local: &HashMap<String, LocalFile>,
1440) -> Vec<Action> {
1441 let mut actions: Vec<Action> = Vec::new();
1442 let by_root: BTreeMap<&str, &AlbumDesired> =
1443 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1444
1445 for d in desired {
1446 let stored = albums.get(&d.root_id);
1447 for artifact in [
1448 d.folder_jpg.as_ref(),
1449 d.folder_webp.as_ref(),
1450 d.folder_mp4.as_ref(),
1451 ]
1452 .into_iter()
1453 .flatten()
1454 {
1455 let needs_write = needs_write_drift(
1456 stored
1457 .and_then(|a| a.artifact(artifact.kind))
1458 .map(|state| (state.hash.as_str(), state.path.as_str())),
1459 artifact.hash.as_str(),
1460 artifact.path.as_str(),
1461 local,
1462 );
1463 if needs_write {
1464 actions.push(Action::WriteArtifact {
1465 kind: artifact.kind,
1466 path: artifact.path.clone(),
1467 source_url: artifact.source_url.clone(),
1468 hash: artifact.hash.clone(),
1469 owner_id: d.root_id.clone(),
1470 content: None,
1471 });
1472 }
1473 }
1474 }
1475
1476 if can_delete {
1478 for (root_id, art) in albums {
1479 for (kind, state) in album_artifacts(art) {
1480 let desired_here = by_root
1481 .get(root_id.as_str())
1482 .is_some_and(|d| album_desires_kind(d, kind));
1483 if !desired_here && !state.path.is_empty() {
1484 actions.push(Action::DeleteArtifact {
1485 kind,
1486 path: state.path.clone(),
1487 owner_id: root_id.clone(),
1488 });
1489 }
1490 }
1491 }
1492 }
1493
1494 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1495 actions
1496}
1497
1498fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1501 let mut out = Vec::new();
1502 if let Some(state) = &art.folder_jpg {
1503 out.push((ArtifactKind::FolderJpg, state));
1504 }
1505 if let Some(state) = &art.folder_webp {
1506 out.push((ArtifactKind::FolderWebp, state));
1507 }
1508 if let Some(state) = &art.folder_mp4 {
1509 out.push((ArtifactKind::FolderMp4, state));
1510 }
1511 out
1512}
1513
1514fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1516 match kind {
1517 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1518 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1519 ArtifactKind::FolderMp4 => d.folder_mp4.is_some(),
1520 ArtifactKind::CoverJpg
1521 | ArtifactKind::CoverWebp
1522 | ArtifactKind::DetailsTxt
1523 | ArtifactKind::LyricsTxt
1524 | ArtifactKind::Lrc
1525 | ArtifactKind::VideoMp4
1526 | ArtifactKind::Playlist => false,
1527 }
1528}
1529
1530fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1532 match action {
1533 Action::WriteArtifact { owner_id, kind, .. }
1534 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1535 _ => ("", ArtifactKind::CoverJpg),
1536 }
1537}
1538
1539pub fn plan_playlist_artifacts(
1577 desired: &[PlaylistDesired],
1578 stored: &BTreeMap<String, PlaylistState>,
1579 can_delete: bool,
1580 list_fully_enumerated: bool,
1581 local: &HashMap<String, LocalFile>,
1582) -> Vec<Action> {
1583 let mut actions: Vec<Action> = Vec::new();
1584 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1585 let deletes_allowed = can_delete && list_fully_enumerated;
1588
1589 for d in desired {
1590 let stored_here = stored.get(&d.id);
1591 let needs_write = needs_write_drift(
1592 stored_here.map(|state| (state.hash.as_str(), state.path.as_str())),
1593 d.hash.as_str(),
1594 d.path.as_str(),
1595 local,
1596 );
1597 if needs_write {
1598 actions.push(Action::WriteArtifact {
1599 kind: ArtifactKind::Playlist,
1600 path: d.path.clone(),
1601 source_url: String::new(),
1602 hash: d.hash.clone(),
1603 owner_id: d.id.clone(),
1604 content: Some(d.content.clone()),
1605 });
1606 }
1607 if deletes_allowed
1609 && let Some(state) = stored_here
1610 && !state.path.is_empty()
1611 && state.path != d.path
1612 {
1613 actions.push(Action::DeleteArtifact {
1614 kind: ArtifactKind::Playlist,
1615 path: state.path.clone(),
1616 owner_id: d.id.clone(),
1617 });
1618 }
1619 }
1620
1621 if deletes_allowed {
1624 for (id, state) in stored {
1625 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1626 actions.push(Action::DeleteArtifact {
1627 kind: ArtifactKind::Playlist,
1628 path: state.path.clone(),
1629 owner_id: id.clone(),
1630 });
1631 }
1632 }
1633 }
1634
1635 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1636 suppress_path_aliasing(&mut actions);
1639 actions
1640}
1641
1642fn playlist_action_key(action: &Action) -> (&str, u8) {
1645 match action {
1646 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1647 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1648 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1649 _ => ("", 3),
1650 }
1651}
1652
1653#[cfg(test)]
1654mod tests {
1655 use super::*;
1656 use crate::hash::content_hash;
1657
1658 fn clip(id: &str) -> Clip {
1659 Clip {
1660 id: id.to_string(),
1661 title: "Song".to_string(),
1662 ..Default::default()
1663 }
1664 }
1665
1666 fn lineage(id: &str) -> LineageContext {
1667 LineageContext::own_root(&clip(id))
1668 }
1669
1670 fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1671 ManifestEntry {
1672 path: path.to_string(),
1673 format,
1674 meta_hash: meta.to_string(),
1675 art_hash: art.to_string(),
1676 size: 100,
1677 preserve: false,
1678 ..Default::default()
1679 }
1680 }
1681
1682 fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1683 ManifestEntry {
1684 preserve: true,
1685 ..entry(path, format, meta, art)
1686 }
1687 }
1688
1689 fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1690 Desired {
1691 clip: clip(id),
1692 lineage: lineage(id),
1693 path: path.to_string(),
1694 format,
1695 meta_hash: meta.to_string(),
1696 art_hash: art.to_string(),
1697 modes: vec![SourceMode::Mirror],
1698 trashed: false,
1699 private: false,
1700 artifacts: Vec::new(),
1701 stems: None,
1702 }
1703 }
1704
1705 fn present(size: u64) -> LocalFile {
1706 LocalFile { exists: true, size }
1707 }
1708
1709 fn local_present(id: &str) -> HashMap<String, LocalFile> {
1710 [(id.to_string(), present(100))].into_iter().collect()
1711 }
1712
1713 fn mirror_ok() -> Vec<SourceStatus> {
1714 vec![SourceStatus {
1715 mode: SourceMode::Mirror,
1716 fully_enumerated: true,
1717 }]
1718 }
1719
1720 #[test]
1723 fn not_in_manifest_downloads() {
1724 let manifest = Manifest::new();
1725 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1726 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1727 assert_eq!(
1728 plan.actions,
1729 vec![Action::Download {
1730 clip: clip("a"),
1731 lineage: lineage("a"),
1732 path: "a.flac".to_string(),
1733 format: AudioFormat::Flac,
1734 }]
1735 );
1736 }
1737
1738 #[test]
1739 fn unchanged_clip_skips() {
1740 let mut manifest = Manifest::new();
1741 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1742 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1743 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1744 assert_eq!(
1745 plan.actions,
1746 vec![Action::Skip {
1747 clip_id: "a".to_string()
1748 }]
1749 );
1750 }
1751
1752 #[test]
1753 fn meta_change_retags_in_place() {
1754 let mut manifest = Manifest::new();
1755 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1756 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1757 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1758 assert_eq!(
1759 plan.actions,
1760 vec![Action::Retag {
1761 clip: clip("a"),
1762 lineage: lineage("a"),
1763 path: "a.flac".to_string(),
1764 }]
1765 );
1766 }
1767
1768 #[test]
1769 fn art_change_retags_in_place() {
1770 let mut manifest = Manifest::new();
1771 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1772 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1773 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1774 assert_eq!(
1775 plan.actions,
1776 vec![Action::Retag {
1777 clip: clip("a"),
1778 lineage: lineage("a"),
1779 path: "a.flac".to_string(),
1780 }]
1781 );
1782 }
1783
1784 #[test]
1785 fn rename_when_path_changes() {
1786 let mut manifest = Manifest::new();
1787 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1788 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1789 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1790 assert_eq!(
1791 plan.actions,
1792 vec![Action::Rename {
1793 from: "old/a.flac".to_string(),
1794 to: "new/a.flac".to_string(),
1795 }]
1796 );
1797 }
1798
1799 #[test]
1800 fn rename_with_meta_change_also_retags() {
1801 let mut manifest = Manifest::new();
1802 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
1803 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
1804 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1805 assert_eq!(
1806 plan.actions,
1807 vec![
1808 Action::Rename {
1809 from: "old/a.flac".to_string(),
1810 to: "new/a.flac".to_string(),
1811 },
1812 Action::Retag {
1813 clip: clip("a"),
1814 lineage: lineage("a"),
1815 path: "new/a.flac".to_string(),
1816 },
1817 ]
1818 );
1819 }
1820
1821 #[test]
1822 fn rename_without_meta_change_does_not_retag() {
1823 let mut manifest = Manifest::new();
1824 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1825 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1826 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1827 assert_eq!(plan.renames(), 1);
1828 assert_eq!(plan.retags(), 0);
1829 }
1830
1831 #[test]
1832 fn bulk_album_rename_moves_and_retags_without_redownload() {
1833 let mut manifest = Manifest::new();
1838 for id in ["a", "b", "c"] {
1839 manifest.insert(
1840 id,
1841 entry(
1842 &format!("Creator/Old Album/{id}.flac"),
1843 AudioFormat::Flac,
1844 "old-meta",
1845 "art",
1846 ),
1847 );
1848 }
1849 let d: Vec<Desired> = ["a", "b", "c"]
1850 .iter()
1851 .map(|id| {
1852 desired(
1853 id,
1854 &format!("Creator/New Album/{id}.flac"),
1855 AudioFormat::Flac,
1856 "new-meta",
1857 "art",
1858 )
1859 })
1860 .collect();
1861 let local: HashMap<String, LocalFile> = ["a", "b", "c"]
1862 .iter()
1863 .map(|id| (id.to_string(), present(100)))
1864 .collect();
1865
1866 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1867
1868 assert_eq!(plan.renames(), 3, "every member folder move is a rename");
1869 assert_eq!(
1870 plan.retags(),
1871 3,
1872 "the album tag change retags each in place"
1873 );
1874 assert_eq!(
1875 plan.downloads(),
1876 0,
1877 "an album rename must never re-download"
1878 );
1879 assert_eq!(
1880 plan.deletes(),
1881 0,
1882 "deletion safety: a rename deletes nothing"
1883 );
1884 for id in ["a", "b", "c"] {
1885 assert!(plan.actions.contains(&Action::Rename {
1886 from: format!("Creator/Old Album/{id}.flac"),
1887 to: format!("Creator/New Album/{id}.flac"),
1888 }));
1889 }
1890 }
1891
1892 #[test]
1893 fn format_change_reformats() {
1894 let mut manifest = Manifest::new();
1895 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1896 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1897 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1898 assert_eq!(
1899 plan.actions,
1900 vec![Action::Reformat {
1901 clip: clip("a"),
1902 path: "a.mp3".to_string(),
1903 from_path: "a.flac".to_string(),
1904 from: AudioFormat::Flac,
1905 to: AudioFormat::Mp3,
1906 }]
1907 );
1908 }
1909
1910 #[test]
1911 fn format_change_takes_precedence_over_rename_and_retag() {
1912 let mut manifest = Manifest::new();
1915 manifest.insert(
1916 "a",
1917 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
1918 );
1919 let d = vec![desired(
1920 "a",
1921 "new/a.mp3",
1922 AudioFormat::Mp3,
1923 "new",
1924 "new-art",
1925 )];
1926 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1927 assert_eq!(plan.reformats(), 1);
1928 assert_eq!(plan.renames(), 0);
1929 assert_eq!(plan.retags(), 0);
1930 }
1931
1932 #[test]
1935 fn zero_length_file_downloads_even_when_hashes_match() {
1936 let mut manifest = Manifest::new();
1937 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1938 let local: HashMap<String, LocalFile> = [(
1939 "a".to_string(),
1940 LocalFile {
1941 exists: true,
1942 size: 0,
1943 },
1944 )]
1945 .into_iter()
1946 .collect();
1947 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1948 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1949 assert_eq!(plan.downloads(), 1);
1950 assert_eq!(plan.skips(), 0);
1951 }
1952
1953 #[test]
1954 fn missing_file_downloads_even_when_hashes_match() {
1955 let mut manifest = Manifest::new();
1956 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1957 let local: HashMap<String, LocalFile> = [(
1958 "a".to_string(),
1959 LocalFile {
1960 exists: false,
1961 size: 0,
1962 },
1963 )]
1964 .into_iter()
1965 .collect();
1966 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1967 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1968 assert_eq!(plan.downloads(), 1);
1969 }
1970
1971 #[test]
1972 fn absent_local_probe_treated_as_missing() {
1973 let mut manifest = Manifest::new();
1975 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1976 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1977 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1978 assert_eq!(plan.downloads(), 1);
1979 }
1980
1981 #[test]
1982 fn missing_file_download_wins_over_format_difference() {
1983 let mut manifest = Manifest::new();
1986 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1987 let local: HashMap<String, LocalFile> = [(
1988 "a".to_string(),
1989 LocalFile {
1990 exists: false,
1991 size: 0,
1992 },
1993 )]
1994 .into_iter()
1995 .collect();
1996 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
1997 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
1998 assert_eq!(plan.downloads(), 1);
1999 assert_eq!(plan.reformats(), 0);
2000 }
2001
2002 #[test]
2005 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
2006 let mut trashed = clip("a");
2011 trashed.status = "complete".to_string();
2012 trashed.is_trashed = true;
2013 assert!(crate::is_downloadable(&trashed));
2014
2015 let mut manifest = Manifest::new();
2016 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2017 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2018 d.clip = trashed;
2019 d.trashed = true;
2020 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2021 assert_eq!(
2022 plan.actions,
2023 vec![Action::Delete {
2024 path: "a.flac".to_string(),
2025 clip_id: "a".to_string(),
2026 }]
2027 );
2028 }
2029
2030 #[test]
2031 fn trashed_clip_deletes_local_file() {
2032 let mut manifest = Manifest::new();
2033 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2034 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2035 d.trashed = true;
2036 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2037 assert_eq!(
2038 plan.actions,
2039 vec![Action::Delete {
2040 path: "a.flac".to_string(),
2041 clip_id: "a".to_string(),
2042 }]
2043 );
2044 }
2045
2046 #[test]
2047 fn trashed_clip_not_in_manifest_skips() {
2048 let manifest = Manifest::new();
2050 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2051 d.trashed = true;
2052 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2053 assert_eq!(
2054 plan.actions,
2055 vec![Action::Skip {
2056 clip_id: "a".to_string()
2057 }]
2058 );
2059 }
2060
2061 #[test]
2062 fn private_clip_is_kept() {
2063 let mut manifest = Manifest::new();
2064 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2065 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2066 d.private = true;
2067 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2068 assert_eq!(
2069 plan.actions,
2070 vec![Action::Skip {
2071 clip_id: "a".to_string()
2072 }]
2073 );
2074 }
2075
2076 #[test]
2077 fn private_beats_trashed_never_deletes() {
2078 let mut manifest = Manifest::new();
2080 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2081 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2082 d.trashed = true;
2083 d.private = true;
2084 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2085 assert_eq!(plan.deletes(), 0);
2086 assert_eq!(plan.skips(), 1);
2087 }
2088
2089 #[test]
2090 fn copy_held_trashed_clip_is_not_deleted() {
2091 let mut manifest = Manifest::new();
2094 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2095 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2096 d.modes = vec![SourceMode::Copy];
2097 d.trashed = true;
2098 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2099 assert_eq!(plan.deletes(), 0);
2100 assert_eq!(
2101 plan.actions,
2102 vec![Action::Skip {
2103 clip_id: "a".to_string()
2104 }]
2105 );
2106 }
2107
2108 #[test]
2111 fn absent_clip_deleted_when_all_mirrors_enumerated() {
2112 let mut manifest = Manifest::new();
2113 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2114 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2115 assert_eq!(
2116 plan.actions,
2117 vec![Action::Delete {
2118 path: "gone.flac".to_string(),
2119 clip_id: "gone".to_string(),
2120 }]
2121 );
2122 }
2123
2124 #[test]
2125 fn absent_clip_kept_when_any_mirror_not_enumerated() {
2126 let mut manifest = Manifest::new();
2127 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2128 let sources = vec![
2129 SourceStatus {
2130 mode: SourceMode::Mirror,
2131 fully_enumerated: true,
2132 },
2133 SourceStatus {
2134 mode: SourceMode::Mirror,
2135 fully_enumerated: false,
2136 },
2137 ];
2138 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2139 assert_eq!(plan.deletes(), 0);
2140 assert_eq!(
2141 plan.actions,
2142 vec![Action::Skip {
2143 clip_id: "gone".to_string()
2144 }]
2145 );
2146 }
2147
2148 #[test]
2149 fn empty_listing_cannot_cause_deletion() {
2150 let mut manifest = Manifest::new();
2153 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2154 let sources = vec![SourceStatus {
2155 mode: SourceMode::Mirror,
2156 fully_enumerated: false,
2157 }];
2158 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2159 assert_eq!(plan.deletes(), 0);
2160 assert_eq!(plan.skips(), 1);
2161 }
2162
2163 #[test]
2164 fn no_mirror_sources_means_no_deletion() {
2165 let mut manifest = Manifest::new();
2167 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2168 let copy_only = vec![SourceStatus {
2169 mode: SourceMode::Copy,
2170 fully_enumerated: true,
2171 }];
2172 assert_eq!(
2173 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
2174 0
2175 );
2176 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
2177 }
2178
2179 #[test]
2180 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
2181 let mut manifest = Manifest::new();
2182 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2183 let sources = vec![
2184 SourceStatus {
2185 mode: SourceMode::Copy,
2186 fully_enumerated: true,
2187 },
2188 SourceStatus {
2189 mode: SourceMode::Mirror,
2190 fully_enumerated: false,
2191 },
2192 ];
2193 assert_eq!(
2194 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
2195 0
2196 );
2197 }
2198
2199 #[test]
2200 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
2201 let mut manifest = Manifest::new();
2205 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
2206 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2207 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
2208 held.modes = vec![SourceMode::Copy];
2209 let local: HashMap<String, LocalFile> = [
2210 ("keep".to_string(), present(100)),
2211 ("gone".to_string(), present(100)),
2212 ]
2213 .into_iter()
2214 .collect();
2215 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
2216 assert!(plan.actions.contains(&Action::Skip {
2217 clip_id: "keep".to_string()
2218 }));
2219 assert!(plan.actions.contains(&Action::Delete {
2220 path: "gone.flac".to_string(),
2221 clip_id: "gone".to_string(),
2222 }));
2223 assert!(
2225 !plan
2226 .actions
2227 .iter()
2228 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
2229 );
2230 }
2231
2232 #[test]
2235 fn orphan_with_preserve_marker_is_kept() {
2236 let mut manifest = Manifest::new();
2239 manifest.insert(
2240 "gone",
2241 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
2242 );
2243 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2244 assert_eq!(plan.deletes(), 0);
2245 assert_eq!(
2246 plan.actions,
2247 vec![Action::Skip {
2248 clip_id: "gone".to_string()
2249 }]
2250 );
2251 }
2252
2253 #[test]
2254 fn trashed_clip_with_preserve_marker_is_kept() {
2255 let mut manifest = Manifest::new();
2258 manifest.insert(
2259 "a",
2260 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2261 );
2262 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2263 d.trashed = true;
2264 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2265 assert_eq!(plan.deletes(), 0);
2266 assert_eq!(plan.skips(), 1);
2267 }
2268
2269 #[test]
2272 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
2273 let mut manifest = Manifest::new();
2275 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2276 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2277 d.trashed = true;
2278 let sources = vec![SourceStatus {
2279 mode: SourceMode::Mirror,
2280 fully_enumerated: false,
2281 }];
2282 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2283 assert_eq!(plan.deletes(), 0);
2284 assert_eq!(plan.skips(), 1);
2285 }
2286
2287 #[test]
2288 fn trashed_clip_kept_when_sources_empty() {
2289 let mut manifest = Manifest::new();
2292 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2293 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2294 d.trashed = true;
2295 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
2296 assert_eq!(plan.deletes(), 0);
2297 assert_eq!(plan.skips(), 1);
2298 }
2299
2300 #[test]
2301 fn failed_copy_listing_suppresses_orphan_deletion() {
2302 let mut manifest = Manifest::new();
2305 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2306 let sources = vec![
2307 SourceStatus {
2308 mode: SourceMode::Mirror,
2309 fully_enumerated: true,
2310 },
2311 SourceStatus {
2312 mode: SourceMode::Copy,
2313 fully_enumerated: false,
2314 },
2315 ];
2316 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2317 assert_eq!(plan.deletes(), 0);
2318 }
2319
2320 #[test]
2321 fn failed_copy_listing_suppresses_trashed_deletion() {
2322 let mut manifest = Manifest::new();
2323 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2324 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2325 d.trashed = true;
2326 let sources = vec![
2327 SourceStatus {
2328 mode: SourceMode::Mirror,
2329 fully_enumerated: true,
2330 },
2331 SourceStatus {
2332 mode: SourceMode::Copy,
2333 fully_enumerated: false,
2334 },
2335 ];
2336 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2337 assert_eq!(plan.deletes(), 0);
2338 assert_eq!(plan.skips(), 1);
2339 }
2340
2341 #[test]
2342 fn empty_path_entry_never_deletes() {
2343 let mut manifest = Manifest::new();
2346 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
2347 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2348 assert_eq!(plan.deletes(), 0);
2349 assert_eq!(
2350 plan.actions,
2351 vec![Action::Skip {
2352 clip_id: "gone".to_string()
2353 }]
2354 );
2355 }
2356
2357 #[test]
2360 fn delete_suppressed_when_path_aliases_rename_target() {
2361 let mut manifest = Manifest::new();
2364 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
2365 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
2366 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
2367 let local: HashMap<String, LocalFile> = [
2368 ("a".to_string(), present(100)),
2369 ("b".to_string(), present(100)),
2370 ]
2371 .into_iter()
2372 .collect();
2373 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2374 assert!(plan.actions.contains(&Action::Rename {
2375 from: "old/a.flac".to_string(),
2376 to: "new/a.flac".to_string(),
2377 }));
2378 assert!(
2380 !plan
2381 .actions
2382 .iter()
2383 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
2384 );
2385 assert!(plan.actions.contains(&Action::Skip {
2386 clip_id: "b".to_string()
2387 }));
2388 }
2389
2390 #[test]
2391 fn delete_suppressed_when_path_aliases_download_target() {
2392 let mut manifest = Manifest::new();
2394 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
2395 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
2396 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2397 assert!(
2398 !plan
2399 .actions
2400 .iter()
2401 .any(|a| matches!(a, Action::Delete { .. }))
2402 );
2403 assert_eq!(plan.downloads(), 1);
2404 }
2405
2406 #[test]
2407 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
2408 let mut actions = vec![
2413 Action::Rename {
2414 from: "old/song.flac".to_string(),
2415 to: "new/cover.jpg".to_string(),
2416 },
2417 Action::DeleteArtifact {
2418 kind: ArtifactKind::CoverJpg,
2419 path: "new/cover.jpg".to_string(),
2420 owner_id: "a".to_string(),
2421 },
2422 ];
2423 suppress_path_aliasing(&mut actions);
2424 assert!(
2426 !actions
2427 .iter()
2428 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
2429 "a sidecar delete must not alias a rename target"
2430 );
2431 assert!(actions.contains(&Action::Skip {
2432 clip_id: "a".to_string()
2433 }));
2434 assert!(actions.contains(&Action::Rename {
2436 from: "old/song.flac".to_string(),
2437 to: "new/cover.jpg".to_string(),
2438 }));
2439 }
2440
2441 #[test]
2442 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
2443 let mut actions = vec![
2446 Action::WriteArtifact {
2447 kind: ArtifactKind::FolderJpg,
2448 path: "creator/album/folder.jpg".to_string(),
2449 source_url: "https://art/large.jpg".to_string(),
2450 hash: "h".to_string(),
2451 owner_id: "root".to_string(),
2452 content: None,
2453 },
2454 Action::DeleteArtifact {
2455 kind: ArtifactKind::FolderJpg,
2456 path: "creator/album/folder.jpg".to_string(),
2457 owner_id: "root-old".to_string(),
2458 },
2459 ];
2460 suppress_path_aliasing(&mut actions);
2461 assert!(
2462 !actions
2463 .iter()
2464 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
2465 );
2466 assert!(actions.contains(&Action::Skip {
2467 clip_id: "root-old".to_string()
2468 }));
2469 }
2470
2471 #[test]
2474 fn duplicate_trashed_does_not_defeat_copy_sibling() {
2475 let mut manifest = Manifest::new();
2478 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2479 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2480 copy_entry.modes = vec![SourceMode::Copy];
2481 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2482 trashed_entry.modes = vec![SourceMode::Mirror];
2483 trashed_entry.trashed = true;
2484 let plan = reconcile(
2485 &manifest,
2486 &[copy_entry, trashed_entry],
2487 &local_present("a"),
2488 &mirror_ok(),
2489 );
2490 assert_eq!(plan.deletes(), 0);
2491 assert_eq!(plan.skips(), 1);
2492 }
2493
2494 #[test]
2495 fn duplicate_trashed_does_not_defeat_private_sibling() {
2496 let mut manifest = Manifest::new();
2497 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2498 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2499 private_entry.private = true;
2500 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2501 trashed_entry.trashed = true;
2502 let plan = reconcile(
2503 &manifest,
2504 &[private_entry, trashed_entry],
2505 &local_present("a"),
2506 &mirror_ok(),
2507 );
2508 assert_eq!(plan.deletes(), 0);
2509 assert_eq!(plan.skips(), 1);
2510 }
2511
2512 #[test]
2513 fn duplicate_trashed_deletes_only_when_all_trashed() {
2514 let mut manifest = Manifest::new();
2516 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2517 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2518 first.trashed = true;
2519 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2520 second.trashed = true;
2521 let plan = reconcile(
2522 &manifest,
2523 &[first, second],
2524 &local_present("a"),
2525 &mirror_ok(),
2526 );
2527 assert_eq!(plan.deletes(), 1);
2528 }
2529
2530 #[test]
2531 fn duplicate_desired_unions_modes() {
2532 let mut manifest = Manifest::new();
2534 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2535 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2536 mirror_entry.modes = vec![SourceMode::Mirror];
2537 mirror_entry.trashed = true;
2538 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2539 copy_entry.modes = vec![SourceMode::Copy];
2540 let plan = reconcile(
2541 &manifest,
2542 &[mirror_entry, copy_entry],
2543 &local_present("a"),
2544 &mirror_ok(),
2545 );
2546 assert_eq!(plan.deletes(), 0);
2548 }
2549
2550 #[test]
2553 fn private_new_clip_downloads() {
2554 let manifest = Manifest::new();
2557 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2558 d.private = true;
2559 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2560 assert_eq!(plan.downloads(), 1);
2561 }
2562
2563 #[test]
2564 fn private_zero_length_file_redownloads() {
2565 let mut manifest = Manifest::new();
2566 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2567 let local: HashMap<String, LocalFile> = [(
2568 "a".to_string(),
2569 LocalFile {
2570 exists: true,
2571 size: 0,
2572 },
2573 )]
2574 .into_iter()
2575 .collect();
2576 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2577 d.private = true;
2578 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2579 assert_eq!(plan.downloads(), 1);
2580 }
2581
2582 #[test]
2583 fn private_meta_change_retags() {
2584 let mut manifest = Manifest::new();
2585 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2586 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2587 d.private = true;
2588 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2589 assert_eq!(plan.retags(), 1);
2590 assert_eq!(plan.deletes(), 0);
2591 }
2592
2593 #[test]
2594 fn absent_private_clip_protected_by_preserve_marker() {
2595 let mut manifest = Manifest::new();
2598 manifest.insert(
2599 "a",
2600 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2601 );
2602 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2603 assert_eq!(plan.deletes(), 0);
2604 assert_eq!(plan.skips(), 1);
2605 }
2606
2607 #[test]
2610 fn output_is_deterministic_regardless_of_input_order() {
2611 let mut manifest = Manifest::new();
2612 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2613 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2614 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2615 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2616 .iter()
2617 .map(|id| (id.to_string(), present(100)))
2618 .collect();
2619
2620 let forward = vec![
2621 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2622 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2623 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2624 ];
2625 let mut reversed = forward.clone();
2626 reversed.reverse();
2627
2628 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2629 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2630 assert_eq!(p1.actions, p2.actions);
2631
2632 let ids: Vec<&str> = p1
2635 .actions
2636 .iter()
2637 .map(|a| match a {
2638 Action::Skip { clip_id } => clip_id.as_str(),
2639 Action::Retag { clip, .. } => clip.id.as_str(),
2640 Action::Download { clip, .. } => clip.id.as_str(),
2641 Action::Delete { clip_id, .. } => clip_id.as_str(),
2642 Action::Reformat { clip, .. } => clip.id.as_str(),
2643 Action::Rename { to, .. } => to.as_str(),
2644 Action::WriteArtifact { owner_id, .. }
2645 | Action::DeleteArtifact { owner_id, .. }
2646 | Action::MoveArtifact { owner_id, .. } => owner_id.as_str(),
2647 Action::WriteStem { clip_id, .. }
2648 | Action::DeleteStem { clip_id, .. }
2649 | Action::MoveStem { clip_id, .. } => clip_id.as_str(),
2650 })
2651 .collect();
2652 assert_eq!(ids, ["a", "b", "c", "z"]);
2653 }
2654
2655 #[test]
2656 fn empty_inputs_do_not_panic() {
2657 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
2658 assert!(plan.is_empty());
2659 assert_eq!(plan.len(), 0);
2660 }
2661
2662 #[test]
2663 fn empty_desired_with_full_manifest_deletes_all() {
2664 let mut manifest = Manifest::new();
2665 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2666 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
2667 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2668 assert_eq!(plan.deletes(), 2);
2669 }
2670
2671 #[test]
2672 fn full_desired_with_empty_manifest_downloads_all() {
2673 let d = vec![
2674 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2675 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
2676 ];
2677 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
2678 assert_eq!(plan.downloads(), 2);
2679 }
2680
2681 #[test]
2682 fn plan_counts_sum_to_len() {
2683 let mut manifest = Manifest::new();
2684 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
2685 manifest.insert(
2686 "retag",
2687 entry("retag.flac", AudioFormat::Flac, "old", "art"),
2688 );
2689 manifest.insert(
2690 "reformat",
2691 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
2692 );
2693 manifest.insert(
2694 "rename",
2695 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
2696 );
2697 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2698 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
2699 .iter()
2700 .map(|id| (id.to_string(), present(100)))
2701 .collect();
2702 let d = vec![
2703 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
2704 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
2705 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
2706 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
2707 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
2708 ];
2709 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2710 let summed = plan.downloads()
2711 + plan.reformats()
2712 + plan.retags()
2713 + plan.renames()
2714 + plan.deletes()
2715 + plan.skips();
2716 assert_eq!(summed, plan.len());
2717 assert_eq!(plan.downloads(), 1);
2718 assert_eq!(plan.reformats(), 1);
2719 assert_eq!(plan.retags(), 1);
2720 assert_eq!(plan.renames(), 1);
2721 assert_eq!(plan.deletes(), 1);
2722 assert_eq!(plan.skips(), 1);
2723 }
2724
2725 fn cover(path: &str, hash: &str) -> ArtifactState {
2728 ArtifactState {
2729 path: path.to_string(),
2730 hash: hash.to_string(),
2731 }
2732 }
2733
2734 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
2735 DesiredArtifact {
2736 kind,
2737 path: path.to_string(),
2738 source_url: url.to_string(),
2739 hash: hash.to_string(),
2740 content: None,
2741 }
2742 }
2743
2744 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
2746 DesiredArtifact {
2747 kind,
2748 path: path.to_string(),
2749 source_url: String::new(),
2750 hash: content_hash(body),
2751 content: Some(body.to_string()),
2752 }
2753 }
2754
2755 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
2757 Desired {
2758 artifacts: arts,
2759 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2760 }
2761 }
2762
2763 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
2765 ManifestEntry {
2766 cover_jpg: Some(cover(cover_path, cover_hash)),
2767 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
2768 }
2769 }
2770
2771 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
2772 plan.actions
2773 .iter()
2774 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
2775 .collect()
2776 }
2777
2778 #[test]
2779 fn write_artifact_emitted_when_manifest_lacks_it() {
2780 let mut manifest = Manifest::new();
2783 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2784 let d = vec![desired_arts(
2785 "a",
2786 vec![art(
2787 ArtifactKind::CoverJpg,
2788 "a/cover.jpg",
2789 "https://art/a",
2790 "h1",
2791 )],
2792 )];
2793 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2794 assert_eq!(plan.artifact_writes(), 1);
2795 assert_eq!(plan.artifact_deletes(), 0);
2796 assert_eq!(plan.skips(), 1);
2797 assert_eq!(
2798 write_artifacts(&plan)[0],
2799 &Action::WriteArtifact {
2800 kind: ArtifactKind::CoverJpg,
2801 path: "a/cover.jpg".to_string(),
2802 source_url: "https://art/a".to_string(),
2803 hash: "h1".to_string(),
2804 owner_id: "a".to_string(),
2805 content: None,
2806 }
2807 );
2808 }
2809
2810 #[test]
2811 fn write_artifact_emitted_when_hash_differs() {
2812 let mut manifest = Manifest::new();
2815 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
2816 let d = vec![desired_arts(
2817 "a",
2818 vec![art(
2819 ArtifactKind::CoverJpg,
2820 "a/cover.jpg",
2821 "https://art/a",
2822 "new",
2823 )],
2824 )];
2825 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2826 assert_eq!(plan.artifact_writes(), 1);
2827 assert_eq!(plan.artifact_deletes(), 0);
2828 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
2829 assert_eq!(hash, "new");
2830 } else {
2831 panic!("expected a WriteArtifact");
2832 }
2833 }
2834
2835 #[test]
2836 fn write_artifact_skipped_when_hash_matches() {
2837 let mut manifest = Manifest::new();
2839 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2840 let d = vec![desired_arts(
2841 "a",
2842 vec![art(
2843 ArtifactKind::CoverJpg,
2844 "a/cover.jpg",
2845 "https://art/a",
2846 "h1",
2847 )],
2848 )];
2849 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2850 assert_eq!(plan.artifact_writes(), 0);
2851 assert_eq!(plan.artifact_deletes(), 0);
2852 assert_eq!(
2853 plan.actions,
2854 vec![Action::Skip {
2855 clip_id: "a".to_string()
2856 }]
2857 );
2858 }
2859
2860 #[test]
2861 fn removed_kind_cover_is_kept_not_deleted() {
2862 let mut manifest = Manifest::new();
2867 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2868 let d = vec![desired_arts("a", vec![])];
2869 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2870 assert_eq!(plan.artifact_deletes(), 0);
2871 assert_eq!(plan.artifact_writes(), 0);
2872 assert_eq!(plan.deletes(), 0);
2874 assert_eq!(
2875 plan.actions,
2876 vec![Action::Skip {
2877 clip_id: "a".to_string()
2878 }]
2879 );
2880 assert!(!plan.actions.iter().any(|a| matches!(
2881 a,
2882 Action::DeleteArtifact {
2883 kind: ArtifactKind::CoverJpg,
2884 ..
2885 }
2886 )));
2887 }
2888
2889 #[test]
2890 fn delete_artifact_never_on_incomplete_listing() {
2891 let mut manifest = Manifest::new();
2896 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2897 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
2898 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
2899 let sources = vec![SourceStatus {
2900 mode: SourceMode::Mirror,
2901 fully_enumerated: false,
2902 }];
2903 let local: HashMap<String, LocalFile> = [
2904 ("a".to_string(), present(100)),
2905 ("b".to_string(), present(100)),
2906 ]
2907 .into_iter()
2908 .collect();
2909 let plan = reconcile(&manifest, &d, &local, &sources);
2910 assert_eq!(plan.artifact_deletes(), 0);
2911 assert_eq!(plan.deletes(), 0);
2912 }
2913
2914 #[test]
2915 fn delete_artifact_never_when_entry_preserved() {
2916 let mut manifest = Manifest::new();
2919 let preserved = ManifestEntry {
2920 preserve: true,
2921 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
2922 };
2923 manifest.insert("a", preserved);
2924 let d = vec![desired_arts("a", vec![])];
2925 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2926 assert_eq!(plan.artifact_deletes(), 0);
2927 }
2928
2929 #[test]
2930 fn co_delete_never_when_path_empty() {
2931 let mut manifest = Manifest::new();
2935 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
2936 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2937 assert_eq!(plan.deletes(), 1);
2938 assert_eq!(plan.artifact_deletes(), 0);
2939 }
2940
2941 #[test]
2942 fn co_delete_absent_clip_deletes_audio_and_cover() {
2943 let mut manifest = Manifest::new();
2946 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2947 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2948 assert_eq!(plan.deletes(), 1);
2949 assert_eq!(plan.artifact_deletes(), 1);
2950 assert!(plan.actions.contains(&Action::Delete {
2951 path: "gone.flac".to_string(),
2952 clip_id: "gone".to_string(),
2953 }));
2954 assert!(plan.actions.contains(&Action::DeleteArtifact {
2955 kind: ArtifactKind::CoverJpg,
2956 path: "gone/cover.jpg".to_string(),
2957 owner_id: "gone".to_string(),
2958 }));
2959 }
2960
2961 #[test]
2962 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
2963 let mut manifest = Manifest::new();
2965 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
2966 let sources = vec![SourceStatus {
2967 mode: SourceMode::Mirror,
2968 fully_enumerated: false,
2969 }];
2970 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2971 assert_eq!(plan.deletes(), 0);
2972 assert_eq!(plan.artifact_deletes(), 0);
2973 }
2974
2975 #[test]
2976 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
2977 let mut manifest = Manifest::new();
2979 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2980 let mut d = desired_arts("a", vec![]);
2981 d.trashed = true;
2982 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2983 assert_eq!(plan.deletes(), 1);
2984 assert_eq!(plan.artifact_deletes(), 1);
2985 }
2986
2987 #[test]
2988 fn co_delete_trashed_suppressed_when_not_enumerated() {
2989 let mut manifest = Manifest::new();
2991 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
2992 let mut d = desired_arts("a", vec![]);
2993 d.trashed = true;
2994 let sources = vec![SourceStatus {
2995 mode: SourceMode::Mirror,
2996 fully_enumerated: false,
2997 }];
2998 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2999 assert_eq!(plan.deletes(), 0);
3000 assert_eq!(plan.artifact_deletes(), 0);
3001 assert_eq!(plan.skips(), 1);
3002 }
3003
3004 #[test]
3005 fn co_delete_trashed_suppressed_when_preserved() {
3006 let mut manifest = Manifest::new();
3008 let preserved = ManifestEntry {
3009 preserve: true,
3010 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
3011 };
3012 manifest.insert("a", preserved);
3013 let mut d = desired_arts("a", vec![]);
3014 d.trashed = true;
3015 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3016 assert_eq!(plan.deletes(), 0);
3017 assert_eq!(plan.artifact_deletes(), 0);
3018 }
3019
3020 #[test]
3023 fn details_sidecar_written_with_inline_content_when_slot_absent() {
3024 let mut manifest = Manifest::new();
3027 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3028 let d = vec![desired_arts(
3029 "a",
3030 vec![text_art(
3031 ArtifactKind::DetailsTxt,
3032 "a.details.txt",
3033 "Title: A\n",
3034 )],
3035 )];
3036 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3037 assert_eq!(plan.artifact_writes(), 1);
3038 assert_eq!(plan.artifact_deletes(), 0);
3039 assert_eq!(
3040 write_artifacts(&plan)[0],
3041 &Action::WriteArtifact {
3042 kind: ArtifactKind::DetailsTxt,
3043 path: "a.details.txt".to_string(),
3044 source_url: String::new(),
3045 hash: content_hash("Title: A\n"),
3046 owner_id: "a".to_string(),
3047 content: Some("Title: A\n".to_string()),
3048 }
3049 );
3050 }
3051
3052 #[test]
3053 fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
3054 let mut manifest = Manifest::new();
3059 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3060 let body = "[re:rs-suno]\nla la\n";
3061 let d = vec![desired_arts(
3062 "a",
3063 vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
3064 )];
3065 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3066 assert_eq!(plan.artifact_writes(), 1);
3067 assert_eq!(plan.artifact_deletes(), 0);
3068 assert_eq!(
3069 write_artifacts(&plan)[0],
3070 &Action::WriteArtifact {
3071 kind: ArtifactKind::Lrc,
3072 path: "a.lrc".to_string(),
3073 source_url: String::new(),
3074 hash: content_hash(body),
3075 owner_id: "a".to_string(),
3076 content: Some(body.to_string()),
3077 }
3078 );
3079 }
3080
3081 #[test]
3082 fn text_sidecars_skipped_when_hash_and_path_match() {
3083 let mut manifest = Manifest::new();
3085 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3086 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3087 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
3088 manifest.insert("a", e);
3089 let d = vec![desired_arts(
3090 "a",
3091 vec![
3092 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
3093 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
3094 ],
3095 )];
3096 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3097 assert_eq!(plan.artifact_writes(), 0);
3098 assert_eq!(plan.artifact_deletes(), 0);
3099 }
3100
3101 #[test]
3102 fn details_rewritten_when_content_hash_differs() {
3103 let mut manifest = Manifest::new();
3106 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3107 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
3108 manifest.insert("a", e);
3109 let d = vec![desired_arts(
3110 "a",
3111 vec![text_art(
3112 ArtifactKind::DetailsTxt,
3113 "a.details.txt",
3114 "Title: New\n",
3115 )],
3116 )];
3117 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3118 assert_eq!(plan.artifact_writes(), 1);
3119 assert_eq!(plan.artifact_deletes(), 0);
3120 }
3121
3122 #[test]
3123 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
3124 let mut manifest = Manifest::new();
3128 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3129 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
3130 manifest.insert("a", e);
3131 let d = vec![desired_arts(
3132 "a",
3133 vec![text_art(
3134 ArtifactKind::LyricsTxt,
3135 "a.lyrics.txt",
3136 "new words\n",
3137 )],
3138 )];
3139 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3140 assert_eq!(plan.artifact_writes(), 1);
3142 assert_eq!(plan.retags(), 0);
3143 }
3144
3145 #[test]
3146 fn text_sidecar_relocated_when_path_differs() {
3147 let mut manifest = Manifest::new();
3150 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3151 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
3152 manifest.insert("a", e);
3153 let d = vec![desired_arts(
3154 "a",
3155 vec![text_art(
3156 ArtifactKind::DetailsTxt,
3157 "new/a.details.txt",
3158 "Title: A\n",
3159 )],
3160 )];
3161 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3162 assert_eq!(plan.artifact_writes(), 1);
3163 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3164 assert_eq!(path, "new/a.details.txt");
3165 } else {
3166 panic!("expected a WriteArtifact");
3167 }
3168 }
3169
3170 #[test]
3171 fn fetched_sidecar_path_drift_emits_move() {
3172 let mut manifest = Manifest::new();
3175 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3176 e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3177 manifest.insert("a", e);
3178 let d = vec![desired_arts(
3179 "a",
3180 vec![art(
3181 ArtifactKind::CoverJpg,
3182 "new/cover.jpg",
3183 "https://art/large.jpg",
3184 "arthash",
3185 )],
3186 )];
3187 let local: HashMap<String, LocalFile> = [
3188 ("a".to_string(), present(100)),
3189 ("old/cover.jpg".to_string(), present(50)),
3190 ]
3191 .into_iter()
3192 .collect();
3193 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3194 assert_eq!(plan.artifact_moves(), 1);
3195 assert_eq!(plan.artifact_writes(), 0);
3196 assert!(plan.actions.contains(&Action::MoveArtifact {
3197 kind: ArtifactKind::CoverJpg,
3198 from: "old/cover.jpg".to_string(),
3199 to: "new/cover.jpg".to_string(),
3200 source_url: "https://art/large.jpg".to_string(),
3201 hash: "arthash".to_string(),
3202 owner_id: "a".to_string(),
3203 }));
3204 }
3205
3206 #[test]
3207 fn sidecar_hash_drift_emits_write_not_move() {
3208 let mut manifest = Manifest::new();
3210 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3211 e.cover_jpg = Some(cover("old/cover.jpg", "oldhash"));
3212 manifest.insert("a", e);
3213 let d = vec![desired_arts(
3214 "a",
3215 vec![art(
3216 ArtifactKind::CoverJpg,
3217 "new/cover.jpg",
3218 "https://art/large.jpg",
3219 "newhash",
3220 )],
3221 )];
3222 let local: HashMap<String, LocalFile> = [
3223 ("a".to_string(), present(100)),
3224 ("old/cover.jpg".to_string(), present(50)),
3225 ]
3226 .into_iter()
3227 .collect();
3228 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3229 assert_eq!(plan.artifact_moves(), 0);
3230 assert_eq!(plan.artifact_writes(), 1);
3231 }
3232
3233 #[test]
3234 fn inline_sidecar_path_drift_stays_a_write() {
3235 let mut manifest = Manifest::new();
3238 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3239 e.lyrics_txt = Some(cover("old/a.lyrics.txt", &content_hash("words\n")));
3240 manifest.insert("a", e);
3241 let d = vec![desired_arts(
3242 "a",
3243 vec![text_art(
3244 ArtifactKind::LyricsTxt,
3245 "new/a.lyrics.txt",
3246 "words\n",
3247 )],
3248 )];
3249 let local: HashMap<String, LocalFile> = [
3250 ("a".to_string(), present(100)),
3251 ("old/a.lyrics.txt".to_string(), present(50)),
3252 ]
3253 .into_iter()
3254 .collect();
3255 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3256 assert_eq!(plan.artifact_moves(), 0);
3257 assert_eq!(plan.artifact_writes(), 1);
3258 }
3259
3260 #[test]
3261 fn sidecar_move_downgrades_to_write_when_old_file_absent() {
3262 let mut manifest = Manifest::new();
3265 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3266 e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3267 manifest.insert("a", e);
3268 let d = vec![desired_arts(
3269 "a",
3270 vec![art(
3271 ArtifactKind::CoverJpg,
3272 "new/cover.jpg",
3273 "https://art/large.jpg",
3274 "arthash",
3275 )],
3276 )];
3277 let local: HashMap<String, LocalFile> = [
3278 ("a".to_string(), present(100)),
3279 (
3280 "old/cover.jpg".to_string(),
3281 LocalFile {
3282 exists: false,
3283 size: 0,
3284 },
3285 ),
3286 ]
3287 .into_iter()
3288 .collect();
3289 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3290 assert_eq!(plan.artifact_moves(), 0);
3291 assert_eq!(plan.artifact_writes(), 1);
3292 }
3293
3294 #[test]
3295 fn move_target_suppresses_a_colliding_delete() {
3296 let mut manifest = Manifest::new();
3299 let mut a = entry("a.flac", AudioFormat::Flac, "m", "art");
3300 a.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3301 manifest.insert("a", a);
3302 let mut b = entry("b.flac", AudioFormat::Flac, "m", "art");
3305 b.details_txt = Some(cover("new/cover.jpg", "bh"));
3306 manifest.insert("b", b);
3307 let d = vec![
3308 desired_arts(
3309 "a",
3310 vec![art(
3311 ArtifactKind::CoverJpg,
3312 "new/cover.jpg",
3313 "https://art/large.jpg",
3314 "arthash",
3315 )],
3316 ),
3317 desired_arts("b", vec![]),
3318 ];
3319 let local: HashMap<String, LocalFile> = [
3320 ("a".to_string(), present(100)),
3321 ("b".to_string(), present(100)),
3322 ("old/cover.jpg".to_string(), present(50)),
3323 ("new/cover.jpg".to_string(), present(50)),
3324 ]
3325 .into_iter()
3326 .collect();
3327 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3328 assert_eq!(plan.artifact_moves(), 1);
3329 assert!(!plan.actions.iter().any(|a| matches!(
3331 a,
3332 Action::DeleteArtifact { path, .. } if path == "new/cover.jpg"
3333 )));
3334 }
3335
3336 #[test]
3337 fn stem_path_drift_emits_move() {
3338 let mut manifest = Manifest::new();
3341 manifest.insert(
3342 "a",
3343 entry_with_stems("a", &[("voc", "old.stems/voc.mp3", "h1")]),
3344 );
3345 let d = vec![stem_desired(
3346 "a",
3347 Some(vec![dstem("voc", "new.stems/voc.mp3", "h1")]),
3348 )];
3349 let local: HashMap<String, LocalFile> = [
3350 ("a".to_string(), present(100)),
3351 ("old.stems/voc.mp3".to_string(), present(50)),
3352 ]
3353 .into_iter()
3354 .collect();
3355 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3356 assert_eq!(plan.stem_moves(), 1);
3357 assert_eq!(plan.stem_writes(), 0);
3358 assert!(plan.actions.contains(&Action::MoveStem {
3359 clip_id: "a".to_string(),
3360 key: "voc".to_string(),
3361 stem_id: "voc".to_string(),
3362 from: "old.stems/voc.mp3".to_string(),
3363 to: "new.stems/voc.mp3".to_string(),
3364 source_url: "https://cdn1.suno.ai/voc.mp3".to_string(),
3365 format: StemFormat::Mp3,
3366 hash: "h1".to_string(),
3367 }));
3368 }
3369
3370 #[test]
3371 fn details_removed_kind_is_deleted_when_feature_off() {
3372 let mut manifest = Manifest::new();
3375 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3376 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3377 manifest.insert("a", e);
3378 let d = vec![desired_arts("a", vec![])];
3379 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3380 assert_eq!(plan.artifact_deletes(), 1);
3381 assert!(plan.actions.contains(&Action::DeleteArtifact {
3382 kind: ArtifactKind::DetailsTxt,
3383 path: "a.details.txt".to_string(),
3384 owner_id: "a".to_string(),
3385 }));
3386 }
3387
3388 #[test]
3389 fn lyrics_removed_kind_is_kept_not_deleted() {
3390 let mut manifest = Manifest::new();
3394 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3395 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3396 manifest.insert("a", e);
3397 let d = vec![desired_arts("a", vec![])];
3398 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3399 assert_eq!(plan.artifact_deletes(), 0);
3400 assert_eq!(plan.deletes(), 0);
3401 }
3402
3403 #[test]
3404 fn lrc_removed_kind_is_kept_not_deleted() {
3405 let mut manifest = Manifest::new();
3408 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3409 e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3410 manifest.insert("a", e);
3411 let d = vec![desired_arts("a", vec![])];
3412 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3413 assert_eq!(plan.artifact_deletes(), 0);
3414 assert_eq!(plan.deletes(), 0);
3415 }
3416
3417 #[test]
3418 fn video_mp4_removed_kind_is_kept_not_deleted() {
3419 let mut manifest = Manifest::new();
3423 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3424 e.video_mp4 = Some(cover("a.mp4", "vid-hash"));
3425 manifest.insert("a", e);
3426 let d = vec![desired_arts("a", vec![])];
3427 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3428 assert_eq!(plan.artifact_deletes(), 0);
3429 assert_eq!(plan.deletes(), 0);
3430 }
3431
3432 #[test]
3433 fn video_mp4_written_when_manifest_lacks_it() {
3434 let mut manifest = Manifest::new();
3437 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3438 let d = vec![desired_arts(
3439 "a",
3440 vec![art(
3441 ArtifactKind::VideoMp4,
3442 "a/song.mp4",
3443 "https://cdn/a/video.mp4",
3444 "vid-hash",
3445 )],
3446 )];
3447 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3448 assert_eq!(plan.artifact_writes(), 1);
3449 assert_eq!(
3450 write_artifacts(&plan)[0],
3451 &Action::WriteArtifact {
3452 kind: ArtifactKind::VideoMp4,
3453 path: "a/song.mp4".to_string(),
3454 source_url: "https://cdn/a/video.mp4".to_string(),
3455 hash: "vid-hash".to_string(),
3456 owner_id: "a".to_string(),
3457 content: None,
3458 }
3459 );
3460 }
3461
3462 #[test]
3463 fn details_removed_kind_not_deleted_on_incomplete_listing() {
3464 let mut manifest = Manifest::new();
3467 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3468 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3469 manifest.insert("a", e);
3470 let d = vec![desired_arts("a", vec![])];
3471 let sources = vec![SourceStatus {
3472 mode: SourceMode::Mirror,
3473 fully_enumerated: false,
3474 }];
3475 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
3476 assert_eq!(plan.artifact_deletes(), 0);
3477 }
3478
3479 #[test]
3480 fn details_removed_kind_not_deleted_when_preserved() {
3481 let mut manifest = Manifest::new();
3484 let mut e = ManifestEntry {
3485 preserve: true,
3486 ..entry("a.flac", AudioFormat::Flac, "m", "art")
3487 };
3488 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3489 manifest.insert("a", e);
3490 let d = vec![desired_arts("a", vec![])];
3491 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3492 assert_eq!(plan.artifact_deletes(), 0);
3493 }
3494
3495 #[test]
3496 fn co_delete_orphan_removes_every_text_sidecar() {
3497 let mut manifest = Manifest::new();
3501 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
3502 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
3503 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
3504 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
3505 e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3506 e.video_mp4 = Some(cover("gone/song.mp4", "vid-hash"));
3507 manifest.insert("gone", e);
3508 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3509 assert_eq!(plan.deletes(), 1);
3510 assert_eq!(plan.artifact_deletes(), 5);
3511 for (kind, path) in [
3512 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
3513 (ArtifactKind::DetailsTxt, "gone.details.txt"),
3514 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
3515 (ArtifactKind::Lrc, "gone.lrc"),
3516 (ArtifactKind::VideoMp4, "gone/song.mp4"),
3517 ] {
3518 assert!(
3519 plan.actions.contains(&Action::DeleteArtifact {
3520 kind,
3521 path: path.to_string(),
3522 owner_id: "gone".to_string(),
3523 }),
3524 "missing co-delete for {kind:?}"
3525 );
3526 }
3527 }
3528
3529 #[test]
3530 fn co_delete_trashed_removes_every_text_sidecar() {
3531 let mut manifest = Manifest::new();
3533 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3534 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3535 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3536 manifest.insert("a", e);
3537 let mut d = desired_arts("a", vec![]);
3538 d.trashed = true;
3539 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3540 assert_eq!(plan.deletes(), 1);
3541 assert_eq!(plan.artifact_deletes(), 2);
3542 }
3543
3544 #[test]
3545 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
3546 let mut manifest = Manifest::new();
3549 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3550 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
3551 let d = vec![desired_arts(
3554 "a",
3555 vec![art(
3556 ArtifactKind::CoverJpg,
3557 "shared/cover.jpg",
3558 "https://art/a",
3559 "h2",
3560 )],
3561 )];
3562 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3563 assert_eq!(plan.artifact_writes(), 1);
3564 assert!(!plan.actions.iter().any(
3566 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
3567 ));
3568 assert!(plan.actions.contains(&Action::Delete {
3570 path: "b.flac".to_string(),
3571 clip_id: "b".to_string(),
3572 }));
3573 }
3574
3575 #[test]
3576 fn suppress_downgrades_delete_artifact_colliding_with_download() {
3577 let mut manifest = Manifest::new();
3579 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
3580 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
3581 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
3582 assert_eq!(plan.downloads(), 1);
3583 assert!(
3584 !plan
3585 .actions
3586 .iter()
3587 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
3588 );
3589 }
3590
3591 #[test]
3592 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
3593 let build = |with_art: bool| {
3597 let mut manifest = Manifest::new();
3598 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
3599 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3600 manifest.insert(
3601 "trash",
3602 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
3603 );
3604 let keep = if with_art {
3605 desired_arts(
3606 "keep",
3607 vec![art(
3608 ArtifactKind::CoverJpg,
3609 "keep/cover.jpg",
3610 "https://art/keep",
3611 "h1",
3612 )],
3613 )
3614 } else {
3615 desired_arts("keep", vec![])
3616 };
3617 let mut trash = desired_arts("trash", vec![]);
3618 trash.trashed = true;
3619 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
3620 .iter()
3621 .map(|id| (id.to_string(), present(100)))
3622 .collect();
3623 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
3624 };
3625
3626 let with = build(true);
3627 let without = build(false);
3628
3629 let audio = |plan: &Plan| -> Vec<Action> {
3631 plan.actions
3632 .iter()
3633 .filter(|a| {
3634 !matches!(
3635 a,
3636 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
3637 )
3638 })
3639 .cloned()
3640 .collect()
3641 };
3642 assert_eq!(audio(&with), audio(&without));
3643 assert_eq!(with.deletes(), without.deletes());
3644 assert_eq!(with.deletes(), 2);
3646 assert_eq!(with.artifact_deletes(), 2);
3650 assert_eq!(with.artifact_writes(), 0);
3651 }
3652
3653 #[test]
3656 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
3657 let mut manifest = Manifest::new();
3663 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3664 assert!(!manifest.get("a").unwrap().preserve);
3665
3666 let private = Desired {
3668 private: true,
3669 ..desired_arts("a", vec![])
3670 };
3671 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
3672 assert_eq!(plan.artifact_deletes(), 0);
3673
3674 let copy_held = Desired {
3676 modes: vec![SourceMode::Copy],
3677 ..desired_arts("a", vec![])
3678 };
3679 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
3680 assert_eq!(plan.artifact_deletes(), 0);
3681 }
3682
3683 #[test]
3684 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
3685 let mut manifest = Manifest::new();
3691 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
3692 let d = vec![desired_arts(
3693 "a",
3694 vec![art(
3695 ArtifactKind::CoverJpg,
3696 "new/cover.jpg",
3697 "https://art/a",
3698 "h1",
3699 )],
3700 )];
3701 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3702 assert_eq!(plan.artifact_writes(), 1);
3703 assert_eq!(plan.artifact_deletes(), 0);
3704 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3705 assert_eq!(path, "new/cover.jpg");
3706 } else {
3707 panic!("expected a WriteArtifact");
3708 }
3709 }
3710
3711 #[test]
3712 fn needs_write_drift_applies_hash_path_and_probe_rules() {
3713 let local: HashMap<String, LocalFile> = [
3714 ("ok".to_string(), present(10)),
3715 ("missing".to_string(), LocalFile::default()),
3716 ("empty".to_string(), present(0)),
3717 ]
3718 .into_iter()
3719 .collect();
3720
3721 assert!(needs_write_drift(None, "h1", "ok", &local));
3722 assert!(!needs_write_drift(Some(("h1", "ok")), "h1", "ok", &local));
3723 assert!(needs_write_drift(Some(("h0", "ok")), "h1", "ok", &local));
3724 assert!(needs_write_drift(
3725 Some(("h1", "missing")),
3726 "h1",
3727 "missing",
3728 &local
3729 ));
3730 assert!(needs_write_drift(
3731 Some(("h1", "empty")),
3732 "h1",
3733 "empty",
3734 &local
3735 ));
3736 assert!(!needs_write_drift(
3737 Some(("h1", "unprobed")),
3738 "h1",
3739 "unprobed",
3740 &local
3741 ));
3742 }
3743
3744 #[test]
3745 fn per_clip_reconcile_ignores_album_and_library_kinds() {
3746 let mut manifest = Manifest::new();
3750 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3751 let d = vec![desired_arts(
3752 "a",
3753 vec![
3754 art(
3755 ArtifactKind::FolderJpg,
3756 "a/folder.jpg",
3757 "https://art/folder",
3758 "hf",
3759 ),
3760 art(
3761 ArtifactKind::Playlist,
3762 "a/list.m3u",
3763 "https://art/list",
3764 "hp",
3765 ),
3766 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
3767 ],
3768 )];
3769 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3770 assert_eq!(plan.artifact_writes(), 1);
3771 let paths: Vec<&str> = plan
3772 .actions
3773 .iter()
3774 .filter_map(|a| match a {
3775 Action::WriteArtifact { path, .. } => Some(path.as_str()),
3776 _ => None,
3777 })
3778 .collect();
3779 assert_eq!(paths, vec!["a/cover.jpg"]);
3780 }
3781
3782 #[test]
3783 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
3784 let mut manifest = Manifest::new();
3785 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3786 let d = vec![desired_arts(
3787 "a",
3788 vec![art(
3789 ArtifactKind::FolderWebp,
3790 "a/folder.webp",
3791 "https://art/folder",
3792 "hf",
3793 )],
3794 )];
3795 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3796 assert_eq!(plan.artifact_writes(), 0);
3797 assert_eq!(plan.artifact_deletes(), 0);
3798 }
3799
3800 fn local_with_missing(audio_id: &str, missing_path: &str) -> HashMap<String, LocalFile> {
3804 let mut m = local_present(audio_id);
3805 m.insert(missing_path.to_owned(), LocalFile::default());
3806 m
3807 }
3808
3809 fn local_with_present_artifact(
3811 audio_id: &str,
3812 artifact_path: &str,
3813 ) -> HashMap<String, LocalFile> {
3814 let mut m = local_present(audio_id);
3815 m.insert(artifact_path.to_owned(), present(50));
3816 m
3817 }
3818
3819 #[test]
3820 fn sidecar_missing_on_disk_forces_rewrite() {
3821 let mut manifest = Manifest::new();
3825 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3826 let d = vec![desired_arts(
3827 "a",
3828 vec![art(
3829 ArtifactKind::CoverJpg,
3830 "a/cover.jpg",
3831 "https://art/a",
3832 "h1",
3833 )],
3834 )];
3835 let local = local_with_missing("a", "a/cover.jpg");
3836 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3837 assert_eq!(
3838 plan.artifact_writes(),
3839 1,
3840 "missing sidecar must be rewritten"
3841 );
3842 assert_eq!(plan.artifact_deletes(), 0);
3843 }
3844
3845 #[test]
3846 fn sidecar_present_on_disk_with_matching_hash_no_churn() {
3847 let mut manifest = Manifest::new();
3849 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3850 let d = vec![desired_arts(
3851 "a",
3852 vec![art(
3853 ArtifactKind::CoverJpg,
3854 "a/cover.jpg",
3855 "https://art/a",
3856 "h1",
3857 )],
3858 )];
3859 let local = local_with_present_artifact("a", "a/cover.jpg");
3860 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3861 assert_eq!(plan.artifact_writes(), 0, "present sidecar must not churn");
3862 assert_eq!(plan.artifact_deletes(), 0);
3863 }
3864
3865 #[test]
3866 fn sidecar_probe_absent_falls_back_to_hash_comparison_no_write() {
3867 let mut manifest = Manifest::new();
3871 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3872 let d = vec![desired_arts(
3873 "a",
3874 vec![art(
3875 ArtifactKind::CoverJpg,
3876 "a/cover.jpg",
3877 "https://art/a",
3878 "h1",
3879 )],
3880 )];
3881 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3883 assert_eq!(
3884 plan.artifact_writes(),
3885 0,
3886 "no write when probe unavailable and hash matches"
3887 );
3888 assert_eq!(
3889 plan.artifact_deletes(),
3890 0,
3891 "missing probe must never trigger a delete"
3892 );
3893 }
3894
3895 #[test]
3896 fn folder_art_missing_on_disk_forces_rewrite() {
3897 let members = vec![album_member(
3900 album_clip("a", 1, "t0", "art-a", ""),
3901 "root",
3902 "c/al/a.flac",
3903 )];
3904 let desired = album_desired(&members, false, false);
3905 let mut albums = BTreeMap::new();
3906 albums.insert(
3907 "root".to_string(),
3908 AlbumArt {
3909 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3910 folder_webp: None,
3911 folder_mp4: None,
3912 },
3913 );
3914 let mut local: HashMap<String, LocalFile> = HashMap::new();
3915 local.insert("c/al/folder.jpg".to_owned(), LocalFile::default());
3916 let actions = plan_album_artifacts(&desired, &albums, true, &local);
3917 assert_eq!(actions.len(), 1, "missing folder art must be rewritten");
3918 assert!(matches!(
3919 &actions[0],
3920 Action::WriteArtifact {
3921 kind: ArtifactKind::FolderJpg,
3922 ..
3923 }
3924 ));
3925 }
3926
3927 #[test]
3928 fn folder_art_present_on_disk_no_churn() {
3929 let members = vec![album_member(
3931 album_clip("a", 1, "t0", "art-a", ""),
3932 "root",
3933 "c/al/a.flac",
3934 )];
3935 let desired = album_desired(&members, false, false);
3936 let mut albums = BTreeMap::new();
3937 albums.insert(
3938 "root".to_string(),
3939 AlbumArt {
3940 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
3941 folder_webp: None,
3942 folder_mp4: None,
3943 },
3944 );
3945 let mut local: HashMap<String, LocalFile> = HashMap::new();
3946 local.insert("c/al/folder.jpg".to_owned(), present(5000));
3947 let actions = plan_album_artifacts(&desired, &albums, true, &local);
3948 assert!(
3949 actions.is_empty(),
3950 "present folder art with matching hash must not churn"
3951 );
3952 }
3953
3954 #[test]
3955 fn playlist_missing_on_disk_forces_rewrite() {
3956 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3959 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3960 let mut local: HashMap<String, LocalFile> = HashMap::new();
3961 local.insert("Mix.m3u8".to_owned(), LocalFile::default());
3962 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
3963 assert_eq!(actions.len(), 1, "missing playlist file must be rewritten");
3964 assert!(matches!(
3965 &actions[0],
3966 Action::WriteArtifact {
3967 kind: ArtifactKind::Playlist,
3968 ..
3969 }
3970 ));
3971 }
3972
3973 #[test]
3974 fn playlist_present_on_disk_no_churn() {
3975 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
3977 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
3978 let mut local: HashMap<String, LocalFile> = HashMap::new();
3979 local.insert("Mix.m3u8".to_owned(), present(200));
3980 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
3981 assert!(
3982 actions.is_empty(),
3983 "present playlist with matching hash must not churn"
3984 );
3985 }
3986
3987 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
3990 Clip {
3991 id: id.to_string(),
3992 title: "Song".to_string(),
3993 image_large_url: image.to_string(),
3994 video_cover_url: video.to_string(),
3995 play_count,
3996 created_at: created_at.to_string(),
3997 ..Default::default()
3998 }
3999 }
4000
4001 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
4002 let mut lineage = LineageContext::own_root(&clip);
4003 lineage.root_id = root_id.to_string();
4004 Desired {
4005 clip,
4006 lineage,
4007 path: path.to_string(),
4008 format: AudioFormat::Flac,
4009 meta_hash: "m".to_string(),
4010 art_hash: "a".to_string(),
4011 modes: vec![SourceMode::Mirror],
4012 trashed: false,
4013 private: false,
4014 artifacts: Vec::new(),
4015 stems: None,
4016 }
4017 }
4018
4019 fn stored(path: &str, hash: &str) -> ArtifactState {
4020 ArtifactState {
4021 path: path.to_string(),
4022 hash: hash.to_string(),
4023 }
4024 }
4025
4026 #[test]
4027 fn folder_jpg_source_is_most_played() {
4028 let members = vec![
4029 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
4030 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
4031 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
4032 ];
4033 let albums = album_desired(&members, false, false);
4034 assert_eq!(albums.len(), 1);
4035 let jpg = albums[0].folder_jpg.as_ref().unwrap();
4036 assert_eq!(jpg.hash, art_url_hash("art-b"));
4038 assert_eq!(jpg.source_url, "art-b");
4039 assert_eq!(jpg.path, "c/al/folder.jpg");
4040 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
4041 }
4042
4043 #[test]
4044 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
4045 let by_time = vec![
4047 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
4048 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
4049 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
4050 ];
4051 let jpg = album_desired(&by_time, false, false)[0]
4052 .folder_jpg
4053 .clone()
4054 .unwrap();
4055 assert_eq!(jpg.source_url, "art-y");
4056
4057 let by_id = vec![
4059 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
4060 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
4061 ];
4062 let jpg = album_desired(&by_id, false, false)[0]
4063 .folder_jpg
4064 .clone()
4065 .unwrap();
4066 assert_eq!(jpg.source_url, "art-g");
4067 }
4068
4069 #[test]
4070 fn folder_webp_source_is_first_created_animated() {
4071 let members = vec![
4072 album_member(
4073 album_clip("a", 9, "t2", "art-a", "vid-a"),
4074 "root",
4075 "c/al/a.flac",
4076 ),
4077 album_member(
4078 album_clip("b", 1, "t0", "art-b", "vid-b"),
4079 "root",
4080 "c/al/b.flac",
4081 ),
4082 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
4083 ];
4084 let webp = album_desired(&members, true, false)[0]
4085 .folder_webp
4086 .clone()
4087 .unwrap();
4088 assert_eq!(webp.source_url, "vid-b");
4090 assert_eq!(webp.hash, art_url_hash("vid-b"));
4091 assert_eq!(webp.path, "c/al/cover.webp");
4092 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
4093 }
4094
4095 #[test]
4096 fn animated_covers_off_yields_no_folder_webp() {
4097 let members = vec![album_member(
4098 album_clip("a", 1, "t0", "art-a", "vid-a"),
4099 "root",
4100 "c/al/a.flac",
4101 )];
4102 let off = album_desired(&members, false, false);
4103 assert!(off[0].folder_webp.is_none());
4104 let on = album_desired(&members, true, false);
4105 assert!(on[0].folder_webp.is_some());
4106 }
4107
4108 #[test]
4109 fn raw_cover_yields_folder_mp4_from_the_webp_source_verbatim() {
4110 let members = vec![
4111 album_member(
4112 album_clip("a", 9, "t2", "art-a", "vid-a"),
4113 "root",
4114 "c/al/a.flac",
4115 ),
4116 album_member(
4117 album_clip("b", 1, "t0", "art-b", "vid-b"),
4118 "root",
4119 "c/al/b.flac",
4120 ),
4121 ];
4122 let album = album_desired(&members, true, true).remove(0);
4126 let webp = album.folder_webp.unwrap();
4127 let mp4 = album.folder_mp4.unwrap();
4128 assert_eq!(mp4.kind, ArtifactKind::FolderMp4);
4129 assert_eq!(mp4.path, "c/al/cover.mp4");
4130 assert_eq!(mp4.source_url, "vid-b");
4131 assert_eq!(mp4.hash, art_url_hash("vid-b"));
4132 assert_eq!(mp4.source_url, webp.source_url, "same variant feeds both");
4133 }
4134
4135 #[test]
4136 fn raw_cover_and_webp_are_independent_toggles() {
4137 let members = vec![album_member(
4138 album_clip("a", 1, "t0", "art-a", "vid-a"),
4139 "root",
4140 "c/al/a.flac",
4141 )];
4142 let webp_only = album_desired(&members, true, false).remove(0);
4144 assert!(webp_only.folder_webp.is_some());
4145 assert!(webp_only.folder_mp4.is_none());
4146 let mp4_only = album_desired(&members, false, true).remove(0);
4148 assert!(mp4_only.folder_webp.is_none());
4149 assert!(mp4_only.folder_mp4.is_some());
4150 }
4151
4152 #[test]
4153 fn raw_cover_needs_an_animated_source() {
4154 let members = vec![album_member(
4156 album_clip("a", 3, "t0", "art-a", ""),
4157 "root",
4158 "c/al/a.flac",
4159 )];
4160 let album = album_desired(&members, true, true).remove(0);
4161 assert!(album.folder_mp4.is_none());
4162 assert!(album.folder_webp.is_none());
4163 }
4164
4165 #[test]
4166 fn album_with_no_art_yields_no_folder_jpg() {
4167 let members = vec![album_member(
4168 album_clip("a", 3, "t0", "", ""),
4169 "root",
4170 "c/al/a.flac",
4171 )];
4172 let albums = album_desired(&members, true, false);
4173 assert!(albums[0].folder_jpg.is_none());
4174 assert!(albums[0].folder_webp.is_none());
4175 }
4176
4177 #[test]
4178 fn album_desired_groups_by_root_id() {
4179 let members = vec![
4180 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
4181 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
4182 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
4183 ];
4184 let albums = album_desired(&members, false, false);
4185 assert_eq!(albums.len(), 2);
4186 assert_eq!(albums[0].root_id, "r1");
4187 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
4188 assert_eq!(
4189 albums[0].folder_jpg.as_ref().unwrap().path,
4190 "c/al1/folder.jpg"
4191 );
4192 assert_eq!(albums[1].root_id, "r2");
4193 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
4194 assert_eq!(
4195 albums[1].folder_jpg.as_ref().unwrap().path,
4196 "c/al2/folder.jpg"
4197 );
4198 }
4199
4200 #[test]
4201 fn plan_writes_folder_art_when_store_empty() {
4202 let members = vec![album_member(
4203 album_clip("a", 1, "t0", "art-a", "vid-a"),
4204 "root",
4205 "c/al/a.flac",
4206 )];
4207 let desired = album_desired(&members, true, false);
4208 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4209 assert_eq!(
4210 actions,
4211 vec![
4212 Action::WriteArtifact {
4213 kind: ArtifactKind::FolderJpg,
4214 path: "c/al/folder.jpg".to_string(),
4215 source_url: "art-a".to_string(),
4216 hash: art_url_hash("art-a"),
4217 owner_id: "root".to_string(),
4218 content: None,
4219 },
4220 Action::WriteArtifact {
4221 kind: ArtifactKind::FolderWebp,
4222 path: "c/al/cover.webp".to_string(),
4223 source_url: "vid-a".to_string(),
4224 hash: art_url_hash("vid-a"),
4225 owner_id: "root".to_string(),
4226 content: None,
4227 },
4228 ]
4229 );
4230 }
4231
4232 #[test]
4233 fn plan_skips_when_hash_and_path_match() {
4234 let members = vec![album_member(
4235 album_clip("a", 1, "t0", "art-a", ""),
4236 "root",
4237 "c/al/a.flac",
4238 )];
4239 let desired = album_desired(&members, false, false);
4240 let mut albums = BTreeMap::new();
4241 albums.insert(
4242 "root".to_string(),
4243 AlbumArt {
4244 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4245 folder_webp: None,
4246 folder_mp4: None,
4247 },
4248 );
4249 assert!(plan_album_artifacts(&desired, &albums, true, &HashMap::new()).is_empty());
4250 }
4251
4252 #[test]
4253 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
4254 let members = vec![album_member(
4255 album_clip("a", 1, "t0", "art-a", ""),
4256 "root",
4257 "c/al/a.flac",
4258 )];
4259 let desired = album_desired(&members, false, false);
4260 let mut albums = BTreeMap::new();
4261 albums.insert(
4262 "root".to_string(),
4263 AlbumArt {
4264 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
4265 folder_webp: None,
4266 folder_mp4: None,
4267 },
4268 );
4269 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4270 assert_eq!(actions.len(), 1);
4271 assert!(matches!(
4272 &actions[0],
4273 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
4274 ));
4275 }
4276
4277 #[test]
4278 fn h1_most_played_flip_to_same_art_writes_nothing() {
4279 let run1 = vec![
4281 album_member(
4282 album_clip("a", 9, "t0", "same-art", ""),
4283 "root",
4284 "c/al/a.flac",
4285 ),
4286 album_member(
4287 album_clip("b", 1, "t1", "same-art", ""),
4288 "root",
4289 "c/al/b.flac",
4290 ),
4291 ];
4292 let desired1 = album_desired(&run1, false, false);
4293 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true, &HashMap::new());
4294 assert_eq!(write1.len(), 1);
4295
4296 let mut albums = BTreeMap::new();
4298 if let Action::WriteArtifact {
4299 path,
4300 hash,
4301 owner_id,
4302 ..
4303 } = &write1[0]
4304 {
4305 albums.insert(
4306 owner_id.clone(),
4307 AlbumArt {
4308 folder_jpg: Some(stored(path, hash)),
4309 folder_webp: None,
4310 folder_mp4: None,
4311 },
4312 );
4313 }
4314
4315 let run2 = vec![
4317 album_member(
4318 album_clip("a", 1, "t0", "same-art", ""),
4319 "root",
4320 "c/al/a.flac",
4321 ),
4322 album_member(
4323 album_clip("b", 9, "t1", "same-art", ""),
4324 "root",
4325 "c/al/b.flac",
4326 ),
4327 ];
4328 let desired2 = album_desired(&run2, false, false);
4329 assert!(plan_album_artifacts(&desired2, &albums, true, &HashMap::new()).is_empty());
4331 }
4332
4333 #[test]
4334 fn h1_flip_to_different_art_writes_exactly_one() {
4335 let mut albums = BTreeMap::new();
4336 albums.insert(
4337 "root".to_string(),
4338 AlbumArt {
4339 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
4340 folder_webp: None,
4341 folder_mp4: None,
4342 },
4343 );
4344 let members = vec![
4346 album_member(
4347 album_clip("a", 1, "t0", "old-art", ""),
4348 "root",
4349 "c/al/a.flac",
4350 ),
4351 album_member(
4352 album_clip("b", 9, "t1", "new-art", ""),
4353 "root",
4354 "c/al/b.flac",
4355 ),
4356 ];
4357 let desired = album_desired(&members, false, false);
4358 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4359 assert_eq!(actions.len(), 1);
4360 assert!(matches!(
4361 &actions[0],
4362 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
4363 ));
4364 }
4365
4366 #[test]
4367 fn one_write_per_album_regardless_of_clip_count() {
4368 let members: Vec<Desired> = (0..200)
4369 .map(|i| {
4370 album_member(
4371 album_clip(
4372 &format!("clip-{i:03}"),
4373 i as u64,
4374 &format!("t{i:03}"),
4375 &format!("art-{i:03}"),
4376 &format!("vid-{i:03}"),
4377 ),
4378 "root",
4379 &format!("c/al/clip-{i:03}.flac"),
4380 )
4381 })
4382 .collect();
4383 let desired = album_desired(&members, true, false);
4384 assert_eq!(desired.len(), 1);
4385 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4386 assert_eq!(actions.len(), 2);
4388 assert_eq!(
4389 actions
4390 .iter()
4391 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
4392 .count(),
4393 2
4394 );
4395 }
4396
4397 #[test]
4398 fn emptied_album_deletes_only_when_can_delete() {
4399 let mut albums = BTreeMap::new();
4400 albums.insert(
4401 "root".to_string(),
4402 AlbumArt {
4403 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
4404 folder_webp: Some(stored("c/al/cover.webp", "hw")),
4405 folder_mp4: Some(stored("c/al/cover.mp4", "hm")),
4406 },
4407 );
4408 let desired: Vec<AlbumDesired> = Vec::new();
4410
4411 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4413
4414 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4416 assert_eq!(
4417 actions,
4418 vec![
4419 Action::DeleteArtifact {
4420 kind: ArtifactKind::FolderJpg,
4421 path: "c/al/folder.jpg".to_string(),
4422 owner_id: "root".to_string(),
4423 },
4424 Action::DeleteArtifact {
4425 kind: ArtifactKind::FolderWebp,
4426 path: "c/al/cover.webp".to_string(),
4427 owner_id: "root".to_string(),
4428 },
4429 Action::DeleteArtifact {
4430 kind: ArtifactKind::FolderMp4,
4431 path: "c/al/cover.mp4".to_string(),
4432 owner_id: "root".to_string(),
4433 },
4434 ]
4435 );
4436 }
4437
4438 #[test]
4439 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
4440 let mut albums = BTreeMap::new();
4441 albums.insert(
4442 "root".to_string(),
4443 AlbumArt {
4444 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4445 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4446 folder_mp4: None,
4447 },
4448 );
4449 let members = vec![album_member(
4452 album_clip("a", 1, "t0", "art-a", "vid-a"),
4453 "root",
4454 "c/al/a.flac",
4455 )];
4456 let desired = album_desired(&members, false, false);
4457
4458 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4459
4460 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4461 assert_eq!(
4462 actions,
4463 vec![Action::DeleteArtifact {
4464 kind: ArtifactKind::FolderWebp,
4465 path: "c/al/cover.webp".to_string(),
4466 owner_id: "root".to_string(),
4467 }]
4468 );
4469 }
4470
4471 #[test]
4472 fn disappeared_raw_cover_deletes_only_that_kind_when_gated() {
4473 let mut albums = BTreeMap::new();
4474 albums.insert(
4475 "root".to_string(),
4476 AlbumArt {
4477 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4478 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4479 folder_mp4: Some(stored("c/al/cover.mp4", &art_url_hash("vid-a"))),
4480 },
4481 );
4482 let members = vec![album_member(
4485 album_clip("a", 1, "t0", "art-a", "vid-a"),
4486 "root",
4487 "c/al/a.flac",
4488 )];
4489 let desired = album_desired(&members, true, false);
4490
4491 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4493
4494 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4496 assert_eq!(
4497 actions,
4498 vec![Action::DeleteArtifact {
4499 kind: ArtifactKind::FolderMp4,
4500 path: "c/al/cover.mp4".to_string(),
4501 owner_id: "root".to_string(),
4502 }]
4503 );
4504 }
4505
4506 #[test]
4507 fn plan_album_artifacts_is_deterministically_ordered() {
4508 let members = vec![
4509 album_member(
4510 album_clip("a", 1, "t0", "art-a", "vid-a"),
4511 "r2",
4512 "c/al2/a.flac",
4513 ),
4514 album_member(
4515 album_clip("b", 1, "t0", "art-b", "vid-b"),
4516 "r1",
4517 "c/al1/b.flac",
4518 ),
4519 ];
4520 let desired = album_desired(&members, true, true);
4521 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4522 let keys: Vec<(&str, ArtifactKind)> = actions
4523 .iter()
4524 .map(|a| match a {
4525 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
4526 _ => unreachable!(),
4527 })
4528 .collect();
4529 assert_eq!(
4530 keys,
4531 vec![
4532 ("r1", ArtifactKind::FolderJpg),
4533 ("r1", ArtifactKind::FolderWebp),
4534 ("r1", ArtifactKind::FolderMp4),
4535 ("r2", ArtifactKind::FolderJpg),
4536 ("r2", ArtifactKind::FolderWebp),
4537 ("r2", ArtifactKind::FolderMp4),
4538 ]
4539 );
4540 }
4541
4542 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
4545 PlaylistDesired {
4546 id: id.to_owned(),
4547 name: name.to_owned(),
4548 path: path.to_owned(),
4549 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
4550 hash: hash.to_owned(),
4551 }
4552 }
4553
4554 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
4555 PlaylistState {
4556 name: name.to_owned(),
4557 path: path.to_owned(),
4558 hash: hash.to_owned(),
4559 }
4560 }
4561
4562 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
4563 entries
4564 .iter()
4565 .map(|(id, state)| ((*id).to_owned(), state.clone()))
4566 .collect()
4567 }
4568
4569 #[test]
4570 fn playlist_write_emitted_for_a_new_playlist() {
4571 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
4572 let actions =
4573 plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true, &HashMap::new());
4574 assert_eq!(
4575 actions,
4576 vec![Action::WriteArtifact {
4577 kind: ArtifactKind::Playlist,
4578 path: "Road Trip.m3u8".to_owned(),
4579 source_url: String::new(),
4580 hash: "h1".to_owned(),
4581 owner_id: "pl1".to_owned(),
4582 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
4583 }]
4584 );
4585 }
4586
4587 #[test]
4588 fn playlist_write_emitted_when_hash_changes() {
4589 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
4592 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4593 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4594 assert_eq!(actions.len(), 1);
4595 assert!(matches!(
4596 &actions[0],
4597 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
4598 ));
4599 }
4600
4601 #[test]
4602 fn playlist_unchanged_is_idempotent() {
4603 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4604 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4605 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4606 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
4607 }
4608
4609 #[test]
4610 fn playlist_rename_writes_new_and_deletes_old_path() {
4611 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4614 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4615 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4616 assert_eq!(
4617 actions,
4618 vec![
4619 Action::WriteArtifact {
4620 kind: ArtifactKind::Playlist,
4621 path: "Summer.m3u8".to_owned(),
4622 source_url: String::new(),
4623 hash: "h2".to_owned(),
4624 owner_id: "pl1".to_owned(),
4625 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
4626 },
4627 Action::DeleteArtifact {
4628 kind: ArtifactKind::Playlist,
4629 path: "Spring.m3u8".to_owned(),
4630 owner_id: "pl1".to_owned(),
4631 },
4632 ]
4633 );
4634 }
4635
4636 #[test]
4637 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
4638 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
4641 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
4642 let actions = plan_playlist_artifacts(&desired, &stored, false, true, &HashMap::new());
4643 assert_eq!(actions.len(), 1);
4644 assert!(matches!(
4645 &actions[0],
4646 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
4647 ));
4648 assert!(
4649 !actions
4650 .iter()
4651 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
4652 "old path must not be deleted when deletes are disallowed"
4653 );
4654 }
4655
4656 #[test]
4657 fn playlist_stale_removed_only_under_full_gate() {
4658 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
4661
4662 let deleted = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4663 assert_eq!(
4664 deleted,
4665 vec![Action::DeleteArtifact {
4666 kind: ArtifactKind::Playlist,
4667 path: "Gone.m3u8".to_owned(),
4668 owner_id: "gone".to_owned(),
4669 }]
4670 );
4671
4672 assert!(plan_playlist_artifacts(&[], &stored, false, true, &HashMap::new()).is_empty());
4674 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4675 assert!(plan_playlist_artifacts(&[], &stored, false, false, &HashMap::new()).is_empty());
4676 }
4677
4678 #[test]
4679 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
4680 let stored = pl_store(&[
4685 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4686 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4687 ]);
4688 let actions = plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new());
4689 assert!(
4690 actions.is_empty(),
4691 "a failed playlist listing must plan zero actions, got {actions:?}"
4692 );
4693 }
4694
4695 #[test]
4696 fn b2_empty_list_deletes_only_when_fully_enumerated() {
4697 let stored = pl_store(&[
4702 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
4703 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
4704 ]);
4705
4706 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
4708
4709 let wiped = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
4712 assert_eq!(
4713 wiped
4714 .iter()
4715 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
4716 .count(),
4717 2
4718 );
4719 }
4720
4721 #[test]
4722 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
4723 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
4728 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
4729 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4730 assert_eq!(actions.len(), 1);
4732 assert!(matches!(
4733 &actions[0],
4734 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
4735 ));
4736 assert!(
4737 !actions.iter().any(|a| match a {
4738 Action::WriteArtifact { owner_id, .. }
4739 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
4740 _ => false,
4741 }),
4742 "a protected (failed-member) playlist must have no action"
4743 );
4744 }
4745
4746 #[test]
4747 fn playlist_rename_collision_downgrades_the_delete() {
4748 let desired = vec![
4754 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
4755 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
4756 ];
4757 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
4758 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
4759 let write_paths: BTreeSet<&str> = actions
4761 .iter()
4762 .filter_map(|a| match a {
4763 Action::WriteArtifact { path, .. } => Some(path.as_str()),
4764 _ => None,
4765 })
4766 .collect();
4767 for a in &actions {
4768 if let Action::DeleteArtifact { path, .. } = a {
4769 assert!(
4770 !write_paths.contains(path.as_str()),
4771 "a playlist delete aliases a write target: {path}"
4772 );
4773 }
4774 }
4775 }
4776
4777 fn dstem(key: &str, path: &str, hash: &str) -> DesiredStem {
4780 DesiredStem {
4781 key: key.to_string(),
4782 stem_id: key.to_string(),
4783 path: path.to_string(),
4784 source_url: format!("https://cdn1.suno.ai/{key}.mp3"),
4785 format: StemFormat::Mp3,
4786 hash: hash.to_string(),
4787 }
4788 }
4789
4790 fn stem_desired(id: &str, stems: Option<Vec<DesiredStem>>) -> Desired {
4792 Desired {
4793 stems,
4794 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
4795 }
4796 }
4797
4798 fn entry_with_stems(id: &str, stems: &[(&str, &str, &str)]) -> ManifestEntry {
4800 let mut e = entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art");
4801 for (key, path, hash) in stems {
4802 e.stems.insert(
4803 key.to_string(),
4804 ArtifactState {
4805 path: path.to_string(),
4806 hash: hash.to_string(),
4807 },
4808 );
4809 }
4810 e
4811 }
4812
4813 fn stem_writes(plan: &Plan) -> Vec<(&str, &str)> {
4814 plan.actions
4815 .iter()
4816 .filter_map(|a| match a {
4817 Action::WriteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4818 _ => None,
4819 })
4820 .collect()
4821 }
4822
4823 fn stem_deletes(plan: &Plan) -> Vec<(&str, &str)> {
4824 plan.actions
4825 .iter()
4826 .filter_map(|a| match a {
4827 Action::DeleteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
4828 _ => None,
4829 })
4830 .collect()
4831 }
4832
4833 #[test]
4834 fn stems_none_keeps_every_existing_stem() {
4835 let mut manifest = Manifest::new();
4838 manifest.insert(
4839 "a",
4840 entry_with_stems(
4841 "a",
4842 &[
4843 ("voc", "a.stems/voc.mp3", "h1"),
4844 ("drm", "a.stems/drm.mp3", "h2"),
4845 ],
4846 ),
4847 );
4848 let d = vec![stem_desired("a", None)];
4849 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4850 assert_eq!(plan.stem_writes(), 0);
4851 assert_eq!(plan.stem_deletes(), 0);
4852 }
4853
4854 #[test]
4855 fn stems_authoritative_writes_missing_stems() {
4856 let mut manifest = Manifest::new();
4857 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
4858 let d = vec![stem_desired(
4859 "a",
4860 Some(vec![
4861 dstem("voc", "a.stems/voc.mp3", "h1"),
4862 dstem("drm", "a.stems/drm.mp3", "h2"),
4863 ]),
4864 )];
4865 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4866 assert_eq!(
4867 stem_writes(&plan),
4868 vec![("voc", "a.stems/voc.mp3"), ("drm", "a.stems/drm.mp3")]
4869 );
4870 assert_eq!(plan.stem_deletes(), 0);
4871 }
4872
4873 #[test]
4874 fn stems_authoritative_rewrites_only_on_hash_or_path_drift() {
4875 let mut manifest = Manifest::new();
4876 manifest.insert(
4878 "a",
4879 entry_with_stems(
4880 "a",
4881 &[
4882 ("voc", "a.stems/voc.mp3", "h1"),
4883 ("drm", "a.stems/drm.mp3", "h2"),
4884 ("bas", "old.stems/bas.mp3", "h3"),
4885 ],
4886 ),
4887 );
4888 let d = vec![stem_desired(
4889 "a",
4890 Some(vec![
4891 dstem("voc", "a.stems/voc.mp3", "h1"), dstem("drm", "a.stems/drm.mp3", "h2-new"), dstem("bas", "a.stems/bas.mp3", "h3"), ]),
4895 )];
4896 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4897 assert_eq!(
4898 stem_writes(&plan),
4899 vec![("drm", "a.stems/drm.mp3"), ("bas", "a.stems/bas.mp3")]
4900 );
4901 assert_eq!(plan.stem_deletes(), 0);
4902 }
4903
4904 #[test]
4905 fn stems_authoritative_removes_a_stem_absent_from_the_set() {
4906 let mut manifest = Manifest::new();
4909 manifest.insert(
4910 "a",
4911 entry_with_stems(
4912 "a",
4913 &[
4914 ("voc", "a.stems/voc.mp3", "h1"),
4915 ("drm", "a.stems/drm.mp3", "h2"),
4916 ],
4917 ),
4918 );
4919 let d = vec![stem_desired(
4920 "a",
4921 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
4922 )];
4923 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4924 assert_eq!(plan.stem_writes(), 0);
4925 assert_eq!(stem_deletes(&plan), vec![("drm", "a.stems/drm.mp3")]);
4926 }
4927
4928 #[test]
4929 fn stems_removal_needs_deletion_allowed() {
4930 let mut manifest = Manifest::new();
4933 manifest.insert(
4934 "a",
4935 entry_with_stems(
4936 "a",
4937 &[
4938 ("voc", "a.stems/voc.mp3", "h1"),
4939 ("drm", "a.stems/drm.mp3", "h2"),
4940 ],
4941 ),
4942 );
4943 let d = vec![stem_desired(
4944 "a",
4945 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
4946 )];
4947
4948 let incomplete = vec![SourceStatus {
4949 mode: SourceMode::Mirror,
4950 fully_enumerated: false,
4951 }];
4952 assert_eq!(
4953 reconcile(&manifest, &d, &local_present("a"), &incomplete).stem_deletes(),
4954 0
4955 );
4956
4957 let copy_only = vec![SourceStatus {
4958 mode: SourceMode::Copy,
4959 fully_enumerated: true,
4960 }];
4961 assert_eq!(
4962 reconcile(&manifest, &d, &local_present("a"), ©_only).stem_deletes(),
4963 0
4964 );
4965 }
4966
4967 #[test]
4968 fn stems_removal_skipped_for_preserved_or_protected_clip() {
4969 let mut manifest = Manifest::new();
4970 let mut e = entry_with_stems(
4971 "a",
4972 &[
4973 ("voc", "a.stems/voc.mp3", "h1"),
4974 ("drm", "a.stems/drm.mp3", "h2"),
4975 ],
4976 );
4977 e.preserve = true;
4978 manifest.insert("a", e);
4979 let authoritative = Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]);
4980
4981 let d = vec![stem_desired("a", authoritative.clone())];
4983 assert_eq!(
4984 reconcile(&manifest, &d, &local_present("a"), &mirror_ok()).stem_deletes(),
4985 0
4986 );
4987
4988 let mut manifest2 = Manifest::new();
4990 manifest2.insert(
4991 "a",
4992 entry_with_stems(
4993 "a",
4994 &[
4995 ("voc", "a.stems/voc.mp3", "h1"),
4996 ("drm", "a.stems/drm.mp3", "h2"),
4997 ],
4998 ),
4999 );
5000 let held = Desired {
5001 modes: vec![SourceMode::Mirror, SourceMode::Copy],
5002 stems: authoritative,
5003 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
5004 };
5005 assert_eq!(
5006 reconcile(&manifest2, &[held], &local_present("a"), &mirror_ok()).stem_deletes(),
5007 0
5008 );
5009 }
5010
5011 #[test]
5012 fn stems_are_co_deleted_when_the_song_is_trashed() {
5013 let mut manifest = Manifest::new();
5016 manifest.insert(
5017 "a",
5018 entry_with_stems(
5019 "a",
5020 &[
5021 ("voc", "a.stems/voc.mp3", "h1"),
5022 ("drm", "a.stems/drm.mp3", "h2"),
5023 ],
5024 ),
5025 );
5026 let trashed = Desired {
5027 trashed: true,
5028 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
5029 };
5030 let plan = reconcile(&manifest, &[trashed], &local_present("a"), &mirror_ok());
5031 assert_eq!(plan.deletes(), 1, "the trashed audio is deleted");
5032 let mut deleted: Vec<&str> = stem_deletes(&plan).into_iter().map(|(k, _)| k).collect();
5033 deleted.sort_unstable();
5034 assert_eq!(deleted, vec!["drm", "voc"], "both stems co-deleted");
5035 }
5036
5037 #[test]
5038 fn stems_are_co_deleted_for_an_absent_clip() {
5039 let mut manifest = Manifest::new();
5040 manifest.insert(
5041 "a",
5042 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5043 );
5044 let plan = reconcile(&manifest, &[], &local_present("a"), &mirror_ok());
5046 assert_eq!(plan.deletes(), 1);
5047 assert_eq!(stem_deletes(&plan), vec![("voc", "a.stems/voc.mp3")]);
5048 }
5049
5050 #[test]
5051 fn stems_are_kept_when_absent_clip_listing_is_incomplete() {
5052 let mut manifest = Manifest::new();
5054 manifest.insert(
5055 "a",
5056 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5057 );
5058 let incomplete = vec![SourceStatus {
5059 mode: SourceMode::Mirror,
5060 fully_enumerated: false,
5061 }];
5062 let plan = reconcile(&manifest, &[], &HashMap::new(), &incomplete);
5063 assert_eq!(plan.deletes(), 0);
5064 assert_eq!(plan.stem_deletes(), 0);
5065 }
5066
5067 #[test]
5068 fn stem_delete_is_suppressed_when_it_aliases_a_stem_write() {
5069 let mut manifest = Manifest::new();
5073 manifest.insert(
5074 "a",
5075 entry_with_stems("a", &[("old", "a.stems/mix.mp3", "h1")]),
5076 );
5077 let d = vec![stem_desired(
5078 "a",
5079 Some(vec![dstem("new", "a.stems/mix.mp3", "h2")]),
5080 )];
5081 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5082 assert_eq!(stem_writes(&plan), vec![("new", "a.stems/mix.mp3")]);
5085 assert!(
5086 !plan.actions.iter().any(|a| matches!(
5087 a,
5088 Action::DeleteStem { path, .. } if path == "a.stems/mix.mp3"
5089 )),
5090 "a stem delete must never alias a stem write target"
5091 );
5092 }
5093}
5094
5095#[cfg(test)]
5108mod proptests {
5109 use super::*;
5110 use proptest::collection::{btree_map, hash_map, vec};
5111 use proptest::prelude::*;
5112 use std::collections::BTreeSet;
5113
5114 type DesiredFields = (
5115 String,
5116 AudioFormat,
5117 String,
5118 String,
5119 Vec<SourceMode>,
5120 bool,
5121 bool,
5122 );
5123
5124 fn audio_format() -> impl Strategy<Value = AudioFormat> {
5125 prop_oneof![
5126 Just(AudioFormat::Mp3),
5127 Just(AudioFormat::Flac),
5128 Just(AudioFormat::Wav),
5129 ]
5130 }
5131
5132 fn source_mode() -> impl Strategy<Value = SourceMode> {
5133 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
5134 }
5135
5136 fn clip_id() -> impl Strategy<Value = String> {
5139 (0u8..8).prop_map(|n| format!("c{n}"))
5140 }
5141
5142 fn small_path() -> impl Strategy<Value = String> {
5143 (0u8..6).prop_map(|n| format!("path{n}"))
5144 }
5145
5146 fn manifest_path() -> impl Strategy<Value = String> {
5149 prop_oneof![
5150 1 => Just(String::new()),
5151 6 => small_path(),
5152 ]
5153 }
5154
5155 fn small_hash() -> impl Strategy<Value = String> {
5156 (0u8..4).prop_map(|n| format!("h{n}"))
5157 }
5158
5159 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
5160 (
5161 manifest_path(),
5162 audio_format(),
5163 small_hash(),
5164 small_hash(),
5165 0u64..4,
5166 any::<bool>(),
5167 )
5168 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
5169 ManifestEntry {
5170 path,
5171 format,
5172 meta_hash,
5173 art_hash,
5174 size,
5175 preserve,
5176 ..Default::default()
5177 }
5178 })
5179 }
5180
5181 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
5182 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
5183 }
5184
5185 fn local_file() -> impl Strategy<Value = LocalFile> {
5186 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
5187 }
5188
5189 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
5190 hash_map(clip_id(), local_file(), 0..8)
5191 }
5192
5193 fn source_status() -> impl Strategy<Value = SourceStatus> {
5194 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
5195 mode,
5196 fully_enumerated,
5197 })
5198 }
5199
5200 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5201 vec(source_status(), 0..5)
5202 }
5203
5204 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5205 vec(
5206 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
5207 mode: SourceMode::Copy,
5208 fully_enumerated,
5209 }),
5210 1..5,
5211 )
5212 }
5213
5214 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
5215 (
5216 small_path(),
5217 audio_format(),
5218 small_hash(),
5219 small_hash(),
5220 vec(source_mode(), 1..3),
5221 any::<bool>(),
5222 any::<bool>(),
5223 )
5224 }
5225
5226 fn build_desired(id: String, fields: DesiredFields) -> Desired {
5227 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
5228 let clip = Clip {
5229 id,
5230 title: "t".to_string(),
5231 ..Default::default()
5232 };
5233 Desired {
5234 lineage: LineageContext::own_root(&clip),
5235 clip,
5236 path,
5237 format,
5238 meta_hash,
5239 art_hash,
5240 modes,
5241 trashed,
5242 private,
5243 artifacts: Vec::new(),
5244 stems: None,
5245 }
5246 }
5247
5248 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
5251 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
5252 items
5253 .into_iter()
5254 .map(|(id, fields)| build_desired(id, fields))
5255 .collect()
5256 })
5257 }
5258
5259 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
5260 desired.iter().map(|d| d.clip.id.as_str()).collect()
5261 }
5262
5263 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
5266 desired
5267 .iter()
5268 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
5269 .map(|d| d.clip.id.as_str())
5270 .collect()
5271 }
5272
5273 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
5276 desired
5277 .iter()
5278 .filter(|d| !d.trashed)
5279 .map(|d| d.clip.id.as_str())
5280 .collect()
5281 }
5282
5283 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
5284 plan.actions
5285 .iter()
5286 .filter_map(|a| match a {
5287 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
5288 _ => None,
5289 })
5290 .collect()
5291 }
5292
5293 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
5294 plan.actions
5295 .iter()
5296 .filter_map(|a| match a {
5297 Action::Download { path, .. } | Action::Reformat { path, .. } => {
5298 Some(path.as_str())
5299 }
5300 Action::Rename { to, .. } => Some(to.as_str()),
5301 _ => None,
5302 })
5303 .collect()
5304 }
5305
5306 proptest! {
5307 #![proptest_config(ProptestConfig {
5308 cases: 256,
5309 failure_persistence: None,
5310 ..ProptestConfig::default()
5311 })]
5312
5313 #[test]
5316 fn inv1_desired_clip_deleted_only_when_fully_trashed(
5317 manifest in manifest_strategy(),
5318 desired in desired_strategy(),
5319 local in local_strategy(),
5320 sources in sources_strategy(),
5321 ) {
5322 let plan = reconcile(&manifest, &desired, &local, &sources);
5323 let present = desired_ids(&desired);
5324 let live = non_trashed_ids(&desired);
5325 for id in delete_clip_ids(&plan) {
5326 prop_assert!(
5327 !(present.contains(id) && live.contains(id)),
5328 "deleted a desired clip with a non-trashed duplicate: {id}"
5329 );
5330 }
5331 }
5332
5333 #[test]
5337 fn inv2_no_delete_when_any_mirror_unenumerated(
5338 manifest in manifest_strategy(),
5339 desired in desired_strategy(),
5340 local in local_strategy(),
5341 mut sources in sources_strategy(),
5342 ) {
5343 sources.push(SourceStatus {
5344 mode: SourceMode::Mirror,
5345 fully_enumerated: false,
5346 });
5347 let plan = reconcile(&manifest, &desired, &local, &sources);
5348 prop_assert_eq!(plan.deletes(), 0);
5349 }
5350
5351 #[test]
5353 fn inv3_all_copy_sources_means_no_deletes(
5354 manifest in manifest_strategy(),
5355 desired in desired_strategy(),
5356 local in local_strategy(),
5357 sources in copy_sources_strategy(),
5358 ) {
5359 let plan = reconcile(&manifest, &desired, &local, &sources);
5360 prop_assert_eq!(plan.deletes(), 0);
5361 }
5362
5363 #[test]
5366 fn inv4_plan_is_deterministic(
5367 manifest in manifest_strategy(),
5368 desired in desired_strategy(),
5369 local in local_strategy(),
5370 sources in sources_strategy(),
5371 ) {
5372 let plan = reconcile(&manifest, &desired, &local, &sources);
5373
5374 let again = reconcile(&manifest, &desired, &local, &sources);
5375 prop_assert_eq!(&plan, &again);
5376
5377 let mut desired_rev = desired.clone();
5378 desired_rev.reverse();
5379 let mut sources_rev = sources.clone();
5380 sources_rev.reverse();
5381 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
5382 prop_assert_eq!(&plan, &shuffled);
5383 }
5384
5385 #[test]
5387 fn inv5_every_delete_is_in_the_manifest(
5388 manifest in manifest_strategy(),
5389 desired in desired_strategy(),
5390 local in local_strategy(),
5391 sources in sources_strategy(),
5392 ) {
5393 let plan = reconcile(&manifest, &desired, &local, &sources);
5394 for id in delete_clip_ids(&plan) {
5395 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
5396 }
5397 }
5398
5399 #[test]
5402 fn inv6_never_deletes_protected_clip(
5403 manifest in manifest_strategy(),
5404 desired in desired_strategy(),
5405 local in local_strategy(),
5406 sources in sources_strategy(),
5407 ) {
5408 let plan = reconcile(&manifest, &desired, &local, &sources);
5409 let protected = protected_ids(&desired);
5410 for id in delete_clip_ids(&plan) {
5411 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
5412 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
5413 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
5414 }
5415 }
5416
5417 #[test]
5420 fn inv7_no_delete_unless_deletion_allowed(
5421 manifest in manifest_strategy(),
5422 desired in desired_strategy(),
5423 local in local_strategy(),
5424 sources in sources_strategy(),
5425 ) {
5426 let plan = reconcile(&manifest, &desired, &local, &sources);
5427 if !deletion_allowed(&sources) {
5428 prop_assert_eq!(plan.deletes(), 0);
5429 }
5430 }
5431
5432 #[test]
5434 fn inv8_at_most_one_delete_per_clip(
5435 manifest in manifest_strategy(),
5436 desired in desired_strategy(),
5437 local in local_strategy(),
5438 sources in sources_strategy(),
5439 ) {
5440 let plan = reconcile(&manifest, &desired, &local, &sources);
5441 let ids = delete_clip_ids(&plan);
5442 let unique: BTreeSet<&str> = ids.iter().copied().collect();
5443 prop_assert_eq!(ids.len(), unique.len());
5444 }
5445
5446 #[test]
5448 fn inv9_no_delete_with_empty_path(
5449 manifest in manifest_strategy(),
5450 desired in desired_strategy(),
5451 local in local_strategy(),
5452 sources in sources_strategy(),
5453 ) {
5454 let plan = reconcile(&manifest, &desired, &local, &sources);
5455 for action in &plan.actions {
5456 if let Action::Delete { path, .. } = action {
5457 prop_assert!(!path.is_empty(), "delete with an empty path");
5458 }
5459 }
5460 }
5461
5462 #[test]
5465 fn inv10_no_delete_aliases_a_write_target(
5466 manifest in manifest_strategy(),
5467 desired in desired_strategy(),
5468 local in local_strategy(),
5469 sources in sources_strategy(),
5470 ) {
5471 let plan = reconcile(&manifest, &desired, &local, &sources);
5472 let targets = write_target_paths(&plan);
5473 for action in &plan.actions {
5474 if let Action::Delete { path, .. } = action {
5475 prop_assert!(
5476 !targets.contains(path.as_str()),
5477 "delete path {path} aliases a write target"
5478 );
5479 }
5480 }
5481 }
5482 }
5483}