restructure + first tests
This commit is contained in:
		
							
								
								
									
										1
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -723,6 +723,7 @@ dependencies = [ | ||||
|  "chrono", | ||||
|  "quick-xml", | ||||
|  "reqwest", | ||||
|  "serde", | ||||
|  "serde_json", | ||||
|  "tokio", | ||||
| ] | ||||
|   | ||||
| @@ -10,3 +10,4 @@ reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"], defau | ||||
| serde_json = "1" | ||||
| chrono = "0.4" | ||||
| quick-xml = "0.38" | ||||
| serde = { version = "1.0.228", features = ["derive"] } | ||||
|   | ||||
							
								
								
									
										19
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| # Plöyer (Ö1 player) | ||||
|  | ||||
| Creates an RSS Feed for some Ö1 journals.  | ||||
|  | ||||
| ## Motivation | ||||
|  | ||||
| This project started because of a simple goal: I wanted to listen to a few Ö1 episodes regularly. I had 2 requirements: | ||||
|  | ||||
| 1. Be able to live-stream (even if I want to start a few minutes after the episode has started). | ||||
| 2. Track the progress to be able to continue later. | ||||
|  | ||||
| [ORF already provides a similar](https://sound.orf.at/podcast/oe1/oe1-journale), but it creates the entry only ~15 minutes AFTER the episodes has ended. Thus, if it's 7:10 and I want to listen to e.g. `Morgenjournal` which starts at 7, I can't do that with the ORF feed. | ||||
| Another option is to use [the live player](https://oe1.orf.at/player/live). There seems to be an issue when I start playing the 7 am episode at 7:10; then (at least in October '25 and on my smartphone) it stops at 7:20 and I can only listen again if I refresh the page, but then I lose the current playtime and I have to seek to the time where I have been. Furthermore, if I only manage to listen to e.g. half the episode, chances are high that the time is not stored, because I refresh the page et al. | ||||
|  | ||||
| I tried a bunch of different options (webplayer + official feed; streaming the episodes to a mp3 file on my server, then serving this file; just redirecting to current episode, letting default webbrowser mp3 player handle everything; writing my own javascript player; using existing javascript player library to consistently stream the episodes) over some months. This project is what I've eventually landed on, it's quite stable for a few months (starting in September '25). It creates an RSS "Podcast" feed, which I consume on my phone with AntennaPod. | ||||
|  | ||||
| ## Data | ||||
|  | ||||
| In `https://audioapi.orf.at/oe1/api/json/current/broadcasts` you can find the broadcasts for the last 7 days---including the current day. Each broadcast has a `href` url attached. If you query this url, you can find details about this broadcast, e.g. under `streams` there's a `loopStreamId`. You can stream your episode from this url `https://loopstream01.apa.at/?channel=oe1&shoutcast=0&id={id}`. | ||||
							
								
								
									
										73
									
								
								src/fetch/broadcast.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/fetch/broadcast.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| use crate::Backend; | ||||
| use chrono::DateTime; | ||||
| use serde_json::Value; | ||||
|  | ||||
| impl Backend { | ||||
|     pub(crate) async fn get_broadcast( | ||||
|         &self, | ||||
|         url: String, | ||||
|     ) -> Result<Option<Broadcast>, Box<dyn std::error::Error>> { | ||||
|         let data: Value = match self { | ||||
|             Backend::Prod => reqwest::get(&url).await?.json().await?, | ||||
|             #[cfg(test)] | ||||
|             Backend::Test => todo!(), | ||||
|         }; | ||||
|         Broadcast::from_data(url, data).await | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone)] | ||||
| pub(crate) struct Broadcast { | ||||
|     pub(crate) url: String, | ||||
|     pub(crate) media_url: String, | ||||
|     pub(crate) title: String, | ||||
|     pub(crate) timestamp: DateTime<chrono::Utc>, | ||||
| } | ||||
|  | ||||
| impl Broadcast { | ||||
|     async fn from_data( | ||||
|         url: String, | ||||
|         data: Value, | ||||
|     ) -> Result<Option<Self>, Box<dyn std::error::Error>> { | ||||
|         let Some(streams) = data["streams"].as_array() else { | ||||
|             return Err(String::from("No 'streams' found").into()); | ||||
|         }; | ||||
|         if streams.is_empty() { | ||||
|             return Ok(None); | ||||
|         } | ||||
|         assert_eq!(streams.len(), 1); | ||||
|  | ||||
|         let Some(id) = streams[0]["loopStreamId"].as_str() else { | ||||
|             return Err(String::from("No 'loopStreamId' found").into()); | ||||
|         }; | ||||
|         let media_url = format!("https://loopstream01.apa.at/?channel=oe1&shoutcast=0&id={id}"); | ||||
|  | ||||
|         let Some(title) = data["title"].as_str() else { | ||||
|             return Err(format!("{url} has no title").into()); | ||||
|         }; | ||||
|  | ||||
|         let Some(timestamp) = data["start"].as_number() else { | ||||
|             return Err(format!("{url} has no start").into()); | ||||
|         }; | ||||
|         let Some(timestamp) = DateTime::from_timestamp(timestamp.as_i64().unwrap() / 1000, 0) | ||||
|         else { | ||||
|             return Err(format!( | ||||
|                 "broadcastDay in {url} not in a valid format (unix timestamp): {timestamp}" | ||||
|             ) | ||||
|             .into()); | ||||
|         }; | ||||
|  | ||||
|         Ok(Some(Self { | ||||
|             url, | ||||
|             media_url, | ||||
|             title: title.into(), | ||||
|             timestamp, | ||||
|         })) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl PartialEq for Broadcast { | ||||
|     fn eq(&self, other: &Self) -> bool { | ||||
|         self.url == other.url | ||||
|     } | ||||
| } | ||||
							
								
								
									
										2
									
								
								src/fetch/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/fetch/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| pub(super) mod broadcast; | ||||
| pub(super) mod overview; | ||||
							
								
								
									
										95
									
								
								src/fetch/overview.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/fetch/overview.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | ||||
| use crate::Backend; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| #[derive(Debug, Serialize, Deserialize)] | ||||
| struct InferenceResponse { | ||||
|     pub title: String, | ||||
|     pub href: String, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize, Deserialize)] | ||||
| struct InferenceDay { | ||||
|     pub broadcasts: Vec<InferenceResponse>, | ||||
| } | ||||
|  | ||||
| type InferenceData = Vec<InferenceDay>; | ||||
|  | ||||
| impl Backend { | ||||
|     /// Gets a list of all broadcasts | ||||
|     pub(crate) async fn get_broadcasts( | ||||
|         &self, | ||||
|         filter_titles: &[String], | ||||
|     ) -> Result<Vec<String>, Box<dyn std::error::Error>> { | ||||
|         let data: InferenceData = match self { | ||||
|             Backend::Prod => { | ||||
|                 let url = "https://audioapi.orf.at/oe1/api/json/current/broadcasts"; | ||||
|                 reqwest::get(url).await?.json::<InferenceData>().await? | ||||
|             } | ||||
|             #[cfg(test)] | ||||
|             Backend::Test => { | ||||
|                 vec![InferenceDay { | ||||
|                     broadcasts: vec![InferenceResponse { | ||||
|                         title: "test-title".into(), | ||||
|                         href: "test-href".into(), | ||||
|                     }], | ||||
|                 }] | ||||
|             } | ||||
|         }; | ||||
|         get_broadcasts(data, filter_titles).await | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Returns a list of urls of all filtered broadcasts. | ||||
| async fn get_broadcasts( | ||||
|     days: InferenceData, | ||||
|     filter_titles: &[String], | ||||
| ) -> Result<Vec<String>, Box<dyn std::error::Error>> { | ||||
|     let mut ret: Vec<String> = Vec::new(); | ||||
|  | ||||
|     for day in days { | ||||
|         for broadcast in day.broadcasts { | ||||
|             if filter_titles.is_empty() || filter_titles.contains(&broadcast.title) { | ||||
|                 { | ||||
|                     ret.push(broadcast.href); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     Ok(ret) | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use crate::Backend; | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn happy() { | ||||
|         let backend = Backend::Test; | ||||
|         let actual = backend.get_broadcasts(&[]).await.unwrap(); | ||||
|         assert_eq!(actual, vec!["test-href"]); | ||||
|     } | ||||
|  | ||||
|     #[ignore] | ||||
|     #[tokio::test] | ||||
|     async fn happy_prod() { | ||||
|         let backend = Backend::Prod; | ||||
|         let actual = backend.get_broadcasts(&[]).await.unwrap(); | ||||
|         assert!(actual.len() > 200); | ||||
|         assert!(actual[0].starts_with("https://audioapi.orf.at/oe1/api/json/4.0/broadcast/")) | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn filter_no_result() { | ||||
|         let backend = Backend::Test; | ||||
|         let actual = backend.get_broadcasts(&["no-match".into()]).await.unwrap(); | ||||
|         assert!(actual.is_empty()); | ||||
|     } | ||||
|  | ||||
|     #[tokio::test] | ||||
|     async fn filter_result() { | ||||
|         let backend = Backend::Test; | ||||
|         let actual = backend.get_broadcasts(&["test-href".into()]).await.unwrap(); | ||||
|         assert_eq!(actual, vec!["test-href"]); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										106
									
								
								src/lib.rs
									
									
									
									
									
								
							
							
						
						
									
										106
									
								
								src/lib.rs
									
									
									
									
									
								
							| @@ -1,8 +1,8 @@ | ||||
| mod fetch; | ||||
| mod rss; | ||||
| mod web; | ||||
|  | ||||
| use chrono::DateTime; | ||||
| use serde_json::Value; | ||||
| use fetch::broadcast::Broadcast; | ||||
| use std::sync::Arc; | ||||
| use tokio::{net::TcpListener, sync::RwLock}; | ||||
|  | ||||
| @@ -12,9 +12,12 @@ pub async fn start( | ||||
|     desc: String, | ||||
|     filter_titles: Vec<String>, | ||||
|     listener: TcpListener, | ||||
|     backend: Backend, | ||||
| ) -> Result<(), Box<dyn std::error::Error>> { | ||||
|     let state = Arc::new(RwLock::new( | ||||
|         Feed::new(title, link, desc, filter_titles).await.unwrap(), | ||||
|         Feed::new(title, link, desc, filter_titles, backend) | ||||
|             .await | ||||
|             .unwrap(), | ||||
|     )); | ||||
|  | ||||
|     web::serve(state, listener).await?; | ||||
| @@ -22,12 +25,19 @@ pub async fn start( | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| pub enum Backend { | ||||
|     Prod, | ||||
|     #[cfg(test)] | ||||
|     Test, | ||||
| } | ||||
|  | ||||
| struct Feed { | ||||
|     episodes: Vec<Broadcast>, | ||||
|     title: String, | ||||
|     link: String, | ||||
|     desc: String, | ||||
|     filter_titles: Vec<String>, | ||||
|     backend: Backend, | ||||
| } | ||||
|  | ||||
| impl Feed { | ||||
| @@ -36,6 +46,7 @@ impl Feed { | ||||
|         link: String, | ||||
|         desc: String, | ||||
|         filter_titles: Vec<String>, | ||||
|         backend: Backend, | ||||
|     ) -> Result<Self, Box<dyn std::error::Error>> { | ||||
|         let mut ret = Self { | ||||
|             episodes: Vec::new(), | ||||
| @@ -43,6 +54,7 @@ impl Feed { | ||||
|             link, | ||||
|             desc, | ||||
|             filter_titles, | ||||
|             backend, | ||||
|         }; | ||||
|  | ||||
|         ret.fetch().await?; | ||||
| @@ -51,11 +63,11 @@ impl Feed { | ||||
|     } | ||||
|  | ||||
|     async fn fetch(&mut self) -> Result<(), Box<dyn std::error::Error>> { | ||||
|         let broadcasts = self.get_all_broadcasts().await?; | ||||
|         let broadcasts = self.backend.get_broadcasts(&self.filter_titles).await?; | ||||
|  | ||||
|         for broadcast in broadcasts { | ||||
|             if !self.has_broadcast_url(&broadcast) { | ||||
|                 if let Some(broadcast) = Broadcast::from_url(broadcast).await.unwrap() { | ||||
|                 if let Some(broadcast) = self.backend.get_broadcast(broadcast).await.unwrap() { | ||||
|                     self.episodes.push(broadcast); | ||||
|                 } else { | ||||
|                     return Ok(()); | ||||
| @@ -76,88 +88,4 @@ impl Feed { | ||||
|     fn has_broadcast_url(&self, url: &str) -> bool { | ||||
|         self.episodes.iter().any(|e| e.url == url) | ||||
|     } | ||||
|  | ||||
|     async fn get_all_broadcasts(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> { | ||||
|         // List of broadcasts: https://audioapi.orf.at/oe1/api/json/current/broadcasts | ||||
|         // | ||||
|         // ^ contains link, e.g. https://audioapi.orf.at/oe1/api/json/4.0/broadcast/797577/20250611 | ||||
|         let url = "https://audioapi.orf.at/oe1/api/json/current/broadcasts"; | ||||
|         let data: Value = reqwest::get(url).await?.json().await?; | ||||
|  | ||||
|         let mut ret: Vec<String> = Vec::new(); | ||||
|  | ||||
|         if let Some(days) = data.as_array() { | ||||
|             for day in days { | ||||
|                 if let Some(broadcasts) = day["broadcasts"].as_array() { | ||||
|                     for broadcast in broadcasts { | ||||
|                         if self.filter_titles.is_empty() | ||||
|                             || self | ||||
|                                 .filter_titles | ||||
|                                 .contains(&broadcast["title"].as_str().unwrap().into()) | ||||
|                         { | ||||
|                             { | ||||
|                                 ret.push(broadcast["href"].as_str().unwrap().into()); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Ok(ret) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone)] | ||||
| struct Broadcast { | ||||
|     url: String, | ||||
|     media_url: String, | ||||
|     title: String, | ||||
|     timestamp: DateTime<chrono::Utc>, | ||||
| } | ||||
|  | ||||
| impl Broadcast { | ||||
|     async fn from_url(url: String) -> Result<Option<Self>, Box<dyn std::error::Error>> { | ||||
|         let data: Value = reqwest::get(&url).await?.json().await?; | ||||
|         let Some(streams) = data["streams"].as_array() else { | ||||
|             return Err(String::from("No 'streams' found").into()); | ||||
|         }; | ||||
|         if streams.is_empty() { | ||||
|             return Ok(None); | ||||
|         } | ||||
|         assert_eq!(streams.len(), 1); | ||||
|  | ||||
|         let Some(id) = streams[0]["loopStreamId"].as_str() else { | ||||
|             return Err(String::from("No 'loopStreamId' found").into()); | ||||
|         }; | ||||
|         let media_url = format!("https://loopstream01.apa.at/?channel=oe1&shoutcast=0&id={id}"); | ||||
|  | ||||
|         let Some(title) = data["title"].as_str() else { | ||||
|             return Err(format!("{url} has no title").into()); | ||||
|         }; | ||||
|  | ||||
|         let Some(timestamp) = data["start"].as_number() else { | ||||
|             return Err(format!("{url} has no start").into()); | ||||
|         }; | ||||
|         let Some(timestamp) = DateTime::from_timestamp(timestamp.as_i64().unwrap() / 1000, 0) | ||||
|         else { | ||||
|             return Err(format!( | ||||
|                 "broadcastDay in {url} not in a valid format (unix timestamp): {timestamp}" | ||||
|             ) | ||||
|             .into()); | ||||
|         }; | ||||
|  | ||||
|         Ok(Some(Self { | ||||
|             url, | ||||
|             media_url, | ||||
|             title: title.into(), | ||||
|             timestamp, | ||||
|         })) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl PartialEq for Broadcast { | ||||
|     fn eq(&self, other: &Self) -> bool { | ||||
|         self.url == other.url | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| use player::Backend; | ||||
|  | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     let listener = tokio::net::TcpListener::bind("0.0.0.0:3029").await?; | ||||
| @@ -12,6 +14,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|             "Ö1 Abendjournal".into(), | ||||
|         ], | ||||
|         listener, | ||||
|         Backend::Prod, | ||||
|     ) | ||||
|     .await?; | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| use crate::{Broadcast, Feed}; | ||||
| use crate::{fetch::broadcast::Broadcast, Feed}; | ||||
|  | ||||
| pub(super) trait ToRss { | ||||
|     fn title(&self) -> &str; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Philipp Hofer
					Philipp Hofer