1use std::collections::{BTreeSet, HashMap, HashSet};
8
9use crate::desired::{LIKED_PLAYLIST_ID, PlaylistInput, build_playlist_desired};
10use crate::graph::LineageStore;
11use crate::model::Clip;
12use crate::reconcile::{
13 Desired, PlaylistDesired, SourceStatus, area_authoritative, area_fully_enumerated,
14 deletion_allowed,
15};
16use crate::vocab::SourceMode;
17
18pub struct AreaListing {
27 kind: AreaKind,
28 mode: SourceMode,
30 clips: Vec<Clip>,
32 authoritative_ignoring_empty: bool,
36}
37
38pub enum AreaKind {
41 Library,
42 Liked,
43 Playlist { id: String, name: String },
44}
45
46impl AreaListing {
47 pub fn listed(
53 kind: AreaKind,
54 mode: SourceMode,
55 clips: Vec<Clip>,
56 complete: bool,
57 any_filtered: bool,
58 narrowed: bool,
59 ) -> Self {
60 Self {
61 kind,
62 mode,
63 clips,
64 authoritative_ignoring_empty: area_authoritative(complete, any_filtered, narrowed),
65 }
66 }
67
68 pub fn failed(kind: AreaKind, mode: SourceMode) -> Self {
71 Self {
72 kind,
73 mode,
74 clips: Vec::new(),
75 authoritative_ignoring_empty: false,
76 }
77 }
78
79 pub fn unresolved_playlist(mode: SourceMode) -> Self {
81 Self::failed(
82 AreaKind::Playlist {
83 id: String::new(),
84 name: String::new(),
85 },
86 mode,
87 )
88 }
89
90 pub fn clips(&self) -> &[Clip] {
92 &self.clips
93 }
94}
95
96pub fn area_mode(area: &AreaListing, force_copy: bool) -> SourceMode {
98 if force_copy {
99 SourceMode::Copy
100 } else {
101 area.mode
102 }
103}
104
105#[must_use]
108pub fn area_enumerated(area: &AreaListing, force_copy: bool) -> bool {
109 area_fully_enumerated(
110 area.authoritative_ignoring_empty,
111 area.clips.is_empty(),
112 area_mode(area, force_copy),
113 )
114}
115
116#[must_use]
119pub fn library_authoritative(areas: &[AreaListing], force_copy: bool) -> bool {
120 areas
121 .iter()
122 .any(|a| matches!(a.kind, AreaKind::Library) && area_enumerated(a, force_copy))
123}
124
125#[must_use]
127pub fn source_statuses(areas: &[AreaListing], force_copy: bool) -> Vec<SourceStatus> {
128 areas
129 .iter()
130 .map(|area| SourceStatus {
131 mode: area_mode(area, force_copy),
132 fully_enumerated: area_enumerated(area, force_copy),
133 })
134 .collect()
135}
136
137#[must_use]
146pub fn adoption_enumerated(areas: &[AreaListing], force_copy: bool) -> bool {
147 library_authoritative(areas, force_copy)
148 || deletion_allowed(&source_statuses(areas, force_copy))
149}
150
151pub fn union_clips(areas: &[AreaListing]) -> Vec<Clip> {
154 let mut seen: HashSet<String> = HashSet::new();
155 let mut union: Vec<Clip> = Vec::new();
156 for area in areas {
157 for clip in &area.clips {
158 if seen.insert(clip.id.clone()) {
159 union.push(clip.clone());
160 }
161 }
162 }
163 union
164}
165
166#[must_use]
172pub fn playlists_enumerated(areas: &[AreaListing], force_copy: bool, members_intact: bool) -> bool {
173 members_intact
174 && areas.iter().all(|area| match area.kind {
175 AreaKind::Library => true,
176 AreaKind::Liked | AreaKind::Playlist { .. } => area_enumerated(area, force_copy),
177 })
178}
179
180pub fn build_scoped_playlist_desired(
187 areas: &[AreaListing],
188 desired: &[Desired],
189 store: &LineageStore,
190 protected: &mut BTreeSet<String>,
191 force_copy: bool,
192 members_intact: bool,
193) -> (Vec<PlaylistDesired>, bool) {
194 let mut owned: Vec<(String, String, Vec<Clip>)> = Vec::new();
195 for area in areas {
196 match &area.kind {
197 AreaKind::Playlist { id, name } => {
198 if members_intact && !id.is_empty() && area_enumerated(area, force_copy) {
199 owned.push((id.clone(), name.clone(), area.clips.clone()));
200 } else if !id.is_empty() {
201 protected.insert(id.clone());
202 }
203 }
204 AreaKind::Liked => {
205 if members_intact && area_enumerated(area, force_copy) {
206 owned.push((
207 LIKED_PLAYLIST_ID.to_owned(),
208 "Liked Songs".to_owned(),
209 area.clips.clone(),
210 ));
211 } else {
212 protected.insert(LIKED_PLAYLIST_ID.to_owned());
213 }
214 }
215 AreaKind::Library => {}
216 }
217 }
218 let rendered: BTreeSet<&str> = owned.iter().map(|(id, _, _)| id.as_str()).collect();
219 for id in store.playlists.keys() {
222 if !rendered.contains(id.as_str()) {
223 protected.insert(id.clone());
224 }
225 }
226 let inputs: Vec<PlaylistInput<'_>> = owned
227 .iter()
228 .map(|(id, name, members)| PlaylistInput {
229 id: id.as_str(),
230 name: name.as_str(),
231 members: members.as_slice(),
232 })
233 .collect();
234 (
235 build_playlist_desired(&inputs, desired),
236 playlists_enumerated(areas, force_copy, members_intact),
237 )
238}
239
240pub fn build_modes_by_id(
249 areas: &[AreaListing],
250 force_copy: bool,
251) -> HashMap<String, Vec<SourceMode>> {
252 let mut map: HashMap<String, (bool, bool)> = HashMap::new();
253 for area in areas {
254 let mode = area_mode(area, force_copy);
255 for clip in &area.clips {
256 let entry = map.entry(clip.id.clone()).or_insert((false, false));
257 match mode {
258 SourceMode::Mirror => entry.0 = true,
259 SourceMode::Copy => entry.1 = true,
260 }
261 }
262 }
263 map.into_iter()
264 .map(|(id, (mirror, copy))| {
265 let mut modes = Vec::new();
266 if mirror {
267 modes.push(SourceMode::Mirror);
268 }
269 if copy {
270 modes.push(SourceMode::Copy);
271 }
272 (id, modes)
273 })
274 .collect()
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::{
281 Action, ArtifactToggles, AudioFormat, LocalFile, Manifest, ManifestEntry, NamingConfig,
282 build_desired, narrows_downloads, reconcile,
283 };
284
285 fn tclip(id: &str) -> Clip {
286 Clip {
287 id: id.to_owned(),
288 title: "Song".to_owned(),
289 handle: "alice".to_owned(),
290 ..Default::default()
291 }
292 }
293
294 fn area(kind: AreaKind, mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
295 AreaListing {
296 kind,
297 mode,
298 clips: ids.iter().map(|id| tclip(id)).collect(),
299 authoritative_ignoring_empty: authoritative,
300 }
301 }
302
303 #[test]
307 fn empty_mirror_area_is_not_enumerated() {
308 let mirror = area(AreaKind::Liked, SourceMode::Mirror, &[], true);
309 assert!(!area_enumerated(&mirror, false));
310 let copy = area(AreaKind::Liked, SourceMode::Copy, &[], true);
311 assert!(area_enumerated(©, false));
312 let full = area(AreaKind::Liked, SourceMode::Mirror, &["x"], true);
314 assert!(area_enumerated(&full, false));
315 }
316
317 #[test]
321 fn adoption_enumerated_covers_a_mirror_playlist_under_library_off() {
322 let playlist = |mode, ids: &[&str], auth| {
323 area(
324 AreaKind::Playlist {
325 id: "p".into(),
326 name: "P".into(),
327 },
328 mode,
329 ids,
330 auth,
331 )
332 };
333 assert!(adoption_enumerated(
335 &[playlist(SourceMode::Mirror, &["pl"], true)],
336 false
337 ));
338 assert!(!adoption_enumerated(
340 &[playlist(SourceMode::Copy, &["pl"], true)],
341 false
342 ));
343 assert!(!adoption_enumerated(
345 &[playlist(SourceMode::Mirror, &[], true)],
346 false
347 ));
348 assert!(!adoption_enumerated(
350 &[playlist(SourceMode::Mirror, &["pl"], false)],
351 false
352 ));
353 assert!(!adoption_enumerated(
355 &[playlist(SourceMode::Mirror, &["pl"], true)],
356 true
357 ));
358 assert!(adoption_enumerated(
360 &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
361 false,
362 ));
363 }
364
365 #[test]
368 fn library_authoritative_counts_protector_not_off() {
369 let with_protector = vec![
370 area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
371 area(
372 AreaKind::Playlist {
373 id: "p".into(),
374 name: "P".into(),
375 },
376 SourceMode::Mirror,
377 &["pl"],
378 true,
379 ),
380 ];
381 assert!(library_authoritative(&with_protector, false));
382
383 let off = vec![area(
384 AreaKind::Playlist {
385 id: "p".into(),
386 name: "P".into(),
387 },
388 SourceMode::Mirror,
389 &["pl"],
390 true,
391 )];
392 assert!(!library_authoritative(&off, false));
393 }
394
395 fn verdict(areas: &[AreaListing]) -> (bool, bool, bool) {
398 let can_delete = deletion_allowed(&source_statuses(areas, false));
399 let lib_auth = library_authoritative(areas, false);
400 (
401 can_delete,
402 lib_auth,
403 narrows_downloads(can_delete, lib_auth),
404 )
405 }
406
407 fn pl_area(mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
408 area(
409 AreaKind::Playlist {
410 id: "p".into(),
411 name: "P".into(),
412 },
413 mode,
414 ids,
415 authoritative,
416 )
417 }
418
419 #[test]
422 fn narrowed_playlist_mirror_disarms_deletion() {
423 let narrowed = pl_area(
424 SourceMode::Mirror,
425 &["a"],
426 area_authoritative(true, false, true),
427 );
428 assert!(!area_enumerated(&narrowed, false));
429 assert!(!deletion_allowed(&source_statuses(&[narrowed], false)));
430
431 let full = pl_area(
432 SourceMode::Mirror,
433 &["a"],
434 area_authoritative(true, false, false),
435 );
436 assert!(area_enumerated(&full, false));
437 assert!(deletion_allowed(&source_statuses(&[full], false)));
438 }
439
440 #[test]
444 fn narrowed_playlist_with_protector_neither_deletes_nor_narrows() {
445 let areas = vec![
446 area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
447 pl_area(
448 SourceMode::Mirror,
449 &["pl"],
450 area_authoritative(true, false, true),
451 ),
452 ];
453 let (can_delete, lib_auth, truncate) = verdict(&areas);
454 assert!(!can_delete, "narrowed playlist mirror is disarmed");
455 assert!(lib_auth, "the protector is an authoritative library");
456 assert!(
457 !truncate,
458 "the full library is listed, so downloads are not narrowed"
459 );
460 }
461
462 #[test]
466 fn narrowed_playlist_off_disarms_and_narrows() {
467 let areas = vec![pl_area(
468 SourceMode::Mirror,
469 &["pl"],
470 area_authoritative(true, false, true),
471 )];
472 let (can_delete, lib_auth, truncate) = verdict(&areas);
473 assert!(!can_delete, "narrowed playlist mirror is disarmed");
474 assert!(!lib_auth, "library=off leaves no library area");
475 assert!(
476 truncate,
477 "no armed deletion and no full library, so downloads narrow"
478 );
479 }
480
481 #[test]
486 fn configured_full_library_mirror_still_deletes_when_narrowed() {
487 let areas = vec![area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)];
488 let (can_delete, lib_auth, truncate) = verdict(&areas);
489 assert!(
490 can_delete,
491 "the configured full-library mirror still deletes"
492 );
493 assert!(lib_auth);
494 assert!(
495 !truncate,
496 "the full library is listed, so downloads are not narrowed"
497 );
498 }
499
500 #[test]
504 fn adoption_skips_pin_on_a_narrowed_library_off_playlist() {
505 let areas = vec![pl_area(
506 SourceMode::Mirror,
507 &["pl"],
508 area_authoritative(true, false, true),
509 )];
510 assert!(!adoption_enumerated(&areas, false));
511 }
512
513 #[test]
516 fn union_keeps_first_area_payload() {
517 let mut lib = tclip("shared");
518 lib.title = "Library".to_owned();
519 let mut pl = tclip("shared");
520 pl.title = "Playlist".to_owned();
521 let areas = vec![
522 AreaListing {
523 kind: AreaKind::Library,
524 mode: SourceMode::Copy,
525 clips: vec![lib, tclip("lib-only")],
526 authoritative_ignoring_empty: true,
527 },
528 AreaListing {
529 kind: AreaKind::Playlist {
530 id: "p".into(),
531 name: "P".into(),
532 },
533 mode: SourceMode::Mirror,
534 clips: vec![pl],
535 authoritative_ignoring_empty: true,
536 },
537 ];
538 let union = union_clips(&areas);
539 assert_eq!(union.len(), 2);
540 assert_eq!(union[0].id, "shared");
541 assert_eq!(union[0].title, "Library");
542 assert_eq!(union[1].id, "lib-only");
543 }
544
545 #[test]
546 fn a_failed_area_suppresses_deletion_for_the_run() {
547 let areas = [
548 area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
549 area(
551 AreaKind::Playlist {
552 id: "p".into(),
553 name: "P".into(),
554 },
555 SourceMode::Mirror,
556 &[],
557 false,
558 ),
559 ];
560 let sources: Vec<SourceStatus> = areas
561 .iter()
562 .map(|a| SourceStatus {
563 mode: area_mode(a, false),
564 fully_enumerated: area_enumerated(a, false),
565 })
566 .collect();
567 assert!(!deletion_allowed(&sources));
568 }
569
570 #[test]
574 fn mixed_mode_deletes_only_mirror_exclusive_orphans() {
575 let areas = vec![
576 area(AreaKind::Liked, SourceMode::Mirror, &["m-live"], true),
577 area(
578 AreaKind::Playlist {
579 id: "p".into(),
580 name: "P".into(),
581 },
582 SourceMode::Copy,
583 &["c-live"],
584 true,
585 ),
586 ];
587 let sources: Vec<SourceStatus> = areas
588 .iter()
589 .map(|a| SourceStatus {
590 mode: area_mode(a, false),
591 fully_enumerated: area_enumerated(a, false),
592 })
593 .collect();
594 assert!(deletion_allowed(&sources));
595
596 let modes = build_modes_by_id(&areas, false);
597 let union = union_clips(&areas);
598 let desired = build_desired(
599 &union.iter().collect::<Vec<_>>(),
600 AudioFormat::Flac,
601 &modes,
602 &HashMap::new(),
603 &BTreeSet::new(),
604 ArtifactToggles::default(),
605 &NamingConfig::default(),
606 );
607
608 let mut manifest = Manifest::new();
609 for id in ["m-live", "c-live", "m-orphan", "c-orphan"] {
611 manifest.insert(
612 id,
613 ManifestEntry {
614 path: format!("{id}.flac"),
615 format: AudioFormat::Flac,
616 size: 100,
617 preserve: id == "c-orphan",
620 ..Default::default()
621 },
622 );
623 }
624 let local: HashMap<String, LocalFile> = manifest
625 .iter()
626 .map(|(id, _)| {
627 (
628 id.clone(),
629 LocalFile {
630 exists: true,
631 size: 100,
632 },
633 )
634 })
635 .collect();
636 let plan = reconcile(&manifest, &desired, &local, &sources);
637 let deleted: Vec<&str> = plan
638 .actions
639 .iter()
640 .filter_map(|a| match a {
641 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
642 _ => None,
643 })
644 .collect();
645 assert_eq!(deleted, vec!["m-orphan"]);
646 }
647
648 #[test]
651 fn build_modes_by_id_copy_wins_and_dedups() {
652 let areas = [
653 area(AreaKind::Liked, SourceMode::Mirror, &["a", "b"], true),
654 pl_area(SourceMode::Copy, &["b", "c"], true),
655 ];
656 let map = build_modes_by_id(&areas, false);
657 assert_eq!(map["a"], vec![SourceMode::Mirror]);
658 assert_eq!(map["b"], vec![SourceMode::Mirror, SourceMode::Copy]);
659 assert_eq!(map["c"], vec![SourceMode::Copy]);
660 }
661
662 #[test]
665 fn playlists_enumerated_gates_on_every_playlist_area() {
666 assert!(playlists_enumerated(
668 &[
669 area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
670 pl_area(SourceMode::Mirror, &["b"], true),
671 ],
672 false,
673 true,
674 ));
675
676 assert!(!playlists_enumerated(
678 &[
679 area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
680 AreaListing::unresolved_playlist(SourceMode::Mirror),
681 ],
682 false,
683 true,
684 ));
685
686 assert!(!playlists_enumerated(
688 &[
689 AreaListing::failed(AreaKind::Liked, SourceMode::Mirror),
690 pl_area(SourceMode::Mirror, &["b"], true),
691 ],
692 false,
693 true,
694 ));
695
696 assert!(!playlists_enumerated(
698 &[
699 area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
700 pl_area(SourceMode::Mirror, &["b"], true),
701 ],
702 false,
703 false,
704 ));
705 }
706
707 #[test]
710 fn playlists_enumerated_ignores_library_only_sets() {
711 assert!(playlists_enumerated(
712 &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
713 false,
714 true,
715 ));
716 assert!(!playlists_enumerated(
717 &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
718 false,
719 false,
720 ));
721 assert!(playlists_enumerated(&[], false, true));
722 assert!(!playlists_enumerated(&[], false, false));
723 }
724}