Skip to main content

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;