Files
oe1-player/src/lib.rs
Philipp Hofer bbc8cf2c80
All checks were successful
CI/CD Pipeline / test (push) Successful in 1m13s
CI/CD Pipeline / deploy (push) Successful in 1m16s
proper datetime
2025-10-13 15:27:29 +02:00

153 lines
4.6 KiB
Rust

use chrono::DateTime;
use serde_json::Value;
pub struct Feed {
episodes: Vec<Broadcast>,
}
impl Feed {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
let mut ret = Self {
episodes: Vec::new(),
};
ret.fetch().await?;
Ok(ret)
}
pub async fn fetch(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let broadcasts = get_all_broadcasts().await?;
for broadcast in broadcasts {
if !self.has_broadcast_url(&broadcast) {
if let Some(broadcast) = Broadcast::from(broadcast).await.unwrap() {
self.episodes.push(broadcast);
} else {
return Ok(());
}
}
}
Ok(())
}
fn has_broadcast_url(&self, url: &str) -> bool {
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
}
}