1use 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
15pub struct AreaListing {
24 kind: AreaKind,
25 mode: SourceMode,
27 clips: Vec<Clip>,
29 authoritative_ignoring_empty: bool,
33}
34
35pub enum AreaKind {
38 Library,
39 Liked,
40 Playlist { id: String, name: String },
41}
42
43impl AreaListing {
44 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 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 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 pub fn clips(&self) -> &[Clip] {
89 &self.clips
90 }
91}
92
93pub 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#[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#[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#[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#[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
148pub 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#[must_use]
169pub fn playlists_enumerated(areas: &[AreaListing], force_copy: bool, members_intact: bool) -> bool {
170 members_intact
171 && areas.iter().all(|area| match area.kind {
172 AreaKind::Library => true,
173 AreaKind::Liked | AreaKind::Playlist { .. } => area_enumerated(area, force_copy),
174 })
175}
176
177pub fn build_scoped_playlist_desired(
184 areas: &[AreaListing],
185 desired: &[Desired],
186 store: &LineageStore,
187 protected: &mut BTreeSet<String>,
188 force_copy: bool,
189 members_intact: bool,
190) -> (Vec<PlaylistDesired>, bool) {
191 let mut owned: Vec<(String, String, Vec<Clip>)> = Vec::new();
192 for area in areas {
193 match &area.kind {
194 AreaKind::Playlist { id, name } => {
195 if members_intact && !id.is_empty() && area_enumerated(area, force_copy) {
196 owned.push((id.clone(), name.clone(), area.clips.clone()));
197 } else if !id.is_empty() {
198 protected.insert(id.clone());
199 }
200 }
201 AreaKind::Liked => {
202 if members_intact && area_enumerated(area, force_copy) {
203 owned.push((
204 LIKED_PLAYLIST_ID.to_owned(),
205 "Liked Songs".to_owned(),
206 area.clips.clone(),
207 ));
208 } else {
209 protected.insert(LIKED_PLAYLIST_ID.to_owned());
210 }
211 }
212 AreaKind::Library => {}
213 }
214 }
215 let rendered: BTreeSet<&str> = owned.iter().map(|(id, _, _)| id.as_str()).collect();
216 for id in store.playlists.keys() {
219 if !rendered.contains(id.as_str()) {
220 protected.insert(id.clone());
221 }
222 }
223 let inputs: Vec<PlaylistInput<'_>> = owned
224 .iter()
225 .map(|(id, name, members)| PlaylistInput {
226 id: id.as_str(),
227 name: name.as_str(),
228 members: members.as_slice(),
229 })
230 .collect();
231 (
232 build_playlist_desired(&inputs, desired),
233 playlists_enumerated(areas, force_copy, members_intact),
234 )
235}
236
237pub fn build_modes_by_id(
246 areas: &[AreaListing],
247 force_copy: bool,
248) -> HashMap<String, Vec<SourceMode>> {
249 let mut map: HashMap<String, (bool, bool)> = HashMap::new();
250 for area in areas {
251 let mode = area_mode(area, force_copy);
252 for clip in &area.clips {
253 let entry = map.entry(clip.id.clone()).or_insert((false, false));
254 match mode {
255 SourceMode::Mirror => entry.0 = true,
256 SourceMode::Copy => entry.1 = true,
257 }
258 }
259 }
260 map.into_iter()
261 .map(|(id, (mirror, copy))| {
262 let mut modes = Vec::new();
263 if mirror {
264 modes.push(SourceMode::Mirror);
265 }
266 if copy {
267 modes.push(SourceMode::Copy);
268 }
269 (id, modes)
270 })
271 .collect()
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::{
278 Action, ArtifactToggles, AudioFormat, LocalFile, Manifest, ManifestEntry, NamingConfig,
279 build_desired, narrows_downloads, reconcile,
280 };
281
282 fn tclip(id: &str) -> Clip {
283 Clip {
284 id: id.to_owned(),
285 title: "Song".to_owned(),
286 handle: "alice".to_owned(),
287 ..Default::default()
288 }
289 }
290
291 fn area(kind: AreaKind, mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
292 AreaListing {
293 kind,
294 mode,
295 clips: ids.iter().map(|id| tclip(id)).collect(),
296 authoritative_ignoring_empty: authoritative,
297 }
298 }
299
300 #[test]
304 fn empty_mirror_area_is_not_enumerated() {
305 let mirror = area(AreaKind::Liked, SourceMode::Mirror, &[], true);
306 assert!(!area_enumerated(&mirror, false));
307 let copy = area(AreaKind::Liked, SourceMode::Copy, &[], true);
308 assert!(area_enumerated(©, false));
309 let full = area(AreaKind::Liked, SourceMode::Mirror, &["x"], true);
311 assert!(area_enumerated(&full, false));
312 }
313
314 #[test]
318 fn adoption_enumerated_covers_a_mirror_playlist_under_library_off() {
319 let playlist = |mode, ids: &[&str], auth| {
320 area(
321 AreaKind::Playlist {
322 id: "p".into(),
323 name: "P".into(),
324 },
325 mode,
326 ids,
327 auth,
328 )
329 };
330 assert!(adoption_enumerated(
332 &[playlist(SourceMode::Mirror, &["pl"], true)],
333 false
334 ));
335 assert!(!adoption_enumerated(
337 &[playlist(SourceMode::Copy, &["pl"], true)],
338 false
339 ));
340 assert!(!adoption_enumerated(
342 &[playlist(SourceMode::Mirror, &[], true)],
343 false
344 ));
345 assert!(!adoption_enumerated(
347 &[playlist(SourceMode::Mirror, &["pl"], false)],
348 false
349 ));
350 assert!(!adoption_enumerated(
352 &[playlist(SourceMode::Mirror, &["pl"], true)],
353 true
354 ));
355 assert!(adoption_enumerated(
357 &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
358 false,
359 ));
360 }
361
362 #[test]
365 fn library_authoritative_counts_protector_not_off() {
366 let with_protector = vec![
367 area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
368 area(
369 AreaKind::Playlist {
370 id: "p".into(),
371 name: "P".into(),
372 },
373 SourceMode::Mirror,
374 &["pl"],
375 true,
376 ),
377 ];
378 assert!(library_authoritative(&with_protector, false));
379
380 let off = vec![area(
381 AreaKind::Playlist {
382 id: "p".into(),
383 name: "P".into(),
384 },
385 SourceMode::Mirror,
386 &["pl"],
387 true,
388 )];
389 assert!(!library_authoritative(&off, false));
390 }
391
392 fn verdict(areas: &[AreaListing]) -> (bool, bool, bool) {
395 let can_delete = deletion_allowed(&source_statuses(areas, false));
396 let lib_auth = library_authoritative(areas, false);
397 (
398 can_delete,
399 lib_auth,
400 narrows_downloads(can_delete, lib_auth),
401 )
402 }
403
404 fn pl_area(mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
405 area(
406 AreaKind::Playlist {
407 id: "p".into(),
408 name: "P".into(),
409 },
410 mode,
411 ids,
412 authoritative,
413 )
414 }
415
416 #[test]
419 fn narrowed_playlist_mirror_disarms_deletion() {
420 let narrowed = pl_area(
421 SourceMode::Mirror,
422 &["a"],
423 area_authoritative(true, false, true),
424 );
425 assert!(!area_enumerated(&narrowed, false));
426 assert!(!deletion_allowed(&source_statuses(&[narrowed], false)));
427
428 let full = pl_area(
429 SourceMode::Mirror,
430 &["a"],
431 area_authoritative(true, false, false),
432 );
433 assert!(area_enumerated(&full, false));
434 assert!(deletion_allowed(&source_statuses(&[full], false)));
435 }
436
437 #[test]
441 fn narrowed_playlist_with_protector_neither_deletes_nor_narrows() {
442 let areas = vec![
443 area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
444 pl_area(
445 SourceMode::Mirror,
446 &["pl"],
447 area_authoritative(true, false, true),
448 ),
449 ];
450 let (can_delete, lib_auth, truncate) = verdict(&areas);
451 assert!(!can_delete, "narrowed playlist mirror is disarmed");
452 assert!(lib_auth, "the protector is an authoritative library");
453 assert!(
454 !truncate,
455 "the full library is listed, so downloads are not narrowed"
456 );
457 }
458
459 #[test]
463 fn narrowed_playlist_off_disarms_and_narrows() {
464 let areas = vec![pl_area(
465 SourceMode::Mirror,
466 &["pl"],
467 area_authoritative(true, false, true),
468 )];
469 let (can_delete, lib_auth, truncate) = verdict(&areas);
470 assert!(!can_delete, "narrowed playlist mirror is disarmed");
471 assert!(!lib_auth, "library=off leaves no library area");
472 assert!(
473 truncate,
474 "no armed deletion and no full library, so downloads narrow"
475 );
476 }
477
478 #[test]
483 fn configured_full_library_mirror_still_deletes_when_narrowed() {
484 let areas = vec![area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)];
485 let (can_delete, lib_auth, truncate) = verdict(&areas);
486 assert!(
487 can_delete,
488 "the configured full-library mirror still deletes"
489 );
490 assert!(lib_auth);
491 assert!(
492 !truncate,
493 "the full library is listed, so downloads are not narrowed"
494 );
495 }
496
497 #[test]
501 fn adoption_skips_pin_on_a_narrowed_library_off_playlist() {
502 let areas = vec![pl_area(
503 SourceMode::Mirror,
504 &["pl"],
505 area_authoritative(true, false, true),
506 )];
507 assert!(!adoption_enumerated(&areas, false));
508 }
509
510 #[test]
513 fn union_keeps_first_area_payload() {
514 let mut lib = tclip("shared");
515 lib.title = "Library".to_owned();
516 let mut pl = tclip("shared");
517 pl.title = "Playlist".to_owned();
518 let areas = vec![
519 AreaListing {
520 kind: AreaKind::Library,
521 mode: SourceMode::Copy,
522 clips: vec![lib, tclip("lib-only")],
523 authoritative_ignoring_empty: true,
524 },
525 AreaListing {
526 kind: AreaKind::Playlist {
527 id: "p".into(),
528 name: "P".into(),
529 },
530 mode: SourceMode::Mirror,
531 clips: vec![pl],
532 authoritative_ignoring_empty: true,
533 },
534 ];
535 let union = union_clips(&areas);
536 assert_eq!(union.len(), 2);
537 assert_eq!(union[0].id, "shared");
538 assert_eq!(union[0].title, "Library");
539 assert_eq!(union[1].id, "lib-only");
540 }
541
542 #[test]
543 fn a_failed_area_suppresses_deletion_for_the_run() {
544 let areas = [
545 area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
546 area(
548 AreaKind::Playlist {
549 id: "p".into(),
550 name: "P".into(),
551 },
552 SourceMode::Mirror,
553 &[],
554 false,
555 ),
556 ];
557 let sources: Vec<SourceStatus> = areas
558 .iter()
559 .map(|a| SourceStatus {
560 mode: area_mode(a, false),
561 fully_enumerated: area_enumerated(a, false),
562 })
563 .collect();
564 assert!(!deletion_allowed(&sources));
565 }
566
567 #[test]
571 fn mixed_mode_deletes_only_mirror_exclusive_orphans() {
572 let areas = vec![
573 area(AreaKind::Liked, SourceMode::Mirror, &["m-live"], true),
574 area(
575 AreaKind::Playlist {
576 id: "p".into(),
577 name: "P".into(),
578 },
579 SourceMode::Copy,
580 &["c-live"],
581 true,
582 ),
583 ];
584 let sources: Vec<SourceStatus> = areas
585 .iter()
586 .map(|a| SourceStatus {
587 mode: area_mode(a, false),
588 fully_enumerated: area_enumerated(a, false),
589 })
590 .collect();
591 assert!(deletion_allowed(&sources));
592
593 let modes = build_modes_by_id(&areas, false);
594 let union = union_clips(&areas);
595 let desired = build_desired(
596 &union.iter().collect::<Vec<_>>(),
597 AudioFormat::Flac,
598 &modes,
599 &HashMap::new(),
600 &BTreeSet::new(),
601 ArtifactToggles::default(),
602 &NamingConfig::default(),
603 );
604
605 let mut manifest = Manifest::new();
606 for id in ["m-live", "c-live", "m-orphan", "c-orphan"] {
608 manifest.insert(
609 id,
610 ManifestEntry {
611 path: format!("{id}.flac"),
612 format: AudioFormat::Flac,
613 size: 100,
614 preserve: id == "c-orphan",
617 ..Default::default()
618 },
619 );
620 }
621 let local: HashMap<String, LocalFile> = manifest
622 .iter()
623 .map(|(id, _)| {
624 (
625 id.clone(),
626 LocalFile {
627 exists: true,
628 size: 100,
629 },
630 )
631 })
632 .collect();
633 let plan = reconcile(&manifest, &desired, &local, &sources);
634 let deleted: Vec<&str> = plan
635 .actions
636 .iter()
637 .filter_map(|a| match a {
638 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
639 _ => None,
640 })
641 .collect();
642 assert_eq!(deleted, vec!["m-orphan"]);
643 }
644
645 #[test]
648 fn build_modes_by_id_copy_wins_and_dedups() {
649 let areas = [
650 area(AreaKind::Liked, SourceMode::Mirror, &["a", "b"], true),
651 pl_area(SourceMode::Copy, &["b", "c"], true),
652 ];
653 let map = build_modes_by_id(&areas, false);
654 assert_eq!(map["a"], vec![SourceMode::Mirror]);
655 assert_eq!(map["b"], vec![SourceMode::Mirror, SourceMode::Copy]);
656 assert_eq!(map["c"], vec![SourceMode::Copy]);
657 }
658
659 #[test]
662 fn playlists_enumerated_gates_on_every_playlist_area() {
663 assert!(playlists_enumerated(
665 &[
666 area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
667 pl_area(SourceMode::Mirror, &["b"], true),
668 ],
669 false,
670 true,
671 ));
672
673 assert!(!playlists_enumerated(
675 &[
676 area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
677 AreaListing::unresolved_playlist(SourceMode::Mirror),
678 ],
679 false,
680 true,
681 ));
682
683 assert!(!playlists_enumerated(
685 &[
686 AreaListing::failed(AreaKind::Liked, SourceMode::Mirror),
687 pl_area(SourceMode::Mirror, &["b"], true),
688 ],
689 false,
690 true,
691 ));
692
693 assert!(!playlists_enumerated(
695 &[
696 area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
697 pl_area(SourceMode::Mirror, &["b"], true),
698 ],
699 false,
700 false,
701 ));
702 }
703
704 #[test]
707 fn playlists_enumerated_ignores_library_only_sets() {
708 assert!(playlists_enumerated(
709 &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
710 false,
711 true,
712 ));
713 assert!(!playlists_enumerated(
714 &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
715 false,
716 false,
717 ));
718 assert!(playlists_enumerated(&[], false, true));
719 assert!(!playlists_enumerated(&[], false, false));
720 }
721}