Skip to main content

suno_core/executor/
mod.rs

1//! The download executor: it applies a reconcile [`Plan`] to disk through ports.
2//!
3//! Reconcile decides *what* to do; the executor does it. It is async and pure
4//! orchestration: every side effect goes through a port ([`Http`] for the
5//! network, [`Filesystem`] for disk, [`Ffmpeg`] for transcoding, [`Clock`] for
6//! waiting), so the whole pipeline is exercised in tests with in-memory doubles
7//! and no real IO, network, or sleeping.
8//!
9//! Safety is the point of this module. A wrong write or delete damages the
10//! user's library, so the executor:
11//!
12//! - writes only atomically (SYNC-13): a failed write leaves the prior file
13//!   intact, because the [`Filesystem`] adapter stages a temp file and renames;
14//! - verifies size (SYNC-14): a download whose body disagrees with the
15//!   provider's `Content-Length` is treated as truncated and retried, and a
16//!   written file whose on-disk size disagrees with the bytes written is a
17//!   failure, never a recorded success;
18//! - classifies errors (SYNC-17): an auth failure or a full disk stops the
19//!   account run (with an auth or disk-full status) and is never retried;
20//!   transient failures (timeouts, 5xx,
21//!   transport, 429) are retried a bounded number of times then recorded and
22//!   skipped; permanent failures are recorded and skipped; and a single clip's
23//!   failure never aborts the run;
24//! - backs off on rate limits (SYNC-16) through the injected [`Clock`], honouring
25//!   a `Retry-After` hint.
26//!
27//! The executor only ever sets the manifest's [`preserve`](ManifestEntry::preserve)
28//! marker on an entry it writes, and only deletes a path whose removal the
29//! [`Filesystem`] confirms. Higher-level safety (empty-listing abort, the
30//! destructive-sync confirmation, exit codes) is the caller's job.
31
32use std::collections::BTreeMap;
33use std::collections::BTreeSet;
34use std::collections::HashMap;
35use std::collections::HashSet;
36use std::sync::Mutex;
37use std::time::Duration;
38
39use futures_util::lock::Mutex as AsyncMutex;
40use futures_util::stream::{self, StreamExt};
41
42use crate::album_art::{AlbumArt, PlaylistState, set_album_artifact, set_playlist};
43use crate::backoff::{backoff_delay, retry_after};
44use crate::client::SunoClient;
45use crate::clock::Clock;
46use crate::error::Error;
47use crate::ffmpeg::Ffmpeg;
48use crate::fs::Filesystem;
49use crate::http::{Http, HttpRequest};
50use crate::lineage::LineageContext;
51use crate::lyrics::AlignedLyrics;
52use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
53use crate::model::Clip;
54use crate::reconcile::{Action, Desired, Plan, set_manifest_artifact, set_manifest_stem};
55use crate::tag::{Cover, TrackMetadata, flac_picture_data_budget, tag_flac, tag_mp3, tag_wav};
56use crate::tag_alac::tag_alac;
57use crate::vocab::{ArtifactKind, AudioFormat, SourceMode, StemFormat, WebpEncodeSettings};
58
59mod artifact;
60mod audio;
61mod cover;
62mod stem;
63mod tag;
64
65/// The shared Suno client behind an async mutex, so concurrent audio work can
66/// serialise its order-sensitive API calls (JWT refresh, adaptive limiter)
67/// without a runtime-specific lock. Held only for the brief WAV-render calls;
68/// the heavy CDN/transcode/tag work runs unlocked.
69type ClientLock<'a, C> = AsyncMutex<&'a SunoClient<C>>;
70
71/// Tunables for one [`execute`] run.
72#[derive(Debug, Clone)]
73pub struct ExecOptions {
74    /// How many times a transient failure is retried before record-and-skip.
75    pub max_retries: u32,
76    /// How many times to poll for a server-side WAV render before giving up.
77    pub wav_poll_attempts: u32,
78    /// How long to wait between WAV render polls.
79    pub wav_poll_interval: Duration,
80    /// How many clips' audio to fetch, transcode, and tag concurrently. Clamped
81    /// to at least one, so a zero collapses to sequential rather than stalling.
82    pub concurrency: u32,
83    /// Embed a bounded animated WebP as the audio file's front cover (in place of
84    /// the static JPEG) for clips that carry a video preview. Off leaves the
85    /// static JPEG embed unchanged.
86    pub embed_animated_cover: bool,
87    /// Settings used for animated WebP cover transcodes.
88    pub cover_webp: WebpEncodeSettings,
89}
90
91impl Default for ExecOptions {
92    fn default() -> Self {
93        Self {
94            max_retries: 3,
95            wav_poll_attempts: 24,
96            wav_poll_interval: Duration::from_secs(5),
97            concurrency: 4,
98            embed_animated_cover: false,
99            cover_webp: WebpEncodeSettings::default(),
100        }
101    }
102}
103
104/// How an [`execute`] run ended.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
106pub enum RunStatus {
107    /// Every action was attempted; some may have failed and been skipped.
108    #[default]
109    Completed,
110    /// An auth failure stopped the run early; remaining actions were not tried.
111    AuthAborted,
112    /// The disk filled; the run stopped early rather than failing every
113    /// remaining clip. Remaining actions were not tried.
114    DiskFull,
115}
116
117/// One action that could not be applied, for the run summary and failure log.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Failure {
120    /// The clip the failed action concerned (or a path when no id applies).
121    pub clip_id: String,
122    /// A short, secret-free reason.
123    pub reason: String,
124}
125
126/// The result of applying a [`Plan`]: per-action counts and the failure list.
127#[derive(Debug, Clone, Default, PartialEq, Eq)]
128pub struct ExecOutcome {
129    pub downloaded: usize,
130    pub reformatted: usize,
131    pub retagged: usize,
132    pub renamed: usize,
133    pub deleted: usize,
134    pub skipped: usize,
135    pub artifacts_written: usize,
136    pub artifacts_deleted: usize,
137    /// Actions that failed and were skipped (auth, transient-exhausted, or
138    /// permanent). The run continued past each one unless it was an auth or
139    /// disk-full abort.
140    pub failures: Vec<Failure>,
141    /// How the run ended.
142    pub status: RunStatus,
143}
144
145impl ExecOutcome {
146    /// Number of failed actions.
147    pub fn failed(&self) -> usize {
148        self.failures.len()
149    }
150
151    fn record(&mut self, effect: Effect) {
152        match effect {
153            Effect::Downloaded => self.downloaded += 1,
154            Effect::Reformatted => self.reformatted += 1,
155            Effect::Retagged => self.retagged += 1,
156            Effect::Renamed => self.renamed += 1,
157            Effect::Deleted => self.deleted += 1,
158            Effect::Skipped => self.skipped += 1,
159            Effect::ArtifactWritten => self.artifacts_written += 1,
160            Effect::ArtifactDeleted => self.artifacts_deleted += 1,
161        }
162    }
163}
164
165/// The IO ports the executor drives, grouped so one value threads them through.
166///
167/// `client` performs the authenticated WAV render flow. The rest are shared
168/// references.
169pub struct Ports<'a, H, F, G, C> {
170    /// Performs the authenticated WAV render and poll flow.
171    pub client: &'a SunoClient<C>,
172    /// The public network port (CDN audio, rendered WAV, cover art).
173    pub http: &'a H,
174    /// The disk port.
175    pub fs: &'a F,
176    /// The transcode port (WAV to FLAC).
177    pub ffmpeg: &'a G,
178    /// The backoff and poll delay port.
179    pub clock: &'a C,
180}
181
182/// Apply `plan` to disk, updating `manifest` and `albums` in place, and return
183/// the outcome.
184///
185/// `desired` carries the per-clip metadata and art hashes plus the source modes
186/// that decide the [`preserve`](ManifestEntry::preserve) marker; it is indexed
187/// by clip id (and by target path, for renames) so each written entry records
188/// the right hashes and protection. `albums` is the album-art store, keyed by
189/// stable root id: folder-art writes and deletes record their state there rather
190/// than on the per-clip `manifest`. `ports` bundles the authenticated client and
191/// the network, disk, transcode, and backoff ports. A single clip's failure
192/// never aborts the run, except an auth failure or a full disk, which stop it
193/// with [`RunStatus::AuthAborted`] or [`RunStatus::DiskFull`].
194///
195/// Audio-producing ([`Download`](Action::Download) /
196/// [`Reformat`](Action::Reformat)), fetched-artifact
197/// ([`WriteArtifact`](Action::WriteArtifact) with no inline content), and stem
198/// ([`WriteStem`](Action::WriteStem)) actions all run their slow,
199/// side-effect-free work concurrently, bounded by
200/// [`ExecOptions::concurrency`]: WAV render + CDN download + transcode + tag for
201/// audio; CDN fetch + optional WebP transcode for artifacts; WAV render + CDN
202/// download for stems. Order-sensitive Suno API calls (WAV render initiation and
203/// poll) are serialised behind an async mutex over the shared [`SunoClient`],
204/// keeping the adaptive limiter and JWT refresh correct. The remaining actions
205/// (retag, rename, delete, artifact deletes, and inline artifact writes) run
206/// serially in plan order.
207///
208/// The outcome is deterministic regardless of completion order: all prepared
209/// results are committed to the manifest in plan-index order, so the same plan
210/// always yields the same manifest and counts whatever the concurrency level. A
211/// per-clip failure is recorded and the run continues; only an auth failure or a
212/// full disk aborts, and it does so promptly by stopping further concurrent work.
213///
214/// `synced` carries this run's fetched aligned (synced) lyrics keyed by clip id;
215/// it is the caller's IO result, not part of the pure plan. Audio tagging embeds
216/// a clip's entry as an MP3 `SYLT` frame and as the plain `USLT`/`LYRICS` text
217/// (FLAC), so a clip absent from the map (an instrumental, a WAV target, or a
218/// run with the feature off) is tagged exactly as before. The synced `.lrc`
219/// sidecar itself is a generated artifact whose body the caller has already
220/// resolved into the plan, so it is written like any other text sidecar.
221#[allow(clippy::too_many_arguments)]
222pub async fn execute<H, F, G, C>(
223    plan: &Plan,
224    manifest: &mut Manifest,
225    albums: &mut BTreeMap<String, AlbumArt>,
226    playlists: &mut BTreeMap<String, PlaylistState>,
227    desired: &[Desired],
228    synced: &HashMap<String, AlignedLyrics>,
229    ports: Ports<'_, H, F, G, C>,
230    opts: &ExecOptions,
231) -> ExecOutcome
232where
233    H: Http,
234    F: Filesystem,
235    G: Ffmpeg,
236    C: Clock,
237{
238    let Ports {
239        client,
240        http,
241        fs,
242        ffmpeg,
243        clock,
244    } = ports;
245    let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
246    let by_path: HashMap<&str, &Desired> = desired.iter().map(|d| (d.path.as_str(), d)).collect();
247    // How many tracked artifact slots reference each path. The inline old-path
248    // cleanup removes a path only once nothing else holds it: each slot that
249    // moves away decrements its reference, and the removal fires only when the
250    // count reaches zero and no action writes the path this run. This keeps a
251    // live file a co-referencing slot still owns (a prior failed swap can leave
252    // two clips sharing a path) while letting the last slot to leave reclaim it,
253    // so nothing is orphaned either (#76).
254    let mut tracked_paths: HashMap<String, u32> = HashMap::new();
255    for (_, entry) in manifest.iter() {
256        for path in entry.artifact_paths() {
257            *tracked_paths.entry(path.to_owned()).or_default() += 1;
258        }
259    }
260    for art in albums.values() {
261        for state in [
262            art.folder_jpg.as_ref(),
263            art.folder_webp.as_ref(),
264            art.folder_mp4.as_ref(),
265        ]
266        .into_iter()
267        .flatten()
268        {
269            *tracked_paths.entry(state.path.clone()).or_default() += 1;
270        }
271    }
272    for playlist in playlists.values() {
273        *tracked_paths.entry(playlist.path.clone()).or_default() += 1;
274    }
275    // Static cover art is otherwise fetched twice per clip (#89): once to embed
276    // in the audio tag and once for the per-song `.jpg` sidecar, both from the
277    // same CDN URL. The audio producer caches each cover it embeds here, keyed by
278    // URL, and the sidecar write drains it rather than re-fetching. Only URLs a
279    // `CoverJpg` sidecar will fetch this run are cached, and the sidecar removes
280    // its entry on use, so the map holds at most the covers for the clips in
281    // flight (bounded by `concurrency`), never the whole library.
282    let cover_wanted: HashSet<&str> = plan
283        .actions
284        .iter()
285        .filter_map(|action| match action {
286            Action::WriteArtifact {
287                kind: ArtifactKind::CoverJpg,
288                source_url,
289                ..
290            } if !source_url.is_empty() => Some(source_url.as_str()),
291            _ => None,
292        })
293        .collect();
294    let cover_cache: Mutex<HashMap<String, Vec<u8>>> = Mutex::new(HashMap::new());
295    // The `both` video-cover retention keeps `cover.webp` (transcoded) and
296    // `cover.mp4` (raw) for an album from the SAME `video_cover_url`. Cache that
297    // source on its first fetch so the second folder artifact drains it rather
298    // than fetching the same MP4 twice (#90 reuses the #89 fetch-once path).
299    let mut folder_cover_uses: HashMap<&str, u32> = HashMap::new();
300    for action in &plan.actions {
301        if let Action::WriteArtifact {
302            kind: ArtifactKind::FolderWebp | ArtifactKind::FolderMp4,
303            source_url,
304            ..
305        } = action
306            && !source_url.is_empty()
307        {
308            *folder_cover_uses.entry(source_url.as_str()).or_default() += 1;
309        }
310    }
311    let shared_cover_urls: HashSet<&str> = folder_cover_uses
312        .into_iter()
313        .filter(|(_, uses)| *uses > 1)
314        .map(|(url, _)| url)
315        .collect();
316    let ctx = Ctx {
317        http,
318        fs,
319        ffmpeg,
320        clock,
321        opts,
322        by_id: &by_id,
323        by_path: &by_path,
324        synced,
325        cover_cache: &cover_cache,
326        cover_wanted: &cover_wanted,
327        shared_cover_urls: &shared_cover_urls,
328    };
329
330    let mut outcome = ExecOutcome::default();
331    // Destinations whose write has actually committed this run, gating old-path
332    // cleanup so a vacated sidecar/stem is kept only when a *successful* write
333    // also targets it (#142). Serial commit order makes this a clean prefix.
334    let mut committed: BTreeSet<String> = BTreeSet::new();
335
336    // Audio (Download/Reformat), fetched-artifact (WriteArtifact with no inline
337    // content), and stem (WriteStem) actions all split their work to maintain the
338    // CRITICAL DELETION-SAFETY INVARIANT: NO destination write, file removal, or
339    // manifest/album/playlist mutation happens off plan order:
340    //
341    // - concurrent preparers ([`prepare`](Ctx::prepare)) do only the slow,
342    //   side-effect-free work — fetch CDN/WAV bytes, transcode, tag — returning
343    //   bytes and the routing metadata the committer needs; and
344    // - a single serial committer below writes those bytes to the destination,
345    //   removes any superseded file, and records the manifest/album/playlist
346    //   entry, in strict plan-index order, interleaved with the remaining serial
347    //   actions.
348    //
349    // The shared client is the only `&mut` port and its API calls must stay
350    // ordered, so it rides behind an async mutex; each producer locks it only for
351    // the brief WAV-render calls and runs the heavy work unlocked. Prepares are
352    // yielded in plan order and bounded to `concurrency` in flight (and buffered),
353    // so at most about `concurrency` payloads are ever held in memory — never the
354    // whole library.
355    let client_lock = AsyncMutex::new(client);
356    let concurrency = opts.concurrency.max(1) as usize;
357    let ctx_ref = &ctx;
358    let client_lock_ref = &client_lock;
359    // Clip IDs already in the manifest before this plan runs. Per-clip
360    // artifacts and stems for these clips are prepared concurrently; for new
361    // clips (not yet in the manifest) the serial apply path handles them after
362    // the audio commit, so the owner-absent guard fires correctly.
363    let pre_clip_ids: HashSet<String> = manifest.entries.keys().cloned().collect();
364    // Clip IDs with a concurrent audio (Download/Reformat) action this run.
365    // Used to keep CoverJpg serial when its audio producer will cache the same
366    // cover URL (#89); preparing both concurrently races the remove vs insert.
367    let audio_clip_ids: HashSet<&str> = plan
368        .actions
369        .iter()
370        .filter_map(|action| match action {
371            Action::Download { clip, .. } | Action::Reformat { clip, .. } => Some(clip.id.as_str()),
372            _ => None,
373        })
374        .collect();
375    let mut prepares = stream::iter(
376        plan.actions
377            .iter()
378            .filter(|action| is_prepareable(action, &pre_clip_ids, &audio_clip_ids))
379            .map(|action| async move { ctx_ref.prepare(client_lock_ref, action).await }),
380    )
381    .buffered(concurrency);
382
383    for action in &plan.actions {
384        // Prepareable actions pull their pre-fetched bytes (yielded in plan order)
385        // and commit them here; every other action applies its own effect. Both the
386        // serial commit and the serial apply run in the same serial loop, so all
387        // destination and manifest effects keep the plan's order exactly.
388        let result = if is_prepareable(action, &pre_clip_ids, &audio_clip_ids) {
389            match prepares.next().await {
390                Some(Ok(Prepared::Audio(rendered))) => ctx.commit_audio(manifest, rendered),
391                Some(Ok(Prepared::Artifact(prepared))) => ctx.commit_artifact(
392                    manifest,
393                    albums,
394                    playlists,
395                    prepared,
396                    &mut tracked_paths,
397                    &committed,
398                ),
399                Some(Ok(Prepared::Stem(prepared))) => {
400                    ctx.commit_stem(manifest, prepared, &mut tracked_paths, &committed)
401                }
402                Some(Err(fail)) => Err(fail),
403                None => unreachable!("buffered yields one result per prepareable action"),
404            }
405        } else {
406            ctx.apply(
407                client_lock_ref,
408                action,
409                manifest,
410                albums,
411                playlists,
412                &mut tracked_paths,
413                &committed,
414            )
415            .await
416        };
417        match result {
418            Ok(effect) => {
419                outcome.record(effect);
420                // Record this action's destination now that its write succeeded.
421                // A later action vacating a path removes it only when no
422                // *committed* write also targets it; commit is strictly serial in
423                // plan order, so a planned-but-failed or not-yet-run write never
424                // protects a stale file from cleanup (#142).
425                if let Some(dest) = written_path(action) {
426                    committed.insert(dest.to_owned());
427                }
428            }
429            Err(fail) => {
430                let abort = abort_status(fail.class);
431                outcome.failures.push(Failure {
432                    clip_id: fail.clip_id,
433                    reason: fail.reason,
434                });
435                if let Some(status) = abort {
436                    // A systemic abort stops the run. Dropping the prepare stream
437                    // cancels any in-flight or completed-but-uncommitted producer;
438                    // because producers touch nothing on disk, the destination and
439                    // manifest are left exactly as the committed prefix wrote them,
440                    // with no untracked files and no removed-but-referenced file.
441                    outcome.status = status;
442                    break;
443                }
444            }
445        }
446    }
447    drop(prepares);
448
449    // Renames and deletes can leave an album directory empty; prune those ghost
450    // directories bottom-up. This runs on both the completed and the aborted
451    // paths, and is best-effort: a prune failure is only a missed tidy that the
452    // next run repeats, never a reason to fail the run.
453    let _ = fs.prune_empty_dirs("");
454    outcome
455}
456
457/// Whether an action has a slow, side-effect-free network or transcode phase
458/// that benefits from concurrent preparation. Audio actions (Download/Reformat)
459/// are always prepareable. A fetched artifact (WriteArtifact with no inline
460/// content) or stem write (WriteStem) is prepareable only when its owning clip
461/// was already in the manifest before this plan started: a new clip's sidecar
462/// cannot be prepared concurrently because its audio has not committed yet (the
463/// manifest entry doesn't exist at prepare time), so it falls through to the
464/// serial apply path which checks the manifest after the audio commits.
465///
466/// Two additional cases stay serial to preserve fetch-once dedup:
467///
468/// - [`FolderWebp`](ArtifactKind::FolderWebp) / [`FolderMp4`](ArtifactKind::FolderMp4):
469///   the `both` retention shares one `video_cover_url`; serial ordering lets
470///   the first fetch insert into `cover_cache` and the second drain it (#90).
471/// - [`CoverJpg`](ArtifactKind::CoverJpg) whose owner clip also has an audio
472///   action this run: the audio producer caches the cover bytes in `cover_cache`
473///   (#89); a concurrent CoverJpg drains the cache before the insert, causing a
474///   double fetch and a leaked entry.
475fn is_prepareable(
476    action: &Action,
477    pre_clip_ids: &HashSet<String>,
478    audio_clip_ids: &HashSet<&str>,
479) -> bool {
480    match action {
481        Action::Download { .. } | Action::Reformat { .. } => true,
482        Action::WriteArtifact {
483            kind,
484            owner_id,
485            content: None,
486            ..
487        } => {
488            if matches!(kind, ArtifactKind::FolderWebp | ArtifactKind::FolderMp4) {
489                return false;
490            }
491            if *kind == ArtifactKind::CoverJpg && audio_clip_ids.contains(owner_id.as_str()) {
492                return false;
493            }
494            !is_per_clip_kind(*kind) || pre_clip_ids.contains(owner_id.as_str())
495        }
496        Action::WriteStem { clip_id, .. } => pre_clip_ids.contains(clip_id.as_str()),
497        _ => false,
498    }
499}
500
501/// The destination path an action writes on success, or `None` for actions that
502/// write no file (skips, deletes). The serial committer records this once the
503/// action succeeds, so a later action vacating that same path keeps it rather
504/// than removing a freshly written file (#142, #76).
505fn written_path(action: &Action) -> Option<&str> {
506    match action {
507        Action::Download { path, .. }
508        | Action::Reformat { path, .. }
509        | Action::WriteArtifact { path, .. }
510        | Action::WriteStem { path, .. } => Some(path),
511        Action::Rename { to, .. }
512        | Action::MoveArtifact { to, .. }
513        | Action::MoveStem { to, .. } => Some(to),
514        _ => None,
515    }
516}
517
518/// A rendered-but-uncommitted audio result: the tagged bytes plus what the serial
519/// committer needs to place them. Produced concurrently and side-effect-free (no
520/// destination write, no removal, no manifest touch); [`commit_audio`] applies
521/// all of those in plan order.
522struct RenderedAudio {
523    clip_id: String,
524    path: String,
525    format: AudioFormat,
526    /// The superseded file to remove after the new one lands (a [`Reformat`]),
527    /// or `None` for a plain [`Download`].
528    from_path: Option<String>,
529    effect: Effect,
530    bytes: Vec<u8>,
531}
532
533/// A fetched-but-uncommitted artifact result: bytes for one
534/// [`WriteArtifact`](Action::WriteArtifact) with no inline content. Produced
535/// concurrently and side-effect-free; [`commit_artifact`](Ctx::commit_artifact)
536/// applies all filesystem and manifest/album/playlist effects in plan order.
537struct PreparedArtifact {
538    kind: ArtifactKind,
539    path: String,
540    hash: String,
541    owner_id: String,
542    bytes: Vec<u8>,
543}
544
545/// A fetched-but-uncommitted stem result: bytes for one
546/// [`WriteStem`](Action::WriteStem) action (including any WAV render + poll).
547/// Produced concurrently and side-effect-free; [`commit_stem`](Ctx::commit_stem)
548/// applies all filesystem and manifest effects in plan order.
549struct PreparedStem {
550    clip_id: String,
551    key: String,
552    path: String,
553    hash: String,
554    bytes: Vec<u8>,
555}
556
557/// The result of one concurrent preparation: audio, an artifact, or a stem.
558enum Prepared {
559    Audio(RenderedAudio),
560    Artifact(PreparedArtifact),
561    Stem(PreparedStem),
562}
563
564/// A cover image resolved for embedding: owned bytes plus their MIME type.
565struct EmbedCover {
566    bytes: Vec<u8>,
567    mime: &'static str,
568}
569
570impl EmbedCover {
571    /// Borrow as the [`Cover`] the taggers take.
572    fn as_cover(&self) -> Cover<'_> {
573        Cover {
574            bytes: &self.bytes,
575            mime: self.mime,
576        }
577    }
578}
579
580/// What an applied action did, for the outcome counters.
581enum Effect {
582    Downloaded,
583    Reformatted,
584    Retagged,
585    Renamed,
586    Deleted,
587    Skipped,
588    ArtifactWritten,
589    ArtifactDeleted,
590}
591
592/// How a failure should be handled (SYNC-17).
593#[derive(Debug, Clone, Copy)]
594enum Class {
595    /// Stop the account run; do not retry.
596    Auth,
597    /// Stop the account run: a full disk is systemic, like auth, so aborting
598    /// beats skipping every remaining clip (each of which would first burn a
599    /// server-side WAV-render budget before failing the same way).
600    Disk,
601    /// Retry a bounded number of times, then record and skip.
602    Transient,
603    /// Record and skip immediately.
604    Permanent,
605}
606
607/// A classified action failure attributed to a clip.
608struct Fail {
609    class: Class,
610    clip_id: String,
611    reason: String,
612}
613
614/// The run-ending status for a failure class, or `None` when the failure is
615/// per-clip and the run continues.
616fn abort_status(class: Class) -> Option<RunStatus> {
617    match class {
618        Class::Auth => Some(RunStatus::AuthAborted),
619        Class::Disk => Some(RunStatus::DiskFull),
620        Class::Transient | Class::Permanent => None,
621    }
622}
623
624fn auth_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
625    Fail {
626        class: Class::Auth,
627        clip_id: clip_id.into(),
628        reason: reason.into(),
629    }
630}
631
632fn transient_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
633    Fail {
634        class: Class::Transient,
635        clip_id: clip_id.into(),
636        reason: reason.into(),
637    }
638}
639
640fn permanent_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
641    Fail {
642        class: Class::Permanent,
643        clip_id: clip_id.into(),
644        reason: reason.into(),
645    }
646}
647
648fn disk_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
649    Fail {
650        class: Class::Disk,
651        clip_id: clip_id.into(),
652        reason: reason.into(),
653    }
654}
655
656/// Whether an artifact kind is album-scoped folder art (owned by a root id and
657/// recorded on the album store) rather than a per-clip sidecar (recorded on the
658/// manifest).
659fn is_album_kind(kind: ArtifactKind) -> bool {
660    matches!(
661        kind,
662        ArtifactKind::FolderJpg | ArtifactKind::FolderWebp | ArtifactKind::FolderMp4
663    )
664}
665
666/// True for the library-scoped playlist artifact, routed to the playlist store.
667fn is_playlist_kind(kind: ArtifactKind) -> bool {
668    matches!(kind, ArtifactKind::Playlist)
669}
670
671/// True for a per-song sidecar (`cover.jpg`/`cover.webp`), whose write requires
672/// the owning clip's manifest entry. Album and playlist kinds are keyed by a
673/// root/playlist id that is deliberately absent from the manifest.
674fn is_per_clip_kind(kind: ArtifactKind) -> bool {
675    matches!(
676        kind,
677        ArtifactKind::CoverJpg
678            | ArtifactKind::CoverWebp
679            | ArtifactKind::DetailsTxt
680            | ArtifactKind::LyricsTxt
681            | ArtifactKind::Lrc
682            | ArtifactKind::VideoMp4
683    )
684}
685
686/// Recover a playlist's display name from its `.m3u8` path's file stem.
687///
688/// The path is `<sanitised name>.m3u8` at the library root, so the stem is the
689/// sanitised name. Reconcile only ever reads a playlist's `path` and `hash`, so
690/// this recovered name is a convenience for humans and its lossiness (the
691/// sanitiser is not reversible) never affects a decision.
692fn playlist_name_from_path(path: &str) -> String {
693    std::path::Path::new(path)
694        .file_stem()
695        .map(|stem| stem.to_string_lossy().into_owned())
696        .unwrap_or_default()
697}
698
699/// A classified fetch failure, not yet attributed to a clip.
700struct FetchError {
701    class: Class,
702    reason: String,
703    retry_after: Option<Duration>,
704}
705
706impl FetchError {
707    fn transient(reason: impl Into<String>, retry_after: Option<Duration>) -> Self {
708        Self {
709            class: Class::Transient,
710            reason: reason.into(),
711            retry_after,
712        }
713    }
714
715    fn permanent(reason: impl Into<String>) -> Self {
716        Self {
717            class: Class::Permanent,
718            reason: reason.into(),
719            retry_after: None,
720        }
721    }
722
723    fn attribute(self, clip_id: &str) -> Fail {
724        Fail {
725            class: self.class,
726            clip_id: clip_id.to_owned(),
727            reason: self.reason,
728        }
729    }
730}
731
732/// The shared, read-only context threaded through every action handler.
733struct Ctx<'a, H, F, G, C> {
734    http: &'a H,
735    fs: &'a F,
736    ffmpeg: &'a G,
737    clock: &'a C,
738    opts: &'a ExecOptions,
739    by_id: &'a HashMap<&'a str, &'a Desired>,
740    by_path: &'a HashMap<&'a str, &'a Desired>,
741    /// This run's fetched aligned (synced) lyrics, keyed by clip id. Audio
742    /// tagging reads a clip's entry to embed an MP3 `SYLT` frame and the plain
743    /// lyric text; a clip absent here is tagged exactly as before. Populated by
744    /// the caller (the fetch is IO), so the engine stays free of direct IO.
745    synced: &'a HashMap<String, AlignedLyrics>,
746    /// Static cover art the audio producer already fetched to embed in the tag,
747    /// keyed by CDN URL, so the matching per-song `.jpg` sidecar reuses it rather
748    /// than fetching the same image again (#89). Only URLs a `CoverJpg` sidecar
749    /// will fetch are inserted (see `cover_wanted`) and each is removed on use, so
750    /// the map stays bounded to the clips in flight. A plain mutex guards it: the
751    /// concurrent producers only ever insert, and the lock is never held across an
752    /// await.
753    cover_cache: &'a Mutex<HashMap<String, Vec<u8>>>,
754    /// The cover URLs a `CoverJpg` sidecar will fetch this run. The producer caches
755    /// a cover only when its URL is here, so a clip whose cover is embedded but
756    /// never written as a sidecar leaves no bytes stranded in `cover_cache`.
757    cover_wanted: &'a HashSet<&'a str>,
758    /// Album video-cover source URLs fetched by more than one folder artifact
759    /// this run. The `both` retention derives `cover.webp` (transcoded) and
760    /// `cover.mp4` (raw) from the SAME `video_cover_url`; the first fetch caches
761    /// the raw source here so the sibling drains it instead of re-fetching (#90
762    /// reuses the #89 fetch-once path). `FolderWebp` sorts before `FolderMp4`, so
763    /// the raw source is always cached before the raw sidecar reads it.
764    shared_cover_urls: &'a HashSet<&'a str>,
765}
766
767impl<H, F, G, C> Ctx<'_, H, F, G, C>
768where
769    H: Http,
770    F: Filesystem,
771    G: Ffmpeg,
772    C: Clock,
773{
774    /// Apply one serial action, returning what it did or why it failed.
775    ///
776    /// Audio actions ([`Download`](Action::Download) and
777    /// [`Reformat`](Action::Reformat)) are always prepared concurrently and never
778    /// reach here. Fetched [`WriteArtifact`](Action::WriteArtifact) and
779    /// [`WriteStem`](Action::WriteStem) actions reach here only when their owning
780    /// clip was NOT in the manifest at plan start (new clips); those for existing
781    /// clips are prepared concurrently and commit through the stream path.
782    #[allow(clippy::too_many_arguments)]
783    async fn apply(
784        &self,
785        client_lock: &ClientLock<'_, C>,
786        action: &Action,
787        manifest: &mut Manifest,
788        albums: &mut BTreeMap<String, AlbumArt>,
789        playlists: &mut BTreeMap<String, PlaylistState>,
790        tracked_paths: &mut HashMap<String, u32>,
791        committed: &BTreeSet<String>,
792    ) -> Result<Effect, Fail> {
793        match action {
794            Action::Download { .. } | Action::Reformat { .. } => {
795                unreachable!("audio actions are prepared concurrently")
796            }
797            Action::Retag {
798                clip,
799                lineage,
800                path,
801            } => self.retag(manifest, clip, lineage, path).await,
802            Action::Rename { from, to } => self.rename(manifest, from, to),
803            Action::Delete { path, clip_id } => self.delete(manifest, path, clip_id),
804            Action::Skip { clip_id } => {
805                self.refresh_preserve(manifest, clip_id);
806                Ok(Effect::Skipped)
807            }
808            Action::WriteArtifact {
809                kind,
810                path,
811                source_url,
812                hash,
813                owner_id,
814                content,
815            } => {
816                // Inline text sidecars carry their body in the plan.
817                // Fetched artifacts for clips already in the manifest are prepared
818                // concurrently and never reach here. Fetched artifacts for new clips
819                // (owner not in the manifest at plan start) are handled here in the
820                // serial path, with the owner-absent guard fired before any fetch.
821                let bytes = match content.as_deref() {
822                    Some(text) => text.as_bytes().to_vec(),
823                    None => {
824                        if is_per_clip_kind(*kind) && manifest.get(owner_id).is_none() {
825                            // Owner never landed (audio failed or never existed).
826                            // Drain any stale cache entry so it doesn't outlive
827                            // this clip, then skip without fetching.
828                            self.cover_cache_lock().remove(source_url);
829                            return Ok(Effect::Skipped);
830                        }
831                        self.artifact_bytes(*kind, source_url, owner_id).await?
832                    }
833                };
834                self.commit_artifact(
835                    manifest,
836                    albums,
837                    playlists,
838                    PreparedArtifact {
839                        kind: *kind,
840                        path: path.clone(),
841                        hash: hash.clone(),
842                        owner_id: owner_id.clone(),
843                        bytes,
844                    },
845                    tracked_paths,
846                    committed,
847                )
848            }
849            Action::DeleteArtifact {
850                kind,
851                path,
852                owner_id,
853            } => self.delete_artifact(manifest, albums, playlists, *kind, path, owner_id),
854            Action::MoveArtifact {
855                kind,
856                from,
857                to,
858                source_url,
859                hash,
860                owner_id,
861            } => {
862                self.move_artifact(
863                    manifest,
864                    albums,
865                    playlists,
866                    *kind,
867                    from,
868                    to,
869                    source_url,
870                    hash,
871                    owner_id,
872                    tracked_paths,
873                    committed,
874                )
875                .await
876            }
877            Action::WriteStem {
878                clip_id,
879                key,
880                stem_id,
881                path,
882                source_url,
883                format,
884                hash,
885            } => {
886                // Stems for clips already in the manifest at plan start are
887                // prepared concurrently and never reach here. Stems for new
888                // clips (owner not yet in the manifest) are fetched here in the
889                // serial path, after the audio commit, with the same owner-absent
890                // guard as the old serial write_stem.
891                if manifest.get(clip_id).is_none() {
892                    return Ok(Effect::Skipped);
893                }
894                let bytes = self
895                    .fetch_stem_bytes(client_lock, clip_id, stem_id, source_url, *format)
896                    .await?;
897                self.commit_stem(
898                    manifest,
899                    PreparedStem {
900                        clip_id: clip_id.clone(),
901                        key: key.clone(),
902                        path: path.clone(),
903                        hash: hash.clone(),
904                        bytes,
905                    },
906                    tracked_paths,
907                    committed,
908                )
909            }
910            Action::DeleteStem { clip_id, key, path } => {
911                self.delete_stem(manifest, clip_id, key, path)
912            }
913            Action::MoveStem {
914                clip_id,
915                key,
916                stem_id,
917                from,
918                to,
919                source_url,
920                format,
921                hash,
922            } => {
923                self.move_stem(
924                    client_lock,
925                    manifest,
926                    clip_id,
927                    key,
928                    stem_id,
929                    from,
930                    to,
931                    source_url,
932                    *format,
933                    hash,
934                    tracked_paths,
935                    committed,
936                )
937                .await
938            }
939        }
940    }
941
942    /// Move the file and update the entry's path (and protection).
943    fn rename(&self, manifest: &mut Manifest, from: &str, to: &str) -> Result<Effect, Fail> {
944        let label = self
945            .by_path
946            .get(to)
947            .map(|d| d.clip.id.clone())
948            .unwrap_or_else(|| to.to_owned());
949        self.fs.rename(from, to).map_err(|err| {
950            if err.is_out_of_space() {
951                disk_fail(label, "disk full: no space left to rename")
952            } else {
953                permanent_fail(label, format!("rename failed: {err}"))
954            }
955        })?;
956
957        let clip_id = self.by_path.get(to).map(|d| d.clip.id.clone()).or_else(|| {
958            manifest
959                .entries
960                .iter()
961                .find(|(_, entry)| entry.path == from)
962                .map(|(id, _)| id.clone())
963        });
964        if let Some(id) = clip_id
965            && let Some(entry) = manifest.entries.get_mut(&id)
966        {
967            entry.path = to.to_owned();
968            if let Some(d) = self.by_path.get(to) {
969                entry.preserve = preserve_for(d);
970            }
971        }
972        Ok(Effect::Renamed)
973    }
974
975    /// Remove the file and drop the manifest entry.
976    fn delete(&self, manifest: &mut Manifest, path: &str, clip_id: &str) -> Result<Effect, Fail> {
977        self.fs
978            .remove(path)
979            .map_err(|err| permanent_fail(clip_id, format!("delete failed: {err}")))?;
980        manifest.remove(clip_id);
981        Ok(Effect::Deleted)
982    }
983
984    /// Classify a core error from the authenticated WAV flow. On a transient
985    /// class within budget, back off through the [`Clock`] and return `None` to
986    /// retry; otherwise return the terminal [`Fail`].
987    async fn retry_core(&self, id: &str, err: Error, attempt: &mut u32) -> Option<Fail> {
988        let fail = classify_core(id, err);
989        if matches!(fail.class, Class::Transient) && *attempt < self.opts.max_retries {
990            self.clock.sleep(backoff_delay(*attempt, None)).await;
991            *attempt += 1;
992            None
993        } else {
994            Some(fail)
995        }
996    }
997
998    /// GET `url`, retrying transient failures with backoff, verifying size.
999    async fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, FetchError> {
1000        let mut attempt: u32 = 0;
1001        loop {
1002            let result = self.http.send(HttpRequest::get(url)).await;
1003            match classify_response(result) {
1004                Ok(body) => return Ok(body),
1005                Err(err) => {
1006                    if matches!(err.class, Class::Transient) && attempt < self.opts.max_retries {
1007                        let delay = backoff_delay(attempt, err.retry_after);
1008                        self.clock.sleep(delay).await;
1009                        attempt += 1;
1010                        continue;
1011                    }
1012                    return Err(err);
1013                }
1014            }
1015        }
1016    }
1017
1018    /// Write `bytes` atomically, then confirm the on-disk size (SYNC-13/14).
1019    fn write_verify(&self, clip_id: &str, path: &str, bytes: &[u8]) -> Result<u64, Fail> {
1020        self.fs.write_atomic(path, bytes).map_err(|err| {
1021            if err.is_out_of_space() {
1022                disk_fail(clip_id, format!("disk full: no space left to write {path}"))
1023            } else {
1024                permanent_fail(clip_id, format!("write failed: {err}"))
1025            }
1026        })?;
1027        match self.fs.metadata(path) {
1028            Some(stat) if stat.size == bytes.len() as u64 => Ok(stat.size),
1029            Some(stat) => Err(permanent_fail(
1030                clip_id,
1031                format!("wrote {} bytes, expected {}", stat.size, bytes.len()),
1032            )),
1033            None => Ok(bytes.len() as u64),
1034        }
1035    }
1036
1037    /// Build the manifest entry for a freshly written file.
1038    fn entry(&self, clip_id: &str, path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
1039        match self.by_id.get(clip_id) {
1040            Some(d) => manifest_entry(d, size),
1041            None => ManifestEntry {
1042                path: path.to_owned(),
1043                format,
1044                size,
1045                ..ManifestEntry::default()
1046            },
1047        }
1048    }
1049}
1050
1051/// Build a manifest entry from the desired record (SYNC-8 preserve rule).
1052fn manifest_entry(d: &Desired, size: u64) -> ManifestEntry {
1053    ManifestEntry {
1054        path: d.path.clone(),
1055        format: d.format,
1056        meta_hash: d.meta_hash.clone(),
1057        art_hash: d.art_hash.clone(),
1058        size,
1059        preserve: preserve_for(d),
1060        ..Default::default()
1061    }
1062}
1063
1064/// Whether a written entry must be preserved across runs: held by any copy
1065/// source, or private. The reconcile delete guard reads this marker later.
1066fn preserve_for(d: &Desired) -> bool {
1067    d.private || d.modes.contains(&SourceMode::Copy)
1068}
1069
1070/// Classify one HTTP result into bytes or a [`FetchError`] (SYNC-14/17).
1071fn classify_response(
1072    result: Result<crate::http::HttpResponse, crate::http::TransportError>,
1073) -> Result<Vec<u8>, FetchError> {
1074    let response = match result {
1075        Ok(response) => response,
1076        Err(err) => {
1077            return Err(FetchError::transient(
1078                format!("transport error: {err}"),
1079                None,
1080            ));
1081        }
1082    };
1083    match response.status {
1084        200..=299 => {
1085            if let Some(expected) = content_length(&response) {
1086                let actual = response.body.len() as u64;
1087                if actual != expected {
1088                    return Err(FetchError::transient(
1089                        format!("truncated download: {actual} of {expected} bytes"),
1090                        None,
1091                    ));
1092                }
1093            }
1094            Ok(response.body)
1095        }
1096        401 | 403 => Err(FetchError::transient(
1097            format!("download rejected: status {}", response.status),
1098            None,
1099        )),
1100        408 => Err(FetchError::transient("request timed out", None)),
1101        429 => Err(FetchError::transient(
1102            "rate limited",
1103            retry_after(&response),
1104        )),
1105        500..=599 => Err(FetchError::transient(
1106            format!("server error {}", response.status),
1107            None,
1108        )),
1109        status => Err(FetchError::permanent(format!(
1110            "download failed: status {status}"
1111        ))),
1112    }
1113}
1114
1115/// Map a core [`Error`] from the authenticated WAV flow to a [`Fail`].
1116fn classify_core(id: &str, err: Error) -> Fail {
1117    let reason = err.to_string();
1118    match err {
1119        Error::Auth(_) => auth_fail(id, reason),
1120        Error::RateLimited { .. } | Error::Connection(_) => transient_fail(id, reason),
1121        Error::Api(_)
1122        | Error::BadRequest(_)
1123        | Error::NotFound(_)
1124        | Error::Tag(_)
1125        | Error::Config(_)
1126        | Error::Refused(_) => permanent_fail(id, reason),
1127    }
1128}
1129
1130/// The provider-reported body size from `Content-Length`, if present and valid.
1131fn content_length(response: &crate::http::HttpResponse) -> Option<u64> {
1132    response.header("content-length")?.trim().parse().ok()
1133}
1134
1135#[cfg(test)]
1136mod tests;