Skip to main content

suno_core/
executor.rs

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