Skip to main content

suno_core/
executor.rs

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