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