Compare commits
18 Commits
1de7535622
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 250feb54cd | |||
|
|
9403b19c71 | ||
|
|
1e9fea17e3 | ||
| 2e11261179 | |||
|
|
704708e37f | ||
| b833b2a27a | |||
|
|
fb7674eac1 | ||
|
|
ad759e1ca9 | ||
|
|
1cab4d0bf5 | ||
|
|
e1e21f8837 | ||
|
|
5ee6a679c5 | ||
|
|
4702017914 | ||
|
|
14ee4d2767 | ||
|
|
cf9a5040dd | ||
|
|
815590076d | ||
|
|
f941eac386 | ||
|
|
2f3abdf546 | ||
|
|
fe59406819 |
@@ -21,8 +21,8 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build
|
run: cargo build
|
||||||
|
|
||||||
- name: Backend tests
|
- name: Tests
|
||||||
run: cargo test --verbose
|
run: cargo test --verbose -- --ignored
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
60
Cargo.lock
generated
60
Cargo.lock
generated
@@ -170,15 +170,6 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "croner"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c344b0690c1ad1c7176fe18eb173e0c927008fdaaa256e40dfd43ddd149c0843"
|
|
||||||
dependencies = [
|
|
||||||
"chrono",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -659,17 +650,6 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "num-derive"
|
|
||||||
version = "0.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -743,9 +723,9 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-cron-scheduler",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1223,21 +1203,6 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-cron-scheduler"
|
|
||||||
version = "0.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5c71ce8f810abc9fabebccc30302a952f9e89c6cf246fafaf170fef164063141"
|
|
||||||
dependencies = [
|
|
||||||
"chrono",
|
|
||||||
"croner",
|
|
||||||
"num-derive",
|
|
||||||
"num-traits",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-macros"
|
name = "tokio-macros"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -1326,21 +1291,9 @@ checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tracing-attributes"
|
|
||||||
version = "0.1.30"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.34"
|
version = "0.1.34"
|
||||||
@@ -1386,17 +1339,6 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "uuid"
|
|
||||||
version = "1.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.3.3",
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ tokio = { version = "1", features = ["full"] }
|
|||||||
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"], default-features = false }
|
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"], default-features = false }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
tokio-cron-scheduler = "0.14"
|
|
||||||
quick-xml = "0.38"
|
quick-xml = "0.38"
|
||||||
|
serde = { version = "1", 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}`.
|
||||||
112
src/fetch/broadcast.rs
Normal file
112
src/fetch/broadcast.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
use crate::Backend;
|
||||||
|
use chrono::DateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct InferenceStream {
|
||||||
|
#[serde(rename = "loopStreamId")]
|
||||||
|
pub loop_stream_id: String,
|
||||||
|
#[serde(rename = "start")]
|
||||||
|
pub start_timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct InferenceData {
|
||||||
|
pub title: String,
|
||||||
|
pub streams: Vec<InferenceStream>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend {
|
||||||
|
pub(crate) async fn get_broadcast(
|
||||||
|
&self,
|
||||||
|
url: String,
|
||||||
|
) -> Result<Option<Broadcast>, Box<dyn std::error::Error>> {
|
||||||
|
let data: InferenceData = match self {
|
||||||
|
Backend::Prod => reqwest::get(&url).await?.json::<InferenceData>().await?,
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
Backend::Test => InferenceData {
|
||||||
|
title: "test-title".into(),
|
||||||
|
streams: vec![InferenceStream {
|
||||||
|
loop_stream_id: "test.mp3".into(),
|
||||||
|
start_timestamp: 1761734636000,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Broadcast::from_data(url, data).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, 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: InferenceData,
|
||||||
|
) -> Result<Option<Self>, Box<dyn std::error::Error>> {
|
||||||
|
if data.streams.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
assert_eq!(data.streams.len(), 1);
|
||||||
|
let stream = &data.streams[0];
|
||||||
|
|
||||||
|
let media_url = format!(
|
||||||
|
"https://loopstream01.apa.at/?channel=oe1&shoutcast=0&id={}",
|
||||||
|
stream.loop_stream_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let Some(timestamp) = DateTime::from_timestamp(stream.start_timestamp / 1000, 0) else {
|
||||||
|
return Err(format!(
|
||||||
|
"broadcastDay in {url} not in a valid format (unix timestamp): {}",
|
||||||
|
stream.start_timestamp
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(Self {
|
||||||
|
url,
|
||||||
|
media_url,
|
||||||
|
title: data.title,
|
||||||
|
timestamp,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for Broadcast {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.url == other.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::Backend;
|
||||||
|
use chrono::{TimeZone, Utc};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn happy() {
|
||||||
|
let backend = Backend::Test;
|
||||||
|
let actual = backend
|
||||||
|
.get_broadcast("test-url".into())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&actual.url, "test-url");
|
||||||
|
assert_eq!(
|
||||||
|
&actual.media_url,
|
||||||
|
"https://loopstream01.apa.at/?channel=oe1&shoutcast=0&id=test.mp3"
|
||||||
|
);
|
||||||
|
assert_eq!(&actual.title, "test-title");
|
||||||
|
assert_eq!(
|
||||||
|
actual.timestamp,
|
||||||
|
Utc.with_ymd_and_hms(2025, 10, 29, 10, 43, 56).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
98
src/fetch/overview.rs
Normal file
98
src/fetch/overview.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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-title".into()])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(actual, vec!["test-href"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/lib.rs
181
src/lib.rs
@@ -1,14 +1,60 @@
|
|||||||
use chrono::DateTime;
|
mod fetch;
|
||||||
use serde_json::Value;
|
mod rss;
|
||||||
|
mod web;
|
||||||
|
|
||||||
pub struct Feed {
|
use fetch::broadcast::Broadcast;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::{net::TcpListener, sync::RwLock};
|
||||||
|
|
||||||
|
pub async fn start(
|
||||||
|
title: String,
|
||||||
|
link: String,
|
||||||
|
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, backend)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
));
|
||||||
|
|
||||||
|
web::serve(state, listener).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Backend {
|
||||||
|
Prod,
|
||||||
|
#[cfg(test)]
|
||||||
|
Test,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Feed {
|
||||||
episodes: Vec<Broadcast>,
|
episodes: Vec<Broadcast>,
|
||||||
|
title: String,
|
||||||
|
link: String,
|
||||||
|
desc: String,
|
||||||
|
filter_titles: Vec<String>,
|
||||||
|
backend: Backend,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Feed {
|
impl Feed {
|
||||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
async fn new(
|
||||||
|
title: String,
|
||||||
|
link: String,
|
||||||
|
desc: String,
|
||||||
|
filter_titles: Vec<String>,
|
||||||
|
backend: Backend,
|
||||||
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let mut ret = Self {
|
let mut ret = Self {
|
||||||
episodes: Vec::new(),
|
episodes: Vec::new(),
|
||||||
|
title,
|
||||||
|
link,
|
||||||
|
desc,
|
||||||
|
filter_titles,
|
||||||
|
backend,
|
||||||
};
|
};
|
||||||
|
|
||||||
ret.fetch().await?;
|
ret.fetch().await?;
|
||||||
@@ -16,12 +62,12 @@ impl Feed {
|
|||||||
Ok(ret)
|
Ok(ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
async fn fetch(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let broadcasts = get_all_broadcasts().await?;
|
let broadcasts = self.backend.get_broadcasts(&self.filter_titles).await?;
|
||||||
|
|
||||||
for broadcast in broadcasts {
|
for broadcast in broadcasts {
|
||||||
if !self.has_broadcast_url(&broadcast) {
|
if !self.has_broadcast_url(&broadcast) {
|
||||||
if let Some(broadcast) = Broadcast::from(broadcast).await.unwrap() {
|
if let Some(broadcast) = self.backend.get_broadcast(broadcast).await.unwrap() {
|
||||||
self.episodes.push(broadcast);
|
self.episodes.push(broadcast);
|
||||||
} else {
|
} else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -29,124 +75,17 @@ impl Feed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.only_keep_last_episodes();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn only_keep_last_episodes(&mut self) {
|
||||||
|
self.episodes = self.episodes.clone().into_iter().rev().take(10).collect();
|
||||||
|
// only keep last 10
|
||||||
|
}
|
||||||
|
|
||||||
fn has_broadcast_url(&self, url: &str) -> bool {
|
fn has_broadcast_url(&self, url: &str) -> bool {
|
||||||
self.episodes.iter().any(|e| e.url == url)
|
self.episodes.iter().any(|e| e.url == url)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_rss(&self) -> String {
|
|
||||||
let mut ret = String::new();
|
|
||||||
ret.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
|
|
||||||
ret.push_str(r#"<rss version="2.0">"#);
|
|
||||||
ret.push_str("<channel>");
|
|
||||||
ret.push_str("<title>Ö1 Live Journal Feed</title>");
|
|
||||||
ret.push_str("<link>https://news.hofer.link</link>");
|
|
||||||
ret.push_str("<description>Feed für Ö1 Journale. Live.</description>");
|
|
||||||
|
|
||||||
for episode in &self.episodes {
|
|
||||||
ret.push_str("<item>");
|
|
||||||
ret.push_str(&format!(
|
|
||||||
"<title>{} ({})</title>",
|
|
||||||
episode.title,
|
|
||||||
episode.timestamp.format("%d.%m.")
|
|
||||||
));
|
|
||||||
ret.push_str(&format!(
|
|
||||||
"<enclosure url=\"{}\" length=\"0\" type=\"audio/mpeg\"/>\n",
|
|
||||||
quick_xml::escape::escape(&episode.media_url)
|
|
||||||
));
|
|
||||||
ret.push_str("<description>Journal</description>");
|
|
||||||
ret.push_str(&format!(
|
|
||||||
"<pubDate>{}</pubDate>",
|
|
||||||
episode.timestamp.to_rfc2822()
|
|
||||||
));
|
|
||||||
ret.push_str("</item>");
|
|
||||||
}
|
|
||||||
|
|
||||||
ret.push_str(" </channel>");
|
|
||||||
ret.push_str("</rss>");
|
|
||||||
|
|
||||||
ret
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_all_broadcasts() -> 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 ["Ö1 Morgenjournal", "Ö1 Mittagsjournal", "Ö1 Abendjournal"]
|
|
||||||
.contains(&broadcast["title"].as_str().unwrap())
|
|
||||||
{
|
|
||||||
ret.push(broadcast["href"].as_str().unwrap().into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Broadcast {
|
|
||||||
pub url: String,
|
|
||||||
pub media_url: String,
|
|
||||||
pub title: String,
|
|
||||||
pub timestamp: DateTime<chrono::Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Broadcast {
|
|
||||||
async fn from(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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/main.rs
48
src/main.rs
@@ -1,40 +1,22 @@
|
|||||||
mod state;
|
use player::Backend;
|
||||||
mod streamer;
|
|
||||||
|
|
||||||
use axum::{routing::get, Router};
|
|
||||||
use chrono::Utc;
|
|
||||||
use state::AppState;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let state = Arc::new(AppState::new().await.unwrap());
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/", get(streamer::stream_handler))
|
|
||||||
.with_state(state.clone());
|
|
||||||
|
|
||||||
let scheduler = JobScheduler::new().await.unwrap();
|
|
||||||
scheduler
|
|
||||||
.add(
|
|
||||||
Job::new_async("0 0 */3 * * *", move |_uuid, _locked| {
|
|
||||||
let state_for_task = state.clone();
|
|
||||||
Box::pin(async move {
|
|
||||||
state_for_task.update().await;
|
|
||||||
println!("Task executed at: {}", Utc::now());
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
scheduler.start().await.unwrap();
|
|
||||||
|
|
||||||
println!("Streaming server running on http://localhost:3029");
|
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3029").await?;
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3029").await?;
|
||||||
axum::serve(listener, app).await?;
|
|
||||||
|
player::start(
|
||||||
|
"Ö1 Live Journal Feed".into(),
|
||||||
|
"https://news.hofer.link".into(),
|
||||||
|
"Feed für Ö1 Journale. Live.".into(),
|
||||||
|
vec![
|
||||||
|
"Ö1 Morgenjournal".into(),
|
||||||
|
"Ö1 Mittagsjournal".into(),
|
||||||
|
"Ö1 Abendjournal".into(),
|
||||||
|
],
|
||||||
|
listener,
|
||||||
|
Backend::Prod,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/rss.rs
Normal file
58
src/rss.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use crate::{fetch::broadcast::Broadcast, Feed};
|
||||||
|
|
||||||
|
pub(super) trait ToRss {
|
||||||
|
fn title(&self) -> &str;
|
||||||
|
fn link(&self) -> &str;
|
||||||
|
fn desc(&self) -> &str;
|
||||||
|
fn episodes(&self) -> &Vec<Broadcast>;
|
||||||
|
|
||||||
|
fn to_rss(&self) -> String {
|
||||||
|
let mut ret = String::new();
|
||||||
|
ret.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
|
||||||
|
ret.push_str(r#"<rss version="2.0">"#);
|
||||||
|
ret.push_str("<channel>");
|
||||||
|
ret.push_str(&format!("<title>{}</title>", self.title()));
|
||||||
|
ret.push_str(&format!("<link>{}</link>", self.link()));
|
||||||
|
ret.push_str(&format!("<description>{}</description>", self.desc()));
|
||||||
|
|
||||||
|
for episode in self.episodes() {
|
||||||
|
let desc = format!("{} ({})", episode.title, episode.timestamp.format("%d.%m."));
|
||||||
|
|
||||||
|
ret.push_str("<item>");
|
||||||
|
ret.push_str(&format!("<title>{desc}</title>",));
|
||||||
|
ret.push_str(&format!(
|
||||||
|
"<enclosure url=\"{}\" length=\"0\" type=\"audio/mpeg\"/>\n",
|
||||||
|
quick_xml::escape::escape(&episode.media_url)
|
||||||
|
));
|
||||||
|
ret.push_str(&format!("<description>{desc}</description>"));
|
||||||
|
ret.push_str(&format!(
|
||||||
|
"<pubDate>{}</pubDate>",
|
||||||
|
episode.timestamp.to_rfc2822()
|
||||||
|
));
|
||||||
|
ret.push_str("</item>");
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.push_str(" </channel>");
|
||||||
|
ret.push_str("</rss>");
|
||||||
|
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToRss for Feed {
|
||||||
|
fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link(&self) -> &str {
|
||||||
|
&self.link
|
||||||
|
}
|
||||||
|
|
||||||
|
fn desc(&self) -> &str {
|
||||||
|
&self.desc
|
||||||
|
}
|
||||||
|
|
||||||
|
fn episodes(&self) -> &Vec<Broadcast> {
|
||||||
|
&self.episodes
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/state.rs
19
src/state.rs
@@ -1,19 +0,0 @@
|
|||||||
use player::Feed;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
pub struct AppState {
|
|
||||||
pub feed: RwLock<Feed>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppState {
|
|
||||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
|
||||||
Ok(Self {
|
|
||||||
feed: RwLock::new(Feed::new().await?),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update(self: Arc<Self>) {
|
|
||||||
self.feed.write().await.fetch().await.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
use crate::state::AppState;
|
|
||||||
use axum::{extract::State, http::HeaderMap, response::IntoResponse};
|
|
||||||
use reqwest::header;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
pub async fn stream_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
|
||||||
state.clone().update().await;
|
|
||||||
|
|
||||||
let content = state.feed.read().await.to_rss();
|
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(header::CONTENT_TYPE, "application/rss+xml".parse().unwrap());
|
|
||||||
(headers, content)
|
|
||||||
}
|
|
||||||
28
src/web.rs
Normal file
28
src/web.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use crate::{rss::ToRss, Feed};
|
||||||
|
use axum::{extract::State, http::HeaderMap, response::IntoResponse, routing::get, Router};
|
||||||
|
use reqwest::header;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::{net::TcpListener, sync::RwLock};
|
||||||
|
|
||||||
|
async fn stream_handler(State(state): State<Arc<RwLock<Feed>>>) -> impl IntoResponse {
|
||||||
|
state.write().await.fetch().await.unwrap();
|
||||||
|
|
||||||
|
let content = state.read().await.to_rss();
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::CONTENT_TYPE, "application/rss+xml".parse().unwrap());
|
||||||
|
(headers, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn serve(
|
||||||
|
state: Arc<RwLock<Feed>>,
|
||||||
|
listener: TcpListener,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(stream_handler))
|
||||||
|
.with_state(state.clone());
|
||||||
|
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
34
tests/integration.rs
Normal file
34
tests/integration.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use player::Backend;
|
||||||
|
|
||||||
|
#[ignore]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await?;
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
// Start server in background task
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = player::start(
|
||||||
|
"Test Feed".into(),
|
||||||
|
"http://test.example".into(),
|
||||||
|
"Test description".into(),
|
||||||
|
vec!["Test Journal".into()],
|
||||||
|
listener,
|
||||||
|
Backend::Prod,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
eprintln!("Server failed to start: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow server startup time
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(3000)).await;
|
||||||
|
|
||||||
|
// Verify route responds with success status
|
||||||
|
let response = reqwest::get(format!("http://{addr}/")).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user