Skip to main content

suno_core/
executor.rs

1//! The download executor: it applies a reconcile [`Plan`] to disk through ports.
2//!
3//! Reconcile decides *what* to do; the executor does it. It is async and pure
4//! orchestration: every side effect goes through a port ([`Http`] for the
5//! network, [`Filesystem`] for disk, [`Ffmpeg`] for transcoding, [`Clock`] for
6//! waiting), so the whole pipeline is exercised in tests with in-memory doubles
7//! and no real IO, network, or sleeping.
8//!
9//! Safety is the point of this module. A wrong write or delete damages the
10//! user's library, so the executor:
11//!
12//! - writes only atomically (SYNC-13): a failed write leaves the prior file
13//!   intact, because the [`Filesystem`] adapter stages a temp file and renames;
14//! - verifies size (SYNC-14): a download whose body disagrees with the
15//!   provider's `Content-Length` is treated as truncated and retried, and a
16//!   written file whose on-disk size disagrees with the bytes written is a
17//!   failure, never a recorded success;
18//! - classifies errors (SYNC-17): an auth failure or a full disk stops the
19//!   account run (with an auth or disk-full status) and is never retried;
20//!   transient failures (timeouts, 5xx,
21//!   transport, 429) are retried a bounded number of times then recorded and
22//!   skipped; permanent failures are recorded and skipped; and a single clip's
23//!   failure never aborts the run;
24//! - backs off on rate limits (SYNC-16) through the injected [`Clock`], honouring
25//!   a `Retry-After` hint.
26//!
27//! The executor only ever sets the manifest's [`preserve`](ManifestEntry::preserve)
28//! marker on an entry it writes, and only deletes a path whose removal the
29//! [`Filesystem`] confirms. Higher-level safety (empty-listing abort, the
30//! destructive-sync confirmation, exit codes) is the caller's job.
31
32use 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/// Tunables for one [`execute`] run.
52#[derive(Debug, Clone)]
53pub struct ExecOptions {
54    /// How many times a transient failure is retried before record-and-skip.
55    pub max_retries: u32,
56    /// How many times to poll for a server-side WAV render before giving up.
57    pub wav_poll_attempts: u32,
58    /// How long to wait between WAV render polls.
59    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/// How an [`execute`] run ended.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum RunStatus {
75    /// Every action was attempted; some may have failed and been skipped.
76    #[default]
77    Completed,
78    /// An auth failure stopped the run early; remaining actions were not tried.
79    AuthAborted,
80    /// The disk filled; the run stopped early rather than failing every
81    /// remaining clip. Remaining actions were not tried.
82    DiskFull,
83}
84
85/// One action that could not be applied, for the run summary and failure log.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Failure {
88    /// The clip the failed action concerned (or a path when no id applies).
89    pub clip_id: String,
90    /// A short, secret-free reason.
91    pub reason: String,
92}
93
94/// The result of applying a [`Plan`]: per-action counts and the failure list.
95#[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    /// Actions that failed and were skipped (auth, transient-exhausted, or
106    /// permanent). The run continued past each one unless it was an auth or
107    /// disk-full abort.
108    pub failures: Vec<Failure>,
109    /// How the run ended.
110    pub status: RunStatus,
111}
112
113impl ExecOutcome {
114    /// Number of failed actions.
115    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
133/// The IO ports the executor drives, grouped so one value threads them through.
134///
135/// `client` is the only `&mut` port: it performs the authenticated WAV render
136/// flow and so mutates its cached session. The rest are shared references.
137pub struct Ports<'a, H, F, G, C> {
138    /// Performs the authenticated WAV render and poll flow.
139    pub client: &'a mut SunoClient<C>,
140    /// The public network port (CDN audio, rendered WAV, cover art).
141    pub http: &'a H,
142    /// The disk port.
143    pub fs: &'a F,
144    /// The transcode port (WAV to FLAC).
145    pub ffmpeg: &'a G,
146    /// The backoff and poll delay port.
147    pub clock: &'a C,
148}
149
150/// Apply `plan` to disk, updating `manifest` and `albums` in place, and return
151/// the outcome.
152///
153/// `desired` carries the per-clip metadata and art hashes plus the source modes
154/// that decide the [`preserve`](ManifestEntry::preserve) marker; it is indexed
155/// by clip id (and by target path, for renames) so each written entry records
156/// the right hashes and protection. `albums` is the album-art store, keyed by
157/// stable root id: folder-art writes and deletes record their state there rather
158/// than on the per-clip `manifest`. `ports` bundles the authenticated client and
159/// the network, disk, transcode, and backoff ports. A single clip's failure
160/// never aborts the run, except an auth failure or a full disk, which stop it
161/// with [`RunStatus::AuthAborted`] or [`RunStatus::DiskFull`].
162pub 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    // Renames and deletes can leave an album directory empty; prune those ghost
214    // directories bottom-up. This runs on both the completed and the aborted
215    // paths, and is best-effort: a prune failure is only a missed tidy that the
216    // next run repeats, never a reason to fail the run.
217    let _ = fs.prune_empty_dirs("");
218    outcome
219}
220
221/// What an applied action did, for the outcome counters.
222enum Effect {
223    Downloaded,
224    Reformatted,
225    Retagged,
226    Renamed,
227    Deleted,
228    Skipped,
229    ArtifactWritten,
230    ArtifactDeleted,
231}
232
233/// How a failure should be handled (SYNC-17).
234#[derive(Debug, Clone, Copy)]
235enum Class {
236    /// Stop the account run; do not retry.
237    Auth,
238    /// Stop the account run: a full disk is systemic, like auth, so aborting
239    /// beats skipping every remaining clip (each of which would first burn a
240    /// server-side WAV-render budget before failing the same way).
241    Disk,
242    /// Retry a bounded number of times, then record and skip.
243    Transient,
244    /// Record and skip immediately.
245    Permanent,
246}
247
248/// A classified action failure attributed to a clip.
249struct Fail {
250    class: Class,
251    clip_id: String,
252    reason: String,
253}
254
255/// The run-ending status for a failure class, or `None` when the failure is
256/// per-clip and the run continues.
257fn 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
297/// Whether an artifact kind is album-scoped folder art (owned by a root id and
298/// recorded on the album store) rather than a per-clip sidecar (recorded on the
299/// manifest).
300fn is_album_kind(kind: ArtifactKind) -> bool {
301    matches!(kind, ArtifactKind::FolderJpg | ArtifactKind::FolderWebp)
302}
303
304/// True for the library-scoped playlist artifact, routed to the playlist store.
305fn is_playlist_kind(kind: ArtifactKind) -> bool {
306    matches!(kind, ArtifactKind::Playlist)
307}
308
309/// True for a per-song sidecar (`cover.jpg`/`cover.webp`), whose write requires
310/// the owning clip's manifest entry. Album and playlist kinds are keyed by a
311/// root/playlist id that is deliberately absent from the manifest.
312fn 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
323/// Recover a playlist's display name from its `.m3u8` path's file stem.
324///
325/// The path is `<sanitised name>.m3u8` at the library root, so the stem is the
326/// sanitised name. Reconcile only ever reads a playlist's `path` and `hash`, so
327/// this recovered name is a convenience for humans and its lossiness (the
328/// sanitiser is not reversible) never affects a decision.
329fn 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
336/// A classified fetch failure, not yet attributed to a clip.
337struct 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
369/// The shared, read-only context threaded through every action handler.
370struct 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    /// Apply one action, returning what it did or why it failed.
388    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    /// Fetch, tag, and write a new file, then record the manifest entry.
457    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    /// Re-encode to a new format at the new path, then remove the old file.
473    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        // A Reformat action carries no lineage, so recover it from the desired
483        // set (the same context that drove naming and the hash), falling back to
484        // a self-rooted context when the clip is not in the current selection.
485        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        // The new file is safely in place; only now drop the old rendering.
493        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    /// Re-tag the existing file in place to match current metadata and art.
501    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            // WAV carries no embedded tags; just record the new hashes so the
517            // next run sees them as current and stops retagging.
518            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    /// Move the file and update the entry's path (and protection).
540    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    /// Remove the file and drop the manifest entry.
573    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    /// Fetch an artifact's bytes, write them atomically, then record the sidecar
582    /// on the owning manifest entry.
583    ///
584    /// The fetch and write share the audio path's resilience: `fetch_bytes`
585    /// retries transient failures and verifies `Content-Length`, and
586    /// `write_verify` confirms the on-disk size. A failure is attributed to the
587    /// owning clip and returned as a per-clip [`Fail`], so a bad sidecar never
588    /// aborts the whole run (only an auth failure or a full disk does, matching
589    /// audio).
590    ///
591    /// The bytes written depend on the kind: a static cover is the fetched image
592    /// verbatim, while an animated cover is the clip's MP4 preview transcoded to
593    /// WebP through the ffmpeg port (see [`artifact_bytes`](Self::artifact_bytes)).
594    ///
595    /// A sidecar is only ever written for a clip whose audio is present: a
596    /// successful `Download`/`Reformat` creates the manifest entry earlier in
597    /// this run, and a prior-run clip already has one. So an absent owning entry
598    /// means the audio failed or never existed this run; we skip (no fetch, no
599    /// write) rather than strand an untracked sidecar with no owning audio.
600    ///
601    /// Folder art ([`FolderJpg`](ArtifactKind::FolderJpg) /
602    /// [`FolderWebp`](ArtifactKind::FolderWebp)) is album-scoped: its `owner_id`
603    /// is the album's stable root id, not a manifest clip, so it skips the
604    /// manifest presence guard and records its state on the album store instead.
605    ///
606    /// When a title or album change moves the audio, reconcile re-emits this
607    /// write at the NEW path; this handler then removes the sidecar left at the
608    /// artifact's previously tracked path, moving it rather than orphaning it.
609    /// The removal happens only after the new file is safely written, and a
610    /// remove failure returns before the state slot advances, so the next run
611    /// re-plans the identical write and retries — self-healing, never an orphan.
612    #[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        // A per-song sidecar needs its owning clip's manifest entry; album and
626        // playlist kinds are keyed elsewhere and skip this guard.
627        if is_per_clip_kind(kind) && manifest.get(owner_id).is_none() {
628            return Ok(Effect::Skipped);
629        }
630        // Capture the path this artifact was last tracked at, BEFORE the slot is
631        // overwritten below, so a path-changing write (a title/album rename that
632        // moves the audio) can clean up the old sidecar it left behind. Cover
633        // kinds live on the manifest, folder kinds on the album store; playlists
634        // reconcile their own old-path delete and so opt out here.
635        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        // A generated artifact (a playlist) carries its body inline and never
663        // touches the network; a fetched one pulls (and transcodes) its source.
664        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        // The new sidecar is safely in place; only now drop a stale copy left at
670        // the previous path (the audio moved). `remove` is idempotent, so an
671        // already-absent old file is fine. On a genuine remove failure we return
672        // BEFORE updating the slot, leaving the manifest/album pointing at the
673        // old path: the next run sees the same path drift, re-plans this write,
674        // and retries the cleanup — convergent, no orphan persists.
675        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    /// Produce a sidecar's bytes from its source, branching on kind.
717    ///
718    /// An animated cover — a per-clip [`CoverWebp`](ArtifactKind::CoverWebp) or an
719    /// album [`FolderWebp`](ArtifactKind::FolderWebp) — fetches the clip's
720    /// `video_cover` MP4 preview and transcodes it to an animated WebP through the
721    /// ffmpeg port; every other kind is the fetched source verbatim (e.g. the
722    /// static [`CoverJpg`](ArtifactKind::CoverJpg) or album
723    /// [`FolderJpg`](ArtifactKind::FolderJpg) image). A fetch or transcode failure
724    /// is attributed to the owning clip and is a per-clip [`Fail`], except a
725    /// disk-full transcode, which aborts the run like the audio FLAC path.
726    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            // The text sidecars are generated and always carry inline content, so
749            // `write_artifact` never reaches this fetch path for them. Guard it so
750            // a future miswiring fails loudly rather than fetching a URL.
751            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    /// Remove a sidecar file and clear its slot on the owning manifest entry.
759    ///
760    /// `remove` is idempotent, so an already-absent sidecar is not a failure.
761    /// When the owning entry is already gone (its audio was deleted earlier this
762    /// run, co-deleting the sidecar), there is no slot to clear and that is fine.
763    ///
764    /// Folder art is album-scoped: its slot is cleared on the album store keyed by
765    /// the album's root id, not on a manifest clip.
766    ///
767    /// The audio `Delete` is applied before its sidecar `DeleteArtifact`. If the
768    /// sidecar removal fails after the audio is already gone, the sidecar lingers
769    /// untracked, but the design stays convergent rather than transactional: the
770    /// next run re-plans the same removal and retries, and any directory it would
771    /// have emptied is pruned once the file finally clears.
772    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    /// Download (and transcode/tag) the audio for `clip` in `format`.
800    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    /// Resolve the rendered WAV URL and download it.
837    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    /// Read the WAV URL, requesting a render and polling if it is not ready.
848    ///
849    /// `None` means the render did not become ready within the poll budget; the
850    /// caller treats that as a non-fatal transient failure, never a silent skip.
851    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    /// Read the rendered WAV URL, retrying transient API failures with backoff
870    /// (SYNC-16/17), so the default FLAC path is as resilient as the CDN path.
871    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    /// Ask Suno to render a WAV, retrying transient API failures with backoff.
889    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    /// Classify a core error from the authenticated WAV flow. On a transient
903    /// class within budget, back off through the [`Clock`] and return `None` to
904    /// retry; otherwise return the terminal [`Fail`].
905    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    /// GET `url`, retrying transient failures with backoff, verifying size.
917    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    /// Download cover art, trying each candidate URL in order; `None` is fine.
937    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    /// Write `bytes` atomically, then confirm the on-disk size (SYNC-13/14).
950    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    /// Build the manifest entry for a freshly written file.
969    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    /// Refresh an existing entry's hashes, protection, and (optionally) size.
982    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    /// Refresh only an entry's preserve marker from the current desired state.
997    ///
998    /// A clip can gain or lose copy/private protection with no file change, which
999    /// reconcile emits as a [`Skip`](Action::Skip). Refreshing here keeps the
1000    /// persisted marker a faithful image of live protection, so the cross-run
1001    /// delete guard (SYNC-8) never reads it stale.
1002    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
1011/// Build a manifest entry from the desired record (SYNC-8 preserve rule).
1012fn 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
1024/// Whether a written entry must be preserved across runs: held by any copy
1025/// source, or private. The reconcile delete guard reads this marker later.
1026fn preserve_for(d: &Desired) -> bool {
1027    d.private || d.modes.contains(&SourceMode::Copy)
1028}
1029
1030/// Classify one HTTP result into bytes or a [`FetchError`] (SYNC-14/17).
1031fn 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
1075/// Map a core [`Error`] from the authenticated WAV flow to a [`Fail`].
1076fn 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
1087/// The provider-reported body size from `Content-Length`, if present and valid.
1088fn 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    // ── Download: MP3 ───────────────────────────────────────────────
1243
1244    #[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    // ── Download: FLAC render + transcode ───────────────────────────
1321
1322    #[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        // The URL was ready immediately, so no render request and no polling.
1362        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    // ── Cover fallback ──────────────────────────────────────────────
1487
1488    #[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    // ── Atomic write and size verification (SYNC-13/14) ─────────────
1532
1533    #[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    // ── Reliability policy (SYNC-16/17) ─────────────────────────────
1602
1603    #[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        // The authenticated WAV-render endpoint rejects auth even after a JWT
1741        // refresh: that is a bad token, so the whole run aborts rather than
1742        // hammering every clip. A CDN media rejection, by contrast, does not.
1743        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    // ── Disk-full aborts the run (issue #17) ────────────────────────
1767
1768    #[test]
1769    fn disk_full_primary_write_aborts_the_run() {
1770        // Two MP3 downloads; the first write is out of space. That is systemic,
1771        // so the run aborts before the second is even attempted: exactly one
1772        // failure is recorded and its reason names the disk-full cause.
1773        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        // The second clip was never fetched: the run aborted first.
1816        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        // The scratch disk fills during the FLAC re-encode; a WAV rendered, but
1823        // there is nowhere to stage the transcode, so the run aborts.
1824        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        // A sidecar write (not a primary download) also aborts on a full disk:
1875        // the owning audio is present, the cover fetch succeeds, but the sidecar
1876        // cannot be written.
1877        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        // The sidecar slot was never recorded: the write failed before it.
1908        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        // write_verify fails before any manifest insert, so a re-download that
1914        // hits a full disk leaves the prior entry (and file) exactly as it was.
1915        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        // A CDN media fetch is unauthenticated, so a 403 is a per-asset
1972        // rejection (often transient), not a bad token: the clip is retried
1973        // then recorded and skipped, and the run carries on to the rest.
1974        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    // ── preserve marker (SYNC-8) ────────────────────────────────────
2046
2047    #[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(&copy_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    // ── Reformat / Retag / Rename / Delete / Skip ───────────────────
2103
2104    #[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        // A move onto a full disk is systemic like a full-disk write: the run
2220        // aborts with DiskFull and the source file is left untouched.
2221        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        // The source is untouched: the move never happened.
2252        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    // ── Pure helpers ────────────────────────────────────────────────
2339
2340    #[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(&copy_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    // ── Skip refreshes the preserve marker (SYNC-8 cross-run) ────────
2374
2375    #[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        // The render was ready on retry, so no fresh convert_wav was needed.
2477        assert_eq!(http.count("/convert_wav/"), 0);
2478        // One transient backoff (1s base), not the 5s poll interval.
2479        assert_eq!(clock.sleeps(), vec![Duration::from_secs(1)]);
2480    }
2481
2482    // ── Phase 6: artifact actions ───────────────────────────────────
2483
2484    #[test]
2485    fn write_artifact_fetches_writes_and_updates_manifest() {
2486        // The owning entry exists (its audio was kept this run); WriteArtifact
2487        // fetches the source, writes the sidecar, and records it on the entry.
2488        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        // A generated text sidecar carries its body inline, so it is written
2530        // verbatim with NO HTTP fetch and the details slot records its state.
2531        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        // An empty HTTP script: any fetch would fail, proving none happens.
2544        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        // The audio moved, so the lyrics sidecar is re-emitted at the new path;
2573        // the executor writes the new file and prunes the stale one.
2574        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        // A text sidecar for a clip with no manifest entry (its audio download
2618        // failed) must be skipped, never writing an untracked file.
2619        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        // `remove` is idempotent, so co-deleting a sidecar that is already gone
2686        // is not a failure.
2687        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        // A permanent 404 on one sidecar fetch is recorded as a per-clip failure;
2721        // the run continues and the following WriteArtifact still succeeds.
2722        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        // The failed sidecar left no file and no manifest record.
2766        assert!(!fs.exists("a/cover.jpg"));
2767        assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
2768        // The following sidecar was written and recorded.
2769        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        // The plan orders the audio Delete before its sidecar DeleteArtifact.
2776        // The audio delete removes the manifest entry; the sidecar delete then
2777        // removes the file and tolerates the now-absent entry.
2778        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        // A clip whose Download fails leaves no manifest entry, so its following
2824        // WriteArtifact must not strand an untracked sidecar: it is skipped with
2825        // no fetch and no write. A following healthy clip still succeeds.
2826        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        // The Download's audio 404s (permanent), so no entry for "a" is created.
2854        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        // "b" already has audio (a prior-run clip), so its sidecar write proceeds.
2861        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        // The audio download is the only failure; the orphan artifact is skipped.
2876        assert_eq!(outcome.failed(), 1);
2877        assert_eq!(outcome.failures[0].clip_id, "a");
2878        assert_eq!(outcome.skipped, 1);
2879        // The orphan sidecar was neither fetched nor written, and left no record.
2880        assert_eq!(http.count("a/large.jpg"), 0);
2881        assert!(!fs.exists("a/cover.jpg"));
2882        assert!(manifest.get("a").is_none());
2883        // The healthy clip's sidecar still succeeded.
2884        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        // A CoverWebp fetches the clip's MP4 preview, runs it through the ffmpeg
2892        // port, and writes the transcoded WebP (not the fetched MP4), recording
2893        // the sidecar on the owning entry.
2894        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        // The fetched MP4 was transcoded: the file holds the ffmpeg WebP output.
2925        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        // A transcode failure is attributed to the owning clip: it is a per-clip
2941        // failure, the run completes, no sidecar is written, and the slot stays
2942        // empty. A healthy static cover in the same run still succeeds.
2943        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        // The animated cover failed to transcode: nothing written, slot empty.
2986        assert!(!fs.exists("a/cover.webp"));
2987        assert_eq!(manifest.get("a").unwrap().cover_webp, None);
2988        // The static cover in the same run still succeeded.
2989        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    // ── Phase 8: folder art routes to the album store ───────────────
2995
2996    #[test]
2997    fn folder_jpg_write_records_album_state_and_skips_manifest() {
2998        // Folder art is owned by the album root id, not a manifest clip: it
2999        // writes even with an empty manifest and records on the album store.
3000        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        // The MP4 was transcoded to WebP, not written verbatim.
3075        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        // The album row had only the one kind, so it is pruned entirely.
3125        assert!(!albums.contains_key("root"));
3126    }
3127
3128    // ── Phase 9: playlist artifacts ─────────────────────────────────
3129
3130    #[test]
3131    fn playlist_write_uses_inline_content_and_records_state() {
3132        // A playlist body is generated, carried inline. With an empty manifest
3133        // and NO http routes, the write still succeeds — proving it skipped the
3134        // network — and records the playlist store keyed by the playlist id.
3135        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        // The exact inline bytes were written, verbatim.
3167        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    // ── Phase 10: old-sidecar cleanup on move + empty-dir prune ──────
3222
3223    #[test]
3224    fn rename_move_relocates_cover_and_prunes_old_album() {
3225        // A title/album change moves the audio (Rename) and re-emits the cover
3226        // at the NEW path. The old cover must be removed and the now-empty old
3227        // album directory pruned, leaving no orphan sidecar and no ghost dir.
3228        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        // Audio moved, the new cover was written, the old cover removed.
3269        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        // The manifest cover slot now points at the new path.
3277        assert_eq!(
3278            manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
3279            "Creator/AlbumB/cover.jpg"
3280        );
3281        // The emptied old album directory is pruned; the new one survives.
3282        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        // An album rename moves folder.jpg: the old file is removed, the album
3289        // store slot advanced to the new path, and the emptied dir pruned.
3290        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        // A direct exercise of the prune port's safety guarantees on a mixed
3350        // tree: nested empties go, anything holding a file (hidden ones too)
3351        // stays, and no file is touched.
3352        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        // Every empty directory, however deeply nested, is pruned bottom-up.
3361        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        // A directory holding any file — including only a hidden dotfile — stays.
3372        assert!(fs.has_dir("keep"));
3373        assert!(fs.has_dir("keep/full"));
3374        assert!(fs.has_dir("hidden"));
3375        // No file was touched.
3376        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        // Pruning under a named root clears its empty children but keeps the
3383        // root itself, even when the root is now empty.
3384        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        // If removing the old sidecar fails, the write is a per-clip failure
3393        // that never aborts the run and does NOT advance the state slot, so the
3394        // next identical run re-attempts the cleanup and the tree converges.
3395        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        // Run 1: the old-cover remove is forced to fail.
3418        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        // The new cover is written but the old one lingers and the slot is stale.
3436        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        // Run 2: the same plan re-runs with the fault cleared and converges.
3445        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        // The idempotent case: a content-only cover rewrite (hash drift, path
3469        // unchanged) attempts no remove and prunes no live directory. A remove
3470        // failure is armed on the cover path, so any spurious remove would
3471        // surface as a failure — none does.
3472        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        // The live directory is untouched by prune.
3518        assert!(fs.has_dir("Album"));
3519    }
3520}