suno_core/reconcile.rs
1//! The pure reconcile engine: it decides what to download, retag, rename,
2//! reformat, and delete.
3//!
4//! This is the highest-risk module in the project. It is intentionally pure:
5//! no IO, no clock, no network. The caller supplies every input (the prior
6//! [`Manifest`], the desired selection, the on-disk probe for each manifest
7//! path, and the per-source enumeration status) and [`reconcile`] returns a
8//! [`Plan`] that the CLI executes later. The plan is itself the dry-run
9//! recording, so there is never an `if dry_run` branch.
10//!
11//! Deletion safety is paramount. The guards encoded here are:
12//!
13//! - SYNC-8: a clip held by any `Copy` source is never deleted; copy and
14//! archive always win. This holds both for the clip's current selection
15//! (`Desired::modes`) and across runs through the persisted
16//! [`ManifestEntry::preserve`] marker, so a copy-held or private clip whose
17//! source is later deselected, or whose copy listing fails, is still kept.
18//! - SYNC-9: never delete on an empty, failed, partial, or truncated listing.
19//! Deletion is allowed only when every selected source (mirror and copy) was
20//! fully enumerated, and only when at least one mirror source was selected.
21//! - SYNC-10: a manifest path that is missing or zero length on disk is treated
22//! as missing and re-downloaded, even when its hashes still match.
23//! - SYNC-12: a clip trashed in Suno is removed from the source and its local
24//! file is deleted under the same enumeration guard; a private or copy-held
25//! clip is kept.
26//!
27//! Every `Delete`, whether for a trashed clip or an absent orphan, flows through
28//! one guard ([`delete_action`]): a manifest entry must exist with a non-empty,
29//! non-preserved path, deletion must be allowed for the run, and the clip must
30//! not be copy-held or private in the current selection. A final pass suppresses
31//! any `Delete` whose path collides with a file another action writes this run.
32
33use std::collections::BTreeMap;
34use std::collections::BTreeSet;
35use std::collections::HashMap;
36use std::collections::HashSet;
37
38use crate::album_art::{AlbumArt, PlaylistState};
39use crate::hash::{art_hash, art_url_hash, webp_art_hash};
40use crate::lineage::LineageContext;
41use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
42use crate::model::Clip;
43use crate::pathkey::{canonical_path_key, same_fs_path};
44use crate::vocab::{ArtifactKind, AudioFormat, SourceMode, StemFormat, WebpEncodeSettings};
45
46/// One desired clip in the current selection.
47///
48/// The caller has already deduped per account and resolved naming and format,
49/// so each entry is the authoritative target state for one clip. `modes` lists
50/// every selected source that currently holds the clip, so a clip can be held
51/// by a `Mirror` and a `Copy` source at once.
52#[derive(Debug, Clone, PartialEq)]
53pub struct Desired {
54 /// The clip itself, carried so actions can be executed without a re-fetch.
55 pub clip: Clip,
56 /// The clip's resolved lineage, carried so the executor tags with the same
57 /// root/parent/album that drove naming and the change hash.
58 pub lineage: LineageContext,
59 /// Resolved relative target path for the file.
60 pub path: String,
61 /// Resolved target format.
62 pub format: AudioFormat,
63 /// Hash of the clip's tag-bearing metadata.
64 pub meta_hash: String,
65 /// Hash of the clip's cover art.
66 pub art_hash: String,
67 /// Every selected source that currently holds this clip.
68 pub modes: Vec<SourceMode>,
69 /// True when the clip is trashed in Suno (removed from the source).
70 pub trashed: bool,
71 /// True when the clip is private; private clips are always kept.
72 pub private: bool,
73 /// The clip's desired external artifacts (cover.jpg, cover.webp, ...).
74 ///
75 /// This is the authoritative target set of sidecars for the clip: an
76 /// artifact present here is written when missing or changed, and a manifest
77 /// artifact absent here is a removed kind and reconciled for deletion. It
78 /// defaults to empty; later phases populate it (P7 covers per-song art), so
79 /// for now every production caller passes an empty vec and only tests set it.
80 pub artifacts: Vec<DesiredArtifact>,
81 /// The clip's desired stem set, when stems are being mirrored.
82 ///
83 /// Tri-state, encoding stem deletion safety:
84 /// - `None` — the stem listing is not authoritative this run (the feature is
85 /// off, `has_stem` is false/absent, or the listing was disabled, failed,
86 /// partial, `400`, or otherwise indeterminate). Existing local stems are
87 /// KEPT and never deleted; a paging error is never read as "no stems".
88 /// - `Some(set)` — an AUTHORITATIVE, fully enumerated set. Stems missing from
89 /// it are written, drifted ones rewritten, and a tracked stem absent from
90 /// it is delete-reconciled through the shared deletion gate.
91 ///
92 /// Defaults to `None`, so any caller that does not mirror stems leaves local
93 /// stems untouched.
94 pub stems: Option<Vec<DesiredStem>>,
95}
96
97/// One desired stem for a clip.
98///
99/// Carries the stable per-stem key (the manifest map key), where the stem file
100/// should live, where to fetch it, and a source change hash that drives rewrite
101/// detection against the manifest.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct DesiredStem {
104 /// The stable key for this stem (server stem id, else label), unique within
105 /// the clip. This is the manifest map key, so add/rewrite/remove target the
106 /// right stem without disturbing the others.
107 pub key: String,
108 /// The stem's own server clip id, used to render its lossless WAV through the
109 /// free `convert_wav` flow. Empty only for a degenerate listing with no id,
110 /// in which case the stem is stored as MP3 (WAV needs an id to render).
111 pub stem_id: String,
112 /// Resolved relative target path for the stem file, inside the song's
113 /// `.stems` sub-folder. Its extension matches [`format`](Self::format).
114 pub path: String,
115 /// The public CDN MP3 URL for the stem (a free GET). Downloaded directly for
116 /// an MP3 stem; for a WAV stem it is the source-of-truth for the rewrite
117 /// hash while the bytes come from the rendered WAV.
118 pub source_url: String,
119 /// The container the stem is stored in (WAV by default, or MP3). Stems are
120 /// always stored RAW; this is never FLAC.
121 pub format: StemFormat,
122 /// Source change hash; a change from the manifest triggers a rewrite.
123 pub hash: String,
124}
125
126/// One desired external artifact for a clip.
127///
128/// Carries where the sidecar should live, where to fetch it, and the content or
129/// source change hash that drives rewrite detection against the manifest.
130#[derive(Debug, Clone, PartialEq)]
131pub struct DesiredArtifact {
132 /// Which artifact class this is.
133 pub kind: ArtifactKind,
134 /// Resolved relative target path for the sidecar.
135 pub path: String,
136 /// The URL the sidecar's bytes are fetched from. Empty for a generated
137 /// artifact that carries its body inline via `content`.
138 pub source_url: String,
139 /// Content/source change hash; a change from the manifest triggers a write.
140 pub hash: String,
141 /// Inline body for a *generated* artifact (the text sidecars). When `Some`,
142 /// the executor writes these exact bytes and never touches the network;
143 /// fetched artifacts (covers) leave it `None`.
144 pub content: Option<String>,
145}
146
147/// The desired folder-art target for one album (one stable root id).
148///
149/// Folder art is album-scoped, so it is reconciled against the album store
150/// ([`AlbumArt`]) rather than the per-clip manifest. Each present kind carries a
151/// [`DesiredArtifact`] whose `hash` is the *content* hash of the chosen art, not
152/// the source clip id: a most-played flip that yields the same art content is a
153/// no-op (HARDENING H1). A `None` kind means the album desires no art of that
154/// kind this run (no art-bearing clip, no animated source, or the feature is
155/// off), which delete-reconciles any stored art of that kind under the shared
156/// deletion gate.
157#[derive(Debug, Clone, PartialEq)]
158pub struct AlbumDesired {
159 /// The album's stable key: the resolved root ancestor id (HARDENING H2).
160 pub root_id: String,
161 /// The desired static `folder.jpg`, from the most-played art-bearing variant.
162 pub folder_jpg: Option<DesiredArtifact>,
163 /// The desired animated `cover.webp`, from the first-created animated variant.
164 pub folder_webp: Option<DesiredArtifact>,
165 /// The desired raw `cover.mp4`: the same variant's `video_cover_url` kept
166 /// verbatim (no transcode). `None` unless raw cover retention is enabled.
167 pub folder_mp4: Option<DesiredArtifact>,
168}
169
170/// The desired `.m3u8` target for one playlist (a Suno playlist, or the
171/// synthetic liked feed).
172///
173/// A playlist's body is *generated* from this run's rendered audio paths, not
174/// fetched, so it is reconciled by a single content [`hash`](Self::hash) over
175/// the full rendered text (HARDENING B1: the name, member order, and every
176/// member's path/title/duration feed it). The rendered body is carried inline
177/// in [`content`](Self::content) so the executor writes it without a network
178/// round-trip. [`path`](Self::path) is `<sanitised name>.m3u8` at the library
179/// root, tracked so a rename removes the stale file.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct PlaylistDesired {
182 /// The playlist's stable key: its Suno id (the synthetic `"liked"` id for
183 /// the liked feed).
184 pub id: String,
185 /// The playlist's display name, as shown on Suno.
186 pub name: String,
187 /// The `.m3u8` file's library-relative path (`<sanitised name>.m3u8`).
188 pub path: String,
189 /// The fully rendered `.m3u8` body, written inline (no fetch).
190 pub content: String,
191 /// The content hash over `content`, driving rewrite detection.
192 pub hash: String,
193}
194
195/// The caller's on-disk probe of one manifest path.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
197pub struct LocalFile {
198 /// Whether the file exists on disk.
199 pub exists: bool,
200 /// Size of the file in bytes (zero when absent).
201 pub size: u64,
202}
203
204/// Per-source enumeration status for one selected source.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub struct SourceStatus {
207 /// The source's mode.
208 pub mode: SourceMode,
209 /// Whether this source was completely and successfully enumerated.
210 pub fully_enumerated: bool,
211}
212
213/// One executable step in a [`Plan`].
214#[derive(Debug, Clone, PartialEq)]
215pub enum Action {
216 /// Download the clip to `path` in `format` (new, missing, or zero length).
217 Download {
218 clip: Clip,
219 lineage: LineageContext,
220 path: String,
221 format: AudioFormat,
222 },
223 /// Render the clip to `path` in `to`, replacing the prior `from` rendering.
224 ///
225 /// A format change always changes the file extension, so the prior file at
226 /// `from_path` is a different path that must be removed once the new file is
227 /// written; carrying it keeps the plan a full account of disk mutations.
228 Reformat {
229 clip: Clip,
230 path: String,
231 from_path: String,
232 from: AudioFormat,
233 to: AudioFormat,
234 },
235 /// Re-tag the existing file at `path` to match current metadata or art.
236 Retag {
237 clip: Clip,
238 lineage: LineageContext,
239 path: String,
240 },
241 /// Move the file from one relative path to another.
242 Rename { from: String, to: String },
243 /// Delete the local file for a clip that has left every mirror source.
244 Delete { path: String, clip_id: String },
245 /// Take no action for a clip; recorded so the plan is a full account.
246 Skip { clip_id: String },
247 /// Write (or rewrite) an external sidecar artifact for its owning clip.
248 ///
249 /// Emitted when the manifest lacks the artifact or its stored hash differs
250 /// from `hash`. A write is additive and never gated by deletion safety.
251 ///
252 /// `content` carries an inline body for *generated* artifacts (playlists):
253 /// when `Some`, the executor writes those exact bytes atomically and skips
254 /// the network entirely; when `None`, it fetches (and transcodes) from
255 /// `source_url` as before. A fetched artifact leaves `source_url` set and
256 /// `content` `None`; a generated one leaves `source_url` empty and `content`
257 /// `Some`.
258 WriteArtifact {
259 kind: ArtifactKind,
260 path: String,
261 source_url: String,
262 hash: String,
263 owner_id: String,
264 content: Option<String>,
265 },
266 /// Relocate a fetched sidecar from `from` to `to` without re-fetching, when
267 /// only its path drifts (a retitle) and its content hash is unchanged.
268 ///
269 /// The executor renames the existing file (a local move), so a retitle no
270 /// longer re-downloads a cover or re-transcodes an animated WebP. If the old
271 /// file has vanished by commit time, or the rename fails, the executor falls
272 /// back to the ordinary fetch-and-write at `to` using `source_url`. Never
273 /// emitted for inline-content kinds (playlists, text), where a rewrite from
274 /// the in-hand bytes is already free.
275 MoveArtifact {
276 kind: ArtifactKind,
277 from: String,
278 to: String,
279 source_url: String,
280 hash: String,
281 owner_id: String,
282 },
283 /// Delete an external sidecar artifact (a removed kind, or a co-deleted
284 /// sidecar of a clip whose audio is being deleted).
285 ///
286 /// Only ever emitted through [`delete_artifact_action`], which shares the
287 /// audio `can_delete` gate and the owning entry's `preserve` marker, so a
288 /// sidecar is never removed on an incomplete listing or for a preserved clip.
289 DeleteArtifact {
290 kind: ArtifactKind,
291 path: String,
292 owner_id: String,
293 },
294 /// Write (or rewrite) one stem file for its owning clip.
295 ///
296 /// Emitted when the clip's manifest stem map lacks this `key`, or its stored
297 /// hash or path drifts (the song moved, or the stem format changed). A write
298 /// is additive and never gated by deletion safety. Stems are stored RAW in
299 /// their native container and never transcoded to FLAC: a `Wav` stem is
300 /// rendered through the free `convert_wav` flow keyed on `stem_id`, an `Mp3`
301 /// stem is fetched straight from `source_url`. `key` is the stable stem key,
302 /// so the executor updates the right slot in the clip's keyed stem map.
303 WriteStem {
304 clip_id: String,
305 key: String,
306 stem_id: String,
307 path: String,
308 source_url: String,
309 format: StemFormat,
310 hash: String,
311 },
312 /// Relocate a stem file from `from` to `to` without re-rendering, when only
313 /// its path drifts (a retitle) and its content hash is unchanged.
314 ///
315 /// The executor renames the existing file, so a retitle no longer re-renders
316 /// a WAV stem through `convert_wav` or re-fetches an MP3 stem. If the old
317 /// file has vanished by commit time, or the rename fails, the executor falls
318 /// back to the ordinary fetch-and-write at `to`.
319 MoveStem {
320 clip_id: String,
321 key: String,
322 stem_id: String,
323 from: String,
324 to: String,
325 source_url: String,
326 format: StemFormat,
327 hash: String,
328 },
329 /// Delete one stem file and clear its slot in the clip's keyed stem map.
330 ///
331 /// Only ever emitted through [`delete_stem_action`], which shares the audio
332 /// `can_delete` gate and the owning entry's `preserve` marker, so a stem is
333 /// never removed on an incomplete listing or for a preserved clip. Emitted
334 /// either when an AUTHORITATIVE stem listing no longer contains `key`, or as
335 /// a co-delete when the owning clip's audio is deleted (so the `.stems`
336 /// folder is never orphaned).
337 DeleteStem {
338 clip_id: String,
339 key: String,
340 path: String,
341 },
342}
343
344/// The reconcile output: an ordered, deterministic list of actions.
345///
346/// The plan is the dry-run recording. The convenience counts let the CLI
347/// summarise a run without re-walking the action list by hand.
348#[derive(Debug, Clone, Default, PartialEq)]
349pub struct Plan {
350 /// The actions, in stable order.
351 pub actions: Vec<Action>,
352}
353
354impl Plan {
355 /// Total number of actions.
356 pub fn len(&self) -> usize {
357 self.actions.len()
358 }
359
360 /// True when there are no actions.
361 pub fn is_empty(&self) -> bool {
362 self.actions.is_empty()
363 }
364
365 /// Number of [`Action::Download`] actions.
366 pub fn downloads(&self) -> usize {
367 self.count(|a| matches!(a, Action::Download { .. }))
368 }
369
370 /// Number of [`Action::Reformat`] actions.
371 pub fn reformats(&self) -> usize {
372 self.count(|a| matches!(a, Action::Reformat { .. }))
373 }
374
375 /// Number of [`Action::Retag`] actions.
376 pub fn retags(&self) -> usize {
377 self.count(|a| matches!(a, Action::Retag { .. }))
378 }
379
380 /// Number of [`Action::Rename`] actions.
381 pub fn renames(&self) -> usize {
382 self.count(|a| matches!(a, Action::Rename { .. }))
383 }
384
385 /// Number of [`Action::Delete`] actions.
386 pub fn deletes(&self) -> usize {
387 self.count(|a| matches!(a, Action::Delete { .. }))
388 }
389
390 /// Number of [`Action::Skip`] actions.
391 pub fn skips(&self) -> usize {
392 self.count(|a| matches!(a, Action::Skip { .. }))
393 }
394
395 /// Number of [`Action::WriteArtifact`] actions.
396 pub fn artifact_writes(&self) -> usize {
397 self.count(|a| matches!(a, Action::WriteArtifact { .. }))
398 }
399
400 /// Number of [`Action::DeleteArtifact`] actions.
401 pub fn artifact_deletes(&self) -> usize {
402 self.count(|a| matches!(a, Action::DeleteArtifact { .. }))
403 }
404
405 /// Number of [`Action::WriteStem`] actions.
406 pub fn stem_writes(&self) -> usize {
407 self.count(|a| matches!(a, Action::WriteStem { .. }))
408 }
409
410 /// Number of [`Action::MoveArtifact`] actions (a sidecar relocated without a
411 /// re-fetch).
412 pub fn artifact_moves(&self) -> usize {
413 self.count(|a| matches!(a, Action::MoveArtifact { .. }))
414 }
415
416 /// Number of [`Action::MoveStem`] actions (a stem relocated without a
417 /// re-render).
418 pub fn stem_moves(&self) -> usize {
419 self.count(|a| matches!(a, Action::MoveStem { .. }))
420 }
421
422 /// Number of [`Action::DeleteStem`] actions.
423 pub fn stem_deletes(&self) -> usize {
424 self.count(|a| matches!(a, Action::DeleteStem { .. }))
425 }
426
427 fn count(&self, pred: impl Fn(&Action) -> bool) -> usize {
428 self.actions.iter().filter(|a| pred(a)).count()
429 }
430}
431
432/// Decide the plan for one reconcile run.
433///
434/// `local` maps a clip id to the probe of that clip's manifest path; entries
435/// are expected for clips present in `manifest`. `sources` lists every selected
436/// source with its enumeration status, which gates every deletion this run.
437///
438/// Duplicate `desired` entries for one clip id (the same clip held by a mirror
439/// and a copy source, say) are aggregated first: the result is private if any
440/// is, copy-held if any is, and trashed only if all are, so a stray trashed
441/// duplicate can never defeat a sibling's protection.
442///
443/// The output order is stable: desired clips are processed in clip-id order,
444/// then absent manifest entries in clip-id order. No output depends on hash-map
445/// iteration order.
446pub fn reconcile(
447 manifest: &Manifest,
448 desired: &[Desired],
449 local: &HashMap<String, LocalFile>,
450 sources: &[SourceStatus],
451) -> Plan {
452 // Aggregate duplicate ids and order by clip id for deterministic output. A
453 // normal run has unique ids with canonical modes, so there is nothing to
454 // merge: sort borrowed references and clone nothing. The owned merge runs
455 // only when a duplicate id or a non-canonical mode list is actually present.
456 let merged: Vec<Desired>;
457 let ordered: Vec<&Desired> = if needs_aggregation(desired) {
458 merged = aggregate_desired(desired);
459 merged.iter().collect()
460 } else {
461 let mut refs: Vec<&Desired> = desired.iter().collect();
462 refs.sort_unstable_by(|a, b| a.clip.id.cmp(&b.clip.id));
463 refs
464 };
465 let desired_ids: HashSet<&str> = ordered.iter().map(|d| d.clip.id.as_str()).collect();
466 // One audio action per desired clip (plus its sidecars) and one per absent
467 // manifest entry; pre-size to cut reallocations on a large library.
468 let mut actions: Vec<Action> = Vec::with_capacity(ordered.len() + manifest.len());
469
470 let can_delete = deletion_allowed(sources);
471
472 for &d in &ordered {
473 // Decide the audio action(s) first (unchanged), then reconcile the
474 // clip's artifacts alongside. A clip whose audio is being deleted this
475 // run has its sidecars co-deleted under the same gate; otherwise its
476 // desired artifacts are written and any removed kind reconciled.
477 let before = actions.len();
478 plan_desired(d, manifest, local, can_delete, &mut actions);
479 let audio_deleted = actions[before..]
480 .iter()
481 .any(|a| matches!(a, Action::Delete { .. }));
482 if audio_deleted {
483 co_delete_artifacts(d.clip.id.as_str(), manifest, can_delete, &mut actions);
484 co_delete_stems(d.clip.id.as_str(), manifest, can_delete, &mut actions);
485 } else {
486 plan_clip_artifacts(d, manifest, local, can_delete, &mut actions);
487 plan_clip_stems(d, manifest, local, can_delete, &mut actions);
488 }
489 }
490
491 // Absent manifest entries, processed in clip-id order (BTreeMap is sorted).
492 for (clip_id, _entry) in manifest.iter() {
493 if desired_ids.contains(clip_id.as_str()) {
494 continue;
495 }
496 match delete_action(clip_id, manifest, can_delete) {
497 Some(action) => {
498 actions.push(action);
499 // Co-delete the absent clip's sidecars and stems under the same
500 // gate, so neither a sidecar nor the `.stems` folder is stranded.
501 co_delete_artifacts(clip_id, manifest, can_delete, &mut actions);
502 co_delete_stems(clip_id, manifest, can_delete, &mut actions);
503 }
504 // SYNC-9 / preserve / empty-path: absence is unreliable or the entry
505 // is protected, so keep the file rather than delete it.
506 None => actions.push(Action::Skip {
507 clip_id: clip_id.clone(),
508 }),
509 }
510 }
511
512 suppress_path_aliasing(&mut actions);
513 Plan { actions }
514}
515
516/// Whether clips may be deleted this run.
517///
518/// SYNC-9: deletion requires at least one selected `Mirror` source and every
519/// selected source (mirror and copy alike) fully enumerated. A failed or partial
520/// copy listing is just as unreliable as a mirror one, so it suppresses deletes
521/// too. With no mirror source there is no authoritative listing to delete
522/// against, and copy-only runs are additive.
523///
524/// This is the single deletion verdict for the run; the CLI threads the same
525/// value into [`plan_album_artifacts`] so folder-art deletes share it.
526pub fn deletion_allowed(sources: &[SourceStatus]) -> bool {
527 let mut saw_mirror = false;
528 for status in sources {
529 if !status.fully_enumerated {
530 return false;
531 }
532 if status.mode == SourceMode::Mirror {
533 saw_mirror = true;
534 }
535 }
536 saw_mirror
537}
538
539/// Whether an area listing is authoritative for deletion, before the
540/// empty-mirror guard.
541///
542/// Any area -- library, liked feed, or playlist -- is authoritative only when
543/// its listing drained completely (`complete`), no member was lost to the
544/// downloadable filter (`any_filtered`), and no `--limit`/`--since` narrowing
545/// was applied (`narrowed`). A member that transiently fails the filter would
546/// otherwise look absent and see its master deleted, so any filter loss disarms
547/// deletion uniformly across every source (#148, #248); a narrowing likewise
548/// always defers deletion to a full run.
549pub fn area_authoritative(complete: bool, any_filtered: bool, narrowed: bool) -> bool {
550 complete && !any_filtered && !narrowed
551}
552
553/// Whether an area that was not deliberately narrowed is fully enumerated after
554/// applying the empty-mirror guard (§5).
555///
556/// An empty Mirror area is never authoritative: an empty listing and a dropped
557/// listing are indistinguishable, so this guard is always applied. An empty Copy
558/// area is authoritative (it protects nothing) and is treated as fully
559/// enumerated so it does not suppress deletion.
560///
561/// `authoritative` is the area's completeness verdict before this guard (the
562/// listing drained, no narrowing, no filter loss). `clips_empty` is whether the
563/// area returned zero clips. `mode` is the area's final mode after any
564/// copy-verb override.
565pub fn area_fully_enumerated(authoritative: bool, clips_empty: bool, mode: SourceMode) -> bool {
566 authoritative && !(clips_empty && mode == SourceMode::Mirror)
567}
568
569/// Whether `--limit`/`--since` may narrow the download selection.
570///
571/// Only a run that neither deletes nor lists an authoritative full library may
572/// truncate the union: narrowing while a mirror is armed would drop a
573/// mirror/protector clip into a deletion (D2), and narrowing when a full library
574/// is listed would regress the library index and folder art built from the
575/// complete set. So truncation structurally implies no deletion.
576pub fn narrows_downloads(can_delete: bool, library_authoritative: bool) -> bool {
577 !can_delete && !library_authoritative
578}
579
580/// The single gate every `Delete` passes through.
581///
582/// Returns a [`Action::Delete`] only when deletion is allowed for the run, a
583/// manifest entry exists for the clip, its path is non-empty, and the entry is
584/// not preserve-marked. A `None` result means the caller must keep the file.
585fn delete_action(clip_id: &str, manifest: &Manifest, can_delete: bool) -> Option<Action> {
586 if !can_delete {
587 return None;
588 }
589 let entry = manifest.get(clip_id)?;
590 if entry.path.is_empty() || entry.preserve {
591 return None;
592 }
593 Some(Action::Delete {
594 path: entry.path.clone(),
595 clip_id: clip_id.to_string(),
596 })
597}
598
599/// The single gate every `DeleteArtifact` passes through.
600///
601/// This is the artifact analogue of [`delete_action`] and deliberately shares
602/// the audio deletion safety: it returns a [`Action::DeleteArtifact`] only when
603/// deletion is allowed for the run (`can_delete`, the same
604/// [`deletion_allowed`] verdict), the owning manifest entry exists, the sidecar
605/// `path` is non-empty (so an empty path can never delete the account root), and
606/// the owning entry is not `preserve`-marked (a preserved clip's artifacts are
607/// preserved too). A `None` result means the caller must keep the sidecar.
608fn delete_artifact_action(
609 owner_id: &str,
610 kind: ArtifactKind,
611 path: &str,
612 manifest: &Manifest,
613 can_delete: bool,
614) -> Option<Action> {
615 if !can_delete {
616 return None;
617 }
618 let entry = manifest.get(owner_id)?;
619 if path.is_empty() || entry.preserve {
620 return None;
621 }
622 Some(Action::DeleteArtifact {
623 kind,
624 path: path.to_string(),
625 owner_id: owner_id.to_string(),
626 })
627}
628
629/// Whether an artifact kind is a per-clip sidecar reconciled per clip.
630///
631/// The per-clip sidecars (cover art, details, lyrics, `.lrc`, video) live on the
632/// manifest entry; album/library classes (folder art, playlists) are owned by
633/// later phases and reconciled elsewhere, so per-clip planning ignores them.
634fn is_per_clip_kind(kind: ArtifactKind) -> bool {
635 matches!(
636 kind,
637 ArtifactKind::CoverJpg
638 | ArtifactKind::CoverWebp
639 | ArtifactKind::DetailsTxt
640 | ArtifactKind::LyricsTxt
641 | ArtifactKind::Lrc
642 | ArtifactKind::VideoMp4
643 )
644}
645
646/// Whether a no-longer-desired ("removed kind") artifact may be delete-reconciled
647/// while its owning clip's audio is kept this run.
648///
649/// The static `CoverJpg` deliberately opts out: a clip's art URL can be
650/// transiently absent for a run (the feed omits it, or a fetch fails), and the
651/// desired set then simply lacks that cover. Treating that absence as a removal
652/// and deleting the on-disk sidecar would churn a perfectly good cover, so an
653/// empty/transient URL must KEEP the existing file. `CoverJpg` is therefore
654/// removed only by [`co_delete_artifacts`], when the owning clip leaves every
655/// mirror source and its audio is deleted (a fully gated path).
656///
657/// `CoverWebp` is the opposite: it is a RETIRED kind. The per-clip animated
658/// cover is now embedded in the audio file, never written as a `<track>.webp`
659/// sidecar, so a desired set NEVER contains `CoverWebp` — its absence is
660/// unconditional, not transient. It is therefore delete-eligible so any
661/// `.webp` sidecar left by an older version is cleaned up on the next
662/// deletion-enabled run, through the same gate every other delete passes.
663///
664/// The text sidecars split on totality. [`render_clip_details`](crate::render_clip_details)
665/// is TOTAL (always renders), so a desired `DetailsTxt` is absent only when the
666/// feature is off — an unambiguous removal that is safe to delete through the
667/// shared gate. [`render_clip_lyrics`](crate::render_clip_lyrics) is PARTIAL
668/// (`None` on empty lyrics), so an absent `LyricsTxt` is ambiguous (feature off
669/// OR a transient empty-lyrics read); it opts out cover-style, so turning the
670/// lyrics feature off leaves existing `.lyrics.txt` files in place. The untimed
671/// [`Lrc`](ArtifactKind::Lrc) sidecar is partial the same way and opts out too.
672///
673/// [`VideoMp4`](ArtifactKind::VideoMp4) also opts out: `video_url` can be
674/// transiently absent, and the video is a large binary a user would not expect
675/// a run to delete merely because the feature was switched off. Like a cover, it
676/// is removed only when its owning audio is deleted.
677fn removed_kind_delete_eligible(kind: ArtifactKind) -> bool {
678 match kind {
679 ArtifactKind::CoverJpg
680 | ArtifactKind::LyricsTxt
681 | ArtifactKind::Lrc
682 | ArtifactKind::VideoMp4 => false,
683 ArtifactKind::CoverWebp
684 | ArtifactKind::DetailsTxt
685 | ArtifactKind::FolderJpg
686 | ArtifactKind::FolderWebp
687 | ArtifactKind::FolderMp4
688 | ArtifactKind::Playlist => true,
689 }
690}
691
692/// The manifest slot for a per-clip artifact kind, if that kind is stored on the
693/// entry. Album/library classes have no per-clip slot yet, so they map to
694/// `None`; the match stays generic so later phases can add slots without
695/// touching callers.
696fn manifest_artifact_by_kind(entry: &ManifestEntry, kind: ArtifactKind) -> Option<&ArtifactState> {
697 match kind {
698 ArtifactKind::CoverJpg => entry.cover_jpg.as_ref(),
699 ArtifactKind::CoverWebp => entry.cover_webp.as_ref(),
700 ArtifactKind::DetailsTxt => entry.details_txt.as_ref(),
701 ArtifactKind::LyricsTxt => entry.lyrics_txt.as_ref(),
702 ArtifactKind::Lrc => entry.lrc.as_ref(),
703 ArtifactKind::VideoMp4 => entry.video_mp4.as_ref(),
704 ArtifactKind::FolderJpg
705 | ArtifactKind::FolderWebp
706 | ArtifactKind::FolderMp4
707 | ArtifactKind::Playlist => None,
708 }
709}
710
711/// The per-clip artifacts an entry currently records, paired with their kind, in
712/// a stable order. Only the per-song sidecars live on the entry today.
713fn manifest_artifacts(entry: &ManifestEntry) -> Vec<(ArtifactKind, &ArtifactState)> {
714 let mut out = Vec::new();
715 if let Some(state) = &entry.cover_jpg {
716 out.push((ArtifactKind::CoverJpg, state));
717 }
718 if let Some(state) = &entry.cover_webp {
719 out.push((ArtifactKind::CoverWebp, state));
720 }
721 if let Some(state) = &entry.details_txt {
722 out.push((ArtifactKind::DetailsTxt, state));
723 }
724 if let Some(state) = &entry.lyrics_txt {
725 out.push((ArtifactKind::LyricsTxt, state));
726 }
727 if let Some(state) = &entry.lrc {
728 out.push((ArtifactKind::Lrc, state));
729 }
730 if let Some(state) = &entry.video_mp4 {
731 out.push((ArtifactKind::VideoMp4, state));
732 }
733 out
734}
735
736/// Set (or clear) the manifest slot for a per-clip artifact kind.
737///
738/// The executor calls this after a [`Action::WriteArtifact`] (with the new
739/// state) or a [`Action::DeleteArtifact`] (with `None`), so the kind-to-field
740/// mapping lives in exactly one place. Album/library classes have no per-clip
741/// slot yet and are no-ops.
742pub(crate) fn set_manifest_artifact(
743 entry: &mut ManifestEntry,
744 kind: ArtifactKind,
745 state: Option<ArtifactState>,
746) {
747 match kind {
748 ArtifactKind::CoverJpg => entry.cover_jpg = state,
749 ArtifactKind::CoverWebp => entry.cover_webp = state,
750 ArtifactKind::DetailsTxt => entry.details_txt = state,
751 ArtifactKind::LyricsTxt => entry.lyrics_txt = state,
752 ArtifactKind::Lrc => entry.lrc = state,
753 ArtifactKind::VideoMp4 => entry.video_mp4 = state,
754 ArtifactKind::FolderJpg
755 | ArtifactKind::FolderWebp
756 | ArtifactKind::FolderMp4
757 | ArtifactKind::Playlist => {}
758 }
759}
760
761/// Set (or clear) one stem slot in a clip's keyed stem map.
762///
763/// The executor calls this after a [`Action::WriteStem`] (with the new state)
764/// or a [`Action::DeleteStem`] (with `None`), so the map mutation lives in one
765/// place. Clearing the last stem leaves an empty map, which serialises away.
766pub(crate) fn set_manifest_stem(
767 entry: &mut ManifestEntry,
768 key: &str,
769 state: Option<ArtifactState>,
770) {
771 match state {
772 Some(state) => {
773 entry.stems.insert(key.to_string(), state);
774 }
775 None => {
776 entry.stems.remove(key);
777 }
778 }
779}
780
781fn needs_write_drift(
782 stored: Option<(&str, &str)>,
783 want_hash: &str,
784 want_path: &str,
785 local: &HashMap<String, LocalFile>,
786) -> bool {
787 match stored {
788 None => true,
789 Some((stored_hash, stored_path)) => {
790 stored_hash != want_hash
791 || !same_fs_path(stored_path, want_path)
792 || local
793 .get(stored_path)
794 .is_some_and(|f| !f.exists || f.size == 0)
795 }
796 }
797}
798
799/// Reconcile the artifacts of a clip whose audio is kept this run.
800///
801/// Writes each desired per-clip artifact that the manifest lacks, whose stored
802/// hash drifts, whose stored path drifts (the audio moved), or whose file is
803/// absent on disk. Delete-reconciles each manifest artifact whose kind is no
804/// longer desired (a removed kind) through the shared [`delete_artifact_action`]
805/// gate, unless the clip is protected this run, and unless the kind opts out of
806/// removed-kind deletion ([`removed_kind_delete_eligible`]) — cover art does, so
807/// a transient empty URL keeps its sidecar rather than deleting it.
808///
809/// `local` is the same path-keyed probe map that [`reconcile`] received,
810/// extended by the caller to include the artifact paths in the manifest. A
811/// manifest slot whose path resolves to a missing or zero-size file forces
812/// `needs_write = true`. A path absent from `local` (probe unavailable) falls
813/// back to hash/path comparison only.
814fn plan_clip_artifacts(
815 d: &Desired,
816 manifest: &Manifest,
817 local: &HashMap<String, LocalFile>,
818 can_delete: bool,
819 out: &mut Vec<Action>,
820) {
821 let owner_id = d.clip.id.as_str();
822 let entry = manifest.get(owner_id);
823
824 for artifact in &d.artifacts {
825 // Per-clip reconcile owns the per-clip sidecars (cover art, details,
826 // lyrics, .lrc, video). Album/library classes (folder art, playlists)
827 // belong to later phases; ignore them here so they are not rewritten
828 // every run.
829 if !is_per_clip_kind(artifact.kind) {
830 continue;
831 }
832 // A write is needed when the manifest lacks the sidecar, its bytes drift
833 // (hash), the clip moved so the sidecar belongs at a new path, or the
834 // tracked file is absent (or empty) on disk. A pure relocation (same
835 // bytes, new path, old file present) is emitted as a MoveArtifact below,
836 // which renames rather than re-fetching (#141).
837 let state = entry.and_then(|e| manifest_artifact_by_kind(e, artifact.kind));
838 let needs_write = needs_write_drift(
839 state.map(|state| (state.hash.as_str(), state.path.as_str())),
840 artifact.hash.as_str(),
841 artifact.path.as_str(),
842 local,
843 );
844 if needs_write {
845 // Downgrade a pure relocation to a rename: only the path drifted (a
846 // retitle), the bytes are unchanged, the kind is fetched (an inline
847 // rewrite is already free), and the old file is confirmed present, so
848 // move it rather than re-fetch or re-transcode (#141). The executor
849 // falls back to a fetch-and-write if the old file has since vanished.
850 if let Some(state) = state
851 && state.hash == artifact.hash
852 && !same_fs_path(&state.path, &artifact.path)
853 && artifact.content.is_none()
854 && local
855 .get(&state.path)
856 .is_some_and(|f| f.exists && f.size > 0)
857 {
858 out.push(Action::MoveArtifact {
859 kind: artifact.kind,
860 from: state.path.clone(),
861 to: artifact.path.clone(),
862 source_url: artifact.source_url.clone(),
863 hash: artifact.hash.clone(),
864 owner_id: owner_id.to_string(),
865 });
866 } else {
867 out.push(Action::WriteArtifact {
868 kind: artifact.kind,
869 path: artifact.path.clone(),
870 source_url: artifact.source_url.clone(),
871 hash: artifact.hash.clone(),
872 owner_id: owner_id.to_string(),
873 content: artifact.content.clone(),
874 });
875 }
876 }
877 }
878
879 // A clip protected THIS run (private or copy-held) keeps its sidecars even
880 // when a kind is no longer desired, regardless of the persisted preserve
881 // marker (which may still be false on the run that first protects the clip).
882 // Preserve wins, so no removed-kind delete is emitted for it.
883 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
884 if !protected_now && let Some(entry) = entry {
885 let desired_kinds: BTreeSet<ArtifactKind> = d
886 .artifacts
887 .iter()
888 .filter(|a| is_per_clip_kind(a.kind))
889 .map(|a| a.kind)
890 .collect();
891 for (kind, state) in manifest_artifacts(entry) {
892 // Cover kinds opt out of removed-kind deletion (see
893 // `removed_kind_delete_eligible`): an absent desired cover means an
894 // empty/transient URL, which must KEEP the on-disk sidecar, never
895 // delete it. Only a co-delete (audio gone) removes a cover. The loop
896 // and gate stay in place for any future kind that opts back in.
897 if removed_kind_delete_eligible(kind)
898 && !desired_kinds.contains(&kind)
899 && let Some(action) =
900 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
901 {
902 out.push(action);
903 }
904 }
905 }
906}
907
908/// Co-delete every sidecar of a clip whose audio is being deleted this run.
909///
910/// Each removal flows through the shared [`delete_artifact_action`] gate, so a
911/// sidecar is co-deleted only when the audio delete itself was allowed; on an
912/// incomplete listing or a preserved entry nothing is emitted.
913fn co_delete_artifacts(
914 owner_id: &str,
915 manifest: &Manifest,
916 can_delete: bool,
917 out: &mut Vec<Action>,
918) {
919 let Some(entry) = manifest.get(owner_id) else {
920 return;
921 };
922 for (kind, state) in manifest_artifacts(entry) {
923 if let Some(action) =
924 delete_artifact_action(owner_id, kind, &state.path, manifest, can_delete)
925 {
926 out.push(action);
927 }
928 }
929}
930
931/// The single gate every [`Action::DeleteStem`] passes through.
932///
933/// The keyed-stem analogue of [`delete_artifact_action`], sharing the exact
934/// audio deletion safety: it returns a delete only when deletion is allowed for
935/// the run (`can_delete`), the owning manifest entry exists, the stem `path` is
936/// non-empty (so an empty path can never delete the account root), and the
937/// owning entry is not `preserve`-marked (a preserved clip's stems are preserved
938/// too). A `None` result means the caller must keep the stem file.
939fn delete_stem_action(
940 clip_id: &str,
941 key: &str,
942 path: &str,
943 manifest: &Manifest,
944 can_delete: bool,
945) -> Option<Action> {
946 if !can_delete {
947 return None;
948 }
949 let entry = manifest.get(clip_id)?;
950 if path.is_empty() || entry.preserve {
951 return None;
952 }
953 Some(Action::DeleteStem {
954 clip_id: clip_id.to_string(),
955 key: key.to_string(),
956 path: path.to_string(),
957 })
958}
959
960/// Reconcile the keyed stems of a clip whose audio is kept this run.
961///
962/// Does nothing when `d.stems` is `None` (the listing was not authoritative:
963/// feature off, `has_stem` false, or a disabled/failed/partial/`400` listing),
964/// so existing local stems are always KEPT — a paging error is never read as
965/// "no stems". When `d.stems` is `Some(set)`, the set is authoritative:
966///
967/// - each desired stem the manifest lacks, whose stored hash drifts, or whose
968/// stored path drifts (the song moved), is written or, when only the path
969/// drifts and the old file is present, relocated with a rename (#141); and
970/// - each tracked stem whose key is absent from the authoritative set is
971/// delete-reconciled through the shared [`delete_stem_action`] gate, unless
972/// the clip is protected this run (private or copy-held).
973///
974/// A protected clip keeps every stem regardless of the persisted `preserve`
975/// marker (which may still be false on the run that first protects the clip).
976fn plan_clip_stems(
977 d: &Desired,
978 manifest: &Manifest,
979 local: &HashMap<String, LocalFile>,
980 can_delete: bool,
981 out: &mut Vec<Action>,
982) {
983 let Some(desired_stems) = &d.stems else {
984 return;
985 };
986 let clip_id = d.clip.id.as_str();
987 let entry = manifest.get(clip_id);
988
989 for stem in desired_stems {
990 let state = entry.and_then(|e| e.stems.get(&stem.key));
991 let needs_write = match state {
992 None => true,
993 Some(state) => state.hash != stem.hash || !same_fs_path(&state.path, &stem.path),
994 };
995 if needs_write {
996 // Downgrade a pure relocation to a rename: only the path drifted and
997 // the bytes are unchanged, so move the raw stem rather than re-render
998 // a WAV via convert_wav or re-fetch an MP3 (#141). The executor falls
999 // back to a fetch-and-write if the old file has since vanished.
1000 if let Some(state) = state
1001 && state.hash == stem.hash
1002 && !same_fs_path(&state.path, &stem.path)
1003 && local
1004 .get(&state.path)
1005 .is_some_and(|f| f.exists && f.size > 0)
1006 {
1007 out.push(Action::MoveStem {
1008 clip_id: clip_id.to_string(),
1009 key: stem.key.clone(),
1010 stem_id: stem.stem_id.clone(),
1011 from: state.path.clone(),
1012 to: stem.path.clone(),
1013 source_url: stem.source_url.clone(),
1014 format: stem.format,
1015 hash: stem.hash.clone(),
1016 });
1017 } else {
1018 out.push(Action::WriteStem {
1019 clip_id: clip_id.to_string(),
1020 key: stem.key.clone(),
1021 stem_id: stem.stem_id.clone(),
1022 path: stem.path.clone(),
1023 source_url: stem.source_url.clone(),
1024 format: stem.format,
1025 hash: stem.hash.clone(),
1026 });
1027 }
1028 }
1029 }
1030
1031 let protected_now = d.private || d.modes.contains(&SourceMode::Copy);
1032 if !protected_now && let Some(entry) = entry {
1033 let desired_keys: BTreeSet<&str> = desired_stems.iter().map(|s| s.key.as_str()).collect();
1034 for (key, state) in &entry.stems {
1035 // A tracked stem the authoritative listing no longer contains is a
1036 // genuine removal (the stem was deleted on Suno), reconciled through
1037 // the shared gate. This fires ONLY for an authoritative set, so an
1038 // empty/partial/paged-error listing (`d.stems == None`) never reaches
1039 // here and can never delete a stem.
1040 if !desired_keys.contains(key.as_str())
1041 && let Some(action) =
1042 delete_stem_action(clip_id, key, &state.path, manifest, can_delete)
1043 {
1044 out.push(action);
1045 }
1046 }
1047 }
1048}
1049
1050/// Co-delete every stem of a clip whose audio is being deleted this run.
1051///
1052/// Each removal flows through the shared [`delete_stem_action`] gate, so a stem
1053/// is co-deleted only when the audio delete itself was allowed; on an incomplete
1054/// listing or a preserved entry nothing is emitted. This is what keeps a
1055/// `.stems` sub-folder from being orphaned when its song is deleted: the stem
1056/// files are removed alongside the audio, and the now-empty folder is pruned.
1057fn co_delete_stems(clip_id: &str, manifest: &Manifest, can_delete: bool, out: &mut Vec<Action>) {
1058 let Some(entry) = manifest.get(clip_id) else {
1059 return;
1060 };
1061 for (key, state) in &entry.stems {
1062 if let Some(action) = delete_stem_action(clip_id, key, &state.path, manifest, can_delete) {
1063 out.push(action);
1064 }
1065 }
1066}
1067
1068/// Collapse duplicate desired entries for one clip id into a single record.
1069///
1070/// Safety folds are order-independent: `private` and copy-held are unions, and
1071/// `trashed` is an intersection. The non-safety fields (clip, path, format,
1072/// hashes) are taken from a deterministic representative so the result never
1073/// depends on input order.
1074fn aggregate_desired(desired: &[Desired]) -> Vec<Desired> {
1075 let mut by_id: BTreeMap<&str, Desired> = BTreeMap::new();
1076 for d in desired {
1077 match by_id.get_mut(d.clip.id.as_str()) {
1078 None => {
1079 by_id.insert(d.clip.id.as_str(), d.clone());
1080 }
1081 Some(acc) => {
1082 let take = rep_key(d) < rep_key(acc);
1083 acc.private = acc.private || d.private;
1084 acc.trashed = acc.trashed && d.trashed;
1085 for mode in &d.modes {
1086 if !acc.modes.contains(mode) {
1087 acc.modes.push(*mode);
1088 }
1089 }
1090 if take {
1091 acc.clip = d.clip.clone();
1092 acc.path = d.path.clone();
1093 acc.format = d.format;
1094 acc.meta_hash = d.meta_hash.clone();
1095 acc.art_hash = d.art_hash.clone();
1096 acc.artifacts = d.artifacts.clone();
1097 acc.stems = d.stems.clone();
1098 }
1099 }
1100 }
1101 }
1102 let mut out: Vec<Desired> = by_id.into_values().collect();
1103 for d in &mut out {
1104 // Normalise modes to a canonical order so aggregation is deterministic.
1105 let has_mirror = d.modes.contains(&SourceMode::Mirror);
1106 let has_copy = d.modes.contains(&SourceMode::Copy);
1107 d.modes.clear();
1108 if has_mirror {
1109 d.modes.push(SourceMode::Mirror);
1110 }
1111 if has_copy {
1112 d.modes.push(SourceMode::Copy);
1113 }
1114 }
1115 out
1116}
1117
1118/// Whether [`aggregate_desired`] must build an owned, merged copy: true when a
1119/// clip id repeats or any entry's modes are not already in canonical
1120/// `[Mirror, Copy]` order. When this is false the input is already the
1121/// aggregated result and can be used as-is.
1122fn needs_aggregation(desired: &[Desired]) -> bool {
1123 let mut seen: HashSet<&str> = HashSet::with_capacity(desired.len());
1124 desired
1125 .iter()
1126 .any(|d| !seen.insert(d.clip.id.as_str()) || !modes_are_canonical(&d.modes))
1127}
1128
1129/// Whether a mode list is already in the canonical, deduplicated order that the
1130/// owned merge would produce (`[Mirror]`, `[Copy]`, `[Mirror, Copy]`, or empty).
1131fn modes_are_canonical(modes: &[SourceMode]) -> bool {
1132 matches!(
1133 modes,
1134 [] | [SourceMode::Mirror] | [SourceMode::Copy] | [SourceMode::Mirror, SourceMode::Copy]
1135 )
1136}
1137
1138/// A deterministic, order-independent sort key for choosing the representative
1139/// non-safety fields when aggregating duplicate desired entries.
1140fn rep_key(d: &Desired) -> (&str, &str, &str, u8) {
1141 let format = match d.format {
1142 AudioFormat::Mp3 => 0,
1143 AudioFormat::Flac => 1,
1144 AudioFormat::Wav => 2,
1145 AudioFormat::Alac => 3,
1146 };
1147 (
1148 d.path.as_str(),
1149 d.meta_hash.as_str(),
1150 d.art_hash.as_str(),
1151 format,
1152 )
1153}
1154
1155/// Downgrade any delete whose path is also written or relocated to by a
1156/// `Download`, `Reformat`, `Rename`, `WriteArtifact`, `WriteStem`,
1157/// `MoveArtifact`, or `MoveStem` this run, so a deletion can never clobber a
1158/// file the same plan just produced. This covers the audio [`Action::Delete`],
1159/// every artifact [`Action::DeleteArtifact`] class, and every
1160/// [`Action::DeleteStem`].
1161///
1162/// Paths are compared by their filesystem-canonical key (NFC + lowercase, see
1163/// [`canonical_path_key`]), not byte-exact, so a departed clip's delete is
1164/// suppressed even when it differs from a kept clip's fresh write target only by
1165/// letter case or Unicode normalisation. On a case-insensitive or NFC-folding
1166/// filesystem those name the same file, and a byte-exact match would miss the
1167/// alias and delete the file the run just wrote.
1168fn suppress_path_aliasing(actions: &mut [Action]) {
1169 // Collect the delete indices whose path a write or move also targets this
1170 // run. Only aliased deletes are rewritten below, so the common (no-alias)
1171 // case does no extra work beyond building the canonical target set.
1172 let aliased: Vec<usize> = {
1173 let targets: BTreeSet<String> = actions
1174 .iter()
1175 .filter_map(|a| match a {
1176 Action::Download { path, .. }
1177 | Action::Reformat { path, .. }
1178 | Action::WriteArtifact { path, .. }
1179 | Action::WriteStem { path, .. } => Some(path.as_str()),
1180 Action::Rename { to, .. }
1181 | Action::MoveArtifact { to, .. }
1182 | Action::MoveStem { to, .. } => Some(to.as_str()),
1183 _ => None,
1184 })
1185 .map(canonical_path_key)
1186 .collect();
1187 actions
1188 .iter()
1189 .enumerate()
1190 .filter_map(|(index, a)| match a {
1191 Action::Delete { path, .. }
1192 | Action::DeleteArtifact { path, .. }
1193 | Action::DeleteStem { path, .. } => {
1194 targets.contains(&canonical_path_key(path)).then_some(index)
1195 }
1196 _ => None,
1197 })
1198 .collect()
1199 };
1200 for index in aliased {
1201 actions[index] = match &actions[index] {
1202 Action::Delete { clip_id, .. } | Action::DeleteStem { clip_id, .. } => Action::Skip {
1203 clip_id: clip_id.clone(),
1204 },
1205 Action::DeleteArtifact { owner_id, .. } => Action::Skip {
1206 clip_id: owner_id.clone(),
1207 },
1208 _ => unreachable!("only delete actions are collected as aliased"),
1209 };
1210 }
1211}
1212
1213/// Append the action(s) for one desired clip.
1214fn plan_desired(
1215 d: &Desired,
1216 manifest: &Manifest,
1217 local: &HashMap<String, LocalFile>,
1218 can_delete: bool,
1219 out: &mut Vec<Action>,
1220) {
1221 let clip_id = d.clip.id.as_str();
1222 let copy_held = d.modes.contains(&SourceMode::Copy);
1223
1224 // SYNC-12: a trashed clip is removed from the source, so its local file is
1225 // deleted, but only when neither private nor copy-held (protection beats
1226 // removal) and only through the shared delete guard. If the guard refuses
1227 // (deletion not allowed, no entry, empty path, or preserve-marked), keep the
1228 // file rather than fall through to a re-download of a clip that is gone.
1229 if d.trashed && !d.private && !copy_held {
1230 match delete_action(clip_id, manifest, can_delete) {
1231 Some(action) => out.push(action),
1232 None => out.push(Action::Skip {
1233 clip_id: clip_id.to_string(),
1234 }),
1235 }
1236 return;
1237 }
1238
1239 let Some(entry) = manifest.get(clip_id) else {
1240 // Not in the manifest: a fresh download.
1241 out.push(Action::Download {
1242 clip: d.clip.clone(),
1243 lineage: d.lineage.clone(),
1244 path: d.path.clone(),
1245 format: d.format,
1246 });
1247 return;
1248 };
1249
1250 // SYNC-10: a missing or zero-length file is treated as missing and
1251 // re-downloaded, even when the hashes still match.
1252 let missing = local.get(clip_id).is_none_or(|f| !f.exists || f.size == 0);
1253 if missing {
1254 out.push(Action::Download {
1255 clip: d.clip.clone(),
1256 lineage: d.lineage.clone(),
1257 path: d.path.clone(),
1258 format: d.format,
1259 });
1260 return;
1261 }
1262
1263 if d.format != entry.format {
1264 // Replace via re-encode; never pre-delete the existing file. The old
1265 // file lives at a different extension, so carry it for cleanup.
1266 out.push(Action::Reformat {
1267 clip: d.clip.clone(),
1268 path: d.path.clone(),
1269 from_path: entry.path.clone(),
1270 from: entry.format,
1271 to: d.format,
1272 });
1273 return;
1274 }
1275
1276 if !same_fs_path(&d.path, &entry.path) {
1277 out.push(Action::Rename {
1278 from: entry.path.clone(),
1279 to: d.path.clone(),
1280 });
1281 // A rename still needs a retag when the metadata or art drifted.
1282 if meta_or_art_changed(d, entry) {
1283 out.push(Action::Retag {
1284 clip: d.clip.clone(),
1285 lineage: d.lineage.clone(),
1286 path: d.path.clone(),
1287 });
1288 }
1289 return;
1290 }
1291
1292 if meta_or_art_changed(d, entry) {
1293 out.push(Action::Retag {
1294 clip: d.clip.clone(),
1295 lineage: d.lineage.clone(),
1296 path: entry.path.clone(),
1297 });
1298 return;
1299 }
1300
1301 out.push(Action::Skip {
1302 clip_id: clip_id.to_string(),
1303 });
1304}
1305
1306/// Whether the desired metadata or art hash differs from the manifest entry.
1307fn meta_or_art_changed(d: &Desired, entry: &ManifestEntry) -> bool {
1308 d.meta_hash != entry.meta_hash || d.art_hash != entry.art_hash
1309}
1310
1311/// Derive the desired folder art for every album in `desired`, grouped by the
1312/// stable root id (HARDENING H2).
1313///
1314/// This is pure: it groups the selected clips by their resolved `root_id`, then
1315/// per album chooses the folder-art sources deterministically:
1316///
1317/// - `folder.jpg` comes from the MOST-PLAYED art-bearing variant; ties break to
1318/// the EARLIEST `created_at`, then the lexicographically smallest id. Its hash
1319/// is the chosen art's content hash ([`art_hash`]), so a most-played flip to a
1320/// variant sharing the same art is a no-op downstream (H1).
1321/// - `cover.webp` (only when `animated_covers` is set) comes from the
1322/// EARLIEST-created variant with a non-empty `video_cover_url`; ties break to
1323/// the smallest id. Its hash folds in the `webp` encode settings, so changing
1324/// quality/lossless/effort re-transcodes it. `None` when no variant has an
1325/// animated source.
1326/// - `cover.mp4` (only when `raw_cover` is set) is that same variant's
1327/// `video_cover_url` kept verbatim (no transcode), so `both` yields the raw
1328/// source beside its WebP re-encode. `None` when no variant has an animated
1329/// source.
1330///
1331/// The album folder is the common parent of the album's clips' audio paths (they
1332/// share `{creator}/{album}/`); `folder.jpg` lands at `{album_dir}/folder.jpg`
1333/// and the animated covers at `{album_dir}/cover.webp` / `{album_dir}/cover.mp4`.
1334pub fn album_desired(
1335 desired: &[Desired],
1336 animated_covers: bool,
1337 raw_cover: bool,
1338 webp: WebpEncodeSettings,
1339) -> Vec<AlbumDesired> {
1340 let mut groups: BTreeMap<&str, Vec<&Desired>> = BTreeMap::new();
1341 for d in desired {
1342 groups
1343 .entry(d.lineage.root_id.as_str())
1344 .or_default()
1345 .push(d);
1346 }
1347
1348 groups
1349 .into_iter()
1350 .map(|(root_id, members)| {
1351 let album_dir = album_dir_of(&members);
1352 let folder_jpg = folder_jpg_source(&members).map(|source| DesiredArtifact {
1353 kind: ArtifactKind::FolderJpg,
1354 path: album_child(&album_dir, "folder.jpg"),
1355 source_url: source.clip.selected_image_url().unwrap_or("").to_owned(),
1356 hash: art_hash(&source.clip),
1357 content: None,
1358 });
1359 let folder_webp = animated_covers
1360 .then(|| folder_webp_source(&members))
1361 .flatten()
1362 .map(|source| DesiredArtifact {
1363 kind: ArtifactKind::FolderWebp,
1364 path: album_child(&album_dir, "cover.webp"),
1365 source_url: source.clip.video_cover_url.clone(),
1366 hash: webp_art_hash(&source.clip.video_cover_url, &webp),
1367 content: None,
1368 });
1369 let folder_mp4 = raw_cover
1370 .then(|| folder_webp_source(&members))
1371 .flatten()
1372 .map(|source| DesiredArtifact {
1373 kind: ArtifactKind::FolderMp4,
1374 path: album_child(&album_dir, "cover.mp4"),
1375 source_url: source.clip.video_cover_url.clone(),
1376 hash: art_url_hash(&source.clip.video_cover_url),
1377 content: None,
1378 });
1379 AlbumDesired {
1380 root_id: root_id.to_owned(),
1381 folder_jpg,
1382 folder_webp,
1383 folder_mp4,
1384 }
1385 })
1386 .collect()
1387}
1388
1389/// The album folder: the common parent of the members' audio paths.
1390///
1391/// The album's clips share `{creator}/{album}/`, so any member's parent is the
1392/// album dir; the smallest is taken so a stray differing path stays deterministic.
1393fn album_dir_of(members: &[&Desired]) -> String {
1394 members
1395 .iter()
1396 .map(|d| parent_dir(&d.path))
1397 .min()
1398 .unwrap_or("")
1399 .to_owned()
1400}
1401
1402/// The most-played art-bearing variant: the `folder.jpg` source.
1403///
1404/// Filtered to variants that carry selectable art, then the winner MAXIMISES
1405/// `play_count`, breaking ties to the EARLIEST `created_at` and then the
1406/// lexicographically smallest id, so selection is fully deterministic.
1407fn folder_jpg_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1408 members
1409 .iter()
1410 .copied()
1411 .filter(|d| {
1412 d.clip
1413 .selected_image_url()
1414 .is_some_and(|url| !url.is_empty())
1415 })
1416 .min_by(|a, b| {
1417 b.clip
1418 .play_count
1419 .cmp(&a.clip.play_count)
1420 .then_with(|| a.clip.created_at.cmp(&b.clip.created_at))
1421 .then_with(|| a.clip.id.cmp(&b.clip.id))
1422 })
1423}
1424
1425/// The first-created animated variant: the `cover.webp` source.
1426///
1427/// Filtered to variants with a non-empty `video_cover_url`, then the winner is
1428/// the EARLIEST `created_at`, tie-broken by the smallest id for determinism.
1429fn folder_webp_source<'a>(members: &[&'a Desired]) -> Option<&'a Desired> {
1430 members
1431 .iter()
1432 .copied()
1433 .filter(|d| !d.clip.video_cover_url.is_empty())
1434 .min_by(|a, b| {
1435 a.clip
1436 .created_at
1437 .cmp(&b.clip.created_at)
1438 .then_with(|| a.clip.id.cmp(&b.clip.id))
1439 })
1440}
1441
1442/// The parent directory of a forward-slash relative path, or `""` at the root.
1443fn parent_dir(path: &str) -> &str {
1444 match path.rsplit_once('/') {
1445 Some((dir, _)) => dir,
1446 None => "",
1447 }
1448}
1449
1450/// Join an album dir and a file name with a forward slash, tolerating an empty
1451/// dir (a path at the account root).
1452fn album_child(album_dir: &str, name: &str) -> String {
1453 if album_dir.is_empty() {
1454 name.to_owned()
1455 } else {
1456 format!("{album_dir}/{name}")
1457 }
1458}
1459
1460/// Plan the folder-art writes and deletes for this run's albums.
1461///
1462/// Writes are keyed on the CHOSEN ART CONTENT HASH (and the target path), never
1463/// the source clip id: for each present desired kind, a [`Action::WriteArtifact`]
1464/// is emitted only when the album store lacks that kind, its stored hash differs,
1465/// its stored path differs, or the tracked file is absent (or empty) on disk.
1466/// When hash, path, and disk presence all match, nothing is written, so a
1467/// most-played flip that resolves to the same art content is a no-op
1468/// (HARDENING H1). Exactly one write can be emitted per album per kind.
1469///
1470/// `local` is a path-keyed probe map built by the caller. A stored path that
1471/// resolves to a missing or zero-size file forces `needs_write = true`. A path
1472/// absent from `local` (probe unavailable) falls back to hash/path comparison.
1473///
1474/// Deletes cover any stored album/kind no longer desired — the album emptied (no
1475/// selected clips root there this run) or the kind's source disappeared (no
1476/// art-bearing or animated variant). Each is emitted only when `can_delete` (the
1477/// shared [`deletion_allowed`] verdict), so folder art is never removed on an
1478/// empty, failed, partial, or truncated listing. Folder art has no preserve
1479/// concept; the `can_delete` gate is the guard.
1480///
1481/// The output is deterministic: actions are sorted by `(root_id, kind)`, and a
1482/// given `(root_id, kind)` yields at most one action (a write or a delete).
1483pub fn plan_album_artifacts(
1484 desired: &[AlbumDesired],
1485 albums: &BTreeMap<String, AlbumArt>,
1486 can_delete: bool,
1487 local: &HashMap<String, LocalFile>,
1488) -> Vec<Action> {
1489 let mut actions: Vec<Action> = Vec::new();
1490 let by_root: BTreeMap<&str, &AlbumDesired> =
1491 desired.iter().map(|d| (d.root_id.as_str(), d)).collect();
1492
1493 for d in desired {
1494 let stored = albums.get(&d.root_id);
1495 for artifact in [
1496 d.folder_jpg.as_ref(),
1497 d.folder_webp.as_ref(),
1498 d.folder_mp4.as_ref(),
1499 ]
1500 .into_iter()
1501 .flatten()
1502 {
1503 let needs_write = needs_write_drift(
1504 stored
1505 .and_then(|a| a.artifact(artifact.kind))
1506 .map(|state| (state.hash.as_str(), state.path.as_str())),
1507 artifact.hash.as_str(),
1508 artifact.path.as_str(),
1509 local,
1510 );
1511 if needs_write {
1512 actions.push(Action::WriteArtifact {
1513 kind: artifact.kind,
1514 path: artifact.path.clone(),
1515 source_url: artifact.source_url.clone(),
1516 hash: artifact.hash.clone(),
1517 owner_id: d.root_id.clone(),
1518 content: None,
1519 });
1520 }
1521 }
1522 }
1523
1524 // Deletes are fully gated: nothing is removed on an unreliable listing.
1525 if can_delete {
1526 for (root_id, art) in albums {
1527 for (kind, state) in album_artifacts(art) {
1528 let desired_here = by_root
1529 .get(root_id.as_str())
1530 .is_some_and(|d| album_desires_kind(d, kind));
1531 if !desired_here && !state.path.is_empty() {
1532 actions.push(Action::DeleteArtifact {
1533 kind,
1534 path: state.path.clone(),
1535 owner_id: root_id.clone(),
1536 });
1537 }
1538 }
1539 }
1540 }
1541
1542 actions.sort_by(|a, b| album_action_key(a).cmp(&album_action_key(b)));
1543 actions
1544}
1545
1546/// The folder-art artifacts an album currently stores, paired with their kind,
1547/// in a stable order.
1548fn album_artifacts(art: &AlbumArt) -> Vec<(ArtifactKind, &ArtifactState)> {
1549 let mut out = Vec::new();
1550 if let Some(state) = &art.folder_jpg {
1551 out.push((ArtifactKind::FolderJpg, state));
1552 }
1553 if let Some(state) = &art.folder_webp {
1554 out.push((ArtifactKind::FolderWebp, state));
1555 }
1556 if let Some(state) = &art.folder_mp4 {
1557 out.push((ArtifactKind::FolderMp4, state));
1558 }
1559 out
1560}
1561
1562/// Whether an [`AlbumDesired`] desires the given folder-art kind this run.
1563fn album_desires_kind(d: &AlbumDesired, kind: ArtifactKind) -> bool {
1564 match kind {
1565 ArtifactKind::FolderJpg => d.folder_jpg.is_some(),
1566 ArtifactKind::FolderWebp => d.folder_webp.is_some(),
1567 ArtifactKind::FolderMp4 => d.folder_mp4.is_some(),
1568 ArtifactKind::CoverJpg
1569 | ArtifactKind::CoverWebp
1570 | ArtifactKind::DetailsTxt
1571 | ArtifactKind::LyricsTxt
1572 | ArtifactKind::Lrc
1573 | ArtifactKind::VideoMp4
1574 | ArtifactKind::Playlist => false,
1575 }
1576}
1577
1578/// The `(root_id, kind)` sort key for a folder-art action, for deterministic order.
1579fn album_action_key(action: &Action) -> (&str, ArtifactKind) {
1580 match action {
1581 Action::WriteArtifact { owner_id, kind, .. }
1582 | Action::DeleteArtifact { owner_id, kind, .. } => (owner_id.as_str(), *kind),
1583 _ => ("", ArtifactKind::CoverJpg),
1584 }
1585}
1586
1587/// Plan the `.m3u8` writes and deletes for this run's playlists.
1588///
1589/// # Writes
1590///
1591/// For each desired playlist a single [`Action::WriteArtifact`] of kind
1592/// [`Playlist`](ArtifactKind::Playlist) is emitted (carrying the rendered body
1593/// inline in `content`) when the store lacks the playlist, its stored hash
1594/// differs, its stored path differs, or the tracked file is absent (or empty)
1595/// on disk. The hash is taken over the full rendered text, so a name, order,
1596/// path, title, or duration change all trigger a rewrite (HARDENING B1); an
1597/// unchanged, present playlist writes nothing (idempotent).
1598///
1599/// `local` is a path-keyed probe map built by the caller. A stored path that
1600/// resolves to a missing or zero-size file forces `needs_write = true`. A path
1601/// absent from `local` (probe unavailable) falls back to hash/path comparison.
1602///
1603/// A **rename** (the same id whose sanitised name, and so path, changed) writes
1604/// the new file and, gated exactly like a stale delete (`can_delete &&
1605/// list_fully_enumerated`), also deletes the old stored path so the previous
1606/// `<oldname>.m3u8` does not linger.
1607///
1608/// # Deletes (HARDENING B2 — paramount)
1609///
1610/// A stored playlist absent from `desired` is stale (removed on Suno) and its
1611/// file is deleted **only** when `can_delete` AND `list_fully_enumerated`. The
1612/// second gate is the playlist-specific safety valve: `list_fully_enumerated`
1613/// is `true` only when the `/api/playlist/me` listing succeeded and was fully
1614/// paginated. If that listing **failed or was not fully enumerated**, the caller
1615/// passes `list_fully_enumerated = false` (and an empty `desired`), so this
1616/// function emits **zero deletes and zero writes** and every existing `.m3u8` is
1617/// left untouched. A failed *member* fetch for one playlist is handled upstream
1618/// by excluding that id from BOTH `desired` and `stored`, so it is never treated
1619/// as stale here.
1620///
1621/// The output is deterministic (sorted by `(owner_id, kind)`) and self-suppresses
1622/// path aliasing, so a rename to a name another playlist also renders this run
1623/// downgrades the colliding delete rather than removing a just-written file.
1624pub fn plan_playlist_artifacts(
1625 desired: &[PlaylistDesired],
1626 stored: &BTreeMap<String, PlaylistState>,
1627 can_delete: bool,
1628 list_fully_enumerated: bool,
1629 local: &HashMap<String, LocalFile>,
1630) -> Vec<Action> {
1631 let mut actions: Vec<Action> = Vec::new();
1632 let desired_ids: BTreeSet<&str> = desired.iter().map(|d| d.id.as_str()).collect();
1633 // Deletes (stale removals and rename cleanups) are gated on BOTH the shared
1634 // deletion verdict and a fully-enumerated playlist listing (B2).
1635 let deletes_allowed = can_delete && list_fully_enumerated;
1636
1637 for d in desired {
1638 let stored_here = stored.get(&d.id);
1639 let needs_write = needs_write_drift(
1640 stored_here.map(|state| (state.hash.as_str(), state.path.as_str())),
1641 d.hash.as_str(),
1642 d.path.as_str(),
1643 local,
1644 );
1645 if needs_write {
1646 actions.push(Action::WriteArtifact {
1647 kind: ArtifactKind::Playlist,
1648 path: d.path.clone(),
1649 source_url: String::new(),
1650 hash: d.hash.clone(),
1651 owner_id: d.id.clone(),
1652 content: Some(d.content.clone()),
1653 });
1654 }
1655 // A rename changed the path: remove the old file, under the delete gate.
1656 if deletes_allowed
1657 && let Some(state) = stored_here
1658 && !state.path.is_empty()
1659 && state.path != d.path
1660 {
1661 actions.push(Action::DeleteArtifact {
1662 kind: ArtifactKind::Playlist,
1663 path: state.path.clone(),
1664 owner_id: d.id.clone(),
1665 });
1666 }
1667 }
1668
1669 // Stale playlists (removed on Suno) are deleted only under the full gate, so
1670 // a failed or partial listing never removes an existing `.m3u8` (B2).
1671 if deletes_allowed {
1672 for (id, state) in stored {
1673 if !desired_ids.contains(id.as_str()) && !state.path.is_empty() {
1674 actions.push(Action::DeleteArtifact {
1675 kind: ArtifactKind::Playlist,
1676 path: state.path.clone(),
1677 owner_id: id.clone(),
1678 });
1679 }
1680 }
1681 }
1682
1683 actions.sort_by(|a, b| playlist_action_key(a).cmp(&playlist_action_key(b)));
1684 // A rename to a name another playlist also renders this run must not delete
1685 // the file that write just produced; downgrade any such colliding delete.
1686 suppress_path_aliasing(&mut actions);
1687 actions
1688}
1689
1690/// The `(owner_id, is_delete)` sort key for a playlist action, so writes and
1691/// deletes for one id stay adjacent and order is deterministic.
1692fn playlist_action_key(action: &Action) -> (&str, u8) {
1693 match action {
1694 Action::WriteArtifact { owner_id, .. } => (owner_id.as_str(), 0),
1695 Action::DeleteArtifact { owner_id, .. } => (owner_id.as_str(), 1),
1696 Action::Skip { clip_id } => (clip_id.as_str(), 2),
1697 _ => ("", 3),
1698 }
1699}
1700
1701#[cfg(test)]
1702mod tests;
1703
1704#[cfg(test)]
1705mod proptests;