tvdb-ep-list 0.4.11

A command-line application for generating TV episode file names
use std::{error::Error, fmt::Display};

use async_recursion::async_recursion;
use reqwest::{
    header::{self, HeaderMap},
    Client, Response, StatusCode, Url,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};

const BASE_PATH: &str = "https://api.thetvdb.com";

#[derive(Debug, Deserialize)]
struct LoginResponse {
    token: String,
}

#[derive(Debug, Serialize)]
struct LoginRequest {
    apikey: String,
}

#[derive(Debug)]
pub enum ClientError {
    InvalidAPIKey,
    HTTPError(StatusCode),
}

impl Display for ClientError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::InvalidAPIKey => write!(f, "Invalid API Key"),
            Self::HTTPError(status) => write!(f, "Response Code {status}"),
        }
    }
}

impl Error for ClientError {}

#[derive(Debug, Deserialize)]
struct SeriesSearch {
    data: Vec<Series>,
}

#[derive(Debug, Deserialize)]
pub struct Series {
    pub id: u64,
    #[serde(rename = "seriesName")]
    pub series_name: String,
}

#[derive(Debug, Deserialize)]
struct SeriesDetailResponse {
    data: SeriesDetail,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeriesDetail {
    pub id: u64,
    pub series_name: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EpisodeResponse {
    data: Vec<Episode>,
    links: Links,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Links {
    next: Option<u64>,
}

#[derive(Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
#[serde(rename_all = "camelCase")]
pub struct Episode {
    pub aired_season: i64,
    pub aired_episode_number: i64,
    pub dvd_season: Option<i64>,
    pub dvd_episode_number: Option<i64>,
    pub episode_name: Option<String>,
}

pub struct Api {
    client: Client,
    default_headers: HeaderMap,
}

impl Api {
    fn url<T: Display>(path: T) -> Result<Url, url::ParseError> {
        Url::parse(&format!("{BASE_PATH}{path}"))
    }

    pub async fn new(api_key: &str) -> Result<Self, Box<dyn Error>> {
        let login_body = LoginRequest {
            apikey: api_key.into(),
        };

        let client = Client::new();

        let login_resp = client
            .post(Self::url("/login")?)
            .json(&login_body)
            .send()
            .await?;

        if login_resp.status() == StatusCode::OK {
            let token_holder: LoginResponse = login_resp.json().await?;
            let mut default_headers = HeaderMap::new();
            default_headers.insert(
                header::AUTHORIZATION,
                format!("Bearer {}", token_holder.token).parse()?,
            );

            Ok(Self {
                client,
                default_headers,
            })
        } else {
            Err(ClientError::InvalidAPIKey.into())
        }
    }

    pub async fn search_series(
        &self,
        name: Option<&str>,
        imdb_id: Option<&str>,
        zap2it_id: Option<&str>,
        slug: Option<&str>,
        accept_language: Option<&str>,
    ) -> Result<Vec<Series>, Box<dyn Error>> {
        let mut headers = self.default_headers.clone();
        let mut queries: Vec<(&str, &str)> = vec![];

        if let Some(name) = name {
            queries.push(("name", name));
        }
        if let Some(imdb_id) = imdb_id {
            queries.push(("imdbId", imdb_id));
        }
        if let Some(zap2it_id) = zap2it_id {
            queries.push(("zap2itId", zap2it_id));
        }
        if let Some(slug) = slug {
            queries.push(("slug", slug));
        }

        if let Some(accept_language) = accept_language {
            headers.insert(header::ACCEPT_LANGUAGE, accept_language.parse()?);
        }

        let response = self
            .client
            .get(Api::url("/search/series")?)
            .headers(headers)
            .query(&queries)
            .send()
            .await?;

        Ok(self.handle_response::<SeriesSearch>(response).await?.data)
    }

    pub async fn get_series(
        &self,
        series_id: u64,
        accept_language: Option<&str>,
    ) -> Result<SeriesDetail, Box<dyn Error>> {
        let mut headers = self.default_headers.clone();

        if let Some(accept_language) = accept_language {
            headers.insert(header::ACCEPT_LANGUAGE, accept_language.parse()?);
        }

        let response = self
            .client
            .get(Api::url(format!("/series/{series_id}"))?)
            .headers(headers)
            .send()
            .await?;

        Ok(self
            .handle_response::<SeriesDetailResponse>(response)
            .await?
            .data)
    }

    pub async fn get_series_episodes(
        &self,
        series_id: u64,
    ) -> Result<Vec<Episode>, Box<dyn Error>> {
        self.get_eps_internal(series_id, 1).await
    }

    #[async_recursion]
    async fn get_eps_internal(
        &self,
        series_id: u64,
        page: u64,
    ) -> Result<Vec<Episode>, Box<dyn Error>> {
        let headers = self.default_headers.clone();

        let response = self
            .client
            .get(Api::url(format!("/series/{series_id}/episodes"))?)
            .headers(headers)
            .query(&[("page", page)])
            .send()
            .await?;

        let resp_body = self.handle_response::<EpisodeResponse>(response).await?;
        let mut episode_list = resp_body.data;

        if let Some(next) = resp_body.links.next {
            episode_list.append(&mut self.get_eps_internal(series_id, next).await?);
        }
        Ok(episode_list)
    }

    async fn handle_response<T: DeserializeOwned>(
        &self,
        resp: Response,
    ) -> Result<T, Box<dyn Error>> {
        if resp.status() == StatusCode::OK {
            Ok(resp.json().await?)
        } else {
            Err(ClientError::HTTPError(resp.status()).into())
        }
    }
}