From 1cab4d0bf54915012ec597ef7a66036ab54a9b7e Mon Sep 17 00:00:00 2001 From: Philipp Hofer Date: Wed, 29 Oct 2025 11:30:16 +0100 Subject: [PATCH] restructure + first tests --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 19 ++++++++ src/fetch/broadcast.rs | 73 ++++++++++++++++++++++++++++ src/fetch/mod.rs | 2 + src/fetch/overview.rs | 95 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 106 +++++++---------------------------------- src/main.rs | 3 ++ src/rss.rs | 2 +- 9 files changed, 212 insertions(+), 90 deletions(-) create mode 100644 README.md create mode 100644 src/fetch/broadcast.rs create mode 100644 src/fetch/mod.rs create mode 100644 src/fetch/overview.rs diff --git a/Cargo.lock b/Cargo.lock index 4d3ec26..d79651a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -723,6 +723,7 @@ dependencies = [ "chrono", "quick-xml", "reqwest", + "serde", "serde_json", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index a48b0c7..fa3e148 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..6974d2b --- /dev/null +++ b/README.md @@ -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}`. diff --git a/src/fetch/broadcast.rs b/src/fetch/broadcast.rs new file mode 100644 index 0000000..d762486 --- /dev/null +++ b/src/fetch/broadcast.rs @@ -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, Box> { + 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, +} + +impl Broadcast { + async fn from_data( + url: String, + data: Value, + ) -> Result, Box> { + 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 + } +} diff --git a/src/fetch/mod.rs b/src/fetch/mod.rs new file mode 100644 index 0000000..4614fd4 --- /dev/null +++ b/src/fetch/mod.rs @@ -0,0 +1,2 @@ +pub(super) mod broadcast; +pub(super) mod overview; diff --git a/src/fetch/overview.rs b/src/fetch/overview.rs new file mode 100644 index 0000000..895b402 --- /dev/null +++ b/src/fetch/overview.rs @@ -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, +} + +type InferenceData = Vec; + +impl Backend { + /// Gets a list of all broadcasts + pub(crate) async fn get_broadcasts( + &self, + filter_titles: &[String], + ) -> Result, Box> { + let data: InferenceData = match self { + Backend::Prod => { + let url = "https://audioapi.orf.at/oe1/api/json/current/broadcasts"; + reqwest::get(url).await?.json::().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, Box> { + let mut ret: Vec = 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"]); + } +} diff --git a/src/lib.rs b/src/lib.rs index 118741b..58c643a 100644 --- a/src/lib.rs +++ b/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, listener: TcpListener, + backend: Backend, ) -> Result<(), Box> { 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, title: String, link: String, desc: String, filter_titles: Vec, + backend: Backend, } impl Feed { @@ -36,6 +46,7 @@ impl Feed { link: String, desc: String, filter_titles: Vec, + backend: Backend, ) -> Result> { 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> { - 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, Box> { - // 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 = 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, -} - -impl Broadcast { - async fn from_url(url: String) -> Result, Box> { - 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 - } } diff --git a/src/main.rs b/src/main.rs index cf619ac..6fb44bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use player::Backend; + #[tokio::main] async fn main() -> Result<(), Box> { let listener = tokio::net::TcpListener::bind("0.0.0.0:3029").await?; @@ -12,6 +14,7 @@ async fn main() -> Result<(), Box> { "Ö1 Abendjournal".into(), ], listener, + Backend::Prod, ) .await?; diff --git a/src/rss.rs b/src/rss.rs index fc507e2..61657b1 100644 --- a/src/rss.rs +++ b/src/rss.rs @@ -1,4 +1,4 @@ -use crate::{Broadcast, Feed}; +use crate::{fetch::broadcast::Broadcast, Feed}; pub(super) trait ToRss { fn title(&self) -> &str;