//! Radarr's wire vocabulary. Nothing in here is a domain type — these are the //! shapes Jellyseerr parses, and they exist so that no other crate has to know //! what `qualityProfileId` means. //! //! Field names are Radarr's, camelCase, and the field *set* is the subset //! Jellyseerr reads plus what makes a response look coherent to a human //! reading it in a browser. Radarr sends a great deal more. use serde::{Deserialize, Serialize}; use crate::RADARR_VERSION; /// Where TMDB serves the images whose paths its API returns. const TMDB_IMAGE_BASE: &str = "https://image.tmdb.org/t/p/original"; /// `GET /api/v3/system/status`. /// /// Jellyseerr reads `version` and `appName`; the rest is what a Radarr answers /// and costs nothing to keep truthful about the process actually serving it. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] // Radarr's own payload is a flat run of feature flags. Reshaping it into // something clippy likes would mean not being Radarr. #[allow(clippy::struct_excessive_bools)] pub struct SystemStatus { pub app_name: &'static str, pub instance_name: &'static str, pub version: &'static str, pub build_time: &'static str, pub is_debug: bool, pub is_production: bool, pub is_admin: bool, pub is_user_interactive: bool, pub startup_path: &'static str, pub app_data: &'static str, pub os_name: &'static str, pub is_net_core: bool, pub is_linux: bool, pub is_osx: bool, pub is_windows: bool, pub is_docker: bool, pub mode: &'static str, pub branch: &'static str, /// DESIGN.md §2: the perimeter is the VPN. Radarr's own word for that is /// `none`, and saying so is more honest than pretending to hold a key. pub authentication: &'static str, pub url_base: &'static str, pub runtime_version: &'static str, pub package_version: &'static str, } impl Default for SystemStatus { fn default() -> Self { Self { app_name: "Radarr", instance_name: "arr", version: RADARR_VERSION, build_time: "2025-01-01T00:00:00Z", is_debug: false, is_production: true, is_admin: false, is_user_interactive: false, startup_path: "/", app_data: "/config", os_name: "linux", is_net_core: true, is_linux: true, is_osx: false, is_windows: false, is_docker: false, mode: "console", branch: "master", authentication: "none", url_base: "", runtime_version: "8.0.0", package_version: concat!("arr ", env!("CARGO_PKG_VERSION")), } } } /// `GET /api/v3/rootfolder`. One entry per real movie root (§5.1). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RootFolder { /// The real root's row id, so a Jellyseerr default folder survives. pub id: i64, pub path: String, pub accessible: bool, /// Always zero. Nothing here decides anything on free space, and reporting /// a number would mean stat-ing a filesystem on every settings page load. pub free_space: i64, pub unmapped_folders: Vec, } /// `GET /api/v3/qualityprofile`. One fake profile per root (§5.1). /// /// The profile is a fiction that exists because Jellyseerr insists on picking /// one per request. It carries the root's policy name so that the operator /// choosing in Jellyseerr's dropdown is choosing something meaningful, but /// whichever id comes back on the request is ignored — the root folder path /// decides. `id` is the root's id, so the two dropdowns line up. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct QualityProfile { pub id: i64, pub name: String, /// There is no upgrade loop here at all (§5.4). pub upgrade_allowed: bool, pub cutoff: i64, /// Empty on purpose: the real rules are policy rows, not a quality list, /// and inventing a plausible-looking one would be a second place for a /// reader to look for the truth. pub items: Vec, pub min_format_score: i64, pub cutoff_format_score: i64, pub format_items: Vec, } /// `GET /api/v3/tag`. Radarr's tags are this project's owners (§4.3). #[derive(Debug, Clone, Serialize)] pub struct Tag { pub id: i64, pub label: String, } /// A poster, in the shape Radarr reports one. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Image { pub cover_type: &'static str, pub remote_url: String, pub url: String, } impl Image { /// TMDB returns a path fragment, not a URL. fn poster(poster_path: &str) -> Self { let url = format!("{TMDB_IMAGE_BASE}{poster_path}"); Self { cover_type: "poster", remote_url: url.clone(), url, } } } /// A movie, as Radarr describes one. /// /// `id` is the load-bearing field: Jellyseerr treats a non-zero `id` on a /// lookup result as "already in the library" and skips the add. So a lookup /// hit that matches a local row must carry that row's id, and one that does /// not must carry zero. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] #[allow(clippy::struct_excessive_bools)] pub struct MovieResource { pub id: i64, pub title: String, pub sort_title: String, pub year: i64, pub tmdb_id: i64, #[serde(skip_serializing_if = "Option::is_none")] pub imdb_id: Option, pub title_slug: String, pub folder_name: String, pub path: String, pub root_folder_path: String, /// Radarr's `monitored` is this project's `wanted` (§4.1). pub monitored: bool, pub has_file: bool, /// Radarr's older name for `has_file`. Jellyseerr reads this one. pub downloaded: bool, pub is_available: bool, pub size_on_disk: i64, pub status: &'static str, pub minimum_availability: &'static str, pub quality_profile_id: i64, /// Radarr's older name for `quality_profile_id`. Jellyseerr reads both. pub profile_id: i64, pub added: String, pub tags: Vec, pub images: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub overview: Option, pub runtime: i64, } impl MovieResource { /// The fields every mapping shares, with everything library-side empty. fn blank(tmdb_id: i64, title: &str, year: Option) -> Self { Self { id: 0, title: title.to_owned(), sort_title: title.to_lowercase(), year: year.unwrap_or_default(), tmdb_id, imdb_id: None, title_slug: title_slug(title, tmdb_id), folder_name: String::new(), path: String::new(), root_folder_path: String::new(), monitored: false, has_file: false, downloaded: false, // Everything reachable on an indexer is available to request; the // §6.2 digital-release gate is the reconcile loop's business, not // a reason to hide a title from the person asking for it. is_available: true, size_on_disk: 0, status: "released", minimum_availability: "released", quality_profile_id: 0, profile_id: 0, added: String::new(), tags: Vec::new(), images: Vec::new(), overview: None, runtime: 0, } } /// A row of the real `movies` table, dressed as a Radarr movie. pub(crate) fn from_library(row: &crate::movies::MovieRow, tags: Vec) -> Self { let folder = folder_name(&row.title, row.year, row.tmdb_id); Self { id: row.id, folder_name: format!("{}/{folder}", row.root_path), path: format!("{}/{folder}", row.root_path), root_folder_path: row.root_path.clone(), monitored: row.wanted, has_file: row.file_count > 0, downloaded: row.file_count > 0, size_on_disk: row.size_on_disk, quality_profile_id: row.root_id, profile_id: row.root_id, added: row.created_at.clone(), tags, ..Self::blank(row.tmdb_id, &row.title, row.year) } } /// A TMDB search hit, dressed as a Radarr movie. pub(crate) fn from_search(hit: &arr_meta::MovieSearchResult) -> Self { Self { images: hit .poster_path .as_deref() .map(Image::poster) .into_iter() .collect(), overview: hit.overview.clone(), ..Self::blank( i64::from(hit.tmdb_id), &hit.title, hit.year().map(i64::from), ) } } /// A fully detailed TMDB movie, dressed as a Radarr movie. pub(crate) fn from_tmdb(movie: &arr_meta::Movie) -> Self { Self { imdb_id: movie.imdb_id.clone(), images: movie .poster_path .as_deref() .map(Image::poster) .into_iter() .collect(), overview: movie.overview.clone(), runtime: movie.runtime.map_or(0, i64::from), ..Self::blank( i64::from(movie.tmdb_id), &movie.title, movie.year().map(i64::from), ) } } /// Overlay what the library knows about a title a lookup just found. /// /// Everything descriptive stays TMDB's — it is fresher, and the local row /// only stores a title and a year — but the identity and intent fields /// come from the row, because those are what Jellyseerr acts on. pub(crate) fn with_library(mut self, row: &crate::movies::MovieRow) -> Self { let folder = folder_name(&row.title, row.year, row.tmdb_id); self.id = row.id; self.folder_name = format!("{}/{folder}", row.root_path); self.path.clone_from(&self.folder_name); self.root_folder_path.clone_from(&row.root_path); self.monitored = row.wanted; self.has_file = row.file_count > 0; self.downloaded = self.has_file; self.size_on_disk = row.size_on_disk; self.quality_profile_id = row.root_id; self.profile_id = row.root_id; self.added.clone_from(&row.created_at); self } } /// `POST /api/v3/movie`. Jellyseerr sends a good deal more than this; the /// fields absent here are absent because nothing is done with them. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AddMovie { pub tmdb_id: i64, #[serde(default)] pub title: String, #[serde(default)] pub year: Option, /// Which root, and so which policy (§5.1). The only routing input. #[serde(default)] pub root_folder_path: Option, /// Read and dropped. Kept in the struct so the reason is visible here /// rather than being an absence someone has to notice. #[serde(default)] pub quality_profile_id: Option, #[serde(default = "default_true")] pub monitored: bool, #[serde(default)] pub tags: Vec, #[serde(default)] pub add_options: Option, } /// Radarr's per-request flags. `search_for_movie` is read and dropped: the /// reconcile loop searches everything wanted (§8), so asking is redundant and /// obeying it would be a second search path to keep correct. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AddOptions { #[serde(default)] pub search_for_movie: bool, } /// 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, pub images: Vec, pub seasons: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub overview: Option, } 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) -> 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, overview: Option, 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, #[serde(default)] pub root_folder_path: Option, #[serde(default)] pub quality_profile_id: Option, #[serde(default)] pub language_profile_id: Option, #[serde(default)] pub monitored: bool, #[serde(default)] pub season_folder: bool, #[serde(default)] pub seasons: Vec, #[serde(default)] pub tags: Vec, #[serde(default)] pub add_options: Option, } #[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, 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}]"), } }