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;