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