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
163pub 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 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
220pub 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]
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(©, false));
287 let full = area(AreaKind::Liked, SourceMode::Mirror, &["x"], true);
289 assert!(area_enumerated(&full, false));
290 }
291
292 #[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 assert!(adoption_enumerated(
310 &[playlist(SourceMode::Mirror, &["pl"], true)],
311 false
312 ));
313 assert!(!adoption_enumerated(
315 &[playlist(SourceMode::Copy, &["pl"], true)],
316 false
317 ));
318 assert!(!adoption_enumerated(
320 &[playlist(SourceMode::Mirror, &[], true)],
321 false
322 ));
323 assert!(!adoption_enumerated(
325 &[playlist(SourceMode::Mirror, &["pl"], false)],
326 false
327 ));
328 assert!(!adoption_enumerated(
330 &[playlist(SourceMode::Mirror, &["pl"], true)],
331 true
332 ));
333 assert!(adoption_enumerated(
335 &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
336 false,
337 ));
338 }
339
340 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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]
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 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 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]
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}