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