Radarr v3 shim for Jellyseerr (#62)
This commit was merged in pull request #62.
This commit is contained in:
+12
@@ -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"
|
||||||
|
}
|
||||||
+74
@@ -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"
|
||||||
|
}
|
||||||
+26
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+26
@@ -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"
|
||||||
|
}
|
||||||
Generated
+15
@@ -48,6 +48,19 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "arr-compat"
|
name = "arr-compat"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"arr-db",
|
||||||
|
"arr-meta",
|
||||||
|
"axum",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"wiremock",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arr-core"
|
name = "arr-core"
|
||||||
@@ -61,7 +74,9 @@ name = "arr-daemon"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arr-api",
|
"arr-api",
|
||||||
|
"arr-compat",
|
||||||
"arr-db",
|
"arr-db",
|
||||||
|
"arr-meta",
|
||||||
"axum",
|
"axum",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ repository.workspace = true
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[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]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -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<sqlx::Error> for CompatError {
|
||||||
|
fn from(error: sqlx::Error) -> Self {
|
||||||
|
if matches!(error, sqlx::Error::RowNotFound) {
|
||||||
|
Self::NotFound
|
||||||
|
} else {
|
||||||
|
Self::Database(error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<arr_meta::Error> for CompatError {
|
||||||
|
fn from(error: arr_meta::Error) -> Self {
|
||||||
|
Self::Upstream(error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Arc<TmdbClient>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TmdbClient>) -> 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<CompatState> {
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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<serde_json::Value>,
|
||||||
|
pub min_format_score: i64,
|
||||||
|
pub cutoff_format_score: i64,
|
||||||
|
pub format_items: Vec<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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<String>,
|
||||||
|
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<i64>,
|
||||||
|
pub images: Vec<Image>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub overview: Option<String>,
|
||||||
|
pub runtime: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MovieResource {
|
||||||
|
/// The fields every mapping shares, with everything library-side empty.
|
||||||
|
fn blank(tmdb_id: i64, title: &str, year: Option<i64>) -> 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<i64>) -> 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<i64>,
|
||||||
|
/// Which root, and so which policy (§5.1). The only routing input.
|
||||||
|
#[serde(default)]
|
||||||
|
pub root_folder_path: Option<String>,
|
||||||
|
/// 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<i64>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub monitored: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub add_options: Option<AddOptions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<i64>, 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}]"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<i64>,
|
||||||
|
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<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `?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<CompatState>,
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
) -> Result<Json<Vec<MovieResource>>, 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<CompatState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<MovieResource>, 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<CompatState>,
|
||||||
|
Json(input): Json<AddMovie>,
|
||||||
|
) -> Result<(StatusCode, Json<MovieResource>), 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<CompatState>,
|
||||||
|
Query(query): Query<LookupQuery>,
|
||||||
|
) -> Result<Json<Vec<MovieResource>>, 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::<u32>() 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<i64, MovieRow> = 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<i64>,
|
||||||
|
tmdb_id: Option<i64>,
|
||||||
|
) -> Result<Vec<MovieRow>, 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<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 = 'movie' 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<i64, CompatError> {
|
||||||
|
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<String> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -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<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.
|
||||||
|
pub(crate) async fn root_folders(
|
||||||
|
State(state): State<CompatState>,
|
||||||
|
) -> Result<Json<Vec<RootFolder>>, 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<CompatState>,
|
||||||
|
) -> Result<Json<Vec<QualityProfile>>, 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<CompatState>) -> Result<Json<Vec<Tag>>, 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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -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<TmdbClient> {
|
||||||
|
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<String>)>(
|
||||||
|
"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<String> =
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -12,7 +12,9 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
arr-api = { workspace = true }
|
arr-api = { workspace = true }
|
||||||
|
arr-compat = { workspace = true }
|
||||||
arr-db = { workspace = true }
|
arr-db = { workspace = true }
|
||||||
|
arr-meta = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
mod config;
|
mod config;
|
||||||
|
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use arr_api::{AppState, Upstreams};
|
use arr_api::{AppState, Upstreams};
|
||||||
|
use arr_compat::CompatState;
|
||||||
use arr_db::Db;
|
use arr_db::Db;
|
||||||
|
use arr_meta::TmdbClient;
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
@@ -59,6 +62,8 @@ enum Error {
|
|||||||
Database(#[from] sqlx::Error),
|
Database(#[from] sqlx::Error),
|
||||||
#[error("database migration: {0}")]
|
#[error("database migration: {0}")]
|
||||||
Migration(#[from] sqlx::migrate::MigrateError),
|
Migration(#[from] sqlx::migrate::MigrateError),
|
||||||
|
#[error("tmdb client: {0}")]
|
||||||
|
Tmdb(#[from] arr_meta::Error),
|
||||||
#[error("bind {addr}: {source}")]
|
#[error("bind {addr}: {source}")]
|
||||||
Bind {
|
Bind {
|
||||||
addr: std::net::SocketAddr,
|
addr: std::net::SocketAddr,
|
||||||
@@ -73,6 +78,13 @@ async fn run() -> Result<(), Error> {
|
|||||||
let database = Db::connect(&config.database_path).await?;
|
let database = Db::connect(&config.database_path).await?;
|
||||||
database.migrate().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(
|
let state = AppState::new(
|
||||||
Upstreams::new(config.prowlarr_url, config.transmission_url)
|
Upstreams::new(config.prowlarr_url, config.transmission_url)
|
||||||
.with_prowlarr_api_key(config.prowlarr_api_key)
|
.with_prowlarr_api_key(config.prowlarr_api_key)
|
||||||
@@ -80,7 +92,9 @@ async fn run() -> Result<(), Error> {
|
|||||||
)?
|
)?
|
||||||
.with_database(database);
|
.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)
|
let listener = tokio::net::TcpListener::bind(config.bind_addr)
|
||||||
.await
|
.await
|
||||||
|
|||||||
Reference in New Issue
Block a user