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