Skip to main content

suno_core/
area.rs

1//! The multi-area sync planner: the pure decision logic for what is
2//! authoritative and what may be deleted across a run's areas (library, liked
3//! feed, playlists). Lifted from the CLI so the deletion-authority logic is
4//! covered by the core suite, beside the leaf predicates it composes in
5//! [`crate::reconcile`].
6
7use std::collections::{BTreeSet, HashMap, HashSet};
8
9use crate::{
10    Clip, Desired, LIKED_PLAYLIST_ID, LineageStore, PlaylistDesired, PlaylistInput, SourceMode,
11    SourceStatus, area_authoritative, area_fully_enumerated, build_playlist_desired,
12    deletion_allowed,
13};
14
15/// One area's listing outcome for the multi-area planner.
16///
17/// The `authoritative_ignoring_empty` flag is the area's completeness verdict
18/// *before* the empty-mirror guard (§5), which [`area_enumerated`] applies later
19/// against the final mode, so a copy-verb override that turns a Mirror area Copy
20/// re-scores an empty area correctly. It is only ever produced by
21/// [`area_authoritative`] via [`AreaListing::listed`], so the #248 filter-loss
22/// guard cannot be bypassed by an out-of-band value.
23pub struct AreaListing {
24    kind: AreaKind,
25    /// The resolved (pre copy-override) mode for this area.
26    mode: SourceMode,
27    /// The area's downloadable clips.
28    clips: Vec<Clip>,
29    /// Completeness modulo the empty-mirror guard: `true` when the listing
30    /// drained, was not deliberately narrowed, and lost no member to the
31    /// downloadable filter.
32    authoritative_ignoring_empty: bool,
33}
34
35/// Which kind of area a listing came from, carrying playlist identity so its
36/// `.m3u8` can be maintained by id and name.
37pub enum AreaKind {
38    Library,
39    Liked,
40    Playlist { id: String, name: String },
41}
42
43impl AreaListing {
44    /// A drained listing. The authority flag is computed from the raw listing
45    /// signals via [`area_authoritative`], so the #248 guard is unbypassable
46    /// from outside the crate: the fields are private, and although in-crate
47    /// tests may construct directly, every production path goes through this
48    /// constructor.
49    pub fn listed(
50        kind: AreaKind,
51        mode: SourceMode,
52        clips: Vec<Clip>,
53        complete: bool,
54        any_filtered: bool,
55        narrowed: bool,
56    ) -> Self {
57        Self {
58            kind,
59            mode,
60            clips,
61            authoritative_ignoring_empty: area_authoritative(complete, any_filtered, narrowed),
62        }
63    }
64
65    /// A failed or empty listing: it holds no clips and is never authoritative,
66    /// so it suppresses deletion without ever vanishing from the sources (§6).
67    pub fn failed(kind: AreaKind, mode: SourceMode) -> Self {
68        Self {
69            kind,
70            mode,
71            clips: Vec::new(),
72            authoritative_ignoring_empty: false,
73        }
74    }
75
76    /// A playlist area whose listing could not be resolved or fetched (§6).
77    pub fn unresolved_playlist(mode: SourceMode) -> Self {
78        Self::failed(
79            AreaKind::Playlist {
80                id: String::new(),
81                name: String::new(),
82            },
83            mode,
84        )
85    }
86
87    /// The area's downloadable clips.
88    pub fn clips(&self) -> &[Clip] {
89        &self.clips
90    }
91}
92
93/// This area's mode after the copy-verb / force-additive override.
94pub fn area_mode(area: &AreaListing, force_copy: bool) -> SourceMode {
95    if force_copy {
96        SourceMode::Copy
97    } else {
98        area.mode
99    }
100}
101
102/// Whether this area is authoritative for deletion, applying the empty-mirror
103/// guard (§5) against the final mode.
104#[must_use]
105pub fn area_enumerated(area: &AreaListing, force_copy: bool) -> bool {
106    area_fully_enumerated(
107        area.authoritative_ignoring_empty,
108        area.clips.is_empty(),
109        area_mode(area, force_copy),
110    )
111}
112
113/// Whether a Library area is present and fully enumerated (the implicit
114/// protector counts; `library="off"` leaves no Library area, so this is false).
115#[must_use]
116pub fn library_authoritative(areas: &[AreaListing], force_copy: bool) -> bool {
117    areas
118        .iter()
119        .any(|a| matches!(a.kind, AreaKind::Library) && area_enumerated(a, force_copy))
120}
121
122/// The per-source enumeration status of every area, for the deletion verdict.
123#[must_use]
124pub fn source_statuses(areas: &[AreaListing], force_copy: bool) -> Vec<SourceStatus> {
125    areas
126        .iter()
127        .map(|area| SourceStatus {
128            mode: area_mode(area, force_copy),
129            fully_enumerated: area_enumerated(area, force_copy),
130        })
131        .collect()
132}
133
134/// Whether first-use adoption can confirm identity from this run's listing.
135///
136/// An authoritative Library is the usual anchor, but a fully-enumerated Mirror
137/// source of any kind (e.g. a playlist under `library="off"`) also arms
138/// deletion. Deleting against an account this library was never pinned to is
139/// the hole the owner pin closes (#149), so such a run is treated as enumerated:
140/// `adopt_decision` then confirms identity by clip overlap and aborts on a
141/// foreign account instead of skipping the pin.
142#[must_use]
143pub fn adoption_enumerated(areas: &[AreaListing], force_copy: bool) -> bool {
144    library_authoritative(areas, force_copy)
145        || deletion_allowed(&source_statuses(areas, force_copy))
146}
147
148/// Build the clip union across areas in canonical order, first area winning per
149/// id so the Library payload is kept (H1).
150pub fn union_clips(areas: &[AreaListing]) -> Vec<Clip> {
151    let mut seen: HashSet<String> = HashSet::new();
152    let mut union: Vec<Clip> = Vec::new();
153    for area in areas {
154        for clip in &area.clips {
155            if seen.insert(clip.id.clone()) {
156                union.push(clip.clone());
157            }
158        }
159    }
160    union
161}
162
163/// Build the `.m3u8` desired state for an area-scoped run (no authoritative
164/// Library). Only the playlist and liked areas that fully enumerated their
165/// members are rendered, and only when `members_intact` (the union was not
166/// truncated by `--limit`/`--since`, so `desired` still holds every member);
167/// every other stored playlist id is protected so no `.m3u8` is rewritten or
168/// deleted from a partial view (B2/D3).
169pub fn build_scoped_playlist_desired(
170    areas: &[AreaListing],
171    desired: &[Desired],
172    store: &LineageStore,
173    protected: &mut BTreeSet<String>,
174    force_copy: bool,
175    members_intact: bool,
176) -> (Vec<PlaylistDesired>, bool) {
177    let mut owned: Vec<(String, String, Vec<Clip>)> = Vec::new();
178    for area in areas {
179        match &area.kind {
180            AreaKind::Playlist { id, name } => {
181                if members_intact && !id.is_empty() && area_enumerated(area, force_copy) {
182                    owned.push((id.clone(), name.clone(), area.clips.clone()));
183                } else if !id.is_empty() {
184                    protected.insert(id.clone());
185                }
186            }
187            AreaKind::Liked => {
188                if members_intact && area_enumerated(area, force_copy) {
189                    owned.push((
190                        LIKED_PLAYLIST_ID.to_owned(),
191                        "Liked Songs".to_owned(),
192                        area.clips.clone(),
193                    ));
194                } else {
195                    protected.insert(LIKED_PLAYLIST_ID.to_owned());
196                }
197            }
198            AreaKind::Library => {}
199        }
200    }
201    let rendered: BTreeSet<&str> = owned.iter().map(|(id, _, _)| id.as_str()).collect();
202    // Protect every stored playlist this run is not authoritatively rewriting, so
203    // a non-selected playlist's `.m3u8` is never treated as stale.
204    for id in store.playlists.keys() {
205        if !rendered.contains(id.as_str()) {
206            protected.insert(id.clone());
207        }
208    }
209    let inputs: Vec<PlaylistInput<'_>> = owned
210        .iter()
211        .map(|(id, name, members)| PlaylistInput {
212            id: id.as_str(),
213            name: name.as_str(),
214            members: members.as_slice(),
215        })
216        .collect();
217    (build_playlist_desired(&inputs, desired), true)
218}
219
220/// Fold a union of per-area clip lists into `modes_by_id`, mapping each clip id
221/// to the deduplicated, canonical-order list of every area mode holding it.
222///
223/// `areas` is processed in canonical area order (Library, Liked, Playlists), and
224/// each clip's modes are normalised to `[Mirror, Copy]` order, mirroring
225/// `aggregate_desired` so a clip held by both a mirror and a copy area is
226/// copy-protected (SYNC-8).
227pub fn build_modes_by_id(areas: &[(SourceMode, Vec<String>)]) -> HashMap<String, Vec<SourceMode>> {
228    let mut map: HashMap<String, (bool, bool)> = HashMap::new();
229    for (mode, ids) in areas {
230        for id in ids {
231            let entry = map.entry(id.clone()).or_insert((false, false));
232            match mode {
233                SourceMode::Mirror => entry.0 = true,
234                SourceMode::Copy => entry.1 = true,
235            }
236        }
237    }
238    map.into_iter()
239        .map(|(id, (mirror, copy))| {
240            let mut modes = Vec::new();
241            if mirror {
242                modes.push(SourceMode::Mirror);
243            }
244            if copy {
245                modes.push(SourceMode::Copy);
246            }
247            (id, modes)
248        })
249        .collect()
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::{
256        Action, ArtifactToggles, AudioFormat, LocalFile, Manifest, ManifestEntry, NamingConfig,
257        build_desired, narrows_downloads, reconcile,
258    };
259
260    fn tclip(id: &str) -> Clip {
261        Clip {
262            id: id.to_owned(),
263            title: "Song".to_owned(),
264            handle: "alice".to_owned(),
265            ..Default::default()
266        }
267    }
268
269    fn area(kind: AreaKind, mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
270        AreaListing {
271            kind,
272            mode,
273            clips: ids.iter().map(|id| tclip(id)).collect(),
274            authoritative_ignoring_empty: authoritative,
275        }
276    }
277
278    // Test 5: an empty Mirror area is never authoritative (a legitimately empty
279    // mirror is indistinguishable from a dropped listing), so deletion is
280    // suppressed. An empty Copy area stays enumerated (it protects nothing).
281    #[test]
282    fn empty_mirror_area_is_not_enumerated() {
283        let mirror = area(AreaKind::Liked, SourceMode::Mirror, &[], true);
284        assert!(!area_enumerated(&mirror, false));
285        let copy = area(AreaKind::Liked, SourceMode::Copy, &[], true);
286        assert!(area_enumerated(&copy, false));
287        // A non-empty mirror that fully listed is authoritative.
288        let full = area(AreaKind::Liked, SourceMode::Mirror, &["x"], true);
289        assert!(area_enumerated(&full, false));
290    }
291
292    // A run under `library="off"` that mirrors a fully-enumerated playlist can
293    // delete, so first-use adoption must confirm identity (enumerated == true)
294    // rather than SkipPin into a delete against an unconfirmed account (#149).
295    #[test]
296    fn adoption_enumerated_covers_a_mirror_playlist_under_library_off() {
297        let playlist = |mode, ids: &[&str], auth| {
298            area(
299                AreaKind::Playlist {
300                    id: "p".into(),
301                    name: "P".into(),
302                },
303                mode,
304                ids,
305                auth,
306            )
307        };
308        // library="off" + a fully-enumerated Mirror playlist arms deletion.
309        assert!(adoption_enumerated(
310            &[playlist(SourceMode::Mirror, &["pl"], true)],
311            false
312        ));
313        // A copy-only run cannot delete, so identity need not be confirmed.
314        assert!(!adoption_enumerated(
315            &[playlist(SourceMode::Copy, &["pl"], true)],
316            false
317        ));
318        // An empty mirror (a dropped or ambiguous listing) is not authoritative.
319        assert!(!adoption_enumerated(
320            &[playlist(SourceMode::Mirror, &[], true)],
321            false
322        ));
323        // A partial (non-authoritative) mirror listing does not arm adoption.
324        assert!(!adoption_enumerated(
325            &[playlist(SourceMode::Mirror, &["pl"], false)],
326            false
327        ));
328        // A force-copy (additive) run never deletes, so never forces the pin.
329        assert!(!adoption_enumerated(
330            &[playlist(SourceMode::Mirror, &["pl"], true)],
331            true
332        ));
333        // The classic authoritative-library anchor still counts.
334        assert!(adoption_enumerated(
335            &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
336            false,
337        ));
338    }
339
340    // library_authoritative counts the implicit protector but is false for
341    // `library="off"` (no library area at all).
342    #[test]
343    fn library_authoritative_counts_protector_not_off() {
344        let with_protector = vec![
345            area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
346            area(
347                AreaKind::Playlist {
348                    id: "p".into(),
349                    name: "P".into(),
350                },
351                SourceMode::Mirror,
352                &["pl"],
353                true,
354            ),
355        ];
356        assert!(library_authoritative(&with_protector, false));
357
358        let off = vec![area(
359            AreaKind::Playlist {
360                id: "p".into(),
361                name: "P".into(),
362            },
363            SourceMode::Mirror,
364            &["pl"],
365            true,
366        )];
367        assert!(!library_authoritative(&off, false));
368    }
369
370    /// (can_delete, library_authoritative, truncate) for a set of areas, exactly
371    /// as `run_one` computes them, for the #148 scenario traces.
372    fn verdict(areas: &[AreaListing]) -> (bool, bool, bool) {
373        let can_delete = deletion_allowed(&source_statuses(areas, false));
374        let lib_auth = library_authoritative(areas, false);
375        (
376            can_delete,
377            lib_auth,
378            narrows_downloads(can_delete, lib_auth),
379        )
380    }
381
382    fn pl_area(mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
383        area(
384            AreaKind::Playlist {
385                id: "p".into(),
386                name: "P".into(),
387            },
388            mode,
389            ids,
390            authoritative,
391        )
392    }
393
394    // The #148 behaviour change at the area level: a narrowed playlist mirror
395    // neither enumerates nor arms deletion; the same listing un-narrowed does both.
396    #[test]
397    fn narrowed_playlist_mirror_disarms_deletion() {
398        let narrowed = pl_area(
399            SourceMode::Mirror,
400            &["a"],
401            area_authoritative(true, false, true),
402        );
403        assert!(!area_enumerated(&narrowed, false));
404        assert!(!deletion_allowed(&source_statuses(&[narrowed], false)));
405
406        let full = pl_area(
407            SourceMode::Mirror,
408            &["a"],
409            area_authoritative(true, false, false),
410        );
411        assert!(area_enumerated(&full, false));
412        assert!(deletion_allowed(&source_statuses(&[full], false)));
413    }
414
415    // #148 scenario (c): a narrowed playlist mirror WITH the injected full-library
416    // protector does not delete (the playlist disarms) and does not narrow
417    // downloads (the protector lists the whole library, which drives index/art).
418    #[test]
419    fn narrowed_playlist_with_protector_neither_deletes_nor_narrows() {
420        let areas = vec![
421            area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
422            pl_area(
423                SourceMode::Mirror,
424                &["pl"],
425                area_authoritative(true, false, true),
426            ),
427        ];
428        let (can_delete, lib_auth, truncate) = verdict(&areas);
429        assert!(!can_delete, "narrowed playlist mirror is disarmed");
430        assert!(lib_auth, "the protector is an authoritative library");
431        assert!(
432            !truncate,
433            "the full library is listed, so downloads are not narrowed"
434        );
435    }
436
437    // #148 scenario (d): a narrowed playlist mirror under `library="off"` (no
438    // protector) does not delete and DOES narrow downloads, matching a narrowed
439    // library-only or liked run.
440    #[test]
441    fn narrowed_playlist_off_disarms_and_narrows() {
442        let areas = vec![pl_area(
443            SourceMode::Mirror,
444            &["pl"],
445            area_authoritative(true, false, true),
446        )];
447        let (can_delete, lib_auth, truncate) = verdict(&areas);
448        assert!(!can_delete, "narrowed playlist mirror is disarmed");
449        assert!(!lib_auth, "library=off leaves no library area");
450        assert!(
451            truncate,
452            "no armed deletion and no full library, so downloads narrow"
453        );
454    }
455
456    // #148 regression guard for scenario (e): a configured unfiltered
457    // `library="mirror"` lists the whole feed regardless of `--limit`/`--since`,
458    // so it stays armed and authoritative. The fix must NOT disarm it — that is
459    // the #149/D2 guarantee that closes the token-swap hole.
460    #[test]
461    fn configured_full_library_mirror_still_deletes_when_narrowed() {
462        let areas = vec![area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)];
463        let (can_delete, lib_auth, truncate) = verdict(&areas);
464        assert!(
465            can_delete,
466            "the configured full-library mirror still deletes"
467        );
468        assert!(lib_auth);
469        assert!(
470            !truncate,
471            "the full library is listed, so downloads are not narrowed"
472        );
473    }
474
475    // A narrowed `library="off"` mirror playlist cannot delete (#148), so first-use
476    // adoption skips the pin rather than confirming identity — the #149 rule that
477    // only a delete-capable run must confirm the account composes cleanly.
478    #[test]
479    fn adoption_skips_pin_on_a_narrowed_library_off_playlist() {
480        let areas = vec![pl_area(
481            SourceMode::Mirror,
482            &["pl"],
483            area_authoritative(true, false, true),
484        )];
485        assert!(!adoption_enumerated(&areas, false));
486    }
487
488    // H1: the union keeps the first area's payload per id (Library wins over a
489    // later playlist copy of the same clip).
490    #[test]
491    fn union_keeps_first_area_payload() {
492        let mut lib = tclip("shared");
493        lib.title = "Library".to_owned();
494        let mut pl = tclip("shared");
495        pl.title = "Playlist".to_owned();
496        let areas = vec![
497            AreaListing {
498                kind: AreaKind::Library,
499                mode: SourceMode::Copy,
500                clips: vec![lib, tclip("lib-only")],
501                authoritative_ignoring_empty: true,
502            },
503            AreaListing {
504                kind: AreaKind::Playlist {
505                    id: "p".into(),
506                    name: "P".into(),
507                },
508                mode: SourceMode::Mirror,
509                clips: vec![pl],
510                authoritative_ignoring_empty: true,
511            },
512        ];
513        let union = union_clips(&areas);
514        assert_eq!(union.len(), 2);
515        assert_eq!(union[0].id, "shared");
516        assert_eq!(union[0].title, "Library");
517        assert_eq!(union[1].id, "lib-only");
518    }
519
520    #[test]
521    fn a_failed_area_suppresses_deletion_for_the_run() {
522        let areas = [
523            area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
524            // Playlist listing failed: empty and non-authoritative.
525            area(
526                AreaKind::Playlist {
527                    id: "p".into(),
528                    name: "P".into(),
529                },
530                SourceMode::Mirror,
531                &[],
532                false,
533            ),
534        ];
535        let sources: Vec<SourceStatus> = areas
536            .iter()
537            .map(|a| SourceStatus {
538                mode: area_mode(a, false),
539                fully_enumerated: area_enumerated(a, false),
540            })
541            .collect();
542        assert!(!deletion_allowed(&sources));
543    }
544
545    // Test 8: with every area enumerated, a mixed Mirror + Copy selection deletes
546    // only orphans exclusive to a Mirror area; a Copy area's orphan is protected
547    // and the run remains armed.
548    #[test]
549    fn mixed_mode_deletes_only_mirror_exclusive_orphans() {
550        let areas = vec![
551            area(AreaKind::Liked, SourceMode::Mirror, &["m-live"], true),
552            area(
553                AreaKind::Playlist {
554                    id: "p".into(),
555                    name: "P".into(),
556                },
557                SourceMode::Copy,
558                &["c-live"],
559                true,
560            ),
561        ];
562        let sources: Vec<SourceStatus> = areas
563            .iter()
564            .map(|a| SourceStatus {
565                mode: area_mode(a, false),
566                fully_enumerated: area_enumerated(a, false),
567            })
568            .collect();
569        assert!(deletion_allowed(&sources));
570
571        let area_modes: Vec<(SourceMode, Vec<String>)> = areas
572            .iter()
573            .map(|a| {
574                (
575                    area_mode(a, false),
576                    a.clips.iter().map(|c| c.id.clone()).collect(),
577                )
578            })
579            .collect();
580        let modes = build_modes_by_id(&area_modes);
581        let union = union_clips(&areas);
582        let desired = build_desired(
583            &union.iter().collect::<Vec<_>>(),
584            AudioFormat::Flac,
585            &modes,
586            &HashMap::new(),
587            &BTreeSet::new(),
588            ArtifactToggles::default(),
589            &NamingConfig::default(),
590        );
591
592        let mut manifest = Manifest::new();
593        // Orphans: one previously from the mirror area, one from the copy area.
594        for id in ["m-live", "c-live", "m-orphan", "c-orphan"] {
595            manifest.insert(
596                id,
597                ManifestEntry {
598                    path: format!("{id}.flac"),
599                    format: AudioFormat::Flac,
600                    size: 100,
601                    // The copy-area orphan carries the preserve marker a prior copy
602                    // run stamped, so it can never be deleted.
603                    preserve: id == "c-orphan",
604                    ..Default::default()
605                },
606            );
607        }
608        let local: HashMap<String, LocalFile> = manifest
609            .iter()
610            .map(|(id, _)| {
611                (
612                    id.clone(),
613                    LocalFile {
614                        exists: true,
615                        size: 100,
616                    },
617                )
618            })
619            .collect();
620        let plan = reconcile(&manifest, &desired, &local, &sources);
621        let deleted: Vec<&str> = plan
622            .actions
623            .iter()
624            .filter_map(|a| match a {
625                Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
626                _ => None,
627            })
628            .collect();
629        assert_eq!(deleted, vec!["m-orphan"]);
630    }
631
632    // Test 7 (SYNC-8): a clip held by a Mirror and a Copy area is stamped
633    // `[Mirror, Copy]`, so build_desired carries the Copy protection.
634    #[test]
635    fn build_modes_by_id_copy_wins_and_dedups() {
636        let map = build_modes_by_id(&[
637            (SourceMode::Mirror, vec!["a".to_owned(), "b".to_owned()]),
638            (SourceMode::Copy, vec!["b".to_owned(), "c".to_owned()]),
639        ]);
640        assert_eq!(map["a"], vec![SourceMode::Mirror]);
641        assert_eq!(map["b"], vec![SourceMode::Mirror, SourceMode::Copy]);
642        assert_eq!(map["c"], vec![SourceMode::Copy]);
643    }
644}