562 lines
18 KiB
Rust
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}]"),
|
|
}
|
|
}
|