Skip to main content

mfp_core/catalog/
query.rs

1//! Search and ordering over the catalog.
2//!
3//! Filtering to locally available episodes lives with the caller, the side that knows
4//! what the download cache holds.
5
6use crate::model::{Catalog, Episode};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum SortKey {
10    Published,
11    Title,
12    Duration,
13    /// The site's own ordering, which only enriched episodes carry.
14    Order,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SortDirection {
19    Ascending,
20    Descending,
21}
22
23/// Whether the episode matches a free-text query, case-insensitively, as a substring of
24/// its title or of any enriched text it has.
25///
26/// An empty query matches everything.
27pub fn matches(episode: &Episode, query: &str) -> bool {
28    matches_needle(episode, &needle(query))
29}
30
31/// The prepared form of a query: trimmed and lowercased once, rather than once per
32/// episode it is tested against.
33fn needle(query: &str) -> String {
34    query.trim().to_lowercase()
35}
36
37fn matches_needle(episode: &Episode, needle: &str) -> bool {
38    if needle.is_empty() {
39        return true;
40    }
41
42    let optional = [&episode.slug, &episode.body, &episode.tracklist];
43    contains(&episode.title, needle)
44        || optional
45            .into_iter()
46            .flatten()
47            .any(|field| contains(field, needle))
48}
49
50/// Whether `haystack` carries `lowercase_needle`, ignoring letter case.
51///
52/// Folds one character at a time rather than lowercasing a copy: a search runs this over
53/// every track listing, where the copies were the bulk of what a keystroke cost.
54fn contains(haystack: &str, lowercase_needle: &str) -> bool {
55    haystack.char_indices().any(|(offset, _)| {
56        let mut folded = haystack[offset..].chars().flat_map(char::to_lowercase);
57        let mut wanted = lowercase_needle.chars();
58        loop {
59            match (wanted.next(), folded.next()) {
60                (None, _) => return true,
61                (Some(_), None) => return false,
62                (Some(left), Some(right)) if left != right => return false,
63                _ => {}
64            }
65        }
66    })
67}
68
69/// Every episode matching the query, in the catalog's current order.
70pub fn search<'a>(catalog: &'a Catalog, query: &str) -> Vec<&'a Episode> {
71    let needle = needle(query);
72    catalog
73        .episodes
74        .iter()
75        .filter(|episode| matches_needle(episode, &needle))
76        .collect()
77}
78
79/// Every episode the predicate reports a complete local audio file for.
80///
81/// The predicate belongs to the caller: only the daemon knows what the download cache holds.
82pub fn filter<'a>(episodes: &[&'a Episode], keep: impl Fn(&Episode) -> bool) -> Vec<&'a Episode> {
83    episodes
84        .iter()
85        .copied()
86        .filter(|episode| keep(episode))
87        .collect()
88}
89
90/// Sorts totally and stably: equal episodes keep their relative order, and an episode
91/// missing the sort key is ordered deterministically rather than dropped.
92pub fn sort(episodes: &mut [&Episode], key: SortKey, direction: SortDirection) {
93    // `None` sorts before `Some`, so an episode missing the key lands at one end rather
94    // than in an arbitrary place
95    match key {
96        SortKey::Published => sort_by_key(episodes, direction, |episode| episode.published_at),
97        SortKey::Title => sort_by_key(episodes, direction, |episode| episode.title.to_lowercase()),
98        SortKey::Duration => sort_by_key(episodes, direction, |episode| episode.duration_secs),
99        SortKey::Order => sort_by_key(episodes, direction, |episode| episode.order),
100    }
101}
102
103/// Sorts on a key computed once per episode rather than once per comparison, which is
104/// what a lowercased title costs when it is folded inside the comparator.
105fn sort_by_key<K: Ord>(
106    episodes: &mut [&Episode],
107    direction: SortDirection,
108    key: impl Fn(&Episode) -> K,
109) {
110    let mut decorated: Vec<(K, &Episode)> = episodes
111        .iter()
112        .map(|episode| (key(episode), *episode))
113        .collect();
114    decorated.sort_by(|left, right| {
115        let ordering = left.0.cmp(&right.0);
116        match direction {
117            SortDirection::Ascending => ordering,
118            SortDirection::Descending => ordering.reverse(),
119        }
120    });
121    for (slot, (_, episode)) in episodes.iter_mut().zip(decorated) {
122        *slot = episode;
123    }
124}
125
126/// The totals the site displays across the whole catalog.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct Statistics {
129    pub episodes: usize,
130    /// How many tracks are listed across every available track listing.
131    pub tracks: usize,
132    /// The summed duration of every episode, in whole seconds.
133    ///
134    /// Raw seconds: the site's own hour figure is a full day too high, so the presentation
135    /// layer divides this rather than copying that arithmetic.
136    pub duration_secs: u64,
137}
138
139/// Track listings come from best-effort enrichment, so a catalog carrying none reports no
140/// tracks rather than failing.
141pub fn statistics(catalog: &Catalog) -> Statistics {
142    Statistics {
143        episodes: catalog.episodes.len(),
144        // one track per line, which is what the site counts as one `<br>`
145        tracks: catalog
146            .episodes
147            .iter()
148            .filter_map(|episode| episode.tracklist.as_deref())
149            .flat_map(str::lines)
150            .filter(|line| !line.trim().is_empty())
151            .count(),
152        duration_secs: catalog
153            .episodes
154            .iter()
155            .map(|episode| episode.duration_secs)
156            .sum(),
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    const FEED: &str = include_str!("../../tests/fixtures/rss.xml");
165    const BUNDLE: &str = include_str!("../../tests/fixtures/client.js");
166
167    fn enriched_catalog() -> Catalog {
168        let mut episodes = super::super::feed::parse(FEED).unwrap();
169        let enrichment = super::super::enrich::enrich_from(BUNDLE, &mut episodes);
170        Catalog {
171            info: enrichment.info,
172            episodes,
173            fetched_at: 0,
174            enriched: enrichment.applied,
175        }
176    }
177
178    fn episode(title: &str, published_at: i64, duration_secs: u64) -> Episode {
179        Episode {
180            bundle_title: None,
181            special: false,
182            title: title.into(),
183            link: String::new(),
184            enclosure_url: format!("https://datashat.net/{title}.mp3"),
185            byte_len: 0,
186            duration_secs,
187            published_at,
188            slug: None,
189            order: None,
190            tracklist: None,
191            body: None,
192            links: None,
193        }
194    }
195
196    fn titles(episodes: &[&Episode]) -> Vec<String> {
197        episodes
198            .iter()
199            .map(|episode| episode.title.clone())
200            .collect()
201    }
202
203    #[test]
204    fn a_query_matches_a_title_in_any_letter_case() {
205        let catalog = enriched_catalog();
206
207        let lower = titles(&search(&catalog, "corticyte"));
208        let upper = titles(&search(&catalog, "CORTICYTE"));
209        let mixed = titles(&search(&catalog, "CoRtIcYtE"));
210
211        assert!(lower.contains(&"Episode 79: Corticyte".to_owned()));
212        assert_eq!(lower, upper);
213        assert_eq!(lower, mixed);
214    }
215
216    #[test]
217    fn a_query_matches_a_title_whose_letters_are_not_ascii() {
218        let episode = episode("Épisode ÜBER Straße", 1, 1);
219
220        assert!(matches(&episode, "épisode"));
221        assert!(matches(&episode, "ÜBER"));
222        assert!(matches(&episode, "straße"));
223        assert!(!matches(&episode, "strasse"));
224    }
225
226    #[test]
227    fn every_episode_whose_title_carries_the_query_is_returned() {
228        let catalog = enriched_catalog();
229
230        let matched = titles(&search(&catalog, "seventy"));
231
232        // the query appears in no title, only in the slugs of episodes 70 to 79
233        assert_eq!(matched.len(), 10);
234        assert!(matched.contains(&"Episode 70: THINGS DISAPPEAR".to_owned()));
235    }
236
237    #[test]
238    fn a_query_matches_text_that_only_a_track_listing_carries() {
239        let catalog = enriched_catalog();
240
241        let matched = search(&catalog, "frog pocket");
242
243        assert!(!matched.is_empty());
244        for episode in &matched {
245            assert!(
246                !episode.title.to_lowercase().contains("frog pocket"),
247                "{} matched on its title, not its track listing",
248                episode.title
249            );
250            assert!(
251                episode
252                    .tracklist
253                    .as_deref()
254                    .unwrap()
255                    .to_lowercase()
256                    .contains("frog pocket")
257            );
258        }
259    }
260
261    #[test]
262    fn searching_un_enriched_episodes_matches_on_the_title_and_does_not_fail() {
263        let catalog = Catalog {
264            info: Vec::new(),
265            episodes: vec![episode("Episode 79: Corticyte", 3, 10)],
266            fetched_at: 0,
267            enriched: false,
268        };
269
270        assert_eq!(search(&catalog, "corticyte").len(), 1);
271        assert_eq!(search(&catalog, "frog pocket").len(), 0);
272    }
273
274    #[test]
275    fn a_query_matching_nothing_returns_an_empty_result() {
276        let catalog = enriched_catalog();
277        assert!(search(&catalog, "zzzz no such text zzzz").is_empty());
278    }
279
280    #[test]
281    fn an_empty_query_returns_every_episode() {
282        let catalog = enriched_catalog();
283        assert_eq!(search(&catalog, "").len(), 79);
284        assert_eq!(search(&catalog, "   ").len(), 79);
285    }
286
287    #[test]
288    fn a_query_matches_a_slug() {
289        let catalog = enriched_catalog();
290        assert_eq!(
291            titles(&search(&catalog, "seventynine")),
292            ["Episode 79: Corticyte"]
293        );
294    }
295
296    #[test]
297    fn search_preserves_the_catalogs_current_order() {
298        let catalog = enriched_catalog();
299        let matched = search(&catalog, "datassette");
300        let expected: Vec<String> = catalog
301            .episodes
302            .iter()
303            .filter(|episode| matches(episode, "datassette"))
304            .map(|episode| episode.title.clone())
305            .collect();
306        assert_eq!(titles(&matched), expected);
307    }
308
309    #[test]
310    fn sorting_by_publication_date_descending_puts_the_newest_first() {
311        let catalog = enriched_catalog();
312        let mut episodes = search(&catalog, "");
313
314        sort(&mut episodes, SortKey::Published, SortDirection::Descending);
315
316        assert_eq!(episodes.len(), 79);
317        assert_eq!(episodes[0].title, "Episode 79: Corticyte");
318        assert_eq!(episodes[78].title, "Episode 01: Datassette");
319        assert!(
320            episodes
321                .windows(2)
322                .all(|pair| pair[0].published_at >= pair[1].published_at)
323        );
324    }
325
326    #[test]
327    fn sorting_by_title_and_duration_covers_both_directions() {
328        let catalog = enriched_catalog();
329
330        for (key, direction) in [
331            (SortKey::Title, SortDirection::Ascending),
332            (SortKey::Title, SortDirection::Descending),
333            (SortKey::Duration, SortDirection::Ascending),
334            (SortKey::Duration, SortDirection::Descending),
335            (SortKey::Published, SortDirection::Ascending),
336            (SortKey::Order, SortDirection::Ascending),
337            (SortKey::Order, SortDirection::Descending),
338        ] {
339            let mut episodes = search(&catalog, "");
340            sort(&mut episodes, key, direction);
341            assert_eq!(episodes.len(), 79, "{key:?} {direction:?} dropped episodes");
342        }
343
344        let mut episodes = search(&catalog, "");
345        sort(&mut episodes, SortKey::Duration, SortDirection::Ascending);
346        assert!(
347            episodes
348                .windows(2)
349                .all(|pair| pair[0].duration_secs <= pair[1].duration_secs)
350        );
351
352        sort(&mut episodes, SortKey::Title, SortDirection::Ascending);
353        assert_eq!(episodes[0].title, "Episode 01: Datassette");
354    }
355
356    #[test]
357    fn sorting_is_stable_for_episodes_that_compare_equal() {
358        let all = [
359            episode("first", 10, 5),
360            episode("second", 10, 5),
361            episode("third", 10, 5),
362        ];
363        let mut episodes: Vec<&Episode> = all.iter().collect();
364
365        sort(&mut episodes, SortKey::Published, SortDirection::Ascending);
366        assert_eq!(titles(&episodes), ["first", "second", "third"]);
367
368        sort(&mut episodes, SortKey::Published, SortDirection::Descending);
369        assert_eq!(titles(&episodes), ["first", "second", "third"]);
370    }
371
372    #[test]
373    fn episodes_missing_the_sort_key_are_ordered_deterministically_not_dropped() {
374        let mut with_order = episode("enriched", 1, 1);
375        with_order.order = Some(7);
376        let all = [episode("bare-a", 1, 1), with_order, episode("bare-b", 1, 1)];
377        let mut episodes: Vec<&Episode> = all.iter().collect();
378
379        sort(&mut episodes, SortKey::Order, SortDirection::Ascending);
380        assert_eq!(titles(&episodes), ["bare-a", "bare-b", "enriched"]);
381
382        sort(&mut episodes, SortKey::Order, SortDirection::Descending);
383        assert_eq!(titles(&episodes), ["enriched", "bare-a", "bare-b"]);
384    }
385
386    #[test]
387    fn filtering_keeps_only_the_locally_available_episodes() {
388        let catalog = enriched_catalog();
389        let episodes = search(&catalog, "");
390        let downloaded = ["seventynine", "one", "two"];
391
392        let local = filter(&episodes, |episode| {
393            downloaded.contains(&episode.id().as_ref())
394        });
395
396        assert_eq!(local.len(), 3);
397        assert_eq!(
398            local.iter().map(|episode| episode.id()).collect::<Vec<_>>(),
399            ["seventynine", "two", "one"]
400        );
401    }
402
403    #[test]
404    fn the_totals_match_the_site_for_the_current_catalog() {
405        let catalog = enriched_catalog();
406
407        let statistics = statistics(&catalog);
408
409        assert_eq!(statistics.episodes, 79);
410        assert_eq!(statistics.tracks, 1380);
411        assert_eq!(statistics.duration_secs, 347_111);
412        // 96 hours, 25 minutes, and 11 seconds, and never the site's day-of-month sum
413        assert_eq!(statistics.duration_secs / 3_600, 96);
414        assert_eq!(statistics.duration_secs % 3_600 / 60, 25);
415        assert_eq!(statistics.duration_secs % 60, 11);
416    }
417
418    #[test]
419    fn statistics_without_enrichment_report_no_tracks_and_still_count_and_sum() {
420        let catalog = Catalog {
421            info: Vec::new(),
422            episodes: vec![episode("one", 1, 100), episode("two", 2, 250)],
423            fetched_at: 0,
424            enriched: false,
425        };
426
427        let statistics = statistics(&catalog);
428
429        assert_eq!(statistics.tracks, 0);
430        assert_eq!(statistics.episodes, 2);
431        assert_eq!(statistics.duration_secs, 350);
432    }
433
434    #[test]
435    fn the_information_pages_are_available_and_change_no_count_or_navigation() {
436        let catalog = enriched_catalog();
437
438        assert_eq!(catalog.info_page("about").unwrap().title, "About");
439        assert_eq!(catalog.info_page("credits").unwrap().title, "Credits");
440        assert!(catalog.info_page("nothing").is_none());
441
442        // counting, ordering, and next-or-previous are over episodes alone
443        assert_eq!(statistics(&catalog).episodes, 79);
444        assert_eq!(search(&catalog, "").len(), 79);
445        assert_eq!(catalog.episodes[0].title, "Episode 79: Corticyte");
446        assert!(catalog.get("about").is_none());
447        assert!(catalog.position("credits").is_none());
448        let position = catalog.position("sixtytwo").unwrap();
449        assert_eq!(catalog.episodes[position - 1].order, Some(63));
450        assert_eq!(catalog.episodes[position + 1].order, Some(61));
451    }
452
453    #[test]
454    fn filtering_with_nothing_downloaded_returns_nothing() {
455        let catalog = enriched_catalog();
456        let episodes = search(&catalog, "");
457        assert!(filter(&episodes, |_| false).is_empty());
458        assert_eq!(filter(&episodes, |_| true).len(), 79);
459    }
460}