AkurAI Build
Menu

BifrOSt-Apps

public

Latest 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")),
    }
}