Skip to main content

suno_core/
executor.rs

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