use std::cmp::Ordering;
use std::collections::BTreeSet;
use super::disc::PlaylistSummary;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HiddenRule {
Short,
Looping,
}
impl HiddenRule {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Short => "short",
Self::Looping => "looping",
}
}
}
pub const MAX_SHORT_PLAYLIST_SECONDS: u32 = 86_400;
#[derive(Debug, Clone, PartialEq)]
pub struct PlaylistFilter {
pub filter_short_playlists: bool,
pub short_playlist_seconds: f64,
pub filter_looping_playlists: bool,
}
impl Default for PlaylistFilter {
fn default() -> Self {
Self {
filter_short_playlists: true,
short_playlist_seconds: 20.0,
filter_looping_playlists: true,
}
}
}
impl PlaylistFilter {
#[must_use]
pub const fn everything() -> Self {
Self {
filter_short_playlists: false,
short_playlist_seconds: 0.0,
filter_looping_playlists: false,
}
}
#[must_use]
pub fn classify(&self, playlist: &PlaylistSummary) -> Vec<HiddenRule> {
let mut rules = Vec::new();
if playlist.total_length < self.short_playlist_seconds {
rules.push(HiddenRule::Short);
}
if playlist.has_loops {
rules.push(HiddenRule::Looping);
}
rules
}
#[must_use]
pub fn keeps(&self, playlist: &PlaylistSummary) -> bool {
!self.classify(playlist).into_iter().any(|rule| self.drops(rule))
}
const fn drops(&self, rule: HiddenRule) -> bool {
match rule {
HiddenRule::Short => self.filter_short_playlists,
HiddenRule::Looping => self.filter_looping_playlists,
}
}
}
#[must_use]
pub fn hidden_by<'a>(
playlists: &'a [PlaylistSummary],
filter: &PlaylistFilter,
) -> Vec<(&'a PlaylistSummary, Vec<HiddenRule>)> {
let mut hidden: Vec<(&PlaylistSummary, Vec<HiddenRule>)> = playlists
.iter()
.map(|playlist| {
let rules =
filter.classify(playlist).into_iter().filter(|&rule| filter.drops(rule)).collect();
(playlist, rules)
})
.filter(|(_, rules): &(_, Vec<HiddenRule>)| !rules.is_empty())
.collect();
hidden.sort_by(|(a, _), (b, _)| presentation_cmp(a, b));
hidden
}
#[must_use]
pub fn presentation_cmp(a: &PlaylistSummary, b: &PlaylistSummary) -> Ordering {
b.total_length
.partial_cmp(&a.total_length)
.unwrap_or(Ordering::Equal)
.then_with(|| a.name.cmp(&b.name))
}
#[must_use]
pub fn presentation_groups(
playlists: &[PlaylistSummary],
filter: &PlaylistFilter,
) -> Vec<Vec<usize>> {
let clip_names: Vec<BTreeSet<&str>> = playlists
.iter()
.map(|playlist| playlist.clips.iter().map(|clip| clip.name.as_str()).collect())
.collect();
let mut sorted: Vec<(usize, &PlaylistSummary, &BTreeSet<&str>)> =
playlists.iter().zip(&clip_names).enumerate().map(|(i, (p, n))| (i, p, n)).collect();
sorted.sort_by(|x, y| presentation_cmp(x.1, y.1));
let mut groups: Vec<Vec<(usize, &BTreeSet<&str>)>> = Vec::new();
for (index, playlist, names) in sorted {
if !filter.keeps(playlist) {
continue;
}
let target = groups
.iter_mut()
.find(|group| group.iter().any(|(_, member)| !names.is_disjoint(member)));
match target {
Some(group) => group.push((index, names)),
None => groups.push(vec![(index, names)]),
}
}
groups.into_iter().map(|group| group.into_iter().map(|(index, _)| index).collect()).collect()
}
#[must_use]
pub fn presentation_order(playlists: &[PlaylistSummary], filter: &PlaylistFilter) -> Vec<usize> {
presentation_groups(playlists, filter).into_iter().flatten().collect()
}
#[must_use]
pub fn table_rows(playlists: &[PlaylistSummary], filter: &PlaylistFilter) -> Vec<(usize, usize)> {
presentation_groups(playlists, filter)
.into_iter()
.enumerate()
.flat_map(|(group, members)| {
members.into_iter().map(move |index| (group.saturating_add(1), index))
})
.collect()
}
#[must_use]
pub fn selection_stream_files(
playlists: &[PlaylistSummary],
selection: &[String],
) -> BTreeSet<String> {
let mut files = BTreeSet::new();
for name in selection {
if let Some(playlist) = playlists.iter().find(|playlist| &playlist.name == name) {
files.extend(playlist.clips.iter().map(|clip| clip.name.clone()));
}
}
files
}
#[must_use]
pub fn selection_order(playlists: &[PlaylistSummary], selection: &[String]) -> Vec<usize> {
selection
.iter()
.filter_map(|name| playlists.iter().position(|playlist| &playlist.name == name))
.collect()
}
#[must_use]
pub fn normalize_playlist_name(name: &str) -> String {
let upper = name.to_ascii_uppercase();
if upper.contains('.') { upper } else { format!("{upper}.MPLS") }
}
#[must_use]
pub fn named_selection(playlists: &[PlaylistSummary], requested: &[String]) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for raw in requested {
let name = normalize_playlist_name(raw);
if playlists.iter().any(|playlist| playlist.name == name) && !names.contains(&name) {
names.push(name);
}
}
names
}
#[cfg(test)]
mod tests {
use std::cmp::Ordering;
use proptest::prelude::{prop_assert, prop_assert_eq, proptest};
use super::{
HiddenRule, MAX_SHORT_PLAYLIST_SECONDS, PlaylistFilter, hidden_by, named_selection,
normalize_playlist_name, presentation_cmp, presentation_groups, presentation_order,
selection_order, selection_stream_files, table_rows,
};
use crate::bdrom::disc::{PlaylistSummary, fixtures};
fn playlist(name: &str, total_length: f64, has_loops: bool, clips: &[&str]) -> PlaylistSummary {
PlaylistSummary {
has_loops,
..fixtures::playlist(name, total_length, fixtures::clips(clips, total_length))
}
}
fn names(playlists: &[PlaylistSummary], order: &[usize]) -> Vec<String> {
order.iter().filter_map(|&i| playlists.get(i).map(|p| p.name.clone())).collect()
}
#[test]
fn orders_groups_by_first_appearance_of_a_shared_clip() {
let playlists = [
playlist("A.MPLS", 100.0, false, &["X.M2TS", "Y.M2TS"]),
playlist("B.MPLS", 50.0, false, &["Y.M2TS"]),
playlist("C.MPLS", 70.0, false, &["Z.M2TS"]),
];
let order = presentation_order(&playlists, &PlaylistFilter::default());
assert_eq!(names(&playlists, &order), ["A.MPLS", "B.MPLS", "C.MPLS"]);
}
#[test]
fn groups_chain_through_a_shared_member() {
let playlists = [
playlist("A.MPLS", 100.0, false, &["X.M2TS", "Y.M2TS"]),
playlist("B.MPLS", 50.0, false, &["Y.M2TS", "W.M2TS"]),
playlist("C.MPLS", 70.0, false, &["Z.M2TS"]),
playlist("D.MPLS", 30.0, false, &["W.M2TS"]),
];
let order = presentation_order(&playlists, &PlaylistFilter::default());
assert_eq!(names(&playlists, &order), ["A.MPLS", "B.MPLS", "D.MPLS", "C.MPLS"]);
}
#[test]
fn equal_lengths_fall_back_to_ordinal_names() {
let playlists = [
playlist("00010.MPLS", 60.0, false, &["B.M2TS"]),
playlist("00002.MPLS", 60.0, false, &["A.M2TS"]),
playlist("00001.MPLS", 30.0, false, &["C.M2TS"]),
];
let order = presentation_order(&playlists, &PlaylistFilter::default());
assert_eq!(names(&playlists, &order), ["00002.MPLS", "00010.MPLS", "00001.MPLS"]);
}
#[test]
fn short_filter_drops_below_the_threshold_only() {
let playlists = [
playlist("SHORT.MPLS", 19.999, false, &["A.M2TS"]),
playlist("EDGE.MPLS", 20.0, false, &["B.M2TS"]),
];
let order = presentation_order(&playlists, &PlaylistFilter::default());
assert_eq!(names(&playlists, &order), ["EDGE.MPLS"]);
let keep_short =
PlaylistFilter { filter_short_playlists: false, ..PlaylistFilter::default() };
assert_eq!(presentation_order(&playlists, &keep_short).len(), 2);
}
#[test]
fn looping_filter_is_independent_of_the_short_filter() {
let playlists = [
playlist("LOOP.MPLS", 100.0, true, &["A.M2TS"]),
playlist("PLAIN.MPLS", 90.0, false, &["B.M2TS"]),
];
let order = presentation_order(&playlists, &PlaylistFilter::default());
assert_eq!(names(&playlists, &order), ["PLAIN.MPLS"]);
let keep_loops =
PlaylistFilter { filter_looping_playlists: false, ..PlaylistFilter::default() };
let order = presentation_order(&playlists, &keep_loops);
assert_eq!(names(&playlists, &order), ["LOOP.MPLS", "PLAIN.MPLS"]);
let playlists =
[playlist("TINY.MPLS", 1.0, true, &["A.M2TS"]), playlist("B.MPLS", 2.0, false, &[])];
let order = presentation_order(&playlists, &PlaylistFilter::everything());
assert_eq!(order.len(), 2);
}
#[test]
fn empty_input_yields_an_empty_order() {
assert!(presentation_order(&[], &PlaylistFilter::default()).is_empty());
assert!(presentation_groups(&[], &PlaylistFilter::default()).is_empty());
}
#[test]
fn table_rows_pair_each_playlist_with_its_group_number() {
let playlists = [
playlist("A.MPLS", 100.0, false, &["X.M2TS", "Y.M2TS"]),
playlist("B.MPLS", 50.0, false, &["Y.M2TS"]),
playlist("C.MPLS", 70.0, false, &["Z.M2TS"]),
playlist("D.MPLS", 5.0, false, &["W.M2TS"]),
];
assert_eq!(table_rows(&playlists, &PlaylistFilter::default()), [(1, 0), (1, 1), (2, 2)]);
assert!(table_rows(&[], &PlaylistFilter::default()).is_empty());
}
#[test]
fn table_rows_follow_the_given_filter() {
let playlists = [
playlist("00001.MPLS", 3600.0, true, &["X.M2TS"]),
playlist("00002.MPLS", 5.0, false, &["Y.M2TS"]),
playlist("00003.MPLS", 60.0, false, &["Z.M2TS"]),
];
assert_eq!(table_rows(&playlists, &PlaylistFilter::default()), [(1, 2)]);
let keep_loops =
PlaylistFilter { filter_looping_playlists: false, ..PlaylistFilter::default() };
assert_eq!(table_rows(&playlists, &keep_loops), [(1, 0), (2, 2)]);
let keep_short =
PlaylistFilter { filter_short_playlists: false, ..PlaylistFilter::default() };
assert_eq!(table_rows(&playlists, &keep_short), [(1, 2), (2, 1)]);
assert_eq!(table_rows(&playlists, &PlaylistFilter::everything()).len(), 3);
}
fn selection_disc() -> [PlaylistSummary; 3] {
[
playlist("00000.MPLS", 100.0, false, &["A.M2TS"]),
playlist("00001.MPLS", 50.0, false, &["A.M2TS"]),
playlist("00002.MPLS", 70.0, false, &["B.M2TS", "C.M2TS"]),
]
}
#[test]
fn stream_files_union_the_selected_clips_once() {
let files = selection_stream_files(&selection_disc(), &["00002.MPLS".to_owned()]);
assert_eq!(files.into_iter().collect::<Vec<_>>(), ["B.M2TS", "C.M2TS"]);
let files = selection_stream_files(
&selection_disc(),
&["00000.MPLS".to_owned(), "00001.MPLS".to_owned(), "99999.MPLS".to_owned()],
);
assert_eq!(files.into_iter().collect::<Vec<_>>(), ["A.M2TS"]);
assert!(selection_stream_files(&selection_disc(), &[]).is_empty());
}
#[test]
fn selection_order_repeats_a_pick_and_skips_an_unknown_name() {
let selection = ["00002.MPLS".to_owned(), "00000.MPLS".to_owned(), "00002.MPLS".to_owned()];
assert_eq!(selection_order(&selection_disc(), &selection), [2, 0, 2]);
assert_eq!(selection_order(&selection_disc(), &["99999.MPLS".to_owned()]), [0_usize; 0]);
}
#[test]
fn playlist_names_normalize_to_the_model_spelling() {
assert_eq!(normalize_playlist_name("00800"), "00800.MPLS");
assert_eq!(normalize_playlist_name("00800.mpls"), "00800.MPLS");
assert_eq!(normalize_playlist_name("00800.MPLS"), "00800.MPLS");
assert_eq!(normalize_playlist_name("feature.m2ts"), "FEATURE.M2TS");
}
#[test]
fn named_selection_normalizes_dedupes_and_keeps_order() {
let disc = selection_disc();
let requested =
["00002".to_owned(), "00000.mpls".to_owned(), "99999".to_owned(), "00002".to_owned()];
assert_eq!(named_selection(&disc, &requested), ["00002.MPLS", "00000.MPLS"]);
assert!(named_selection(&disc, &["X".to_owned()]).is_empty());
assert!(named_selection(&disc, &[]).is_empty());
}
#[test]
fn presentation_cmp_orders_longest_first_then_by_name() {
let long = playlist("00010.MPLS", 100.0, false, &[]);
let short = playlist("00001.MPLS", 50.0, false, &[]);
let tie = playlist("00020.MPLS", 100.0, false, &[]);
assert_eq!(presentation_cmp(&long, &short), Ordering::Less);
assert_eq!(presentation_cmp(&short, &long), Ordering::Greater);
assert_eq!(presentation_cmp(&long, &tie), Ordering::Less);
let nan = playlist("00005.MPLS", f64::NAN, false, &[]);
assert_eq!(presentation_cmp(&nan, &long), Ordering::Less);
assert_eq!(presentation_cmp(&nan, &nan), Ordering::Equal);
}
#[test]
fn classify_names_the_matching_rules_short_first() {
let filter = PlaylistFilter::default();
assert_eq!(
filter.classify(&playlist("A.MPLS", 5.0, true, &[])),
[HiddenRule::Short, HiddenRule::Looping]
);
assert_eq!(filter.classify(&playlist("B.MPLS", 5.0, false, &[])), [HiddenRule::Short]);
assert_eq!(filter.classify(&playlist("C.MPLS", 100.0, true, &[])), [HiddenRule::Looping]);
assert!(filter.classify(&playlist("D.MPLS", 100.0, false, &[])).is_empty());
assert!(filter.classify(&playlist("E.MPLS", 20.0, false, &[])).is_empty());
}
#[test]
fn classify_reads_the_threshold_but_not_the_switches() {
let off = PlaylistFilter {
filter_short_playlists: false,
filter_looping_playlists: false,
..PlaylistFilter::default()
};
assert_eq!(
off.classify(&playlist("A.MPLS", 5.0, true, &[])),
[HiddenRule::Short, HiddenRule::Looping]
);
let raised = PlaylistFilter { short_playlist_seconds: 60.0, ..PlaylistFilter::default() };
assert_eq!(raised.classify(&playlist("B.MPLS", 50.0, false, &[])), [HiddenRule::Short]);
}
#[test]
fn the_threshold_ceiling_is_one_day_and_zero_disables_the_short_rule() {
assert_eq!(MAX_SHORT_PLAYLIST_SECONDS, 86_400);
let ceiling = PlaylistFilter {
short_playlist_seconds: f64::from(MAX_SHORT_PLAYLIST_SECONDS),
..PlaylistFilter::default()
};
assert_eq!(
ceiling.classify(&playlist("A.MPLS", 86_399.0, false, &[])),
[HiddenRule::Short]
);
let off = PlaylistFilter { short_playlist_seconds: 0.0, ..PlaylistFilter::default() };
assert!(off.classify(&playlist("B.MPLS", 0.0, false, &[])).is_empty());
}
#[test]
fn labels_are_the_printed_rule_names() {
assert_eq!(HiddenRule::Short.label(), "short");
assert_eq!(HiddenRule::Looping.label(), "looping");
}
fn hidden_names(
hidden: &[(&PlaylistSummary, Vec<HiddenRule>)],
) -> Vec<(String, Vec<HiddenRule>)> {
hidden.iter().map(|(playlist, rules)| (playlist.name.clone(), rules.clone())).collect()
}
#[test]
fn hidden_by_pairs_each_withheld_playlist_with_the_rules_that_dropped_it() {
let playlists = [
playlist("00001.MPLS", 5765.0, true, &["A.M2TS"]),
playlist("00013.MPLS", 3600.0, false, &["B.M2TS"]),
playlist("00090.MPLS", 5.0, true, &["C.M2TS"]),
playlist("00091.MPLS", 10.0, false, &["D.M2TS"]),
];
assert_eq!(
hidden_names(&hidden_by(&playlists, &PlaylistFilter::default())),
[
("00001.MPLS".to_owned(), vec![HiddenRule::Looping]),
("00091.MPLS".to_owned(), vec![HiddenRule::Short]),
("00090.MPLS".to_owned(), vec![HiddenRule::Short, HiddenRule::Looping]),
]
);
let listed: Vec<usize> = table_rows(&playlists, &PlaylistFilter::default())
.into_iter()
.map(|(_, i)| i)
.collect();
assert_eq!(listed, [1]);
}
#[test]
fn hidden_by_names_only_the_rules_whose_switch_is_on() {
let playlists = [playlist("00090.MPLS", 5.0, true, &["C.M2TS"])];
let looping_only =
PlaylistFilter { filter_short_playlists: false, ..PlaylistFilter::default() };
assert_eq!(
hidden_names(&hidden_by(&playlists, &looping_only)),
[("00090.MPLS".to_owned(), vec![HiddenRule::Looping])]
);
let short_only =
PlaylistFilter { filter_looping_playlists: false, ..PlaylistFilter::default() };
assert_eq!(
hidden_names(&hidden_by(&playlists, &short_only)),
[("00090.MPLS".to_owned(), vec![HiddenRule::Short])]
);
}
#[test]
fn hidden_by_withholds_nothing_when_no_rule_matches_or_is_on() {
let playlists = [
playlist("00001.MPLS", 5765.0, true, &["A.M2TS"]),
playlist("00090.MPLS", 5.0, false, &["C.M2TS"]),
];
assert!(hidden_by(&playlists, &PlaylistFilter::everything()).is_empty());
let edge = [playlist("00014.MPLS", 20.0, false, &["E.M2TS"])];
assert!(hidden_by(&edge, &PlaylistFilter::default()).is_empty());
assert!(hidden_by(&[], &PlaylistFilter::default()).is_empty());
}
#[test]
fn presentation_groups_exposes_the_group_boundaries() {
let playlists = [
playlist("A.MPLS", 100.0, false, &["X.M2TS", "Y.M2TS"]),
playlist("B.MPLS", 50.0, false, &["Y.M2TS"]),
playlist("C.MPLS", 70.0, false, &["Z.M2TS"]),
];
let groups = presentation_groups(&playlists, &PlaylistFilter::default());
assert_eq!(groups, [vec![0, 1], vec![2]]);
}
proptest! {
#[test]
fn order_is_a_permutation_of_the_kept_indices(
lengths in proptest::collection::vec(0.0_f64..200.0, 0..12),
loops in proptest::collection::vec(proptest::bool::ANY, 0..12),
) {
let playlists: Vec<PlaylistSummary> = lengths
.iter()
.zip(loops.iter().chain(std::iter::repeat(&false)))
.enumerate()
.map(|(i, (&len, &lp))| playlist(&format!("{i:05}.MPLS"), len, lp, &[]))
.collect();
let filter = PlaylistFilter::default();
let mut order = presentation_order(&playlists, &filter);
order.sort_unstable();
let kept: Vec<usize> = playlists
.iter()
.enumerate()
.filter(|(_, p)| filter.keeps(p))
.map(|(i, _)| i)
.collect();
prop_assert_eq!(order, kept);
}
#[test]
fn everything_keeps_all_and_leads_with_the_longest(
lengths in proptest::collection::vec(0.0_f64..200.0, 1..12),
) {
let playlists: Vec<PlaylistSummary> = lengths
.iter()
.enumerate()
.map(|(i, &len)| playlist(&format!("{i:05}.MPLS"), len, false, &[]))
.collect();
let order = presentation_order(&playlists, &PlaylistFilter::everything());
prop_assert_eq!(order.len(), playlists.len());
let first = order.first().and_then(|&i| playlists.get(i));
let leads = first.is_some_and(|p| {
playlists.iter().all(|q| {
p.total_length > q.total_length
|| (p.total_length.to_bits() == q.total_length.to_bits()
&& p.name <= q.name)
})
});
prop_assert!(leads);
}
#[test]
fn keeps_iff_no_classified_rule_switch_is_on(
length in 0.0_f64..40.0,
has_loops in proptest::bool::ANY,
filter_short in proptest::bool::ANY,
filter_looping in proptest::bool::ANY,
threshold in 0.0_f64..40.0,
) {
let subject = playlist("00000.MPLS", length, has_loops, &[]);
let filter = PlaylistFilter {
filter_short_playlists: filter_short,
short_playlist_seconds: threshold,
filter_looping_playlists: filter_looping,
};
let via_rules = filter.classify(&subject).into_iter().all(|rule| match rule {
HiddenRule::Short => !filter.filter_short_playlists,
HiddenRule::Looping => !filter.filter_looping_playlists,
});
prop_assert_eq!(filter.keeps(&subject), via_rules);
let dropped_short = filter_short && length < threshold;
let dropped_looping = filter_looping && has_loops;
prop_assert_eq!(filter.keeps(&subject), !(dropped_short || dropped_looping));
}
#[test]
fn hidden_and_listed_partition_the_disc(
lengths in proptest::collection::vec(0.0_f64..40.0, 0..12),
loops in proptest::collection::vec(proptest::bool::ANY, 0..12),
filter_short in proptest::bool::ANY,
filter_looping in proptest::bool::ANY,
) {
let playlists: Vec<PlaylistSummary> = lengths
.iter()
.zip(loops.iter().chain(std::iter::repeat(&false)))
.enumerate()
.map(|(i, (&len, &lp))| playlist(&format!("{i:05}.MPLS"), len, lp, &[]))
.collect();
let filter = PlaylistFilter {
filter_short_playlists: filter_short,
filter_looping_playlists: filter_looping,
..PlaylistFilter::default()
};
let hidden = hidden_by(&playlists, &filter);
let listed = presentation_order(&playlists, &filter);
prop_assert_eq!(hidden.len().checked_add(listed.len()), Some(playlists.len()));
let withheld: Vec<&str> =
hidden.iter().map(|(playlist, _)| playlist.name.as_str()).collect();
for &index in &listed {
let name = playlists.get(index).map(|p| p.name.as_str());
prop_assert!(name.is_some_and(|name| !withheld.contains(&name)));
}
}
#[test]
fn order_is_deterministic(
lengths in proptest::collection::vec(0.0_f64..200.0, 0..12),
) {
let playlists: Vec<PlaylistSummary> = lengths
.iter()
.enumerate()
.map(|(i, &len)| playlist(&format!("{i:05}.MPLS"), len, false, &["S.M2TS"]))
.collect();
let filter = PlaylistFilter::default();
prop_assert_eq!(
presentation_order(&playlists, &filter),
presentation_order(&playlists, &filter)
);
}
}
}