1use std::collections::BTreeMap;
32use std::collections::HashMap;
33use std::time::Duration;
34
35use crate::backoff::{backoff_delay, retry_after};
36use crate::client::SunoClient;
37use crate::clock::Clock;
38use crate::config::AudioFormat;
39use crate::error::Error;
40use crate::ffmpeg::{Ffmpeg, WebpEncodeSettings};
41use crate::fs::Filesystem;
42use crate::graph::{AlbumArt, PlaylistState};
43use crate::http::{Http, HttpRequest};
44use crate::lineage::LineageContext;
45use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
46use crate::model::Clip;
47use crate::reconcile::{Action, ArtifactKind, Desired, Plan, SourceMode, set_manifest_artifact};
48use crate::tag::{TrackMetadata, tag_flac, tag_mp3};
49
50#[derive(Debug, Clone)]
52pub struct ExecOptions {
53 pub max_retries: u32,
55 pub wav_poll_attempts: u32,
57 pub wav_poll_interval: Duration,
59}
60
61impl Default for ExecOptions {
62 fn default() -> Self {
63 Self {
64 max_retries: 3,
65 wav_poll_attempts: 24,
66 wav_poll_interval: Duration::from_secs(5),
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum RunStatus {
74 #[default]
76 Completed,
77 AuthAborted,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Failure {
84 pub clip_id: String,
86 pub reason: String,
88}
89
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
92pub struct ExecOutcome {
93 pub downloaded: usize,
94 pub reformatted: usize,
95 pub retagged: usize,
96 pub renamed: usize,
97 pub deleted: usize,
98 pub skipped: usize,
99 pub artifacts_written: usize,
100 pub artifacts_deleted: usize,
101 pub failures: Vec<Failure>,
104 pub status: RunStatus,
106}
107
108impl ExecOutcome {
109 pub fn failed(&self) -> usize {
111 self.failures.len()
112 }
113
114 fn record(&mut self, effect: Effect) {
115 match effect {
116 Effect::Downloaded => self.downloaded += 1,
117 Effect::Reformatted => self.reformatted += 1,
118 Effect::Retagged => self.retagged += 1,
119 Effect::Renamed => self.renamed += 1,
120 Effect::Deleted => self.deleted += 1,
121 Effect::Skipped => self.skipped += 1,
122 Effect::ArtifactWritten => self.artifacts_written += 1,
123 Effect::ArtifactDeleted => self.artifacts_deleted += 1,
124 }
125 }
126}
127
128pub struct Ports<'a, H, F, G, C> {
133 pub client: &'a mut SunoClient<C>,
135 pub http: &'a H,
137 pub fs: &'a F,
139 pub ffmpeg: &'a G,
141 pub clock: &'a C,
143}
144
145pub async fn execute<H, F, G, C>(
158 plan: &Plan,
159 manifest: &mut Manifest,
160 albums: &mut BTreeMap<String, AlbumArt>,
161 playlists: &mut BTreeMap<String, PlaylistState>,
162 desired: &[Desired],
163 ports: Ports<'_, H, F, G, C>,
164 opts: &ExecOptions,
165) -> ExecOutcome
166where
167 H: Http,
168 F: Filesystem,
169 G: Ffmpeg,
170 C: Clock,
171{
172 let Ports {
173 client,
174 http,
175 fs,
176 ffmpeg,
177 clock,
178 } = ports;
179 let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
180 let by_path: HashMap<&str, &Desired> = desired.iter().map(|d| (d.path.as_str(), d)).collect();
181 let ctx = Ctx {
182 http,
183 fs,
184 ffmpeg,
185 clock,
186 opts,
187 by_id: &by_id,
188 by_path: &by_path,
189 };
190
191 let mut outcome = ExecOutcome::default();
192 for action in &plan.actions {
193 match ctx.apply(action, client, manifest, albums, playlists).await {
194 Ok(effect) => outcome.record(effect),
195 Err(fail) => {
196 let aborts = matches!(fail.class, Class::Auth);
197 outcome.failures.push(Failure {
198 clip_id: fail.clip_id,
199 reason: fail.reason,
200 });
201 if aborts {
202 outcome.status = RunStatus::AuthAborted;
203 break;
204 }
205 }
206 }
207 }
208 let _ = fs.prune_empty_dirs("");
213 outcome
214}
215
216enum Effect {
218 Downloaded,
219 Reformatted,
220 Retagged,
221 Renamed,
222 Deleted,
223 Skipped,
224 ArtifactWritten,
225 ArtifactDeleted,
226}
227
228#[derive(Debug, Clone, Copy)]
230enum Class {
231 Auth,
233 Transient,
235 Permanent,
237}
238
239struct Fail {
241 class: Class,
242 clip_id: String,
243 reason: String,
244}
245
246fn auth_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
247 Fail {
248 class: Class::Auth,
249 clip_id: clip_id.into(),
250 reason: reason.into(),
251 }
252}
253
254fn transient_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
255 Fail {
256 class: Class::Transient,
257 clip_id: clip_id.into(),
258 reason: reason.into(),
259 }
260}
261
262fn permanent_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
263 Fail {
264 class: Class::Permanent,
265 clip_id: clip_id.into(),
266 reason: reason.into(),
267 }
268}
269
270fn is_album_kind(kind: ArtifactKind) -> bool {
274 matches!(kind, ArtifactKind::FolderJpg | ArtifactKind::FolderWebp)
275}
276
277fn is_playlist_kind(kind: ArtifactKind) -> bool {
279 matches!(kind, ArtifactKind::Playlist)
280}
281
282fn is_per_clip_kind(kind: ArtifactKind) -> bool {
286 matches!(kind, ArtifactKind::CoverJpg | ArtifactKind::CoverWebp)
287}
288
289fn playlist_name_from_path(path: &str) -> String {
296 std::path::Path::new(path)
297 .file_stem()
298 .map(|stem| stem.to_string_lossy().into_owned())
299 .unwrap_or_default()
300}
301
302struct FetchError {
304 class: Class,
305 reason: String,
306 retry_after: Option<Duration>,
307}
308
309impl FetchError {
310 fn transient(reason: impl Into<String>, retry_after: Option<Duration>) -> Self {
311 Self {
312 class: Class::Transient,
313 reason: reason.into(),
314 retry_after,
315 }
316 }
317
318 fn permanent(reason: impl Into<String>) -> Self {
319 Self {
320 class: Class::Permanent,
321 reason: reason.into(),
322 retry_after: None,
323 }
324 }
325
326 fn attribute(self, clip_id: &str) -> Fail {
327 Fail {
328 class: self.class,
329 clip_id: clip_id.to_owned(),
330 reason: self.reason,
331 }
332 }
333}
334
335struct Ctx<'a, H, F, G, C> {
337 http: &'a H,
338 fs: &'a F,
339 ffmpeg: &'a G,
340 clock: &'a C,
341 opts: &'a ExecOptions,
342 by_id: &'a HashMap<&'a str, &'a Desired>,
343 by_path: &'a HashMap<&'a str, &'a Desired>,
344}
345
346impl<H, F, G, C> Ctx<'_, H, F, G, C>
347where
348 H: Http,
349 F: Filesystem,
350 G: Ffmpeg,
351 C: Clock,
352{
353 async fn apply(
355 &self,
356 action: &Action,
357 client: &mut SunoClient<C>,
358 manifest: &mut Manifest,
359 albums: &mut BTreeMap<String, AlbumArt>,
360 playlists: &mut BTreeMap<String, PlaylistState>,
361 ) -> Result<Effect, Fail> {
362 match action {
363 Action::Download {
364 clip,
365 lineage,
366 path,
367 format,
368 } => {
369 self.download(client, manifest, clip, lineage, path, *format)
370 .await
371 }
372 Action::Reformat {
373 clip,
374 path,
375 from_path,
376 from: _,
377 to,
378 } => {
379 self.reformat(client, manifest, clip, path, from_path, *to)
380 .await
381 }
382 Action::Retag {
383 clip,
384 lineage,
385 path,
386 } => self.retag(manifest, clip, lineage, path).await,
387 Action::Rename { from, to } => self.rename(manifest, from, to),
388 Action::Delete { path, clip_id } => self.delete(manifest, path, clip_id),
389 Action::Skip { clip_id } => {
390 self.refresh_preserve(manifest, clip_id);
391 Ok(Effect::Skipped)
392 }
393 Action::WriteArtifact {
394 kind,
395 path,
396 source_url,
397 hash,
398 owner_id,
399 content,
400 } => {
401 self.write_artifact(
402 manifest,
403 albums,
404 playlists,
405 *kind,
406 path,
407 source_url,
408 hash,
409 owner_id,
410 content.as_deref(),
411 )
412 .await
413 }
414 Action::DeleteArtifact {
415 kind,
416 path,
417 owner_id,
418 } => self.delete_artifact(manifest, albums, playlists, *kind, path, owner_id),
419 }
420 }
421
422 async fn download(
424 &self,
425 client: &mut SunoClient<C>,
426 manifest: &mut Manifest,
427 clip: &Clip,
428 lineage: &LineageContext,
429 path: &str,
430 format: AudioFormat,
431 ) -> Result<Effect, Fail> {
432 let tagged = self.produce_audio(client, clip, lineage, format).await?;
433 let size = self.write_verify(&clip.id, path, &tagged)?;
434 manifest.insert(clip.id.clone(), self.entry(&clip.id, path, format, size));
435 Ok(Effect::Downloaded)
436 }
437
438 async fn reformat(
440 &self,
441 client: &mut SunoClient<C>,
442 manifest: &mut Manifest,
443 clip: &Clip,
444 path: &str,
445 from_path: &str,
446 to: AudioFormat,
447 ) -> Result<Effect, Fail> {
448 let lineage = self
452 .by_id
453 .get(clip.id.as_str())
454 .map(|d| d.lineage.clone())
455 .unwrap_or_else(|| LineageContext::own_root(clip));
456 let tagged = self.produce_audio(client, clip, &lineage, to).await?;
457 let size = self.write_verify(&clip.id, path, &tagged)?;
458 self.fs
460 .remove(from_path)
461 .map_err(|err| permanent_fail(&clip.id, format!("could not remove old file: {err}")))?;
462 manifest.insert(clip.id.clone(), self.entry(&clip.id, path, to, size));
463 Ok(Effect::Reformatted)
464 }
465
466 async fn retag(
468 &self,
469 manifest: &mut Manifest,
470 clip: &Clip,
471 lineage: &LineageContext,
472 path: &str,
473 ) -> Result<Effect, Fail> {
474 let Some(format) = manifest.get(&clip.id).map(|entry| entry.format) else {
475 return Err(permanent_fail(
476 &clip.id,
477 "retag target missing from manifest",
478 ));
479 };
480
481 if format == AudioFormat::Wav {
482 self.refresh_hashes(manifest, &clip.id, None);
485 return Ok(Effect::Retagged);
486 }
487
488 let meta = TrackMetadata::from_clip(clip, lineage);
489 let cover = self.fetch_cover(clip).await;
490 let existing = self
491 .fs
492 .read(path)
493 .map_err(|err| permanent_fail(&clip.id, format!("could not read for retag: {err}")))?;
494 let tagged = match format {
495 AudioFormat::Mp3 => tag_mp3(&existing, &meta, cover.as_deref()),
496 AudioFormat::Flac => tag_flac(&existing, &meta, cover.as_deref()),
497 AudioFormat::Wav => unreachable!("WAV handled above"),
498 }
499 .map_err(|err| permanent_fail(&clip.id, err.to_string()))?;
500 let size = self.write_verify(&clip.id, path, &tagged)?;
501 self.refresh_hashes(manifest, &clip.id, Some(size));
502 Ok(Effect::Retagged)
503 }
504
505 fn rename(&self, manifest: &mut Manifest, from: &str, to: &str) -> Result<Effect, Fail> {
507 let label = self
508 .by_path
509 .get(to)
510 .map(|d| d.clip.id.clone())
511 .unwrap_or_else(|| to.to_owned());
512 self.fs
513 .rename(from, to)
514 .map_err(|err| permanent_fail(label, format!("rename failed: {err}")))?;
515
516 let clip_id = self.by_path.get(to).map(|d| d.clip.id.clone()).or_else(|| {
517 manifest
518 .entries
519 .iter()
520 .find(|(_, entry)| entry.path == from)
521 .map(|(id, _)| id.clone())
522 });
523 if let Some(id) = clip_id
524 && let Some(entry) = manifest.entries.get_mut(&id)
525 {
526 entry.path = to.to_owned();
527 if let Some(d) = self.by_path.get(to) {
528 entry.preserve = preserve_for(d);
529 }
530 }
531 Ok(Effect::Renamed)
532 }
533
534 fn delete(&self, manifest: &mut Manifest, path: &str, clip_id: &str) -> Result<Effect, Fail> {
536 self.fs
537 .remove(path)
538 .map_err(|err| permanent_fail(clip_id, format!("delete failed: {err}")))?;
539 manifest.remove(clip_id);
540 Ok(Effect::Deleted)
541 }
542
543 #[allow(clippy::too_many_arguments)]
574 async fn write_artifact(
575 &self,
576 manifest: &mut Manifest,
577 albums: &mut BTreeMap<String, AlbumArt>,
578 playlists: &mut BTreeMap<String, PlaylistState>,
579 kind: ArtifactKind,
580 path: &str,
581 source_url: &str,
582 hash: &str,
583 owner_id: &str,
584 content: Option<&str>,
585 ) -> Result<Effect, Fail> {
586 if is_per_clip_kind(kind) && manifest.get(owner_id).is_none() {
589 return Ok(Effect::Skipped);
590 }
591 let old_path = match kind {
597 ArtifactKind::CoverJpg => manifest
598 .get(owner_id)
599 .and_then(|e| e.cover_jpg.as_ref())
600 .map(|s| s.path.clone()),
601 ArtifactKind::CoverWebp => manifest
602 .get(owner_id)
603 .and_then(|e| e.cover_webp.as_ref())
604 .map(|s| s.path.clone()),
605 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp => albums
606 .get(owner_id)
607 .and_then(|a| a.artifact(kind))
608 .map(|s| s.path.clone()),
609 ArtifactKind::Playlist => None,
610 };
611 let bytes = match content {
614 Some(text) => text.as_bytes().to_vec(),
615 None => self.artifact_bytes(kind, source_url, owner_id).await?,
616 };
617 self.write_verify(owner_id, path, &bytes)?;
618 if let Some(old) = old_path.as_deref()
625 && !old.is_empty()
626 && old != path
627 {
628 self.fs.remove(old).map_err(|err| {
629 permanent_fail(
630 owner_id,
631 format!("could not remove old sidecar {old}: {err}"),
632 )
633 })?;
634 }
635 if is_album_kind(kind) {
636 albums.entry(owner_id.to_owned()).or_default().set(
637 kind,
638 Some(ArtifactState {
639 path: path.to_owned(),
640 hash: hash.to_owned(),
641 }),
642 );
643 } else if is_playlist_kind(kind) {
644 playlists.insert(
645 owner_id.to_owned(),
646 PlaylistState {
647 name: playlist_name_from_path(path),
648 path: path.to_owned(),
649 hash: hash.to_owned(),
650 },
651 );
652 } else if let Some(entry) = manifest.entries.get_mut(owner_id) {
653 set_manifest_artifact(
654 entry,
655 kind,
656 Some(ArtifactState {
657 path: path.to_owned(),
658 hash: hash.to_owned(),
659 }),
660 );
661 }
662 Ok(Effect::ArtifactWritten)
663 }
664
665 async fn artifact_bytes(
676 &self,
677 kind: ArtifactKind,
678 source_url: &str,
679 owner_id: &str,
680 ) -> Result<Vec<u8>, Fail> {
681 let source = self
682 .fetch_bytes(source_url)
683 .await
684 .map_err(|err| err.attribute(owner_id))?;
685 match kind {
686 ArtifactKind::CoverWebp | ArtifactKind::FolderWebp => self
687 .ffmpeg
688 .mp4_to_webp(&source, WebpEncodeSettings::default())
689 .await
690 .map_err(|err| permanent_fail(owner_id, format!("cover transcode failed: {err}"))),
691 _ => Ok(source),
692 }
693 }
694
695 fn delete_artifact(
710 &self,
711 manifest: &mut Manifest,
712 albums: &mut BTreeMap<String, AlbumArt>,
713 playlists: &mut BTreeMap<String, PlaylistState>,
714 kind: ArtifactKind,
715 path: &str,
716 owner_id: &str,
717 ) -> Result<Effect, Fail> {
718 self.fs
719 .remove(path)
720 .map_err(|err| permanent_fail(owner_id, format!("artifact delete failed: {err}")))?;
721 if is_album_kind(kind) {
722 if let Some(art) = albums.get_mut(owner_id) {
723 art.set(kind, None);
724 if art.is_empty() {
725 albums.remove(owner_id);
726 }
727 }
728 } else if is_playlist_kind(kind) {
729 playlists.remove(owner_id);
730 } else if let Some(entry) = manifest.entries.get_mut(owner_id) {
731 set_manifest_artifact(entry, kind, None);
732 }
733 Ok(Effect::ArtifactDeleted)
734 }
735
736 async fn produce_audio(
738 &self,
739 client: &mut SunoClient<C>,
740 clip: &Clip,
741 lineage: &LineageContext,
742 format: AudioFormat,
743 ) -> Result<Vec<u8>, Fail> {
744 let meta = TrackMetadata::from_clip(clip, lineage);
745 match format {
746 AudioFormat::Mp3 => {
747 let url = clip.mp3_url();
748 let audio = self
749 .fetch_bytes(&url)
750 .await
751 .map_err(|err| err.attribute(&clip.id))?;
752 let cover = self.fetch_cover(clip).await;
753 tag_mp3(&audio, &meta, cover.as_deref())
754 .map_err(|err| permanent_fail(&clip.id, err.to_string()))
755 }
756 AudioFormat::Flac => {
757 let wav = self.fetch_wav(client, clip).await?;
758 let flac =
759 self.ffmpeg.wav_to_flac(&wav).await.map_err(|err| {
760 permanent_fail(&clip.id, format!("transcode failed: {err}"))
761 })?;
762 let cover = self.fetch_cover(clip).await;
763 tag_flac(&flac, &meta, cover.as_deref())
764 .map_err(|err| permanent_fail(&clip.id, err.to_string()))
765 }
766 AudioFormat::Wav => self.fetch_wav(client, clip).await,
767 }
768 }
769
770 async fn fetch_wav(&self, client: &mut SunoClient<C>, clip: &Clip) -> Result<Vec<u8>, Fail> {
772 let url = match self.resolve_wav_url(client, &clip.id).await? {
773 Some(url) => url,
774 None => return Err(transient_fail(&clip.id, "WAV render was not ready")),
775 };
776 self.fetch_bytes(&url)
777 .await
778 .map_err(|err| err.attribute(&clip.id))
779 }
780
781 async fn resolve_wav_url(
786 &self,
787 client: &mut SunoClient<C>,
788 id: &str,
789 ) -> Result<Option<String>, Fail> {
790 if let Some(url) = self.wav_url_retrying(client, id).await? {
791 return Ok(Some(url));
792 }
793 self.request_wav_retrying(client, id).await?;
794 for _ in 0..self.opts.wav_poll_attempts {
795 self.clock.sleep(self.opts.wav_poll_interval).await;
796 if let Some(url) = self.wav_url_retrying(client, id).await? {
797 return Ok(Some(url));
798 }
799 }
800 Ok(None)
801 }
802
803 async fn wav_url_retrying(
806 &self,
807 client: &mut SunoClient<C>,
808 id: &str,
809 ) -> Result<Option<String>, Fail> {
810 let mut attempt: u32 = 0;
811 loop {
812 match client.wav_url(self.http, id).await {
813 Ok(url) => return Ok(url),
814 Err(err) => match self.retry_core(id, err, &mut attempt).await {
815 Some(fail) => return Err(fail),
816 None => continue,
817 },
818 }
819 }
820 }
821
822 async fn request_wav_retrying(&self, client: &mut SunoClient<C>, id: &str) -> Result<(), Fail> {
824 let mut attempt: u32 = 0;
825 loop {
826 match client.request_wav(self.http, id).await {
827 Ok(()) => return Ok(()),
828 Err(err) => match self.retry_core(id, err, &mut attempt).await {
829 Some(fail) => return Err(fail),
830 None => continue,
831 },
832 }
833 }
834 }
835
836 async fn retry_core(&self, id: &str, err: Error, attempt: &mut u32) -> Option<Fail> {
840 let fail = classify_core(id, err);
841 if matches!(fail.class, Class::Transient) && *attempt < self.opts.max_retries {
842 self.clock.sleep(backoff_delay(*attempt, None)).await;
843 *attempt += 1;
844 None
845 } else {
846 Some(fail)
847 }
848 }
849
850 async fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, FetchError> {
852 let mut attempt: u32 = 0;
853 loop {
854 let result = self.http.send(HttpRequest::get(url)).await;
855 match classify_response(result) {
856 Ok(body) => return Ok(body),
857 Err(err) => {
858 if matches!(err.class, Class::Transient) && attempt < self.opts.max_retries {
859 let delay = backoff_delay(attempt, err.retry_after);
860 self.clock.sleep(delay).await;
861 attempt += 1;
862 continue;
863 }
864 return Err(err);
865 }
866 }
867 }
868 }
869
870 async fn fetch_cover(&self, clip: &Clip) -> Option<Vec<u8>> {
872 for url in clip.cover_candidates() {
873 if let Ok(response) = self.http.send(HttpRequest::get(url)).await
874 && (200..=299).contains(&response.status)
875 && !response.body.is_empty()
876 {
877 return Some(response.body);
878 }
879 }
880 None
881 }
882
883 fn write_verify(&self, clip_id: &str, path: &str, bytes: &[u8]) -> Result<u64, Fail> {
885 self.fs
886 .write_atomic(path, bytes)
887 .map_err(|err| permanent_fail(clip_id, format!("write failed: {err}")))?;
888 match self.fs.metadata(path) {
889 Some(stat) if stat.size == bytes.len() as u64 => Ok(stat.size),
890 Some(stat) => Err(permanent_fail(
891 clip_id,
892 format!("wrote {} bytes, expected {}", stat.size, bytes.len()),
893 )),
894 None => Ok(bytes.len() as u64),
895 }
896 }
897
898 fn entry(&self, clip_id: &str, path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
900 match self.by_id.get(clip_id) {
901 Some(d) => manifest_entry(d, size),
902 None => ManifestEntry {
903 path: path.to_owned(),
904 format,
905 size,
906 ..ManifestEntry::default()
907 },
908 }
909 }
910
911 fn refresh_hashes(&self, manifest: &mut Manifest, clip_id: &str, size: Option<u64>) {
913 let desired = self.by_id.get(clip_id).copied();
914 if let Some(entry) = manifest.entries.get_mut(clip_id) {
915 if let Some(d) = desired {
916 entry.meta_hash = d.meta_hash.clone();
917 entry.art_hash = d.art_hash.clone();
918 entry.preserve = preserve_for(d);
919 }
920 if let Some(size) = size {
921 entry.size = size;
922 }
923 }
924 }
925
926 fn refresh_preserve(&self, manifest: &mut Manifest, clip_id: &str) {
933 if let Some(d) = self.by_id.get(clip_id).copied()
934 && let Some(entry) = manifest.entries.get_mut(clip_id)
935 {
936 entry.preserve = preserve_for(d);
937 }
938 }
939}
940
941fn manifest_entry(d: &Desired, size: u64) -> ManifestEntry {
943 ManifestEntry {
944 path: d.path.clone(),
945 format: d.format,
946 meta_hash: d.meta_hash.clone(),
947 art_hash: d.art_hash.clone(),
948 size,
949 preserve: preserve_for(d),
950 ..Default::default()
951 }
952}
953
954fn preserve_for(d: &Desired) -> bool {
957 d.private || d.modes.contains(&SourceMode::Copy)
958}
959
960fn classify_response(
962 result: Result<crate::http::HttpResponse, crate::http::TransportError>,
963) -> Result<Vec<u8>, FetchError> {
964 let response = match result {
965 Ok(response) => response,
966 Err(err) => {
967 return Err(FetchError::transient(
968 format!("transport error: {err}"),
969 None,
970 ));
971 }
972 };
973 match response.status {
974 200..=299 => {
975 if let Some(expected) = content_length(&response) {
976 let actual = response.body.len() as u64;
977 if actual != expected {
978 return Err(FetchError::transient(
979 format!("truncated download: {actual} of {expected} bytes"),
980 None,
981 ));
982 }
983 }
984 Ok(response.body)
985 }
986 401 | 403 => Err(FetchError::transient(
987 format!("download rejected: status {}", response.status),
988 None,
989 )),
990 408 => Err(FetchError::transient("request timed out", None)),
991 429 => Err(FetchError::transient(
992 "rate limited",
993 retry_after(&response),
994 )),
995 500..=599 => Err(FetchError::transient(
996 format!("server error {}", response.status),
997 None,
998 )),
999 status => Err(FetchError::permanent(format!(
1000 "download failed: status {status}"
1001 ))),
1002 }
1003}
1004
1005fn classify_core(id: &str, err: Error) -> Fail {
1007 let reason = err.to_string();
1008 match err {
1009 Error::Auth(_) => auth_fail(id, reason),
1010 Error::RateLimited { .. } | Error::Connection(_) => transient_fail(id, reason),
1011 Error::Api(_) | Error::NotFound(_) | Error::Tag(_) | Error::Config(_) => {
1012 permanent_fail(id, reason)
1013 }
1014 }
1015}
1016
1017fn content_length(response: &crate::http::HttpResponse) -> Option<u64> {
1019 response.header("content-length")?.trim().parse().ok()
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use super::*;
1025 use crate::ClerkAuth;
1026 use crate::http::HttpResponse;
1027 use crate::testutil::{MemFs, RecordingClock, Reply, ScriptedHttp, StubFfmpeg};
1028
1029 fn clip(id: &str) -> Clip {
1030 Clip {
1031 id: id.to_owned(),
1032 title: "Song".to_owned(),
1033 audio_url: format!("https://cdn1.suno.ai/{id}.mp3"),
1034 ..Default::default()
1035 }
1036 }
1037
1038 fn art_clip(id: &str) -> Clip {
1039 Clip {
1040 image_large_url: format!("https://art.suno.ai/{id}/large.jpg"),
1041 image_url: format!("https://art.suno.ai/{id}/small.jpg"),
1042 ..clip(id)
1043 }
1044 }
1045
1046 fn ext(format: AudioFormat) -> &'static str {
1047 match format {
1048 AudioFormat::Mp3 => "mp3",
1049 AudioFormat::Flac => "flac",
1050 AudioFormat::Wav => "wav",
1051 }
1052 }
1053
1054 fn desired(clip: Clip, format: AudioFormat) -> Desired {
1055 Desired {
1056 path: format!("{}.{}", clip.id, ext(format)),
1057 lineage: LineageContext::own_root(&clip),
1058 clip,
1059 format,
1060 meta_hash: "m".to_owned(),
1061 art_hash: "art".to_owned(),
1062 modes: vec![SourceMode::Mirror],
1063 trashed: false,
1064 private: false,
1065 artifacts: Vec::new(),
1066 }
1067 }
1068
1069 fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
1070 ManifestEntry {
1071 path: path.to_owned(),
1072 format,
1073 meta_hash: "old".to_owned(),
1074 art_hash: "old-art".to_owned(),
1075 size: 8,
1076 preserve: false,
1077 ..Default::default()
1078 }
1079 }
1080
1081 #[allow(clippy::too_many_arguments)]
1082 fn run(
1083 plan: &Plan,
1084 manifest: &mut Manifest,
1085 desired: &[Desired],
1086 http: &ScriptedHttp,
1087 fs: &MemFs,
1088 ffmpeg: &StubFfmpeg,
1089 clock: &RecordingClock,
1090 opts: &ExecOptions,
1091 ) -> ExecOutcome {
1092 let mut albums = BTreeMap::new();
1093 run_with_albums(
1094 plan,
1095 manifest,
1096 &mut albums,
1097 desired,
1098 http,
1099 fs,
1100 ffmpeg,
1101 clock,
1102 opts,
1103 )
1104 }
1105
1106 #[allow(clippy::too_many_arguments)]
1107 fn run_with_albums(
1108 plan: &Plan,
1109 manifest: &mut Manifest,
1110 albums: &mut BTreeMap<String, AlbumArt>,
1111 desired: &[Desired],
1112 http: &ScriptedHttp,
1113 fs: &MemFs,
1114 ffmpeg: &StubFfmpeg,
1115 clock: &RecordingClock,
1116 opts: &ExecOptions,
1117 ) -> ExecOutcome {
1118 let mut playlists = BTreeMap::new();
1119 run_full(
1120 plan,
1121 manifest,
1122 albums,
1123 &mut playlists,
1124 desired,
1125 http,
1126 fs,
1127 ffmpeg,
1128 clock,
1129 opts,
1130 )
1131 }
1132
1133 #[allow(clippy::too_many_arguments)]
1134 fn run_full(
1135 plan: &Plan,
1136 manifest: &mut Manifest,
1137 albums: &mut BTreeMap<String, AlbumArt>,
1138 playlists: &mut BTreeMap<String, PlaylistState>,
1139 desired: &[Desired],
1140 http: &ScriptedHttp,
1141 fs: &MemFs,
1142 ffmpeg: &StubFfmpeg,
1143 clock: &RecordingClock,
1144 opts: &ExecOptions,
1145 ) -> ExecOutcome {
1146 let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1147 pollster::block_on(execute(
1148 plan,
1149 manifest,
1150 albums,
1151 playlists,
1152 desired,
1153 Ports {
1154 client: &mut client,
1155 http,
1156 fs,
1157 ffmpeg,
1158 clock,
1159 },
1160 opts,
1161 ))
1162 }
1163
1164 fn small_poll() -> ExecOptions {
1165 ExecOptions {
1166 max_retries: 3,
1167 wav_poll_attempts: 2,
1168 wav_poll_interval: Duration::from_secs(5),
1169 }
1170 }
1171
1172 #[test]
1175 fn download_mp3_writes_tagged_file_and_records_manifest() {
1176 let c = art_clip("a");
1177 let d = desired(c.clone(), AudioFormat::Mp3);
1178 let plan = Plan {
1179 actions: vec![Action::Download {
1180 clip: c.clone(),
1181 lineage: LineageContext::own_root(&c),
1182 path: d.path.clone(),
1183 format: AudioFormat::Mp3,
1184 }],
1185 };
1186 let http = ScriptedHttp::new()
1187 .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
1188 .route("a/large.jpg", Reply::ok(b"art-bytes".to_vec()));
1189 let fs = MemFs::new();
1190 let ffmpeg = StubFfmpeg::flac();
1191 let clock = RecordingClock::new();
1192 let mut manifest = Manifest::new();
1193
1194 let outcome = run(
1195 &plan,
1196 &mut manifest,
1197 &[d],
1198 &http,
1199 &fs,
1200 &ffmpeg,
1201 &clock,
1202 &ExecOptions::default(),
1203 );
1204
1205 assert_eq!(outcome.downloaded, 1);
1206 assert_eq!(outcome.failed(), 0);
1207 assert_eq!(outcome.status, RunStatus::Completed);
1208 let written = fs.read_file("a.mp3").unwrap();
1209 assert_eq!(&written[..3], b"ID3");
1210 assert!(written.ends_with(b"mp3-body"));
1211 let entry = manifest.get("a").unwrap();
1212 assert_eq!(entry.path, "a.mp3");
1213 assert_eq!(entry.format, AudioFormat::Mp3);
1214 assert_eq!(entry.meta_hash, "m");
1215 assert_eq!(entry.art_hash, "art");
1216 assert_eq!(entry.size, written.len() as u64);
1217 assert!(!entry.preserve);
1218 }
1219
1220 #[test]
1221 fn download_mp3_uses_cdn_fallback_when_audio_url_empty() {
1222 let mut c = clip("a");
1223 c.audio_url = String::new();
1224 let d = desired(c.clone(), AudioFormat::Mp3);
1225 let plan = Plan {
1226 actions: vec![Action::Download {
1227 clip: c.clone(),
1228 lineage: LineageContext::own_root(&c),
1229 path: d.path.clone(),
1230 format: AudioFormat::Mp3,
1231 }],
1232 };
1233 let http = ScriptedHttp::new().route("cdn1.suno.ai/a.mp3", Reply::ok(b"body".to_vec()));
1234 let fs = MemFs::new();
1235 let mut manifest = Manifest::new();
1236 let outcome = run(
1237 &plan,
1238 &mut manifest,
1239 &[d],
1240 &http,
1241 &fs,
1242 &StubFfmpeg::flac(),
1243 &RecordingClock::new(),
1244 &ExecOptions::default(),
1245 );
1246 assert_eq!(outcome.downloaded, 1);
1247 assert_eq!(http.count("cdn1.suno.ai/a.mp3"), 1);
1248 }
1249
1250 #[test]
1253 fn download_flac_renders_transcodes_and_records() {
1254 let c = clip("b");
1255 let d = desired(c.clone(), AudioFormat::Flac);
1256 let plan = Plan {
1257 actions: vec![Action::Download {
1258 clip: c.clone(),
1259 lineage: LineageContext::own_root(&c),
1260 path: d.path.clone(),
1261 format: AudioFormat::Flac,
1262 }],
1263 };
1264 let http = ScriptedHttp::new()
1265 .with_auth()
1266 .route(
1267 "/wav_file/",
1268 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/b.wav"}"#),
1269 )
1270 .route("b.wav", Reply::ok(b"wav-bytes".to_vec()));
1271 let fs = MemFs::new();
1272 let clock = RecordingClock::new();
1273 let mut manifest = Manifest::new();
1274
1275 let outcome = run(
1276 &plan,
1277 &mut manifest,
1278 &[d],
1279 &http,
1280 &fs,
1281 &StubFfmpeg::flac(),
1282 &clock,
1283 &ExecOptions::default(),
1284 );
1285
1286 assert_eq!(outcome.downloaded, 1);
1287 assert_eq!(outcome.failed(), 0);
1288 let written = fs.read_file("b.flac").unwrap();
1289 assert_eq!(&written[..4], b"fLaC");
1290 assert_eq!(manifest.get("b").unwrap().format, AudioFormat::Flac);
1291 assert_eq!(http.count("/convert_wav/"), 0);
1293 assert!(clock.sleeps().is_empty());
1294 }
1295
1296 #[test]
1297 fn download_flac_requests_render_then_polls_until_ready() {
1298 let c = clip("c");
1299 let d = desired(c.clone(), AudioFormat::Flac);
1300 let plan = Plan {
1301 actions: vec![Action::Download {
1302 clip: c.clone(),
1303 lineage: LineageContext::own_root(&c),
1304 path: d.path.clone(),
1305 format: AudioFormat::Flac,
1306 }],
1307 };
1308 let http = ScriptedHttp::new()
1309 .with_auth()
1310 .route_seq(
1311 "/wav_file/",
1312 vec![
1313 Reply::json("{}"),
1314 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/c.wav"}"#),
1315 ],
1316 )
1317 .route("/convert_wav/", Reply::status(200))
1318 .route("c.wav", Reply::ok(b"wav".to_vec()));
1319 let clock = RecordingClock::new();
1320 let mut manifest = Manifest::new();
1321
1322 let outcome = run(
1323 &plan,
1324 &mut manifest,
1325 &[d],
1326 &http,
1327 &fs_new(),
1328 &StubFfmpeg::flac(),
1329 &clock,
1330 &small_poll(),
1331 );
1332
1333 assert_eq!(outcome.downloaded, 1);
1334 assert_eq!(http.count("/convert_wav/"), 1);
1335 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
1336 }
1337
1338 #[test]
1339 fn download_flac_unavailable_render_is_a_nonfatal_failure() {
1340 let c = clip("d");
1341 let d = desired(c.clone(), AudioFormat::Flac);
1342 let plan = Plan {
1343 actions: vec![Action::Download {
1344 clip: c.clone(),
1345 lineage: LineageContext::own_root(&c),
1346 path: d.path.clone(),
1347 format: AudioFormat::Flac,
1348 }],
1349 };
1350 let http = ScriptedHttp::new()
1351 .with_auth()
1352 .route("/wav_file/", Reply::json("{}"))
1353 .route("/convert_wav/", Reply::status(200));
1354 let fs = MemFs::new();
1355 let clock = RecordingClock::new();
1356 let mut manifest = Manifest::new();
1357
1358 let outcome = run(
1359 &plan,
1360 &mut manifest,
1361 &[d],
1362 &http,
1363 &fs,
1364 &StubFfmpeg::flac(),
1365 &clock,
1366 &small_poll(),
1367 );
1368
1369 assert_eq!(outcome.downloaded, 0);
1370 assert_eq!(outcome.failed(), 1);
1371 assert_eq!(outcome.failures[0].clip_id, "d");
1372 assert_eq!(outcome.status, RunStatus::Completed);
1373 assert!(!fs.exists("d.flac"));
1374 assert_eq!(clock.sleeps().len(), 2);
1375 }
1376
1377 #[test]
1378 fn flac_transcode_failure_is_recorded_and_skipped() {
1379 let c = clip("t");
1380 let d = desired(c.clone(), AudioFormat::Flac);
1381 let plan = Plan {
1382 actions: vec![Action::Download {
1383 clip: c.clone(),
1384 lineage: LineageContext::own_root(&c),
1385 path: d.path.clone(),
1386 format: AudioFormat::Flac,
1387 }],
1388 };
1389 let http = ScriptedHttp::new()
1390 .with_auth()
1391 .route(
1392 "/wav_file/",
1393 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/t.wav"}"#),
1394 )
1395 .route("t.wav", Reply::ok(b"wav".to_vec()));
1396 let fs = MemFs::new();
1397 let mut manifest = Manifest::new();
1398
1399 let outcome = run(
1400 &plan,
1401 &mut manifest,
1402 &[d],
1403 &http,
1404 &fs,
1405 &StubFfmpeg::failing(),
1406 &RecordingClock::new(),
1407 &ExecOptions::default(),
1408 );
1409
1410 assert_eq!(outcome.downloaded, 0);
1411 assert_eq!(outcome.failed(), 1);
1412 assert!(!fs.exists("t.flac"));
1413 assert!(manifest.get("t").is_none());
1414 }
1415
1416 #[test]
1419 fn cover_falls_back_when_large_image_is_missing() {
1420 let c = art_clip("e");
1421 let d = desired(c.clone(), AudioFormat::Mp3);
1422 let plan = Plan {
1423 actions: vec![Action::Download {
1424 clip: c.clone(),
1425 lineage: LineageContext::own_root(&c),
1426 path: d.path.clone(),
1427 format: AudioFormat::Mp3,
1428 }],
1429 };
1430 let http = ScriptedHttp::new()
1431 .route("e.mp3", Reply::ok(b"body".to_vec()))
1432 .route("e/large.jpg", Reply::status(404))
1433 .route("e/small.jpg", Reply::ok(b"the-art".to_vec()));
1434 let fs = MemFs::new();
1435 let mut manifest = Manifest::new();
1436
1437 let outcome = run(
1438 &plan,
1439 &mut manifest,
1440 &[d],
1441 &http,
1442 &fs,
1443 &StubFfmpeg::flac(),
1444 &RecordingClock::new(),
1445 &ExecOptions::default(),
1446 );
1447
1448 assert_eq!(outcome.downloaded, 1);
1449 let calls = http.calls();
1450 let large = calls
1451 .iter()
1452 .position(|u| u.contains("e/large.jpg"))
1453 .unwrap();
1454 let small = calls
1455 .iter()
1456 .position(|u| u.contains("e/small.jpg"))
1457 .unwrap();
1458 assert!(large < small, "large art tried before small");
1459 }
1460
1461 #[test]
1464 fn failed_write_leaves_the_prior_file_intact() {
1465 let c = clip("f");
1466 let d = desired(c.clone(), AudioFormat::Mp3);
1467 let plan = Plan {
1468 actions: vec![Action::Download {
1469 clip: c.clone(),
1470 lineage: LineageContext::own_root(&c),
1471 path: d.path.clone(),
1472 format: AudioFormat::Mp3,
1473 }],
1474 };
1475 let http = ScriptedHttp::new().route("f.mp3", Reply::ok(b"new-body".to_vec()));
1476 let fs = MemFs::new()
1477 .with_file("f.mp3", b"OLD-CONTENT".to_vec())
1478 .fail_write("f.mp3");
1479 let mut manifest = Manifest::new();
1480
1481 let outcome = run(
1482 &plan,
1483 &mut manifest,
1484 &[d],
1485 &http,
1486 &fs,
1487 &StubFfmpeg::flac(),
1488 &RecordingClock::new(),
1489 &ExecOptions::default(),
1490 );
1491
1492 assert_eq!(outcome.downloaded, 0);
1493 assert_eq!(outcome.failed(), 1);
1494 assert_eq!(fs.read_file("f.mp3").unwrap(), b"OLD-CONTENT");
1495 assert!(manifest.get("f").is_none());
1496 }
1497
1498 #[test]
1499 fn size_mismatch_after_write_is_a_failure() {
1500 let c = clip("g");
1501 let d = desired(c.clone(), AudioFormat::Mp3);
1502 let plan = Plan {
1503 actions: vec![Action::Download {
1504 clip: c.clone(),
1505 lineage: LineageContext::own_root(&c),
1506 path: d.path.clone(),
1507 format: AudioFormat::Mp3,
1508 }],
1509 };
1510 let http = ScriptedHttp::new().route("g.mp3", Reply::ok(b"body".to_vec()));
1511 let fs = MemFs::new().corrupt_write("g.mp3");
1512 let mut manifest = Manifest::new();
1513
1514 let outcome = run(
1515 &plan,
1516 &mut manifest,
1517 &[d],
1518 &http,
1519 &fs,
1520 &StubFfmpeg::flac(),
1521 &RecordingClock::new(),
1522 &ExecOptions::default(),
1523 );
1524
1525 assert_eq!(outcome.downloaded, 0);
1526 assert_eq!(outcome.failed(), 1);
1527 assert!(outcome.failures[0].reason.contains("expected"));
1528 assert!(manifest.get("g").is_none());
1529 }
1530
1531 #[test]
1534 fn transient_failure_is_retried_then_skipped() {
1535 let c = clip("h");
1536 let d = desired(c.clone(), AudioFormat::Mp3);
1537 let plan = Plan {
1538 actions: vec![Action::Download {
1539 clip: c.clone(),
1540 lineage: LineageContext::own_root(&c),
1541 path: d.path.clone(),
1542 format: AudioFormat::Mp3,
1543 }],
1544 };
1545 let http = ScriptedHttp::new().route("h.mp3", Reply::status(500));
1546 let fs = MemFs::new();
1547 let clock = RecordingClock::new();
1548 let opts = ExecOptions {
1549 max_retries: 2,
1550 ..ExecOptions::default()
1551 };
1552 let mut manifest = Manifest::new();
1553
1554 let outcome = run(
1555 &plan,
1556 &mut manifest,
1557 &[d],
1558 &http,
1559 &fs,
1560 &StubFfmpeg::flac(),
1561 &clock,
1562 &opts,
1563 );
1564
1565 assert_eq!(outcome.downloaded, 0);
1566 assert_eq!(outcome.failed(), 1);
1567 assert_eq!(http.count("h.mp3"), 3);
1568 assert_eq!(clock.sleeps().len(), 2);
1569 }
1570
1571 #[test]
1572 fn truncated_download_is_retried_then_succeeds() {
1573 let c = clip("i");
1574 let d = desired(c.clone(), AudioFormat::Mp3);
1575 let plan = Plan {
1576 actions: vec![Action::Download {
1577 clip: c.clone(),
1578 lineage: LineageContext::own_root(&c),
1579 path: d.path.clone(),
1580 format: AudioFormat::Mp3,
1581 }],
1582 };
1583 let http = ScriptedHttp::new().route_seq(
1584 "i.mp3",
1585 vec![
1586 Reply::ok(b"short".to_vec()).with_content_length(999),
1587 Reply::ok(b"good-body".to_vec()),
1588 ],
1589 );
1590 let fs = MemFs::new();
1591 let clock = RecordingClock::new();
1592 let mut manifest = Manifest::new();
1593
1594 let outcome = run(
1595 &plan,
1596 &mut manifest,
1597 &[d],
1598 &http,
1599 &fs,
1600 &StubFfmpeg::flac(),
1601 &clock,
1602 &ExecOptions::default(),
1603 );
1604
1605 assert_eq!(outcome.downloaded, 1);
1606 assert_eq!(http.count("i.mp3"), 2);
1607 assert_eq!(clock.sleeps().len(), 1);
1608 }
1609
1610 #[test]
1611 fn rate_limit_backs_off_using_retry_after() {
1612 let c = clip("j");
1613 let d = desired(c.clone(), AudioFormat::Mp3);
1614 let plan = Plan {
1615 actions: vec![Action::Download {
1616 clip: c.clone(),
1617 lineage: LineageContext::own_root(&c),
1618 path: d.path.clone(),
1619 format: AudioFormat::Mp3,
1620 }],
1621 };
1622 let http = ScriptedHttp::new().route_seq(
1623 "j.mp3",
1624 vec![
1625 Reply::status(429).with_retry_after(7),
1626 Reply::ok(b"body".to_vec()),
1627 ],
1628 );
1629 let fs = MemFs::new();
1630 let clock = RecordingClock::new();
1631 let mut manifest = Manifest::new();
1632
1633 let outcome = run(
1634 &plan,
1635 &mut manifest,
1636 &[d],
1637 &http,
1638 &fs,
1639 &StubFfmpeg::flac(),
1640 &clock,
1641 &ExecOptions::default(),
1642 );
1643
1644 assert_eq!(outcome.downloaded, 1);
1645 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
1646 }
1647
1648 #[test]
1649 fn auth_failure_aborts_the_run() {
1650 let c1 = clip("k1");
1651 let c2 = clip("k2");
1652 let d1 = desired(c1.clone(), AudioFormat::Flac);
1653 let d2 = desired(c2.clone(), AudioFormat::Flac);
1654 let plan = Plan {
1655 actions: vec![
1656 Action::Download {
1657 clip: c1.clone(),
1658 lineage: LineageContext::own_root(&c1),
1659 path: d1.path.clone(),
1660 format: AudioFormat::Flac,
1661 },
1662 Action::Download {
1663 clip: c2.clone(),
1664 lineage: LineageContext::own_root(&c2),
1665 path: d2.path.clone(),
1666 format: AudioFormat::Flac,
1667 },
1668 ],
1669 };
1670 let http = ScriptedHttp::new()
1674 .with_auth()
1675 .route("/wav_file/", Reply::status(401));
1676 let fs = MemFs::new();
1677 let mut manifest = Manifest::new();
1678
1679 let outcome = run(
1680 &plan,
1681 &mut manifest,
1682 &[d1, d2],
1683 &http,
1684 &fs,
1685 &StubFfmpeg::flac(),
1686 &RecordingClock::new(),
1687 &small_poll(),
1688 );
1689
1690 assert_eq!(outcome.status, RunStatus::AuthAborted);
1691 assert_eq!(outcome.failed(), 1);
1692 assert_eq!(outcome.failures[0].clip_id, "k1");
1693 assert_eq!(outcome.downloaded, 0);
1694 }
1695
1696 #[test]
1697 fn cdn_download_rejection_skips_the_clip_without_aborting() {
1698 let c1 = clip("k1");
1699 let c2 = clip("k2");
1700 let d1 = desired(c1.clone(), AudioFormat::Mp3);
1701 let d2 = desired(c2.clone(), AudioFormat::Mp3);
1702 let plan = Plan {
1703 actions: vec![
1704 Action::Download {
1705 clip: c1.clone(),
1706 lineage: LineageContext::own_root(&c1),
1707 path: d1.path.clone(),
1708 format: AudioFormat::Mp3,
1709 },
1710 Action::Download {
1711 clip: c2.clone(),
1712 lineage: LineageContext::own_root(&c2),
1713 path: d2.path.clone(),
1714 format: AudioFormat::Mp3,
1715 },
1716 ],
1717 };
1718 let http = ScriptedHttp::new()
1722 .route("k1.mp3", Reply::status(403))
1723 .route("k2.mp3", Reply::ok(b"body".to_vec()));
1724 let fs = MemFs::new();
1725 let mut manifest = Manifest::new();
1726
1727 let outcome = run(
1728 &plan,
1729 &mut manifest,
1730 &[d1, d2],
1731 &http,
1732 &fs,
1733 &StubFfmpeg::flac(),
1734 &RecordingClock::new(),
1735 &ExecOptions::default(),
1736 );
1737
1738 assert_ne!(outcome.status, RunStatus::AuthAborted);
1739 assert_eq!(outcome.downloaded, 1);
1740 assert_eq!(outcome.failed(), 1);
1741 assert_eq!(outcome.failures[0].clip_id, "k1");
1742 }
1743
1744 #[test]
1745 fn one_clip_failure_does_not_abort_the_run() {
1746 let c1 = clip("l1");
1747 let c2 = clip("l2");
1748 let d1 = desired(c1.clone(), AudioFormat::Mp3);
1749 let d2 = desired(c2.clone(), AudioFormat::Mp3);
1750 let plan = Plan {
1751 actions: vec![
1752 Action::Download {
1753 clip: c1.clone(),
1754 lineage: LineageContext::own_root(&c1),
1755 path: d1.path.clone(),
1756 format: AudioFormat::Mp3,
1757 },
1758 Action::Download {
1759 clip: c2.clone(),
1760 lineage: LineageContext::own_root(&c2),
1761 path: d2.path.clone(),
1762 format: AudioFormat::Mp3,
1763 },
1764 ],
1765 };
1766 let http = ScriptedHttp::new()
1767 .route("l1.mp3", Reply::status(404))
1768 .route("l2.mp3", Reply::ok(b"body".to_vec()));
1769 let fs = MemFs::new();
1770 let mut manifest = Manifest::new();
1771
1772 let outcome = run(
1773 &plan,
1774 &mut manifest,
1775 &[d1, d2],
1776 &http,
1777 &fs,
1778 &StubFfmpeg::flac(),
1779 &RecordingClock::new(),
1780 &ExecOptions::default(),
1781 );
1782
1783 assert_eq!(outcome.status, RunStatus::Completed);
1784 assert_eq!(outcome.downloaded, 1);
1785 assert_eq!(outcome.failed(), 1);
1786 assert_eq!(outcome.failures[0].clip_id, "l1");
1787 assert!(fs.exists("l2.mp3"));
1788 assert!(manifest.get("l2").is_some());
1789 assert!(manifest.get("l1").is_none());
1790 }
1791
1792 #[test]
1795 fn preserve_is_set_for_copy_held_and_private_clips() {
1796 let mut mirror = desired(clip("m1"), AudioFormat::Mp3);
1797 mirror.modes = vec![SourceMode::Mirror];
1798 let mut copy_held = desired(clip("m2"), AudioFormat::Mp3);
1799 copy_held.modes = vec![SourceMode::Mirror, SourceMode::Copy];
1800 let mut private = desired(clip("m3"), AudioFormat::Mp3);
1801 private.private = true;
1802
1803 let plan = Plan {
1804 actions: vec![
1805 Action::Download {
1806 clip: mirror.clip.clone(),
1807 lineage: LineageContext::own_root(&mirror.clip),
1808 path: mirror.path.clone(),
1809 format: AudioFormat::Mp3,
1810 },
1811 Action::Download {
1812 clip: copy_held.clip.clone(),
1813 lineage: LineageContext::own_root(©_held.clip),
1814 path: copy_held.path.clone(),
1815 format: AudioFormat::Mp3,
1816 },
1817 Action::Download {
1818 clip: private.clip.clone(),
1819 lineage: LineageContext::own_root(&private.clip),
1820 path: private.path.clone(),
1821 format: AudioFormat::Mp3,
1822 },
1823 ],
1824 };
1825 let http = ScriptedHttp::new()
1826 .route("m1.mp3", Reply::ok(b"a".to_vec()))
1827 .route("m2.mp3", Reply::ok(b"b".to_vec()))
1828 .route("m3.mp3", Reply::ok(b"c".to_vec()));
1829 let fs = MemFs::new();
1830 let mut manifest = Manifest::new();
1831
1832 let outcome = run(
1833 &plan,
1834 &mut manifest,
1835 &[mirror, copy_held, private],
1836 &http,
1837 &fs,
1838 &StubFfmpeg::flac(),
1839 &RecordingClock::new(),
1840 &ExecOptions::default(),
1841 );
1842
1843 assert_eq!(outcome.downloaded, 3);
1844 assert!(!manifest.get("m1").unwrap().preserve);
1845 assert!(manifest.get("m2").unwrap().preserve);
1846 assert!(manifest.get("m3").unwrap().preserve);
1847 }
1848
1849 #[test]
1852 fn reformat_writes_new_format_and_removes_old_file() {
1853 let c = clip("n");
1854 let d = desired(c.clone(), AudioFormat::Mp3);
1855 let plan = Plan {
1856 actions: vec![Action::Reformat {
1857 clip: c.clone(),
1858 path: "n.mp3".to_owned(),
1859 from_path: "n.flac".to_owned(),
1860 from: AudioFormat::Flac,
1861 to: AudioFormat::Mp3,
1862 }],
1863 };
1864 let http = ScriptedHttp::new().route("n.mp3", Reply::ok(b"body".to_vec()));
1865 let fs = MemFs::new().with_file("n.flac", b"OLD-FLAC".to_vec());
1866 let mut manifest = Manifest::new();
1867 manifest.insert("n", entry("n.flac", AudioFormat::Flac));
1868
1869 let outcome = run(
1870 &plan,
1871 &mut manifest,
1872 &[d],
1873 &http,
1874 &fs,
1875 &StubFfmpeg::flac(),
1876 &RecordingClock::new(),
1877 &ExecOptions::default(),
1878 );
1879
1880 assert_eq!(outcome.reformatted, 1);
1881 assert!(fs.exists("n.mp3"));
1882 assert!(!fs.exists("n.flac"));
1883 let updated = manifest.get("n").unwrap();
1884 assert_eq!(updated.path, "n.mp3");
1885 assert_eq!(updated.format, AudioFormat::Mp3);
1886 assert_eq!(updated.meta_hash, "m");
1887 }
1888
1889 #[test]
1890 fn retag_rewrites_file_and_updates_hashes() {
1891 let c = clip("o");
1892 let mut d = desired(c.clone(), AudioFormat::Mp3);
1893 d.meta_hash = "new".to_owned();
1894 d.art_hash = "new-art".to_owned();
1895 let existing = tag_mp3(
1896 b"audio",
1897 &TrackMetadata::from_clip(&c, &LineageContext::own_root(&c)),
1898 None,
1899 )
1900 .unwrap();
1901 let fs = MemFs::new().with_file("o.mp3", existing.clone());
1902 let mut manifest = Manifest::new();
1903 let mut start = entry("o.mp3", AudioFormat::Mp3);
1904 start.size = existing.len() as u64;
1905 manifest.insert("o", start);
1906 let plan = Plan {
1907 actions: vec![Action::Retag {
1908 clip: c.clone(),
1909 lineage: LineageContext::own_root(&c),
1910 path: "o.mp3".to_owned(),
1911 }],
1912 };
1913
1914 let outcome = run(
1915 &plan,
1916 &mut manifest,
1917 &[d],
1918 &ScriptedHttp::new(),
1919 &fs,
1920 &StubFfmpeg::flac(),
1921 &RecordingClock::new(),
1922 &ExecOptions::default(),
1923 );
1924
1925 assert_eq!(outcome.retagged, 1);
1926 let updated = manifest.get("o").unwrap();
1927 assert_eq!(updated.meta_hash, "new");
1928 assert_eq!(updated.art_hash, "new-art");
1929 assert_eq!(&fs.read_file("o.mp3").unwrap()[..3], b"ID3");
1930 }
1931
1932 #[test]
1933 fn rename_moves_file_and_updates_manifest_path() {
1934 let c = clip("p");
1935 let mut d = desired(c.clone(), AudioFormat::Mp3);
1936 d.path = "new/p.mp3".to_owned();
1937 let fs = MemFs::new().with_file("old/p.mp3", b"DATA".to_vec());
1938 let mut manifest = Manifest::new();
1939 manifest.insert("p", entry("old/p.mp3", AudioFormat::Mp3));
1940 let plan = Plan {
1941 actions: vec![Action::Rename {
1942 from: "old/p.mp3".to_owned(),
1943 to: "new/p.mp3".to_owned(),
1944 }],
1945 };
1946
1947 let outcome = run(
1948 &plan,
1949 &mut manifest,
1950 &[d],
1951 &ScriptedHttp::new(),
1952 &fs,
1953 &StubFfmpeg::flac(),
1954 &RecordingClock::new(),
1955 &ExecOptions::default(),
1956 );
1957
1958 assert_eq!(outcome.renamed, 1);
1959 assert!(fs.exists("new/p.mp3"));
1960 assert!(!fs.exists("old/p.mp3"));
1961 assert_eq!(manifest.get("p").unwrap().path, "new/p.mp3");
1962 }
1963
1964 #[test]
1965 fn delete_removes_file_and_manifest_entry() {
1966 let fs = MemFs::new().with_file("q.mp3", b"DATA".to_vec());
1967 let mut manifest = Manifest::new();
1968 manifest.insert("q", entry("q.mp3", AudioFormat::Mp3));
1969 let plan = Plan {
1970 actions: vec![Action::Delete {
1971 path: "q.mp3".to_owned(),
1972 clip_id: "q".to_owned(),
1973 }],
1974 };
1975
1976 let outcome = run(
1977 &plan,
1978 &mut manifest,
1979 &[],
1980 &ScriptedHttp::new(),
1981 &fs,
1982 &StubFfmpeg::flac(),
1983 &RecordingClock::new(),
1984 &ExecOptions::default(),
1985 );
1986
1987 assert_eq!(outcome.deleted, 1);
1988 assert!(!fs.exists("q.mp3"));
1989 assert!(manifest.get("q").is_none());
1990 }
1991
1992 #[test]
1993 fn failed_delete_keeps_the_manifest_entry() {
1994 let fs = MemFs::new()
1995 .with_file("s.mp3", b"DATA".to_vec())
1996 .fail_remove("s.mp3");
1997 let mut manifest = Manifest::new();
1998 manifest.insert("s", entry("s.mp3", AudioFormat::Mp3));
1999 let plan = Plan {
2000 actions: vec![Action::Delete {
2001 path: "s.mp3".to_owned(),
2002 clip_id: "s".to_owned(),
2003 }],
2004 };
2005
2006 let outcome = run(
2007 &plan,
2008 &mut manifest,
2009 &[],
2010 &ScriptedHttp::new(),
2011 &fs,
2012 &StubFfmpeg::flac(),
2013 &RecordingClock::new(),
2014 &ExecOptions::default(),
2015 );
2016
2017 assert_eq!(outcome.deleted, 0);
2018 assert_eq!(outcome.failed(), 1);
2019 assert!(manifest.get("s").is_some());
2020 assert!(fs.exists("s.mp3"));
2021 }
2022
2023 #[test]
2024 fn skip_is_a_noop() {
2025 let mut manifest = Manifest::new();
2026 let plan = Plan {
2027 actions: vec![Action::Skip {
2028 clip_id: "r".to_owned(),
2029 }],
2030 };
2031 let outcome = run(
2032 &plan,
2033 &mut manifest,
2034 &[],
2035 &ScriptedHttp::new(),
2036 &MemFs::new(),
2037 &StubFfmpeg::flac(),
2038 &RecordingClock::new(),
2039 &ExecOptions::default(),
2040 );
2041 assert_eq!(outcome.skipped, 1);
2042 assert_eq!(outcome.failed(), 0);
2043 }
2044
2045 #[test]
2048 fn header_helpers_parse_or_ignore() {
2049 let resp = HttpResponse {
2050 status: 200,
2051 headers: vec![("Content-Length".to_owned(), "42".to_owned())],
2052 body: Vec::new(),
2053 };
2054 assert_eq!(content_length(&resp), Some(42));
2055
2056 let bare = HttpResponse {
2057 status: 200,
2058 headers: Vec::new(),
2059 body: Vec::new(),
2060 };
2061 assert_eq!(content_length(&bare), None);
2062 }
2063
2064 #[test]
2065 fn preserve_rule_covers_copy_and_private() {
2066 let base = desired(clip("x"), AudioFormat::Mp3);
2067 assert!(!preserve_for(&base));
2068 let mut copy_held = base.clone();
2069 copy_held.modes = vec![SourceMode::Copy];
2070 assert!(preserve_for(©_held));
2071 let mut private = base.clone();
2072 private.private = true;
2073 assert!(preserve_for(&private));
2074 }
2075
2076 fn fs_new() -> MemFs {
2077 MemFs::new()
2078 }
2079
2080 #[test]
2083 fn skip_sets_preserve_when_a_clip_becomes_copy_held() {
2084 let c = clip("s1");
2085 let mut d = desired(c.clone(), AudioFormat::Mp3);
2086 d.modes = vec![SourceMode::Copy];
2087 let plan = Plan {
2088 actions: vec![Action::Skip {
2089 clip_id: "s1".to_owned(),
2090 }],
2091 };
2092 let mut manifest = Manifest::new();
2093 manifest.insert("s1".to_owned(), entry("s1.mp3", AudioFormat::Mp3));
2094 assert!(!manifest.get("s1").unwrap().preserve);
2095
2096 let outcome = run(
2097 &plan,
2098 &mut manifest,
2099 &[d],
2100 &ScriptedHttp::new(),
2101 &fs_new(),
2102 &StubFfmpeg::flac(),
2103 &RecordingClock::new(),
2104 &ExecOptions::default(),
2105 );
2106
2107 assert_eq!(outcome.skipped, 1);
2108 assert!(
2109 manifest.get("s1").unwrap().preserve,
2110 "a copy-held skip must mark the entry preserved"
2111 );
2112 }
2113
2114 #[test]
2115 fn skip_clears_stale_preserve_when_a_clip_returns_to_mirror_only() {
2116 let c = clip("s2");
2117 let d = desired(c.clone(), AudioFormat::Mp3);
2118 let plan = Plan {
2119 actions: vec![Action::Skip {
2120 clip_id: "s2".to_owned(),
2121 }],
2122 };
2123 let mut manifest = Manifest::new();
2124 let mut stale = entry("s2.mp3", AudioFormat::Mp3);
2125 stale.preserve = true;
2126 manifest.insert("s2".to_owned(), stale);
2127
2128 run(
2129 &plan,
2130 &mut manifest,
2131 &[d],
2132 &ScriptedHttp::new(),
2133 &fs_new(),
2134 &StubFfmpeg::flac(),
2135 &RecordingClock::new(),
2136 &ExecOptions::default(),
2137 );
2138
2139 assert!(
2140 !manifest.get("s2").unwrap().preserve,
2141 "a mirror-only skip must clear a stale preserve marker"
2142 );
2143 }
2144
2145 #[test]
2146 fn flac_render_retries_a_rate_limited_wav_lookup() {
2147 let c = clip("rl");
2148 let d = desired(c.clone(), AudioFormat::Flac);
2149 let plan = Plan {
2150 actions: vec![Action::Download {
2151 clip: c.clone(),
2152 lineage: LineageContext::own_root(&c),
2153 path: d.path.clone(),
2154 format: AudioFormat::Flac,
2155 }],
2156 };
2157 let http = ScriptedHttp::new()
2158 .with_auth()
2159 .route_seq(
2160 "/wav_file/",
2161 vec![
2162 Reply::status(429),
2163 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/rl.wav"}"#),
2164 ],
2165 )
2166 .route("rl.wav", Reply::ok(b"wav".to_vec()));
2167 let clock = RecordingClock::new();
2168 let mut manifest = Manifest::new();
2169
2170 let outcome = run(
2171 &plan,
2172 &mut manifest,
2173 &[d],
2174 &http,
2175 &fs_new(),
2176 &StubFfmpeg::flac(),
2177 &clock,
2178 &small_poll(),
2179 );
2180
2181 assert_eq!(outcome.downloaded, 1);
2182 assert_eq!(outcome.failed(), 0);
2183 assert_eq!(http.count("/convert_wav/"), 0);
2185 assert_eq!(clock.sleeps(), vec![Duration::from_secs(1)]);
2187 }
2188
2189 #[test]
2192 fn write_artifact_fetches_writes_and_updates_manifest() {
2193 let mut manifest = Manifest::new();
2196 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
2197 let plan = Plan {
2198 actions: vec![Action::WriteArtifact {
2199 kind: ArtifactKind::CoverJpg,
2200 path: "a/cover.jpg".to_owned(),
2201 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
2202 hash: "h1".to_owned(),
2203 owner_id: "a".to_owned(),
2204 content: None,
2205 }],
2206 };
2207 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"jpg-bytes".to_vec()));
2208 let fs = MemFs::new();
2209
2210 let outcome = run(
2211 &plan,
2212 &mut manifest,
2213 &[],
2214 &http,
2215 &fs,
2216 &StubFfmpeg::flac(),
2217 &RecordingClock::new(),
2218 &ExecOptions::default(),
2219 );
2220
2221 assert_eq!(outcome.artifacts_written, 1);
2222 assert_eq!(outcome.failed(), 0);
2223 assert_eq!(outcome.status, RunStatus::Completed);
2224 assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"jpg-bytes");
2225 assert_eq!(
2226 manifest.get("a").unwrap().cover_jpg,
2227 Some(ArtifactState {
2228 path: "a/cover.jpg".to_owned(),
2229 hash: "h1".to_owned(),
2230 })
2231 );
2232 }
2233
2234 #[test]
2235 fn delete_artifact_removes_file_and_clears_slot() {
2236 let fs = MemFs::new().with_file("a/cover.jpg", b"jpg".to_vec());
2237 let mut manifest = Manifest::new();
2238 let mut e = entry("a.mp3", AudioFormat::Mp3);
2239 e.cover_jpg = Some(ArtifactState {
2240 path: "a/cover.jpg".to_owned(),
2241 hash: "h1".to_owned(),
2242 });
2243 manifest.insert("a", e);
2244 let plan = Plan {
2245 actions: vec![Action::DeleteArtifact {
2246 kind: ArtifactKind::CoverJpg,
2247 path: "a/cover.jpg".to_owned(),
2248 owner_id: "a".to_owned(),
2249 }],
2250 };
2251
2252 let outcome = run(
2253 &plan,
2254 &mut manifest,
2255 &[],
2256 &ScriptedHttp::new(),
2257 &fs,
2258 &StubFfmpeg::flac(),
2259 &RecordingClock::new(),
2260 &ExecOptions::default(),
2261 );
2262
2263 assert_eq!(outcome.artifacts_deleted, 1);
2264 assert!(!fs.exists("a/cover.jpg"));
2265 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
2266 }
2267
2268 #[test]
2269 fn delete_artifact_tolerates_already_absent_file() {
2270 let mut manifest = Manifest::new();
2273 let mut e = entry("a.mp3", AudioFormat::Mp3);
2274 e.cover_jpg = Some(ArtifactState {
2275 path: "a/cover.jpg".to_owned(),
2276 hash: "h1".to_owned(),
2277 });
2278 manifest.insert("a", e);
2279 let plan = Plan {
2280 actions: vec![Action::DeleteArtifact {
2281 kind: ArtifactKind::CoverJpg,
2282 path: "a/cover.jpg".to_owned(),
2283 owner_id: "a".to_owned(),
2284 }],
2285 };
2286
2287 let outcome = run(
2288 &plan,
2289 &mut manifest,
2290 &[],
2291 &ScriptedHttp::new(),
2292 &MemFs::new(),
2293 &StubFfmpeg::flac(),
2294 &RecordingClock::new(),
2295 &ExecOptions::default(),
2296 );
2297
2298 assert_eq!(outcome.artifacts_deleted, 1);
2299 assert_eq!(outcome.failed(), 0);
2300 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
2301 }
2302
2303 #[test]
2304 fn write_artifact_http_failure_is_a_per_clip_failure_not_a_run_abort() {
2305 let mut manifest = Manifest::new();
2308 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
2309 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
2310 let plan = Plan {
2311 actions: vec![
2312 Action::WriteArtifact {
2313 kind: ArtifactKind::CoverJpg,
2314 path: "a/cover.jpg".to_owned(),
2315 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
2316 hash: "h1".to_owned(),
2317 owner_id: "a".to_owned(),
2318 content: None,
2319 },
2320 Action::WriteArtifact {
2321 kind: ArtifactKind::CoverJpg,
2322 path: "b/cover.jpg".to_owned(),
2323 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
2324 hash: "h2".to_owned(),
2325 owner_id: "b".to_owned(),
2326 content: None,
2327 },
2328 ],
2329 };
2330 let http = ScriptedHttp::new()
2331 .route("a/large.jpg", Reply::status(404))
2332 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
2333 let fs = MemFs::new();
2334
2335 let outcome = run(
2336 &plan,
2337 &mut manifest,
2338 &[],
2339 &http,
2340 &fs,
2341 &StubFfmpeg::flac(),
2342 &RecordingClock::new(),
2343 &ExecOptions::default(),
2344 );
2345
2346 assert_eq!(outcome.status, RunStatus::Completed);
2347 assert_eq!(outcome.failed(), 1);
2348 assert_eq!(outcome.failures[0].clip_id, "a");
2349 assert_eq!(outcome.artifacts_written, 1);
2350 assert!(!fs.exists("a/cover.jpg"));
2352 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
2353 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
2355 assert!(manifest.get("b").unwrap().cover_jpg.is_some());
2356 }
2357
2358 #[test]
2359 fn co_delete_executes_audio_delete_then_artifact_delete() {
2360 let fs = MemFs::new()
2364 .with_file("gone.mp3", b"DATA".to_vec())
2365 .with_file("gone/cover.jpg", b"jpg".to_vec());
2366 let mut manifest = Manifest::new();
2367 let mut e = entry("gone.mp3", AudioFormat::Mp3);
2368 e.cover_jpg = Some(ArtifactState {
2369 path: "gone/cover.jpg".to_owned(),
2370 hash: "h1".to_owned(),
2371 });
2372 manifest.insert("gone", e);
2373 let plan = Plan {
2374 actions: vec![
2375 Action::Delete {
2376 path: "gone.mp3".to_owned(),
2377 clip_id: "gone".to_owned(),
2378 },
2379 Action::DeleteArtifact {
2380 kind: ArtifactKind::CoverJpg,
2381 path: "gone/cover.jpg".to_owned(),
2382 owner_id: "gone".to_owned(),
2383 },
2384 ],
2385 };
2386
2387 let outcome = run(
2388 &plan,
2389 &mut manifest,
2390 &[],
2391 &ScriptedHttp::new(),
2392 &fs,
2393 &StubFfmpeg::flac(),
2394 &RecordingClock::new(),
2395 &ExecOptions::default(),
2396 );
2397
2398 assert_eq!(outcome.deleted, 1);
2399 assert_eq!(outcome.artifacts_deleted, 1);
2400 assert_eq!(outcome.failed(), 0);
2401 assert!(!fs.exists("gone.mp3"));
2402 assert!(!fs.exists("gone/cover.jpg"));
2403 assert!(manifest.get("gone").is_none());
2404 }
2405
2406 #[test]
2407 fn write_artifact_is_skipped_when_the_owner_audio_is_absent() {
2408 let ca = clip("a");
2412 let plan = Plan {
2413 actions: vec![
2414 Action::Download {
2415 clip: ca.clone(),
2416 lineage: LineageContext::own_root(&ca),
2417 path: "a.mp3".to_owned(),
2418 format: AudioFormat::Mp3,
2419 },
2420 Action::WriteArtifact {
2421 kind: ArtifactKind::CoverJpg,
2422 path: "a/cover.jpg".to_owned(),
2423 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
2424 hash: "h1".to_owned(),
2425 owner_id: "a".to_owned(),
2426 content: None,
2427 },
2428 Action::WriteArtifact {
2429 kind: ArtifactKind::CoverJpg,
2430 path: "b/cover.jpg".to_owned(),
2431 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
2432 hash: "h2".to_owned(),
2433 owner_id: "b".to_owned(),
2434 content: None,
2435 },
2436 ],
2437 };
2438 let http = ScriptedHttp::new()
2440 .route("a.mp3", Reply::status(404))
2441 .route("a/large.jpg", Reply::ok(b"jpg-a".to_vec()))
2442 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
2443 let fs = MemFs::new();
2444 let mut manifest = Manifest::new();
2445 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
2447
2448 let outcome = run(
2449 &plan,
2450 &mut manifest,
2451 &[],
2452 &http,
2453 &fs,
2454 &StubFfmpeg::flac(),
2455 &RecordingClock::new(),
2456 &ExecOptions::default(),
2457 );
2458
2459 assert_eq!(outcome.status, RunStatus::Completed);
2460 assert_eq!(outcome.failed(), 1);
2462 assert_eq!(outcome.failures[0].clip_id, "a");
2463 assert_eq!(outcome.skipped, 1);
2464 assert_eq!(http.count("a/large.jpg"), 0);
2466 assert!(!fs.exists("a/cover.jpg"));
2467 assert!(manifest.get("a").is_none());
2468 assert_eq!(outcome.artifacts_written, 1);
2470 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
2471 assert!(manifest.get("b").unwrap().cover_jpg.is_some());
2472 }
2473
2474 #[test]
2475 fn write_artifact_transcodes_animated_cover_to_webp() {
2476 let mut manifest = Manifest::new();
2480 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
2481 let plan = Plan {
2482 actions: vec![Action::WriteArtifact {
2483 kind: ArtifactKind::CoverWebp,
2484 path: "a/cover.webp".to_owned(),
2485 source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
2486 hash: "v1".to_owned(),
2487 owner_id: "a".to_owned(),
2488 content: None,
2489 }],
2490 };
2491 let http = ScriptedHttp::new().route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
2492 let fs = MemFs::new();
2493 let ffmpeg = StubFfmpeg::webp();
2494
2495 let outcome = run(
2496 &plan,
2497 &mut manifest,
2498 &[],
2499 &http,
2500 &fs,
2501 &ffmpeg,
2502 &RecordingClock::new(),
2503 &ExecOptions::default(),
2504 );
2505
2506 assert_eq!(outcome.artifacts_written, 1);
2507 assert_eq!(outcome.failed(), 0);
2508 assert_eq!(outcome.status, RunStatus::Completed);
2509 assert_eq!(http.count("a/video.mp4"), 1);
2511 let written = fs.read_file("a/cover.webp").unwrap();
2512 assert_ne!(written, b"mp4-bytes");
2513 assert!(written.starts_with(b"RIFF"));
2514 assert_eq!(
2515 manifest.get("a").unwrap().cover_webp,
2516 Some(ArtifactState {
2517 path: "a/cover.webp".to_owned(),
2518 hash: "v1".to_owned(),
2519 })
2520 );
2521 }
2522
2523 #[test]
2524 fn write_artifact_webp_transcode_failure_is_per_clip() {
2525 let mut manifest = Manifest::new();
2529 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
2530 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
2531 let plan = Plan {
2532 actions: vec![
2533 Action::WriteArtifact {
2534 kind: ArtifactKind::CoverWebp,
2535 path: "a/cover.webp".to_owned(),
2536 source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
2537 hash: "v1".to_owned(),
2538 owner_id: "a".to_owned(),
2539 content: None,
2540 },
2541 Action::WriteArtifact {
2542 kind: ArtifactKind::CoverJpg,
2543 path: "b/cover.jpg".to_owned(),
2544 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
2545 hash: "h1".to_owned(),
2546 owner_id: "b".to_owned(),
2547 content: None,
2548 },
2549 ],
2550 };
2551 let http = ScriptedHttp::new()
2552 .route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec()))
2553 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
2554 let fs = MemFs::new();
2555
2556 let outcome = run(
2557 &plan,
2558 &mut manifest,
2559 &[],
2560 &http,
2561 &fs,
2562 &StubFfmpeg::failing(),
2563 &RecordingClock::new(),
2564 &ExecOptions::default(),
2565 );
2566
2567 assert_eq!(outcome.status, RunStatus::Completed);
2568 assert_eq!(outcome.failed(), 1);
2569 assert_eq!(outcome.failures[0].clip_id, "a");
2570 assert!(!fs.exists("a/cover.webp"));
2572 assert_eq!(manifest.get("a").unwrap().cover_webp, None);
2573 assert_eq!(outcome.artifacts_written, 1);
2575 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
2576 assert!(manifest.get("b").unwrap().cover_jpg.is_some());
2577 }
2578
2579 #[test]
2582 fn folder_jpg_write_records_album_state_and_skips_manifest() {
2583 let mut manifest = Manifest::new();
2586 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
2587 let plan = Plan {
2588 actions: vec![Action::WriteArtifact {
2589 kind: ArtifactKind::FolderJpg,
2590 path: "creator/album/folder.jpg".to_owned(),
2591 source_url: "https://art.suno.ai/root/large.jpg".to_owned(),
2592 hash: "jh".to_owned(),
2593 owner_id: "root".to_owned(),
2594 content: None,
2595 }],
2596 };
2597 let http = ScriptedHttp::new().route("root/large.jpg", Reply::ok(b"folder-jpg".to_vec()));
2598 let fs = MemFs::new();
2599
2600 let outcome = run_with_albums(
2601 &plan,
2602 &mut manifest,
2603 &mut albums,
2604 &[],
2605 &http,
2606 &fs,
2607 &StubFfmpeg::flac(),
2608 &RecordingClock::new(),
2609 &ExecOptions::default(),
2610 );
2611
2612 assert_eq!(outcome.artifacts_written, 1);
2613 assert_eq!(outcome.status, RunStatus::Completed);
2614 assert_eq!(
2615 fs.read_file("creator/album/folder.jpg").unwrap(),
2616 b"folder-jpg"
2617 );
2618 assert_eq!(
2619 albums.get("root").unwrap().folder_jpg,
2620 Some(ArtifactState {
2621 path: "creator/album/folder.jpg".to_owned(),
2622 hash: "jh".to_owned(),
2623 })
2624 );
2625 assert!(manifest.get("root").is_none());
2626 }
2627
2628 #[test]
2629 fn folder_webp_write_transcodes_and_records_album_state() {
2630 let mut manifest = Manifest::new();
2631 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
2632 let plan = Plan {
2633 actions: vec![Action::WriteArtifact {
2634 kind: ArtifactKind::FolderWebp,
2635 path: "creator/album/cover.webp".to_owned(),
2636 source_url: "https://cdn.suno.ai/root/video.mp4".to_owned(),
2637 hash: "wh".to_owned(),
2638 owner_id: "root".to_owned(),
2639 content: None,
2640 }],
2641 };
2642 let http = ScriptedHttp::new().route("root/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
2643 let fs = MemFs::new();
2644
2645 let outcome = run_with_albums(
2646 &plan,
2647 &mut manifest,
2648 &mut albums,
2649 &[],
2650 &http,
2651 &fs,
2652 &StubFfmpeg::webp(),
2653 &RecordingClock::new(),
2654 &ExecOptions::default(),
2655 );
2656
2657 assert_eq!(outcome.artifacts_written, 1);
2658 assert_eq!(outcome.failed(), 0);
2659 let written = fs.read_file("creator/album/cover.webp").unwrap();
2661 assert_ne!(written, b"mp4-bytes");
2662 assert!(written.starts_with(b"RIFF"));
2663 assert_eq!(
2664 albums.get("root").unwrap().folder_webp,
2665 Some(ArtifactState {
2666 path: "creator/album/cover.webp".to_owned(),
2667 hash: "wh".to_owned(),
2668 })
2669 );
2670 }
2671
2672 #[test]
2673 fn folder_art_delete_clears_album_state() {
2674 let fs = MemFs::new().with_file("creator/album/folder.jpg", b"jpg".to_vec());
2675 let mut manifest = Manifest::new();
2676 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
2677 albums.insert(
2678 "root".to_owned(),
2679 AlbumArt {
2680 folder_jpg: Some(ArtifactState {
2681 path: "creator/album/folder.jpg".to_owned(),
2682 hash: "jh".to_owned(),
2683 }),
2684 folder_webp: None,
2685 },
2686 );
2687 let plan = Plan {
2688 actions: vec![Action::DeleteArtifact {
2689 kind: ArtifactKind::FolderJpg,
2690 path: "creator/album/folder.jpg".to_owned(),
2691 owner_id: "root".to_owned(),
2692 }],
2693 };
2694
2695 let outcome = run_with_albums(
2696 &plan,
2697 &mut manifest,
2698 &mut albums,
2699 &[],
2700 &ScriptedHttp::new(),
2701 &fs,
2702 &StubFfmpeg::flac(),
2703 &RecordingClock::new(),
2704 &ExecOptions::default(),
2705 );
2706
2707 assert_eq!(outcome.artifacts_deleted, 1);
2708 assert!(!fs.exists("creator/album/folder.jpg"));
2709 assert!(!albums.contains_key("root"));
2711 }
2712
2713 #[test]
2716 fn playlist_write_uses_inline_content_and_records_state() {
2717 let mut manifest = Manifest::new();
2721 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
2722 let mut playlists: BTreeMap<String, PlaylistState> = BTreeMap::new();
2723 let body = "#EXTM3U\n#PLAYLIST:Road Trip\n#EXTINF:60,One\nA/One.flac\n";
2724 let plan = Plan {
2725 actions: vec![Action::WriteArtifact {
2726 kind: ArtifactKind::Playlist,
2727 path: "Road Trip.m3u8".to_owned(),
2728 source_url: String::new(),
2729 hash: "ph1".to_owned(),
2730 owner_id: "pl1".to_owned(),
2731 content: Some(body.to_owned()),
2732 }],
2733 };
2734 let fs = MemFs::new();
2735
2736 let outcome = run_full(
2737 &plan,
2738 &mut manifest,
2739 &mut albums,
2740 &mut playlists,
2741 &[],
2742 &ScriptedHttp::new(),
2743 &fs,
2744 &StubFfmpeg::flac(),
2745 &RecordingClock::new(),
2746 &ExecOptions::default(),
2747 );
2748
2749 assert_eq!(outcome.artifacts_written, 1);
2750 assert_eq!(outcome.failed(), 0);
2751 assert_eq!(fs.read_file("Road Trip.m3u8").unwrap(), body.as_bytes());
2753 assert_eq!(
2754 playlists.get("pl1"),
2755 Some(&PlaylistState {
2756 name: "Road Trip".to_owned(),
2757 path: "Road Trip.m3u8".to_owned(),
2758 hash: "ph1".to_owned(),
2759 })
2760 );
2761 }
2762
2763 #[test]
2764 fn playlist_delete_removes_file_and_clears_state() {
2765 let fs = MemFs::new().with_file("Old.m3u8", b"#EXTM3U\n".to_vec());
2766 let mut manifest = Manifest::new();
2767 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
2768 let mut playlists: BTreeMap<String, PlaylistState> = BTreeMap::new();
2769 playlists.insert(
2770 "pl1".to_owned(),
2771 PlaylistState {
2772 name: "Old".to_owned(),
2773 path: "Old.m3u8".to_owned(),
2774 hash: "ph1".to_owned(),
2775 },
2776 );
2777 let plan = Plan {
2778 actions: vec![Action::DeleteArtifact {
2779 kind: ArtifactKind::Playlist,
2780 path: "Old.m3u8".to_owned(),
2781 owner_id: "pl1".to_owned(),
2782 }],
2783 };
2784
2785 let outcome = run_full(
2786 &plan,
2787 &mut manifest,
2788 &mut albums,
2789 &mut playlists,
2790 &[],
2791 &ScriptedHttp::new(),
2792 &fs,
2793 &StubFfmpeg::flac(),
2794 &RecordingClock::new(),
2795 &ExecOptions::default(),
2796 );
2797
2798 assert_eq!(outcome.artifacts_deleted, 1);
2799 assert!(!fs.exists("Old.m3u8"));
2800 assert!(
2801 !playlists.contains_key("pl1"),
2802 "the playlist row is cleared on delete"
2803 );
2804 }
2805
2806 #[test]
2809 fn rename_move_relocates_cover_and_prunes_old_album() {
2810 let mut manifest = Manifest::new();
2814 let mut e = entry("Creator/AlbumA/song.flac", AudioFormat::Flac);
2815 e.cover_jpg = Some(ArtifactState {
2816 path: "Creator/AlbumA/cover.jpg".to_owned(),
2817 hash: "h1".to_owned(),
2818 });
2819 manifest.insert("a", e);
2820 let fs = MemFs::new()
2821 .with_file("Creator/AlbumA/song.flac", b"AUDIO".to_vec())
2822 .with_file("Creator/AlbumA/cover.jpg", b"old-jpg".to_vec());
2823 let plan = Plan {
2824 actions: vec![
2825 Action::Rename {
2826 from: "Creator/AlbumA/song.flac".to_owned(),
2827 to: "Creator/AlbumB/song.flac".to_owned(),
2828 },
2829 Action::WriteArtifact {
2830 kind: ArtifactKind::CoverJpg,
2831 path: "Creator/AlbumB/cover.jpg".to_owned(),
2832 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
2833 hash: "h1".to_owned(),
2834 owner_id: "a".to_owned(),
2835 content: None,
2836 },
2837 ],
2838 };
2839 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new-jpg".to_vec()));
2840
2841 let outcome = run(
2842 &plan,
2843 &mut manifest,
2844 &[],
2845 &http,
2846 &fs,
2847 &StubFfmpeg::flac(),
2848 &RecordingClock::new(),
2849 &ExecOptions::default(),
2850 );
2851
2852 assert_eq!(outcome.failed(), 0);
2853 assert!(fs.exists("Creator/AlbumB/song.flac"));
2855 assert_eq!(
2856 fs.read_file("Creator/AlbumB/cover.jpg").unwrap(),
2857 b"new-jpg"
2858 );
2859 assert!(!fs.exists("Creator/AlbumA/cover.jpg"));
2860 assert!(!fs.exists("Creator/AlbumA/song.flac"));
2861 assert_eq!(
2863 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
2864 "Creator/AlbumB/cover.jpg"
2865 );
2866 assert!(!fs.has_dir("Creator/AlbumA"));
2868 assert!(fs.has_dir("Creator/AlbumB"));
2869 }
2870
2871 #[test]
2872 fn rename_move_relocates_folder_art_and_prunes_old_album() {
2873 let mut manifest = Manifest::new();
2876 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
2877 albums.insert(
2878 "root".to_owned(),
2879 AlbumArt {
2880 folder_jpg: Some(ArtifactState {
2881 path: "Creator/AlbumA/folder.jpg".to_owned(),
2882 hash: "jh".to_owned(),
2883 }),
2884 folder_webp: None,
2885 },
2886 );
2887 let fs = MemFs::new().with_file("Creator/AlbumA/folder.jpg", b"old-folder".to_vec());
2888 let plan = Plan {
2889 actions: vec![Action::WriteArtifact {
2890 kind: ArtifactKind::FolderJpg,
2891 path: "Creator/AlbumB/folder.jpg".to_owned(),
2892 source_url: "https://art.suno.ai/root/large.jpg".to_owned(),
2893 hash: "jh".to_owned(),
2894 owner_id: "root".to_owned(),
2895 content: None,
2896 }],
2897 };
2898 let http = ScriptedHttp::new().route("root/large.jpg", Reply::ok(b"new-folder".to_vec()));
2899
2900 let outcome = run_with_albums(
2901 &plan,
2902 &mut manifest,
2903 &mut albums,
2904 &[],
2905 &http,
2906 &fs,
2907 &StubFfmpeg::flac(),
2908 &RecordingClock::new(),
2909 &ExecOptions::default(),
2910 );
2911
2912 assert_eq!(outcome.failed(), 0);
2913 assert_eq!(
2914 fs.read_file("Creator/AlbumB/folder.jpg").unwrap(),
2915 b"new-folder"
2916 );
2917 assert!(!fs.exists("Creator/AlbumA/folder.jpg"));
2918 assert_eq!(
2919 albums
2920 .get("root")
2921 .unwrap()
2922 .folder_jpg
2923 .as_ref()
2924 .unwrap()
2925 .path,
2926 "Creator/AlbumB/folder.jpg"
2927 );
2928 assert!(!fs.has_dir("Creator/AlbumA"));
2929 assert!(fs.has_dir("Creator/AlbumB"));
2930 }
2931
2932 #[test]
2933 fn prune_empty_dirs_removes_only_empty_dirs() {
2934 let fs = MemFs::new()
2938 .with_file("keep/full/song.flac", b"x".to_vec())
2939 .with_file("hidden/.suno-manifest.json", b"{}".to_vec())
2940 .with_dir("empty/leaf")
2941 .with_dir("nested/a/b/c");
2942
2943 fs.prune_empty_dirs("").unwrap();
2944
2945 for gone in [
2947 "empty",
2948 "empty/leaf",
2949 "nested",
2950 "nested/a",
2951 "nested/a/b",
2952 "nested/a/b/c",
2953 ] {
2954 assert!(!fs.has_dir(gone), "empty dir {gone} should be pruned");
2955 }
2956 assert!(fs.has_dir("keep"));
2958 assert!(fs.has_dir("keep/full"));
2959 assert!(fs.has_dir("hidden"));
2960 assert!(fs.exists("keep/full/song.flac"));
2962 assert!(fs.exists("hidden/.suno-manifest.json"));
2963 }
2964
2965 #[test]
2966 fn prune_empty_dirs_never_removes_the_named_root() {
2967 let fs = MemFs::new().with_dir("empty/leaf");
2970 fs.prune_empty_dirs("empty").unwrap();
2971 assert!(fs.has_dir("empty"), "the named root is never removed");
2972 assert!(!fs.has_dir("empty/leaf"));
2973 }
2974
2975 #[test]
2976 fn old_sidecar_remove_failure_is_per_clip_and_converges_next_run() {
2977 let mut manifest = Manifest::new();
2981 let mut e = entry("a.flac", AudioFormat::Flac);
2982 e.cover_jpg = Some(ArtifactState {
2983 path: "AlbumA/cover.jpg".to_owned(),
2984 hash: "h1".to_owned(),
2985 });
2986 manifest.insert("a", e);
2987 let fs = MemFs::new()
2988 .with_file("a.flac", b"AUDIO".to_vec())
2989 .with_file("AlbumA/cover.jpg", b"old".to_vec());
2990 let plan = Plan {
2991 actions: vec![Action::WriteArtifact {
2992 kind: ArtifactKind::CoverJpg,
2993 path: "AlbumB/cover.jpg".to_owned(),
2994 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
2995 hash: "h1".to_owned(),
2996 owner_id: "a".to_owned(),
2997 content: None,
2998 }],
2999 };
3000 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new".to_vec()));
3001
3002 fs.arm_fail_remove("AlbumA/cover.jpg");
3004 let first = run(
3005 &plan,
3006 &mut manifest,
3007 &[],
3008 &http,
3009 &fs,
3010 &StubFfmpeg::flac(),
3011 &RecordingClock::new(),
3012 &ExecOptions::default(),
3013 );
3014 assert_eq!(
3015 first.status,
3016 RunStatus::Completed,
3017 "a remove failure never aborts the run"
3018 );
3019 assert_eq!(first.failed(), 1);
3020 assert!(fs.exists("AlbumB/cover.jpg"));
3022 assert!(fs.exists("AlbumA/cover.jpg"));
3023 assert_eq!(
3024 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
3025 "AlbumA/cover.jpg"
3026 );
3027 assert!(fs.has_dir("AlbumA"), "the orphan keeps its directory alive");
3028
3029 fs.disarm_fail_remove("AlbumA/cover.jpg");
3031 let second = run(
3032 &plan,
3033 &mut manifest,
3034 &[],
3035 &http,
3036 &fs,
3037 &StubFfmpeg::flac(),
3038 &RecordingClock::new(),
3039 &ExecOptions::default(),
3040 );
3041 assert_eq!(second.failed(), 0);
3042 assert!(fs.exists("AlbumB/cover.jpg"));
3043 assert!(!fs.exists("AlbumA/cover.jpg"), "no orphan persists");
3044 assert_eq!(
3045 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
3046 "AlbumB/cover.jpg"
3047 );
3048 assert!(!fs.has_dir("AlbumA"), "the emptied directory is pruned");
3049 }
3050
3051 #[test]
3052 fn same_path_artifact_rewrite_does_no_remove_and_prunes_nothing() {
3053 let mut manifest = Manifest::new();
3058 let mut e = entry("Album/a.mp3", AudioFormat::Mp3);
3059 e.cover_jpg = Some(ArtifactState {
3060 path: "Album/cover.jpg".to_owned(),
3061 hash: "h1".to_owned(),
3062 });
3063 manifest.insert("a", e);
3064 let fs = MemFs::new()
3065 .with_file("Album/a.mp3", b"AUDIO".to_vec())
3066 .with_file("Album/cover.jpg", b"old".to_vec());
3067 fs.arm_fail_remove("Album/cover.jpg");
3068 let plan = Plan {
3069 actions: vec![Action::WriteArtifact {
3070 kind: ArtifactKind::CoverJpg,
3071 path: "Album/cover.jpg".to_owned(),
3072 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
3073 hash: "h2".to_owned(),
3074 owner_id: "a".to_owned(),
3075 content: None,
3076 }],
3077 };
3078 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new".to_vec()));
3079
3080 let outcome = run(
3081 &plan,
3082 &mut manifest,
3083 &[],
3084 &http,
3085 &fs,
3086 &StubFfmpeg::flac(),
3087 &RecordingClock::new(),
3088 &ExecOptions::default(),
3089 );
3090
3091 assert_eq!(
3092 outcome.failed(),
3093 0,
3094 "no remove is attempted, so the armed failure never fires"
3095 );
3096 assert_eq!(outcome.artifacts_written, 1);
3097 assert_eq!(fs.read_file("Album/cover.jpg").unwrap(), b"new");
3098 assert_eq!(
3099 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().hash,
3100 "h2"
3101 );
3102 assert!(fs.has_dir("Album"));
3104 }
3105}