1use std::collections::BTreeMap;
33use std::collections::BTreeSet;
34use std::collections::HashMap;
35use std::collections::HashSet;
36use std::sync::Mutex;
37use std::time::Duration;
38
39use futures_util::lock::Mutex as AsyncMutex;
40use futures_util::stream::{self, StreamExt};
41
42use crate::backoff::{backoff_delay, retry_after};
43use crate::client::SunoClient;
44use crate::clock::Clock;
45use crate::config::{AudioFormat, StemFormat};
46use crate::error::Error;
47use crate::ffmpeg::{Ffmpeg, WebpEncodeSettings};
48use crate::fs::Filesystem;
49use crate::graph::{AlbumArt, PlaylistState};
50use crate::http::{Http, HttpRequest};
51use crate::lineage::LineageContext;
52use crate::lyrics::AlignedLyrics;
53use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
54use crate::model::Clip;
55use crate::reconcile::{
56 Action, ArtifactKind, Desired, Plan, SourceMode, set_manifest_artifact, set_manifest_stem,
57};
58use crate::tag::{TrackMetadata, tag_flac, tag_mp3, tag_wav};
59use crate::tag_alac::tag_alac;
60
61type ClientLock<'a, C> = AsyncMutex<&'a SunoClient<C>>;
66
67#[derive(Debug, Clone)]
69pub struct ExecOptions {
70 pub max_retries: u32,
72 pub wav_poll_attempts: u32,
74 pub wav_poll_interval: Duration,
76 pub concurrency: u32,
79 pub cover_webp: WebpEncodeSettings,
81}
82
83impl Default for ExecOptions {
84 fn default() -> Self {
85 Self {
86 max_retries: 3,
87 wav_poll_attempts: 24,
88 wav_poll_interval: Duration::from_secs(5),
89 concurrency: 4,
90 cover_webp: WebpEncodeSettings::default(),
91 }
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub enum RunStatus {
98 #[default]
100 Completed,
101 AuthAborted,
103 DiskFull,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Failure {
111 pub clip_id: String,
113 pub reason: String,
115}
116
117#[derive(Debug, Clone, Default, PartialEq, Eq)]
119pub struct ExecOutcome {
120 pub downloaded: usize,
121 pub reformatted: usize,
122 pub retagged: usize,
123 pub renamed: usize,
124 pub deleted: usize,
125 pub skipped: usize,
126 pub artifacts_written: usize,
127 pub artifacts_deleted: usize,
128 pub failures: Vec<Failure>,
132 pub status: RunStatus,
134}
135
136impl ExecOutcome {
137 pub fn failed(&self) -> usize {
139 self.failures.len()
140 }
141
142 fn record(&mut self, effect: Effect) {
143 match effect {
144 Effect::Downloaded => self.downloaded += 1,
145 Effect::Reformatted => self.reformatted += 1,
146 Effect::Retagged => self.retagged += 1,
147 Effect::Renamed => self.renamed += 1,
148 Effect::Deleted => self.deleted += 1,
149 Effect::Skipped => self.skipped += 1,
150 Effect::ArtifactWritten => self.artifacts_written += 1,
151 Effect::ArtifactDeleted => self.artifacts_deleted += 1,
152 }
153 }
154}
155
156pub struct Ports<'a, H, F, G, C> {
161 pub client: &'a SunoClient<C>,
163 pub http: &'a H,
165 pub fs: &'a F,
167 pub ffmpeg: &'a G,
169 pub clock: &'a C,
171}
172
173#[allow(clippy::too_many_arguments)]
213pub async fn execute<H, F, G, C>(
214 plan: &Plan,
215 manifest: &mut Manifest,
216 albums: &mut BTreeMap<String, AlbumArt>,
217 playlists: &mut BTreeMap<String, PlaylistState>,
218 desired: &[Desired],
219 synced: &HashMap<String, AlignedLyrics>,
220 ports: Ports<'_, H, F, G, C>,
221 opts: &ExecOptions,
222) -> ExecOutcome
223where
224 H: Http,
225 F: Filesystem,
226 G: Ffmpeg,
227 C: Clock,
228{
229 let Ports {
230 client,
231 http,
232 fs,
233 ffmpeg,
234 clock,
235 } = ports;
236 let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
237 let by_path: HashMap<&str, &Desired> = desired.iter().map(|d| (d.path.as_str(), d)).collect();
238 let mut tracked_paths: HashMap<String, u32> = HashMap::new();
246 for (_, entry) in manifest.iter() {
247 for path in entry.artifact_paths() {
248 *tracked_paths.entry(path.to_owned()).or_default() += 1;
249 }
250 }
251 for art in albums.values() {
252 for state in [
253 art.folder_jpg.as_ref(),
254 art.folder_webp.as_ref(),
255 art.folder_mp4.as_ref(),
256 ]
257 .into_iter()
258 .flatten()
259 {
260 *tracked_paths.entry(state.path.clone()).or_default() += 1;
261 }
262 }
263 for playlist in playlists.values() {
264 *tracked_paths.entry(playlist.path.clone()).or_default() += 1;
265 }
266 let cover_wanted: HashSet<&str> = plan
274 .actions
275 .iter()
276 .filter_map(|action| match action {
277 Action::WriteArtifact {
278 kind: ArtifactKind::CoverJpg,
279 source_url,
280 ..
281 } if !source_url.is_empty() => Some(source_url.as_str()),
282 _ => None,
283 })
284 .collect();
285 let cover_cache: Mutex<HashMap<String, Vec<u8>>> = Mutex::new(HashMap::new());
286 let mut folder_cover_uses: HashMap<&str, u32> = HashMap::new();
291 for action in &plan.actions {
292 if let Action::WriteArtifact {
293 kind: ArtifactKind::FolderWebp | ArtifactKind::FolderMp4,
294 source_url,
295 ..
296 } = action
297 && !source_url.is_empty()
298 {
299 *folder_cover_uses.entry(source_url.as_str()).or_default() += 1;
300 }
301 }
302 let shared_cover_urls: HashSet<&str> = folder_cover_uses
303 .into_iter()
304 .filter(|(_, uses)| *uses > 1)
305 .map(|(url, _)| url)
306 .collect();
307 let ctx = Ctx {
308 http,
309 fs,
310 ffmpeg,
311 clock,
312 opts,
313 by_id: &by_id,
314 by_path: &by_path,
315 synced,
316 cover_cache: &cover_cache,
317 cover_wanted: &cover_wanted,
318 shared_cover_urls: &shared_cover_urls,
319 };
320
321 let mut outcome = ExecOutcome::default();
322 let mut committed: BTreeSet<String> = BTreeSet::new();
326
327 let client_lock = AsyncMutex::new(client);
347 let concurrency = opts.concurrency.max(1) as usize;
348 let ctx_ref = &ctx;
349 let client_lock_ref = &client_lock;
350 let pre_clip_ids: HashSet<String> = manifest.entries.keys().cloned().collect();
355 let audio_clip_ids: HashSet<&str> = plan
359 .actions
360 .iter()
361 .filter_map(|action| match action {
362 Action::Download { clip, .. } | Action::Reformat { clip, .. } => Some(clip.id.as_str()),
363 _ => None,
364 })
365 .collect();
366 let mut prepares = stream::iter(
367 plan.actions
368 .iter()
369 .filter(|action| is_prepareable(action, &pre_clip_ids, &audio_clip_ids))
370 .map(|action| async move { ctx_ref.prepare(client_lock_ref, action).await }),
371 )
372 .buffered(concurrency);
373
374 for action in &plan.actions {
375 let result = if is_prepareable(action, &pre_clip_ids, &audio_clip_ids) {
380 match prepares.next().await {
381 Some(Ok(Prepared::Audio(rendered))) => ctx.commit_audio(manifest, rendered),
382 Some(Ok(Prepared::Artifact(prepared))) => ctx.commit_artifact(
383 manifest,
384 albums,
385 playlists,
386 prepared,
387 &mut tracked_paths,
388 &committed,
389 ),
390 Some(Ok(Prepared::Stem(prepared))) => {
391 ctx.commit_stem(manifest, prepared, &mut tracked_paths, &committed)
392 }
393 Some(Err(fail)) => Err(fail),
394 None => unreachable!("buffered yields one result per prepareable action"),
395 }
396 } else {
397 ctx.apply(
398 client_lock_ref,
399 action,
400 manifest,
401 albums,
402 playlists,
403 &mut tracked_paths,
404 &committed,
405 )
406 .await
407 };
408 match result {
409 Ok(effect) => {
410 outcome.record(effect);
411 if let Some(dest) = written_path(action) {
417 committed.insert(dest.to_owned());
418 }
419 }
420 Err(fail) => {
421 let abort = abort_status(fail.class);
422 outcome.failures.push(Failure {
423 clip_id: fail.clip_id,
424 reason: fail.reason,
425 });
426 if let Some(status) = abort {
427 outcome.status = status;
433 break;
434 }
435 }
436 }
437 }
438 drop(prepares);
439
440 let _ = fs.prune_empty_dirs("");
445 outcome
446}
447
448fn is_prepareable(
467 action: &Action,
468 pre_clip_ids: &HashSet<String>,
469 audio_clip_ids: &HashSet<&str>,
470) -> bool {
471 match action {
472 Action::Download { .. } | Action::Reformat { .. } => true,
473 Action::WriteArtifact {
474 kind,
475 owner_id,
476 content: None,
477 ..
478 } => {
479 if matches!(kind, ArtifactKind::FolderWebp | ArtifactKind::FolderMp4) {
480 return false;
481 }
482 if *kind == ArtifactKind::CoverJpg && audio_clip_ids.contains(owner_id.as_str()) {
483 return false;
484 }
485 !is_per_clip_kind(*kind) || pre_clip_ids.contains(owner_id.as_str())
486 }
487 Action::WriteStem { clip_id, .. } => pre_clip_ids.contains(clip_id.as_str()),
488 _ => false,
489 }
490}
491
492fn written_path(action: &Action) -> Option<&str> {
497 match action {
498 Action::Download { path, .. }
499 | Action::Reformat { path, .. }
500 | Action::WriteArtifact { path, .. }
501 | Action::WriteStem { path, .. } => Some(path),
502 Action::Rename { to, .. }
503 | Action::MoveArtifact { to, .. }
504 | Action::MoveStem { to, .. } => Some(to),
505 _ => None,
506 }
507}
508
509struct RenderedAudio {
514 clip_id: String,
515 path: String,
516 format: AudioFormat,
517 from_path: Option<String>,
520 effect: Effect,
521 bytes: Vec<u8>,
522}
523
524struct PreparedArtifact {
529 kind: ArtifactKind,
530 path: String,
531 hash: String,
532 owner_id: String,
533 bytes: Vec<u8>,
534}
535
536struct PreparedStem {
541 clip_id: String,
542 key: String,
543 path: String,
544 hash: String,
545 bytes: Vec<u8>,
546}
547
548enum Prepared {
550 Audio(RenderedAudio),
551 Artifact(PreparedArtifact),
552 Stem(PreparedStem),
553}
554
555enum Effect {
557 Downloaded,
558 Reformatted,
559 Retagged,
560 Renamed,
561 Deleted,
562 Skipped,
563 ArtifactWritten,
564 ArtifactDeleted,
565}
566
567#[derive(Debug, Clone, Copy)]
569enum Class {
570 Auth,
572 Disk,
576 Transient,
578 Permanent,
580}
581
582struct Fail {
584 class: Class,
585 clip_id: String,
586 reason: String,
587}
588
589fn abort_status(class: Class) -> Option<RunStatus> {
592 match class {
593 Class::Auth => Some(RunStatus::AuthAborted),
594 Class::Disk => Some(RunStatus::DiskFull),
595 Class::Transient | Class::Permanent => None,
596 }
597}
598
599fn auth_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
600 Fail {
601 class: Class::Auth,
602 clip_id: clip_id.into(),
603 reason: reason.into(),
604 }
605}
606
607fn transient_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
608 Fail {
609 class: Class::Transient,
610 clip_id: clip_id.into(),
611 reason: reason.into(),
612 }
613}
614
615fn permanent_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
616 Fail {
617 class: Class::Permanent,
618 clip_id: clip_id.into(),
619 reason: reason.into(),
620 }
621}
622
623fn disk_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
624 Fail {
625 class: Class::Disk,
626 clip_id: clip_id.into(),
627 reason: reason.into(),
628 }
629}
630
631fn is_album_kind(kind: ArtifactKind) -> bool {
635 matches!(
636 kind,
637 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::FolderMp4
638 )
639}
640
641fn is_playlist_kind(kind: ArtifactKind) -> bool {
643 matches!(kind, ArtifactKind::Playlist)
644}
645
646fn is_per_clip_kind(kind: ArtifactKind) -> bool {
650 matches!(
651 kind,
652 ArtifactKind::CoverJpg
653 | ArtifactKind::CoverWebp
654 | ArtifactKind::DetailsTxt
655 | ArtifactKind::LyricsTxt
656 | ArtifactKind::Lrc
657 | ArtifactKind::VideoMp4
658 )
659}
660
661fn playlist_name_from_path(path: &str) -> String {
668 std::path::Path::new(path)
669 .file_stem()
670 .map(|stem| stem.to_string_lossy().into_owned())
671 .unwrap_or_default()
672}
673
674struct FetchError {
676 class: Class,
677 reason: String,
678 retry_after: Option<Duration>,
679}
680
681impl FetchError {
682 fn transient(reason: impl Into<String>, retry_after: Option<Duration>) -> Self {
683 Self {
684 class: Class::Transient,
685 reason: reason.into(),
686 retry_after,
687 }
688 }
689
690 fn permanent(reason: impl Into<String>) -> Self {
691 Self {
692 class: Class::Permanent,
693 reason: reason.into(),
694 retry_after: None,
695 }
696 }
697
698 fn attribute(self, clip_id: &str) -> Fail {
699 Fail {
700 class: self.class,
701 clip_id: clip_id.to_owned(),
702 reason: self.reason,
703 }
704 }
705}
706
707struct Ctx<'a, H, F, G, C> {
709 http: &'a H,
710 fs: &'a F,
711 ffmpeg: &'a G,
712 clock: &'a C,
713 opts: &'a ExecOptions,
714 by_id: &'a HashMap<&'a str, &'a Desired>,
715 by_path: &'a HashMap<&'a str, &'a Desired>,
716 synced: &'a HashMap<String, AlignedLyrics>,
721 cover_cache: &'a Mutex<HashMap<String, Vec<u8>>>,
729 cover_wanted: &'a HashSet<&'a str>,
733 shared_cover_urls: &'a HashSet<&'a str>,
740}
741
742impl<H, F, G, C> Ctx<'_, H, F, G, C>
743where
744 H: Http,
745 F: Filesystem,
746 G: Ffmpeg,
747 C: Clock,
748{
749 #[allow(clippy::too_many_arguments)]
758 async fn apply(
759 &self,
760 client_lock: &ClientLock<'_, C>,
761 action: &Action,
762 manifest: &mut Manifest,
763 albums: &mut BTreeMap<String, AlbumArt>,
764 playlists: &mut BTreeMap<String, PlaylistState>,
765 tracked_paths: &mut HashMap<String, u32>,
766 committed: &BTreeSet<String>,
767 ) -> Result<Effect, Fail> {
768 match action {
769 Action::Download { .. } | Action::Reformat { .. } => {
770 unreachable!("audio actions are prepared concurrently")
771 }
772 Action::Retag {
773 clip,
774 lineage,
775 path,
776 } => self.retag(manifest, clip, lineage, path).await,
777 Action::Rename { from, to } => self.rename(manifest, from, to),
778 Action::Delete { path, clip_id } => self.delete(manifest, path, clip_id),
779 Action::Skip { clip_id } => {
780 self.refresh_preserve(manifest, clip_id);
781 Ok(Effect::Skipped)
782 }
783 Action::WriteArtifact {
784 kind,
785 path,
786 source_url,
787 hash,
788 owner_id,
789 content,
790 } => {
791 let bytes = match content.as_deref() {
797 Some(text) => text.as_bytes().to_vec(),
798 None => {
799 if is_per_clip_kind(*kind) && manifest.get(owner_id).is_none() {
800 self.cover_cache_lock().remove(source_url);
804 return Ok(Effect::Skipped);
805 }
806 self.artifact_bytes(*kind, source_url, owner_id).await?
807 }
808 };
809 self.commit_artifact(
810 manifest,
811 albums,
812 playlists,
813 PreparedArtifact {
814 kind: *kind,
815 path: path.clone(),
816 hash: hash.clone(),
817 owner_id: owner_id.clone(),
818 bytes,
819 },
820 tracked_paths,
821 committed,
822 )
823 }
824 Action::DeleteArtifact {
825 kind,
826 path,
827 owner_id,
828 } => self.delete_artifact(manifest, albums, playlists, *kind, path, owner_id),
829 Action::MoveArtifact {
830 kind,
831 from,
832 to,
833 source_url,
834 hash,
835 owner_id,
836 } => {
837 self.move_artifact(
838 manifest,
839 albums,
840 playlists,
841 *kind,
842 from,
843 to,
844 source_url,
845 hash,
846 owner_id,
847 tracked_paths,
848 committed,
849 )
850 .await
851 }
852 Action::WriteStem {
853 clip_id,
854 key,
855 stem_id,
856 path,
857 source_url,
858 format,
859 hash,
860 } => {
861 if manifest.get(clip_id).is_none() {
867 return Ok(Effect::Skipped);
868 }
869 let bytes = self
870 .fetch_stem_bytes(client_lock, clip_id, stem_id, source_url, *format)
871 .await?;
872 self.commit_stem(
873 manifest,
874 PreparedStem {
875 clip_id: clip_id.clone(),
876 key: key.clone(),
877 path: path.clone(),
878 hash: hash.clone(),
879 bytes,
880 },
881 tracked_paths,
882 committed,
883 )
884 }
885 Action::DeleteStem { clip_id, key, path } => {
886 self.delete_stem(manifest, clip_id, key, path)
887 }
888 Action::MoveStem {
889 clip_id,
890 key,
891 stem_id,
892 from,
893 to,
894 source_url,
895 format,
896 hash,
897 } => {
898 self.move_stem(
899 client_lock,
900 manifest,
901 clip_id,
902 key,
903 stem_id,
904 from,
905 to,
906 source_url,
907 *format,
908 hash,
909 tracked_paths,
910 committed,
911 )
912 .await
913 }
914 }
915 }
916
917 async fn prepare_audio(
926 &self,
927 client_lock: &ClientLock<'_, C>,
928 action: &Action,
929 ) -> Result<RenderedAudio, Fail> {
930 match action {
931 Action::Download {
932 clip,
933 lineage,
934 path,
935 format,
936 } => {
937 let bytes = self
938 .produce_audio(client_lock, clip, lineage, *format)
939 .await?;
940 Ok(RenderedAudio {
941 clip_id: clip.id.clone(),
942 path: path.clone(),
943 format: *format,
944 from_path: None,
945 effect: Effect::Downloaded,
946 bytes,
947 })
948 }
949 Action::Reformat {
950 clip,
951 path,
952 from_path,
953 from: _,
954 to,
955 } => {
956 let lineage = self
961 .by_id
962 .get(clip.id.as_str())
963 .map(|d| d.lineage.clone())
964 .unwrap_or_else(|| LineageContext::own_root(clip));
965 let bytes = self.produce_audio(client_lock, clip, &lineage, *to).await?;
966 Ok(RenderedAudio {
967 clip_id: clip.id.clone(),
968 path: path.clone(),
969 format: *to,
970 from_path: Some(from_path.clone()),
971 effect: Effect::Reformatted,
972 bytes,
973 })
974 }
975 _ => unreachable!("prepare_audio only handles audio actions"),
976 }
977 }
978
979 fn commit_audio(
987 &self,
988 manifest: &mut Manifest,
989 rendered: RenderedAudio,
990 ) -> Result<Effect, Fail> {
991 let RenderedAudio {
992 clip_id,
993 path,
994 format,
995 from_path,
996 effect,
997 bytes,
998 } = rendered;
999 let size = self.write_verify(&clip_id, &path, &bytes)?;
1000 if let Some(from) = from_path {
1001 self.fs.remove(&from).map_err(|err| {
1003 permanent_fail(&clip_id, format!("could not remove old file: {err}"))
1004 })?;
1005 }
1006 manifest.insert(clip_id.clone(), self.entry(&clip_id, &path, format, size));
1007 Ok(effect)
1008 }
1009
1010 fn cover_cache_lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Vec<u8>>> {
1012 self.cover_cache.lock().expect("cover cache mutex poisoned")
1013 }
1014
1015 async fn prepare(
1019 &self,
1020 client_lock: &ClientLock<'_, C>,
1021 action: &Action,
1022 ) -> Result<Prepared, Fail> {
1023 match action {
1024 Action::Download { .. } | Action::Reformat { .. } => self
1025 .prepare_audio(client_lock, action)
1026 .await
1027 .map(Prepared::Audio),
1028 Action::WriteArtifact {
1029 kind,
1030 path,
1031 source_url,
1032 hash,
1033 owner_id,
1034 content: None,
1035 } => {
1036 let bytes = self.artifact_bytes(*kind, source_url, owner_id).await?;
1037 Ok(Prepared::Artifact(PreparedArtifact {
1038 kind: *kind,
1039 path: path.clone(),
1040 hash: hash.clone(),
1041 owner_id: owner_id.clone(),
1042 bytes,
1043 }))
1044 }
1045 Action::WriteStem {
1046 clip_id,
1047 key,
1048 stem_id,
1049 path,
1050 source_url,
1051 format,
1052 hash,
1053 } => {
1054 let bytes = self
1055 .fetch_stem_bytes(client_lock, clip_id, stem_id, source_url, *format)
1056 .await?;
1057 Ok(Prepared::Stem(PreparedStem {
1058 clip_id: clip_id.clone(),
1059 key: key.clone(),
1060 path: path.clone(),
1061 hash: hash.clone(),
1062 bytes,
1063 }))
1064 }
1065 _ => unreachable!("prepare only handles prepareable actions"),
1066 }
1067 }
1068
1069 fn commit_artifact(
1081 &self,
1082 manifest: &mut Manifest,
1083 albums: &mut BTreeMap<String, AlbumArt>,
1084 playlists: &mut BTreeMap<String, PlaylistState>,
1085 prepared: PreparedArtifact,
1086 tracked_paths: &mut HashMap<String, u32>,
1087 committed: &BTreeSet<String>,
1088 ) -> Result<Effect, Fail> {
1089 let PreparedArtifact {
1090 kind,
1091 path,
1092 hash,
1093 owner_id,
1094 bytes,
1095 } = prepared;
1096 if is_per_clip_kind(kind) && manifest.get(&owner_id).is_none() {
1097 return Ok(Effect::Skipped);
1098 }
1099 let old_path = match kind {
1100 ArtifactKind::CoverJpg => manifest
1101 .get(&owner_id)
1102 .and_then(|e| e.cover_jpg.as_ref())
1103 .map(|s| s.path.clone()),
1104 ArtifactKind::CoverWebp => manifest
1105 .get(&owner_id)
1106 .and_then(|e| e.cover_webp.as_ref())
1107 .map(|s| s.path.clone()),
1108 ArtifactKind::DetailsTxt => manifest
1109 .get(&owner_id)
1110 .and_then(|e| e.details_txt.as_ref())
1111 .map(|s| s.path.clone()),
1112 ArtifactKind::LyricsTxt => manifest
1113 .get(&owner_id)
1114 .and_then(|e| e.lyrics_txt.as_ref())
1115 .map(|s| s.path.clone()),
1116 ArtifactKind::Lrc => manifest
1117 .get(&owner_id)
1118 .and_then(|e| e.lrc.as_ref())
1119 .map(|s| s.path.clone()),
1120 ArtifactKind::VideoMp4 => manifest
1121 .get(&owner_id)
1122 .and_then(|e| e.video_mp4.as_ref())
1123 .map(|s| s.path.clone()),
1124 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::FolderMp4 => albums
1125 .get(&owner_id)
1126 .and_then(|a| a.artifact(kind))
1127 .map(|s| s.path.clone()),
1128 ArtifactKind::Playlist => None,
1129 };
1130 self.write_verify(&owner_id, &path, &bytes)?;
1131 if let Some(old) = old_path.as_deref()
1132 && !old.is_empty()
1133 && old != path
1134 {
1135 let still_referenced = tracked_paths
1136 .get_mut(old)
1137 .map(|count| {
1138 *count = count.saturating_sub(1);
1139 *count > 0
1140 })
1141 .unwrap_or(false);
1142 if !still_referenced && !committed.contains(old) {
1143 self.fs.remove(old).map_err(|err| {
1144 permanent_fail(
1145 &owner_id,
1146 format!("could not remove old sidecar {old}: {err}"),
1147 )
1148 })?;
1149 }
1150 }
1151 if is_album_kind(kind) {
1152 albums.entry(owner_id.to_owned()).or_default().set(
1153 kind,
1154 Some(ArtifactState {
1155 path: path.to_owned(),
1156 hash: hash.to_owned(),
1157 }),
1158 );
1159 } else if is_playlist_kind(kind) {
1160 playlists.insert(
1161 owner_id.to_owned(),
1162 PlaylistState {
1163 name: playlist_name_from_path(&path),
1164 path: path.to_owned(),
1165 hash: hash.to_owned(),
1166 },
1167 );
1168 } else if let Some(entry) = manifest.entries.get_mut(&owner_id) {
1169 set_manifest_artifact(
1170 entry,
1171 kind,
1172 Some(ArtifactState {
1173 path: path.to_owned(),
1174 hash: hash.to_owned(),
1175 }),
1176 );
1177 }
1178 Ok(Effect::ArtifactWritten)
1179 }
1180
1181 fn commit_stem(
1190 &self,
1191 manifest: &mut Manifest,
1192 prepared: PreparedStem,
1193 tracked_paths: &mut HashMap<String, u32>,
1194 committed: &BTreeSet<String>,
1195 ) -> Result<Effect, Fail> {
1196 let PreparedStem {
1197 clip_id,
1198 key,
1199 path,
1200 hash,
1201 bytes,
1202 } = prepared;
1203 if manifest.get(&clip_id).is_none() {
1204 return Ok(Effect::Skipped);
1205 }
1206 let old_path = manifest
1207 .get(&clip_id)
1208 .and_then(|e| e.stems.get(&key))
1209 .map(|s| s.path.clone());
1210 self.write_verify(&clip_id, &path, &bytes)?;
1211 if let Some(old) = old_path.as_deref()
1212 && !old.is_empty()
1213 && old != path
1214 {
1215 let still_referenced = tracked_paths
1216 .get_mut(old)
1217 .map(|count| {
1218 *count = count.saturating_sub(1);
1219 *count > 0
1220 })
1221 .unwrap_or(false);
1222 if !still_referenced && !committed.contains(old) {
1223 self.fs.remove(old).map_err(|err| {
1224 permanent_fail(&clip_id, format!("could not remove old stem {old}: {err}"))
1225 })?;
1226 }
1227 }
1228 if let Some(entry) = manifest.entries.get_mut(&clip_id) {
1229 set_manifest_stem(
1230 entry,
1231 &key,
1232 Some(ArtifactState {
1233 path: path.to_owned(),
1234 hash: hash.to_owned(),
1235 }),
1236 );
1237 }
1238 Ok(Effect::ArtifactWritten)
1239 }
1240
1241 async fn retag(
1243 &self,
1244 manifest: &mut Manifest,
1245 clip: &Clip,
1246 lineage: &LineageContext,
1247 path: &str,
1248 ) -> Result<Effect, Fail> {
1249 let Some(format) = manifest.get(&clip.id).map(|entry| entry.format) else {
1250 return Err(permanent_fail(
1251 &clip.id,
1252 "retag target missing from manifest",
1253 ));
1254 };
1255
1256 if format == AudioFormat::Wav {
1257 let (meta, synced) = self.track_meta(clip, lineage);
1258 let cover = self.fetch_cover(clip).await;
1259 let existing = self.fs.read(path).map_err(|err| {
1260 permanent_fail(&clip.id, format!("could not read for retag: {err}"))
1261 })?;
1262 let tagged = tag_wav(&existing, &meta, cover.as_deref(), synced)
1263 .map_err(|err| permanent_fail(&clip.id, err.to_string()))?;
1264 let size = self.write_verify(&clip.id, path, &tagged)?;
1265 self.refresh_hashes(manifest, &clip.id, Some(size));
1266 return Ok(Effect::Retagged);
1267 }
1268
1269 let (meta, synced) = self.track_meta(clip, lineage);
1270 let cover = self.fetch_cover(clip).await;
1271 let existing = self
1272 .fs
1273 .read(path)
1274 .map_err(|err| permanent_fail(&clip.id, format!("could not read for retag: {err}")))?;
1275 let tagged = match format {
1276 AudioFormat::Mp3 => tag_mp3(&existing, &meta, cover.as_deref(), synced),
1277 AudioFormat::Flac => tag_flac(&existing, &meta, cover.as_deref()),
1278 AudioFormat::Alac => tag_alac(&existing, &meta, cover.as_deref()),
1279 AudioFormat::Wav => unreachable!("WAV handled above"),
1280 }
1281 .map_err(|err| permanent_fail(&clip.id, err.to_string()))?;
1282 let size = self.write_verify(&clip.id, path, &tagged)?;
1283 self.refresh_hashes(manifest, &clip.id, Some(size));
1284 Ok(Effect::Retagged)
1285 }
1286
1287 fn rename(&self, manifest: &mut Manifest, from: &str, to: &str) -> Result<Effect, Fail> {
1289 let label = self
1290 .by_path
1291 .get(to)
1292 .map(|d| d.clip.id.clone())
1293 .unwrap_or_else(|| to.to_owned());
1294 self.fs.rename(from, to).map_err(|err| {
1295 if err.is_out_of_space() {
1296 disk_fail(label, "disk full: no space left to rename")
1297 } else {
1298 permanent_fail(label, format!("rename failed: {err}"))
1299 }
1300 })?;
1301
1302 let clip_id = self.by_path.get(to).map(|d| d.clip.id.clone()).or_else(|| {
1303 manifest
1304 .entries
1305 .iter()
1306 .find(|(_, entry)| entry.path == from)
1307 .map(|(id, _)| id.clone())
1308 });
1309 if let Some(id) = clip_id
1310 && let Some(entry) = manifest.entries.get_mut(&id)
1311 {
1312 entry.path = to.to_owned();
1313 if let Some(d) = self.by_path.get(to) {
1314 entry.preserve = preserve_for(d);
1315 }
1316 }
1317 Ok(Effect::Renamed)
1318 }
1319
1320 fn delete(&self, manifest: &mut Manifest, path: &str, clip_id: &str) -> Result<Effect, Fail> {
1322 self.fs
1323 .remove(path)
1324 .map_err(|err| permanent_fail(clip_id, format!("delete failed: {err}")))?;
1325 manifest.remove(clip_id);
1326 Ok(Effect::Deleted)
1327 }
1328
1329 #[allow(clippy::too_many_arguments)]
1341 async fn move_artifact(
1342 &self,
1343 manifest: &mut Manifest,
1344 albums: &mut BTreeMap<String, AlbumArt>,
1345 playlists: &mut BTreeMap<String, PlaylistState>,
1346 kind: ArtifactKind,
1347 from: &str,
1348 to: &str,
1349 source_url: &str,
1350 hash: &str,
1351 owner_id: &str,
1352 tracked_paths: &mut HashMap<String, u32>,
1353 committed: &BTreeSet<String>,
1354 ) -> Result<Effect, Fail> {
1355 if is_per_clip_kind(kind) && manifest.get(owner_id).is_none() {
1357 return Ok(Effect::Skipped);
1358 }
1359 let exclusive =
1365 tracked_paths.get(from).is_none_or(|count| *count <= 1) && !committed.contains(from);
1366 if from != to && exclusive {
1367 match self.fs.rename(from, to) {
1368 Ok(()) => {
1369 if let Some(count) = tracked_paths.get_mut(from) {
1370 *count = count.saturating_sub(1);
1371 }
1372 if let Some(entry) = manifest.entries.get_mut(owner_id) {
1373 set_manifest_artifact(
1374 entry,
1375 kind,
1376 Some(ArtifactState {
1377 path: to.to_owned(),
1378 hash: hash.to_owned(),
1379 }),
1380 );
1381 }
1382 return Ok(Effect::Renamed);
1383 }
1384 Err(err) if err.is_out_of_space() => {
1385 return Err(disk_fail(
1386 owner_id,
1387 "disk full: no space left to move sidecar",
1388 ));
1389 }
1390 Err(_) => {}
1393 }
1394 }
1395 let bytes = self.artifact_bytes(kind, source_url, owner_id).await?;
1396 self.commit_artifact(
1397 manifest,
1398 albums,
1399 playlists,
1400 PreparedArtifact {
1401 kind,
1402 path: to.to_owned(),
1403 hash: hash.to_owned(),
1404 owner_id: owner_id.to_owned(),
1405 bytes,
1406 },
1407 tracked_paths,
1408 committed,
1409 )
1410 }
1411 async fn artifact_bytes(
1422 &self,
1423 kind: ArtifactKind,
1424 source_url: &str,
1425 owner_id: &str,
1426 ) -> Result<Vec<u8>, Fail> {
1427 let cached = self.cover_cache_lock().remove(source_url);
1431 let source = match cached {
1432 Some(bytes) => bytes,
1433 None => {
1434 let fetched = self
1435 .fetch_bytes(source_url)
1436 .await
1437 .map_err(|err| err.attribute(owner_id))?;
1438 if self.shared_cover_urls.contains(source_url) {
1443 self.cover_cache_lock()
1444 .insert(source_url.to_owned(), fetched.clone());
1445 }
1446 fetched
1447 }
1448 };
1449 match kind {
1450 ArtifactKind::CoverWebp | ArtifactKind::FolderWebp => self
1451 .ffmpeg
1452 .mp4_to_webp(&source, self.opts.cover_webp)
1453 .await
1454 .map_err(|err| {
1455 if err.is_out_of_space() {
1456 disk_fail(owner_id, "disk full: no space left to transcode")
1457 } else {
1458 permanent_fail(owner_id, format!("cover transcode failed: {err}"))
1459 }
1460 }),
1461 ArtifactKind::DetailsTxt | ArtifactKind::LyricsTxt | ArtifactKind::Lrc => Err(
1465 permanent_fail(owner_id, "text sidecar requires inline content"),
1466 ),
1467 ArtifactKind::CoverJpg
1468 | ArtifactKind::FolderJpg
1469 | ArtifactKind::FolderMp4
1470 | ArtifactKind::Playlist
1471 | ArtifactKind::VideoMp4 => Ok(source),
1472 }
1473 }
1474
1475 fn delete_artifact(
1490 &self,
1491 manifest: &mut Manifest,
1492 albums: &mut BTreeMap<String, AlbumArt>,
1493 playlists: &mut BTreeMap<String, PlaylistState>,
1494 kind: ArtifactKind,
1495 path: &str,
1496 owner_id: &str,
1497 ) -> Result<Effect, Fail> {
1498 self.fs
1499 .remove(path)
1500 .map_err(|err| permanent_fail(owner_id, format!("artifact delete failed: {err}")))?;
1501 if is_album_kind(kind) {
1502 if let Some(art) = albums.get_mut(owner_id) {
1503 art.set(kind, None);
1504 if art.is_empty() {
1505 albums.remove(owner_id);
1506 }
1507 }
1508 } else if is_playlist_kind(kind) {
1509 playlists.remove(owner_id);
1510 } else if let Some(entry) = manifest.entries.get_mut(owner_id) {
1511 set_manifest_artifact(entry, kind, None);
1512 }
1513 Ok(Effect::ArtifactDeleted)
1514 }
1515
1516 #[allow(clippy::too_many_arguments)]
1528 async fn move_stem(
1529 &self,
1530 client_lock: &ClientLock<'_, C>,
1531 manifest: &mut Manifest,
1532 clip_id: &str,
1533 key: &str,
1534 stem_id: &str,
1535 from: &str,
1536 to: &str,
1537 source_url: &str,
1538 format: StemFormat,
1539 hash: &str,
1540 tracked_paths: &mut HashMap<String, u32>,
1541 committed: &BTreeSet<String>,
1542 ) -> Result<Effect, Fail> {
1543 if manifest.get(clip_id).is_none() {
1544 return Ok(Effect::Skipped);
1545 }
1546 let exclusive =
1547 tracked_paths.get(from).is_none_or(|count| *count <= 1) && !committed.contains(from);
1548 if from != to && exclusive {
1549 match self.fs.rename(from, to) {
1550 Ok(()) => {
1551 if let Some(count) = tracked_paths.get_mut(from) {
1552 *count = count.saturating_sub(1);
1553 }
1554 if let Some(entry) = manifest.entries.get_mut(clip_id) {
1555 set_manifest_stem(
1556 entry,
1557 key,
1558 Some(ArtifactState {
1559 path: to.to_owned(),
1560 hash: hash.to_owned(),
1561 }),
1562 );
1563 }
1564 return Ok(Effect::Renamed);
1565 }
1566 Err(err) if err.is_out_of_space() => {
1567 return Err(disk_fail(clip_id, "disk full: no space left to move stem"));
1568 }
1569 Err(_) => {}
1572 }
1573 }
1574 let bytes = self
1575 .fetch_stem_bytes(client_lock, clip_id, stem_id, source_url, format)
1576 .await?;
1577 self.commit_stem(
1578 manifest,
1579 PreparedStem {
1580 clip_id: clip_id.to_owned(),
1581 key: key.to_owned(),
1582 path: to.to_owned(),
1583 hash: hash.to_owned(),
1584 bytes,
1585 },
1586 tracked_paths,
1587 committed,
1588 )
1589 }
1590
1591 async fn fetch_stem_bytes(
1601 &self,
1602 client_lock: &ClientLock<'_, C>,
1603 clip_id: &str,
1604 stem_id: &str,
1605 source_url: &str,
1606 format: StemFormat,
1607 ) -> Result<Vec<u8>, Fail> {
1608 let url = match format {
1609 StemFormat::Wav if !stem_id.is_empty() => {
1610 match self.resolve_wav_url(client_lock, stem_id).await? {
1611 Some(url) => url,
1612 None => return Err(transient_fail(clip_id, "stem WAV render was not ready")),
1613 }
1614 }
1615 _ => source_url.to_owned(),
1617 };
1618 self.fetch_bytes(&url)
1619 .await
1620 .map_err(|err| err.attribute(clip_id))
1621 }
1622
1623 fn delete_stem(
1630 &self,
1631 manifest: &mut Manifest,
1632 clip_id: &str,
1633 key: &str,
1634 path: &str,
1635 ) -> Result<Effect, Fail> {
1636 self.fs
1637 .remove(path)
1638 .map_err(|err| permanent_fail(clip_id, format!("stem delete failed: {err}")))?;
1639 if let Some(entry) = manifest.entries.get_mut(clip_id) {
1640 set_manifest_stem(entry, key, None);
1641 }
1642 Ok(Effect::ArtifactDeleted)
1643 }
1644
1645 async fn produce_audio(
1647 &self,
1648 client_lock: &ClientLock<'_, C>,
1649 clip: &Clip,
1650 lineage: &LineageContext,
1651 format: AudioFormat,
1652 ) -> Result<Vec<u8>, Fail> {
1653 let (meta, synced) = self.track_meta(clip, lineage);
1654 match format {
1655 AudioFormat::Mp3 => {
1656 let url = clip.mp3_url();
1657 let audio = self
1658 .fetch_bytes(&url)
1659 .await
1660 .map_err(|err| err.attribute(&clip.id))?;
1661 let cover = self.fetch_cover(clip).await;
1662 tag_mp3(&audio, &meta, cover.as_deref(), synced)
1663 .map_err(|err| permanent_fail(&clip.id, err.to_string()))
1664 }
1665 AudioFormat::Flac | AudioFormat::Alac => {
1666 let wav = self.fetch_wav(client_lock, clip).await?;
1667 let audio = self
1668 .ffmpeg
1669 .wav_to_lossless(&wav, format)
1670 .await
1671 .map_err(|err| {
1672 if err.is_out_of_space() {
1673 disk_fail(&clip.id, "disk full: no space left to transcode")
1674 } else {
1675 permanent_fail(&clip.id, format!("transcode failed: {err}"))
1676 }
1677 })?;
1678 let cover = self.fetch_cover(clip).await;
1679 let tagged = match format {
1680 AudioFormat::Alac => tag_alac(&audio, &meta, cover.as_deref()),
1681 _ => tag_flac(&audio, &meta, cover.as_deref()),
1682 };
1683 tagged.map_err(|err| permanent_fail(&clip.id, err.to_string()))
1684 }
1685 AudioFormat::Wav => {
1686 let wav = self.fetch_wav(client_lock, clip).await?;
1687 let cover = self.fetch_cover(clip).await;
1688 tag_wav(&wav, &meta, cover.as_deref(), synced)
1689 .map_err(|err| permanent_fail(&clip.id, err.to_string()))
1690 }
1691 }
1692 }
1693
1694 fn synced_for(&self, clip_id: &str) -> Option<&AlignedLyrics> {
1696 self.synced
1697 .get(clip_id)
1698 .filter(|aligned| !aligned.is_empty())
1699 }
1700
1701 fn track_meta<'m>(
1708 &'m self,
1709 clip: &Clip,
1710 lineage: &LineageContext,
1711 ) -> (TrackMetadata, Option<&'m AlignedLyrics>) {
1712 let synced = self.synced_for(&clip.id);
1713 let mut meta = TrackMetadata::from_clip(clip, lineage);
1714 if let Some(aligned) = synced {
1715 meta.lyrics = aligned.plain_text();
1716 }
1717 (meta, synced)
1718 }
1719
1720 async fn fetch_wav(
1722 &self,
1723 client_lock: &ClientLock<'_, C>,
1724 clip: &Clip,
1725 ) -> Result<Vec<u8>, Fail> {
1726 let url = match self.resolve_wav_url(client_lock, &clip.id).await? {
1727 Some(url) => url,
1728 None => return Err(transient_fail(&clip.id, "WAV render was not ready")),
1729 };
1730 self.fetch_bytes(&url)
1731 .await
1732 .map_err(|err| err.attribute(&clip.id))
1733 }
1734
1735 async fn resolve_wav_url(
1744 &self,
1745 client_lock: &ClientLock<'_, C>,
1746 id: &str,
1747 ) -> Result<Option<String>, Fail> {
1748 if let Some(url) = self.wav_url_retrying(client_lock, id).await? {
1749 return Ok(Some(url));
1750 }
1751 self.request_wav_retrying(client_lock, id).await?;
1752 for _ in 0..self.opts.wav_poll_attempts {
1753 self.clock.sleep(self.opts.wav_poll_interval).await;
1754 if let Some(url) = self.wav_url_retrying(client_lock, id).await? {
1755 return Ok(Some(url));
1756 }
1757 }
1758 Ok(None)
1759 }
1760
1761 async fn wav_url_retrying(
1764 &self,
1765 client_lock: &ClientLock<'_, C>,
1766 id: &str,
1767 ) -> Result<Option<String>, Fail> {
1768 let mut attempt: u32 = 0;
1769 loop {
1770 let result = {
1771 let client = client_lock.lock().await;
1772 client.wav_url(self.http, id).await
1773 };
1774 match result {
1775 Ok(url) => return Ok(url),
1776 Err(err) => match self.retry_core(id, err, &mut attempt).await {
1777 Some(fail) => return Err(fail),
1778 None => continue,
1779 },
1780 }
1781 }
1782 }
1783
1784 async fn request_wav_retrying(
1786 &self,
1787 client_lock: &ClientLock<'_, C>,
1788 id: &str,
1789 ) -> Result<(), Fail> {
1790 let mut attempt: u32 = 0;
1791 loop {
1792 let result = {
1793 let client = client_lock.lock().await;
1794 client.request_wav(self.http, id).await
1795 };
1796 match result {
1797 Ok(()) => return Ok(()),
1798 Err(err) => match self.retry_core(id, err, &mut attempt).await {
1799 Some(fail) => return Err(fail),
1800 None => continue,
1801 },
1802 }
1803 }
1804 }
1805
1806 async fn retry_core(&self, id: &str, err: Error, attempt: &mut u32) -> Option<Fail> {
1810 let fail = classify_core(id, err);
1811 if matches!(fail.class, Class::Transient) && *attempt < self.opts.max_retries {
1812 self.clock.sleep(backoff_delay(*attempt, None)).await;
1813 *attempt += 1;
1814 None
1815 } else {
1816 Some(fail)
1817 }
1818 }
1819
1820 async fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, FetchError> {
1822 let mut attempt: u32 = 0;
1823 loop {
1824 let result = self.http.send(HttpRequest::get(url)).await;
1825 match classify_response(result) {
1826 Ok(body) => return Ok(body),
1827 Err(err) => {
1828 if matches!(err.class, Class::Transient) && attempt < self.opts.max_retries {
1829 let delay = backoff_delay(attempt, err.retry_after);
1830 self.clock.sleep(delay).await;
1831 attempt += 1;
1832 continue;
1833 }
1834 return Err(err);
1835 }
1836 }
1837 }
1838 }
1839
1840 async fn fetch_cover(&self, clip: &Clip) -> Option<Vec<u8>> {
1842 for url in clip.cover_candidates() {
1843 if let Ok(response) = self.http.send(HttpRequest::get(url)).await
1844 && (200..=299).contains(&response.status)
1845 && !response.body.is_empty()
1846 {
1847 if self.cover_wanted.contains(url) {
1851 self.cover_cache_lock()
1852 .insert(url.to_owned(), response.body.clone());
1853 }
1854 return Some(response.body);
1855 }
1856 }
1857 None
1858 }
1859
1860 fn write_verify(&self, clip_id: &str, path: &str, bytes: &[u8]) -> Result<u64, Fail> {
1862 self.fs.write_atomic(path, bytes).map_err(|err| {
1863 if err.is_out_of_space() {
1864 disk_fail(clip_id, format!("disk full: no space left to write {path}"))
1865 } else {
1866 permanent_fail(clip_id, format!("write failed: {err}"))
1867 }
1868 })?;
1869 match self.fs.metadata(path) {
1870 Some(stat) if stat.size == bytes.len() as u64 => Ok(stat.size),
1871 Some(stat) => Err(permanent_fail(
1872 clip_id,
1873 format!("wrote {} bytes, expected {}", stat.size, bytes.len()),
1874 )),
1875 None => Ok(bytes.len() as u64),
1876 }
1877 }
1878
1879 fn entry(&self, clip_id: &str, path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
1881 match self.by_id.get(clip_id) {
1882 Some(d) => manifest_entry(d, size),
1883 None => ManifestEntry {
1884 path: path.to_owned(),
1885 format,
1886 size,
1887 ..ManifestEntry::default()
1888 },
1889 }
1890 }
1891
1892 fn refresh_hashes(&self, manifest: &mut Manifest, clip_id: &str, size: Option<u64>) {
1894 let desired = self.by_id.get(clip_id).copied();
1895 if let Some(entry) = manifest.entries.get_mut(clip_id) {
1896 if let Some(d) = desired {
1897 entry.meta_hash = d.meta_hash.clone();
1898 entry.art_hash = d.art_hash.clone();
1899 entry.preserve = preserve_for(d);
1900 }
1901 if let Some(size) = size {
1902 entry.size = size;
1903 }
1904 }
1905 }
1906
1907 fn refresh_preserve(&self, manifest: &mut Manifest, clip_id: &str) {
1914 if let Some(d) = self.by_id.get(clip_id).copied()
1915 && let Some(entry) = manifest.entries.get_mut(clip_id)
1916 {
1917 entry.preserve = preserve_for(d);
1918 }
1919 }
1920}
1921
1922fn manifest_entry(d: &Desired, size: u64) -> ManifestEntry {
1924 ManifestEntry {
1925 path: d.path.clone(),
1926 format: d.format,
1927 meta_hash: d.meta_hash.clone(),
1928 art_hash: d.art_hash.clone(),
1929 size,
1930 preserve: preserve_for(d),
1931 ..Default::default()
1932 }
1933}
1934
1935fn preserve_for(d: &Desired) -> bool {
1938 d.private || d.modes.contains(&SourceMode::Copy)
1939}
1940
1941fn classify_response(
1943 result: Result<crate::http::HttpResponse, crate::http::TransportError>,
1944) -> Result<Vec<u8>, FetchError> {
1945 let response = match result {
1946 Ok(response) => response,
1947 Err(err) => {
1948 return Err(FetchError::transient(
1949 format!("transport error: {err}"),
1950 None,
1951 ));
1952 }
1953 };
1954 match response.status {
1955 200..=299 => {
1956 if let Some(expected) = content_length(&response) {
1957 let actual = response.body.len() as u64;
1958 if actual != expected {
1959 return Err(FetchError::transient(
1960 format!("truncated download: {actual} of {expected} bytes"),
1961 None,
1962 ));
1963 }
1964 }
1965 Ok(response.body)
1966 }
1967 401 | 403 => Err(FetchError::transient(
1968 format!("download rejected: status {}", response.status),
1969 None,
1970 )),
1971 408 => Err(FetchError::transient("request timed out", None)),
1972 429 => Err(FetchError::transient(
1973 "rate limited",
1974 retry_after(&response),
1975 )),
1976 500..=599 => Err(FetchError::transient(
1977 format!("server error {}", response.status),
1978 None,
1979 )),
1980 status => Err(FetchError::permanent(format!(
1981 "download failed: status {status}"
1982 ))),
1983 }
1984}
1985
1986fn classify_core(id: &str, err: Error) -> Fail {
1988 let reason = err.to_string();
1989 match err {
1990 Error::Auth(_) => auth_fail(id, reason),
1991 Error::RateLimited { .. } | Error::Connection(_) => transient_fail(id, reason),
1992 Error::Api(_)
1993 | Error::BadRequest(_)
1994 | Error::NotFound(_)
1995 | Error::Tag(_)
1996 | Error::Config(_)
1997 | Error::Refused(_) => permanent_fail(id, reason),
1998 }
1999}
2000
2001fn content_length(response: &crate::http::HttpResponse) -> Option<u64> {
2003 response.header("content-length")?.trim().parse().ok()
2004}
2005
2006#[cfg(test)]
2007mod tests {
2008 use super::*;
2009 use crate::ClerkAuth;
2010 use crate::http::HttpResponse;
2011 use crate::testutil::{MemFs, RecordingClock, Reply, ScriptedHttp, StubFfmpeg};
2012
2013 fn clip(id: &str) -> Clip {
2014 Clip {
2015 id: id.to_owned(),
2016 title: "Song".to_owned(),
2017 audio_url: format!("https://cdn1.suno.ai/{id}.mp3"),
2018 ..Default::default()
2019 }
2020 }
2021
2022 fn art_clip(id: &str) -> Clip {
2023 Clip {
2024 image_large_url: format!("https://art.suno.ai/{id}/large.jpg"),
2025 image_url: format!("https://art.suno.ai/{id}/small.jpg"),
2026 ..clip(id)
2027 }
2028 }
2029
2030 fn desired(clip: Clip, format: AudioFormat) -> Desired {
2031 Desired {
2032 path: format!("{}.{}", clip.id, format.ext()),
2033 lineage: LineageContext::own_root(&clip),
2034 clip,
2035 format,
2036 meta_hash: "m".to_owned(),
2037 art_hash: "art".to_owned(),
2038 modes: vec![SourceMode::Mirror],
2039 trashed: false,
2040 private: false,
2041 artifacts: Vec::new(),
2042 stems: None,
2043 }
2044 }
2045
2046 fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
2047 ManifestEntry {
2048 path: path.to_owned(),
2049 format,
2050 meta_hash: "old".to_owned(),
2051 art_hash: "old-art".to_owned(),
2052 size: 8,
2053 preserve: false,
2054 ..Default::default()
2055 }
2056 }
2057
2058 #[allow(clippy::too_many_arguments)]
2059 fn run<G: Ffmpeg>(
2060 plan: &Plan,
2061 manifest: &mut Manifest,
2062 desired: &[Desired],
2063 http: &ScriptedHttp,
2064 fs: &MemFs,
2065 ffmpeg: &G,
2066 clock: &RecordingClock,
2067 opts: &ExecOptions,
2068 ) -> ExecOutcome {
2069 let mut albums = BTreeMap::new();
2070 run_with_albums(
2071 plan,
2072 manifest,
2073 &mut albums,
2074 desired,
2075 http,
2076 fs,
2077 ffmpeg,
2078 clock,
2079 opts,
2080 )
2081 }
2082
2083 #[allow(clippy::too_many_arguments)]
2084 fn run_with_albums<G: Ffmpeg>(
2085 plan: &Plan,
2086 manifest: &mut Manifest,
2087 albums: &mut BTreeMap<String, AlbumArt>,
2088 desired: &[Desired],
2089 http: &ScriptedHttp,
2090 fs: &MemFs,
2091 ffmpeg: &G,
2092 clock: &RecordingClock,
2093 opts: &ExecOptions,
2094 ) -> ExecOutcome {
2095 let mut playlists = BTreeMap::new();
2096 run_full(
2097 plan,
2098 manifest,
2099 albums,
2100 &mut playlists,
2101 desired,
2102 http,
2103 fs,
2104 ffmpeg,
2105 clock,
2106 opts,
2107 )
2108 }
2109
2110 #[allow(clippy::too_many_arguments)]
2111 fn run_full<G: Ffmpeg>(
2112 plan: &Plan,
2113 manifest: &mut Manifest,
2114 albums: &mut BTreeMap<String, AlbumArt>,
2115 playlists: &mut BTreeMap<String, PlaylistState>,
2116 desired: &[Desired],
2117 http: &ScriptedHttp,
2118 fs: &MemFs,
2119 ffmpeg: &G,
2120 clock: &RecordingClock,
2121 opts: &ExecOptions,
2122 ) -> ExecOutcome {
2123 let client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
2124 let synced = HashMap::new();
2125 pollster::block_on(execute(
2126 plan,
2127 manifest,
2128 albums,
2129 playlists,
2130 desired,
2131 &synced,
2132 Ports {
2133 client: &client,
2134 http,
2135 fs,
2136 ffmpeg,
2137 clock,
2138 },
2139 opts,
2140 ))
2141 }
2142
2143 fn small_poll() -> ExecOptions {
2144 ExecOptions {
2145 max_retries: 3,
2146 wav_poll_attempts: 2,
2147 wav_poll_interval: Duration::from_secs(5),
2148 concurrency: 4,
2149 cover_webp: WebpEncodeSettings::default(),
2150 }
2151 }
2152
2153 #[test]
2156 fn download_mp3_writes_tagged_file_and_records_manifest() {
2157 let c = art_clip("a");
2158 let d = desired(c.clone(), AudioFormat::Mp3);
2159 let plan = Plan {
2160 actions: vec![Action::Download {
2161 clip: c.clone(),
2162 lineage: LineageContext::own_root(&c),
2163 path: d.path.clone(),
2164 format: AudioFormat::Mp3,
2165 }],
2166 };
2167 let http = ScriptedHttp::new()
2168 .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
2169 .route("a/large.jpg", Reply::ok(b"art-bytes".to_vec()));
2170 let fs = MemFs::new();
2171 let ffmpeg = StubFfmpeg::flac();
2172 let clock = RecordingClock::new();
2173 let mut manifest = Manifest::new();
2174
2175 let outcome = run(
2176 &plan,
2177 &mut manifest,
2178 &[d],
2179 &http,
2180 &fs,
2181 &ffmpeg,
2182 &clock,
2183 &ExecOptions::default(),
2184 );
2185
2186 assert_eq!(outcome.downloaded, 1);
2187 assert_eq!(outcome.failed(), 0);
2188 assert_eq!(outcome.status, RunStatus::Completed);
2189 let written = fs.read_file("a.mp3").unwrap();
2190 assert_eq!(&written[..3], b"ID3");
2191 assert!(written.ends_with(b"mp3-body"));
2192 let entry = manifest.get("a").unwrap();
2193 assert_eq!(entry.path, "a.mp3");
2194 assert_eq!(entry.format, AudioFormat::Mp3);
2195 assert_eq!(entry.meta_hash, "m");
2196 assert_eq!(entry.art_hash, "art");
2197 assert_eq!(entry.size, written.len() as u64);
2198 assert!(!entry.preserve);
2199 }
2200
2201 #[test]
2202 fn download_mp3_embeds_sylt_and_lyrics_from_synced_map() {
2203 let c = art_clip("a");
2206 let d = desired(c.clone(), AudioFormat::Mp3);
2207 let plan = Plan {
2208 actions: vec![Action::Download {
2209 clip: c.clone(),
2210 lineage: LineageContext::own_root(&c),
2211 path: d.path.clone(),
2212 format: AudioFormat::Mp3,
2213 }],
2214 };
2215 let http = ScriptedHttp::new()
2216 .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
2217 .route("a/large.jpg", Reply::ok(b"art-bytes".to_vec()));
2218 let fs = MemFs::new();
2219 let ffmpeg = StubFfmpeg::flac();
2220 let clock = RecordingClock::new();
2221 let mut manifest = Manifest::new();
2222 let mut albums = BTreeMap::new();
2223 let mut playlists = BTreeMap::new();
2224 let mut synced = HashMap::new();
2225 synced.insert(
2226 "a".to_string(),
2227 AlignedLyrics::from_json(&serde_json::json!({
2228 "aligned_words": [],
2229 "aligned_lyrics": [
2230 {"text": "hi there", "start_s": 0.5, "end_s": 1.2, "section": "Verse 1",
2231 "words": [
2232 {"text": "hi", "start_s": 0.5, "end_s": 0.8},
2233 {"text": "there", "start_s": 0.9, "end_s": 1.2}
2234 ]}
2235 ]
2236 })),
2237 );
2238 let client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
2239 let outcome = pollster::block_on(execute(
2240 &plan,
2241 &mut manifest,
2242 &mut albums,
2243 &mut playlists,
2244 &[d],
2245 &synced,
2246 Ports {
2247 client: &client,
2248 http: &http,
2249 fs: &fs,
2250 ffmpeg: &ffmpeg,
2251 clock: &clock,
2252 },
2253 &ExecOptions::default(),
2254 ));
2255
2256 assert_eq!(outcome.downloaded, 1);
2257 let written = fs.read_file("a.mp3").unwrap();
2258 let tag = id3::Tag::read_from2(std::io::Cursor::new(written)).unwrap();
2259 assert_eq!(
2260 tag.synchronised_lyrics().count(),
2261 1,
2262 "a SYLT frame is embedded"
2263 );
2264 assert_eq!(
2266 tag.lyrics().next().map(|frame| frame.text.as_str()),
2267 Some("hi there")
2268 );
2269 }
2270
2271 #[test]
2272 fn download_mp3_embeds_no_sylt_when_synced_map_empty() {
2273 let c = art_clip("a");
2276 let d = desired(c.clone(), AudioFormat::Mp3);
2277 let plan = Plan {
2278 actions: vec![Action::Download {
2279 clip: c.clone(),
2280 lineage: LineageContext::own_root(&c),
2281 path: d.path.clone(),
2282 format: AudioFormat::Mp3,
2283 }],
2284 };
2285 let http = ScriptedHttp::new()
2286 .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
2287 .route("a/large.jpg", Reply::ok(b"art-bytes".to_vec()));
2288 let fs = MemFs::new();
2289 let ffmpeg = StubFfmpeg::flac();
2290 let clock = RecordingClock::new();
2291 let mut manifest = Manifest::new();
2292 let mut albums = BTreeMap::new();
2293 let mut playlists = BTreeMap::new();
2294 let client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
2295 let outcome = pollster::block_on(execute(
2296 &plan,
2297 &mut manifest,
2298 &mut albums,
2299 &mut playlists,
2300 &[d],
2301 &HashMap::new(),
2302 Ports {
2303 client: &client,
2304 http: &http,
2305 fs: &fs,
2306 ffmpeg: &ffmpeg,
2307 clock: &clock,
2308 },
2309 &ExecOptions::default(),
2310 ));
2311 assert_eq!(outcome.downloaded, 1);
2312 let written = fs.read_file("a.mp3").unwrap();
2313 let tag = id3::Tag::read_from2(std::io::Cursor::new(written)).unwrap();
2314 assert_eq!(tag.synchronised_lyrics().count(), 0);
2315 assert_eq!(tag.lyrics().count(), 0);
2316 }
2317
2318 #[test]
2319 fn download_mp3_uses_cdn_fallback_when_audio_url_empty() {
2320 let mut c = clip("a");
2321 c.audio_url = String::new();
2322 let d = desired(c.clone(), AudioFormat::Mp3);
2323 let plan = Plan {
2324 actions: vec![Action::Download {
2325 clip: c.clone(),
2326 lineage: LineageContext::own_root(&c),
2327 path: d.path.clone(),
2328 format: AudioFormat::Mp3,
2329 }],
2330 };
2331 let http = ScriptedHttp::new().route("cdn1.suno.ai/a.mp3", Reply::ok(b"body".to_vec()));
2332 let fs = MemFs::new();
2333 let mut manifest = Manifest::new();
2334 let outcome = run(
2335 &plan,
2336 &mut manifest,
2337 &[d],
2338 &http,
2339 &fs,
2340 &StubFfmpeg::flac(),
2341 &RecordingClock::new(),
2342 &ExecOptions::default(),
2343 );
2344 assert_eq!(outcome.downloaded, 1);
2345 assert_eq!(http.count("cdn1.suno.ai/a.mp3"), 1);
2346 }
2347
2348 #[test]
2351 fn download_flac_renders_transcodes_and_records() {
2352 let c = clip("b");
2353 let d = desired(c.clone(), AudioFormat::Flac);
2354 let plan = Plan {
2355 actions: vec![Action::Download {
2356 clip: c.clone(),
2357 lineage: LineageContext::own_root(&c),
2358 path: d.path.clone(),
2359 format: AudioFormat::Flac,
2360 }],
2361 };
2362 let http = ScriptedHttp::new()
2363 .with_auth()
2364 .route(
2365 "/wav_file/",
2366 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/b.wav"}"#),
2367 )
2368 .route("b.wav", Reply::ok(b"wav-bytes".to_vec()));
2369 let fs = MemFs::new();
2370 let clock = RecordingClock::new();
2371 let mut manifest = Manifest::new();
2372
2373 let outcome = run(
2374 &plan,
2375 &mut manifest,
2376 &[d],
2377 &http,
2378 &fs,
2379 &StubFfmpeg::flac(),
2380 &clock,
2381 &ExecOptions::default(),
2382 );
2383
2384 assert_eq!(outcome.downloaded, 1);
2385 assert_eq!(outcome.failed(), 0);
2386 let written = fs.read_file("b.flac").unwrap();
2387 assert_eq!(&written[..4], b"fLaC");
2388 assert_eq!(manifest.get("b").unwrap().format, AudioFormat::Flac);
2389 assert_eq!(http.count("/convert_wav/"), 0);
2391 assert!(clock.sleeps().is_empty());
2392 }
2393
2394 #[test]
2395 fn download_flac_requests_render_then_polls_until_ready() {
2396 let c = clip("c");
2397 let d = desired(c.clone(), AudioFormat::Flac);
2398 let plan = Plan {
2399 actions: vec![Action::Download {
2400 clip: c.clone(),
2401 lineage: LineageContext::own_root(&c),
2402 path: d.path.clone(),
2403 format: AudioFormat::Flac,
2404 }],
2405 };
2406 let http = ScriptedHttp::new()
2407 .with_auth()
2408 .route_seq(
2409 "/wav_file/",
2410 vec![
2411 Reply::json("{}"),
2412 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/c.wav"}"#),
2413 ],
2414 )
2415 .route("/convert_wav/", Reply::status(200))
2416 .route("c.wav", Reply::ok(b"wav".to_vec()));
2417 let clock = RecordingClock::new();
2418 let mut manifest = Manifest::new();
2419
2420 let outcome = run(
2421 &plan,
2422 &mut manifest,
2423 &[d],
2424 &http,
2425 &fs_new(),
2426 &StubFfmpeg::flac(),
2427 &clock,
2428 &small_poll(),
2429 );
2430
2431 assert_eq!(outcome.downloaded, 1);
2432 assert_eq!(http.count("/convert_wav/"), 1);
2433 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
2434 }
2435
2436 #[test]
2437 fn download_flac_unavailable_render_is_a_nonfatal_failure() {
2438 let c = clip("d");
2439 let d = desired(c.clone(), AudioFormat::Flac);
2440 let plan = Plan {
2441 actions: vec![Action::Download {
2442 clip: c.clone(),
2443 lineage: LineageContext::own_root(&c),
2444 path: d.path.clone(),
2445 format: AudioFormat::Flac,
2446 }],
2447 };
2448 let http = ScriptedHttp::new()
2449 .with_auth()
2450 .route("/wav_file/", Reply::json("{}"))
2451 .route("/convert_wav/", Reply::status(200));
2452 let fs = MemFs::new();
2453 let clock = RecordingClock::new();
2454 let mut manifest = Manifest::new();
2455
2456 let outcome = run(
2457 &plan,
2458 &mut manifest,
2459 &[d],
2460 &http,
2461 &fs,
2462 &StubFfmpeg::flac(),
2463 &clock,
2464 &small_poll(),
2465 );
2466
2467 assert_eq!(outcome.downloaded, 0);
2468 assert_eq!(outcome.failed(), 1);
2469 assert_eq!(outcome.failures[0].clip_id, "d");
2470 assert_eq!(outcome.status, RunStatus::Completed);
2471 assert!(!fs.exists("d.flac"));
2472 assert_eq!(clock.sleeps().len(), 2);
2473 }
2474
2475 #[test]
2476 fn flac_transcode_failure_is_recorded_and_skipped() {
2477 let c = clip("t");
2478 let d = desired(c.clone(), AudioFormat::Flac);
2479 let plan = Plan {
2480 actions: vec![Action::Download {
2481 clip: c.clone(),
2482 lineage: LineageContext::own_root(&c),
2483 path: d.path.clone(),
2484 format: AudioFormat::Flac,
2485 }],
2486 };
2487 let http = ScriptedHttp::new()
2488 .with_auth()
2489 .route(
2490 "/wav_file/",
2491 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/t.wav"}"#),
2492 )
2493 .route("t.wav", Reply::ok(b"wav".to_vec()));
2494 let fs = MemFs::new();
2495 let mut manifest = Manifest::new();
2496
2497 let outcome = run(
2498 &plan,
2499 &mut manifest,
2500 &[d],
2501 &http,
2502 &fs,
2503 &StubFfmpeg::failing(),
2504 &RecordingClock::new(),
2505 &ExecOptions::default(),
2506 );
2507
2508 assert_eq!(outcome.downloaded, 0);
2509 assert_eq!(outcome.failed(), 1);
2510 assert!(!fs.exists("t.flac"));
2511 assert!(manifest.get("t").is_none());
2512 }
2513
2514 #[test]
2517 fn cover_falls_back_when_large_image_is_missing() {
2518 let c = art_clip("e");
2519 let d = desired(c.clone(), AudioFormat::Mp3);
2520 let plan = Plan {
2521 actions: vec![Action::Download {
2522 clip: c.clone(),
2523 lineage: LineageContext::own_root(&c),
2524 path: d.path.clone(),
2525 format: AudioFormat::Mp3,
2526 }],
2527 };
2528 let http = ScriptedHttp::new()
2529 .route("e.mp3", Reply::ok(b"body".to_vec()))
2530 .route("e/large.jpg", Reply::status(404))
2531 .route("e/small.jpg", Reply::ok(b"the-art".to_vec()));
2532 let fs = MemFs::new();
2533 let mut manifest = Manifest::new();
2534
2535 let outcome = run(
2536 &plan,
2537 &mut manifest,
2538 &[d],
2539 &http,
2540 &fs,
2541 &StubFfmpeg::flac(),
2542 &RecordingClock::new(),
2543 &ExecOptions::default(),
2544 );
2545
2546 assert_eq!(outcome.downloaded, 1);
2547 let calls = http.calls();
2548 let large = calls
2549 .iter()
2550 .position(|u| u.contains("e/large.jpg"))
2551 .unwrap();
2552 let small = calls
2553 .iter()
2554 .position(|u| u.contains("e/small.jpg"))
2555 .unwrap();
2556 assert!(large < small, "large art tried before small");
2557 }
2558
2559 #[test]
2562 fn download_reuses_the_embedded_cover_for_the_jpg_sidecar() {
2563 let c = art_clip("a");
2566 let d = desired(c.clone(), AudioFormat::Mp3);
2567 let plan = Plan {
2568 actions: vec![
2569 Action::Download {
2570 clip: c.clone(),
2571 lineage: LineageContext::own_root(&c),
2572 path: d.path.clone(),
2573 format: AudioFormat::Mp3,
2574 },
2575 Action::WriteArtifact {
2576 kind: ArtifactKind::CoverJpg,
2577 path: "a/cover.jpg".to_owned(),
2578 source_url: c.selected_image_url().unwrap().to_owned(),
2579 hash: "art".to_owned(),
2580 owner_id: "a".to_owned(),
2581 content: None,
2582 },
2583 ],
2584 };
2585 let http = ScriptedHttp::new()
2586 .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
2587 .route("a/large.jpg", Reply::ok(b"the-art".to_vec()));
2588 let fs = MemFs::new();
2589 let mut manifest = Manifest::new();
2590
2591 let outcome = run(
2592 &plan,
2593 &mut manifest,
2594 &[d],
2595 &http,
2596 &fs,
2597 &StubFfmpeg::flac(),
2598 &RecordingClock::new(),
2599 &ExecOptions::default(),
2600 );
2601
2602 assert_eq!(outcome.downloaded, 1);
2603 assert_eq!(outcome.artifacts_written, 1);
2604 assert_eq!(outcome.failed(), 0);
2605 assert_eq!(http.count("a/large.jpg"), 1);
2607 assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"the-art");
2609 assert_eq!(&fs.read_file("a.mp3").unwrap()[..3], b"ID3");
2610 }
2611
2612 #[test]
2613 fn concurrent_downloads_reuse_each_clips_own_cover() {
2614 let a = art_clip("a");
2617 let b = art_clip("b");
2618 let da = desired(a.clone(), AudioFormat::Mp3);
2619 let db = desired(b.clone(), AudioFormat::Mp3);
2620 let plan = Plan {
2621 actions: vec![
2622 Action::Download {
2623 clip: a.clone(),
2624 lineage: LineageContext::own_root(&a),
2625 path: da.path.clone(),
2626 format: AudioFormat::Mp3,
2627 },
2628 Action::WriteArtifact {
2629 kind: ArtifactKind::CoverJpg,
2630 path: "a/cover.jpg".to_owned(),
2631 source_url: a.selected_image_url().unwrap().to_owned(),
2632 hash: "art".to_owned(),
2633 owner_id: "a".to_owned(),
2634 content: None,
2635 },
2636 Action::Download {
2637 clip: b.clone(),
2638 lineage: LineageContext::own_root(&b),
2639 path: db.path.clone(),
2640 format: AudioFormat::Mp3,
2641 },
2642 Action::WriteArtifact {
2643 kind: ArtifactKind::CoverJpg,
2644 path: "b/cover.jpg".to_owned(),
2645 source_url: b.selected_image_url().unwrap().to_owned(),
2646 hash: "art".to_owned(),
2647 owner_id: "b".to_owned(),
2648 content: None,
2649 },
2650 ],
2651 };
2652 let http = ScriptedHttp::new()
2653 .route("a.mp3", Reply::ok(b"a-mp3".to_vec()))
2654 .route("b.mp3", Reply::ok(b"b-mp3".to_vec()))
2655 .route("a/large.jpg", Reply::ok(b"art-a".to_vec()))
2656 .route("b/large.jpg", Reply::ok(b"art-b".to_vec()));
2657 let fs = MemFs::new();
2658 let mut manifest = Manifest::new();
2659
2660 let outcome = run(
2661 &plan,
2662 &mut manifest,
2663 &[da, db],
2664 &http,
2665 &fs,
2666 &StubFfmpeg::flac(),
2667 &RecordingClock::new(),
2668 &small_poll(),
2669 );
2670
2671 assert_eq!(outcome.downloaded, 2);
2672 assert_eq!(outcome.artifacts_written, 2);
2673 assert_eq!(http.count("a/large.jpg"), 1);
2674 assert_eq!(http.count("b/large.jpg"), 1);
2675 assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"art-a");
2676 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"art-b");
2677 }
2678
2679 #[test]
2680 fn cover_sidecar_refetches_when_embed_fell_back_to_another_url() {
2681 let c = art_clip("e");
2686 let d = desired(c.clone(), AudioFormat::Mp3);
2687 let plan = Plan {
2688 actions: vec![
2689 Action::Download {
2690 clip: c.clone(),
2691 lineage: LineageContext::own_root(&c),
2692 path: d.path.clone(),
2693 format: AudioFormat::Mp3,
2694 },
2695 Action::WriteArtifact {
2696 kind: ArtifactKind::CoverJpg,
2697 path: "e/cover.jpg".to_owned(),
2698 source_url: "https://art.suno.ai/e/large.jpg".to_owned(),
2699 hash: "art".to_owned(),
2700 owner_id: "e".to_owned(),
2701 content: None,
2702 },
2703 ],
2704 };
2705 let http = ScriptedHttp::new()
2706 .route("e.mp3", Reply::ok(b"body".to_vec()))
2707 .route("e/large.jpg", Reply::status(404))
2708 .route("e/small.jpg", Reply::ok(b"small-art".to_vec()));
2709 let fs = MemFs::new();
2710 let mut manifest = Manifest::new();
2711
2712 let outcome = run(
2713 &plan,
2714 &mut manifest,
2715 &[d],
2716 &http,
2717 &fs,
2718 &StubFfmpeg::flac(),
2719 &RecordingClock::new(),
2720 &ExecOptions::default(),
2721 );
2722
2723 assert_eq!(outcome.downloaded, 1);
2724 assert_eq!(http.count("e/small.jpg"), 1);
2727 assert!(
2728 http.count("e/large.jpg") >= 2,
2729 "sidecar refetched the large URL"
2730 );
2731 assert_eq!(manifest.get("e").unwrap().cover_jpg, None);
2732 assert!(!fs.exists("e/cover.jpg"));
2733 }
2734
2735 #[test]
2738 fn failed_write_leaves_the_prior_file_intact() {
2739 let c = clip("f");
2740 let d = desired(c.clone(), AudioFormat::Mp3);
2741 let plan = Plan {
2742 actions: vec![Action::Download {
2743 clip: c.clone(),
2744 lineage: LineageContext::own_root(&c),
2745 path: d.path.clone(),
2746 format: AudioFormat::Mp3,
2747 }],
2748 };
2749 let http = ScriptedHttp::new().route("f.mp3", Reply::ok(b"new-body".to_vec()));
2750 let fs = MemFs::new()
2751 .with_file("f.mp3", b"OLD-CONTENT".to_vec())
2752 .fail_write("f.mp3");
2753 let mut manifest = Manifest::new();
2754
2755 let outcome = run(
2756 &plan,
2757 &mut manifest,
2758 &[d],
2759 &http,
2760 &fs,
2761 &StubFfmpeg::flac(),
2762 &RecordingClock::new(),
2763 &ExecOptions::default(),
2764 );
2765
2766 assert_eq!(outcome.downloaded, 0);
2767 assert_eq!(outcome.failed(), 1);
2768 assert_eq!(fs.read_file("f.mp3").unwrap(), b"OLD-CONTENT");
2769 assert!(manifest.get("f").is_none());
2770 }
2771
2772 #[test]
2773 fn size_mismatch_after_write_is_a_failure() {
2774 let c = clip("g");
2775 let d = desired(c.clone(), AudioFormat::Mp3);
2776 let plan = Plan {
2777 actions: vec![Action::Download {
2778 clip: c.clone(),
2779 lineage: LineageContext::own_root(&c),
2780 path: d.path.clone(),
2781 format: AudioFormat::Mp3,
2782 }],
2783 };
2784 let http = ScriptedHttp::new().route("g.mp3", Reply::ok(b"body".to_vec()));
2785 let fs = MemFs::new().corrupt_write("g.mp3");
2786 let mut manifest = Manifest::new();
2787
2788 let outcome = run(
2789 &plan,
2790 &mut manifest,
2791 &[d],
2792 &http,
2793 &fs,
2794 &StubFfmpeg::flac(),
2795 &RecordingClock::new(),
2796 &ExecOptions::default(),
2797 );
2798
2799 assert_eq!(outcome.downloaded, 0);
2800 assert_eq!(outcome.failed(), 1);
2801 assert!(outcome.failures[0].reason.contains("expected"));
2802 assert!(manifest.get("g").is_none());
2803 }
2804
2805 #[test]
2808 fn transient_failure_is_retried_then_skipped() {
2809 let c = clip("h");
2810 let d = desired(c.clone(), AudioFormat::Mp3);
2811 let plan = Plan {
2812 actions: vec![Action::Download {
2813 clip: c.clone(),
2814 lineage: LineageContext::own_root(&c),
2815 path: d.path.clone(),
2816 format: AudioFormat::Mp3,
2817 }],
2818 };
2819 let http = ScriptedHttp::new().route("h.mp3", Reply::status(500));
2820 let fs = MemFs::new();
2821 let clock = RecordingClock::new();
2822 let opts = ExecOptions {
2823 max_retries: 2,
2824 ..ExecOptions::default()
2825 };
2826 let mut manifest = Manifest::new();
2827
2828 let outcome = run(
2829 &plan,
2830 &mut manifest,
2831 &[d],
2832 &http,
2833 &fs,
2834 &StubFfmpeg::flac(),
2835 &clock,
2836 &opts,
2837 );
2838
2839 assert_eq!(outcome.downloaded, 0);
2840 assert_eq!(outcome.failed(), 1);
2841 assert_eq!(http.count("h.mp3"), 3);
2842 assert_eq!(clock.sleeps().len(), 2);
2843 }
2844
2845 #[test]
2846 fn truncated_download_is_retried_then_succeeds() {
2847 let c = clip("i");
2848 let d = desired(c.clone(), AudioFormat::Mp3);
2849 let plan = Plan {
2850 actions: vec![Action::Download {
2851 clip: c.clone(),
2852 lineage: LineageContext::own_root(&c),
2853 path: d.path.clone(),
2854 format: AudioFormat::Mp3,
2855 }],
2856 };
2857 let http = ScriptedHttp::new().route_seq(
2858 "i.mp3",
2859 vec![
2860 Reply::ok(b"short".to_vec()).with_content_length(999),
2861 Reply::ok(b"good-body".to_vec()),
2862 ],
2863 );
2864 let fs = MemFs::new();
2865 let clock = RecordingClock::new();
2866 let mut manifest = Manifest::new();
2867
2868 let outcome = run(
2869 &plan,
2870 &mut manifest,
2871 &[d],
2872 &http,
2873 &fs,
2874 &StubFfmpeg::flac(),
2875 &clock,
2876 &ExecOptions::default(),
2877 );
2878
2879 assert_eq!(outcome.downloaded, 1);
2880 assert_eq!(http.count("i.mp3"), 2);
2881 assert_eq!(clock.sleeps().len(), 1);
2882 }
2883
2884 #[test]
2885 fn rate_limit_backs_off_using_retry_after() {
2886 let c = clip("j");
2887 let d = desired(c.clone(), AudioFormat::Mp3);
2888 let plan = Plan {
2889 actions: vec![Action::Download {
2890 clip: c.clone(),
2891 lineage: LineageContext::own_root(&c),
2892 path: d.path.clone(),
2893 format: AudioFormat::Mp3,
2894 }],
2895 };
2896 let http = ScriptedHttp::new().route_seq(
2897 "j.mp3",
2898 vec![
2899 Reply::status(429).with_retry_after(7),
2900 Reply::ok(b"body".to_vec()),
2901 ],
2902 );
2903 let fs = MemFs::new();
2904 let clock = RecordingClock::new();
2905 let mut manifest = Manifest::new();
2906
2907 let outcome = run(
2908 &plan,
2909 &mut manifest,
2910 &[d],
2911 &http,
2912 &fs,
2913 &StubFfmpeg::flac(),
2914 &clock,
2915 &ExecOptions::default(),
2916 );
2917
2918 assert_eq!(outcome.downloaded, 1);
2919 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
2920 }
2921
2922 #[test]
2923 fn auth_failure_aborts_the_run() {
2924 let c1 = clip("k1");
2925 let c2 = clip("k2");
2926 let d1 = desired(c1.clone(), AudioFormat::Flac);
2927 let d2 = desired(c2.clone(), AudioFormat::Flac);
2928 let plan = Plan {
2929 actions: vec![
2930 Action::Download {
2931 clip: c1.clone(),
2932 lineage: LineageContext::own_root(&c1),
2933 path: d1.path.clone(),
2934 format: AudioFormat::Flac,
2935 },
2936 Action::Download {
2937 clip: c2.clone(),
2938 lineage: LineageContext::own_root(&c2),
2939 path: d2.path.clone(),
2940 format: AudioFormat::Flac,
2941 },
2942 ],
2943 };
2944 let http = ScriptedHttp::new()
2948 .with_auth()
2949 .route("/wav_file/", Reply::status(401));
2950 let fs = MemFs::new();
2951 let mut manifest = Manifest::new();
2952
2953 let outcome = run(
2954 &plan,
2955 &mut manifest,
2956 &[d1, d2],
2957 &http,
2958 &fs,
2959 &StubFfmpeg::flac(),
2960 &RecordingClock::new(),
2961 &small_poll(),
2962 );
2963
2964 assert_eq!(outcome.status, RunStatus::AuthAborted);
2965 assert_eq!(outcome.failed(), 1);
2966 assert_eq!(outcome.failures[0].clip_id, "k1");
2967 assert_eq!(outcome.downloaded, 0);
2968 }
2969
2970 #[test]
2973 fn disk_full_primary_write_aborts_the_run() {
2974 let c1 = clip("d1");
2978 let c2 = clip("d2");
2979 let d1 = desired(c1.clone(), AudioFormat::Mp3);
2980 let d2 = desired(c2.clone(), AudioFormat::Mp3);
2981 let plan = Plan {
2982 actions: vec![
2983 Action::Download {
2984 clip: c1.clone(),
2985 lineage: LineageContext::own_root(&c1),
2986 path: d1.path.clone(),
2987 format: AudioFormat::Mp3,
2988 },
2989 Action::Download {
2990 clip: c2.clone(),
2991 lineage: LineageContext::own_root(&c2),
2992 path: d2.path.clone(),
2993 format: AudioFormat::Mp3,
2994 },
2995 ],
2996 };
2997 let http = ScriptedHttp::new()
2998 .route("d1.mp3", Reply::ok(b"body-1".to_vec()))
2999 .route("d2.mp3", Reply::ok(b"body-2".to_vec()));
3000 let fs = MemFs::new().fail_write_out_of_space("d1.mp3");
3001 let mut manifest = Manifest::new();
3002
3003 let outcome = run(
3004 &plan,
3005 &mut manifest,
3006 &[d1, d2],
3007 &http,
3008 &fs,
3009 &StubFfmpeg::flac(),
3010 &RecordingClock::new(),
3011 &ExecOptions::default(),
3012 );
3013
3014 assert_eq!(outcome.status, RunStatus::DiskFull);
3015 assert_eq!(outcome.failed(), 1);
3016 assert_eq!(outcome.failures[0].clip_id, "d1");
3017 assert!(outcome.failures[0].reason.contains("disk full"));
3018 assert_eq!(outcome.downloaded, 0);
3019 assert_eq!(http.count("d2.mp3"), 0);
3021 assert!(!fs.exists("d2.mp3"));
3022 }
3023
3024 #[test]
3025 fn disk_full_flac_transcode_aborts_the_run() {
3026 let c1 = clip("d1");
3029 let c2 = clip("d2");
3030 let d1 = desired(c1.clone(), AudioFormat::Flac);
3031 let d2 = desired(c2.clone(), AudioFormat::Flac);
3032 let plan = Plan {
3033 actions: vec![
3034 Action::Download {
3035 clip: c1.clone(),
3036 lineage: LineageContext::own_root(&c1),
3037 path: d1.path.clone(),
3038 format: AudioFormat::Flac,
3039 },
3040 Action::Download {
3041 clip: c2.clone(),
3042 lineage: LineageContext::own_root(&c2),
3043 path: d2.path.clone(),
3044 format: AudioFormat::Flac,
3045 },
3046 ],
3047 };
3048 let http = ScriptedHttp::new()
3049 .with_auth()
3050 .route(
3051 "/wav_file/",
3052 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/d1.wav"}"#),
3053 )
3054 .route(".wav", Reply::ok(b"wav".to_vec()));
3055 let fs = MemFs::new();
3056 let mut manifest = Manifest::new();
3057
3058 let outcome = run(
3059 &plan,
3060 &mut manifest,
3061 &[d1, d2],
3062 &http,
3063 &fs,
3064 &StubFfmpeg::out_of_space(),
3065 &RecordingClock::new(),
3066 &ExecOptions::default(),
3067 );
3068
3069 assert_eq!(outcome.status, RunStatus::DiskFull);
3070 assert_eq!(outcome.failed(), 1);
3071 assert_eq!(outcome.failures[0].clip_id, "d1");
3072 assert!(outcome.failures[0].reason.contains("disk full"));
3073 assert_eq!(outcome.downloaded, 0);
3074 }
3075
3076 #[test]
3077 fn disk_full_artifact_write_aborts_the_run() {
3078 let mut manifest = Manifest::new();
3082 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
3083 let plan = Plan {
3084 actions: vec![Action::WriteArtifact {
3085 kind: ArtifactKind::CoverJpg,
3086 path: "a/cover.jpg".to_owned(),
3087 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
3088 hash: "h1".to_owned(),
3089 owner_id: "a".to_owned(),
3090 content: None,
3091 }],
3092 };
3093 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"jpg-bytes".to_vec()));
3094 let fs = MemFs::new().fail_write_out_of_space("a/cover.jpg");
3095
3096 let outcome = run(
3097 &plan,
3098 &mut manifest,
3099 &[],
3100 &http,
3101 &fs,
3102 &StubFfmpeg::flac(),
3103 &RecordingClock::new(),
3104 &ExecOptions::default(),
3105 );
3106
3107 assert_eq!(outcome.status, RunStatus::DiskFull);
3108 assert_eq!(outcome.failed(), 1);
3109 assert!(outcome.failures[0].reason.contains("disk full"));
3110 assert_eq!(outcome.artifacts_written, 0);
3111 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
3113 }
3114
3115 #[test]
3116 fn disk_full_leaves_the_failed_clips_manifest_entry_unchanged() {
3117 let c = clip("m");
3120 let d = desired(c.clone(), AudioFormat::Mp3);
3121 let plan = Plan {
3122 actions: vec![Action::Download {
3123 clip: c.clone(),
3124 lineage: LineageContext::own_root(&c),
3125 path: d.path.clone(),
3126 format: AudioFormat::Mp3,
3127 }],
3128 };
3129 let http = ScriptedHttp::new().route("m.mp3", Reply::ok(b"new-body".to_vec()));
3130 let fs = MemFs::new()
3131 .with_file("m.mp3", b"OLD-CONTENT".to_vec())
3132 .fail_write_out_of_space("m.mp3");
3133 let mut manifest = Manifest::new();
3134 let before = entry("m.mp3", AudioFormat::Mp3);
3135 manifest.insert("m", before.clone());
3136
3137 let outcome = run(
3138 &plan,
3139 &mut manifest,
3140 &[d],
3141 &http,
3142 &fs,
3143 &StubFfmpeg::flac(),
3144 &RecordingClock::new(),
3145 &ExecOptions::default(),
3146 );
3147
3148 assert_eq!(outcome.status, RunStatus::DiskFull);
3149 assert_eq!(manifest.get("m"), Some(&before));
3150 assert_eq!(fs.read_file("m.mp3").unwrap(), b"OLD-CONTENT");
3151 }
3152
3153 #[test]
3154 fn cdn_download_rejection_skips_the_clip_without_aborting() {
3155 let c1 = clip("k1");
3156 let c2 = clip("k2");
3157 let d1 = desired(c1.clone(), AudioFormat::Mp3);
3158 let d2 = desired(c2.clone(), AudioFormat::Mp3);
3159 let plan = Plan {
3160 actions: vec![
3161 Action::Download {
3162 clip: c1.clone(),
3163 lineage: LineageContext::own_root(&c1),
3164 path: d1.path.clone(),
3165 format: AudioFormat::Mp3,
3166 },
3167 Action::Download {
3168 clip: c2.clone(),
3169 lineage: LineageContext::own_root(&c2),
3170 path: d2.path.clone(),
3171 format: AudioFormat::Mp3,
3172 },
3173 ],
3174 };
3175 let http = ScriptedHttp::new()
3179 .route("k1.mp3", Reply::status(403))
3180 .route("k2.mp3", Reply::ok(b"body".to_vec()));
3181 let fs = MemFs::new();
3182 let mut manifest = Manifest::new();
3183
3184 let outcome = run(
3185 &plan,
3186 &mut manifest,
3187 &[d1, d2],
3188 &http,
3189 &fs,
3190 &StubFfmpeg::flac(),
3191 &RecordingClock::new(),
3192 &ExecOptions::default(),
3193 );
3194
3195 assert_ne!(outcome.status, RunStatus::AuthAborted);
3196 assert_eq!(outcome.downloaded, 1);
3197 assert_eq!(outcome.failed(), 1);
3198 assert_eq!(outcome.failures[0].clip_id, "k1");
3199 }
3200
3201 #[test]
3202 fn one_clip_failure_does_not_abort_the_run() {
3203 let c1 = clip("l1");
3204 let c2 = clip("l2");
3205 let d1 = desired(c1.clone(), AudioFormat::Mp3);
3206 let d2 = desired(c2.clone(), AudioFormat::Mp3);
3207 let plan = Plan {
3208 actions: vec![
3209 Action::Download {
3210 clip: c1.clone(),
3211 lineage: LineageContext::own_root(&c1),
3212 path: d1.path.clone(),
3213 format: AudioFormat::Mp3,
3214 },
3215 Action::Download {
3216 clip: c2.clone(),
3217 lineage: LineageContext::own_root(&c2),
3218 path: d2.path.clone(),
3219 format: AudioFormat::Mp3,
3220 },
3221 ],
3222 };
3223 let http = ScriptedHttp::new()
3224 .route("l1.mp3", Reply::status(404))
3225 .route("l2.mp3", Reply::ok(b"body".to_vec()));
3226 let fs = MemFs::new();
3227 let mut manifest = Manifest::new();
3228
3229 let outcome = run(
3230 &plan,
3231 &mut manifest,
3232 &[d1, d2],
3233 &http,
3234 &fs,
3235 &StubFfmpeg::flac(),
3236 &RecordingClock::new(),
3237 &ExecOptions::default(),
3238 );
3239
3240 assert_eq!(outcome.status, RunStatus::Completed);
3241 assert_eq!(outcome.downloaded, 1);
3242 assert_eq!(outcome.failed(), 1);
3243 assert_eq!(outcome.failures[0].clip_id, "l1");
3244 assert!(fs.exists("l2.mp3"));
3245 assert!(manifest.get("l2").is_some());
3246 assert!(manifest.get("l1").is_none());
3247 }
3248
3249 #[test]
3252 fn preserve_is_set_for_copy_held_and_private_clips() {
3253 let mut mirror = desired(clip("m1"), AudioFormat::Mp3);
3254 mirror.modes = vec![SourceMode::Mirror];
3255 let mut copy_held = desired(clip("m2"), AudioFormat::Mp3);
3256 copy_held.modes = vec![SourceMode::Mirror, SourceMode::Copy];
3257 let mut private = desired(clip("m3"), AudioFormat::Mp3);
3258 private.private = true;
3259
3260 let plan = Plan {
3261 actions: vec![
3262 Action::Download {
3263 clip: mirror.clip.clone(),
3264 lineage: LineageContext::own_root(&mirror.clip),
3265 path: mirror.path.clone(),
3266 format: AudioFormat::Mp3,
3267 },
3268 Action::Download {
3269 clip: copy_held.clip.clone(),
3270 lineage: LineageContext::own_root(©_held.clip),
3271 path: copy_held.path.clone(),
3272 format: AudioFormat::Mp3,
3273 },
3274 Action::Download {
3275 clip: private.clip.clone(),
3276 lineage: LineageContext::own_root(&private.clip),
3277 path: private.path.clone(),
3278 format: AudioFormat::Mp3,
3279 },
3280 ],
3281 };
3282 let http = ScriptedHttp::new()
3283 .route("m1.mp3", Reply::ok(b"a".to_vec()))
3284 .route("m2.mp3", Reply::ok(b"b".to_vec()))
3285 .route("m3.mp3", Reply::ok(b"c".to_vec()));
3286 let fs = MemFs::new();
3287 let mut manifest = Manifest::new();
3288
3289 let outcome = run(
3290 &plan,
3291 &mut manifest,
3292 &[mirror, copy_held, private],
3293 &http,
3294 &fs,
3295 &StubFfmpeg::flac(),
3296 &RecordingClock::new(),
3297 &ExecOptions::default(),
3298 );
3299
3300 assert_eq!(outcome.downloaded, 3);
3301 assert!(!manifest.get("m1").unwrap().preserve);
3302 assert!(manifest.get("m2").unwrap().preserve);
3303 assert!(manifest.get("m3").unwrap().preserve);
3304 }
3305
3306 #[test]
3309 fn reformat_writes_new_format_and_removes_old_file() {
3310 let c = clip("n");
3311 let d = desired(c.clone(), AudioFormat::Mp3);
3312 let plan = Plan {
3313 actions: vec![Action::Reformat {
3314 clip: c.clone(),
3315 path: "n.mp3".to_owned(),
3316 from_path: "n.flac".to_owned(),
3317 from: AudioFormat::Flac,
3318 to: AudioFormat::Mp3,
3319 }],
3320 };
3321 let http = ScriptedHttp::new().route("n.mp3", Reply::ok(b"body".to_vec()));
3322 let fs = MemFs::new().with_file("n.flac", b"OLD-FLAC".to_vec());
3323 let mut manifest = Manifest::new();
3324 manifest.insert("n", entry("n.flac", AudioFormat::Flac));
3325
3326 let outcome = run(
3327 &plan,
3328 &mut manifest,
3329 &[d],
3330 &http,
3331 &fs,
3332 &StubFfmpeg::flac(),
3333 &RecordingClock::new(),
3334 &ExecOptions::default(),
3335 );
3336
3337 assert_eq!(outcome.reformatted, 1);
3338 assert!(fs.exists("n.mp3"));
3339 assert!(!fs.exists("n.flac"));
3340 let updated = manifest.get("n").unwrap();
3341 assert_eq!(updated.path, "n.mp3");
3342 assert_eq!(updated.format, AudioFormat::Mp3);
3343 assert_eq!(updated.meta_hash, "m");
3344 }
3345
3346 #[test]
3347 fn retag_rewrites_file_and_updates_hashes() {
3348 let c = clip("o");
3349 let mut d = desired(c.clone(), AudioFormat::Mp3);
3350 d.meta_hash = "new".to_owned();
3351 d.art_hash = "new-art".to_owned();
3352 let existing = tag_mp3(
3353 b"audio",
3354 &TrackMetadata::from_clip(&c, &LineageContext::own_root(&c)),
3355 None,
3356 None,
3357 )
3358 .unwrap();
3359 let fs = MemFs::new().with_file("o.mp3", existing.clone());
3360 let mut manifest = Manifest::new();
3361 let mut start = entry("o.mp3", AudioFormat::Mp3);
3362 start.size = existing.len() as u64;
3363 manifest.insert("o", start);
3364 let plan = Plan {
3365 actions: vec![Action::Retag {
3366 clip: c.clone(),
3367 lineage: LineageContext::own_root(&c),
3368 path: "o.mp3".to_owned(),
3369 }],
3370 };
3371
3372 let outcome = run(
3373 &plan,
3374 &mut manifest,
3375 &[d],
3376 &ScriptedHttp::new(),
3377 &fs,
3378 &StubFfmpeg::flac(),
3379 &RecordingClock::new(),
3380 &ExecOptions::default(),
3381 );
3382
3383 assert_eq!(outcome.retagged, 1);
3384 let updated = manifest.get("o").unwrap();
3385 assert_eq!(updated.meta_hash, "new");
3386 assert_eq!(updated.art_hash, "new-art");
3387 assert_eq!(&fs.read_file("o.mp3").unwrap()[..3], b"ID3");
3388 }
3389
3390 #[test]
3391 fn rename_moves_file_and_updates_manifest_path() {
3392 let c = clip("p");
3393 let mut d = desired(c.clone(), AudioFormat::Mp3);
3394 d.path = "new/p.mp3".to_owned();
3395 let fs = MemFs::new().with_file("old/p.mp3", b"DATA".to_vec());
3396 let mut manifest = Manifest::new();
3397 manifest.insert("p", entry("old/p.mp3", AudioFormat::Mp3));
3398 let plan = Plan {
3399 actions: vec![Action::Rename {
3400 from: "old/p.mp3".to_owned(),
3401 to: "new/p.mp3".to_owned(),
3402 }],
3403 };
3404
3405 let outcome = run(
3406 &plan,
3407 &mut manifest,
3408 &[d],
3409 &ScriptedHttp::new(),
3410 &fs,
3411 &StubFfmpeg::flac(),
3412 &RecordingClock::new(),
3413 &ExecOptions::default(),
3414 );
3415
3416 assert_eq!(outcome.renamed, 1);
3417 assert!(fs.exists("new/p.mp3"));
3418 assert!(!fs.exists("old/p.mp3"));
3419 assert_eq!(manifest.get("p").unwrap().path, "new/p.mp3");
3420 }
3421
3422 #[test]
3423 fn disk_full_rename_aborts_the_run() {
3424 let c = clip("p");
3427 let mut d = desired(c.clone(), AudioFormat::Mp3);
3428 d.path = "new/p.mp3".to_owned();
3429 let fs = MemFs::new()
3430 .with_file("old/p.mp3", b"DATA".to_vec())
3431 .fail_rename_out_of_space("new/p.mp3");
3432 let mut manifest = Manifest::new();
3433 manifest.insert("p", entry("old/p.mp3", AudioFormat::Mp3));
3434 let plan = Plan {
3435 actions: vec![Action::Rename {
3436 from: "old/p.mp3".to_owned(),
3437 to: "new/p.mp3".to_owned(),
3438 }],
3439 };
3440
3441 let outcome = run(
3442 &plan,
3443 &mut manifest,
3444 &[d],
3445 &ScriptedHttp::new(),
3446 &fs,
3447 &StubFfmpeg::flac(),
3448 &RecordingClock::new(),
3449 &ExecOptions::default(),
3450 );
3451
3452 assert_eq!(outcome.status, RunStatus::DiskFull);
3453 assert_eq!(outcome.renamed, 0);
3454 assert_eq!(outcome.failed(), 1);
3455 assert!(outcome.failures[0].reason.contains("disk full"));
3456 assert!(fs.exists("old/p.mp3"));
3458 assert!(!fs.exists("new/p.mp3"));
3459 assert_eq!(manifest.get("p").unwrap().path, "old/p.mp3");
3460 }
3461
3462 #[test]
3463 fn delete_removes_file_and_manifest_entry() {
3464 let fs = MemFs::new().with_file("q.mp3", b"DATA".to_vec());
3465 let mut manifest = Manifest::new();
3466 manifest.insert("q", entry("q.mp3", AudioFormat::Mp3));
3467 let plan = Plan {
3468 actions: vec![Action::Delete {
3469 path: "q.mp3".to_owned(),
3470 clip_id: "q".to_owned(),
3471 }],
3472 };
3473
3474 let outcome = run(
3475 &plan,
3476 &mut manifest,
3477 &[],
3478 &ScriptedHttp::new(),
3479 &fs,
3480 &StubFfmpeg::flac(),
3481 &RecordingClock::new(),
3482 &ExecOptions::default(),
3483 );
3484
3485 assert_eq!(outcome.deleted, 1);
3486 assert!(!fs.exists("q.mp3"));
3487 assert!(manifest.get("q").is_none());
3488 }
3489
3490 #[test]
3491 fn failed_delete_keeps_the_manifest_entry() {
3492 let fs = MemFs::new()
3493 .with_file("s.mp3", b"DATA".to_vec())
3494 .fail_remove("s.mp3");
3495 let mut manifest = Manifest::new();
3496 manifest.insert("s", entry("s.mp3", AudioFormat::Mp3));
3497 let plan = Plan {
3498 actions: vec![Action::Delete {
3499 path: "s.mp3".to_owned(),
3500 clip_id: "s".to_owned(),
3501 }],
3502 };
3503
3504 let outcome = run(
3505 &plan,
3506 &mut manifest,
3507 &[],
3508 &ScriptedHttp::new(),
3509 &fs,
3510 &StubFfmpeg::flac(),
3511 &RecordingClock::new(),
3512 &ExecOptions::default(),
3513 );
3514
3515 assert_eq!(outcome.deleted, 0);
3516 assert_eq!(outcome.failed(), 1);
3517 assert!(manifest.get("s").is_some());
3518 assert!(fs.exists("s.mp3"));
3519 }
3520
3521 #[test]
3522 fn skip_is_a_noop() {
3523 let mut manifest = Manifest::new();
3524 let plan = Plan {
3525 actions: vec![Action::Skip {
3526 clip_id: "r".to_owned(),
3527 }],
3528 };
3529 let outcome = run(
3530 &plan,
3531 &mut manifest,
3532 &[],
3533 &ScriptedHttp::new(),
3534 &MemFs::new(),
3535 &StubFfmpeg::flac(),
3536 &RecordingClock::new(),
3537 &ExecOptions::default(),
3538 );
3539 assert_eq!(outcome.skipped, 1);
3540 assert_eq!(outcome.failed(), 0);
3541 }
3542
3543 #[test]
3546 fn header_helpers_parse_or_ignore() {
3547 let resp = HttpResponse {
3548 status: 200,
3549 headers: vec![("Content-Length".to_owned(), "42".to_owned())],
3550 body: Vec::new(),
3551 };
3552 assert_eq!(content_length(&resp), Some(42));
3553
3554 let bare = HttpResponse {
3555 status: 200,
3556 headers: Vec::new(),
3557 body: Vec::new(),
3558 };
3559 assert_eq!(content_length(&bare), None);
3560 }
3561
3562 #[test]
3563 fn preserve_rule_covers_copy_and_private() {
3564 let base = desired(clip("x"), AudioFormat::Mp3);
3565 assert!(!preserve_for(&base));
3566 let mut copy_held = base.clone();
3567 copy_held.modes = vec![SourceMode::Copy];
3568 assert!(preserve_for(©_held));
3569 let mut private = base.clone();
3570 private.private = true;
3571 assert!(preserve_for(&private));
3572 }
3573
3574 fn fs_new() -> MemFs {
3575 MemFs::new()
3576 }
3577
3578 #[test]
3581 fn skip_sets_preserve_when_a_clip_becomes_copy_held() {
3582 let c = clip("s1");
3583 let mut d = desired(c.clone(), AudioFormat::Mp3);
3584 d.modes = vec![SourceMode::Copy];
3585 let plan = Plan {
3586 actions: vec![Action::Skip {
3587 clip_id: "s1".to_owned(),
3588 }],
3589 };
3590 let mut manifest = Manifest::new();
3591 manifest.insert("s1".to_owned(), entry("s1.mp3", AudioFormat::Mp3));
3592 assert!(!manifest.get("s1").unwrap().preserve);
3593
3594 let outcome = run(
3595 &plan,
3596 &mut manifest,
3597 &[d],
3598 &ScriptedHttp::new(),
3599 &fs_new(),
3600 &StubFfmpeg::flac(),
3601 &RecordingClock::new(),
3602 &ExecOptions::default(),
3603 );
3604
3605 assert_eq!(outcome.skipped, 1);
3606 assert!(
3607 manifest.get("s1").unwrap().preserve,
3608 "a copy-held skip must mark the entry preserved"
3609 );
3610 }
3611
3612 #[test]
3613 fn skip_clears_stale_preserve_when_a_clip_returns_to_mirror_only() {
3614 let c = clip("s2");
3615 let d = desired(c.clone(), AudioFormat::Mp3);
3616 let plan = Plan {
3617 actions: vec![Action::Skip {
3618 clip_id: "s2".to_owned(),
3619 }],
3620 };
3621 let mut manifest = Manifest::new();
3622 let mut stale = entry("s2.mp3", AudioFormat::Mp3);
3623 stale.preserve = true;
3624 manifest.insert("s2".to_owned(), stale);
3625
3626 run(
3627 &plan,
3628 &mut manifest,
3629 &[d],
3630 &ScriptedHttp::new(),
3631 &fs_new(),
3632 &StubFfmpeg::flac(),
3633 &RecordingClock::new(),
3634 &ExecOptions::default(),
3635 );
3636
3637 assert!(
3638 !manifest.get("s2").unwrap().preserve,
3639 "a mirror-only skip must clear a stale preserve marker"
3640 );
3641 }
3642
3643 #[test]
3644 fn flac_render_retries_a_rate_limited_wav_lookup() {
3645 let c = clip("rl");
3646 let d = desired(c.clone(), AudioFormat::Flac);
3647 let plan = Plan {
3648 actions: vec![Action::Download {
3649 clip: c.clone(),
3650 lineage: LineageContext::own_root(&c),
3651 path: d.path.clone(),
3652 format: AudioFormat::Flac,
3653 }],
3654 };
3655 let http = ScriptedHttp::new()
3656 .with_auth()
3657 .route_seq(
3658 "/wav_file/",
3659 vec![
3660 Reply::status(429),
3661 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/rl.wav"}"#),
3662 ],
3663 )
3664 .route("rl.wav", Reply::ok(b"wav".to_vec()));
3665 let clock = RecordingClock::new();
3666 let mut manifest = Manifest::new();
3667
3668 let outcome = run(
3669 &plan,
3670 &mut manifest,
3671 &[d],
3672 &http,
3673 &fs_new(),
3674 &StubFfmpeg::flac(),
3675 &clock,
3676 &small_poll(),
3677 );
3678
3679 assert_eq!(outcome.downloaded, 1);
3680 assert_eq!(outcome.failed(), 0);
3681 assert_eq!(http.count("/convert_wav/"), 0);
3683 assert_eq!(clock.sleeps(), vec![Duration::from_secs(1)]);
3685 }
3686
3687 #[test]
3690 fn write_artifact_fetches_writes_and_updates_manifest() {
3691 let mut manifest = Manifest::new();
3694 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
3695 let plan = Plan {
3696 actions: vec![Action::WriteArtifact {
3697 kind: ArtifactKind::CoverJpg,
3698 path: "a/cover.jpg".to_owned(),
3699 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
3700 hash: "h1".to_owned(),
3701 owner_id: "a".to_owned(),
3702 content: None,
3703 }],
3704 };
3705 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"jpg-bytes".to_vec()));
3706 let fs = MemFs::new();
3707
3708 let outcome = run(
3709 &plan,
3710 &mut manifest,
3711 &[],
3712 &http,
3713 &fs,
3714 &StubFfmpeg::flac(),
3715 &RecordingClock::new(),
3716 &ExecOptions::default(),
3717 );
3718
3719 assert_eq!(outcome.artifacts_written, 1);
3720 assert_eq!(outcome.failed(), 0);
3721 assert_eq!(outcome.status, RunStatus::Completed);
3722 assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"jpg-bytes");
3723 assert_eq!(
3724 manifest.get("a").unwrap().cover_jpg,
3725 Some(ArtifactState {
3726 path: "a/cover.jpg".to_owned(),
3727 hash: "h1".to_owned(),
3728 })
3729 );
3730 }
3731
3732 #[test]
3733 fn write_text_sidecar_records_slot_with_no_network_fetch() {
3734 let mut manifest = Manifest::new();
3737 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
3738 let plan = Plan {
3739 actions: vec![Action::WriteArtifact {
3740 kind: ArtifactKind::DetailsTxt,
3741 path: "a.details.txt".to_owned(),
3742 source_url: String::new(),
3743 hash: "dh".to_owned(),
3744 owner_id: "a".to_owned(),
3745 content: Some("Title: A\n".to_owned()),
3746 }],
3747 };
3748 let http = ScriptedHttp::new();
3750 let fs = MemFs::new();
3751
3752 let outcome = run(
3753 &plan,
3754 &mut manifest,
3755 &[],
3756 &http,
3757 &fs,
3758 &StubFfmpeg::flac(),
3759 &RecordingClock::new(),
3760 &ExecOptions::default(),
3761 );
3762
3763 assert_eq!(outcome.artifacts_written, 1);
3764 assert_eq!(outcome.failed(), 0);
3765 assert_eq!(fs.read_file("a.details.txt").unwrap(), b"Title: A\n");
3766 assert_eq!(
3767 manifest.get("a").unwrap().details_txt,
3768 Some(ArtifactState {
3769 path: "a.details.txt".to_owned(),
3770 hash: "dh".to_owned(),
3771 })
3772 );
3773 }
3774
3775 #[test]
3776 fn write_lyrics_sidecar_relocation_removes_old_file() {
3777 let mut manifest = Manifest::new();
3780 let mut e = entry("old/a.flac", AudioFormat::Flac);
3781 e.lyrics_txt = Some(ArtifactState {
3782 path: "old/a.lyrics.txt".to_owned(),
3783 hash: "lh".to_owned(),
3784 });
3785 manifest.insert("a", e);
3786 let fs = MemFs::new()
3787 .with_file("old/a.flac", b"AUDIO".to_vec())
3788 .with_file("old/a.lyrics.txt", b"old words\n".to_vec());
3789 let plan = Plan {
3790 actions: vec![Action::WriteArtifact {
3791 kind: ArtifactKind::LyricsTxt,
3792 path: "new/a.lyrics.txt".to_owned(),
3793 source_url: String::new(),
3794 hash: "lh".to_owned(),
3795 owner_id: "a".to_owned(),
3796 content: Some("new words\n".to_owned()),
3797 }],
3798 };
3799
3800 let outcome = run(
3801 &plan,
3802 &mut manifest,
3803 &[],
3804 &ScriptedHttp::new(),
3805 &fs,
3806 &StubFfmpeg::flac(),
3807 &RecordingClock::new(),
3808 &ExecOptions::default(),
3809 );
3810
3811 assert_eq!(outcome.failed(), 0);
3812 assert_eq!(fs.read_file("new/a.lyrics.txt").unwrap(), b"new words\n");
3813 assert!(!fs.exists("old/a.lyrics.txt"));
3814 assert_eq!(
3815 manifest.get("a").unwrap().lyrics_txt.as_ref().unwrap().path,
3816 "new/a.lyrics.txt"
3817 );
3818 }
3819
3820 #[test]
3821 fn sidecar_path_swap_never_deletes_a_file_written_this_run() {
3822 let mut manifest = Manifest::new();
3828 let mut a = entry("a.flac", AudioFormat::Flac);
3829 a.lyrics_txt = Some(ArtifactState {
3830 path: "x.lyrics.txt".to_owned(),
3831 hash: "ah".to_owned(),
3832 });
3833 manifest.insert("a", a);
3834 let mut b = entry("b.flac", AudioFormat::Flac);
3835 b.lyrics_txt = Some(ArtifactState {
3836 path: "y.lyrics.txt".to_owned(),
3837 hash: "bh".to_owned(),
3838 });
3839 manifest.insert("b", b);
3840 let fs = MemFs::new()
3841 .with_file("a.flac", b"A".to_vec())
3842 .with_file("b.flac", b"B".to_vec())
3843 .with_file("x.lyrics.txt", b"A words\n".to_vec())
3844 .with_file("y.lyrics.txt", b"B words\n".to_vec());
3845 let plan = Plan {
3847 actions: vec![
3848 Action::WriteArtifact {
3849 kind: ArtifactKind::LyricsTxt,
3850 path: "y.lyrics.txt".to_owned(),
3851 source_url: String::new(),
3852 hash: "ah".to_owned(),
3853 owner_id: "a".to_owned(),
3854 content: Some("A words\n".to_owned()),
3855 },
3856 Action::WriteArtifact {
3857 kind: ArtifactKind::LyricsTxt,
3858 path: "x.lyrics.txt".to_owned(),
3859 source_url: String::new(),
3860 hash: "bh".to_owned(),
3861 owner_id: "b".to_owned(),
3862 content: Some("B words\n".to_owned()),
3863 },
3864 ],
3865 };
3866
3867 let outcome = run(
3868 &plan,
3869 &mut manifest,
3870 &[],
3871 &ScriptedHttp::new(),
3872 &fs,
3873 &StubFfmpeg::flac(),
3874 &RecordingClock::new(),
3875 &ExecOptions::default(),
3876 );
3877
3878 assert_eq!(outcome.failed(), 0);
3879 assert_eq!(fs.read_file("y.lyrics.txt").unwrap(), b"A words\n");
3881 assert_eq!(fs.read_file("x.lyrics.txt").unwrap(), b"B words\n");
3882 assert_eq!(
3883 manifest.get("a").unwrap().lyrics_txt.as_ref().unwrap().path,
3884 "y.lyrics.txt"
3885 );
3886 assert_eq!(
3887 manifest.get("b").unwrap().lyrics_txt.as_ref().unwrap().path,
3888 "x.lyrics.txt"
3889 );
3890 }
3891
3892 #[test]
3893 fn old_sidecar_kept_when_another_clip_still_references_it() {
3894 let mut manifest = Manifest::new();
3899 let mut a = entry("a.flac", AudioFormat::Flac);
3900 a.lyrics_txt = Some(ArtifactState {
3901 path: "y.lyrics.txt".to_owned(),
3902 hash: "ah".to_owned(),
3903 });
3904 manifest.insert("a", a);
3905 let mut b = entry("b.flac", AudioFormat::Flac);
3906 b.lyrics_txt = Some(ArtifactState {
3907 path: "y.lyrics.txt".to_owned(),
3908 hash: "bh".to_owned(),
3909 });
3910 manifest.insert("b", b);
3911 let fs = MemFs::new()
3912 .with_file("a.flac", b"A".to_vec())
3913 .with_file("b.flac", b"B".to_vec())
3914 .with_file("y.lyrics.txt", b"A words\n".to_vec());
3915 let plan = Plan {
3918 actions: vec![Action::WriteArtifact {
3919 kind: ArtifactKind::LyricsTxt,
3920 path: "x.lyrics.txt".to_owned(),
3921 source_url: String::new(),
3922 hash: "bh".to_owned(),
3923 owner_id: "b".to_owned(),
3924 content: Some("B words\n".to_owned()),
3925 }],
3926 };
3927
3928 let outcome = run(
3929 &plan,
3930 &mut manifest,
3931 &[],
3932 &ScriptedHttp::new(),
3933 &fs,
3934 &StubFfmpeg::flac(),
3935 &RecordingClock::new(),
3936 &ExecOptions::default(),
3937 );
3938
3939 assert_eq!(outcome.failed(), 0);
3940 assert!(
3941 fs.exists("y.lyrics.txt"),
3942 "A's live sidecar must not be deleted"
3943 );
3944 assert_eq!(fs.read_file("x.lyrics.txt").unwrap(), b"B words\n");
3945 }
3946
3947 #[test]
3948 fn shared_old_path_is_reclaimed_when_every_referencing_clip_moves_away() {
3949 let mut manifest = Manifest::new();
3955 let mut a = entry("a.flac", AudioFormat::Flac);
3956 a.lyrics_txt = Some(ArtifactState {
3957 path: "s.lyrics.txt".to_owned(),
3958 hash: "ah".to_owned(),
3959 });
3960 manifest.insert("a", a);
3961 let mut b = entry("b.flac", AudioFormat::Flac);
3962 b.lyrics_txt = Some(ArtifactState {
3963 path: "s.lyrics.txt".to_owned(),
3964 hash: "bh".to_owned(),
3965 });
3966 manifest.insert("b", b);
3967 let fs = MemFs::new()
3968 .with_file("a.flac", b"A".to_vec())
3969 .with_file("b.flac", b"B".to_vec())
3970 .with_file("s.lyrics.txt", b"shared\n".to_vec());
3971 let plan = Plan {
3972 actions: vec![
3973 Action::WriteArtifact {
3974 kind: ArtifactKind::LyricsTxt,
3975 path: "pa.lyrics.txt".to_owned(),
3976 source_url: String::new(),
3977 hash: "ah".to_owned(),
3978 owner_id: "a".to_owned(),
3979 content: Some("A words\n".to_owned()),
3980 },
3981 Action::WriteArtifact {
3982 kind: ArtifactKind::LyricsTxt,
3983 path: "pb.lyrics.txt".to_owned(),
3984 source_url: String::new(),
3985 hash: "bh".to_owned(),
3986 owner_id: "b".to_owned(),
3987 content: Some("B words\n".to_owned()),
3988 },
3989 ],
3990 };
3991
3992 let outcome = run(
3993 &plan,
3994 &mut manifest,
3995 &[],
3996 &ScriptedHttp::new(),
3997 &fs,
3998 &StubFfmpeg::flac(),
3999 &RecordingClock::new(),
4000 &ExecOptions::default(),
4001 );
4002
4003 assert_eq!(outcome.failed(), 0);
4004 assert_eq!(fs.read_file("pa.lyrics.txt").unwrap(), b"A words\n");
4005 assert_eq!(fs.read_file("pb.lyrics.txt").unwrap(), b"B words\n");
4006 assert!(
4007 !fs.exists("s.lyrics.txt"),
4008 "the vacated shared path must be reclaimed, not orphaned"
4009 );
4010 }
4011
4012 #[test]
4013 fn write_text_sidecar_skipped_when_owner_audio_absent() {
4014 let plan = Plan {
4017 actions: vec![Action::WriteArtifact {
4018 kind: ArtifactKind::DetailsTxt,
4019 path: "gone.details.txt".to_owned(),
4020 source_url: String::new(),
4021 hash: "dh".to_owned(),
4022 owner_id: "gone".to_owned(),
4023 content: Some("Title: Gone\n".to_owned()),
4024 }],
4025 };
4026 let fs = MemFs::new();
4027 let mut manifest = Manifest::new();
4028
4029 let outcome = run(
4030 &plan,
4031 &mut manifest,
4032 &[],
4033 &ScriptedHttp::new(),
4034 &fs,
4035 &StubFfmpeg::flac(),
4036 &RecordingClock::new(),
4037 &ExecOptions::default(),
4038 );
4039
4040 assert_eq!(outcome.artifacts_written, 0);
4041 assert_eq!(outcome.skipped, 1);
4042 assert!(!fs.exists("gone.details.txt"));
4043 assert!(manifest.get("gone").is_none());
4044 }
4045
4046 #[test]
4047 fn delete_artifact_removes_file_and_clears_slot() {
4048 let fs = MemFs::new().with_file("a/cover.jpg", b"jpg".to_vec());
4049 let mut manifest = Manifest::new();
4050 let mut e = entry("a.mp3", AudioFormat::Mp3);
4051 e.cover_jpg = Some(ArtifactState {
4052 path: "a/cover.jpg".to_owned(),
4053 hash: "h1".to_owned(),
4054 });
4055 manifest.insert("a", e);
4056 let plan = Plan {
4057 actions: vec![Action::DeleteArtifact {
4058 kind: ArtifactKind::CoverJpg,
4059 path: "a/cover.jpg".to_owned(),
4060 owner_id: "a".to_owned(),
4061 }],
4062 };
4063
4064 let outcome = run(
4065 &plan,
4066 &mut manifest,
4067 &[],
4068 &ScriptedHttp::new(),
4069 &fs,
4070 &StubFfmpeg::flac(),
4071 &RecordingClock::new(),
4072 &ExecOptions::default(),
4073 );
4074
4075 assert_eq!(outcome.artifacts_deleted, 1);
4076 assert!(!fs.exists("a/cover.jpg"));
4077 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
4078 }
4079
4080 #[test]
4081 fn delete_artifact_tolerates_already_absent_file() {
4082 let mut manifest = Manifest::new();
4085 let mut e = entry("a.mp3", AudioFormat::Mp3);
4086 e.cover_jpg = Some(ArtifactState {
4087 path: "a/cover.jpg".to_owned(),
4088 hash: "h1".to_owned(),
4089 });
4090 manifest.insert("a", e);
4091 let plan = Plan {
4092 actions: vec![Action::DeleteArtifact {
4093 kind: ArtifactKind::CoverJpg,
4094 path: "a/cover.jpg".to_owned(),
4095 owner_id: "a".to_owned(),
4096 }],
4097 };
4098
4099 let outcome = run(
4100 &plan,
4101 &mut manifest,
4102 &[],
4103 &ScriptedHttp::new(),
4104 &MemFs::new(),
4105 &StubFfmpeg::flac(),
4106 &RecordingClock::new(),
4107 &ExecOptions::default(),
4108 );
4109
4110 assert_eq!(outcome.artifacts_deleted, 1);
4111 assert_eq!(outcome.failed(), 0);
4112 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
4113 }
4114
4115 #[test]
4116 fn write_artifact_http_failure_is_a_per_clip_failure_not_a_run_abort() {
4117 let mut manifest = Manifest::new();
4120 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
4121 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
4122 let plan = Plan {
4123 actions: vec![
4124 Action::WriteArtifact {
4125 kind: ArtifactKind::CoverJpg,
4126 path: "a/cover.jpg".to_owned(),
4127 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
4128 hash: "h1".to_owned(),
4129 owner_id: "a".to_owned(),
4130 content: None,
4131 },
4132 Action::WriteArtifact {
4133 kind: ArtifactKind::CoverJpg,
4134 path: "b/cover.jpg".to_owned(),
4135 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
4136 hash: "h2".to_owned(),
4137 owner_id: "b".to_owned(),
4138 content: None,
4139 },
4140 ],
4141 };
4142 let http = ScriptedHttp::new()
4143 .route("a/large.jpg", Reply::status(404))
4144 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
4145 let fs = MemFs::new();
4146
4147 let outcome = run(
4148 &plan,
4149 &mut manifest,
4150 &[],
4151 &http,
4152 &fs,
4153 &StubFfmpeg::flac(),
4154 &RecordingClock::new(),
4155 &ExecOptions::default(),
4156 );
4157
4158 assert_eq!(outcome.status, RunStatus::Completed);
4159 assert_eq!(outcome.failed(), 1);
4160 assert_eq!(outcome.failures[0].clip_id, "a");
4161 assert_eq!(outcome.artifacts_written, 1);
4162 assert!(!fs.exists("a/cover.jpg"));
4164 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
4165 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
4167 assert!(manifest.get("b").unwrap().cover_jpg.is_some());
4168 }
4169
4170 #[test]
4171 fn stranded_old_sidecar_removed_when_colliding_writer_fails() {
4172 let mut manifest = Manifest::new();
4178 let mut a = entry("a.mp3", AudioFormat::Mp3);
4179 a.cover_jpg = Some(ArtifactState {
4180 path: "shared/cover.jpg".to_owned(),
4181 hash: "ha".to_owned(),
4182 });
4183 manifest.insert("a", a);
4184 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
4185 let fs = MemFs::new().with_file("shared/cover.jpg", b"old-shared".to_vec());
4186 let plan = Plan {
4187 actions: vec![
4188 Action::WriteArtifact {
4189 kind: ArtifactKind::CoverJpg,
4190 path: "a/cover.jpg".to_owned(),
4191 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
4192 hash: "ha".to_owned(),
4193 owner_id: "a".to_owned(),
4194 content: None,
4195 },
4196 Action::WriteArtifact {
4197 kind: ArtifactKind::CoverJpg,
4198 path: "shared/cover.jpg".to_owned(),
4199 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
4200 hash: "hb".to_owned(),
4201 owner_id: "b".to_owned(),
4202 content: None,
4203 },
4204 ],
4205 };
4206 let http = ScriptedHttp::new()
4207 .route("a/large.jpg", Reply::ok(b"jpg-a".to_vec()))
4208 .route("b/large.jpg", Reply::status(404));
4209
4210 let outcome = run(
4211 &plan,
4212 &mut manifest,
4213 &[],
4214 &http,
4215 &fs,
4216 &StubFfmpeg::flac(),
4217 &RecordingClock::new(),
4218 &ExecOptions::default(),
4219 );
4220
4221 assert_eq!(outcome.failed(), 1);
4222 assert_eq!(outcome.failures[0].clip_id, "b");
4223 assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"jpg-a");
4225 assert!(
4226 !fs.exists("shared/cover.jpg"),
4227 "the vacated file must be removed once the colliding writer failed"
4228 );
4229 assert_eq!(
4230 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
4231 "a/cover.jpg"
4232 );
4233 }
4234
4235 #[test]
4236 fn committed_write_at_old_path_is_preserved() {
4237 let mut manifest = Manifest::new();
4242 let mut a = entry("a.mp3", AudioFormat::Mp3);
4243 a.cover_jpg = Some(ArtifactState {
4244 path: "shared/cover.jpg".to_owned(),
4245 hash: "ha".to_owned(),
4246 });
4247 manifest.insert("a", a);
4248 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
4249 let fs = MemFs::new().with_file("shared/cover.jpg", b"old-shared".to_vec());
4250 let plan = Plan {
4251 actions: vec![
4252 Action::WriteArtifact {
4253 kind: ArtifactKind::CoverJpg,
4254 path: "shared/cover.jpg".to_owned(),
4255 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
4256 hash: "hb".to_owned(),
4257 owner_id: "b".to_owned(),
4258 content: None,
4259 },
4260 Action::WriteArtifact {
4261 kind: ArtifactKind::CoverJpg,
4262 path: "a/cover.jpg".to_owned(),
4263 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
4264 hash: "ha".to_owned(),
4265 owner_id: "a".to_owned(),
4266 content: None,
4267 },
4268 ],
4269 };
4270 let http = ScriptedHttp::new()
4271 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()))
4272 .route("a/large.jpg", Reply::ok(b"jpg-a".to_vec()));
4273
4274 let outcome = run(
4275 &plan,
4276 &mut manifest,
4277 &[],
4278 &http,
4279 &fs,
4280 &StubFfmpeg::flac(),
4281 &RecordingClock::new(),
4282 &ExecOptions::default(),
4283 );
4284
4285 assert_eq!(outcome.failed(), 0);
4286 assert_eq!(fs.read_file("shared/cover.jpg").unwrap(), b"jpg-b");
4288 assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"jpg-a");
4289 assert_eq!(
4290 manifest.get("b").unwrap().cover_jpg.as_ref().unwrap().path,
4291 "shared/cover.jpg"
4292 );
4293 assert_eq!(
4294 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
4295 "a/cover.jpg"
4296 );
4297 }
4298
4299 #[test]
4300 fn cover_move_renames_without_fetching() {
4301 let mut manifest = Manifest::new();
4305 let mut e = entry("a.mp3", AudioFormat::Mp3);
4306 e.cover_jpg = Some(ArtifactState {
4307 path: "old/cover.jpg".to_owned(),
4308 hash: "h".to_owned(),
4309 });
4310 manifest.insert("a", e);
4311 let fs = MemFs::new().with_file("old/cover.jpg", b"JPGBYTES".to_vec());
4312 let plan = Plan {
4313 actions: vec![Action::MoveArtifact {
4314 kind: ArtifactKind::CoverJpg,
4315 from: "old/cover.jpg".to_owned(),
4316 to: "new/cover.jpg".to_owned(),
4317 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
4318 hash: "h".to_owned(),
4319 owner_id: "a".to_owned(),
4320 }],
4321 };
4322
4323 let outcome = run(
4324 &plan,
4325 &mut manifest,
4326 &[],
4327 &ScriptedHttp::new(),
4328 &fs,
4329 &StubFfmpeg::flac(),
4330 &RecordingClock::new(),
4331 &ExecOptions::default(),
4332 );
4333
4334 assert_eq!(outcome.failed(), 0);
4335 assert_eq!(outcome.renamed, 1, "counted as a rename, not a write");
4336 assert_eq!(fs.read_file("new/cover.jpg").unwrap(), b"JPGBYTES");
4338 assert!(!fs.exists("old/cover.jpg"));
4339 assert_eq!(
4340 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
4341 "new/cover.jpg"
4342 );
4343 }
4344
4345 #[test]
4346 fn cover_move_falls_back_to_fetch_when_old_file_missing() {
4347 let mut manifest = Manifest::new();
4350 let mut e = entry("a.mp3", AudioFormat::Mp3);
4351 e.cover_jpg = Some(ArtifactState {
4352 path: "old/cover.jpg".to_owned(),
4353 hash: "h".to_owned(),
4354 });
4355 manifest.insert("a", e);
4356 let fs = MemFs::new(); let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"FETCHED".to_vec()));
4358 let plan = Plan {
4359 actions: vec![Action::MoveArtifact {
4360 kind: ArtifactKind::CoverJpg,
4361 from: "old/cover.jpg".to_owned(),
4362 to: "new/cover.jpg".to_owned(),
4363 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
4364 hash: "h".to_owned(),
4365 owner_id: "a".to_owned(),
4366 }],
4367 };
4368
4369 let outcome = run(
4370 &plan,
4371 &mut manifest,
4372 &[],
4373 &http,
4374 &fs,
4375 &StubFfmpeg::flac(),
4376 &RecordingClock::new(),
4377 &ExecOptions::default(),
4378 );
4379
4380 assert_eq!(outcome.failed(), 0);
4381 assert_eq!(fs.read_file("new/cover.jpg").unwrap(), b"FETCHED");
4382 assert_eq!(
4383 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
4384 "new/cover.jpg"
4385 );
4386 }
4387
4388 #[test]
4389 fn cover_move_falls_back_when_source_co_referenced() {
4390 let mut manifest = Manifest::new();
4394 let mut a = entry("a.mp3", AudioFormat::Mp3);
4395 a.cover_jpg = Some(ArtifactState {
4396 path: "old/cover.jpg".to_owned(),
4397 hash: "h".to_owned(),
4398 });
4399 manifest.insert("a", a);
4400 let mut b = entry("b.mp3", AudioFormat::Mp3);
4401 b.cover_jpg = Some(ArtifactState {
4402 path: "old/cover.jpg".to_owned(),
4403 hash: "h".to_owned(),
4404 });
4405 manifest.insert("b", b);
4406 let fs = MemFs::new().with_file("old/cover.jpg", b"SHARED".to_vec());
4407 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"FETCHED-A".to_vec()));
4408 let plan = Plan {
4410 actions: vec![Action::MoveArtifact {
4411 kind: ArtifactKind::CoverJpg,
4412 from: "old/cover.jpg".to_owned(),
4413 to: "a/cover.jpg".to_owned(),
4414 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
4415 hash: "h".to_owned(),
4416 owner_id: "a".to_owned(),
4417 }],
4418 };
4419
4420 let outcome = run(
4421 &plan,
4422 &mut manifest,
4423 &[],
4424 &http,
4425 &fs,
4426 &StubFfmpeg::flac(),
4427 &RecordingClock::new(),
4428 &ExecOptions::default(),
4429 );
4430
4431 assert_eq!(outcome.failed(), 0);
4432 assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"FETCHED-A");
4434 assert_eq!(
4435 fs.read_file("old/cover.jpg").unwrap(),
4436 b"SHARED",
4437 "the co-referenced file must survive"
4438 );
4439 }
4440
4441 #[test]
4442 fn stem_move_renames_without_refetch() {
4443 let mut manifest = Manifest::new();
4446 let mut e = entry("a.flac", AudioFormat::Flac);
4447 e.stems.insert(
4448 "voc".to_owned(),
4449 ArtifactState {
4450 path: "old.stems/voc.mp3".to_owned(),
4451 hash: "h1".to_owned(),
4452 },
4453 );
4454 manifest.insert("a", e);
4455 let fs = MemFs::new().with_file("old.stems/voc.mp3", b"STEMBYTES".to_vec());
4456 let plan = Plan {
4457 actions: vec![Action::MoveStem {
4458 clip_id: "a".to_owned(),
4459 key: "voc".to_owned(),
4460 stem_id: "voc".to_owned(),
4461 from: "old.stems/voc.mp3".to_owned(),
4462 to: "new.stems/voc.mp3".to_owned(),
4463 source_url: "https://cdn1.suno.ai/voc.mp3".to_owned(),
4464 format: StemFormat::Mp3,
4465 hash: "h1".to_owned(),
4466 }],
4467 };
4468
4469 let outcome = run(
4470 &plan,
4471 &mut manifest,
4472 &[],
4473 &ScriptedHttp::new(),
4474 &fs,
4475 &StubFfmpeg::flac(),
4476 &RecordingClock::new(),
4477 &ExecOptions::default(),
4478 );
4479
4480 assert_eq!(outcome.failed(), 0);
4481 assert_eq!(outcome.renamed, 1);
4482 assert_eq!(fs.read_file("new.stems/voc.mp3").unwrap(), b"STEMBYTES");
4483 assert!(!fs.exists("old.stems/voc.mp3"));
4484 assert_eq!(
4485 manifest.get("a").unwrap().stems.get("voc").unwrap().path,
4486 "new.stems/voc.mp3"
4487 );
4488 }
4489
4490 #[test]
4491 fn stem_move_falls_back_to_fetch_when_source_co_referenced() {
4492 let mut manifest = Manifest::new();
4497 let mut a = entry("a.flac", AudioFormat::Flac);
4498 a.stems.insert(
4499 "voc".to_owned(),
4500 ArtifactState {
4501 path: "shared.stems/voc.mp3".to_owned(),
4502 hash: "h".to_owned(),
4503 },
4504 );
4505 manifest.insert("a", a);
4506 let mut b = entry("b.flac", AudioFormat::Flac);
4507 b.stems.insert(
4508 "voc".to_owned(),
4509 ArtifactState {
4510 path: "shared.stems/voc.mp3".to_owned(),
4511 hash: "h".to_owned(),
4512 },
4513 );
4514 manifest.insert("b", b);
4515 let fs = MemFs::new().with_file("shared.stems/voc.mp3", b"A-STEM".to_vec());
4516 let http = ScriptedHttp::new().route("bvoc.mp3", Reply::ok(b"B-STEM".to_vec()));
4517 let plan = Plan {
4518 actions: vec![Action::MoveStem {
4519 clip_id: "b".to_owned(),
4520 key: "voc".to_owned(),
4521 stem_id: "bvoc".to_owned(),
4522 from: "shared.stems/voc.mp3".to_owned(),
4523 to: "b.stems/voc.mp3".to_owned(),
4524 source_url: "https://cdn1.suno.ai/bvoc.mp3".to_owned(),
4525 format: StemFormat::Mp3,
4526 hash: "h".to_owned(),
4527 }],
4528 };
4529
4530 let outcome = run(
4531 &plan,
4532 &mut manifest,
4533 &[],
4534 &http,
4535 &fs,
4536 &StubFfmpeg::flac(),
4537 &RecordingClock::new(),
4538 &ExecOptions::default(),
4539 );
4540
4541 assert_eq!(outcome.failed(), 0);
4542 assert_eq!(fs.read_file("b.stems/voc.mp3").unwrap(), b"B-STEM");
4544 assert_eq!(
4545 fs.read_file("shared.stems/voc.mp3").unwrap(),
4546 b"A-STEM",
4547 "the co-referenced stem must survive"
4548 );
4549 }
4550
4551 #[test]
4552 fn write_stem_keeps_shared_stem_when_co_referenced() {
4553 let mut manifest = Manifest::new();
4557 let mut a = entry("a.flac", AudioFormat::Flac);
4558 a.stems.insert(
4559 "voc".to_owned(),
4560 ArtifactState {
4561 path: "shared.stems/voc.mp3".to_owned(),
4562 hash: "h".to_owned(),
4563 },
4564 );
4565 manifest.insert("a", a);
4566 let mut b = entry("b.flac", AudioFormat::Flac);
4567 b.stems.insert(
4568 "voc".to_owned(),
4569 ArtifactState {
4570 path: "shared.stems/voc.mp3".to_owned(),
4571 hash: "h".to_owned(),
4572 },
4573 );
4574 manifest.insert("b", b);
4575 let fs = MemFs::new().with_file("shared.stems/voc.mp3", b"A-STEM".to_vec());
4576 let http = ScriptedHttp::new().route("bvoc.mp3", Reply::ok(b"B-STEM".to_vec()));
4577 let plan = Plan {
4578 actions: vec![Action::WriteStem {
4579 clip_id: "b".to_owned(),
4580 key: "voc".to_owned(),
4581 stem_id: "bvoc".to_owned(),
4582 path: "b.stems/voc.mp3".to_owned(),
4583 source_url: "https://cdn1.suno.ai/bvoc.mp3".to_owned(),
4584 format: StemFormat::Mp3,
4585 hash: "bh".to_owned(),
4586 }],
4587 };
4588
4589 let outcome = run(
4590 &plan,
4591 &mut manifest,
4592 &[],
4593 &http,
4594 &fs,
4595 &StubFfmpeg::flac(),
4596 &RecordingClock::new(),
4597 &ExecOptions::default(),
4598 );
4599
4600 assert_eq!(outcome.failed(), 0);
4601 assert_eq!(fs.read_file("b.stems/voc.mp3").unwrap(), b"B-STEM");
4602 assert_eq!(
4603 fs.read_file("shared.stems/voc.mp3").unwrap(),
4604 b"A-STEM",
4605 "the co-referenced stem must survive"
4606 );
4607 }
4608
4609 #[test]
4610 fn co_delete_executes_audio_delete_then_artifact_delete() {
4611 let fs = MemFs::new()
4615 .with_file("gone.mp3", b"DATA".to_vec())
4616 .with_file("gone/cover.jpg", b"jpg".to_vec());
4617 let mut manifest = Manifest::new();
4618 let mut e = entry("gone.mp3", AudioFormat::Mp3);
4619 e.cover_jpg = Some(ArtifactState {
4620 path: "gone/cover.jpg".to_owned(),
4621 hash: "h1".to_owned(),
4622 });
4623 manifest.insert("gone", e);
4624 let plan = Plan {
4625 actions: vec![
4626 Action::Delete {
4627 path: "gone.mp3".to_owned(),
4628 clip_id: "gone".to_owned(),
4629 },
4630 Action::DeleteArtifact {
4631 kind: ArtifactKind::CoverJpg,
4632 path: "gone/cover.jpg".to_owned(),
4633 owner_id: "gone".to_owned(),
4634 },
4635 ],
4636 };
4637
4638 let outcome = run(
4639 &plan,
4640 &mut manifest,
4641 &[],
4642 &ScriptedHttp::new(),
4643 &fs,
4644 &StubFfmpeg::flac(),
4645 &RecordingClock::new(),
4646 &ExecOptions::default(),
4647 );
4648
4649 assert_eq!(outcome.deleted, 1);
4650 assert_eq!(outcome.artifacts_deleted, 1);
4651 assert_eq!(outcome.failed(), 0);
4652 assert!(!fs.exists("gone.mp3"));
4653 assert!(!fs.exists("gone/cover.jpg"));
4654 assert!(manifest.get("gone").is_none());
4655 }
4656
4657 #[test]
4658 fn write_stem_mp3_stores_raw_and_records_slot() {
4659 let mut manifest = Manifest::new();
4663 manifest.insert("a", entry("a.flac", AudioFormat::Flac));
4664 let plan = Plan {
4665 actions: vec![Action::WriteStem {
4666 clip_id: "a".to_owned(),
4667 key: "voc".to_owned(),
4668 stem_id: "voc".to_owned(),
4669 path: "a.stems/a - Vocals [voc].mp3".to_owned(),
4670 source_url: "https://cdn1.suno.ai/voc.mp3".to_owned(),
4671 format: StemFormat::Mp3,
4672 hash: "vh".to_owned(),
4673 }],
4674 };
4675 let http = ScriptedHttp::new().route("voc.mp3", Reply::ok(b"stem-bytes".to_vec()));
4676 let fs = MemFs::new();
4677
4678 let outcome = run(
4679 &plan,
4680 &mut manifest,
4681 &[],
4682 &http,
4683 &fs,
4684 &StubFfmpeg::flac(),
4685 &RecordingClock::new(),
4686 &ExecOptions::default(),
4687 );
4688
4689 assert_eq!(outcome.artifacts_written, 1);
4690 assert_eq!(outcome.failed(), 0);
4691 assert_eq!(
4693 fs.read_file("a.stems/a - Vocals [voc].mp3").unwrap(),
4694 b"stem-bytes"
4695 );
4696 assert_eq!(http.count("convert_wav"), 0);
4698 assert_eq!(http.count("/api/gen/"), 0);
4699 assert_eq!(
4700 manifest.get("a").unwrap().stems.get("voc"),
4701 Some(&ArtifactState {
4702 path: "a.stems/a - Vocals [voc].mp3".to_owned(),
4703 hash: "vh".to_owned(),
4704 })
4705 );
4706 }
4707
4708 #[test]
4709 fn write_stem_wav_renders_via_convert_wav_and_stores_raw() {
4710 let mut manifest = Manifest::new();
4714 manifest.insert("a", entry("a.flac", AudioFormat::Flac));
4715 let plan = Plan {
4716 actions: vec![Action::WriteStem {
4717 clip_id: "a".to_owned(),
4718 key: "voc".to_owned(),
4719 stem_id: "stemvoc".to_owned(),
4720 path: "a.stems/a - Vocals [stemvoc].wav".to_owned(),
4721 source_url: "https://cdn1.suno.ai/stemvoc.mp3".to_owned(),
4722 format: StemFormat::Wav,
4723 hash: "vh".to_owned(),
4724 }],
4725 };
4726 let http = ScriptedHttp::new()
4729 .with_auth()
4730 .route_seq(
4731 "stemvoc/wav_file/",
4732 vec![
4733 Reply::json("{}"),
4734 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/stemvoc.wav"}"#),
4735 ],
4736 )
4737 .route("stemvoc/convert_wav/", Reply::status(200))
4738 .route("stemvoc.wav", Reply::ok(b"RIFFwav-bytes".to_vec()));
4739 let fs = MemFs::new();
4740
4741 let outcome = run(
4742 &plan,
4743 &mut manifest,
4744 &[],
4745 &http,
4746 &fs,
4747 &StubFfmpeg::flac(),
4748 &RecordingClock::new(),
4749 &small_poll(),
4750 );
4751
4752 assert_eq!(outcome.artifacts_written, 1);
4753 assert_eq!(outcome.failed(), 0);
4754 assert_eq!(
4757 fs.read_file("a.stems/a - Vocals [stemvoc].wav").unwrap(),
4758 b"RIFFwav-bytes"
4759 );
4760 assert!(!fs.exists("a.stems/a - Vocals [stemvoc].flac"));
4761 assert_eq!(http.count("convert_wav"), 1);
4763 assert_eq!(http.count("stem_task"), 0);
4764 assert_eq!(http.count("separate"), 0);
4765 assert_eq!(
4766 manifest.get("a").unwrap().stems.get("voc").unwrap().path,
4767 "a.stems/a - Vocals [stemvoc].wav"
4768 );
4769 }
4770
4771 #[test]
4772 fn write_stem_is_skipped_when_owner_audio_is_absent() {
4773 let mut manifest = Manifest::new();
4776 let plan = Plan {
4777 actions: vec![Action::WriteStem {
4778 clip_id: "ghost".to_owned(),
4779 key: "voc".to_owned(),
4780 stem_id: "voc".to_owned(),
4781 path: "ghost.stems/voc.mp3".to_owned(),
4782 source_url: "https://cdn1.suno.ai/voc.mp3".to_owned(),
4783 format: StemFormat::Mp3,
4784 hash: "vh".to_owned(),
4785 }],
4786 };
4787 let http = ScriptedHttp::new();
4789 let fs = MemFs::new();
4790
4791 let outcome = run(
4792 &plan,
4793 &mut manifest,
4794 &[],
4795 &http,
4796 &fs,
4797 &StubFfmpeg::flac(),
4798 &RecordingClock::new(),
4799 &ExecOptions::default(),
4800 );
4801
4802 assert_eq!(outcome.skipped, 1);
4803 assert_eq!(outcome.artifacts_written, 0);
4804 assert_eq!(outcome.failed(), 0);
4805 assert!(!fs.exists("ghost.stems/voc.mp3"));
4806 }
4807
4808 #[test]
4809 fn write_stem_relocates_the_old_file_on_a_path_move() {
4810 let fs = MemFs::new().with_file("old.stems/voc.mp3", b"old".to_vec());
4813 let mut manifest = Manifest::new();
4814 let mut e = entry("new.flac", AudioFormat::Flac);
4815 e.stems.insert(
4816 "voc".to_owned(),
4817 ArtifactState {
4818 path: "old.stems/voc.mp3".to_owned(),
4819 hash: "vh".to_owned(),
4820 },
4821 );
4822 manifest.insert("a", e);
4823 let plan = Plan {
4824 actions: vec![Action::WriteStem {
4825 clip_id: "a".to_owned(),
4826 key: "voc".to_owned(),
4827 stem_id: "voc".to_owned(),
4828 path: "new.stems/voc.mp3".to_owned(),
4829 source_url: "https://cdn1.suno.ai/voc.mp3".to_owned(),
4830 format: StemFormat::Mp3,
4831 hash: "vh".to_owned(),
4832 }],
4833 };
4834 let http = ScriptedHttp::new().route("voc.mp3", Reply::ok(b"new".to_vec()));
4835
4836 let outcome = run(
4837 &plan,
4838 &mut manifest,
4839 &[],
4840 &http,
4841 &fs,
4842 &StubFfmpeg::flac(),
4843 &RecordingClock::new(),
4844 &ExecOptions::default(),
4845 );
4846
4847 assert_eq!(outcome.artifacts_written, 1);
4848 assert!(fs.exists("new.stems/voc.mp3"));
4849 assert!(
4850 !fs.exists("old.stems/voc.mp3"),
4851 "the old stem is moved, not left behind"
4852 );
4853 assert_eq!(
4854 manifest.get("a").unwrap().stems.get("voc").unwrap().path,
4855 "new.stems/voc.mp3"
4856 );
4857 }
4858
4859 #[test]
4860 fn delete_stem_removes_file_and_clears_slot() {
4861 let fs = MemFs::new().with_file("a.stems/voc.mp3", b"stem".to_vec());
4862 let mut manifest = Manifest::new();
4863 let mut e = entry("a.flac", AudioFormat::Flac);
4864 e.stems.insert(
4865 "voc".to_owned(),
4866 ArtifactState {
4867 path: "a.stems/voc.mp3".to_owned(),
4868 hash: "vh".to_owned(),
4869 },
4870 );
4871 manifest.insert("a", e);
4872 let plan = Plan {
4873 actions: vec![Action::DeleteStem {
4874 clip_id: "a".to_owned(),
4875 key: "voc".to_owned(),
4876 path: "a.stems/voc.mp3".to_owned(),
4877 }],
4878 };
4879
4880 let outcome = run(
4881 &plan,
4882 &mut manifest,
4883 &[],
4884 &ScriptedHttp::new(),
4885 &fs,
4886 &StubFfmpeg::flac(),
4887 &RecordingClock::new(),
4888 &ExecOptions::default(),
4889 );
4890
4891 assert_eq!(outcome.artifacts_deleted, 1);
4892 assert!(!fs.exists("a.stems/voc.mp3"));
4893 assert!(manifest.get("a").unwrap().stems.is_empty());
4894 }
4895
4896 #[test]
4897 fn co_deleting_the_last_stem_prunes_the_stems_folder() {
4898 let fs = MemFs::new()
4901 .with_file("song.flac", b"DATA".to_vec())
4902 .with_file("song.stems/voc.mp3", b"stem".to_vec());
4903 assert!(fs.has_dir("song.stems"));
4904 let mut manifest = Manifest::new();
4905 let mut e = entry("song.flac", AudioFormat::Flac);
4906 e.stems.insert(
4907 "voc".to_owned(),
4908 ArtifactState {
4909 path: "song.stems/voc.mp3".to_owned(),
4910 hash: "vh".to_owned(),
4911 },
4912 );
4913 manifest.insert("a", e);
4914 let plan = Plan {
4915 actions: vec![
4916 Action::Delete {
4917 path: "song.flac".to_owned(),
4918 clip_id: "a".to_owned(),
4919 },
4920 Action::DeleteStem {
4921 clip_id: "a".to_owned(),
4922 key: "voc".to_owned(),
4923 path: "song.stems/voc.mp3".to_owned(),
4924 },
4925 ],
4926 };
4927
4928 let outcome = run(
4929 &plan,
4930 &mut manifest,
4931 &[],
4932 &ScriptedHttp::new(),
4933 &fs,
4934 &StubFfmpeg::flac(),
4935 &RecordingClock::new(),
4936 &ExecOptions::default(),
4937 );
4938
4939 assert_eq!(outcome.deleted, 1);
4940 assert_eq!(outcome.artifacts_deleted, 1);
4941 assert!(!fs.exists("song.flac"));
4942 assert!(!fs.exists("song.stems/voc.mp3"));
4943 assert!(
4944 !fs.has_dir("song.stems"),
4945 "the emptied .stems folder is pruned"
4946 );
4947 assert!(manifest.get("a").is_none());
4948 }
4949
4950 #[test]
4951 fn full_stems_mirror_mp3_is_get_only_with_zero_gen_traffic() {
4952 let http = ScriptedHttp::new()
4957 .with_auth()
4958 .route("clip1/stems/pages", Reply::json(r#"{"pages": 1}"#))
4959 .route(
4960 "clip1/stems?page=0",
4961 Reply::json(
4962 r#"{"stems":[
4963 {"id":"s1","title":"Song (Vocals)","status":"complete","audio_url":"https://cdn1.suno.ai/s1.mp3"},
4964 {"id":"s2","title":"Song (Drums)","status":"complete","audio_url":"https://cdn1.suno.ai/s2.mp3"}
4965 ]}"#,
4966 ),
4967 )
4968 .route("s1.mp3", Reply::ok(b"vocals-bytes".to_vec()))
4969 .route("s2.mp3", Reply::ok(b"drums-bytes".to_vec()));
4970
4971 let auth = ClerkAuth::new("eyJtoken");
4973 pollster::block_on(auth.authenticate(&http)).unwrap();
4974 let client = SunoClient::new(auth, RecordingClock::new());
4975 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
4976 assert!(complete);
4977 assert_eq!(stems.len(), 2);
4978 assert_eq!(stems[0].label, "Vocals");
4979
4980 let mut manifest = Manifest::new();
4982 manifest.insert("clip1", entry("clip1.flac", AudioFormat::Flac));
4983 let desired_stems: Vec<crate::reconcile::DesiredStem> = stems
4984 .iter()
4985 .map(|s| crate::reconcile::DesiredStem {
4986 key: s.id.clone(),
4987 stem_id: s.id.clone(),
4988 path: format!("clip1.stems/{}.mp3", s.id),
4989 source_url: s.url.clone(),
4990 format: StemFormat::Mp3,
4991 hash: crate::art_url_hash(&s.url),
4992 })
4993 .collect();
4994 let d = Desired {
4995 path: "clip1.flac".to_owned(),
4996 stems: Some(desired_stems),
4997 ..desired(clip("clip1"), AudioFormat::Flac)
4998 };
4999 let local: HashMap<String, crate::reconcile::LocalFile> = [(
5000 "clip1".to_owned(),
5001 crate::reconcile::LocalFile {
5002 exists: true,
5003 size: 100,
5004 },
5005 )]
5006 .into_iter()
5007 .collect();
5008 let sources = [crate::reconcile::SourceStatus {
5009 mode: SourceMode::Mirror,
5010 fully_enumerated: true,
5011 }];
5012 let plan =
5013 crate::reconcile::reconcile(&manifest, std::slice::from_ref(&d), &local, &sources);
5014 assert_eq!(plan.stem_writes(), 2);
5015
5016 let fs = MemFs::new();
5017 let outcome = run(
5018 &plan,
5019 &mut manifest,
5020 std::slice::from_ref(&d),
5021 &http,
5022 &fs,
5023 &StubFfmpeg::flac(),
5024 &RecordingClock::new(),
5025 &ExecOptions::default(),
5026 );
5027
5028 assert_eq!(outcome.artifacts_written, 2, "both stems downloaded");
5029 assert_eq!(fs.read_file("clip1.stems/s1.mp3").unwrap(), b"vocals-bytes");
5030 assert_eq!(fs.read_file("clip1.stems/s2.mp3").unwrap(), b"drums-bytes");
5031 assert_eq!(http.count("/api/gen/"), 0);
5034 assert_eq!(http.count("stem_task"), 0);
5035 assert_eq!(http.count("separate"), 0);
5036 assert_eq!(http.count("generate"), 0);
5037 assert!(!fs.exists("clip1.stems/s1.flac"));
5039 }
5040
5041 #[test]
5042 fn full_stems_mirror_wav_default_renders_free_wav_and_no_generation() {
5043 let http = ScriptedHttp::new()
5047 .with_auth()
5048 .route("clip1/stems/pages", Reply::json(r#"{"pages": 1}"#))
5049 .route(
5050 "clip1/stems?page=0",
5051 Reply::json(
5052 r#"{"stems":[
5053 {"id":"s1","title":"Song (Vocals)","status":"complete","audio_url":"https://cdn1.suno.ai/s1.mp3"},
5054 {"id":"s2","title":"Song (Drums)","status":"complete","audio_url":"https://cdn1.suno.ai/s2.mp3"}
5055 ]}"#,
5056 ),
5057 )
5058 .route(
5061 "s1/wav_file/",
5062 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/s1.wav"}"#),
5063 )
5064 .route(
5065 "s2/wav_file/",
5066 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/s2.wav"}"#),
5067 )
5068 .route("s1.wav", Reply::ok(b"RIFFvocals".to_vec()))
5069 .route("s2.wav", Reply::ok(b"RIFFdrums".to_vec()));
5070
5071 let auth = ClerkAuth::new("eyJtoken");
5072 pollster::block_on(auth.authenticate(&http)).unwrap();
5073 let client = SunoClient::new(auth, RecordingClock::new());
5074 let (stems, _complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
5075
5076 let mut manifest = Manifest::new();
5077 manifest.insert("clip1", entry("clip1.flac", AudioFormat::Flac));
5078 let desired_stems: Vec<crate::reconcile::DesiredStem> = stems
5079 .iter()
5080 .map(|s| crate::reconcile::DesiredStem {
5081 key: s.id.clone(),
5082 stem_id: s.id.clone(),
5083 path: format!("clip1.stems/{}.wav", s.id),
5084 source_url: s.url.clone(),
5085 format: StemFormat::Wav,
5086 hash: crate::art_url_hash(&s.url),
5087 })
5088 .collect();
5089 let d = Desired {
5090 path: "clip1.flac".to_owned(),
5091 stems: Some(desired_stems),
5092 ..desired(clip("clip1"), AudioFormat::Flac)
5093 };
5094 let local: HashMap<String, crate::reconcile::LocalFile> = [(
5095 "clip1".to_owned(),
5096 crate::reconcile::LocalFile {
5097 exists: true,
5098 size: 100,
5099 },
5100 )]
5101 .into_iter()
5102 .collect();
5103 let sources = [crate::reconcile::SourceStatus {
5104 mode: SourceMode::Mirror,
5105 fully_enumerated: true,
5106 }];
5107 let plan =
5108 crate::reconcile::reconcile(&manifest, std::slice::from_ref(&d), &local, &sources);
5109
5110 let fs = MemFs::new();
5111 let outcome = run(
5112 &plan,
5113 &mut manifest,
5114 std::slice::from_ref(&d),
5115 &http,
5116 &fs,
5117 &StubFfmpeg::flac(),
5118 &RecordingClock::new(),
5119 &small_poll(),
5120 );
5121
5122 assert_eq!(outcome.artifacts_written, 2);
5123 assert_eq!(fs.read_file("clip1.stems/s1.wav").unwrap(), b"RIFFvocals");
5125 assert_eq!(fs.read_file("clip1.stems/s2.wav").unwrap(), b"RIFFdrums");
5126 assert!(!fs.exists("clip1.stems/s1.flac"));
5127 assert_eq!(http.count("stem_task"), 0);
5129 assert_eq!(http.count("separate"), 0);
5130 assert_eq!(http.count("generate"), 0);
5131 }
5132
5133 #[test]
5134 fn write_artifact_is_skipped_when_the_owner_audio_is_absent() {
5135 let ca = clip("a");
5139 let plan = Plan {
5140 actions: vec![
5141 Action::Download {
5142 clip: ca.clone(),
5143 lineage: LineageContext::own_root(&ca),
5144 path: "a.mp3".to_owned(),
5145 format: AudioFormat::Mp3,
5146 },
5147 Action::WriteArtifact {
5148 kind: ArtifactKind::CoverJpg,
5149 path: "a/cover.jpg".to_owned(),
5150 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
5151 hash: "h1".to_owned(),
5152 owner_id: "a".to_owned(),
5153 content: None,
5154 },
5155 Action::WriteArtifact {
5156 kind: ArtifactKind::CoverJpg,
5157 path: "b/cover.jpg".to_owned(),
5158 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
5159 hash: "h2".to_owned(),
5160 owner_id: "b".to_owned(),
5161 content: None,
5162 },
5163 ],
5164 };
5165 let http = ScriptedHttp::new()
5167 .route("a.mp3", Reply::status(404))
5168 .route("a/large.jpg", Reply::ok(b"jpg-a".to_vec()))
5169 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
5170 let fs = MemFs::new();
5171 let mut manifest = Manifest::new();
5172 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
5174
5175 let outcome = run(
5176 &plan,
5177 &mut manifest,
5178 &[],
5179 &http,
5180 &fs,
5181 &StubFfmpeg::flac(),
5182 &RecordingClock::new(),
5183 &ExecOptions::default(),
5184 );
5185
5186 assert_eq!(outcome.status, RunStatus::Completed);
5187 assert_eq!(outcome.failed(), 1);
5189 assert_eq!(outcome.failures[0].clip_id, "a");
5190 assert_eq!(outcome.skipped, 1);
5191 assert_eq!(http.count("a/large.jpg"), 0);
5193 assert!(!fs.exists("a/cover.jpg"));
5194 assert!(manifest.get("a").is_none());
5195 assert_eq!(outcome.artifacts_written, 1);
5197 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
5198 assert!(manifest.get("b").unwrap().cover_jpg.is_some());
5199 }
5200
5201 #[test]
5202 fn write_artifact_transcodes_animated_cover_to_webp() {
5203 let mut manifest = Manifest::new();
5207 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
5208 let plan = Plan {
5209 actions: vec![Action::WriteArtifact {
5210 kind: ArtifactKind::CoverWebp,
5211 path: "a/cover.webp".to_owned(),
5212 source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
5213 hash: "v1".to_owned(),
5214 owner_id: "a".to_owned(),
5215 content: None,
5216 }],
5217 };
5218 let http = ScriptedHttp::new().route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
5219 let fs = MemFs::new();
5220 let ffmpeg = StubFfmpeg::webp();
5221
5222 let outcome = run(
5223 &plan,
5224 &mut manifest,
5225 &[],
5226 &http,
5227 &fs,
5228 &ffmpeg,
5229 &RecordingClock::new(),
5230 &ExecOptions::default(),
5231 );
5232
5233 assert_eq!(outcome.artifacts_written, 1);
5234 assert_eq!(outcome.failed(), 0);
5235 assert_eq!(outcome.status, RunStatus::Completed);
5236 assert_eq!(http.count("a/video.mp4"), 1);
5238 let written = fs.read_file("a/cover.webp").unwrap();
5239 assert_ne!(written, b"mp4-bytes");
5240 assert!(written.starts_with(b"RIFF"));
5241 assert_eq!(
5242 manifest.get("a").unwrap().cover_webp,
5243 Some(ArtifactState {
5244 path: "a/cover.webp".to_owned(),
5245 hash: "v1".to_owned(),
5246 })
5247 );
5248 }
5249
5250 #[test]
5251 fn write_artifact_webp_transcode_failure_is_per_clip() {
5252 let mut manifest = Manifest::new();
5256 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
5257 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
5258 let plan = Plan {
5259 actions: vec![
5260 Action::WriteArtifact {
5261 kind: ArtifactKind::CoverWebp,
5262 path: "a/cover.webp".to_owned(),
5263 source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
5264 hash: "v1".to_owned(),
5265 owner_id: "a".to_owned(),
5266 content: None,
5267 },
5268 Action::WriteArtifact {
5269 kind: ArtifactKind::CoverJpg,
5270 path: "b/cover.jpg".to_owned(),
5271 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
5272 hash: "h1".to_owned(),
5273 owner_id: "b".to_owned(),
5274 content: None,
5275 },
5276 ],
5277 };
5278 let http = ScriptedHttp::new()
5279 .route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec()))
5280 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
5281 let fs = MemFs::new();
5282
5283 let outcome = run(
5284 &plan,
5285 &mut manifest,
5286 &[],
5287 &http,
5288 &fs,
5289 &StubFfmpeg::failing(),
5290 &RecordingClock::new(),
5291 &ExecOptions::default(),
5292 );
5293
5294 assert_eq!(outcome.status, RunStatus::Completed);
5295 assert_eq!(outcome.failed(), 1);
5296 assert_eq!(outcome.failures[0].clip_id, "a");
5297 assert!(!fs.exists("a/cover.webp"));
5299 assert_eq!(manifest.get("a").unwrap().cover_webp, None);
5300 assert_eq!(outcome.artifacts_written, 1);
5302 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
5303 assert!(manifest.get("b").unwrap().cover_jpg.is_some());
5304 }
5305
5306 #[test]
5307 fn write_artifact_uses_configured_webp_settings() {
5308 use std::sync::{Arc, Mutex};
5309
5310 struct RecordingWebpFfmpeg {
5311 seen: Arc<Mutex<Vec<WebpEncodeSettings>>>,
5312 }
5313
5314 impl Ffmpeg for RecordingWebpFfmpeg {
5315 async fn wav_to_lossless(
5316 &self,
5317 _wav: &[u8],
5318 _format: AudioFormat,
5319 ) -> Result<Vec<u8>, crate::ffmpeg::FfmpegError> {
5320 Ok(Vec::new())
5321 }
5322
5323 async fn mp4_to_webp(
5324 &self,
5325 _mp4: &[u8],
5326 settings: WebpEncodeSettings,
5327 ) -> Result<Vec<u8>, crate::ffmpeg::FfmpegError> {
5328 let seen = Arc::clone(&self.seen);
5329 seen.lock().unwrap().push(settings);
5330 Ok(b"RIFF\x00\x00\x00\x00WEBP".to_vec())
5331 }
5332 }
5333
5334 let mut manifest = Manifest::new();
5335 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
5336 let plan = Plan {
5337 actions: vec![Action::WriteArtifact {
5338 kind: ArtifactKind::CoverWebp,
5339 path: "a/cover.webp".to_owned(),
5340 source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
5341 hash: "v1".to_owned(),
5342 owner_id: "a".to_owned(),
5343 content: None,
5344 }],
5345 };
5346 let seen = Arc::new(Mutex::new(Vec::new()));
5347 let ffmpeg = RecordingWebpFfmpeg {
5348 seen: Arc::clone(&seen),
5349 };
5350 let opts = ExecOptions {
5351 cover_webp: WebpEncodeSettings {
5352 quality: 88,
5353 max_fps: 12,
5354 max_width: Some(720),
5355 lossless: false,
5356 compression_level: 4,
5357 },
5358 ..ExecOptions::default()
5359 };
5360
5361 let _ = run(
5362 &plan,
5363 &mut manifest,
5364 &[],
5365 &ScriptedHttp::new().route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec())),
5366 &MemFs::new(),
5367 &ffmpeg,
5368 &RecordingClock::new(),
5369 &opts,
5370 );
5371
5372 assert_eq!(
5373 seen.lock().unwrap().as_slice(),
5374 &[WebpEncodeSettings {
5375 quality: 88,
5376 max_fps: 12,
5377 max_width: Some(720),
5378 lossless: false,
5379 compression_level: 4,
5380 }]
5381 );
5382 }
5383
5384 #[test]
5387 fn folder_jpg_write_records_album_state_and_skips_manifest() {
5388 let mut manifest = Manifest::new();
5391 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
5392 let plan = Plan {
5393 actions: vec![Action::WriteArtifact {
5394 kind: ArtifactKind::FolderJpg,
5395 path: "creator/album/folder.jpg".to_owned(),
5396 source_url: "https://art.suno.ai/root/large.jpg".to_owned(),
5397 hash: "jh".to_owned(),
5398 owner_id: "root".to_owned(),
5399 content: None,
5400 }],
5401 };
5402 let http = ScriptedHttp::new().route("root/large.jpg", Reply::ok(b"folder-jpg".to_vec()));
5403 let fs = MemFs::new();
5404
5405 let outcome = run_with_albums(
5406 &plan,
5407 &mut manifest,
5408 &mut albums,
5409 &[],
5410 &http,
5411 &fs,
5412 &StubFfmpeg::flac(),
5413 &RecordingClock::new(),
5414 &ExecOptions::default(),
5415 );
5416
5417 assert_eq!(outcome.artifacts_written, 1);
5418 assert_eq!(outcome.status, RunStatus::Completed);
5419 assert_eq!(
5420 fs.read_file("creator/album/folder.jpg").unwrap(),
5421 b"folder-jpg"
5422 );
5423 assert_eq!(
5424 albums.get("root").unwrap().folder_jpg,
5425 Some(ArtifactState {
5426 path: "creator/album/folder.jpg".to_owned(),
5427 hash: "jh".to_owned(),
5428 })
5429 );
5430 assert!(manifest.get("root").is_none());
5431 }
5432
5433 #[test]
5434 fn folder_webp_write_transcodes_and_records_album_state() {
5435 let mut manifest = Manifest::new();
5436 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
5437 let plan = Plan {
5438 actions: vec![Action::WriteArtifact {
5439 kind: ArtifactKind::FolderWebp,
5440 path: "creator/album/cover.webp".to_owned(),
5441 source_url: "https://cdn.suno.ai/root/video.mp4".to_owned(),
5442 hash: "wh".to_owned(),
5443 owner_id: "root".to_owned(),
5444 content: None,
5445 }],
5446 };
5447 let http = ScriptedHttp::new().route("root/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
5448 let fs = MemFs::new();
5449
5450 let outcome = run_with_albums(
5451 &plan,
5452 &mut manifest,
5453 &mut albums,
5454 &[],
5455 &http,
5456 &fs,
5457 &StubFfmpeg::webp(),
5458 &RecordingClock::new(),
5459 &ExecOptions::default(),
5460 );
5461
5462 assert_eq!(outcome.artifacts_written, 1);
5463 assert_eq!(outcome.failed(), 0);
5464 let written = fs.read_file("creator/album/cover.webp").unwrap();
5466 assert_ne!(written, b"mp4-bytes");
5467 assert!(written.starts_with(b"RIFF"));
5468 assert_eq!(
5469 albums.get("root").unwrap().folder_webp,
5470 Some(ArtifactState {
5471 path: "creator/album/cover.webp".to_owned(),
5472 hash: "wh".to_owned(),
5473 })
5474 );
5475 }
5476
5477 #[test]
5478 fn folder_mp4_write_keeps_the_source_verbatim() {
5479 let mut manifest = Manifest::new();
5480 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
5481 let plan = Plan {
5482 actions: vec![Action::WriteArtifact {
5483 kind: ArtifactKind::FolderMp4,
5484 path: "creator/album/cover.mp4".to_owned(),
5485 source_url: "https://cdn.suno.ai/root/video.mp4".to_owned(),
5486 hash: "mh".to_owned(),
5487 owner_id: "root".to_owned(),
5488 content: None,
5489 }],
5490 };
5491 let http = ScriptedHttp::new().route("root/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
5492 let fs = MemFs::new();
5493
5494 let outcome = run_with_albums(
5495 &plan,
5496 &mut manifest,
5497 &mut albums,
5498 &[],
5499 &http,
5500 &fs,
5501 &StubFfmpeg::webp(),
5502 &RecordingClock::new(),
5503 &ExecOptions::default(),
5504 );
5505
5506 assert_eq!(outcome.artifacts_written, 1);
5507 assert_eq!(outcome.failed(), 0);
5508 assert_eq!(
5510 fs.read_file("creator/album/cover.mp4").unwrap(),
5511 b"mp4-bytes"
5512 );
5513 assert_eq!(
5514 albums.get("root").unwrap().folder_mp4,
5515 Some(ArtifactState {
5516 path: "creator/album/cover.mp4".to_owned(),
5517 hash: "mh".to_owned(),
5518 })
5519 );
5520 }
5521
5522 #[test]
5523 fn both_folder_covers_fetch_the_video_cover_once() {
5524 let mut manifest = Manifest::new();
5525 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
5526 let plan = Plan {
5530 actions: vec![
5531 Action::WriteArtifact {
5532 kind: ArtifactKind::FolderWebp,
5533 path: "creator/album/cover.webp".to_owned(),
5534 source_url: "https://cdn.suno.ai/root/video.mp4".to_owned(),
5535 hash: "wh".to_owned(),
5536 owner_id: "root".to_owned(),
5537 content: None,
5538 },
5539 Action::WriteArtifact {
5540 kind: ArtifactKind::FolderMp4,
5541 path: "creator/album/cover.mp4".to_owned(),
5542 source_url: "https://cdn.suno.ai/root/video.mp4".to_owned(),
5543 hash: "mh".to_owned(),
5544 owner_id: "root".to_owned(),
5545 content: None,
5546 },
5547 ],
5548 };
5549 let http = ScriptedHttp::new().route("root/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
5550 let fs = MemFs::new();
5551
5552 let outcome = run_with_albums(
5553 &plan,
5554 &mut manifest,
5555 &mut albums,
5556 &[],
5557 &http,
5558 &fs,
5559 &StubFfmpeg::webp(),
5560 &RecordingClock::new(),
5561 &ExecOptions::default(),
5562 );
5563
5564 assert_eq!(outcome.artifacts_written, 2);
5565 assert_eq!(outcome.failed(), 0);
5566 assert_eq!(http.count("root/video.mp4"), 1);
5568 assert!(
5570 fs.read_file("creator/album/cover.webp")
5571 .unwrap()
5572 .starts_with(b"RIFF")
5573 );
5574 assert_eq!(
5575 fs.read_file("creator/album/cover.mp4").unwrap(),
5576 b"mp4-bytes"
5577 );
5578 }
5579
5580 #[test]
5581 fn folder_art_delete_clears_album_state() {
5582 let fs = MemFs::new().with_file("creator/album/folder.jpg", b"jpg".to_vec());
5583 let mut manifest = Manifest::new();
5584 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
5585 albums.insert(
5586 "root".to_owned(),
5587 AlbumArt {
5588 folder_jpg: Some(ArtifactState {
5589 path: "creator/album/folder.jpg".to_owned(),
5590 hash: "jh".to_owned(),
5591 }),
5592 folder_webp: None,
5593 folder_mp4: None,
5594 },
5595 );
5596 let plan = Plan {
5597 actions: vec![Action::DeleteArtifact {
5598 kind: ArtifactKind::FolderJpg,
5599 path: "creator/album/folder.jpg".to_owned(),
5600 owner_id: "root".to_owned(),
5601 }],
5602 };
5603
5604 let outcome = run_with_albums(
5605 &plan,
5606 &mut manifest,
5607 &mut albums,
5608 &[],
5609 &ScriptedHttp::new(),
5610 &fs,
5611 &StubFfmpeg::flac(),
5612 &RecordingClock::new(),
5613 &ExecOptions::default(),
5614 );
5615
5616 assert_eq!(outcome.artifacts_deleted, 1);
5617 assert!(!fs.exists("creator/album/folder.jpg"));
5618 assert!(!albums.contains_key("root"));
5620 }
5621
5622 #[test]
5625 fn playlist_write_uses_inline_content_and_records_state() {
5626 let mut manifest = Manifest::new();
5630 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
5631 let mut playlists: BTreeMap<String, PlaylistState> = BTreeMap::new();
5632 let body = "#EXTM3U\n#PLAYLIST:Road Trip\n#EXTINF:60,One\nA/One.flac\n";
5633 let plan = Plan {
5634 actions: vec![Action::WriteArtifact {
5635 kind: ArtifactKind::Playlist,
5636 path: "Road Trip.m3u8".to_owned(),
5637 source_url: String::new(),
5638 hash: "ph1".to_owned(),
5639 owner_id: "pl1".to_owned(),
5640 content: Some(body.to_owned()),
5641 }],
5642 };
5643 let fs = MemFs::new();
5644
5645 let outcome = run_full(
5646 &plan,
5647 &mut manifest,
5648 &mut albums,
5649 &mut playlists,
5650 &[],
5651 &ScriptedHttp::new(),
5652 &fs,
5653 &StubFfmpeg::flac(),
5654 &RecordingClock::new(),
5655 &ExecOptions::default(),
5656 );
5657
5658 assert_eq!(outcome.artifacts_written, 1);
5659 assert_eq!(outcome.failed(), 0);
5660 assert_eq!(fs.read_file("Road Trip.m3u8").unwrap(), body.as_bytes());
5662 assert_eq!(
5663 playlists.get("pl1"),
5664 Some(&PlaylistState {
5665 name: "Road Trip".to_owned(),
5666 path: "Road Trip.m3u8".to_owned(),
5667 hash: "ph1".to_owned(),
5668 })
5669 );
5670 }
5671
5672 #[test]
5673 fn playlist_delete_removes_file_and_clears_state() {
5674 let fs = MemFs::new().with_file("Old.m3u8", b"#EXTM3U\n".to_vec());
5675 let mut manifest = Manifest::new();
5676 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
5677 let mut playlists: BTreeMap<String, PlaylistState> = BTreeMap::new();
5678 playlists.insert(
5679 "pl1".to_owned(),
5680 PlaylistState {
5681 name: "Old".to_owned(),
5682 path: "Old.m3u8".to_owned(),
5683 hash: "ph1".to_owned(),
5684 },
5685 );
5686 let plan = Plan {
5687 actions: vec![Action::DeleteArtifact {
5688 kind: ArtifactKind::Playlist,
5689 path: "Old.m3u8".to_owned(),
5690 owner_id: "pl1".to_owned(),
5691 }],
5692 };
5693
5694 let outcome = run_full(
5695 &plan,
5696 &mut manifest,
5697 &mut albums,
5698 &mut playlists,
5699 &[],
5700 &ScriptedHttp::new(),
5701 &fs,
5702 &StubFfmpeg::flac(),
5703 &RecordingClock::new(),
5704 &ExecOptions::default(),
5705 );
5706
5707 assert_eq!(outcome.artifacts_deleted, 1);
5708 assert!(!fs.exists("Old.m3u8"));
5709 assert!(
5710 !playlists.contains_key("pl1"),
5711 "the playlist row is cleared on delete"
5712 );
5713 }
5714
5715 #[test]
5718 fn rename_move_relocates_cover_and_prunes_old_album() {
5719 let mut manifest = Manifest::new();
5723 let mut e = entry("Creator/AlbumA/song.flac", AudioFormat::Flac);
5724 e.cover_jpg = Some(ArtifactState {
5725 path: "Creator/AlbumA/cover.jpg".to_owned(),
5726 hash: "h1".to_owned(),
5727 });
5728 manifest.insert("a", e);
5729 let fs = MemFs::new()
5730 .with_file("Creator/AlbumA/song.flac", b"AUDIO".to_vec())
5731 .with_file("Creator/AlbumA/cover.jpg", b"old-jpg".to_vec());
5732 let plan = Plan {
5733 actions: vec![
5734 Action::Rename {
5735 from: "Creator/AlbumA/song.flac".to_owned(),
5736 to: "Creator/AlbumB/song.flac".to_owned(),
5737 },
5738 Action::WriteArtifact {
5739 kind: ArtifactKind::CoverJpg,
5740 path: "Creator/AlbumB/cover.jpg".to_owned(),
5741 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
5742 hash: "h1".to_owned(),
5743 owner_id: "a".to_owned(),
5744 content: None,
5745 },
5746 ],
5747 };
5748 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new-jpg".to_vec()));
5749
5750 let outcome = run(
5751 &plan,
5752 &mut manifest,
5753 &[],
5754 &http,
5755 &fs,
5756 &StubFfmpeg::flac(),
5757 &RecordingClock::new(),
5758 &ExecOptions::default(),
5759 );
5760
5761 assert_eq!(outcome.failed(), 0);
5762 assert!(fs.exists("Creator/AlbumB/song.flac"));
5764 assert_eq!(
5765 fs.read_file("Creator/AlbumB/cover.jpg").unwrap(),
5766 b"new-jpg"
5767 );
5768 assert!(!fs.exists("Creator/AlbumA/cover.jpg"));
5769 assert!(!fs.exists("Creator/AlbumA/song.flac"));
5770 assert_eq!(
5772 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
5773 "Creator/AlbumB/cover.jpg"
5774 );
5775 assert!(!fs.has_dir("Creator/AlbumA"));
5777 assert!(fs.has_dir("Creator/AlbumB"));
5778 }
5779
5780 #[test]
5781 fn rename_move_relocates_folder_art_and_prunes_old_album() {
5782 let mut manifest = Manifest::new();
5785 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
5786 albums.insert(
5787 "root".to_owned(),
5788 AlbumArt {
5789 folder_jpg: Some(ArtifactState {
5790 path: "Creator/AlbumA/folder.jpg".to_owned(),
5791 hash: "jh".to_owned(),
5792 }),
5793 folder_webp: None,
5794 folder_mp4: None,
5795 },
5796 );
5797 let fs = MemFs::new().with_file("Creator/AlbumA/folder.jpg", b"old-folder".to_vec());
5798 let plan = Plan {
5799 actions: vec![Action::WriteArtifact {
5800 kind: ArtifactKind::FolderJpg,
5801 path: "Creator/AlbumB/folder.jpg".to_owned(),
5802 source_url: "https://art.suno.ai/root/large.jpg".to_owned(),
5803 hash: "jh".to_owned(),
5804 owner_id: "root".to_owned(),
5805 content: None,
5806 }],
5807 };
5808 let http = ScriptedHttp::new().route("root/large.jpg", Reply::ok(b"new-folder".to_vec()));
5809
5810 let outcome = run_with_albums(
5811 &plan,
5812 &mut manifest,
5813 &mut albums,
5814 &[],
5815 &http,
5816 &fs,
5817 &StubFfmpeg::flac(),
5818 &RecordingClock::new(),
5819 &ExecOptions::default(),
5820 );
5821
5822 assert_eq!(outcome.failed(), 0);
5823 assert_eq!(
5824 fs.read_file("Creator/AlbumB/folder.jpg").unwrap(),
5825 b"new-folder"
5826 );
5827 assert!(!fs.exists("Creator/AlbumA/folder.jpg"));
5828 assert_eq!(
5829 albums
5830 .get("root")
5831 .unwrap()
5832 .folder_jpg
5833 .as_ref()
5834 .unwrap()
5835 .path,
5836 "Creator/AlbumB/folder.jpg"
5837 );
5838 assert!(!fs.has_dir("Creator/AlbumA"));
5839 assert!(fs.has_dir("Creator/AlbumB"));
5840 }
5841
5842 #[test]
5843 fn prune_empty_dirs_removes_only_empty_dirs() {
5844 let fs = MemFs::new()
5848 .with_file("keep/full/song.flac", b"x".to_vec())
5849 .with_file("hidden/.suno-manifest.json", b"{}".to_vec())
5850 .with_dir("empty/leaf")
5851 .with_dir("nested/a/b/c");
5852
5853 fs.prune_empty_dirs("").unwrap();
5854
5855 for gone in [
5857 "empty",
5858 "empty/leaf",
5859 "nested",
5860 "nested/a",
5861 "nested/a/b",
5862 "nested/a/b/c",
5863 ] {
5864 assert!(!fs.has_dir(gone), "empty dir {gone} should be pruned");
5865 }
5866 assert!(fs.has_dir("keep"));
5868 assert!(fs.has_dir("keep/full"));
5869 assert!(fs.has_dir("hidden"));
5870 assert!(fs.exists("keep/full/song.flac"));
5872 assert!(fs.exists("hidden/.suno-manifest.json"));
5873 }
5874
5875 #[test]
5876 fn prune_empty_dirs_never_removes_the_named_root() {
5877 let fs = MemFs::new().with_dir("empty/leaf");
5880 fs.prune_empty_dirs("empty").unwrap();
5881 assert!(fs.has_dir("empty"), "the named root is never removed");
5882 assert!(!fs.has_dir("empty/leaf"));
5883 }
5884
5885 #[test]
5886 fn old_sidecar_remove_failure_is_per_clip_and_converges_next_run() {
5887 let mut manifest = Manifest::new();
5891 let mut e = entry("a.flac", AudioFormat::Flac);
5892 e.cover_jpg = Some(ArtifactState {
5893 path: "AlbumA/cover.jpg".to_owned(),
5894 hash: "h1".to_owned(),
5895 });
5896 manifest.insert("a", e);
5897 let fs = MemFs::new()
5898 .with_file("a.flac", b"AUDIO".to_vec())
5899 .with_file("AlbumA/cover.jpg", b"old".to_vec());
5900 let plan = Plan {
5901 actions: vec![Action::WriteArtifact {
5902 kind: ArtifactKind::CoverJpg,
5903 path: "AlbumB/cover.jpg".to_owned(),
5904 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
5905 hash: "h1".to_owned(),
5906 owner_id: "a".to_owned(),
5907 content: None,
5908 }],
5909 };
5910 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new".to_vec()));
5911
5912 fs.arm_fail_remove("AlbumA/cover.jpg");
5914 let first = run(
5915 &plan,
5916 &mut manifest,
5917 &[],
5918 &http,
5919 &fs,
5920 &StubFfmpeg::flac(),
5921 &RecordingClock::new(),
5922 &ExecOptions::default(),
5923 );
5924 assert_eq!(
5925 first.status,
5926 RunStatus::Completed,
5927 "a remove failure never aborts the run"
5928 );
5929 assert_eq!(first.failed(), 1);
5930 assert!(fs.exists("AlbumB/cover.jpg"));
5932 assert!(fs.exists("AlbumA/cover.jpg"));
5933 assert_eq!(
5934 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
5935 "AlbumA/cover.jpg"
5936 );
5937 assert!(fs.has_dir("AlbumA"), "the orphan keeps its directory alive");
5938
5939 fs.disarm_fail_remove("AlbumA/cover.jpg");
5941 let second = run(
5942 &plan,
5943 &mut manifest,
5944 &[],
5945 &http,
5946 &fs,
5947 &StubFfmpeg::flac(),
5948 &RecordingClock::new(),
5949 &ExecOptions::default(),
5950 );
5951 assert_eq!(second.failed(), 0);
5952 assert!(fs.exists("AlbumB/cover.jpg"));
5953 assert!(!fs.exists("AlbumA/cover.jpg"), "no orphan persists");
5954 assert_eq!(
5955 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
5956 "AlbumB/cover.jpg"
5957 );
5958 assert!(!fs.has_dir("AlbumA"), "the emptied directory is pruned");
5959 }
5960
5961 #[test]
5962 fn same_path_artifact_rewrite_does_no_remove_and_prunes_nothing() {
5963 let mut manifest = Manifest::new();
5968 let mut e = entry("Album/a.mp3", AudioFormat::Mp3);
5969 e.cover_jpg = Some(ArtifactState {
5970 path: "Album/cover.jpg".to_owned(),
5971 hash: "h1".to_owned(),
5972 });
5973 manifest.insert("a", e);
5974 let fs = MemFs::new()
5975 .with_file("Album/a.mp3", b"AUDIO".to_vec())
5976 .with_file("Album/cover.jpg", b"old".to_vec());
5977 fs.arm_fail_remove("Album/cover.jpg");
5978 let plan = Plan {
5979 actions: vec![Action::WriteArtifact {
5980 kind: ArtifactKind::CoverJpg,
5981 path: "Album/cover.jpg".to_owned(),
5982 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
5983 hash: "h2".to_owned(),
5984 owner_id: "a".to_owned(),
5985 content: None,
5986 }],
5987 };
5988 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new".to_vec()));
5989
5990 let outcome = run(
5991 &plan,
5992 &mut manifest,
5993 &[],
5994 &http,
5995 &fs,
5996 &StubFfmpeg::flac(),
5997 &RecordingClock::new(),
5998 &ExecOptions::default(),
5999 );
6000
6001 assert_eq!(
6002 outcome.failed(),
6003 0,
6004 "no remove is attempted, so the armed failure never fires"
6005 );
6006 assert_eq!(outcome.artifacts_written, 1);
6007 assert_eq!(fs.read_file("Album/cover.jpg").unwrap(), b"new");
6008 assert_eq!(
6009 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().hash,
6010 "h2"
6011 );
6012 assert!(fs.has_dir("Album"));
6014 }
6015
6016 mod concurrency {
6019 use super::*;
6020 use crate::ffmpeg::FfmpegError;
6021 use crate::fs::{FileStat, FsError};
6022 use crate::http::{HttpRequest, TransportError};
6023 use std::future::Future;
6024 use std::pin::Pin;
6025 use std::sync::Arc;
6026 use std::sync::atomic::{AtomicUsize, Ordering};
6027 use std::task::{Context, Poll};
6028
6029 #[derive(Default)]
6034 struct YieldOnce {
6035 yielded: bool,
6036 }
6037
6038 impl Future for YieldOnce {
6039 type Output = ();
6040 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
6041 if self.yielded {
6042 Poll::Ready(())
6043 } else {
6044 self.yielded = true;
6045 cx.waker().wake_by_ref();
6046 Poll::Pending
6047 }
6048 }
6049 }
6050
6051 struct GatedHttp {
6055 inner: ScriptedHttp,
6056 inflight: Arc<AtomicUsize>,
6057 peak: Arc<AtomicUsize>,
6058 }
6059
6060 impl GatedHttp {
6061 fn new(inner: ScriptedHttp) -> Self {
6062 Self {
6063 inner,
6064 inflight: Arc::new(AtomicUsize::new(0)),
6065 peak: Arc::new(AtomicUsize::new(0)),
6066 }
6067 }
6068
6069 fn peak(&self) -> usize {
6070 self.peak.load(Ordering::SeqCst)
6071 }
6072
6073 fn count(&self, needle: &str) -> usize {
6074 self.inner.count(needle)
6075 }
6076 }
6077
6078 impl Http for GatedHttp {
6079 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, TransportError> {
6080 let now = self.inflight.fetch_add(1, Ordering::SeqCst) + 1;
6081 self.peak.fetch_max(now, Ordering::SeqCst);
6082 YieldOnce::default().await;
6083 let out = self.inner.send(request).await;
6084 self.inflight.fetch_sub(1, Ordering::SeqCst);
6085 out
6086 }
6087 }
6088
6089 fn download(id: &str, format: AudioFormat) -> (Clip, Desired, Action) {
6090 let c = clip(id);
6091 let d = desired(c.clone(), format);
6092 let action = Action::Download {
6093 clip: c.clone(),
6094 lineage: LineageContext::own_root(&c),
6095 path: d.path.clone(),
6096 format,
6097 };
6098 (c, d, action)
6099 }
6100
6101 fn opts_with(concurrency: u32) -> ExecOptions {
6102 ExecOptions {
6103 concurrency,
6104 ..small_poll()
6105 }
6106 }
6107
6108 #[test]
6109 fn concurrency_never_exceeds_the_configured_bound() {
6110 let count = 6;
6111 let concurrency = 3;
6112 let mut scripted = ScriptedHttp::new().with_auth();
6113 let mut actions = Vec::new();
6114 let mut desireds = Vec::new();
6115 for i in 0..count {
6116 let id = format!("c{i}");
6117 scripted = scripted.route(&format!("{id}.mp3"), Reply::ok(b"mp3-body".to_vec()));
6118 let (_c, d, action) = download(&id, AudioFormat::Mp3);
6119 actions.push(action);
6120 desireds.push(d);
6121 }
6122 let http = GatedHttp::new(scripted);
6123 let fs = MemFs::new();
6124 let plan = Plan { actions };
6125 let mut manifest = Manifest::new();
6126
6127 let outcome = run_gated_fs(
6128 &plan,
6129 &mut manifest,
6130 &desireds,
6131 &http,
6132 &fs,
6133 &opts_with(concurrency),
6134 );
6135
6136 assert_eq!(outcome.downloaded, count);
6137 assert!(
6138 http.peak() <= concurrency as usize,
6139 "peak {} exceeded the bound {concurrency}",
6140 http.peak()
6141 );
6142 assert_eq!(
6143 http.peak(),
6144 concurrency as usize,
6145 "expected the run to saturate the bound"
6146 );
6147 }
6148
6149 fn run_gated_fs(
6153 plan: &Plan,
6154 manifest: &mut Manifest,
6155 desired: &[Desired],
6156 http: &GatedHttp,
6157 fs: &MemFs,
6158 opts: &ExecOptions,
6159 ) -> ExecOutcome {
6160 let ffmpeg = StubFfmpeg::flac();
6161 let clock = RecordingClock::new();
6162 let mut albums = BTreeMap::new();
6163 let mut playlists = BTreeMap::new();
6164 let client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
6165 pollster::block_on(execute(
6166 plan,
6167 manifest,
6168 &mut albums,
6169 &mut playlists,
6170 desired,
6171 &HashMap::new(),
6172 Ports {
6173 client: &client,
6174 http,
6175 fs,
6176 ffmpeg: &ffmpeg,
6177 clock: &clock,
6178 },
6179 opts,
6180 ))
6181 }
6182
6183 #[test]
6184 fn a_failing_clip_does_not_abort_the_others() {
6185 let mut scripted = ScriptedHttp::new().with_auth();
6186 scripted = scripted
6187 .route("ok1.mp3", Reply::ok(b"one".to_vec()))
6188 .route("bad.mp3", Reply::status(404))
6189 .route("ok2.mp3", Reply::ok(b"two".to_vec()));
6190 let (_a, d1, a1) = download("ok1", AudioFormat::Mp3);
6191 let (_b, d2, a2) = download("bad", AudioFormat::Mp3);
6192 let (_c, d3, a3) = download("ok2", AudioFormat::Mp3);
6193 let http = GatedHttp::new(scripted);
6194 let fs = MemFs::new();
6195 let plan = Plan {
6196 actions: vec![a1, a2, a3],
6197 };
6198 let mut manifest = Manifest::new();
6199
6200 let outcome = run_gated_fs(
6201 &plan,
6202 &mut manifest,
6203 &[d1, d2, d3],
6204 &http,
6205 &fs,
6206 &opts_with(3),
6207 );
6208
6209 assert_eq!(outcome.downloaded, 2);
6210 assert_eq!(outcome.failed(), 1);
6211 assert_eq!(outcome.status, RunStatus::Completed);
6212 assert_eq!(outcome.failures[0].clip_id, "bad");
6213 assert!(manifest.get("ok1").is_some());
6214 assert!(manifest.get("ok2").is_some());
6215 assert!(manifest.get("bad").is_none());
6216 }
6217
6218 #[test]
6219 fn outcome_is_identical_across_concurrency_levels() {
6220 fn build() -> (Plan, Vec<Desired>) {
6223 let mut actions = Vec::new();
6224 let mut desireds = Vec::new();
6225 for id in ["a", "b", "c", "d"] {
6226 let (_c, d, action) = download(id, AudioFormat::Mp3);
6227 actions.push(action);
6228 desireds.push(d);
6229 }
6230 let (_e, de, ae) = download("fail", AudioFormat::Mp3);
6232 actions.insert(2, ae);
6233 desireds.push(de);
6234 actions.push(Action::Skip {
6236 clip_id: "gone".to_owned(),
6237 });
6238 actions.push(Action::Delete {
6239 path: "old.mp3".to_owned(),
6240 clip_id: "old".to_owned(),
6241 });
6242 (Plan { actions }, desireds)
6243 }
6244
6245 fn http() -> ScriptedHttp {
6246 ScriptedHttp::new()
6247 .with_auth()
6248 .route("a.mp3", Reply::ok(b"a".to_vec()))
6249 .route("b.mp3", Reply::ok(b"b".to_vec()))
6250 .route("c.mp3", Reply::ok(b"c".to_vec()))
6251 .route("d.mp3", Reply::ok(b"d".to_vec()))
6252 .route("fail.mp3", Reply::status(404))
6253 }
6254
6255 fn seed_manifest() -> Manifest {
6256 let mut m = Manifest::new();
6257 m.insert("old".to_owned(), entry("old.mp3", AudioFormat::Mp3));
6258 m
6259 }
6260
6261 let (plan, desireds) = build();
6262
6263 let mut m1 = seed_manifest();
6264 let fs1 = MemFs::new().with_file("old.mp3", b"x".to_vec());
6265 let out1 = run_gated_fs(
6266 &plan,
6267 &mut m1,
6268 &desireds,
6269 &GatedHttp::new(http()),
6270 &fs1,
6271 &opts_with(1),
6272 );
6273
6274 let mut m8 = seed_manifest();
6275 let fs8 = MemFs::new().with_file("old.mp3", b"x".to_vec());
6276 let out8 = run_gated_fs(
6277 &plan,
6278 &mut m8,
6279 &desireds,
6280 &GatedHttp::new(http()),
6281 &fs8,
6282 &opts_with(8),
6283 );
6284
6285 assert_eq!(out1, out8, "outcome must not depend on concurrency");
6286 assert_eq!(m1, m8, "final manifest must not depend on concurrency");
6287 assert_eq!(out8.downloaded, 4);
6288 assert_eq!(out8.deleted, 1);
6289 assert_eq!(out8.skipped, 1);
6290 assert_eq!(out8.failed(), 1);
6291 }
6292
6293 #[test]
6294 fn a_systemic_disk_full_aborts_promptly() {
6295 let count = 8;
6296 let concurrency = 2;
6297 let mut scripted = ScriptedHttp::new().with_auth();
6298 let mut actions = Vec::new();
6299 let mut desireds = Vec::new();
6300 for i in 0..count {
6301 let id = format!("d{i}");
6302 scripted = scripted.route(&format!("{id}.mp3"), Reply::ok(b"mp3-body".to_vec()));
6303 let (_c, d, action) = download(&id, AudioFormat::Mp3);
6304 actions.push(action);
6305 desireds.push(d);
6306 }
6307 let fs = MemFs::new().fail_write_out_of_space("d0.mp3");
6309 let http = GatedHttp::new(scripted);
6310 let plan = Plan { actions };
6311 let mut manifest = Manifest::new();
6312
6313 let outcome = run_gated_fs(
6314 &plan,
6315 &mut manifest,
6316 &desireds,
6317 &http,
6318 &fs,
6319 &opts_with(concurrency),
6320 );
6321
6322 assert_eq!(outcome.status, RunStatus::DiskFull);
6323 assert!(
6324 outcome.downloaded < count,
6325 "a systemic abort must stop remaining work, downloaded {}",
6326 outcome.downloaded
6327 );
6328 }
6329
6330 #[test]
6331 fn limiter_records_a_rate_limit_under_concurrent_calls() {
6332 let scripted = ScriptedHttp::new()
6337 .with_auth()
6338 .route_seq(
6339 "/gen/x/wav_file/",
6340 vec![
6341 Reply::status(429),
6342 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/x.wav"}"#),
6343 ],
6344 )
6345 .route(
6346 "/gen/y/wav_file/",
6347 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/y.wav"}"#),
6348 )
6349 .route(
6350 "/gen/z/wav_file/",
6351 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#),
6352 )
6353 .route("x.wav", Reply::ok(b"wav-x".to_vec()))
6354 .route("y.wav", Reply::ok(b"wav-y".to_vec()))
6355 .route("z.wav", Reply::ok(b"wav-z".to_vec()));
6356
6357 let mut actions = Vec::new();
6358 let mut desireds = Vec::new();
6359 for id in ["x", "y", "z"] {
6360 let (_c, d, action) = download(id, AudioFormat::Flac);
6361 actions.push(action);
6362 desireds.push(d);
6363 }
6364 let plan = Plan { actions };
6365 let fs = MemFs::new();
6366 let ffmpeg = StubFfmpeg::flac();
6367 let clock = RecordingClock::new();
6368 let mut albums = BTreeMap::new();
6369 let mut playlists = BTreeMap::new();
6370 let mut manifest = Manifest::new();
6371 let client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
6372
6373 let outcome = pollster::block_on(execute(
6374 &plan,
6375 &mut manifest,
6376 &mut albums,
6377 &mut playlists,
6378 &desireds,
6379 &HashMap::new(),
6380 Ports {
6381 client: &client,
6382 http: &scripted,
6383 fs: &fs,
6384 ffmpeg: &ffmpeg,
6385 clock: &clock,
6386 },
6387 &opts_with(3),
6388 ));
6389
6390 assert_eq!(outcome.downloaded, 3);
6391 assert_eq!(outcome.failed(), 0);
6392 assert!(
6393 (client.limiter_rate() - 1.0).abs() < 1e-9,
6394 "one 429 must halve the rate to 1.0, got {}",
6395 client.limiter_rate()
6396 );
6397 }
6398
6399 #[test]
6400 fn a_download_is_committed_in_plan_order_around_a_rename() {
6401 let c_new = clip("new");
6409 let mut d_new = desired(c_new.clone(), AudioFormat::Mp3);
6410 d_new.path = "shared.mp3".to_owned();
6411 let plan = Plan {
6412 actions: vec![
6413 Action::Rename {
6414 from: "shared.mp3".to_owned(),
6415 to: "moved.mp3".to_owned(),
6416 },
6417 Action::Download {
6418 clip: c_new.clone(),
6419 lineage: LineageContext::own_root(&c_new),
6420 path: "shared.mp3".to_owned(),
6421 format: AudioFormat::Mp3,
6422 },
6423 ],
6424 };
6425 let scripted = ScriptedHttp::new()
6426 .with_auth()
6427 .route("new.mp3", Reply::ok(b"NEW-BODY".to_vec()));
6428 let http = GatedHttp::new(scripted);
6429 let fs = MemFs::new().with_file("shared.mp3", b"ORIGINAL".to_vec());
6430 let mut manifest = Manifest::new();
6431 manifest.insert("orig", entry("shared.mp3", AudioFormat::Mp3));
6432
6433 let outcome = run_gated_fs(&plan, &mut manifest, &[d_new], &http, &fs, &opts_with(4));
6434
6435 assert_eq!(outcome.renamed, 1);
6436 assert_eq!(outcome.downloaded, 1);
6437 assert_eq!(
6438 fs.read_file("moved.mp3").as_deref(),
6439 Some(&b"ORIGINAL"[..]),
6440 "the rename must carry the original bytes, untouched by the download"
6441 );
6442 let landed = fs.read_file("shared.mp3").expect("new download must land");
6443 assert_ne!(
6444 landed, b"ORIGINAL",
6445 "the new download must replace the moved original, not corrupt it"
6446 );
6447 assert_eq!(manifest.get("orig").unwrap().path, "moved.mp3");
6448 assert_eq!(manifest.get("new").unwrap().path, "shared.mp3");
6449 }
6450
6451 #[test]
6452 fn an_aborted_reformat_leaves_the_old_file_and_manifest_consistent() {
6453 let boom = clip("boom");
6459 let mut d_boom = desired(boom.clone(), AudioFormat::Mp3);
6460 d_boom.path = "boom.mp3".to_owned();
6461 let reformer = clip("r");
6462 let d_reformer = desired(reformer.clone(), AudioFormat::Mp3);
6463 let plan = Plan {
6464 actions: vec![
6465 Action::Download {
6466 clip: boom.clone(),
6467 lineage: LineageContext::own_root(&boom),
6468 path: "boom.mp3".to_owned(),
6469 format: AudioFormat::Mp3,
6470 },
6471 Action::Reformat {
6472 clip: reformer.clone(),
6473 path: "r_new.mp3".to_owned(),
6474 from_path: "r_old.flac".to_owned(),
6475 from: AudioFormat::Flac,
6476 to: AudioFormat::Mp3,
6477 },
6478 ],
6479 };
6480 let scripted = ScriptedHttp::new()
6481 .with_auth()
6482 .route("boom.mp3", Reply::ok(b"boom-body".to_vec()))
6483 .route("r.mp3", Reply::ok(b"reformatted".to_vec()));
6484 let http = GatedHttp::new(scripted);
6485 let fs = MemFs::new()
6487 .with_file("r_old.flac", b"OLD-FLAC".to_vec())
6488 .fail_write_out_of_space("boom.mp3");
6489 let mut manifest = Manifest::new();
6490 manifest.insert("r", entry("r_old.flac", AudioFormat::Flac));
6491
6492 let outcome = run_gated_fs(
6493 &plan,
6494 &mut manifest,
6495 &[d_boom, d_reformer],
6496 &http,
6497 &fs,
6498 &opts_with(4),
6499 );
6500
6501 assert_eq!(outcome.status, RunStatus::DiskFull);
6502 assert!(
6503 fs.exists("r_old.flac"),
6504 "the old file must survive the abort"
6505 );
6506 assert!(
6507 !fs.exists("r_new.mp3"),
6508 "no reformatted file may be written"
6509 );
6510 let still = manifest.get("r").expect("the manifest must still track r");
6511 assert_eq!(
6512 still.path, "r_old.flac",
6513 "the manifest must still point at the surviving old file"
6514 );
6515 assert_eq!(still.format, AudioFormat::Flac);
6516 }
6517
6518 #[test]
6519 fn a_systemic_abort_leaves_no_untracked_destination_files() {
6520 let mut scripted = ScriptedHttp::new().with_auth();
6525 let mut actions = Vec::new();
6526 let mut desireds = Vec::new();
6527 for id in ["a0", "a1", "boom", "a3", "a4"] {
6528 scripted = scripted.route(&format!("{id}.mp3"), Reply::ok(b"body".to_vec()));
6529 let (_c, d, action) = download(id, AudioFormat::Mp3);
6530 actions.push(action);
6531 desireds.push(d);
6532 }
6533 let http = GatedHttp::new(scripted);
6534 let fs = MemFs::new().fail_write_out_of_space("boom.mp3");
6535 let plan = Plan { actions };
6536 let mut manifest = Manifest::new();
6537
6538 let outcome = run_gated_fs(&plan, &mut manifest, &desireds, &http, &fs, &opts_with(2));
6539
6540 assert_eq!(outcome.status, RunStatus::DiskFull);
6541 let tracked: std::collections::BTreeSet<String> = manifest
6542 .entries
6543 .values()
6544 .map(|entry| entry.path.clone())
6545 .collect();
6546 for path in fs.paths() {
6547 assert!(
6548 tracked.contains(&path),
6549 "found an untracked destination file: {path}"
6550 );
6551 }
6552 assert!(
6553 !fs.exists("a3.mp3"),
6554 "uncommitted renders must not be on disk"
6555 );
6556 assert!(
6557 !fs.exists("a4.mp3"),
6558 "uncommitted renders must not be on disk"
6559 );
6560 }
6561
6562 struct CountingFfmpeg {
6568 inner: StubFfmpeg,
6569 held: Arc<AtomicUsize>,
6570 peak: Arc<AtomicUsize>,
6571 }
6572
6573 impl Ffmpeg for CountingFfmpeg {
6574 fn wav_to_lossless(
6575 &self,
6576 wav: &[u8],
6577 format: AudioFormat,
6578 ) -> impl Future<Output = Result<Vec<u8>, FfmpegError>> + Send {
6579 let fut = self.inner.wav_to_lossless(wav, format);
6580 let held = self.held.clone();
6581 let peak = self.peak.clone();
6582 async move {
6583 let out = fut.await;
6584 if out.is_ok() {
6585 let now = held.fetch_add(1, Ordering::SeqCst) + 1;
6586 peak.fetch_max(now, Ordering::SeqCst);
6587 }
6588 out
6589 }
6590 }
6591
6592 fn mp4_to_webp(
6593 &self,
6594 mp4: &[u8],
6595 settings: WebpEncodeSettings,
6596 ) -> impl Future<Output = Result<Vec<u8>, FfmpegError>> + Send {
6597 self.inner.mp4_to_webp(mp4, settings)
6598 }
6599 }
6600
6601 struct CountingFs {
6605 inner: MemFs,
6606 held: Arc<AtomicUsize>,
6607 }
6608
6609 impl Filesystem for CountingFs {
6610 fn write_atomic(&self, path: &str, bytes: &[u8]) -> Result<(), FsError> {
6611 let out = self.inner.write_atomic(path, bytes);
6612 self.held.fetch_sub(1, Ordering::SeqCst);
6613 out
6614 }
6615
6616 fn rename(&self, from: &str, to: &str) -> Result<(), FsError> {
6617 self.inner.rename(from, to)
6618 }
6619
6620 fn remove(&self, path: &str) -> Result<(), FsError> {
6621 self.inner.remove(path)
6622 }
6623
6624 fn prune_empty_dirs(&self, root: &str) -> Result<(), FsError> {
6625 self.inner.prune_empty_dirs(root)
6626 }
6627
6628 fn read(&self, path: &str) -> Result<Vec<u8>, FsError> {
6629 self.inner.read(path)
6630 }
6631
6632 fn metadata(&self, path: &str) -> Option<FileStat> {
6633 self.inner.metadata(path)
6634 }
6635 }
6636
6637 #[test]
6638 fn rendered_payloads_in_memory_stay_bounded_by_concurrency() {
6639 let count = 12;
6643 let concurrency = 3;
6644 let mut scripted = ScriptedHttp::new().with_auth();
6645 let mut actions = Vec::new();
6646 let mut desireds = Vec::new();
6647 for i in 0..count {
6648 let id = format!("f{i}");
6649 scripted = scripted
6650 .route(
6651 &format!("/gen/{id}/wav_file/"),
6652 Reply::json(&format!(
6653 r#"{{"wav_file_url": "https://cdn1.suno.ai/{id}.wav"}}"#
6654 )),
6655 )
6656 .route(&format!("{id}.wav"), Reply::ok(b"wav-body".to_vec()));
6657 let (_c, d, action) = download(&id, AudioFormat::Flac);
6658 actions.push(action);
6659 desireds.push(d);
6660 }
6661 let http = GatedHttp::new(scripted);
6662 let held = Arc::new(AtomicUsize::new(0));
6663 let peak = Arc::new(AtomicUsize::new(0));
6664 let ffmpeg = CountingFfmpeg {
6665 inner: StubFfmpeg::flac(),
6666 held: held.clone(),
6667 peak: peak.clone(),
6668 };
6669 let fs = CountingFs {
6670 inner: MemFs::new(),
6671 held: held.clone(),
6672 };
6673 let clock = RecordingClock::new();
6674 let mut albums = BTreeMap::new();
6675 let mut playlists = BTreeMap::new();
6676 let mut manifest = Manifest::new();
6677 let client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
6678 let plan = Plan { actions };
6679
6680 let outcome = pollster::block_on(execute(
6681 &plan,
6682 &mut manifest,
6683 &mut albums,
6684 &mut playlists,
6685 &desireds,
6686 &HashMap::new(),
6687 Ports {
6688 client: &client,
6689 http: &http,
6690 fs: &fs,
6691 ffmpeg: &ffmpeg,
6692 clock: &clock,
6693 },
6694 &opts_with(concurrency),
6695 ));
6696
6697 assert_eq!(outcome.downloaded, count as usize);
6698 assert_eq!(
6699 held.load(Ordering::SeqCst),
6700 0,
6701 "every payload must be committed"
6702 );
6703 assert!(
6704 peak.load(Ordering::SeqCst) <= concurrency as usize + 1,
6705 "peak live payloads {} exceeded the bound {}",
6706 peak.load(Ordering::SeqCst),
6707 concurrency + 1
6708 );
6709 assert!(
6710 peak.load(Ordering::SeqCst) >= 2,
6711 "the render should genuinely overlap, peak was {}",
6712 peak.load(Ordering::SeqCst)
6713 );
6714 }
6715
6716 #[test]
6717 fn artifact_fetches_run_concurrently() {
6718 let count = 4usize;
6722 let concurrency = 2u32;
6723 let mut scripted = ScriptedHttp::new().with_auth();
6724 let mut actions = Vec::new();
6725 let mut manifest = Manifest::new();
6726 for i in 0..count {
6727 let id = format!("a{i}");
6728 scripted = scripted.route(&format!("{id}.jpg"), Reply::ok(b"jpg-bytes".to_vec()));
6729 manifest.insert(&id, entry(&format!("{id}.mp3"), AudioFormat::Mp3));
6730 actions.push(Action::WriteArtifact {
6731 kind: ArtifactKind::CoverJpg,
6732 path: format!("{id}/cover.jpg"),
6733 source_url: format!("https://art.suno.ai/{id}.jpg"),
6734 hash: format!("h{i}"),
6735 owner_id: id,
6736 content: None,
6737 });
6738 }
6739 let http = GatedHttp::new(scripted);
6740 let fs = MemFs::new();
6741 let plan = Plan { actions };
6742
6743 let outcome = run_gated_fs(
6744 &plan,
6745 &mut manifest,
6746 &[],
6747 &http,
6748 &fs,
6749 &opts_with(concurrency),
6750 );
6751
6752 assert_eq!(outcome.artifacts_written, count);
6753 assert_eq!(outcome.failed(), 0);
6754 assert!(
6755 http.peak() >= concurrency as usize,
6756 "artifact fetches must overlap: peak {} < concurrency {}",
6757 http.peak(),
6758 concurrency,
6759 );
6760 }
6761
6762 #[test]
6763 fn stem_fetches_run_concurrently() {
6764 let count = 4usize;
6767 let concurrency = 2u32;
6768 let mut scripted = ScriptedHttp::new().with_auth();
6769 let mut actions = Vec::new();
6770 let mut manifest = Manifest::new();
6771 for i in 0..count {
6772 let id = format!("s{i}");
6773 scripted =
6774 scripted.route(&format!("{id}voc.mp3"), Reply::ok(b"stem-bytes".to_vec()));
6775 manifest.insert(&id, entry(&format!("{id}.mp3"), AudioFormat::Mp3));
6776 actions.push(Action::WriteStem {
6777 clip_id: id.clone(),
6778 key: "voc".to_owned(),
6779 stem_id: format!("{id}voc"),
6780 path: format!("{id}.stems/voc.mp3"),
6781 source_url: format!("https://cdn1.suno.ai/{id}voc.mp3"),
6782 format: StemFormat::Mp3,
6783 hash: format!("h{i}"),
6784 });
6785 }
6786 let http = GatedHttp::new(scripted);
6787 let fs = MemFs::new();
6788 let plan = Plan { actions };
6789
6790 let outcome = run_gated_fs(
6791 &plan,
6792 &mut manifest,
6793 &[],
6794 &http,
6795 &fs,
6796 &opts_with(concurrency),
6797 );
6798
6799 assert_eq!(outcome.artifacts_written, count);
6800 assert_eq!(outcome.failed(), 0);
6801 assert!(
6802 http.peak() >= concurrency as usize,
6803 "stem fetches must overlap: peak {} < concurrency {}",
6804 http.peak(),
6805 concurrency,
6806 );
6807 }
6808
6809 #[test]
6810 fn prepareable_outcome_is_identical_across_concurrency_levels_with_artifacts_and_stems() {
6811 fn build() -> (Plan, Vec<Desired>) {
6817 let mut actions = Vec::new();
6818 let mut desireds = Vec::new();
6819 for id in ["x", "y", "z"] {
6820 let (_c, d, action) = download(id, AudioFormat::Mp3);
6821 desireds.push(d);
6822 actions.push(action);
6823 actions.push(Action::WriteArtifact {
6825 kind: ArtifactKind::CoverJpg,
6826 path: format!("{id}/cover.jpg"),
6827 source_url: format!("https://art.suno.ai/{id}.jpg"),
6828 hash: format!("art-{id}"),
6829 owner_id: id.to_owned(),
6830 content: None,
6831 });
6832 actions.push(Action::WriteStem {
6834 clip_id: id.to_owned(),
6835 key: "voc".to_owned(),
6836 stem_id: format!("{id}voc"),
6837 path: format!("{id}.stems/voc.mp3"),
6838 source_url: format!("https://cdn1.suno.ai/{id}voc.mp3"),
6839 format: StemFormat::Mp3,
6840 hash: format!("stem-{id}"),
6841 });
6842 }
6843 let (_f, df, af) = download("fail", AudioFormat::Mp3);
6845 desireds.push(df);
6846 actions.insert(3, af);
6847 actions.push(Action::Delete {
6849 path: "old.mp3".to_owned(),
6850 clip_id: "old".to_owned(),
6851 });
6852 (Plan { actions }, desireds)
6853 }
6854
6855 fn http() -> ScriptedHttp {
6856 ScriptedHttp::new()
6857 .with_auth()
6858 .route("x.mp3", Reply::ok(b"x-audio".to_vec()))
6859 .route("y.mp3", Reply::ok(b"y-audio".to_vec()))
6860 .route("z.mp3", Reply::ok(b"z-audio".to_vec()))
6861 .route("fail.mp3", Reply::status(404))
6862 .route("x.jpg", Reply::ok(b"x-jpg".to_vec()))
6863 .route("y.jpg", Reply::ok(b"y-jpg".to_vec()))
6864 .route("z.jpg", Reply::ok(b"z-jpg".to_vec()))
6865 .route("xvoc.mp3", Reply::ok(b"x-voc".to_vec()))
6866 .route("yvoc.mp3", Reply::ok(b"y-voc".to_vec()))
6867 .route("zvoc.mp3", Reply::ok(b"z-voc".to_vec()))
6868 }
6869
6870 fn seed_manifest() -> Manifest {
6871 let mut m = Manifest::new();
6872 m.insert("old".to_owned(), entry("old.mp3", AudioFormat::Mp3));
6873 m
6874 }
6875
6876 let (plan, desireds) = build();
6877
6878 let mut m1 = seed_manifest();
6879 let fs1 = MemFs::new().with_file("old.mp3", b"x".to_vec());
6880 let out1 = run_gated_fs(
6881 &plan,
6882 &mut m1,
6883 &desireds,
6884 &GatedHttp::new(http()),
6885 &fs1,
6886 &opts_with(1),
6887 );
6888
6889 let mut m8 = seed_manifest();
6890 let fs8 = MemFs::new().with_file("old.mp3", b"x".to_vec());
6891 let out8 = run_gated_fs(
6892 &plan,
6893 &mut m8,
6894 &desireds,
6895 &GatedHttp::new(http()),
6896 &fs8,
6897 &opts_with(8),
6898 );
6899
6900 assert_eq!(out1, out8, "outcome must not depend on concurrency");
6901 assert_eq!(m1, m8, "final manifest must not depend on concurrency");
6902 assert_eq!(out8.downloaded, 3);
6903 assert_eq!(out8.deleted, 1);
6904 assert_eq!(out8.failed(), 1);
6905 assert_eq!(out8.artifacts_written, 6);
6907 }
6908
6909 #[test]
6910 fn both_folder_covers_fetch_video_cover_once_under_concurrency() {
6911 let scripted = ScriptedHttp::new()
6916 .with_auth()
6917 .route("root/video.mp4", Reply::ok(b"mp4-bytes".to_vec()))
6918 .route("d0.mp3", Reply::ok(b"audio".to_vec()))
6919 .route("d1.mp3", Reply::ok(b"audio".to_vec()));
6920 let mut actions = vec![
6921 Action::WriteArtifact {
6922 kind: ArtifactKind::FolderWebp,
6923 path: "album/cover.webp".to_owned(),
6924 source_url: "https://cdn.suno.ai/root/video.mp4".to_owned(),
6925 hash: "wh".to_owned(),
6926 owner_id: "root".to_owned(),
6927 content: None,
6928 },
6929 Action::WriteArtifact {
6930 kind: ArtifactKind::FolderMp4,
6931 path: "album/cover.mp4".to_owned(),
6932 source_url: "https://cdn.suno.ai/root/video.mp4".to_owned(),
6933 hash: "mh".to_owned(),
6934 owner_id: "root".to_owned(),
6935 content: None,
6936 },
6937 ];
6938 let mut desireds = vec![];
6939 for id in ["d0", "d1"] {
6940 let (_c, d, a) = download(id, AudioFormat::Mp3);
6941 actions.push(a);
6942 desireds.push(d);
6943 }
6944 let plan = Plan { actions };
6945 let http = GatedHttp::new(scripted);
6946 let ffmpeg = StubFfmpeg::webp();
6947 let clock = RecordingClock::new();
6948 let mut manifest = Manifest::new();
6949 let mut albums = BTreeMap::new();
6950 let mut playlists = BTreeMap::new();
6951 let client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
6952 pollster::block_on(execute(
6953 &plan,
6954 &mut manifest,
6955 &mut albums,
6956 &mut playlists,
6957 &desireds,
6958 &HashMap::new(),
6959 Ports {
6960 client: &client,
6961 http: &http,
6962 fs: &MemFs::new(),
6963 ffmpeg: &ffmpeg,
6964 clock: &clock,
6965 },
6966 &opts_with(4),
6967 ));
6968
6969 assert_eq!(
6970 http.count("root/video.mp4"),
6971 1,
6972 "video_cover_url must be fetched exactly once even under concurrency"
6973 );
6974 }
6975
6976 #[test]
6977 fn existing_clip_audio_and_cover_sidecar_share_cover_fetch() {
6978 let c = art_clip("e");
6984 let cover_url = c.image_large_url.clone();
6985 let d = desired(c.clone(), AudioFormat::Mp3);
6986 let scripted = ScriptedHttp::new()
6987 .with_auth()
6988 .route("e.mp3", Reply::ok(b"audio".to_vec()))
6989 .route("e/large.jpg", Reply::ok(b"cover-jpg".to_vec()));
6990 let plan = Plan {
6991 actions: vec![
6992 Action::Reformat {
6993 clip: c,
6994 path: "e.mp3".to_owned(),
6995 from_path: "e-old.mp3".to_owned(),
6996 from: AudioFormat::Mp3,
6997 to: AudioFormat::Mp3,
6998 },
6999 Action::WriteArtifact {
7000 kind: ArtifactKind::CoverJpg,
7001 path: "e/cover.jpg".to_owned(),
7002 source_url: cover_url,
7003 hash: "new-art".to_owned(),
7004 owner_id: "e".to_owned(),
7005 content: None,
7006 },
7007 ],
7008 };
7009 let mut manifest = Manifest::new();
7010 manifest.insert("e".to_owned(), entry("e-old.mp3", AudioFormat::Mp3));
7011 let fs = MemFs::new().with_file("e-old.mp3", b"old-audio".to_vec());
7012 let http = GatedHttp::new(scripted);
7013 let outcome = run_gated_fs(&plan, &mut manifest, &[d], &http, &fs, &opts_with(4));
7014
7015 assert_eq!(outcome.reformatted, 1);
7016 assert_eq!(outcome.failed(), 0);
7017 assert_eq!(
7018 http.count("e/large.jpg"),
7019 1,
7020 "cover must be fetched exactly once, not once per concurrent action"
7021 );
7022 }
7023 }
7024}