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