Sonarr v3 shim for Jellyseerr (#84)
This commit was merged in pull request #84.
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 _;
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¶ms).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", ¶ms).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, ¶ms).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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user