Sonarr v3 shim for Jellyseerr (#84)
ci / web (push) Successful in 55s
ci / rust (push) Successful in 1m36s
e2e / e2e (push) Successful in 1m24s

This commit was merged in pull request #84.
This commit is contained in:
2026-08-22 23:33:09 +01:00
parent f38fb1277b
commit 38bd4102bb
11 changed files with 1009 additions and 11 deletions
+8 -1
View File
@@ -20,6 +20,7 @@
mod error;
mod model;
mod movies;
mod series;
mod system;
use std::sync::Arc;
@@ -31,7 +32,8 @@ use axum::Router;
pub use error::CompatError;
pub use model::{
AddMovie, AddOptions, MovieResource, QualityProfile, RootFolder, SystemStatus, Tag,
AddMovie, AddOptions, AddSeries, AddSeriesOptions, MovieResource, QualityProfile, RootFolder,
SeasonResource, SeriesResource, SystemStatus, Tag,
};
/// Everything here hangs off `/api/v3`, the prefix Radarr v3 serves and the
@@ -108,10 +110,15 @@ fn endpoints() -> Router<CompatState> {
.route("/rootFolder", get(system::root_folders))
.route("/qualityprofile", get(system::quality_profiles))
.route("/qualityProfile", get(system::quality_profiles))
.route("/languageprofile", get(system::language_profiles))
.route("/languageProfile", get(system::language_profiles))
.route("/tag", get(system::tags))
.route("/movie", get(movies::list).post(movies::add))
.route("/movie/lookup", get(movies::lookup))
.route("/movie/{movie_id}", get(movies::get))
.route("/series", get(series::list).post(series::add))
.route("/series/lookup", get(series::lookup))
.route("/series/{series_id}", get(series::get))
}
#[cfg(test)]
+183
View File
@@ -334,6 +334,189 @@ pub struct AddOptions {
pub search_for_movie: bool,
}
/// A season in Sonarr's series resource. Jellyseerr changes `monitored` on
/// the seasons selected in a request before posting the resource back.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeasonResource {
pub season_number: i64,
#[serde(default)]
pub monitored: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SeriesResource {
pub id: i64,
pub title: String,
pub sort_title: String,
pub year: i64,
pub tvdb_id: i64,
pub tmdb_id: i64,
pub title_slug: String,
pub path: String,
pub root_folder_path: String,
pub monitored: bool,
pub status: String,
pub ended: bool,
pub quality_profile_id: i64,
pub language_profile_id: i64,
pub added: String,
pub tags: Vec<i64>,
pub images: Vec<Image>,
pub seasons: Vec<SeasonResource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub overview: Option<String>,
}
impl SeriesResource {
pub(crate) fn from_search(hit: &arr_meta::SeriesSearchResult) -> Self {
Self::blank(
i64::from(hit.tmdb_id),
&hit.title,
hit.year().map(i64::from),
hit.overview.clone(),
hit.poster_path.as_deref(),
)
}
pub(crate) fn from_tmdb(series: &arr_meta::Series) -> Self {
let mut resource = Self::blank(
i64::from(series.tmdb_id),
&series.title,
series.year().map(i64::from),
series.overview.clone(),
series.poster_path.as_deref(),
);
resource.tvdb_id = series.tvdb_id.map_or(0, i64::from);
sonarr_status(&series.status).clone_into(&mut resource.status);
resource.ended = series.status == "Ended" || series.status == "Canceled";
resource.seasons = series
.seasons
.iter()
.map(|season| SeasonResource {
season_number: i64::from(season.number),
monitored: false,
})
.collect();
resource
}
pub(crate) fn from_library(row: &crate::series::SeriesRow, tags: Vec<i64>) -> Self {
let folder = folder_name(&row.title, row.year, row.tmdb_id);
Self {
id: row.id,
title: row.title.clone(),
sort_title: row.title.to_lowercase(),
year: row.year.unwrap_or_default(),
tvdb_id: 0,
tmdb_id: row.tmdb_id,
title_slug: title_slug(&row.title, row.tmdb_id),
path: format!("{}/{folder}", row.root_path),
root_folder_path: row.root_path.clone(),
monitored: row.seasons.iter().any(|season| season.monitored),
status: if row.upstream_ended {
"ended"
} else {
"continuing"
}
.to_owned(),
ended: row.upstream_ended,
quality_profile_id: row.root_id,
language_profile_id: row.root_id,
added: row.created_at.clone(),
tags,
images: Vec::new(),
seasons: row.seasons.clone(),
overview: None,
}
}
pub(crate) fn with_library(mut self, row: &crate::series::SeriesRow) -> Self {
let library = Self::from_library(row, Vec::new());
self.id = library.id;
self.path = library.path;
self.root_folder_path = library.root_folder_path;
self.monitored = library.monitored;
self.quality_profile_id = library.quality_profile_id;
self.language_profile_id = library.language_profile_id;
self.added = library.added;
self.seasons = library.seasons;
self
}
fn blank(
tmdb_id: i64,
title: &str,
year: Option<i64>,
overview: Option<String>,
poster_path: Option<&str>,
) -> Self {
Self {
id: 0,
title: title.to_owned(),
sort_title: title.to_lowercase(),
year: year.unwrap_or_default(),
tvdb_id: 0,
tmdb_id,
title_slug: title_slug(title, tmdb_id),
path: String::new(),
root_folder_path: String::new(),
monitored: false,
status: "continuing".to_owned(),
ended: false,
quality_profile_id: 0,
language_profile_id: 0,
added: String::new(),
tags: Vec::new(),
images: poster_path.map(Image::poster).into_iter().collect(),
seasons: Vec::new(),
overview,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddSeries {
pub tmdb_id: i64,
#[serde(default)]
pub title: String,
#[serde(default)]
pub year: Option<i64>,
#[serde(default)]
pub root_folder_path: Option<String>,
#[serde(default)]
pub quality_profile_id: Option<i64>,
#[serde(default)]
pub language_profile_id: Option<i64>,
#[serde(default)]
pub monitored: bool,
#[serde(default)]
pub season_folder: bool,
#[serde(default)]
pub seasons: Vec<SeasonResource>,
#[serde(default)]
pub tags: Vec<i64>,
#[serde(default)]
pub add_options: Option<AddSeriesOptions>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddSeriesOptions {
#[serde(default)]
pub search_for_missing_episodes: bool,
}
fn sonarr_status(status: &str) -> &'static str {
if status == "Ended" || status == "Canceled" {
"ended"
} else {
"continuing"
}
}
fn default_true() -> bool {
true
}
+319
View File
@@ -0,0 +1,319 @@
//! Sonarr's `series` surface, translated onto series, seasons and episodes.
use std::collections::HashMap;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use crate::error::CompatError;
use crate::model::{AddSeries, SeasonResource, SeriesResource};
use crate::CompatState;
#[derive(Debug, Clone)]
pub(crate) struct SeriesRow {
pub(crate) id: i64,
pub(crate) tmdb_id: i64,
pub(crate) title: String,
pub(crate) year: Option<i64>,
pub(crate) root_id: i64,
pub(crate) root_path: String,
pub(crate) upstream_ended: bool,
pub(crate) created_at: String,
pub(crate) seasons: Vec<SeasonResource>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ListQuery {
#[serde(default)]
tmdb_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct LookupQuery {
#[serde(default)]
term: String,
}
pub(crate) async fn list(
State(state): State<CompatState>,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<SeriesResource>>, CompatError> {
let rows = load_series(&state, None, query.tmdb_id).await?;
let mut tags = load_tags(&state).await?;
Ok(Json(
rows.iter()
.map(|row| SeriesResource::from_library(row, tags.remove(&row.id).unwrap_or_default()))
.collect(),
))
}
pub(crate) async fn get(
State(state): State<CompatState>,
Path(id): Path<i64>,
) -> Result<Json<SeriesResource>, CompatError> {
let row = load_series(&state, Some(id), None)
.await?
.into_iter()
.next()
.ok_or(CompatError::NotFound)?;
let tags = load_tags(&state).await?.remove(&row.id).unwrap_or_default();
Ok(Json(SeriesResource::from_library(&row, tags)))
}
pub(crate) async fn add(
State(state): State<CompatState>,
Json(input): Json<AddSeries>,
) -> Result<(StatusCode, Json<SeriesResource>), CompatError> {
if input.tmdb_id <= 0 {
return Err(validation("TmdbId", "a TMDB id is required"));
}
if input.title.trim().is_empty() {
return Err(validation("Title", "a title is required"));
}
if !load_series(&state, None, Some(input.tmdb_id))
.await?
.is_empty()
{
return Err(validation("TmdbId", "this series has already been added"));
}
let root_id = resolve_root(&state, input.root_folder_path.as_deref()).await?;
let tmdb_id = u32::try_from(input.tmdb_id)
.map_err(|_| validation("TmdbId", "a valid TMDB id is required"))?;
let metadata = state.tmdb()?.series(tmdb_id).await?;
let requested: Vec<u32> = input
.seasons
.iter()
.filter(|season| season.monitored)
.filter_map(|season| u32::try_from(season.season_number).ok())
.collect();
let mut seasons = Vec::with_capacity(metadata.seasons.len());
for summary in &metadata.seasons {
let detail = state.tmdb()?.season(tmdb_id, summary.number).await?;
seasons.push((detail, requested.contains(&summary.number)));
}
let mut tx = state.pool().begin().await?;
let series_id = sqlx::query(
"INSERT INTO series (tmdb_id, title, year, original_language, root_id, auto_track, upstream_ended) \
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id",
)
.bind(input.tmdb_id)
.bind(input.title.trim())
.bind(input.year.or_else(|| metadata.year().map(i64::from)))
.bind(&metadata.original_language)
.bind(root_id)
// A Jellyseerr season request must not turn into auto-track. Its intent
// ends at the selected episodes; future seasons remain untouched (§4.1).
.bind(false)
.bind(metadata.status == "Ended" || metadata.status == "Canceled")
.fetch_one(&mut *tx)
.await?
.get::<i64, _>(0);
for (season, wanted) in seasons {
let season_id: i64 = sqlx::query_scalar(
"INSERT INTO seasons (series_id, number, tracked) VALUES (?, ?, ?) RETURNING id",
)
.bind(series_id)
.bind(i64::from(season.number))
.bind(wanted)
.fetch_one(&mut *tx)
.await?;
for episode in season.episodes {
sqlx::query(
"INSERT INTO episodes (season_id, number, title, air_date, wanted) VALUES (?, ?, ?, ?, ?)",
)
.bind(season_id)
.bind(i64::from(episode.number))
.bind(episode.title)
.bind(episode.air_date.map(|date| date.to_string()))
.bind(wanted)
.execute(&mut *tx)
.await?;
}
}
for tag in &input.tags {
sqlx::query(
"INSERT OR IGNORE INTO title_owners (title_kind, title_id, owner_id) \
SELECT 'series', ?, id FROM owners WHERE id = ?",
)
.bind(series_id)
.bind(tag)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
let row = load_series(&state, Some(series_id), None)
.await?
.into_iter()
.next()
.ok_or(CompatError::NotFound)?;
let tags = load_tags(&state).await?.remove(&row.id).unwrap_or_default();
Ok((
StatusCode::CREATED,
Json(SeriesResource::from_library(&row, tags)),
))
}
pub(crate) async fn lookup(
State(state): State<CompatState>,
Query(query): Query<LookupQuery>,
) -> Result<Json<Vec<SeriesResource>>, CompatError> {
let term = query.term.trim();
if term.is_empty() {
return Ok(Json(Vec::new()));
}
let library: HashMap<i64, SeriesRow> = load_series(&state, None, None)
.await?
.into_iter()
.map(|row| (row.tmdb_id, row))
.collect();
if let Some(id) = term.strip_prefix("tmdb:") {
let Ok(id) = id.trim().parse::<u32>() else {
return Ok(Json(Vec::new()));
};
return match state.tmdb()?.series(id).await {
Ok(series) => {
let resource = SeriesResource::from_tmdb(&series);
Ok(Json(vec![match library.get(&i64::from(series.tmdb_id)) {
Some(row) => resource.with_library(row),
None => resource,
}]))
}
Err(arr_meta::Error::NotFound { .. }) => Ok(Json(Vec::new())),
Err(error) => Err(error.into()),
};
}
if let Some(id) = term.strip_prefix("tvdb:") {
let Ok(id) = id.trim().parse::<u32>() else {
return Ok(Json(Vec::new()));
};
let hits = state.tmdb()?.find_series_by_tvdb(id).await?;
return Ok(Json(
hits.iter()
.map(|hit| {
let mut resource = SeriesResource::from_search(hit);
resource.tvdb_id = i64::from(id);
match library.get(&i64::from(hit.tmdb_id)) {
Some(row) => resource.with_library(row),
None => resource,
}
})
.collect(),
));
}
let hits = state.tmdb()?.search_series(term).await?;
Ok(Json(
hits.iter()
.map(|hit| {
let resource = SeriesResource::from_search(hit);
match library.get(&i64::from(hit.tmdb_id)) {
Some(row) => resource.with_library(row),
None => resource,
}
})
.collect(),
))
}
async fn load_series(
state: &CompatState,
id: Option<i64>,
tmdb_id: Option<i64>,
) -> Result<Vec<SeriesRow>, CompatError> {
let rows = sqlx::query!(
r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year, s.root_id AS "root_id!: i64",
r.path AS "root_path!: String",
s.upstream_ended AS "upstream_ended!: bool",
s.created_at AS "created_at!: String"
FROM series s JOIN roots r ON r.id = s.root_id
WHERE (?1 IS NULL OR s.id = ?1) AND (?2 IS NULL OR s.tmdb_id = ?2)
ORDER BY s.title, s.year, s.id"#,
id,
tmdb_id,
)
.fetch_all(state.pool())
.await?;
let all_seasons = sqlx::query!(
r#"SELECT series_id AS "series_id!: i64", number AS "number!: i64",
tracked AS "tracked!: bool" FROM seasons ORDER BY number"#
)
.fetch_all(state.pool())
.await?;
Ok(rows
.into_iter()
.map(|row| SeriesRow {
id: row.id,
tmdb_id: row.tmdb_id,
title: row.title,
year: row.year,
root_id: row.root_id,
root_path: row.root_path,
upstream_ended: row.upstream_ended,
created_at: row.created_at,
seasons: all_seasons
.iter()
.filter(|season| season.series_id == row.id)
.map(|season| SeasonResource {
season_number: season.number,
monitored: season.tracked,
})
.collect(),
})
.collect())
}
async fn load_tags(state: &CompatState) -> Result<HashMap<i64, Vec<i64>>, CompatError> {
let rows = sqlx::query!(
r#"SELECT title_id AS "title_id!: i64", owner_id AS "owner_id!: i64"
FROM title_owners WHERE title_kind = 'series' ORDER BY owner_id"#
)
.fetch_all(state.pool())
.await?;
let mut tags: HashMap<i64, Vec<i64>> = HashMap::new();
for row in rows {
tags.entry(row.title_id).or_default().push(row.owner_id);
}
Ok(tags)
}
async fn resolve_root(state: &CompatState, path: Option<&str>) -> Result<i64, CompatError> {
let Some(path) = path.map(normalise_path).filter(|path| !path.is_empty()) else {
return Err(validation(
"RootFolderPath",
"a root folder path is required",
));
};
state
.database()
.list_roots()
.await?
.into_iter()
.find(|root| root.kind == "tv" && normalise_path(&root.path) == path)
.map(|root| root.id)
.ok_or_else(|| {
validation(
"RootFolderPath",
&format!("{path} is not a configured TV root"),
)
})
}
fn normalise_path(path: &str) -> String {
path.trim().trim_end_matches('/').to_owned()
}
fn validation(property: &'static str, message: &str) -> CompatError {
CompatError::Validation {
property,
message: message.to_owned(),
}
}
use sqlx::Row as _;
+11 -7
View File
@@ -13,10 +13,7 @@ pub(crate) async fn status() -> Json<SystemStatus> {
Json(SystemStatus::default())
}
/// `GET /api/v3/rootfolder` — the real movie roots (§5.1).
///
/// TV roots are excluded rather than merged: they belong to the `series`
/// half of the shim, and a Radarr offered a TV root would file films into it.
/// `GET /api/v3/rootfolder` — the real roots (§5.1).
pub(crate) async fn root_folders(
State(state): State<CompatState>,
) -> Result<Json<Vec<RootFolder>>, CompatError> {
@@ -24,7 +21,6 @@ pub(crate) async fn root_folders(
Ok(Json(
roots
.into_iter()
.filter(|root| root.kind == "movie")
.map(|root| RootFolder {
id: root.id,
path: root.path,
@@ -36,7 +32,7 @@ pub(crate) async fn root_folders(
))
}
/// `GET /api/v3/qualityprofile` — one fake profile per movie root (§5.1).
/// `GET /api/v3/qualityprofile` — one fake profile per root (§5.1).
pub(crate) async fn quality_profiles(
State(state): State<CompatState>,
) -> Result<Json<Vec<QualityProfile>>, CompatError> {
@@ -44,7 +40,6 @@ pub(crate) async fn quality_profiles(
Ok(Json(
roots
.into_iter()
.filter(|root| root.kind == "movie")
.map(|root| QualityProfile {
id: root.id,
name: root.policy_name,
@@ -59,6 +54,15 @@ pub(crate) async fn quality_profiles(
))
}
/// Sonarr v3 still exposes language profiles. The real language rules are
/// attached to roots, so these are the same harmless root-backed fictions as
/// quality profiles.
pub(crate) async fn language_profiles(
State(state): State<CompatState>,
) -> Result<Json<Vec<QualityProfile>>, CompatError> {
quality_profiles(State(state)).await
}
/// `GET /api/v3/tag` — the owners from §4.3, which is what Radarr's tags map
/// onto here. Jellyseerr can then attach one to a request and the notification
/// routing follows.
+133 -1
View File
@@ -115,7 +115,7 @@ async fn root_folders_and_profiles_are_the_real_roots() {
let folders = get_json(format!("{base}/api/v3/rootfolder")).await;
let folders = folders.as_array().expect("array");
assert_eq!(folders.len(), 2, "§5.1: two movie roots");
assert_eq!(folders.len(), 4, "§5.1: two roots per media kind");
let paths: Vec<&str> = folders
.iter()
.filter_map(|folder| folder["path"].as_str())
@@ -136,6 +136,138 @@ async fn root_folders_and_profiles_are_the_real_roots() {
.any(|profile| profile["name"] == "Movies — main"));
}
async fn tmdb_with_show() -> MockServer {
let server = MockServer::start().await;
let series = json!({
"id": 1396,
"name": "Breaking Bad",
"original_language": "en",
"first_air_date": "2008-01-20",
"status": "Ended",
"overview": "A chemistry teacher changes careers.",
"poster_path": "/breaking-bad.jpg",
"external_ids": {"tvdb_id": 81189},
"seasons": [
{"season_number": 0, "episode_count": 1},
{"season_number": 1, "episode_count": 2},
{"season_number": 2, "episode_count": 1}
]
});
Mock::given(method("GET"))
.and(path("/3/tv/1396"))
.respond_with(ResponseTemplate::new(200).set_body_json(series))
.mount(&server)
.await;
for (number, episodes) in [
(
0,
json!([{"episode_number": 1, "name": "Special", "air_date": "2009-01-01"}]),
),
(
1,
json!([
{"episode_number": 1, "name": "Pilot", "air_date": "2008-01-20"},
{"episode_number": 2, "name": "Cat's in the Bag...", "air_date": "2008-01-27"}
]),
),
(
2,
json!([{"episode_number": 1, "name": "Seven Thirty-Seven", "air_date": "2009-03-08"}]),
),
] {
Mock::given(method("GET"))
.and(path(format!("/3/tv/1396/season/{number}")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"season_number": number,
"episodes": episodes
})))
.mount(&server)
.await;
}
Mock::given(method("GET"))
.and(path("/3/search/tv"))
.and(query_param("query", "breaking bad"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"results": [{
"id": 1396, "name": "Breaking Bad", "original_language": "en",
"first_air_date": "2008-01-20", "overview": "A chemistry teacher changes careers.",
"poster_path": "/breaking-bad.jpg"
}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/3/find/81189"))
.and(query_param("external_source", "tvdb_id"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tv_results": [{
"id": 1396, "name": "Breaking Bad", "original_language": "en",
"first_air_date": "2008-01-20", "overview": "A chemistry teacher changes careers.",
"poster_path": "/breaking-bad.jpg"
}]
})))
.mount(&server)
.await;
server
}
#[tokio::test]
async fn a_season_request_marks_only_that_seasons_episodes_wanted() {
let (_dir, database, _unused) = shim().await;
let tmdb = tmdb_with_show().await;
let base = serve(CompatState::new(database.clone()).with_tmdb(client(&tmdb))).await;
let lookup = get_json(format!("{base}/api/v3/series/lookup?term=tmdb:1396")).await;
assert_eq!(lookup[0]["id"], 0);
assert_eq!(lookup[0]["seasons"].as_array().map(Vec::len), Some(3));
assert_eq!(lookup[0]["tvdbId"], 81189);
let by_tvdb = get_json(format!("{base}/api/v3/series/lookup?term=tvdb:81189")).await;
assert_eq!(by_tvdb[0]["tmdbId"], 1396);
assert_eq!(by_tvdb[0]["tvdbId"], 81189);
let response = reqwest::Client::new()
.post(format!("{base}/api/v3/series"))
.json(&json!({
"tmdbId": 1396,
"title": "Breaking Bad",
"year": 2008,
"rootFolderPath": "/mnt/media/tv/main/",
"qualityProfileId": 9999,
"languageProfileId": 9999,
"monitored": false,
"seasons": [
{"seasonNumber": 0, "monitored": false},
{"seasonNumber": 1, "monitored": false},
{"seasonNumber": 2, "monitored": true}
],
"addOptions": {"searchForMissingEpisodes": true}
}))
.send()
.await
.expect("add series");
assert_eq!(response.status(), 201);
let created: Value = response.json().await.expect("json");
assert_eq!(created["rootFolderPath"], "/mnt/media/tv/main");
assert_eq!(created["monitored"], true);
let wanted = sqlx::query_as::<_, (i64, i64, bool)>(
"SELECT seasons.number, episodes.number, episodes.wanted
FROM episodes JOIN seasons ON seasons.id = episodes.season_id
ORDER BY seasons.number, episodes.number",
)
.fetch_all(database.pool())
.await
.expect("wanted set");
assert_eq!(
wanted,
vec![(0, 1, false), (1, 1, false), (1, 2, false), (2, 1, true)]
);
let listed = get_json(format!("{base}/api/v3/series?tmdbId=1396")).await;
assert_eq!(listed[0]["id"], created["id"]);
assert_eq!(listed[0]["seasons"][2]["monitored"], true);
}
#[tokio::test]
async fn tags_are_owners() {
let (_dir, database, base) = shim().await;
+53 -1
View File
@@ -8,7 +8,10 @@ use serde::de::DeserializeOwned;
use crate::cache::Cache;
use crate::error::{Error, Result};
use crate::model::{Movie, MovieSearchResult, RawFindPage, RawMovie, RawSearchPage};
use crate::model::{
Movie, MovieSearchResult, RawFindPage, RawMovie, RawSearchPage, RawSeason, RawSeries,
RawSeriesSearchPage, Season, Series, SeriesSearchResult,
};
/// TMDB's v3 API root.
pub const DEFAULT_BASE_URL: &str = "https://api.themoviedb.org/3/";
@@ -101,6 +104,18 @@ impl TmdbClient {
Ok(page.movie_results.into_iter().map(Into::into).collect())
}
/// Resolve a TVDB series id through TMDB's external-id index.
///
/// # Errors
///
/// Any of [`Error`]; see its variants for what callers should distinguish.
pub async fn find_series_by_tvdb(&self, tvdb_id: u32) -> Result<Vec<SeriesSearchResult>> {
let path = format!("find/{tvdb_id}");
let params = [("external_source", "tvdb_id".to_owned())];
let page: RawFindPage = self.get_json(&path, &params).await?;
Ok(page.tv_results.into_iter().map(Into::into).collect())
}
/// Full detail for one movie, including the digital release date.
///
/// One HTTP call: release dates come back appended to the same response
@@ -116,6 +131,43 @@ impl TmdbClient {
Ok(raw.into())
}
/// Search TMDB for TV series by title.
///
/// # Errors
///
/// Any of [`Error`]; see its variants for what callers should distinguish.
pub async fn search_series(&self, query: &str) -> Result<Vec<SeriesSearchResult>> {
let params = [
("query", query.trim().to_owned()),
("include_adult", "false".to_owned()),
];
let page: RawSeriesSearchPage = self.get_json("search/tv", &params).await?;
Ok(page.results.into_iter().map(Into::into).collect())
}
/// Full detail for one TV series, including its season summaries.
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
pub async fn series(&self, tmdb_id: u32) -> Result<Series> {
let path = format!("tv/{tmdb_id}");
let params = [("append_to_response", "external_ids".to_owned())];
let raw: RawSeries = self.get_json(&path, &params).await?;
Ok(raw.into())
}
/// Episodes in one TV season.
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such season, otherwise any of [`Error`].
pub async fn season(&self, tmdb_id: u32, season_number: u32) -> Result<Season> {
let path = format!("tv/{tmdb_id}/season/{season_number}");
let raw: RawSeason = self.get_json(&path, &[]).await?;
Ok(raw.into())
}
/// Drop every cached response. For a user-initiated "refresh metadata".
pub fn clear_cache(&self) {
self.cache.clear();
+1 -1
View File
@@ -23,4 +23,4 @@ mod model;
pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL};
pub use error::{Error, Result};
pub use model::{Movie, MovieSearchResult};
pub use model::{Episode, Movie, MovieSearchResult, Season, Series, SeriesSearchResult};
+181
View File
@@ -97,6 +97,62 @@ impl Movie {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SeriesSearchResult {
pub tmdb_id: u32,
pub title: String,
pub original_language: String,
pub first_air_date: Option<NaiveDate>,
pub overview: Option<String>,
pub poster_path: Option<String>,
}
impl SeriesSearchResult {
#[must_use]
pub fn year(&self) -> Option<i32> {
self.first_air_date.map(|date| date.year())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Series {
pub tmdb_id: u32,
pub tvdb_id: Option<u32>,
pub title: String,
pub original_language: String,
pub first_air_date: Option<NaiveDate>,
pub status: String,
pub overview: Option<String>,
pub poster_path: Option<String>,
pub seasons: Vec<SeasonSummary>,
}
impl Series {
#[must_use]
pub fn year(&self) -> Option<i32> {
self.first_air_date.map(|date| date.year())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SeasonSummary {
pub number: u32,
pub episode_count: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Season {
pub number: u32,
pub episodes: Vec<Episode>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Episode {
pub number: u32,
pub title: String,
pub air_date: Option<NaiveDate>,
}
// --- TMDB wire types -------------------------------------------------------
//
// Private on purpose. TMDB's field names stop here.
@@ -107,10 +163,135 @@ pub(crate) struct RawSearchPage {
pub(crate) results: Vec<RawSearchResult>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawSeriesSearchPage {
#[serde(default)]
pub(crate) results: Vec<RawSeriesSearchResult>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawSeriesSearchResult {
id: u32,
#[serde(default)]
name: String,
#[serde(default)]
original_language: String,
#[serde(default)]
first_air_date: Option<String>,
#[serde(default)]
overview: Option<String>,
#[serde(default)]
poster_path: Option<String>,
}
impl From<RawSeriesSearchResult> for SeriesSearchResult {
fn from(raw: RawSeriesSearchResult) -> Self {
Self {
tmdb_id: raw.id,
title: raw.name,
original_language: raw.original_language,
first_air_date: raw.first_air_date.as_deref().and_then(parse_date),
overview: non_empty(raw.overview),
poster_path: non_empty(raw.poster_path),
}
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawSeries {
id: u32,
#[serde(default)]
name: String,
#[serde(default)]
original_language: String,
#[serde(default)]
first_air_date: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
overview: Option<String>,
#[serde(default)]
poster_path: Option<String>,
#[serde(default)]
seasons: Vec<RawSeasonSummary>,
#[serde(default)]
external_ids: Option<RawExternalIds>,
}
#[derive(Debug, Deserialize)]
struct RawExternalIds {
tvdb_id: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct RawSeasonSummary {
season_number: u32,
#[serde(default)]
episode_count: u32,
}
impl From<RawSeries> for Series {
fn from(raw: RawSeries) -> Self {
Self {
tmdb_id: raw.id,
tvdb_id: raw.external_ids.and_then(|ids| ids.tvdb_id),
title: raw.name,
original_language: raw.original_language,
first_air_date: raw.first_air_date.as_deref().and_then(parse_date),
status: raw.status,
overview: non_empty(raw.overview),
poster_path: non_empty(raw.poster_path),
seasons: raw
.seasons
.into_iter()
.map(|season| SeasonSummary {
number: season.season_number,
episode_count: season.episode_count,
})
.collect(),
}
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawSeason {
season_number: u32,
#[serde(default)]
episodes: Vec<RawEpisode>,
}
#[derive(Debug, Deserialize)]
struct RawEpisode {
episode_number: u32,
#[serde(default)]
name: String,
#[serde(default)]
air_date: Option<String>,
}
impl From<RawSeason> for Season {
fn from(raw: RawSeason) -> Self {
Self {
number: raw.season_number,
episodes: raw
.episodes
.into_iter()
.map(|episode| Episode {
number: episode.episode_number,
title: episode.name,
air_date: episode.air_date.as_deref().and_then(parse_date),
})
.collect(),
}
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawFindPage {
#[serde(default)]
pub(crate) movie_results: Vec<RawSearchResult>,
#[serde(default)]
pub(crate) tv_results: Vec<RawSeriesSearchResult>,
}
#[derive(Debug, Deserialize)]