Skip to main content

mfp_core/
model.rs

1//! The episode model and the catalog that holds it.
2//!
3//! An episode has two tiers of fields. The first six come from the authoritative RSS feed
4//! and are present for every episode. The rest come from best-effort enrichment out of the
5//! site's client bundle, and are `Option` so an absent value cannot be mistaken for a real
6//! one.
7
8use std::borrow::Cow;
9
10use serde::{Deserialize, Serialize};
11
12/// One episode of musicforprogramming.net.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Episode {
15    pub title: String,
16    /// The episode's page on the site.
17    pub link: String,
18    /// The audio enclosure URL, and the episode's stable identity: enrichment records join
19    /// to episodes by exact string equality against it.
20    pub enclosure_url: String,
21    /// The byte length the feed declares for the enclosure.
22    pub byte_len: u64,
23    /// The duration the feed declares in `itunes:duration`, in seconds.
24    ///
25    /// An approximate total, never a measured audio length. Consumers must present it as
26    /// approximate.
27    pub duration_secs: u64,
28    /// Publication time as whole seconds since the Unix epoch.
29    pub published_at: i64,
30
31    /// Site slug, when enrichment supplied one.
32    #[serde(default)]
33    pub slug: Option<String>,
34    /// The site's own catalog ordering, when enrichment supplied one.
35    #[serde(default)]
36    pub order: Option<u32>,
37    /// Track listing as plain text, one track per line, when enrichment supplied one.
38    #[serde(default)]
39    pub tracklist: Option<String>,
40    /// Descriptive body as plain text, when enrichment supplied one.
41    #[serde(default)]
42    pub body: Option<String>,
43    /// Related links as plain text, one per line, when enrichment supplied any.
44    #[serde(default)]
45    pub links: Option<String>,
46    /// The site's own title for this episode, when enrichment supplied one.
47    ///
48    /// The feed says `Episode 79: Corticyte` where the site says `79: Corticyte`, and
49    /// anything replicating the site's presentation wants the latter, which
50    /// [`Episode::site_title`] resolves.
51    #[serde(default)]
52    pub bundle_title: Option<String>,
53    /// Whether the site flags this episode for distinct colouring.
54    ///
55    /// Exactly one episode carries it today; absent enrichment every episode reports
56    /// `false`, so an unknown flag is never mistaken for a set one. Presentation only:
57    /// ordering, playback, and download all ignore it.
58    #[serde(default)]
59    pub special: bool,
60}
61
62impl Episode {
63    /// The episode's stable filesystem-safe identifier, used to name its on-disk artifacts
64    /// and as the `slug` clients pass over the wire.
65    ///
66    /// The enrichment slug where one exists, a deterministic derivation from the enclosure
67    /// URL otherwise, so the same episode resolves to the same name across runs whether or
68    /// not enrichment succeeded. Borrowed from the slug in the enriched case, so scanning
69    /// the catalog for one identifier allocates nothing.
70    pub fn id(&self) -> Cow<'_, str> {
71        match &self.slug {
72            Some(slug) => Cow::Borrowed(slug),
73            None => Cow::Owned(format!(
74                "ep-{:016x}",
75                fnv1a64(self.enclosure_url.as_bytes())
76            )),
77        }
78    }
79}
80
81impl Episode {
82    /// The title as the site itself writes it, which every view replicating the site draws.
83    ///
84    /// Enrichment's title where there is one, otherwise the feed's with its `Episode `
85    /// prefix stripped, since the feed writes `Episode 79: Corticyte` where the site writes
86    /// `79: Corticyte` - so an un-enriched catalog degrades to the same shape rather than
87    /// a visibly different one.
88    pub fn site_title(&self) -> &str {
89        match &self.bundle_title {
90            Some(title) => title,
91            None => site_title(&self.title),
92        }
93    }
94}
95
96/// The site's form of a title that may have come from the feed.
97///
98/// The feed writes `Episode 79: Corticyte` where the site writes `79: Corticyte`. Anything
99/// rendering the site's presentation from a title it did not get from enrichment - the
100/// marquee, which only ever sees the daemon's snapshot - normalises through this, so the
101/// two cannot drift.
102pub fn site_title(title: &str) -> &str {
103    title.strip_prefix("Episode ").unwrap_or(title)
104}
105
106/// FNV-1a over 64 bits.
107///
108/// Written out rather than taken from `DefaultHasher`, whose output std explicitly does
109/// not guarantee across releases, because on-disk names must survive a toolchain upgrade.
110fn fnv1a64(bytes: &[u8]) -> u64 {
111    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
112    for byte in bytes {
113        hash ^= u64::from(*byte);
114        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
115    }
116    hash
117}
118
119/// One of the site's non-episode pages, kept so the interface can present it as the site
120/// does.
121///
122/// Same enrichment source as track listings, but with no audio to join to, which is why
123/// these live beside [`Catalog::episodes`] rather than in it.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct InfoPage {
126    /// Site slug, which is this page's identity: `about` or `credits`.
127    pub slug: String,
128    /// Display title as the bundle gives it.
129    pub title: String,
130    pub body: String,
131}
132
133/// The resolved episode list together with when it was built.
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct Catalog {
136    /// Every episode the feed listed, in catalog order.
137    pub episodes: Vec<Episode>,
138    /// The site's information pages, keyed by their slug.
139    ///
140    /// Deliberately separate from `episodes`: every count, ordering, and next-or-previous
141    /// computation is over episodes alone, and keeping these out of that list makes it true
142    /// by construction rather than by remembering to filter.
143    #[serde(default)]
144    pub info: Vec<InfoPage>,
145    /// When this catalog was built from the network, as whole seconds since the Unix epoch.
146    /// A catalog read back from disk keeps its fetch time, so callers can tell how stale it
147    /// is.
148    pub fetched_at: i64,
149    /// Whether the client bundle enriched this catalog. False means every episode carries
150    /// feed fields only.
151    pub enriched: bool,
152}
153
154impl Catalog {
155    pub fn get(&self, id: &str) -> Option<&Episode> {
156        self.episodes.iter().find(|episode| episode.id() == id)
157    }
158
159    /// The index of the episode with this identifier, for `next` and `previous`.
160    pub fn position(&self, id: &str) -> Option<usize> {
161        self.episodes.iter().position(|episode| episode.id() == id)
162    }
163
164    /// How long ago this catalog was fetched, in seconds, relative to `now`.
165    pub fn age_secs(&self, now: i64) -> i64 {
166        (now - self.fetched_at).max(0)
167    }
168
169    /// The information page with this slug, if enrichment supplied one.
170    pub fn info_page(&self, slug: &str) -> Option<&InfoPage> {
171        self.info.iter().find(|page| page.slug == slug)
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn episode(slug: Option<&str>) -> Episode {
180        Episode {
181            bundle_title: None,
182            special: false,
183            title: "Episode 79".into(),
184            link: "https://musicforprogramming.net/seventynine".into(),
185            enclosure_url: "https://datashat.net/music_for_programming_79.mp3".into(),
186            byte_len: 441_000_000,
187            duration_secs: 14_400,
188            published_at: 1_700_000_000,
189            slug: slug.map(str::to_owned),
190            order: None,
191            tracklist: None,
192            body: None,
193            links: None,
194        }
195    }
196
197    #[test]
198    fn an_un_enriched_episode_reports_its_optional_fields_absent() {
199        let episode = episode(None);
200        assert!(episode.slug.is_none());
201        assert!(episode.order.is_none());
202        assert!(episode.tracklist.is_none());
203        assert!(episode.body.is_none());
204        assert!(episode.links.is_none());
205    }
206
207    #[test]
208    fn an_enriched_episode_is_identified_by_its_slug() {
209        assert_eq!(episode(Some("seventynine")).id(), "seventynine");
210    }
211
212    #[test]
213    fn an_enriched_identifier_borrows_its_slug_rather_than_copying_it() {
214        let episode = episode(Some("seventynine"));
215        assert!(matches!(episode.id(), Cow::Borrowed(_)));
216    }
217
218    #[test]
219    fn an_un_enriched_identifier_is_derived_from_the_enclosure_url() {
220        let episode = episode(None);
221        let id = episode.id();
222        assert_eq!(id, episode.id());
223        assert!(id.starts_with("ep-"));
224        assert!(
225            id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
226            "{id} is not filesystem-safe"
227        );
228    }
229
230    #[test]
231    fn different_enclosure_urls_get_different_identifiers() {
232        let mut other = episode(None);
233        other.enclosure_url = "https://datashat.net/music_for_programming_78.mp3".into();
234        assert_ne!(episode(None).id(), other.id());
235    }
236
237    #[test]
238    fn an_episode_round_trips_through_serde() {
239        let mut original = episode(Some("seventynine"));
240        original.order = Some(79);
241        original.tracklist = Some("A & B\nC".into());
242        let json = serde_json::to_string(&original).unwrap();
243        assert_eq!(serde_json::from_str::<Episode>(&json).unwrap(), original);
244    }
245
246    #[test]
247    fn optional_fields_default_to_absent_when_the_document_omits_them() {
248        let json = r#"{
249            "title": "Episode 79",
250            "link": "https://musicforprogramming.net/seventynine",
251            "enclosure_url": "https://datashat.net/music_for_programming_79.mp3",
252            "byte_len": 441000000,
253            "duration_secs": 14400,
254            "published_at": 1700000000
255        }"#;
256        assert_eq!(
257            serde_json::from_str::<Episode>(json).unwrap(),
258            episode(None)
259        );
260    }
261
262    #[test]
263    fn the_site_title_is_the_bundles_where_enrichment_supplied_one() {
264        let mut episode = episode(Some("seventynine"));
265        episode.title = "Episode 79: Corticyte".into();
266        episode.bundle_title = Some("79: Corticyte".into());
267        assert_eq!(episode.site_title(), "79: Corticyte");
268    }
269
270    #[test]
271    fn an_un_enriched_site_title_drops_the_feeds_episode_prefix() {
272        // the feed writes `Episode 79: Corticyte` where the site writes `79: Corticyte`,
273        // so an un-enriched catalog must not render a visibly different shape
274        let mut episode = episode(None);
275        episode.title = "Episode 79: Corticyte".into();
276        assert_eq!(episode.site_title(), "79: Corticyte");
277    }
278
279    #[test]
280    fn a_title_without_the_prefix_is_left_alone() {
281        let mut episode = episode(None);
282        episode.title = "79: Corticyte".into();
283        assert_eq!(episode.site_title(), "79: Corticyte");
284    }
285
286    #[test]
287    fn an_episode_is_not_special_unless_the_flag_says_so() {
288        assert!(!episode(None).special);
289    }
290
291    #[test]
292    fn a_document_omitting_the_special_flag_reads_it_as_unset() {
293        let json = r#"{
294            "title": "Episode 79",
295            "link": "https://musicforprogramming.net/seventynine",
296            "enclosure_url": "https://datashat.net/music_for_programming_79.mp3",
297            "byte_len": 441000000,
298            "duration_secs": 14400,
299            "published_at": 1700000000
300        }"#;
301        assert!(!serde_json::from_str::<Episode>(json).unwrap().special);
302    }
303
304    #[test]
305    fn information_pages_are_found_by_slug_and_kept_out_of_the_episode_list() {
306        let catalog = Catalog {
307            episodes: vec![episode(Some("seventynine"))],
308            info: vec![
309                InfoPage {
310                    slug: "about".into(),
311                    title: "About".into(),
312                    body: "Through years of trial and error".into(),
313                },
314                InfoPage {
315                    slug: "credits".into(),
316                    title: "Credits".into(),
317                    body: "Music For Programming is maintained by".into(),
318                },
319            ],
320            fetched_at: 0,
321            enriched: true,
322        };
323
324        assert_eq!(catalog.info_page("about").unwrap().title, "About");
325        assert_eq!(catalog.info_page("credits").unwrap().title, "Credits");
326        assert!(catalog.info_page("seventynine").is_none());
327
328        // the whole point of the separate collection: counting and navigating never see them
329        assert_eq!(catalog.episodes.len(), 1);
330        assert!(catalog.get("about").is_none());
331        assert!(catalog.position("credits").is_none());
332    }
333
334    #[test]
335    fn a_catalog_without_information_pages_is_still_a_catalog() {
336        let json = r#"{"episodes":[],"fetched_at":0,"enriched":false}"#;
337        let catalog: Catalog = serde_json::from_str(json).unwrap();
338        assert!(catalog.info.is_empty());
339        assert!(catalog.info_page("about").is_none());
340    }
341
342    #[test]
343    fn a_catalog_finds_episodes_and_reports_its_age() {
344        let catalog = Catalog {
345            info: Vec::new(),
346            episodes: vec![episode(Some("seventyeight")), episode(Some("seventynine"))],
347            fetched_at: 1_000,
348            enriched: true,
349        };
350        assert_eq!(catalog.position("seventynine"), Some(1));
351        assert_eq!(catalog.get("seventynine").unwrap().id(), "seventynine");
352        assert!(catalog.get("nope").is_none());
353        assert_eq!(catalog.age_secs(4_600), 3_600);
354        assert_eq!(catalog.age_secs(500), 0);
355    }
356}