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