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;