Files
arr/crates/arr-compat/src/model.rs
T

562 lines
18 KiB
Rust

//! 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,
}
/// A season in Sonarr's series resource. Jellyseerr changes `monitored` on
/// the seasons selected in a request before posting the resource back.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeasonResource {
pub season_number: i64,
#[serde(default)]
pub monitored: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SeriesResource {
pub id: i64,
pub title: String,
pub sort_title: String,
pub year: i64,
pub tvdb_id: i64,
pub tmdb_id: i64,
pub title_slug: String,
pub path: String,
pub root_folder_path: String,
pub monitored: bool,
pub status: String,
pub ended: bool,
pub quality_profile_id: i64,
pub language_profile_id: i64,
pub added: String,
pub tags: Vec<i64>,
pub images: Vec<Image>,
pub seasons: Vec<SeasonResource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub overview: Option<String>,
}
impl SeriesResource {
pub(crate) fn from_search(hit: &arr_meta::SeriesSearchResult) -> Self {
Self::blank(
i64::from(hit.tmdb_id),
&hit.title,
hit.year().map(i64::from),
hit.overview.clone(),
hit.poster_path.as_deref(),
)
}
pub(crate) fn from_tmdb(series: &arr_meta::Series) -> Self {
let mut resource = Self::blank(
i64::from(series.tmdb_id),
&series.title,
series.year().map(i64::from),
series.overview.clone(),
series.poster_path.as_deref(),
);
resource.tvdb_id = series.tvdb_id.map_or(0, i64::from);
sonarr_status(&series.status).clone_into(&mut resource.status);
resource.ended = series.status == "Ended" || series.status == "Canceled";
resource.seasons = series
.seasons
.iter()
.map(|season| SeasonResource {
season_number: i64::from(season.number),
monitored: false,
})
.collect();
resource
}
pub(crate) fn from_library(row: &crate::series::SeriesRow, tags: Vec<i64>) -> Self {
let folder = folder_name(&row.title, row.year, row.tmdb_id);
Self {
id: row.id,
title: row.title.clone(),
sort_title: row.title.to_lowercase(),
year: row.year.unwrap_or_default(),
tvdb_id: row.tvdb_id.unwrap_or(0),
tmdb_id: row.tmdb_id,
title_slug: title_slug(&row.title, row.tmdb_id),
path: format!("{}/{folder}", row.root_path),
root_folder_path: row.root_path.clone(),
monitored: row.seasons.iter().any(|season| season.monitored),
status: if row.upstream_ended {
"ended"
} else {
"continuing"
}
.to_owned(),
ended: row.upstream_ended,
quality_profile_id: row.root_id,
language_profile_id: row.root_id,
added: row.created_at.clone(),
tags,
images: Vec::new(),
seasons: row.seasons.clone(),
overview: None,
}
}
pub(crate) fn with_library(mut self, row: &crate::series::SeriesRow) -> Self {
let library = Self::from_library(row, Vec::new());
self.id = library.id;
self.path = library.path;
self.root_folder_path = library.root_folder_path;
self.monitored = library.monitored;
self.quality_profile_id = library.quality_profile_id;
self.language_profile_id = library.language_profile_id;
self.added = library.added;
self.seasons = library.seasons;
self
}
fn blank(
tmdb_id: i64,
title: &str,
year: Option<i64>,
overview: Option<String>,
poster_path: Option<&str>,
) -> Self {
Self {
id: 0,
title: title.to_owned(),
sort_title: title.to_lowercase(),
year: year.unwrap_or_default(),
tvdb_id: 0,
tmdb_id,
title_slug: title_slug(title, tmdb_id),
path: String::new(),
root_folder_path: String::new(),
monitored: false,
status: "continuing".to_owned(),
ended: false,
quality_profile_id: 0,
language_profile_id: 0,
added: String::new(),
tags: Vec::new(),
images: poster_path.map(Image::poster).into_iter().collect(),
seasons: Vec::new(),
overview,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddSeries {
pub tmdb_id: i64,
#[serde(default)]
pub title: String,
#[serde(default)]
pub year: Option<i64>,
#[serde(default)]
pub root_folder_path: Option<String>,
#[serde(default)]
pub quality_profile_id: Option<i64>,
#[serde(default)]
pub language_profile_id: Option<i64>,
#[serde(default)]
pub monitored: bool,
#[serde(default)]
pub season_folder: bool,
#[serde(default)]
pub seasons: Vec<SeasonResource>,
#[serde(default)]
pub tags: Vec<i64>,
#[serde(default)]
pub add_options: Option<AddSeriesOptions>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddSeriesOptions {
#[serde(default)]
pub search_for_missing_episodes: bool,
}
fn sonarr_status(status: &str) -> &'static str {
if status == "Ended" || status == "Canceled" {
"ended"
} else {
"continuing"
}
}
fn default_true() -> bool {
true
}
/// 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}]"),
}
}