Skip to main content

suno_core/
executor.rs

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