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