1use std::collections::BTreeMap;
34use std::collections::BTreeSet;
35use std::collections::HashMap;
36use std::collections::HashSet;
37
38use crate::album_art::{AlbumArt, PlaylistState};
39use crate::config::{AudioFormat, StemFormat};
40use crate::ffmpeg::WebpEncodeSettings;
41use crate::hash::{art_hash, art_url_hash, webp_art_hash};
42use crate::lineage::LineageContext;
43use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
44use crate::model::Clip;
45use crate::pathkey::{canonical_path_key, same_fs_path};
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
59pub enum ArtifactKind {
60 CoverJpg,
62 CoverWebp,
68 DetailsTxt,
70 LyricsTxt,
72 Lrc,
74 VideoMp4,
77 FolderJpg,
79 FolderWebp,
81 FolderMp4,
85 Playlist,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum SourceMode {
93 Mirror,
95 Copy,
97}
98
99#[derive(Debug, Clone, PartialEq)]
106pub struct Desired {
107 pub clip: Clip,
109 pub lineage: LineageContext,
112 pub path: String,
114 pub format: AudioFormat,
116 pub meta_hash: String,
118 pub art_hash: String,
120 pub modes: Vec<SourceMode>,
122 pub trashed: bool,
124 pub private: bool,
126 pub artifacts: Vec<DesiredArtifact>,
134 pub stems: Option<Vec<DesiredStem>>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct DesiredStem {
157 pub key: String,
161 pub stem_id: String,
165 pub path: String,
168 pub source_url: String,
172 pub format: StemFormat,
175 pub hash: String,
177}
178
179#[derive(Debug, Clone, PartialEq)]
184pub struct DesiredArtifact {
185 pub kind: ArtifactKind,
187 pub path: String,
189 pub source_url: String,
192 pub hash: String,
194 pub content: Option<String>,
198}
199
200#[derive(Debug, Clone, PartialEq)]
211pub struct AlbumDesired {
212 pub root_id: String,
214 pub folder_jpg: Option<DesiredArtifact>,
216 pub folder_webp: Option<DesiredArtifact>,
218 pub folder_mp4: Option<DesiredArtifact>,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct PlaylistDesired {
235 pub id: String,
238 pub name: String,
240 pub path: String,
242 pub content: String,
244 pub hash: String,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
250pub struct LocalFile {
251 pub exists: bool,
253 pub size: u64,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub struct SourceStatus {
260 pub mode: SourceMode,
262 pub fully_enumerated: bool,
264}
265
266#[derive(Debug, Clone, PartialEq)]
268pub enum Action {
269 Download {
271 clip: Clip,
272 lineage: LineageContext,
273 path: String,
274 format: AudioFormat,
275 },
276 Reformat {
282 clip: Clip,
283 path: String,
284 from_path: String,
285 from: AudioFormat,
286 to: AudioFormat,
287 },
288 Retag {
290 clip: Clip,
291 lineage: LineageContext,
292 path: String,
293 },
294 Rename { from: String, to: String },
296 Delete { path: String, clip_id: String },
298 Skip { clip_id: String },
300 WriteArtifact {
312 kind: ArtifactKind,
313 path: String,
314 source_url: String,
315 hash: String,
316 owner_id: String,
317 content: Option<String>,
318 },
319 MoveArtifact {
329 kind: ArtifactKind,
330 from: String,
331 to: String,
332 source_url: String,
333 hash: String,
334 owner_id: String,
335 },
336 DeleteArtifact {
343 kind: ArtifactKind,
344 path: String,
345 owner_id: String,
346 },
347 WriteStem {
357 clip_id: String,
358 key: String,
359 stem_id: String,
360 path: String,
361 source_url: String,
362 format: StemFormat,
363 hash: String,
364 },
365 MoveStem {
373 clip_id: String,
374 key: String,
375 stem_id: String,
376 from: String,
377 to: String,
378 source_url: String,
379 format: StemFormat,
380 hash: String,
381 },
382 DeleteStem {
391 clip_id: String,
392 key: String,
393 path: String,
394 },
395}
396
397#[derive(Debug, Clone, Default, PartialEq)]
402pub struct Plan {
403 pub actions: Vec<Action>,
405}
406
407impl Plan {
408 pub fn len(&self) -> usize {
410 self.actions.len()
411 }
412
413 pub fn is_empty(&self) -> bool {
415 self.actions.is_empty()
416 }
417
418 pub fn downloads(&self) -> usize {
420 self.count(|a| matches!(a, Action::Download { .. }))
421 }
422
423 pub fn reformats(&self) -> usize {
425 self.count(|a| matches!(a, Action::Reformat { .. }))
426 }
427
428 pub fn retags(&self) -> usize {
430 self.count(|a| matches!(a, Action::Retag { .. }))
431 }
432
433 pub fn renames(&self) -> usize {
435 self.count(|a| matches!(a, Action::Rename { .. }))
436 }
437
438 pub fn deletes(&self) -> usize {
440 self.count(|a| matches!(a, Action::Delete { .. }))
441 }
442
443 pub fn skips(&self) -> usize {
445 self.count(|a| matches!(a, Action::Skip { .. }))
446 }
447
448 pub fn artifact_writes(&self) -> usize {
450 self.count(|a| matches!(a, Action::WriteArtifact { .. }))
451 }
452
453 pub fn artifact_deletes(&self) -> usize {
455 self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
456 }
457
458 pub fn stem_writes(&self) -> usize {
460 self.count(|a| matches!(a, Action::WriteStem { .. }))
461 }
462
463 pub fn artifact_moves(&self) -> usize {
466 self.count(|a| matches!(a, Action::MoveArtifact { .. }))
467 }
468
469 pub fn stem_moves(&self) -> usize {
472 self.count(|a| matches!(a, Action::MoveStem { .. }))
473 }
474
475 pub fn stem_deletes(&self) -> usize {
477 self.count(|a| matches!(a, Action::DeleteStem { .. }))
478 }
479
480 fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
481 self.actions.iter().filter(|a| pred(a)).count()
482 }
483}
484
485pub fn reconcile(
500 manifest: &Manifest,
501 desired: &[Desired],
502 local: &HashMap<String, LocalFile>,
503 sources: &[SourceStatus],
504) -> Plan {
505 let merged: Vec<Desired>;
510 let ordered: Vec<&Desired> = if needs_aggregation(desired) {
511 merged = aggregate_desired(desired);
512 merged.iter().collect()
513 } else {
514 let mut refs: Vec<&Desired> = desired.iter().collect();
515 refs.sort_unstable_by(|a, b| a.clip.id.cmp(&b.clip.id));
516 refs
517 };
518 let desired_ids: HashSet<&str> = ordered.iter().map(|d| d.clip.id.as_str()).collect();
519 let mut actions: Vec<Action> = Vec::with_capacity(ordered.len() + manifest.len());
522
523 let can_delete = deletion_allowed(sources);
524
525 for &d in &ordered {
526 let before = actions.len();
531 plan_desired(d, manifest, local, can_delete, &mut actions);
532 let audio_deleted = actions[before..]
533 .iter()
534 .any(|a| matches!(a, Action::Delete { .. }));
535 if audio_deleted {
536 co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
537 co_delete_stems(d.clip.id.as_str(), manifest, can_delete, &mut actions);
538 } else {
539 plan_clip_artifacts(d, manifest, local, can_delete, &mut actions);
540 plan_clip_stems(d, manifest, local, can_delete, &mut actions);
541 }
542 }
543
544 for (clip_id, _entry) in manifest.iter() {
546 if desired_ids.contains(clip_id.as_str()) {
547 continue;
548 }
549 match delete_action(clip_id, manifest, can_delete) {
550 Some(action) => {
551 actions.push(action);
552 co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
555 co_delete_stems(clip_id, manifest, can_delete, &mut actions);
556 }
557 None => actions.push(Action::Skip {
560 clip_id: clip_id.clone(),
561 }),
562 }
563 }
564
565 suppress_path_aliasing(&mut actions);
566 Plan { actions }
567}
568
569pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
580 let mut saw_mirror = false;
581 for status in sources {
582 if !status.fully_enumerated {
583 return false;
584 }
585 if status.mode == SourceMode::Mirror {
586 saw_mirror = true;
587 }
588 }
589 saw_mirror
590}
591
592pub fn area_authoritative(complete: bool, any_filtered: bool, narrowed: bool) -> bool {
603 complete && !any_filtered && !narrowed
604}
605
606pub fn area_fully_enumerated(authoritative: bool, clips_empty: bool, mode: SourceMode) -> bool {
619 authoritative && !(clips_empty && mode == SourceMode::Mirror)
620}
621
622pub fn narrows_downloads(can_delete: bool, library_authoritative: bool) -> bool {
630 !can_delete && !library_authoritative
631}
632
633fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
639 if !can_delete {
640 return None;
641 }
642 let entry = manifest.get(clip_id)?;
643 if entry.path.is_empty() || entry.preserve {
644 return None;
645 }
646 Some(Action::Delete {
647 path: entry.path.clone(),
648 clip_id: clip_id.to_string(),
649 })
650}
651
652fn delete_artifact_action(
662 owner_id: &str,
663 kind: ArtifactKind,
664 path: &str,
665 manifest: &Manifest,
666 can_delete: bool,
667) -> Option<Action> {
668 if !can_delete {
669 return None;
670 }
671 let entry = manifest.get(owner_id)?;
672 if path.is_empty() || entry.preserve {
673 return None;
674 }
675 Some(Action::DeleteArtifact {
676 kind,
677 path: path.to_string(),
678 owner_id: owner_id.to_string(),
679 })
680}
681
682fn is_per_clip_kind(kind: ArtifactKind) -> bool {
688 matches!(
689 kind,
690 ArtifactKind::CoverJpg
691 | ArtifactKind::CoverWebp
692 | ArtifactKind::DetailsTxt
693 | ArtifactKind::LyricsTxt
694 | ArtifactKind::Lrc
695 | ArtifactKind::VideoMp4
696 )
697}
698
699fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
731 match kind {
732 ArtifactKind::CoverJpg
733 | ArtifactKind::LyricsTxt
734 | ArtifactKind::Lrc
735 | ArtifactKind::VideoMp4 => false,
736 ArtifactKind::CoverWebp
737 | ArtifactKind::DetailsTxt
738 | ArtifactKind::FolderJpg
739 | ArtifactKind::FolderWebp
740 | ArtifactKind::FolderMp4
741 | ArtifactKind::Playlist => true,
742 }
743}
744
745fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
750 match kind {
751 ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
752 ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
753 ArtifactKind::DetailsTxt => entry.details_txt.as_ref(),
754 ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
755 ArtifactKind::Lrc => entry.lrc.as_ref(),
756 ArtifactKind::VideoMp4 => entry.video_mp4.as_ref(),
757 ArtifactKind::FolderJpg
758 | ArtifactKind::FolderWebp
759 | ArtifactKind::FolderMp4
760 | ArtifactKind::Playlist => None,
761 }
762}
763
764fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
767 let mut out = Vec::new();
768 if let Some(state) = &entry.cover_jpg {
769 out.push((ArtifactKind::CoverJpg, state));
770 }
771 if let Some(state) = &entry.cover_webp {
772 out.push((ArtifactKind::CoverWebp, state));
773 }
774 if let Some(state) = &entry.details_txt {
775 out.push((ArtifactKind::DetailsTxt, state));
776 }
777 if let Some(state) = &entry.lyrics_txt {
778 out.push((ArtifactKind::LyricsTxt, state));
779 }
780 if let Some(state) = &entry.lrc {
781 out.push((ArtifactKind::Lrc, state));
782 }
783 if let Some(state) = &entry.video_mp4 {
784 out.push((ArtifactKind::VideoMp4, state));
785 }
786 out
787}
788
789pub(crate) fn set_manifest_artifact(
796 entry: &mut ManifestEntry,
797 kind: ArtifactKind,
798 state: Option<ArtifactState>,
799) {
800 match kind {
801 ArtifactKind::CoverJpg => entry.cover_jpg = state,
802 ArtifactKind::CoverWebp => entry.cover_webp = state,
803 ArtifactKind::DetailsTxt => entry.details_txt = state,
804 ArtifactKind::LyricsTxt => entry.lyrics_txt = state,
805 ArtifactKind::Lrc => entry.lrc = state,
806 ArtifactKind::VideoMp4 => entry.video_mp4 = state,
807 ArtifactKind::FolderJpg
808 | ArtifactKind::FolderWebp
809 | ArtifactKind::FolderMp4
810 | ArtifactKind::Playlist => {}
811 }
812}
813
814pub(crate) fn set_manifest_stem(
820 entry: &mut ManifestEntry,
821 key: &str,
822 state: Option<ArtifactState>,
823) {
824 match state {
825 Some(state) => {
826 entry.stems.insert(key.to_string(), state);
827 }
828 None => {
829 entry.stems.remove(key);
830 }
831 }
832}
833
834fn needs_write_drift(
835 stored: Option<(&str, &str)>,
836 want_hash: &str,
837 want_path: &str,
838 local: &HashMap<String, LocalFile>,
839) -> bool {
840 match stored {
841 None => true,
842 Some((stored_hash, stored_path)) => {
843 stored_hash != want_hash
844 || !same_fs_path(stored_path, want_path)
845 || local
846 .get(stored_path)
847 .is_some_and(|f| !f.exists || f.size == 0)
848 }
849 }
850}
851
852fn plan_clip_artifacts(
868 d: &Desired,
869 manifest: &Manifest,
870 local: &HashMap<String, LocalFile>,
871 can_delete: bool,
872 out: &mut Vec<Action>,
873) {
874 let owner_id = d.clip.id.as_str();
875 let entry = manifest.get(owner_id);
876
877 for artifact in &d.artifacts {
878 if !is_per_clip_kind(artifact.kind) {
883 continue;
884 }
885 let state = entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind));
891 let needs_write = needs_write_drift(
892 state.map(|state| (state.hash.as_str(), state.path.as_str())),
893 artifact.hash.as_str(),
894 artifact.path.as_str(),
895 local,
896 );
897 if needs_write {
898 if let Some(state) = state
904 && state.hash == artifact.hash
905 && !same_fs_path(&state.path, &artifact.path)
906 && artifact.content.is_none()
907 && local
908 .get(&state.path)
909 .is_some_and(|f| f.exists && f.size > 0)
910 {
911 out.push(Action::MoveArtifact {
912 kind: artifact.kind,
913 from: state.path.clone(),
914 to: artifact.path.clone(),
915 source_url: artifact.source_url.clone(),
916 hash: artifact.hash.clone(),
917 owner_id: owner_id.to_string(),
918 });
919 } else {
920 out.push(Action::WriteArtifact {
921 kind: artifact.kind,
922 path: artifact.path.clone(),
923 source_url: artifact.source_url.clone(),
924 hash: artifact.hash.clone(),
925 owner_id: owner_id.to_string(),
926 content: artifact.content.clone(),
927 });
928 }
929 }
930 }
931
932 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
937 if !protected_now && let Some(entry) = entry {
938 let desired_kinds: BTreeSet<ArtifactKind> = d
939 .artifacts
940 .iter()
941 .filter(|a| is_per_clip_kind(a.kind))
942 .map(|a| a.kind)
943 .collect();
944 for (kind, state) in manifest_artifacts(entry) {
945 if removed_kind_delete_eligible(kind)
951 && !desired_kinds.contains(&kind)
952 && let Some(action) =
953 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
954 {
955 out.push(action);
956 }
957 }
958 }
959}
960
961fn co_delete_artifacts(
967 owner_id: &str,
968 manifest: &Manifest,
969 can_delete: bool,
970 out: &mut Vec<Action>,
971) {
972 let Some(entry) = manifest.get(owner_id) else {
973 return;
974 };
975 for (kind, state) in manifest_artifacts(entry) {
976 if let Some(action) =
977 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
978 {
979 out.push(action);
980 }
981 }
982}
983
984fn delete_stem_action(
993 clip_id: &str,
994 key: &str,
995 path: &str,
996 manifest: &Manifest,
997 can_delete: bool,
998) -> Option<Action> {
999 if !can_delete {
1000 return None;
1001 }
1002 let entry = manifest.get(clip_id)?;
1003 if path.is_empty() || entry.preserve {
1004 return None;
1005 }
1006 Some(Action::DeleteStem {
1007 clip_id: clip_id.to_string(),
1008 key: key.to_string(),
1009 path: path.to_string(),
1010 })
1011}
1012
1013fn plan_clip_stems(
1030 d: &Desired,
1031 manifest: &Manifest,
1032 local: &HashMap<String, LocalFile>,
1033 can_delete: bool,
1034 out: &mut Vec<Action>,
1035) {
1036 let Some(desired_stems) = &d.stems else {
1037 return;
1038 };
1039 let clip_id = d.clip.id.as_str();
1040 let entry = manifest.get(clip_id);
1041
1042 for stem in desired_stems {
1043 let state = entry.and_then(|e| e.stems.get(&stem.key));
1044 let needs_write = match state {
1045 None => true,
1046 Some(state) => state.hash != stem.hash || !same_fs_path(&state.path, &stem.path),
1047 };
1048 if needs_write {
1049 if let Some(state) = state
1054 && state.hash == stem.hash
1055 && !same_fs_path(&state.path, &stem.path)
1056 && local
1057 .get(&state.path)
1058 .is_some_and(|f| f.exists && f.size > 0)
1059 {
1060 out.push(Action::MoveStem {
1061 clip_id: clip_id.to_string(),
1062 key: stem.key.clone(),
1063 stem_id: stem.stem_id.clone(),
1064 from: state.path.clone(),
1065 to: stem.path.clone(),
1066 source_url: stem.source_url.clone(),
1067 format: stem.format,
1068 hash: stem.hash.clone(),
1069 });
1070 } else {
1071 out.push(Action::WriteStem {
1072 clip_id: clip_id.to_string(),
1073 key: stem.key.clone(),
1074 stem_id: stem.stem_id.clone(),
1075 path: stem.path.clone(),
1076 source_url: stem.source_url.clone(),
1077 format: stem.format,
1078 hash: stem.hash.clone(),
1079 });
1080 }
1081 }
1082 }
1083
1084 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
1085 if !protected_now && let Some(entry) = entry {
1086 let desired_keys: BTreeSet<&str> = desired_stems.iter().map(|s| s.key.as_str()).collect();
1087 for (key, state) in &entry.stems {
1088 if !desired_keys.contains(key.as_str())
1094 && let Some(action) =
1095 delete_stem_action(clip_id, key, &state.path, manifest, can_delete)
1096 {
1097 out.push(action);
1098 }
1099 }
1100 }
1101}
1102
1103fn co_delete_stems(clip_id: &str, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
1111 let Some(entry) = manifest.get(clip_id) else {
1112 return;
1113 };
1114 for (key, state) in &entry.stems {
1115 if let Some(action) = delete_stem_action(clip_id, key, &state.path, manifest, can_delete) {
1116 out.push(action);
1117 }
1118 }
1119}
1120
1121fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
1128 let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
1129 for d in desired {
1130 match by_id.get_mut(d.clip.id.as_str()) {
1131 None => {
1132 by_id.insert(d.clip.id.as_str(), d.clone());
1133 }
1134 Some(acc) => {
1135 let take = rep_key(d) < rep_key(acc);
1136 acc.private = acc.private || d.private;
1137 acc.trashed = acc.trashed && d.trashed;
1138 for mode in &d.modes {
1139 if !acc.modes.contains(mode) {
1140 acc.modes.push(*mode);
1141 }
1142 }
1143 if take {
1144 acc.clip = d.clip.clone();
1145 acc.path = d.path.clone();
1146 acc.format = d.format;
1147 acc.meta_hash = d.meta_hash.clone();
1148 acc.art_hash = d.art_hash.clone();
1149 acc.artifacts = d.artifacts.clone();
1150 acc.stems = d.stems.clone();
1151 }
1152 }
1153 }
1154 }
1155 let mut out: Vec<Desired> = by_id.into_values().collect();
1156 for d in &mut out {
1157 let has_mirror = d.modes.contains(&SourceMode::Mirror);
1159 let has_copy = d.modes.contains(&SourceMode::Copy);
1160 d.modes.clear();
1161 if has_mirror {
1162 d.modes.push(SourceMode::Mirror);
1163 }
1164 if has_copy {
1165 d.modes.push(SourceMode::Copy);
1166 }
1167 }
1168 out
1169}
1170
1171fn needs_aggregation(desired: &[Desired]) -> bool {
1176 let mut seen: HashSet<&str> = HashSet::with_capacity(desired.len());
1177 desired
1178 .iter()
1179 .any(|d| !seen.insert(d.clip.id.as_str()) || !modes_are_canonical(&d.modes))
1180}
1181
1182fn modes_are_canonical(modes: &[SourceMode]) -> bool {
1185 matches!(
1186 modes,
1187 [] | [SourceMode::Mirror] | [SourceMode::Copy] | [SourceMode::Mirror, SourceMode::Copy]
1188 )
1189}
1190
1191fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
1194 let format = match d.format {
1195 AudioFormat::Mp3 => 0,
1196 AudioFormat::Flac => 1,
1197 AudioFormat::Wav => 2,
1198 AudioFormat::Alac => 3,
1199 };
1200 (
1201 d.path.as_str(),
1202 d.meta_hash.as_str(),
1203 d.art_hash.as_str(),
1204 format,
1205 )
1206}
1207
1208fn suppress_path_aliasing(actions: &mut [Action]) {
1222 let aliased: Vec<usize> = {
1226 let targets: BTreeSet<String> = actions
1227 .iter()
1228 .filter_map(|a| match a {
1229 Action::Download { path, .. }
1230 | Action::Reformat { path, .. }
1231 | Action::WriteArtifact { path, .. }
1232 | Action::WriteStem { path, .. } => Some(path.as_str()),
1233 Action::Rename { to, .. }
1234 | Action::MoveArtifact { to, .. }
1235 | Action::MoveStem { to, .. } => Some(to.as_str()),
1236 _ => None,
1237 })
1238 .map(canonical_path_key)
1239 .collect();
1240 actions
1241 .iter()
1242 .enumerate()
1243 .filter_map(|(index, a)| match a {
1244 Action::Delete { path, .. }
1245 | Action::DeleteArtifact { path, .. }
1246 | Action::DeleteStem { path, .. } => {
1247 targets.contains(&canonical_path_key(path)).then_some(index)
1248 }
1249 _ => None,
1250 })
1251 .collect()
1252 };
1253 for index in aliased {
1254 actions[index] = match &actions[index] {
1255 Action::Delete { clip_id, .. } | Action::DeleteStem { clip_id, .. } => Action::Skip {
1256 clip_id: clip_id.clone(),
1257 },
1258 Action::DeleteArtifact { owner_id, .. } => Action::Skip {
1259 clip_id: owner_id.clone(),
1260 },
1261 _ => unreachable!("only delete actions are collected as aliased"),
1262 };
1263 }
1264}
1265
1266fn plan_desired(
1268 d: &Desired,
1269 manifest: &Manifest,
1270 local: &HashMap<String, LocalFile>,
1271 can_delete: bool,
1272 out: &mut Vec<Action>,
1273) {
1274 let clip_id = d.clip.id.as_str();
1275 let copy_held = d.modes.contains(&SourceMode::Copy);
1276
1277 if d.trashed && !d.private && !copy_held {
1283 match delete_action(clip_id, manifest, can_delete) {
1284 Some(action) => out.push(action),
1285 None => out.push(Action::Skip {
1286 clip_id: clip_id.to_string(),
1287 }),
1288 }
1289 return;
1290 }
1291
1292 let Some(entry) = manifest.get(clip_id) else {
1293 out.push(Action::Download {
1295 clip: d.clip.clone(),
1296 lineage: d.lineage.clone(),
1297 path: d.path.clone(),
1298 format: d.format,
1299 });
1300 return;
1301 };
1302
1303 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
1306 if missing {
1307 out.push(Action::Download {
1308 clip: d.clip.clone(),
1309 lineage: d.lineage.clone(),
1310 path: d.path.clone(),
1311 format: d.format,
1312 });
1313 return;
1314 }
1315
1316 if d.format != entry.format {
1317 out.push(Action::Reformat {
1320 clip: d.clip.clone(),
1321 path: d.path.clone(),
1322 from_path: entry.path.clone(),
1323 from: entry.format,
1324 to: d.format,
1325 });
1326 return;
1327 }
1328
1329 if !same_fs_path(&d.path, &entry.path) {
1330 out.push(Action::Rename {
1331 from: entry.path.clone(),
1332 to: d.path.clone(),
1333 });
1334 if meta_or_art_changed(d, entry) {
1336 out.push(Action::Retag {
1337 clip: d.clip.clone(),
1338 lineage: d.lineage.clone(),
1339 path: d.path.clone(),
1340 });
1341 }
1342 return;
1343 }
1344
1345 if meta_or_art_changed(d, entry) {
1346 out.push(Action::Retag {
1347 clip: d.clip.clone(),
1348 lineage: d.lineage.clone(),
1349 path: entry.path.clone(),
1350 });
1351 return;
1352 }
1353
1354 out.push(Action::Skip {
1355 clip_id: clip_id.to_string(),
1356 });
1357}
1358
1359fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
1361 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
1362}
1363
1364pub fn album_desired(
1390 desired: &[Desired],
1391 animated_covers: bool,
1392 raw_cover: bool,
1393 webp: WebpEncodeSettings,
1394) -> Vec<AlbumDesired> {
1395 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
1396 for d in desired {
1397 groups
1398 .entry(d.lineage.root_id.as_str())
1399 .or_default()
1400 .push(d);
1401 }
1402
1403 groups
1404 .into_iter()
1405 .map(|(root_id, members)| {
1406 let album_dir = album_dir_of(&members);
1407 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
1408 kind: ArtifactKind::FolderJpg,
1409 path: album_child(&album_dir, "folder.jpg"),
1410 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
1411 hash: art_hash(&source.clip),
1412 content: None,
1413 });
1414 let folder_webp = animated_covers
1415 .then(|| folder_webp_source(&members))
1416 .flatten()
1417 .map(|source| DesiredArtifact {
1418 kind: ArtifactKind::FolderWebp,
1419 path: album_child(&album_dir, "cover.webp"),
1420 source_url: source.clip.video_cover_url.clone(),
1421 hash: webp_art_hash(&source.clip.video_cover_url, &webp),
1422 content: None,
1423 });
1424 let folder_mp4 = raw_cover
1425 .then(|| folder_webp_source(&members))
1426 .flatten()
1427 .map(|source| DesiredArtifact {
1428 kind: ArtifactKind::FolderMp4,
1429 path: album_child(&album_dir, "cover.mp4"),
1430 source_url: source.clip.video_cover_url.clone(),
1431 hash: art_url_hash(&source.clip.video_cover_url),
1432 content: None,
1433 });
1434 AlbumDesired {
1435 root_id: root_id.to_owned(),
1436 folder_jpg,
1437 folder_webp,
1438 folder_mp4,
1439 }
1440 })
1441 .collect()
1442}
1443
1444fn album_dir_of(members: &[&Desired]) -> String {
1449 members
1450 .iter()
1451 .map(|d| parent_dir(&d.path))
1452 .min()
1453 .unwrap_or("")
1454 .to_owned()
1455}
1456
1457fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1463 members
1464 .iter()
1465 .copied()
1466 .filter(|d| {
1467 d.clip
1468 .selected_image_url()
1469 .is_some_and(|url| !url.is_empty())
1470 })
1471 .min_by(|a, b| {
1472 b.clip
1473 .play_count
1474 .cmp(&a.clip.play_count)
1475 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
1476 .then_with(|| a.clip.id.cmp(&b.clip.id))
1477 })
1478}
1479
1480fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1485 members
1486 .iter()
1487 .copied()
1488 .filter(|d| !d.clip.video_cover_url.is_empty())
1489 .min_by(|a, b| {
1490 a.clip
1491 .created_at
1492 .cmp(&b.clip.created_at)
1493 .then_with(|| a.clip.id.cmp(&b.clip.id))
1494 })
1495}
1496
1497fn parent_dir(path: &str) -> &str {
1499 match path.rsplit_once('/') {
1500 Some((dir, _)) => dir,
1501 None => "",
1502 }
1503}
1504
1505fn album_child(album_dir: &str, name: &str) -> String {
1508 if album_dir.is_empty() {
1509 name.to_owned()
1510 } else {
1511 format!("{album_dir}/{name}")
1512 }
1513}
1514
1515pub fn plan_album_artifacts(
1539 desired: &[AlbumDesired],
1540 albums: &BTreeMap<String, AlbumArt>,
1541 can_delete: bool,
1542 local: &HashMap<String, LocalFile>,
1543) -> Vec<Action> {
1544 let mut actions: Vec<Action> = Vec::new();
1545 let by_root: BTreeMap<&str, &AlbumDesired> =
1546 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1547
1548 for d in desired {
1549 let stored = albums.get(&d.root_id);
1550 for artifact in [
1551 d.folder_jpg.as_ref(),
1552 d.folder_webp.as_ref(),
1553 d.folder_mp4.as_ref(),
1554 ]
1555 .into_iter()
1556 .flatten()
1557 {
1558 let needs_write = needs_write_drift(
1559 stored
1560 .and_then(|a| a.artifact(artifact.kind))
1561 .map(|state| (state.hash.as_str(), state.path.as_str())),
1562 artifact.hash.as_str(),
1563 artifact.path.as_str(),
1564 local,
1565 );
1566 if needs_write {
1567 actions.push(Action::WriteArtifact {
1568 kind: artifact.kind,
1569 path: artifact.path.clone(),
1570 source_url: artifact.source_url.clone(),
1571 hash: artifact.hash.clone(),
1572 owner_id: d.root_id.clone(),
1573 content: None,
1574 });
1575 }
1576 }
1577 }
1578
1579 if can_delete {
1581 for (root_id, art) in albums {
1582 for (kind, state) in album_artifacts(art) {
1583 let desired_here = by_root
1584 .get(root_id.as_str())
1585 .is_some_and(|d| album_desires_kind(d, kind));
1586 if !desired_here && !state.path.is_empty() {
1587 actions.push(Action::DeleteArtifact {
1588 kind,
1589 path: state.path.clone(),
1590 owner_id: root_id.clone(),
1591 });
1592 }
1593 }
1594 }
1595 }
1596
1597 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1598 actions
1599}
1600
1601fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1604 let mut out = Vec::new();
1605 if let Some(state) = &art.folder_jpg {
1606 out.push((ArtifactKind::FolderJpg, state));
1607 }
1608 if let Some(state) = &art.folder_webp {
1609 out.push((ArtifactKind::FolderWebp, state));
1610 }
1611 if let Some(state) = &art.folder_mp4 {
1612 out.push((ArtifactKind::FolderMp4, state));
1613 }
1614 out
1615}
1616
1617fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1619 match kind {
1620 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1621 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1622 ArtifactKind::FolderMp4 => d.folder_mp4.is_some(),
1623 ArtifactKind::CoverJpg
1624 | ArtifactKind::CoverWebp
1625 | ArtifactKind::DetailsTxt
1626 | ArtifactKind::LyricsTxt
1627 | ArtifactKind::Lrc
1628 | ArtifactKind::VideoMp4
1629 | ArtifactKind::Playlist => false,
1630 }
1631}
1632
1633fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1635 match action {
1636 Action::WriteArtifact { owner_id, kind, .. }
1637 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1638 _ => ("", ArtifactKind::CoverJpg),
1639 }
1640}
1641
1642pub fn plan_playlist_artifacts(
1680 desired: &[PlaylistDesired],
1681 stored: &BTreeMap<String, PlaylistState>,
1682 can_delete: bool,
1683 list_fully_enumerated: bool,
1684 local: &HashMap<String, LocalFile>,
1685) -> Vec<Action> {
1686 let mut actions: Vec<Action> = Vec::new();
1687 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1688 let deletes_allowed = can_delete && list_fully_enumerated;
1691
1692 for d in desired {
1693 let stored_here = stored.get(&d.id);
1694 let needs_write = needs_write_drift(
1695 stored_here.map(|state| (state.hash.as_str(), state.path.as_str())),
1696 d.hash.as_str(),
1697 d.path.as_str(),
1698 local,
1699 );
1700 if needs_write {
1701 actions.push(Action::WriteArtifact {
1702 kind: ArtifactKind::Playlist,
1703 path: d.path.clone(),
1704 source_url: String::new(),
1705 hash: d.hash.clone(),
1706 owner_id: d.id.clone(),
1707 content: Some(d.content.clone()),
1708 });
1709 }
1710 if deletes_allowed
1712 && let Some(state) = stored_here
1713 && !state.path.is_empty()
1714 && state.path != d.path
1715 {
1716 actions.push(Action::DeleteArtifact {
1717 kind: ArtifactKind::Playlist,
1718 path: state.path.clone(),
1719 owner_id: d.id.clone(),
1720 });
1721 }
1722 }
1723
1724 if deletes_allowed {
1727 for (id, state) in stored {
1728 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1729 actions.push(Action::DeleteArtifact {
1730 kind: ArtifactKind::Playlist,
1731 path: state.path.clone(),
1732 owner_id: id.clone(),
1733 });
1734 }
1735 }
1736 }
1737
1738 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1739 suppress_path_aliasing(&mut actions);
1742 actions
1743}
1744
1745fn playlist_action_key(action: &Action) -> (&str, u8) {
1748 match action {
1749 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1750 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1751 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1752 _ => ("", 3),
1753 }
1754}
1755
1756#[cfg(test)]
1757mod tests {
1758 use super::*;
1759 use crate::hash::content_hash;
1760
1761 fn clip(id: &str) -> Clip {
1762 Clip {
1763 id: id.to_string(),
1764 title: "Song".to_string(),
1765 ..Default::default()
1766 }
1767 }
1768
1769 fn lineage(id: &str) -> LineageContext {
1770 LineageContext::own_root(&clip(id))
1771 }
1772
1773 fn entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1774 ManifestEntry {
1775 path: path.to_string(),
1776 format,
1777 meta_hash: meta.to_string(),
1778 art_hash: art.to_string(),
1779 size: 100,
1780 preserve: false,
1781 ..Default::default()
1782 }
1783 }
1784
1785 fn preserved_entry(path: &str, format: AudioFormat, meta: &str, art: &str) -> ManifestEntry {
1786 ManifestEntry {
1787 preserve: true,
1788 ..entry(path, format, meta, art)
1789 }
1790 }
1791
1792 fn desired(id: &str, path: &str, format: AudioFormat, meta: &str, art: &str) -> Desired {
1793 Desired {
1794 clip: clip(id),
1795 lineage: lineage(id),
1796 path: path.to_string(),
1797 format,
1798 meta_hash: meta.to_string(),
1799 art_hash: art.to_string(),
1800 modes: vec![SourceMode::Mirror],
1801 trashed: false,
1802 private: false,
1803 artifacts: Vec::new(),
1804 stems: None,
1805 }
1806 }
1807
1808 fn present(size: u64) -> LocalFile {
1809 LocalFile { exists: true, size }
1810 }
1811
1812 fn local_present(id: &str) -> HashMap<String, LocalFile> {
1813 [(id.to_string(), present(100))].into_iter().collect()
1814 }
1815
1816 fn mirror_ok() -> Vec<SourceStatus> {
1817 vec![SourceStatus {
1818 mode: SourceMode::Mirror,
1819 fully_enumerated: true,
1820 }]
1821 }
1822
1823 #[test]
1826 fn not_in_manifest_downloads() {
1827 let manifest = Manifest::new();
1828 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1829 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
1830 assert_eq!(
1831 plan.actions,
1832 vec![Action::Download {
1833 clip: clip("a"),
1834 lineage: lineage("a"),
1835 path: "a.flac".to_string(),
1836 format: AudioFormat::Flac,
1837 }]
1838 );
1839 }
1840
1841 #[test]
1842 fn unchanged_clip_skips() {
1843 let mut manifest = Manifest::new();
1844 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
1845 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
1846 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1847 assert_eq!(
1848 plan.actions,
1849 vec![Action::Skip {
1850 clip_id: "a".to_string()
1851 }]
1852 );
1853 }
1854
1855 #[test]
1856 fn nested_manifest_path_reconciles_without_rename_or_delete() {
1857 let clip = Clip {
1864 id: "clipaaaa-1234".to_owned(),
1865 title: "Song".to_owned(),
1866 display_name: "alice".to_owned(),
1867 image_large_url: "https://art.suno.ai/clipaaaa-1234/large.jpg".to_owned(),
1868 ..Clip::default()
1869 };
1870 let clips = [&clip];
1871 let modes: HashMap<String, Vec<SourceMode>> = [(clip.id.clone(), vec![SourceMode::Mirror])]
1872 .into_iter()
1873 .collect();
1874 let desired = crate::desired::build_desired(
1875 &clips,
1876 AudioFormat::Flac,
1877 &modes,
1878 &HashMap::new(),
1879 &BTreeSet::new(),
1880 crate::desired::ArtifactToggles::default(),
1881 &crate::naming::NamingConfig::default(),
1882 );
1883 let d = &desired[0];
1884
1885 let stored_audio = d.path.replace('\\', "/");
1887 assert!(
1888 !stored_audio.contains('\\') && stored_audio.contains('/'),
1889 "expected a nested forward-slash path, got {stored_audio}"
1890 );
1891 let cover = d
1892 .artifacts
1893 .iter()
1894 .find(|a| a.kind == ArtifactKind::CoverJpg)
1895 .expect("an art-bearing clip yields a cover.jpg");
1896
1897 let mut manifest = Manifest::new();
1898 manifest.insert(
1899 clip.id.clone(),
1900 ManifestEntry {
1901 path: stored_audio.clone(),
1902 format: AudioFormat::Flac,
1903 meta_hash: d.meta_hash.clone(),
1904 art_hash: d.art_hash.clone(),
1905 size: 100,
1906 cover_jpg: Some(ArtifactState {
1907 path: cover.path.replace('\\', "/"),
1908 hash: cover.hash.clone(),
1909 }),
1910 ..Default::default()
1911 },
1912 );
1913 let local: HashMap<String, LocalFile> =
1914 [(clip.id.clone(), present(100))].into_iter().collect();
1915
1916 let plan = reconcile(&manifest, &desired, &local, &mirror_ok());
1917
1918 assert_eq!(
1919 plan.renames(),
1920 0,
1921 "the recomputed path drifted from the stored forward-slash path"
1922 );
1923 assert_eq!(plan.deletes(), 0, "no clip should be deleted");
1924 assert_eq!(plan.reformats(), 0);
1925 assert_eq!(plan.downloads(), 0);
1926 assert_eq!(plan.retags(), 0);
1927 assert_eq!(plan.artifact_writes(), 0, "the cover.jpg drifted");
1928 assert_eq!(plan.artifact_moves(), 0);
1929 assert_eq!(plan.skips(), 1, "the unchanged clip is skipped");
1930 }
1931
1932 #[test]
1933 fn meta_change_retags_in_place() {
1934 let mut manifest = Manifest::new();
1935 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
1936 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "new", "art")];
1937 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1938 assert_eq!(
1939 plan.actions,
1940 vec![Action::Retag {
1941 clip: clip("a"),
1942 lineage: lineage("a"),
1943 path: "a.flac".to_string(),
1944 }]
1945 );
1946 }
1947
1948 #[test]
1949 fn art_change_retags_in_place() {
1950 let mut manifest = Manifest::new();
1951 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "old-art"));
1952 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "new-art")];
1953 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1954 assert_eq!(
1955 plan.actions,
1956 vec![Action::Retag {
1957 clip: clip("a"),
1958 lineage: lineage("a"),
1959 path: "a.flac".to_string(),
1960 }]
1961 );
1962 }
1963
1964 #[test]
1965 fn rename_when_path_changes() {
1966 let mut manifest = Manifest::new();
1967 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
1968 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
1969 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1970 assert_eq!(
1971 plan.actions,
1972 vec![Action::Rename {
1973 from: "old/a.flac".to_string(),
1974 to: "new/a.flac".to_string(),
1975 }]
1976 );
1977 }
1978
1979 #[test]
1980 fn case_only_path_change_is_not_a_rename() {
1981 let mut manifest = Manifest::new();
1985 manifest.insert(
1986 "a",
1987 entry("Creator/Song.flac", AudioFormat::Flac, "m", "art"),
1988 );
1989 let d = vec![desired(
1990 "a",
1991 "Creator/song.flac",
1992 AudioFormat::Flac,
1993 "m",
1994 "art",
1995 )];
1996 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
1997 assert_eq!(
1998 plan.actions,
1999 vec![Action::Skip {
2000 clip_id: "a".to_string()
2001 }]
2002 );
2003 }
2004
2005 #[test]
2006 fn case_only_path_change_with_meta_drift_retags_in_place() {
2007 let mut manifest = Manifest::new();
2010 manifest.insert(
2011 "a",
2012 entry("Creator/Song.flac", AudioFormat::Flac, "old", "art"),
2013 );
2014 let d = vec![desired(
2015 "a",
2016 "Creator/song.flac",
2017 AudioFormat::Flac,
2018 "new",
2019 "art",
2020 )];
2021 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2022 assert_eq!(
2023 plan.actions,
2024 vec![Action::Retag {
2025 clip: clip("a"),
2026 lineage: lineage("a"),
2027 path: "Creator/Song.flac".to_string(),
2028 }]
2029 );
2030 }
2031
2032 #[test]
2033 fn rename_with_meta_change_also_retags() {
2034 let mut manifest = Manifest::new();
2035 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "old", "art"));
2036 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "new", "art")];
2037 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2038 assert_eq!(
2039 plan.actions,
2040 vec![
2041 Action::Rename {
2042 from: "old/a.flac".to_string(),
2043 to: "new/a.flac".to_string(),
2044 },
2045 Action::Retag {
2046 clip: clip("a"),
2047 lineage: lineage("a"),
2048 path: "new/a.flac".to_string(),
2049 },
2050 ]
2051 );
2052 }
2053
2054 #[test]
2055 fn bulk_album_rename_moves_and_retags_without_redownload() {
2056 let mut manifest = Manifest::new();
2061 for id in ["a", "b", "c"] {
2062 manifest.insert(
2063 id,
2064 entry(
2065 &format!("Creator/Old Album/{id}.flac"),
2066 AudioFormat::Flac,
2067 "old-meta",
2068 "art",
2069 ),
2070 );
2071 }
2072 let d: Vec<Desired> = ["a", "b", "c"]
2073 .iter()
2074 .map(|id| {
2075 desired(
2076 id,
2077 &format!("Creator/New Album/{id}.flac"),
2078 AudioFormat::Flac,
2079 "new-meta",
2080 "art",
2081 )
2082 })
2083 .collect();
2084 let local: HashMap<String, LocalFile> = ["a", "b", "c"]
2085 .iter()
2086 .map(|id| (id.to_string(), present(100)))
2087 .collect();
2088
2089 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2090
2091 assert_eq!(plan.renames(), 3, "every member folder move is a rename");
2092 assert_eq!(
2093 plan.retags(),
2094 3,
2095 "the album tag change retags each in place"
2096 );
2097 assert_eq!(
2098 plan.downloads(),
2099 0,
2100 "an album rename must never re-download"
2101 );
2102 assert_eq!(
2103 plan.deletes(),
2104 0,
2105 "deletion safety: a rename deletes nothing"
2106 );
2107 for id in ["a", "b", "c"] {
2108 assert!(plan.actions.contains(&Action::Rename {
2109 from: format!("Creator/Old Album/{id}.flac"),
2110 to: format!("Creator/New Album/{id}.flac"),
2111 }));
2112 }
2113 }
2114
2115 #[test]
2116 fn mis_rooted_clip_moves_never_deletes_even_when_deletion_is_armed() {
2117 let mut manifest = Manifest::new();
2123 manifest.insert(
2124 "child",
2125 entry("Creator/Root A/child.flac", AudioFormat::Flac, "m", "art"),
2126 );
2127 let d = vec![desired(
2128 "child",
2129 "Creator/Root B/child.flac",
2130 AudioFormat::Flac,
2131 "m",
2132 "art",
2133 )];
2134 let plan = reconcile(&manifest, &d, &local_present("child"), &mirror_ok());
2135
2136 assert_eq!(
2137 plan.actions,
2138 vec![Action::Rename {
2139 from: "Creator/Root A/child.flac".to_string(),
2140 to: "Creator/Root B/child.flac".to_string(),
2141 }],
2142 "a mis-rooted clip is moved, not deleted or re-downloaded"
2143 );
2144 assert_eq!(
2145 plan.deletes(),
2146 0,
2147 "deletion safety: a re-root deletes nothing"
2148 );
2149 assert_eq!(plan.downloads(), 0, "a re-root never re-fetches audio");
2150 }
2151
2152 #[test]
2153 fn format_change_reformats() {
2154 let mut manifest = Manifest::new();
2155 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2156 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
2157 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2158 assert_eq!(
2159 plan.actions,
2160 vec![Action::Reformat {
2161 clip: clip("a"),
2162 path: "a.mp3".to_string(),
2163 from_path: "a.flac".to_string(),
2164 from: AudioFormat::Flac,
2165 to: AudioFormat::Mp3,
2166 }]
2167 );
2168 }
2169
2170 #[test]
2171 fn format_change_takes_precedence_over_rename_and_retag() {
2172 let mut manifest = Manifest::new();
2175 manifest.insert(
2176 "a",
2177 entry("old/a.flac", AudioFormat::Flac, "old", "old-art"),
2178 );
2179 let d = vec![desired(
2180 "a",
2181 "new/a.mp3",
2182 AudioFormat::Mp3,
2183 "new",
2184 "new-art",
2185 )];
2186 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
2187 assert_eq!(plan.reformats(), 1);
2188 assert_eq!(plan.renames(), 0);
2189 assert_eq!(plan.retags(), 0);
2190 }
2191
2192 #[test]
2195 fn zero_length_file_downloads_even_when_hashes_match() {
2196 let mut manifest = Manifest::new();
2197 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2198 let local: HashMap<String, LocalFile> = [(
2199 "a".to_string(),
2200 LocalFile {
2201 exists: true,
2202 size: 0,
2203 },
2204 )]
2205 .into_iter()
2206 .collect();
2207 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2208 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2209 assert_eq!(plan.downloads(), 1);
2210 assert_eq!(plan.skips(), 0);
2211 }
2212
2213 #[test]
2214 fn missing_file_downloads_even_when_hashes_match() {
2215 let mut manifest = Manifest::new();
2216 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2217 let local: HashMap<String, LocalFile> = [(
2218 "a".to_string(),
2219 LocalFile {
2220 exists: false,
2221 size: 0,
2222 },
2223 )]
2224 .into_iter()
2225 .collect();
2226 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2227 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2228 assert_eq!(plan.downloads(), 1);
2229 }
2230
2231 #[test]
2232 fn absent_local_probe_treated_as_missing() {
2233 let mut manifest = Manifest::new();
2235 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2236 let d = vec![desired("a", "a.flac", AudioFormat::Flac, "m", "art")];
2237 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2238 assert_eq!(plan.downloads(), 1);
2239 }
2240
2241 #[test]
2242 fn missing_file_download_wins_over_format_difference() {
2243 let mut manifest = Manifest::new();
2246 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2247 let local: HashMap<String, LocalFile> = [(
2248 "a".to_string(),
2249 LocalFile {
2250 exists: false,
2251 size: 0,
2252 },
2253 )]
2254 .into_iter()
2255 .collect();
2256 let d = vec![desired("a", "a.mp3", AudioFormat::Mp3, "m", "art")];
2257 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2258 assert_eq!(plan.downloads(), 1);
2259 assert_eq!(plan.reformats(), 0);
2260 }
2261
2262 #[test]
2265 fn trashed_but_complete_clip_is_downloadable_yet_still_deletes() {
2266 let mut trashed = clip("a");
2271 trashed.status = "complete".to_string();
2272 trashed.is_trashed = true;
2273 assert!(crate::is_downloadable(&trashed));
2274
2275 let mut manifest = Manifest::new();
2276 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2277 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2278 d.clip = trashed;
2279 d.trashed = true;
2280 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2281 assert_eq!(
2282 plan.actions,
2283 vec![Action::Delete {
2284 path: "a.flac".to_string(),
2285 clip_id: "a".to_string(),
2286 }]
2287 );
2288 }
2289
2290 #[test]
2291 fn trashed_clip_deletes_local_file() {
2292 let mut manifest = Manifest::new();
2293 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2294 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2295 d.trashed = true;
2296 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2297 assert_eq!(
2298 plan.actions,
2299 vec![Action::Delete {
2300 path: "a.flac".to_string(),
2301 clip_id: "a".to_string(),
2302 }]
2303 );
2304 }
2305
2306 #[test]
2307 fn trashed_clip_not_in_manifest_skips() {
2308 let manifest = Manifest::new();
2310 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2311 d.trashed = true;
2312 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2313 assert_eq!(
2314 plan.actions,
2315 vec![Action::Skip {
2316 clip_id: "a".to_string()
2317 }]
2318 );
2319 }
2320
2321 #[test]
2322 fn private_clip_is_kept() {
2323 let mut manifest = Manifest::new();
2324 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2325 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2326 d.private = true;
2327 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2328 assert_eq!(
2329 plan.actions,
2330 vec![Action::Skip {
2331 clip_id: "a".to_string()
2332 }]
2333 );
2334 }
2335
2336 #[test]
2337 fn private_beats_trashed_never_deletes() {
2338 let mut manifest = Manifest::new();
2340 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2341 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2342 d.trashed = true;
2343 d.private = true;
2344 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2345 assert_eq!(plan.deletes(), 0);
2346 assert_eq!(plan.skips(), 1);
2347 }
2348
2349 #[test]
2350 fn copy_held_trashed_clip_is_not_deleted() {
2351 let mut manifest = Manifest::new();
2354 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2355 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2356 d.modes = vec![SourceMode::Copy];
2357 d.trashed = true;
2358 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2359 assert_eq!(plan.deletes(), 0);
2360 assert_eq!(
2361 plan.actions,
2362 vec![Action::Skip {
2363 clip_id: "a".to_string()
2364 }]
2365 );
2366 }
2367
2368 #[test]
2371 fn absent_clip_deleted_when_all_mirrors_enumerated() {
2372 let mut manifest = Manifest::new();
2373 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2374 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2375 assert_eq!(
2376 plan.actions,
2377 vec![Action::Delete {
2378 path: "gone.flac".to_string(),
2379 clip_id: "gone".to_string(),
2380 }]
2381 );
2382 }
2383
2384 #[test]
2385 fn absent_clip_kept_when_any_mirror_not_enumerated() {
2386 let mut manifest = Manifest::new();
2387 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2388 let sources = vec![
2389 SourceStatus {
2390 mode: SourceMode::Mirror,
2391 fully_enumerated: true,
2392 },
2393 SourceStatus {
2394 mode: SourceMode::Mirror,
2395 fully_enumerated: false,
2396 },
2397 ];
2398 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2399 assert_eq!(plan.deletes(), 0);
2400 assert_eq!(
2401 plan.actions,
2402 vec![Action::Skip {
2403 clip_id: "gone".to_string()
2404 }]
2405 );
2406 }
2407
2408 #[test]
2409 fn empty_listing_cannot_cause_deletion() {
2410 let mut manifest = Manifest::new();
2413 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2414 let sources = vec![SourceStatus {
2415 mode: SourceMode::Mirror,
2416 fully_enumerated: false,
2417 }];
2418 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2419 assert_eq!(plan.deletes(), 0);
2420 assert_eq!(plan.skips(), 1);
2421 }
2422
2423 #[test]
2424 fn no_mirror_sources_means_no_deletion() {
2425 let mut manifest = Manifest::new();
2427 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2428 let copy_only = vec![SourceStatus {
2429 mode: SourceMode::Copy,
2430 fully_enumerated: true,
2431 }];
2432 assert_eq!(
2433 reconcile(&manifest, &[], &HashMap::new(), ©_only).deletes(),
2434 0
2435 );
2436 assert_eq!(reconcile(&manifest, &[], &HashMap::new(), &[]).deletes(), 0);
2437 }
2438
2439 #[test]
2440 fn copy_source_with_unenumerated_mirror_still_suppresses_deletion() {
2441 let mut manifest = Manifest::new();
2442 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2443 let sources = vec![
2444 SourceStatus {
2445 mode: SourceMode::Copy,
2446 fully_enumerated: true,
2447 },
2448 SourceStatus {
2449 mode: SourceMode::Mirror,
2450 fully_enumerated: false,
2451 },
2452 ];
2453 assert_eq!(
2454 reconcile(&manifest, &[], &HashMap::new(), &sources).deletes(),
2455 0
2456 );
2457 }
2458
2459 #[test]
2460 fn area_authoritative_requires_all_conditions() {
2461 assert!(area_authoritative(true, false, false));
2463 assert!(!area_authoritative(false, false, false));
2465 assert!(!area_authoritative(true, true, false));
2467 assert!(!area_authoritative(true, false, true));
2469 assert!(!area_authoritative(false, true, true));
2471 }
2472
2473 #[test]
2474 fn area_fully_enumerated_applies_empty_mirror_guard() {
2475 assert!(area_fully_enumerated(true, false, SourceMode::Mirror));
2477 assert!(!area_fully_enumerated(true, true, SourceMode::Mirror));
2479 assert!(area_fully_enumerated(true, true, SourceMode::Copy));
2481 assert!(area_fully_enumerated(true, false, SourceMode::Copy));
2483 assert!(!area_fully_enumerated(false, false, SourceMode::Mirror));
2485 assert!(!area_fully_enumerated(false, true, SourceMode::Copy));
2486 }
2487
2488 #[test]
2489 fn narrows_downloads_only_when_no_deletion_and_no_full_library() {
2490 assert!(narrows_downloads(false, false));
2492 assert!(!narrows_downloads(true, false));
2494 assert!(!narrows_downloads(false, true));
2496 assert!(!narrows_downloads(true, true));
2498 }
2499
2500 #[test]
2501 fn narrowing_never_coexists_with_deletion() {
2502 for can_delete in [false, true] {
2503 for lib_auth in [false, true] {
2504 assert!(
2505 !(narrows_downloads(can_delete, lib_auth) && can_delete),
2506 "truncate must imply !can_delete"
2507 );
2508 }
2509 }
2510 }
2511
2512 #[test]
2513 fn copy_held_clip_in_desired_is_never_a_deletion_candidate() {
2514 let mut manifest = Manifest::new();
2518 manifest.insert("keep", entry("keep.flac", AudioFormat::Flac, "m", "art"));
2519 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2520 let mut held = desired("keep", "keep.flac", AudioFormat::Flac, "m", "art");
2521 held.modes = vec![SourceMode::Copy];
2522 let local: HashMap<String, LocalFile> = [
2523 ("keep".to_string(), present(100)),
2524 ("gone".to_string(), present(100)),
2525 ]
2526 .into_iter()
2527 .collect();
2528 let plan = reconcile(&manifest, &[held], &local, &mirror_ok());
2529 assert!(plan.actions.contains(&Action::Skip {
2530 clip_id: "keep".to_string()
2531 }));
2532 assert!(plan.actions.contains(&Action::Delete {
2533 path: "gone.flac".to_string(),
2534 clip_id: "gone".to_string(),
2535 }));
2536 assert!(
2538 !plan
2539 .actions
2540 .iter()
2541 .any(|a| matches!(a, Action::Delete { clip_id, .. } if clip_id == "keep"))
2542 );
2543 }
2544
2545 #[test]
2548 fn orphan_with_preserve_marker_is_kept() {
2549 let mut manifest = Manifest::new();
2552 manifest.insert(
2553 "gone",
2554 preserved_entry("gone.flac", AudioFormat::Flac, "m", "art"),
2555 );
2556 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2557 assert_eq!(plan.deletes(), 0);
2558 assert_eq!(
2559 plan.actions,
2560 vec![Action::Skip {
2561 clip_id: "gone".to_string()
2562 }]
2563 );
2564 }
2565
2566 #[test]
2567 fn trashed_clip_with_preserve_marker_is_kept() {
2568 let mut manifest = Manifest::new();
2571 manifest.insert(
2572 "a",
2573 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2574 );
2575 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2576 d.trashed = true;
2577 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2578 assert_eq!(plan.deletes(), 0);
2579 assert_eq!(plan.skips(), 1);
2580 }
2581
2582 #[test]
2585 fn trashed_clip_kept_when_a_mirror_is_not_enumerated() {
2586 let mut manifest = Manifest::new();
2588 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2589 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2590 d.trashed = true;
2591 let sources = vec![SourceStatus {
2592 mode: SourceMode::Mirror,
2593 fully_enumerated: false,
2594 }];
2595 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2596 assert_eq!(plan.deletes(), 0);
2597 assert_eq!(plan.skips(), 1);
2598 }
2599
2600 #[test]
2601 fn trashed_clip_kept_when_sources_empty() {
2602 let mut manifest = Manifest::new();
2605 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2606 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2607 d.trashed = true;
2608 let plan = reconcile(&manifest, &[d], &local_present("a"), &[]);
2609 assert_eq!(plan.deletes(), 0);
2610 assert_eq!(plan.skips(), 1);
2611 }
2612
2613 #[test]
2614 fn failed_copy_listing_suppresses_orphan_deletion() {
2615 let mut manifest = Manifest::new();
2618 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
2619 let sources = vec![
2620 SourceStatus {
2621 mode: SourceMode::Mirror,
2622 fully_enumerated: true,
2623 },
2624 SourceStatus {
2625 mode: SourceMode::Copy,
2626 fully_enumerated: false,
2627 },
2628 ];
2629 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
2630 assert_eq!(plan.deletes(), 0);
2631 }
2632
2633 #[test]
2634 fn failed_copy_listing_suppresses_trashed_deletion() {
2635 let mut manifest = Manifest::new();
2636 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2637 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2638 d.trashed = true;
2639 let sources = vec![
2640 SourceStatus {
2641 mode: SourceMode::Mirror,
2642 fully_enumerated: true,
2643 },
2644 SourceStatus {
2645 mode: SourceMode::Copy,
2646 fully_enumerated: false,
2647 },
2648 ];
2649 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
2650 assert_eq!(plan.deletes(), 0);
2651 assert_eq!(plan.skips(), 1);
2652 }
2653
2654 #[test]
2655 fn empty_path_entry_never_deletes() {
2656 let mut manifest = Manifest::new();
2659 manifest.insert("gone", entry("", AudioFormat::Flac, "m", "art"));
2660 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2661 assert_eq!(plan.deletes(), 0);
2662 assert_eq!(
2663 plan.actions,
2664 vec![Action::Skip {
2665 clip_id: "gone".to_string()
2666 }]
2667 );
2668 }
2669
2670 #[test]
2673 fn delete_suppressed_when_path_aliases_rename_target() {
2674 let mut manifest = Manifest::new();
2677 manifest.insert("a", entry("old/a.flac", AudioFormat::Flac, "m", "art"));
2678 manifest.insert("b", entry("new/a.flac", AudioFormat::Flac, "m", "art"));
2679 let d = vec![desired("a", "new/a.flac", AudioFormat::Flac, "m", "art")];
2680 let local: HashMap<String, LocalFile> = [
2681 ("a".to_string(), present(100)),
2682 ("b".to_string(), present(100)),
2683 ]
2684 .into_iter()
2685 .collect();
2686 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
2687 assert!(plan.actions.contains(&Action::Rename {
2688 from: "old/a.flac".to_string(),
2689 to: "new/a.flac".to_string(),
2690 }));
2691 assert!(
2693 !plan
2694 .actions
2695 .iter()
2696 .any(|a| matches!(a, Action::Delete { path, .. } if path == "new/a.flac"))
2697 );
2698 assert!(plan.actions.contains(&Action::Skip {
2699 clip_id: "b".to_string()
2700 }));
2701 }
2702
2703 #[test]
2704 fn delete_suppressed_when_path_aliases_download_target() {
2705 let mut manifest = Manifest::new();
2707 manifest.insert("b", entry("shared.flac", AudioFormat::Flac, "m", "art"));
2708 let d = vec![desired("a", "shared.flac", AudioFormat::Flac, "m", "art")];
2709 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2710 assert!(
2711 !plan
2712 .actions
2713 .iter()
2714 .any(|a| matches!(a, Action::Delete { .. }))
2715 );
2716 assert_eq!(plan.downloads(), 1);
2717 }
2718
2719 #[test]
2720 fn delete_suppressed_when_path_case_aliases_download_target() {
2721 let mut manifest = Manifest::new();
2726 manifest.insert(
2727 "b",
2728 entry("Creator/Song.flac", AudioFormat::Flac, "m", "art"),
2729 );
2730 let d = vec![desired(
2731 "a",
2732 "Creator/song.flac",
2733 AudioFormat::Flac,
2734 "m",
2735 "art",
2736 )];
2737 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2738 assert!(
2739 !plan
2740 .actions
2741 .iter()
2742 .any(|a| matches!(a, Action::Delete { .. })),
2743 "case-only alias was not suppressed: {:?}",
2744 plan.actions
2745 );
2746 assert_eq!(plan.downloads(), 1);
2747 assert!(plan.actions.contains(&Action::Skip {
2748 clip_id: "b".to_string()
2749 }));
2750 }
2751
2752 #[test]
2753 fn delete_suppressed_when_path_nfc_aliases_download_target() {
2754 let nfc = "Creator/\u{00e9}toile.flac"; let nfd = "Creator/e\u{0301}toile.flac"; let mut manifest = Manifest::new();
2759 manifest.insert("b", entry(nfd, AudioFormat::Flac, "m", "art"));
2760 let d = vec![desired("a", nfc, AudioFormat::Flac, "m", "art")];
2761 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
2762 assert!(
2763 !plan
2764 .actions
2765 .iter()
2766 .any(|a| matches!(a, Action::Delete { .. })),
2767 "NFC/NFD alias was not suppressed: {:?}",
2768 plan.actions
2769 );
2770 assert_eq!(plan.downloads(), 1);
2771 }
2772
2773 #[test]
2774 fn delete_artifact_suppressed_when_path_aliases_rename_target() {
2775 let mut actions = vec![
2780 Action::Rename {
2781 from: "old/song.flac".to_string(),
2782 to: "new/cover.jpg".to_string(),
2783 },
2784 Action::DeleteArtifact {
2785 kind: ArtifactKind::CoverJpg,
2786 path: "new/cover.jpg".to_string(),
2787 owner_id: "a".to_string(),
2788 },
2789 ];
2790 suppress_path_aliasing(&mut actions);
2791 assert!(
2793 !actions
2794 .iter()
2795 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
2796 "a sidecar delete must not alias a rename target"
2797 );
2798 assert!(actions.contains(&Action::Skip {
2799 clip_id: "a".to_string()
2800 }));
2801 assert!(actions.contains(&Action::Rename {
2803 from: "old/song.flac".to_string(),
2804 to: "new/cover.jpg".to_string(),
2805 }));
2806 }
2807
2808 #[test]
2809 fn delete_artifact_suppressed_when_path_aliases_write_artifact_target() {
2810 let mut actions = vec![
2813 Action::WriteArtifact {
2814 kind: ArtifactKind::FolderJpg,
2815 path: "creator/album/folder.jpg".to_string(),
2816 source_url: "https://art/large.jpg".to_string(),
2817 hash: "h".to_string(),
2818 owner_id: "root".to_string(),
2819 content: None,
2820 },
2821 Action::DeleteArtifact {
2822 kind: ArtifactKind::FolderJpg,
2823 path: "creator/album/folder.jpg".to_string(),
2824 owner_id: "root-old".to_string(),
2825 },
2826 ];
2827 suppress_path_aliasing(&mut actions);
2828 assert!(
2829 !actions
2830 .iter()
2831 .any(|a| matches!(a, Action::DeleteArtifact { .. }))
2832 );
2833 assert!(actions.contains(&Action::Skip {
2834 clip_id: "root-old".to_string()
2835 }));
2836 }
2837
2838 #[test]
2841 fn duplicate_trashed_does_not_defeat_copy_sibling() {
2842 let mut manifest = Manifest::new();
2845 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2846 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2847 copy_entry.modes = vec![SourceMode::Copy];
2848 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2849 trashed_entry.modes = vec![SourceMode::Mirror];
2850 trashed_entry.trashed = true;
2851 let plan = reconcile(
2852 &manifest,
2853 &[copy_entry, trashed_entry],
2854 &local_present("a"),
2855 &mirror_ok(),
2856 );
2857 assert_eq!(plan.deletes(), 0);
2858 assert_eq!(plan.skips(), 1);
2859 }
2860
2861 #[test]
2862 fn duplicate_trashed_does_not_defeat_private_sibling() {
2863 let mut manifest = Manifest::new();
2864 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2865 let mut private_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2866 private_entry.private = true;
2867 let mut trashed_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2868 trashed_entry.trashed = true;
2869 let plan = reconcile(
2870 &manifest,
2871 &[private_entry, trashed_entry],
2872 &local_present("a"),
2873 &mirror_ok(),
2874 );
2875 assert_eq!(plan.deletes(), 0);
2876 assert_eq!(plan.skips(), 1);
2877 }
2878
2879 #[test]
2880 fn duplicate_trashed_deletes_only_when_all_trashed() {
2881 let mut manifest = Manifest::new();
2883 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2884 let mut first = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2885 first.trashed = true;
2886 let mut second = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2887 second.trashed = true;
2888 let plan = reconcile(
2889 &manifest,
2890 &[first, second],
2891 &local_present("a"),
2892 &mirror_ok(),
2893 );
2894 assert_eq!(plan.deletes(), 1);
2895 }
2896
2897 #[test]
2898 fn duplicate_desired_unions_modes() {
2899 let mut manifest = Manifest::new();
2901 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2902 let mut mirror_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2903 mirror_entry.modes = vec![SourceMode::Mirror];
2904 mirror_entry.trashed = true;
2905 let mut copy_entry = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2906 copy_entry.modes = vec![SourceMode::Copy];
2907 let plan = reconcile(
2908 &manifest,
2909 &[mirror_entry, copy_entry],
2910 &local_present("a"),
2911 &mirror_ok(),
2912 );
2913 assert_eq!(plan.deletes(), 0);
2915 }
2916
2917 #[test]
2920 fn private_new_clip_downloads() {
2921 let manifest = Manifest::new();
2924 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2925 d.private = true;
2926 let plan = reconcile(&manifest, &[d], &HashMap::new(), &mirror_ok());
2927 assert_eq!(plan.downloads(), 1);
2928 }
2929
2930 #[test]
2931 fn private_zero_length_file_redownloads() {
2932 let mut manifest = Manifest::new();
2933 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2934 let local: HashMap<String, LocalFile> = [(
2935 "a".to_string(),
2936 LocalFile {
2937 exists: true,
2938 size: 0,
2939 },
2940 )]
2941 .into_iter()
2942 .collect();
2943 let mut d = desired("a", "a.flac", AudioFormat::Flac, "m", "art");
2944 d.private = true;
2945 let plan = reconcile(&manifest, &[d], &local, &mirror_ok());
2946 assert_eq!(plan.downloads(), 1);
2947 }
2948
2949 #[test]
2950 fn private_meta_change_retags() {
2951 let mut manifest = Manifest::new();
2952 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "old", "art"));
2953 let mut d = desired("a", "a.flac", AudioFormat::Flac, "new", "art");
2954 d.private = true;
2955 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
2956 assert_eq!(plan.retags(), 1);
2957 assert_eq!(plan.deletes(), 0);
2958 }
2959
2960 #[test]
2961 fn absent_private_clip_protected_by_preserve_marker() {
2962 let mut manifest = Manifest::new();
2965 manifest.insert(
2966 "a",
2967 preserved_entry("a.flac", AudioFormat::Flac, "m", "art"),
2968 );
2969 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
2970 assert_eq!(plan.deletes(), 0);
2971 assert_eq!(plan.skips(), 1);
2972 }
2973
2974 #[test]
2977 fn output_is_deterministic_regardless_of_input_order() {
2978 let mut manifest = Manifest::new();
2979 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
2980 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "old", "art"));
2981 manifest.insert("z", entry("z.flac", AudioFormat::Flac, "m", "art"));
2982 let local: HashMap<String, LocalFile> = ["a", "b", "z"]
2983 .iter()
2984 .map(|id| (id.to_string(), present(100)))
2985 .collect();
2986
2987 let forward = vec![
2988 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
2989 desired("b", "b.flac", AudioFormat::Flac, "new", "art"),
2990 desired("c", "c.flac", AudioFormat::Flac, "m", "art"),
2991 ];
2992 let mut reversed = forward.clone();
2993 reversed.reverse();
2994
2995 let p1 = reconcile(&manifest, &forward, &local, &mirror_ok());
2996 let p2 = reconcile(&manifest, &reversed, &local, &mirror_ok());
2997 assert_eq!(p1.actions, p2.actions);
2998
2999 let ids: Vec<&str> = p1
3002 .actions
3003 .iter()
3004 .map(|a| match a {
3005 Action::Skip { clip_id } => clip_id.as_str(),
3006 Action::Retag { clip, .. } => clip.id.as_str(),
3007 Action::Download { clip, .. } => clip.id.as_str(),
3008 Action::Delete { clip_id, .. } => clip_id.as_str(),
3009 Action::Reformat { clip, .. } => clip.id.as_str(),
3010 Action::Rename { to, .. } => to.as_str(),
3011 Action::WriteArtifact { owner_id, .. }
3012 | Action::DeleteArtifact { owner_id, .. }
3013 | Action::MoveArtifact { owner_id, .. } => owner_id.as_str(),
3014 Action::WriteStem { clip_id, .. }
3015 | Action::DeleteStem { clip_id, .. }
3016 | Action::MoveStem { clip_id, .. } => clip_id.as_str(),
3017 })
3018 .collect();
3019 assert_eq!(ids, ["a", "b", "c", "z"]);
3020 }
3021
3022 #[test]
3023 fn empty_inputs_do_not_panic() {
3024 let plan = reconcile(&Manifest::new(), &[], &HashMap::new(), &[]);
3025 assert!(plan.is_empty());
3026 assert_eq!(plan.len(), 0);
3027 }
3028
3029 #[test]
3030 fn empty_desired_with_full_manifest_deletes_all() {
3031 let mut manifest = Manifest::new();
3032 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3033 manifest.insert("b", entry("b.flac", AudioFormat::Flac, "m", "art"));
3034 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3035 assert_eq!(plan.deletes(), 2);
3036 }
3037
3038 #[test]
3039 fn full_desired_with_empty_manifest_downloads_all() {
3040 let d = vec![
3041 desired("a", "a.flac", AudioFormat::Flac, "m", "art"),
3042 desired("b", "b.flac", AudioFormat::Flac, "m", "art"),
3043 ];
3044 let plan = reconcile(&Manifest::new(), &d, &HashMap::new(), &mirror_ok());
3045 assert_eq!(plan.downloads(), 2);
3046 }
3047
3048 #[test]
3049 fn plan_counts_sum_to_len() {
3050 let mut manifest = Manifest::new();
3051 manifest.insert("skip", entry("skip.flac", AudioFormat::Flac, "m", "art"));
3052 manifest.insert(
3053 "retag",
3054 entry("retag.flac", AudioFormat::Flac, "old", "art"),
3055 );
3056 manifest.insert(
3057 "reformat",
3058 entry("reformat.flac", AudioFormat::Flac, "m", "art"),
3059 );
3060 manifest.insert(
3061 "rename",
3062 entry("old/rename.flac", AudioFormat::Flac, "m", "art"),
3063 );
3064 manifest.insert("gone", entry("gone.flac", AudioFormat::Flac, "m", "art"));
3065 let local: HashMap<String, LocalFile> = ["skip", "retag", "reformat", "rename", "gone"]
3066 .iter()
3067 .map(|id| (id.to_string(), present(100)))
3068 .collect();
3069 let d = vec![
3070 desired("skip", "skip.flac", AudioFormat::Flac, "m", "art"),
3071 desired("retag", "retag.flac", AudioFormat::Flac, "new", "art"),
3072 desired("reformat", "reformat.mp3", AudioFormat::Mp3, "m", "art"),
3073 desired("rename", "new/rename.flac", AudioFormat::Flac, "m", "art"),
3074 desired("download", "download.flac", AudioFormat::Flac, "m", "art"),
3075 ];
3076 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3077 let summed = plan.downloads()
3078 + plan.reformats()
3079 + plan.retags()
3080 + plan.renames()
3081 + plan.deletes()
3082 + plan.skips();
3083 assert_eq!(summed, plan.len());
3084 assert_eq!(plan.downloads(), 1);
3085 assert_eq!(plan.reformats(), 1);
3086 assert_eq!(plan.retags(), 1);
3087 assert_eq!(plan.renames(), 1);
3088 assert_eq!(plan.deletes(), 1);
3089 assert_eq!(plan.skips(), 1);
3090 }
3091
3092 fn cover(path: &str, hash: &str) -> ArtifactState {
3095 ArtifactState {
3096 path: path.to_string(),
3097 hash: hash.to_string(),
3098 }
3099 }
3100
3101 fn art(kind: ArtifactKind, path: &str, url: &str, hash: &str) -> DesiredArtifact {
3102 DesiredArtifact {
3103 kind,
3104 path: path.to_string(),
3105 source_url: url.to_string(),
3106 hash: hash.to_string(),
3107 content: None,
3108 }
3109 }
3110
3111 fn text_art(kind: ArtifactKind, path: &str, body: &str) -> DesiredArtifact {
3113 DesiredArtifact {
3114 kind,
3115 path: path.to_string(),
3116 source_url: String::new(),
3117 hash: content_hash(body),
3118 content: Some(body.to_string()),
3119 }
3120 }
3121
3122 fn desired_arts(id: &str, arts: Vec<DesiredArtifact>) -> Desired {
3124 Desired {
3125 artifacts: arts,
3126 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
3127 }
3128 }
3129
3130 fn entry_with_cover_jpg(id: &str, cover_path: &str, cover_hash: &str) -> ManifestEntry {
3132 ManifestEntry {
3133 cover_jpg: Some(cover(cover_path, cover_hash)),
3134 ..entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art")
3135 }
3136 }
3137
3138 fn write_artifacts(plan: &Plan) -> Vec<&Action> {
3139 plan.actions
3140 .iter()
3141 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
3142 .collect()
3143 }
3144
3145 #[test]
3146 fn write_artifact_emitted_when_manifest_lacks_it() {
3147 let mut manifest = Manifest::new();
3150 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3151 let d = vec![desired_arts(
3152 "a",
3153 vec![art(
3154 ArtifactKind::CoverJpg,
3155 "a/cover.jpg",
3156 "https://art/a",
3157 "h1",
3158 )],
3159 )];
3160 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3161 assert_eq!(plan.artifact_writes(), 1);
3162 assert_eq!(plan.artifact_deletes(), 0);
3163 assert_eq!(plan.skips(), 1);
3164 assert_eq!(
3165 write_artifacts(&plan)[0],
3166 &Action::WriteArtifact {
3167 kind: ArtifactKind::CoverJpg,
3168 path: "a/cover.jpg".to_string(),
3169 source_url: "https://art/a".to_string(),
3170 hash: "h1".to_string(),
3171 owner_id: "a".to_string(),
3172 content: None,
3173 }
3174 );
3175 }
3176
3177 #[test]
3178 fn write_artifact_emitted_when_hash_differs() {
3179 let mut manifest = Manifest::new();
3182 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "old"));
3183 let d = vec![desired_arts(
3184 "a",
3185 vec![art(
3186 ArtifactKind::CoverJpg,
3187 "a/cover.jpg",
3188 "https://art/a",
3189 "new",
3190 )],
3191 )];
3192 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3193 assert_eq!(plan.artifact_writes(), 1);
3194 assert_eq!(plan.artifact_deletes(), 0);
3195 if let Action::WriteArtifact { hash, .. } = write_artifacts(&plan)[0] {
3196 assert_eq!(hash, "new");
3197 } else {
3198 panic!("expected a WriteArtifact");
3199 }
3200 }
3201
3202 #[test]
3203 fn write_artifact_skipped_when_hash_matches() {
3204 let mut manifest = Manifest::new();
3206 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3207 let d = vec![desired_arts(
3208 "a",
3209 vec![art(
3210 ArtifactKind::CoverJpg,
3211 "a/cover.jpg",
3212 "https://art/a",
3213 "h1",
3214 )],
3215 )];
3216 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3217 assert_eq!(plan.artifact_writes(), 0);
3218 assert_eq!(plan.artifact_deletes(), 0);
3219 assert_eq!(
3220 plan.actions,
3221 vec![Action::Skip {
3222 clip_id: "a".to_string()
3223 }]
3224 );
3225 }
3226
3227 #[test]
3228 fn removed_kind_cover_is_kept_not_deleted() {
3229 let mut manifest = Manifest::new();
3234 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3235 let d = vec![desired_arts("a", vec![])];
3236 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3237 assert_eq!(plan.artifact_deletes(), 0);
3238 assert_eq!(plan.artifact_writes(), 0);
3239 assert_eq!(plan.deletes(), 0);
3241 assert_eq!(
3242 plan.actions,
3243 vec![Action::Skip {
3244 clip_id: "a".to_string()
3245 }]
3246 );
3247 assert!(!plan.actions.iter().any(|a| matches!(
3248 a,
3249 Action::DeleteArtifact {
3250 kind: ArtifactKind::CoverJpg,
3251 ..
3252 }
3253 )));
3254 }
3255
3256 #[test]
3257 fn delete_artifact_never_on_incomplete_listing() {
3258 let mut manifest = Manifest::new();
3263 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3264 manifest.insert("b", entry_with_cover_jpg("b", "b/cover.jpg", "h1"));
3265 let d = vec![desired_arts("a", vec![]), desired_arts("b", vec![])];
3266 let sources = vec![SourceStatus {
3267 mode: SourceMode::Mirror,
3268 fully_enumerated: false,
3269 }];
3270 let local: HashMap<String, LocalFile> = [
3271 ("a".to_string(), present(100)),
3272 ("b".to_string(), present(100)),
3273 ]
3274 .into_iter()
3275 .collect();
3276 let plan = reconcile(&manifest, &d, &local, &sources);
3277 assert_eq!(plan.artifact_deletes(), 0);
3278 assert_eq!(plan.deletes(), 0);
3279 }
3280
3281 #[test]
3282 fn delete_artifact_never_when_entry_preserved() {
3283 let mut manifest = Manifest::new();
3286 let preserved = ManifestEntry {
3287 preserve: true,
3288 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
3289 };
3290 manifest.insert("a", preserved);
3291 let d = vec![desired_arts("a", vec![])];
3292 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3293 assert_eq!(plan.artifact_deletes(), 0);
3294 }
3295
3296 #[test]
3297 fn co_delete_never_when_path_empty() {
3298 let mut manifest = Manifest::new();
3302 manifest.insert("gone", entry_with_cover_jpg("gone", "", "h1"));
3303 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3304 assert_eq!(plan.deletes(), 1);
3305 assert_eq!(plan.artifact_deletes(), 0);
3306 }
3307
3308 #[test]
3309 fn co_delete_absent_clip_deletes_audio_and_cover() {
3310 let mut manifest = Manifest::new();
3313 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3314 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3315 assert_eq!(plan.deletes(), 1);
3316 assert_eq!(plan.artifact_deletes(), 1);
3317 assert!(plan.actions.contains(&Action::Delete {
3318 path: "gone.flac".to_string(),
3319 clip_id: "gone".to_string(),
3320 }));
3321 assert!(plan.actions.contains(&Action::DeleteArtifact {
3322 kind: ArtifactKind::CoverJpg,
3323 path: "gone/cover.jpg".to_string(),
3324 owner_id: "gone".to_string(),
3325 }));
3326 }
3327
3328 #[test]
3329 fn co_delete_absent_clip_suppressed_when_not_enumerated() {
3330 let mut manifest = Manifest::new();
3332 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
3333 let sources = vec![SourceStatus {
3334 mode: SourceMode::Mirror,
3335 fully_enumerated: false,
3336 }];
3337 let plan = reconcile(&manifest, &[], &HashMap::new(), &sources);
3338 assert_eq!(plan.deletes(), 0);
3339 assert_eq!(plan.artifact_deletes(), 0);
3340 }
3341
3342 #[test]
3343 fn co_delete_trashed_desired_clip_removes_audio_and_cover() {
3344 let mut manifest = Manifest::new();
3346 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3347 let mut d = desired_arts("a", vec![]);
3348 d.trashed = true;
3349 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3350 assert_eq!(plan.deletes(), 1);
3351 assert_eq!(plan.artifact_deletes(), 1);
3352 }
3353
3354 #[test]
3355 fn co_delete_trashed_suppressed_when_not_enumerated() {
3356 let mut manifest = Manifest::new();
3358 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
3359 let mut d = desired_arts("a", vec![]);
3360 d.trashed = true;
3361 let sources = vec![SourceStatus {
3362 mode: SourceMode::Mirror,
3363 fully_enumerated: false,
3364 }];
3365 let plan = reconcile(&manifest, &[d], &local_present("a"), &sources);
3366 assert_eq!(plan.deletes(), 0);
3367 assert_eq!(plan.artifact_deletes(), 0);
3368 assert_eq!(plan.skips(), 1);
3369 }
3370
3371 #[test]
3372 fn co_delete_trashed_suppressed_when_preserved() {
3373 let mut manifest = Manifest::new();
3375 let preserved = ManifestEntry {
3376 preserve: true,
3377 ..entry_with_cover_jpg("a", "a/cover.jpg", "h1")
3378 };
3379 manifest.insert("a", preserved);
3380 let mut d = desired_arts("a", vec![]);
3381 d.trashed = true;
3382 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3383 assert_eq!(plan.deletes(), 0);
3384 assert_eq!(plan.artifact_deletes(), 0);
3385 }
3386
3387 #[test]
3390 fn details_sidecar_written_with_inline_content_when_slot_absent() {
3391 let mut manifest = Manifest::new();
3394 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3395 let d = vec![desired_arts(
3396 "a",
3397 vec![text_art(
3398 ArtifactKind::DetailsTxt,
3399 "a.details.txt",
3400 "Title: A\n",
3401 )],
3402 )];
3403 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3404 assert_eq!(plan.artifact_writes(), 1);
3405 assert_eq!(plan.artifact_deletes(), 0);
3406 assert_eq!(
3407 write_artifacts(&plan)[0],
3408 &Action::WriteArtifact {
3409 kind: ArtifactKind::DetailsTxt,
3410 path: "a.details.txt".to_string(),
3411 source_url: String::new(),
3412 hash: content_hash("Title: A\n"),
3413 owner_id: "a".to_string(),
3414 content: Some("Title: A\n".to_string()),
3415 }
3416 );
3417 }
3418
3419 #[test]
3420 fn lrc_sidecar_written_with_inline_content_when_slot_absent() {
3421 let mut manifest = Manifest::new();
3426 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3427 let body = "[re:rs-suno]\nla la\n";
3428 let d = vec![desired_arts(
3429 "a",
3430 vec![text_art(ArtifactKind::Lrc, "a.lrc", body)],
3431 )];
3432 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3433 assert_eq!(plan.artifact_writes(), 1);
3434 assert_eq!(plan.artifact_deletes(), 0);
3435 assert_eq!(
3436 write_artifacts(&plan)[0],
3437 &Action::WriteArtifact {
3438 kind: ArtifactKind::Lrc,
3439 path: "a.lrc".to_string(),
3440 source_url: String::new(),
3441 hash: content_hash(body),
3442 owner_id: "a".to_string(),
3443 content: Some(body.to_string()),
3444 }
3445 );
3446 }
3447
3448 #[test]
3449 fn text_sidecars_skipped_when_hash_and_path_match() {
3450 let mut manifest = Manifest::new();
3452 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3453 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3454 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("la la\n")));
3455 manifest.insert("a", e);
3456 let d = vec![desired_arts(
3457 "a",
3458 vec![
3459 text_art(ArtifactKind::DetailsTxt, "a.details.txt", "Title: A\n"),
3460 text_art(ArtifactKind::LyricsTxt, "a.lyrics.txt", "la la\n"),
3461 ],
3462 )];
3463 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3464 assert_eq!(plan.artifact_writes(), 0);
3465 assert_eq!(plan.artifact_deletes(), 0);
3466 }
3467
3468 #[test]
3469 fn details_rewritten_when_content_hash_differs() {
3470 let mut manifest = Manifest::new();
3473 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3474 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: Old\n")));
3475 manifest.insert("a", e);
3476 let d = vec![desired_arts(
3477 "a",
3478 vec![text_art(
3479 ArtifactKind::DetailsTxt,
3480 "a.details.txt",
3481 "Title: New\n",
3482 )],
3483 )];
3484 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3485 assert_eq!(plan.artifact_writes(), 1);
3486 assert_eq!(plan.artifact_deletes(), 0);
3487 }
3488
3489 #[test]
3490 fn lyrics_rewritten_when_content_hash_differs_though_meta_unchanged() {
3491 let mut manifest = Manifest::new();
3495 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3496 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("old words\n")));
3497 manifest.insert("a", e);
3498 let d = vec![desired_arts(
3499 "a",
3500 vec![text_art(
3501 ArtifactKind::LyricsTxt,
3502 "a.lyrics.txt",
3503 "new words\n",
3504 )],
3505 )];
3506 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3507 assert_eq!(plan.artifact_writes(), 1);
3509 assert_eq!(plan.retags(), 0);
3510 }
3511
3512 #[test]
3513 fn text_sidecar_relocated_when_path_differs() {
3514 let mut manifest = Manifest::new();
3517 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3518 e.details_txt = Some(cover("old/a.details.txt", &content_hash("Title: A\n")));
3519 manifest.insert("a", e);
3520 let d = vec![desired_arts(
3521 "a",
3522 vec![text_art(
3523 ArtifactKind::DetailsTxt,
3524 "new/a.details.txt",
3525 "Title: A\n",
3526 )],
3527 )];
3528 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3529 assert_eq!(plan.artifact_writes(), 1);
3530 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
3531 assert_eq!(path, "new/a.details.txt");
3532 } else {
3533 panic!("expected a WriteArtifact");
3534 }
3535 }
3536
3537 #[test]
3538 fn fetched_sidecar_path_drift_emits_move() {
3539 let mut manifest = Manifest::new();
3542 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3543 e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3544 manifest.insert("a", e);
3545 let d = vec![desired_arts(
3546 "a",
3547 vec![art(
3548 ArtifactKind::CoverJpg,
3549 "new/cover.jpg",
3550 "https://art/large.jpg",
3551 "arthash",
3552 )],
3553 )];
3554 let local: HashMap<String, LocalFile> = [
3555 ("a".to_string(), present(100)),
3556 ("old/cover.jpg".to_string(), present(50)),
3557 ]
3558 .into_iter()
3559 .collect();
3560 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3561 assert_eq!(plan.artifact_moves(), 1);
3562 assert_eq!(plan.artifact_writes(), 0);
3563 assert!(plan.actions.contains(&Action::MoveArtifact {
3564 kind: ArtifactKind::CoverJpg,
3565 from: "old/cover.jpg".to_string(),
3566 to: "new/cover.jpg".to_string(),
3567 source_url: "https://art/large.jpg".to_string(),
3568 hash: "arthash".to_string(),
3569 owner_id: "a".to_string(),
3570 }));
3571 }
3572
3573 #[test]
3574 fn sidecar_hash_drift_emits_write_not_move() {
3575 let mut manifest = Manifest::new();
3577 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3578 e.cover_jpg = Some(cover("old/cover.jpg", "oldhash"));
3579 manifest.insert("a", e);
3580 let d = vec![desired_arts(
3581 "a",
3582 vec![art(
3583 ArtifactKind::CoverJpg,
3584 "new/cover.jpg",
3585 "https://art/large.jpg",
3586 "newhash",
3587 )],
3588 )];
3589 let local: HashMap<String, LocalFile> = [
3590 ("a".to_string(), present(100)),
3591 ("old/cover.jpg".to_string(), present(50)),
3592 ]
3593 .into_iter()
3594 .collect();
3595 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3596 assert_eq!(plan.artifact_moves(), 0);
3597 assert_eq!(plan.artifact_writes(), 1);
3598 }
3599
3600 #[test]
3601 fn inline_sidecar_path_drift_stays_a_write() {
3602 let mut manifest = Manifest::new();
3605 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3606 e.lyrics_txt = Some(cover("old/a.lyrics.txt", &content_hash("words\n")));
3607 manifest.insert("a", e);
3608 let d = vec![desired_arts(
3609 "a",
3610 vec![text_art(
3611 ArtifactKind::LyricsTxt,
3612 "new/a.lyrics.txt",
3613 "words\n",
3614 )],
3615 )];
3616 let local: HashMap<String, LocalFile> = [
3617 ("a".to_string(), present(100)),
3618 ("old/a.lyrics.txt".to_string(), present(50)),
3619 ]
3620 .into_iter()
3621 .collect();
3622 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3623 assert_eq!(plan.artifact_moves(), 0);
3624 assert_eq!(plan.artifact_writes(), 1);
3625 }
3626
3627 #[test]
3628 fn sidecar_move_downgrades_to_write_when_old_file_absent() {
3629 let mut manifest = Manifest::new();
3632 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3633 e.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3634 manifest.insert("a", e);
3635 let d = vec![desired_arts(
3636 "a",
3637 vec![art(
3638 ArtifactKind::CoverJpg,
3639 "new/cover.jpg",
3640 "https://art/large.jpg",
3641 "arthash",
3642 )],
3643 )];
3644 let local: HashMap<String, LocalFile> = [
3645 ("a".to_string(), present(100)),
3646 (
3647 "old/cover.jpg".to_string(),
3648 LocalFile {
3649 exists: false,
3650 size: 0,
3651 },
3652 ),
3653 ]
3654 .into_iter()
3655 .collect();
3656 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3657 assert_eq!(plan.artifact_moves(), 0);
3658 assert_eq!(plan.artifact_writes(), 1);
3659 }
3660
3661 #[test]
3662 fn move_target_suppresses_a_colliding_delete() {
3663 let mut manifest = Manifest::new();
3666 let mut a = entry("a.flac", AudioFormat::Flac, "m", "art");
3667 a.cover_jpg = Some(cover("old/cover.jpg", "arthash"));
3668 manifest.insert("a", a);
3669 let mut b = entry("b.flac", AudioFormat::Flac, "m", "art");
3672 b.details_txt = Some(cover("new/cover.jpg", "bh"));
3673 manifest.insert("b", b);
3674 let d = vec![
3675 desired_arts(
3676 "a",
3677 vec![art(
3678 ArtifactKind::CoverJpg,
3679 "new/cover.jpg",
3680 "https://art/large.jpg",
3681 "arthash",
3682 )],
3683 ),
3684 desired_arts("b", vec![]),
3685 ];
3686 let local: HashMap<String, LocalFile> = [
3687 ("a".to_string(), present(100)),
3688 ("b".to_string(), present(100)),
3689 ("old/cover.jpg".to_string(), present(50)),
3690 ("new/cover.jpg".to_string(), present(50)),
3691 ]
3692 .into_iter()
3693 .collect();
3694 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3695 assert_eq!(plan.artifact_moves(), 1);
3696 assert!(!plan.actions.iter().any(|a| matches!(
3698 a,
3699 Action::DeleteArtifact { path, .. } if path == "new/cover.jpg"
3700 )));
3701 }
3702
3703 #[test]
3704 fn stem_path_drift_emits_move() {
3705 let mut manifest = Manifest::new();
3708 manifest.insert(
3709 "a",
3710 entry_with_stems("a", &[("voc", "old.stems/voc.mp3", "h1")]),
3711 );
3712 let d = vec![stem_desired(
3713 "a",
3714 Some(vec![dstem("voc", "new.stems/voc.mp3", "h1")]),
3715 )];
3716 let local: HashMap<String, LocalFile> = [
3717 ("a".to_string(), present(100)),
3718 ("old.stems/voc.mp3".to_string(), present(50)),
3719 ]
3720 .into_iter()
3721 .collect();
3722 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
3723 assert_eq!(plan.stem_moves(), 1);
3724 assert_eq!(plan.stem_writes(), 0);
3725 assert!(plan.actions.contains(&Action::MoveStem {
3726 clip_id: "a".to_string(),
3727 key: "voc".to_string(),
3728 stem_id: "voc".to_string(),
3729 from: "old.stems/voc.mp3".to_string(),
3730 to: "new.stems/voc.mp3".to_string(),
3731 source_url: "https://cdn1.suno.ai/voc.mp3".to_string(),
3732 format: StemFormat::Mp3,
3733 hash: "h1".to_string(),
3734 }));
3735 }
3736
3737 #[test]
3738 fn details_removed_kind_is_deleted_when_feature_off() {
3739 let mut manifest = Manifest::new();
3742 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3743 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3744 manifest.insert("a", e);
3745 let d = vec![desired_arts("a", vec![])];
3746 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3747 assert_eq!(plan.artifact_deletes(), 1);
3748 assert!(plan.actions.contains(&Action::DeleteArtifact {
3749 kind: ArtifactKind::DetailsTxt,
3750 path: "a.details.txt".to_string(),
3751 owner_id: "a".to_string(),
3752 }));
3753 }
3754
3755 #[test]
3756 fn cover_webp_retired_sidecar_is_deleted_through_the_gate() {
3757 let mut manifest = Manifest::new();
3762 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3763 e.cover_webp = Some(cover("a.webp", "v1"));
3764 manifest.insert("a", e);
3765 let d = vec![desired_arts("a", vec![])];
3766 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3767 assert_eq!(plan.artifact_deletes(), 1);
3768 assert!(plan.actions.contains(&Action::DeleteArtifact {
3769 kind: ArtifactKind::CoverWebp,
3770 path: "a.webp".to_string(),
3771 owner_id: "a".to_string(),
3772 }));
3773 }
3774
3775 #[test]
3776 fn cover_webp_retired_cleanup_respects_the_deletion_gate() {
3777 let mut manifest = Manifest::new();
3781 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3782 e.cover_webp = Some(cover("a.webp", "v1"));
3783 manifest.insert("a", e);
3784 let d = vec![desired_arts("a", vec![])];
3785 let not_enumerated = vec![SourceStatus {
3786 mode: SourceMode::Mirror,
3787 fully_enumerated: false,
3788 }];
3789 let plan = reconcile(&manifest, &d, &local_present("a"), ¬_enumerated);
3790 assert_eq!(plan.artifact_deletes(), 0);
3791 assert_eq!(plan.deletes(), 0);
3792 }
3793
3794 #[test]
3795 fn lyrics_removed_kind_is_kept_not_deleted() {
3796 let mut manifest = Manifest::new();
3800 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3801 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3802 manifest.insert("a", e);
3803 let d = vec![desired_arts("a", vec![])];
3804 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3805 assert_eq!(plan.artifact_deletes(), 0);
3806 assert_eq!(plan.deletes(), 0);
3807 }
3808
3809 #[test]
3810 fn lrc_removed_kind_is_kept_not_deleted() {
3811 let mut manifest = Manifest::new();
3814 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3815 e.lrc = Some(cover("a.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3816 manifest.insert("a", e);
3817 let d = vec![desired_arts("a", vec![])];
3818 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3819 assert_eq!(plan.artifact_deletes(), 0);
3820 assert_eq!(plan.deletes(), 0);
3821 }
3822
3823 #[test]
3824 fn video_mp4_removed_kind_is_kept_not_deleted() {
3825 let mut manifest = Manifest::new();
3829 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3830 e.video_mp4 = Some(cover("a.mp4", "vid-hash"));
3831 manifest.insert("a", e);
3832 let d = vec![desired_arts("a", vec![])];
3833 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3834 assert_eq!(plan.artifact_deletes(), 0);
3835 assert_eq!(plan.deletes(), 0);
3836 }
3837
3838 #[test]
3839 fn video_mp4_written_when_manifest_lacks_it() {
3840 let mut manifest = Manifest::new();
3843 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3844 let d = vec![desired_arts(
3845 "a",
3846 vec![art(
3847 ArtifactKind::VideoMp4,
3848 "a/song.mp4",
3849 "https://cdn/a/video.mp4",
3850 "vid-hash",
3851 )],
3852 )];
3853 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3854 assert_eq!(plan.artifact_writes(), 1);
3855 assert_eq!(
3856 write_artifacts(&plan)[0],
3857 &Action::WriteArtifact {
3858 kind: ArtifactKind::VideoMp4,
3859 path: "a/song.mp4".to_string(),
3860 source_url: "https://cdn/a/video.mp4".to_string(),
3861 hash: "vid-hash".to_string(),
3862 owner_id: "a".to_string(),
3863 content: None,
3864 }
3865 );
3866 }
3867
3868 #[test]
3869 fn details_removed_kind_not_deleted_on_incomplete_listing() {
3870 let mut manifest = Manifest::new();
3873 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3874 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3875 manifest.insert("a", e);
3876 let d = vec![desired_arts("a", vec![])];
3877 let sources = vec![SourceStatus {
3878 mode: SourceMode::Mirror,
3879 fully_enumerated: false,
3880 }];
3881 let plan = reconcile(&manifest, &d, &local_present("a"), &sources);
3882 assert_eq!(plan.artifact_deletes(), 0);
3883 }
3884
3885 #[test]
3886 fn details_removed_kind_not_deleted_when_preserved() {
3887 let mut manifest = Manifest::new();
3890 let mut e = ManifestEntry {
3891 preserve: true,
3892 ..entry("a.flac", AudioFormat::Flac, "m", "art")
3893 };
3894 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3895 manifest.insert("a", e);
3896 let d = vec![desired_arts("a", vec![])];
3897 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3898 assert_eq!(plan.artifact_deletes(), 0);
3899 }
3900
3901 #[test]
3902 fn co_delete_orphan_removes_every_text_sidecar() {
3903 let mut manifest = Manifest::new();
3907 let mut e = entry("gone.flac", AudioFormat::Flac, "m", "art");
3908 e.cover_jpg = Some(cover("gone/cover.jpg", "h1"));
3909 e.details_txt = Some(cover("gone.details.txt", &content_hash("Title: G\n")));
3910 e.lyrics_txt = Some(cover("gone.lyrics.txt", &content_hash("words\n")));
3911 e.lrc = Some(cover("gone.lrc", &content_hash("[re:rs-suno]\nwords\n")));
3912 e.video_mp4 = Some(cover("gone/song.mp4", "vid-hash"));
3913 manifest.insert("gone", e);
3914 let plan = reconcile(&manifest, &[], &HashMap::new(), &mirror_ok());
3915 assert_eq!(plan.deletes(), 1);
3916 assert_eq!(plan.artifact_deletes(), 5);
3917 for (kind, path) in [
3918 (ArtifactKind::CoverJpg, "gone/cover.jpg"),
3919 (ArtifactKind::DetailsTxt, "gone.details.txt"),
3920 (ArtifactKind::LyricsTxt, "gone.lyrics.txt"),
3921 (ArtifactKind::Lrc, "gone.lrc"),
3922 (ArtifactKind::VideoMp4, "gone/song.mp4"),
3923 ] {
3924 assert!(
3925 plan.actions.contains(&Action::DeleteArtifact {
3926 kind,
3927 path: path.to_string(),
3928 owner_id: "gone".to_string(),
3929 }),
3930 "missing co-delete for {kind:?}"
3931 );
3932 }
3933 }
3934
3935 #[test]
3936 fn co_delete_trashed_removes_every_text_sidecar() {
3937 let mut manifest = Manifest::new();
3939 let mut e = entry("a.flac", AudioFormat::Flac, "m", "art");
3940 e.details_txt = Some(cover("a.details.txt", &content_hash("Title: A\n")));
3941 e.lyrics_txt = Some(cover("a.lyrics.txt", &content_hash("words\n")));
3942 manifest.insert("a", e);
3943 let mut d = desired_arts("a", vec![]);
3944 d.trashed = true;
3945 let plan = reconcile(&manifest, &[d], &local_present("a"), &mirror_ok());
3946 assert_eq!(plan.deletes(), 1);
3947 assert_eq!(plan.artifact_deletes(), 2);
3948 }
3949
3950 #[test]
3951 fn suppress_downgrades_delete_artifact_colliding_with_write_artifact() {
3952 let mut manifest = Manifest::new();
3955 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
3956 manifest.insert("b", entry_with_cover_jpg("b", "shared/cover.jpg", "h1"));
3957 let d = vec![desired_arts(
3960 "a",
3961 vec![art(
3962 ArtifactKind::CoverJpg,
3963 "shared/cover.jpg",
3964 "https://art/a",
3965 "h2",
3966 )],
3967 )];
3968 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
3969 assert_eq!(plan.artifact_writes(), 1);
3970 assert!(!plan.actions.iter().any(
3972 |a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/cover.jpg")
3973 ));
3974 assert!(plan.actions.contains(&Action::Delete {
3976 path: "b.flac".to_string(),
3977 clip_id: "b".to_string(),
3978 }));
3979 }
3980
3981 #[test]
3982 fn suppress_downgrades_delete_artifact_colliding_with_download() {
3983 let mut manifest = Manifest::new();
3985 manifest.insert("b", entry_with_cover_jpg("b", "shared/x", "h1"));
3986 let d = vec![desired("a", "shared/x", AudioFormat::Flac, "m", "art")];
3987 let plan = reconcile(&manifest, &d, &HashMap::new(), &mirror_ok());
3988 assert_eq!(plan.downloads(), 1);
3989 assert!(
3990 !plan
3991 .actions
3992 .iter()
3993 .any(|a| matches!(a, Action::DeleteArtifact { path, .. } if path == "shared/x"))
3994 );
3995 }
3996
3997 #[test]
3998 fn adding_artifacts_leaves_the_audio_plan_unchanged() {
3999 let build = |with_art: bool| {
4003 let mut manifest = Manifest::new();
4004 manifest.insert("keep", entry_with_cover_jpg("keep", "keep/cover.jpg", "h1"));
4005 manifest.insert("gone", entry_with_cover_jpg("gone", "gone/cover.jpg", "h1"));
4006 manifest.insert(
4007 "trash",
4008 entry_with_cover_jpg("trash", "trash/cover.jpg", "h1"),
4009 );
4010 let keep = if with_art {
4011 desired_arts(
4012 "keep",
4013 vec![art(
4014 ArtifactKind::CoverJpg,
4015 "keep/cover.jpg",
4016 "https://art/keep",
4017 "h1",
4018 )],
4019 )
4020 } else {
4021 desired_arts("keep", vec![])
4022 };
4023 let mut trash = desired_arts("trash", vec![]);
4024 trash.trashed = true;
4025 let local: HashMap<String, LocalFile> = ["keep", "gone", "trash"]
4026 .iter()
4027 .map(|id| (id.to_string(), present(100)))
4028 .collect();
4029 reconcile(&manifest, &[keep, trash], &local, &mirror_ok())
4030 };
4031
4032 let with = build(true);
4033 let without = build(false);
4034
4035 let audio = |plan: &Plan| -> Vec<Action> {
4037 plan.actions
4038 .iter()
4039 .filter(|a| {
4040 !matches!(
4041 a,
4042 Action::WriteArtifact { .. } | Action::DeleteArtifact { .. }
4043 )
4044 })
4045 .cloned()
4046 .collect()
4047 };
4048 assert_eq!(audio(&with), audio(&without));
4049 assert_eq!(with.deletes(), without.deletes());
4050 assert_eq!(with.deletes(), 2);
4052 assert_eq!(with.artifact_deletes(), 2);
4056 assert_eq!(with.artifact_writes(), 0);
4057 }
4058
4059 #[test]
4062 fn removed_kind_sidecar_kept_when_clip_is_protected_this_run() {
4063 let mut manifest = Manifest::new();
4069 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
4070 assert!(!manifest.get("a").unwrap().preserve);
4071
4072 let private = Desired {
4074 private: true,
4075 ..desired_arts("a", vec![])
4076 };
4077 let plan = reconcile(&manifest, &[private], &local_present("a"), &mirror_ok());
4078 assert_eq!(plan.artifact_deletes(), 0);
4079
4080 let copy_held = Desired {
4082 modes: vec![SourceMode::Copy],
4083 ..desired_arts("a", vec![])
4084 };
4085 let plan = reconcile(&manifest, &[copy_held], &local_present("a"), &mirror_ok());
4086 assert_eq!(plan.artifact_deletes(), 0);
4087 }
4088
4089 #[test]
4090 fn write_artifact_emitted_when_path_differs_even_if_hash_matches() {
4091 let mut manifest = Manifest::new();
4097 manifest.insert("a", entry_with_cover_jpg("a", "old/cover.jpg", "h1"));
4098 let d = vec![desired_arts(
4099 "a",
4100 vec![art(
4101 ArtifactKind::CoverJpg,
4102 "new/cover.jpg",
4103 "https://art/a",
4104 "h1",
4105 )],
4106 )];
4107 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4108 assert_eq!(plan.artifact_writes(), 1);
4109 assert_eq!(plan.artifact_deletes(), 0);
4110 if let Action::WriteArtifact { path, .. } = write_artifacts(&plan)[0] {
4111 assert_eq!(path, "new/cover.jpg");
4112 } else {
4113 panic!("expected a WriteArtifact");
4114 }
4115 }
4116
4117 #[test]
4118 fn needs_write_drift_applies_hash_path_and_probe_rules() {
4119 let local: HashMap<String, LocalFile> = [
4120 ("ok".to_string(), present(10)),
4121 ("missing".to_string(), LocalFile::default()),
4122 ("empty".to_string(), present(0)),
4123 ]
4124 .into_iter()
4125 .collect();
4126
4127 assert!(needs_write_drift(None, "h1", "ok", &local));
4128 assert!(!needs_write_drift(Some(("h1", "ok")), "h1", "ok", &local));
4129 assert!(needs_write_drift(Some(("h0", "ok")), "h1", "ok", &local));
4130 assert!(needs_write_drift(
4131 Some(("h1", "missing")),
4132 "h1",
4133 "missing",
4134 &local
4135 ));
4136 assert!(needs_write_drift(
4137 Some(("h1", "empty")),
4138 "h1",
4139 "empty",
4140 &local
4141 ));
4142 assert!(!needs_write_drift(
4143 Some(("h1", "unprobed")),
4144 "h1",
4145 "unprobed",
4146 &local
4147 ));
4148 }
4149
4150 #[test]
4151 fn per_clip_reconcile_ignores_album_and_library_kinds() {
4152 let mut manifest = Manifest::new();
4156 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
4157 let d = vec![desired_arts(
4158 "a",
4159 vec![
4160 art(
4161 ArtifactKind::FolderJpg,
4162 "a/folder.jpg",
4163 "https://art/folder",
4164 "hf",
4165 ),
4166 art(
4167 ArtifactKind::Playlist,
4168 "a/list.m3u",
4169 "https://art/list",
4170 "hp",
4171 ),
4172 art(ArtifactKind::CoverJpg, "a/cover.jpg", "https://art/a", "h1"),
4173 ],
4174 )];
4175 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4176 assert_eq!(plan.artifact_writes(), 1);
4177 let paths: Vec<&str> = plan
4178 .actions
4179 .iter()
4180 .filter_map(|a| match a {
4181 Action::WriteArtifact { path, .. } => Some(path.as_str()),
4182 _ => None,
4183 })
4184 .collect();
4185 assert_eq!(paths, vec!["a/cover.jpg"]);
4186 }
4187
4188 #[test]
4189 fn per_clip_reconcile_emits_nothing_for_album_only_artifacts() {
4190 let mut manifest = Manifest::new();
4191 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
4192 let d = vec![desired_arts(
4193 "a",
4194 vec![art(
4195 ArtifactKind::FolderWebp,
4196 "a/folder.webp",
4197 "https://art/folder",
4198 "hf",
4199 )],
4200 )];
4201 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4202 assert_eq!(plan.artifact_writes(), 0);
4203 assert_eq!(plan.artifact_deletes(), 0);
4204 }
4205
4206 fn local_with_missing(audio_id: &str, missing_path: &str) -> HashMap<String, LocalFile> {
4210 let mut m = local_present(audio_id);
4211 m.insert(missing_path.to_owned(), LocalFile::default());
4212 m
4213 }
4214
4215 fn local_with_present_artifact(
4217 audio_id: &str,
4218 artifact_path: &str,
4219 ) -> HashMap<String, LocalFile> {
4220 let mut m = local_present(audio_id);
4221 m.insert(artifact_path.to_owned(), present(50));
4222 m
4223 }
4224
4225 #[test]
4226 fn sidecar_missing_on_disk_forces_rewrite() {
4227 let mut manifest = Manifest::new();
4231 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
4232 let d = vec![desired_arts(
4233 "a",
4234 vec![art(
4235 ArtifactKind::CoverJpg,
4236 "a/cover.jpg",
4237 "https://art/a",
4238 "h1",
4239 )],
4240 )];
4241 let local = local_with_missing("a", "a/cover.jpg");
4242 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
4243 assert_eq!(
4244 plan.artifact_writes(),
4245 1,
4246 "missing sidecar must be rewritten"
4247 );
4248 assert_eq!(plan.artifact_deletes(), 0);
4249 }
4250
4251 #[test]
4252 fn sidecar_present_on_disk_with_matching_hash_no_churn() {
4253 let mut manifest = Manifest::new();
4255 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
4256 let d = vec![desired_arts(
4257 "a",
4258 vec![art(
4259 ArtifactKind::CoverJpg,
4260 "a/cover.jpg",
4261 "https://art/a",
4262 "h1",
4263 )],
4264 )];
4265 let local = local_with_present_artifact("a", "a/cover.jpg");
4266 let plan = reconcile(&manifest, &d, &local, &mirror_ok());
4267 assert_eq!(plan.artifact_writes(), 0, "present sidecar must not churn");
4268 assert_eq!(plan.artifact_deletes(), 0);
4269 }
4270
4271 #[test]
4272 fn sidecar_probe_absent_falls_back_to_hash_comparison_no_write() {
4273 let mut manifest = Manifest::new();
4277 manifest.insert("a", entry_with_cover_jpg("a", "a/cover.jpg", "h1"));
4278 let d = vec![desired_arts(
4279 "a",
4280 vec![art(
4281 ArtifactKind::CoverJpg,
4282 "a/cover.jpg",
4283 "https://art/a",
4284 "h1",
4285 )],
4286 )];
4287 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
4289 assert_eq!(
4290 plan.artifact_writes(),
4291 0,
4292 "no write when probe unavailable and hash matches"
4293 );
4294 assert_eq!(
4295 plan.artifact_deletes(),
4296 0,
4297 "missing probe must never trigger a delete"
4298 );
4299 }
4300
4301 #[test]
4302 fn folder_art_missing_on_disk_forces_rewrite() {
4303 let members = vec![album_member(
4306 album_clip("a", 1, "t0", "art-a", ""),
4307 "root",
4308 "c/al/a.flac",
4309 )];
4310 let desired = album_desired(&members, false, false, WebpEncodeSettings::default());
4311 let mut albums = BTreeMap::new();
4312 albums.insert(
4313 "root".to_string(),
4314 AlbumArt {
4315 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4316 folder_webp: None,
4317 folder_mp4: None,
4318 },
4319 );
4320 let mut local: HashMap<String, LocalFile> = HashMap::new();
4321 local.insert("c/al/folder.jpg".to_owned(), LocalFile::default());
4322 let actions = plan_album_artifacts(&desired, &albums, true, &local);
4323 assert_eq!(actions.len(), 1, "missing folder art must be rewritten");
4324 assert!(matches!(
4325 &actions[0],
4326 Action::WriteArtifact {
4327 kind: ArtifactKind::FolderJpg,
4328 ..
4329 }
4330 ));
4331 }
4332
4333 #[test]
4334 fn folder_art_present_on_disk_no_churn() {
4335 let members = vec![album_member(
4337 album_clip("a", 1, "t0", "art-a", ""),
4338 "root",
4339 "c/al/a.flac",
4340 )];
4341 let desired = album_desired(&members, false, false, WebpEncodeSettings::default());
4342 let mut albums = BTreeMap::new();
4343 albums.insert(
4344 "root".to_string(),
4345 AlbumArt {
4346 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4347 folder_webp: None,
4348 folder_mp4: None,
4349 },
4350 );
4351 let mut local: HashMap<String, LocalFile> = HashMap::new();
4352 local.insert("c/al/folder.jpg".to_owned(), present(5000));
4353 let actions = plan_album_artifacts(&desired, &albums, true, &local);
4354 assert!(
4355 actions.is_empty(),
4356 "present folder art with matching hash must not churn"
4357 );
4358 }
4359
4360 #[test]
4361 fn playlist_missing_on_disk_forces_rewrite() {
4362 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4365 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4366 let mut local: HashMap<String, LocalFile> = HashMap::new();
4367 local.insert("Mix.m3u8".to_owned(), LocalFile::default());
4368 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
4369 assert_eq!(actions.len(), 1, "missing playlist file must be rewritten");
4370 assert!(matches!(
4371 &actions[0],
4372 Action::WriteArtifact {
4373 kind: ArtifactKind::Playlist,
4374 ..
4375 }
4376 ));
4377 }
4378
4379 #[test]
4380 fn playlist_present_on_disk_no_churn() {
4381 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
4383 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
4384 let mut local: HashMap<String, LocalFile> = HashMap::new();
4385 local.insert("Mix.m3u8".to_owned(), present(200));
4386 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
4387 assert!(
4388 actions.is_empty(),
4389 "present playlist with matching hash must not churn"
4390 );
4391 }
4392
4393 fn album_clip(id: &str, play_count: u64, created_at: &str, image: &str, video: &str) -> Clip {
4396 Clip {
4397 id: id.to_string(),
4398 title: "Song".to_string(),
4399 image_large_url: image.to_string(),
4400 video_cover_url: video.to_string(),
4401 play_count,
4402 created_at: created_at.to_string(),
4403 ..Default::default()
4404 }
4405 }
4406
4407 fn album_member(clip: Clip, root_id: &str, path: &str) -> Desired {
4408 let mut lineage = LineageContext::own_root(&clip);
4409 lineage.root_id = root_id.to_string();
4410 Desired {
4411 clip,
4412 lineage,
4413 path: path.to_string(),
4414 format: AudioFormat::Flac,
4415 meta_hash: "m".to_string(),
4416 art_hash: "a".to_string(),
4417 modes: vec![SourceMode::Mirror],
4418 trashed: false,
4419 private: false,
4420 artifacts: Vec::new(),
4421 stems: None,
4422 }
4423 }
4424
4425 fn stored(path: &str, hash: &str) -> ArtifactState {
4426 ArtifactState {
4427 path: path.to_string(),
4428 hash: hash.to_string(),
4429 }
4430 }
4431
4432 #[test]
4433 fn folder_jpg_source_is_most_played() {
4434 let members = vec![
4435 album_member(album_clip("a", 5, "t0", "art-a", ""), "root", "c/al/a.flac"),
4436 album_member(album_clip("b", 9, "t1", "art-b", ""), "root", "c/al/b.flac"),
4437 album_member(album_clip("c", 2, "t2", "art-c", ""), "root", "c/al/c.flac"),
4438 ];
4439 let albums = album_desired(&members, false, false, WebpEncodeSettings::default());
4440 assert_eq!(albums.len(), 1);
4441 let jpg = albums[0].folder_jpg.as_ref().unwrap();
4442 assert_eq!(jpg.hash, art_url_hash("art-b"));
4444 assert_eq!(jpg.source_url, "art-b");
4445 assert_eq!(jpg.path, "c/al/folder.jpg");
4446 assert_eq!(jpg.kind, ArtifactKind::FolderJpg);
4447 }
4448
4449 #[test]
4450 fn folder_jpg_tie_breaks_earliest_then_lex_id() {
4451 let by_time = vec![
4453 album_member(album_clip("z", 4, "t2", "art-z", ""), "root", "c/al/z.flac"),
4454 album_member(album_clip("y", 4, "t0", "art-y", ""), "root", "c/al/y.flac"),
4455 album_member(album_clip("x", 4, "t1", "art-x", ""), "root", "c/al/x.flac"),
4456 ];
4457 let jpg = album_desired(&by_time, false, false, WebpEncodeSettings::default())[0]
4458 .folder_jpg
4459 .clone()
4460 .unwrap();
4461 assert_eq!(jpg.source_url, "art-y");
4462
4463 let by_id = vec![
4465 album_member(album_clip("m", 4, "t0", "art-m", ""), "root", "c/al/m.flac"),
4466 album_member(album_clip("g", 4, "t0", "art-g", ""), "root", "c/al/g.flac"),
4467 ];
4468 let jpg = album_desired(&by_id, false, false, WebpEncodeSettings::default())[0]
4469 .folder_jpg
4470 .clone()
4471 .unwrap();
4472 assert_eq!(jpg.source_url, "art-g");
4473 }
4474
4475 #[test]
4476 fn folder_webp_source_is_first_created_animated() {
4477 let members = vec![
4478 album_member(
4479 album_clip("a", 9, "t2", "art-a", "vid-a"),
4480 "root",
4481 "c/al/a.flac",
4482 ),
4483 album_member(
4484 album_clip("b", 1, "t0", "art-b", "vid-b"),
4485 "root",
4486 "c/al/b.flac",
4487 ),
4488 album_member(album_clip("c", 5, "t1", "art-c", ""), "root", "c/al/c.flac"),
4489 ];
4490 let webp = album_desired(&members, true, false, WebpEncodeSettings::default())[0]
4491 .folder_webp
4492 .clone()
4493 .unwrap();
4494 assert_eq!(webp.source_url, "vid-b");
4496 assert_eq!(
4497 webp.hash,
4498 webp_art_hash("vid-b", &WebpEncodeSettings::default())
4499 );
4500 assert_eq!(webp.path, "c/al/cover.webp");
4501 assert_eq!(webp.kind, ArtifactKind::FolderWebp);
4502
4503 let hi = WebpEncodeSettings {
4506 quality: 40,
4507 ..WebpEncodeSettings::default()
4508 };
4509 let rehashed = album_desired(&members, true, false, hi)[0]
4510 .folder_webp
4511 .clone()
4512 .unwrap();
4513 assert_ne!(rehashed.hash, webp.hash);
4514 }
4515
4516 #[test]
4517 fn animated_covers_off_yields_no_folder_webp() {
4518 let members = vec![album_member(
4519 album_clip("a", 1, "t0", "art-a", "vid-a"),
4520 "root",
4521 "c/al/a.flac",
4522 )];
4523 let off = album_desired(&members, false, false, WebpEncodeSettings::default());
4524 assert!(off[0].folder_webp.is_none());
4525 let on = album_desired(&members, true, false, WebpEncodeSettings::default());
4526 assert!(on[0].folder_webp.is_some());
4527 }
4528
4529 #[test]
4530 fn raw_cover_yields_folder_mp4_from_the_webp_source_verbatim() {
4531 let members = vec![
4532 album_member(
4533 album_clip("a", 9, "t2", "art-a", "vid-a"),
4534 "root",
4535 "c/al/a.flac",
4536 ),
4537 album_member(
4538 album_clip("b", 1, "t0", "art-b", "vid-b"),
4539 "root",
4540 "c/al/b.flac",
4541 ),
4542 ];
4543 let album = album_desired(&members, true, true, WebpEncodeSettings::default()).remove(0);
4547 let webp = album.folder_webp.unwrap();
4548 let mp4 = album.folder_mp4.unwrap();
4549 assert_eq!(mp4.kind, ArtifactKind::FolderMp4);
4550 assert_eq!(mp4.path, "c/al/cover.mp4");
4551 assert_eq!(mp4.source_url, "vid-b");
4552 assert_eq!(mp4.hash, art_url_hash("vid-b"));
4553 assert_eq!(mp4.source_url, webp.source_url, "same variant feeds both");
4554 }
4555
4556 #[test]
4557 fn raw_cover_and_webp_are_independent_toggles() {
4558 let members = vec![album_member(
4559 album_clip("a", 1, "t0", "art-a", "vid-a"),
4560 "root",
4561 "c/al/a.flac",
4562 )];
4563 let webp_only =
4565 album_desired(&members, true, false, WebpEncodeSettings::default()).remove(0);
4566 assert!(webp_only.folder_webp.is_some());
4567 assert!(webp_only.folder_mp4.is_none());
4568 let mp4_only =
4570 album_desired(&members, false, true, WebpEncodeSettings::default()).remove(0);
4571 assert!(mp4_only.folder_webp.is_none());
4572 assert!(mp4_only.folder_mp4.is_some());
4573 }
4574
4575 #[test]
4576 fn raw_cover_needs_an_animated_source() {
4577 let members = vec![album_member(
4579 album_clip("a", 3, "t0", "art-a", ""),
4580 "root",
4581 "c/al/a.flac",
4582 )];
4583 let album = album_desired(&members, true, true, WebpEncodeSettings::default()).remove(0);
4584 assert!(album.folder_mp4.is_none());
4585 assert!(album.folder_webp.is_none());
4586 }
4587
4588 #[test]
4589 fn album_with_no_art_yields_no_folder_jpg() {
4590 let members = vec![album_member(
4591 album_clip("a", 3, "t0", "", ""),
4592 "root",
4593 "c/al/a.flac",
4594 )];
4595 let albums = album_desired(&members, true, false, WebpEncodeSettings::default());
4596 assert!(albums[0].folder_jpg.is_none());
4597 assert!(albums[0].folder_webp.is_none());
4598 }
4599
4600 #[test]
4601 fn album_desired_groups_by_root_id() {
4602 let members = vec![
4603 album_member(album_clip("a", 1, "t0", "art-a", ""), "r1", "c/al1/a.flac"),
4604 album_member(album_clip("b", 1, "t0", "art-b", ""), "r2", "c/al2/b.flac"),
4605 album_member(album_clip("c", 9, "t0", "art-c", ""), "r1", "c/al1/c.flac"),
4606 ];
4607 let albums = album_desired(&members, false, false, WebpEncodeSettings::default());
4608 assert_eq!(albums.len(), 2);
4609 assert_eq!(albums[0].root_id, "r1");
4610 assert_eq!(albums[0].folder_jpg.as_ref().unwrap().source_url, "art-c");
4611 assert_eq!(
4612 albums[0].folder_jpg.as_ref().unwrap().path,
4613 "c/al1/folder.jpg"
4614 );
4615 assert_eq!(albums[1].root_id, "r2");
4616 assert_eq!(albums[1].folder_jpg.as_ref().unwrap().source_url, "art-b");
4617 assert_eq!(
4618 albums[1].folder_jpg.as_ref().unwrap().path,
4619 "c/al2/folder.jpg"
4620 );
4621 }
4622
4623 #[test]
4624 fn plan_writes_folder_art_when_store_empty() {
4625 let members = vec![album_member(
4626 album_clip("a", 1, "t0", "art-a", "vid-a"),
4627 "root",
4628 "c/al/a.flac",
4629 )];
4630 let desired = album_desired(&members, true, false, WebpEncodeSettings::default());
4631 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4632 assert_eq!(
4633 actions,
4634 vec![
4635 Action::WriteArtifact {
4636 kind: ArtifactKind::FolderJpg,
4637 path: "c/al/folder.jpg".to_string(),
4638 source_url: "art-a".to_string(),
4639 hash: art_url_hash("art-a"),
4640 owner_id: "root".to_string(),
4641 content: None,
4642 },
4643 Action::WriteArtifact {
4644 kind: ArtifactKind::FolderWebp,
4645 path: "c/al/cover.webp".to_string(),
4646 source_url: "vid-a".to_string(),
4647 hash: webp_art_hash("vid-a", &WebpEncodeSettings::default()),
4648 owner_id: "root".to_string(),
4649 content: None,
4650 },
4651 ]
4652 );
4653 }
4654
4655 #[test]
4656 fn plan_skips_when_hash_and_path_match() {
4657 let members = vec![album_member(
4658 album_clip("a", 1, "t0", "art-a", ""),
4659 "root",
4660 "c/al/a.flac",
4661 )];
4662 let desired = album_desired(&members, false, false, WebpEncodeSettings::default());
4663 let mut albums = BTreeMap::new();
4664 albums.insert(
4665 "root".to_string(),
4666 AlbumArt {
4667 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4668 folder_webp: None,
4669 folder_mp4: None,
4670 },
4671 );
4672 assert!(plan_album_artifacts(&desired, &albums, true, &HashMap::new()).is_empty());
4673 }
4674
4675 #[test]
4676 fn plan_rewrites_when_path_drifts_even_if_hash_matches() {
4677 let members = vec![album_member(
4678 album_clip("a", 1, "t0", "art-a", ""),
4679 "root",
4680 "c/al/a.flac",
4681 )];
4682 let desired = album_desired(&members, false, false, WebpEncodeSettings::default());
4683 let mut albums = BTreeMap::new();
4684 albums.insert(
4685 "root".to_string(),
4686 AlbumArt {
4687 folder_jpg: Some(stored("old/folder.jpg", &art_url_hash("art-a"))),
4688 folder_webp: None,
4689 folder_mp4: None,
4690 },
4691 );
4692 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4693 assert_eq!(actions.len(), 1);
4694 assert!(matches!(
4695 &actions[0],
4696 Action::WriteArtifact { path, .. } if path == "c/al/folder.jpg"
4697 ));
4698 }
4699
4700 #[test]
4701 fn h1_most_played_flip_to_same_art_writes_nothing() {
4702 let run1 = vec![
4704 album_member(
4705 album_clip("a", 9, "t0", "same-art", ""),
4706 "root",
4707 "c/al/a.flac",
4708 ),
4709 album_member(
4710 album_clip("b", 1, "t1", "same-art", ""),
4711 "root",
4712 "c/al/b.flac",
4713 ),
4714 ];
4715 let desired1 = album_desired(&run1, false, false, WebpEncodeSettings::default());
4716 let write1 = plan_album_artifacts(&desired1, &BTreeMap::new(), true, &HashMap::new());
4717 assert_eq!(write1.len(), 1);
4718
4719 let mut albums = BTreeMap::new();
4721 if let Action::WriteArtifact {
4722 path,
4723 hash,
4724 owner_id,
4725 ..
4726 } = &write1[0]
4727 {
4728 albums.insert(
4729 owner_id.clone(),
4730 AlbumArt {
4731 folder_jpg: Some(stored(path, hash)),
4732 folder_webp: None,
4733 folder_mp4: None,
4734 },
4735 );
4736 }
4737
4738 let run2 = vec![
4740 album_member(
4741 album_clip("a", 1, "t0", "same-art", ""),
4742 "root",
4743 "c/al/a.flac",
4744 ),
4745 album_member(
4746 album_clip("b", 9, "t1", "same-art", ""),
4747 "root",
4748 "c/al/b.flac",
4749 ),
4750 ];
4751 let desired2 = album_desired(&run2, false, false, WebpEncodeSettings::default());
4752 assert!(plan_album_artifacts(&desired2, &albums, true, &HashMap::new()).is_empty());
4754 }
4755
4756 #[test]
4757 fn h1_flip_to_different_art_writes_exactly_one() {
4758 let mut albums = BTreeMap::new();
4759 albums.insert(
4760 "root".to_string(),
4761 AlbumArt {
4762 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("old-art"))),
4763 folder_webp: None,
4764 folder_mp4: None,
4765 },
4766 );
4767 let members = vec![
4769 album_member(
4770 album_clip("a", 1, "t0", "old-art", ""),
4771 "root",
4772 "c/al/a.flac",
4773 ),
4774 album_member(
4775 album_clip("b", 9, "t1", "new-art", ""),
4776 "root",
4777 "c/al/b.flac",
4778 ),
4779 ];
4780 let desired = album_desired(&members, false, false, WebpEncodeSettings::default());
4781 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4782 assert_eq!(actions.len(), 1);
4783 assert!(matches!(
4784 &actions[0],
4785 Action::WriteArtifact { hash, .. } if *hash == art_url_hash("new-art")
4786 ));
4787 }
4788
4789 #[test]
4790 fn one_write_per_album_regardless_of_clip_count() {
4791 let members: Vec<Desired> = (0..200)
4792 .map(|i| {
4793 album_member(
4794 album_clip(
4795 &format!("clip-{i:03}"),
4796 i as u64,
4797 &format!("t{i:03}"),
4798 &format!("art-{i:03}"),
4799 &format!("vid-{i:03}"),
4800 ),
4801 "root",
4802 &format!("c/al/clip-{i:03}.flac"),
4803 )
4804 })
4805 .collect();
4806 let desired = album_desired(&members, true, false, WebpEncodeSettings::default());
4807 assert_eq!(desired.len(), 1);
4808 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4809 assert_eq!(actions.len(), 2);
4811 assert_eq!(
4812 actions
4813 .iter()
4814 .filter(|a| matches!(a, Action::WriteArtifact { .. }))
4815 .count(),
4816 2
4817 );
4818 }
4819
4820 #[test]
4821 fn emptied_album_deletes_only_when_can_delete() {
4822 let mut albums = BTreeMap::new();
4823 albums.insert(
4824 "root".to_string(),
4825 AlbumArt {
4826 folder_jpg: Some(stored("c/al/folder.jpg", "h")),
4827 folder_webp: Some(stored("c/al/cover.webp", "hw")),
4828 folder_mp4: Some(stored("c/al/cover.mp4", "hm")),
4829 },
4830 );
4831 let desired: Vec<AlbumDesired> = Vec::new();
4833
4834 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4836
4837 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4839 assert_eq!(
4840 actions,
4841 vec![
4842 Action::DeleteArtifact {
4843 kind: ArtifactKind::FolderJpg,
4844 path: "c/al/folder.jpg".to_string(),
4845 owner_id: "root".to_string(),
4846 },
4847 Action::DeleteArtifact {
4848 kind: ArtifactKind::FolderWebp,
4849 path: "c/al/cover.webp".to_string(),
4850 owner_id: "root".to_string(),
4851 },
4852 Action::DeleteArtifact {
4853 kind: ArtifactKind::FolderMp4,
4854 path: "c/al/cover.mp4".to_string(),
4855 owner_id: "root".to_string(),
4856 },
4857 ]
4858 );
4859 }
4860
4861 #[test]
4862 fn disappeared_webp_source_deletes_only_that_kind_when_gated() {
4863 let mut albums = BTreeMap::new();
4864 albums.insert(
4865 "root".to_string(),
4866 AlbumArt {
4867 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4868 folder_webp: Some(stored("c/al/cover.webp", &art_url_hash("vid-a"))),
4869 folder_mp4: None,
4870 },
4871 );
4872 let members = vec![album_member(
4875 album_clip("a", 1, "t0", "art-a", "vid-a"),
4876 "root",
4877 "c/al/a.flac",
4878 )];
4879 let desired = album_desired(&members, false, false, WebpEncodeSettings::default());
4880
4881 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4882
4883 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4884 assert_eq!(
4885 actions,
4886 vec![Action::DeleteArtifact {
4887 kind: ArtifactKind::FolderWebp,
4888 path: "c/al/cover.webp".to_string(),
4889 owner_id: "root".to_string(),
4890 }]
4891 );
4892 }
4893
4894 #[test]
4895 fn disappeared_raw_cover_deletes_only_that_kind_when_gated() {
4896 let mut albums = BTreeMap::new();
4897 albums.insert(
4898 "root".to_string(),
4899 AlbumArt {
4900 folder_jpg: Some(stored("c/al/folder.jpg", &art_url_hash("art-a"))),
4901 folder_webp: Some(stored(
4902 "c/al/cover.webp",
4903 &webp_art_hash("vid-a", &WebpEncodeSettings::default()),
4904 )),
4905 folder_mp4: Some(stored("c/al/cover.mp4", &art_url_hash("vid-a"))),
4906 },
4907 );
4908 let members = vec![album_member(
4911 album_clip("a", 1, "t0", "art-a", "vid-a"),
4912 "root",
4913 "c/al/a.flac",
4914 )];
4915 let desired = album_desired(&members, true, false, WebpEncodeSettings::default());
4916
4917 assert!(plan_album_artifacts(&desired, &albums, false, &HashMap::new()).is_empty());
4919
4920 let actions = plan_album_artifacts(&desired, &albums, true, &HashMap::new());
4922 assert_eq!(
4923 actions,
4924 vec![Action::DeleteArtifact {
4925 kind: ArtifactKind::FolderMp4,
4926 path: "c/al/cover.mp4".to_string(),
4927 owner_id: "root".to_string(),
4928 }]
4929 );
4930 }
4931
4932 #[test]
4933 fn plan_album_artifacts_is_deterministically_ordered() {
4934 let members = vec![
4935 album_member(
4936 album_clip("a", 1, "t0", "art-a", "vid-a"),
4937 "r2",
4938 "c/al2/a.flac",
4939 ),
4940 album_member(
4941 album_clip("b", 1, "t0", "art-b", "vid-b"),
4942 "r1",
4943 "c/al1/b.flac",
4944 ),
4945 ];
4946 let desired = album_desired(&members, true, true, WebpEncodeSettings::default());
4947 let actions = plan_album_artifacts(&desired, &BTreeMap::new(), true, &HashMap::new());
4948 let keys: Vec<(&str, ArtifactKind)> = actions
4949 .iter()
4950 .map(|a| match a {
4951 Action::WriteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
4952 _ => unreachable!(),
4953 })
4954 .collect();
4955 assert_eq!(
4956 keys,
4957 vec![
4958 ("r1", ArtifactKind::FolderJpg),
4959 ("r1", ArtifactKind::FolderWebp),
4960 ("r1", ArtifactKind::FolderMp4),
4961 ("r2", ArtifactKind::FolderJpg),
4962 ("r2", ArtifactKind::FolderWebp),
4963 ("r2", ArtifactKind::FolderMp4),
4964 ]
4965 );
4966 }
4967
4968 fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
4971 PlaylistDesired {
4972 id: id.to_owned(),
4973 name: name.to_owned(),
4974 path: path.to_owned(),
4975 content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
4976 hash: hash.to_owned(),
4977 }
4978 }
4979
4980 fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
4981 PlaylistState {
4982 name: name.to_owned(),
4983 path: path.to_owned(),
4984 hash: hash.to_owned(),
4985 }
4986 }
4987
4988 fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
4989 entries
4990 .iter()
4991 .map(|(id, state)| ((*id).to_owned(), state.clone()))
4992 .collect()
4993 }
4994
4995 #[test]
4996 fn playlist_write_emitted_for_a_new_playlist() {
4997 let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
4998 let actions =
4999 plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true, &HashMap::new());
5000 assert_eq!(
5001 actions,
5002 vec![Action::WriteArtifact {
5003 kind: ArtifactKind::Playlist,
5004 path: "Road Trip.m3u8".to_owned(),
5005 source_url: String::new(),
5006 hash: "h1".to_owned(),
5007 owner_id: "pl1".to_owned(),
5008 content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
5009 }]
5010 );
5011 }
5012
5013 #[test]
5014 fn playlist_write_emitted_when_hash_changes() {
5015 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
5018 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
5019 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
5020 assert_eq!(actions.len(), 1);
5021 assert!(matches!(
5022 &actions[0],
5023 Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
5024 ));
5025 }
5026
5027 #[test]
5028 fn playlist_unchanged_is_idempotent() {
5029 let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
5030 let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
5031 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
5032 assert!(actions.is_empty(), "an unchanged playlist plans nothing");
5033 }
5034
5035 #[test]
5036 fn playlist_rename_writes_new_and_deletes_old_path() {
5037 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
5040 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
5041 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
5042 assert_eq!(
5043 actions,
5044 vec![
5045 Action::WriteArtifact {
5046 kind: ArtifactKind::Playlist,
5047 path: "Summer.m3u8".to_owned(),
5048 source_url: String::new(),
5049 hash: "h2".to_owned(),
5050 owner_id: "pl1".to_owned(),
5051 content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
5052 },
5053 Action::DeleteArtifact {
5054 kind: ArtifactKind::Playlist,
5055 path: "Spring.m3u8".to_owned(),
5056 owner_id: "pl1".to_owned(),
5057 },
5058 ]
5059 );
5060 }
5061
5062 #[test]
5063 fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
5064 let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
5067 let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
5068 let actions = plan_playlist_artifacts(&desired, &stored, false, true, &HashMap::new());
5069 assert_eq!(actions.len(), 1);
5070 assert!(matches!(
5071 &actions[0],
5072 Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
5073 ));
5074 assert!(
5075 !actions
5076 .iter()
5077 .any(|a| matches!(a, Action::DeleteArtifact { .. })),
5078 "old path must not be deleted when deletes are disallowed"
5079 );
5080 }
5081
5082 #[test]
5083 fn playlist_stale_removed_only_under_full_gate() {
5084 let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
5087
5088 let deleted = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
5089 assert_eq!(
5090 deleted,
5091 vec![Action::DeleteArtifact {
5092 kind: ArtifactKind::Playlist,
5093 path: "Gone.m3u8".to_owned(),
5094 owner_id: "gone".to_owned(),
5095 }]
5096 );
5097
5098 assert!(plan_playlist_artifacts(&[], &stored, false, true, &HashMap::new()).is_empty());
5100 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
5101 assert!(plan_playlist_artifacts(&[], &stored, false, false, &HashMap::new()).is_empty());
5102 }
5103
5104 #[test]
5105 fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
5106 let stored = pl_store(&[
5111 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
5112 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
5113 ]);
5114 let actions = plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new());
5115 assert!(
5116 actions.is_empty(),
5117 "a failed playlist listing must plan zero actions, got {actions:?}"
5118 );
5119 }
5120
5121 #[test]
5122 fn b2_empty_list_deletes_only_when_fully_enumerated() {
5123 let stored = pl_store(&[
5128 ("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
5129 ("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
5130 ]);
5131
5132 assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
5134
5135 let wiped = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
5138 assert_eq!(
5139 wiped
5140 .iter()
5141 .filter(|a| matches!(a, Action::DeleteArtifact { .. }))
5142 .count(),
5143 2
5144 );
5145 }
5146
5147 #[test]
5148 fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
5149 let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
5154 let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
5155 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
5156 assert_eq!(actions.len(), 1);
5158 assert!(matches!(
5159 &actions[0],
5160 Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
5161 ));
5162 assert!(
5163 !actions.iter().any(|a| match a {
5164 Action::WriteArtifact { owner_id, .. }
5165 | Action::DeleteArtifact { owner_id, .. } => owner_id == "pl_fail",
5166 _ => false,
5167 }),
5168 "a protected (failed-member) playlist must have no action"
5169 );
5170 }
5171
5172 #[test]
5173 fn playlist_rename_collision_downgrades_the_delete() {
5174 let desired = vec![
5180 pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
5181 pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
5182 ];
5183 let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
5184 let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
5185 let write_paths: BTreeSet<&str> = actions
5187 .iter()
5188 .filter_map(|a| match a {
5189 Action::WriteArtifact { path, .. } => Some(path.as_str()),
5190 _ => None,
5191 })
5192 .collect();
5193 for a in &actions {
5194 if let Action::DeleteArtifact { path, .. } = a {
5195 assert!(
5196 !write_paths.contains(path.as_str()),
5197 "a playlist delete aliases a write target: {path}"
5198 );
5199 }
5200 }
5201 }
5202
5203 fn dstem(key: &str, path: &str, hash: &str) -> DesiredStem {
5206 DesiredStem {
5207 key: key.to_string(),
5208 stem_id: key.to_string(),
5209 path: path.to_string(),
5210 source_url: format!("https://cdn1.suno.ai/{key}.mp3"),
5211 format: StemFormat::Mp3,
5212 hash: hash.to_string(),
5213 }
5214 }
5215
5216 fn stem_desired(id: &str, stems: Option<Vec<DesiredStem>>) -> Desired {
5218 Desired {
5219 stems,
5220 ..desired(id, &format!("{id}.flac"), AudioFormat::Flac, "m", "art")
5221 }
5222 }
5223
5224 fn entry_with_stems(id: &str, stems: &[(&str, &str, &str)]) -> ManifestEntry {
5226 let mut e = entry(&format!("{id}.flac"), AudioFormat::Flac, "m", "art");
5227 for (key, path, hash) in stems {
5228 e.stems.insert(
5229 key.to_string(),
5230 ArtifactState {
5231 path: path.to_string(),
5232 hash: hash.to_string(),
5233 },
5234 );
5235 }
5236 e
5237 }
5238
5239 fn stem_writes(plan: &Plan) -> Vec<(&str, &str)> {
5240 plan.actions
5241 .iter()
5242 .filter_map(|a| match a {
5243 Action::WriteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
5244 _ => None,
5245 })
5246 .collect()
5247 }
5248
5249 fn stem_deletes(plan: &Plan) -> Vec<(&str, &str)> {
5250 plan.actions
5251 .iter()
5252 .filter_map(|a| match a {
5253 Action::DeleteStem { key, path, .. } => Some((key.as_str(), path.as_str())),
5254 _ => None,
5255 })
5256 .collect()
5257 }
5258
5259 #[test]
5260 fn stems_none_keeps_every_existing_stem() {
5261 let mut manifest = Manifest::new();
5264 manifest.insert(
5265 "a",
5266 entry_with_stems(
5267 "a",
5268 &[
5269 ("voc", "a.stems/voc.mp3", "h1"),
5270 ("drm", "a.stems/drm.mp3", "h2"),
5271 ],
5272 ),
5273 );
5274 let d = vec![stem_desired("a", None)];
5275 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5276 assert_eq!(plan.stem_writes(), 0);
5277 assert_eq!(plan.stem_deletes(), 0);
5278 }
5279
5280 #[test]
5281 fn stems_authoritative_writes_missing_stems() {
5282 let mut manifest = Manifest::new();
5283 manifest.insert("a", entry("a.flac", AudioFormat::Flac, "m", "art"));
5284 let d = vec![stem_desired(
5285 "a",
5286 Some(vec![
5287 dstem("voc", "a.stems/voc.mp3", "h1"),
5288 dstem("drm", "a.stems/drm.mp3", "h2"),
5289 ]),
5290 )];
5291 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5292 assert_eq!(
5293 stem_writes(&plan),
5294 vec![("voc", "a.stems/voc.mp3"), ("drm", "a.stems/drm.mp3")]
5295 );
5296 assert_eq!(plan.stem_deletes(), 0);
5297 }
5298
5299 #[test]
5300 fn stems_authoritative_rewrites_only_on_hash_or_path_drift() {
5301 let mut manifest = Manifest::new();
5302 manifest.insert(
5304 "a",
5305 entry_with_stems(
5306 "a",
5307 &[
5308 ("voc", "a.stems/voc.mp3", "h1"),
5309 ("drm", "a.stems/drm.mp3", "h2"),
5310 ("bas", "old.stems/bas.mp3", "h3"),
5311 ],
5312 ),
5313 );
5314 let d = vec![stem_desired(
5315 "a",
5316 Some(vec![
5317 dstem("voc", "a.stems/voc.mp3", "h1"), dstem("drm", "a.stems/drm.mp3", "h2-new"), dstem("bas", "a.stems/bas.mp3", "h3"), ]),
5321 )];
5322 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5323 assert_eq!(
5324 stem_writes(&plan),
5325 vec![("drm", "a.stems/drm.mp3"), ("bas", "a.stems/bas.mp3")]
5326 );
5327 assert_eq!(plan.stem_deletes(), 0);
5328 }
5329
5330 #[test]
5331 fn stems_authoritative_removes_a_stem_absent_from_the_set() {
5332 let mut manifest = Manifest::new();
5335 manifest.insert(
5336 "a",
5337 entry_with_stems(
5338 "a",
5339 &[
5340 ("voc", "a.stems/voc.mp3", "h1"),
5341 ("drm", "a.stems/drm.mp3", "h2"),
5342 ],
5343 ),
5344 );
5345 let d = vec![stem_desired(
5346 "a",
5347 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
5348 )];
5349 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5350 assert_eq!(plan.stem_writes(), 0);
5351 assert_eq!(stem_deletes(&plan), vec![("drm", "a.stems/drm.mp3")]);
5352 }
5353
5354 #[test]
5355 fn stems_removal_needs_deletion_allowed() {
5356 let mut manifest = Manifest::new();
5359 manifest.insert(
5360 "a",
5361 entry_with_stems(
5362 "a",
5363 &[
5364 ("voc", "a.stems/voc.mp3", "h1"),
5365 ("drm", "a.stems/drm.mp3", "h2"),
5366 ],
5367 ),
5368 );
5369 let d = vec![stem_desired(
5370 "a",
5371 Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]),
5372 )];
5373
5374 let incomplete = vec![SourceStatus {
5375 mode: SourceMode::Mirror,
5376 fully_enumerated: false,
5377 }];
5378 assert_eq!(
5379 reconcile(&manifest, &d, &local_present("a"), &incomplete).stem_deletes(),
5380 0
5381 );
5382
5383 let copy_only = vec![SourceStatus {
5384 mode: SourceMode::Copy,
5385 fully_enumerated: true,
5386 }];
5387 assert_eq!(
5388 reconcile(&manifest, &d, &local_present("a"), ©_only).stem_deletes(),
5389 0
5390 );
5391 }
5392
5393 #[test]
5394 fn stems_removal_skipped_for_preserved_or_protected_clip() {
5395 let mut manifest = Manifest::new();
5396 let mut e = entry_with_stems(
5397 "a",
5398 &[
5399 ("voc", "a.stems/voc.mp3", "h1"),
5400 ("drm", "a.stems/drm.mp3", "h2"),
5401 ],
5402 );
5403 e.preserve = true;
5404 manifest.insert("a", e);
5405 let authoritative = Some(vec![dstem("voc", "a.stems/voc.mp3", "h1")]);
5406
5407 let d = vec![stem_desired("a", authoritative.clone())];
5409 assert_eq!(
5410 reconcile(&manifest, &d, &local_present("a"), &mirror_ok()).stem_deletes(),
5411 0
5412 );
5413
5414 let mut manifest2 = Manifest::new();
5416 manifest2.insert(
5417 "a",
5418 entry_with_stems(
5419 "a",
5420 &[
5421 ("voc", "a.stems/voc.mp3", "h1"),
5422 ("drm", "a.stems/drm.mp3", "h2"),
5423 ],
5424 ),
5425 );
5426 let held = Desired {
5427 modes: vec![SourceMode::Mirror, SourceMode::Copy],
5428 stems: authoritative,
5429 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
5430 };
5431 assert_eq!(
5432 reconcile(&manifest2, &[held], &local_present("a"), &mirror_ok()).stem_deletes(),
5433 0
5434 );
5435 }
5436
5437 #[test]
5438 fn stems_are_co_deleted_when_the_song_is_trashed() {
5439 let mut manifest = Manifest::new();
5442 manifest.insert(
5443 "a",
5444 entry_with_stems(
5445 "a",
5446 &[
5447 ("voc", "a.stems/voc.mp3", "h1"),
5448 ("drm", "a.stems/drm.mp3", "h2"),
5449 ],
5450 ),
5451 );
5452 let trashed = Desired {
5453 trashed: true,
5454 ..desired("a", "a.flac", AudioFormat::Flac, "m", "art")
5455 };
5456 let plan = reconcile(&manifest, &[trashed], &local_present("a"), &mirror_ok());
5457 assert_eq!(plan.deletes(), 1, "the trashed audio is deleted");
5458 let mut deleted: Vec<&str> = stem_deletes(&plan).into_iter().map(|(k, _)| k).collect();
5459 deleted.sort_unstable();
5460 assert_eq!(deleted, vec!["drm", "voc"], "both stems co-deleted");
5461 }
5462
5463 #[test]
5464 fn stems_are_co_deleted_for_an_absent_clip() {
5465 let mut manifest = Manifest::new();
5466 manifest.insert(
5467 "a",
5468 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5469 );
5470 let plan = reconcile(&manifest, &[], &local_present("a"), &mirror_ok());
5472 assert_eq!(plan.deletes(), 1);
5473 assert_eq!(stem_deletes(&plan), vec![("voc", "a.stems/voc.mp3")]);
5474 }
5475
5476 #[test]
5477 fn stems_are_kept_when_absent_clip_listing_is_incomplete() {
5478 let mut manifest = Manifest::new();
5480 manifest.insert(
5481 "a",
5482 entry_with_stems("a", &[("voc", "a.stems/voc.mp3", "h1")]),
5483 );
5484 let incomplete = vec![SourceStatus {
5485 mode: SourceMode::Mirror,
5486 fully_enumerated: false,
5487 }];
5488 let plan = reconcile(&manifest, &[], &HashMap::new(), &incomplete);
5489 assert_eq!(plan.deletes(), 0);
5490 assert_eq!(plan.stem_deletes(), 0);
5491 }
5492
5493 #[test]
5494 fn stem_delete_is_suppressed_when_it_aliases_a_stem_write() {
5495 let mut manifest = Manifest::new();
5499 manifest.insert(
5500 "a",
5501 entry_with_stems("a", &[("old", "a.stems/mix.mp3", "h1")]),
5502 );
5503 let d = vec![stem_desired(
5504 "a",
5505 Some(vec![dstem("new", "a.stems/mix.mp3", "h2")]),
5506 )];
5507 let plan = reconcile(&manifest, &d, &local_present("a"), &mirror_ok());
5508 assert_eq!(stem_writes(&plan), vec![("new", "a.stems/mix.mp3")]);
5511 assert!(
5512 !plan.actions.iter().any(|a| matches!(
5513 a,
5514 Action::DeleteStem { path, .. } if path == "a.stems/mix.mp3"
5515 )),
5516 "a stem delete must never alias a stem write target"
5517 );
5518 }
5519}
5520
5521#[cfg(test)]
5534mod proptests {
5535 use super::*;
5536 use proptest::collection::{btree_map, hash_map, vec};
5537 use proptest::prelude::*;
5538 use std::collections::BTreeSet;
5539
5540 type DesiredFields = (
5541 String,
5542 AudioFormat,
5543 String,
5544 String,
5545 Vec<SourceMode>,
5546 bool,
5547 bool,
5548 );
5549
5550 fn audio_format() -> impl Strategy<Value = AudioFormat> {
5551 prop_oneof![
5552 Just(AudioFormat::Mp3),
5553 Just(AudioFormat::Flac),
5554 Just(AudioFormat::Wav),
5555 ]
5556 }
5557
5558 fn source_mode() -> impl Strategy<Value = SourceMode> {
5559 prop_oneof![Just(SourceMode::Mirror), Just(SourceMode::Copy)]
5560 }
5561
5562 fn clip_id() -> impl Strategy<Value = String> {
5565 (0u8..8).prop_map(|n| format!("c{n}"))
5566 }
5567
5568 fn small_path() -> impl Strategy<Value = String> {
5569 (0u8..6).prop_map(|n| format!("path{n}"))
5570 }
5571
5572 fn manifest_path() -> impl Strategy<Value = String> {
5575 prop_oneof![
5576 1 => Just(String::new()),
5577 6 => small_path(),
5578 ]
5579 }
5580
5581 fn small_hash() -> impl Strategy<Value = String> {
5582 (0u8..4).prop_map(|n| format!("h{n}"))
5583 }
5584
5585 fn manifest_entry() -> impl Strategy<Value = ManifestEntry> {
5586 (
5587 manifest_path(),
5588 audio_format(),
5589 small_hash(),
5590 small_hash(),
5591 0u64..4,
5592 any::<bool>(),
5593 )
5594 .prop_map(|(path, format, meta_hash, art_hash, size, preserve)| {
5595 ManifestEntry {
5596 path,
5597 format,
5598 meta_hash,
5599 art_hash,
5600 size,
5601 preserve,
5602 ..Default::default()
5603 }
5604 })
5605 }
5606
5607 fn manifest_strategy() -> impl Strategy<Value = Manifest> {
5608 btree_map(clip_id(), manifest_entry(), 0..8).prop_map(|entries| Manifest { entries })
5609 }
5610
5611 fn local_file() -> impl Strategy<Value = LocalFile> {
5612 (any::<bool>(), 0u64..4).prop_map(|(exists, size)| LocalFile { exists, size })
5613 }
5614
5615 fn local_strategy() -> impl Strategy<Value = HashMap<String, LocalFile>> {
5616 hash_map(clip_id(), local_file(), 0..8)
5617 }
5618
5619 fn source_status() -> impl Strategy<Value = SourceStatus> {
5620 (source_mode(), any::<bool>()).prop_map(|(mode, fully_enumerated)| SourceStatus {
5621 mode,
5622 fully_enumerated,
5623 })
5624 }
5625
5626 fn sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5627 vec(source_status(), 0..5)
5628 }
5629
5630 fn copy_sources_strategy() -> impl Strategy<Value = Vec<SourceStatus>> {
5631 vec(
5632 any::<bool>().prop_map(|fully_enumerated| SourceStatus {
5633 mode: SourceMode::Copy,
5634 fully_enumerated,
5635 }),
5636 1..5,
5637 )
5638 }
5639
5640 fn desired_fields() -> impl Strategy<Value = DesiredFields> {
5641 (
5642 small_path(),
5643 audio_format(),
5644 small_hash(),
5645 small_hash(),
5646 vec(source_mode(), 1..3),
5647 any::<bool>(),
5648 any::<bool>(),
5649 )
5650 }
5651
5652 fn build_desired(id: String, fields: DesiredFields) -> Desired {
5653 let (path, format, meta_hash, art_hash, modes, trashed, private) = fields;
5654 let clip = Clip {
5655 id,
5656 title: "t".to_string(),
5657 ..Default::default()
5658 };
5659 Desired {
5660 lineage: LineageContext::own_root(&clip),
5661 clip,
5662 path,
5663 format,
5664 meta_hash,
5665 art_hash,
5666 modes,
5667 trashed,
5668 private,
5669 artifacts: Vec::new(),
5670 stems: None,
5671 }
5672 }
5673
5674 fn desired_strategy() -> impl Strategy<Value = Vec<Desired>> {
5677 vec((clip_id(), desired_fields()), 0..10).prop_map(|items| {
5678 items
5679 .into_iter()
5680 .map(|(id, fields)| build_desired(id, fields))
5681 .collect()
5682 })
5683 }
5684
5685 fn desired_ids(desired: &[Desired]) -> BTreeSet<&str> {
5686 desired.iter().map(|d| d.clip.id.as_str()).collect()
5687 }
5688
5689 fn protected_ids(desired: &[Desired]) -> BTreeSet<&str> {
5692 desired
5693 .iter()
5694 .filter(|d| d.private || d.modes.contains(&SourceMode::Copy))
5695 .map(|d| d.clip.id.as_str())
5696 .collect()
5697 }
5698
5699 fn non_trashed_ids(desired: &[Desired]) -> BTreeSet<&str> {
5702 desired
5703 .iter()
5704 .filter(|d| !d.trashed)
5705 .map(|d| d.clip.id.as_str())
5706 .collect()
5707 }
5708
5709 fn delete_clip_ids(plan: &Plan) -> Vec<&str> {
5710 plan.actions
5711 .iter()
5712 .filter_map(|a| match a {
5713 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
5714 _ => None,
5715 })
5716 .collect()
5717 }
5718
5719 fn write_target_paths(plan: &Plan) -> BTreeSet<&str> {
5720 plan.actions
5721 .iter()
5722 .filter_map(|a| match a {
5723 Action::Download { path, .. } | Action::Reformat { path, .. } => {
5724 Some(path.as_str())
5725 }
5726 Action::Rename { to, .. } => Some(to.as_str()),
5727 _ => None,
5728 })
5729 .collect()
5730 }
5731
5732 proptest! {
5733 #![proptest_config(ProptestConfig {
5734 cases: 256,
5735 failure_persistence: None,
5736 ..ProptestConfig::default()
5737 })]
5738
5739 #[test]
5742 fn inv1_desired_clip_deleted_only_when_fully_trashed(
5743 manifest in manifest_strategy(),
5744 desired in desired_strategy(),
5745 local in local_strategy(),
5746 sources in sources_strategy(),
5747 ) {
5748 let plan = reconcile(&manifest, &desired, &local, &sources);
5749 let present = desired_ids(&desired);
5750 let live = non_trashed_ids(&desired);
5751 for id in delete_clip_ids(&plan) {
5752 prop_assert!(
5753 !(present.contains(id) && live.contains(id)),
5754 "deleted a desired clip with a non-trashed duplicate: {id}"
5755 );
5756 }
5757 }
5758
5759 #[test]
5763 fn inv2_no_delete_when_any_mirror_unenumerated(
5764 manifest in manifest_strategy(),
5765 desired in desired_strategy(),
5766 local in local_strategy(),
5767 mut sources in sources_strategy(),
5768 ) {
5769 sources.push(SourceStatus {
5770 mode: SourceMode::Mirror,
5771 fully_enumerated: false,
5772 });
5773 let plan = reconcile(&manifest, &desired, &local, &sources);
5774 prop_assert_eq!(plan.deletes(), 0);
5775 }
5776
5777 #[test]
5779 fn inv3_all_copy_sources_means_no_deletes(
5780 manifest in manifest_strategy(),
5781 desired in desired_strategy(),
5782 local in local_strategy(),
5783 sources in copy_sources_strategy(),
5784 ) {
5785 let plan = reconcile(&manifest, &desired, &local, &sources);
5786 prop_assert_eq!(plan.deletes(), 0);
5787 }
5788
5789 #[test]
5792 fn inv4_plan_is_deterministic(
5793 manifest in manifest_strategy(),
5794 desired in desired_strategy(),
5795 local in local_strategy(),
5796 sources in sources_strategy(),
5797 ) {
5798 let plan = reconcile(&manifest, &desired, &local, &sources);
5799
5800 let again = reconcile(&manifest, &desired, &local, &sources);
5801 prop_assert_eq!(&plan, &again);
5802
5803 let mut desired_rev = desired.clone();
5804 desired_rev.reverse();
5805 let mut sources_rev = sources.clone();
5806 sources_rev.reverse();
5807 let shuffled = reconcile(&manifest, &desired_rev, &local, &sources_rev);
5808 prop_assert_eq!(&plan, &shuffled);
5809 }
5810
5811 #[test]
5813 fn inv5_every_delete_is_in_the_manifest(
5814 manifest in manifest_strategy(),
5815 desired in desired_strategy(),
5816 local in local_strategy(),
5817 sources in sources_strategy(),
5818 ) {
5819 let plan = reconcile(&manifest, &desired, &local, &sources);
5820 for id in delete_clip_ids(&plan) {
5821 prop_assert!(manifest.contains(id), "deleted a clip absent from the manifest: {id}");
5822 }
5823 }
5824
5825 #[test]
5828 fn inv6_never_deletes_protected_clip(
5829 manifest in manifest_strategy(),
5830 desired in desired_strategy(),
5831 local in local_strategy(),
5832 sources in sources_strategy(),
5833 ) {
5834 let plan = reconcile(&manifest, &desired, &local, &sources);
5835 let protected = protected_ids(&desired);
5836 for id in delete_clip_ids(&plan) {
5837 prop_assert!(!protected.contains(id), "deleted a copy-held or private clip: {id}");
5838 let preserved = manifest.get(id).map(|e| e.preserve).unwrap_or(false);
5839 prop_assert!(!preserved, "deleted a preserve-marked clip: {id}");
5840 }
5841 }
5842
5843 #[test]
5846 fn inv7_no_delete_unless_deletion_allowed(
5847 manifest in manifest_strategy(),
5848 desired in desired_strategy(),
5849 local in local_strategy(),
5850 sources in sources_strategy(),
5851 ) {
5852 let plan = reconcile(&manifest, &desired, &local, &sources);
5853 if !deletion_allowed(&sources) {
5854 prop_assert_eq!(plan.deletes(), 0);
5855 }
5856 }
5857
5858 #[test]
5860 fn inv8_at_most_one_delete_per_clip(
5861 manifest in manifest_strategy(),
5862 desired in desired_strategy(),
5863 local in local_strategy(),
5864 sources in sources_strategy(),
5865 ) {
5866 let plan = reconcile(&manifest, &desired, &local, &sources);
5867 let ids = delete_clip_ids(&plan);
5868 let unique: BTreeSet<&str> = ids.iter().copied().collect();
5869 prop_assert_eq!(ids.len(), unique.len());
5870 }
5871
5872 #[test]
5874 fn inv9_no_delete_with_empty_path(
5875 manifest in manifest_strategy(),
5876 desired in desired_strategy(),
5877 local in local_strategy(),
5878 sources in sources_strategy(),
5879 ) {
5880 let plan = reconcile(&manifest, &desired, &local, &sources);
5881 for action in &plan.actions {
5882 if let Action::Delete { path, .. } = action {
5883 prop_assert!(!path.is_empty(), "delete with an empty path");
5884 }
5885 }
5886 }
5887
5888 #[test]
5891 fn inv10_no_delete_aliases_a_write_target(
5892 manifest in manifest_strategy(),
5893 desired in desired_strategy(),
5894 local in local_strategy(),
5895 sources in sources_strategy(),
5896 ) {
5897 let plan = reconcile(&manifest, &desired, &local, &sources);
5898 let targets = write_target_paths(&plan);
5899 for action in &plan.actions {
5900 if let Action::Delete { path, .. } = action {
5901 prop_assert!(
5902 !targets.contains(path.as_str()),
5903 "delete path {path} aliases a write target"
5904 );
5905 }
5906 }
5907 }
5908 }
5909}