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