Skip to main content

suno_core/
executor.rs

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