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