1use std::collections::BTreeMap;
33use std::collections::BTreeSet;
34use std::collections::HashMap;
35use std::time::Duration;
36
37use crate::backoff::{backoff_delay, retry_after};
38use crate::client::SunoClient;
39use crate::clock::Clock;
40use crate::config::AudioFormat;
41use crate::error::Error;
42use crate::ffmpeg::{Ffmpeg, WebpEncodeSettings};
43use crate::fs::Filesystem;
44use crate::graph::{AlbumArt, PlaylistState};
45use crate::http::{Http, HttpRequest};
46use crate::lineage::LineageContext;
47use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
48use crate::model::Clip;
49use crate::reconcile::{Action, ArtifactKind, Desired, Plan, SourceMode, set_manifest_artifact};
50use crate::tag::{TrackMetadata, tag_flac, tag_mp3};
51
52#[derive(Debug, Clone)]
54pub struct ExecOptions {
55 pub max_retries: u32,
57 pub wav_poll_attempts: u32,
59 pub wav_poll_interval: Duration,
61}
62
63impl Default for ExecOptions {
64 fn default() -> Self {
65 Self {
66 max_retries: 3,
67 wav_poll_attempts: 24,
68 wav_poll_interval: Duration::from_secs(5),
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum RunStatus {
76 #[default]
78 Completed,
79 AuthAborted,
81 DiskFull,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct Failure {
89 pub clip_id: String,
91 pub reason: String,
93}
94
95#[derive(Debug, Clone, Default, PartialEq, Eq)]
97pub struct ExecOutcome {
98 pub downloaded: usize,
99 pub reformatted: usize,
100 pub retagged: usize,
101 pub renamed: usize,
102 pub deleted: usize,
103 pub skipped: usize,
104 pub artifacts_written: usize,
105 pub artifacts_deleted: usize,
106 pub failures: Vec<Failure>,
110 pub status: RunStatus,
112}
113
114impl ExecOutcome {
115 pub fn failed(&self) -> usize {
117 self.failures.len()
118 }
119
120 fn record(&mut self, effect: Effect) {
121 match effect {
122 Effect::Downloaded => self.downloaded += 1,
123 Effect::Reformatted => self.reformatted += 1,
124 Effect::Retagged => self.retagged += 1,
125 Effect::Renamed => self.renamed += 1,
126 Effect::Deleted => self.deleted += 1,
127 Effect::Skipped => self.skipped += 1,
128 Effect::ArtifactWritten => self.artifacts_written += 1,
129 Effect::ArtifactDeleted => self.artifacts_deleted += 1,
130 }
131 }
132}
133
134pub struct Ports<'a, H, F, G, C> {
139 pub client: &'a mut SunoClient<C>,
141 pub http: &'a H,
143 pub fs: &'a F,
145 pub ffmpeg: &'a G,
147 pub clock: &'a C,
149}
150
151pub async fn execute<H, F, G, C>(
164 plan: &Plan,
165 manifest: &mut Manifest,
166 albums: &mut BTreeMap<String, AlbumArt>,
167 playlists: &mut BTreeMap<String, PlaylistState>,
168 desired: &[Desired],
169 ports: Ports<'_, H, F, G, C>,
170 opts: &ExecOptions,
171) -> ExecOutcome
172where
173 H: Http,
174 F: Filesystem,
175 G: Ffmpeg,
176 C: Clock,
177{
178 let Ports {
179 client,
180 http,
181 fs,
182 ffmpeg,
183 clock,
184 } = ports;
185 let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
186 let by_path: HashMap<&str, &Desired> = desired.iter().map(|d| (d.path.as_str(), d)).collect();
187 let write_targets: BTreeSet<String> = plan
191 .actions
192 .iter()
193 .filter_map(|a| match a {
194 Action::Download { path, .. }
195 | Action::Reformat { path, .. }
196 | Action::WriteArtifact { path, .. } => Some(path.clone()),
197 Action::Rename { to, .. } => Some(to.clone()),
198 _ => None,
199 })
200 .collect();
201 let mut tracked_paths: HashMap<String, u32> = HashMap::new();
209 for (_, entry) in manifest.iter() {
210 for path in entry.artifact_paths() {
211 *tracked_paths.entry(path.to_owned()).or_default() += 1;
212 }
213 }
214 for art in albums.values() {
215 for state in [art.folder_jpg.as_ref(), art.folder_webp.as_ref()]
216 .into_iter()
217 .flatten()
218 {
219 *tracked_paths.entry(state.path.clone()).or_default() += 1;
220 }
221 }
222 for playlist in playlists.values() {
223 *tracked_paths.entry(playlist.path.clone()).or_default() += 1;
224 }
225 let ctx = Ctx {
226 http,
227 fs,
228 ffmpeg,
229 clock,
230 opts,
231 by_id: &by_id,
232 by_path: &by_path,
233 write_targets: &write_targets,
234 };
235
236 let mut outcome = ExecOutcome::default();
237 for action in &plan.actions {
238 match ctx
239 .apply(
240 action,
241 client,
242 manifest,
243 albums,
244 playlists,
245 &mut tracked_paths,
246 )
247 .await
248 {
249 Ok(effect) => outcome.record(effect),
250 Err(fail) => {
251 let abort = abort_status(fail.class);
252 outcome.failures.push(Failure {
253 clip_id: fail.clip_id,
254 reason: fail.reason,
255 });
256 if let Some(status) = abort {
257 outcome.status = status;
258 break;
259 }
260 }
261 }
262 }
263 let _ = fs.prune_empty_dirs("");
268 outcome
269}
270
271enum Effect {
273 Downloaded,
274 Reformatted,
275 Retagged,
276 Renamed,
277 Deleted,
278 Skipped,
279 ArtifactWritten,
280 ArtifactDeleted,
281}
282
283#[derive(Debug, Clone, Copy)]
285enum Class {
286 Auth,
288 Disk,
292 Transient,
294 Permanent,
296}
297
298struct Fail {
300 class: Class,
301 clip_id: String,
302 reason: String,
303}
304
305fn abort_status(class: Class) -> Option<RunStatus> {
308 match class {
309 Class::Auth => Some(RunStatus::AuthAborted),
310 Class::Disk => Some(RunStatus::DiskFull),
311 Class::Transient | Class::Permanent => None,
312 }
313}
314
315fn auth_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
316 Fail {
317 class: Class::Auth,
318 clip_id: clip_id.into(),
319 reason: reason.into(),
320 }
321}
322
323fn transient_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
324 Fail {
325 class: Class::Transient,
326 clip_id: clip_id.into(),
327 reason: reason.into(),
328 }
329}
330
331fn permanent_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
332 Fail {
333 class: Class::Permanent,
334 clip_id: clip_id.into(),
335 reason: reason.into(),
336 }
337}
338
339fn disk_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
340 Fail {
341 class: Class::Disk,
342 clip_id: clip_id.into(),
343 reason: reason.into(),
344 }
345}
346
347fn is_album_kind(kind: ArtifactKind) -> bool {
351 matches!(kind, ArtifactKind::FolderJpg | ArtifactKind::FolderWebp)
352}
353
354fn is_playlist_kind(kind: ArtifactKind) -> bool {
356 matches!(kind, ArtifactKind::Playlist)
357}
358
359fn is_per_clip_kind(kind: ArtifactKind) -> bool {
363 matches!(
364 kind,
365 ArtifactKind::CoverJpg
366 | ArtifactKind::CoverWebp
367 | ArtifactKind::DetailsTxt
368 | ArtifactKind::LyricsTxt
369 | ArtifactKind::Lrc
370 | ArtifactKind::VideoMp4
371 )
372}
373
374fn playlist_name_from_path(path: &str) -> String {
381 std::path::Path::new(path)
382 .file_stem()
383 .map(|stem| stem.to_string_lossy().into_owned())
384 .unwrap_or_default()
385}
386
387struct FetchError {
389 class: Class,
390 reason: String,
391 retry_after: Option<Duration>,
392}
393
394impl FetchError {
395 fn transient(reason: impl Into<String>, retry_after: Option<Duration>) -> Self {
396 Self {
397 class: Class::Transient,
398 reason: reason.into(),
399 retry_after,
400 }
401 }
402
403 fn permanent(reason: impl Into<String>) -> Self {
404 Self {
405 class: Class::Permanent,
406 reason: reason.into(),
407 retry_after: None,
408 }
409 }
410
411 fn attribute(self, clip_id: &str) -> Fail {
412 Fail {
413 class: self.class,
414 clip_id: clip_id.to_owned(),
415 reason: self.reason,
416 }
417 }
418}
419
420struct Ctx<'a, H, F, G, C> {
422 http: &'a H,
423 fs: &'a F,
424 ffmpeg: &'a G,
425 clock: &'a C,
426 opts: &'a ExecOptions,
427 by_id: &'a HashMap<&'a str, &'a Desired>,
428 by_path: &'a HashMap<&'a str, &'a Desired>,
429 write_targets: &'a BTreeSet<String>,
436}
437
438impl<H, F, G, C> Ctx<'_, H, F, G, C>
439where
440 H: Http,
441 F: Filesystem,
442 G: Ffmpeg,
443 C: Clock,
444{
445 async fn apply(
447 &self,
448 action: &Action,
449 client: &mut SunoClient<C>,
450 manifest: &mut Manifest,
451 albums: &mut BTreeMap<String, AlbumArt>,
452 playlists: &mut BTreeMap<String, PlaylistState>,
453 tracked_paths: &mut HashMap<String, u32>,
454 ) -> Result<Effect, Fail> {
455 match action {
456 Action::Download {
457 clip,
458 lineage,
459 path,
460 format,
461 } => {
462 self.download(client, manifest, clip, lineage, path, *format)
463 .await
464 }
465 Action::Reformat {
466 clip,
467 path,
468 from_path,
469 from: _,
470 to,
471 } => {
472 self.reformat(client, manifest, clip, path, from_path, *to)
473 .await
474 }
475 Action::Retag {
476 clip,
477 lineage,
478 path,
479 } => self.retag(manifest, clip, lineage, path).await,
480 Action::Rename { from, to } => self.rename(manifest, from, to),
481 Action::Delete { path, clip_id } => self.delete(manifest, path, clip_id),
482 Action::Skip { clip_id } => {
483 self.refresh_preserve(manifest, clip_id);
484 Ok(Effect::Skipped)
485 }
486 Action::WriteArtifact {
487 kind,
488 path,
489 source_url,
490 hash,
491 owner_id,
492 content,
493 } => {
494 self.write_artifact(
495 manifest,
496 albums,
497 playlists,
498 *kind,
499 path,
500 source_url,
501 hash,
502 owner_id,
503 content.as_deref(),
504 tracked_paths,
505 )
506 .await
507 }
508 Action::DeleteArtifact {
509 kind,
510 path,
511 owner_id,
512 } => self.delete_artifact(manifest, albums, playlists, *kind, path, owner_id),
513 }
514 }
515
516 async fn download(
518 &self,
519 client: &mut SunoClient<C>,
520 manifest: &mut Manifest,
521 clip: &Clip,
522 lineage: &LineageContext,
523 path: &str,
524 format: AudioFormat,
525 ) -> Result<Effect, Fail> {
526 let tagged = self.produce_audio(client, clip, lineage, format).await?;
527 let size = self.write_verify(&clip.id, path, &tagged)?;
528 manifest.insert(clip.id.clone(), self.entry(&clip.id, path, format, size));
529 Ok(Effect::Downloaded)
530 }
531
532 async fn reformat(
534 &self,
535 client: &mut SunoClient<C>,
536 manifest: &mut Manifest,
537 clip: &Clip,
538 path: &str,
539 from_path: &str,
540 to: AudioFormat,
541 ) -> Result<Effect, Fail> {
542 let lineage = self
546 .by_id
547 .get(clip.id.as_str())
548 .map(|d| d.lineage.clone())
549 .unwrap_or_else(|| LineageContext::own_root(clip));
550 let tagged = self.produce_audio(client, clip, &lineage, to).await?;
551 let size = self.write_verify(&clip.id, path, &tagged)?;
552 self.fs
554 .remove(from_path)
555 .map_err(|err| permanent_fail(&clip.id, format!("could not remove old file: {err}")))?;
556 manifest.insert(clip.id.clone(), self.entry(&clip.id, path, to, size));
557 Ok(Effect::Reformatted)
558 }
559
560 async fn retag(
562 &self,
563 manifest: &mut Manifest,
564 clip: &Clip,
565 lineage: &LineageContext,
566 path: &str,
567 ) -> Result<Effect, Fail> {
568 let Some(format) = manifest.get(&clip.id).map(|entry| entry.format) else {
569 return Err(permanent_fail(
570 &clip.id,
571 "retag target missing from manifest",
572 ));
573 };
574
575 if format == AudioFormat::Wav {
576 self.refresh_hashes(manifest, &clip.id, None);
579 return Ok(Effect::Retagged);
580 }
581
582 let meta = TrackMetadata::from_clip(clip, lineage);
583 let cover = self.fetch_cover(clip).await;
584 let existing = self
585 .fs
586 .read(path)
587 .map_err(|err| permanent_fail(&clip.id, format!("could not read for retag: {err}")))?;
588 let tagged = match format {
589 AudioFormat::Mp3 => tag_mp3(&existing, &meta, cover.as_deref()),
590 AudioFormat::Flac => tag_flac(&existing, &meta, cover.as_deref()),
591 AudioFormat::Wav => unreachable!("WAV handled above"),
592 }
593 .map_err(|err| permanent_fail(&clip.id, err.to_string()))?;
594 let size = self.write_verify(&clip.id, path, &tagged)?;
595 self.refresh_hashes(manifest, &clip.id, Some(size));
596 Ok(Effect::Retagged)
597 }
598
599 fn rename(&self, manifest: &mut Manifest, from: &str, to: &str) -> Result<Effect, Fail> {
601 let label = self
602 .by_path
603 .get(to)
604 .map(|d| d.clip.id.clone())
605 .unwrap_or_else(|| to.to_owned());
606 self.fs.rename(from, to).map_err(|err| {
607 if err.is_out_of_space() {
608 disk_fail(label, "disk full: no space left to rename")
609 } else {
610 permanent_fail(label, format!("rename failed: {err}"))
611 }
612 })?;
613
614 let clip_id = self.by_path.get(to).map(|d| d.clip.id.clone()).or_else(|| {
615 manifest
616 .entries
617 .iter()
618 .find(|(_, entry)| entry.path == from)
619 .map(|(id, _)| id.clone())
620 });
621 if let Some(id) = clip_id
622 && let Some(entry) = manifest.entries.get_mut(&id)
623 {
624 entry.path = to.to_owned();
625 if let Some(d) = self.by_path.get(to) {
626 entry.preserve = preserve_for(d);
627 }
628 }
629 Ok(Effect::Renamed)
630 }
631
632 fn delete(&self, manifest: &mut Manifest, path: &str, clip_id: &str) -> Result<Effect, Fail> {
634 self.fs
635 .remove(path)
636 .map_err(|err| permanent_fail(clip_id, format!("delete failed: {err}")))?;
637 manifest.remove(clip_id);
638 Ok(Effect::Deleted)
639 }
640
641 #[allow(clippy::too_many_arguments)]
673 async fn write_artifact(
674 &self,
675 manifest: &mut Manifest,
676 albums: &mut BTreeMap<String, AlbumArt>,
677 playlists: &mut BTreeMap<String, PlaylistState>,
678 kind: ArtifactKind,
679 path: &str,
680 source_url: &str,
681 hash: &str,
682 owner_id: &str,
683 content: Option<&str>,
684 tracked_paths: &mut HashMap<String, u32>,
685 ) -> Result<Effect, Fail> {
686 if is_per_clip_kind(kind) && manifest.get(owner_id).is_none() {
689 return Ok(Effect::Skipped);
690 }
691 let old_path = match kind {
697 ArtifactKind::CoverJpg => manifest
698 .get(owner_id)
699 .and_then(|e| e.cover_jpg.as_ref())
700 .map(|s| s.path.clone()),
701 ArtifactKind::CoverWebp => manifest
702 .get(owner_id)
703 .and_then(|e| e.cover_webp.as_ref())
704 .map(|s| s.path.clone()),
705 ArtifactKind::DetailsTxt => manifest
706 .get(owner_id)
707 .and_then(|e| e.details_txt.as_ref())
708 .map(|s| s.path.clone()),
709 ArtifactKind::LyricsTxt => manifest
710 .get(owner_id)
711 .and_then(|e| e.lyrics_txt.as_ref())
712 .map(|s| s.path.clone()),
713 ArtifactKind::Lrc => manifest
714 .get(owner_id)
715 .and_then(|e| e.lrc.as_ref())
716 .map(|s| s.path.clone()),
717 ArtifactKind::VideoMp4 => manifest
718 .get(owner_id)
719 .and_then(|e| e.video_mp4.as_ref())
720 .map(|s| s.path.clone()),
721 ArtifactKind::FolderJpg | ArtifactKind::FolderWebp => albums
722 .get(owner_id)
723 .and_then(|a| a.artifact(kind))
724 .map(|s| s.path.clone()),
725 ArtifactKind::Playlist => None,
726 };
727 let bytes = match content {
730 Some(text) => text.as_bytes().to_vec(),
731 None => self.artifact_bytes(kind, source_url, owner_id).await?,
732 };
733 self.write_verify(owner_id, path, &bytes)?;
734 if let Some(old) = old_path.as_deref()
751 && !old.is_empty()
752 && old != path
753 {
754 let still_referenced = tracked_paths
755 .get_mut(old)
756 .map(|count| {
757 *count = count.saturating_sub(1);
758 *count > 0
759 })
760 .unwrap_or(false);
761 if !still_referenced && !self.write_targets.contains(old) {
762 self.fs.remove(old).map_err(|err| {
763 permanent_fail(
764 owner_id,
765 format!("could not remove old sidecar {old}: {err}"),
766 )
767 })?;
768 }
769 }
770 if is_album_kind(kind) {
771 albums.entry(owner_id.to_owned()).or_default().set(
772 kind,
773 Some(ArtifactState {
774 path: path.to_owned(),
775 hash: hash.to_owned(),
776 }),
777 );
778 } else if is_playlist_kind(kind) {
779 playlists.insert(
780 owner_id.to_owned(),
781 PlaylistState {
782 name: playlist_name_from_path(path),
783 path: path.to_owned(),
784 hash: hash.to_owned(),
785 },
786 );
787 } else if let Some(entry) = manifest.entries.get_mut(owner_id) {
788 set_manifest_artifact(
789 entry,
790 kind,
791 Some(ArtifactState {
792 path: path.to_owned(),
793 hash: hash.to_owned(),
794 }),
795 );
796 }
797 Ok(Effect::ArtifactWritten)
798 }
799
800 async fn artifact_bytes(
811 &self,
812 kind: ArtifactKind,
813 source_url: &str,
814 owner_id: &str,
815 ) -> Result<Vec<u8>, Fail> {
816 let source = self
817 .fetch_bytes(source_url)
818 .await
819 .map_err(|err| err.attribute(owner_id))?;
820 match kind {
821 ArtifactKind::CoverWebp | ArtifactKind::FolderWebp => self
822 .ffmpeg
823 .mp4_to_webp(&source, WebpEncodeSettings::default())
824 .await
825 .map_err(|err| {
826 if err.is_out_of_space() {
827 disk_fail(owner_id, "disk full: no space left to transcode")
828 } else {
829 permanent_fail(owner_id, format!("cover transcode failed: {err}"))
830 }
831 }),
832 ArtifactKind::DetailsTxt | ArtifactKind::LyricsTxt | ArtifactKind::Lrc => Err(
836 permanent_fail(owner_id, "text sidecar requires inline content"),
837 ),
838 ArtifactKind::CoverJpg
839 | ArtifactKind::FolderJpg
840 | ArtifactKind::Playlist
841 | ArtifactKind::VideoMp4 => Ok(source),
842 }
843 }
844
845 fn delete_artifact(
860 &self,
861 manifest: &mut Manifest,
862 albums: &mut BTreeMap<String, AlbumArt>,
863 playlists: &mut BTreeMap<String, PlaylistState>,
864 kind: ArtifactKind,
865 path: &str,
866 owner_id: &str,
867 ) -> Result<Effect, Fail> {
868 self.fs
869 .remove(path)
870 .map_err(|err| permanent_fail(owner_id, format!("artifact delete failed: {err}")))?;
871 if is_album_kind(kind) {
872 if let Some(art) = albums.get_mut(owner_id) {
873 art.set(kind, None);
874 if art.is_empty() {
875 albums.remove(owner_id);
876 }
877 }
878 } else if is_playlist_kind(kind) {
879 playlists.remove(owner_id);
880 } else if let Some(entry) = manifest.entries.get_mut(owner_id) {
881 set_manifest_artifact(entry, kind, None);
882 }
883 Ok(Effect::ArtifactDeleted)
884 }
885
886 async fn produce_audio(
888 &self,
889 client: &mut SunoClient<C>,
890 clip: &Clip,
891 lineage: &LineageContext,
892 format: AudioFormat,
893 ) -> Result<Vec<u8>, Fail> {
894 let meta = TrackMetadata::from_clip(clip, lineage);
895 match format {
896 AudioFormat::Mp3 => {
897 let url = clip.mp3_url();
898 let audio = self
899 .fetch_bytes(&url)
900 .await
901 .map_err(|err| err.attribute(&clip.id))?;
902 let cover = self.fetch_cover(clip).await;
903 tag_mp3(&audio, &meta, cover.as_deref())
904 .map_err(|err| permanent_fail(&clip.id, err.to_string()))
905 }
906 AudioFormat::Flac => {
907 let wav = self.fetch_wav(client, clip).await?;
908 let flac = self.ffmpeg.wav_to_flac(&wav).await.map_err(|err| {
909 if err.is_out_of_space() {
910 disk_fail(&clip.id, "disk full: no space left to transcode")
911 } else {
912 permanent_fail(&clip.id, format!("transcode failed: {err}"))
913 }
914 })?;
915 let cover = self.fetch_cover(clip).await;
916 tag_flac(&flac, &meta, cover.as_deref())
917 .map_err(|err| permanent_fail(&clip.id, err.to_string()))
918 }
919 AudioFormat::Wav => self.fetch_wav(client, clip).await,
920 }
921 }
922
923 async fn fetch_wav(&self, client: &mut SunoClient<C>, clip: &Clip) -> Result<Vec<u8>, Fail> {
925 let url = match self.resolve_wav_url(client, &clip.id).await? {
926 Some(url) => url,
927 None => return Err(transient_fail(&clip.id, "WAV render was not ready")),
928 };
929 self.fetch_bytes(&url)
930 .await
931 .map_err(|err| err.attribute(&clip.id))
932 }
933
934 async fn resolve_wav_url(
939 &self,
940 client: &mut SunoClient<C>,
941 id: &str,
942 ) -> Result<Option<String>, Fail> {
943 if let Some(url) = self.wav_url_retrying(client, id).await? {
944 return Ok(Some(url));
945 }
946 self.request_wav_retrying(client, id).await?;
947 for _ in 0..self.opts.wav_poll_attempts {
948 self.clock.sleep(self.opts.wav_poll_interval).await;
949 if let Some(url) = self.wav_url_retrying(client, id).await? {
950 return Ok(Some(url));
951 }
952 }
953 Ok(None)
954 }
955
956 async fn wav_url_retrying(
959 &self,
960 client: &mut SunoClient<C>,
961 id: &str,
962 ) -> Result<Option<String>, Fail> {
963 let mut attempt: u32 = 0;
964 loop {
965 match client.wav_url(self.http, id).await {
966 Ok(url) => return Ok(url),
967 Err(err) => match self.retry_core(id, err, &mut attempt).await {
968 Some(fail) => return Err(fail),
969 None => continue,
970 },
971 }
972 }
973 }
974
975 async fn request_wav_retrying(&self, client: &mut SunoClient<C>, id: &str) -> Result<(), Fail> {
977 let mut attempt: u32 = 0;
978 loop {
979 match client.request_wav(self.http, id).await {
980 Ok(()) => return Ok(()),
981 Err(err) => match self.retry_core(id, err, &mut attempt).await {
982 Some(fail) => return Err(fail),
983 None => continue,
984 },
985 }
986 }
987 }
988
989 async fn retry_core(&self, id: &str, err: Error, attempt: &mut u32) -> Option<Fail> {
993 let fail = classify_core(id, err);
994 if matches!(fail.class, Class::Transient) && *attempt < self.opts.max_retries {
995 self.clock.sleep(backoff_delay(*attempt, None)).await;
996 *attempt += 1;
997 None
998 } else {
999 Some(fail)
1000 }
1001 }
1002
1003 async fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, FetchError> {
1005 let mut attempt: u32 = 0;
1006 loop {
1007 let result = self.http.send(HttpRequest::get(url)).await;
1008 match classify_response(result) {
1009 Ok(body) => return Ok(body),
1010 Err(err) => {
1011 if matches!(err.class, Class::Transient) && attempt < self.opts.max_retries {
1012 let delay = backoff_delay(attempt, err.retry_after);
1013 self.clock.sleep(delay).await;
1014 attempt += 1;
1015 continue;
1016 }
1017 return Err(err);
1018 }
1019 }
1020 }
1021 }
1022
1023 async fn fetch_cover(&self, clip: &Clip) -> Option<Vec<u8>> {
1025 for url in clip.cover_candidates() {
1026 if let Ok(response) = self.http.send(HttpRequest::get(url)).await
1027 && (200..=299).contains(&response.status)
1028 && !response.body.is_empty()
1029 {
1030 return Some(response.body);
1031 }
1032 }
1033 None
1034 }
1035
1036 fn write_verify(&self, clip_id: &str, path: &str, bytes: &[u8]) -> Result<u64, Fail> {
1038 self.fs.write_atomic(path, bytes).map_err(|err| {
1039 if err.is_out_of_space() {
1040 disk_fail(clip_id, format!("disk full: no space left to write {path}"))
1041 } else {
1042 permanent_fail(clip_id, format!("write failed: {err}"))
1043 }
1044 })?;
1045 match self.fs.metadata(path) {
1046 Some(stat) if stat.size == bytes.len() as u64 => Ok(stat.size),
1047 Some(stat) => Err(permanent_fail(
1048 clip_id,
1049 format!("wrote {} bytes, expected {}", stat.size, bytes.len()),
1050 )),
1051 None => Ok(bytes.len() as u64),
1052 }
1053 }
1054
1055 fn entry(&self, clip_id: &str, path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
1057 match self.by_id.get(clip_id) {
1058 Some(d) => manifest_entry(d, size),
1059 None => ManifestEntry {
1060 path: path.to_owned(),
1061 format,
1062 size,
1063 ..ManifestEntry::default()
1064 },
1065 }
1066 }
1067
1068 fn refresh_hashes(&self, manifest: &mut Manifest, clip_id: &str, size: Option<u64>) {
1070 let desired = self.by_id.get(clip_id).copied();
1071 if let Some(entry) = manifest.entries.get_mut(clip_id) {
1072 if let Some(d) = desired {
1073 entry.meta_hash = d.meta_hash.clone();
1074 entry.art_hash = d.art_hash.clone();
1075 entry.preserve = preserve_for(d);
1076 }
1077 if let Some(size) = size {
1078 entry.size = size;
1079 }
1080 }
1081 }
1082
1083 fn refresh_preserve(&self, manifest: &mut Manifest, clip_id: &str) {
1090 if let Some(d) = self.by_id.get(clip_id).copied()
1091 && let Some(entry) = manifest.entries.get_mut(clip_id)
1092 {
1093 entry.preserve = preserve_for(d);
1094 }
1095 }
1096}
1097
1098fn manifest_entry(d: &Desired, size: u64) -> ManifestEntry {
1100 ManifestEntry {
1101 path: d.path.clone(),
1102 format: d.format,
1103 meta_hash: d.meta_hash.clone(),
1104 art_hash: d.art_hash.clone(),
1105 size,
1106 preserve: preserve_for(d),
1107 ..Default::default()
1108 }
1109}
1110
1111fn preserve_for(d: &Desired) -> bool {
1114 d.private || d.modes.contains(&SourceMode::Copy)
1115}
1116
1117fn classify_response(
1119 result: Result<crate::http::HttpResponse, crate::http::TransportError>,
1120) -> Result<Vec<u8>, FetchError> {
1121 let response = match result {
1122 Ok(response) => response,
1123 Err(err) => {
1124 return Err(FetchError::transient(
1125 format!("transport error: {err}"),
1126 None,
1127 ));
1128 }
1129 };
1130 match response.status {
1131 200..=299 => {
1132 if let Some(expected) = content_length(&response) {
1133 let actual = response.body.len() as u64;
1134 if actual != expected {
1135 return Err(FetchError::transient(
1136 format!("truncated download: {actual} of {expected} bytes"),
1137 None,
1138 ));
1139 }
1140 }
1141 Ok(response.body)
1142 }
1143 401 | 403 => Err(FetchError::transient(
1144 format!("download rejected: status {}", response.status),
1145 None,
1146 )),
1147 408 => Err(FetchError::transient("request timed out", None)),
1148 429 => Err(FetchError::transient(
1149 "rate limited",
1150 retry_after(&response),
1151 )),
1152 500..=599 => Err(FetchError::transient(
1153 format!("server error {}", response.status),
1154 None,
1155 )),
1156 status => Err(FetchError::permanent(format!(
1157 "download failed: status {status}"
1158 ))),
1159 }
1160}
1161
1162fn classify_core(id: &str, err: Error) -> Fail {
1164 let reason = err.to_string();
1165 match err {
1166 Error::Auth(_) => auth_fail(id, reason),
1167 Error::RateLimited { .. } | Error::Connection(_) => transient_fail(id, reason),
1168 Error::Api(_) | Error::NotFound(_) | Error::Tag(_) | Error::Config(_) => {
1169 permanent_fail(id, reason)
1170 }
1171 }
1172}
1173
1174fn content_length(response: &crate::http::HttpResponse) -> Option<u64> {
1176 response.header("content-length")?.trim().parse().ok()
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::*;
1182 use crate::ClerkAuth;
1183 use crate::http::HttpResponse;
1184 use crate::testutil::{MemFs, RecordingClock, Reply, ScriptedHttp, StubFfmpeg};
1185
1186 fn clip(id: &str) -> Clip {
1187 Clip {
1188 id: id.to_owned(),
1189 title: "Song".to_owned(),
1190 audio_url: format!("https://cdn1.suno.ai/{id}.mp3"),
1191 ..Default::default()
1192 }
1193 }
1194
1195 fn art_clip(id: &str) -> Clip {
1196 Clip {
1197 image_large_url: format!("https://art.suno.ai/{id}/large.jpg"),
1198 image_url: format!("https://art.suno.ai/{id}/small.jpg"),
1199 ..clip(id)
1200 }
1201 }
1202
1203 fn ext(format: AudioFormat) -> &'static str {
1204 match format {
1205 AudioFormat::Mp3 => "mp3",
1206 AudioFormat::Flac => "flac",
1207 AudioFormat::Wav => "wav",
1208 }
1209 }
1210
1211 fn desired(clip: Clip, format: AudioFormat) -> Desired {
1212 Desired {
1213 path: format!("{}.{}", clip.id, ext(format)),
1214 lineage: LineageContext::own_root(&clip),
1215 clip,
1216 format,
1217 meta_hash: "m".to_owned(),
1218 art_hash: "art".to_owned(),
1219 modes: vec![SourceMode::Mirror],
1220 trashed: false,
1221 private: false,
1222 artifacts: Vec::new(),
1223 }
1224 }
1225
1226 fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
1227 ManifestEntry {
1228 path: path.to_owned(),
1229 format,
1230 meta_hash: "old".to_owned(),
1231 art_hash: "old-art".to_owned(),
1232 size: 8,
1233 preserve: false,
1234 ..Default::default()
1235 }
1236 }
1237
1238 #[allow(clippy::too_many_arguments)]
1239 fn run(
1240 plan: &Plan,
1241 manifest: &mut Manifest,
1242 desired: &[Desired],
1243 http: &ScriptedHttp,
1244 fs: &MemFs,
1245 ffmpeg: &StubFfmpeg,
1246 clock: &RecordingClock,
1247 opts: &ExecOptions,
1248 ) -> ExecOutcome {
1249 let mut albums = BTreeMap::new();
1250 run_with_albums(
1251 plan,
1252 manifest,
1253 &mut albums,
1254 desired,
1255 http,
1256 fs,
1257 ffmpeg,
1258 clock,
1259 opts,
1260 )
1261 }
1262
1263 #[allow(clippy::too_many_arguments)]
1264 fn run_with_albums(
1265 plan: &Plan,
1266 manifest: &mut Manifest,
1267 albums: &mut BTreeMap<String, AlbumArt>,
1268 desired: &[Desired],
1269 http: &ScriptedHttp,
1270 fs: &MemFs,
1271 ffmpeg: &StubFfmpeg,
1272 clock: &RecordingClock,
1273 opts: &ExecOptions,
1274 ) -> ExecOutcome {
1275 let mut playlists = BTreeMap::new();
1276 run_full(
1277 plan,
1278 manifest,
1279 albums,
1280 &mut playlists,
1281 desired,
1282 http,
1283 fs,
1284 ffmpeg,
1285 clock,
1286 opts,
1287 )
1288 }
1289
1290 #[allow(clippy::too_many_arguments)]
1291 fn run_full(
1292 plan: &Plan,
1293 manifest: &mut Manifest,
1294 albums: &mut BTreeMap<String, AlbumArt>,
1295 playlists: &mut BTreeMap<String, PlaylistState>,
1296 desired: &[Desired],
1297 http: &ScriptedHttp,
1298 fs: &MemFs,
1299 ffmpeg: &StubFfmpeg,
1300 clock: &RecordingClock,
1301 opts: &ExecOptions,
1302 ) -> ExecOutcome {
1303 let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1304 pollster::block_on(execute(
1305 plan,
1306 manifest,
1307 albums,
1308 playlists,
1309 desired,
1310 Ports {
1311 client: &mut client,
1312 http,
1313 fs,
1314 ffmpeg,
1315 clock,
1316 },
1317 opts,
1318 ))
1319 }
1320
1321 fn small_poll() -> ExecOptions {
1322 ExecOptions {
1323 max_retries: 3,
1324 wav_poll_attempts: 2,
1325 wav_poll_interval: Duration::from_secs(5),
1326 }
1327 }
1328
1329 #[test]
1332 fn download_mp3_writes_tagged_file_and_records_manifest() {
1333 let c = art_clip("a");
1334 let d = desired(c.clone(), AudioFormat::Mp3);
1335 let plan = Plan {
1336 actions: vec![Action::Download {
1337 clip: c.clone(),
1338 lineage: LineageContext::own_root(&c),
1339 path: d.path.clone(),
1340 format: AudioFormat::Mp3,
1341 }],
1342 };
1343 let http = ScriptedHttp::new()
1344 .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
1345 .route("a/large.jpg", Reply::ok(b"art-bytes".to_vec()));
1346 let fs = MemFs::new();
1347 let ffmpeg = StubFfmpeg::flac();
1348 let clock = RecordingClock::new();
1349 let mut manifest = Manifest::new();
1350
1351 let outcome = run(
1352 &plan,
1353 &mut manifest,
1354 &[d],
1355 &http,
1356 &fs,
1357 &ffmpeg,
1358 &clock,
1359 &ExecOptions::default(),
1360 );
1361
1362 assert_eq!(outcome.downloaded, 1);
1363 assert_eq!(outcome.failed(), 0);
1364 assert_eq!(outcome.status, RunStatus::Completed);
1365 let written = fs.read_file("a.mp3").unwrap();
1366 assert_eq!(&written[..3], b"ID3");
1367 assert!(written.ends_with(b"mp3-body"));
1368 let entry = manifest.get("a").unwrap();
1369 assert_eq!(entry.path, "a.mp3");
1370 assert_eq!(entry.format, AudioFormat::Mp3);
1371 assert_eq!(entry.meta_hash, "m");
1372 assert_eq!(entry.art_hash, "art");
1373 assert_eq!(entry.size, written.len() as u64);
1374 assert!(!entry.preserve);
1375 }
1376
1377 #[test]
1378 fn download_mp3_uses_cdn_fallback_when_audio_url_empty() {
1379 let mut c = clip("a");
1380 c.audio_url = String::new();
1381 let d = desired(c.clone(), AudioFormat::Mp3);
1382 let plan = Plan {
1383 actions: vec![Action::Download {
1384 clip: c.clone(),
1385 lineage: LineageContext::own_root(&c),
1386 path: d.path.clone(),
1387 format: AudioFormat::Mp3,
1388 }],
1389 };
1390 let http = ScriptedHttp::new().route("cdn1.suno.ai/a.mp3", Reply::ok(b"body".to_vec()));
1391 let fs = MemFs::new();
1392 let mut manifest = Manifest::new();
1393 let outcome = run(
1394 &plan,
1395 &mut manifest,
1396 &[d],
1397 &http,
1398 &fs,
1399 &StubFfmpeg::flac(),
1400 &RecordingClock::new(),
1401 &ExecOptions::default(),
1402 );
1403 assert_eq!(outcome.downloaded, 1);
1404 assert_eq!(http.count("cdn1.suno.ai/a.mp3"), 1);
1405 }
1406
1407 #[test]
1410 fn download_flac_renders_transcodes_and_records() {
1411 let c = clip("b");
1412 let d = desired(c.clone(), AudioFormat::Flac);
1413 let plan = Plan {
1414 actions: vec![Action::Download {
1415 clip: c.clone(),
1416 lineage: LineageContext::own_root(&c),
1417 path: d.path.clone(),
1418 format: AudioFormat::Flac,
1419 }],
1420 };
1421 let http = ScriptedHttp::new()
1422 .with_auth()
1423 .route(
1424 "/wav_file/",
1425 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/b.wav"}"#),
1426 )
1427 .route("b.wav", Reply::ok(b"wav-bytes".to_vec()));
1428 let fs = MemFs::new();
1429 let clock = RecordingClock::new();
1430 let mut manifest = Manifest::new();
1431
1432 let outcome = run(
1433 &plan,
1434 &mut manifest,
1435 &[d],
1436 &http,
1437 &fs,
1438 &StubFfmpeg::flac(),
1439 &clock,
1440 &ExecOptions::default(),
1441 );
1442
1443 assert_eq!(outcome.downloaded, 1);
1444 assert_eq!(outcome.failed(), 0);
1445 let written = fs.read_file("b.flac").unwrap();
1446 assert_eq!(&written[..4], b"fLaC");
1447 assert_eq!(manifest.get("b").unwrap().format, AudioFormat::Flac);
1448 assert_eq!(http.count("/convert_wav/"), 0);
1450 assert!(clock.sleeps().is_empty());
1451 }
1452
1453 #[test]
1454 fn download_flac_requests_render_then_polls_until_ready() {
1455 let c = clip("c");
1456 let d = desired(c.clone(), AudioFormat::Flac);
1457 let plan = Plan {
1458 actions: vec![Action::Download {
1459 clip: c.clone(),
1460 lineage: LineageContext::own_root(&c),
1461 path: d.path.clone(),
1462 format: AudioFormat::Flac,
1463 }],
1464 };
1465 let http = ScriptedHttp::new()
1466 .with_auth()
1467 .route_seq(
1468 "/wav_file/",
1469 vec![
1470 Reply::json("{}"),
1471 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/c.wav"}"#),
1472 ],
1473 )
1474 .route("/convert_wav/", Reply::status(200))
1475 .route("c.wav", Reply::ok(b"wav".to_vec()));
1476 let clock = RecordingClock::new();
1477 let mut manifest = Manifest::new();
1478
1479 let outcome = run(
1480 &plan,
1481 &mut manifest,
1482 &[d],
1483 &http,
1484 &fs_new(),
1485 &StubFfmpeg::flac(),
1486 &clock,
1487 &small_poll(),
1488 );
1489
1490 assert_eq!(outcome.downloaded, 1);
1491 assert_eq!(http.count("/convert_wav/"), 1);
1492 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
1493 }
1494
1495 #[test]
1496 fn download_flac_unavailable_render_is_a_nonfatal_failure() {
1497 let c = clip("d");
1498 let d = desired(c.clone(), AudioFormat::Flac);
1499 let plan = Plan {
1500 actions: vec![Action::Download {
1501 clip: c.clone(),
1502 lineage: LineageContext::own_root(&c),
1503 path: d.path.clone(),
1504 format: AudioFormat::Flac,
1505 }],
1506 };
1507 let http = ScriptedHttp::new()
1508 .with_auth()
1509 .route("/wav_file/", Reply::json("{}"))
1510 .route("/convert_wav/", Reply::status(200));
1511 let fs = MemFs::new();
1512 let clock = RecordingClock::new();
1513 let mut manifest = Manifest::new();
1514
1515 let outcome = run(
1516 &plan,
1517 &mut manifest,
1518 &[d],
1519 &http,
1520 &fs,
1521 &StubFfmpeg::flac(),
1522 &clock,
1523 &small_poll(),
1524 );
1525
1526 assert_eq!(outcome.downloaded, 0);
1527 assert_eq!(outcome.failed(), 1);
1528 assert_eq!(outcome.failures[0].clip_id, "d");
1529 assert_eq!(outcome.status, RunStatus::Completed);
1530 assert!(!fs.exists("d.flac"));
1531 assert_eq!(clock.sleeps().len(), 2);
1532 }
1533
1534 #[test]
1535 fn flac_transcode_failure_is_recorded_and_skipped() {
1536 let c = clip("t");
1537 let d = desired(c.clone(), AudioFormat::Flac);
1538 let plan = Plan {
1539 actions: vec![Action::Download {
1540 clip: c.clone(),
1541 lineage: LineageContext::own_root(&c),
1542 path: d.path.clone(),
1543 format: AudioFormat::Flac,
1544 }],
1545 };
1546 let http = ScriptedHttp::new()
1547 .with_auth()
1548 .route(
1549 "/wav_file/",
1550 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/t.wav"}"#),
1551 )
1552 .route("t.wav", Reply::ok(b"wav".to_vec()));
1553 let fs = MemFs::new();
1554 let mut manifest = Manifest::new();
1555
1556 let outcome = run(
1557 &plan,
1558 &mut manifest,
1559 &[d],
1560 &http,
1561 &fs,
1562 &StubFfmpeg::failing(),
1563 &RecordingClock::new(),
1564 &ExecOptions::default(),
1565 );
1566
1567 assert_eq!(outcome.downloaded, 0);
1568 assert_eq!(outcome.failed(), 1);
1569 assert!(!fs.exists("t.flac"));
1570 assert!(manifest.get("t").is_none());
1571 }
1572
1573 #[test]
1576 fn cover_falls_back_when_large_image_is_missing() {
1577 let c = art_clip("e");
1578 let d = desired(c.clone(), AudioFormat::Mp3);
1579 let plan = Plan {
1580 actions: vec![Action::Download {
1581 clip: c.clone(),
1582 lineage: LineageContext::own_root(&c),
1583 path: d.path.clone(),
1584 format: AudioFormat::Mp3,
1585 }],
1586 };
1587 let http = ScriptedHttp::new()
1588 .route("e.mp3", Reply::ok(b"body".to_vec()))
1589 .route("e/large.jpg", Reply::status(404))
1590 .route("e/small.jpg", Reply::ok(b"the-art".to_vec()));
1591 let fs = MemFs::new();
1592 let mut manifest = Manifest::new();
1593
1594 let outcome = run(
1595 &plan,
1596 &mut manifest,
1597 &[d],
1598 &http,
1599 &fs,
1600 &StubFfmpeg::flac(),
1601 &RecordingClock::new(),
1602 &ExecOptions::default(),
1603 );
1604
1605 assert_eq!(outcome.downloaded, 1);
1606 let calls = http.calls();
1607 let large = calls
1608 .iter()
1609 .position(|u| u.contains("e/large.jpg"))
1610 .unwrap();
1611 let small = calls
1612 .iter()
1613 .position(|u| u.contains("e/small.jpg"))
1614 .unwrap();
1615 assert!(large < small, "large art tried before small");
1616 }
1617
1618 #[test]
1621 fn failed_write_leaves_the_prior_file_intact() {
1622 let c = clip("f");
1623 let d = desired(c.clone(), AudioFormat::Mp3);
1624 let plan = Plan {
1625 actions: vec![Action::Download {
1626 clip: c.clone(),
1627 lineage: LineageContext::own_root(&c),
1628 path: d.path.clone(),
1629 format: AudioFormat::Mp3,
1630 }],
1631 };
1632 let http = ScriptedHttp::new().route("f.mp3", Reply::ok(b"new-body".to_vec()));
1633 let fs = MemFs::new()
1634 .with_file("f.mp3", b"OLD-CONTENT".to_vec())
1635 .fail_write("f.mp3");
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 &RecordingClock::new(),
1646 &ExecOptions::default(),
1647 );
1648
1649 assert_eq!(outcome.downloaded, 0);
1650 assert_eq!(outcome.failed(), 1);
1651 assert_eq!(fs.read_file("f.mp3").unwrap(), b"OLD-CONTENT");
1652 assert!(manifest.get("f").is_none());
1653 }
1654
1655 #[test]
1656 fn size_mismatch_after_write_is_a_failure() {
1657 let c = clip("g");
1658 let d = desired(c.clone(), AudioFormat::Mp3);
1659 let plan = Plan {
1660 actions: vec![Action::Download {
1661 clip: c.clone(),
1662 lineage: LineageContext::own_root(&c),
1663 path: d.path.clone(),
1664 format: AudioFormat::Mp3,
1665 }],
1666 };
1667 let http = ScriptedHttp::new().route("g.mp3", Reply::ok(b"body".to_vec()));
1668 let fs = MemFs::new().corrupt_write("g.mp3");
1669 let mut manifest = Manifest::new();
1670
1671 let outcome = run(
1672 &plan,
1673 &mut manifest,
1674 &[d],
1675 &http,
1676 &fs,
1677 &StubFfmpeg::flac(),
1678 &RecordingClock::new(),
1679 &ExecOptions::default(),
1680 );
1681
1682 assert_eq!(outcome.downloaded, 0);
1683 assert_eq!(outcome.failed(), 1);
1684 assert!(outcome.failures[0].reason.contains("expected"));
1685 assert!(manifest.get("g").is_none());
1686 }
1687
1688 #[test]
1691 fn transient_failure_is_retried_then_skipped() {
1692 let c = clip("h");
1693 let d = desired(c.clone(), AudioFormat::Mp3);
1694 let plan = Plan {
1695 actions: vec![Action::Download {
1696 clip: c.clone(),
1697 lineage: LineageContext::own_root(&c),
1698 path: d.path.clone(),
1699 format: AudioFormat::Mp3,
1700 }],
1701 };
1702 let http = ScriptedHttp::new().route("h.mp3", Reply::status(500));
1703 let fs = MemFs::new();
1704 let clock = RecordingClock::new();
1705 let opts = ExecOptions {
1706 max_retries: 2,
1707 ..ExecOptions::default()
1708 };
1709 let mut manifest = Manifest::new();
1710
1711 let outcome = run(
1712 &plan,
1713 &mut manifest,
1714 &[d],
1715 &http,
1716 &fs,
1717 &StubFfmpeg::flac(),
1718 &clock,
1719 &opts,
1720 );
1721
1722 assert_eq!(outcome.downloaded, 0);
1723 assert_eq!(outcome.failed(), 1);
1724 assert_eq!(http.count("h.mp3"), 3);
1725 assert_eq!(clock.sleeps().len(), 2);
1726 }
1727
1728 #[test]
1729 fn truncated_download_is_retried_then_succeeds() {
1730 let c = clip("i");
1731 let d = desired(c.clone(), AudioFormat::Mp3);
1732 let plan = Plan {
1733 actions: vec![Action::Download {
1734 clip: c.clone(),
1735 lineage: LineageContext::own_root(&c),
1736 path: d.path.clone(),
1737 format: AudioFormat::Mp3,
1738 }],
1739 };
1740 let http = ScriptedHttp::new().route_seq(
1741 "i.mp3",
1742 vec![
1743 Reply::ok(b"short".to_vec()).with_content_length(999),
1744 Reply::ok(b"good-body".to_vec()),
1745 ],
1746 );
1747 let fs = MemFs::new();
1748 let clock = RecordingClock::new();
1749 let mut manifest = Manifest::new();
1750
1751 let outcome = run(
1752 &plan,
1753 &mut manifest,
1754 &[d],
1755 &http,
1756 &fs,
1757 &StubFfmpeg::flac(),
1758 &clock,
1759 &ExecOptions::default(),
1760 );
1761
1762 assert_eq!(outcome.downloaded, 1);
1763 assert_eq!(http.count("i.mp3"), 2);
1764 assert_eq!(clock.sleeps().len(), 1);
1765 }
1766
1767 #[test]
1768 fn rate_limit_backs_off_using_retry_after() {
1769 let c = clip("j");
1770 let d = desired(c.clone(), AudioFormat::Mp3);
1771 let plan = Plan {
1772 actions: vec![Action::Download {
1773 clip: c.clone(),
1774 lineage: LineageContext::own_root(&c),
1775 path: d.path.clone(),
1776 format: AudioFormat::Mp3,
1777 }],
1778 };
1779 let http = ScriptedHttp::new().route_seq(
1780 "j.mp3",
1781 vec![
1782 Reply::status(429).with_retry_after(7),
1783 Reply::ok(b"body".to_vec()),
1784 ],
1785 );
1786 let fs = MemFs::new();
1787 let clock = RecordingClock::new();
1788 let mut manifest = Manifest::new();
1789
1790 let outcome = run(
1791 &plan,
1792 &mut manifest,
1793 &[d],
1794 &http,
1795 &fs,
1796 &StubFfmpeg::flac(),
1797 &clock,
1798 &ExecOptions::default(),
1799 );
1800
1801 assert_eq!(outcome.downloaded, 1);
1802 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
1803 }
1804
1805 #[test]
1806 fn auth_failure_aborts_the_run() {
1807 let c1 = clip("k1");
1808 let c2 = clip("k2");
1809 let d1 = desired(c1.clone(), AudioFormat::Flac);
1810 let d2 = desired(c2.clone(), AudioFormat::Flac);
1811 let plan = Plan {
1812 actions: vec![
1813 Action::Download {
1814 clip: c1.clone(),
1815 lineage: LineageContext::own_root(&c1),
1816 path: d1.path.clone(),
1817 format: AudioFormat::Flac,
1818 },
1819 Action::Download {
1820 clip: c2.clone(),
1821 lineage: LineageContext::own_root(&c2),
1822 path: d2.path.clone(),
1823 format: AudioFormat::Flac,
1824 },
1825 ],
1826 };
1827 let http = ScriptedHttp::new()
1831 .with_auth()
1832 .route("/wav_file/", Reply::status(401));
1833 let fs = MemFs::new();
1834 let mut manifest = Manifest::new();
1835
1836 let outcome = run(
1837 &plan,
1838 &mut manifest,
1839 &[d1, d2],
1840 &http,
1841 &fs,
1842 &StubFfmpeg::flac(),
1843 &RecordingClock::new(),
1844 &small_poll(),
1845 );
1846
1847 assert_eq!(outcome.status, RunStatus::AuthAborted);
1848 assert_eq!(outcome.failed(), 1);
1849 assert_eq!(outcome.failures[0].clip_id, "k1");
1850 assert_eq!(outcome.downloaded, 0);
1851 }
1852
1853 #[test]
1856 fn disk_full_primary_write_aborts_the_run() {
1857 let c1 = clip("d1");
1861 let c2 = clip("d2");
1862 let d1 = desired(c1.clone(), AudioFormat::Mp3);
1863 let d2 = desired(c2.clone(), AudioFormat::Mp3);
1864 let plan = Plan {
1865 actions: vec![
1866 Action::Download {
1867 clip: c1.clone(),
1868 lineage: LineageContext::own_root(&c1),
1869 path: d1.path.clone(),
1870 format: AudioFormat::Mp3,
1871 },
1872 Action::Download {
1873 clip: c2.clone(),
1874 lineage: LineageContext::own_root(&c2),
1875 path: d2.path.clone(),
1876 format: AudioFormat::Mp3,
1877 },
1878 ],
1879 };
1880 let http = ScriptedHttp::new()
1881 .route("d1.mp3", Reply::ok(b"body-1".to_vec()))
1882 .route("d2.mp3", Reply::ok(b"body-2".to_vec()));
1883 let fs = MemFs::new().fail_write_out_of_space("d1.mp3");
1884 let mut manifest = Manifest::new();
1885
1886 let outcome = run(
1887 &plan,
1888 &mut manifest,
1889 &[d1, d2],
1890 &http,
1891 &fs,
1892 &StubFfmpeg::flac(),
1893 &RecordingClock::new(),
1894 &ExecOptions::default(),
1895 );
1896
1897 assert_eq!(outcome.status, RunStatus::DiskFull);
1898 assert_eq!(outcome.failed(), 1);
1899 assert_eq!(outcome.failures[0].clip_id, "d1");
1900 assert!(outcome.failures[0].reason.contains("disk full"));
1901 assert_eq!(outcome.downloaded, 0);
1902 assert_eq!(http.count("d2.mp3"), 0);
1904 assert!(!fs.exists("d2.mp3"));
1905 }
1906
1907 #[test]
1908 fn disk_full_flac_transcode_aborts_the_run() {
1909 let c1 = clip("d1");
1912 let c2 = clip("d2");
1913 let d1 = desired(c1.clone(), AudioFormat::Flac);
1914 let d2 = desired(c2.clone(), AudioFormat::Flac);
1915 let plan = Plan {
1916 actions: vec![
1917 Action::Download {
1918 clip: c1.clone(),
1919 lineage: LineageContext::own_root(&c1),
1920 path: d1.path.clone(),
1921 format: AudioFormat::Flac,
1922 },
1923 Action::Download {
1924 clip: c2.clone(),
1925 lineage: LineageContext::own_root(&c2),
1926 path: d2.path.clone(),
1927 format: AudioFormat::Flac,
1928 },
1929 ],
1930 };
1931 let http = ScriptedHttp::new()
1932 .with_auth()
1933 .route(
1934 "/wav_file/",
1935 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/d1.wav"}"#),
1936 )
1937 .route(".wav", Reply::ok(b"wav".to_vec()));
1938 let fs = MemFs::new();
1939 let mut manifest = Manifest::new();
1940
1941 let outcome = run(
1942 &plan,
1943 &mut manifest,
1944 &[d1, d2],
1945 &http,
1946 &fs,
1947 &StubFfmpeg::out_of_space(),
1948 &RecordingClock::new(),
1949 &ExecOptions::default(),
1950 );
1951
1952 assert_eq!(outcome.status, RunStatus::DiskFull);
1953 assert_eq!(outcome.failed(), 1);
1954 assert_eq!(outcome.failures[0].clip_id, "d1");
1955 assert!(outcome.failures[0].reason.contains("disk full"));
1956 assert_eq!(outcome.downloaded, 0);
1957 }
1958
1959 #[test]
1960 fn disk_full_artifact_write_aborts_the_run() {
1961 let mut manifest = Manifest::new();
1965 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
1966 let plan = Plan {
1967 actions: vec![Action::WriteArtifact {
1968 kind: ArtifactKind::CoverJpg,
1969 path: "a/cover.jpg".to_owned(),
1970 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
1971 hash: "h1".to_owned(),
1972 owner_id: "a".to_owned(),
1973 content: None,
1974 }],
1975 };
1976 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"jpg-bytes".to_vec()));
1977 let fs = MemFs::new().fail_write_out_of_space("a/cover.jpg");
1978
1979 let outcome = run(
1980 &plan,
1981 &mut manifest,
1982 &[],
1983 &http,
1984 &fs,
1985 &StubFfmpeg::flac(),
1986 &RecordingClock::new(),
1987 &ExecOptions::default(),
1988 );
1989
1990 assert_eq!(outcome.status, RunStatus::DiskFull);
1991 assert_eq!(outcome.failed(), 1);
1992 assert!(outcome.failures[0].reason.contains("disk full"));
1993 assert_eq!(outcome.artifacts_written, 0);
1994 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
1996 }
1997
1998 #[test]
1999 fn disk_full_leaves_the_failed_clips_manifest_entry_unchanged() {
2000 let c = clip("m");
2003 let d = desired(c.clone(), AudioFormat::Mp3);
2004 let plan = Plan {
2005 actions: vec![Action::Download {
2006 clip: c.clone(),
2007 lineage: LineageContext::own_root(&c),
2008 path: d.path.clone(),
2009 format: AudioFormat::Mp3,
2010 }],
2011 };
2012 let http = ScriptedHttp::new().route("m.mp3", Reply::ok(b"new-body".to_vec()));
2013 let fs = MemFs::new()
2014 .with_file("m.mp3", b"OLD-CONTENT".to_vec())
2015 .fail_write_out_of_space("m.mp3");
2016 let mut manifest = Manifest::new();
2017 let before = entry("m.mp3", AudioFormat::Mp3);
2018 manifest.insert("m", before.clone());
2019
2020 let outcome = run(
2021 &plan,
2022 &mut manifest,
2023 &[d],
2024 &http,
2025 &fs,
2026 &StubFfmpeg::flac(),
2027 &RecordingClock::new(),
2028 &ExecOptions::default(),
2029 );
2030
2031 assert_eq!(outcome.status, RunStatus::DiskFull);
2032 assert_eq!(manifest.get("m"), Some(&before));
2033 assert_eq!(fs.read_file("m.mp3").unwrap(), b"OLD-CONTENT");
2034 }
2035
2036 #[test]
2037 fn cdn_download_rejection_skips_the_clip_without_aborting() {
2038 let c1 = clip("k1");
2039 let c2 = clip("k2");
2040 let d1 = desired(c1.clone(), AudioFormat::Mp3);
2041 let d2 = desired(c2.clone(), AudioFormat::Mp3);
2042 let plan = Plan {
2043 actions: vec![
2044 Action::Download {
2045 clip: c1.clone(),
2046 lineage: LineageContext::own_root(&c1),
2047 path: d1.path.clone(),
2048 format: AudioFormat::Mp3,
2049 },
2050 Action::Download {
2051 clip: c2.clone(),
2052 lineage: LineageContext::own_root(&c2),
2053 path: d2.path.clone(),
2054 format: AudioFormat::Mp3,
2055 },
2056 ],
2057 };
2058 let http = ScriptedHttp::new()
2062 .route("k1.mp3", Reply::status(403))
2063 .route("k2.mp3", Reply::ok(b"body".to_vec()));
2064 let fs = MemFs::new();
2065 let mut manifest = Manifest::new();
2066
2067 let outcome = run(
2068 &plan,
2069 &mut manifest,
2070 &[d1, d2],
2071 &http,
2072 &fs,
2073 &StubFfmpeg::flac(),
2074 &RecordingClock::new(),
2075 &ExecOptions::default(),
2076 );
2077
2078 assert_ne!(outcome.status, RunStatus::AuthAborted);
2079 assert_eq!(outcome.downloaded, 1);
2080 assert_eq!(outcome.failed(), 1);
2081 assert_eq!(outcome.failures[0].clip_id, "k1");
2082 }
2083
2084 #[test]
2085 fn one_clip_failure_does_not_abort_the_run() {
2086 let c1 = clip("l1");
2087 let c2 = clip("l2");
2088 let d1 = desired(c1.clone(), AudioFormat::Mp3);
2089 let d2 = desired(c2.clone(), AudioFormat::Mp3);
2090 let plan = Plan {
2091 actions: vec![
2092 Action::Download {
2093 clip: c1.clone(),
2094 lineage: LineageContext::own_root(&c1),
2095 path: d1.path.clone(),
2096 format: AudioFormat::Mp3,
2097 },
2098 Action::Download {
2099 clip: c2.clone(),
2100 lineage: LineageContext::own_root(&c2),
2101 path: d2.path.clone(),
2102 format: AudioFormat::Mp3,
2103 },
2104 ],
2105 };
2106 let http = ScriptedHttp::new()
2107 .route("l1.mp3", Reply::status(404))
2108 .route("l2.mp3", Reply::ok(b"body".to_vec()));
2109 let fs = MemFs::new();
2110 let mut manifest = Manifest::new();
2111
2112 let outcome = run(
2113 &plan,
2114 &mut manifest,
2115 &[d1, d2],
2116 &http,
2117 &fs,
2118 &StubFfmpeg::flac(),
2119 &RecordingClock::new(),
2120 &ExecOptions::default(),
2121 );
2122
2123 assert_eq!(outcome.status, RunStatus::Completed);
2124 assert_eq!(outcome.downloaded, 1);
2125 assert_eq!(outcome.failed(), 1);
2126 assert_eq!(outcome.failures[0].clip_id, "l1");
2127 assert!(fs.exists("l2.mp3"));
2128 assert!(manifest.get("l2").is_some());
2129 assert!(manifest.get("l1").is_none());
2130 }
2131
2132 #[test]
2135 fn preserve_is_set_for_copy_held_and_private_clips() {
2136 let mut mirror = desired(clip("m1"), AudioFormat::Mp3);
2137 mirror.modes = vec![SourceMode::Mirror];
2138 let mut copy_held = desired(clip("m2"), AudioFormat::Mp3);
2139 copy_held.modes = vec![SourceMode::Mirror, SourceMode::Copy];
2140 let mut private = desired(clip("m3"), AudioFormat::Mp3);
2141 private.private = true;
2142
2143 let plan = Plan {
2144 actions: vec![
2145 Action::Download {
2146 clip: mirror.clip.clone(),
2147 lineage: LineageContext::own_root(&mirror.clip),
2148 path: mirror.path.clone(),
2149 format: AudioFormat::Mp3,
2150 },
2151 Action::Download {
2152 clip: copy_held.clip.clone(),
2153 lineage: LineageContext::own_root(©_held.clip),
2154 path: copy_held.path.clone(),
2155 format: AudioFormat::Mp3,
2156 },
2157 Action::Download {
2158 clip: private.clip.clone(),
2159 lineage: LineageContext::own_root(&private.clip),
2160 path: private.path.clone(),
2161 format: AudioFormat::Mp3,
2162 },
2163 ],
2164 };
2165 let http = ScriptedHttp::new()
2166 .route("m1.mp3", Reply::ok(b"a".to_vec()))
2167 .route("m2.mp3", Reply::ok(b"b".to_vec()))
2168 .route("m3.mp3", Reply::ok(b"c".to_vec()));
2169 let fs = MemFs::new();
2170 let mut manifest = Manifest::new();
2171
2172 let outcome = run(
2173 &plan,
2174 &mut manifest,
2175 &[mirror, copy_held, private],
2176 &http,
2177 &fs,
2178 &StubFfmpeg::flac(),
2179 &RecordingClock::new(),
2180 &ExecOptions::default(),
2181 );
2182
2183 assert_eq!(outcome.downloaded, 3);
2184 assert!(!manifest.get("m1").unwrap().preserve);
2185 assert!(manifest.get("m2").unwrap().preserve);
2186 assert!(manifest.get("m3").unwrap().preserve);
2187 }
2188
2189 #[test]
2192 fn reformat_writes_new_format_and_removes_old_file() {
2193 let c = clip("n");
2194 let d = desired(c.clone(), AudioFormat::Mp3);
2195 let plan = Plan {
2196 actions: vec![Action::Reformat {
2197 clip: c.clone(),
2198 path: "n.mp3".to_owned(),
2199 from_path: "n.flac".to_owned(),
2200 from: AudioFormat::Flac,
2201 to: AudioFormat::Mp3,
2202 }],
2203 };
2204 let http = ScriptedHttp::new().route("n.mp3", Reply::ok(b"body".to_vec()));
2205 let fs = MemFs::new().with_file("n.flac", b"OLD-FLAC".to_vec());
2206 let mut manifest = Manifest::new();
2207 manifest.insert("n", entry("n.flac", AudioFormat::Flac));
2208
2209 let outcome = run(
2210 &plan,
2211 &mut manifest,
2212 &[d],
2213 &http,
2214 &fs,
2215 &StubFfmpeg::flac(),
2216 &RecordingClock::new(),
2217 &ExecOptions::default(),
2218 );
2219
2220 assert_eq!(outcome.reformatted, 1);
2221 assert!(fs.exists("n.mp3"));
2222 assert!(!fs.exists("n.flac"));
2223 let updated = manifest.get("n").unwrap();
2224 assert_eq!(updated.path, "n.mp3");
2225 assert_eq!(updated.format, AudioFormat::Mp3);
2226 assert_eq!(updated.meta_hash, "m");
2227 }
2228
2229 #[test]
2230 fn retag_rewrites_file_and_updates_hashes() {
2231 let c = clip("o");
2232 let mut d = desired(c.clone(), AudioFormat::Mp3);
2233 d.meta_hash = "new".to_owned();
2234 d.art_hash = "new-art".to_owned();
2235 let existing = tag_mp3(
2236 b"audio",
2237 &TrackMetadata::from_clip(&c, &LineageContext::own_root(&c)),
2238 None,
2239 )
2240 .unwrap();
2241 let fs = MemFs::new().with_file("o.mp3", existing.clone());
2242 let mut manifest = Manifest::new();
2243 let mut start = entry("o.mp3", AudioFormat::Mp3);
2244 start.size = existing.len() as u64;
2245 manifest.insert("o", start);
2246 let plan = Plan {
2247 actions: vec![Action::Retag {
2248 clip: c.clone(),
2249 lineage: LineageContext::own_root(&c),
2250 path: "o.mp3".to_owned(),
2251 }],
2252 };
2253
2254 let outcome = run(
2255 &plan,
2256 &mut manifest,
2257 &[d],
2258 &ScriptedHttp::new(),
2259 &fs,
2260 &StubFfmpeg::flac(),
2261 &RecordingClock::new(),
2262 &ExecOptions::default(),
2263 );
2264
2265 assert_eq!(outcome.retagged, 1);
2266 let updated = manifest.get("o").unwrap();
2267 assert_eq!(updated.meta_hash, "new");
2268 assert_eq!(updated.art_hash, "new-art");
2269 assert_eq!(&fs.read_file("o.mp3").unwrap()[..3], b"ID3");
2270 }
2271
2272 #[test]
2273 fn rename_moves_file_and_updates_manifest_path() {
2274 let c = clip("p");
2275 let mut d = desired(c.clone(), AudioFormat::Mp3);
2276 d.path = "new/p.mp3".to_owned();
2277 let fs = MemFs::new().with_file("old/p.mp3", b"DATA".to_vec());
2278 let mut manifest = Manifest::new();
2279 manifest.insert("p", entry("old/p.mp3", AudioFormat::Mp3));
2280 let plan = Plan {
2281 actions: vec![Action::Rename {
2282 from: "old/p.mp3".to_owned(),
2283 to: "new/p.mp3".to_owned(),
2284 }],
2285 };
2286
2287 let outcome = run(
2288 &plan,
2289 &mut manifest,
2290 &[d],
2291 &ScriptedHttp::new(),
2292 &fs,
2293 &StubFfmpeg::flac(),
2294 &RecordingClock::new(),
2295 &ExecOptions::default(),
2296 );
2297
2298 assert_eq!(outcome.renamed, 1);
2299 assert!(fs.exists("new/p.mp3"));
2300 assert!(!fs.exists("old/p.mp3"));
2301 assert_eq!(manifest.get("p").unwrap().path, "new/p.mp3");
2302 }
2303
2304 #[test]
2305 fn disk_full_rename_aborts_the_run() {
2306 let c = clip("p");
2309 let mut d = desired(c.clone(), AudioFormat::Mp3);
2310 d.path = "new/p.mp3".to_owned();
2311 let fs = MemFs::new()
2312 .with_file("old/p.mp3", b"DATA".to_vec())
2313 .fail_rename_out_of_space("new/p.mp3");
2314 let mut manifest = Manifest::new();
2315 manifest.insert("p", entry("old/p.mp3", AudioFormat::Mp3));
2316 let plan = Plan {
2317 actions: vec![Action::Rename {
2318 from: "old/p.mp3".to_owned(),
2319 to: "new/p.mp3".to_owned(),
2320 }],
2321 };
2322
2323 let outcome = run(
2324 &plan,
2325 &mut manifest,
2326 &[d],
2327 &ScriptedHttp::new(),
2328 &fs,
2329 &StubFfmpeg::flac(),
2330 &RecordingClock::new(),
2331 &ExecOptions::default(),
2332 );
2333
2334 assert_eq!(outcome.status, RunStatus::DiskFull);
2335 assert_eq!(outcome.renamed, 0);
2336 assert_eq!(outcome.failed(), 1);
2337 assert!(outcome.failures[0].reason.contains("disk full"));
2338 assert!(fs.exists("old/p.mp3"));
2340 assert!(!fs.exists("new/p.mp3"));
2341 assert_eq!(manifest.get("p").unwrap().path, "old/p.mp3");
2342 }
2343
2344 #[test]
2345 fn delete_removes_file_and_manifest_entry() {
2346 let fs = MemFs::new().with_file("q.mp3", b"DATA".to_vec());
2347 let mut manifest = Manifest::new();
2348 manifest.insert("q", entry("q.mp3", AudioFormat::Mp3));
2349 let plan = Plan {
2350 actions: vec![Action::Delete {
2351 path: "q.mp3".to_owned(),
2352 clip_id: "q".to_owned(),
2353 }],
2354 };
2355
2356 let outcome = run(
2357 &plan,
2358 &mut manifest,
2359 &[],
2360 &ScriptedHttp::new(),
2361 &fs,
2362 &StubFfmpeg::flac(),
2363 &RecordingClock::new(),
2364 &ExecOptions::default(),
2365 );
2366
2367 assert_eq!(outcome.deleted, 1);
2368 assert!(!fs.exists("q.mp3"));
2369 assert!(manifest.get("q").is_none());
2370 }
2371
2372 #[test]
2373 fn failed_delete_keeps_the_manifest_entry() {
2374 let fs = MemFs::new()
2375 .with_file("s.mp3", b"DATA".to_vec())
2376 .fail_remove("s.mp3");
2377 let mut manifest = Manifest::new();
2378 manifest.insert("s", entry("s.mp3", AudioFormat::Mp3));
2379 let plan = Plan {
2380 actions: vec![Action::Delete {
2381 path: "s.mp3".to_owned(),
2382 clip_id: "s".to_owned(),
2383 }],
2384 };
2385
2386 let outcome = run(
2387 &plan,
2388 &mut manifest,
2389 &[],
2390 &ScriptedHttp::new(),
2391 &fs,
2392 &StubFfmpeg::flac(),
2393 &RecordingClock::new(),
2394 &ExecOptions::default(),
2395 );
2396
2397 assert_eq!(outcome.deleted, 0);
2398 assert_eq!(outcome.failed(), 1);
2399 assert!(manifest.get("s").is_some());
2400 assert!(fs.exists("s.mp3"));
2401 }
2402
2403 #[test]
2404 fn skip_is_a_noop() {
2405 let mut manifest = Manifest::new();
2406 let plan = Plan {
2407 actions: vec![Action::Skip {
2408 clip_id: "r".to_owned(),
2409 }],
2410 };
2411 let outcome = run(
2412 &plan,
2413 &mut manifest,
2414 &[],
2415 &ScriptedHttp::new(),
2416 &MemFs::new(),
2417 &StubFfmpeg::flac(),
2418 &RecordingClock::new(),
2419 &ExecOptions::default(),
2420 );
2421 assert_eq!(outcome.skipped, 1);
2422 assert_eq!(outcome.failed(), 0);
2423 }
2424
2425 #[test]
2428 fn header_helpers_parse_or_ignore() {
2429 let resp = HttpResponse {
2430 status: 200,
2431 headers: vec![("Content-Length".to_owned(), "42".to_owned())],
2432 body: Vec::new(),
2433 };
2434 assert_eq!(content_length(&resp), Some(42));
2435
2436 let bare = HttpResponse {
2437 status: 200,
2438 headers: Vec::new(),
2439 body: Vec::new(),
2440 };
2441 assert_eq!(content_length(&bare), None);
2442 }
2443
2444 #[test]
2445 fn preserve_rule_covers_copy_and_private() {
2446 let base = desired(clip("x"), AudioFormat::Mp3);
2447 assert!(!preserve_for(&base));
2448 let mut copy_held = base.clone();
2449 copy_held.modes = vec![SourceMode::Copy];
2450 assert!(preserve_for(©_held));
2451 let mut private = base.clone();
2452 private.private = true;
2453 assert!(preserve_for(&private));
2454 }
2455
2456 fn fs_new() -> MemFs {
2457 MemFs::new()
2458 }
2459
2460 #[test]
2463 fn skip_sets_preserve_when_a_clip_becomes_copy_held() {
2464 let c = clip("s1");
2465 let mut d = desired(c.clone(), AudioFormat::Mp3);
2466 d.modes = vec![SourceMode::Copy];
2467 let plan = Plan {
2468 actions: vec![Action::Skip {
2469 clip_id: "s1".to_owned(),
2470 }],
2471 };
2472 let mut manifest = Manifest::new();
2473 manifest.insert("s1".to_owned(), entry("s1.mp3", AudioFormat::Mp3));
2474 assert!(!manifest.get("s1").unwrap().preserve);
2475
2476 let outcome = run(
2477 &plan,
2478 &mut manifest,
2479 &[d],
2480 &ScriptedHttp::new(),
2481 &fs_new(),
2482 &StubFfmpeg::flac(),
2483 &RecordingClock::new(),
2484 &ExecOptions::default(),
2485 );
2486
2487 assert_eq!(outcome.skipped, 1);
2488 assert!(
2489 manifest.get("s1").unwrap().preserve,
2490 "a copy-held skip must mark the entry preserved"
2491 );
2492 }
2493
2494 #[test]
2495 fn skip_clears_stale_preserve_when_a_clip_returns_to_mirror_only() {
2496 let c = clip("s2");
2497 let d = desired(c.clone(), AudioFormat::Mp3);
2498 let plan = Plan {
2499 actions: vec![Action::Skip {
2500 clip_id: "s2".to_owned(),
2501 }],
2502 };
2503 let mut manifest = Manifest::new();
2504 let mut stale = entry("s2.mp3", AudioFormat::Mp3);
2505 stale.preserve = true;
2506 manifest.insert("s2".to_owned(), stale);
2507
2508 run(
2509 &plan,
2510 &mut manifest,
2511 &[d],
2512 &ScriptedHttp::new(),
2513 &fs_new(),
2514 &StubFfmpeg::flac(),
2515 &RecordingClock::new(),
2516 &ExecOptions::default(),
2517 );
2518
2519 assert!(
2520 !manifest.get("s2").unwrap().preserve,
2521 "a mirror-only skip must clear a stale preserve marker"
2522 );
2523 }
2524
2525 #[test]
2526 fn flac_render_retries_a_rate_limited_wav_lookup() {
2527 let c = clip("rl");
2528 let d = desired(c.clone(), AudioFormat::Flac);
2529 let plan = Plan {
2530 actions: vec![Action::Download {
2531 clip: c.clone(),
2532 lineage: LineageContext::own_root(&c),
2533 path: d.path.clone(),
2534 format: AudioFormat::Flac,
2535 }],
2536 };
2537 let http = ScriptedHttp::new()
2538 .with_auth()
2539 .route_seq(
2540 "/wav_file/",
2541 vec![
2542 Reply::status(429),
2543 Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/rl.wav"}"#),
2544 ],
2545 )
2546 .route("rl.wav", Reply::ok(b"wav".to_vec()));
2547 let clock = RecordingClock::new();
2548 let mut manifest = Manifest::new();
2549
2550 let outcome = run(
2551 &plan,
2552 &mut manifest,
2553 &[d],
2554 &http,
2555 &fs_new(),
2556 &StubFfmpeg::flac(),
2557 &clock,
2558 &small_poll(),
2559 );
2560
2561 assert_eq!(outcome.downloaded, 1);
2562 assert_eq!(outcome.failed(), 0);
2563 assert_eq!(http.count("/convert_wav/"), 0);
2565 assert_eq!(clock.sleeps(), vec![Duration::from_secs(1)]);
2567 }
2568
2569 #[test]
2572 fn write_artifact_fetches_writes_and_updates_manifest() {
2573 let mut manifest = Manifest::new();
2576 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
2577 let plan = Plan {
2578 actions: vec![Action::WriteArtifact {
2579 kind: ArtifactKind::CoverJpg,
2580 path: "a/cover.jpg".to_owned(),
2581 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
2582 hash: "h1".to_owned(),
2583 owner_id: "a".to_owned(),
2584 content: None,
2585 }],
2586 };
2587 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"jpg-bytes".to_vec()));
2588 let fs = MemFs::new();
2589
2590 let outcome = run(
2591 &plan,
2592 &mut manifest,
2593 &[],
2594 &http,
2595 &fs,
2596 &StubFfmpeg::flac(),
2597 &RecordingClock::new(),
2598 &ExecOptions::default(),
2599 );
2600
2601 assert_eq!(outcome.artifacts_written, 1);
2602 assert_eq!(outcome.failed(), 0);
2603 assert_eq!(outcome.status, RunStatus::Completed);
2604 assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"jpg-bytes");
2605 assert_eq!(
2606 manifest.get("a").unwrap().cover_jpg,
2607 Some(ArtifactState {
2608 path: "a/cover.jpg".to_owned(),
2609 hash: "h1".to_owned(),
2610 })
2611 );
2612 }
2613
2614 #[test]
2615 fn write_text_sidecar_records_slot_with_no_network_fetch() {
2616 let mut manifest = Manifest::new();
2619 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
2620 let plan = Plan {
2621 actions: vec![Action::WriteArtifact {
2622 kind: ArtifactKind::DetailsTxt,
2623 path: "a.details.txt".to_owned(),
2624 source_url: String::new(),
2625 hash: "dh".to_owned(),
2626 owner_id: "a".to_owned(),
2627 content: Some("Title: A\n".to_owned()),
2628 }],
2629 };
2630 let http = ScriptedHttp::new();
2632 let fs = MemFs::new();
2633
2634 let outcome = run(
2635 &plan,
2636 &mut manifest,
2637 &[],
2638 &http,
2639 &fs,
2640 &StubFfmpeg::flac(),
2641 &RecordingClock::new(),
2642 &ExecOptions::default(),
2643 );
2644
2645 assert_eq!(outcome.artifacts_written, 1);
2646 assert_eq!(outcome.failed(), 0);
2647 assert_eq!(fs.read_file("a.details.txt").unwrap(), b"Title: A\n");
2648 assert_eq!(
2649 manifest.get("a").unwrap().details_txt,
2650 Some(ArtifactState {
2651 path: "a.details.txt".to_owned(),
2652 hash: "dh".to_owned(),
2653 })
2654 );
2655 }
2656
2657 #[test]
2658 fn write_lyrics_sidecar_relocation_removes_old_file() {
2659 let mut manifest = Manifest::new();
2662 let mut e = entry("old/a.flac", AudioFormat::Flac);
2663 e.lyrics_txt = Some(ArtifactState {
2664 path: "old/a.lyrics.txt".to_owned(),
2665 hash: "lh".to_owned(),
2666 });
2667 manifest.insert("a", e);
2668 let fs = MemFs::new()
2669 .with_file("old/a.flac", b"AUDIO".to_vec())
2670 .with_file("old/a.lyrics.txt", b"old words\n".to_vec());
2671 let plan = Plan {
2672 actions: vec![Action::WriteArtifact {
2673 kind: ArtifactKind::LyricsTxt,
2674 path: "new/a.lyrics.txt".to_owned(),
2675 source_url: String::new(),
2676 hash: "lh".to_owned(),
2677 owner_id: "a".to_owned(),
2678 content: Some("new words\n".to_owned()),
2679 }],
2680 };
2681
2682 let outcome = run(
2683 &plan,
2684 &mut manifest,
2685 &[],
2686 &ScriptedHttp::new(),
2687 &fs,
2688 &StubFfmpeg::flac(),
2689 &RecordingClock::new(),
2690 &ExecOptions::default(),
2691 );
2692
2693 assert_eq!(outcome.failed(), 0);
2694 assert_eq!(fs.read_file("new/a.lyrics.txt").unwrap(), b"new words\n");
2695 assert!(!fs.exists("old/a.lyrics.txt"));
2696 assert_eq!(
2697 manifest.get("a").unwrap().lyrics_txt.as_ref().unwrap().path,
2698 "new/a.lyrics.txt"
2699 );
2700 }
2701
2702 #[test]
2703 fn sidecar_path_swap_never_deletes_a_file_written_this_run() {
2704 let mut manifest = Manifest::new();
2710 let mut a = entry("a.flac", AudioFormat::Flac);
2711 a.lyrics_txt = Some(ArtifactState {
2712 path: "x.lyrics.txt".to_owned(),
2713 hash: "ah".to_owned(),
2714 });
2715 manifest.insert("a", a);
2716 let mut b = entry("b.flac", AudioFormat::Flac);
2717 b.lyrics_txt = Some(ArtifactState {
2718 path: "y.lyrics.txt".to_owned(),
2719 hash: "bh".to_owned(),
2720 });
2721 manifest.insert("b", b);
2722 let fs = MemFs::new()
2723 .with_file("a.flac", b"A".to_vec())
2724 .with_file("b.flac", b"B".to_vec())
2725 .with_file("x.lyrics.txt", b"A words\n".to_vec())
2726 .with_file("y.lyrics.txt", b"B words\n".to_vec());
2727 let plan = Plan {
2729 actions: vec![
2730 Action::WriteArtifact {
2731 kind: ArtifactKind::LyricsTxt,
2732 path: "y.lyrics.txt".to_owned(),
2733 source_url: String::new(),
2734 hash: "ah".to_owned(),
2735 owner_id: "a".to_owned(),
2736 content: Some("A words\n".to_owned()),
2737 },
2738 Action::WriteArtifact {
2739 kind: ArtifactKind::LyricsTxt,
2740 path: "x.lyrics.txt".to_owned(),
2741 source_url: String::new(),
2742 hash: "bh".to_owned(),
2743 owner_id: "b".to_owned(),
2744 content: Some("B words\n".to_owned()),
2745 },
2746 ],
2747 };
2748
2749 let outcome = run(
2750 &plan,
2751 &mut manifest,
2752 &[],
2753 &ScriptedHttp::new(),
2754 &fs,
2755 &StubFfmpeg::flac(),
2756 &RecordingClock::new(),
2757 &ExecOptions::default(),
2758 );
2759
2760 assert_eq!(outcome.failed(), 0);
2761 assert_eq!(fs.read_file("y.lyrics.txt").unwrap(), b"A words\n");
2763 assert_eq!(fs.read_file("x.lyrics.txt").unwrap(), b"B words\n");
2764 assert_eq!(
2765 manifest.get("a").unwrap().lyrics_txt.as_ref().unwrap().path,
2766 "y.lyrics.txt"
2767 );
2768 assert_eq!(
2769 manifest.get("b").unwrap().lyrics_txt.as_ref().unwrap().path,
2770 "x.lyrics.txt"
2771 );
2772 }
2773
2774 #[test]
2775 fn old_sidecar_kept_when_another_clip_still_references_it() {
2776 let mut manifest = Manifest::new();
2781 let mut a = entry("a.flac", AudioFormat::Flac);
2782 a.lyrics_txt = Some(ArtifactState {
2783 path: "y.lyrics.txt".to_owned(),
2784 hash: "ah".to_owned(),
2785 });
2786 manifest.insert("a", a);
2787 let mut b = entry("b.flac", AudioFormat::Flac);
2788 b.lyrics_txt = Some(ArtifactState {
2789 path: "y.lyrics.txt".to_owned(),
2790 hash: "bh".to_owned(),
2791 });
2792 manifest.insert("b", b);
2793 let fs = MemFs::new()
2794 .with_file("a.flac", b"A".to_vec())
2795 .with_file("b.flac", b"B".to_vec())
2796 .with_file("y.lyrics.txt", b"A words\n".to_vec());
2797 let plan = Plan {
2800 actions: vec![Action::WriteArtifact {
2801 kind: ArtifactKind::LyricsTxt,
2802 path: "x.lyrics.txt".to_owned(),
2803 source_url: String::new(),
2804 hash: "bh".to_owned(),
2805 owner_id: "b".to_owned(),
2806 content: Some("B words\n".to_owned()),
2807 }],
2808 };
2809
2810 let outcome = run(
2811 &plan,
2812 &mut manifest,
2813 &[],
2814 &ScriptedHttp::new(),
2815 &fs,
2816 &StubFfmpeg::flac(),
2817 &RecordingClock::new(),
2818 &ExecOptions::default(),
2819 );
2820
2821 assert_eq!(outcome.failed(), 0);
2822 assert!(
2823 fs.exists("y.lyrics.txt"),
2824 "A's live sidecar must not be deleted"
2825 );
2826 assert_eq!(fs.read_file("x.lyrics.txt").unwrap(), b"B words\n");
2827 }
2828
2829 #[test]
2830 fn shared_old_path_is_reclaimed_when_every_referencing_clip_moves_away() {
2831 let mut manifest = Manifest::new();
2837 let mut a = entry("a.flac", AudioFormat::Flac);
2838 a.lyrics_txt = Some(ArtifactState {
2839 path: "s.lyrics.txt".to_owned(),
2840 hash: "ah".to_owned(),
2841 });
2842 manifest.insert("a", a);
2843 let mut b = entry("b.flac", AudioFormat::Flac);
2844 b.lyrics_txt = Some(ArtifactState {
2845 path: "s.lyrics.txt".to_owned(),
2846 hash: "bh".to_owned(),
2847 });
2848 manifest.insert("b", b);
2849 let fs = MemFs::new()
2850 .with_file("a.flac", b"A".to_vec())
2851 .with_file("b.flac", b"B".to_vec())
2852 .with_file("s.lyrics.txt", b"shared\n".to_vec());
2853 let plan = Plan {
2854 actions: vec![
2855 Action::WriteArtifact {
2856 kind: ArtifactKind::LyricsTxt,
2857 path: "pa.lyrics.txt".to_owned(),
2858 source_url: String::new(),
2859 hash: "ah".to_owned(),
2860 owner_id: "a".to_owned(),
2861 content: Some("A words\n".to_owned()),
2862 },
2863 Action::WriteArtifact {
2864 kind: ArtifactKind::LyricsTxt,
2865 path: "pb.lyrics.txt".to_owned(),
2866 source_url: String::new(),
2867 hash: "bh".to_owned(),
2868 owner_id: "b".to_owned(),
2869 content: Some("B words\n".to_owned()),
2870 },
2871 ],
2872 };
2873
2874 let outcome = run(
2875 &plan,
2876 &mut manifest,
2877 &[],
2878 &ScriptedHttp::new(),
2879 &fs,
2880 &StubFfmpeg::flac(),
2881 &RecordingClock::new(),
2882 &ExecOptions::default(),
2883 );
2884
2885 assert_eq!(outcome.failed(), 0);
2886 assert_eq!(fs.read_file("pa.lyrics.txt").unwrap(), b"A words\n");
2887 assert_eq!(fs.read_file("pb.lyrics.txt").unwrap(), b"B words\n");
2888 assert!(
2889 !fs.exists("s.lyrics.txt"),
2890 "the vacated shared path must be reclaimed, not orphaned"
2891 );
2892 }
2893
2894 #[test]
2895 fn write_text_sidecar_skipped_when_owner_audio_absent() {
2896 let plan = Plan {
2899 actions: vec![Action::WriteArtifact {
2900 kind: ArtifactKind::DetailsTxt,
2901 path: "gone.details.txt".to_owned(),
2902 source_url: String::new(),
2903 hash: "dh".to_owned(),
2904 owner_id: "gone".to_owned(),
2905 content: Some("Title: Gone\n".to_owned()),
2906 }],
2907 };
2908 let fs = MemFs::new();
2909 let mut manifest = Manifest::new();
2910
2911 let outcome = run(
2912 &plan,
2913 &mut manifest,
2914 &[],
2915 &ScriptedHttp::new(),
2916 &fs,
2917 &StubFfmpeg::flac(),
2918 &RecordingClock::new(),
2919 &ExecOptions::default(),
2920 );
2921
2922 assert_eq!(outcome.artifacts_written, 0);
2923 assert_eq!(outcome.skipped, 1);
2924 assert!(!fs.exists("gone.details.txt"));
2925 assert!(manifest.get("gone").is_none());
2926 }
2927
2928 #[test]
2929 fn delete_artifact_removes_file_and_clears_slot() {
2930 let fs = MemFs::new().with_file("a/cover.jpg", b"jpg".to_vec());
2931 let mut manifest = Manifest::new();
2932 let mut e = entry("a.mp3", AudioFormat::Mp3);
2933 e.cover_jpg = Some(ArtifactState {
2934 path: "a/cover.jpg".to_owned(),
2935 hash: "h1".to_owned(),
2936 });
2937 manifest.insert("a", e);
2938 let plan = Plan {
2939 actions: vec![Action::DeleteArtifact {
2940 kind: ArtifactKind::CoverJpg,
2941 path: "a/cover.jpg".to_owned(),
2942 owner_id: "a".to_owned(),
2943 }],
2944 };
2945
2946 let outcome = run(
2947 &plan,
2948 &mut manifest,
2949 &[],
2950 &ScriptedHttp::new(),
2951 &fs,
2952 &StubFfmpeg::flac(),
2953 &RecordingClock::new(),
2954 &ExecOptions::default(),
2955 );
2956
2957 assert_eq!(outcome.artifacts_deleted, 1);
2958 assert!(!fs.exists("a/cover.jpg"));
2959 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
2960 }
2961
2962 #[test]
2963 fn delete_artifact_tolerates_already_absent_file() {
2964 let mut manifest = Manifest::new();
2967 let mut e = entry("a.mp3", AudioFormat::Mp3);
2968 e.cover_jpg = Some(ArtifactState {
2969 path: "a/cover.jpg".to_owned(),
2970 hash: "h1".to_owned(),
2971 });
2972 manifest.insert("a", e);
2973 let plan = Plan {
2974 actions: vec![Action::DeleteArtifact {
2975 kind: ArtifactKind::CoverJpg,
2976 path: "a/cover.jpg".to_owned(),
2977 owner_id: "a".to_owned(),
2978 }],
2979 };
2980
2981 let outcome = run(
2982 &plan,
2983 &mut manifest,
2984 &[],
2985 &ScriptedHttp::new(),
2986 &MemFs::new(),
2987 &StubFfmpeg::flac(),
2988 &RecordingClock::new(),
2989 &ExecOptions::default(),
2990 );
2991
2992 assert_eq!(outcome.artifacts_deleted, 1);
2993 assert_eq!(outcome.failed(), 0);
2994 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
2995 }
2996
2997 #[test]
2998 fn write_artifact_http_failure_is_a_per_clip_failure_not_a_run_abort() {
2999 let mut manifest = Manifest::new();
3002 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
3003 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
3004 let plan = Plan {
3005 actions: vec![
3006 Action::WriteArtifact {
3007 kind: ArtifactKind::CoverJpg,
3008 path: "a/cover.jpg".to_owned(),
3009 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
3010 hash: "h1".to_owned(),
3011 owner_id: "a".to_owned(),
3012 content: None,
3013 },
3014 Action::WriteArtifact {
3015 kind: ArtifactKind::CoverJpg,
3016 path: "b/cover.jpg".to_owned(),
3017 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
3018 hash: "h2".to_owned(),
3019 owner_id: "b".to_owned(),
3020 content: None,
3021 },
3022 ],
3023 };
3024 let http = ScriptedHttp::new()
3025 .route("a/large.jpg", Reply::status(404))
3026 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
3027 let fs = MemFs::new();
3028
3029 let outcome = run(
3030 &plan,
3031 &mut manifest,
3032 &[],
3033 &http,
3034 &fs,
3035 &StubFfmpeg::flac(),
3036 &RecordingClock::new(),
3037 &ExecOptions::default(),
3038 );
3039
3040 assert_eq!(outcome.status, RunStatus::Completed);
3041 assert_eq!(outcome.failed(), 1);
3042 assert_eq!(outcome.failures[0].clip_id, "a");
3043 assert_eq!(outcome.artifacts_written, 1);
3044 assert!(!fs.exists("a/cover.jpg"));
3046 assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
3047 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
3049 assert!(manifest.get("b").unwrap().cover_jpg.is_some());
3050 }
3051
3052 #[test]
3053 fn co_delete_executes_audio_delete_then_artifact_delete() {
3054 let fs = MemFs::new()
3058 .with_file("gone.mp3", b"DATA".to_vec())
3059 .with_file("gone/cover.jpg", b"jpg".to_vec());
3060 let mut manifest = Manifest::new();
3061 let mut e = entry("gone.mp3", AudioFormat::Mp3);
3062 e.cover_jpg = Some(ArtifactState {
3063 path: "gone/cover.jpg".to_owned(),
3064 hash: "h1".to_owned(),
3065 });
3066 manifest.insert("gone", e);
3067 let plan = Plan {
3068 actions: vec![
3069 Action::Delete {
3070 path: "gone.mp3".to_owned(),
3071 clip_id: "gone".to_owned(),
3072 },
3073 Action::DeleteArtifact {
3074 kind: ArtifactKind::CoverJpg,
3075 path: "gone/cover.jpg".to_owned(),
3076 owner_id: "gone".to_owned(),
3077 },
3078 ],
3079 };
3080
3081 let outcome = run(
3082 &plan,
3083 &mut manifest,
3084 &[],
3085 &ScriptedHttp::new(),
3086 &fs,
3087 &StubFfmpeg::flac(),
3088 &RecordingClock::new(),
3089 &ExecOptions::default(),
3090 );
3091
3092 assert_eq!(outcome.deleted, 1);
3093 assert_eq!(outcome.artifacts_deleted, 1);
3094 assert_eq!(outcome.failed(), 0);
3095 assert!(!fs.exists("gone.mp3"));
3096 assert!(!fs.exists("gone/cover.jpg"));
3097 assert!(manifest.get("gone").is_none());
3098 }
3099
3100 #[test]
3101 fn write_artifact_is_skipped_when_the_owner_audio_is_absent() {
3102 let ca = clip("a");
3106 let plan = Plan {
3107 actions: vec![
3108 Action::Download {
3109 clip: ca.clone(),
3110 lineage: LineageContext::own_root(&ca),
3111 path: "a.mp3".to_owned(),
3112 format: AudioFormat::Mp3,
3113 },
3114 Action::WriteArtifact {
3115 kind: ArtifactKind::CoverJpg,
3116 path: "a/cover.jpg".to_owned(),
3117 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
3118 hash: "h1".to_owned(),
3119 owner_id: "a".to_owned(),
3120 content: None,
3121 },
3122 Action::WriteArtifact {
3123 kind: ArtifactKind::CoverJpg,
3124 path: "b/cover.jpg".to_owned(),
3125 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
3126 hash: "h2".to_owned(),
3127 owner_id: "b".to_owned(),
3128 content: None,
3129 },
3130 ],
3131 };
3132 let http = ScriptedHttp::new()
3134 .route("a.mp3", Reply::status(404))
3135 .route("a/large.jpg", Reply::ok(b"jpg-a".to_vec()))
3136 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
3137 let fs = MemFs::new();
3138 let mut manifest = Manifest::new();
3139 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
3141
3142 let outcome = run(
3143 &plan,
3144 &mut manifest,
3145 &[],
3146 &http,
3147 &fs,
3148 &StubFfmpeg::flac(),
3149 &RecordingClock::new(),
3150 &ExecOptions::default(),
3151 );
3152
3153 assert_eq!(outcome.status, RunStatus::Completed);
3154 assert_eq!(outcome.failed(), 1);
3156 assert_eq!(outcome.failures[0].clip_id, "a");
3157 assert_eq!(outcome.skipped, 1);
3158 assert_eq!(http.count("a/large.jpg"), 0);
3160 assert!(!fs.exists("a/cover.jpg"));
3161 assert!(manifest.get("a").is_none());
3162 assert_eq!(outcome.artifacts_written, 1);
3164 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
3165 assert!(manifest.get("b").unwrap().cover_jpg.is_some());
3166 }
3167
3168 #[test]
3169 fn write_artifact_transcodes_animated_cover_to_webp() {
3170 let mut manifest = Manifest::new();
3174 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
3175 let plan = Plan {
3176 actions: vec![Action::WriteArtifact {
3177 kind: ArtifactKind::CoverWebp,
3178 path: "a/cover.webp".to_owned(),
3179 source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
3180 hash: "v1".to_owned(),
3181 owner_id: "a".to_owned(),
3182 content: None,
3183 }],
3184 };
3185 let http = ScriptedHttp::new().route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
3186 let fs = MemFs::new();
3187 let ffmpeg = StubFfmpeg::webp();
3188
3189 let outcome = run(
3190 &plan,
3191 &mut manifest,
3192 &[],
3193 &http,
3194 &fs,
3195 &ffmpeg,
3196 &RecordingClock::new(),
3197 &ExecOptions::default(),
3198 );
3199
3200 assert_eq!(outcome.artifacts_written, 1);
3201 assert_eq!(outcome.failed(), 0);
3202 assert_eq!(outcome.status, RunStatus::Completed);
3203 assert_eq!(http.count("a/video.mp4"), 1);
3205 let written = fs.read_file("a/cover.webp").unwrap();
3206 assert_ne!(written, b"mp4-bytes");
3207 assert!(written.starts_with(b"RIFF"));
3208 assert_eq!(
3209 manifest.get("a").unwrap().cover_webp,
3210 Some(ArtifactState {
3211 path: "a/cover.webp".to_owned(),
3212 hash: "v1".to_owned(),
3213 })
3214 );
3215 }
3216
3217 #[test]
3218 fn write_artifact_webp_transcode_failure_is_per_clip() {
3219 let mut manifest = Manifest::new();
3223 manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
3224 manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
3225 let plan = Plan {
3226 actions: vec![
3227 Action::WriteArtifact {
3228 kind: ArtifactKind::CoverWebp,
3229 path: "a/cover.webp".to_owned(),
3230 source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
3231 hash: "v1".to_owned(),
3232 owner_id: "a".to_owned(),
3233 content: None,
3234 },
3235 Action::WriteArtifact {
3236 kind: ArtifactKind::CoverJpg,
3237 path: "b/cover.jpg".to_owned(),
3238 source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
3239 hash: "h1".to_owned(),
3240 owner_id: "b".to_owned(),
3241 content: None,
3242 },
3243 ],
3244 };
3245 let http = ScriptedHttp::new()
3246 .route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec()))
3247 .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
3248 let fs = MemFs::new();
3249
3250 let outcome = run(
3251 &plan,
3252 &mut manifest,
3253 &[],
3254 &http,
3255 &fs,
3256 &StubFfmpeg::failing(),
3257 &RecordingClock::new(),
3258 &ExecOptions::default(),
3259 );
3260
3261 assert_eq!(outcome.status, RunStatus::Completed);
3262 assert_eq!(outcome.failed(), 1);
3263 assert_eq!(outcome.failures[0].clip_id, "a");
3264 assert!(!fs.exists("a/cover.webp"));
3266 assert_eq!(manifest.get("a").unwrap().cover_webp, None);
3267 assert_eq!(outcome.artifacts_written, 1);
3269 assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
3270 assert!(manifest.get("b").unwrap().cover_jpg.is_some());
3271 }
3272
3273 #[test]
3276 fn folder_jpg_write_records_album_state_and_skips_manifest() {
3277 let mut manifest = Manifest::new();
3280 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
3281 let plan = Plan {
3282 actions: vec![Action::WriteArtifact {
3283 kind: ArtifactKind::FolderJpg,
3284 path: "creator/album/folder.jpg".to_owned(),
3285 source_url: "https://art.suno.ai/root/large.jpg".to_owned(),
3286 hash: "jh".to_owned(),
3287 owner_id: "root".to_owned(),
3288 content: None,
3289 }],
3290 };
3291 let http = ScriptedHttp::new().route("root/large.jpg", Reply::ok(b"folder-jpg".to_vec()));
3292 let fs = MemFs::new();
3293
3294 let outcome = run_with_albums(
3295 &plan,
3296 &mut manifest,
3297 &mut albums,
3298 &[],
3299 &http,
3300 &fs,
3301 &StubFfmpeg::flac(),
3302 &RecordingClock::new(),
3303 &ExecOptions::default(),
3304 );
3305
3306 assert_eq!(outcome.artifacts_written, 1);
3307 assert_eq!(outcome.status, RunStatus::Completed);
3308 assert_eq!(
3309 fs.read_file("creator/album/folder.jpg").unwrap(),
3310 b"folder-jpg"
3311 );
3312 assert_eq!(
3313 albums.get("root").unwrap().folder_jpg,
3314 Some(ArtifactState {
3315 path: "creator/album/folder.jpg".to_owned(),
3316 hash: "jh".to_owned(),
3317 })
3318 );
3319 assert!(manifest.get("root").is_none());
3320 }
3321
3322 #[test]
3323 fn folder_webp_write_transcodes_and_records_album_state() {
3324 let mut manifest = Manifest::new();
3325 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
3326 let plan = Plan {
3327 actions: vec![Action::WriteArtifact {
3328 kind: ArtifactKind::FolderWebp,
3329 path: "creator/album/cover.webp".to_owned(),
3330 source_url: "https://cdn.suno.ai/root/video.mp4".to_owned(),
3331 hash: "wh".to_owned(),
3332 owner_id: "root".to_owned(),
3333 content: None,
3334 }],
3335 };
3336 let http = ScriptedHttp::new().route("root/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
3337 let fs = MemFs::new();
3338
3339 let outcome = run_with_albums(
3340 &plan,
3341 &mut manifest,
3342 &mut albums,
3343 &[],
3344 &http,
3345 &fs,
3346 &StubFfmpeg::webp(),
3347 &RecordingClock::new(),
3348 &ExecOptions::default(),
3349 );
3350
3351 assert_eq!(outcome.artifacts_written, 1);
3352 assert_eq!(outcome.failed(), 0);
3353 let written = fs.read_file("creator/album/cover.webp").unwrap();
3355 assert_ne!(written, b"mp4-bytes");
3356 assert!(written.starts_with(b"RIFF"));
3357 assert_eq!(
3358 albums.get("root").unwrap().folder_webp,
3359 Some(ArtifactState {
3360 path: "creator/album/cover.webp".to_owned(),
3361 hash: "wh".to_owned(),
3362 })
3363 );
3364 }
3365
3366 #[test]
3367 fn folder_art_delete_clears_album_state() {
3368 let fs = MemFs::new().with_file("creator/album/folder.jpg", b"jpg".to_vec());
3369 let mut manifest = Manifest::new();
3370 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
3371 albums.insert(
3372 "root".to_owned(),
3373 AlbumArt {
3374 folder_jpg: Some(ArtifactState {
3375 path: "creator/album/folder.jpg".to_owned(),
3376 hash: "jh".to_owned(),
3377 }),
3378 folder_webp: None,
3379 },
3380 );
3381 let plan = Plan {
3382 actions: vec![Action::DeleteArtifact {
3383 kind: ArtifactKind::FolderJpg,
3384 path: "creator/album/folder.jpg".to_owned(),
3385 owner_id: "root".to_owned(),
3386 }],
3387 };
3388
3389 let outcome = run_with_albums(
3390 &plan,
3391 &mut manifest,
3392 &mut albums,
3393 &[],
3394 &ScriptedHttp::new(),
3395 &fs,
3396 &StubFfmpeg::flac(),
3397 &RecordingClock::new(),
3398 &ExecOptions::default(),
3399 );
3400
3401 assert_eq!(outcome.artifacts_deleted, 1);
3402 assert!(!fs.exists("creator/album/folder.jpg"));
3403 assert!(!albums.contains_key("root"));
3405 }
3406
3407 #[test]
3410 fn playlist_write_uses_inline_content_and_records_state() {
3411 let mut manifest = Manifest::new();
3415 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
3416 let mut playlists: BTreeMap<String, PlaylistState> = BTreeMap::new();
3417 let body = "#EXTM3U\n#PLAYLIST:Road Trip\n#EXTINF:60,One\nA/One.flac\n";
3418 let plan = Plan {
3419 actions: vec![Action::WriteArtifact {
3420 kind: ArtifactKind::Playlist,
3421 path: "Road Trip.m3u8".to_owned(),
3422 source_url: String::new(),
3423 hash: "ph1".to_owned(),
3424 owner_id: "pl1".to_owned(),
3425 content: Some(body.to_owned()),
3426 }],
3427 };
3428 let fs = MemFs::new();
3429
3430 let outcome = run_full(
3431 &plan,
3432 &mut manifest,
3433 &mut albums,
3434 &mut playlists,
3435 &[],
3436 &ScriptedHttp::new(),
3437 &fs,
3438 &StubFfmpeg::flac(),
3439 &RecordingClock::new(),
3440 &ExecOptions::default(),
3441 );
3442
3443 assert_eq!(outcome.artifacts_written, 1);
3444 assert_eq!(outcome.failed(), 0);
3445 assert_eq!(fs.read_file("Road Trip.m3u8").unwrap(), body.as_bytes());
3447 assert_eq!(
3448 playlists.get("pl1"),
3449 Some(&PlaylistState {
3450 name: "Road Trip".to_owned(),
3451 path: "Road Trip.m3u8".to_owned(),
3452 hash: "ph1".to_owned(),
3453 })
3454 );
3455 }
3456
3457 #[test]
3458 fn playlist_delete_removes_file_and_clears_state() {
3459 let fs = MemFs::new().with_file("Old.m3u8", b"#EXTM3U\n".to_vec());
3460 let mut manifest = Manifest::new();
3461 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
3462 let mut playlists: BTreeMap<String, PlaylistState> = BTreeMap::new();
3463 playlists.insert(
3464 "pl1".to_owned(),
3465 PlaylistState {
3466 name: "Old".to_owned(),
3467 path: "Old.m3u8".to_owned(),
3468 hash: "ph1".to_owned(),
3469 },
3470 );
3471 let plan = Plan {
3472 actions: vec![Action::DeleteArtifact {
3473 kind: ArtifactKind::Playlist,
3474 path: "Old.m3u8".to_owned(),
3475 owner_id: "pl1".to_owned(),
3476 }],
3477 };
3478
3479 let outcome = run_full(
3480 &plan,
3481 &mut manifest,
3482 &mut albums,
3483 &mut playlists,
3484 &[],
3485 &ScriptedHttp::new(),
3486 &fs,
3487 &StubFfmpeg::flac(),
3488 &RecordingClock::new(),
3489 &ExecOptions::default(),
3490 );
3491
3492 assert_eq!(outcome.artifacts_deleted, 1);
3493 assert!(!fs.exists("Old.m3u8"));
3494 assert!(
3495 !playlists.contains_key("pl1"),
3496 "the playlist row is cleared on delete"
3497 );
3498 }
3499
3500 #[test]
3503 fn rename_move_relocates_cover_and_prunes_old_album() {
3504 let mut manifest = Manifest::new();
3508 let mut e = entry("Creator/AlbumA/song.flac", AudioFormat::Flac);
3509 e.cover_jpg = Some(ArtifactState {
3510 path: "Creator/AlbumA/cover.jpg".to_owned(),
3511 hash: "h1".to_owned(),
3512 });
3513 manifest.insert("a", e);
3514 let fs = MemFs::new()
3515 .with_file("Creator/AlbumA/song.flac", b"AUDIO".to_vec())
3516 .with_file("Creator/AlbumA/cover.jpg", b"old-jpg".to_vec());
3517 let plan = Plan {
3518 actions: vec![
3519 Action::Rename {
3520 from: "Creator/AlbumA/song.flac".to_owned(),
3521 to: "Creator/AlbumB/song.flac".to_owned(),
3522 },
3523 Action::WriteArtifact {
3524 kind: ArtifactKind::CoverJpg,
3525 path: "Creator/AlbumB/cover.jpg".to_owned(),
3526 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
3527 hash: "h1".to_owned(),
3528 owner_id: "a".to_owned(),
3529 content: None,
3530 },
3531 ],
3532 };
3533 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new-jpg".to_vec()));
3534
3535 let outcome = run(
3536 &plan,
3537 &mut manifest,
3538 &[],
3539 &http,
3540 &fs,
3541 &StubFfmpeg::flac(),
3542 &RecordingClock::new(),
3543 &ExecOptions::default(),
3544 );
3545
3546 assert_eq!(outcome.failed(), 0);
3547 assert!(fs.exists("Creator/AlbumB/song.flac"));
3549 assert_eq!(
3550 fs.read_file("Creator/AlbumB/cover.jpg").unwrap(),
3551 b"new-jpg"
3552 );
3553 assert!(!fs.exists("Creator/AlbumA/cover.jpg"));
3554 assert!(!fs.exists("Creator/AlbumA/song.flac"));
3555 assert_eq!(
3557 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
3558 "Creator/AlbumB/cover.jpg"
3559 );
3560 assert!(!fs.has_dir("Creator/AlbumA"));
3562 assert!(fs.has_dir("Creator/AlbumB"));
3563 }
3564
3565 #[test]
3566 fn rename_move_relocates_folder_art_and_prunes_old_album() {
3567 let mut manifest = Manifest::new();
3570 let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
3571 albums.insert(
3572 "root".to_owned(),
3573 AlbumArt {
3574 folder_jpg: Some(ArtifactState {
3575 path: "Creator/AlbumA/folder.jpg".to_owned(),
3576 hash: "jh".to_owned(),
3577 }),
3578 folder_webp: None,
3579 },
3580 );
3581 let fs = MemFs::new().with_file("Creator/AlbumA/folder.jpg", b"old-folder".to_vec());
3582 let plan = Plan {
3583 actions: vec![Action::WriteArtifact {
3584 kind: ArtifactKind::FolderJpg,
3585 path: "Creator/AlbumB/folder.jpg".to_owned(),
3586 source_url: "https://art.suno.ai/root/large.jpg".to_owned(),
3587 hash: "jh".to_owned(),
3588 owner_id: "root".to_owned(),
3589 content: None,
3590 }],
3591 };
3592 let http = ScriptedHttp::new().route("root/large.jpg", Reply::ok(b"new-folder".to_vec()));
3593
3594 let outcome = run_with_albums(
3595 &plan,
3596 &mut manifest,
3597 &mut albums,
3598 &[],
3599 &http,
3600 &fs,
3601 &StubFfmpeg::flac(),
3602 &RecordingClock::new(),
3603 &ExecOptions::default(),
3604 );
3605
3606 assert_eq!(outcome.failed(), 0);
3607 assert_eq!(
3608 fs.read_file("Creator/AlbumB/folder.jpg").unwrap(),
3609 b"new-folder"
3610 );
3611 assert!(!fs.exists("Creator/AlbumA/folder.jpg"));
3612 assert_eq!(
3613 albums
3614 .get("root")
3615 .unwrap()
3616 .folder_jpg
3617 .as_ref()
3618 .unwrap()
3619 .path,
3620 "Creator/AlbumB/folder.jpg"
3621 );
3622 assert!(!fs.has_dir("Creator/AlbumA"));
3623 assert!(fs.has_dir("Creator/AlbumB"));
3624 }
3625
3626 #[test]
3627 fn prune_empty_dirs_removes_only_empty_dirs() {
3628 let fs = MemFs::new()
3632 .with_file("keep/full/song.flac", b"x".to_vec())
3633 .with_file("hidden/.suno-manifest.json", b"{}".to_vec())
3634 .with_dir("empty/leaf")
3635 .with_dir("nested/a/b/c");
3636
3637 fs.prune_empty_dirs("").unwrap();
3638
3639 for gone in [
3641 "empty",
3642 "empty/leaf",
3643 "nested",
3644 "nested/a",
3645 "nested/a/b",
3646 "nested/a/b/c",
3647 ] {
3648 assert!(!fs.has_dir(gone), "empty dir {gone} should be pruned");
3649 }
3650 assert!(fs.has_dir("keep"));
3652 assert!(fs.has_dir("keep/full"));
3653 assert!(fs.has_dir("hidden"));
3654 assert!(fs.exists("keep/full/song.flac"));
3656 assert!(fs.exists("hidden/.suno-manifest.json"));
3657 }
3658
3659 #[test]
3660 fn prune_empty_dirs_never_removes_the_named_root() {
3661 let fs = MemFs::new().with_dir("empty/leaf");
3664 fs.prune_empty_dirs("empty").unwrap();
3665 assert!(fs.has_dir("empty"), "the named root is never removed");
3666 assert!(!fs.has_dir("empty/leaf"));
3667 }
3668
3669 #[test]
3670 fn old_sidecar_remove_failure_is_per_clip_and_converges_next_run() {
3671 let mut manifest = Manifest::new();
3675 let mut e = entry("a.flac", AudioFormat::Flac);
3676 e.cover_jpg = Some(ArtifactState {
3677 path: "AlbumA/cover.jpg".to_owned(),
3678 hash: "h1".to_owned(),
3679 });
3680 manifest.insert("a", e);
3681 let fs = MemFs::new()
3682 .with_file("a.flac", b"AUDIO".to_vec())
3683 .with_file("AlbumA/cover.jpg", b"old".to_vec());
3684 let plan = Plan {
3685 actions: vec![Action::WriteArtifact {
3686 kind: ArtifactKind::CoverJpg,
3687 path: "AlbumB/cover.jpg".to_owned(),
3688 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
3689 hash: "h1".to_owned(),
3690 owner_id: "a".to_owned(),
3691 content: None,
3692 }],
3693 };
3694 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new".to_vec()));
3695
3696 fs.arm_fail_remove("AlbumA/cover.jpg");
3698 let first = run(
3699 &plan,
3700 &mut manifest,
3701 &[],
3702 &http,
3703 &fs,
3704 &StubFfmpeg::flac(),
3705 &RecordingClock::new(),
3706 &ExecOptions::default(),
3707 );
3708 assert_eq!(
3709 first.status,
3710 RunStatus::Completed,
3711 "a remove failure never aborts the run"
3712 );
3713 assert_eq!(first.failed(), 1);
3714 assert!(fs.exists("AlbumB/cover.jpg"));
3716 assert!(fs.exists("AlbumA/cover.jpg"));
3717 assert_eq!(
3718 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
3719 "AlbumA/cover.jpg"
3720 );
3721 assert!(fs.has_dir("AlbumA"), "the orphan keeps its directory alive");
3722
3723 fs.disarm_fail_remove("AlbumA/cover.jpg");
3725 let second = run(
3726 &plan,
3727 &mut manifest,
3728 &[],
3729 &http,
3730 &fs,
3731 &StubFfmpeg::flac(),
3732 &RecordingClock::new(),
3733 &ExecOptions::default(),
3734 );
3735 assert_eq!(second.failed(), 0);
3736 assert!(fs.exists("AlbumB/cover.jpg"));
3737 assert!(!fs.exists("AlbumA/cover.jpg"), "no orphan persists");
3738 assert_eq!(
3739 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
3740 "AlbumB/cover.jpg"
3741 );
3742 assert!(!fs.has_dir("AlbumA"), "the emptied directory is pruned");
3743 }
3744
3745 #[test]
3746 fn same_path_artifact_rewrite_does_no_remove_and_prunes_nothing() {
3747 let mut manifest = Manifest::new();
3752 let mut e = entry("Album/a.mp3", AudioFormat::Mp3);
3753 e.cover_jpg = Some(ArtifactState {
3754 path: "Album/cover.jpg".to_owned(),
3755 hash: "h1".to_owned(),
3756 });
3757 manifest.insert("a", e);
3758 let fs = MemFs::new()
3759 .with_file("Album/a.mp3", b"AUDIO".to_vec())
3760 .with_file("Album/cover.jpg", b"old".to_vec());
3761 fs.arm_fail_remove("Album/cover.jpg");
3762 let plan = Plan {
3763 actions: vec![Action::WriteArtifact {
3764 kind: ArtifactKind::CoverJpg,
3765 path: "Album/cover.jpg".to_owned(),
3766 source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
3767 hash: "h2".to_owned(),
3768 owner_id: "a".to_owned(),
3769 content: None,
3770 }],
3771 };
3772 let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new".to_vec()));
3773
3774 let outcome = run(
3775 &plan,
3776 &mut manifest,
3777 &[],
3778 &http,
3779 &fs,
3780 &StubFfmpeg::flac(),
3781 &RecordingClock::new(),
3782 &ExecOptions::default(),
3783 );
3784
3785 assert_eq!(
3786 outcome.failed(),
3787 0,
3788 "no remove is attempted, so the armed failure never fires"
3789 );
3790 assert_eq!(outcome.artifacts_written, 1);
3791 assert_eq!(fs.read_file("Album/cover.jpg").unwrap(), b"new");
3792 assert_eq!(
3793 manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().hash,
3794 "h2"
3795 );
3796 assert!(fs.has_dir("Album"));
3798 }
3799}