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