1use std::borrow::Cow;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Episode {
15 pub title: String,
16 pub link: String,
18 pub enclosure_url: String,
21 pub byte_len: u64,
23 pub duration_secs: u64,
28 pub published_at: i64,
30
31 #[serde(default)]
33 pub slug: Option<String>,
34 #[serde(default)]
36 pub order: Option<u32>,
37 #[serde(default)]
39 pub tracklist: Option<String>,
40 #[serde(default)]
42 pub body: Option<String>,
43 #[serde(default)]
45 pub links: Option<String>,
46 #[serde(default)]
52 pub bundle_title: Option<String>,
53 #[serde(default)]
59 pub special: bool,
60}
61
62impl Episode {
63 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 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
96pub fn site_title(title: &str) -> &str {
103 title.strip_prefix("Episode ").unwrap_or(title)
104}
105
106fn 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct InfoPage {
126 pub slug: String,
128 pub title: String,
130 pub body: String,
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct Catalog {
136 pub episodes: Vec<Episode>,
138 #[serde(default)]
144 pub info: Vec<InfoPage>,
145 pub fetched_at: i64,
149 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 pub fn position(&self, id: &str) -> Option<usize> {
161 self.episodes.iter().position(|episode| episode.id() == id)
162 }
163
164 pub fn age_secs(&self, now: i64) -> i64 {
166 (now - self.fetched_at).max(0)
167 }
168
169 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 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 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}