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
+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)]