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};
59
60type ClientLock<'a, C> = AsyncMutex<&'a mut SunoClient<C>>;
65
66#[derive(Debug, Clone)]
68pub struct ExecOptions {
69 pub max_retries: u32,
71 pub wav_poll_attempts: u32,
73 pub wav_poll_interval: Duration,
75 pub concurrency: u32,
78}
79
80impl Default for ExecOptions {
81 fn default() -> Self {
82 Self {
83 max_retries: 3,
84 wav_poll_attempts: 24,
85 wav_poll_interval: Duration::from_secs(5),
86 concurrency: 4,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum RunStatus {
94 #[default]
96 Completed,
97 AuthAborted,
99 DiskFull,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct Failure {
107 pub clip_id: String,
109 pub reason: String,
111}
112
113#[derive(Debug, Clone, Default, PartialEq, Eq)]
115pub struct ExecOutcome {
116 pub downloaded: usize,
117 pub reformatted: usize,
118 pub retagged: usize,
119 pub renamed: usize,
120 pub deleted: usize,
121 pub skipped: usize,
122 pub artifacts_written: usize,
123 pub artifacts_deleted: usize,
124 pub failures: Vec<Failure>,
128 pub status: RunStatus,
130}
131
132impl ExecOutcome {
133 pub fn failed(&self) -> usize {
135 self.failures.len()
136 }
137
138 fn record(&mut self, effect: Effect) {
139 match effect {
140 Effect::Downloaded => self.downloaded += 1,
141 Effect::Reformatted => self.reformatted += 1,
142 Effect::Retagged => self.retagged += 1,
143 Effect::Renamed => self.renamed += 1,
144 Effect::Deleted => self.deleted += 1,
145 Effect::Skipped => self.skipped += 1,
146 Effect::ArtifactWritten => self.artifacts_written += 1,
147 Effect::ArtifactDeleted => self.artifacts_deleted += 1,
148 }
149 }
150}
151
152pub struct Ports<'a, H, F, G, C> {
157 pub client: &'a mut SunoClient<C>,
159 pub http: &'a H,
161 pub fs: &'a F,
163 pub ffmpeg: &'a G,
165 pub clock: &'a C,
167}
168
169#[allow(clippy::too_many_arguments)]
204pub async fn execute<H, F, G, C>(
205 plan: &Plan,
206 manifest: &mut Manifest,
207 albums: &mut BTreeMap<String, AlbumArt>,
208 playlists: &mut BTreeMap<String, PlaylistState>,
209 desired: &[Desired],
210 synced: &HashMap<String, AlignedLyrics>,
211 ports: Ports<'_, H, F, G, C>,
212 opts: &ExecOptions,
213) -> ExecOutcome
214where
215 H: Http,
216 F: Filesystem,
217 G: Ffmpeg,
218 C: Clock,
219{
220 let Ports {
221 client,
222 http,
223 fs,
224 ffmpeg,
225 clock,
226 } = ports;
227 let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
228 let by_path: HashMap<&str, &Desired> = desired.iter().map(|d| (d.path.as_str(), d)).collect();
229 let write_targets: BTreeSet<String> = plan
233 .actions
234 .iter()
235 .filter_map(|a| match a {
236 Action::Download { path, .. }
237 | Action::Reformat { path, .. }
238 | Action::WriteArtifact { path, .. }
239 | Action::WriteStem { path, .. } => Some(path.clone()),
240 Action::Rename { to, .. } => Some(to.clone()),
241 _ => None,
242 })
243 .collect();
244 let mut tracked_paths: HashMap<String, u32> = HashMap::new();
252 for (_, entry) in manifest.iter() {
253 for path in entry.artifact_paths() {
254 *tracked_paths.entry(path.to_owned()).or_default() += 1;
255 }
256 }
257 for art in albums.values() {
258 for state in [art.folder_jpg.as_ref(), art.folder_webp.as_ref()]
259 .into_iter()
260 .flatten()
261 {
262 *tracked_paths.entry(state.path.clone()).or_default() += 1;
263 }
264 }
265 for playlist in playlists.values() {
266 *tracked_paths.entry(playlist.path.clone()).or_default() += 1;
267 }
268 let cover_wanted: HashSet<&str> = plan
276 .actions
277 .iter()
278 .filter_map(|action| match action {
279 Action::WriteArtifact {
280 kind: ArtifactKind::CoverJpg,
281 source_url,
282 ..
283 } if !source_url.is_empty() => Some(source_url.as_str()),
284 _ => None,
285 })
286 .collect();
287 let cover_cache: Mutex<HashMap<String, Vec<u8>>> = Mutex::new(HashMap::new());
288 let ctx = Ctx {
289 http,
290 fs,
291 ffmpeg,
292 clock,
293 opts,
294 by_id: &by_id,
295 by_path: &by_path,
296 synced,
297 write_targets: &write_targets,
298 cover_cache: &cover_cache,
299 cover_wanted: &cover_wanted,
300 };
301
302 let mut outcome = ExecOutcome::default();
303
304 let client_lock = AsyncMutex::new(client);
323 let concurrency = opts.concurrency.max(1) as usize;
324 let ctx_ref = &ctx;
325 let client_lock_ref = &client_lock;
326 let mut renders = stream::iter(
327 plan.actions
328 .iter()
329 .filter(|action| is_audio_action(action))
330 .map(|action| async move { ctx_ref.prepare_audio(client_lock_ref, action).await }),
331 )
332 .buffered(concurrency);
333
334 for action in &plan.actions {
335 let result = if is_audio_action(action) {
341 match renders.next().await {
342 Some(Ok(rendered)) => ctx.commit_audio(manifest, rendered),
343 Some(Err(fail)) => Err(fail),
344 None => unreachable!("buffered yields one result per audio action"),
345 }
346 } else {
347 ctx.apply(
348 client_lock_ref,
349 action,
350 manifest,
351 albums,
352 playlists,
353 &mut tracked_paths,
354 )
355 .await
356 };
357 match result {
358 Ok(effect) => outcome.record(effect),
359 Err(fail) => {
360 let abort = abort_status(fail.class);
361 outcome.failures.push(Failure {
362 clip_id: fail.clip_id,
363 reason: fail.reason,
364 });
365 if let Some(status) = abort {
366 outcome.status = status;
372 break;
373 }
374 }
375 }
376 }
377 drop(renders);
378
379 let _ = fs.prune_empty_dirs("");
384 outcome
385}
386
387fn is_audio_action(action: &Action) -> bool {
392 matches!(action, Action::Download { .. } | Action::Reformat { .. })
393}
394
395struct RenderedAudio {
400 clip_id: String,
401 path: String,
402 format: AudioFormat,
403 from_path: Option<String>,
406 effect: Effect,
407 bytes: Vec<u8>,
408}
409
410enum Effect {
412 Downloaded,
413 Reformatted,
414 Retagged,
415 Renamed,
416 Deleted,
417 Skipped,
418 ArtifactWritten,
419 ArtifactDeleted,
420}
421
422#[derive(Debug, Clone, Copy)]
424enum Class {
425 Auth,
427 Disk,
431 Transient,
433 Permanent,
435}
436
437struct Fail {
439 class: Class,
440 clip_id: String,
441 reason: String,
442}
443
444fn abort_status(class: Class) -> Option<RunStatus> {
447 match class {
448 Class::Auth => Some(RunStatus::AuthAborted),
449 Class::Disk => Some(RunStatus::DiskFull),
450 Class::Transient | Class::Permanent => None,
451 }
452}
453
454fn auth_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
455 Fail {
456 class: Class::Auth,
457 clip_id: clip_id.into(),
458 reason: reason.into(),
459 }
460}
461
462fn transient_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
463 Fail {
464 class: Class::Transient,
465 clip_id: clip_id.into(),
466 reason: reason.into(),
467 }
468}
469
470fn permanent_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
471 Fail {
472 class: Class::Permanent,
473 clip_id: clip_id.into(),
474 reason: reason.into(),
475 }
476}
477
478fn disk_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
479 Fail {
480 class: Class::Disk,
481 clip_id: clip_id.into(),
482 reason: reason.into(),
483 }
484}
485
486fn is_album_kind(kind: ArtifactKind) -> bool {
490 matches!(kind, ArtifactKind::FolderJpg | ArtifactKind::FolderWebp)
491}
492
493fn is_playlist_kind(kind: ArtifactKind) -> bool {
495 matches!(kind, ArtifactKind::Playlist)
496}
497
498fn is_per_clip_kind(kind: ArtifactKind) -> bool {
502 matches!(
503 kind,
504 ArtifactKind::CoverJpg
505 | ArtifactKind::CoverWebp
506 | ArtifactKind::DetailsTxt
507 | ArtifactKind::LyricsTxt
508 | ArtifactKind::Lrc
509 | ArtifactKind::VideoMp4
510 )
511}
512
513fn playlist_name_from_path(path: &str) -> String {
520 std::path::Path::new(path)
521 .file_stem()
522 .map(|stem| stem.to_string_lossy().into_owned())
523 .unwrap_or_default()
524}
525
526struct FetchError {
528 class: Class,
529 reason: String,
530 retry_after: Option<Duration>,
531}
532
533impl FetchError {
534 fn transient(reason: impl Into<String>, retry_after: Option<Duration>) -> Self {
535 Self {
536 class: Class::Transient,
537 reason: reason.into(),
538 retry_after,
539 }
540 }
541
542 fn permanent(reason: impl Into<String>) -> Self {
543 Self {
544 class: Class::Permanent,
545 reason: reason.into(),
546 retry_after: None,
547 }
548 }
549
550 fn attribute(self, clip_id: &str) -> Fail {
551 Fail {
552 class: self.class,
553 clip_id: clip_id.to_owned(),
554 reason: self.reason,
555 }
556 }
557}
558
559struct Ctx<'a, H, F, G, C> {
561 http: &'a H,
562 fs: &'a F,
563 ffmpeg: &'a G,
564 clock: &'a C,
565 opts: &'a ExecOptions,
566 by_id: &'a HashMap<&'a str, &'a Desired>,
567 by_path: &'a HashMap<&'a str, &'a Desired>,
568 synced: &'a HashMap<String, AlignedLyrics>,
573 write_targets: &'a BTreeSet<String>,
580 cover_cache: &'a Mutex<HashMap<String, Vec<u8>>>,
588 cover_wanted: &'a HashSet<&'a str>,
592}
593
594impl<H, F, G, C> Ctx<'_, H, F, G, C>
595where
596 H: Http,
597 F: Filesystem,
598 G: Ffmpeg,
599 C: Clock,
600{
601 async fn apply(
607 &self,
608 client_lock: &ClientLock<'_, C>,
609 action: &Action,
610 manifest: &mut Manifest,
611 albums: &mut BTreeMap<String, AlbumArt>,
612 playlists: &mut BTreeMap<String, PlaylistState>,
613 tracked_paths: &mut HashMap<String, u32>,
614 ) -> Result<Effect, Fail> {
615 match action {
616 Action::Download { .. } | Action::Reformat { .. } => {
617 unreachable!("audio actions are applied in the concurrent phase")
618 }
619 Action::Retag {
620 clip,
621 lineage,
622 path,
623 } => self.retag(manifest, clip, lineage, path).await,
624 Action::Rename { from, to } => self.rename(manifest, from, to),
625 Action::Delete { path, clip_id } => self.delete(manifest, path, clip_id),
626 Action::Skip { clip_id } => {
627 self.refresh_preserve(manifest, clip_id);
628 Ok(Effect::Skipped)
629 }
630 Action::WriteArtifact {
631 kind,
632 path,
633 source_url,
634 hash,
635 owner_id,
636 content,
637 } => {
638 self.write_artifact(
639 manifest,
640 albums,
641 playlists,
642 *kind,
643 path,
644 source_url,
645 hash,
646 owner_id,
647 content.as_deref(),
648 tracked_paths,
649 )
650 .await
651 }
652 Action::DeleteArtifact {
653 kind,
654 path,
655 owner_id,
656 } => self.delete_artifact(manifest, albums, playlists, *kind, path, owner_id),
657 Action::WriteStem {
658 clip_id,
659 key,
660 stem_id,
661 path,
662 source_url,
663 format,
664 hash,
665 } => {
666 self.write_stem(
667 client_lock,
668 manifest,
669 clip_id,
670 key,
671 stem_id,
672 path,
673 source_url,
674 *format,
675 hash,
676 )
677 .await
678 }
679 Action::DeleteStem { clip_id, key, path } => {
680 self.delete_stem(manifest, clip_id, key, path)
681 }
682 }
683 }
684
685 async fn prepare_audio(
694 &self,
695 client_lock: &ClientLock<'_, C>,
696 action: &Action,
697 ) -> Result<RenderedAudio, Fail> {
698 match action {
699 Action::Download {
700 clip,
701 lineage,
702 path,
703 format,
704 } => {
705 let bytes = self
706 .produce_audio(client_lock, clip, lineage, *format)
707 .await?;
708 Ok(RenderedAudio {
709 clip_id: clip.id.clone(),
710 path: path.clone(),
711 format: *format,
712 from_path: None,
713 effect: Effect::Downloaded,
714 bytes,
715 })
716 }
717 Action::Reformat {
718 clip,
719 path,
720 from_path,
721 from: _,
722 to,
723 } => {
724 let lineage = self
729 .by_id
730 .get(clip.id.as_str())
731 .map(|d| d.lineage.clone())
732 .unwrap_or_else(|| LineageContext::own_root(clip));
733 let bytes = self.produce_audio(client_lock, clip, &lineage, *to).await?;
734 Ok(RenderedAudio {
735 clip_id: clip.id.clone(),
736 path: path.clone(),
737 format: *to,
738 from_path: Some(from_path.clone()),
739 effect: Effect::Reformatted,
740 bytes,
741 })
742 }
743 _ => unreachable!("prepare_audio only handles audio actions"),
744 }
745 }
746
747 fn commit_audio(
755 &self,
756 manifest: &mut Manifest,
757 rendered: RenderedAudio,
758 ) -> Result<Effect, Fail> {
759 let RenderedAudio {
760 clip_id,
761 path,
762 format,
763 from_path,
764 effect,
765 bytes,
766 } = rendered;
767 let size = self.write_verify(&clip_id, &path, &bytes)?;
768 if let Some(from) = from_path {
769 self.fs.remove(&from).map_err(|err| {
771 permanent_fail(&clip_id, format!("could not remove old file: {err}"))
772 })?;
773 }
774 manifest.insert(clip_id.clone(), self.entry(&clip_id, &path, format, size));
775 Ok(effect)
776 }
777
778 async fn retag(
780 &self,
781 manifest: &mut Manifest,
782 clip: &Clip,
783 lineage: &LineageContext,
784 path: &str,
785 ) -> Result<Effect, Fail> {
786 let Some(format) = manifest.get(&clip.id).map(|entry| entry.format) else {
787 return Err(permanent_fail(
788 &clip.id,
789 "retag target missing from manifest",
790 ));
791 };
792
793 if format == AudioFormat::Wav {
794 self.refresh_hashes(manifest, &clip.id, None);
797 return Ok(Effect::Retagged);
798 }
799
800 let (meta, synced) = self.track_meta(clip, lineage);
801 let cover = self.fetch_cover(clip).await;
802 let existing = self
803 .fs
804 .read(path)
805 .map_err(|err| permanent_fail(&clip.id, format!("could not read for retag: {err}")))?;
806 let tagged = match format {
807 AudioFormat::Mp3 => tag_mp3(&existing, &meta, cover.as_deref(), synced),
808 AudioFormat::Flac => tag_flac(&existing, &meta, cover.as_deref()),
809 AudioFormat::Wav => unreachable!("WAV handled above"),
810 }
811 .map_err(|err| permanent_fail(&clip.id, err.to_string()))?;
812 let size = self.write_verify(&clip.id, path, &tagged)?;
813 self.refresh_hashes(manifest, &clip.id, Some(size));
814 Ok(Effect::Retagged)
815 }
816
817 fn rename(&self, manifest: &mut Manifest, from: &str, to: &str) -> Result<Effect, Fail> {
819 let label = self
820 .by_path
821 .get(to)
822 .map(|d| d.clip.id.clone())
823 .unwrap_or_else(|| to.to_owned());
824 self.fs.rename(from, to).map_err(|err| {
825 if err.is_out_of_space() {
826 disk_fail(label, "disk full: no space left to rename")
827 } else {
828 permanent_fail(label, format!("rename failed: {err}"))
829 }
830 })?;
831
832 let clip_id = self.by_path.get(to).map(|d| d.clip.id.clone()).or_else(|| {
833 manifest
834 .entries
835 .iter()
836 .find(|(_, entry)| entry.path == from)
837 .map(|(id, _)| id.clone())
838 });
839 if let Some(id) = clip_id
840 && let Some(entry) = manifest.entries.get_mut(&id)
841 {
842 entry.path = to.to_owned();
843 if let Some(d) = self.by_path.get(to) {
844 entry.preserve = preserve_for(d);
845 }
846 }
847 Ok(Effect::Renamed)
848 }
849
850 fn delete(&self, manifest: &mut Manifest, path: &str, clip_id: &str) -> Result<Effect, Fail> {
852 self.fs
853 .remove(path)
854 .map_err(|err| permanent_fail(clip_id, format!("delete failed: {err}")))?;
855 manifest.remove(clip_id);
856 Ok(Effect::Deleted)
857 }
858
859 #[allow(clippy::too_many_arguments)]
891 async fn write_artifact(
892 &self,
893 manifest: &mut Manifest,
894 albums: &mut BTreeMap<String, AlbumArt>,
895 playlists: &mut BTreeMap<String, PlaylistState>,
896 kind: ArtifactKind,
897 path: &str,
898 source_url: &str,
899 hash: &str,
900 owner_id: &str,
901 content: Option<&str>,
902 tracked_paths: &mut HashMap<String, u32>,
903 ) -> Result<Effect, Fail> {
904 if is_per_clip_kind(kind) && manifest.get(owner_id).is_none() {
907 self.cover_cache
914 .lock()
915 .expect("cover cache mutex poisoned")
916 .remove(source_url);
917 return Ok(Effect::Skipped);
918 }
919 let old_path = match kind {
925 ArtifactKind::CoverJpg => manifest
926 .get(owner_id)
927 .and_then(|e| e.cover_jpg.as_ref())
928 .map(|s| s.path.clone()),
929 ArtifactKind::CoverWebp => manifest
930 .get(owner_id)
931 .and_then(|e| e.cover_webp.as_ref())
932 .map(|s| s.path.clone()),
933 ArtifactKind::DetailsTxt => manifest
934 .get(owner_id)
935 .and_then(|e| e.details_txt.as_ref())
936 .map(|s| s.path.clone()),
937 ArtifactKind::LyricsTxt => manifest
938 .get(owner_id)
939 .and_then(|e| e.lyrics_txt.as_ref())
940 .map(|s| s.path.clone()),
941 ArtifactKind::Lrc => manifest
942 .get(owner_id)
943 .and_then(|e| e.lrc.as_ref())
944 .map(|s| s.path.clone()),
945 ArtifactKind::VideoMp4 => manifest
946 .get(owner_id)
947 .and_then(|e| e.video_mp4.as_ref())
948 .map(|s| s.path.clone()),
949 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp => albums
950 .get(owner_id)
951 .and_then(|a| a.artifact(kind))
952 .map(|s| s.path.clone()),
953 ArtifactKind::Playlist => None,
954 };
955 let bytes = match content {
958 Some(text) => text.as_bytes().to_vec(),
959 None => self.artifact_bytes(kind, source_url, owner_id).await?,
960 };
961 self.write_verify(owner_id, path, &bytes)?;
962 if let Some(old) = old_path.as_deref()
979 && !old.is_empty()
980 && old != path
981 {
982 let still_referenced = tracked_paths
983 .get_mut(old)
984 .map(|count| {
985 *count = count.saturating_sub(1);
986 *count > 0
987 })
988 .unwrap_or(false);
989 if !still_referenced && !self.write_targets.contains(old) {
990 self.fs.remove(old).map_err(|err| {
991 permanent_fail(
992 owner_id,
993 format!("could not remove old sidecar {old}: {err}"),
994 )
995 })?;
996 }
997 }
998 if is_album_kind(kind) {
999 albums.entry(owner_id.to_owned()).or_default().set(
1000 kind,
1001 Some(ArtifactState {
1002 path: path.to_owned(),
1003 hash: hash.to_owned(),
1004 }),
1005 );
1006 } else if is_playlist_kind(kind) {
1007 playlists.insert(
1008 owner_id.to_owned(),
1009 PlaylistState {
1010 name: playlist_name_from_path(path),
1011 path: path.to_owned(),
1012 hash: hash.to_owned(),
1013 },
1014 );
1015 } else if let Some(entry) = manifest.entries.get_mut(owner_id) {
1016 set_manifest_artifact(
1017 entry,
1018 kind,
1019 Some(ArtifactState {
1020 path: path.to_owned(),
1021 hash: hash.to_owned(),
1022 }),
1023 );
1024 }
1025 Ok(Effect::ArtifactWritten)
1026 }
1027
1028 async fn artifact_bytes(
1039 &self,
1040 kind: ArtifactKind,
1041 source_url: &str,
1042 owner_id: &str,
1043 ) -> Result<Vec<u8>, Fail> {
1044 let cached = self
1048 .cover_cache
1049 .lock()
1050 .expect("cover cache mutex poisoned")
1051 .remove(source_url);
1052 let source = match cached {
1053 Some(bytes) => bytes,
1054 None => self
1055 .fetch_bytes(source_url)
1056 .await
1057 .map_err(|err| err.attribute(owner_id))?,
1058 };
1059 match kind {
1060 ArtifactKind::CoverWebp | ArtifactKind::FolderWebp => self
1061 .ffmpeg
1062 .mp4_to_webp(&source, WebpEncodeSettings::default())
1063 .await
1064 .map_err(|err| {
1065 if err.is_out_of_space() {
1066 disk_fail(owner_id, "disk full: no space left to transcode")
1067 } else {
1068 permanent_fail(owner_id, format!("cover transcode failed: {err}"))
1069 }
1070 }),
1071 ArtifactKind::DetailsTxt | ArtifactKind::LyricsTxt | ArtifactKind::Lrc => Err(
1075 permanent_fail(owner_id, "text sidecar requires inline content"),
1076 ),
1077 ArtifactKind::CoverJpg
1078 | ArtifactKind::FolderJpg
1079 | ArtifactKind::Playlist
1080 | ArtifactKind::VideoMp4 => Ok(source),
1081 }
1082 }
1083
1084 fn delete_artifact(
1099 &self,
1100 manifest: &mut Manifest,
1101 albums: &mut BTreeMap<String, AlbumArt>,
1102 playlists: &mut BTreeMap<String, PlaylistState>,
1103 kind: ArtifactKind,
1104 path: &str,
1105 owner_id: &str,
1106 ) -> Result<Effect, Fail> {
1107 self.fs
1108 .remove(path)
1109 .map_err(|err| permanent_fail(owner_id, format!("artifact delete failed: {err}")))?;
1110 if is_album_kind(kind) {
1111 if let Some(art) = albums.get_mut(owner_id) {
1112 art.set(kind, None);
1113 if art.is_empty() {
1114 albums.remove(owner_id);
1115 }
1116 }
1117 } else if is_playlist_kind(kind) {
1118 playlists.remove(owner_id);
1119 } else if let Some(entry) = manifest.entries.get_mut(owner_id) {
1120 set_manifest_artifact(entry, kind, None);
1121 }
1122 Ok(Effect::ArtifactDeleted)
1123 }
1124
1125 #[allow(clippy::too_many_arguments)]
1149 async fn write_stem(
1150 &self,
1151 client_lock: &ClientLock<'_, C>,
1152 manifest: &mut Manifest,
1153 clip_id: &str,
1154 key: &str,
1155 stem_id: &str,
1156 path: &str,
1157 source_url: &str,
1158 format: StemFormat,
1159 hash: &str,
1160 ) -> Result<Effect, Fail> {
1161 if manifest.get(clip_id).is_none() {
1163 return Ok(Effect::Skipped);
1164 }
1165 let old_path = manifest
1166 .get(clip_id)
1167 .and_then(|e| e.stems.get(key))
1168 .map(|s| s.path.clone());
1169 let bytes = self
1170 .fetch_stem_bytes(client_lock, clip_id, stem_id, source_url, format)
1171 .await?;
1172 self.write_verify(clip_id, path, &bytes)?;
1173 if let Some(old) = old_path.as_deref()
1180 && !old.is_empty()
1181 && old != path
1182 && !self.write_targets.contains(old)
1183 {
1184 self.fs.remove(old).map_err(|err| {
1185 permanent_fail(clip_id, format!("could not remove old stem {old}: {err}"))
1186 })?;
1187 }
1188 if let Some(entry) = manifest.entries.get_mut(clip_id) {
1189 set_manifest_stem(
1190 entry,
1191 key,
1192 Some(ArtifactState {
1193 path: path.to_owned(),
1194 hash: hash.to_owned(),
1195 }),
1196 );
1197 }
1198 Ok(Effect::ArtifactWritten)
1199 }
1200
1201 async fn fetch_stem_bytes(
1211 &self,
1212 client_lock: &ClientLock<'_, C>,
1213 clip_id: &str,
1214 stem_id: &str,
1215 source_url: &str,
1216 format: StemFormat,
1217 ) -> Result<Vec<u8>, Fail> {
1218 let url = match format {
1219 StemFormat::Wav if !stem_id.is_empty() => {
1220 match self.resolve_wav_url(client_lock, stem_id).await? {
1221 Some(url) => url,
1222 None => return Err(transient_fail(clip_id, "stem WAV render was not ready")),
1223 }
1224 }
1225 _ => source_url.to_owned(),
1227 };
1228 self.fetch_bytes(&url)
1229 .await
1230 .map_err(|err| err.attribute(clip_id))
1231 }
1232
1233 fn delete_stem(
1240 &self,
1241 manifest: &mut Manifest,
1242 clip_id: &str,
1243 key: &str,
1244 path: &str,
1245 ) -> Result<Effect, Fail> {
1246 self.fs
1247 .remove(path)
1248 .map_err(|err| permanent_fail(clip_id, format!("stem delete failed: {err}")))?;
1249 if let Some(entry) = manifest.entries.get_mut(clip_id) {
1250 set_manifest_stem(entry, key, None);
1251 }
1252 Ok(Effect::ArtifactDeleted)
1253 }
1254
1255 async fn produce_audio(
1257 &self,
1258 client_lock: &ClientLock<'_, C>,
1259 clip: &Clip,
1260 lineage: &LineageContext,
1261 format: AudioFormat,
1262 ) -> Result<Vec<u8>, Fail> {
1263 let (meta, synced) = self.track_meta(clip, lineage);
1264 match format {
1265 AudioFormat::Mp3 => {
1266 let url = clip.mp3_url();
1267 let audio = self
1268 .fetch_bytes(&url)
1269 .await
1270 .map_err(|err| err.attribute(&clip.id))?;
1271 let cover = self.fetch_cover(clip).await;
1272 tag_mp3(&audio, &meta, cover.as_deref(), synced)
1273 .map_err(|err| permanent_fail(&clip.id, err.to_string()))
1274 }
1275 AudioFormat::Flac => {
1276 let wav = self.fetch_wav(client_lock, clip).await?;
1277 let flac = self.ffmpeg.wav_to_flac(&wav).await.map_err(|err| {
1278 if err.is_out_of_space() {
1279 disk_fail(&clip.id, "disk full: no space left to transcode")
1280 } else {
1281 permanent_fail(&clip.id, format!("transcode failed: {err}"))
1282 }
1283 })?;
1284 let cover = self.fetch_cover(clip).await;
1285 tag_flac(&flac, &meta, cover.as_deref())
1286 .map_err(|err| permanent_fail(&clip.id, err.to_string()))
1287 }
1288 AudioFormat::Wav => self.fetch_wav(client_lock, clip).await,
1289 }
1290 }
1291
1292 fn synced_for(&self, clip_id: &str) -> Option<&AlignedLyrics> {
1294 self.synced
1295 .get(clip_id)
1296 .filter(|aligned| !aligned.is_empty())
1297 }
1298
1299 fn track_meta<'m>(
1306 &'m self,
1307 clip: &Clip,
1308 lineage: &LineageContext,
1309 ) -> (TrackMetadata, Option<&'m AlignedLyrics>) {
1310 let synced = self.synced_for(&clip.id);
1311 let mut meta = TrackMetadata::from_clip(clip, lineage);
1312 if let Some(aligned) = synced {
1313 meta.lyrics = aligned.plain_text();
1314 }
1315 (meta, synced)
1316 }
1317
1318 async fn fetch_wav(
1320 &self,
1321 client_lock: &ClientLock<'_, C>,
1322 clip: &Clip,
1323 ) -> Result<Vec<u8>, Fail> {
1324 let url = match self.resolve_wav_url(client_lock, &clip.id).await? {
1325 Some(url) => url,
1326 None => return Err(transient_fail(&clip.id, "WAV render was not ready")),
1327 };
1328 self.fetch_bytes(&url)
1329 .await
1330 .map_err(|err| err.attribute(&clip.id))
1331 }
1332
1333 async fn resolve_wav_url(
1342 &self,
1343 client_lock: &ClientLock<'_, C>,
1344 id: &str,
1345 ) -> Result<Option<String>, Fail> {
1346 if let Some(url) = self.wav_url_retrying(client_lock, id).await? {
1347 return Ok(Some(url));
1348 }
1349 self.request_wav_retrying(client_lock, id).await?;
1350 for _ in 0..self.opts.wav_poll_attempts {
1351 self.clock.sleep(self.opts.wav_poll_interval).await;
1352 if let Some(url) = self.wav_url_retrying(client_lock, id).await? {
1353 return Ok(Some(url));
1354 }
1355 }
1356 Ok(None)
1357 }
1358
1359 async fn wav_url_retrying(
1362 &self,
1363 client_lock: &ClientLock<'_, C>,
1364 id: &str,
1365 ) -> Result<Option<String>, Fail> {
1366 let mut attempt: u32 = 0;
1367 loop {
1368 let result = {
1369 let mut client = client_lock.lock().await;
1370 client.wav_url(self.http, id).await
1371 };
1372 match result {
1373 Ok(url) => return Ok(url),
1374 Err(err) => match self.retry_core(id, err, &mut attempt).await {
1375 Some(fail) => return Err(fail),
1376 None => continue,
1377 },
1378 }
1379 }
1380 }
1381
1382 async fn request_wav_retrying(
1384 &self,
1385 client_lock: &ClientLock<'_, C>,
1386 id: &str,
1387 ) -> Result<(), Fail> {
1388 let mut attempt: u32 = 0;
1389 loop {
1390 let result = {
1391 let mut client = client_lock.lock().await;
1392 client.request_wav(self.http, id).await
1393 };
1394 match result {
1395 Ok(()) => return Ok(()),
1396 Err(err) => match self.retry_core(id, err, &mut attempt).await {
1397 Some(fail) => return Err(fail),
1398 None => continue,
1399 },
1400 }
1401 }
1402 }
1403
1404 async fn retry_core(&self, id: &str, err: Error, attempt: &mut u32) -> Option<Fail> {
1408 let fail = classify_core(id, err);
1409 if matches!(fail.class, Class::Transient) && *attempt < self.opts.max_retries {
1410 self.clock.sleep(backoff_delay(*attempt, None)).await;
1411 *attempt += 1;
1412 None
1413 } else {
1414 Some(fail)
1415 }
1416 }
1417
1418 async fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, FetchError> {
1420 let mut attempt: u32 = 0;
1421 loop {
1422 let result = self.http.send(HttpRequest::get(url)).await;
1423 match classify_response(result) {
1424 Ok(body) => return Ok(body),
1425 Err(err) => {
1426 if matches!(err.class, Class::Transient) && attempt < self.opts.max_retries {
1427 let delay = backoff_delay(attempt, err.retry_after);
1428 self.clock.sleep(delay).await;
1429 attempt += 1;
1430 continue;
1431 }
1432 return Err(err);
1433 }
1434 }
1435 }
1436 }
1437
1438 async fn fetch_cover(&self, clip: &Clip) -> Option<Vec<u8>> {
1440 for url in clip.cover_candidates() {
1441 if let Ok(response) = self.http.send(HttpRequest::get(url)).await
1442 && (200..=299).contains(&response.status)
1443 && !response.body.is_empty()
1444 {
1445 if self.cover_wanted.contains(url) {
1449 self.cover_cache
1450 .lock()
1451 .expect("cover cache mutex poisoned")
1452 .insert(url.to_owned(), response.body.clone());
1453 }
1454 return Some(response.body);
1455 }
1456 }
1457 None
1458 }
1459
1460 fn write_verify(&self, clip_id: &str, path: &str, bytes: &[u8]) -> Result<u64, Fail> {
1462 self.fs.write_atomic(path, bytes).map_err(|err| {
1463 if err.is_out_of_space() {
1464 disk_fail(clip_id, format!("disk full: no space left to write {path}"))
1465 } else {
1466 permanent_fail(clip_id, format!("write failed: {err}"))
1467 }
1468 })?;
1469 match self.fs.metadata(path) {
1470 Some(stat) if stat.size == bytes.len() as u64 => Ok(stat.size),
1471 Some(stat) => Err(permanent_fail(
1472 clip_id,
1473 format!("wrote {} bytes, expected {}", stat.size, bytes.len()),
1474 )),
1475 None => Ok(bytes.len() as u64),
1476 }
1477 }
1478
1479 fn entry(&self, clip_id: &str, path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
1481 match self.by_id.get(clip_id) {
1482 Some(d) => manifest_entry(d, size),
1483 None => ManifestEntry {
1484 path: path.to_owned(),
1485 format,
1486 size,
1487 ..ManifestEntry::default()
1488 },
1489 }
1490 }
1491
1492 fn refresh_hashes(&self, manifest: &mut Manifest, clip_id: &str, size: Option<u64>) {
1494 let desired = self.by_id.get(clip_id).copied();
1495 if let Some(entry) = manifest.entries.get_mut(clip_id) {
1496 if let Some(d) = desired {
1497 entry.meta_hash = d.meta_hash.clone();
1498 entry.art_hash = d.art_hash.clone();
1499 entry.preserve = preserve_for(d);
1500 }
1501 if let Some(size) = size {
1502 entry.size = size;
1503 }
1504 }
1505 }
1506
1507 fn refresh_preserve(&self, manifest: &mut Manifest, clip_id: &str) {
1514 if let Some(d) = self.by_id.get(clip_id).copied()
1515 && let Some(entry) = manifest.entries.get_mut(clip_id)
1516 {
1517 entry.preserve = preserve_for(d);
1518 }
1519 }
1520}
1521
1522fn manifest_entry(d: &Desired, size: u64) -> ManifestEntry {
1524 ManifestEntry {
1525 path: d.path.clone(),
1526 format: d.format,
1527 meta_hash: d.meta_hash.clone(),
1528 art_hash: d.art_hash.clone(),
1529 size,
1530 preserve: preserve_for(d),
1531 ..Default::default()
1532 }
1533}
1534
1535fn preserve_for(d: &Desired) -> bool {
1538 d.private || d.modes.contains(&SourceMode::Copy)
1539}
1540
1541fn classify_response(
1543 result: Result<crate::http::HttpResponse, crate::http::TransportError>,
1544) -> Result<Vec<u8>, FetchError> {
1545 let response = match result {
1546 Ok(response) => response,
1547 Err(err) => {
1548 return Err(FetchError::transient(
1549 format!("transport error: {err}"),
1550 None,
1551 ));
1552 }
1553 };
1554 match response.status {
1555 200..=299 => {
1556 if let Some(expected) = content_length(&response) {
1557 let actual = response.body.len() as u64;
1558 if actual != expected {
1559 return Err(FetchError::transient(
1560 format!("truncated download: {actual} of {expected} bytes"),
1561 None,
1562 ));
1563 }
1564 }
1565 Ok(response.body)
1566 }
1567 401 | 403 => Err(FetchError::transient(
1568 format!("download rejected: status {}", response.status),
1569 None,
1570 )),
1571 408 => Err(FetchError::transient("request timed out", None)),
1572 429 => Err(FetchError::transient(
1573 "rate limited",
1574 retry_after(&response),
1575 )),
1576 500..=599 => Err(FetchError::transient(
1577 format!("server error {}", response.status),
1578 None,
1579 )),
1580 status => Err(FetchError::permanent(format!(
1581 "download failed: status {status}"
1582 ))),
1583 }
1584}
1585
1586fn classify_core(id: &str, err: Error) -> Fail {
1588 let reason = err.to_string();
1589 match err {
1590 Error::Auth(_) => auth_fail(id, reason),
1591 Error::RateLimited { .. } | Error::Connection(_) => transient_fail(id, reason),
1592 Error::Api(_)
1593 | Error::NotFound(_)
1594 | Error::Tag(_)
1595 | Error::Config(_)
1596 | Error::Refused(_) => permanent_fail(id, reason),
1597 }
1598}
1599
1600fn content_length(response: &crate::http::HttpResponse) -> Option<u64> {
1602 response.header("content-length")?.trim().parse().ok()
1603}
1604
1605#[cfg(test)]
1606mod tests {
1607 use super::*;
1608 use crate::ClerkAuth;
1609 use crate::http::HttpResponse;
1610 use crate::testutil::{MemFs, RecordingClock, Reply, ScriptedHttp, StubFfmpeg};
1611
1612 fn clip(id: &str) -> Clip {
1613 Clip {
1614 id: id.to_owned(),
1615 title: "Song".to_owned(),
1616 audio_url: format!("https://cdn1.suno.ai/{id}.mp3"),
1617 ..Default::default()
1618 }
1619 }
1620
1621 fn art_clip(id: &str) -> Clip {
1622 Clip {
1623 image_large_url: format!("https://art.suno.ai/{id}/large.jpg"),
1624 image_url: format!("https://art.suno.ai/{id}/small.jpg"),
1625 ..clip(id)
1626 }
1627 }
1628
1629 fn ext(format: AudioFormat) -> &'static str {
1630 match format {
1631 AudioFormat::Mp3 => "mp3",
1632 AudioFormat::Flac => "flac",
1633 AudioFormat::Wav => "wav",
1634 }
1635 }
1636
1637 fn desired(clip: Clip, format: AudioFormat) -> Desired {
1638 Desired {
1639 path: format!("{}.{}", clip.id, ext(format)),
1640 lineage: LineageContext::own_root(&clip),
1641 clip,
1642 format,
1643 meta_hash: "m".to_owned(),
1644 art_hash: "art".to_owned(),
1645 modes: vec![SourceMode::Mirror],
1646 trashed: false,
1647 private: false,
1648 artifacts: Vec::new(),
1649 stems: None,
1650 }
1651 }
1652
1653 fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
1654 ManifestEntry {
1655 path: path.to_owned(),
1656 format,
1657 meta_hash: "old".to_owned(),
1658 art_hash: "old-art".to_owned(),
1659 size: 8,
1660 preserve: false,
1661 ..Default::default()
1662 }
1663 }
1664
1665 #[allow(clippy::too_many_arguments)]
1666 fn run(
1667 plan: &Plan,
1668 manifest: &mut Manifest,
1669 desired: &[Desired],
1670 http: &ScriptedHttp,
1671 fs: &MemFs,
1672 ffmpeg: &StubFfmpeg,
1673 clock: &RecordingClock,
1674 opts: &ExecOptions,
1675 ) -> ExecOutcome {
1676 let mut albums = BTreeMap::new();
1677 run_with_albums(
1678 plan,
1679 manifest,
1680 &mut albums,
1681 desired,
1682 http,
1683 fs,
1684 ffmpeg,
1685 clock,
1686 opts,
1687 )
1688 }
1689
1690 #[allow(clippy::too_many_arguments)]
1691 fn run_with_albums(
1692 plan: &Plan,
1693 manifest: &mut Manifest,
1694 albums: &mut BTreeMap<String, AlbumArt>,
1695 desired: &[Desired],
1696 http: &ScriptedHttp,
1697 fs: &MemFs,
1698 ffmpeg: &StubFfmpeg,
1699 clock: &RecordingClock,
1700 opts: &ExecOptions,
1701 ) -> ExecOutcome {
1702 let mut playlists = BTreeMap::new();
1703 run_full(
1704 plan,
1705 manifest,
1706 albums,
1707 &mut playlists,
1708 desired,
1709 http,
1710 fs,
1711 ffmpeg,
1712 clock,
1713 opts,
1714 )
1715 }
1716
1717 #[allow(clippy::too_many_arguments)]
1718 fn run_full(
1719 plan: &Plan,
1720 manifest: &mut Manifest,
1721 albums: &mut BTreeMap<String, AlbumArt>,
1722 playlists: &mut BTreeMap<String, PlaylistState>,
1723 desired: &[Desired],
1724 http: &ScriptedHttp,
1725 fs: &MemFs,
1726 ffmpeg: &StubFfmpeg,
1727 clock: &RecordingClock,
1728 opts: &ExecOptions,
1729 ) -> ExecOutcome {
1730 let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1731 let synced = HashMap::new();
1732 pollster::block_on(execute(
1733 plan,
1734 manifest,
1735 albums,
1736 playlists,
1737 desired,
1738 &synced,
1739 Ports {
1740 client: &mut client,
1741 http,
1742 fs,
1743 ffmpeg,
1744 clock,
1745 },
1746 opts,
1747 ))
1748 }
1749
1750 fn small_poll() -> ExecOptions {
1751 ExecOptions {
1752 max_retries: 3,
1753 wav_poll_attempts: 2,
1754 wav_poll_interval: Duration::from_secs(5),
1755 concurrency: 4,
1756 }
1757 }
1758
1759 #[test]
1762 fn download_mp3_writes_tagged_file_and_records_manifest() {
1763 let c = art_clip("a");
1764 let d = desired(c.clone(), AudioFormat::Mp3);
1765 let plan = Plan {
1766 actions: vec![Action::Download {
1767 clip: c.clone(),
1768 lineage: LineageContext::own_root(&c),
1769 path: d.path.clone(),
1770 format: AudioFormat::Mp3,
1771 }],
1772 };
1773 let http = ScriptedHttp::new()
1774 .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
1775 .route("a/large.jpg", Reply::ok(b"art-bytes".to_vec()));
1776 let fs = MemFs::new();
1777 let ffmpeg = StubFfmpeg::flac();
1778 let clock = RecordingClock::new();
1779 let mut manifest = Manifest::new();
1780
1781 let outcome = run(
1782 &plan,
1783 &mut manifest,
1784 &[d],
1785 &http,
1786 &fs,
1787 &ffmpeg,
1788 &clock,
1789 &ExecOptions::default(),
1790 );
1791
1792 assert_eq!(outcome.downloaded, 1);
1793 assert_eq!(outcome.failed(), 0);
1794 assert_eq!(outcome.status, RunStatus::Completed);
1795 let written = fs.read_file("a.mp3").unwrap();
1796 assert_eq!(&written[..3], b"ID3");
1797 assert!(written.ends_with(b"mp3-body"));
1798 let entry = manifest.get("a").unwrap();
1799 assert_eq!(entry.path, "a.mp3");
1800 assert_eq!(entry.format, AudioFormat::Mp3);
1801 assert_eq!(entry.meta_hash, "m");
1802 assert_eq!(entry.art_hash, "art");
1803 assert_eq!(entry.size, written.len() as u64);
1804 assert!(!entry.preserve);
1805 }
1806
1807 #[test]
1808 fn download_mp3_embeds_sylt_and_lyrics_from_synced_map() {
1809 let c = art_clip("a");
1812 let d = desired(c.clone(), AudioFormat::Mp3);
1813 let plan = Plan {
1814 actions: vec![Action::Download {
1815 clip: c.clone(),
1816 lineage: LineageContext::own_root(&c),
1817 path: d.path.clone(),
1818 format: AudioFormat::Mp3,
1819 }],
1820 };
1821 let http = ScriptedHttp::new()
1822 .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
1823 .route("a/large.jpg", Reply::ok(b"art-bytes".to_vec()));
1824 let fs = MemFs::new();
1825 let ffmpeg = StubFfmpeg::flac();
1826 let clock = RecordingClock::new();
1827 let mut manifest = Manifest::new();
1828 let mut albums = BTreeMap::new();
1829 let mut playlists = BTreeMap::new();
1830 let mut synced = HashMap::new();
1831 synced.insert(
1832 "a".to_string(),
1833 AlignedLyrics::from_json(&serde_json::json!({
1834 "aligned_words": [],
1835 "aligned_lyrics": [
1836 {"text": "hi there", "start_s": 0.5, "end_s": 1.2, "section": "Verse 1",
1837 "words": [
1838 {"text": "hi", "start_s": 0.5, "end_s": 0.8},
1839 {"text": "there", "start_s": 0.9, "end_s": 1.2}
1840 ]}
1841 ]
1842 })),
1843 );
1844 let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1845 let outcome = pollster::block_on(execute(
1846 &plan,
1847 &mut manifest,
1848 &mut albums,
1849 &mut playlists,
1850 &[d],
1851 &synced,
1852 Ports {
1853 client: &mut client,
1854 http: &http,
1855 fs: &fs,
1856 ffmpeg: &ffmpeg,
1857 clock: &clock,
1858 },
1859 &ExecOptions::default(),
1860 ));
1861
1862 assert_eq!(outcome.downloaded, 1);
1863 let written = fs.read_file("a.mp3").unwrap();
1864 let tag = id3::Tag::read_from2(std::io::Cursor::new(written)).unwrap();
1865 assert_eq!(
1866 tag.synchronised_lyrics().count(),
1867 1,
1868 "a SYLT frame is embedded"
1869 );
1870 assert_eq!(
1872 tag.lyrics().next().map(|frame| frame.text.as_str()),
1873 Some("hi there")
1874 );
1875 }
1876
1877 #[test]
1878 fn download_mp3_embeds_no_sylt_when_synced_map_empty() {
1879 let c = art_clip("a");
1882 let d = desired(c.clone(), AudioFormat::Mp3);
1883 let plan = Plan {
1884 actions: vec![Action::Download {
1885 clip: c.clone(),
1886 lineage: LineageContext::own_root(&c),
1887 path: d.path.clone(),
1888 format: AudioFormat::Mp3,
1889 }],
1890 };
1891 let http = ScriptedHttp::new()
1892 .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
1893 .route("a/large.jpg", Reply::ok(b"art-bytes".to_vec()));
1894 let fs = MemFs::new();
1895 let ffmpeg = StubFfmpeg::flac();
1896 let clock = RecordingClock::new();
1897 let mut manifest = Manifest::new();
1898 let mut albums = BTreeMap::new();
1899 let mut playlists = BTreeMap::new();
1900 let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1901 let outcome = pollster::block_on(execute(
1902 &plan,
1903 &mut manifest,
1904 &mut albums,
1905 &mut playlists,
1906 &[d],
1907 &HashMap::new(),
1908 Ports {
1909 client: &mut client,
1910 http: &http,
1911 fs: &fs,
1912 ffmpeg: &ffmpeg,
1913 clock: &clock,
1914 },
1915 &ExecOptions::default(),
1916 ));
1917 assert_eq!(outcome.downloaded, 1);
1918 let written = fs.read_file("a.mp3").unwrap();
1919 let tag = id3::Tag::read_from2(std::io::Cursor::new(written)).unwrap();
1920 assert_eq!(tag.synchronised_lyrics().count(), 0);
1921 assert_eq!(tag.lyrics().count(), 0);
1922 }
1923
1924 #[test]
1925 fn download_mp3_uses_cdn_fallback_when_audio_url_empty() {
1926 let mut c = clip("a");
1927 c.audio_url = String::new();
1928 let d = desired(c.clone(), AudioFormat::Mp3);
1929 let plan = Plan {
1930 actions: vec![Action::Download {
1931 clip: c.clone(),
1932 lineage: LineageContext::own_root(&c),
1933 path: d.path.clone(),
1934 format: AudioFormat::Mp3,
1935 }],
1936 };
1937 let http = ScriptedHttp::new().route("cdn1.suno.ai/a.mp3", Reply::ok(b"body".to_vec()));
1938 let fs = MemFs::new();
1939 let mut manifest = Manifest::new();
1940 let outcome = run(
1941 &plan,
1942 &mut manifest,
1943 &[d],
1944 &http,
1945 &fs,
1946 &StubFfmpeg::flac(),
1947 &RecordingClock::new(),
1948 &ExecOptions::default(),
1949 );
1950 assert_eq!(outcome.downloaded, 1);
1951 assert_eq!(http.count("cdn1.suno.ai/a.mp3"), 1);
1952 }
1953
1954 #[test]
1957 fn download_flac_renders_transcodes_and_records() {
1958 let c = clip("b");
1959 let d = desired(c.clone(), AudioFormat::Flac);
1960 let plan = Plan {
1961 actions: vec![Action::Download {
1962 clip: c.clone(),
1963 lineage: LineageContext::own_root(&c),
1964 path: d.path.clone(),
1965 format: AudioFormat::Flac,
1966 }],
1967 };
1968 let http = ScriptedHttp::new()
1969 .with_auth()
1970 .route(
1971 "/wav_file/",
1972 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/b.wav"}"#),
1973 )
1974 .route("b.wav", Reply::ok(b"wav-bytes".to_vec()));
1975 let fs = MemFs::new();
1976 let clock = RecordingClock::new();
1977 let mut manifest = Manifest::new();
1978
1979 let outcome = run(
1980 &plan,
1981 &mut manifest,
1982 &[d],
1983 &http,
1984 &fs,
1985 &StubFfmpeg::flac(),
1986 &clock,
1987 &ExecOptions::default(),
1988 );
1989
1990 assert_eq!(outcome.downloaded, 1);
1991 assert_eq!(outcome.failed(), 0);
1992 let written = fs.read_file("b.flac").unwrap();
1993 assert_eq!(&written[..4], b"fLaC");
1994 assert_eq!(manifest.get("b").unwrap().format, AudioFormat::Flac);
1995 assert_eq!(http.count("/convert_wav/"), 0);
1997 assert!(clock.sleeps().is_empty());
1998 }
1999
2000 #[test]
2001 fn download_flac_requests_render_then_polls_until_ready() {
2002 let c = clip("c");
2003 let d = desired(c.clone(), AudioFormat::Flac);
2004 let plan = Plan {
2005 actions: vec![Action::Download {
2006 clip: c.clone(),
2007 lineage: LineageContext::own_root(&c),
2008 path: d.path.clone(),
2009 format: AudioFormat::Flac,
2010 }],
2011 };
2012 let http = ScriptedHttp::new()
2013 .with_auth()
2014 .route_seq(
2015 "/wav_file/",
2016 vec![
2017 Reply::json("{}"),
2018 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/c.wav"}"#),
2019 ],
2020 )
2021 .route("/convert_wav/", Reply::status(200))
2022 .route("c.wav", Reply::ok(b"wav".to_vec()));
2023 let clock = RecordingClock::new();
2024 let mut manifest = Manifest::new();
2025
2026 let outcome = run(
2027 &plan,
2028 &mut manifest,
2029 &[d],
2030 &http,
2031 &fs_new(),
2032 &StubFfmpeg::flac(),
2033 &clock,
2034 &small_poll(),
2035 );
2036
2037 assert_eq!(outcome.downloaded, 1);
2038 assert_eq!(http.count("/convert_wav/"), 1);
2039 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
2040 }
2041
2042 #[test]
2043 fn download_flac_unavailable_render_is_a_nonfatal_failure() {
2044 let c = clip("d");
2045 let d = desired(c.clone(), AudioFormat::Flac);
2046 let plan = Plan {
2047 actions: vec![Action::Download {
2048 clip: c.clone(),
2049 lineage: LineageContext::own_root(&c),
2050 path: d.path.clone(),
2051 format: AudioFormat::Flac,
2052 }],
2053 };
2054 let http = ScriptedHttp::new()
2055 .with_auth()
2056 .route("/wav_file/", Reply::json("{}"))
2057 .route("/convert_wav/", Reply::status(200));
2058 let fs = MemFs::new();
2059 let clock = RecordingClock::new();
2060 let mut manifest = Manifest::new();
2061
2062 let outcome = run(
2063 &plan,
2064 &mut manifest,
2065 &[d],
2066 &http,
2067 &fs,
2068 &StubFfmpeg::flac(),
2069 &clock,
2070 &small_poll(),
2071 );
2072
2073 assert_eq!(outcome.downloaded, 0);
2074 assert_eq!(outcome.failed(), 1);
2075 assert_eq!(outcome.failures[0].clip_id, "d");
2076 assert_eq!(outcome.status, RunStatus::Completed);
2077 assert!(!fs.exists("d.flac"));
2078 assert_eq!(clock.sleeps().len(), 2);
2079 }
2080
2081 #[test]
2082 fn flac_transcode_failure_is_recorded_and_skipped() {
2083 let c = clip("t");
2084 let d = desired(c.clone(), AudioFormat::Flac);
2085 let plan = Plan {
2086 actions: vec![Action::Download {
2087 clip: c.clone(),
2088 lineage: LineageContext::own_root(&c),
2089 path: d.path.clone(),
2090 format: AudioFormat::Flac,
2091 }],
2092 };
2093 let http = ScriptedHttp::new()
2094 .with_auth()
2095 .route(
2096 "/wav_file/",
2097 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/t.wav"}"#),
2098 )
2099 .route("t.wav", Reply::ok(b"wav".to_vec()));
2100 let fs = MemFs::new();
2101 let mut manifest = Manifest::new();
2102
2103 let outcome = run(
2104 &plan,
2105 &mut manifest,
2106 &[d],
2107 &http,
2108 &fs,
2109 &StubFfmpeg::failing(),
2110 &RecordingClock::new(),
2111 &ExecOptions::default(),
2112 );
2113
2114 assert_eq!(outcome.downloaded, 0);
2115 assert_eq!(outcome.failed(), 1);
2116 assert!(!fs.exists("t.flac"));
2117 assert!(manifest.get("t").is_none());
2118 }
2119
2120 #[test]
2123 fn cover_falls_back_when_large_image_is_missing() {
2124 let c = art_clip("e");
2125 let d = desired(c.clone(), AudioFormat::Mp3);
2126 let plan = Plan {
2127 actions: vec![Action::Download {
2128 clip: c.clone(),
2129 lineage: LineageContext::own_root(&c),
2130 path: d.path.clone(),
2131 format: AudioFormat::Mp3,
2132 }],
2133 };
2134 let http = ScriptedHttp::new()
2135 .route("e.mp3", Reply::ok(b"body".to_vec()))
2136 .route("e/large.jpg", Reply::status(404))
2137 .route("e/small.jpg", Reply::ok(b"the-art".to_vec()));
2138 let fs = MemFs::new();
2139 let mut manifest = Manifest::new();
2140
2141 let outcome = run(
2142 &plan,
2143 &mut manifest,
2144 &[d],
2145 &http,
2146 &fs,
2147 &StubFfmpeg::flac(),
2148 &RecordingClock::new(),
2149 &ExecOptions::default(),
2150 );
2151
2152 assert_eq!(outcome.downloaded, 1);
2153 let calls = http.calls();
2154 let large = calls
2155 .iter()
2156 .position(|u| u.contains("e/large.jpg"))
2157 .unwrap();
2158 let small = calls
2159 .iter()
2160 .position(|u| u.contains("e/small.jpg"))
2161 .unwrap();
2162 assert!(large < small, "large art tried before small");
2163 }
2164
2165 #[test]
2168 fn download_reuses_the_embedded_cover_for_the_jpg_sidecar() {
2169 let c = art_clip("a");
2172 let d = desired(c.clone(), AudioFormat::Mp3);
2173 let plan = Plan {
2174 actions: vec![
2175 Action::Download {
2176 clip: c.clone(),
2177 lineage: LineageContext::own_root(&c),
2178 path: d.path.clone(),
2179 format: AudioFormat::Mp3,
2180 },
2181 Action::WriteArtifact {
2182 kind: ArtifactKind::CoverJpg,
2183 path: "a/cover.jpg".to_owned(),
2184 source_url: c.selected_image_url().unwrap().to_owned(),
2185 hash: "art".to_owned(),
2186 owner_id: "a".to_owned(),
2187 content: None,
2188 },
2189 ],
2190 };
2191 let http = ScriptedHttp::new()
2192 .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
2193 .route("a/large.jpg", Reply::ok(b"the-art".to_vec()));
2194 let fs = MemFs::new();
2195 let mut manifest = Manifest::new();
2196
2197 let outcome = run(
2198 &plan,
2199 &mut manifest,
2200 &[d],
2201 &http,
2202 &fs,
2203 &StubFfmpeg::flac(),
2204 &RecordingClock::new(),
2205 &ExecOptions::default(),
2206 );
2207
2208 assert_eq!(outcome.downloaded, 1);
2209 assert_eq!(outcome.artifacts_written, 1);
2210 assert_eq!(outcome.failed(), 0);
2211 assert_eq!(http.count("a/large.jpg"), 1);
2213 assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"the-art");
2215 assert_eq!(&fs.read_file("a.mp3").unwrap()[..3], b"ID3");
2216 }
2217
2218 #[test]
2219 fn concurrent_downloads_reuse_each_clips_own_cover() {
2220 let a = art_clip("a");
2223 let b = art_clip("b");
2224 let da = desired(a.clone(), AudioFormat::Mp3);
2225 let db = desired(b.clone(), AudioFormat::Mp3);
2226 let plan = Plan {
2227 actions: vec![
2228 Action::Download {
2229 clip: a.clone(),
2230 lineage: LineageContext::own_root(&a),
2231 path: da.path.clone(),
2232 format: AudioFormat::Mp3,
2233 },
2234 Action::WriteArtifact {
2235 kind: ArtifactKind::CoverJpg,
2236 path: "a/cover.jpg".to_owned(),
2237 source_url: a.selected_image_url().unwrap().to_owned(),
2238 hash: "art".to_owned(),
2239 owner_id: "a".to_owned(),
2240 content: None,
2241 },
2242 Action::Download {
2243 clip: b.clone(),
2244 lineage: LineageContext::own_root(&b),
2245 path: db.path.clone(),
2246 format: AudioFormat::Mp3,
2247 },
2248 Action::WriteArtifact {
2249 kind: ArtifactKind::CoverJpg,
2250 path: "b/cover.jpg".to_owned(),
2251 source_url: b.selected_image_url().unwrap().to_owned(),
2252 hash: "art".to_owned(),
2253 owner_id: "b".to_owned(),
2254 content: None,
2255 },
2256 ],
2257 };
2258 let http = ScriptedHttp::new()
2259 .route("a.mp3", Reply::ok(b"a-mp3".to_vec()))
2260 .route("b.mp3", Reply::ok(b"b-mp3".to_vec()))
2261 .route("a/large.jpg", Reply::ok(b"art-a".to_vec()))
2262 .route("b/large.jpg", Reply::ok(b"art-b".to_vec()));
2263 let fs = MemFs::new();
2264 let mut manifest = Manifest::new();
2265
2266 let outcome = run(
2267 &plan,
2268 &mut manifest,
2269 &[da, db],
2270 &http,
2271 &fs,
2272 &StubFfmpeg::flac(),
2273 &RecordingClock::new(),
2274 &small_poll(),
2275 );
2276
2277 assert_eq!(outcome.downloaded, 2);
2278 assert_eq!(outcome.artifacts_written, 2);
2279 assert_eq!(http.count("a/large.jpg"), 1);
2280 assert_eq!(http.count("b/large.jpg"), 1);
2281 assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"art-a");
2282 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"art-b");
2283 }
2284
2285 #[test]
2286 fn cover_sidecar_refetches_when_embed_fell_back_to_another_url() {
2287 let c = art_clip("e");
2292 let d = desired(c.clone(), AudioFormat::Mp3);
2293 let plan = Plan {
2294 actions: vec![
2295 Action::Download {
2296 clip: c.clone(),
2297 lineage: LineageContext::own_root(&c),
2298 path: d.path.clone(),
2299 format: AudioFormat::Mp3,
2300 },
2301 Action::WriteArtifact {
2302 kind: ArtifactKind::CoverJpg,
2303 path: "e/cover.jpg".to_owned(),
2304 source_url: "https://art.suno.ai/e/large.jpg".to_owned(),
2305 hash: "art".to_owned(),
2306 owner_id: "e".to_owned(),
2307 content: None,
2308 },
2309 ],
2310 };
2311 let http = ScriptedHttp::new()
2312 .route("e.mp3", Reply::ok(b"body".to_vec()))
2313 .route("e/large.jpg", Reply::status(404))
2314 .route("e/small.jpg", Reply::ok(b"small-art".to_vec()));
2315 let fs = MemFs::new();
2316 let mut manifest = Manifest::new();
2317
2318 let outcome = run(
2319 &plan,
2320 &mut manifest,
2321 &[d],
2322 &http,
2323 &fs,
2324 &StubFfmpeg::flac(),
2325 &RecordingClock::new(),
2326 &ExecOptions::default(),
2327 );
2328
2329 assert_eq!(outcome.downloaded, 1);
2330 assert_eq!(http.count("e/small.jpg"), 1);
2333 assert!(
2334 http.count("e/large.jpg") >= 2,
2335 "sidecar refetched the large URL"
2336 );
2337 assert_eq!(manifest.get("e").unwrap().cover_jpg, None);
2338 assert!(!fs.exists("e/cover.jpg"));
2339 }
2340
2341 #[test]
2344 fn failed_write_leaves_the_prior_file_intact() {
2345 let c = clip("f");
2346 let d = desired(c.clone(), AudioFormat::Mp3);
2347 let plan = Plan {
2348 actions: vec![Action::Download {
2349 clip: c.clone(),
2350 lineage: LineageContext::own_root(&c),
2351 path: d.path.clone(),
2352 format: AudioFormat::Mp3,
2353 }],
2354 };
2355 let http = ScriptedHttp::new().route("f.mp3", Reply::ok(b"new-body".to_vec()));
2356 let fs = MemFs::new()
2357 .with_file("f.mp3", b"OLD-CONTENT".to_vec())
2358 .fail_write("f.mp3");
2359 let mut manifest = Manifest::new();
2360
2361 let outcome = run(
2362 &plan,
2363 &mut manifest,
2364 &[d],
2365 &http,
2366 &fs,
2367 &StubFfmpeg::flac(),
2368 &RecordingClock::new(),
2369 &ExecOptions::default(),
2370 );
2371
2372 assert_eq!(outcome.downloaded, 0);
2373 assert_eq!(outcome.failed(), 1);
2374 assert_eq!(fs.read_file("f.mp3").unwrap(), b"OLD-CONTENT");
2375 assert!(manifest.get("f").is_none());
2376 }
2377
2378 #[test]
2379 fn size_mismatch_after_write_is_a_failure() {
2380 let c = clip("g");
2381 let d = desired(c.clone(), AudioFormat::Mp3);
2382 let plan = Plan {
2383 actions: vec![Action::Download {
2384 clip: c.clone(),
2385 lineage: LineageContext::own_root(&c),
2386 path: d.path.clone(),
2387 format: AudioFormat::Mp3,
2388 }],
2389 };
2390 let http = ScriptedHttp::new().route("g.mp3", Reply::ok(b"body".to_vec()));
2391 let fs = MemFs::new().corrupt_write("g.mp3");
2392 let mut manifest = Manifest::new();
2393
2394 let outcome = run(
2395 &plan,
2396 &mut manifest,
2397 &[d],
2398 &http,
2399 &fs,
2400 &StubFfmpeg::flac(),
2401 &RecordingClock::new(),
2402 &ExecOptions::default(),
2403 );
2404
2405 assert_eq!(outcome.downloaded, 0);
2406 assert_eq!(outcome.failed(), 1);
2407 assert!(outcome.failures[0].reason.contains("expected"));
2408 assert!(manifest.get("g").is_none());
2409 }
2410
2411 #[test]
2414 fn transient_failure_is_retried_then_skipped() {
2415 let c = clip("h");
2416 let d = desired(c.clone(), AudioFormat::Mp3);
2417 let plan = Plan {
2418 actions: vec![Action::Download {
2419 clip: c.clone(),
2420 lineage: LineageContext::own_root(&c),
2421 path: d.path.clone(),
2422 format: AudioFormat::Mp3,
2423 }],
2424 };
2425 let http = ScriptedHttp::new().route("h.mp3", Reply::status(500));
2426 let fs = MemFs::new();
2427 let clock = RecordingClock::new();
2428 let opts = ExecOptions {
2429 max_retries: 2,
2430 ..ExecOptions::default()
2431 };
2432 let mut manifest = Manifest::new();
2433
2434 let outcome = run(
2435 &plan,
2436 &mut manifest,
2437 &[d],
2438 &http,
2439 &fs,
2440 &StubFfmpeg::flac(),
2441 &clock,
2442 &opts,
2443 );
2444
2445 assert_eq!(outcome.downloaded, 0);
2446 assert_eq!(outcome.failed(), 1);
2447 assert_eq!(http.count("h.mp3"), 3);
2448 assert_eq!(clock.sleeps().len(), 2);
2449 }
2450
2451 #[test]
2452 fn truncated_download_is_retried_then_succeeds() {
2453 let c = clip("i");
2454 let d = desired(c.clone(), AudioFormat::Mp3);
2455 let plan = Plan {
2456 actions: vec![Action::Download {
2457 clip: c.clone(),
2458 lineage: LineageContext::own_root(&c),
2459 path: d.path.clone(),
2460 format: AudioFormat::Mp3,
2461 }],
2462 };
2463 let http = ScriptedHttp::new().route_seq(
2464 "i.mp3",
2465 vec![
2466 Reply::ok(b"short".to_vec()).with_content_length(999),
2467 Reply::ok(b"good-body".to_vec()),
2468 ],
2469 );
2470 let fs = MemFs::new();
2471 let clock = RecordingClock::new();
2472 let mut manifest = Manifest::new();
2473
2474 let outcome = run(
2475 &plan,
2476 &mut manifest,
2477 &[d],
2478 &http,
2479 &fs,
2480 &StubFfmpeg::flac(),
2481 &clock,
2482 &ExecOptions::default(),
2483 );
2484
2485 assert_eq!(outcome.downloaded, 1);
2486 assert_eq!(http.count("i.mp3"), 2);
2487 assert_eq!(clock.sleeps().len(), 1);
2488 }
2489
2490 #[test]
2491 fn rate_limit_backs_off_using_retry_after() {
2492 let c = clip("j");
2493 let d = desired(c.clone(), AudioFormat::Mp3);
2494 let plan = Plan {
2495 actions: vec![Action::Download {
2496 clip: c.clone(),
2497 lineage: LineageContext::own_root(&c),
2498 path: d.path.clone(),
2499 format: AudioFormat::Mp3,
2500 }],
2501 };
2502 let http = ScriptedHttp::new().route_seq(
2503 "j.mp3",
2504 vec![
2505 Reply::status(429).with_retry_after(7),
2506 Reply::ok(b"body".to_vec()),
2507 ],
2508 );
2509 let fs = MemFs::new();
2510 let clock = RecordingClock::new();
2511 let mut manifest = Manifest::new();
2512
2513 let outcome = run(
2514 &plan,
2515 &mut manifest,
2516 &[d],
2517 &http,
2518 &fs,
2519 &StubFfmpeg::flac(),
2520 &clock,
2521 &ExecOptions::default(),
2522 );
2523
2524 assert_eq!(outcome.downloaded, 1);
2525 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
2526 }
2527
2528 #[test]
2529 fn auth_failure_aborts_the_run() {
2530 let c1 = clip("k1");
2531 let c2 = clip("k2");
2532 let d1 = desired(c1.clone(), AudioFormat::Flac);
2533 let d2 = desired(c2.clone(), AudioFormat::Flac);
2534 let plan = Plan {
2535 actions: vec![
2536 Action::Download {
2537 clip: c1.clone(),
2538 lineage: LineageContext::own_root(&c1),
2539 path: d1.path.clone(),
2540 format: AudioFormat::Flac,
2541 },
2542 Action::Download {
2543 clip: c2.clone(),
2544 lineage: LineageContext::own_root(&c2),
2545 path: d2.path.clone(),
2546 format: AudioFormat::Flac,
2547 },
2548 ],
2549 };
2550 let http = ScriptedHttp::new()
2554 .with_auth()
2555 .route("/wav_file/", Reply::status(401));
2556 let fs = MemFs::new();
2557 let mut manifest = Manifest::new();
2558
2559 let outcome = run(
2560 &plan,
2561 &mut manifest,
2562 &[d1, d2],
2563 &http,
2564 &fs,
2565 &StubFfmpeg::flac(),
2566 &RecordingClock::new(),
2567 &small_poll(),
2568 );
2569
2570 assert_eq!(outcome.status, RunStatus::AuthAborted);
2571 assert_eq!(outcome.failed(), 1);
2572 assert_eq!(outcome.failures[0].clip_id, "k1");
2573 assert_eq!(outcome.downloaded, 0);
2574 }
2575
2576 #[test]
2579 fn disk_full_primary_write_aborts_the_run() {
2580 let c1 = clip("d1");
2584 let c2 = clip("d2");
2585 let d1 = desired(c1.clone(), AudioFormat::Mp3);
2586 let d2 = desired(c2.clone(), AudioFormat::Mp3);
2587 let plan = Plan {
2588 actions: vec![
2589 Action::Download {
2590 clip: c1.clone(),
2591 lineage: LineageContext::own_root(&c1),
2592 path: d1.path.clone(),
2593 format: AudioFormat::Mp3,
2594 },
2595 Action::Download {
2596 clip: c2.clone(),
2597 lineage: LineageContext::own_root(&c2),
2598 path: d2.path.clone(),
2599 format: AudioFormat::Mp3,
2600 },
2601 ],
2602 };
2603 let http = ScriptedHttp::new()
2604 .route("d1.mp3", Reply::ok(b"body-1".to_vec()))
2605 .route("d2.mp3", Reply::ok(b"body-2".to_vec()));
2606 let fs = MemFs::new().fail_write_out_of_space("d1.mp3");
2607 let mut manifest = Manifest::new();
2608
2609 let outcome = run(
2610 &plan,
2611 &mut manifest,
2612 &[d1, d2],
2613 &http,
2614 &fs,
2615 &StubFfmpeg::flac(),
2616 &RecordingClock::new(),
2617 &ExecOptions::default(),
2618 );
2619
2620 assert_eq!(outcome.status, RunStatus::DiskFull);
2621 assert_eq!(outcome.failed(), 1);
2622 assert_eq!(outcome.failures[0].clip_id, "d1");
2623 assert!(outcome.failures[0].reason.contains("disk full"));
2624 assert_eq!(outcome.downloaded, 0);
2625 assert_eq!(http.count("d2.mp3"), 0);
2627 assert!(!fs.exists("d2.mp3"));
2628 }
2629
2630 #[test]
2631 fn disk_full_flac_transcode_aborts_the_run() {
2632 let c1 = clip("d1");
2635 let c2 = clip("d2");
2636 let d1 = desired(c1.clone(), AudioFormat::Flac);
2637 let d2 = desired(c2.clone(), AudioFormat::Flac);
2638 let plan = Plan {
2639 actions: vec![
2640 Action::Download {
2641 clip: c1.clone(),
2642 lineage: LineageContext::own_root(&c1),
2643 path: d1.path.clone(),
2644 format: AudioFormat::Flac,
2645 },
2646 Action::Download {
2647 clip: c2.clone(),
2648 lineage: LineageContext::own_root(&c2),
2649 path: d2.path.clone(),
2650 format: AudioFormat::Flac,
2651 },
2652 ],
2653 };
2654 let http = ScriptedHttp::new()
2655 .with_auth()
2656 .route(
2657 "/wav_file/",
2658 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/d1.wav"}"#),
2659 )
2660 .route(".wav", Reply::ok(b"wav".to_vec()));
2661 let fs = MemFs::new();
2662 let mut manifest = Manifest::new();
2663
2664 let outcome = run(
2665 &plan,
2666 &mut manifest,
2667 &[d1, d2],
2668 &http,
2669 &fs,
2670 &StubFfmpeg::out_of_space(),
2671 &RecordingClock::new(),
2672 &ExecOptions::default(),
2673 );
2674
2675 assert_eq!(outcome.status, RunStatus::DiskFull);
2676 assert_eq!(outcome.failed(), 1);
2677 assert_eq!(outcome.failures[0].clip_id, "d1");
2678 assert!(outcome.failures[0].reason.contains("disk full"));
2679 assert_eq!(outcome.downloaded, 0);
2680 }
2681
2682 #[test]
2683 fn disk_full_artifact_write_aborts_the_run() {
2684 let mut manifest = Manifest::new();
2688 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
2689 let plan = Plan {
2690 actions: vec![Action::WriteArtifact {
2691 kind: ArtifactKind::CoverJpg,
2692 path: "a/cover.jpg".to_owned(),
2693 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
2694 hash: "h1".to_owned(),
2695 owner_id: "a".to_owned(),
2696 content: None,
2697 }],
2698 };
2699 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"jpg-bytes".to_vec()));
2700 let fs = MemFs::new().fail_write_out_of_space("a/cover.jpg");
2701
2702 let outcome = run(
2703 &plan,
2704 &mut manifest,
2705 &[],
2706 &http,
2707 &fs,
2708 &StubFfmpeg::flac(),
2709 &RecordingClock::new(),
2710 &ExecOptions::default(),
2711 );
2712
2713 assert_eq!(outcome.status, RunStatus::DiskFull);
2714 assert_eq!(outcome.failed(), 1);
2715 assert!(outcome.failures[0].reason.contains("disk full"));
2716 assert_eq!(outcome.artifacts_written, 0);
2717 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
2719 }
2720
2721 #[test]
2722 fn disk_full_leaves_the_failed_clips_manifest_entry_unchanged() {
2723 let c = clip("m");
2726 let d = desired(c.clone(), AudioFormat::Mp3);
2727 let plan = Plan {
2728 actions: vec![Action::Download {
2729 clip: c.clone(),
2730 lineage: LineageContext::own_root(&c),
2731 path: d.path.clone(),
2732 format: AudioFormat::Mp3,
2733 }],
2734 };
2735 let http = ScriptedHttp::new().route("m.mp3", Reply::ok(b"new-body".to_vec()));
2736 let fs = MemFs::new()
2737 .with_file("m.mp3", b"OLD-CONTENT".to_vec())
2738 .fail_write_out_of_space("m.mp3");
2739 let mut manifest = Manifest::new();
2740 let before = entry("m.mp3", AudioFormat::Mp3);
2741 manifest.insert("m", before.clone());
2742
2743 let outcome = run(
2744 &plan,
2745 &mut manifest,
2746 &[d],
2747 &http,
2748 &fs,
2749 &StubFfmpeg::flac(),
2750 &RecordingClock::new(),
2751 &ExecOptions::default(),
2752 );
2753
2754 assert_eq!(outcome.status, RunStatus::DiskFull);
2755 assert_eq!(manifest.get("m"), Some(&before));
2756 assert_eq!(fs.read_file("m.mp3").unwrap(), b"OLD-CONTENT");
2757 }
2758
2759 #[test]
2760 fn cdn_download_rejection_skips_the_clip_without_aborting() {
2761 let c1 = clip("k1");
2762 let c2 = clip("k2");
2763 let d1 = desired(c1.clone(), AudioFormat::Mp3);
2764 let d2 = desired(c2.clone(), AudioFormat::Mp3);
2765 let plan = Plan {
2766 actions: vec![
2767 Action::Download {
2768 clip: c1.clone(),
2769 lineage: LineageContext::own_root(&c1),
2770 path: d1.path.clone(),
2771 format: AudioFormat::Mp3,
2772 },
2773 Action::Download {
2774 clip: c2.clone(),
2775 lineage: LineageContext::own_root(&c2),
2776 path: d2.path.clone(),
2777 format: AudioFormat::Mp3,
2778 },
2779 ],
2780 };
2781 let http = ScriptedHttp::new()
2785 .route("k1.mp3", Reply::status(403))
2786 .route("k2.mp3", Reply::ok(b"body".to_vec()));
2787 let fs = MemFs::new();
2788 let mut manifest = Manifest::new();
2789
2790 let outcome = run(
2791 &plan,
2792 &mut manifest,
2793 &[d1, d2],
2794 &http,
2795 &fs,
2796 &StubFfmpeg::flac(),
2797 &RecordingClock::new(),
2798 &ExecOptions::default(),
2799 );
2800
2801 assert_ne!(outcome.status, RunStatus::AuthAborted);
2802 assert_eq!(outcome.downloaded, 1);
2803 assert_eq!(outcome.failed(), 1);
2804 assert_eq!(outcome.failures[0].clip_id, "k1");
2805 }
2806
2807 #[test]
2808 fn one_clip_failure_does_not_abort_the_run() {
2809 let c1 = clip("l1");
2810 let c2 = clip("l2");
2811 let d1 = desired(c1.clone(), AudioFormat::Mp3);
2812 let d2 = desired(c2.clone(), AudioFormat::Mp3);
2813 let plan = Plan {
2814 actions: vec![
2815 Action::Download {
2816 clip: c1.clone(),
2817 lineage: LineageContext::own_root(&c1),
2818 path: d1.path.clone(),
2819 format: AudioFormat::Mp3,
2820 },
2821 Action::Download {
2822 clip: c2.clone(),
2823 lineage: LineageContext::own_root(&c2),
2824 path: d2.path.clone(),
2825 format: AudioFormat::Mp3,
2826 },
2827 ],
2828 };
2829 let http = ScriptedHttp::new()
2830 .route("l1.mp3", Reply::status(404))
2831 .route("l2.mp3", Reply::ok(b"body".to_vec()));
2832 let fs = MemFs::new();
2833 let mut manifest = Manifest::new();
2834
2835 let outcome = run(
2836 &plan,
2837 &mut manifest,
2838 &[d1, d2],
2839 &http,
2840 &fs,
2841 &StubFfmpeg::flac(),
2842 &RecordingClock::new(),
2843 &ExecOptions::default(),
2844 );
2845
2846 assert_eq!(outcome.status, RunStatus::Completed);
2847 assert_eq!(outcome.downloaded, 1);
2848 assert_eq!(outcome.failed(), 1);
2849 assert_eq!(outcome.failures[0].clip_id, "l1");
2850 assert!(fs.exists("l2.mp3"));
2851 assert!(manifest.get("l2").is_some());
2852 assert!(manifest.get("l1").is_none());
2853 }
2854
2855 #[test]
2858 fn preserve_is_set_for_copy_held_and_private_clips() {
2859 let mut mirror = desired(clip("m1"), AudioFormat::Mp3);
2860 mirror.modes = vec![SourceMode::Mirror];
2861 let mut copy_held = desired(clip("m2"), AudioFormat::Mp3);
2862 copy_held.modes = vec![SourceMode::Mirror, SourceMode::Copy];
2863 let mut private = desired(clip("m3"), AudioFormat::Mp3);
2864 private.private = true;
2865
2866 let plan = Plan {
2867 actions: vec![
2868 Action::Download {
2869 clip: mirror.clip.clone(),
2870 lineage: LineageContext::own_root(&mirror.clip),
2871 path: mirror.path.clone(),
2872 format: AudioFormat::Mp3,
2873 },
2874 Action::Download {
2875 clip: copy_held.clip.clone(),
2876 lineage: LineageContext::own_root(©_held.clip),
2877 path: copy_held.path.clone(),
2878 format: AudioFormat::Mp3,
2879 },
2880 Action::Download {
2881 clip: private.clip.clone(),
2882 lineage: LineageContext::own_root(&private.clip),
2883 path: private.path.clone(),
2884 format: AudioFormat::Mp3,
2885 },
2886 ],
2887 };
2888 let http = ScriptedHttp::new()
2889 .route("m1.mp3", Reply::ok(b"a".to_vec()))
2890 .route("m2.mp3", Reply::ok(b"b".to_vec()))
2891 .route("m3.mp3", Reply::ok(b"c".to_vec()));
2892 let fs = MemFs::new();
2893 let mut manifest = Manifest::new();
2894
2895 let outcome = run(
2896 &plan,
2897 &mut manifest,
2898 &[mirror, copy_held, private],
2899 &http,
2900 &fs,
2901 &StubFfmpeg::flac(),
2902 &RecordingClock::new(),
2903 &ExecOptions::default(),
2904 );
2905
2906 assert_eq!(outcome.downloaded, 3);
2907 assert!(!manifest.get("m1").unwrap().preserve);
2908 assert!(manifest.get("m2").unwrap().preserve);
2909 assert!(manifest.get("m3").unwrap().preserve);
2910 }
2911
2912 #[test]
2915 fn reformat_writes_new_format_and_removes_old_file() {
2916 let c = clip("n");
2917 let d = desired(c.clone(), AudioFormat::Mp3);
2918 let plan = Plan {
2919 actions: vec![Action::Reformat {
2920 clip: c.clone(),
2921 path: "n.mp3".to_owned(),
2922 from_path: "n.flac".to_owned(),
2923 from: AudioFormat::Flac,
2924 to: AudioFormat::Mp3,
2925 }],
2926 };
2927 let http = ScriptedHttp::new().route("n.mp3", Reply::ok(b"body".to_vec()));
2928 let fs = MemFs::new().with_file("n.flac", b"OLD-FLAC".to_vec());
2929 let mut manifest = Manifest::new();
2930 manifest.insert("n", entry("n.flac", AudioFormat::Flac));
2931
2932 let outcome = run(
2933 &plan,
2934 &mut manifest,
2935 &[d],
2936 &http,
2937 &fs,
2938 &StubFfmpeg::flac(),
2939 &RecordingClock::new(),
2940 &ExecOptions::default(),
2941 );
2942
2943 assert_eq!(outcome.reformatted, 1);
2944 assert!(fs.exists("n.mp3"));
2945 assert!(!fs.exists("n.flac"));
2946 let updated = manifest.get("n").unwrap();
2947 assert_eq!(updated.path, "n.mp3");
2948 assert_eq!(updated.format, AudioFormat::Mp3);
2949 assert_eq!(updated.meta_hash, "m");
2950 }
2951
2952 #[test]
2953 fn retag_rewrites_file_and_updates_hashes() {
2954 let c = clip("o");
2955 let mut d = desired(c.clone(), AudioFormat::Mp3);
2956 d.meta_hash = "new".to_owned();
2957 d.art_hash = "new-art".to_owned();
2958 let existing = tag_mp3(
2959 b"audio",
2960 &TrackMetadata::from_clip(&c, &LineageContext::own_root(&c)),
2961 None,
2962 None,
2963 )
2964 .unwrap();
2965 let fs = MemFs::new().with_file("o.mp3", existing.clone());
2966 let mut manifest = Manifest::new();
2967 let mut start = entry("o.mp3", AudioFormat::Mp3);
2968 start.size = existing.len() as u64;
2969 manifest.insert("o", start);
2970 let plan = Plan {
2971 actions: vec![Action::Retag {
2972 clip: c.clone(),
2973 lineage: LineageContext::own_root(&c),
2974 path: "o.mp3".to_owned(),
2975 }],
2976 };
2977
2978 let outcome = run(
2979 &plan,
2980 &mut manifest,
2981 &[d],
2982 &ScriptedHttp::new(),
2983 &fs,
2984 &StubFfmpeg::flac(),
2985 &RecordingClock::new(),
2986 &ExecOptions::default(),
2987 );
2988
2989 assert_eq!(outcome.retagged, 1);
2990 let updated = manifest.get("o").unwrap();
2991 assert_eq!(updated.meta_hash, "new");
2992 assert_eq!(updated.art_hash, "new-art");
2993 assert_eq!(&fs.read_file("o.mp3").unwrap()[..3], b"ID3");
2994 }
2995
2996 #[test]
2997 fn rename_moves_file_and_updates_manifest_path() {
2998 let c = clip("p");
2999 let mut d = desired(c.clone(), AudioFormat::Mp3);
3000 d.path = "new/p.mp3".to_owned();
3001 let fs = MemFs::new().with_file("old/p.mp3", b"DATA".to_vec());
3002 let mut manifest = Manifest::new();
3003 manifest.insert("p", entry("old/p.mp3", AudioFormat::Mp3));
3004 let plan = Plan {
3005 actions: vec![Action::Rename {
3006 from: "old/p.mp3".to_owned(),
3007 to: "new/p.mp3".to_owned(),
3008 }],
3009 };
3010
3011 let outcome = run(
3012 &plan,
3013 &mut manifest,
3014 &[d],
3015 &ScriptedHttp::new(),
3016 &fs,
3017 &StubFfmpeg::flac(),
3018 &RecordingClock::new(),
3019 &ExecOptions::default(),
3020 );
3021
3022 assert_eq!(outcome.renamed, 1);
3023 assert!(fs.exists("new/p.mp3"));
3024 assert!(!fs.exists("old/p.mp3"));
3025 assert_eq!(manifest.get("p").unwrap().path, "new/p.mp3");
3026 }
3027
3028 #[test]
3029 fn disk_full_rename_aborts_the_run() {
3030 let c = clip("p");
3033 let mut d = desired(c.clone(), AudioFormat::Mp3);
3034 d.path = "new/p.mp3".to_owned();
3035 let fs = MemFs::new()
3036 .with_file("old/p.mp3", b"DATA".to_vec())
3037 .fail_rename_out_of_space("new/p.mp3");
3038 let mut manifest = Manifest::new();
3039 manifest.insert("p", entry("old/p.mp3", AudioFormat::Mp3));
3040 let plan = Plan {
3041 actions: vec![Action::Rename {
3042 from: "old/p.mp3".to_owned(),
3043 to: "new/p.mp3".to_owned(),
3044 }],
3045 };
3046
3047 let outcome = run(
3048 &plan,
3049 &mut manifest,
3050 &[d],
3051 &ScriptedHttp::new(),
3052 &fs,
3053 &StubFfmpeg::flac(),
3054 &RecordingClock::new(),
3055 &ExecOptions::default(),
3056 );
3057
3058 assert_eq!(outcome.status, RunStatus::DiskFull);
3059 assert_eq!(outcome.renamed, 0);
3060 assert_eq!(outcome.failed(), 1);
3061 assert!(outcome.failures[0].reason.contains("disk full"));
3062 assert!(fs.exists("old/p.mp3"));
3064 assert!(!fs.exists("new/p.mp3"));
3065 assert_eq!(manifest.get("p").unwrap().path, "old/p.mp3");
3066 }
3067
3068 #[test]
3069 fn delete_removes_file_and_manifest_entry() {
3070 let fs = MemFs::new().with_file("q.mp3", b"DATA".to_vec());
3071 let mut manifest = Manifest::new();
3072 manifest.insert("q", entry("q.mp3", AudioFormat::Mp3));
3073 let plan = Plan {
3074 actions: vec![Action::Delete {
3075 path: "q.mp3".to_owned(),
3076 clip_id: "q".to_owned(),
3077 }],
3078 };
3079
3080 let outcome = run(
3081 &plan,
3082 &mut manifest,
3083 &[],
3084 &ScriptedHttp::new(),
3085 &fs,
3086 &StubFfmpeg::flac(),
3087 &RecordingClock::new(),
3088 &ExecOptions::default(),
3089 );
3090
3091 assert_eq!(outcome.deleted, 1);
3092 assert!(!fs.exists("q.mp3"));
3093 assert!(manifest.get("q").is_none());
3094 }
3095
3096 #[test]
3097 fn failed_delete_keeps_the_manifest_entry() {
3098 let fs = MemFs::new()
3099 .with_file("s.mp3", b"DATA".to_vec())
3100 .fail_remove("s.mp3");
3101 let mut manifest = Manifest::new();
3102 manifest.insert("s", entry("s.mp3", AudioFormat::Mp3));
3103 let plan = Plan {
3104 actions: vec![Action::Delete {
3105 path: "s.mp3".to_owned(),
3106 clip_id: "s".to_owned(),
3107 }],
3108 };
3109
3110 let outcome = run(
3111 &plan,
3112 &mut manifest,
3113 &[],
3114 &ScriptedHttp::new(),
3115 &fs,
3116 &StubFfmpeg::flac(),
3117 &RecordingClock::new(),
3118 &ExecOptions::default(),
3119 );
3120
3121 assert_eq!(outcome.deleted, 0);
3122 assert_eq!(outcome.failed(), 1);
3123 assert!(manifest.get("s").is_some());
3124 assert!(fs.exists("s.mp3"));
3125 }
3126
3127 #[test]
3128 fn skip_is_a_noop() {
3129 let mut manifest = Manifest::new();
3130 let plan = Plan {
3131 actions: vec![Action::Skip {
3132 clip_id: "r".to_owned(),
3133 }],
3134 };
3135 let outcome = run(
3136 &plan,
3137 &mut manifest,
3138 &[],
3139 &ScriptedHttp::new(),
3140 &MemFs::new(),
3141 &StubFfmpeg::flac(),
3142 &RecordingClock::new(),
3143 &ExecOptions::default(),
3144 );
3145 assert_eq!(outcome.skipped, 1);
3146 assert_eq!(outcome.failed(), 0);
3147 }
3148
3149 #[test]
3152 fn header_helpers_parse_or_ignore() {
3153 let resp = HttpResponse {
3154 status: 200,
3155 headers: vec![("Content-Length".to_owned(), "42".to_owned())],
3156 body: Vec::new(),
3157 };
3158 assert_eq!(content_length(&resp), Some(42));
3159
3160 let bare = HttpResponse {
3161 status: 200,
3162 headers: Vec::new(),
3163 body: Vec::new(),
3164 };
3165 assert_eq!(content_length(&bare), None);
3166 }
3167
3168 #[test]
3169 fn preserve_rule_covers_copy_and_private() {
3170 let base = desired(clip("x"), AudioFormat::Mp3);
3171 assert!(!preserve_for(&base));
3172 let mut copy_held = base.clone();
3173 copy_held.modes = vec![SourceMode::Copy];
3174 assert!(preserve_for(©_held));
3175 let mut private = base.clone();
3176 private.private = true;
3177 assert!(preserve_for(&private));
3178 }
3179
3180 fn fs_new() -> MemFs {
3181 MemFs::new()
3182 }
3183
3184 #[test]
3187 fn skip_sets_preserve_when_a_clip_becomes_copy_held() {
3188 let c = clip("s1");
3189 let mut d = desired(c.clone(), AudioFormat::Mp3);
3190 d.modes = vec![SourceMode::Copy];
3191 let plan = Plan {
3192 actions: vec![Action::Skip {
3193 clip_id: "s1".to_owned(),
3194 }],
3195 };
3196 let mut manifest = Manifest::new();
3197 manifest.insert("s1".to_owned(), entry("s1.mp3", AudioFormat::Mp3));
3198 assert!(!manifest.get("s1").unwrap().preserve);
3199
3200 let outcome = run(
3201 &plan,
3202 &mut manifest,
3203 &[d],
3204 &ScriptedHttp::new(),
3205 &fs_new(),
3206 &StubFfmpeg::flac(),
3207 &RecordingClock::new(),
3208 &ExecOptions::default(),
3209 );
3210
3211 assert_eq!(outcome.skipped, 1);
3212 assert!(
3213 manifest.get("s1").unwrap().preserve,
3214 "a copy-held skip must mark the entry preserved"
3215 );
3216 }
3217
3218 #[test]
3219 fn skip_clears_stale_preserve_when_a_clip_returns_to_mirror_only() {
3220 let c = clip("s2");
3221 let d = desired(c.clone(), AudioFormat::Mp3);
3222 let plan = Plan {
3223 actions: vec![Action::Skip {
3224 clip_id: "s2".to_owned(),
3225 }],
3226 };
3227 let mut manifest = Manifest::new();
3228 let mut stale = entry("s2.mp3", AudioFormat::Mp3);
3229 stale.preserve = true;
3230 manifest.insert("s2".to_owned(), stale);
3231
3232 run(
3233 &plan,
3234 &mut manifest,
3235 &[d],
3236 &ScriptedHttp::new(),
3237 &fs_new(),
3238 &StubFfmpeg::flac(),
3239 &RecordingClock::new(),
3240 &ExecOptions::default(),
3241 );
3242
3243 assert!(
3244 !manifest.get("s2").unwrap().preserve,
3245 "a mirror-only skip must clear a stale preserve marker"
3246 );
3247 }
3248
3249 #[test]
3250 fn flac_render_retries_a_rate_limited_wav_lookup() {
3251 let c = clip("rl");
3252 let d = desired(c.clone(), AudioFormat::Flac);
3253 let plan = Plan {
3254 actions: vec![Action::Download {
3255 clip: c.clone(),
3256 lineage: LineageContext::own_root(&c),
3257 path: d.path.clone(),
3258 format: AudioFormat::Flac,
3259 }],
3260 };
3261 let http = ScriptedHttp::new()
3262 .with_auth()
3263 .route_seq(
3264 "/wav_file/",
3265 vec![
3266 Reply::status(429),
3267 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/rl.wav"}"#),
3268 ],
3269 )
3270 .route("rl.wav", Reply::ok(b"wav".to_vec()));
3271 let clock = RecordingClock::new();
3272 let mut manifest = Manifest::new();
3273
3274 let outcome = run(
3275 &plan,
3276 &mut manifest,
3277 &[d],
3278 &http,
3279 &fs_new(),
3280 &StubFfmpeg::flac(),
3281 &clock,
3282 &small_poll(),
3283 );
3284
3285 assert_eq!(outcome.downloaded, 1);
3286 assert_eq!(outcome.failed(), 0);
3287 assert_eq!(http.count("/convert_wav/"), 0);
3289 assert_eq!(clock.sleeps(), vec![Duration::from_secs(1)]);
3291 }
3292
3293 #[test]
3296 fn write_artifact_fetches_writes_and_updates_manifest() {
3297 let mut manifest = Manifest::new();
3300 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
3301 let plan = Plan {
3302 actions: vec![Action::WriteArtifact {
3303 kind: ArtifactKind::CoverJpg,
3304 path: "a/cover.jpg".to_owned(),
3305 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
3306 hash: "h1".to_owned(),
3307 owner_id: "a".to_owned(),
3308 content: None,
3309 }],
3310 };
3311 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"jpg-bytes".to_vec()));
3312 let fs = MemFs::new();
3313
3314 let outcome = run(
3315 &plan,
3316 &mut manifest,
3317 &[],
3318 &http,
3319 &fs,
3320 &StubFfmpeg::flac(),
3321 &RecordingClock::new(),
3322 &ExecOptions::default(),
3323 );
3324
3325 assert_eq!(outcome.artifacts_written, 1);
3326 assert_eq!(outcome.failed(), 0);
3327 assert_eq!(outcome.status, RunStatus::Completed);
3328 assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"jpg-bytes");
3329 assert_eq!(
3330 manifest.get("a").unwrap().cover_jpg,
3331 Some(ArtifactState {
3332 path: "a/cover.jpg".to_owned(),
3333 hash: "h1".to_owned(),
3334 })
3335 );
3336 }
3337
3338 #[test]
3339 fn write_text_sidecar_records_slot_with_no_network_fetch() {
3340 let mut manifest = Manifest::new();
3343 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
3344 let plan = Plan {
3345 actions: vec![Action::WriteArtifact {
3346 kind: ArtifactKind::DetailsTxt,
3347 path: "a.details.txt".to_owned(),
3348 source_url: String::new(),
3349 hash: "dh".to_owned(),
3350 owner_id: "a".to_owned(),
3351 content: Some("Title: A\n".to_owned()),
3352 }],
3353 };
3354 let http = ScriptedHttp::new();
3356 let fs = MemFs::new();
3357
3358 let outcome = run(
3359 &plan,
3360 &mut manifest,
3361 &[],
3362 &http,
3363 &fs,
3364 &StubFfmpeg::flac(),
3365 &RecordingClock::new(),
3366 &ExecOptions::default(),
3367 );
3368
3369 assert_eq!(outcome.artifacts_written, 1);
3370 assert_eq!(outcome.failed(), 0);
3371 assert_eq!(fs.read_file("a.details.txt").unwrap(), b"Title: A\n");
3372 assert_eq!(
3373 manifest.get("a").unwrap().details_txt,
3374 Some(ArtifactState {
3375 path: "a.details.txt".to_owned(),
3376 hash: "dh".to_owned(),
3377 })
3378 );
3379 }
3380
3381 #[test]
3382 fn write_lyrics_sidecar_relocation_removes_old_file() {
3383 let mut manifest = Manifest::new();
3386 let mut e = entry("old/a.flac", AudioFormat::Flac);
3387 e.lyrics_txt = Some(ArtifactState {
3388 path: "old/a.lyrics.txt".to_owned(),
3389 hash: "lh".to_owned(),
3390 });
3391 manifest.insert("a", e);
3392 let fs = MemFs::new()
3393 .with_file("old/a.flac", b"AUDIO".to_vec())
3394 .with_file("old/a.lyrics.txt", b"old words\n".to_vec());
3395 let plan = Plan {
3396 actions: vec![Action::WriteArtifact {
3397 kind: ArtifactKind::LyricsTxt,
3398 path: "new/a.lyrics.txt".to_owned(),
3399 source_url: String::new(),
3400 hash: "lh".to_owned(),
3401 owner_id: "a".to_owned(),
3402 content: Some("new words\n".to_owned()),
3403 }],
3404 };
3405
3406 let outcome = run(
3407 &plan,
3408 &mut manifest,
3409 &[],
3410 &ScriptedHttp::new(),
3411 &fs,
3412 &StubFfmpeg::flac(),
3413 &RecordingClock::new(),
3414 &ExecOptions::default(),
3415 );
3416
3417 assert_eq!(outcome.failed(), 0);
3418 assert_eq!(fs.read_file("new/a.lyrics.txt").unwrap(), b"new words\n");
3419 assert!(!fs.exists("old/a.lyrics.txt"));
3420 assert_eq!(
3421 manifest.get("a").unwrap().lyrics_txt.as_ref().unwrap().path,
3422 "new/a.lyrics.txt"
3423 );
3424 }
3425
3426 #[test]
3427 fn sidecar_path_swap_never_deletes_a_file_written_this_run() {
3428 let mut manifest = Manifest::new();
3434 let mut a = entry("a.flac", AudioFormat::Flac);
3435 a.lyrics_txt = Some(ArtifactState {
3436 path: "x.lyrics.txt".to_owned(),
3437 hash: "ah".to_owned(),
3438 });
3439 manifest.insert("a", a);
3440 let mut b = entry("b.flac", AudioFormat::Flac);
3441 b.lyrics_txt = Some(ArtifactState {
3442 path: "y.lyrics.txt".to_owned(),
3443 hash: "bh".to_owned(),
3444 });
3445 manifest.insert("b", b);
3446 let fs = MemFs::new()
3447 .with_file("a.flac", b"A".to_vec())
3448 .with_file("b.flac", b"B".to_vec())
3449 .with_file("x.lyrics.txt", b"A words\n".to_vec())
3450 .with_file("y.lyrics.txt", b"B words\n".to_vec());
3451 let plan = Plan {
3453 actions: vec![
3454 Action::WriteArtifact {
3455 kind: ArtifactKind::LyricsTxt,
3456 path: "y.lyrics.txt".to_owned(),
3457 source_url: String::new(),
3458 hash: "ah".to_owned(),
3459 owner_id: "a".to_owned(),
3460 content: Some("A words\n".to_owned()),
3461 },
3462 Action::WriteArtifact {
3463 kind: ArtifactKind::LyricsTxt,
3464 path: "x.lyrics.txt".to_owned(),
3465 source_url: String::new(),
3466 hash: "bh".to_owned(),
3467 owner_id: "b".to_owned(),
3468 content: Some("B words\n".to_owned()),
3469 },
3470 ],
3471 };
3472
3473 let outcome = run(
3474 &plan,
3475 &mut manifest,
3476 &[],
3477 &ScriptedHttp::new(),
3478 &fs,
3479 &StubFfmpeg::flac(),
3480 &RecordingClock::new(),
3481 &ExecOptions::default(),
3482 );
3483
3484 assert_eq!(outcome.failed(), 0);
3485 assert_eq!(fs.read_file("y.lyrics.txt").unwrap(), b"A words\n");
3487 assert_eq!(fs.read_file("x.lyrics.txt").unwrap(), b"B words\n");
3488 assert_eq!(
3489 manifest.get("a").unwrap().lyrics_txt.as_ref().unwrap().path,
3490 "y.lyrics.txt"
3491 );
3492 assert_eq!(
3493 manifest.get("b").unwrap().lyrics_txt.as_ref().unwrap().path,
3494 "x.lyrics.txt"
3495 );
3496 }
3497
3498 #[test]
3499 fn old_sidecar_kept_when_another_clip_still_references_it() {
3500 let mut manifest = Manifest::new();
3505 let mut a = entry("a.flac", AudioFormat::Flac);
3506 a.lyrics_txt = Some(ArtifactState {
3507 path: "y.lyrics.txt".to_owned(),
3508 hash: "ah".to_owned(),
3509 });
3510 manifest.insert("a", a);
3511 let mut b = entry("b.flac", AudioFormat::Flac);
3512 b.lyrics_txt = Some(ArtifactState {
3513 path: "y.lyrics.txt".to_owned(),
3514 hash: "bh".to_owned(),
3515 });
3516 manifest.insert("b", b);
3517 let fs = MemFs::new()
3518 .with_file("a.flac", b"A".to_vec())
3519 .with_file("b.flac", b"B".to_vec())
3520 .with_file("y.lyrics.txt", b"A words\n".to_vec());
3521 let plan = Plan {
3524 actions: vec![Action::WriteArtifact {
3525 kind: ArtifactKind::LyricsTxt,
3526 path: "x.lyrics.txt".to_owned(),
3527 source_url: String::new(),
3528 hash: "bh".to_owned(),
3529 owner_id: "b".to_owned(),
3530 content: Some("B words\n".to_owned()),
3531 }],
3532 };
3533
3534 let outcome = run(
3535 &plan,
3536 &mut manifest,
3537 &[],
3538 &ScriptedHttp::new(),
3539 &fs,
3540 &StubFfmpeg::flac(),
3541 &RecordingClock::new(),
3542 &ExecOptions::default(),
3543 );
3544
3545 assert_eq!(outcome.failed(), 0);
3546 assert!(
3547 fs.exists("y.lyrics.txt"),
3548 "A's live sidecar must not be deleted"
3549 );
3550 assert_eq!(fs.read_file("x.lyrics.txt").unwrap(), b"B words\n");
3551 }
3552
3553 #[test]
3554 fn shared_old_path_is_reclaimed_when_every_referencing_clip_moves_away() {
3555 let mut manifest = Manifest::new();
3561 let mut a = entry("a.flac", AudioFormat::Flac);
3562 a.lyrics_txt = Some(ArtifactState {
3563 path: "s.lyrics.txt".to_owned(),
3564 hash: "ah".to_owned(),
3565 });
3566 manifest.insert("a", a);
3567 let mut b = entry("b.flac", AudioFormat::Flac);
3568 b.lyrics_txt = Some(ArtifactState {
3569 path: "s.lyrics.txt".to_owned(),
3570 hash: "bh".to_owned(),
3571 });
3572 manifest.insert("b", b);
3573 let fs = MemFs::new()
3574 .with_file("a.flac", b"A".to_vec())
3575 .with_file("b.flac", b"B".to_vec())
3576 .with_file("s.lyrics.txt", b"shared\n".to_vec());
3577 let plan = Plan {
3578 actions: vec![
3579 Action::WriteArtifact {
3580 kind: ArtifactKind::LyricsTxt,
3581 path: "pa.lyrics.txt".to_owned(),
3582 source_url: String::new(),
3583 hash: "ah".to_owned(),
3584 owner_id: "a".to_owned(),
3585 content: Some("A words\n".to_owned()),
3586 },
3587 Action::WriteArtifact {
3588 kind: ArtifactKind::LyricsTxt,
3589 path: "pb.lyrics.txt".to_owned(),
3590 source_url: String::new(),
3591 hash: "bh".to_owned(),
3592 owner_id: "b".to_owned(),
3593 content: Some("B words\n".to_owned()),
3594 },
3595 ],
3596 };
3597
3598 let outcome = run(
3599 &plan,
3600 &mut manifest,
3601 &[],
3602 &ScriptedHttp::new(),
3603 &fs,
3604 &StubFfmpeg::flac(),
3605 &RecordingClock::new(),
3606 &ExecOptions::default(),
3607 );
3608
3609 assert_eq!(outcome.failed(), 0);
3610 assert_eq!(fs.read_file("pa.lyrics.txt").unwrap(), b"A words\n");
3611 assert_eq!(fs.read_file("pb.lyrics.txt").unwrap(), b"B words\n");
3612 assert!(
3613 !fs.exists("s.lyrics.txt"),
3614 "the vacated shared path must be reclaimed, not orphaned"
3615 );
3616 }
3617
3618 #[test]
3619 fn write_text_sidecar_skipped_when_owner_audio_absent() {
3620 let plan = Plan {
3623 actions: vec![Action::WriteArtifact {
3624 kind: ArtifactKind::DetailsTxt,
3625 path: "gone.details.txt".to_owned(),
3626 source_url: String::new(),
3627 hash: "dh".to_owned(),
3628 owner_id: "gone".to_owned(),
3629 content: Some("Title: Gone\n".to_owned()),
3630 }],
3631 };
3632 let fs = MemFs::new();
3633 let mut manifest = Manifest::new();
3634
3635 let outcome = run(
3636 &plan,
3637 &mut manifest,
3638 &[],
3639 &ScriptedHttp::new(),
3640 &fs,
3641 &StubFfmpeg::flac(),
3642 &RecordingClock::new(),
3643 &ExecOptions::default(),
3644 );
3645
3646 assert_eq!(outcome.artifacts_written, 0);
3647 assert_eq!(outcome.skipped, 1);
3648 assert!(!fs.exists("gone.details.txt"));
3649 assert!(manifest.get("gone").is_none());
3650 }
3651
3652 #[test]
3653 fn delete_artifact_removes_file_and_clears_slot() {
3654 let fs = MemFs::new().with_file("a/cover.jpg", b"jpg".to_vec());
3655 let mut manifest = Manifest::new();
3656 let mut e = entry("a.mp3", AudioFormat::Mp3);
3657 e.cover_jpg = Some(ArtifactState {
3658 path: "a/cover.jpg".to_owned(),
3659 hash: "h1".to_owned(),
3660 });
3661 manifest.insert("a", e);
3662 let plan = Plan {
3663 actions: vec![Action::DeleteArtifact {
3664 kind: ArtifactKind::CoverJpg,
3665 path: "a/cover.jpg".to_owned(),
3666 owner_id: "a".to_owned(),
3667 }],
3668 };
3669
3670 let outcome = run(
3671 &plan,
3672 &mut manifest,
3673 &[],
3674 &ScriptedHttp::new(),
3675 &fs,
3676 &StubFfmpeg::flac(),
3677 &RecordingClock::new(),
3678 &ExecOptions::default(),
3679 );
3680
3681 assert_eq!(outcome.artifacts_deleted, 1);
3682 assert!(!fs.exists("a/cover.jpg"));
3683 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
3684 }
3685
3686 #[test]
3687 fn delete_artifact_tolerates_already_absent_file() {
3688 let mut manifest = Manifest::new();
3691 let mut e = entry("a.mp3", AudioFormat::Mp3);
3692 e.cover_jpg = Some(ArtifactState {
3693 path: "a/cover.jpg".to_owned(),
3694 hash: "h1".to_owned(),
3695 });
3696 manifest.insert("a", e);
3697 let plan = Plan {
3698 actions: vec![Action::DeleteArtifact {
3699 kind: ArtifactKind::CoverJpg,
3700 path: "a/cover.jpg".to_owned(),
3701 owner_id: "a".to_owned(),
3702 }],
3703 };
3704
3705 let outcome = run(
3706 &plan,
3707 &mut manifest,
3708 &[],
3709 &ScriptedHttp::new(),
3710 &MemFs::new(),
3711 &StubFfmpeg::flac(),
3712 &RecordingClock::new(),
3713 &ExecOptions::default(),
3714 );
3715
3716 assert_eq!(outcome.artifacts_deleted, 1);
3717 assert_eq!(outcome.failed(), 0);
3718 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
3719 }
3720
3721 #[test]
3722 fn write_artifact_http_failure_is_a_per_clip_failure_not_a_run_abort() {
3723 let mut manifest = Manifest::new();
3726 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
3727 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
3728 let plan = Plan {
3729 actions: vec![
3730 Action::WriteArtifact {
3731 kind: ArtifactKind::CoverJpg,
3732 path: "a/cover.jpg".to_owned(),
3733 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
3734 hash: "h1".to_owned(),
3735 owner_id: "a".to_owned(),
3736 content: None,
3737 },
3738 Action::WriteArtifact {
3739 kind: ArtifactKind::CoverJpg,
3740 path: "b/cover.jpg".to_owned(),
3741 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
3742 hash: "h2".to_owned(),
3743 owner_id: "b".to_owned(),
3744 content: None,
3745 },
3746 ],
3747 };
3748 let http = ScriptedHttp::new()
3749 .route("a/large.jpg", Reply::status(404))
3750 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
3751 let fs = MemFs::new();
3752
3753 let outcome = run(
3754 &plan,
3755 &mut manifest,
3756 &[],
3757 &http,
3758 &fs,
3759 &StubFfmpeg::flac(),
3760 &RecordingClock::new(),
3761 &ExecOptions::default(),
3762 );
3763
3764 assert_eq!(outcome.status, RunStatus::Completed);
3765 assert_eq!(outcome.failed(), 1);
3766 assert_eq!(outcome.failures[0].clip_id, "a");
3767 assert_eq!(outcome.artifacts_written, 1);
3768 assert!(!fs.exists("a/cover.jpg"));
3770 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
3771 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
3773 assert!(manifest.get("b").unwrap().cover_jpg.is_some());
3774 }
3775
3776 #[test]
3777 fn co_delete_executes_audio_delete_then_artifact_delete() {
3778 let fs = MemFs::new()
3782 .with_file("gone.mp3", b"DATA".to_vec())
3783 .with_file("gone/cover.jpg", b"jpg".to_vec());
3784 let mut manifest = Manifest::new();
3785 let mut e = entry("gone.mp3", AudioFormat::Mp3);
3786 e.cover_jpg = Some(ArtifactState {
3787 path: "gone/cover.jpg".to_owned(),
3788 hash: "h1".to_owned(),
3789 });
3790 manifest.insert("gone", e);
3791 let plan = Plan {
3792 actions: vec![
3793 Action::Delete {
3794 path: "gone.mp3".to_owned(),
3795 clip_id: "gone".to_owned(),
3796 },
3797 Action::DeleteArtifact {
3798 kind: ArtifactKind::CoverJpg,
3799 path: "gone/cover.jpg".to_owned(),
3800 owner_id: "gone".to_owned(),
3801 },
3802 ],
3803 };
3804
3805 let outcome = run(
3806 &plan,
3807 &mut manifest,
3808 &[],
3809 &ScriptedHttp::new(),
3810 &fs,
3811 &StubFfmpeg::flac(),
3812 &RecordingClock::new(),
3813 &ExecOptions::default(),
3814 );
3815
3816 assert_eq!(outcome.deleted, 1);
3817 assert_eq!(outcome.artifacts_deleted, 1);
3818 assert_eq!(outcome.failed(), 0);
3819 assert!(!fs.exists("gone.mp3"));
3820 assert!(!fs.exists("gone/cover.jpg"));
3821 assert!(manifest.get("gone").is_none());
3822 }
3823
3824 #[test]
3825 fn write_stem_mp3_stores_raw_and_records_slot() {
3826 let mut manifest = Manifest::new();
3830 manifest.insert("a", entry("a.flac", AudioFormat::Flac));
3831 let plan = Plan {
3832 actions: vec![Action::WriteStem {
3833 clip_id: "a".to_owned(),
3834 key: "voc".to_owned(),
3835 stem_id: "voc".to_owned(),
3836 path: "a.stems/a - Vocals [voc].mp3".to_owned(),
3837 source_url: "https://cdn1.suno.ai/voc.mp3".to_owned(),
3838 format: StemFormat::Mp3,
3839 hash: "vh".to_owned(),
3840 }],
3841 };
3842 let http = ScriptedHttp::new().route("voc.mp3", Reply::ok(b"stem-bytes".to_vec()));
3843 let fs = MemFs::new();
3844
3845 let outcome = run(
3846 &plan,
3847 &mut manifest,
3848 &[],
3849 &http,
3850 &fs,
3851 &StubFfmpeg::flac(),
3852 &RecordingClock::new(),
3853 &ExecOptions::default(),
3854 );
3855
3856 assert_eq!(outcome.artifacts_written, 1);
3857 assert_eq!(outcome.failed(), 0);
3858 assert_eq!(
3860 fs.read_file("a.stems/a - Vocals [voc].mp3").unwrap(),
3861 b"stem-bytes"
3862 );
3863 assert_eq!(http.count("convert_wav"), 0);
3865 assert_eq!(http.count("/api/gen/"), 0);
3866 assert_eq!(
3867 manifest.get("a").unwrap().stems.get("voc"),
3868 Some(&ArtifactState {
3869 path: "a.stems/a - Vocals [voc].mp3".to_owned(),
3870 hash: "vh".to_owned(),
3871 })
3872 );
3873 }
3874
3875 #[test]
3876 fn write_stem_wav_renders_via_convert_wav_and_stores_raw() {
3877 let mut manifest = Manifest::new();
3881 manifest.insert("a", entry("a.flac", AudioFormat::Flac));
3882 let plan = Plan {
3883 actions: vec![Action::WriteStem {
3884 clip_id: "a".to_owned(),
3885 key: "voc".to_owned(),
3886 stem_id: "stemvoc".to_owned(),
3887 path: "a.stems/a - Vocals [stemvoc].wav".to_owned(),
3888 source_url: "https://cdn1.suno.ai/stemvoc.mp3".to_owned(),
3889 format: StemFormat::Wav,
3890 hash: "vh".to_owned(),
3891 }],
3892 };
3893 let http = ScriptedHttp::new()
3896 .with_auth()
3897 .route_seq(
3898 "stemvoc/wav_file/",
3899 vec![
3900 Reply::json("{}"),
3901 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/stemvoc.wav"}"#),
3902 ],
3903 )
3904 .route("stemvoc/convert_wav/", Reply::status(200))
3905 .route("stemvoc.wav", Reply::ok(b"RIFFwav-bytes".to_vec()));
3906 let fs = MemFs::new();
3907
3908 let outcome = run(
3909 &plan,
3910 &mut manifest,
3911 &[],
3912 &http,
3913 &fs,
3914 &StubFfmpeg::flac(),
3915 &RecordingClock::new(),
3916 &small_poll(),
3917 );
3918
3919 assert_eq!(outcome.artifacts_written, 1);
3920 assert_eq!(outcome.failed(), 0);
3921 assert_eq!(
3924 fs.read_file("a.stems/a - Vocals [stemvoc].wav").unwrap(),
3925 b"RIFFwav-bytes"
3926 );
3927 assert!(!fs.exists("a.stems/a - Vocals [stemvoc].flac"));
3928 assert_eq!(http.count("convert_wav"), 1);
3930 assert_eq!(http.count("stem_task"), 0);
3931 assert_eq!(http.count("separate"), 0);
3932 assert_eq!(
3933 manifest.get("a").unwrap().stems.get("voc").unwrap().path,
3934 "a.stems/a - Vocals [stemvoc].wav"
3935 );
3936 }
3937
3938 #[test]
3939 fn write_stem_is_skipped_when_owner_audio_is_absent() {
3940 let mut manifest = Manifest::new();
3943 let plan = Plan {
3944 actions: vec![Action::WriteStem {
3945 clip_id: "ghost".to_owned(),
3946 key: "voc".to_owned(),
3947 stem_id: "voc".to_owned(),
3948 path: "ghost.stems/voc.mp3".to_owned(),
3949 source_url: "https://cdn1.suno.ai/voc.mp3".to_owned(),
3950 format: StemFormat::Mp3,
3951 hash: "vh".to_owned(),
3952 }],
3953 };
3954 let http = ScriptedHttp::new();
3956 let fs = MemFs::new();
3957
3958 let outcome = run(
3959 &plan,
3960 &mut manifest,
3961 &[],
3962 &http,
3963 &fs,
3964 &StubFfmpeg::flac(),
3965 &RecordingClock::new(),
3966 &ExecOptions::default(),
3967 );
3968
3969 assert_eq!(outcome.skipped, 1);
3970 assert_eq!(outcome.artifacts_written, 0);
3971 assert_eq!(outcome.failed(), 0);
3972 assert!(!fs.exists("ghost.stems/voc.mp3"));
3973 }
3974
3975 #[test]
3976 fn write_stem_relocates_the_old_file_on_a_path_move() {
3977 let fs = MemFs::new().with_file("old.stems/voc.mp3", b"old".to_vec());
3980 let mut manifest = Manifest::new();
3981 let mut e = entry("new.flac", AudioFormat::Flac);
3982 e.stems.insert(
3983 "voc".to_owned(),
3984 ArtifactState {
3985 path: "old.stems/voc.mp3".to_owned(),
3986 hash: "vh".to_owned(),
3987 },
3988 );
3989 manifest.insert("a", e);
3990 let plan = Plan {
3991 actions: vec![Action::WriteStem {
3992 clip_id: "a".to_owned(),
3993 key: "voc".to_owned(),
3994 stem_id: "voc".to_owned(),
3995 path: "new.stems/voc.mp3".to_owned(),
3996 source_url: "https://cdn1.suno.ai/voc.mp3".to_owned(),
3997 format: StemFormat::Mp3,
3998 hash: "vh".to_owned(),
3999 }],
4000 };
4001 let http = ScriptedHttp::new().route("voc.mp3", Reply::ok(b"new".to_vec()));
4002
4003 let outcome = run(
4004 &plan,
4005 &mut manifest,
4006 &[],
4007 &http,
4008 &fs,
4009 &StubFfmpeg::flac(),
4010 &RecordingClock::new(),
4011 &ExecOptions::default(),
4012 );
4013
4014 assert_eq!(outcome.artifacts_written, 1);
4015 assert!(fs.exists("new.stems/voc.mp3"));
4016 assert!(
4017 !fs.exists("old.stems/voc.mp3"),
4018 "the old stem is moved, not left behind"
4019 );
4020 assert_eq!(
4021 manifest.get("a").unwrap().stems.get("voc").unwrap().path,
4022 "new.stems/voc.mp3"
4023 );
4024 }
4025
4026 #[test]
4027 fn delete_stem_removes_file_and_clears_slot() {
4028 let fs = MemFs::new().with_file("a.stems/voc.mp3", b"stem".to_vec());
4029 let mut manifest = Manifest::new();
4030 let mut e = entry("a.flac", AudioFormat::Flac);
4031 e.stems.insert(
4032 "voc".to_owned(),
4033 ArtifactState {
4034 path: "a.stems/voc.mp3".to_owned(),
4035 hash: "vh".to_owned(),
4036 },
4037 );
4038 manifest.insert("a", e);
4039 let plan = Plan {
4040 actions: vec![Action::DeleteStem {
4041 clip_id: "a".to_owned(),
4042 key: "voc".to_owned(),
4043 path: "a.stems/voc.mp3".to_owned(),
4044 }],
4045 };
4046
4047 let outcome = run(
4048 &plan,
4049 &mut manifest,
4050 &[],
4051 &ScriptedHttp::new(),
4052 &fs,
4053 &StubFfmpeg::flac(),
4054 &RecordingClock::new(),
4055 &ExecOptions::default(),
4056 );
4057
4058 assert_eq!(outcome.artifacts_deleted, 1);
4059 assert!(!fs.exists("a.stems/voc.mp3"));
4060 assert!(manifest.get("a").unwrap().stems.is_empty());
4061 }
4062
4063 #[test]
4064 fn co_deleting_the_last_stem_prunes_the_stems_folder() {
4065 let fs = MemFs::new()
4068 .with_file("song.flac", b"DATA".to_vec())
4069 .with_file("song.stems/voc.mp3", b"stem".to_vec());
4070 assert!(fs.has_dir("song.stems"));
4071 let mut manifest = Manifest::new();
4072 let mut e = entry("song.flac", AudioFormat::Flac);
4073 e.stems.insert(
4074 "voc".to_owned(),
4075 ArtifactState {
4076 path: "song.stems/voc.mp3".to_owned(),
4077 hash: "vh".to_owned(),
4078 },
4079 );
4080 manifest.insert("a", e);
4081 let plan = Plan {
4082 actions: vec![
4083 Action::Delete {
4084 path: "song.flac".to_owned(),
4085 clip_id: "a".to_owned(),
4086 },
4087 Action::DeleteStem {
4088 clip_id: "a".to_owned(),
4089 key: "voc".to_owned(),
4090 path: "song.stems/voc.mp3".to_owned(),
4091 },
4092 ],
4093 };
4094
4095 let outcome = run(
4096 &plan,
4097 &mut manifest,
4098 &[],
4099 &ScriptedHttp::new(),
4100 &fs,
4101 &StubFfmpeg::flac(),
4102 &RecordingClock::new(),
4103 &ExecOptions::default(),
4104 );
4105
4106 assert_eq!(outcome.deleted, 1);
4107 assert_eq!(outcome.artifacts_deleted, 1);
4108 assert!(!fs.exists("song.flac"));
4109 assert!(!fs.exists("song.stems/voc.mp3"));
4110 assert!(
4111 !fs.has_dir("song.stems"),
4112 "the emptied .stems folder is pruned"
4113 );
4114 assert!(manifest.get("a").is_none());
4115 }
4116
4117 #[test]
4118 fn write_stem_mp3_never_issues_a_generation_post() {
4119 let mut manifest = Manifest::new();
4122 manifest.insert("a", entry("a.flac", AudioFormat::Flac));
4123 let plan = Plan {
4124 actions: vec![Action::WriteStem {
4125 clip_id: "a".to_owned(),
4126 key: "voc".to_owned(),
4127 stem_id: "voc".to_owned(),
4128 path: "a.stems/voc.mp3".to_owned(),
4129 source_url: "https://cdn1.suno.ai/voc.mp3".to_owned(),
4130 format: StemFormat::Mp3,
4131 hash: "vh".to_owned(),
4132 }],
4133 };
4134 let http = ScriptedHttp::new().route("voc.mp3", Reply::ok(b"stem".to_vec()));
4135
4136 run(
4137 &plan,
4138 &mut manifest,
4139 &[],
4140 &http,
4141 &MemFs::new(),
4142 &StubFfmpeg::flac(),
4143 &RecordingClock::new(),
4144 &ExecOptions::default(),
4145 );
4146
4147 assert_eq!(
4148 http.count("stem_task"),
4149 0,
4150 "no generation endpoint is ever hit"
4151 );
4152 assert_eq!(http.count("convert_wav"), 0);
4153 assert_eq!(http.count("/api/gen/"), 0);
4154 }
4155
4156 #[test]
4157 fn full_stems_mirror_mp3_is_get_only_with_zero_gen_traffic() {
4158 let http = ScriptedHttp::new()
4163 .with_auth()
4164 .route("clip1/stems/pages", Reply::json(r#"{"pages": 1}"#))
4165 .route(
4166 "clip1/stems?page=0",
4167 Reply::json(
4168 r#"{"stems":[
4169 {"id":"s1","title":"Song (Vocals)","status":"complete","audio_url":"https://cdn1.suno.ai/s1.mp3"},
4170 {"id":"s2","title":"Song (Drums)","status":"complete","audio_url":"https://cdn1.suno.ai/s2.mp3"}
4171 ]}"#,
4172 ),
4173 )
4174 .route("s1.mp3", Reply::ok(b"vocals-bytes".to_vec()))
4175 .route("s2.mp3", Reply::ok(b"drums-bytes".to_vec()));
4176
4177 let mut auth = ClerkAuth::new("eyJtoken");
4179 pollster::block_on(auth.authenticate(&http)).unwrap();
4180 let mut client = SunoClient::new(auth, RecordingClock::new());
4181 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
4182 assert!(complete);
4183 assert_eq!(stems.len(), 2);
4184 assert_eq!(stems[0].label, "Vocals");
4185
4186 let mut manifest = Manifest::new();
4188 manifest.insert("clip1", entry("clip1.flac", AudioFormat::Flac));
4189 let desired_stems: Vec<crate::reconcile::DesiredStem> = stems
4190 .iter()
4191 .map(|s| crate::reconcile::DesiredStem {
4192 key: s.id.clone(),
4193 stem_id: s.id.clone(),
4194 path: format!("clip1.stems/{}.mp3", s.id),
4195 source_url: s.url.clone(),
4196 format: StemFormat::Mp3,
4197 hash: crate::art_url_hash(&s.url),
4198 })
4199 .collect();
4200 let d = Desired {
4201 path: "clip1.flac".to_owned(),
4202 stems: Some(desired_stems),
4203 ..desired(clip("clip1"), AudioFormat::Flac)
4204 };
4205 let local: HashMap<String, crate::reconcile::LocalFile> = [(
4206 "clip1".to_owned(),
4207 crate::reconcile::LocalFile {
4208 exists: true,
4209 size: 100,
4210 },
4211 )]
4212 .into_iter()
4213 .collect();
4214 let sources = [crate::reconcile::SourceStatus {
4215 mode: SourceMode::Mirror,
4216 fully_enumerated: true,
4217 }];
4218 let plan =
4219 crate::reconcile::reconcile(&manifest, std::slice::from_ref(&d), &local, &sources);
4220 assert_eq!(plan.stem_writes(), 2);
4221
4222 let fs = MemFs::new();
4223 let outcome = run(
4224 &plan,
4225 &mut manifest,
4226 std::slice::from_ref(&d),
4227 &http,
4228 &fs,
4229 &StubFfmpeg::flac(),
4230 &RecordingClock::new(),
4231 &ExecOptions::default(),
4232 );
4233
4234 assert_eq!(outcome.artifacts_written, 2, "both stems downloaded");
4235 assert_eq!(fs.read_file("clip1.stems/s1.mp3").unwrap(), b"vocals-bytes");
4236 assert_eq!(fs.read_file("clip1.stems/s2.mp3").unwrap(), b"drums-bytes");
4237 assert_eq!(http.count("/api/gen/"), 0);
4240 assert_eq!(http.count("stem_task"), 0);
4241 assert_eq!(http.count("separate"), 0);
4242 assert_eq!(http.count("generate"), 0);
4243 assert!(!fs.exists("clip1.stems/s1.flac"));
4245 }
4246
4247 #[test]
4248 fn full_stems_mirror_wav_default_renders_free_wav_and_no_generation() {
4249 let http = ScriptedHttp::new()
4253 .with_auth()
4254 .route("clip1/stems/pages", Reply::json(r#"{"pages": 1}"#))
4255 .route(
4256 "clip1/stems?page=0",
4257 Reply::json(
4258 r#"{"stems":[
4259 {"id":"s1","title":"Song (Vocals)","status":"complete","audio_url":"https://cdn1.suno.ai/s1.mp3"},
4260 {"id":"s2","title":"Song (Drums)","status":"complete","audio_url":"https://cdn1.suno.ai/s2.mp3"}
4261 ]}"#,
4262 ),
4263 )
4264 .route(
4267 "s1/wav_file/",
4268 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/s1.wav"}"#),
4269 )
4270 .route(
4271 "s2/wav_file/",
4272 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/s2.wav"}"#),
4273 )
4274 .route("s1.wav", Reply::ok(b"RIFFvocals".to_vec()))
4275 .route("s2.wav", Reply::ok(b"RIFFdrums".to_vec()));
4276
4277 let mut auth = ClerkAuth::new("eyJtoken");
4278 pollster::block_on(auth.authenticate(&http)).unwrap();
4279 let mut client = SunoClient::new(auth, RecordingClock::new());
4280 let (stems, _complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
4281
4282 let mut manifest = Manifest::new();
4283 manifest.insert("clip1", entry("clip1.flac", AudioFormat::Flac));
4284 let desired_stems: Vec<crate::reconcile::DesiredStem> = stems
4285 .iter()
4286 .map(|s| crate::reconcile::DesiredStem {
4287 key: s.id.clone(),
4288 stem_id: s.id.clone(),
4289 path: format!("clip1.stems/{}.wav", s.id),
4290 source_url: s.url.clone(),
4291 format: StemFormat::Wav,
4292 hash: crate::art_url_hash(&s.url),
4293 })
4294 .collect();
4295 let d = Desired {
4296 path: "clip1.flac".to_owned(),
4297 stems: Some(desired_stems),
4298 ..desired(clip("clip1"), AudioFormat::Flac)
4299 };
4300 let local: HashMap<String, crate::reconcile::LocalFile> = [(
4301 "clip1".to_owned(),
4302 crate::reconcile::LocalFile {
4303 exists: true,
4304 size: 100,
4305 },
4306 )]
4307 .into_iter()
4308 .collect();
4309 let sources = [crate::reconcile::SourceStatus {
4310 mode: SourceMode::Mirror,
4311 fully_enumerated: true,
4312 }];
4313 let plan =
4314 crate::reconcile::reconcile(&manifest, std::slice::from_ref(&d), &local, &sources);
4315
4316 let fs = MemFs::new();
4317 let outcome = run(
4318 &plan,
4319 &mut manifest,
4320 std::slice::from_ref(&d),
4321 &http,
4322 &fs,
4323 &StubFfmpeg::flac(),
4324 &RecordingClock::new(),
4325 &small_poll(),
4326 );
4327
4328 assert_eq!(outcome.artifacts_written, 2);
4329 assert_eq!(fs.read_file("clip1.stems/s1.wav").unwrap(), b"RIFFvocals");
4331 assert_eq!(fs.read_file("clip1.stems/s2.wav").unwrap(), b"RIFFdrums");
4332 assert!(!fs.exists("clip1.stems/s1.flac"));
4333 assert_eq!(http.count("stem_task"), 0);
4335 assert_eq!(http.count("separate"), 0);
4336 assert_eq!(http.count("generate"), 0);
4337 }
4338
4339 #[test]
4340 fn write_artifact_is_skipped_when_the_owner_audio_is_absent() {
4341 let ca = clip("a");
4345 let plan = Plan {
4346 actions: vec![
4347 Action::Download {
4348 clip: ca.clone(),
4349 lineage: LineageContext::own_root(&ca),
4350 path: "a.mp3".to_owned(),
4351 format: AudioFormat::Mp3,
4352 },
4353 Action::WriteArtifact {
4354 kind: ArtifactKind::CoverJpg,
4355 path: "a/cover.jpg".to_owned(),
4356 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
4357 hash: "h1".to_owned(),
4358 owner_id: "a".to_owned(),
4359 content: None,
4360 },
4361 Action::WriteArtifact {
4362 kind: ArtifactKind::CoverJpg,
4363 path: "b/cover.jpg".to_owned(),
4364 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
4365 hash: "h2".to_owned(),
4366 owner_id: "b".to_owned(),
4367 content: None,
4368 },
4369 ],
4370 };
4371 let http = ScriptedHttp::new()
4373 .route("a.mp3", Reply::status(404))
4374 .route("a/large.jpg", Reply::ok(b"jpg-a".to_vec()))
4375 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
4376 let fs = MemFs::new();
4377 let mut manifest = Manifest::new();
4378 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
4380
4381 let outcome = run(
4382 &plan,
4383 &mut manifest,
4384 &[],
4385 &http,
4386 &fs,
4387 &StubFfmpeg::flac(),
4388 &RecordingClock::new(),
4389 &ExecOptions::default(),
4390 );
4391
4392 assert_eq!(outcome.status, RunStatus::Completed);
4393 assert_eq!(outcome.failed(), 1);
4395 assert_eq!(outcome.failures[0].clip_id, "a");
4396 assert_eq!(outcome.skipped, 1);
4397 assert_eq!(http.count("a/large.jpg"), 0);
4399 assert!(!fs.exists("a/cover.jpg"));
4400 assert!(manifest.get("a").is_none());
4401 assert_eq!(outcome.artifacts_written, 1);
4403 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
4404 assert!(manifest.get("b").unwrap().cover_jpg.is_some());
4405 }
4406
4407 #[test]
4408 fn write_artifact_transcodes_animated_cover_to_webp() {
4409 let mut manifest = Manifest::new();
4413 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
4414 let plan = Plan {
4415 actions: vec![Action::WriteArtifact {
4416 kind: ArtifactKind::CoverWebp,
4417 path: "a/cover.webp".to_owned(),
4418 source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
4419 hash: "v1".to_owned(),
4420 owner_id: "a".to_owned(),
4421 content: None,
4422 }],
4423 };
4424 let http = ScriptedHttp::new().route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
4425 let fs = MemFs::new();
4426 let ffmpeg = StubFfmpeg::webp();
4427
4428 let outcome = run(
4429 &plan,
4430 &mut manifest,
4431 &[],
4432 &http,
4433 &fs,
4434 &ffmpeg,
4435 &RecordingClock::new(),
4436 &ExecOptions::default(),
4437 );
4438
4439 assert_eq!(outcome.artifacts_written, 1);
4440 assert_eq!(outcome.failed(), 0);
4441 assert_eq!(outcome.status, RunStatus::Completed);
4442 assert_eq!(http.count("a/video.mp4"), 1);
4444 let written = fs.read_file("a/cover.webp").unwrap();
4445 assert_ne!(written, b"mp4-bytes");
4446 assert!(written.starts_with(b"RIFF"));
4447 assert_eq!(
4448 manifest.get("a").unwrap().cover_webp,
4449 Some(ArtifactState {
4450 path: "a/cover.webp".to_owned(),
4451 hash: "v1".to_owned(),
4452 })
4453 );
4454 }
4455
4456 #[test]
4457 fn write_artifact_webp_transcode_failure_is_per_clip() {
4458 let mut manifest = Manifest::new();
4462 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
4463 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
4464 let plan = Plan {
4465 actions: vec![
4466 Action::WriteArtifact {
4467 kind: ArtifactKind::CoverWebp,
4468 path: "a/cover.webp".to_owned(),
4469 source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
4470 hash: "v1".to_owned(),
4471 owner_id: "a".to_owned(),
4472 content: None,
4473 },
4474 Action::WriteArtifact {
4475 kind: ArtifactKind::CoverJpg,
4476 path: "b/cover.jpg".to_owned(),
4477 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
4478 hash: "h1".to_owned(),
4479 owner_id: "b".to_owned(),
4480 content: None,
4481 },
4482 ],
4483 };
4484 let http = ScriptedHttp::new()
4485 .route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec()))
4486 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
4487 let fs = MemFs::new();
4488
4489 let outcome = run(
4490 &plan,
4491 &mut manifest,
4492 &[],
4493 &http,
4494 &fs,
4495 &StubFfmpeg::failing(),
4496 &RecordingClock::new(),
4497 &ExecOptions::default(),
4498 );
4499
4500 assert_eq!(outcome.status, RunStatus::Completed);
4501 assert_eq!(outcome.failed(), 1);
4502 assert_eq!(outcome.failures[0].clip_id, "a");
4503 assert!(!fs.exists("a/cover.webp"));
4505 assert_eq!(manifest.get("a").unwrap().cover_webp, None);
4506 assert_eq!(outcome.artifacts_written, 1);
4508 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
4509 assert!(manifest.get("b").unwrap().cover_jpg.is_some());
4510 }
4511
4512 #[test]
4515 fn folder_jpg_write_records_album_state_and_skips_manifest() {
4516 let mut manifest = Manifest::new();
4519 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
4520 let plan = Plan {
4521 actions: vec![Action::WriteArtifact {
4522 kind: ArtifactKind::FolderJpg,
4523 path: "creator/album/folder.jpg".to_owned(),
4524 source_url: "https://art.suno.ai/root/large.jpg".to_owned(),
4525 hash: "jh".to_owned(),
4526 owner_id: "root".to_owned(),
4527 content: None,
4528 }],
4529 };
4530 let http = ScriptedHttp::new().route("root/large.jpg", Reply::ok(b"folder-jpg".to_vec()));
4531 let fs = MemFs::new();
4532
4533 let outcome = run_with_albums(
4534 &plan,
4535 &mut manifest,
4536 &mut albums,
4537 &[],
4538 &http,
4539 &fs,
4540 &StubFfmpeg::flac(),
4541 &RecordingClock::new(),
4542 &ExecOptions::default(),
4543 );
4544
4545 assert_eq!(outcome.artifacts_written, 1);
4546 assert_eq!(outcome.status, RunStatus::Completed);
4547 assert_eq!(
4548 fs.read_file("creator/album/folder.jpg").unwrap(),
4549 b"folder-jpg"
4550 );
4551 assert_eq!(
4552 albums.get("root").unwrap().folder_jpg,
4553 Some(ArtifactState {
4554 path: "creator/album/folder.jpg".to_owned(),
4555 hash: "jh".to_owned(),
4556 })
4557 );
4558 assert!(manifest.get("root").is_none());
4559 }
4560
4561 #[test]
4562 fn folder_webp_write_transcodes_and_records_album_state() {
4563 let mut manifest = Manifest::new();
4564 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
4565 let plan = Plan {
4566 actions: vec![Action::WriteArtifact {
4567 kind: ArtifactKind::FolderWebp,
4568 path: "creator/album/cover.webp".to_owned(),
4569 source_url: "https://cdn.suno.ai/root/video.mp4".to_owned(),
4570 hash: "wh".to_owned(),
4571 owner_id: "root".to_owned(),
4572 content: None,
4573 }],
4574 };
4575 let http = ScriptedHttp::new().route("root/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
4576 let fs = MemFs::new();
4577
4578 let outcome = run_with_albums(
4579 &plan,
4580 &mut manifest,
4581 &mut albums,
4582 &[],
4583 &http,
4584 &fs,
4585 &StubFfmpeg::webp(),
4586 &RecordingClock::new(),
4587 &ExecOptions::default(),
4588 );
4589
4590 assert_eq!(outcome.artifacts_written, 1);
4591 assert_eq!(outcome.failed(), 0);
4592 let written = fs.read_file("creator/album/cover.webp").unwrap();
4594 assert_ne!(written, b"mp4-bytes");
4595 assert!(written.starts_with(b"RIFF"));
4596 assert_eq!(
4597 albums.get("root").unwrap().folder_webp,
4598 Some(ArtifactState {
4599 path: "creator/album/cover.webp".to_owned(),
4600 hash: "wh".to_owned(),
4601 })
4602 );
4603 }
4604
4605 #[test]
4606 fn folder_art_delete_clears_album_state() {
4607 let fs = MemFs::new().with_file("creator/album/folder.jpg", b"jpg".to_vec());
4608 let mut manifest = Manifest::new();
4609 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
4610 albums.insert(
4611 "root".to_owned(),
4612 AlbumArt {
4613 folder_jpg: Some(ArtifactState {
4614 path: "creator/album/folder.jpg".to_owned(),
4615 hash: "jh".to_owned(),
4616 }),
4617 folder_webp: None,
4618 },
4619 );
4620 let plan = Plan {
4621 actions: vec![Action::DeleteArtifact {
4622 kind: ArtifactKind::FolderJpg,
4623 path: "creator/album/folder.jpg".to_owned(),
4624 owner_id: "root".to_owned(),
4625 }],
4626 };
4627
4628 let outcome = run_with_albums(
4629 &plan,
4630 &mut manifest,
4631 &mut albums,
4632 &[],
4633 &ScriptedHttp::new(),
4634 &fs,
4635 &StubFfmpeg::flac(),
4636 &RecordingClock::new(),
4637 &ExecOptions::default(),
4638 );
4639
4640 assert_eq!(outcome.artifacts_deleted, 1);
4641 assert!(!fs.exists("creator/album/folder.jpg"));
4642 assert!(!albums.contains_key("root"));
4644 }
4645
4646 #[test]
4649 fn playlist_write_uses_inline_content_and_records_state() {
4650 let mut manifest = Manifest::new();
4654 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
4655 let mut playlists: BTreeMap<String, PlaylistState> = BTreeMap::new();
4656 let body = "#EXTM3U\n#PLAYLIST:Road Trip\n#EXTINF:60,One\nA/One.flac\n";
4657 let plan = Plan {
4658 actions: vec![Action::WriteArtifact {
4659 kind: ArtifactKind::Playlist,
4660 path: "Road Trip.m3u8".to_owned(),
4661 source_url: String::new(),
4662 hash: "ph1".to_owned(),
4663 owner_id: "pl1".to_owned(),
4664 content: Some(body.to_owned()),
4665 }],
4666 };
4667 let fs = MemFs::new();
4668
4669 let outcome = run_full(
4670 &plan,
4671 &mut manifest,
4672 &mut albums,
4673 &mut playlists,
4674 &[],
4675 &ScriptedHttp::new(),
4676 &fs,
4677 &StubFfmpeg::flac(),
4678 &RecordingClock::new(),
4679 &ExecOptions::default(),
4680 );
4681
4682 assert_eq!(outcome.artifacts_written, 1);
4683 assert_eq!(outcome.failed(), 0);
4684 assert_eq!(fs.read_file("Road Trip.m3u8").unwrap(), body.as_bytes());
4686 assert_eq!(
4687 playlists.get("pl1"),
4688 Some(&PlaylistState {
4689 name: "Road Trip".to_owned(),
4690 path: "Road Trip.m3u8".to_owned(),
4691 hash: "ph1".to_owned(),
4692 })
4693 );
4694 }
4695
4696 #[test]
4697 fn playlist_delete_removes_file_and_clears_state() {
4698 let fs = MemFs::new().with_file("Old.m3u8", b"#EXTM3U\n".to_vec());
4699 let mut manifest = Manifest::new();
4700 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
4701 let mut playlists: BTreeMap<String, PlaylistState> = BTreeMap::new();
4702 playlists.insert(
4703 "pl1".to_owned(),
4704 PlaylistState {
4705 name: "Old".to_owned(),
4706 path: "Old.m3u8".to_owned(),
4707 hash: "ph1".to_owned(),
4708 },
4709 );
4710 let plan = Plan {
4711 actions: vec![Action::DeleteArtifact {
4712 kind: ArtifactKind::Playlist,
4713 path: "Old.m3u8".to_owned(),
4714 owner_id: "pl1".to_owned(),
4715 }],
4716 };
4717
4718 let outcome = run_full(
4719 &plan,
4720 &mut manifest,
4721 &mut albums,
4722 &mut playlists,
4723 &[],
4724 &ScriptedHttp::new(),
4725 &fs,
4726 &StubFfmpeg::flac(),
4727 &RecordingClock::new(),
4728 &ExecOptions::default(),
4729 );
4730
4731 assert_eq!(outcome.artifacts_deleted, 1);
4732 assert!(!fs.exists("Old.m3u8"));
4733 assert!(
4734 !playlists.contains_key("pl1"),
4735 "the playlist row is cleared on delete"
4736 );
4737 }
4738
4739 #[test]
4742 fn rename_move_relocates_cover_and_prunes_old_album() {
4743 let mut manifest = Manifest::new();
4747 let mut e = entry("Creator/AlbumA/song.flac", AudioFormat::Flac);
4748 e.cover_jpg = Some(ArtifactState {
4749 path: "Creator/AlbumA/cover.jpg".to_owned(),
4750 hash: "h1".to_owned(),
4751 });
4752 manifest.insert("a", e);
4753 let fs = MemFs::new()
4754 .with_file("Creator/AlbumA/song.flac", b"AUDIO".to_vec())
4755 .with_file("Creator/AlbumA/cover.jpg", b"old-jpg".to_vec());
4756 let plan = Plan {
4757 actions: vec![
4758 Action::Rename {
4759 from: "Creator/AlbumA/song.flac".to_owned(),
4760 to: "Creator/AlbumB/song.flac".to_owned(),
4761 },
4762 Action::WriteArtifact {
4763 kind: ArtifactKind::CoverJpg,
4764 path: "Creator/AlbumB/cover.jpg".to_owned(),
4765 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
4766 hash: "h1".to_owned(),
4767 owner_id: "a".to_owned(),
4768 content: None,
4769 },
4770 ],
4771 };
4772 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new-jpg".to_vec()));
4773
4774 let outcome = run(
4775 &plan,
4776 &mut manifest,
4777 &[],
4778 &http,
4779 &fs,
4780 &StubFfmpeg::flac(),
4781 &RecordingClock::new(),
4782 &ExecOptions::default(),
4783 );
4784
4785 assert_eq!(outcome.failed(), 0);
4786 assert!(fs.exists("Creator/AlbumB/song.flac"));
4788 assert_eq!(
4789 fs.read_file("Creator/AlbumB/cover.jpg").unwrap(),
4790 b"new-jpg"
4791 );
4792 assert!(!fs.exists("Creator/AlbumA/cover.jpg"));
4793 assert!(!fs.exists("Creator/AlbumA/song.flac"));
4794 assert_eq!(
4796 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
4797 "Creator/AlbumB/cover.jpg"
4798 );
4799 assert!(!fs.has_dir("Creator/AlbumA"));
4801 assert!(fs.has_dir("Creator/AlbumB"));
4802 }
4803
4804 #[test]
4805 fn rename_move_relocates_folder_art_and_prunes_old_album() {
4806 let mut manifest = Manifest::new();
4809 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
4810 albums.insert(
4811 "root".to_owned(),
4812 AlbumArt {
4813 folder_jpg: Some(ArtifactState {
4814 path: "Creator/AlbumA/folder.jpg".to_owned(),
4815 hash: "jh".to_owned(),
4816 }),
4817 folder_webp: None,
4818 },
4819 );
4820 let fs = MemFs::new().with_file("Creator/AlbumA/folder.jpg", b"old-folder".to_vec());
4821 let plan = Plan {
4822 actions: vec![Action::WriteArtifact {
4823 kind: ArtifactKind::FolderJpg,
4824 path: "Creator/AlbumB/folder.jpg".to_owned(),
4825 source_url: "https://art.suno.ai/root/large.jpg".to_owned(),
4826 hash: "jh".to_owned(),
4827 owner_id: "root".to_owned(),
4828 content: None,
4829 }],
4830 };
4831 let http = ScriptedHttp::new().route("root/large.jpg", Reply::ok(b"new-folder".to_vec()));
4832
4833 let outcome = run_with_albums(
4834 &plan,
4835 &mut manifest,
4836 &mut albums,
4837 &[],
4838 &http,
4839 &fs,
4840 &StubFfmpeg::flac(),
4841 &RecordingClock::new(),
4842 &ExecOptions::default(),
4843 );
4844
4845 assert_eq!(outcome.failed(), 0);
4846 assert_eq!(
4847 fs.read_file("Creator/AlbumB/folder.jpg").unwrap(),
4848 b"new-folder"
4849 );
4850 assert!(!fs.exists("Creator/AlbumA/folder.jpg"));
4851 assert_eq!(
4852 albums
4853 .get("root")
4854 .unwrap()
4855 .folder_jpg
4856 .as_ref()
4857 .unwrap()
4858 .path,
4859 "Creator/AlbumB/folder.jpg"
4860 );
4861 assert!(!fs.has_dir("Creator/AlbumA"));
4862 assert!(fs.has_dir("Creator/AlbumB"));
4863 }
4864
4865 #[test]
4866 fn prune_empty_dirs_removes_only_empty_dirs() {
4867 let fs = MemFs::new()
4871 .with_file("keep/full/song.flac", b"x".to_vec())
4872 .with_file("hidden/.suno-manifest.json", b"{}".to_vec())
4873 .with_dir("empty/leaf")
4874 .with_dir("nested/a/b/c");
4875
4876 fs.prune_empty_dirs("").unwrap();
4877
4878 for gone in [
4880 "empty",
4881 "empty/leaf",
4882 "nested",
4883 "nested/a",
4884 "nested/a/b",
4885 "nested/a/b/c",
4886 ] {
4887 assert!(!fs.has_dir(gone), "empty dir {gone} should be pruned");
4888 }
4889 assert!(fs.has_dir("keep"));
4891 assert!(fs.has_dir("keep/full"));
4892 assert!(fs.has_dir("hidden"));
4893 assert!(fs.exists("keep/full/song.flac"));
4895 assert!(fs.exists("hidden/.suno-manifest.json"));
4896 }
4897
4898 #[test]
4899 fn prune_empty_dirs_never_removes_the_named_root() {
4900 let fs = MemFs::new().with_dir("empty/leaf");
4903 fs.prune_empty_dirs("empty").unwrap();
4904 assert!(fs.has_dir("empty"), "the named root is never removed");
4905 assert!(!fs.has_dir("empty/leaf"));
4906 }
4907
4908 #[test]
4909 fn old_sidecar_remove_failure_is_per_clip_and_converges_next_run() {
4910 let mut manifest = Manifest::new();
4914 let mut e = entry("a.flac", AudioFormat::Flac);
4915 e.cover_jpg = Some(ArtifactState {
4916 path: "AlbumA/cover.jpg".to_owned(),
4917 hash: "h1".to_owned(),
4918 });
4919 manifest.insert("a", e);
4920 let fs = MemFs::new()
4921 .with_file("a.flac", b"AUDIO".to_vec())
4922 .with_file("AlbumA/cover.jpg", b"old".to_vec());
4923 let plan = Plan {
4924 actions: vec![Action::WriteArtifact {
4925 kind: ArtifactKind::CoverJpg,
4926 path: "AlbumB/cover.jpg".to_owned(),
4927 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
4928 hash: "h1".to_owned(),
4929 owner_id: "a".to_owned(),
4930 content: None,
4931 }],
4932 };
4933 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new".to_vec()));
4934
4935 fs.arm_fail_remove("AlbumA/cover.jpg");
4937 let first = run(
4938 &plan,
4939 &mut manifest,
4940 &[],
4941 &http,
4942 &fs,
4943 &StubFfmpeg::flac(),
4944 &RecordingClock::new(),
4945 &ExecOptions::default(),
4946 );
4947 assert_eq!(
4948 first.status,
4949 RunStatus::Completed,
4950 "a remove failure never aborts the run"
4951 );
4952 assert_eq!(first.failed(), 1);
4953 assert!(fs.exists("AlbumB/cover.jpg"));
4955 assert!(fs.exists("AlbumA/cover.jpg"));
4956 assert_eq!(
4957 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
4958 "AlbumA/cover.jpg"
4959 );
4960 assert!(fs.has_dir("AlbumA"), "the orphan keeps its directory alive");
4961
4962 fs.disarm_fail_remove("AlbumA/cover.jpg");
4964 let second = run(
4965 &plan,
4966 &mut manifest,
4967 &[],
4968 &http,
4969 &fs,
4970 &StubFfmpeg::flac(),
4971 &RecordingClock::new(),
4972 &ExecOptions::default(),
4973 );
4974 assert_eq!(second.failed(), 0);
4975 assert!(fs.exists("AlbumB/cover.jpg"));
4976 assert!(!fs.exists("AlbumA/cover.jpg"), "no orphan persists");
4977 assert_eq!(
4978 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
4979 "AlbumB/cover.jpg"
4980 );
4981 assert!(!fs.has_dir("AlbumA"), "the emptied directory is pruned");
4982 }
4983
4984 #[test]
4985 fn same_path_artifact_rewrite_does_no_remove_and_prunes_nothing() {
4986 let mut manifest = Manifest::new();
4991 let mut e = entry("Album/a.mp3", AudioFormat::Mp3);
4992 e.cover_jpg = Some(ArtifactState {
4993 path: "Album/cover.jpg".to_owned(),
4994 hash: "h1".to_owned(),
4995 });
4996 manifest.insert("a", e);
4997 let fs = MemFs::new()
4998 .with_file("Album/a.mp3", b"AUDIO".to_vec())
4999 .with_file("Album/cover.jpg", b"old".to_vec());
5000 fs.arm_fail_remove("Album/cover.jpg");
5001 let plan = Plan {
5002 actions: vec![Action::WriteArtifact {
5003 kind: ArtifactKind::CoverJpg,
5004 path: "Album/cover.jpg".to_owned(),
5005 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
5006 hash: "h2".to_owned(),
5007 owner_id: "a".to_owned(),
5008 content: None,
5009 }],
5010 };
5011 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new".to_vec()));
5012
5013 let outcome = run(
5014 &plan,
5015 &mut manifest,
5016 &[],
5017 &http,
5018 &fs,
5019 &StubFfmpeg::flac(),
5020 &RecordingClock::new(),
5021 &ExecOptions::default(),
5022 );
5023
5024 assert_eq!(
5025 outcome.failed(),
5026 0,
5027 "no remove is attempted, so the armed failure never fires"
5028 );
5029 assert_eq!(outcome.artifacts_written, 1);
5030 assert_eq!(fs.read_file("Album/cover.jpg").unwrap(), b"new");
5031 assert_eq!(
5032 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().hash,
5033 "h2"
5034 );
5035 assert!(fs.has_dir("Album"));
5037 }
5038
5039 mod concurrency {
5042 use super::*;
5043 use crate::ffmpeg::FfmpegError;
5044 use crate::fs::{FileStat, FsError};
5045 use crate::http::{HttpRequest, TransportError};
5046 use std::future::Future;
5047 use std::pin::Pin;
5048 use std::sync::Arc;
5049 use std::sync::atomic::{AtomicUsize, Ordering};
5050 use std::task::{Context, Poll};
5051
5052 #[derive(Default)]
5057 struct YieldOnce {
5058 yielded: bool,
5059 }
5060
5061 impl Future for YieldOnce {
5062 type Output = ();
5063 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
5064 if self.yielded {
5065 Poll::Ready(())
5066 } else {
5067 self.yielded = true;
5068 cx.waker().wake_by_ref();
5069 Poll::Pending
5070 }
5071 }
5072 }
5073
5074 struct GatedHttp {
5078 inner: ScriptedHttp,
5079 inflight: Arc<AtomicUsize>,
5080 peak: Arc<AtomicUsize>,
5081 }
5082
5083 impl GatedHttp {
5084 fn new(inner: ScriptedHttp) -> Self {
5085 Self {
5086 inner,
5087 inflight: Arc::new(AtomicUsize::new(0)),
5088 peak: Arc::new(AtomicUsize::new(0)),
5089 }
5090 }
5091
5092 fn peak(&self) -> usize {
5093 self.peak.load(Ordering::SeqCst)
5094 }
5095 }
5096
5097 impl Http for GatedHttp {
5098 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, TransportError> {
5099 let now = self.inflight.fetch_add(1, Ordering::SeqCst) + 1;
5100 self.peak.fetch_max(now, Ordering::SeqCst);
5101 YieldOnce::default().await;
5102 let out = self.inner.send(request).await;
5103 self.inflight.fetch_sub(1, Ordering::SeqCst);
5104 out
5105 }
5106 }
5107
5108 fn download(id: &str, format: AudioFormat) -> (Clip, Desired, Action) {
5109 let c = clip(id);
5110 let d = desired(c.clone(), format);
5111 let action = Action::Download {
5112 clip: c.clone(),
5113 lineage: LineageContext::own_root(&c),
5114 path: d.path.clone(),
5115 format,
5116 };
5117 (c, d, action)
5118 }
5119
5120 fn opts_with(concurrency: u32) -> ExecOptions {
5121 ExecOptions {
5122 concurrency,
5123 ..small_poll()
5124 }
5125 }
5126
5127 #[test]
5128 fn concurrency_never_exceeds_the_configured_bound() {
5129 let count = 6;
5130 let concurrency = 3;
5131 let mut scripted = ScriptedHttp::new().with_auth();
5132 let mut actions = Vec::new();
5133 let mut desireds = Vec::new();
5134 for i in 0..count {
5135 let id = format!("c{i}");
5136 scripted = scripted.route(&format!("{id}.mp3"), Reply::ok(b"mp3-body".to_vec()));
5137 let (_c, d, action) = download(&id, AudioFormat::Mp3);
5138 actions.push(action);
5139 desireds.push(d);
5140 }
5141 let http = GatedHttp::new(scripted);
5142 let fs = MemFs::new();
5143 let plan = Plan { actions };
5144 let mut manifest = Manifest::new();
5145
5146 let outcome = run_gated_fs(
5147 &plan,
5148 &mut manifest,
5149 &desireds,
5150 &http,
5151 &fs,
5152 &opts_with(concurrency),
5153 );
5154
5155 assert_eq!(outcome.downloaded, count);
5156 assert!(
5157 http.peak() <= concurrency as usize,
5158 "peak {} exceeded the bound {concurrency}",
5159 http.peak()
5160 );
5161 assert_eq!(
5162 http.peak(),
5163 concurrency as usize,
5164 "expected the run to saturate the bound"
5165 );
5166 }
5167
5168 fn run_gated_fs(
5172 plan: &Plan,
5173 manifest: &mut Manifest,
5174 desired: &[Desired],
5175 http: &GatedHttp,
5176 fs: &MemFs,
5177 opts: &ExecOptions,
5178 ) -> ExecOutcome {
5179 let ffmpeg = StubFfmpeg::flac();
5180 let clock = RecordingClock::new();
5181 let mut albums = BTreeMap::new();
5182 let mut playlists = BTreeMap::new();
5183 let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
5184 pollster::block_on(execute(
5185 plan,
5186 manifest,
5187 &mut albums,
5188 &mut playlists,
5189 desired,
5190 &HashMap::new(),
5191 Ports {
5192 client: &mut client,
5193 http,
5194 fs,
5195 ffmpeg: &ffmpeg,
5196 clock: &clock,
5197 },
5198 opts,
5199 ))
5200 }
5201
5202 #[test]
5203 fn a_failing_clip_does_not_abort_the_others() {
5204 let mut scripted = ScriptedHttp::new().with_auth();
5205 scripted = scripted
5206 .route("ok1.mp3", Reply::ok(b"one".to_vec()))
5207 .route("bad.mp3", Reply::status(404))
5208 .route("ok2.mp3", Reply::ok(b"two".to_vec()));
5209 let (_a, d1, a1) = download("ok1", AudioFormat::Mp3);
5210 let (_b, d2, a2) = download("bad", AudioFormat::Mp3);
5211 let (_c, d3, a3) = download("ok2", AudioFormat::Mp3);
5212 let http = GatedHttp::new(scripted);
5213 let fs = MemFs::new();
5214 let plan = Plan {
5215 actions: vec![a1, a2, a3],
5216 };
5217 let mut manifest = Manifest::new();
5218
5219 let outcome = run_gated_fs(
5220 &plan,
5221 &mut manifest,
5222 &[d1, d2, d3],
5223 &http,
5224 &fs,
5225 &opts_with(3),
5226 );
5227
5228 assert_eq!(outcome.downloaded, 2);
5229 assert_eq!(outcome.failed(), 1);
5230 assert_eq!(outcome.status, RunStatus::Completed);
5231 assert_eq!(outcome.failures[0].clip_id, "bad");
5232 assert!(manifest.get("ok1").is_some());
5233 assert!(manifest.get("ok2").is_some());
5234 assert!(manifest.get("bad").is_none());
5235 }
5236
5237 #[test]
5238 fn outcome_is_identical_across_concurrency_levels() {
5239 fn build() -> (Plan, Vec<Desired>) {
5242 let mut actions = Vec::new();
5243 let mut desireds = Vec::new();
5244 for id in ["a", "b", "c", "d"] {
5245 let (_c, d, action) = download(id, AudioFormat::Mp3);
5246 actions.push(action);
5247 desireds.push(d);
5248 }
5249 let (_e, de, ae) = download("fail", AudioFormat::Mp3);
5251 actions.insert(2, ae);
5252 desireds.push(de);
5253 actions.push(Action::Skip {
5255 clip_id: "gone".to_owned(),
5256 });
5257 actions.push(Action::Delete {
5258 path: "old.mp3".to_owned(),
5259 clip_id: "old".to_owned(),
5260 });
5261 (Plan { actions }, desireds)
5262 }
5263
5264 fn http() -> ScriptedHttp {
5265 ScriptedHttp::new()
5266 .with_auth()
5267 .route("a.mp3", Reply::ok(b"a".to_vec()))
5268 .route("b.mp3", Reply::ok(b"b".to_vec()))
5269 .route("c.mp3", Reply::ok(b"c".to_vec()))
5270 .route("d.mp3", Reply::ok(b"d".to_vec()))
5271 .route("fail.mp3", Reply::status(404))
5272 }
5273
5274 fn seed_manifest() -> Manifest {
5275 let mut m = Manifest::new();
5276 m.insert("old".to_owned(), entry("old.mp3", AudioFormat::Mp3));
5277 m
5278 }
5279
5280 let (plan, desireds) = build();
5281
5282 let mut m1 = seed_manifest();
5283 let fs1 = MemFs::new().with_file("old.mp3", b"x".to_vec());
5284 let out1 = run_gated_fs(
5285 &plan,
5286 &mut m1,
5287 &desireds,
5288 &GatedHttp::new(http()),
5289 &fs1,
5290 &opts_with(1),
5291 );
5292
5293 let mut m8 = seed_manifest();
5294 let fs8 = MemFs::new().with_file("old.mp3", b"x".to_vec());
5295 let out8 = run_gated_fs(
5296 &plan,
5297 &mut m8,
5298 &desireds,
5299 &GatedHttp::new(http()),
5300 &fs8,
5301 &opts_with(8),
5302 );
5303
5304 assert_eq!(out1, out8, "outcome must not depend on concurrency");
5305 assert_eq!(m1, m8, "final manifest must not depend on concurrency");
5306 assert_eq!(out8.downloaded, 4);
5307 assert_eq!(out8.deleted, 1);
5308 assert_eq!(out8.skipped, 1);
5309 assert_eq!(out8.failed(), 1);
5310 }
5311
5312 #[test]
5313 fn a_systemic_disk_full_aborts_promptly() {
5314 let count = 8;
5315 let concurrency = 2;
5316 let mut scripted = ScriptedHttp::new().with_auth();
5317 let mut actions = Vec::new();
5318 let mut desireds = Vec::new();
5319 for i in 0..count {
5320 let id = format!("d{i}");
5321 scripted = scripted.route(&format!("{id}.mp3"), Reply::ok(b"mp3-body".to_vec()));
5322 let (_c, d, action) = download(&id, AudioFormat::Mp3);
5323 actions.push(action);
5324 desireds.push(d);
5325 }
5326 let fs = MemFs::new().fail_write_out_of_space("d0.mp3");
5328 let http = GatedHttp::new(scripted);
5329 let plan = Plan { actions };
5330 let mut manifest = Manifest::new();
5331
5332 let outcome = run_gated_fs(
5333 &plan,
5334 &mut manifest,
5335 &desireds,
5336 &http,
5337 &fs,
5338 &opts_with(concurrency),
5339 );
5340
5341 assert_eq!(outcome.status, RunStatus::DiskFull);
5342 assert!(
5343 outcome.downloaded < count,
5344 "a systemic abort must stop remaining work, downloaded {}",
5345 outcome.downloaded
5346 );
5347 }
5348
5349 #[test]
5350 fn limiter_records_a_rate_limit_under_concurrent_calls() {
5351 let scripted = ScriptedHttp::new()
5356 .with_auth()
5357 .route_seq(
5358 "/gen/x/wav_file/",
5359 vec![
5360 Reply::status(429),
5361 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/x.wav"}"#),
5362 ],
5363 )
5364 .route(
5365 "/gen/y/wav_file/",
5366 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/y.wav"}"#),
5367 )
5368 .route(
5369 "/gen/z/wav_file/",
5370 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#),
5371 )
5372 .route("x.wav", Reply::ok(b"wav-x".to_vec()))
5373 .route("y.wav", Reply::ok(b"wav-y".to_vec()))
5374 .route("z.wav", Reply::ok(b"wav-z".to_vec()));
5375
5376 let mut actions = Vec::new();
5377 let mut desireds = Vec::new();
5378 for id in ["x", "y", "z"] {
5379 let (_c, d, action) = download(id, AudioFormat::Flac);
5380 actions.push(action);
5381 desireds.push(d);
5382 }
5383 let plan = Plan { actions };
5384 let fs = MemFs::new();
5385 let ffmpeg = StubFfmpeg::flac();
5386 let clock = RecordingClock::new();
5387 let mut albums = BTreeMap::new();
5388 let mut playlists = BTreeMap::new();
5389 let mut manifest = Manifest::new();
5390 let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
5391
5392 let outcome = pollster::block_on(execute(
5393 &plan,
5394 &mut manifest,
5395 &mut albums,
5396 &mut playlists,
5397 &desireds,
5398 &HashMap::new(),
5399 Ports {
5400 client: &mut client,
5401 http: &scripted,
5402 fs: &fs,
5403 ffmpeg: &ffmpeg,
5404 clock: &clock,
5405 },
5406 &opts_with(3),
5407 ));
5408
5409 assert_eq!(outcome.downloaded, 3);
5410 assert_eq!(outcome.failed(), 0);
5411 assert!(
5412 (client.limiter_rate() - 1.0).abs() < 1e-9,
5413 "one 429 must halve the rate to 1.0, got {}",
5414 client.limiter_rate()
5415 );
5416 }
5417
5418 #[test]
5419 fn a_download_is_committed_in_plan_order_around_a_rename() {
5420 let c_new = clip("new");
5428 let mut d_new = desired(c_new.clone(), AudioFormat::Mp3);
5429 d_new.path = "shared.mp3".to_owned();
5430 let plan = Plan {
5431 actions: vec![
5432 Action::Rename {
5433 from: "shared.mp3".to_owned(),
5434 to: "moved.mp3".to_owned(),
5435 },
5436 Action::Download {
5437 clip: c_new.clone(),
5438 lineage: LineageContext::own_root(&c_new),
5439 path: "shared.mp3".to_owned(),
5440 format: AudioFormat::Mp3,
5441 },
5442 ],
5443 };
5444 let scripted = ScriptedHttp::new()
5445 .with_auth()
5446 .route("new.mp3", Reply::ok(b"NEW-BODY".to_vec()));
5447 let http = GatedHttp::new(scripted);
5448 let fs = MemFs::new().with_file("shared.mp3", b"ORIGINAL".to_vec());
5449 let mut manifest = Manifest::new();
5450 manifest.insert("orig", entry("shared.mp3", AudioFormat::Mp3));
5451
5452 let outcome = run_gated_fs(&plan, &mut manifest, &[d_new], &http, &fs, &opts_with(4));
5453
5454 assert_eq!(outcome.renamed, 1);
5455 assert_eq!(outcome.downloaded, 1);
5456 assert_eq!(
5457 fs.read_file("moved.mp3").as_deref(),
5458 Some(&b"ORIGINAL"[..]),
5459 "the rename must carry the original bytes, untouched by the download"
5460 );
5461 let landed = fs.read_file("shared.mp3").expect("new download must land");
5462 assert_ne!(
5463 landed, b"ORIGINAL",
5464 "the new download must replace the moved original, not corrupt it"
5465 );
5466 assert_eq!(manifest.get("orig").unwrap().path, "moved.mp3");
5467 assert_eq!(manifest.get("new").unwrap().path, "shared.mp3");
5468 }
5469
5470 #[test]
5471 fn an_aborted_reformat_leaves_the_old_file_and_manifest_consistent() {
5472 let boom = clip("boom");
5478 let mut d_boom = desired(boom.clone(), AudioFormat::Mp3);
5479 d_boom.path = "boom.mp3".to_owned();
5480 let reformer = clip("r");
5481 let d_reformer = desired(reformer.clone(), AudioFormat::Mp3);
5482 let plan = Plan {
5483 actions: vec![
5484 Action::Download {
5485 clip: boom.clone(),
5486 lineage: LineageContext::own_root(&boom),
5487 path: "boom.mp3".to_owned(),
5488 format: AudioFormat::Mp3,
5489 },
5490 Action::Reformat {
5491 clip: reformer.clone(),
5492 path: "r_new.mp3".to_owned(),
5493 from_path: "r_old.flac".to_owned(),
5494 from: AudioFormat::Flac,
5495 to: AudioFormat::Mp3,
5496 },
5497 ],
5498 };
5499 let scripted = ScriptedHttp::new()
5500 .with_auth()
5501 .route("boom.mp3", Reply::ok(b"boom-body".to_vec()))
5502 .route("r.mp3", Reply::ok(b"reformatted".to_vec()));
5503 let http = GatedHttp::new(scripted);
5504 let fs = MemFs::new()
5506 .with_file("r_old.flac", b"OLD-FLAC".to_vec())
5507 .fail_write_out_of_space("boom.mp3");
5508 let mut manifest = Manifest::new();
5509 manifest.insert("r", entry("r_old.flac", AudioFormat::Flac));
5510
5511 let outcome = run_gated_fs(
5512 &plan,
5513 &mut manifest,
5514 &[d_boom, d_reformer],
5515 &http,
5516 &fs,
5517 &opts_with(4),
5518 );
5519
5520 assert_eq!(outcome.status, RunStatus::DiskFull);
5521 assert!(
5522 fs.exists("r_old.flac"),
5523 "the old file must survive the abort"
5524 );
5525 assert!(
5526 !fs.exists("r_new.mp3"),
5527 "no reformatted file may be written"
5528 );
5529 let still = manifest.get("r").expect("the manifest must still track r");
5530 assert_eq!(
5531 still.path, "r_old.flac",
5532 "the manifest must still point at the surviving old file"
5533 );
5534 assert_eq!(still.format, AudioFormat::Flac);
5535 }
5536
5537 #[test]
5538 fn a_systemic_abort_leaves_no_untracked_destination_files() {
5539 let mut scripted = ScriptedHttp::new().with_auth();
5544 let mut actions = Vec::new();
5545 let mut desireds = Vec::new();
5546 for id in ["a0", "a1", "boom", "a3", "a4"] {
5547 scripted = scripted.route(&format!("{id}.mp3"), Reply::ok(b"body".to_vec()));
5548 let (_c, d, action) = download(id, AudioFormat::Mp3);
5549 actions.push(action);
5550 desireds.push(d);
5551 }
5552 let http = GatedHttp::new(scripted);
5553 let fs = MemFs::new().fail_write_out_of_space("boom.mp3");
5554 let plan = Plan { actions };
5555 let mut manifest = Manifest::new();
5556
5557 let outcome = run_gated_fs(&plan, &mut manifest, &desireds, &http, &fs, &opts_with(2));
5558
5559 assert_eq!(outcome.status, RunStatus::DiskFull);
5560 let tracked: std::collections::BTreeSet<String> = manifest
5561 .entries
5562 .values()
5563 .map(|entry| entry.path.clone())
5564 .collect();
5565 for path in fs.paths() {
5566 assert!(
5567 tracked.contains(&path),
5568 "found an untracked destination file: {path}"
5569 );
5570 }
5571 assert!(
5572 !fs.exists("a3.mp3"),
5573 "uncommitted renders must not be on disk"
5574 );
5575 assert!(
5576 !fs.exists("a4.mp3"),
5577 "uncommitted renders must not be on disk"
5578 );
5579 }
5580
5581 struct CountingFfmpeg {
5587 inner: StubFfmpeg,
5588 held: Arc<AtomicUsize>,
5589 peak: Arc<AtomicUsize>,
5590 }
5591
5592 impl Ffmpeg for CountingFfmpeg {
5593 fn wav_to_flac(
5594 &self,
5595 wav: &[u8],
5596 ) -> impl Future<Output = Result<Vec<u8>, FfmpegError>> + Send {
5597 let fut = self.inner.wav_to_flac(wav);
5598 let held = self.held.clone();
5599 let peak = self.peak.clone();
5600 async move {
5601 let out = fut.await;
5602 if out.is_ok() {
5603 let now = held.fetch_add(1, Ordering::SeqCst) + 1;
5604 peak.fetch_max(now, Ordering::SeqCst);
5605 }
5606 out
5607 }
5608 }
5609
5610 fn mp4_to_webp(
5611 &self,
5612 mp4: &[u8],
5613 settings: WebpEncodeSettings,
5614 ) -> impl Future<Output = Result<Vec<u8>, FfmpegError>> + Send {
5615 self.inner.mp4_to_webp(mp4, settings)
5616 }
5617 }
5618
5619 struct CountingFs {
5623 inner: MemFs,
5624 held: Arc<AtomicUsize>,
5625 }
5626
5627 impl Filesystem for CountingFs {
5628 fn write_atomic(&self, path: &str, bytes: &[u8]) -> Result<(), FsError> {
5629 let out = self.inner.write_atomic(path, bytes);
5630 self.held.fetch_sub(1, Ordering::SeqCst);
5631 out
5632 }
5633
5634 fn rename(&self, from: &str, to: &str) -> Result<(), FsError> {
5635 self.inner.rename(from, to)
5636 }
5637
5638 fn remove(&self, path: &str) -> Result<(), FsError> {
5639 self.inner.remove(path)
5640 }
5641
5642 fn prune_empty_dirs(&self, root: &str) -> Result<(), FsError> {
5643 self.inner.prune_empty_dirs(root)
5644 }
5645
5646 fn read(&self, path: &str) -> Result<Vec<u8>, FsError> {
5647 self.inner.read(path)
5648 }
5649
5650 fn metadata(&self, path: &str) -> Option<FileStat> {
5651 self.inner.metadata(path)
5652 }
5653 }
5654
5655 #[test]
5656 fn rendered_payloads_in_memory_stay_bounded_by_concurrency() {
5657 let count = 12;
5661 let concurrency = 3;
5662 let mut scripted = ScriptedHttp::new().with_auth();
5663 let mut actions = Vec::new();
5664 let mut desireds = Vec::new();
5665 for i in 0..count {
5666 let id = format!("f{i}");
5667 scripted = scripted
5668 .route(
5669 &format!("/gen/{id}/wav_file/"),
5670 Reply::json(&format!(
5671 r#"{{"wav_file_url": "https://cdn1.suno.ai/{id}.wav"}}"#
5672 )),
5673 )
5674 .route(&format!("{id}.wav"), Reply::ok(b"wav-body".to_vec()));
5675 let (_c, d, action) = download(&id, AudioFormat::Flac);
5676 actions.push(action);
5677 desireds.push(d);
5678 }
5679 let http = GatedHttp::new(scripted);
5680 let held = Arc::new(AtomicUsize::new(0));
5681 let peak = Arc::new(AtomicUsize::new(0));
5682 let ffmpeg = CountingFfmpeg {
5683 inner: StubFfmpeg::flac(),
5684 held: held.clone(),
5685 peak: peak.clone(),
5686 };
5687 let fs = CountingFs {
5688 inner: MemFs::new(),
5689 held: held.clone(),
5690 };
5691 let clock = RecordingClock::new();
5692 let mut albums = BTreeMap::new();
5693 let mut playlists = BTreeMap::new();
5694 let mut manifest = Manifest::new();
5695 let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
5696 let plan = Plan { actions };
5697
5698 let outcome = pollster::block_on(execute(
5699 &plan,
5700 &mut manifest,
5701 &mut albums,
5702 &mut playlists,
5703 &desireds,
5704 &HashMap::new(),
5705 Ports {
5706 client: &mut client,
5707 http: &http,
5708 fs: &fs,
5709 ffmpeg: &ffmpeg,
5710 clock: &clock,
5711 },
5712 &opts_with(concurrency),
5713 ));
5714
5715 assert_eq!(outcome.downloaded, count as usize);
5716 assert_eq!(
5717 held.load(Ordering::SeqCst),
5718 0,
5719 "every payload must be committed"
5720 );
5721 assert!(
5722 peak.load(Ordering::SeqCst) <= concurrency as usize + 1,
5723 "peak live payloads {} exceeded the bound {}",
5724 peak.load(Ordering::SeqCst),
5725 concurrency + 1
5726 );
5727 assert!(
5728 peak.load(Ordering::SeqCst) >= 2,
5729 "the render should genuinely overlap, peak was {}",
5730 peak.load(Ordering::SeqCst)
5731 );
5732 }
5733 }
5734}