Skip to main content

suno_core/
executor.rs

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