diff --git a/.sqlx/query-0ace60e25bb38dd1075f0ab119547b12f719b90a3b1d3adc0995982a508ae7bc.json b/.sqlx/query-0ace60e25bb38dd1075f0ab119547b12f719b90a3b1d3adc0995982a508ae7bc.json new file mode 100644 index 0000000..75a643f --- /dev/null +++ b/.sqlx/query-0ace60e25bb38dd1075f0ab119547b12f719b90a3b1d3adc0995982a508ae7bc.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR IGNORE INTO title_owners (title_kind, title_id, owner_id) SELECT 'movie', ?, id FROM owners WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "0ace60e25bb38dd1075f0ab119547b12f719b90a3b1d3adc0995982a508ae7bc" +} diff --git a/.sqlx/query-3adb45309bbd3f1de7d968abd8e5a65d4c6a2b890a54af9d5d2d956cca783892.json b/.sqlx/query-3adb45309bbd3f1de7d968abd8e5a65d4c6a2b890a54af9d5d2d956cca783892.json new file mode 100644 index 0000000..a9d0770 --- /dev/null +++ b/.sqlx/query-3adb45309bbd3f1de7d968abd8e5a65d4c6a2b890a54af9d5d2d956cca783892.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT m.id AS \"id!: i64\",\n m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\",\n m.year,\n m.wanted AS \"wanted!: bool\",\n m.root_id AS \"root_id!: i64\",\n r.path AS \"root_path!: String\",\n m.created_at AS \"created_at!: String\",\n (SELECT count(*) FROM media_files f\n WHERE f.owner_kind = 'movie' AND f.owner_id = m.id)\n AS \"file_count!: i64\",\n (SELECT coalesce(sum(f.size), 0) FROM media_files f\n WHERE f.owner_kind = 'movie' AND f.owner_id = m.id)\n AS \"size_on_disk!: i64\"\n FROM movies m\n JOIN roots r ON r.id = m.root_id\n WHERE (?1 IS NULL OR m.id = ?1)\n AND (?2 IS NULL OR m.tmdb_id = ?2)\n ORDER BY m.title, m.year, m.id\n ", + "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": "wanted!: bool", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "root_id!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "root_path!: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "created_at!: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "file_count!: i64", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "size_on_disk!: i64", + "ordinal": 9, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "3adb45309bbd3f1de7d968abd8e5a65d4c6a2b890a54af9d5d2d956cca783892" +} diff --git a/.sqlx/query-500cfed4016c5956bc73f2c9bd14f36e1c32672bd171009fb164e6593f98ec31.json b/.sqlx/query-500cfed4016c5956bc73f2c9bd14f36e1c32672bd171009fb164e6593f98ec31.json new file mode 100644 index 0000000..f9c6de9 --- /dev/null +++ b/.sqlx/query-500cfed4016c5956bc73f2c9bd14f36e1c32672bd171009fb164e6593f98ec31.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 = 'movie' 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": "500cfed4016c5956bc73f2c9bd14f36e1c32672bd171009fb164e6593f98ec31" +} diff --git a/.sqlx/query-6d6796a0f35b6ee3e94c3ff2b9575c2912f03aae8d8fcf67b7fc354d41c15ede.json b/.sqlx/query-6d6796a0f35b6ee3e94c3ff2b9575c2912f03aae8d8fcf67b7fc354d41c15ede.json new file mode 100644 index 0000000..8deed9e --- /dev/null +++ b/.sqlx/query-6d6796a0f35b6ee3e94c3ff2b9575c2912f03aae8d8fcf67b7fc354d41c15ede.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted) VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "6d6796a0f35b6ee3e94c3ff2b9575c2912f03aae8d8fcf67b7fc354d41c15ede" +} diff --git a/.sqlx/query-b5aed94114cf4f1764ff5d6110196527b87bb0c61a7c692a42b2fada7e1ed8e8.json b/.sqlx/query-b5aed94114cf4f1764ff5d6110196527b87bb0c61a7c692a42b2fada7e1ed8e8.json new file mode 100644 index 0000000..35c070f --- /dev/null +++ b/.sqlx/query-b5aed94114cf4f1764ff5d6110196527b87bb0c61a7c692a42b2fada7e1ed8e8.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", name AS \"label!: String\" FROM owners ORDER BY name", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "label!: String", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false + ] + }, + "hash": "b5aed94114cf4f1764ff5d6110196527b87bb0c61a7c692a42b2fada7e1ed8e8" +} diff --git a/Cargo.lock b/Cargo.lock index bca7f7d..a38c007 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,19 @@ dependencies = [ [[package]] name = "arr-compat" version = "0.1.0" +dependencies = [ + "arr-db", + "arr-meta", + "axum", + "reqwest", + "serde", + "serde_json", + "sqlx", + "tempfile", + "tokio", + "tracing", + "wiremock", +] [[package]] name = "arr-core" @@ -61,7 +74,9 @@ name = "arr-daemon" version = "0.1.0" dependencies = [ "arr-api", + "arr-compat", "arr-db", + "arr-meta", "axum", "reqwest", "serde", diff --git a/crates/arr-compat/Cargo.toml b/crates/arr-compat/Cargo.toml index 4dcdca8..19b54cc 100644 --- a/crates/arr-compat/Cargo.toml +++ b/crates/arr-compat/Cargo.toml @@ -7,6 +7,19 @@ repository.workspace = true publish = false [dependencies] +arr-db = { workspace = true } +arr-meta = { workspace = true } +axum = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +reqwest = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true } +wiremock = { workspace = true } [lints] workspace = true diff --git a/crates/arr-compat/src/error.rs b/crates/arr-compat/src/error.rs new file mode 100644 index 0000000..f175696 --- /dev/null +++ b/crates/arr-compat/src/error.rs @@ -0,0 +1,100 @@ +//! Failures, in the shapes Radarr produces them. +//! +//! Jellyseerr surfaces the response body verbatim in its own logs and settings +//! UI, so a validation failure that comes back in Radarr's shape is readable +//! there without anyone knowing this is not Radarr. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; + +/// Everything the shim can answer with instead of a result. +#[derive(Debug)] +pub enum CompatError { + /// The request was well-formed but names something that does not exist + /// here — usually a root folder path Jellyseerr was configured with by + /// hand. `property` is Radarr's field name, `PascalCase` as it sends it. + Validation { + /// Radarr's name for the offending field, e.g. `RootFolderPath`. + property: &'static str, + /// What is wrong with it, in a sentence. + message: String, + }, + /// No such movie. + NotFound, + /// TMDB is unconfigured or would not answer. + Upstream(String), + /// The database would not answer. + Database(String), +} + +/// One entry of Radarr's validation-failure body. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ValidationFailure { + property_name: &'static str, + error_message: String, + severity: &'static str, +} + +/// Radarr's shape for everything that is not a validation failure. +#[derive(Debug, Serialize)] +struct Message { + message: String, +} + +impl IntoResponse for CompatError { + fn into_response(self) -> Response { + match self { + // A list, not an object: Radarr's model binder reports every + // failing field at once and Jellyseerr's client reads index 0. + Self::Validation { property, message } => ( + StatusCode::BAD_REQUEST, + Json(vec![ValidationFailure { + property_name: property, + error_message: message, + severity: "error", + }]), + ) + .into_response(), + Self::NotFound => ( + StatusCode::NOT_FOUND, + Json(Message { + message: "NotFound".into(), + }), + ) + .into_response(), + Self::Upstream(detail) => { + tracing::warn!(%detail, "compat shim upstream failure"); + (StatusCode::BAD_GATEWAY, Json(Message { message: detail })).into_response() + } + Self::Database(detail) => { + tracing::error!(%detail, "compat shim database error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(Message { + message: "database error".into(), + }), + ) + .into_response() + } + } + } +} + +impl From for CompatError { + fn from(error: sqlx::Error) -> Self { + if matches!(error, sqlx::Error::RowNotFound) { + Self::NotFound + } else { + Self::Database(error.to_string()) + } + } +} + +impl From for CompatError { + fn from(error: arr_meta::Error) -> Self { + Self::Upstream(error.to_string()) + } +} diff --git a/crates/arr-compat/src/lib.rs b/crates/arr-compat/src/lib.rs index ac13547..5d63197 100644 --- a/crates/arr-compat/src/lib.rs +++ b/crates/arr-compat/src/lib.rs @@ -1 +1,118 @@ -//! arr-compat — see DESIGN.md. +//! arr-compat — the Radarr v3 slice Jellyseerr calls. See DESIGN.md §9.4. +//! +//! Jellyseerr already does Jellyfin user auth, discovery and request approval, +//! none of which this project is trying to solve. So it stays, and it keeps +//! talking to what it believes is a Radarr. This crate is the translation +//! layer and nothing else: +//! +//! - It maps onto the real domain (`roots`, `movies`, `owners`) and holds no +//! state of its own. +//! - It never reaches into `arr-core`, and `arr-core` never learns what a +//! "quality profile" is. Radarr's vocabulary stops at this crate's edge. +//! - Where Radarr's model and this one disagree, this one wins and the +//! difference is absorbed here. Policy attaches to a root (§5.1), so the +//! quality profile Jellyseerr sends is read, acknowledged and dropped; the +//! root folder path is what decides which policy a request lands under. +//! +//! What is deliberately not here: everything Jellyseerr does not call. This is +//! not an attempt at Radarr compatibility in general. + +mod error; +mod model; +mod movies; +mod system; + +use std::sync::Arc; + +use arr_db::Db; +use arr_meta::TmdbClient; +use axum::routing::get; +use axum::Router; + +pub use error::CompatError; +pub use model::{ + AddMovie, AddOptions, MovieResource, QualityProfile, RootFolder, SystemStatus, Tag, +}; + +/// Everything here hangs off `/api/v3`, the prefix Radarr v3 serves and the +/// one Jellyseerr appends to whatever URL an operator types into it. +pub const API_PREFIX: &str = "/api/v3"; + +/// What the shim answers `system/status` with. +/// +/// Jellyseerr branches on the major version to decide which Radarr dialect to +/// speak, so this is not decoration: it has to name a real release that serves +/// `/api/v3`, and it has to stay put. +pub const RADARR_VERSION: &str = "5.14.0.9383"; + +/// What the shim needs to answer: the real database, and TMDB for `movie/lookup`. +/// +/// Cheap to clone — [`Db`] is a pool handle and the TMDB client is shared +/// behind an [`Arc`] so its response cache is not duplicated per clone. +#[derive(Debug, Clone)] +pub struct CompatState { + database: Db, + tmdb: Option>, +} + +impl CompatState { + /// The shim over a migrated database, with no TMDB client. + /// + /// Without one `movie/lookup` is the only endpoint that stops working, and + /// it fails loudly rather than answering an empty list — an empty lookup + /// looks to Jellyseerr like "TMDB has never heard of this film". + #[must_use] + pub fn new(database: Db) -> Self { + Self { + database, + tmdb: None, + } + } + + /// Attach the TMDB client `movie/lookup` searches through. + #[must_use] + pub fn with_tmdb(mut self, tmdb: Arc) -> Self { + self.tmdb = Some(tmdb); + self + } + + pub(crate) fn pool(&self) -> &sqlx::SqlitePool { + self.database.pool() + } + + pub(crate) fn database(&self) -> &Db { + &self.database + } + + pub(crate) fn tmdb(&self) -> Result<&TmdbClient, CompatError> { + self.tmdb + .as_deref() + .ok_or_else(|| CompatError::Upstream("TMDB is not configured".into())) + } +} + +/// The shim, mounted under [`API_PREFIX`], ready to `merge` into the app. +pub fn router(state: CompatState) -> Router { + Router::new().nest(API_PREFIX, endpoints().with_state(state)) +} + +/// Radarr runs on ASP.NET, whose routing is case-insensitive, and Jellyseerr +/// takes advantage: it asks for `qualityProfile` while the documentation says +/// `qualityprofile`. `axum` matches exactly, so both spellings are registered +/// rather than lower-casing every incoming path and losing the ability to see +/// what was actually requested in a trace. +fn endpoints() -> Router { + Router::new() + .route("/system/status", get(system::status)) + .route("/rootfolder", get(system::root_folders)) + .route("/rootFolder", get(system::root_folders)) + .route("/qualityprofile", get(system::quality_profiles)) + .route("/qualityProfile", get(system::quality_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)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/arr-compat/src/model.rs b/crates/arr-compat/src/model.rs new file mode 100644 index 0000000..effa6ff --- /dev/null +++ b/crates/arr-compat/src/model.rs @@ -0,0 +1,378 @@ +//! Radarr's wire vocabulary. Nothing in here is a domain type — these are the +//! shapes Jellyseerr parses, and they exist so that no other crate has to know +//! what `qualityProfileId` means. +//! +//! Field names are Radarr's, camelCase, and the field *set* is the subset +//! Jellyseerr reads plus what makes a response look coherent to a human +//! reading it in a browser. Radarr sends a great deal more. + +use serde::{Deserialize, Serialize}; + +use crate::RADARR_VERSION; + +/// Where TMDB serves the images whose paths its API returns. +const TMDB_IMAGE_BASE: &str = "https://image.tmdb.org/t/p/original"; + +/// `GET /api/v3/system/status`. +/// +/// Jellyseerr reads `version` and `appName`; the rest is what a Radarr answers +/// and costs nothing to keep truthful about the process actually serving it. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +// Radarr's own payload is a flat run of feature flags. Reshaping it into +// something clippy likes would mean not being Radarr. +#[allow(clippy::struct_excessive_bools)] +pub struct SystemStatus { + pub app_name: &'static str, + pub instance_name: &'static str, + pub version: &'static str, + pub build_time: &'static str, + pub is_debug: bool, + pub is_production: bool, + pub is_admin: bool, + pub is_user_interactive: bool, + pub startup_path: &'static str, + pub app_data: &'static str, + pub os_name: &'static str, + pub is_net_core: bool, + pub is_linux: bool, + pub is_osx: bool, + pub is_windows: bool, + pub is_docker: bool, + pub mode: &'static str, + pub branch: &'static str, + /// DESIGN.md §2: the perimeter is the VPN. Radarr's own word for that is + /// `none`, and saying so is more honest than pretending to hold a key. + pub authentication: &'static str, + pub url_base: &'static str, + pub runtime_version: &'static str, + pub package_version: &'static str, +} + +impl Default for SystemStatus { + fn default() -> Self { + Self { + app_name: "Radarr", + instance_name: "arr", + version: RADARR_VERSION, + build_time: "2025-01-01T00:00:00Z", + is_debug: false, + is_production: true, + is_admin: false, + is_user_interactive: false, + startup_path: "/", + app_data: "/config", + os_name: "linux", + is_net_core: true, + is_linux: true, + is_osx: false, + is_windows: false, + is_docker: false, + mode: "console", + branch: "master", + authentication: "none", + url_base: "", + runtime_version: "8.0.0", + package_version: concat!("arr ", env!("CARGO_PKG_VERSION")), + } + } +} + +/// `GET /api/v3/rootfolder`. One entry per real movie root (§5.1). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RootFolder { + /// The real root's row id, so a Jellyseerr default folder survives. + pub id: i64, + pub path: String, + pub accessible: bool, + /// Always zero. Nothing here decides anything on free space, and reporting + /// a number would mean stat-ing a filesystem on every settings page load. + pub free_space: i64, + pub unmapped_folders: Vec, +} + +/// `GET /api/v3/qualityprofile`. One fake profile per root (§5.1). +/// +/// The profile is a fiction that exists because Jellyseerr insists on picking +/// one per request. It carries the root's policy name so that the operator +/// choosing in Jellyseerr's dropdown is choosing something meaningful, but +/// whichever id comes back on the request is ignored — the root folder path +/// decides. `id` is the root's id, so the two dropdowns line up. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QualityProfile { + pub id: i64, + pub name: String, + /// There is no upgrade loop here at all (§5.4). + pub upgrade_allowed: bool, + pub cutoff: i64, + /// Empty on purpose: the real rules are policy rows, not a quality list, + /// and inventing a plausible-looking one would be a second place for a + /// reader to look for the truth. + pub items: Vec, + pub min_format_score: i64, + pub cutoff_format_score: i64, + pub format_items: Vec, +} + +/// `GET /api/v3/tag`. Radarr's tags are this project's owners (§4.3). +#[derive(Debug, Clone, Serialize)] +pub struct Tag { + pub id: i64, + pub label: String, +} + +/// A poster, in the shape Radarr reports one. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Image { + pub cover_type: &'static str, + pub remote_url: String, + pub url: String, +} + +impl Image { + /// TMDB returns a path fragment, not a URL. + fn poster(poster_path: &str) -> Self { + let url = format!("{TMDB_IMAGE_BASE}{poster_path}"); + Self { + cover_type: "poster", + remote_url: url.clone(), + url, + } + } +} + +/// A movie, as Radarr describes one. +/// +/// `id` is the load-bearing field: Jellyseerr treats a non-zero `id` on a +/// lookup result as "already in the library" and skips the add. So a lookup +/// hit that matches a local row must carry that row's id, and one that does +/// not must carry zero. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(clippy::struct_excessive_bools)] +pub struct MovieResource { + pub id: i64, + pub title: String, + pub sort_title: String, + pub year: i64, + pub tmdb_id: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub imdb_id: Option, + pub title_slug: String, + pub folder_name: String, + pub path: String, + pub root_folder_path: String, + /// Radarr's `monitored` is this project's `wanted` (§4.1). + pub monitored: bool, + pub has_file: bool, + /// Radarr's older name for `has_file`. Jellyseerr reads this one. + pub downloaded: bool, + pub is_available: bool, + pub size_on_disk: i64, + pub status: &'static str, + pub minimum_availability: &'static str, + pub quality_profile_id: i64, + /// Radarr's older name for `quality_profile_id`. Jellyseerr reads both. + pub profile_id: i64, + pub added: String, + pub tags: Vec, + pub images: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub overview: Option, + pub runtime: i64, +} + +impl MovieResource { + /// The fields every mapping shares, with everything library-side empty. + fn blank(tmdb_id: i64, title: &str, year: Option) -> Self { + Self { + id: 0, + title: title.to_owned(), + sort_title: title.to_lowercase(), + year: year.unwrap_or_default(), + tmdb_id, + imdb_id: None, + title_slug: title_slug(title, tmdb_id), + folder_name: String::new(), + path: String::new(), + root_folder_path: String::new(), + monitored: false, + has_file: false, + downloaded: false, + // Everything reachable on an indexer is available to request; the + // §6.2 digital-release gate is the reconcile loop's business, not + // a reason to hide a title from the person asking for it. + is_available: true, + size_on_disk: 0, + status: "released", + minimum_availability: "released", + quality_profile_id: 0, + profile_id: 0, + added: String::new(), + tags: Vec::new(), + images: Vec::new(), + overview: None, + runtime: 0, + } + } + + /// A row of the real `movies` table, dressed as a Radarr movie. + pub(crate) fn from_library(row: &crate::movies::MovieRow, tags: Vec) -> Self { + let folder = folder_name(&row.title, row.year, row.tmdb_id); + Self { + id: row.id, + folder_name: format!("{}/{folder}", row.root_path), + path: format!("{}/{folder}", row.root_path), + root_folder_path: row.root_path.clone(), + monitored: row.wanted, + has_file: row.file_count > 0, + downloaded: row.file_count > 0, + size_on_disk: row.size_on_disk, + quality_profile_id: row.root_id, + profile_id: row.root_id, + added: row.created_at.clone(), + tags, + ..Self::blank(row.tmdb_id, &row.title, row.year) + } + } + + /// A TMDB search hit, dressed as a Radarr movie. + pub(crate) fn from_search(hit: &arr_meta::MovieSearchResult) -> Self { + Self { + images: hit + .poster_path + .as_deref() + .map(Image::poster) + .into_iter() + .collect(), + overview: hit.overview.clone(), + ..Self::blank( + i64::from(hit.tmdb_id), + &hit.title, + hit.year().map(i64::from), + ) + } + } + + /// A fully detailed TMDB movie, dressed as a Radarr movie. + pub(crate) fn from_tmdb(movie: &arr_meta::Movie) -> Self { + Self { + imdb_id: movie.imdb_id.clone(), + images: movie + .poster_path + .as_deref() + .map(Image::poster) + .into_iter() + .collect(), + overview: movie.overview.clone(), + runtime: movie.runtime.map_or(0, i64::from), + ..Self::blank( + i64::from(movie.tmdb_id), + &movie.title, + movie.year().map(i64::from), + ) + } + } + + /// Overlay what the library knows about a title a lookup just found. + /// + /// Everything descriptive stays TMDB's — it is fresher, and the local row + /// only stores a title and a year — but the identity and intent fields + /// come from the row, because those are what Jellyseerr acts on. + pub(crate) fn with_library(mut self, row: &crate::movies::MovieRow) -> Self { + let folder = folder_name(&row.title, row.year, row.tmdb_id); + self.id = row.id; + self.folder_name = format!("{}/{folder}", row.root_path); + self.path.clone_from(&self.folder_name); + self.root_folder_path.clone_from(&row.root_path); + self.monitored = row.wanted; + self.has_file = row.file_count > 0; + self.downloaded = self.has_file; + self.size_on_disk = row.size_on_disk; + self.quality_profile_id = row.root_id; + self.profile_id = row.root_id; + self.added.clone_from(&row.created_at); + self + } +} + +/// `POST /api/v3/movie`. Jellyseerr sends a good deal more than this; the +/// fields absent here are absent because nothing is done with them. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddMovie { + pub tmdb_id: i64, + #[serde(default)] + pub title: String, + #[serde(default)] + pub year: Option, + /// Which root, and so which policy (§5.1). The only routing input. + #[serde(default)] + pub root_folder_path: Option, + /// Read and dropped. Kept in the struct so the reason is visible here + /// rather than being an absence someone has to notice. + #[serde(default)] + pub quality_profile_id: Option, + #[serde(default = "default_true")] + pub monitored: bool, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub add_options: Option, +} + +/// Radarr's per-request flags. `search_for_movie` is read and dropped: the +/// reconcile loop searches everything wanted (§8), so asking is redundant and +/// obeying it would be a second search path to keep correct. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddOptions { + #[serde(default)] + pub search_for_movie: bool, +} + +fn default_true() -> bool { + true +} + +/// Radarr's slug: lower-case, punctuation dropped, spaces hyphenated, id +/// appended. Jellyseerr echoes it back on add and never parses it. +fn title_slug(title: &str, tmdb_id: i64) -> String { + let mut slug = String::with_capacity(title.len() + 8); + let mut pending_dash = false; + for character in title.chars() { + if character.is_ascii_alphanumeric() { + if pending_dash && !slug.is_empty() { + slug.push('-'); + } + pending_dash = false; + slug.extend(character.to_lowercase()); + } else { + pending_dash = true; + } + } + if slug.is_empty() { + return tmdb_id.to_string(); + } + format!("{slug}-{tmdb_id}") +} + +/// The §7.4 folder: `Title (Year) [tmdbid-N]`. The provider id is what turns +/// Jellyfin's matching from string guessing into a lookup. +/// +/// Path separators are stripped rather than escaped — a title containing one +/// would otherwise name a directory that is not the one meant. +pub(crate) fn folder_name(title: &str, year: Option, tmdb_id: i64) -> String { + let safe: String = title + .chars() + .filter(|character| !matches!(character, '/' | '\\') && !character.is_control()) + .collect(); + let safe = safe.trim(); + match year { + Some(year) => format!("{safe} ({year}) [tmdbid-{tmdb_id}]"), + None => format!("{safe} [tmdbid-{tmdb_id}]"), + } +} diff --git a/crates/arr-compat/src/movies.rs b/crates/arr-compat/src/movies.rs new file mode 100644 index 0000000..e5f08d3 --- /dev/null +++ b/crates/arr-compat/src/movies.rs @@ -0,0 +1,322 @@ +//! `movie`, `movie/{id}` and `movie/lookup`. +//! +//! Two of Radarr's ideas have to be absorbed here rather than passed on: +//! +//! - **The quality profile.** Policy attaches to a root (§5.1), so the profile +//! id on an add is read and dropped and `rootFolderPath` decides everything. +//! - **`searchForMovie`.** The reconcile loop searches whatever is wanted +//! (§8). Acting on the flag would be a second search path to keep correct, +//! so it is dropped too. + +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::{AddMovie, MovieResource}; +use crate::CompatState; + +/// A row of `movies` with everything a Radarr movie needs joined on. +#[derive(Debug, Clone)] +pub(crate) struct MovieRow { + pub(crate) id: i64, + pub(crate) tmdb_id: i64, + pub(crate) title: String, + pub(crate) year: Option, + pub(crate) wanted: bool, + pub(crate) root_id: i64, + pub(crate) root_path: String, + pub(crate) created_at: String, + pub(crate) file_count: i64, + pub(crate) size_on_disk: i64, +} + +/// `?tmdbId=` on the list endpoint, and nothing else Radarr accepts there. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ListQuery { + #[serde(default)] + tmdb_id: Option, +} + +/// `?term=` on lookup. +#[derive(Debug, Deserialize)] +pub(crate) struct LookupQuery { + #[serde(default)] + term: String, +} + +/// `GET /api/v3/movie`, optionally narrowed to one TMDB id — Jellyseerr's +/// existence check. +pub(crate) async fn list( + State(state): State, + Query(query): Query, +) -> Result>, CompatError> { + let rows = load_movies(&state, None, query.tmdb_id).await?; + let mut tags = load_tags(&state).await?; + Ok(Json( + rows.iter() + .map(|row| MovieResource::from_library(row, tags.remove(&row.id).unwrap_or_default())) + .collect(), + )) +} + +/// `GET /api/v3/movie/{id}`. +pub(crate) async fn get( + State(state): State, + Path(id): Path, +) -> Result, CompatError> { + let row = load_movies(&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(MovieResource::from_library(&row, tags))) +} + +/// `POST /api/v3/movie` — the request Jellyseerr approves turning into a +/// wanted movie under the root the operator picked. +pub(crate) async fn add( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), CompatError> { + if input.tmdb_id <= 0 { + return Err(CompatError::Validation { + property: "TmdbId", + message: "a TMDB id is required".into(), + }); + } + let title = input.title.trim(); + if title.is_empty() { + return Err(CompatError::Validation { + property: "Title", + message: "a title is required".into(), + }); + } + let root = resolve_root(&state, input.root_folder_path.as_deref()).await?; + + if let Some(existing) = load_movies(&state, None, Some(input.tmdb_id)) + .await? + .first() + { + return Err(CompatError::Validation { + property: "TmdbId", + message: format!("{} has already been added", existing.title), + }); + } + + // §5.2 is expressed against the title's original language, and nothing + // else fills it in yet, so take it while a TMDB client is already at hand. + // Best effort: TMDB being down is not a reason to refuse a request. + let original_language = original_language(&state, input.tmdb_id).await; + + let inserted = sqlx::query!( + "INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted) \ + VALUES (?, ?, ?, ?, ?, ?)", + input.tmdb_id, + title, + input.year, + original_language, + root, + input.monitored, + ) + .execute(state.pool()) + .await? + .last_insert_rowid(); + + attach_owners(&state, inserted, &input.tags).await?; + + let row = load_movies(&state, Some(inserted), 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(MovieResource::from_library(&row, tags)), + )) +} + +/// `GET /api/v3/movie/lookup?term=` — TMDB search, with the library's own id +/// stamped onto anything already added. +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 tmdb = state.tmdb()?; + + if let Some(id) = term.strip_prefix("tmdb:") { + let Ok(id) = id.trim().parse::() else { + return Ok(Json(Vec::new())); + }; + return match tmdb.movie(id).await { + Ok(movie) => { + let resource = MovieResource::from_tmdb(&movie); + let existing = load_movies(&state, None, Some(i64::from(movie.tmdb_id))).await?; + Ok(Json(vec![match existing.first() { + Some(row) => resource.with_library(row), + None => resource, + }])) + } + // Radarr answers an unknown id with an empty list, not a 404, and + // Jellyseerr reads that as "not a film" rather than as an outage. + Err(arr_meta::Error::NotFound { .. }) => Ok(Json(Vec::new())), + Err(error) => Err(error.into()), + }; + } + + // `imdb:` is the other prefix Radarr accepts. TMDB's find-by-external-id + // endpoint is not wired up here, and guessing from the digits would return + // the wrong film, so it answers empty until there is a reason to add it. + if term.starts_with("imdb:") { + tracing::debug!(term, "compat lookup by IMDb id is not supported"); + return Ok(Json(Vec::new())); + } + + let hits = tmdb.search_movies(term, None).await?; + let library: HashMap = load_movies(&state, None, None) + .await? + .into_iter() + .map(|row| (row.tmdb_id, row)) + .collect(); + + Ok(Json( + hits.iter() + .map(|hit| { + let resource = MovieResource::from_search(hit); + match library.get(&i64::from(hit.tmdb_id)) { + Some(row) => resource.with_library(row), + None => resource, + } + }) + .collect(), + )) +} + +/// Every movie, or the one matching an id or a TMDB id. +/// +/// One query rather than three near-identical ones: a `NULL` filter matches +/// everything, so the shape of the row mapping stays in a single place. +async fn load_movies( + state: &CompatState, + id: Option, + tmdb_id: Option, +) -> Result, CompatError> { + Ok(sqlx::query_as!( + MovieRow, + r#" + SELECT m.id AS "id!: i64", + m.tmdb_id AS "tmdb_id!: i64", + m.title AS "title!: String", + m.year, + m.wanted AS "wanted!: bool", + m.root_id AS "root_id!: i64", + r.path AS "root_path!: String", + m.created_at AS "created_at!: String", + (SELECT count(*) FROM media_files f + WHERE f.owner_kind = 'movie' AND f.owner_id = m.id) + AS "file_count!: i64", + (SELECT coalesce(sum(f.size), 0) FROM media_files f + WHERE f.owner_kind = 'movie' AND f.owner_id = m.id) + AS "size_on_disk!: i64" + FROM movies m + JOIN roots r ON r.id = m.root_id + WHERE (?1 IS NULL OR m.id = ?1) + AND (?2 IS NULL OR m.tmdb_id = ?2) + ORDER BY m.title, m.year, m.id + "#, + id, + tmdb_id, + ) + .fetch_all(state.pool()) + .await?) +} + +/// Owner ids per movie, in one query. The library is small enough that +/// fetching the whole mapping beats a query per row. +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 = 'movie' 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) +} + +/// Which root a `rootFolderPath` names, rejecting anything else. +/// +/// No default and no fallback to the quality profile: guessing here files a +/// film under the wrong policy, and for the `kids` root that means the wrong +/// audio in front of a child (§5.2). +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(CompatError::Validation { + property: "RootFolderPath", + message: "a root folder path is required".into(), + }); + }; + state + .database() + .list_roots() + .await? + .into_iter() + .find(|root| root.kind == "movie" && normalise_path(&root.path) == path) + .map(|root| root.id) + .ok_or(CompatError::Validation { + property: "RootFolderPath", + message: format!("{path} is not a configured movie root"), + }) +} + +/// Trailing separators are cosmetic; Jellyseerr stores whatever was typed. +fn normalise_path(path: &str) -> String { + path.trim().trim_end_matches('/').to_owned() +} + +/// The title's original language from TMDB (§5.2), or nothing. +async fn original_language(state: &CompatState, tmdb_id: i64) -> Option { + let id = u32::try_from(tmdb_id).ok()?; + match state.tmdb().ok()?.movie(id).await { + Ok(movie) if !movie.original_language.is_empty() => Some(movie.original_language), + Ok(_) => None, + Err(error) => { + tracing::warn!(%error, tmdb_id, "compat add could not read the original language"); + None + } + } +} + +/// Radarr's tags are owners (§4.3). Ids that name no owner are dropped rather +/// than failing the add: Jellyseerr's tag list can outlive an owner row, and +/// losing a notification recipient is better than losing the request. +async fn attach_owners( + state: &CompatState, + movie_id: i64, + tags: &[i64], +) -> Result<(), CompatError> { + for tag in tags { + sqlx::query!( + "INSERT OR IGNORE INTO title_owners (title_kind, title_id, owner_id) \ + SELECT 'movie', ?, id FROM owners WHERE id = ?", + movie_id, + tag, + ) + .execute(state.pool()) + .await?; + } + Ok(()) +} diff --git a/crates/arr-compat/src/system.rs b/crates/arr-compat/src/system.rs new file mode 100644 index 0000000..d5d4678 --- /dev/null +++ b/crates/arr-compat/src/system.rs @@ -0,0 +1,80 @@ +//! The four endpoints Jellyseerr calls before it will save a server: status, +//! root folders, quality profiles and tags. + +use axum::extract::State; +use axum::Json; + +use crate::error::CompatError; +use crate::model::{QualityProfile, RootFolder, SystemStatus, Tag}; +use crate::CompatState; + +/// `GET /api/v3/system/status` — Jellyseerr's "Test" button. +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. +pub(crate) async fn root_folders( + State(state): State, +) -> Result>, CompatError> { + let roots = state.database().list_roots().await?; + Ok(Json( + roots + .into_iter() + .filter(|root| root.kind == "movie") + .map(|root| RootFolder { + id: root.id, + path: root.path, + accessible: true, + free_space: 0, + unmapped_folders: Vec::new(), + }) + .collect(), + )) +} + +/// `GET /api/v3/qualityprofile` — one fake profile per movie root (§5.1). +pub(crate) async fn quality_profiles( + State(state): State, +) -> Result>, CompatError> { + let roots = state.database().list_roots().await?; + Ok(Json( + roots + .into_iter() + .filter(|root| root.kind == "movie") + .map(|root| QualityProfile { + id: root.id, + name: root.policy_name, + upgrade_allowed: false, + cutoff: root.id, + items: Vec::new(), + min_format_score: 0, + cutoff_format_score: 0, + format_items: Vec::new(), + }) + .collect(), + )) +} + +/// `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. +pub(crate) async fn tags(State(state): State) -> Result>, CompatError> { + let owners = sqlx::query!( + r#"SELECT id AS "id!: i64", name AS "label!: String" FROM owners ORDER BY name"# + ) + .fetch_all(state.pool()) + .await?; + Ok(Json( + owners + .into_iter() + .map(|owner| Tag { + id: owner.id, + label: owner.label, + }) + .collect(), + )) +} diff --git a/crates/arr-compat/src/tests.rs b/crates/arr-compat/src/tests.rs new file mode 100644 index 0000000..6672acd --- /dev/null +++ b/crates/arr-compat/src/tests.rs @@ -0,0 +1,384 @@ +//! The shim is only worth anything if Jellyseerr's actual call sequence works, +//! so the tests follow that sequence rather than the module layout: connect, +//! read the folders and profiles, look a film up, add it. + +use std::sync::Arc; + +use arr_db::Db; +use arr_meta::TmdbClient; +use serde_json::{json, Value}; +use wiremock::matchers::{method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use super::{router, CompatState}; + +/// A migrated database, the shim serving it, and its base URL. The temporary +/// directory has to outlive the test, so it comes back with the rest. +async fn shim() -> (tempfile::TempDir, Db, String) { + let dir = tempfile::tempdir().expect("tempdir"); + let database = Db::connect(dir.path().join("arr.db")) + .await + .expect("connect"); + database.migrate().await.expect("migrate"); + let base = serve(CompatState::new(database.clone())).await; + (dir, database, base) +} + +async fn serve(state: CompatState) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, router(state)).await.expect("serve"); + }); + format!("http://{address}") +} + +async fn get_json(url: String) -> Value { + reqwest::get(url) + .await + .expect("request") + .json() + .await + .expect("json body") +} + +/// A TMDB that knows one film, wired the way `arr-meta` calls it. +async fn tmdb_with_dune() -> MockServer { + let server = MockServer::start().await; + let movie = json!({ + "id": 693_134, + "imdb_id": "tt15239678", + "title": "Dune: Part Two", + "original_title": "Dune: Part Two", + "original_language": "en", + "origin_country": ["US"], + "release_date": "2024-02-27", + "runtime": 167, + "status": "Released", + "overview": "Paul Atreides unites with Chani.", + "poster_path": "/dune.jpg", + "release_dates": { "results": [] } + }); + Mock::given(method("GET")) + .and(path("/3/movie/693134")) + .respond_with(ResponseTemplate::new(200).set_body_json(movie)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/3/search/movie")) + .and(query_param("query", "dune")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{ + "id": 693_134, + "title": "Dune: Part Two", + "original_title": "Dune: Part Two", + "original_language": "en", + "release_date": "2024-02-27", + "overview": "Paul Atreides unites with Chani.", + "poster_path": "/dune.jpg" + }] + }))) + .mount(&server) + .await; + server +} + +fn client(server: &MockServer) -> Arc { + Arc::new( + TmdbClient::builder("key") + .base_url(format!("{}/3/", server.uri())) + .build() + .expect("tmdb client"), + ) +} + +#[tokio::test] +async fn status_names_a_radarr_that_serves_v3() { + let (_dir, _database, base) = shim().await; + let body = get_json(format!("{base}/api/v3/system/status")).await; + assert_eq!(body["appName"], "Radarr"); + assert_eq!(body["version"], super::RADARR_VERSION); + assert!( + body["version"] + .as_str() + .is_some_and(|version| version.starts_with('5')), + "Jellyseerr branches on the major version: {body}" + ); + assert_eq!(body["authentication"], "none"); +} + +#[tokio::test] +async fn root_folders_and_profiles_are_the_real_roots() { + let (_dir, _database, base) = shim().await; + + 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"); + let paths: Vec<&str> = folders + .iter() + .filter_map(|folder| folder["path"].as_str()) + .collect(); + assert!(paths.contains(&"/mnt/media/movies/main")); + assert!(paths.contains(&"/mnt/media/movies/kids")); + + // §5.1: one fake profile per root, and the ids line up so that the two + // dropdowns in Jellyseerr cannot be set to disagree. + let profiles = get_json(format!("{base}/api/v3/qualityProfile")).await; + let profiles = profiles.as_array().expect("array"); + assert_eq!(profiles.len(), folders.len()); + for (profile, folder) in profiles.iter().zip(folders) { + assert_eq!(profile["id"], folder["id"]); + } + assert!(profiles + .iter() + .any(|profile| profile["name"] == "Movies — main")); +} + +#[tokio::test] +async fn tags_are_owners() { + let (_dir, database, base) = shim().await; + sqlx::query("INSERT INTO owners (name, ntfy_topic) VALUES ('miguel', 'arr-miguel')") + .execute(database.pool()) + .await + .expect("owner"); + + let tags = get_json(format!("{base}/api/v3/tag")).await; + assert_eq!(tags[0]["label"], "miguel"); +} + +#[tokio::test] +async fn a_request_creates_a_wanted_movie_in_the_named_root() { + let (_dir, database, _unused) = shim().await; + let tmdb = tmdb_with_dune().await; + let base = serve(CompatState::new(database.clone()).with_tmdb(client(&tmdb))).await; + + let owner_id: i64 = sqlx::query_scalar( + "INSERT INTO owners (name, ntfy_topic) VALUES ('kid', 't') RETURNING id", + ) + .fetch_one(database.pool()) + .await + .expect("owner"); + + let response = reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&json!({ + "tmdbId": 693_134, + "title": "Dune: Part Two", + "year": 2024, + // Trailing separator, because Jellyseerr stores what was typed. + "rootFolderPath": "/mnt/media/movies/kids/", + "qualityProfileId": 9999, + "monitored": true, + "tags": [owner_id], + "addOptions": { "searchForMovie": true } + })) + .send() + .await + .expect("add"); + assert_eq!(response.status(), 201); + let created: Value = response.json().await.expect("json"); + + assert!(created["id"].as_i64().is_some_and(|id| id > 0)); + assert_eq!(created["monitored"], true); + assert_eq!(created["rootFolderPath"], "/mnt/media/movies/kids"); + assert_eq!( + created["path"], "/mnt/media/movies/kids/Dune: Part Two (2024) [tmdbid-693134]", + "§7.4 layout, provider id included" + ); + assert_eq!(created["tags"][0], owner_id); + + let row = sqlx::query_as::<_, (i64, bool, String, Option)>( + "SELECT m.root_id, m.wanted, r.audience, m.original_language + FROM movies m JOIN roots r ON r.id = m.root_id WHERE m.tmdb_id = 693134", + ) + .fetch_one(database.pool()) + .await + .expect("movie row"); + assert!(row.1, "§4.1: monitored maps onto wanted"); + assert_eq!(row.2, "kids", "the path decided the root, not the profile"); + assert_eq!( + row.3.as_deref(), + Some("en"), + "§5.2 needs the original language" + ); + + // The quality profile Jellyseerr sent named nothing here and was ignored; + // what comes back is the root's own profile id (§5.1). + assert_eq!(created["qualityProfileId"], row.0); +} + +#[tokio::test] +async fn an_unknown_root_is_a_radarr_validation_failure() { + let (_dir, _database, base) = shim().await; + let response = reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&json!({ + "tmdbId": 1, + "title": "Somewhere Else", + "rootFolderPath": "/mnt/media/movies/nope" + })) + .send() + .await + .expect("add"); + assert_eq!(response.status(), 400); + let body: Value = response.json().await.expect("json"); + assert_eq!(body[0]["propertyName"], "RootFolderPath"); + assert_eq!(body[0]["severity"], "error"); +} + +#[tokio::test] +async fn adding_the_same_film_twice_is_rejected_the_way_radarr_rejects_it() { + let (_dir, _database, base) = shim().await; + let request = json!({ + "tmdbId": 693_134, + "title": "Dune: Part Two", + "year": 2024, + "rootFolderPath": "/mnt/media/movies/main" + }); + let first = reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&request) + .send() + .await + .expect("first add"); + assert_eq!(first.status(), 201); + + let second = reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&request) + .send() + .await + .expect("second add"); + assert_eq!(second.status(), 400); + let body: Value = second.json().await.expect("json"); + assert_eq!(body[0]["propertyName"], "TmdbId"); +} + +#[tokio::test] +async fn the_library_answers_the_existence_check() { + let (_dir, _database, base) = shim().await; + reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&json!({ + "tmdbId": 693_134, "title": "Dune: Part Two", "year": 2024, + "rootFolderPath": "/mnt/media/movies/main" + })) + .send() + .await + .expect("add"); + + let all = get_json(format!("{base}/api/v3/movie")).await; + assert_eq!(all.as_array().map(Vec::len), Some(1)); + let id = all[0]["id"].as_i64().expect("id"); + + let filtered = get_json(format!("{base}/api/v3/movie?tmdbId=693134")).await; + assert_eq!(filtered.as_array().map(Vec::len), Some(1)); + let missing = get_json(format!("{base}/api/v3/movie?tmdbId=1")).await; + assert_eq!(missing.as_array().map(Vec::len), Some(0)); + + let one = get_json(format!("{base}/api/v3/movie/{id}")).await; + assert_eq!(one["tmdbId"], 693_134); + let absent = reqwest::get(format!("{base}/api/v3/movie/424242")) + .await + .expect("request"); + assert_eq!(absent.status(), 404); +} + +#[tokio::test] +async fn lookup_stamps_the_library_id_onto_a_film_already_added() { + let (_dir, database, _unused) = shim().await; + let tmdb = tmdb_with_dune().await; + let base = serve(CompatState::new(database.clone()).with_tmdb(client(&tmdb))).await; + + // Not yet in the library: id 0 is what tells Jellyseerr to go on and add. + let before = get_json(format!("{base}/api/v3/movie/lookup?term=tmdb:693134")).await; + assert_eq!(before.as_array().map(Vec::len), Some(1)); + assert_eq!(before[0]["id"], 0); + assert_eq!(before[0]["tmdbId"], 693_134); + assert_eq!(before[0]["imdbId"], "tt15239678"); + assert_eq!(before[0]["monitored"], false); + + let created: Value = reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&json!({ + "tmdbId": 693_134, "title": "Dune: Part Two", "year": 2024, + "rootFolderPath": "/mnt/media/movies/main" + })) + .send() + .await + .expect("add") + .json() + .await + .expect("json"); + + let after = get_json(format!("{base}/api/v3/movie/lookup?term=tmdb:693134")).await; + assert_eq!(after[0]["id"], created["id"]); + assert_eq!(after[0]["monitored"], true); + assert_eq!(after[0]["hasFile"], false); + + // A free-text search takes the same overlay. + let searched = get_json(format!("{base}/api/v3/movie/lookup?term=dune")).await; + assert_eq!(searched[0]["id"], created["id"]); +} + +#[tokio::test] +async fn an_unknown_tmdb_id_looks_up_empty_rather_than_failing() { + let (_dir, database, _unused) = shim().await; + let tmdb = tmdb_with_dune().await; + Mock::given(method("GET")) + .and(path("/3/movie/1")) + .respond_with(ResponseTemplate::new(404)) + .mount(&tmdb) + .await; + let base = serve(CompatState::new(database).with_tmdb(client(&tmdb))).await; + + let body = get_json(format!("{base}/api/v3/movie/lookup?term=tmdb:1")).await; + assert_eq!(body.as_array().map(Vec::len), Some(0)); + let by_imdb = get_json(format!("{base}/api/v3/movie/lookup?term=imdb:tt15239678")).await; + assert_eq!(by_imdb.as_array().map(Vec::len), Some(0)); +} + +#[tokio::test] +async fn lookup_without_tmdb_fails_loudly_instead_of_answering_empty() { + let (_dir, _database, base) = shim().await; + let response = reqwest::get(format!("{base}/api/v3/movie/lookup?term=dune")) + .await + .expect("request"); + assert_eq!( + response.status(), + 502, + "an empty list would read as 'no such film'" + ); +} + +#[tokio::test] +async fn adding_survives_tmdb_being_down() { + let (_dir, database, _unused) = shim().await; + let tmdb = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/3/movie/693134")) + .respond_with(ResponseTemplate::new(500)) + .mount(&tmdb) + .await; + let base = serve(CompatState::new(database.clone()).with_tmdb(client(&tmdb))).await; + + let response = reqwest::Client::new() + .post(format!("{base}/api/v3/movie")) + .json(&json!({ + "tmdbId": 693_134, "title": "Dune: Part Two", "year": 2024, + "rootFolderPath": "/mnt/media/movies/main" + })) + .send() + .await + .expect("add"); + assert_eq!(response.status(), 201); + + let language: Option = + sqlx::query_scalar("SELECT original_language FROM movies WHERE tmdb_id = 693134") + .fetch_one(database.pool()) + .await + .expect("row"); + assert_eq!(language, None, "left for the metadata refresh to fill"); +} diff --git a/crates/arr-daemon/Cargo.toml b/crates/arr-daemon/Cargo.toml index 83cd807..7b0d9f5 100644 --- a/crates/arr-daemon/Cargo.toml +++ b/crates/arr-daemon/Cargo.toml @@ -12,7 +12,9 @@ path = "src/main.rs" [dependencies] arr-api = { workspace = true } +arr-compat = { workspace = true } arr-db = { workspace = true } +arr-meta = { workspace = true } axum = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index 93f42d6..1438fd7 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -3,9 +3,12 @@ mod config; use std::process::ExitCode; +use std::sync::Arc; use arr_api::{AppState, Upstreams}; +use arr_compat::CompatState; use arr_db::Db; +use arr_meta::TmdbClient; use config::Config; use tower_http::trace::TraceLayer; @@ -59,6 +62,8 @@ enum Error { Database(#[from] sqlx::Error), #[error("database migration: {0}")] Migration(#[from] sqlx::migrate::MigrateError), + #[error("tmdb client: {0}")] + Tmdb(#[from] arr_meta::Error), #[error("bind {addr}: {source}")] Bind { addr: std::net::SocketAddr, @@ -73,6 +78,13 @@ async fn run() -> Result<(), Error> { let database = Db::connect(&config.database_path).await?; database.migrate().await?; + // Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and + // needs its own TMDB client for `movie/lookup`. + let mut compat = CompatState::new(database.clone()); + if let Some(key) = &config.tmdb_api_key { + compat = compat.with_tmdb(Arc::new(TmdbClient::new(key)?)); + } + let state = AppState::new( Upstreams::new(config.prowlarr_url, config.transmission_url) .with_prowlarr_api_key(config.prowlarr_api_key) @@ -80,7 +92,9 @@ async fn run() -> Result<(), Error> { )? .with_database(database); - let app = arr_api::router(state).layer(TraceLayer::new_for_http()); + let app = arr_api::router(state) + .merge(arr_compat::router(compat)) + .layer(TraceLayer::new_for_http()); let listener = tokio::net::TcpListener::bind(config.bind_addr) .await