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