Menu
BifrOSt-Apps
publicLatest change f35619cfe0419c596645328bf0e1b682ad3a4473 - Harden RÚV for the 0.1.1 candidate by Ólafur Búi Ólafsson
use crate::model::{
AppError, Article, Channel, Episode, HomeData, Program, ProgramCard, ScheduleEvent,
SearchResults, Subtitle, VodShelf,
};
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, Offset, TimeZone, Utc};
use reqwest::{Client, Response, StatusCode, redirect};
use serde::de::{DeserializeOwned, Error as _};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::time::Duration;
use url::Url;
const RSS_ENDPOINT: &str = "https://www.ruv.is/rss/frettir";
const GRAPHQL_ENDPOINT: &str = "https://spilari.nyr.ruv.is/gql/";
const LIVE_ENDPOINT: &str = "https://geo.spilari.ruv.is/channel/";
const ARTICLE_SEARCH_ENDPOINT: &str = "https://gql.nyr.ruv.is/search/";
const API_BODY_LIMIT: usize = 2 * 1024 * 1024;
const RSS_BODY_LIMIT: usize = 4 * 1024 * 1024;
const IMAGE_BODY_LIMIT: usize = 12 * 1024 * 1024;
const HTML_FIELD_LIMIT: usize = 512 * 1024;
// An episode claiming to run longer than a year is malformed metadata, not
// media; such durations are dropped at DTO conversion (APP-5.4).
const MAX_EPISODE_DURATION_SECONDS: u64 = 365 * 24 * 60 * 60;
const FRONTPAGE_QUERY: &str = r#"
query getFrontpage($type: Station!, $limit: Int) {
Featured(station: $type) {
id
metro: panels(slug: { value: "hladbord" }) {
title type slug
programs {
title foreign_title id programID slug description short_description uuid
episodes { description id rating title image }
}
}
panels(limit: $limit) {
title type slug
programs {
title foreign_title id programID slug description short_description uuid
episodes { description id rating title image }
}
}
}
}
"#;
const PROGRAM_QUERY: &str = r#"
query getEpisode($programID: Int!) {
Program(id: $programID) {
slug title description short_description foreign_title id image reverse_episode_order
episodes { title id firstrun description image rating }
}
}
"#;
const EPISODE_QUERY: &str = r#"
query getSerie($programID: Int!, $episodeID: [String!] = [""]) {
Program(id: $programID) {
slug title description short_description foreign_title format id image image_og channel
episodes(limit: 1, id: { value: $episodeID }) {
title id description duration firstrun scope file rating file_expires image image_og
subtitles { name value }
}
}
}
"#;
const SCHEDULE_QUERY: &str = r#"
query getSchedule($channel: Channels!, $date: String!) {
Schedule(channel: $channel, date: $date) {
events {
title subtitle duration_friendly start_time start_time_friendly end_time_friendly
image is_live description scope rating id episodeId programId
}
}
}
"#;
const SEARCH_QUERY: &str = r#"
query getSearch($type: SearchQueryType!, $text: String!) {
Search(type: $type, text: $text) {
id episodeId title slug image description
}
}
"#;
#[derive(Clone)]
pub struct RuvClient {
client: Client,
}
impl RuvClient {
pub fn new() -> Result<Self, AppError> {
let redirects = redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= 5 || !is_redirect_origin_allowed(attempt.url()) {
attempt.stop()
} else {
attempt.follow()
}
});
let client = Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(15))
.redirect(redirects)
.build()
.map_err(|_| AppError::Network)?;
Ok(Self { client })
}
pub async fn home(&self) -> Result<HomeData, AppError> {
let articles = self.news().await?;
let shelves = self.vod().await?;
Ok(HomeData { articles, shelves })
}
pub async fn news(&self) -> Result<Vec<Article>, AppError> {
let response = self
.client
.get(RSS_ENDPOINT)
.send()
.await
.map_err(|_| AppError::Network)?;
let bytes = read_response(response, RSS_BODY_LIMIT).await?;
parse_rss_body(&bytes)
}
pub async fn vod(&self) -> Result<Vec<VodShelf>, AppError> {
let bytes = self
.post_graphql(FRONTPAGE_QUERY, json!({ "type": "tv", "limit": null }))
.await?;
parse_frontpage_body(&bytes)
}
pub async fn program(&self, program_id: i64) -> Result<Program, AppError> {
if program_id <= 0 {
return Err(AppError::NotFound);
}
let bytes = self
.post_graphql(PROGRAM_QUERY, json!({ "programID": program_id }))
.await?;
parse_program_body(&bytes, program_id)
}
pub async fn episode(&self, program_id: i64, episode_id: &str) -> Result<Program, AppError> {
let episode_id = episode_id.trim();
if program_id <= 0 || episode_id.is_empty() || episode_id.len() > 128 {
return Err(AppError::NotFound);
}
let bytes = self
.post_graphql(
EPISODE_QUERY,
json!({ "programID": program_id, "episodeID": [episode_id] }),
)
.await?;
parse_program_body(&bytes, program_id)
}
pub async fn schedule(
&self,
channel: Channel,
date: NaiveDate,
) -> Result<Vec<ScheduleEvent>, AppError> {
let bytes = self
.post_graphql(
SCHEDULE_QUERY,
json!({ "channel": channel.slug(), "date": date.format("%Y-%m-%d").to_string() }),
)
.await?;
parse_schedule_body(&bytes)
}
pub async fn live(&self, channel: Channel) -> Result<String, AppError> {
let endpoint = format!("{LIVE_ENDPOINT}{}", channel.slug());
let endpoint = validate_url(&endpoint, UrlKind::Api)?;
let response = self
.client
.get(endpoint)
.send()
.await
.map_err(|_| AppError::Network)?;
let bytes = read_response(response, API_BODY_LIMIT).await?;
let resolver = parse_live_body(&bytes)?;
if let Some(switcher) = resolver.switcher.as_deref()
&& let Ok(switcher_url) = validate_url(switcher, UrlKind::Resolver)
&& let Ok(response) = self.client.get(switcher_url).send().await
&& let Ok(body) = read_response(response, API_BODY_LIMIT).await
&& let Ok(location) = serde_json::from_slice::<SwitcherDto>(&body)
&& let Some(stream) = location.location.as_deref().and_then(stream_url)
{
return Ok(stream);
}
resolver
.url
.as_deref()
.and_then(stream_url)
.ok_or(AppError::UntrustedUrl)
}
pub async fn search(&self, query: &str) -> Result<SearchResults, AppError> {
let query = query.trim();
if query.is_empty() {
return Ok(SearchResults {
articles: Vec::new(),
programs: Vec::new(),
});
}
if query.chars().count() > 200 {
return Err(AppError::InvalidResponse);
}
let article_url = build_search_url(query)?;
let article_response = self
.client
.get(article_url)
.send()
.await
.map_err(|_| AppError::Network)?;
let article_bytes = read_response(article_response, API_BODY_LIMIT).await?;
let articles = parse_article_search_body(&article_bytes)?;
let program_bytes = self
.post_graphql(SEARCH_QUERY, json!({ "type": "tv", "text": query }))
.await?;
let programs = parse_vod_search_body(&program_bytes)?;
Ok(SearchResults { articles, programs })
}
pub async fn image(&self, url: &str) -> Result<Vec<u8>, AppError> {
let url = validate_url(url, UrlKind::Image)?;
let response = self
.client
.get(url)
.send()
.await
.map_err(|_| AppError::Network)?;
if !is_image_content_type(response.headers()) {
return Err(AppError::InvalidResponse);
}
if validate_url(response.url().as_str(), UrlKind::Image).is_err() {
return Err(AppError::UntrustedUrl);
}
read_response(response, IMAGE_BODY_LIMIT).await
}
async fn post_graphql<T: Serialize>(
&self,
query: &str,
variables: T,
) -> Result<Vec<u8>, AppError> {
let endpoint = validate_url(GRAPHQL_ENDPOINT, UrlKind::Api)?;
let response = self
.client
.post(endpoint)
.json(&json!({ "query": query, "variables": variables }))
.send()
.await
.map_err(|_| AppError::Network)?;
read_response(response, API_BODY_LIMIT).await
}
}
async fn read_response(response: Response, maximum: usize) -> Result<Vec<u8>, AppError> {
if response.status() == StatusCode::NOT_FOUND {
return Err(AppError::NotFound);
}
if !response.status().is_success() {
return Err(AppError::InvalidResponse);
}
if response
.content_length()
.is_some_and(|size| size > maximum as u64)
{
return Err(AppError::ResponseTooLarge);
}
let mut response = response;
let initial = response.content_length().unwrap_or(0).min(maximum as u64) as usize;
let mut body = Vec::with_capacity(initial);
while let Some(chunk) = response.chunk().await.map_err(|_| AppError::Network)? {
bounded_extend(&mut body, &chunk, maximum)?;
}
Ok(body)
}
fn bounded_extend(body: &mut Vec<u8>, chunk: &[u8], maximum: usize) -> Result<(), AppError> {
if chunk.len() > maximum.saturating_sub(body.len()) {
return Err(AppError::ResponseTooLarge);
}
body.extend_from_slice(chunk);
Ok(())
}
fn parse_rss_body(bytes: &[u8]) -> Result<Vec<Article>, AppError> {
let xml = std::str::from_utf8(bytes).map_err(|_| AppError::InvalidResponse)?;
let rss: RssDto = quick_xml::de::from_str(xml).map_err(|_| AppError::InvalidResponse)?;
let mut articles = Vec::with_capacity(rss.channel.items.len());
for item in rss.channel.items {
let Some(title) = item.title.and_then(nonempty) else {
continue;
};
let Some(link) = item
.link
.and_then(|value| sanitize_url(&value, UrlKind::Article))
else {
continue;
};
let description = item.description.unwrap_or_default();
let body = readable_html(&description)?;
let image_url = item
.enclosure
.and_then(|value| value.url)
.and_then(|value| sanitize_url(&value, UrlKind::Image));
articles.push(Article {
title,
url: link,
author: item.creator.and_then(nonempty),
published: item.pub_date.as_deref().and_then(parse_timestamp),
body,
image_url,
});
}
Ok(articles)
}
fn parse_frontpage_body(bytes: &[u8]) -> Result<Vec<VodShelf>, AppError> {
let envelope: GraphQlEnvelope<FrontpageDataDto> = parse_graphql(bytes)?;
let featured = envelope
.data
.and_then(|data| data.featured)
.ok_or(AppError::InvalidResponse)?;
let mut panels = featured.metro;
panels.extend(featured.panels);
let mut shelves = Vec::with_capacity(panels.len());
for panel in panels {
let Some(title) = nonempty(panel.title) else {
continue;
};
let Some(slug) = nonempty(panel.slug) else {
continue;
};
let programs = panel
.programs
.into_iter()
.filter_map(program_card_from_dto)
.collect();
shelves.push(VodShelf {
title,
slug,
programs,
});
}
Ok(shelves)
}
fn parse_program_body(bytes: &[u8], requested_id: i64) -> Result<Program, AppError> {
let envelope: GraphQlEnvelope<ProgramDataDto> = parse_graphql(bytes)?;
let dto = envelope
.data
.and_then(|data| data.program)
.ok_or(AppError::NotFound)?;
let program_id = dto.id.filter(|id| *id > 0).unwrap_or(requested_id);
let title = nonempty(dto.title).ok_or(AppError::InvalidResponse)?;
let description = dto
.description
.or(dto.short_description)
.unwrap_or_default();
let episodes = dto
.episodes
.into_iter()
.filter_map(episode_from_dto)
.collect();
Ok(Program {
program_id,
title,
foreign_title: dto.foreign_title.and_then(nonempty),
description,
image_url: dto.image.and_then(|url| sanitize_url(&url, UrlKind::Image)),
episodes,
})
}
fn parse_schedule_body(bytes: &[u8]) -> Result<Vec<ScheduleEvent>, AppError> {
let envelope: GraphQlEnvelope<ScheduleDataDto> = parse_graphql(bytes)?;
let events = envelope
.data
.and_then(|data| data.schedule)
.map(|schedule| schedule.events)
.ok_or(AppError::InvalidResponse)?;
Ok(events
.into_iter()
.filter_map(|event| {
let id = event.id?;
let title = nonempty(event.title)?;
let start = event.start_time.as_deref().and_then(parse_timestamp)?;
Some(ScheduleEvent {
id,
title,
subtitle: event.subtitle.and_then(nonempty),
description: event.description.unwrap_or_default(),
start,
end: event.end_time.as_deref().and_then(parse_timestamp),
image_url: event
.image
.and_then(|url| sanitize_url(&url, UrlKind::Image)),
program_id: event.program_id.filter(|value| *value > 0),
episode_id: event.episode_id.and_then(nonempty),
is_live: event.is_live.unwrap_or(false),
})
})
.collect())
}
fn parse_live_body(bytes: &[u8]) -> Result<LiveDto, AppError> {
serde_json::from_slice(bytes).map_err(|_| AppError::InvalidResponse)
}
fn parse_article_search_body(bytes: &[u8]) -> Result<Vec<Article>, AppError> {
let rows: Vec<ArticleSearchDto> =
serde_json::from_slice(bytes).map_err(|_| AppError::InvalidResponse)?;
let mut articles = Vec::with_capacity(rows.len());
for row in rows {
let Some(title) = nonempty(row.title) else {
continue;
};
let Some(url) = row
.url
.and_then(|value| sanitize_url(&value, UrlKind::Article))
else {
continue;
};
let body = readable_html(row.description.as_deref().unwrap_or_default())?;
articles.push(Article {
title,
url,
author: None,
published: row.date.as_ref().and_then(parse_json_timestamp),
body,
image_url: row.main_image.and_then(image_from_search),
});
}
Ok(articles)
}
fn parse_vod_search_body(bytes: &[u8]) -> Result<Vec<ProgramCard>, AppError> {
let envelope: GraphQlEnvelope<SearchDataDto> = parse_graphql(bytes)?;
Ok(envelope
.data
.map(|data| data.search)
.unwrap_or_default()
.into_iter()
.filter_map(program_card_from_search)
.collect())
}
fn parse_graphql<T: DeserializeOwned>(bytes: &[u8]) -> Result<GraphQlEnvelope<T>, AppError> {
let envelope: GraphQlEnvelope<T> =
serde_json::from_slice(bytes).map_err(|_| AppError::InvalidResponse)?;
if !envelope.errors.is_empty() {
return Err(AppError::InvalidResponse);
}
Ok(envelope)
}
fn program_card_from_dto(dto: ProgramCardDto) -> Option<ProgramCard> {
let program_id = dto.program_id.or(dto.id).filter(|value| *value > 0)?;
let title = nonempty(dto.title)?;
let first_episode = dto.episodes.into_iter().next();
let image = dto.image.or_else(|| {
first_episode
.as_ref()
.and_then(|episode| episode.image.clone())
});
Some(ProgramCard {
program_id,
title,
foreign_title: dto.foreign_title.and_then(nonempty),
description: dto
.description
.or(dto.short_description)
.unwrap_or_default(),
image_url: image.and_then(|url| sanitize_url(&url, UrlKind::Image)),
episode_id: first_episode
.as_ref()
.and_then(|episode| episode.id.clone())
.and_then(nonempty),
episode_title: first_episode
.as_ref()
.map(|episode| episode.title.clone())
.and_then(nonempty),
rating: first_episode.and_then(|episode| episode.rating),
})
}
fn program_card_from_search(dto: SearchProgramDto) -> Option<ProgramCard> {
Some(ProgramCard {
program_id: dto.id.filter(|value| *value > 0)?,
title: nonempty(dto.title)?,
foreign_title: None,
description: dto.description.unwrap_or_default(),
image_url: dto.image.and_then(|url| sanitize_url(&url, UrlKind::Image)),
episode_id: dto.episode_id.and_then(nonempty),
episode_title: None,
rating: None,
})
}
fn episode_from_dto(dto: EpisodeDto) -> Option<Episode> {
let id = dto.id.and_then(nonempty)?;
// A missing title stays empty; the UI renders a localized
// "unknown-episode" label at presentation time (APP-5.3).
let title = nonempty(dto.title).unwrap_or_default();
let subtitles = dto
.subtitles
.into_iter()
.filter_map(|subtitle| {
let name = nonempty(subtitle.name)?;
let url = subtitle
.value
.and_then(|value| sanitize_url(&value, UrlKind::Subtitle))?;
Some(Subtitle { name, url })
})
.collect();
Some(Episode {
id,
title,
description: dto.description.unwrap_or_default(),
duration_seconds: dto
.duration
.filter(|&seconds| seconds <= MAX_EPISODE_DURATION_SECONDS),
first_run: dto.firstrun.as_deref().and_then(parse_timestamp),
scope: dto.scope.and_then(nonempty),
stream_url: dto.file.as_deref().and_then(stream_url),
image_url: dto
.image
.or(dto.image_og)
.and_then(|url| sanitize_url(&url, UrlKind::Image)),
rating: dto.rating,
subtitles,
})
}
fn image_from_search(image: MainImageDto) -> Option<String> {
image
.image_url
.or(image.original_url)
.or(image.src)
.or_else(|| {
["large", "medium", "small", "thumbnail"]
.into_iter()
.find_map(|key| image.renditions.get(key).and_then(url_from_json))
})
.and_then(|url| sanitize_url(&url, UrlKind::Image))
}
fn url_from_json(value: &Value) -> Option<String> {
match value {
Value::String(value) => Some(value.clone()),
Value::Object(object) => ["image_url", "url", "src"]
.into_iter()
.find_map(|key| object.get(key).and_then(Value::as_str).map(str::to_owned)),
_ => None,
}
}
fn readable_html(html: &str) -> Result<String, AppError> {
if html.len() > HTML_FIELD_LIMIT {
return Err(AppError::ResponseTooLarge);
}
html2text::from_read(html.as_bytes(), 100)
.map(|text| normalize_text(&text))
.map_err(|_| AppError::InvalidResponse)
}
fn normalize_text(text: &str) -> String {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn nonempty(value: String) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
fn parse_timestamp(value: &str) -> Option<DateTime<FixedOffset>> {
let value = value.trim();
DateTime::parse_from_rfc3339(value)
.or_else(|_| DateTime::parse_from_rfc2822(value))
.ok()
.or_else(|| {
["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"]
.into_iter()
.find_map(|format| NaiveDateTime::parse_from_str(value, format).ok())
.and_then(|naive| Utc.fix().from_local_datetime(&naive).single())
})
}
fn parse_json_timestamp(value: &Value) -> Option<DateTime<FixedOffset>> {
match value {
Value::String(value) => parse_timestamp(value),
Value::Number(value) => value
.as_i64()
.and_then(|seconds| Utc.timestamp_opt(seconds, 0).single())
.map(|date| date.fixed_offset()),
_ => None,
}
}
fn build_search_url(query: &str) -> Result<Url, AppError> {
let mut url = validate_url(ARTICLE_SEARCH_ENDPOINT, UrlKind::Api)?;
url.query_pairs_mut()
.append_pair("search", query)
.append_pair("page", "1")
.append_pair("limit", "20");
Ok(url)
}
fn stream_url(value: &str) -> Option<String> {
let url = validate_url(value, UrlKind::Stream).ok()?;
let is_hls = url.path().to_ascii_lowercase().ends_with(".m3u8");
is_hls.then(|| url.into())
}
fn sanitize_url(value: &str, kind: UrlKind) -> Option<String> {
validate_url(value, kind).ok().map(Into::into)
}
fn validate_url(value: &str, kind: UrlKind) -> Result<Url, AppError> {
let url = Url::parse(value).map_err(|_| AppError::UntrustedUrl)?;
if url.scheme() != "https"
|| !url.username().is_empty()
|| url.password().is_some()
|| url.port_or_known_default() != Some(443)
{
return Err(AppError::UntrustedUrl);
}
let host = url.host_str().ok_or(AppError::UntrustedUrl)?;
let trusted = match kind {
UrlKind::Api => matches!(
host,
"www.ruv.is" | "spilari.nyr.ruv.is" | "geo.spilari.ruv.is" | "gql.nyr.ruv.is"
),
UrlKind::Article => matches!(host, "ruv.is" | "www.ruv.is" | "nyr.ruv.is"),
UrlKind::Image => host == "ruv.is" || host.ends_with(".ruv.is"),
UrlKind::Resolver => host == "switcher.cdn.ruv.is",
UrlKind::Stream => {
host.ends_with(".cdn.ruv.is")
|| matches!(host, "ruv-vod.akamaized.net" | "ruvlive.akamaized.net")
}
UrlKind::Subtitle => host == "ruv.is" || host.ends_with(".ruv.is"),
};
trusted.then_some(url).ok_or(AppError::UntrustedUrl)
}
fn is_redirect_origin_allowed(url: &Url) -> bool {
if url.scheme() != "https" || url.port_or_known_default() != Some(443) {
return false;
}
matches!(
url.host_str(),
Some(host)
if host == "ruv.is"
|| host.ends_with(".ruv.is")
|| matches!(host, "ruv-vod.akamaized.net" | "ruvlive.akamaized.net")
)
}
fn is_image_content_type(headers: &reqwest::header::HeaderMap) -> bool {
let Some(value) = headers
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
else {
return false;
};
matches!(
value.split(';').next().map(str::trim),
Some("image/jpeg" | "image/png" | "image/webp" | "image/gif" | "image/avif")
)
}
#[derive(Clone, Copy)]
enum UrlKind {
Api,
Article,
Image,
Resolver,
Stream,
Subtitle,
}
#[derive(Debug, Deserialize)]
#[serde(bound(deserialize = "T: Deserialize<'de>"))]
struct GraphQlEnvelope<T> {
#[serde(default)]
data: Option<T>,
#[serde(default)]
errors: Vec<GraphQlErrorDto>,
}
#[derive(Debug, Deserialize)]
struct GraphQlErrorDto {
#[serde(default)]
_message: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RssDto {
channel: RssChannelDto,
}
#[derive(Debug, Deserialize)]
struct RssChannelDto {
#[serde(rename = "item", default)]
items: Vec<RssItemDto>,
}
#[derive(Debug, Deserialize)]
struct RssItemDto {
#[serde(default)]
title: Option<String>,
#[serde(default)]
link: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(rename = "creator", alias = "dc:creator", default)]
creator: Option<String>,
#[serde(rename = "pubDate", default)]
pub_date: Option<String>,
#[serde(default)]
enclosure: Option<RssEnclosureDto>,
}
#[derive(Debug, Deserialize)]
struct RssEnclosureDto {
#[serde(rename = "@url", default)]
url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct FrontpageDataDto {
#[serde(rename = "Featured", default)]
featured: Option<FeaturedDto>,
}
#[derive(Debug, Default, Deserialize)]
struct FeaturedDto {
#[serde(default)]
metro: Vec<PanelDto>,
#[serde(default)]
panels: Vec<PanelDto>,
}
#[derive(Debug, Default, Deserialize)]
struct PanelDto {
#[serde(default)]
title: String,
#[serde(default)]
slug: String,
#[serde(default)]
programs: Vec<ProgramCardDto>,
}
#[derive(Debug, Default, Deserialize)]
struct ProgramCardDto {
#[serde(
default,
rename = "programID",
deserialize_with = "deserialize_i64_option"
)]
program_id: Option<i64>,
#[serde(default, deserialize_with = "deserialize_i64_option")]
id: Option<i64>,
#[serde(default)]
title: String,
#[serde(default)]
foreign_title: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
short_description: Option<String>,
#[serde(default)]
image: Option<String>,
#[serde(default)]
episodes: Vec<EpisodeDto>,
}
#[derive(Debug, Deserialize)]
struct ProgramDataDto {
#[serde(rename = "Program", default)]
program: Option<ProgramDto>,
}
#[derive(Debug, Default, Deserialize)]
struct ProgramDto {
#[serde(default, deserialize_with = "deserialize_i64_option")]
id: Option<i64>,
#[serde(default)]
title: String,
#[serde(default)]
foreign_title: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
short_description: Option<String>,
#[serde(default)]
image: Option<String>,
#[serde(default)]
episodes: Vec<EpisodeDto>,
}
#[derive(Debug, Default, Deserialize)]
struct EpisodeDto {
#[serde(default, deserialize_with = "deserialize_string_option")]
id: Option<String>,
#[serde(default)]
title: String,
#[serde(default)]
description: Option<String>,
#[serde(default, deserialize_with = "deserialize_u64_option")]
duration: Option<u64>,
#[serde(default)]
firstrun: Option<String>,
#[serde(default)]
scope: Option<String>,
#[serde(default)]
file: Option<String>,
#[serde(default)]
image: Option<String>,
#[serde(default)]
image_og: Option<String>,
#[serde(default, deserialize_with = "deserialize_u8_option")]
rating: Option<u8>,
#[serde(default)]
subtitles: Vec<SubtitleDto>,
}
#[derive(Debug, Default, Deserialize)]
struct SubtitleDto {
#[serde(default)]
name: String,
#[serde(default)]
value: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ScheduleDataDto {
#[serde(rename = "Schedule", default)]
schedule: Option<ScheduleDto>,
}
#[derive(Debug, Default, Deserialize)]
struct ScheduleDto {
#[serde(default)]
events: Vec<ScheduleEventDto>,
}
#[derive(Debug, Default, Deserialize)]
struct ScheduleEventDto {
#[serde(default, deserialize_with = "deserialize_i64_option")]
id: Option<i64>,
#[serde(default)]
title: String,
#[serde(default)]
subtitle: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
start_time: Option<String>,
#[serde(default)]
end_time: Option<String>,
#[serde(default)]
image: Option<String>,
#[serde(
default,
rename = "programId",
deserialize_with = "deserialize_i64_option"
)]
program_id: Option<i64>,
#[serde(
default,
rename = "episodeId",
deserialize_with = "deserialize_string_option"
)]
episode_id: Option<String>,
#[serde(default)]
is_live: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct SearchDataDto {
#[serde(rename = "Search", default)]
search: Vec<SearchProgramDto>,
}
#[derive(Debug, Default, Deserialize)]
struct SearchProgramDto {
#[serde(default, deserialize_with = "deserialize_i64_option")]
id: Option<i64>,
#[serde(
default,
rename = "episodeId",
deserialize_with = "deserialize_string_option"
)]
episode_id: Option<String>,
#[serde(default)]
title: String,
#[serde(default)]
image: Option<String>,
#[serde(default)]
description: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ArticleSearchDto {
#[serde(default)]
title: String,
#[serde(default)]
url: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
date: Option<Value>,
#[serde(default)]
main_image: Option<MainImageDto>,
}
#[derive(Debug, Default, Deserialize)]
struct MainImageDto {
#[serde(default)]
original_url: Option<String>,
#[serde(default)]
image_url: Option<String>,
#[serde(default)]
src: Option<String>,
#[serde(default)]
renditions: HashMap<String, Value>,
}
#[derive(Debug, Deserialize)]
struct LiveDto {
#[serde(default)]
switcher: Option<String>,
#[serde(default)]
url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SwitcherDto {
#[serde(default)]
location: Option<String>,
}
fn deserialize_i64_option<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(value)) => Ok(value.as_i64()),
Some(Value::String(value)) => value.parse().map(Some).map_err(D::Error::custom),
Some(_) => Err(D::Error::custom("expected integer")),
}
}
fn deserialize_u64_option<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(value)) => Ok(value.as_u64()),
Some(Value::String(value)) => value.parse().map(Some).map_err(D::Error::custom),
Some(_) => Err(D::Error::custom("expected unsigned integer")),
}
}
fn deserialize_u8_option<'de, D>(deserializer: D) -> Result<Option<u8>, D::Error>
where
D: Deserializer<'de>,
{
let value = deserialize_u64_option(deserializer)?;
value
.map(u8::try_from)
.transpose()
.map_err(D::Error::custom)
}
fn deserialize_string_option<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => Ok(Some(value)),
Some(Value::Number(value)) => Ok(Some(value.to_string())),
Some(_) => Err(D::Error::custom("expected string")),
}
}