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