use anyhow::Result;
use futures::future::try_join_all;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::video::episode_id;
const BASE_URL: &str = "https://api.themoviedb.org/3";
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct Tv {
pub id: i32,
pub name: String,
pub overview: String,
pub first_air_date: String,
pub number_of_episodes: i32,
pub number_of_seasons: i32,
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct TvSeasonEpisode {
pub id: i32,
pub season_number: i32,
pub episode_number: i32,
pub name: String,
pub overview: String,
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct TvSeason {
pub id: i32,
pub season_number: i32,
pub name: String,
pub overview: String,
pub episodes: Vec<TvSeasonEpisode>,
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct Show {
pub id: i32,
pub name: String,
pub overview: String,
pub year: i32,
pub first_air_date: String,
pub number_of_episodes: i32,
pub number_of_seasons: i32,
pub seasons: Vec<TvSeason>,
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct TvSearchResult {
pub id: i32,
pub name: String,
pub overview: String,
pub first_air_date: Option<String>,
pub original_language: Option<String>,
pub popularity: Option<f64>,
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct TvSearchResponse {
pub page: i32,
pub results: Vec<TvSearchResult>,
pub total_pages: i32,
pub total_results: i32,
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct Movie {
pub id: i32,
pub title: String,
pub overview: String,
pub release_date: String,
pub original_language: String,
pub popularity: f64,
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct MovieSearchResult {
pub id: i32,
pub title: String,
pub overview: String,
pub release_date: Option<String>,
pub original_language: Option<String>,
pub popularity: Option<f64>,
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct MovieSearchResponse {
pub page: i32,
pub results: Vec<MovieSearchResult>,
pub total_pages: i32,
pub total_results: i32,
}
trait ResponseExt {
async fn decode<T: for<'de> Deserialize<'de>>(self) -> Result<T>;
}
impl ResponseExt for reqwest::Response {
async fn decode<T: for<'de> Deserialize<'de>>(self) -> Result<T> {
let url = self.url().to_string();
let text = self.text().await?;
serde_json::from_str(&text)
.map_err(|e| anyhow::anyhow!("Failed to deserialize response from {url}: {e}:\n{text}"))
}
}
impl Show {
pub fn episodes(&self) -> HashMap<String, &TvSeasonEpisode> {
self.seasons
.iter()
.flat_map(|season| {
season.episodes.iter().map(move |episode| {
(
episode_id(season.season_number, episode.episode_number),
episode,
)
})
})
.collect()
}
}
pub struct TmdbClient {
client: reqwest::Client,
token: String,
}
impl TmdbClient {
pub fn new(token: String) -> Result<Self> {
Ok(Self {
client: reqwest::Client::new(),
token,
})
}
pub async fn show(&self, id: i32) -> Result<Show> {
let series = self.series(id).await?;
let seasons = try_join_all(
(1..=series.number_of_seasons)
.map(|season_number| self.season(id, season_number))
.collect::<Vec<_>>(),
)
.await?;
let year = series
.first_air_date
.split('-')
.next()
.and_then(|y| y.parse().ok())
.unwrap_or(0);
Ok(Show {
id: series.id,
name: series.name,
overview: series.overview,
year,
first_air_date: series.first_air_date,
number_of_episodes: series.number_of_episodes,
number_of_seasons: series.number_of_seasons,
seasons,
})
}
pub async fn series(&self, id: i32) -> Result<Tv> {
self.client
.get(format!("{}/tv/{}", BASE_URL, id))
.bearer_auth(&self.token)
.send()
.await?
.decode()
.await
}
pub async fn season(&self, id: i32, season: i32) -> Result<TvSeason> {
self.client
.get(format!("{}/tv/{}/season/{}", BASE_URL, id, season))
.bearer_auth(&self.token)
.send()
.await?
.decode()
.await
}
pub async fn search_tv(&self, query: &str) -> Result<TvSearchResponse> {
self.client
.get(format!("{}/search/tv", BASE_URL))
.bearer_auth(&self.token)
.query(&[("query", query)])
.send()
.await?
.decode()
.await
}
pub async fn search_movie(&self, query: &str) -> Result<MovieSearchResponse> {
self.client
.get(format!("{}/search/movie", BASE_URL))
.bearer_auth(&self.token)
.query(&[("query", query)])
.send()
.await?
.decode()
.await
}
pub async fn movie(&self, id: i32) -> Result<Movie> {
self.client
.get(format!("{}/movie/{}", BASE_URL, id))
.bearer_auth(&self.token)
.send()
.await?
.decode()
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_episode_id_generation() {
let episode_id = episode_id(1, 5);
assert_eq!(episode_id, "S01E05");
}
#[test]
fn test_episode_id_double_digits() {
let episode_id = episode_id(10, 23);
assert_eq!(episode_id, "S10E23");
}
#[test]
fn test_episode_id_single_digit() {
let episode_id = episode_id(2, 7);
assert_eq!(episode_id, "S02E07");
}
#[test]
fn test_show_episodes_mapping() {
let show = Show {
id: 1,
name: "Test Show".to_string(),
overview: "A test show".to_string(),
year: 2020,
first_air_date: "2020-01-01".to_string(),
number_of_episodes: 2,
number_of_seasons: 1,
seasons: vec![TvSeason {
id: 1,
season_number: 1,
name: "Season 1".to_string(),
overview: "First season".to_string(),
episodes: vec![
TvSeasonEpisode {
id: 1,
season_number: 1,
episode_number: 1,
name: "Pilot".to_string(),
overview: "First episode".to_string(),
},
TvSeasonEpisode {
id: 2,
season_number: 1,
episode_number: 2,
name: "Second Episode".to_string(),
overview: "Second episode".to_string(),
},
],
}],
};
let episodes = show.episodes();
assert_eq!(episodes.len(), 2);
assert!(episodes.contains_key("S01E01"));
assert!(episodes.contains_key("S01E02"));
assert_eq!(episodes.get("S01E01").unwrap().name, "Pilot");
assert_eq!(episodes.get("S01E02").unwrap().name, "Second Episode");
}
#[test]
fn test_show_episodes_multiple_seasons() {
let show = Show {
id: 1,
name: "Test Show".to_string(),
overview: "A test show".to_string(),
year: 2020,
first_air_date: "2020-01-01".to_string(),
number_of_episodes: 3,
number_of_seasons: 2,
seasons: vec![
TvSeason {
id: 1,
season_number: 1,
name: "Season 1".to_string(),
overview: "First season".to_string(),
episodes: vec![TvSeasonEpisode {
id: 1,
season_number: 1,
episode_number: 1,
name: "Pilot".to_string(),
overview: "First episode".to_string(),
}],
},
TvSeason {
id: 2,
season_number: 2,
name: "Season 2".to_string(),
overview: "Second season".to_string(),
episodes: vec![
TvSeasonEpisode {
id: 2,
season_number: 2,
episode_number: 1,
name: "Season 2 Premiere".to_string(),
overview: "First episode of season 2".to_string(),
},
TvSeasonEpisode {
id: 3,
season_number: 2,
episode_number: 2,
name: "Episode 2".to_string(),
overview: "Second episode of season 2".to_string(),
},
],
},
],
};
let episodes = show.episodes();
assert_eq!(episodes.len(), 3);
assert!(episodes.contains_key("S01E01"));
assert!(episodes.contains_key("S02E01"));
assert!(episodes.contains_key("S02E02"));
}
#[test]
fn test_show_episodes_empty() {
let show = Show {
id: 1,
name: "Test Show".to_string(),
overview: "A test show".to_string(),
year: 2020,
first_air_date: "2020-01-01".to_string(),
number_of_episodes: 0,
number_of_seasons: 0,
seasons: vec![],
};
let episodes = show.episodes();
assert_eq!(episodes.len(), 0);
}
}