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::collections::HashSet;
36use std::sync::Mutex;
37use std::time::Duration;
38
39use futures_util::lock::Mutex as AsyncMutex;
40use futures_util::stream::{self, StreamExt};
41
42use crate::backoff::{backoff_delay, retry_after};
43use crate::client::SunoClient;
44use crate::clock::Clock;
45use crate::config::{AudioFormat, StemFormat};
46use crate::error::Error;
47use crate::ffmpeg::{Ffmpeg, WebpEncodeSettings};
48use crate::fs::Filesystem;
49use crate::graph::{AlbumArt, PlaylistState};
50use crate::http::{Http, HttpRequest};
51use crate::lineage::LineageContext;
52use crate::lyrics::AlignedLyrics;
53use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
54use crate::model::Clip;
55use crate::reconcile::{
56    Action, ArtifactKind, Desired, Plan, SourceMode, set_manifest_artifact, set_manifest_stem,
57};
58use crate::tag::{TrackMetadata, tag_flac, tag_mp3};
59
60/// The shared Suno client behind an async mutex, so concurrent audio work can
61/// serialise its order-sensitive API calls (JWT refresh, adaptive limiter)
62/// without a runtime-specific lock. Held only for the brief WAV-render calls;
63/// the heavy CDN/transcode/tag work runs unlocked.
64type ClientLock<'a, C> = AsyncMutex<&'a mut SunoClient<C>>;
65
66/// Tunables for one [`execute`] run.
67#[derive(Debug, Clone)]
68pub struct ExecOptions {
69    /// How many times a transient failure is retried before record-and-skip.
70    pub max_retries: u32,
71    /// How many times to poll for a server-side WAV render before giving up.
72    pub wav_poll_attempts: u32,
73    /// How long to wait between WAV render polls.
74    pub wav_poll_interval: Duration,
75    /// How many clips' audio to fetch, transcode, and tag concurrently. Clamped
76    /// to at least one, so a zero collapses to sequential rather than stalling.
77    pub concurrency: u32,
78}
79
80impl Default for ExecOptions {
81    fn default() -> Self {
82        Self {
83            max_retries: 3,
84            wav_poll_attempts: 24,
85            wav_poll_interval: Duration::from_secs(5),
86            concurrency: 4,
87        }
88    }
89}
90
91/// How an [`execute`] run ended.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum RunStatus {
94    /// Every action was attempted; some may have failed and been skipped.
95    #[default]
96    Completed,
97    /// An auth failure stopped the run early; remaining actions were not tried.
98    AuthAborted,
99    /// The disk filled; the run stopped early rather than failing every
100    /// remaining clip. Remaining actions were not tried.
101    DiskFull,
102}
103
104/// One action that could not be applied, for the run summary and failure log.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct Failure {
107    /// The clip the failed action concerned (or a path when no id applies).
108    pub clip_id: String,
109    /// A short, secret-free reason.
110    pub reason: String,
111}
112
113/// The result of applying a [`Plan`]: per-action counts and the failure list.
114#[derive(Debug, Clone, Default, PartialEq, Eq)]
115pub struct ExecOutcome {
116    pub downloaded: usize,
117    pub reformatted: usize,
118    pub retagged: usize,
119    pub renamed: usize,
120    pub deleted: usize,
121    pub skipped: usize,
122    pub artifacts_written: usize,
123    pub artifacts_deleted: usize,
124    /// Actions that failed and were skipped (auth, transient-exhausted, or
125    /// permanent). The run continued past each one unless it was an auth or
126    /// disk-full abort.
127    pub failures: Vec<Failure>,
128    /// How the run ended.
129    pub status: RunStatus,
130}
131
132impl ExecOutcome {
133    /// Number of failed actions.
134    pub fn failed(&self) -> usize {
135        self.failures.len()
136    }
137
138    fn record(&mut self, effect: Effect) {
139        match effect {
140            Effect::Downloaded => self.downloaded += 1,
141            Effect::Reformatted => self.reformatted += 1,
142            Effect::Retagged => self.retagged += 1,
143            Effect::Renamed => self.renamed += 1,
144            Effect::Deleted => self.deleted += 1,
145            Effect::Skipped => self.skipped += 1,
146            Effect::ArtifactWritten => self.artifacts_written += 1,
147            Effect::ArtifactDeleted => self.artifacts_deleted += 1,
148        }
149    }
150}
151
152/// The IO ports the executor drives, grouped so one value threads them through.
153///
154/// `client` is the only `&mut` port: it performs the authenticated WAV render
155/// flow and so mutates its cached session. The rest are shared references.
156pub struct Ports<'a, H, F, G, C> {
157    /// Performs the authenticated WAV render and poll flow.
158    pub client: &'a mut SunoClient<C>,
159    /// The public network port (CDN audio, rendered WAV, cover art).
160    pub http: &'a H,
161    /// The disk port.
162    pub fs: &'a F,
163    /// The transcode port (WAV to FLAC).
164    pub ffmpeg: &'a G,
165    /// The backoff and poll delay port.
166    pub clock: &'a C,
167}
168
169/// Apply `plan` to disk, updating `manifest` and `albums` in place, and return
170/// the outcome.
171///
172/// `desired` carries the per-clip metadata and art hashes plus the source modes
173/// that decide the [`preserve`](ManifestEntry::preserve) marker; it is indexed
174/// by clip id (and by target path, for renames) so each written entry records
175/// the right hashes and protection. `albums` is the album-art store, keyed by
176/// stable root id: folder-art writes and deletes record their state there rather
177/// than on the per-clip `manifest`. `ports` bundles the authenticated client and
178/// the network, disk, transcode, and backoff ports. A single clip's failure
179/// never aborts the run, except an auth failure or a full disk, which stop it
180/// with [`RunStatus::AuthAborted`] or [`RunStatus::DiskFull`].
181///
182/// The audio-producing actions ([`Download`](Action::Download) and
183/// [`Reformat`](Action::Reformat)) run concurrently, bounded by
184/// [`ExecOptions::concurrency`]: their slow parts (WAV render, CDN download,
185/// transcode, tag) overlap while the order-sensitive Suno API calls are
186/// serialised behind an async mutex over the shared [`SunoClient`], keeping the
187/// adaptive limiter and JWT refresh correct. The remaining actions (retag,
188/// rename, delete, and artifact writes/deletes) then run serially in plan order.
189///
190/// The outcome is deterministic regardless of completion order: concurrent audio
191/// results are committed to the manifest in plan-index order, so the same plan
192/// always yields the same manifest and counts whatever the concurrency level. A
193/// per-clip failure is recorded and the run continues; only an auth failure or a
194/// full disk aborts, and it does so promptly by stopping further audio work.
195///
196/// `synced` carries this run's fetched aligned (synced) lyrics keyed by clip id;
197/// it is the caller's IO result, not part of the pure plan. Audio tagging embeds
198/// a clip's entry as an MP3 `SYLT` frame and as the plain `USLT`/`LYRICS` text
199/// (FLAC), so a clip absent from the map (an instrumental, a WAV target, or a
200/// run with the feature off) is tagged exactly as before. The synced `.lrc`
201/// sidecar itself is a generated artifact whose body the caller has already
202/// resolved into the plan, so it is written like any other text sidecar.
203#[allow(clippy::too_many_arguments)]
204pub async fn execute<H, F, G, C>(
205    plan: &Plan,
206    manifest: &mut Manifest,
207    albums: &mut BTreeMap<String, AlbumArt>,
208    playlists: &mut BTreeMap<String, PlaylistState>,
209    desired: &[Desired],
210    synced: &HashMap<String, AlignedLyrics>,
211    ports: Ports<'_, H, F, G, C>,
212    opts: &ExecOptions,
213) -> ExecOutcome
214where
215    H: Http,
216    F: Filesystem,
217    G: Ffmpeg,
218    C: Clock,
219{
220    let Ports {
221        client,
222        http,
223        fs,
224        ffmpeg,
225        clock,
226    } = ports;
227    let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
228    let by_path: HashMap<&str, &Desired> = desired.iter().map(|d| (d.path.as_str(), d)).collect();
229    // Every path this run writes, so the inline old-sidecar cleanup never removes
230    // a file another action produces this run (the non-planned twin of
231    // `suppress_path_aliasing`).
232    let write_targets: BTreeSet<String> = plan
233        .actions
234        .iter()
235        .filter_map(|a| match a {
236            Action::Download { path, .. }
237            | Action::Reformat { path, .. }
238            | Action::WriteArtifact { path, .. }
239            | Action::WriteStem { path, .. } => Some(path.clone()),
240            Action::Rename { to, .. } => Some(to.clone()),
241            _ => None,
242        })
243        .collect();
244    // How many tracked artifact slots reference each path. The inline old-path
245    // cleanup removes a path only once nothing else holds it: each slot that
246    // moves away decrements its reference, and the removal fires only when the
247    // count reaches zero and no action writes the path this run. This keeps a
248    // live file a co-referencing slot still owns (a prior failed swap can leave
249    // two clips sharing a path) while letting the last slot to leave reclaim it,
250    // so nothing is orphaned either (#76).
251    let mut tracked_paths: HashMap<String, u32> = HashMap::new();
252    for (_, entry) in manifest.iter() {
253        for path in entry.artifact_paths() {
254            *tracked_paths.entry(path.to_owned()).or_default() += 1;
255        }
256    }
257    for art in albums.values() {
258        for state in [art.folder_jpg.as_ref(), art.folder_webp.as_ref()]
259            .into_iter()
260            .flatten()
261        {
262            *tracked_paths.entry(state.path.clone()).or_default() += 1;
263        }
264    }
265    for playlist in playlists.values() {
266        *tracked_paths.entry(playlist.path.clone()).or_default() += 1;
267    }
268    // Static cover art is otherwise fetched twice per clip (#89): once to embed
269    // in the audio tag and once for the per-song `.jpg` sidecar, both from the
270    // same CDN URL. The audio producer caches each cover it embeds here, keyed by
271    // URL, and the sidecar write drains it rather than re-fetching. Only URLs a
272    // `CoverJpg` sidecar will fetch this run are cached, and the sidecar removes
273    // its entry on use, so the map holds at most the covers for the clips in
274    // flight (bounded by `concurrency`), never the whole library.
275    let cover_wanted: HashSet<&str> = plan
276        .actions
277        .iter()
278        .filter_map(|action| match action {
279            Action::WriteArtifact {
280                kind: ArtifactKind::CoverJpg,
281                source_url,
282                ..
283            } if !source_url.is_empty() => Some(source_url.as_str()),
284            _ => None,
285        })
286        .collect();
287    let cover_cache: Mutex<HashMap<String, Vec<u8>>> = Mutex::new(HashMap::new());
288    let ctx = Ctx {
289        http,
290        fs,
291        ffmpeg,
292        clock,
293        opts,
294        by_id: &by_id,
295        by_path: &by_path,
296        synced,
297        write_targets: &write_targets,
298        cover_cache: &cover_cache,
299        cover_wanted: &cover_wanted,
300    };
301
302    let mut outcome = ExecOutcome::default();
303
304    // The audio-producing actions ([`Download`](Action::Download) /
305    // [`Reformat`](Action::Reformat)) render concurrently, but their work is
306    // deliberately split so that NO destination write, file removal, or manifest
307    // update happens off the plan's order:
308    //
309    // - the parallel producers ([`prepare_audio`](Ctx::prepare_audio)) do only
310    //   the slow, side-effect-free work (fetch the CDN/WAV bytes, transcode, and
311    //   tag), returning the tagged bytes; and
312    // - a single serial committer below writes those bytes to the destination,
313    //   removes any superseded file, and records the manifest entry, in strict
314    //   plan-index order, interleaved with the non-audio actions.
315    //
316    // The shared client is the only `&mut` port and its API calls must stay
317    // ordered, so it rides behind an async mutex; each producer locks it only for
318    // the brief WAV-render calls and runs the heavy work unlocked. Renders are
319    // yielded in plan order and bounded to `concurrency` in flight (and buffered),
320    // so at most about `concurrency` tagged payloads are ever held in memory -
321    // never the whole library.
322    let client_lock = AsyncMutex::new(client);
323    let concurrency = opts.concurrency.max(1) as usize;
324    let ctx_ref = &ctx;
325    let client_lock_ref = &client_lock;
326    let mut renders = stream::iter(
327        plan.actions
328            .iter()
329            .filter(|action| is_audio_action(action))
330            .map(|action| async move { ctx_ref.prepare_audio(client_lock_ref, action).await }),
331    )
332    .buffered(concurrency);
333
334    for action in &plan.actions {
335        // Audio actions pull their pre-rendered bytes (yielded in plan order) and
336        // commit them here; every other action applies its own effect. Both the
337        // audio commit and the non-audio apply run serially, so all destination
338        // and manifest effects keep the plan's order exactly as the sequential
339        // executor did.
340        let result = if is_audio_action(action) {
341            match renders.next().await {
342                Some(Ok(rendered)) => ctx.commit_audio(manifest, rendered),
343                Some(Err(fail)) => Err(fail),
344                None => unreachable!("buffered yields one result per audio action"),
345            }
346        } else {
347            ctx.apply(
348                client_lock_ref,
349                action,
350                manifest,
351                albums,
352                playlists,
353                &mut tracked_paths,
354            )
355            .await
356        };
357        match result {
358            Ok(effect) => outcome.record(effect),
359            Err(fail) => {
360                let abort = abort_status(fail.class);
361                outcome.failures.push(Failure {
362                    clip_id: fail.clip_id,
363                    reason: fail.reason,
364                });
365                if let Some(status) = abort {
366                    // A systemic abort stops the run. Dropping the render stream
367                    // cancels any in-flight or completed-but-uncommitted producer;
368                    // because producers touch nothing on disk, the destination and
369                    // manifest are left exactly as the committed prefix wrote them,
370                    // with no untracked files and no removed-but-referenced file.
371                    outcome.status = status;
372                    break;
373                }
374            }
375        }
376    }
377    drop(renders);
378
379    // Renames and deletes can leave an album directory empty; prune those ghost
380    // directories bottom-up. This runs on both the completed and the aborted
381    // paths, and is best-effort: a prune failure is only a missed tidy that the
382    // next run repeats, never a reason to fail the run.
383    let _ = fs.prune_empty_dirs("");
384    outcome
385}
386
387/// Whether an action produces audio: it fetches, transcodes, and tags a clip's
388/// file. Its slow render runs in the concurrent phase; its destination write and
389/// manifest update are committed serially in plan order. Everything else touches
390/// the manifest, album, or playlist stores directly and runs serially.
391fn is_audio_action(action: &Action) -> bool {
392    matches!(action, Action::Download { .. } | Action::Reformat { .. })
393}
394
395/// A rendered-but-uncommitted audio result: the tagged bytes plus what the serial
396/// committer needs to place them. Produced concurrently and side-effect-free (no
397/// destination write, no removal, no manifest touch); [`commit_audio`] applies
398/// all of those in plan order.
399struct RenderedAudio {
400    clip_id: String,
401    path: String,
402    format: AudioFormat,
403    /// The superseded file to remove after the new one lands (a [`Reformat`]),
404    /// or `None` for a plain [`Download`].
405    from_path: Option<String>,
406    effect: Effect,
407    bytes: Vec<u8>,
408}
409
410/// What an applied action did, for the outcome counters.
411enum Effect {
412    Downloaded,
413    Reformatted,
414    Retagged,
415    Renamed,
416    Deleted,
417    Skipped,
418    ArtifactWritten,
419    ArtifactDeleted,
420}
421
422/// How a failure should be handled (SYNC-17).
423#[derive(Debug, Clone, Copy)]
424enum Class {
425    /// Stop the account run; do not retry.
426    Auth,
427    /// Stop the account run: a full disk is systemic, like auth, so aborting
428    /// beats skipping every remaining clip (each of which would first burn a
429    /// server-side WAV-render budget before failing the same way).
430    Disk,
431    /// Retry a bounded number of times, then record and skip.
432    Transient,
433    /// Record and skip immediately.
434    Permanent,
435}
436
437/// A classified action failure attributed to a clip.
438struct Fail {
439    class: Class,
440    clip_id: String,
441    reason: String,
442}
443
444/// The run-ending status for a failure class, or `None` when the failure is
445/// per-clip and the run continues.
446fn abort_status(class: Class) -> Option<RunStatus> {
447    match class {
448        Class::Auth => Some(RunStatus::AuthAborted),
449        Class::Disk => Some(RunStatus::DiskFull),
450        Class::Transient | Class::Permanent => None,
451    }
452}
453
454fn auth_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
455    Fail {
456        class: Class::Auth,
457        clip_id: clip_id.into(),
458        reason: reason.into(),
459    }
460}
461
462fn transient_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
463    Fail {
464        class: Class::Transient,
465        clip_id: clip_id.into(),
466        reason: reason.into(),
467    }
468}
469
470fn permanent_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
471    Fail {
472        class: Class::Permanent,
473        clip_id: clip_id.into(),
474        reason: reason.into(),
475    }
476}
477
478fn disk_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
479    Fail {
480        class: Class::Disk,
481        clip_id: clip_id.into(),
482        reason: reason.into(),
483    }
484}
485
486/// Whether an artifact kind is album-scoped folder art (owned by a root id and
487/// recorded on the album store) rather than a per-clip sidecar (recorded on the
488/// manifest).
489fn is_album_kind(kind: ArtifactKind) -> bool {
490    matches!(kind, ArtifactKind::FolderJpg | ArtifactKind::FolderWebp)
491}
492
493/// True for the library-scoped playlist artifact, routed to the playlist store.
494fn is_playlist_kind(kind: ArtifactKind) -> bool {
495    matches!(kind, ArtifactKind::Playlist)
496}
497
498/// True for a per-song sidecar (`cover.jpg`/`cover.webp`), whose write requires
499/// the owning clip's manifest entry. Album and playlist kinds are keyed by a
500/// root/playlist id that is deliberately absent from the manifest.
501fn is_per_clip_kind(kind: ArtifactKind) -> bool {
502    matches!(
503        kind,
504        ArtifactKind::CoverJpg
505            | ArtifactKind::CoverWebp
506            | ArtifactKind::DetailsTxt
507            | ArtifactKind::LyricsTxt
508            | ArtifactKind::Lrc
509            | ArtifactKind::VideoMp4
510    )
511}
512
513/// Recover a playlist's display name from its `.m3u8` path's file stem.
514///
515/// The path is `<sanitised name>.m3u8` at the library root, so the stem is the
516/// sanitised name. Reconcile only ever reads a playlist's `path` and `hash`, so
517/// this recovered name is a convenience for humans and its lossiness (the
518/// sanitiser is not reversible) never affects a decision.
519fn playlist_name_from_path(path: &str) -> String {
520    std::path::Path::new(path)
521        .file_stem()
522        .map(|stem| stem.to_string_lossy().into_owned())
523        .unwrap_or_default()
524}
525
526/// A classified fetch failure, not yet attributed to a clip.
527struct FetchError {
528    class: Class,
529    reason: String,
530    retry_after: Option<Duration>,
531}
532
533impl FetchError {
534    fn transient(reason: impl Into<String>, retry_after: Option<Duration>) -> Self {
535        Self {
536            class: Class::Transient,
537            reason: reason.into(),
538            retry_after,
539        }
540    }
541
542    fn permanent(reason: impl Into<String>) -> Self {
543        Self {
544            class: Class::Permanent,
545            reason: reason.into(),
546            retry_after: None,
547        }
548    }
549
550    fn attribute(self, clip_id: &str) -> Fail {
551        Fail {
552            class: self.class,
553            clip_id: clip_id.to_owned(),
554            reason: self.reason,
555        }
556    }
557}
558
559/// The shared, read-only context threaded through every action handler.
560struct Ctx<'a, H, F, G, C> {
561    http: &'a H,
562    fs: &'a F,
563    ffmpeg: &'a G,
564    clock: &'a C,
565    opts: &'a ExecOptions,
566    by_id: &'a HashMap<&'a str, &'a Desired>,
567    by_path: &'a HashMap<&'a str, &'a Desired>,
568    /// This run's fetched aligned (synced) lyrics, keyed by clip id. Audio
569    /// tagging reads a clip's entry to embed an MP3 `SYLT` frame and the plain
570    /// lyric text; a clip absent here is tagged exactly as before. Populated by
571    /// the caller (the fetch is IO), so the engine stays free of direct IO.
572    synced: &'a HashMap<String, AlignedLyrics>,
573    /// Every destination path this run writes (audio downloads and reformats,
574    /// artifact writes, and rename targets). The inline old-sidecar cleanup in
575    /// [`write_artifact`](Ctx::write_artifact) skips any path in this set, so a
576    /// path swap between two clips can never delete a file the same run just
577    /// wrote. This mirrors [`suppress_path_aliasing`] for the one removal that
578    /// is not itself a planned action.
579    write_targets: &'a BTreeSet<String>,
580    /// Static cover art the audio producer already fetched to embed in the tag,
581    /// keyed by CDN URL, so the matching per-song `.jpg` sidecar reuses it rather
582    /// than fetching the same image again (#89). Only URLs a `CoverJpg` sidecar
583    /// will fetch are inserted (see `cover_wanted`) and each is removed on use, so
584    /// the map stays bounded to the clips in flight. A plain mutex guards it: the
585    /// concurrent producers only ever insert, and the lock is never held across an
586    /// await.
587    cover_cache: &'a Mutex<HashMap<String, Vec<u8>>>,
588    /// The cover URLs a `CoverJpg` sidecar will fetch this run. The producer caches
589    /// a cover only when its URL is here, so a clip whose cover is embedded but
590    /// never written as a sidecar leaves no bytes stranded in `cover_cache`.
591    cover_wanted: &'a HashSet<&'a str>,
592}
593
594impl<H, F, G, C> Ctx<'_, H, F, G, C>
595where
596    H: Http,
597    F: Filesystem,
598    G: Ffmpeg,
599    C: Clock,
600{
601    /// Apply one non-audio action, returning what it did or why it failed.
602    ///
603    /// Audio actions ([`Download`](Action::Download) /
604    /// [`Reformat`](Action::Reformat)) run in the concurrent phase through
605    /// [`prepare_audio`](Self::prepare_audio) and never reach here.
606    async fn apply(
607        &self,
608        client_lock: &ClientLock<'_, C>,
609        action: &Action,
610        manifest: &mut Manifest,
611        albums: &mut BTreeMap<String, AlbumArt>,
612        playlists: &mut BTreeMap<String, PlaylistState>,
613        tracked_paths: &mut HashMap<String, u32>,
614    ) -> Result<Effect, Fail> {
615        match action {
616            Action::Download { .. } | Action::Reformat { .. } => {
617                unreachable!("audio actions are applied in the concurrent phase")
618            }
619            Action::Retag {
620                clip,
621                lineage,
622                path,
623            } => self.retag(manifest, clip, lineage, path).await,
624            Action::Rename { from, to } => self.rename(manifest, from, to),
625            Action::Delete { path, clip_id } => self.delete(manifest, path, clip_id),
626            Action::Skip { clip_id } => {
627                self.refresh_preserve(manifest, clip_id);
628                Ok(Effect::Skipped)
629            }
630            Action::WriteArtifact {
631                kind,
632                path,
633                source_url,
634                hash,
635                owner_id,
636                content,
637            } => {
638                self.write_artifact(
639                    manifest,
640                    albums,
641                    playlists,
642                    *kind,
643                    path,
644                    source_url,
645                    hash,
646                    owner_id,
647                    content.as_deref(),
648                    tracked_paths,
649                )
650                .await
651            }
652            Action::DeleteArtifact {
653                kind,
654                path,
655                owner_id,
656            } => self.delete_artifact(manifest, albums, playlists, *kind, path, owner_id),
657            Action::WriteStem {
658                clip_id,
659                key,
660                stem_id,
661                path,
662                source_url,
663                format,
664                hash,
665            } => {
666                self.write_stem(
667                    client_lock,
668                    manifest,
669                    clip_id,
670                    key,
671                    stem_id,
672                    path,
673                    source_url,
674                    *format,
675                    hash,
676                )
677                .await
678            }
679            Action::DeleteStem { clip_id, key, path } => {
680                self.delete_stem(manifest, clip_id, key, path)
681            }
682        }
683    }
684
685    /// Render one audio action's tagged bytes, side-effect-free.
686    ///
687    /// This is the concurrent part: it fetches, transcodes, and tags the file
688    /// (through shared ports, plus the client behind `client_lock`), then returns
689    /// the bytes and where they must go. It deliberately writes nothing, removes
690    /// nothing, and never touches `manifest`, so many run at once and an aborted
691    /// run can drop them with no destination or manifest effect. The serial
692    /// [`commit_audio`](Self::commit_audio) applies those effects in plan order.
693    async fn prepare_audio(
694        &self,
695        client_lock: &ClientLock<'_, C>,
696        action: &Action,
697    ) -> Result<RenderedAudio, Fail> {
698        match action {
699            Action::Download {
700                clip,
701                lineage,
702                path,
703                format,
704            } => {
705                let bytes = self
706                    .produce_audio(client_lock, clip, lineage, *format)
707                    .await?;
708                Ok(RenderedAudio {
709                    clip_id: clip.id.clone(),
710                    path: path.clone(),
711                    format: *format,
712                    from_path: None,
713                    effect: Effect::Downloaded,
714                    bytes,
715                })
716            }
717            Action::Reformat {
718                clip,
719                path,
720                from_path,
721                from: _,
722                to,
723            } => {
724                // A Reformat action carries no lineage, so recover it from the
725                // desired set (the same context that drove naming and the hash),
726                // falling back to a self-rooted context when the clip is not in
727                // the current selection.
728                let lineage = self
729                    .by_id
730                    .get(clip.id.as_str())
731                    .map(|d| d.lineage.clone())
732                    .unwrap_or_else(|| LineageContext::own_root(clip));
733                let bytes = self.produce_audio(client_lock, clip, &lineage, *to).await?;
734                Ok(RenderedAudio {
735                    clip_id: clip.id.clone(),
736                    path: path.clone(),
737                    format: *to,
738                    from_path: Some(from_path.clone()),
739                    effect: Effect::Reformatted,
740                    bytes,
741                })
742            }
743            _ => unreachable!("prepare_audio only handles audio actions"),
744        }
745    }
746
747    /// Commit one rendered audio result serially, in plan order.
748    ///
749    /// Writes the tagged bytes to the destination, then, for a [`Reformat`], drops
750    /// the superseded file, then records the manifest entry. Ordering the write
751    /// before the removal keeps a crash from losing both copies; keeping all of
752    /// this off the concurrent phase preserves the sequential executor's plan-order
753    /// guarantee for every destination and manifest effect.
754    fn commit_audio(
755        &self,
756        manifest: &mut Manifest,
757        rendered: RenderedAudio,
758    ) -> Result<Effect, Fail> {
759        let RenderedAudio {
760            clip_id,
761            path,
762            format,
763            from_path,
764            effect,
765            bytes,
766        } = rendered;
767        let size = self.write_verify(&clip_id, &path, &bytes)?;
768        if let Some(from) = from_path {
769            // The new file is safely in place; only now drop the old rendering.
770            self.fs.remove(&from).map_err(|err| {
771                permanent_fail(&clip_id, format!("could not remove old file: {err}"))
772            })?;
773        }
774        manifest.insert(clip_id.clone(), self.entry(&clip_id, &path, format, size));
775        Ok(effect)
776    }
777
778    /// Re-tag the existing file in place to match current metadata and art.
779    async fn retag(
780        &self,
781        manifest: &mut Manifest,
782        clip: &Clip,
783        lineage: &LineageContext,
784        path: &str,
785    ) -> Result<Effect, Fail> {
786        let Some(format) = manifest.get(&clip.id).map(|entry| entry.format) else {
787            return Err(permanent_fail(
788                &clip.id,
789                "retag target missing from manifest",
790            ));
791        };
792
793        if format == AudioFormat::Wav {
794            // WAV carries no embedded tags; just record the new hashes so the
795            // next run sees them as current and stops retagging.
796            self.refresh_hashes(manifest, &clip.id, None);
797            return Ok(Effect::Retagged);
798        }
799
800        let (meta, synced) = self.track_meta(clip, lineage);
801        let cover = self.fetch_cover(clip).await;
802        let existing = self
803            .fs
804            .read(path)
805            .map_err(|err| permanent_fail(&clip.id, format!("could not read for retag: {err}")))?;
806        let tagged = match format {
807            AudioFormat::Mp3 => tag_mp3(&existing, &meta, cover.as_deref(), synced),
808            AudioFormat::Flac => tag_flac(&existing, &meta, cover.as_deref()),
809            AudioFormat::Wav => unreachable!("WAV handled above"),
810        }
811        .map_err(|err| permanent_fail(&clip.id, err.to_string()))?;
812        let size = self.write_verify(&clip.id, path, &tagged)?;
813        self.refresh_hashes(manifest, &clip.id, Some(size));
814        Ok(Effect::Retagged)
815    }
816
817    /// Move the file and update the entry's path (and protection).
818    fn rename(&self, manifest: &mut Manifest, from: &str, to: &str) -> Result<Effect, Fail> {
819        let label = self
820            .by_path
821            .get(to)
822            .map(|d| d.clip.id.clone())
823            .unwrap_or_else(|| to.to_owned());
824        self.fs.rename(from, to).map_err(|err| {
825            if err.is_out_of_space() {
826                disk_fail(label, "disk full: no space left to rename")
827            } else {
828                permanent_fail(label, format!("rename failed: {err}"))
829            }
830        })?;
831
832        let clip_id = self.by_path.get(to).map(|d| d.clip.id.clone()).or_else(|| {
833            manifest
834                .entries
835                .iter()
836                .find(|(_, entry)| entry.path == from)
837                .map(|(id, _)| id.clone())
838        });
839        if let Some(id) = clip_id
840            && let Some(entry) = manifest.entries.get_mut(&id)
841        {
842            entry.path = to.to_owned();
843            if let Some(d) = self.by_path.get(to) {
844                entry.preserve = preserve_for(d);
845            }
846        }
847        Ok(Effect::Renamed)
848    }
849
850    /// Remove the file and drop the manifest entry.
851    fn delete(&self, manifest: &mut Manifest, path: &str, clip_id: &str) -> Result<Effect, Fail> {
852        self.fs
853            .remove(path)
854            .map_err(|err| permanent_fail(clip_id, format!("delete failed: {err}")))?;
855        manifest.remove(clip_id);
856        Ok(Effect::Deleted)
857    }
858
859    /// Fetch an artifact's bytes, write them atomically, then record the sidecar
860    /// on the owning manifest entry.
861    ///
862    /// The fetch and write share the audio path's resilience: `fetch_bytes`
863    /// retries transient failures and verifies `Content-Length`, and
864    /// `write_verify` confirms the on-disk size. A failure is attributed to the
865    /// owning clip and returned as a per-clip [`Fail`], so a bad sidecar never
866    /// aborts the whole run (only an auth failure or a full disk does, matching
867    /// audio).
868    ///
869    /// The bytes written depend on the kind: a static cover is the fetched image
870    /// verbatim, while an animated cover is the clip's MP4 preview transcoded to
871    /// WebP through the ffmpeg port (see [`artifact_bytes`](Self::artifact_bytes)).
872    ///
873    /// A sidecar is only ever written for a clip whose audio is present: a
874    /// successful `Download`/`Reformat` creates the manifest entry earlier in
875    /// this run, and a prior-run clip already has one. So an absent owning entry
876    /// means the audio failed or never existed this run; we skip (no fetch, no
877    /// write) rather than strand an untracked sidecar with no owning audio.
878    ///
879    /// Folder art ([`FolderJpg`](ArtifactKind::FolderJpg) /
880    /// [`FolderWebp`](ArtifactKind::FolderWebp)) is album-scoped: its `owner_id`
881    /// is the album's stable root id, not a manifest clip, so it skips the
882    /// manifest presence guard and records its state on the album store instead.
883    ///
884    /// When a title or album change moves the audio, reconcile re-emits this
885    /// write at the NEW path; this handler then removes the sidecar left at the
886    /// artifact's previously tracked path, moving it rather than orphaning it.
887    /// The removal happens only after the new file is safely written, and a
888    /// remove failure returns before the state slot advances, so the next run
889    /// re-plans the identical write and retries — self-healing, never an orphan.
890    #[allow(clippy::too_many_arguments)]
891    async fn write_artifact(
892        &self,
893        manifest: &mut Manifest,
894        albums: &mut BTreeMap<String, AlbumArt>,
895        playlists: &mut BTreeMap<String, PlaylistState>,
896        kind: ArtifactKind,
897        path: &str,
898        source_url: &str,
899        hash: &str,
900        owner_id: &str,
901        content: Option<&str>,
902        tracked_paths: &mut HashMap<String, u32>,
903    ) -> Result<Effect, Fail> {
904        // A per-song sidecar needs its owning clip's manifest entry; album and
905        // playlist kinds are keyed elsewhere and skip this guard.
906        if is_per_clip_kind(kind) && manifest.get(owner_id).is_none() {
907            // The owning audio never landed this run, so this sidecar is skipped
908            // and will never drain a cover the producer cached for it. Drop that
909            // entry now: an insert without a matching sidecar write must not
910            // outlive its clip, keeping `cover_cache` bounded to the clips in
911            // flight (#89). A non-cover kind has no entry here, so this is a
912            // harmless no-op for them.
913            self.cover_cache
914                .lock()
915                .expect("cover cache mutex poisoned")
916                .remove(source_url);
917            return Ok(Effect::Skipped);
918        }
919        // Capture the path this artifact was last tracked at, BEFORE the slot is
920        // overwritten below, so a path-changing write (a title/album rename that
921        // moves the audio) can clean up the old sidecar it left behind. Cover
922        // kinds live on the manifest, folder kinds on the album store; playlists
923        // reconcile their own old-path delete and so opt out here.
924        let old_path = match kind {
925            ArtifactKind::CoverJpg => manifest
926                .get(owner_id)
927                .and_then(|e| e.cover_jpg.as_ref())
928                .map(|s| s.path.clone()),
929            ArtifactKind::CoverWebp => manifest
930                .get(owner_id)
931                .and_then(|e| e.cover_webp.as_ref())
932                .map(|s| s.path.clone()),
933            ArtifactKind::DetailsTxt => manifest
934                .get(owner_id)
935                .and_then(|e| e.details_txt.as_ref())
936                .map(|s| s.path.clone()),
937            ArtifactKind::LyricsTxt => manifest
938                .get(owner_id)
939                .and_then(|e| e.lyrics_txt.as_ref())
940                .map(|s| s.path.clone()),
941            ArtifactKind::Lrc => manifest
942                .get(owner_id)
943                .and_then(|e| e.lrc.as_ref())
944                .map(|s| s.path.clone()),
945            ArtifactKind::VideoMp4 => manifest
946                .get(owner_id)
947                .and_then(|e| e.video_mp4.as_ref())
948                .map(|s| s.path.clone()),
949            ArtifactKind::FolderJpg | ArtifactKind::FolderWebp => albums
950                .get(owner_id)
951                .and_then(|a| a.artifact(kind))
952                .map(|s| s.path.clone()),
953            ArtifactKind::Playlist => None,
954        };
955        // A generated artifact (a playlist) carries its body inline and never
956        // touches the network; a fetched one pulls (and transcodes) its source.
957        let bytes = match content {
958            Some(text) => text.as_bytes().to_vec(),
959            None => self.artifact_bytes(kind, source_url, owner_id).await?,
960        };
961        self.write_verify(owner_id, path, &bytes)?;
962        // The new sidecar is safely in place; only now drop a stale copy left at
963        // the previous path (the audio moved). `remove` is idempotent, so an
964        // already-absent old file is fine. On a genuine remove failure we return
965        // BEFORE updating the slot, leaving the manifest/album pointing at the
966        // old path: the next run sees the same path drift, re-plans this write,
967        // and retries the cleanup — convergent, no orphan persists.
968        //
969        // The removal is gated so it can never delete a live file (#76). This
970        // slot is releasing `old`, so drop its reference in `tracked_paths`; the
971        // file is removed only once nothing else holds it — no other tracked slot
972        // still references it (count now zero) and no action writes it this run
973        // (`write_targets`, the non-planned twin of `suppress_path_aliasing`).
974        // On a path swap (A: x -> y while B: y -> x) `write_targets` keeps each
975        // freshly written file; when two slots share a path after a prior failed
976        // swap, the first to move keeps it and the last to leave reclaims it, so
977        // a co-owned file is never deleted and a vacated one is never orphaned.
978        if let Some(old) = old_path.as_deref()
979            && !old.is_empty()
980            && old != path
981        {
982            let still_referenced = tracked_paths
983                .get_mut(old)
984                .map(|count| {
985                    *count = count.saturating_sub(1);
986                    *count > 0
987                })
988                .unwrap_or(false);
989            if !still_referenced && !self.write_targets.contains(old) {
990                self.fs.remove(old).map_err(|err| {
991                    permanent_fail(
992                        owner_id,
993                        format!("could not remove old sidecar {old}: {err}"),
994                    )
995                })?;
996            }
997        }
998        if is_album_kind(kind) {
999            albums.entry(owner_id.to_owned()).or_default().set(
1000                kind,
1001                Some(ArtifactState {
1002                    path: path.to_owned(),
1003                    hash: hash.to_owned(),
1004                }),
1005            );
1006        } else if is_playlist_kind(kind) {
1007            playlists.insert(
1008                owner_id.to_owned(),
1009                PlaylistState {
1010                    name: playlist_name_from_path(path),
1011                    path: path.to_owned(),
1012                    hash: hash.to_owned(),
1013                },
1014            );
1015        } else if let Some(entry) = manifest.entries.get_mut(owner_id) {
1016            set_manifest_artifact(
1017                entry,
1018                kind,
1019                Some(ArtifactState {
1020                    path: path.to_owned(),
1021                    hash: hash.to_owned(),
1022                }),
1023            );
1024        }
1025        Ok(Effect::ArtifactWritten)
1026    }
1027
1028    /// Produce a sidecar's bytes from its source, branching on kind.
1029    ///
1030    /// An animated cover — a per-clip [`CoverWebp`](ArtifactKind::CoverWebp) or an
1031    /// album [`FolderWebp`](ArtifactKind::FolderWebp) — fetches the clip's
1032    /// `video_cover` MP4 preview and transcodes it to an animated WebP through the
1033    /// ffmpeg port; every other kind is the fetched source verbatim (e.g. the
1034    /// static [`CoverJpg`](ArtifactKind::CoverJpg) or album
1035    /// [`FolderJpg`](ArtifactKind::FolderJpg) image). A fetch or transcode failure
1036    /// is attributed to the owning clip and is a per-clip [`Fail`], except a
1037    /// disk-full transcode, which aborts the run like the audio FLAC path.
1038    async fn artifact_bytes(
1039        &self,
1040        kind: ArtifactKind,
1041        source_url: &str,
1042        owner_id: &str,
1043    ) -> Result<Vec<u8>, Fail> {
1044        // Reuse the cover the audio producer already fetched for the embedded tag
1045        // when it cached this exact URL (#89); otherwise fetch it now. The guard
1046        // is taken and dropped in its own statement so it never spans the await.
1047        let cached = self
1048            .cover_cache
1049            .lock()
1050            .expect("cover cache mutex poisoned")
1051            .remove(source_url);
1052        let source = match cached {
1053            Some(bytes) => bytes,
1054            None => self
1055                .fetch_bytes(source_url)
1056                .await
1057                .map_err(|err| err.attribute(owner_id))?,
1058        };
1059        match kind {
1060            ArtifactKind::CoverWebp | ArtifactKind::FolderWebp => self
1061                .ffmpeg
1062                .mp4_to_webp(&source, WebpEncodeSettings::default())
1063                .await
1064                .map_err(|err| {
1065                    if err.is_out_of_space() {
1066                        disk_fail(owner_id, "disk full: no space left to transcode")
1067                    } else {
1068                        permanent_fail(owner_id, format!("cover transcode failed: {err}"))
1069                    }
1070                }),
1071            // The text sidecars are generated and always carry inline content, so
1072            // `write_artifact` never reaches this fetch path for them. Guard it so
1073            // a future miswiring fails loudly rather than fetching a URL.
1074            ArtifactKind::DetailsTxt | ArtifactKind::LyricsTxt | ArtifactKind::Lrc => Err(
1075                permanent_fail(owner_id, "text sidecar requires inline content"),
1076            ),
1077            ArtifactKind::CoverJpg
1078            | ArtifactKind::FolderJpg
1079            | ArtifactKind::Playlist
1080            | ArtifactKind::VideoMp4 => Ok(source),
1081        }
1082    }
1083
1084    /// Remove a sidecar file and clear its slot on the owning manifest entry.
1085    ///
1086    /// `remove` is idempotent, so an already-absent sidecar is not a failure.
1087    /// When the owning entry is already gone (its audio was deleted earlier this
1088    /// run, co-deleting the sidecar), there is no slot to clear and that is fine.
1089    ///
1090    /// Folder art is album-scoped: its slot is cleared on the album store keyed by
1091    /// the album's root id, not on a manifest clip.
1092    ///
1093    /// The audio `Delete` is applied before its sidecar `DeleteArtifact`. If the
1094    /// sidecar removal fails after the audio is already gone, the sidecar lingers
1095    /// untracked, but the design stays convergent rather than transactional: the
1096    /// next run re-plans the same removal and retries, and any directory it would
1097    /// have emptied is pruned once the file finally clears.
1098    fn delete_artifact(
1099        &self,
1100        manifest: &mut Manifest,
1101        albums: &mut BTreeMap<String, AlbumArt>,
1102        playlists: &mut BTreeMap<String, PlaylistState>,
1103        kind: ArtifactKind,
1104        path: &str,
1105        owner_id: &str,
1106    ) -> Result<Effect, Fail> {
1107        self.fs
1108            .remove(path)
1109            .map_err(|err| permanent_fail(owner_id, format!("artifact delete failed: {err}")))?;
1110        if is_album_kind(kind) {
1111            if let Some(art) = albums.get_mut(owner_id) {
1112                art.set(kind, None);
1113                if art.is_empty() {
1114                    albums.remove(owner_id);
1115                }
1116            }
1117        } else if is_playlist_kind(kind) {
1118            playlists.remove(owner_id);
1119        } else if let Some(entry) = manifest.entries.get_mut(owner_id) {
1120            set_manifest_artifact(entry, kind, None);
1121        }
1122        Ok(Effect::ArtifactDeleted)
1123    }
1124
1125    /// Fetch one stem's bytes, write them atomically, then record the stem on
1126    /// the owning clip's keyed stem map.
1127    ///
1128    /// Mirrors [`write_artifact`](Self::write_artifact) for the keyed-stem case,
1129    /// sharing the fetch resilience (`fetch_bytes` retries and verifies
1130    /// `Content-Length`) and the atomic size-verified write. A stem is only ever
1131    /// written for a clip whose audio is present, so an absent owning manifest
1132    /// entry means the audio failed or never existed this run; we skip rather
1133    /// than strand an untracked stem with no owning audio.
1134    ///
1135    /// Stems are stored RAW in their native container and are NEVER transcoded to
1136    /// FLAC, even when the song's own format is FLAC — they are the deliberate
1137    /// exception. A `Wav` stem is rendered through the free `convert_wav` flow
1138    /// (see [`fetch_stem_bytes`](Self::fetch_stem_bytes)); an `Mp3` stem is fetched
1139    /// straight from its public CDN url. Either way the bytes land verbatim at
1140    /// `path`, whose extension already matches the stem format.
1141    ///
1142    /// When a title/album change moves the song, reconcile re-emits this write at
1143    /// the NEW path; this handler then removes the stem left at the previously
1144    /// tracked path, moving it rather than orphaning it. The removal happens only
1145    /// after the new file is safely written and only when nothing else this run
1146    /// writes that path, and a remove failure returns before the slot advances so
1147    /// the next run re-plans the identical write and retries — self-healing.
1148    #[allow(clippy::too_many_arguments)]
1149    async fn write_stem(
1150        &self,
1151        client_lock: &ClientLock<'_, C>,
1152        manifest: &mut Manifest,
1153        clip_id: &str,
1154        key: &str,
1155        stem_id: &str,
1156        path: &str,
1157        source_url: &str,
1158        format: StemFormat,
1159        hash: &str,
1160    ) -> Result<Effect, Fail> {
1161        // A stem needs its owning clip's manifest entry (its audio must exist).
1162        if manifest.get(clip_id).is_none() {
1163            return Ok(Effect::Skipped);
1164        }
1165        let old_path = manifest
1166            .get(clip_id)
1167            .and_then(|e| e.stems.get(key))
1168            .map(|s| s.path.clone());
1169        let bytes = self
1170            .fetch_stem_bytes(client_lock, clip_id, stem_id, source_url, format)
1171            .await?;
1172        self.write_verify(clip_id, path, &bytes)?;
1173        // The new stem is in place; only now drop a stale copy left at the old
1174        // path (the song moved, or the stem format changed). `remove` is
1175        // idempotent. A path this run also writes is never removed (the
1176        // non-planned twin of `suppress_path_aliasing`). On a genuine remove
1177        // failure we return BEFORE updating the slot, so the next run re-plans the
1178        // same write and retries the cleanup — no orphan.
1179        if let Some(old) = old_path.as_deref()
1180            && !old.is_empty()
1181            && old != path
1182            && !self.write_targets.contains(old)
1183        {
1184            self.fs.remove(old).map_err(|err| {
1185                permanent_fail(clip_id, format!("could not remove old stem {old}: {err}"))
1186            })?;
1187        }
1188        if let Some(entry) = manifest.entries.get_mut(clip_id) {
1189            set_manifest_stem(
1190                entry,
1191                key,
1192                Some(ArtifactState {
1193                    path: path.to_owned(),
1194                    hash: hash.to_owned(),
1195                }),
1196            );
1197        }
1198        Ok(Effect::ArtifactWritten)
1199    }
1200
1201    /// Resolve a stem's RAW bytes in its native container, never transcoding.
1202    ///
1203    /// A `Wav` stem renders the stem clip's lossless WAV through the very same
1204    /// free `convert_wav` + poll flow the main FLAC/WAV audio uses
1205    /// ([`resolve_wav_url`](Self::resolve_wav_url)), keyed on the stem's own
1206    /// `stem_id`, then downloads that WAV. An `Mp3` stem (or a degenerate `Wav`
1207    /// stem with no id to render) downloads its public CDN url directly. Stems
1208    /// are the deliberate exception to the source format: the bytes are returned
1209    /// exactly as delivered and are never re-encoded to FLAC.
1210    async fn fetch_stem_bytes(
1211        &self,
1212        client_lock: &ClientLock<'_, C>,
1213        clip_id: &str,
1214        stem_id: &str,
1215        source_url: &str,
1216        format: StemFormat,
1217    ) -> Result<Vec<u8>, Fail> {
1218        let url = match format {
1219            StemFormat::Wav if !stem_id.is_empty() => {
1220                match self.resolve_wav_url(client_lock, stem_id).await? {
1221                    Some(url) => url,
1222                    None => return Err(transient_fail(clip_id, "stem WAV render was not ready")),
1223                }
1224            }
1225            // Mp3, or a Wav stem with no id to render, downloads the CDN mp3.
1226            _ => source_url.to_owned(),
1227        };
1228        self.fetch_bytes(&url)
1229            .await
1230            .map_err(|err| err.attribute(clip_id))
1231    }
1232
1233    /// Remove one stem file and clear its slot in the owning clip's stem map.
1234    ///
1235    /// `remove` is idempotent, so an already-absent stem is not a failure. When
1236    /// the owning entry is already gone (its audio was deleted earlier this run,
1237    /// co-deleting the stem), there is no slot to clear and that is fine; the
1238    /// emptied `.stems` folder is pruned by the end-of-run directory sweep.
1239    fn delete_stem(
1240        &self,
1241        manifest: &mut Manifest,
1242        clip_id: &str,
1243        key: &str,
1244        path: &str,
1245    ) -> Result<Effect, Fail> {
1246        self.fs
1247            .remove(path)
1248            .map_err(|err| permanent_fail(clip_id, format!("stem delete failed: {err}")))?;
1249        if let Some(entry) = manifest.entries.get_mut(clip_id) {
1250            set_manifest_stem(entry, key, None);
1251        }
1252        Ok(Effect::ArtifactDeleted)
1253    }
1254
1255    /// Download (and transcode/tag) the audio for `clip` in `format`.
1256    async fn produce_audio(
1257        &self,
1258        client_lock: &ClientLock<'_, C>,
1259        clip: &Clip,
1260        lineage: &LineageContext,
1261        format: AudioFormat,
1262    ) -> Result<Vec<u8>, Fail> {
1263        let (meta, synced) = self.track_meta(clip, lineage);
1264        match format {
1265            AudioFormat::Mp3 => {
1266                let url = clip.mp3_url();
1267                let audio = self
1268                    .fetch_bytes(&url)
1269                    .await
1270                    .map_err(|err| err.attribute(&clip.id))?;
1271                let cover = self.fetch_cover(clip).await;
1272                tag_mp3(&audio, &meta, cover.as_deref(), synced)
1273                    .map_err(|err| permanent_fail(&clip.id, err.to_string()))
1274            }
1275            AudioFormat::Flac => {
1276                let wav = self.fetch_wav(client_lock, clip).await?;
1277                let flac = self.ffmpeg.wav_to_flac(&wav).await.map_err(|err| {
1278                    if err.is_out_of_space() {
1279                        disk_fail(&clip.id, "disk full: no space left to transcode")
1280                    } else {
1281                        permanent_fail(&clip.id, format!("transcode failed: {err}"))
1282                    }
1283                })?;
1284                let cover = self.fetch_cover(clip).await;
1285                tag_flac(&flac, &meta, cover.as_deref())
1286                    .map_err(|err| permanent_fail(&clip.id, err.to_string()))
1287            }
1288            AudioFormat::Wav => self.fetch_wav(client_lock, clip).await,
1289        }
1290    }
1291
1292    /// This run's non-empty aligned lyrics for a clip, if any were fetched.
1293    fn synced_for(&self, clip_id: &str) -> Option<&AlignedLyrics> {
1294        self.synced
1295            .get(clip_id)
1296            .filter(|aligned| !aligned.is_empty())
1297    }
1298
1299    /// The track metadata for a clip, paired with its synced lyrics (if any).
1300    ///
1301    /// The feed omits per-clip lyrics, so when this run fetched aligned lyrics
1302    /// for the clip the plain text is folded into `lyrics` here, which the MP3
1303    /// `USLT` and FLAC `LYRICS` tags then carry. The returned [`AlignedLyrics`]
1304    /// is passed on to [`tag_mp3`] for the word-level `SYLT` frame.
1305    fn track_meta<'m>(
1306        &'m self,
1307        clip: &Clip,
1308        lineage: &LineageContext,
1309    ) -> (TrackMetadata, Option<&'m AlignedLyrics>) {
1310        let synced = self.synced_for(&clip.id);
1311        let mut meta = TrackMetadata::from_clip(clip, lineage);
1312        if let Some(aligned) = synced {
1313            meta.lyrics = aligned.plain_text();
1314        }
1315        (meta, synced)
1316    }
1317
1318    /// Resolve the rendered WAV URL and download it.
1319    async fn fetch_wav(
1320        &self,
1321        client_lock: &ClientLock<'_, C>,
1322        clip: &Clip,
1323    ) -> Result<Vec<u8>, Fail> {
1324        let url = match self.resolve_wav_url(client_lock, &clip.id).await? {
1325            Some(url) => url,
1326            None => return Err(transient_fail(&clip.id, "WAV render was not ready")),
1327        };
1328        self.fetch_bytes(&url)
1329            .await
1330            .map_err(|err| err.attribute(&clip.id))
1331    }
1332
1333    /// Read the WAV URL, requesting a render and polling if it is not ready.
1334    ///
1335    /// `None` means the render did not become ready within the poll budget; the
1336    /// caller treats that as a non-fatal transient failure, never a silent skip.
1337    ///
1338    /// Each client call briefly locks `client_lock`; the poll waits happen
1339    /// unlocked, so concurrent clips interleave their WAV renders rather than
1340    /// serialising behind one clip's whole poll budget.
1341    async fn resolve_wav_url(
1342        &self,
1343        client_lock: &ClientLock<'_, C>,
1344        id: &str,
1345    ) -> Result<Option<String>, Fail> {
1346        if let Some(url) = self.wav_url_retrying(client_lock, id).await? {
1347            return Ok(Some(url));
1348        }
1349        self.request_wav_retrying(client_lock, id).await?;
1350        for _ in 0..self.opts.wav_poll_attempts {
1351            self.clock.sleep(self.opts.wav_poll_interval).await;
1352            if let Some(url) = self.wav_url_retrying(client_lock, id).await? {
1353                return Ok(Some(url));
1354            }
1355        }
1356        Ok(None)
1357    }
1358
1359    /// Read the rendered WAV URL, retrying transient API failures with backoff
1360    /// (SYNC-16/17), so the default FLAC path is as resilient as the CDN path.
1361    async fn wav_url_retrying(
1362        &self,
1363        client_lock: &ClientLock<'_, C>,
1364        id: &str,
1365    ) -> Result<Option<String>, Fail> {
1366        let mut attempt: u32 = 0;
1367        loop {
1368            let result = {
1369                let mut client = client_lock.lock().await;
1370                client.wav_url(self.http, id).await
1371            };
1372            match result {
1373                Ok(url) => return Ok(url),
1374                Err(err) => match self.retry_core(id, err, &mut attempt).await {
1375                    Some(fail) => return Err(fail),
1376                    None => continue,
1377                },
1378            }
1379        }
1380    }
1381
1382    /// Ask Suno to render a WAV, retrying transient API failures with backoff.
1383    async fn request_wav_retrying(
1384        &self,
1385        client_lock: &ClientLock<'_, C>,
1386        id: &str,
1387    ) -> Result<(), Fail> {
1388        let mut attempt: u32 = 0;
1389        loop {
1390            let result = {
1391                let mut client = client_lock.lock().await;
1392                client.request_wav(self.http, id).await
1393            };
1394            match result {
1395                Ok(()) => return Ok(()),
1396                Err(err) => match self.retry_core(id, err, &mut attempt).await {
1397                    Some(fail) => return Err(fail),
1398                    None => continue,
1399                },
1400            }
1401        }
1402    }
1403
1404    /// Classify a core error from the authenticated WAV flow. On a transient
1405    /// class within budget, back off through the [`Clock`] and return `None` to
1406    /// retry; otherwise return the terminal [`Fail`].
1407    async fn retry_core(&self, id: &str, err: Error, attempt: &mut u32) -> Option<Fail> {
1408        let fail = classify_core(id, err);
1409        if matches!(fail.class, Class::Transient) && *attempt < self.opts.max_retries {
1410            self.clock.sleep(backoff_delay(*attempt, None)).await;
1411            *attempt += 1;
1412            None
1413        } else {
1414            Some(fail)
1415        }
1416    }
1417
1418    /// GET `url`, retrying transient failures with backoff, verifying size.
1419    async fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, FetchError> {
1420        let mut attempt: u32 = 0;
1421        loop {
1422            let result = self.http.send(HttpRequest::get(url)).await;
1423            match classify_response(result) {
1424                Ok(body) => return Ok(body),
1425                Err(err) => {
1426                    if matches!(err.class, Class::Transient) && attempt < self.opts.max_retries {
1427                        let delay = backoff_delay(attempt, err.retry_after);
1428                        self.clock.sleep(delay).await;
1429                        attempt += 1;
1430                        continue;
1431                    }
1432                    return Err(err);
1433                }
1434            }
1435        }
1436    }
1437
1438    /// Download cover art, trying each candidate URL in order; `None` is fine.
1439    async fn fetch_cover(&self, clip: &Clip) -> Option<Vec<u8>> {
1440        for url in clip.cover_candidates() {
1441            if let Ok(response) = self.http.send(HttpRequest::get(url)).await
1442                && (200..=299).contains(&response.status)
1443                && !response.body.is_empty()
1444            {
1445                // A `CoverJpg` sidecar will fetch this exact URL this run; keep the
1446                // bytes so its write reuses them instead of fetching again (#89).
1447                // The lock guards only the insert, never the await above.
1448                if self.cover_wanted.contains(url) {
1449                    self.cover_cache
1450                        .lock()
1451                        .expect("cover cache mutex poisoned")
1452                        .insert(url.to_owned(), response.body.clone());
1453                }
1454                return Some(response.body);
1455            }
1456        }
1457        None
1458    }
1459
1460    /// Write `bytes` atomically, then confirm the on-disk size (SYNC-13/14).
1461    fn write_verify(&self, clip_id: &str, path: &str, bytes: &[u8]) -> Result<u64, Fail> {
1462        self.fs.write_atomic(path, bytes).map_err(|err| {
1463            if err.is_out_of_space() {
1464                disk_fail(clip_id, format!("disk full: no space left to write {path}"))
1465            } else {
1466                permanent_fail(clip_id, format!("write failed: {err}"))
1467            }
1468        })?;
1469        match self.fs.metadata(path) {
1470            Some(stat) if stat.size == bytes.len() as u64 => Ok(stat.size),
1471            Some(stat) => Err(permanent_fail(
1472                clip_id,
1473                format!("wrote {} bytes, expected {}", stat.size, bytes.len()),
1474            )),
1475            None => Ok(bytes.len() as u64),
1476        }
1477    }
1478
1479    /// Build the manifest entry for a freshly written file.
1480    fn entry(&self, clip_id: &str, path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
1481        match self.by_id.get(clip_id) {
1482            Some(d) => manifest_entry(d, size),
1483            None => ManifestEntry {
1484                path: path.to_owned(),
1485                format,
1486                size,
1487                ..ManifestEntry::default()
1488            },
1489        }
1490    }
1491
1492    /// Refresh an existing entry's hashes, protection, and (optionally) size.
1493    fn refresh_hashes(&self, manifest: &mut Manifest, clip_id: &str, size: Option<u64>) {
1494        let desired = self.by_id.get(clip_id).copied();
1495        if let Some(entry) = manifest.entries.get_mut(clip_id) {
1496            if let Some(d) = desired {
1497                entry.meta_hash = d.meta_hash.clone();
1498                entry.art_hash = d.art_hash.clone();
1499                entry.preserve = preserve_for(d);
1500            }
1501            if let Some(size) = size {
1502                entry.size = size;
1503            }
1504        }
1505    }
1506
1507    /// Refresh only an entry's preserve marker from the current desired state.
1508    ///
1509    /// A clip can gain or lose copy/private protection with no file change, which
1510    /// reconcile emits as a [`Skip`](Action::Skip). Refreshing here keeps the
1511    /// persisted marker a faithful image of live protection, so the cross-run
1512    /// delete guard (SYNC-8) never reads it stale.
1513    fn refresh_preserve(&self, manifest: &mut Manifest, clip_id: &str) {
1514        if let Some(d) = self.by_id.get(clip_id).copied()
1515            && let Some(entry) = manifest.entries.get_mut(clip_id)
1516        {
1517            entry.preserve = preserve_for(d);
1518        }
1519    }
1520}
1521
1522/// Build a manifest entry from the desired record (SYNC-8 preserve rule).
1523fn manifest_entry(d: &Desired, size: u64) -> ManifestEntry {
1524    ManifestEntry {
1525        path: d.path.clone(),
1526        format: d.format,
1527        meta_hash: d.meta_hash.clone(),
1528        art_hash: d.art_hash.clone(),
1529        size,
1530        preserve: preserve_for(d),
1531        ..Default::default()
1532    }
1533}
1534
1535/// Whether a written entry must be preserved across runs: held by any copy
1536/// source, or private. The reconcile delete guard reads this marker later.
1537fn preserve_for(d: &Desired) -> bool {
1538    d.private || d.modes.contains(&SourceMode::Copy)
1539}
1540
1541/// Classify one HTTP result into bytes or a [`FetchError`] (SYNC-14/17).
1542fn classify_response(
1543    result: Result<crate::http::HttpResponse, crate::http::TransportError>,
1544) -> Result<Vec<u8>, FetchError> {
1545    let response = match result {
1546        Ok(response) => response,
1547        Err(err) => {
1548            return Err(FetchError::transient(
1549                format!("transport error: {err}"),
1550                None,
1551            ));
1552        }
1553    };
1554    match response.status {
1555        200..=299 => {
1556            if let Some(expected) = content_length(&response) {
1557                let actual = response.body.len() as u64;
1558                if actual != expected {
1559                    return Err(FetchError::transient(
1560                        format!("truncated download: {actual} of {expected} bytes"),
1561                        None,
1562                    ));
1563                }
1564            }
1565            Ok(response.body)
1566        }
1567        401 | 403 => Err(FetchError::transient(
1568            format!("download rejected: status {}", response.status),
1569            None,
1570        )),
1571        408 => Err(FetchError::transient("request timed out", None)),
1572        429 => Err(FetchError::transient(
1573            "rate limited",
1574            retry_after(&response),
1575        )),
1576        500..=599 => Err(FetchError::transient(
1577            format!("server error {}", response.status),
1578            None,
1579        )),
1580        status => Err(FetchError::permanent(format!(
1581            "download failed: status {status}"
1582        ))),
1583    }
1584}
1585
1586/// Map a core [`Error`] from the authenticated WAV flow to a [`Fail`].
1587fn classify_core(id: &str, err: Error) -> Fail {
1588    let reason = err.to_string();
1589    match err {
1590        Error::Auth(_) => auth_fail(id, reason),
1591        Error::RateLimited { .. } | Error::Connection(_) => transient_fail(id, reason),
1592        Error::Api(_)
1593        | Error::NotFound(_)
1594        | Error::Tag(_)
1595        | Error::Config(_)
1596        | Error::Refused(_) => permanent_fail(id, reason),
1597    }
1598}
1599
1600/// The provider-reported body size from `Content-Length`, if present and valid.
1601fn content_length(response: &crate::http::HttpResponse) -> Option<u64> {
1602    response.header("content-length")?.trim().parse().ok()
1603}
1604
1605#[cfg(test)]
1606mod tests {
1607    use super::*;
1608    use crate::ClerkAuth;
1609    use crate::http::HttpResponse;
1610    use crate::testutil::{MemFs, RecordingClock, Reply, ScriptedHttp, StubFfmpeg};
1611
1612    fn clip(id: &str) -> Clip {
1613        Clip {
1614            id: id.to_owned(),
1615            title: "Song".to_owned(),
1616            audio_url: format!("https://cdn1.suno.ai/{id}.mp3"),
1617            ..Default::default()
1618        }
1619    }
1620
1621    fn art_clip(id: &str) -> Clip {
1622        Clip {
1623            image_large_url: format!("https://art.suno.ai/{id}/large.jpg"),
1624            image_url: format!("https://art.suno.ai/{id}/small.jpg"),
1625            ..clip(id)
1626        }
1627    }
1628
1629    fn ext(format: AudioFormat) -> &'static str {
1630        match format {
1631            AudioFormat::Mp3 => "mp3",
1632            AudioFormat::Flac => "flac",
1633            AudioFormat::Wav => "wav",
1634        }
1635    }
1636
1637    fn desired(clip: Clip, format: AudioFormat) -> Desired {
1638        Desired {
1639            path: format!("{}.{}", clip.id, ext(format)),
1640            lineage: LineageContext::own_root(&clip),
1641            clip,
1642            format,
1643            meta_hash: "m".to_owned(),
1644            art_hash: "art".to_owned(),
1645            modes: vec![SourceMode::Mirror],
1646            trashed: false,
1647            private: false,
1648            artifacts: Vec::new(),
1649            stems: None,
1650        }
1651    }
1652
1653    fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
1654        ManifestEntry {
1655            path: path.to_owned(),
1656            format,
1657            meta_hash: "old".to_owned(),
1658            art_hash: "old-art".to_owned(),
1659            size: 8,
1660            preserve: false,
1661            ..Default::default()
1662        }
1663    }
1664
1665    #[allow(clippy::too_many_arguments)]
1666    fn run(
1667        plan: &Plan,
1668        manifest: &mut Manifest,
1669        desired: &[Desired],
1670        http: &ScriptedHttp,
1671        fs: &MemFs,
1672        ffmpeg: &StubFfmpeg,
1673        clock: &RecordingClock,
1674        opts: &ExecOptions,
1675    ) -> ExecOutcome {
1676        let mut albums = BTreeMap::new();
1677        run_with_albums(
1678            plan,
1679            manifest,
1680            &mut albums,
1681            desired,
1682            http,
1683            fs,
1684            ffmpeg,
1685            clock,
1686            opts,
1687        )
1688    }
1689
1690    #[allow(clippy::too_many_arguments)]
1691    fn run_with_albums(
1692        plan: &Plan,
1693        manifest: &mut Manifest,
1694        albums: &mut BTreeMap<String, AlbumArt>,
1695        desired: &[Desired],
1696        http: &ScriptedHttp,
1697        fs: &MemFs,
1698        ffmpeg: &StubFfmpeg,
1699        clock: &RecordingClock,
1700        opts: &ExecOptions,
1701    ) -> ExecOutcome {
1702        let mut playlists = BTreeMap::new();
1703        run_full(
1704            plan,
1705            manifest,
1706            albums,
1707            &mut playlists,
1708            desired,
1709            http,
1710            fs,
1711            ffmpeg,
1712            clock,
1713            opts,
1714        )
1715    }
1716
1717    #[allow(clippy::too_many_arguments)]
1718    fn run_full(
1719        plan: &Plan,
1720        manifest: &mut Manifest,
1721        albums: &mut BTreeMap<String, AlbumArt>,
1722        playlists: &mut BTreeMap<String, PlaylistState>,
1723        desired: &[Desired],
1724        http: &ScriptedHttp,
1725        fs: &MemFs,
1726        ffmpeg: &StubFfmpeg,
1727        clock: &RecordingClock,
1728        opts: &ExecOptions,
1729    ) -> ExecOutcome {
1730        let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1731        let synced = HashMap::new();
1732        pollster::block_on(execute(
1733            plan,
1734            manifest,
1735            albums,
1736            playlists,
1737            desired,
1738            &synced,
1739            Ports {
1740                client: &mut client,
1741                http,
1742                fs,
1743                ffmpeg,
1744                clock,
1745            },
1746            opts,
1747        ))
1748    }
1749
1750    fn small_poll() -> ExecOptions {
1751        ExecOptions {
1752            max_retries: 3,
1753            wav_poll_attempts: 2,
1754            wav_poll_interval: Duration::from_secs(5),
1755            concurrency: 4,
1756        }
1757    }
1758
1759    // ── Download: MP3 ───────────────────────────────────────────────
1760
1761    #[test]
1762    fn download_mp3_writes_tagged_file_and_records_manifest() {
1763        let c = art_clip("a");
1764        let d = desired(c.clone(), AudioFormat::Mp3);
1765        let plan = Plan {
1766            actions: vec![Action::Download {
1767                clip: c.clone(),
1768                lineage: LineageContext::own_root(&c),
1769                path: d.path.clone(),
1770                format: AudioFormat::Mp3,
1771            }],
1772        };
1773        let http = ScriptedHttp::new()
1774            .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
1775            .route("a/large.jpg", Reply::ok(b"art-bytes".to_vec()));
1776        let fs = MemFs::new();
1777        let ffmpeg = StubFfmpeg::flac();
1778        let clock = RecordingClock::new();
1779        let mut manifest = Manifest::new();
1780
1781        let outcome = run(
1782            &plan,
1783            &mut manifest,
1784            &[d],
1785            &http,
1786            &fs,
1787            &ffmpeg,
1788            &clock,
1789            &ExecOptions::default(),
1790        );
1791
1792        assert_eq!(outcome.downloaded, 1);
1793        assert_eq!(outcome.failed(), 0);
1794        assert_eq!(outcome.status, RunStatus::Completed);
1795        let written = fs.read_file("a.mp3").unwrap();
1796        assert_eq!(&written[..3], b"ID3");
1797        assert!(written.ends_with(b"mp3-body"));
1798        let entry = manifest.get("a").unwrap();
1799        assert_eq!(entry.path, "a.mp3");
1800        assert_eq!(entry.format, AudioFormat::Mp3);
1801        assert_eq!(entry.meta_hash, "m");
1802        assert_eq!(entry.art_hash, "art");
1803        assert_eq!(entry.size, written.len() as u64);
1804        assert!(!entry.preserve);
1805    }
1806
1807    #[test]
1808    fn download_mp3_embeds_sylt_and_lyrics_from_synced_map() {
1809        // A clip whose alignment was fetched this run gets a word-level SYLT frame
1810        // and its plain lyric text embedded (USLT), end to end through execute.
1811        let c = art_clip("a");
1812        let d = desired(c.clone(), AudioFormat::Mp3);
1813        let plan = Plan {
1814            actions: vec![Action::Download {
1815                clip: c.clone(),
1816                lineage: LineageContext::own_root(&c),
1817                path: d.path.clone(),
1818                format: AudioFormat::Mp3,
1819            }],
1820        };
1821        let http = ScriptedHttp::new()
1822            .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
1823            .route("a/large.jpg", Reply::ok(b"art-bytes".to_vec()));
1824        let fs = MemFs::new();
1825        let ffmpeg = StubFfmpeg::flac();
1826        let clock = RecordingClock::new();
1827        let mut manifest = Manifest::new();
1828        let mut albums = BTreeMap::new();
1829        let mut playlists = BTreeMap::new();
1830        let mut synced = HashMap::new();
1831        synced.insert(
1832            "a".to_string(),
1833            AlignedLyrics::from_json(&serde_json::json!({
1834                "aligned_words": [],
1835                "aligned_lyrics": [
1836                    {"text": "hi there", "start_s": 0.5, "end_s": 1.2, "section": "Verse 1",
1837                     "words": [
1838                         {"text": "hi", "start_s": 0.5, "end_s": 0.8},
1839                         {"text": "there", "start_s": 0.9, "end_s": 1.2}
1840                     ]}
1841                ]
1842            })),
1843        );
1844        let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1845        let outcome = pollster::block_on(execute(
1846            &plan,
1847            &mut manifest,
1848            &mut albums,
1849            &mut playlists,
1850            &[d],
1851            &synced,
1852            Ports {
1853                client: &mut client,
1854                http: &http,
1855                fs: &fs,
1856                ffmpeg: &ffmpeg,
1857                clock: &clock,
1858            },
1859            &ExecOptions::default(),
1860        ));
1861
1862        assert_eq!(outcome.downloaded, 1);
1863        let written = fs.read_file("a.mp3").unwrap();
1864        let tag = id3::Tag::read_from2(std::io::Cursor::new(written)).unwrap();
1865        assert_eq!(
1866            tag.synchronised_lyrics().count(),
1867            1,
1868            "a SYLT frame is embedded"
1869        );
1870        // The plain lyric text is populated from the alignment for the USLT frame.
1871        assert_eq!(
1872            tag.lyrics().next().map(|frame| frame.text.as_str()),
1873            Some("hi there")
1874        );
1875    }
1876
1877    #[test]
1878    fn download_mp3_embeds_no_sylt_when_synced_map_empty() {
1879        // The synced map is empty when the feature is off (no alignment fetched),
1880        // so no SYLT frame and no lyric text are embedded.
1881        let c = art_clip("a");
1882        let d = desired(c.clone(), AudioFormat::Mp3);
1883        let plan = Plan {
1884            actions: vec![Action::Download {
1885                clip: c.clone(),
1886                lineage: LineageContext::own_root(&c),
1887                path: d.path.clone(),
1888                format: AudioFormat::Mp3,
1889            }],
1890        };
1891        let http = ScriptedHttp::new()
1892            .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
1893            .route("a/large.jpg", Reply::ok(b"art-bytes".to_vec()));
1894        let fs = MemFs::new();
1895        let ffmpeg = StubFfmpeg::flac();
1896        let clock = RecordingClock::new();
1897        let mut manifest = Manifest::new();
1898        let mut albums = BTreeMap::new();
1899        let mut playlists = BTreeMap::new();
1900        let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1901        let outcome = pollster::block_on(execute(
1902            &plan,
1903            &mut manifest,
1904            &mut albums,
1905            &mut playlists,
1906            &[d],
1907            &HashMap::new(),
1908            Ports {
1909                client: &mut client,
1910                http: &http,
1911                fs: &fs,
1912                ffmpeg: &ffmpeg,
1913                clock: &clock,
1914            },
1915            &ExecOptions::default(),
1916        ));
1917        assert_eq!(outcome.downloaded, 1);
1918        let written = fs.read_file("a.mp3").unwrap();
1919        let tag = id3::Tag::read_from2(std::io::Cursor::new(written)).unwrap();
1920        assert_eq!(tag.synchronised_lyrics().count(), 0);
1921        assert_eq!(tag.lyrics().count(), 0);
1922    }
1923
1924    #[test]
1925    fn download_mp3_uses_cdn_fallback_when_audio_url_empty() {
1926        let mut c = clip("a");
1927        c.audio_url = String::new();
1928        let d = desired(c.clone(), AudioFormat::Mp3);
1929        let plan = Plan {
1930            actions: vec![Action::Download {
1931                clip: c.clone(),
1932                lineage: LineageContext::own_root(&c),
1933                path: d.path.clone(),
1934                format: AudioFormat::Mp3,
1935            }],
1936        };
1937        let http = ScriptedHttp::new().route("cdn1.suno.ai/a.mp3", Reply::ok(b"body".to_vec()));
1938        let fs = MemFs::new();
1939        let mut manifest = Manifest::new();
1940        let outcome = run(
1941            &plan,
1942            &mut manifest,
1943            &[d],
1944            &http,
1945            &fs,
1946            &StubFfmpeg::flac(),
1947            &RecordingClock::new(),
1948            &ExecOptions::default(),
1949        );
1950        assert_eq!(outcome.downloaded, 1);
1951        assert_eq!(http.count("cdn1.suno.ai/a.mp3"), 1);
1952    }
1953
1954    // ── Download: FLAC render + transcode ───────────────────────────
1955
1956    #[test]
1957    fn download_flac_renders_transcodes_and_records() {
1958        let c = clip("b");
1959        let d = desired(c.clone(), AudioFormat::Flac);
1960        let plan = Plan {
1961            actions: vec![Action::Download {
1962                clip: c.clone(),
1963                lineage: LineageContext::own_root(&c),
1964                path: d.path.clone(),
1965                format: AudioFormat::Flac,
1966            }],
1967        };
1968        let http = ScriptedHttp::new()
1969            .with_auth()
1970            .route(
1971                "/wav_file/",
1972                Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/b.wav"}"#),
1973            )
1974            .route("b.wav", Reply::ok(b"wav-bytes".to_vec()));
1975        let fs = MemFs::new();
1976        let clock = RecordingClock::new();
1977        let mut manifest = Manifest::new();
1978
1979        let outcome = run(
1980            &plan,
1981            &mut manifest,
1982            &[d],
1983            &http,
1984            &fs,
1985            &StubFfmpeg::flac(),
1986            &clock,
1987            &ExecOptions::default(),
1988        );
1989
1990        assert_eq!(outcome.downloaded, 1);
1991        assert_eq!(outcome.failed(), 0);
1992        let written = fs.read_file("b.flac").unwrap();
1993        assert_eq!(&written[..4], b"fLaC");
1994        assert_eq!(manifest.get("b").unwrap().format, AudioFormat::Flac);
1995        // The URL was ready immediately, so no render request and no polling.
1996        assert_eq!(http.count("/convert_wav/"), 0);
1997        assert!(clock.sleeps().is_empty());
1998    }
1999
2000    #[test]
2001    fn download_flac_requests_render_then_polls_until_ready() {
2002        let c = clip("c");
2003        let d = desired(c.clone(), AudioFormat::Flac);
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::Flac,
2010            }],
2011        };
2012        let http = ScriptedHttp::new()
2013            .with_auth()
2014            .route_seq(
2015                "/wav_file/",
2016                vec![
2017                    Reply::json("{}"),
2018                    Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/c.wav"}"#),
2019                ],
2020            )
2021            .route("/convert_wav/", Reply::status(200))
2022            .route("c.wav", Reply::ok(b"wav".to_vec()));
2023        let clock = RecordingClock::new();
2024        let mut manifest = Manifest::new();
2025
2026        let outcome = run(
2027            &plan,
2028            &mut manifest,
2029            &[d],
2030            &http,
2031            &fs_new(),
2032            &StubFfmpeg::flac(),
2033            &clock,
2034            &small_poll(),
2035        );
2036
2037        assert_eq!(outcome.downloaded, 1);
2038        assert_eq!(http.count("/convert_wav/"), 1);
2039        assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
2040    }
2041
2042    #[test]
2043    fn download_flac_unavailable_render_is_a_nonfatal_failure() {
2044        let c = clip("d");
2045        let d = desired(c.clone(), AudioFormat::Flac);
2046        let plan = Plan {
2047            actions: vec![Action::Download {
2048                clip: c.clone(),
2049                lineage: LineageContext::own_root(&c),
2050                path: d.path.clone(),
2051                format: AudioFormat::Flac,
2052            }],
2053        };
2054        let http = ScriptedHttp::new()
2055            .with_auth()
2056            .route("/wav_file/", Reply::json("{}"))
2057            .route("/convert_wav/", Reply::status(200));
2058        let fs = MemFs::new();
2059        let clock = RecordingClock::new();
2060        let mut manifest = Manifest::new();
2061
2062        let outcome = run(
2063            &plan,
2064            &mut manifest,
2065            &[d],
2066            &http,
2067            &fs,
2068            &StubFfmpeg::flac(),
2069            &clock,
2070            &small_poll(),
2071        );
2072
2073        assert_eq!(outcome.downloaded, 0);
2074        assert_eq!(outcome.failed(), 1);
2075        assert_eq!(outcome.failures[0].clip_id, "d");
2076        assert_eq!(outcome.status, RunStatus::Completed);
2077        assert!(!fs.exists("d.flac"));
2078        assert_eq!(clock.sleeps().len(), 2);
2079    }
2080
2081    #[test]
2082    fn flac_transcode_failure_is_recorded_and_skipped() {
2083        let c = clip("t");
2084        let d = desired(c.clone(), AudioFormat::Flac);
2085        let plan = Plan {
2086            actions: vec![Action::Download {
2087                clip: c.clone(),
2088                lineage: LineageContext::own_root(&c),
2089                path: d.path.clone(),
2090                format: AudioFormat::Flac,
2091            }],
2092        };
2093        let http = ScriptedHttp::new()
2094            .with_auth()
2095            .route(
2096                "/wav_file/",
2097                Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/t.wav"}"#),
2098            )
2099            .route("t.wav", Reply::ok(b"wav".to_vec()));
2100        let fs = MemFs::new();
2101        let mut manifest = Manifest::new();
2102
2103        let outcome = run(
2104            &plan,
2105            &mut manifest,
2106            &[d],
2107            &http,
2108            &fs,
2109            &StubFfmpeg::failing(),
2110            &RecordingClock::new(),
2111            &ExecOptions::default(),
2112        );
2113
2114        assert_eq!(outcome.downloaded, 0);
2115        assert_eq!(outcome.failed(), 1);
2116        assert!(!fs.exists("t.flac"));
2117        assert!(manifest.get("t").is_none());
2118    }
2119
2120    // ── Cover fallback ──────────────────────────────────────────────
2121
2122    #[test]
2123    fn cover_falls_back_when_large_image_is_missing() {
2124        let c = art_clip("e");
2125        let d = desired(c.clone(), AudioFormat::Mp3);
2126        let plan = Plan {
2127            actions: vec![Action::Download {
2128                clip: c.clone(),
2129                lineage: LineageContext::own_root(&c),
2130                path: d.path.clone(),
2131                format: AudioFormat::Mp3,
2132            }],
2133        };
2134        let http = ScriptedHttp::new()
2135            .route("e.mp3", Reply::ok(b"body".to_vec()))
2136            .route("e/large.jpg", Reply::status(404))
2137            .route("e/small.jpg", Reply::ok(b"the-art".to_vec()));
2138        let fs = MemFs::new();
2139        let mut manifest = Manifest::new();
2140
2141        let outcome = run(
2142            &plan,
2143            &mut manifest,
2144            &[d],
2145            &http,
2146            &fs,
2147            &StubFfmpeg::flac(),
2148            &RecordingClock::new(),
2149            &ExecOptions::default(),
2150        );
2151
2152        assert_eq!(outcome.downloaded, 1);
2153        let calls = http.calls();
2154        let large = calls
2155            .iter()
2156            .position(|u| u.contains("e/large.jpg"))
2157            .unwrap();
2158        let small = calls
2159            .iter()
2160            .position(|u| u.contains("e/small.jpg"))
2161            .unwrap();
2162        assert!(large < small, "large art tried before small");
2163    }
2164
2165    // ── Cover reuse: embed + sidecar share one fetch (#89) ──────────
2166
2167    #[test]
2168    fn download_reuses_the_embedded_cover_for_the_jpg_sidecar() {
2169        // The embedded tag and the `.jpg` sidecar want the same cover URL; it is
2170        // fetched once and the bytes serve both.
2171        let c = art_clip("a");
2172        let d = desired(c.clone(), AudioFormat::Mp3);
2173        let plan = Plan {
2174            actions: vec![
2175                Action::Download {
2176                    clip: c.clone(),
2177                    lineage: LineageContext::own_root(&c),
2178                    path: d.path.clone(),
2179                    format: AudioFormat::Mp3,
2180                },
2181                Action::WriteArtifact {
2182                    kind: ArtifactKind::CoverJpg,
2183                    path: "a/cover.jpg".to_owned(),
2184                    source_url: c.selected_image_url().unwrap().to_owned(),
2185                    hash: "art".to_owned(),
2186                    owner_id: "a".to_owned(),
2187                    content: None,
2188                },
2189            ],
2190        };
2191        let http = ScriptedHttp::new()
2192            .route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
2193            .route("a/large.jpg", Reply::ok(b"the-art".to_vec()));
2194        let fs = MemFs::new();
2195        let mut manifest = Manifest::new();
2196
2197        let outcome = run(
2198            &plan,
2199            &mut manifest,
2200            &[d],
2201            &http,
2202            &fs,
2203            &StubFfmpeg::flac(),
2204            &RecordingClock::new(),
2205            &ExecOptions::default(),
2206        );
2207
2208        assert_eq!(outcome.downloaded, 1);
2209        assert_eq!(outcome.artifacts_written, 1);
2210        assert_eq!(outcome.failed(), 0);
2211        // Fetched once, not twice.
2212        assert_eq!(http.count("a/large.jpg"), 1);
2213        // The sidecar carries the fetched bytes, and the audio was tagged.
2214        assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"the-art");
2215        assert_eq!(&fs.read_file("a.mp3").unwrap()[..3], b"ID3");
2216    }
2217
2218    #[test]
2219    fn concurrent_downloads_reuse_each_clips_own_cover() {
2220        // Two clips render concurrently; each `.jpg` sidecar gets its own cover
2221        // (no cross-contamination) and each cover URL is fetched exactly once.
2222        let a = art_clip("a");
2223        let b = art_clip("b");
2224        let da = desired(a.clone(), AudioFormat::Mp3);
2225        let db = desired(b.clone(), AudioFormat::Mp3);
2226        let plan = Plan {
2227            actions: vec![
2228                Action::Download {
2229                    clip: a.clone(),
2230                    lineage: LineageContext::own_root(&a),
2231                    path: da.path.clone(),
2232                    format: AudioFormat::Mp3,
2233                },
2234                Action::WriteArtifact {
2235                    kind: ArtifactKind::CoverJpg,
2236                    path: "a/cover.jpg".to_owned(),
2237                    source_url: a.selected_image_url().unwrap().to_owned(),
2238                    hash: "art".to_owned(),
2239                    owner_id: "a".to_owned(),
2240                    content: None,
2241                },
2242                Action::Download {
2243                    clip: b.clone(),
2244                    lineage: LineageContext::own_root(&b),
2245                    path: db.path.clone(),
2246                    format: AudioFormat::Mp3,
2247                },
2248                Action::WriteArtifact {
2249                    kind: ArtifactKind::CoverJpg,
2250                    path: "b/cover.jpg".to_owned(),
2251                    source_url: b.selected_image_url().unwrap().to_owned(),
2252                    hash: "art".to_owned(),
2253                    owner_id: "b".to_owned(),
2254                    content: None,
2255                },
2256            ],
2257        };
2258        let http = ScriptedHttp::new()
2259            .route("a.mp3", Reply::ok(b"a-mp3".to_vec()))
2260            .route("b.mp3", Reply::ok(b"b-mp3".to_vec()))
2261            .route("a/large.jpg", Reply::ok(b"art-a".to_vec()))
2262            .route("b/large.jpg", Reply::ok(b"art-b".to_vec()));
2263        let fs = MemFs::new();
2264        let mut manifest = Manifest::new();
2265
2266        let outcome = run(
2267            &plan,
2268            &mut manifest,
2269            &[da, db],
2270            &http,
2271            &fs,
2272            &StubFfmpeg::flac(),
2273            &RecordingClock::new(),
2274            &small_poll(),
2275        );
2276
2277        assert_eq!(outcome.downloaded, 2);
2278        assert_eq!(outcome.artifacts_written, 2);
2279        assert_eq!(http.count("a/large.jpg"), 1);
2280        assert_eq!(http.count("b/large.jpg"), 1);
2281        assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"art-a");
2282        assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"art-b");
2283    }
2284
2285    #[test]
2286    fn cover_sidecar_refetches_when_embed_fell_back_to_another_url() {
2287        // The large image 404s so the embed falls back to the small image; the
2288        // sidecar still wants the (dead) large URL and must NOT be handed the
2289        // small bytes. Reuse is keyed on the exact URL, so nothing is cached and
2290        // the sidecar fetches the large URL itself (then fails on the 404).
2291        let c = art_clip("e");
2292        let d = desired(c.clone(), AudioFormat::Mp3);
2293        let plan = Plan {
2294            actions: vec![
2295                Action::Download {
2296                    clip: c.clone(),
2297                    lineage: LineageContext::own_root(&c),
2298                    path: d.path.clone(),
2299                    format: AudioFormat::Mp3,
2300                },
2301                Action::WriteArtifact {
2302                    kind: ArtifactKind::CoverJpg,
2303                    path: "e/cover.jpg".to_owned(),
2304                    source_url: "https://art.suno.ai/e/large.jpg".to_owned(),
2305                    hash: "art".to_owned(),
2306                    owner_id: "e".to_owned(),
2307                    content: None,
2308                },
2309            ],
2310        };
2311        let http = ScriptedHttp::new()
2312            .route("e.mp3", Reply::ok(b"body".to_vec()))
2313            .route("e/large.jpg", Reply::status(404))
2314            .route("e/small.jpg", Reply::ok(b"small-art".to_vec()));
2315        let fs = MemFs::new();
2316        let mut manifest = Manifest::new();
2317
2318        let outcome = run(
2319            &plan,
2320            &mut manifest,
2321            &[d],
2322            &http,
2323            &fs,
2324            &StubFfmpeg::flac(),
2325            &RecordingClock::new(),
2326            &ExecOptions::default(),
2327        );
2328
2329        assert_eq!(outcome.downloaded, 1);
2330        // The small image was fetched once (the embed fallback) and never reused
2331        // for the large-keyed sidecar; the sidecar went to the network itself.
2332        assert_eq!(http.count("e/small.jpg"), 1);
2333        assert!(
2334            http.count("e/large.jpg") >= 2,
2335            "sidecar refetched the large URL"
2336        );
2337        assert_eq!(manifest.get("e").unwrap().cover_jpg, None);
2338        assert!(!fs.exists("e/cover.jpg"));
2339    }
2340
2341    // ── Atomic write and size verification (SYNC-13/14) ─────────────
2342
2343    #[test]
2344    fn failed_write_leaves_the_prior_file_intact() {
2345        let c = clip("f");
2346        let d = desired(c.clone(), AudioFormat::Mp3);
2347        let plan = Plan {
2348            actions: vec![Action::Download {
2349                clip: c.clone(),
2350                lineage: LineageContext::own_root(&c),
2351                path: d.path.clone(),
2352                format: AudioFormat::Mp3,
2353            }],
2354        };
2355        let http = ScriptedHttp::new().route("f.mp3", Reply::ok(b"new-body".to_vec()));
2356        let fs = MemFs::new()
2357            .with_file("f.mp3", b"OLD-CONTENT".to_vec())
2358            .fail_write("f.mp3");
2359        let mut manifest = Manifest::new();
2360
2361        let outcome = run(
2362            &plan,
2363            &mut manifest,
2364            &[d],
2365            &http,
2366            &fs,
2367            &StubFfmpeg::flac(),
2368            &RecordingClock::new(),
2369            &ExecOptions::default(),
2370        );
2371
2372        assert_eq!(outcome.downloaded, 0);
2373        assert_eq!(outcome.failed(), 1);
2374        assert_eq!(fs.read_file("f.mp3").unwrap(), b"OLD-CONTENT");
2375        assert!(manifest.get("f").is_none());
2376    }
2377
2378    #[test]
2379    fn size_mismatch_after_write_is_a_failure() {
2380        let c = clip("g");
2381        let d = desired(c.clone(), AudioFormat::Mp3);
2382        let plan = Plan {
2383            actions: vec![Action::Download {
2384                clip: c.clone(),
2385                lineage: LineageContext::own_root(&c),
2386                path: d.path.clone(),
2387                format: AudioFormat::Mp3,
2388            }],
2389        };
2390        let http = ScriptedHttp::new().route("g.mp3", Reply::ok(b"body".to_vec()));
2391        let fs = MemFs::new().corrupt_write("g.mp3");
2392        let mut manifest = Manifest::new();
2393
2394        let outcome = run(
2395            &plan,
2396            &mut manifest,
2397            &[d],
2398            &http,
2399            &fs,
2400            &StubFfmpeg::flac(),
2401            &RecordingClock::new(),
2402            &ExecOptions::default(),
2403        );
2404
2405        assert_eq!(outcome.downloaded, 0);
2406        assert_eq!(outcome.failed(), 1);
2407        assert!(outcome.failures[0].reason.contains("expected"));
2408        assert!(manifest.get("g").is_none());
2409    }
2410
2411    // ── Reliability policy (SYNC-16/17) ─────────────────────────────
2412
2413    #[test]
2414    fn transient_failure_is_retried_then_skipped() {
2415        let c = clip("h");
2416        let d = desired(c.clone(), AudioFormat::Mp3);
2417        let plan = Plan {
2418            actions: vec![Action::Download {
2419                clip: c.clone(),
2420                lineage: LineageContext::own_root(&c),
2421                path: d.path.clone(),
2422                format: AudioFormat::Mp3,
2423            }],
2424        };
2425        let http = ScriptedHttp::new().route("h.mp3", Reply::status(500));
2426        let fs = MemFs::new();
2427        let clock = RecordingClock::new();
2428        let opts = ExecOptions {
2429            max_retries: 2,
2430            ..ExecOptions::default()
2431        };
2432        let mut manifest = Manifest::new();
2433
2434        let outcome = run(
2435            &plan,
2436            &mut manifest,
2437            &[d],
2438            &http,
2439            &fs,
2440            &StubFfmpeg::flac(),
2441            &clock,
2442            &opts,
2443        );
2444
2445        assert_eq!(outcome.downloaded, 0);
2446        assert_eq!(outcome.failed(), 1);
2447        assert_eq!(http.count("h.mp3"), 3);
2448        assert_eq!(clock.sleeps().len(), 2);
2449    }
2450
2451    #[test]
2452    fn truncated_download_is_retried_then_succeeds() {
2453        let c = clip("i");
2454        let d = desired(c.clone(), AudioFormat::Mp3);
2455        let plan = Plan {
2456            actions: vec![Action::Download {
2457                clip: c.clone(),
2458                lineage: LineageContext::own_root(&c),
2459                path: d.path.clone(),
2460                format: AudioFormat::Mp3,
2461            }],
2462        };
2463        let http = ScriptedHttp::new().route_seq(
2464            "i.mp3",
2465            vec![
2466                Reply::ok(b"short".to_vec()).with_content_length(999),
2467                Reply::ok(b"good-body".to_vec()),
2468            ],
2469        );
2470        let fs = MemFs::new();
2471        let clock = RecordingClock::new();
2472        let mut manifest = Manifest::new();
2473
2474        let outcome = run(
2475            &plan,
2476            &mut manifest,
2477            &[d],
2478            &http,
2479            &fs,
2480            &StubFfmpeg::flac(),
2481            &clock,
2482            &ExecOptions::default(),
2483        );
2484
2485        assert_eq!(outcome.downloaded, 1);
2486        assert_eq!(http.count("i.mp3"), 2);
2487        assert_eq!(clock.sleeps().len(), 1);
2488    }
2489
2490    #[test]
2491    fn rate_limit_backs_off_using_retry_after() {
2492        let c = clip("j");
2493        let d = desired(c.clone(), AudioFormat::Mp3);
2494        let plan = Plan {
2495            actions: vec![Action::Download {
2496                clip: c.clone(),
2497                lineage: LineageContext::own_root(&c),
2498                path: d.path.clone(),
2499                format: AudioFormat::Mp3,
2500            }],
2501        };
2502        let http = ScriptedHttp::new().route_seq(
2503            "j.mp3",
2504            vec![
2505                Reply::status(429).with_retry_after(7),
2506                Reply::ok(b"body".to_vec()),
2507            ],
2508        );
2509        let fs = MemFs::new();
2510        let clock = RecordingClock::new();
2511        let mut manifest = Manifest::new();
2512
2513        let outcome = run(
2514            &plan,
2515            &mut manifest,
2516            &[d],
2517            &http,
2518            &fs,
2519            &StubFfmpeg::flac(),
2520            &clock,
2521            &ExecOptions::default(),
2522        );
2523
2524        assert_eq!(outcome.downloaded, 1);
2525        assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
2526    }
2527
2528    #[test]
2529    fn auth_failure_aborts_the_run() {
2530        let c1 = clip("k1");
2531        let c2 = clip("k2");
2532        let d1 = desired(c1.clone(), AudioFormat::Flac);
2533        let d2 = desired(c2.clone(), AudioFormat::Flac);
2534        let plan = Plan {
2535            actions: vec![
2536                Action::Download {
2537                    clip: c1.clone(),
2538                    lineage: LineageContext::own_root(&c1),
2539                    path: d1.path.clone(),
2540                    format: AudioFormat::Flac,
2541                },
2542                Action::Download {
2543                    clip: c2.clone(),
2544                    lineage: LineageContext::own_root(&c2),
2545                    path: d2.path.clone(),
2546                    format: AudioFormat::Flac,
2547                },
2548            ],
2549        };
2550        // The authenticated WAV-render endpoint rejects auth even after a JWT
2551        // refresh: that is a bad token, so the whole run aborts rather than
2552        // hammering every clip. A CDN media rejection, by contrast, does not.
2553        let http = ScriptedHttp::new()
2554            .with_auth()
2555            .route("/wav_file/", Reply::status(401));
2556        let fs = MemFs::new();
2557        let mut manifest = Manifest::new();
2558
2559        let outcome = run(
2560            &plan,
2561            &mut manifest,
2562            &[d1, d2],
2563            &http,
2564            &fs,
2565            &StubFfmpeg::flac(),
2566            &RecordingClock::new(),
2567            &small_poll(),
2568        );
2569
2570        assert_eq!(outcome.status, RunStatus::AuthAborted);
2571        assert_eq!(outcome.failed(), 1);
2572        assert_eq!(outcome.failures[0].clip_id, "k1");
2573        assert_eq!(outcome.downloaded, 0);
2574    }
2575
2576    // ── Disk-full aborts the run (issue #17) ────────────────────────
2577
2578    #[test]
2579    fn disk_full_primary_write_aborts_the_run() {
2580        // Two MP3 downloads; the first write is out of space. That is systemic,
2581        // so the run aborts before the second is even attempted: exactly one
2582        // failure is recorded and its reason names the disk-full cause.
2583        let c1 = clip("d1");
2584        let c2 = clip("d2");
2585        let d1 = desired(c1.clone(), AudioFormat::Mp3);
2586        let d2 = desired(c2.clone(), AudioFormat::Mp3);
2587        let plan = Plan {
2588            actions: vec![
2589                Action::Download {
2590                    clip: c1.clone(),
2591                    lineage: LineageContext::own_root(&c1),
2592                    path: d1.path.clone(),
2593                    format: AudioFormat::Mp3,
2594                },
2595                Action::Download {
2596                    clip: c2.clone(),
2597                    lineage: LineageContext::own_root(&c2),
2598                    path: d2.path.clone(),
2599                    format: AudioFormat::Mp3,
2600                },
2601            ],
2602        };
2603        let http = ScriptedHttp::new()
2604            .route("d1.mp3", Reply::ok(b"body-1".to_vec()))
2605            .route("d2.mp3", Reply::ok(b"body-2".to_vec()));
2606        let fs = MemFs::new().fail_write_out_of_space("d1.mp3");
2607        let mut manifest = Manifest::new();
2608
2609        let outcome = run(
2610            &plan,
2611            &mut manifest,
2612            &[d1, d2],
2613            &http,
2614            &fs,
2615            &StubFfmpeg::flac(),
2616            &RecordingClock::new(),
2617            &ExecOptions::default(),
2618        );
2619
2620        assert_eq!(outcome.status, RunStatus::DiskFull);
2621        assert_eq!(outcome.failed(), 1);
2622        assert_eq!(outcome.failures[0].clip_id, "d1");
2623        assert!(outcome.failures[0].reason.contains("disk full"));
2624        assert_eq!(outcome.downloaded, 0);
2625        // The second clip was never fetched: the run aborted first.
2626        assert_eq!(http.count("d2.mp3"), 0);
2627        assert!(!fs.exists("d2.mp3"));
2628    }
2629
2630    #[test]
2631    fn disk_full_flac_transcode_aborts_the_run() {
2632        // The scratch disk fills during the FLAC re-encode; a WAV rendered, but
2633        // there is nowhere to stage the transcode, so the run aborts.
2634        let c1 = clip("d1");
2635        let c2 = clip("d2");
2636        let d1 = desired(c1.clone(), AudioFormat::Flac);
2637        let d2 = desired(c2.clone(), AudioFormat::Flac);
2638        let plan = Plan {
2639            actions: vec![
2640                Action::Download {
2641                    clip: c1.clone(),
2642                    lineage: LineageContext::own_root(&c1),
2643                    path: d1.path.clone(),
2644                    format: AudioFormat::Flac,
2645                },
2646                Action::Download {
2647                    clip: c2.clone(),
2648                    lineage: LineageContext::own_root(&c2),
2649                    path: d2.path.clone(),
2650                    format: AudioFormat::Flac,
2651                },
2652            ],
2653        };
2654        let http = ScriptedHttp::new()
2655            .with_auth()
2656            .route(
2657                "/wav_file/",
2658                Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/d1.wav"}"#),
2659            )
2660            .route(".wav", Reply::ok(b"wav".to_vec()));
2661        let fs = MemFs::new();
2662        let mut manifest = Manifest::new();
2663
2664        let outcome = run(
2665            &plan,
2666            &mut manifest,
2667            &[d1, d2],
2668            &http,
2669            &fs,
2670            &StubFfmpeg::out_of_space(),
2671            &RecordingClock::new(),
2672            &ExecOptions::default(),
2673        );
2674
2675        assert_eq!(outcome.status, RunStatus::DiskFull);
2676        assert_eq!(outcome.failed(), 1);
2677        assert_eq!(outcome.failures[0].clip_id, "d1");
2678        assert!(outcome.failures[0].reason.contains("disk full"));
2679        assert_eq!(outcome.downloaded, 0);
2680    }
2681
2682    #[test]
2683    fn disk_full_artifact_write_aborts_the_run() {
2684        // A sidecar write (not a primary download) also aborts on a full disk:
2685        // the owning audio is present, the cover fetch succeeds, but the sidecar
2686        // cannot be written.
2687        let mut manifest = Manifest::new();
2688        manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
2689        let plan = Plan {
2690            actions: vec![Action::WriteArtifact {
2691                kind: ArtifactKind::CoverJpg,
2692                path: "a/cover.jpg".to_owned(),
2693                source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
2694                hash: "h1".to_owned(),
2695                owner_id: "a".to_owned(),
2696                content: None,
2697            }],
2698        };
2699        let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"jpg-bytes".to_vec()));
2700        let fs = MemFs::new().fail_write_out_of_space("a/cover.jpg");
2701
2702        let outcome = run(
2703            &plan,
2704            &mut manifest,
2705            &[],
2706            &http,
2707            &fs,
2708            &StubFfmpeg::flac(),
2709            &RecordingClock::new(),
2710            &ExecOptions::default(),
2711        );
2712
2713        assert_eq!(outcome.status, RunStatus::DiskFull);
2714        assert_eq!(outcome.failed(), 1);
2715        assert!(outcome.failures[0].reason.contains("disk full"));
2716        assert_eq!(outcome.artifacts_written, 0);
2717        // The sidecar slot was never recorded: the write failed before it.
2718        assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
2719    }
2720
2721    #[test]
2722    fn disk_full_leaves_the_failed_clips_manifest_entry_unchanged() {
2723        // write_verify fails before any manifest insert, so a re-download that
2724        // hits a full disk leaves the prior entry (and file) exactly as it was.
2725        let c = clip("m");
2726        let d = desired(c.clone(), AudioFormat::Mp3);
2727        let plan = Plan {
2728            actions: vec![Action::Download {
2729                clip: c.clone(),
2730                lineage: LineageContext::own_root(&c),
2731                path: d.path.clone(),
2732                format: AudioFormat::Mp3,
2733            }],
2734        };
2735        let http = ScriptedHttp::new().route("m.mp3", Reply::ok(b"new-body".to_vec()));
2736        let fs = MemFs::new()
2737            .with_file("m.mp3", b"OLD-CONTENT".to_vec())
2738            .fail_write_out_of_space("m.mp3");
2739        let mut manifest = Manifest::new();
2740        let before = entry("m.mp3", AudioFormat::Mp3);
2741        manifest.insert("m", before.clone());
2742
2743        let outcome = run(
2744            &plan,
2745            &mut manifest,
2746            &[d],
2747            &http,
2748            &fs,
2749            &StubFfmpeg::flac(),
2750            &RecordingClock::new(),
2751            &ExecOptions::default(),
2752        );
2753
2754        assert_eq!(outcome.status, RunStatus::DiskFull);
2755        assert_eq!(manifest.get("m"), Some(&before));
2756        assert_eq!(fs.read_file("m.mp3").unwrap(), b"OLD-CONTENT");
2757    }
2758
2759    #[test]
2760    fn cdn_download_rejection_skips_the_clip_without_aborting() {
2761        let c1 = clip("k1");
2762        let c2 = clip("k2");
2763        let d1 = desired(c1.clone(), AudioFormat::Mp3);
2764        let d2 = desired(c2.clone(), AudioFormat::Mp3);
2765        let plan = Plan {
2766            actions: vec![
2767                Action::Download {
2768                    clip: c1.clone(),
2769                    lineage: LineageContext::own_root(&c1),
2770                    path: d1.path.clone(),
2771                    format: AudioFormat::Mp3,
2772                },
2773                Action::Download {
2774                    clip: c2.clone(),
2775                    lineage: LineageContext::own_root(&c2),
2776                    path: d2.path.clone(),
2777                    format: AudioFormat::Mp3,
2778                },
2779            ],
2780        };
2781        // A CDN media fetch is unauthenticated, so a 403 is a per-asset
2782        // rejection (often transient), not a bad token: the clip is retried
2783        // then recorded and skipped, and the run carries on to the rest.
2784        let http = ScriptedHttp::new()
2785            .route("k1.mp3", Reply::status(403))
2786            .route("k2.mp3", Reply::ok(b"body".to_vec()));
2787        let fs = MemFs::new();
2788        let mut manifest = Manifest::new();
2789
2790        let outcome = run(
2791            &plan,
2792            &mut manifest,
2793            &[d1, d2],
2794            &http,
2795            &fs,
2796            &StubFfmpeg::flac(),
2797            &RecordingClock::new(),
2798            &ExecOptions::default(),
2799        );
2800
2801        assert_ne!(outcome.status, RunStatus::AuthAborted);
2802        assert_eq!(outcome.downloaded, 1);
2803        assert_eq!(outcome.failed(), 1);
2804        assert_eq!(outcome.failures[0].clip_id, "k1");
2805    }
2806
2807    #[test]
2808    fn one_clip_failure_does_not_abort_the_run() {
2809        let c1 = clip("l1");
2810        let c2 = clip("l2");
2811        let d1 = desired(c1.clone(), AudioFormat::Mp3);
2812        let d2 = desired(c2.clone(), AudioFormat::Mp3);
2813        let plan = Plan {
2814            actions: vec![
2815                Action::Download {
2816                    clip: c1.clone(),
2817                    lineage: LineageContext::own_root(&c1),
2818                    path: d1.path.clone(),
2819                    format: AudioFormat::Mp3,
2820                },
2821                Action::Download {
2822                    clip: c2.clone(),
2823                    lineage: LineageContext::own_root(&c2),
2824                    path: d2.path.clone(),
2825                    format: AudioFormat::Mp3,
2826                },
2827            ],
2828        };
2829        let http = ScriptedHttp::new()
2830            .route("l1.mp3", Reply::status(404))
2831            .route("l2.mp3", Reply::ok(b"body".to_vec()));
2832        let fs = MemFs::new();
2833        let mut manifest = Manifest::new();
2834
2835        let outcome = run(
2836            &plan,
2837            &mut manifest,
2838            &[d1, d2],
2839            &http,
2840            &fs,
2841            &StubFfmpeg::flac(),
2842            &RecordingClock::new(),
2843            &ExecOptions::default(),
2844        );
2845
2846        assert_eq!(outcome.status, RunStatus::Completed);
2847        assert_eq!(outcome.downloaded, 1);
2848        assert_eq!(outcome.failed(), 1);
2849        assert_eq!(outcome.failures[0].clip_id, "l1");
2850        assert!(fs.exists("l2.mp3"));
2851        assert!(manifest.get("l2").is_some());
2852        assert!(manifest.get("l1").is_none());
2853    }
2854
2855    // ── preserve marker (SYNC-8) ────────────────────────────────────
2856
2857    #[test]
2858    fn preserve_is_set_for_copy_held_and_private_clips() {
2859        let mut mirror = desired(clip("m1"), AudioFormat::Mp3);
2860        mirror.modes = vec![SourceMode::Mirror];
2861        let mut copy_held = desired(clip("m2"), AudioFormat::Mp3);
2862        copy_held.modes = vec![SourceMode::Mirror, SourceMode::Copy];
2863        let mut private = desired(clip("m3"), AudioFormat::Mp3);
2864        private.private = true;
2865
2866        let plan = Plan {
2867            actions: vec![
2868                Action::Download {
2869                    clip: mirror.clip.clone(),
2870                    lineage: LineageContext::own_root(&mirror.clip),
2871                    path: mirror.path.clone(),
2872                    format: AudioFormat::Mp3,
2873                },
2874                Action::Download {
2875                    clip: copy_held.clip.clone(),
2876                    lineage: LineageContext::own_root(&copy_held.clip),
2877                    path: copy_held.path.clone(),
2878                    format: AudioFormat::Mp3,
2879                },
2880                Action::Download {
2881                    clip: private.clip.clone(),
2882                    lineage: LineageContext::own_root(&private.clip),
2883                    path: private.path.clone(),
2884                    format: AudioFormat::Mp3,
2885                },
2886            ],
2887        };
2888        let http = ScriptedHttp::new()
2889            .route("m1.mp3", Reply::ok(b"a".to_vec()))
2890            .route("m2.mp3", Reply::ok(b"b".to_vec()))
2891            .route("m3.mp3", Reply::ok(b"c".to_vec()));
2892        let fs = MemFs::new();
2893        let mut manifest = Manifest::new();
2894
2895        let outcome = run(
2896            &plan,
2897            &mut manifest,
2898            &[mirror, copy_held, private],
2899            &http,
2900            &fs,
2901            &StubFfmpeg::flac(),
2902            &RecordingClock::new(),
2903            &ExecOptions::default(),
2904        );
2905
2906        assert_eq!(outcome.downloaded, 3);
2907        assert!(!manifest.get("m1").unwrap().preserve);
2908        assert!(manifest.get("m2").unwrap().preserve);
2909        assert!(manifest.get("m3").unwrap().preserve);
2910    }
2911
2912    // ── Reformat / Retag / Rename / Delete / Skip ───────────────────
2913
2914    #[test]
2915    fn reformat_writes_new_format_and_removes_old_file() {
2916        let c = clip("n");
2917        let d = desired(c.clone(), AudioFormat::Mp3);
2918        let plan = Plan {
2919            actions: vec![Action::Reformat {
2920                clip: c.clone(),
2921                path: "n.mp3".to_owned(),
2922                from_path: "n.flac".to_owned(),
2923                from: AudioFormat::Flac,
2924                to: AudioFormat::Mp3,
2925            }],
2926        };
2927        let http = ScriptedHttp::new().route("n.mp3", Reply::ok(b"body".to_vec()));
2928        let fs = MemFs::new().with_file("n.flac", b"OLD-FLAC".to_vec());
2929        let mut manifest = Manifest::new();
2930        manifest.insert("n", entry("n.flac", AudioFormat::Flac));
2931
2932        let outcome = run(
2933            &plan,
2934            &mut manifest,
2935            &[d],
2936            &http,
2937            &fs,
2938            &StubFfmpeg::flac(),
2939            &RecordingClock::new(),
2940            &ExecOptions::default(),
2941        );
2942
2943        assert_eq!(outcome.reformatted, 1);
2944        assert!(fs.exists("n.mp3"));
2945        assert!(!fs.exists("n.flac"));
2946        let updated = manifest.get("n").unwrap();
2947        assert_eq!(updated.path, "n.mp3");
2948        assert_eq!(updated.format, AudioFormat::Mp3);
2949        assert_eq!(updated.meta_hash, "m");
2950    }
2951
2952    #[test]
2953    fn retag_rewrites_file_and_updates_hashes() {
2954        let c = clip("o");
2955        let mut d = desired(c.clone(), AudioFormat::Mp3);
2956        d.meta_hash = "new".to_owned();
2957        d.art_hash = "new-art".to_owned();
2958        let existing = tag_mp3(
2959            b"audio",
2960            &TrackMetadata::from_clip(&c, &LineageContext::own_root(&c)),
2961            None,
2962            None,
2963        )
2964        .unwrap();
2965        let fs = MemFs::new().with_file("o.mp3", existing.clone());
2966        let mut manifest = Manifest::new();
2967        let mut start = entry("o.mp3", AudioFormat::Mp3);
2968        start.size = existing.len() as u64;
2969        manifest.insert("o", start);
2970        let plan = Plan {
2971            actions: vec![Action::Retag {
2972                clip: c.clone(),
2973                lineage: LineageContext::own_root(&c),
2974                path: "o.mp3".to_owned(),
2975            }],
2976        };
2977
2978        let outcome = run(
2979            &plan,
2980            &mut manifest,
2981            &[d],
2982            &ScriptedHttp::new(),
2983            &fs,
2984            &StubFfmpeg::flac(),
2985            &RecordingClock::new(),
2986            &ExecOptions::default(),
2987        );
2988
2989        assert_eq!(outcome.retagged, 1);
2990        let updated = manifest.get("o").unwrap();
2991        assert_eq!(updated.meta_hash, "new");
2992        assert_eq!(updated.art_hash, "new-art");
2993        assert_eq!(&fs.read_file("o.mp3").unwrap()[..3], b"ID3");
2994    }
2995
2996    #[test]
2997    fn rename_moves_file_and_updates_manifest_path() {
2998        let c = clip("p");
2999        let mut d = desired(c.clone(), AudioFormat::Mp3);
3000        d.path = "new/p.mp3".to_owned();
3001        let fs = MemFs::new().with_file("old/p.mp3", b"DATA".to_vec());
3002        let mut manifest = Manifest::new();
3003        manifest.insert("p", entry("old/p.mp3", AudioFormat::Mp3));
3004        let plan = Plan {
3005            actions: vec![Action::Rename {
3006                from: "old/p.mp3".to_owned(),
3007                to: "new/p.mp3".to_owned(),
3008            }],
3009        };
3010
3011        let outcome = run(
3012            &plan,
3013            &mut manifest,
3014            &[d],
3015            &ScriptedHttp::new(),
3016            &fs,
3017            &StubFfmpeg::flac(),
3018            &RecordingClock::new(),
3019            &ExecOptions::default(),
3020        );
3021
3022        assert_eq!(outcome.renamed, 1);
3023        assert!(fs.exists("new/p.mp3"));
3024        assert!(!fs.exists("old/p.mp3"));
3025        assert_eq!(manifest.get("p").unwrap().path, "new/p.mp3");
3026    }
3027
3028    #[test]
3029    fn disk_full_rename_aborts_the_run() {
3030        // A move onto a full disk is systemic like a full-disk write: the run
3031        // aborts with DiskFull and the source file is left untouched.
3032        let c = clip("p");
3033        let mut d = desired(c.clone(), AudioFormat::Mp3);
3034        d.path = "new/p.mp3".to_owned();
3035        let fs = MemFs::new()
3036            .with_file("old/p.mp3", b"DATA".to_vec())
3037            .fail_rename_out_of_space("new/p.mp3");
3038        let mut manifest = Manifest::new();
3039        manifest.insert("p", entry("old/p.mp3", AudioFormat::Mp3));
3040        let plan = Plan {
3041            actions: vec![Action::Rename {
3042                from: "old/p.mp3".to_owned(),
3043                to: "new/p.mp3".to_owned(),
3044            }],
3045        };
3046
3047        let outcome = run(
3048            &plan,
3049            &mut manifest,
3050            &[d],
3051            &ScriptedHttp::new(),
3052            &fs,
3053            &StubFfmpeg::flac(),
3054            &RecordingClock::new(),
3055            &ExecOptions::default(),
3056        );
3057
3058        assert_eq!(outcome.status, RunStatus::DiskFull);
3059        assert_eq!(outcome.renamed, 0);
3060        assert_eq!(outcome.failed(), 1);
3061        assert!(outcome.failures[0].reason.contains("disk full"));
3062        // The source is untouched: the move never happened.
3063        assert!(fs.exists("old/p.mp3"));
3064        assert!(!fs.exists("new/p.mp3"));
3065        assert_eq!(manifest.get("p").unwrap().path, "old/p.mp3");
3066    }
3067
3068    #[test]
3069    fn delete_removes_file_and_manifest_entry() {
3070        let fs = MemFs::new().with_file("q.mp3", b"DATA".to_vec());
3071        let mut manifest = Manifest::new();
3072        manifest.insert("q", entry("q.mp3", AudioFormat::Mp3));
3073        let plan = Plan {
3074            actions: vec![Action::Delete {
3075                path: "q.mp3".to_owned(),
3076                clip_id: "q".to_owned(),
3077            }],
3078        };
3079
3080        let outcome = run(
3081            &plan,
3082            &mut manifest,
3083            &[],
3084            &ScriptedHttp::new(),
3085            &fs,
3086            &StubFfmpeg::flac(),
3087            &RecordingClock::new(),
3088            &ExecOptions::default(),
3089        );
3090
3091        assert_eq!(outcome.deleted, 1);
3092        assert!(!fs.exists("q.mp3"));
3093        assert!(manifest.get("q").is_none());
3094    }
3095
3096    #[test]
3097    fn failed_delete_keeps_the_manifest_entry() {
3098        let fs = MemFs::new()
3099            .with_file("s.mp3", b"DATA".to_vec())
3100            .fail_remove("s.mp3");
3101        let mut manifest = Manifest::new();
3102        manifest.insert("s", entry("s.mp3", AudioFormat::Mp3));
3103        let plan = Plan {
3104            actions: vec![Action::Delete {
3105                path: "s.mp3".to_owned(),
3106                clip_id: "s".to_owned(),
3107            }],
3108        };
3109
3110        let outcome = run(
3111            &plan,
3112            &mut manifest,
3113            &[],
3114            &ScriptedHttp::new(),
3115            &fs,
3116            &StubFfmpeg::flac(),
3117            &RecordingClock::new(),
3118            &ExecOptions::default(),
3119        );
3120
3121        assert_eq!(outcome.deleted, 0);
3122        assert_eq!(outcome.failed(), 1);
3123        assert!(manifest.get("s").is_some());
3124        assert!(fs.exists("s.mp3"));
3125    }
3126
3127    #[test]
3128    fn skip_is_a_noop() {
3129        let mut manifest = Manifest::new();
3130        let plan = Plan {
3131            actions: vec![Action::Skip {
3132                clip_id: "r".to_owned(),
3133            }],
3134        };
3135        let outcome = run(
3136            &plan,
3137            &mut manifest,
3138            &[],
3139            &ScriptedHttp::new(),
3140            &MemFs::new(),
3141            &StubFfmpeg::flac(),
3142            &RecordingClock::new(),
3143            &ExecOptions::default(),
3144        );
3145        assert_eq!(outcome.skipped, 1);
3146        assert_eq!(outcome.failed(), 0);
3147    }
3148
3149    // ── Pure helpers ────────────────────────────────────────────────
3150
3151    #[test]
3152    fn header_helpers_parse_or_ignore() {
3153        let resp = HttpResponse {
3154            status: 200,
3155            headers: vec![("Content-Length".to_owned(), "42".to_owned())],
3156            body: Vec::new(),
3157        };
3158        assert_eq!(content_length(&resp), Some(42));
3159
3160        let bare = HttpResponse {
3161            status: 200,
3162            headers: Vec::new(),
3163            body: Vec::new(),
3164        };
3165        assert_eq!(content_length(&bare), None);
3166    }
3167
3168    #[test]
3169    fn preserve_rule_covers_copy_and_private() {
3170        let base = desired(clip("x"), AudioFormat::Mp3);
3171        assert!(!preserve_for(&base));
3172        let mut copy_held = base.clone();
3173        copy_held.modes = vec![SourceMode::Copy];
3174        assert!(preserve_for(&copy_held));
3175        let mut private = base.clone();
3176        private.private = true;
3177        assert!(preserve_for(&private));
3178    }
3179
3180    fn fs_new() -> MemFs {
3181        MemFs::new()
3182    }
3183
3184    // ── Skip refreshes the preserve marker (SYNC-8 cross-run) ────────
3185
3186    #[test]
3187    fn skip_sets_preserve_when_a_clip_becomes_copy_held() {
3188        let c = clip("s1");
3189        let mut d = desired(c.clone(), AudioFormat::Mp3);
3190        d.modes = vec![SourceMode::Copy];
3191        let plan = Plan {
3192            actions: vec![Action::Skip {
3193                clip_id: "s1".to_owned(),
3194            }],
3195        };
3196        let mut manifest = Manifest::new();
3197        manifest.insert("s1".to_owned(), entry("s1.mp3", AudioFormat::Mp3));
3198        assert!(!manifest.get("s1").unwrap().preserve);
3199
3200        let outcome = run(
3201            &plan,
3202            &mut manifest,
3203            &[d],
3204            &ScriptedHttp::new(),
3205            &fs_new(),
3206            &StubFfmpeg::flac(),
3207            &RecordingClock::new(),
3208            &ExecOptions::default(),
3209        );
3210
3211        assert_eq!(outcome.skipped, 1);
3212        assert!(
3213            manifest.get("s1").unwrap().preserve,
3214            "a copy-held skip must mark the entry preserved"
3215        );
3216    }
3217
3218    #[test]
3219    fn skip_clears_stale_preserve_when_a_clip_returns_to_mirror_only() {
3220        let c = clip("s2");
3221        let d = desired(c.clone(), AudioFormat::Mp3);
3222        let plan = Plan {
3223            actions: vec![Action::Skip {
3224                clip_id: "s2".to_owned(),
3225            }],
3226        };
3227        let mut manifest = Manifest::new();
3228        let mut stale = entry("s2.mp3", AudioFormat::Mp3);
3229        stale.preserve = true;
3230        manifest.insert("s2".to_owned(), stale);
3231
3232        run(
3233            &plan,
3234            &mut manifest,
3235            &[d],
3236            &ScriptedHttp::new(),
3237            &fs_new(),
3238            &StubFfmpeg::flac(),
3239            &RecordingClock::new(),
3240            &ExecOptions::default(),
3241        );
3242
3243        assert!(
3244            !manifest.get("s2").unwrap().preserve,
3245            "a mirror-only skip must clear a stale preserve marker"
3246        );
3247    }
3248
3249    #[test]
3250    fn flac_render_retries_a_rate_limited_wav_lookup() {
3251        let c = clip("rl");
3252        let d = desired(c.clone(), AudioFormat::Flac);
3253        let plan = Plan {
3254            actions: vec![Action::Download {
3255                clip: c.clone(),
3256                lineage: LineageContext::own_root(&c),
3257                path: d.path.clone(),
3258                format: AudioFormat::Flac,
3259            }],
3260        };
3261        let http = ScriptedHttp::new()
3262            .with_auth()
3263            .route_seq(
3264                "/wav_file/",
3265                vec![
3266                    Reply::status(429),
3267                    Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/rl.wav"}"#),
3268                ],
3269            )
3270            .route("rl.wav", Reply::ok(b"wav".to_vec()));
3271        let clock = RecordingClock::new();
3272        let mut manifest = Manifest::new();
3273
3274        let outcome = run(
3275            &plan,
3276            &mut manifest,
3277            &[d],
3278            &http,
3279            &fs_new(),
3280            &StubFfmpeg::flac(),
3281            &clock,
3282            &small_poll(),
3283        );
3284
3285        assert_eq!(outcome.downloaded, 1);
3286        assert_eq!(outcome.failed(), 0);
3287        // The render was ready on retry, so no fresh convert_wav was needed.
3288        assert_eq!(http.count("/convert_wav/"), 0);
3289        // One transient backoff (1s base), not the 5s poll interval.
3290        assert_eq!(clock.sleeps(), vec![Duration::from_secs(1)]);
3291    }
3292
3293    // ── Phase 6: artifact actions ───────────────────────────────────
3294
3295    #[test]
3296    fn write_artifact_fetches_writes_and_updates_manifest() {
3297        // The owning entry exists (its audio was kept this run); WriteArtifact
3298        // fetches the source, writes the sidecar, and records it on the entry.
3299        let mut manifest = Manifest::new();
3300        manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
3301        let plan = Plan {
3302            actions: vec![Action::WriteArtifact {
3303                kind: ArtifactKind::CoverJpg,
3304                path: "a/cover.jpg".to_owned(),
3305                source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
3306                hash: "h1".to_owned(),
3307                owner_id: "a".to_owned(),
3308                content: None,
3309            }],
3310        };
3311        let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"jpg-bytes".to_vec()));
3312        let fs = MemFs::new();
3313
3314        let outcome = run(
3315            &plan,
3316            &mut manifest,
3317            &[],
3318            &http,
3319            &fs,
3320            &StubFfmpeg::flac(),
3321            &RecordingClock::new(),
3322            &ExecOptions::default(),
3323        );
3324
3325        assert_eq!(outcome.artifacts_written, 1);
3326        assert_eq!(outcome.failed(), 0);
3327        assert_eq!(outcome.status, RunStatus::Completed);
3328        assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"jpg-bytes");
3329        assert_eq!(
3330            manifest.get("a").unwrap().cover_jpg,
3331            Some(ArtifactState {
3332                path: "a/cover.jpg".to_owned(),
3333                hash: "h1".to_owned(),
3334            })
3335        );
3336    }
3337
3338    #[test]
3339    fn write_text_sidecar_records_slot_with_no_network_fetch() {
3340        // A generated text sidecar carries its body inline, so it is written
3341        // verbatim with NO HTTP fetch and the details slot records its state.
3342        let mut manifest = Manifest::new();
3343        manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
3344        let plan = Plan {
3345            actions: vec![Action::WriteArtifact {
3346                kind: ArtifactKind::DetailsTxt,
3347                path: "a.details.txt".to_owned(),
3348                source_url: String::new(),
3349                hash: "dh".to_owned(),
3350                owner_id: "a".to_owned(),
3351                content: Some("Title: A\n".to_owned()),
3352            }],
3353        };
3354        // An empty HTTP script: any fetch would fail, proving none happens.
3355        let http = ScriptedHttp::new();
3356        let fs = MemFs::new();
3357
3358        let outcome = run(
3359            &plan,
3360            &mut manifest,
3361            &[],
3362            &http,
3363            &fs,
3364            &StubFfmpeg::flac(),
3365            &RecordingClock::new(),
3366            &ExecOptions::default(),
3367        );
3368
3369        assert_eq!(outcome.artifacts_written, 1);
3370        assert_eq!(outcome.failed(), 0);
3371        assert_eq!(fs.read_file("a.details.txt").unwrap(), b"Title: A\n");
3372        assert_eq!(
3373            manifest.get("a").unwrap().details_txt,
3374            Some(ArtifactState {
3375                path: "a.details.txt".to_owned(),
3376                hash: "dh".to_owned(),
3377            })
3378        );
3379    }
3380
3381    #[test]
3382    fn write_lyrics_sidecar_relocation_removes_old_file() {
3383        // The audio moved, so the lyrics sidecar is re-emitted at the new path;
3384        // the executor writes the new file and prunes the stale one.
3385        let mut manifest = Manifest::new();
3386        let mut e = entry("old/a.flac", AudioFormat::Flac);
3387        e.lyrics_txt = Some(ArtifactState {
3388            path: "old/a.lyrics.txt".to_owned(),
3389            hash: "lh".to_owned(),
3390        });
3391        manifest.insert("a", e);
3392        let fs = MemFs::new()
3393            .with_file("old/a.flac", b"AUDIO".to_vec())
3394            .with_file("old/a.lyrics.txt", b"old words\n".to_vec());
3395        let plan = Plan {
3396            actions: vec![Action::WriteArtifact {
3397                kind: ArtifactKind::LyricsTxt,
3398                path: "new/a.lyrics.txt".to_owned(),
3399                source_url: String::new(),
3400                hash: "lh".to_owned(),
3401                owner_id: "a".to_owned(),
3402                content: Some("new words\n".to_owned()),
3403            }],
3404        };
3405
3406        let outcome = run(
3407            &plan,
3408            &mut manifest,
3409            &[],
3410            &ScriptedHttp::new(),
3411            &fs,
3412            &StubFfmpeg::flac(),
3413            &RecordingClock::new(),
3414            &ExecOptions::default(),
3415        );
3416
3417        assert_eq!(outcome.failed(), 0);
3418        assert_eq!(fs.read_file("new/a.lyrics.txt").unwrap(), b"new words\n");
3419        assert!(!fs.exists("old/a.lyrics.txt"));
3420        assert_eq!(
3421            manifest.get("a").unwrap().lyrics_txt.as_ref().unwrap().path,
3422            "new/a.lyrics.txt"
3423        );
3424    }
3425
3426    #[test]
3427    fn sidecar_path_swap_never_deletes_a_file_written_this_run() {
3428        // Two clips swap sidecar paths in one run (A: x -> y while B: y -> x).
3429        // Each write's inline old-path cleanup must skip a path another action
3430        // writes this run, or the second write would delete the first's freshly
3431        // written file (issue #76). The guard is kind-agnostic; lyrics stands in
3432        // for every sidecar, including the .mp4 video.
3433        let mut manifest = Manifest::new();
3434        let mut a = entry("a.flac", AudioFormat::Flac);
3435        a.lyrics_txt = Some(ArtifactState {
3436            path: "x.lyrics.txt".to_owned(),
3437            hash: "ah".to_owned(),
3438        });
3439        manifest.insert("a", a);
3440        let mut b = entry("b.flac", AudioFormat::Flac);
3441        b.lyrics_txt = Some(ArtifactState {
3442            path: "y.lyrics.txt".to_owned(),
3443            hash: "bh".to_owned(),
3444        });
3445        manifest.insert("b", b);
3446        let fs = MemFs::new()
3447            .with_file("a.flac", b"A".to_vec())
3448            .with_file("b.flac", b"B".to_vec())
3449            .with_file("x.lyrics.txt", b"A words\n".to_vec())
3450            .with_file("y.lyrics.txt", b"B words\n".to_vec());
3451        // A moves its sidecar x -> y; B moves its sidecar y -> x (the swap).
3452        let plan = Plan {
3453            actions: vec![
3454                Action::WriteArtifact {
3455                    kind: ArtifactKind::LyricsTxt,
3456                    path: "y.lyrics.txt".to_owned(),
3457                    source_url: String::new(),
3458                    hash: "ah".to_owned(),
3459                    owner_id: "a".to_owned(),
3460                    content: Some("A words\n".to_owned()),
3461                },
3462                Action::WriteArtifact {
3463                    kind: ArtifactKind::LyricsTxt,
3464                    path: "x.lyrics.txt".to_owned(),
3465                    source_url: String::new(),
3466                    hash: "bh".to_owned(),
3467                    owner_id: "b".to_owned(),
3468                    content: Some("B words\n".to_owned()),
3469                },
3470            ],
3471        };
3472
3473        let outcome = run(
3474            &plan,
3475            &mut manifest,
3476            &[],
3477            &ScriptedHttp::new(),
3478            &fs,
3479            &StubFfmpeg::flac(),
3480            &RecordingClock::new(),
3481            &ExecOptions::default(),
3482        );
3483
3484        assert_eq!(outcome.failed(), 0);
3485        // Both freshly written files survive; neither cleanup clobbered the other.
3486        assert_eq!(fs.read_file("y.lyrics.txt").unwrap(), b"A words\n");
3487        assert_eq!(fs.read_file("x.lyrics.txt").unwrap(), b"B words\n");
3488        assert_eq!(
3489            manifest.get("a").unwrap().lyrics_txt.as_ref().unwrap().path,
3490            "y.lyrics.txt"
3491        );
3492        assert_eq!(
3493            manifest.get("b").unwrap().lyrics_txt.as_ref().unwrap().path,
3494            "x.lyrics.txt"
3495        );
3496    }
3497
3498    #[test]
3499    fn old_sidecar_kept_when_another_clip_still_references_it() {
3500        // A prior failed swap can leave two clips pointing at one path (A -> y and
3501        // B -> y). When B now moves y -> x, its cleanup must not delete y, which is
3502        // still A's live file (#76). tracked_paths counts two references to y, so
3503        // the removal is skipped even though y is not a write target this run.
3504        let mut manifest = Manifest::new();
3505        let mut a = entry("a.flac", AudioFormat::Flac);
3506        a.lyrics_txt = Some(ArtifactState {
3507            path: "y.lyrics.txt".to_owned(),
3508            hash: "ah".to_owned(),
3509        });
3510        manifest.insert("a", a);
3511        let mut b = entry("b.flac", AudioFormat::Flac);
3512        b.lyrics_txt = Some(ArtifactState {
3513            path: "y.lyrics.txt".to_owned(),
3514            hash: "bh".to_owned(),
3515        });
3516        manifest.insert("b", b);
3517        let fs = MemFs::new()
3518            .with_file("a.flac", b"A".to_vec())
3519            .with_file("b.flac", b"B".to_vec())
3520            .with_file("y.lyrics.txt", b"A words\n".to_vec());
3521        // Only B moves this run: y -> x. A is stable, so y is not a write target;
3522        // the tracked-reference count is what protects A's file.
3523        let plan = Plan {
3524            actions: vec![Action::WriteArtifact {
3525                kind: ArtifactKind::LyricsTxt,
3526                path: "x.lyrics.txt".to_owned(),
3527                source_url: String::new(),
3528                hash: "bh".to_owned(),
3529                owner_id: "b".to_owned(),
3530                content: Some("B words\n".to_owned()),
3531            }],
3532        };
3533
3534        let outcome = run(
3535            &plan,
3536            &mut manifest,
3537            &[],
3538            &ScriptedHttp::new(),
3539            &fs,
3540            &StubFfmpeg::flac(),
3541            &RecordingClock::new(),
3542            &ExecOptions::default(),
3543        );
3544
3545        assert_eq!(outcome.failed(), 0);
3546        assert!(
3547            fs.exists("y.lyrics.txt"),
3548            "A's live sidecar must not be deleted"
3549        );
3550        assert_eq!(fs.read_file("x.lyrics.txt").unwrap(), b"B words\n");
3551    }
3552
3553    #[test]
3554    fn shared_old_path_is_reclaimed_when_every_referencing_clip_moves_away() {
3555        // Two clips share one path (A -> s and B -> s, from a prior failed swap).
3556        // When BOTH move away this run, the path is no longer live, so the last
3557        // mover must reclaim it: it is neither kept as an orphan nor deleted while
3558        // still referenced. The dynamic reference count drops to zero only after
3559        // both moves, so exactly the final cleanup removes it (#76).
3560        let mut manifest = Manifest::new();
3561        let mut a = entry("a.flac", AudioFormat::Flac);
3562        a.lyrics_txt = Some(ArtifactState {
3563            path: "s.lyrics.txt".to_owned(),
3564            hash: "ah".to_owned(),
3565        });
3566        manifest.insert("a", a);
3567        let mut b = entry("b.flac", AudioFormat::Flac);
3568        b.lyrics_txt = Some(ArtifactState {
3569            path: "s.lyrics.txt".to_owned(),
3570            hash: "bh".to_owned(),
3571        });
3572        manifest.insert("b", b);
3573        let fs = MemFs::new()
3574            .with_file("a.flac", b"A".to_vec())
3575            .with_file("b.flac", b"B".to_vec())
3576            .with_file("s.lyrics.txt", b"shared\n".to_vec());
3577        let plan = Plan {
3578            actions: vec![
3579                Action::WriteArtifact {
3580                    kind: ArtifactKind::LyricsTxt,
3581                    path: "pa.lyrics.txt".to_owned(),
3582                    source_url: String::new(),
3583                    hash: "ah".to_owned(),
3584                    owner_id: "a".to_owned(),
3585                    content: Some("A words\n".to_owned()),
3586                },
3587                Action::WriteArtifact {
3588                    kind: ArtifactKind::LyricsTxt,
3589                    path: "pb.lyrics.txt".to_owned(),
3590                    source_url: String::new(),
3591                    hash: "bh".to_owned(),
3592                    owner_id: "b".to_owned(),
3593                    content: Some("B words\n".to_owned()),
3594                },
3595            ],
3596        };
3597
3598        let outcome = run(
3599            &plan,
3600            &mut manifest,
3601            &[],
3602            &ScriptedHttp::new(),
3603            &fs,
3604            &StubFfmpeg::flac(),
3605            &RecordingClock::new(),
3606            &ExecOptions::default(),
3607        );
3608
3609        assert_eq!(outcome.failed(), 0);
3610        assert_eq!(fs.read_file("pa.lyrics.txt").unwrap(), b"A words\n");
3611        assert_eq!(fs.read_file("pb.lyrics.txt").unwrap(), b"B words\n");
3612        assert!(
3613            !fs.exists("s.lyrics.txt"),
3614            "the vacated shared path must be reclaimed, not orphaned"
3615        );
3616    }
3617
3618    #[test]
3619    fn write_text_sidecar_skipped_when_owner_audio_absent() {
3620        // A text sidecar for a clip with no manifest entry (its audio download
3621        // failed) must be skipped, never writing an untracked file.
3622        let plan = Plan {
3623            actions: vec![Action::WriteArtifact {
3624                kind: ArtifactKind::DetailsTxt,
3625                path: "gone.details.txt".to_owned(),
3626                source_url: String::new(),
3627                hash: "dh".to_owned(),
3628                owner_id: "gone".to_owned(),
3629                content: Some("Title: Gone\n".to_owned()),
3630            }],
3631        };
3632        let fs = MemFs::new();
3633        let mut manifest = Manifest::new();
3634
3635        let outcome = run(
3636            &plan,
3637            &mut manifest,
3638            &[],
3639            &ScriptedHttp::new(),
3640            &fs,
3641            &StubFfmpeg::flac(),
3642            &RecordingClock::new(),
3643            &ExecOptions::default(),
3644        );
3645
3646        assert_eq!(outcome.artifacts_written, 0);
3647        assert_eq!(outcome.skipped, 1);
3648        assert!(!fs.exists("gone.details.txt"));
3649        assert!(manifest.get("gone").is_none());
3650    }
3651
3652    #[test]
3653    fn delete_artifact_removes_file_and_clears_slot() {
3654        let fs = MemFs::new().with_file("a/cover.jpg", b"jpg".to_vec());
3655        let mut manifest = Manifest::new();
3656        let mut e = entry("a.mp3", AudioFormat::Mp3);
3657        e.cover_jpg = Some(ArtifactState {
3658            path: "a/cover.jpg".to_owned(),
3659            hash: "h1".to_owned(),
3660        });
3661        manifest.insert("a", e);
3662        let plan = Plan {
3663            actions: vec![Action::DeleteArtifact {
3664                kind: ArtifactKind::CoverJpg,
3665                path: "a/cover.jpg".to_owned(),
3666                owner_id: "a".to_owned(),
3667            }],
3668        };
3669
3670        let outcome = run(
3671            &plan,
3672            &mut manifest,
3673            &[],
3674            &ScriptedHttp::new(),
3675            &fs,
3676            &StubFfmpeg::flac(),
3677            &RecordingClock::new(),
3678            &ExecOptions::default(),
3679        );
3680
3681        assert_eq!(outcome.artifacts_deleted, 1);
3682        assert!(!fs.exists("a/cover.jpg"));
3683        assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
3684    }
3685
3686    #[test]
3687    fn delete_artifact_tolerates_already_absent_file() {
3688        // `remove` is idempotent, so co-deleting a sidecar that is already gone
3689        // is not a failure.
3690        let mut manifest = Manifest::new();
3691        let mut e = entry("a.mp3", AudioFormat::Mp3);
3692        e.cover_jpg = Some(ArtifactState {
3693            path: "a/cover.jpg".to_owned(),
3694            hash: "h1".to_owned(),
3695        });
3696        manifest.insert("a", e);
3697        let plan = Plan {
3698            actions: vec![Action::DeleteArtifact {
3699                kind: ArtifactKind::CoverJpg,
3700                path: "a/cover.jpg".to_owned(),
3701                owner_id: "a".to_owned(),
3702            }],
3703        };
3704
3705        let outcome = run(
3706            &plan,
3707            &mut manifest,
3708            &[],
3709            &ScriptedHttp::new(),
3710            &MemFs::new(),
3711            &StubFfmpeg::flac(),
3712            &RecordingClock::new(),
3713            &ExecOptions::default(),
3714        );
3715
3716        assert_eq!(outcome.artifacts_deleted, 1);
3717        assert_eq!(outcome.failed(), 0);
3718        assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
3719    }
3720
3721    #[test]
3722    fn write_artifact_http_failure_is_a_per_clip_failure_not_a_run_abort() {
3723        // A permanent 404 on one sidecar fetch is recorded as a per-clip failure;
3724        // the run continues and the following WriteArtifact still succeeds.
3725        let mut manifest = Manifest::new();
3726        manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
3727        manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
3728        let plan = Plan {
3729            actions: vec![
3730                Action::WriteArtifact {
3731                    kind: ArtifactKind::CoverJpg,
3732                    path: "a/cover.jpg".to_owned(),
3733                    source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
3734                    hash: "h1".to_owned(),
3735                    owner_id: "a".to_owned(),
3736                    content: None,
3737                },
3738                Action::WriteArtifact {
3739                    kind: ArtifactKind::CoverJpg,
3740                    path: "b/cover.jpg".to_owned(),
3741                    source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
3742                    hash: "h2".to_owned(),
3743                    owner_id: "b".to_owned(),
3744                    content: None,
3745                },
3746            ],
3747        };
3748        let http = ScriptedHttp::new()
3749            .route("a/large.jpg", Reply::status(404))
3750            .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
3751        let fs = MemFs::new();
3752
3753        let outcome = run(
3754            &plan,
3755            &mut manifest,
3756            &[],
3757            &http,
3758            &fs,
3759            &StubFfmpeg::flac(),
3760            &RecordingClock::new(),
3761            &ExecOptions::default(),
3762        );
3763
3764        assert_eq!(outcome.status, RunStatus::Completed);
3765        assert_eq!(outcome.failed(), 1);
3766        assert_eq!(outcome.failures[0].clip_id, "a");
3767        assert_eq!(outcome.artifacts_written, 1);
3768        // The failed sidecar left no file and no manifest record.
3769        assert!(!fs.exists("a/cover.jpg"));
3770        assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
3771        // The following sidecar was written and recorded.
3772        assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
3773        assert!(manifest.get("b").unwrap().cover_jpg.is_some());
3774    }
3775
3776    #[test]
3777    fn co_delete_executes_audio_delete_then_artifact_delete() {
3778        // The plan orders the audio Delete before its sidecar DeleteArtifact.
3779        // The audio delete removes the manifest entry; the sidecar delete then
3780        // removes the file and tolerates the now-absent entry.
3781        let fs = MemFs::new()
3782            .with_file("gone.mp3", b"DATA".to_vec())
3783            .with_file("gone/cover.jpg", b"jpg".to_vec());
3784        let mut manifest = Manifest::new();
3785        let mut e = entry("gone.mp3", AudioFormat::Mp3);
3786        e.cover_jpg = Some(ArtifactState {
3787            path: "gone/cover.jpg".to_owned(),
3788            hash: "h1".to_owned(),
3789        });
3790        manifest.insert("gone", e);
3791        let plan = Plan {
3792            actions: vec![
3793                Action::Delete {
3794                    path: "gone.mp3".to_owned(),
3795                    clip_id: "gone".to_owned(),
3796                },
3797                Action::DeleteArtifact {
3798                    kind: ArtifactKind::CoverJpg,
3799                    path: "gone/cover.jpg".to_owned(),
3800                    owner_id: "gone".to_owned(),
3801                },
3802            ],
3803        };
3804
3805        let outcome = run(
3806            &plan,
3807            &mut manifest,
3808            &[],
3809            &ScriptedHttp::new(),
3810            &fs,
3811            &StubFfmpeg::flac(),
3812            &RecordingClock::new(),
3813            &ExecOptions::default(),
3814        );
3815
3816        assert_eq!(outcome.deleted, 1);
3817        assert_eq!(outcome.artifacts_deleted, 1);
3818        assert_eq!(outcome.failed(), 0);
3819        assert!(!fs.exists("gone.mp3"));
3820        assert!(!fs.exists("gone/cover.jpg"));
3821        assert!(manifest.get("gone").is_none());
3822    }
3823
3824    #[test]
3825    fn write_stem_mp3_stores_raw_and_records_slot() {
3826        // An MP3 stem is downloaded straight from its CDN url and stored verbatim
3827        // (no transcode, no WAV render): the bytes land at the `.mp3` path and the
3828        // keyed slot records the path and hash.
3829        let mut manifest = Manifest::new();
3830        manifest.insert("a", entry("a.flac", AudioFormat::Flac));
3831        let plan = Plan {
3832            actions: vec![Action::WriteStem {
3833                clip_id: "a".to_owned(),
3834                key: "voc".to_owned(),
3835                stem_id: "voc".to_owned(),
3836                path: "a.stems/a - Vocals [voc].mp3".to_owned(),
3837                source_url: "https://cdn1.suno.ai/voc.mp3".to_owned(),
3838                format: StemFormat::Mp3,
3839                hash: "vh".to_owned(),
3840            }],
3841        };
3842        let http = ScriptedHttp::new().route("voc.mp3", Reply::ok(b"stem-bytes".to_vec()));
3843        let fs = MemFs::new();
3844
3845        let outcome = run(
3846            &plan,
3847            &mut manifest,
3848            &[],
3849            &http,
3850            &fs,
3851            &StubFfmpeg::flac(),
3852            &RecordingClock::new(),
3853            &ExecOptions::default(),
3854        );
3855
3856        assert_eq!(outcome.artifacts_written, 1);
3857        assert_eq!(outcome.failed(), 0);
3858        // Bytes are stored exactly as delivered (no transcode applied).
3859        assert_eq!(
3860            fs.read_file("a.stems/a - Vocals [voc].mp3").unwrap(),
3861            b"stem-bytes"
3862        );
3863        // An MP3 stem never renders WAV: no convert_wav, no generation.
3864        assert_eq!(http.count("convert_wav"), 0);
3865        assert_eq!(http.count("/api/gen/"), 0);
3866        assert_eq!(
3867            manifest.get("a").unwrap().stems.get("voc"),
3868            Some(&ArtifactState {
3869                path: "a.stems/a - Vocals [voc].mp3".to_owned(),
3870                hash: "vh".to_owned(),
3871            })
3872        );
3873    }
3874
3875    #[test]
3876    fn write_stem_wav_renders_via_convert_wav_and_stores_raw() {
3877        // A WAV stem (the default) renders the stem clip's lossless WAV through the
3878        // free convert_wav flow keyed on the stem id, then downloads and stores it
3879        // RAW as `.wav` — it is NEVER transcoded to FLAC, even for a FLAC song.
3880        let mut manifest = Manifest::new();
3881        manifest.insert("a", entry("a.flac", AudioFormat::Flac));
3882        let plan = Plan {
3883            actions: vec![Action::WriteStem {
3884                clip_id: "a".to_owned(),
3885                key: "voc".to_owned(),
3886                stem_id: "stemvoc".to_owned(),
3887                path: "a.stems/a - Vocals [stemvoc].wav".to_owned(),
3888                source_url: "https://cdn1.suno.ai/stemvoc.mp3".to_owned(),
3889                format: StemFormat::Wav,
3890                hash: "vh".to_owned(),
3891            }],
3892        };
3893        // wav_file is not ready on the first poll, so the flow POSTs convert_wav
3894        // (free) and polls again — exactly the main FLAC/WAV render path.
3895        let http = ScriptedHttp::new()
3896            .with_auth()
3897            .route_seq(
3898                "stemvoc/wav_file/",
3899                vec![
3900                    Reply::json("{}"),
3901                    Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/stemvoc.wav"}"#),
3902                ],
3903            )
3904            .route("stemvoc/convert_wav/", Reply::status(200))
3905            .route("stemvoc.wav", Reply::ok(b"RIFFwav-bytes".to_vec()));
3906        let fs = MemFs::new();
3907
3908        let outcome = run(
3909            &plan,
3910            &mut manifest,
3911            &[],
3912            &http,
3913            &fs,
3914            &StubFfmpeg::flac(),
3915            &RecordingClock::new(),
3916            &small_poll(),
3917        );
3918
3919        assert_eq!(outcome.artifacts_written, 1);
3920        assert_eq!(outcome.failed(), 0);
3921        // The rendered WAV is stored verbatim; ffmpeg (WAV->FLAC) is never invoked,
3922        // so the stored bytes are the raw WAV, not a FLAC transcode.
3923        assert_eq!(
3924            fs.read_file("a.stems/a - Vocals [stemvoc].wav").unwrap(),
3925            b"RIFFwav-bytes"
3926        );
3927        assert!(!fs.exists("a.stems/a - Vocals [stemvoc].flac"));
3928        // The free WAV render ran; no credit-spending generation endpoint did.
3929        assert_eq!(http.count("convert_wav"), 1);
3930        assert_eq!(http.count("stem_task"), 0);
3931        assert_eq!(http.count("separate"), 0);
3932        assert_eq!(
3933            manifest.get("a").unwrap().stems.get("voc").unwrap().path,
3934            "a.stems/a - Vocals [stemvoc].wav"
3935        );
3936    }
3937
3938    #[test]
3939    fn write_stem_is_skipped_when_owner_audio_is_absent() {
3940        // No owning manifest entry (audio failed or never existed) => skip with
3941        // no fetch and no write, so a stem is never stranded without its song.
3942        let mut manifest = Manifest::new();
3943        let plan = Plan {
3944            actions: vec![Action::WriteStem {
3945                clip_id: "ghost".to_owned(),
3946                key: "voc".to_owned(),
3947                stem_id: "voc".to_owned(),
3948                path: "ghost.stems/voc.mp3".to_owned(),
3949                source_url: "https://cdn1.suno.ai/voc.mp3".to_owned(),
3950                format: StemFormat::Mp3,
3951                hash: "vh".to_owned(),
3952            }],
3953        };
3954        // Empty HTTP script: any fetch would error, proving none happens.
3955        let http = ScriptedHttp::new();
3956        let fs = MemFs::new();
3957
3958        let outcome = run(
3959            &plan,
3960            &mut manifest,
3961            &[],
3962            &http,
3963            &fs,
3964            &StubFfmpeg::flac(),
3965            &RecordingClock::new(),
3966            &ExecOptions::default(),
3967        );
3968
3969        assert_eq!(outcome.skipped, 1);
3970        assert_eq!(outcome.artifacts_written, 0);
3971        assert_eq!(outcome.failed(), 0);
3972        assert!(!fs.exists("ghost.stems/voc.mp3"));
3973    }
3974
3975    #[test]
3976    fn write_stem_relocates_the_old_file_on_a_path_move() {
3977        // The song was renamed, so the stem moves: the new file is written and the
3978        // stale copy at the previously tracked path is removed (moved, not orphaned).
3979        let fs = MemFs::new().with_file("old.stems/voc.mp3", b"old".to_vec());
3980        let mut manifest = Manifest::new();
3981        let mut e = entry("new.flac", AudioFormat::Flac);
3982        e.stems.insert(
3983            "voc".to_owned(),
3984            ArtifactState {
3985                path: "old.stems/voc.mp3".to_owned(),
3986                hash: "vh".to_owned(),
3987            },
3988        );
3989        manifest.insert("a", e);
3990        let plan = Plan {
3991            actions: vec![Action::WriteStem {
3992                clip_id: "a".to_owned(),
3993                key: "voc".to_owned(),
3994                stem_id: "voc".to_owned(),
3995                path: "new.stems/voc.mp3".to_owned(),
3996                source_url: "https://cdn1.suno.ai/voc.mp3".to_owned(),
3997                format: StemFormat::Mp3,
3998                hash: "vh".to_owned(),
3999            }],
4000        };
4001        let http = ScriptedHttp::new().route("voc.mp3", Reply::ok(b"new".to_vec()));
4002
4003        let outcome = run(
4004            &plan,
4005            &mut manifest,
4006            &[],
4007            &http,
4008            &fs,
4009            &StubFfmpeg::flac(),
4010            &RecordingClock::new(),
4011            &ExecOptions::default(),
4012        );
4013
4014        assert_eq!(outcome.artifacts_written, 1);
4015        assert!(fs.exists("new.stems/voc.mp3"));
4016        assert!(
4017            !fs.exists("old.stems/voc.mp3"),
4018            "the old stem is moved, not left behind"
4019        );
4020        assert_eq!(
4021            manifest.get("a").unwrap().stems.get("voc").unwrap().path,
4022            "new.stems/voc.mp3"
4023        );
4024    }
4025
4026    #[test]
4027    fn delete_stem_removes_file_and_clears_slot() {
4028        let fs = MemFs::new().with_file("a.stems/voc.mp3", b"stem".to_vec());
4029        let mut manifest = Manifest::new();
4030        let mut e = entry("a.flac", AudioFormat::Flac);
4031        e.stems.insert(
4032            "voc".to_owned(),
4033            ArtifactState {
4034                path: "a.stems/voc.mp3".to_owned(),
4035                hash: "vh".to_owned(),
4036            },
4037        );
4038        manifest.insert("a", e);
4039        let plan = Plan {
4040            actions: vec![Action::DeleteStem {
4041                clip_id: "a".to_owned(),
4042                key: "voc".to_owned(),
4043                path: "a.stems/voc.mp3".to_owned(),
4044            }],
4045        };
4046
4047        let outcome = run(
4048            &plan,
4049            &mut manifest,
4050            &[],
4051            &ScriptedHttp::new(),
4052            &fs,
4053            &StubFfmpeg::flac(),
4054            &RecordingClock::new(),
4055            &ExecOptions::default(),
4056        );
4057
4058        assert_eq!(outcome.artifacts_deleted, 1);
4059        assert!(!fs.exists("a.stems/voc.mp3"));
4060        assert!(manifest.get("a").unwrap().stems.is_empty());
4061    }
4062
4063    #[test]
4064    fn co_deleting_the_last_stem_prunes_the_stems_folder() {
4065        // Deleting a song co-deletes its stems; the emptied `.stems` folder is
4066        // pruned by the end-of-run sweep, so it can never be orphaned.
4067        let fs = MemFs::new()
4068            .with_file("song.flac", b"DATA".to_vec())
4069            .with_file("song.stems/voc.mp3", b"stem".to_vec());
4070        assert!(fs.has_dir("song.stems"));
4071        let mut manifest = Manifest::new();
4072        let mut e = entry("song.flac", AudioFormat::Flac);
4073        e.stems.insert(
4074            "voc".to_owned(),
4075            ArtifactState {
4076                path: "song.stems/voc.mp3".to_owned(),
4077                hash: "vh".to_owned(),
4078            },
4079        );
4080        manifest.insert("a", e);
4081        let plan = Plan {
4082            actions: vec![
4083                Action::Delete {
4084                    path: "song.flac".to_owned(),
4085                    clip_id: "a".to_owned(),
4086                },
4087                Action::DeleteStem {
4088                    clip_id: "a".to_owned(),
4089                    key: "voc".to_owned(),
4090                    path: "song.stems/voc.mp3".to_owned(),
4091                },
4092            ],
4093        };
4094
4095        let outcome = run(
4096            &plan,
4097            &mut manifest,
4098            &[],
4099            &ScriptedHttp::new(),
4100            &fs,
4101            &StubFfmpeg::flac(),
4102            &RecordingClock::new(),
4103            &ExecOptions::default(),
4104        );
4105
4106        assert_eq!(outcome.deleted, 1);
4107        assert_eq!(outcome.artifacts_deleted, 1);
4108        assert!(!fs.exists("song.flac"));
4109        assert!(!fs.exists("song.stems/voc.mp3"));
4110        assert!(
4111            !fs.has_dir("song.stems"),
4112            "the emptied .stems folder is pruned"
4113        );
4114        assert!(manifest.get("a").is_none());
4115    }
4116
4117    #[test]
4118    fn write_stem_mp3_never_issues_a_generation_post() {
4119        // The MP3 stem path is GET-only: writing a stem fetches its CDN url and
4120        // never POSTs, let alone to any generation or WAV-render endpoint.
4121        let mut manifest = Manifest::new();
4122        manifest.insert("a", entry("a.flac", AudioFormat::Flac));
4123        let plan = Plan {
4124            actions: vec![Action::WriteStem {
4125                clip_id: "a".to_owned(),
4126                key: "voc".to_owned(),
4127                stem_id: "voc".to_owned(),
4128                path: "a.stems/voc.mp3".to_owned(),
4129                source_url: "https://cdn1.suno.ai/voc.mp3".to_owned(),
4130                format: StemFormat::Mp3,
4131                hash: "vh".to_owned(),
4132            }],
4133        };
4134        let http = ScriptedHttp::new().route("voc.mp3", Reply::ok(b"stem".to_vec()));
4135
4136        run(
4137            &plan,
4138            &mut manifest,
4139            &[],
4140            &http,
4141            &MemFs::new(),
4142            &StubFfmpeg::flac(),
4143            &RecordingClock::new(),
4144            &ExecOptions::default(),
4145        );
4146
4147        assert_eq!(
4148            http.count("stem_task"),
4149            0,
4150            "no generation endpoint is ever hit"
4151        );
4152        assert_eq!(http.count("convert_wav"), 0);
4153        assert_eq!(http.count("/api/gen/"), 0);
4154    }
4155
4156    #[test]
4157    fn full_stems_mirror_mp3_is_get_only_with_zero_gen_traffic() {
4158        // End-to-end #100 path with MP3 stems: list a clip's existing stems (free
4159        // GET over the live page-count + 0-indexed page shape), reconcile them into
4160        // WriteStem actions, and execute (download) them. With MP3 the whole flow
4161        // is GET-only and touches NO `/api/gen/` endpoint at all.
4162        let http = ScriptedHttp::new()
4163            .with_auth()
4164            .route("clip1/stems/pages", Reply::json(r#"{"pages": 1}"#))
4165            .route(
4166                "clip1/stems?page=0",
4167                Reply::json(
4168                    r#"{"stems":[
4169                        {"id":"s1","title":"Song (Vocals)","status":"complete","audio_url":"https://cdn1.suno.ai/s1.mp3"},
4170                        {"id":"s2","title":"Song (Drums)","status":"complete","audio_url":"https://cdn1.suno.ai/s2.mp3"}
4171                    ]}"#,
4172                ),
4173            )
4174            .route("s1.mp3", Reply::ok(b"vocals-bytes".to_vec()))
4175            .route("s2.mp3", Reply::ok(b"drums-bytes".to_vec()));
4176
4177        // List the existing stems through the client (GET-only, free).
4178        let mut auth = ClerkAuth::new("eyJtoken");
4179        pollster::block_on(auth.authenticate(&http)).unwrap();
4180        let mut client = SunoClient::new(auth, RecordingClock::new());
4181        let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
4182        assert!(complete);
4183        assert_eq!(stems.len(), 2);
4184        assert_eq!(stems[0].label, "Vocals");
4185
4186        // Reconcile the listed MP3 stems into a plan (audio already present -> Skip).
4187        let mut manifest = Manifest::new();
4188        manifest.insert("clip1", entry("clip1.flac", AudioFormat::Flac));
4189        let desired_stems: Vec<crate::reconcile::DesiredStem> = stems
4190            .iter()
4191            .map(|s| crate::reconcile::DesiredStem {
4192                key: s.id.clone(),
4193                stem_id: s.id.clone(),
4194                path: format!("clip1.stems/{}.mp3", s.id),
4195                source_url: s.url.clone(),
4196                format: StemFormat::Mp3,
4197                hash: crate::art_url_hash(&s.url),
4198            })
4199            .collect();
4200        let d = Desired {
4201            path: "clip1.flac".to_owned(),
4202            stems: Some(desired_stems),
4203            ..desired(clip("clip1"), AudioFormat::Flac)
4204        };
4205        let local: HashMap<String, crate::reconcile::LocalFile> = [(
4206            "clip1".to_owned(),
4207            crate::reconcile::LocalFile {
4208                exists: true,
4209                size: 100,
4210            },
4211        )]
4212        .into_iter()
4213        .collect();
4214        let sources = [crate::reconcile::SourceStatus {
4215            mode: SourceMode::Mirror,
4216            fully_enumerated: true,
4217        }];
4218        let plan =
4219            crate::reconcile::reconcile(&manifest, std::slice::from_ref(&d), &local, &sources);
4220        assert_eq!(plan.stem_writes(), 2);
4221
4222        let fs = MemFs::new();
4223        let outcome = run(
4224            &plan,
4225            &mut manifest,
4226            std::slice::from_ref(&d),
4227            &http,
4228            &fs,
4229            &StubFfmpeg::flac(),
4230            &RecordingClock::new(),
4231            &ExecOptions::default(),
4232        );
4233
4234        assert_eq!(outcome.artifacts_written, 2, "both stems downloaded");
4235        assert_eq!(fs.read_file("clip1.stems/s1.mp3").unwrap(), b"vocals-bytes");
4236        assert_eq!(fs.read_file("clip1.stems/s2.mp3").unwrap(), b"drums-bytes");
4237        // The MP3 mirror path never touches any /api/gen/ endpoint (no render, no
4238        // generation, no separation).
4239        assert_eq!(http.count("/api/gen/"), 0);
4240        assert_eq!(http.count("stem_task"), 0);
4241        assert_eq!(http.count("separate"), 0);
4242        assert_eq!(http.count("generate"), 0);
4243        // No stem is ever written as FLAC.
4244        assert!(!fs.exists("clip1.stems/s1.flac"));
4245    }
4246
4247    #[test]
4248    fn full_stems_mirror_wav_default_renders_free_wav_and_no_generation() {
4249        // End-to-end #100 path with WAV stems (the default): each stem's lossless
4250        // WAV is rendered through the FREE convert_wav flow and stored RAW as
4251        // `.wav`. The mirror makes NO credit-spending generation POST.
4252        let http = ScriptedHttp::new()
4253            .with_auth()
4254            .route("clip1/stems/pages", Reply::json(r#"{"pages": 1}"#))
4255            .route(
4256                "clip1/stems?page=0",
4257                Reply::json(
4258                    r#"{"stems":[
4259                        {"id":"s1","title":"Song (Vocals)","status":"complete","audio_url":"https://cdn1.suno.ai/s1.mp3"},
4260                        {"id":"s2","title":"Song (Drums)","status":"complete","audio_url":"https://cdn1.suno.ai/s2.mp3"}
4261                    ]}"#,
4262                ),
4263            )
4264            // Each stem's WAV is already rendered, so wav_file returns the url and
4265            // no convert_wav POST is even needed (still free either way).
4266            .route(
4267                "s1/wav_file/",
4268                Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/s1.wav"}"#),
4269            )
4270            .route(
4271                "s2/wav_file/",
4272                Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/s2.wav"}"#),
4273            )
4274            .route("s1.wav", Reply::ok(b"RIFFvocals".to_vec()))
4275            .route("s2.wav", Reply::ok(b"RIFFdrums".to_vec()));
4276
4277        let mut auth = ClerkAuth::new("eyJtoken");
4278        pollster::block_on(auth.authenticate(&http)).unwrap();
4279        let mut client = SunoClient::new(auth, RecordingClock::new());
4280        let (stems, _complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
4281
4282        let mut manifest = Manifest::new();
4283        manifest.insert("clip1", entry("clip1.flac", AudioFormat::Flac));
4284        let desired_stems: Vec<crate::reconcile::DesiredStem> = stems
4285            .iter()
4286            .map(|s| crate::reconcile::DesiredStem {
4287                key: s.id.clone(),
4288                stem_id: s.id.clone(),
4289                path: format!("clip1.stems/{}.wav", s.id),
4290                source_url: s.url.clone(),
4291                format: StemFormat::Wav,
4292                hash: crate::art_url_hash(&s.url),
4293            })
4294            .collect();
4295        let d = Desired {
4296            path: "clip1.flac".to_owned(),
4297            stems: Some(desired_stems),
4298            ..desired(clip("clip1"), AudioFormat::Flac)
4299        };
4300        let local: HashMap<String, crate::reconcile::LocalFile> = [(
4301            "clip1".to_owned(),
4302            crate::reconcile::LocalFile {
4303                exists: true,
4304                size: 100,
4305            },
4306        )]
4307        .into_iter()
4308        .collect();
4309        let sources = [crate::reconcile::SourceStatus {
4310            mode: SourceMode::Mirror,
4311            fully_enumerated: true,
4312        }];
4313        let plan =
4314            crate::reconcile::reconcile(&manifest, std::slice::from_ref(&d), &local, &sources);
4315
4316        let fs = MemFs::new();
4317        let outcome = run(
4318            &plan,
4319            &mut manifest,
4320            std::slice::from_ref(&d),
4321            &http,
4322            &fs,
4323            &StubFfmpeg::flac(),
4324            &RecordingClock::new(),
4325            &small_poll(),
4326        );
4327
4328        assert_eq!(outcome.artifacts_written, 2);
4329        // Stems are stored RAW as WAV (no FLAC transcode, even for a FLAC song).
4330        assert_eq!(fs.read_file("clip1.stems/s1.wav").unwrap(), b"RIFFvocals");
4331        assert_eq!(fs.read_file("clip1.stems/s2.wav").unwrap(), b"RIFFdrums");
4332        assert!(!fs.exists("clip1.stems/s1.flac"));
4333        // No credit-spending generation/separation endpoint is ever hit.
4334        assert_eq!(http.count("stem_task"), 0);
4335        assert_eq!(http.count("separate"), 0);
4336        assert_eq!(http.count("generate"), 0);
4337    }
4338
4339    #[test]
4340    fn write_artifact_is_skipped_when_the_owner_audio_is_absent() {
4341        // A clip whose Download fails leaves no manifest entry, so its following
4342        // WriteArtifact must not strand an untracked sidecar: it is skipped with
4343        // no fetch and no write. A following healthy clip still succeeds.
4344        let ca = clip("a");
4345        let plan = Plan {
4346            actions: vec![
4347                Action::Download {
4348                    clip: ca.clone(),
4349                    lineage: LineageContext::own_root(&ca),
4350                    path: "a.mp3".to_owned(),
4351                    format: AudioFormat::Mp3,
4352                },
4353                Action::WriteArtifact {
4354                    kind: ArtifactKind::CoverJpg,
4355                    path: "a/cover.jpg".to_owned(),
4356                    source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
4357                    hash: "h1".to_owned(),
4358                    owner_id: "a".to_owned(),
4359                    content: None,
4360                },
4361                Action::WriteArtifact {
4362                    kind: ArtifactKind::CoverJpg,
4363                    path: "b/cover.jpg".to_owned(),
4364                    source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
4365                    hash: "h2".to_owned(),
4366                    owner_id: "b".to_owned(),
4367                    content: None,
4368                },
4369            ],
4370        };
4371        // The Download's audio 404s (permanent), so no entry for "a" is created.
4372        let http = ScriptedHttp::new()
4373            .route("a.mp3", Reply::status(404))
4374            .route("a/large.jpg", Reply::ok(b"jpg-a".to_vec()))
4375            .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
4376        let fs = MemFs::new();
4377        let mut manifest = Manifest::new();
4378        // "b" already has audio (a prior-run clip), so its sidecar write proceeds.
4379        manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
4380
4381        let outcome = run(
4382            &plan,
4383            &mut manifest,
4384            &[],
4385            &http,
4386            &fs,
4387            &StubFfmpeg::flac(),
4388            &RecordingClock::new(),
4389            &ExecOptions::default(),
4390        );
4391
4392        assert_eq!(outcome.status, RunStatus::Completed);
4393        // The audio download is the only failure; the orphan artifact is skipped.
4394        assert_eq!(outcome.failed(), 1);
4395        assert_eq!(outcome.failures[0].clip_id, "a");
4396        assert_eq!(outcome.skipped, 1);
4397        // The orphan sidecar was neither fetched nor written, and left no record.
4398        assert_eq!(http.count("a/large.jpg"), 0);
4399        assert!(!fs.exists("a/cover.jpg"));
4400        assert!(manifest.get("a").is_none());
4401        // The healthy clip's sidecar still succeeded.
4402        assert_eq!(outcome.artifacts_written, 1);
4403        assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
4404        assert!(manifest.get("b").unwrap().cover_jpg.is_some());
4405    }
4406
4407    #[test]
4408    fn write_artifact_transcodes_animated_cover_to_webp() {
4409        // A CoverWebp fetches the clip's MP4 preview, runs it through the ffmpeg
4410        // port, and writes the transcoded WebP (not the fetched MP4), recording
4411        // the sidecar on the owning entry.
4412        let mut manifest = Manifest::new();
4413        manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
4414        let plan = Plan {
4415            actions: vec![Action::WriteArtifact {
4416                kind: ArtifactKind::CoverWebp,
4417                path: "a/cover.webp".to_owned(),
4418                source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
4419                hash: "v1".to_owned(),
4420                owner_id: "a".to_owned(),
4421                content: None,
4422            }],
4423        };
4424        let http = ScriptedHttp::new().route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
4425        let fs = MemFs::new();
4426        let ffmpeg = StubFfmpeg::webp();
4427
4428        let outcome = run(
4429            &plan,
4430            &mut manifest,
4431            &[],
4432            &http,
4433            &fs,
4434            &ffmpeg,
4435            &RecordingClock::new(),
4436            &ExecOptions::default(),
4437        );
4438
4439        assert_eq!(outcome.artifacts_written, 1);
4440        assert_eq!(outcome.failed(), 0);
4441        assert_eq!(outcome.status, RunStatus::Completed);
4442        // The fetched MP4 was transcoded: the file holds the ffmpeg WebP output.
4443        assert_eq!(http.count("a/video.mp4"), 1);
4444        let written = fs.read_file("a/cover.webp").unwrap();
4445        assert_ne!(written, b"mp4-bytes");
4446        assert!(written.starts_with(b"RIFF"));
4447        assert_eq!(
4448            manifest.get("a").unwrap().cover_webp,
4449            Some(ArtifactState {
4450                path: "a/cover.webp".to_owned(),
4451                hash: "v1".to_owned(),
4452            })
4453        );
4454    }
4455
4456    #[test]
4457    fn write_artifact_webp_transcode_failure_is_per_clip() {
4458        // A transcode failure is attributed to the owning clip: it is a per-clip
4459        // failure, the run completes, no sidecar is written, and the slot stays
4460        // empty. A healthy static cover in the same run still succeeds.
4461        let mut manifest = Manifest::new();
4462        manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
4463        manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
4464        let plan = Plan {
4465            actions: vec![
4466                Action::WriteArtifact {
4467                    kind: ArtifactKind::CoverWebp,
4468                    path: "a/cover.webp".to_owned(),
4469                    source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
4470                    hash: "v1".to_owned(),
4471                    owner_id: "a".to_owned(),
4472                    content: None,
4473                },
4474                Action::WriteArtifact {
4475                    kind: ArtifactKind::CoverJpg,
4476                    path: "b/cover.jpg".to_owned(),
4477                    source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
4478                    hash: "h1".to_owned(),
4479                    owner_id: "b".to_owned(),
4480                    content: None,
4481                },
4482            ],
4483        };
4484        let http = ScriptedHttp::new()
4485            .route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec()))
4486            .route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
4487        let fs = MemFs::new();
4488
4489        let outcome = run(
4490            &plan,
4491            &mut manifest,
4492            &[],
4493            &http,
4494            &fs,
4495            &StubFfmpeg::failing(),
4496            &RecordingClock::new(),
4497            &ExecOptions::default(),
4498        );
4499
4500        assert_eq!(outcome.status, RunStatus::Completed);
4501        assert_eq!(outcome.failed(), 1);
4502        assert_eq!(outcome.failures[0].clip_id, "a");
4503        // The animated cover failed to transcode: nothing written, slot empty.
4504        assert!(!fs.exists("a/cover.webp"));
4505        assert_eq!(manifest.get("a").unwrap().cover_webp, None);
4506        // The static cover in the same run still succeeded.
4507        assert_eq!(outcome.artifacts_written, 1);
4508        assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
4509        assert!(manifest.get("b").unwrap().cover_jpg.is_some());
4510    }
4511
4512    // ── Phase 8: folder art routes to the album store ───────────────
4513
4514    #[test]
4515    fn folder_jpg_write_records_album_state_and_skips_manifest() {
4516        // Folder art is owned by the album root id, not a manifest clip: it
4517        // writes even with an empty manifest and records on the album store.
4518        let mut manifest = Manifest::new();
4519        let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
4520        let plan = Plan {
4521            actions: vec![Action::WriteArtifact {
4522                kind: ArtifactKind::FolderJpg,
4523                path: "creator/album/folder.jpg".to_owned(),
4524                source_url: "https://art.suno.ai/root/large.jpg".to_owned(),
4525                hash: "jh".to_owned(),
4526                owner_id: "root".to_owned(),
4527                content: None,
4528            }],
4529        };
4530        let http = ScriptedHttp::new().route("root/large.jpg", Reply::ok(b"folder-jpg".to_vec()));
4531        let fs = MemFs::new();
4532
4533        let outcome = run_with_albums(
4534            &plan,
4535            &mut manifest,
4536            &mut albums,
4537            &[],
4538            &http,
4539            &fs,
4540            &StubFfmpeg::flac(),
4541            &RecordingClock::new(),
4542            &ExecOptions::default(),
4543        );
4544
4545        assert_eq!(outcome.artifacts_written, 1);
4546        assert_eq!(outcome.status, RunStatus::Completed);
4547        assert_eq!(
4548            fs.read_file("creator/album/folder.jpg").unwrap(),
4549            b"folder-jpg"
4550        );
4551        assert_eq!(
4552            albums.get("root").unwrap().folder_jpg,
4553            Some(ArtifactState {
4554                path: "creator/album/folder.jpg".to_owned(),
4555                hash: "jh".to_owned(),
4556            })
4557        );
4558        assert!(manifest.get("root").is_none());
4559    }
4560
4561    #[test]
4562    fn folder_webp_write_transcodes_and_records_album_state() {
4563        let mut manifest = Manifest::new();
4564        let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
4565        let plan = Plan {
4566            actions: vec![Action::WriteArtifact {
4567                kind: ArtifactKind::FolderWebp,
4568                path: "creator/album/cover.webp".to_owned(),
4569                source_url: "https://cdn.suno.ai/root/video.mp4".to_owned(),
4570                hash: "wh".to_owned(),
4571                owner_id: "root".to_owned(),
4572                content: None,
4573            }],
4574        };
4575        let http = ScriptedHttp::new().route("root/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
4576        let fs = MemFs::new();
4577
4578        let outcome = run_with_albums(
4579            &plan,
4580            &mut manifest,
4581            &mut albums,
4582            &[],
4583            &http,
4584            &fs,
4585            &StubFfmpeg::webp(),
4586            &RecordingClock::new(),
4587            &ExecOptions::default(),
4588        );
4589
4590        assert_eq!(outcome.artifacts_written, 1);
4591        assert_eq!(outcome.failed(), 0);
4592        // The MP4 was transcoded to WebP, not written verbatim.
4593        let written = fs.read_file("creator/album/cover.webp").unwrap();
4594        assert_ne!(written, b"mp4-bytes");
4595        assert!(written.starts_with(b"RIFF"));
4596        assert_eq!(
4597            albums.get("root").unwrap().folder_webp,
4598            Some(ArtifactState {
4599                path: "creator/album/cover.webp".to_owned(),
4600                hash: "wh".to_owned(),
4601            })
4602        );
4603    }
4604
4605    #[test]
4606    fn folder_art_delete_clears_album_state() {
4607        let fs = MemFs::new().with_file("creator/album/folder.jpg", b"jpg".to_vec());
4608        let mut manifest = Manifest::new();
4609        let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
4610        albums.insert(
4611            "root".to_owned(),
4612            AlbumArt {
4613                folder_jpg: Some(ArtifactState {
4614                    path: "creator/album/folder.jpg".to_owned(),
4615                    hash: "jh".to_owned(),
4616                }),
4617                folder_webp: None,
4618            },
4619        );
4620        let plan = Plan {
4621            actions: vec![Action::DeleteArtifact {
4622                kind: ArtifactKind::FolderJpg,
4623                path: "creator/album/folder.jpg".to_owned(),
4624                owner_id: "root".to_owned(),
4625            }],
4626        };
4627
4628        let outcome = run_with_albums(
4629            &plan,
4630            &mut manifest,
4631            &mut albums,
4632            &[],
4633            &ScriptedHttp::new(),
4634            &fs,
4635            &StubFfmpeg::flac(),
4636            &RecordingClock::new(),
4637            &ExecOptions::default(),
4638        );
4639
4640        assert_eq!(outcome.artifacts_deleted, 1);
4641        assert!(!fs.exists("creator/album/folder.jpg"));
4642        // The album row had only the one kind, so it is pruned entirely.
4643        assert!(!albums.contains_key("root"));
4644    }
4645
4646    // ── Phase 9: playlist artifacts ─────────────────────────────────
4647
4648    #[test]
4649    fn playlist_write_uses_inline_content_and_records_state() {
4650        // A playlist body is generated, carried inline. With an empty manifest
4651        // and NO http routes, the write still succeeds — proving it skipped the
4652        // network — and records the playlist store keyed by the playlist id.
4653        let mut manifest = Manifest::new();
4654        let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
4655        let mut playlists: BTreeMap<String, PlaylistState> = BTreeMap::new();
4656        let body = "#EXTM3U\n#PLAYLIST:Road Trip\n#EXTINF:60,One\nA/One.flac\n";
4657        let plan = Plan {
4658            actions: vec![Action::WriteArtifact {
4659                kind: ArtifactKind::Playlist,
4660                path: "Road Trip.m3u8".to_owned(),
4661                source_url: String::new(),
4662                hash: "ph1".to_owned(),
4663                owner_id: "pl1".to_owned(),
4664                content: Some(body.to_owned()),
4665            }],
4666        };
4667        let fs = MemFs::new();
4668
4669        let outcome = run_full(
4670            &plan,
4671            &mut manifest,
4672            &mut albums,
4673            &mut playlists,
4674            &[],
4675            &ScriptedHttp::new(),
4676            &fs,
4677            &StubFfmpeg::flac(),
4678            &RecordingClock::new(),
4679            &ExecOptions::default(),
4680        );
4681
4682        assert_eq!(outcome.artifacts_written, 1);
4683        assert_eq!(outcome.failed(), 0);
4684        // The exact inline bytes were written, verbatim.
4685        assert_eq!(fs.read_file("Road Trip.m3u8").unwrap(), body.as_bytes());
4686        assert_eq!(
4687            playlists.get("pl1"),
4688            Some(&PlaylistState {
4689                name: "Road Trip".to_owned(),
4690                path: "Road Trip.m3u8".to_owned(),
4691                hash: "ph1".to_owned(),
4692            })
4693        );
4694    }
4695
4696    #[test]
4697    fn playlist_delete_removes_file_and_clears_state() {
4698        let fs = MemFs::new().with_file("Old.m3u8", b"#EXTM3U\n".to_vec());
4699        let mut manifest = Manifest::new();
4700        let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
4701        let mut playlists: BTreeMap<String, PlaylistState> = BTreeMap::new();
4702        playlists.insert(
4703            "pl1".to_owned(),
4704            PlaylistState {
4705                name: "Old".to_owned(),
4706                path: "Old.m3u8".to_owned(),
4707                hash: "ph1".to_owned(),
4708            },
4709        );
4710        let plan = Plan {
4711            actions: vec![Action::DeleteArtifact {
4712                kind: ArtifactKind::Playlist,
4713                path: "Old.m3u8".to_owned(),
4714                owner_id: "pl1".to_owned(),
4715            }],
4716        };
4717
4718        let outcome = run_full(
4719            &plan,
4720            &mut manifest,
4721            &mut albums,
4722            &mut playlists,
4723            &[],
4724            &ScriptedHttp::new(),
4725            &fs,
4726            &StubFfmpeg::flac(),
4727            &RecordingClock::new(),
4728            &ExecOptions::default(),
4729        );
4730
4731        assert_eq!(outcome.artifacts_deleted, 1);
4732        assert!(!fs.exists("Old.m3u8"));
4733        assert!(
4734            !playlists.contains_key("pl1"),
4735            "the playlist row is cleared on delete"
4736        );
4737    }
4738
4739    // ── Phase 10: old-sidecar cleanup on move + empty-dir prune ──────
4740
4741    #[test]
4742    fn rename_move_relocates_cover_and_prunes_old_album() {
4743        // A title/album change moves the audio (Rename) and re-emits the cover
4744        // at the NEW path. The old cover must be removed and the now-empty old
4745        // album directory pruned, leaving no orphan sidecar and no ghost dir.
4746        let mut manifest = Manifest::new();
4747        let mut e = entry("Creator/AlbumA/song.flac", AudioFormat::Flac);
4748        e.cover_jpg = Some(ArtifactState {
4749            path: "Creator/AlbumA/cover.jpg".to_owned(),
4750            hash: "h1".to_owned(),
4751        });
4752        manifest.insert("a", e);
4753        let fs = MemFs::new()
4754            .with_file("Creator/AlbumA/song.flac", b"AUDIO".to_vec())
4755            .with_file("Creator/AlbumA/cover.jpg", b"old-jpg".to_vec());
4756        let plan = Plan {
4757            actions: vec![
4758                Action::Rename {
4759                    from: "Creator/AlbumA/song.flac".to_owned(),
4760                    to: "Creator/AlbumB/song.flac".to_owned(),
4761                },
4762                Action::WriteArtifact {
4763                    kind: ArtifactKind::CoverJpg,
4764                    path: "Creator/AlbumB/cover.jpg".to_owned(),
4765                    source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
4766                    hash: "h1".to_owned(),
4767                    owner_id: "a".to_owned(),
4768                    content: None,
4769                },
4770            ],
4771        };
4772        let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new-jpg".to_vec()));
4773
4774        let outcome = run(
4775            &plan,
4776            &mut manifest,
4777            &[],
4778            &http,
4779            &fs,
4780            &StubFfmpeg::flac(),
4781            &RecordingClock::new(),
4782            &ExecOptions::default(),
4783        );
4784
4785        assert_eq!(outcome.failed(), 0);
4786        // Audio moved, the new cover was written, the old cover removed.
4787        assert!(fs.exists("Creator/AlbumB/song.flac"));
4788        assert_eq!(
4789            fs.read_file("Creator/AlbumB/cover.jpg").unwrap(),
4790            b"new-jpg"
4791        );
4792        assert!(!fs.exists("Creator/AlbumA/cover.jpg"));
4793        assert!(!fs.exists("Creator/AlbumA/song.flac"));
4794        // The manifest cover slot now points at the new path.
4795        assert_eq!(
4796            manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
4797            "Creator/AlbumB/cover.jpg"
4798        );
4799        // The emptied old album directory is pruned; the new one survives.
4800        assert!(!fs.has_dir("Creator/AlbumA"));
4801        assert!(fs.has_dir("Creator/AlbumB"));
4802    }
4803
4804    #[test]
4805    fn rename_move_relocates_folder_art_and_prunes_old_album() {
4806        // An album rename moves folder.jpg: the old file is removed, the album
4807        // store slot advanced to the new path, and the emptied dir pruned.
4808        let mut manifest = Manifest::new();
4809        let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
4810        albums.insert(
4811            "root".to_owned(),
4812            AlbumArt {
4813                folder_jpg: Some(ArtifactState {
4814                    path: "Creator/AlbumA/folder.jpg".to_owned(),
4815                    hash: "jh".to_owned(),
4816                }),
4817                folder_webp: None,
4818            },
4819        );
4820        let fs = MemFs::new().with_file("Creator/AlbumA/folder.jpg", b"old-folder".to_vec());
4821        let plan = Plan {
4822            actions: vec![Action::WriteArtifact {
4823                kind: ArtifactKind::FolderJpg,
4824                path: "Creator/AlbumB/folder.jpg".to_owned(),
4825                source_url: "https://art.suno.ai/root/large.jpg".to_owned(),
4826                hash: "jh".to_owned(),
4827                owner_id: "root".to_owned(),
4828                content: None,
4829            }],
4830        };
4831        let http = ScriptedHttp::new().route("root/large.jpg", Reply::ok(b"new-folder".to_vec()));
4832
4833        let outcome = run_with_albums(
4834            &plan,
4835            &mut manifest,
4836            &mut albums,
4837            &[],
4838            &http,
4839            &fs,
4840            &StubFfmpeg::flac(),
4841            &RecordingClock::new(),
4842            &ExecOptions::default(),
4843        );
4844
4845        assert_eq!(outcome.failed(), 0);
4846        assert_eq!(
4847            fs.read_file("Creator/AlbumB/folder.jpg").unwrap(),
4848            b"new-folder"
4849        );
4850        assert!(!fs.exists("Creator/AlbumA/folder.jpg"));
4851        assert_eq!(
4852            albums
4853                .get("root")
4854                .unwrap()
4855                .folder_jpg
4856                .as_ref()
4857                .unwrap()
4858                .path,
4859            "Creator/AlbumB/folder.jpg"
4860        );
4861        assert!(!fs.has_dir("Creator/AlbumA"));
4862        assert!(fs.has_dir("Creator/AlbumB"));
4863    }
4864
4865    #[test]
4866    fn prune_empty_dirs_removes_only_empty_dirs() {
4867        // A direct exercise of the prune port's safety guarantees on a mixed
4868        // tree: nested empties go, anything holding a file (hidden ones too)
4869        // stays, and no file is touched.
4870        let fs = MemFs::new()
4871            .with_file("keep/full/song.flac", b"x".to_vec())
4872            .with_file("hidden/.suno-manifest.json", b"{}".to_vec())
4873            .with_dir("empty/leaf")
4874            .with_dir("nested/a/b/c");
4875
4876        fs.prune_empty_dirs("").unwrap();
4877
4878        // Every empty directory, however deeply nested, is pruned bottom-up.
4879        for gone in [
4880            "empty",
4881            "empty/leaf",
4882            "nested",
4883            "nested/a",
4884            "nested/a/b",
4885            "nested/a/b/c",
4886        ] {
4887            assert!(!fs.has_dir(gone), "empty dir {gone} should be pruned");
4888        }
4889        // A directory holding any file — including only a hidden dotfile — stays.
4890        assert!(fs.has_dir("keep"));
4891        assert!(fs.has_dir("keep/full"));
4892        assert!(fs.has_dir("hidden"));
4893        // No file was touched.
4894        assert!(fs.exists("keep/full/song.flac"));
4895        assert!(fs.exists("hidden/.suno-manifest.json"));
4896    }
4897
4898    #[test]
4899    fn prune_empty_dirs_never_removes_the_named_root() {
4900        // Pruning under a named root clears its empty children but keeps the
4901        // root itself, even when the root is now empty.
4902        let fs = MemFs::new().with_dir("empty/leaf");
4903        fs.prune_empty_dirs("empty").unwrap();
4904        assert!(fs.has_dir("empty"), "the named root is never removed");
4905        assert!(!fs.has_dir("empty/leaf"));
4906    }
4907
4908    #[test]
4909    fn old_sidecar_remove_failure_is_per_clip_and_converges_next_run() {
4910        // If removing the old sidecar fails, the write is a per-clip failure
4911        // that never aborts the run and does NOT advance the state slot, so the
4912        // next identical run re-attempts the cleanup and the tree converges.
4913        let mut manifest = Manifest::new();
4914        let mut e = entry("a.flac", AudioFormat::Flac);
4915        e.cover_jpg = Some(ArtifactState {
4916            path: "AlbumA/cover.jpg".to_owned(),
4917            hash: "h1".to_owned(),
4918        });
4919        manifest.insert("a", e);
4920        let fs = MemFs::new()
4921            .with_file("a.flac", b"AUDIO".to_vec())
4922            .with_file("AlbumA/cover.jpg", b"old".to_vec());
4923        let plan = Plan {
4924            actions: vec![Action::WriteArtifact {
4925                kind: ArtifactKind::CoverJpg,
4926                path: "AlbumB/cover.jpg".to_owned(),
4927                source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
4928                hash: "h1".to_owned(),
4929                owner_id: "a".to_owned(),
4930                content: None,
4931            }],
4932        };
4933        let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new".to_vec()));
4934
4935        // Run 1: the old-cover remove is forced to fail.
4936        fs.arm_fail_remove("AlbumA/cover.jpg");
4937        let first = run(
4938            &plan,
4939            &mut manifest,
4940            &[],
4941            &http,
4942            &fs,
4943            &StubFfmpeg::flac(),
4944            &RecordingClock::new(),
4945            &ExecOptions::default(),
4946        );
4947        assert_eq!(
4948            first.status,
4949            RunStatus::Completed,
4950            "a remove failure never aborts the run"
4951        );
4952        assert_eq!(first.failed(), 1);
4953        // The new cover is written but the old one lingers and the slot is stale.
4954        assert!(fs.exists("AlbumB/cover.jpg"));
4955        assert!(fs.exists("AlbumA/cover.jpg"));
4956        assert_eq!(
4957            manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
4958            "AlbumA/cover.jpg"
4959        );
4960        assert!(fs.has_dir("AlbumA"), "the orphan keeps its directory alive");
4961
4962        // Run 2: the same plan re-runs with the fault cleared and converges.
4963        fs.disarm_fail_remove("AlbumA/cover.jpg");
4964        let second = run(
4965            &plan,
4966            &mut manifest,
4967            &[],
4968            &http,
4969            &fs,
4970            &StubFfmpeg::flac(),
4971            &RecordingClock::new(),
4972            &ExecOptions::default(),
4973        );
4974        assert_eq!(second.failed(), 0);
4975        assert!(fs.exists("AlbumB/cover.jpg"));
4976        assert!(!fs.exists("AlbumA/cover.jpg"), "no orphan persists");
4977        assert_eq!(
4978            manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
4979            "AlbumB/cover.jpg"
4980        );
4981        assert!(!fs.has_dir("AlbumA"), "the emptied directory is pruned");
4982    }
4983
4984    #[test]
4985    fn same_path_artifact_rewrite_does_no_remove_and_prunes_nothing() {
4986        // The idempotent case: a content-only cover rewrite (hash drift, path
4987        // unchanged) attempts no remove and prunes no live directory. A remove
4988        // failure is armed on the cover path, so any spurious remove would
4989        // surface as a failure — none does.
4990        let mut manifest = Manifest::new();
4991        let mut e = entry("Album/a.mp3", AudioFormat::Mp3);
4992        e.cover_jpg = Some(ArtifactState {
4993            path: "Album/cover.jpg".to_owned(),
4994            hash: "h1".to_owned(),
4995        });
4996        manifest.insert("a", e);
4997        let fs = MemFs::new()
4998            .with_file("Album/a.mp3", b"AUDIO".to_vec())
4999            .with_file("Album/cover.jpg", b"old".to_vec());
5000        fs.arm_fail_remove("Album/cover.jpg");
5001        let plan = Plan {
5002            actions: vec![Action::WriteArtifact {
5003                kind: ArtifactKind::CoverJpg,
5004                path: "Album/cover.jpg".to_owned(),
5005                source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
5006                hash: "h2".to_owned(),
5007                owner_id: "a".to_owned(),
5008                content: None,
5009            }],
5010        };
5011        let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new".to_vec()));
5012
5013        let outcome = run(
5014            &plan,
5015            &mut manifest,
5016            &[],
5017            &http,
5018            &fs,
5019            &StubFfmpeg::flac(),
5020            &RecordingClock::new(),
5021            &ExecOptions::default(),
5022        );
5023
5024        assert_eq!(
5025            outcome.failed(),
5026            0,
5027            "no remove is attempted, so the armed failure never fires"
5028        );
5029        assert_eq!(outcome.artifacts_written, 1);
5030        assert_eq!(fs.read_file("Album/cover.jpg").unwrap(), b"new");
5031        assert_eq!(
5032            manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().hash,
5033            "h2"
5034        );
5035        // The live directory is untouched by prune.
5036        assert!(fs.has_dir("Album"));
5037    }
5038
5039    // ── Concurrency (issue #22) ─────────────────────────────────────
5040
5041    mod concurrency {
5042        use super::*;
5043        use crate::ffmpeg::FfmpegError;
5044        use crate::fs::{FileStat, FsError};
5045        use crate::http::{HttpRequest, TransportError};
5046        use std::future::Future;
5047        use std::pin::Pin;
5048        use std::sync::Arc;
5049        use std::sync::atomic::{AtomicUsize, Ordering};
5050        use std::task::{Context, Poll};
5051
5052        /// A future that pends exactly once before resolving, waking itself so a
5053        /// single-threaded executor re-polls. It forces the [`Http`] port to
5054        /// yield, so [`buffer_unordered`](futures_util::stream::StreamExt) parks
5055        /// each in-flight request and the true overlap becomes observable.
5056        #[derive(Default)]
5057        struct YieldOnce {
5058            yielded: bool,
5059        }
5060
5061        impl Future for YieldOnce {
5062            type Output = ();
5063            fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
5064                if self.yielded {
5065                    Poll::Ready(())
5066                } else {
5067                    self.yielded = true;
5068                    cx.waker().wake_by_ref();
5069                    Poll::Pending
5070                }
5071            }
5072        }
5073
5074        /// An [`Http`] double that wraps [`ScriptedHttp`] and records the peak
5075        /// number of concurrently in-flight requests. Each `send` bumps a live
5076        /// counter, yields once (so peers can start), then delegates.
5077        struct GatedHttp {
5078            inner: ScriptedHttp,
5079            inflight: Arc<AtomicUsize>,
5080            peak: Arc<AtomicUsize>,
5081        }
5082
5083        impl GatedHttp {
5084            fn new(inner: ScriptedHttp) -> Self {
5085                Self {
5086                    inner,
5087                    inflight: Arc::new(AtomicUsize::new(0)),
5088                    peak: Arc::new(AtomicUsize::new(0)),
5089                }
5090            }
5091
5092            fn peak(&self) -> usize {
5093                self.peak.load(Ordering::SeqCst)
5094            }
5095        }
5096
5097        impl Http for GatedHttp {
5098            async fn send(&self, request: HttpRequest) -> Result<HttpResponse, TransportError> {
5099                let now = self.inflight.fetch_add(1, Ordering::SeqCst) + 1;
5100                self.peak.fetch_max(now, Ordering::SeqCst);
5101                YieldOnce::default().await;
5102                let out = self.inner.send(request).await;
5103                self.inflight.fetch_sub(1, Ordering::SeqCst);
5104                out
5105            }
5106        }
5107
5108        fn download(id: &str, format: AudioFormat) -> (Clip, Desired, Action) {
5109            let c = clip(id);
5110            let d = desired(c.clone(), format);
5111            let action = Action::Download {
5112                clip: c.clone(),
5113                lineage: LineageContext::own_root(&c),
5114                path: d.path.clone(),
5115                format,
5116            };
5117            (c, d, action)
5118        }
5119
5120        fn opts_with(concurrency: u32) -> ExecOptions {
5121            ExecOptions {
5122                concurrency,
5123                ..small_poll()
5124            }
5125        }
5126
5127        #[test]
5128        fn concurrency_never_exceeds_the_configured_bound() {
5129            let count = 6;
5130            let concurrency = 3;
5131            let mut scripted = ScriptedHttp::new().with_auth();
5132            let mut actions = Vec::new();
5133            let mut desireds = Vec::new();
5134            for i in 0..count {
5135                let id = format!("c{i}");
5136                scripted = scripted.route(&format!("{id}.mp3"), Reply::ok(b"mp3-body".to_vec()));
5137                let (_c, d, action) = download(&id, AudioFormat::Mp3);
5138                actions.push(action);
5139                desireds.push(d);
5140            }
5141            let http = GatedHttp::new(scripted);
5142            let fs = MemFs::new();
5143            let plan = Plan { actions };
5144            let mut manifest = Manifest::new();
5145
5146            let outcome = run_gated_fs(
5147                &plan,
5148                &mut manifest,
5149                &desireds,
5150                &http,
5151                &fs,
5152                &opts_with(concurrency),
5153            );
5154
5155            assert_eq!(outcome.downloaded, count);
5156            assert!(
5157                http.peak() <= concurrency as usize,
5158                "peak {} exceeded the bound {concurrency}",
5159                http.peak()
5160            );
5161            assert_eq!(
5162                http.peak(),
5163                concurrency as usize,
5164                "expected the run to saturate the bound"
5165            );
5166        }
5167
5168        /// Run a gated plan against a caller-supplied [`MemFs`], returning the
5169        /// outcome. The client is built here so the limiter can be inspected by
5170        /// the caller-facing variant below.
5171        fn run_gated_fs(
5172            plan: &Plan,
5173            manifest: &mut Manifest,
5174            desired: &[Desired],
5175            http: &GatedHttp,
5176            fs: &MemFs,
5177            opts: &ExecOptions,
5178        ) -> ExecOutcome {
5179            let ffmpeg = StubFfmpeg::flac();
5180            let clock = RecordingClock::new();
5181            let mut albums = BTreeMap::new();
5182            let mut playlists = BTreeMap::new();
5183            let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
5184            pollster::block_on(execute(
5185                plan,
5186                manifest,
5187                &mut albums,
5188                &mut playlists,
5189                desired,
5190                &HashMap::new(),
5191                Ports {
5192                    client: &mut client,
5193                    http,
5194                    fs,
5195                    ffmpeg: &ffmpeg,
5196                    clock: &clock,
5197                },
5198                opts,
5199            ))
5200        }
5201
5202        #[test]
5203        fn a_failing_clip_does_not_abort_the_others() {
5204            let mut scripted = ScriptedHttp::new().with_auth();
5205            scripted = scripted
5206                .route("ok1.mp3", Reply::ok(b"one".to_vec()))
5207                .route("bad.mp3", Reply::status(404))
5208                .route("ok2.mp3", Reply::ok(b"two".to_vec()));
5209            let (_a, d1, a1) = download("ok1", AudioFormat::Mp3);
5210            let (_b, d2, a2) = download("bad", AudioFormat::Mp3);
5211            let (_c, d3, a3) = download("ok2", AudioFormat::Mp3);
5212            let http = GatedHttp::new(scripted);
5213            let fs = MemFs::new();
5214            let plan = Plan {
5215                actions: vec![a1, a2, a3],
5216            };
5217            let mut manifest = Manifest::new();
5218
5219            let outcome = run_gated_fs(
5220                &plan,
5221                &mut manifest,
5222                &[d1, d2, d3],
5223                &http,
5224                &fs,
5225                &opts_with(3),
5226            );
5227
5228            assert_eq!(outcome.downloaded, 2);
5229            assert_eq!(outcome.failed(), 1);
5230            assert_eq!(outcome.status, RunStatus::Completed);
5231            assert_eq!(outcome.failures[0].clip_id, "bad");
5232            assert!(manifest.get("ok1").is_some());
5233            assert!(manifest.get("ok2").is_some());
5234            assert!(manifest.get("bad").is_none());
5235        }
5236
5237        #[test]
5238        fn outcome_is_identical_across_concurrency_levels() {
5239            // A plan mixing successful and failing downloads with serial phase-2
5240            // actions (a skip and a delete), so both phases contribute.
5241            fn build() -> (Plan, Vec<Desired>) {
5242                let mut actions = Vec::new();
5243                let mut desireds = Vec::new();
5244                for id in ["a", "b", "c", "d"] {
5245                    let (_c, d, action) = download(id, AudioFormat::Mp3);
5246                    actions.push(action);
5247                    desireds.push(d);
5248                }
5249                // A failing download in the middle of the audio set.
5250                let (_e, de, ae) = download("fail", AudioFormat::Mp3);
5251                actions.insert(2, ae);
5252                desireds.push(de);
5253                // Phase-2 actions.
5254                actions.push(Action::Skip {
5255                    clip_id: "gone".to_owned(),
5256                });
5257                actions.push(Action::Delete {
5258                    path: "old.mp3".to_owned(),
5259                    clip_id: "old".to_owned(),
5260                });
5261                (Plan { actions }, desireds)
5262            }
5263
5264            fn http() -> ScriptedHttp {
5265                ScriptedHttp::new()
5266                    .with_auth()
5267                    .route("a.mp3", Reply::ok(b"a".to_vec()))
5268                    .route("b.mp3", Reply::ok(b"b".to_vec()))
5269                    .route("c.mp3", Reply::ok(b"c".to_vec()))
5270                    .route("d.mp3", Reply::ok(b"d".to_vec()))
5271                    .route("fail.mp3", Reply::status(404))
5272            }
5273
5274            fn seed_manifest() -> Manifest {
5275                let mut m = Manifest::new();
5276                m.insert("old".to_owned(), entry("old.mp3", AudioFormat::Mp3));
5277                m
5278            }
5279
5280            let (plan, desireds) = build();
5281
5282            let mut m1 = seed_manifest();
5283            let fs1 = MemFs::new().with_file("old.mp3", b"x".to_vec());
5284            let out1 = run_gated_fs(
5285                &plan,
5286                &mut m1,
5287                &desireds,
5288                &GatedHttp::new(http()),
5289                &fs1,
5290                &opts_with(1),
5291            );
5292
5293            let mut m8 = seed_manifest();
5294            let fs8 = MemFs::new().with_file("old.mp3", b"x".to_vec());
5295            let out8 = run_gated_fs(
5296                &plan,
5297                &mut m8,
5298                &desireds,
5299                &GatedHttp::new(http()),
5300                &fs8,
5301                &opts_with(8),
5302            );
5303
5304            assert_eq!(out1, out8, "outcome must not depend on concurrency");
5305            assert_eq!(m1, m8, "final manifest must not depend on concurrency");
5306            assert_eq!(out8.downloaded, 4);
5307            assert_eq!(out8.deleted, 1);
5308            assert_eq!(out8.skipped, 1);
5309            assert_eq!(out8.failed(), 1);
5310        }
5311
5312        #[test]
5313        fn a_systemic_disk_full_aborts_promptly() {
5314            let count = 8;
5315            let concurrency = 2;
5316            let mut scripted = ScriptedHttp::new().with_auth();
5317            let mut actions = Vec::new();
5318            let mut desireds = Vec::new();
5319            for i in 0..count {
5320                let id = format!("d{i}");
5321                scripted = scripted.route(&format!("{id}.mp3"), Reply::ok(b"mp3-body".to_vec()));
5322                let (_c, d, action) = download(&id, AudioFormat::Mp3);
5323                actions.push(action);
5324                desireds.push(d);
5325            }
5326            // The very first clip's write hits ENOSPC, a systemic failure.
5327            let fs = MemFs::new().fail_write_out_of_space("d0.mp3");
5328            let http = GatedHttp::new(scripted);
5329            let plan = Plan { actions };
5330            let mut manifest = Manifest::new();
5331
5332            let outcome = run_gated_fs(
5333                &plan,
5334                &mut manifest,
5335                &desireds,
5336                &http,
5337                &fs,
5338                &opts_with(concurrency),
5339            );
5340
5341            assert_eq!(outcome.status, RunStatus::DiskFull);
5342            assert!(
5343                outcome.downloaded < count,
5344                "a systemic abort must stop remaining work, downloaded {}",
5345                outcome.downloaded
5346            );
5347        }
5348
5349        #[test]
5350        fn limiter_records_a_rate_limit_under_concurrent_calls() {
5351            // Three concurrent FLAC renders; exactly one clip is throttled once
5352            // on its wav_file read. The shared limiter must record that single
5353            // 429 (halving 2.0 -> 1.0) with no lost or duplicated update, proving
5354            // the mutex keeps the AIMD state correct under concurrency.
5355            let scripted = ScriptedHttp::new()
5356                .with_auth()
5357                .route_seq(
5358                    "/gen/x/wav_file/",
5359                    vec![
5360                        Reply::status(429),
5361                        Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/x.wav"}"#),
5362                    ],
5363                )
5364                .route(
5365                    "/gen/y/wav_file/",
5366                    Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/y.wav"}"#),
5367                )
5368                .route(
5369                    "/gen/z/wav_file/",
5370                    Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#),
5371                )
5372                .route("x.wav", Reply::ok(b"wav-x".to_vec()))
5373                .route("y.wav", Reply::ok(b"wav-y".to_vec()))
5374                .route("z.wav", Reply::ok(b"wav-z".to_vec()));
5375
5376            let mut actions = Vec::new();
5377            let mut desireds = Vec::new();
5378            for id in ["x", "y", "z"] {
5379                let (_c, d, action) = download(id, AudioFormat::Flac);
5380                actions.push(action);
5381                desireds.push(d);
5382            }
5383            let plan = Plan { actions };
5384            let fs = MemFs::new();
5385            let ffmpeg = StubFfmpeg::flac();
5386            let clock = RecordingClock::new();
5387            let mut albums = BTreeMap::new();
5388            let mut playlists = BTreeMap::new();
5389            let mut manifest = Manifest::new();
5390            let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
5391
5392            let outcome = pollster::block_on(execute(
5393                &plan,
5394                &mut manifest,
5395                &mut albums,
5396                &mut playlists,
5397                &desireds,
5398                &HashMap::new(),
5399                Ports {
5400                    client: &mut client,
5401                    http: &scripted,
5402                    fs: &fs,
5403                    ffmpeg: &ffmpeg,
5404                    clock: &clock,
5405                },
5406                &opts_with(3),
5407            ));
5408
5409            assert_eq!(outcome.downloaded, 3);
5410            assert_eq!(outcome.failed(), 0);
5411            assert!(
5412                (client.limiter_rate() - 1.0).abs() < 1e-9,
5413                "one 429 must halve the rate to 1.0, got {}",
5414                client.limiter_rate()
5415            );
5416        }
5417
5418        #[test]
5419        fn a_download_is_committed_in_plan_order_around_a_rename() {
5420            // Plan order: rename "orig" away from shared.mp3 first, then download
5421            // a new clip into shared.mp3. A parallel executor that performed the
5422            // download's destination write off plan order would write shared.mp3
5423            // before the rename ran, letting the rename carry those fresh bytes
5424            // to moved.mp3 and stranding shared.mp3 - corrupting both clips.
5425            // Committing every destination effect serially in plan order keeps
5426            // moved.mp3 = the original and shared.mp3 = the new download.
5427            let c_new = clip("new");
5428            let mut d_new = desired(c_new.clone(), AudioFormat::Mp3);
5429            d_new.path = "shared.mp3".to_owned();
5430            let plan = Plan {
5431                actions: vec![
5432                    Action::Rename {
5433                        from: "shared.mp3".to_owned(),
5434                        to: "moved.mp3".to_owned(),
5435                    },
5436                    Action::Download {
5437                        clip: c_new.clone(),
5438                        lineage: LineageContext::own_root(&c_new),
5439                        path: "shared.mp3".to_owned(),
5440                        format: AudioFormat::Mp3,
5441                    },
5442                ],
5443            };
5444            let scripted = ScriptedHttp::new()
5445                .with_auth()
5446                .route("new.mp3", Reply::ok(b"NEW-BODY".to_vec()));
5447            let http = GatedHttp::new(scripted);
5448            let fs = MemFs::new().with_file("shared.mp3", b"ORIGINAL".to_vec());
5449            let mut manifest = Manifest::new();
5450            manifest.insert("orig", entry("shared.mp3", AudioFormat::Mp3));
5451
5452            let outcome = run_gated_fs(&plan, &mut manifest, &[d_new], &http, &fs, &opts_with(4));
5453
5454            assert_eq!(outcome.renamed, 1);
5455            assert_eq!(outcome.downloaded, 1);
5456            assert_eq!(
5457                fs.read_file("moved.mp3").as_deref(),
5458                Some(&b"ORIGINAL"[..]),
5459                "the rename must carry the original bytes, untouched by the download"
5460            );
5461            let landed = fs.read_file("shared.mp3").expect("new download must land");
5462            assert_ne!(
5463                landed, b"ORIGINAL",
5464                "the new download must replace the moved original, not corrupt it"
5465            );
5466            assert_eq!(manifest.get("orig").unwrap().path, "moved.mp3");
5467            assert_eq!(manifest.get("new").unwrap().path, "shared.mp3");
5468        }
5469
5470        #[test]
5471        fn an_aborted_reformat_leaves_the_old_file_and_manifest_consistent() {
5472            // A systemic disk-full abort strikes the download committed before the
5473            // reformat. Because the reformat's slow render is side-effect-free and
5474            // its destination write + old-file removal only happen in the serial
5475            // commit (which the abort skips), the old file survives and the
5476            // manifest still points at it: no removed-but-referenced file.
5477            let boom = clip("boom");
5478            let mut d_boom = desired(boom.clone(), AudioFormat::Mp3);
5479            d_boom.path = "boom.mp3".to_owned();
5480            let reformer = clip("r");
5481            let d_reformer = desired(reformer.clone(), AudioFormat::Mp3);
5482            let plan = Plan {
5483                actions: vec![
5484                    Action::Download {
5485                        clip: boom.clone(),
5486                        lineage: LineageContext::own_root(&boom),
5487                        path: "boom.mp3".to_owned(),
5488                        format: AudioFormat::Mp3,
5489                    },
5490                    Action::Reformat {
5491                        clip: reformer.clone(),
5492                        path: "r_new.mp3".to_owned(),
5493                        from_path: "r_old.flac".to_owned(),
5494                        from: AudioFormat::Flac,
5495                        to: AudioFormat::Mp3,
5496                    },
5497                ],
5498            };
5499            let scripted = ScriptedHttp::new()
5500                .with_auth()
5501                .route("boom.mp3", Reply::ok(b"boom-body".to_vec()))
5502                .route("r.mp3", Reply::ok(b"reformatted".to_vec()));
5503            let http = GatedHttp::new(scripted);
5504            // The download's write hits ENOSPC, a systemic abort.
5505            let fs = MemFs::new()
5506                .with_file("r_old.flac", b"OLD-FLAC".to_vec())
5507                .fail_write_out_of_space("boom.mp3");
5508            let mut manifest = Manifest::new();
5509            manifest.insert("r", entry("r_old.flac", AudioFormat::Flac));
5510
5511            let outcome = run_gated_fs(
5512                &plan,
5513                &mut manifest,
5514                &[d_boom, d_reformer],
5515                &http,
5516                &fs,
5517                &opts_with(4),
5518            );
5519
5520            assert_eq!(outcome.status, RunStatus::DiskFull);
5521            assert!(
5522                fs.exists("r_old.flac"),
5523                "the old file must survive the abort"
5524            );
5525            assert!(
5526                !fs.exists("r_new.mp3"),
5527                "no reformatted file may be written"
5528            );
5529            let still = manifest.get("r").expect("the manifest must still track r");
5530            assert_eq!(
5531                still.path, "r_old.flac",
5532                "the manifest must still point at the surviving old file"
5533            );
5534            assert_eq!(still.format, AudioFormat::Flac);
5535        }
5536
5537        #[test]
5538        fn a_systemic_abort_leaves_no_untracked_destination_files() {
5539            // Two clips commit, the third's write hits ENOSPC (a systemic abort),
5540            // and the rest never commit. Every file remaining on disk must be one
5541            // the manifest tracks: producers write nothing, so an abort cannot
5542            // strand an untracked file from an in-flight or buffered render.
5543            let mut scripted = ScriptedHttp::new().with_auth();
5544            let mut actions = Vec::new();
5545            let mut desireds = Vec::new();
5546            for id in ["a0", "a1", "boom", "a3", "a4"] {
5547                scripted = scripted.route(&format!("{id}.mp3"), Reply::ok(b"body".to_vec()));
5548                let (_c, d, action) = download(id, AudioFormat::Mp3);
5549                actions.push(action);
5550                desireds.push(d);
5551            }
5552            let http = GatedHttp::new(scripted);
5553            let fs = MemFs::new().fail_write_out_of_space("boom.mp3");
5554            let plan = Plan { actions };
5555            let mut manifest = Manifest::new();
5556
5557            let outcome = run_gated_fs(&plan, &mut manifest, &desireds, &http, &fs, &opts_with(2));
5558
5559            assert_eq!(outcome.status, RunStatus::DiskFull);
5560            let tracked: std::collections::BTreeSet<String> = manifest
5561                .entries
5562                .values()
5563                .map(|entry| entry.path.clone())
5564                .collect();
5565            for path in fs.paths() {
5566                assert!(
5567                    tracked.contains(&path),
5568                    "found an untracked destination file: {path}"
5569                );
5570            }
5571            assert!(
5572                !fs.exists("a3.mp3"),
5573                "uncommitted renders must not be on disk"
5574            );
5575            assert!(
5576                !fs.exists("a4.mp3"),
5577                "uncommitted renders must not be on disk"
5578            );
5579        }
5580
5581        /// An [`Ffmpeg`] double that counts how many rendered FLAC payloads are
5582        /// live: it bumps a shared counter (tracking the peak) when a transcode
5583        /// yields bytes, and [`CountingFs`] drops it back on the committing write.
5584        /// The [transcode, write] window is a superset of the true in-memory hold,
5585        /// so the observed peak upper-bounds the real one.
5586        struct CountingFfmpeg {
5587            inner: StubFfmpeg,
5588            held: Arc<AtomicUsize>,
5589            peak: Arc<AtomicUsize>,
5590        }
5591
5592        impl Ffmpeg for CountingFfmpeg {
5593            fn wav_to_flac(
5594                &self,
5595                wav: &[u8],
5596            ) -> impl Future<Output = Result<Vec<u8>, FfmpegError>> + Send {
5597                let fut = self.inner.wav_to_flac(wav);
5598                let held = self.held.clone();
5599                let peak = self.peak.clone();
5600                async move {
5601                    let out = fut.await;
5602                    if out.is_ok() {
5603                        let now = held.fetch_add(1, Ordering::SeqCst) + 1;
5604                        peak.fetch_max(now, Ordering::SeqCst);
5605                    }
5606                    out
5607                }
5608            }
5609
5610            fn mp4_to_webp(
5611                &self,
5612                mp4: &[u8],
5613                settings: WebpEncodeSettings,
5614            ) -> impl Future<Output = Result<Vec<u8>, FfmpegError>> + Send {
5615                self.inner.mp4_to_webp(mp4, settings)
5616            }
5617        }
5618
5619        /// A [`Filesystem`] double wrapping [`MemFs`] that decrements the live
5620        /// payload counter on each committing write, closing the window opened by
5621        /// [`CountingFfmpeg`].
5622        struct CountingFs {
5623            inner: MemFs,
5624            held: Arc<AtomicUsize>,
5625        }
5626
5627        impl Filesystem for CountingFs {
5628            fn write_atomic(&self, path: &str, bytes: &[u8]) -> Result<(), FsError> {
5629                let out = self.inner.write_atomic(path, bytes);
5630                self.held.fetch_sub(1, Ordering::SeqCst);
5631                out
5632            }
5633
5634            fn rename(&self, from: &str, to: &str) -> Result<(), FsError> {
5635                self.inner.rename(from, to)
5636            }
5637
5638            fn remove(&self, path: &str) -> Result<(), FsError> {
5639                self.inner.remove(path)
5640            }
5641
5642            fn prune_empty_dirs(&self, root: &str) -> Result<(), FsError> {
5643                self.inner.prune_empty_dirs(root)
5644            }
5645
5646            fn read(&self, path: &str) -> Result<Vec<u8>, FsError> {
5647                self.inner.read(path)
5648            }
5649
5650            fn metadata(&self, path: &str) -> Option<FileStat> {
5651                self.inner.metadata(path)
5652            }
5653        }
5654
5655        #[test]
5656        fn rendered_payloads_in_memory_stay_bounded_by_concurrency() {
5657            // Far more FLAC clips than the concurrency bound. The ordered buffered
5658            // render keeps at most about `concurrency` transcoded payloads live at
5659            // once (never the whole library), so peak held <= concurrency + 1.
5660            let count = 12;
5661            let concurrency = 3;
5662            let mut scripted = ScriptedHttp::new().with_auth();
5663            let mut actions = Vec::new();
5664            let mut desireds = Vec::new();
5665            for i in 0..count {
5666                let id = format!("f{i}");
5667                scripted = scripted
5668                    .route(
5669                        &format!("/gen/{id}/wav_file/"),
5670                        Reply::json(&format!(
5671                            r#"{{"wav_file_url": "https://cdn1.suno.ai/{id}.wav"}}"#
5672                        )),
5673                    )
5674                    .route(&format!("{id}.wav"), Reply::ok(b"wav-body".to_vec()));
5675                let (_c, d, action) = download(&id, AudioFormat::Flac);
5676                actions.push(action);
5677                desireds.push(d);
5678            }
5679            let http = GatedHttp::new(scripted);
5680            let held = Arc::new(AtomicUsize::new(0));
5681            let peak = Arc::new(AtomicUsize::new(0));
5682            let ffmpeg = CountingFfmpeg {
5683                inner: StubFfmpeg::flac(),
5684                held: held.clone(),
5685                peak: peak.clone(),
5686            };
5687            let fs = CountingFs {
5688                inner: MemFs::new(),
5689                held: held.clone(),
5690            };
5691            let clock = RecordingClock::new();
5692            let mut albums = BTreeMap::new();
5693            let mut playlists = BTreeMap::new();
5694            let mut manifest = Manifest::new();
5695            let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
5696            let plan = Plan { actions };
5697
5698            let outcome = pollster::block_on(execute(
5699                &plan,
5700                &mut manifest,
5701                &mut albums,
5702                &mut playlists,
5703                &desireds,
5704                &HashMap::new(),
5705                Ports {
5706                    client: &mut client,
5707                    http: &http,
5708                    fs: &fs,
5709                    ffmpeg: &ffmpeg,
5710                    clock: &clock,
5711                },
5712                &opts_with(concurrency),
5713            ));
5714
5715            assert_eq!(outcome.downloaded, count as usize);
5716            assert_eq!(
5717                held.load(Ordering::SeqCst),
5718                0,
5719                "every payload must be committed"
5720            );
5721            assert!(
5722                peak.load(Ordering::SeqCst) <= concurrency as usize + 1,
5723                "peak live payloads {} exceeded the bound {}",
5724                peak.load(Ordering::SeqCst),
5725                concurrency + 1
5726            );
5727            assert!(
5728                peak.load(Ordering::SeqCst) >= 2,
5729                "the render should genuinely overlap, peak was {}",
5730                peak.load(Ordering::SeqCst)
5731            );
5732        }
5733    }
5734}