Files
arr/crates/arr-compat/src/lib.rs
naps62-yolo 38bd4102bb
ci / web (push) Successful in 55s
ci / rust (push) Successful in 1m36s
e2e / e2e (push) Successful in 1m24s
Sonarr v3 shim for Jellyseerr (#84)
2026-08-22 23:33:09 +01:00

126 lines
4.6 KiB
Rust

//! 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 series;
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, AddSeries, AddSeriesOptions, MovieResource, QualityProfile, RootFolder,
SeasonResource, SeriesResource, 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("/languageprofile", get(system::language_profiles))
.route("/languageProfile", get(system::language_profiles))
.route("/tag", get(system::tags))
.route("/movie", get(movies::list).post(movies::add))
.route("/movie/lookup", get(movies::lookup))
.route("/movie/{movie_id}", get(movies::get))
.route("/series", get(series::list).post(series::add))
.route("/series/lookup", get(series::lookup))
.route("/series/{series_id}", get(series::get))
}
#[cfg(test)]
mod tests;