restructure + first tests
This commit is contained in:
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