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