diff --git a/.sqlx/query-2934322298dad8dfe3583122fc56e10dd8785b11d41338c7936e47e94590ab37.json b/.sqlx/query-2934322298dad8dfe3583122fc56e10dd8785b11d41338c7936e47e94590ab37.json new file mode 100644 index 0000000..ca463dc --- /dev/null +++ b/.sqlx/query-2934322298dad8dfe3583122fc56e10dd8785b11d41338c7936e47e94590ab37.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, s.root_id AS \"root_id!: i64\",\n r.path AS \"root_path!: String\",\n s.upstream_ended AS \"upstream_ended!: bool\",\n s.created_at AS \"created_at!: String\"\n FROM series s JOIN roots r ON r.id = s.root_id\n WHERE (?1 IS NULL OR s.id = ?1) AND (?2 IS NULL OR s.tmdb_id = ?2)\n ORDER BY s.title, s.year, s.id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "root_id!: i64", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "root_path!: String", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "upstream_ended!: bool", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "created_at!: String", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "2934322298dad8dfe3583122fc56e10dd8785b11d41338c7936e47e94590ab37" +} diff --git a/.sqlx/query-79411288157a57119c31625aa46e23d46571c894fa42a0d109079c738dee97ac.json b/.sqlx/query-79411288157a57119c31625aa46e23d46571c894fa42a0d109079c738dee97ac.json new file mode 100644 index 0000000..abd1bf9 --- /dev/null +++ b/.sqlx/query-79411288157a57119c31625aa46e23d46571c894fa42a0d109079c738dee97ac.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT series_id AS \"series_id!: i64\", number AS \"number!: i64\",\n tracked AS \"tracked!: bool\" FROM seasons ORDER BY number", + "describe": { + "columns": [ + { + "name": "series_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "number!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "tracked!: bool", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "79411288157a57119c31625aa46e23d46571c894fa42a0d109079c738dee97ac" +} diff --git a/.sqlx/query-f4659aa8838d2572b24da09ec4efb538474842113d3f1f7c36d7f33f23ce65e8.json b/.sqlx/query-f4659aa8838d2572b24da09ec4efb538474842113d3f1f7c36d7f33f23ce65e8.json new file mode 100644 index 0000000..88d9ec4 --- /dev/null +++ b/.sqlx/query-f4659aa8838d2572b24da09ec4efb538474842113d3f1f7c36d7f33f23ce65e8.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT title_id AS \"title_id!: i64\", owner_id AS \"owner_id!: i64\"\n FROM title_owners WHERE title_kind = 'series' ORDER BY owner_id", + "describe": { + "columns": [ + { + "name": "title_id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "owner_id!: i64", + "ordinal": 1, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "f4659aa8838d2572b24da09ec4efb538474842113d3f1f7c36d7f33f23ce65e8" +} diff --git a/crates/arr-compat/src/lib.rs b/crates/arr-compat/src/lib.rs index 5d63197..db12055 100644 --- a/crates/arr-compat/src/lib.rs +++ b/crates/arr-compat/src/lib.rs @@ -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 { .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)] diff --git a/crates/arr-compat/src/model.rs b/crates/arr-compat/src/model.rs index effa6ff..cf32d05 100644 --- a/crates/arr-compat/src/model.rs +++ b/crates/arr-compat/src/model.rs @@ -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, + pub images: Vec, + pub seasons: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub overview: Option, +} + +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) -> 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, + overview: Option, + 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, + #[serde(default)] + pub root_folder_path: Option, + #[serde(default)] + pub quality_profile_id: Option, + #[serde(default)] + pub language_profile_id: Option, + #[serde(default)] + pub monitored: bool, + #[serde(default)] + pub season_folder: bool, + #[serde(default)] + pub seasons: Vec, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub add_options: Option, +} + +#[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 } diff --git a/crates/arr-compat/src/series.rs b/crates/arr-compat/src/series.rs new file mode 100644 index 0000000..aca0087 --- /dev/null +++ b/crates/arr-compat/src/series.rs @@ -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, + pub(crate) root_id: i64, + pub(crate) root_path: String, + pub(crate) upstream_ended: bool, + pub(crate) created_at: String, + pub(crate) seasons: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ListQuery { + #[serde(default)] + tmdb_id: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LookupQuery { + #[serde(default)] + term: String, +} + +pub(crate) async fn list( + State(state): State, + Query(query): Query, +) -> Result>, 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, + Path(id): Path, +) -> Result, 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, + Json(input): Json, +) -> Result<(StatusCode, Json), 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 = 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::(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, + Query(query): Query, +) -> Result>, CompatError> { + let term = query.term.trim(); + if term.is_empty() { + return Ok(Json(Vec::new())); + } + let library: HashMap = 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::() 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::() 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, + tmdb_id: Option, +) -> Result, 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>, 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> = 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 { + 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 _; diff --git a/crates/arr-compat/src/system.rs b/crates/arr-compat/src/system.rs index d5d4678..0093d24 100644 --- a/crates/arr-compat/src/system.rs +++ b/crates/arr-compat/src/system.rs @@ -13,10 +13,7 @@ pub(crate) async fn status() -> Json { 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, ) -> Result>, 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, ) -> Result>, 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, +) -> Result>, 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. diff --git a/crates/arr-compat/src/tests.rs b/crates/arr-compat/src/tests.rs index 6672acd..3010b82 100644 --- a/crates/arr-compat/src/tests.rs +++ b/crates/arr-compat/src/tests.rs @@ -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; diff --git a/crates/arr-meta/src/client.rs b/crates/arr-meta/src/client.rs index a4544e2..f5ea4b3 100644 --- a/crates/arr-meta/src/client.rs +++ b/crates/arr-meta/src/client.rs @@ -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> { + 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> { + 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 { + 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 { + 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(); diff --git a/crates/arr-meta/src/lib.rs b/crates/arr-meta/src/lib.rs index aa218ff..51417e8 100644 --- a/crates/arr-meta/src/lib.rs +++ b/crates/arr-meta/src/lib.rs @@ -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}; diff --git a/crates/arr-meta/src/model.rs b/crates/arr-meta/src/model.rs index 1df921c..dd3dd93 100644 --- a/crates/arr-meta/src/model.rs +++ b/crates/arr-meta/src/model.rs @@ -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, + pub overview: Option, + pub poster_path: Option, +} + +impl SeriesSearchResult { + #[must_use] + pub fn year(&self) -> Option { + 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, + pub title: String, + pub original_language: String, + pub first_air_date: Option, + pub status: String, + pub overview: Option, + pub poster_path: Option, + pub seasons: Vec, +} + +impl Series { + #[must_use] + pub fn year(&self) -> Option { + 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, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Episode { + pub number: u32, + pub title: String, + pub air_date: Option, +} + // --- TMDB wire types ------------------------------------------------------- // // Private on purpose. TMDB's field names stop here. @@ -107,10 +163,135 @@ pub(crate) struct RawSearchPage { pub(crate) results: Vec, } +#[derive(Debug, Deserialize)] +pub(crate) struct RawSeriesSearchPage { + #[serde(default)] + pub(crate) results: Vec, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct RawSeriesSearchResult { + id: u32, + #[serde(default)] + name: String, + #[serde(default)] + original_language: String, + #[serde(default)] + first_air_date: Option, + #[serde(default)] + overview: Option, + #[serde(default)] + poster_path: Option, +} + +impl From 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, + #[serde(default)] + status: String, + #[serde(default)] + overview: Option, + #[serde(default)] + poster_path: Option, + #[serde(default)] + seasons: Vec, + #[serde(default)] + external_ids: Option, +} + +#[derive(Debug, Deserialize)] +struct RawExternalIds { + tvdb_id: Option, +} + +#[derive(Debug, Deserialize)] +struct RawSeasonSummary { + season_number: u32, + #[serde(default)] + episode_count: u32, +} + +impl From 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, +} + +#[derive(Debug, Deserialize)] +struct RawEpisode { + episode_number: u32, + #[serde(default)] + name: String, + #[serde(default)] + air_date: Option, +} + +impl From 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, + #[serde(default)] + pub(crate) tv_results: Vec, } #[derive(Debug, Deserialize)]