use std::time::UNIX_EPOCH; use arr_core::policy::{evaluate, Candidate}; use arr_core::score::score; use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict}; use arr_db::policy::language; use arr_db::{blacklist, Blacklist}; use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest, TvSelector, TvTarget}; use axum::extract::{Query, State}; use axum::Json; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use crate::movies::{ApiError, ErrorBody, Movie}; use crate::state::AppState; #[derive(Debug, Deserialize, IntoParams)] pub struct SearchQuery { q: String, } /// Which title a manual release search is for. Exactly one of the two. /// /// Intent lives at the leaf (`DESIGN.md` §4.1), so the TV side of this is an /// episode even when the release that satisfies it is a season pack. #[derive(Debug, Deserialize, IntoParams)] pub struct ReleasesQuery { movie_id: Option, episode_id: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct SearchResponse { pub kind: SearchInputKind, /// In-library hits, grouped first (§9.2): movies and series by title, /// episodes by episode title with their series and `SxxEyy` for context. pub library: Vec, pub tmdb: Vec, pub manual: Option, } /// One in-library hit. Tagged so a client can branch on what it found /// without re-deriving it from fields. #[derive(Debug, Clone, Serialize, ToSchema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum LibraryResult { Movie(Movie), Series(LibrarySeries), Episode(LibraryEpisode), } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct LibrarySeries { pub id: i64, pub tmdb_id: i64, pub title: String, pub year: Option, pub original_language: Option, pub root_id: i64, pub blocked: bool, /// Stored artwork (§9.6), so a row renders without a TMDB call. pub poster_path: Option, /// TMDB's rating, out of 10; `null` when TMDB has no votes for it. pub vote_average: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct LibraryEpisode { pub episode_id: i64, pub series_id: i64, pub series_title: String, /// `SxxEyy`, so the episode title reads in context (§9.2). pub tag: String, /// The episode title — what the search matched on. pub title: String, /// The series' poster — an episode has no artwork of its own worth /// showing at row size. pub poster_path: Option, /// The series' rating, out of 10; `null` when TMDB has no votes for it. pub vote_average: Option, /// The series' TMDB id — the episode row's trailer chip resolves through /// it (#148); an episode has no videos of its own worth listing. pub series_tmdb_id: i64, } #[derive(Debug, Clone, Copy, Serialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum SearchInputKind { Text, TmdbId, ImdbId, Magnet, TorrentUrl, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct TmdbMovie { pub tmdb_id: u32, pub title: String, pub original_title: String, pub original_language: String, pub year: Option, pub overview: Option, pub poster_path: Option, /// TMDB's rating, out of 10; `null` when TMDB has no votes for it. pub vote_average: Option, /// How many votes the rating rests on. pub vote_count: u32, } #[derive(Debug, Clone, Serialize, ToSchema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum TmdbResult { Movie(TmdbMovie), Series(TmdbSeries), } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct TmdbSeries { pub tmdb_id: u32, pub title: String, pub original_language: String, pub year: Option, pub overview: Option, pub poster_path: Option, /// TMDB's rating, out of 10; `null` when TMDB has no votes for it. pub vote_average: Option, /// How many votes the rating rests on. pub vote_count: u32, } /// One release's score, kept as its terms so the UI can explain a ranking /// (`DESIGN.md` §5.5) rather than showing a bare number. #[derive(Debug, Clone, Copy, Default, Serialize, ToSchema)] pub struct ScoreTerms { /// Distance from the target size for this release's own resolution. /// Zero when the release carries no size, or the policy no band for it. pub size: i64, /// The source-tier tiebreaker. pub source: i64, /// The log-scaled seeder term. pub seeders: i64, /// Where the release sits in the policy's resolution preference. This is /// what makes a 4K release outrank a 1080p one that scores the same /// against its own size band. pub resolution: i64, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ClassifiedRelease { pub indexer_id: i64, pub guid: String, pub name: String, pub size: Option, pub seeders: Option, pub publish_date: Option, pub download_url: String, pub parsed: serde_json::Value, pub score: i64, /// `score` broken into the terms that produced it. pub score_terms: ScoreTerms, pub verdict: String, pub rule: Option, } #[utoipa::path( get, path = "/api/search", tag = "search", params(SearchQuery), responses( (status = 200, body = SearchResponse), (status = 422, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn search( State(state): State, Query(query): Query, ) -> Result, ApiError> { let input = query.q.trim(); if input.is_empty() { return Err(ApiError::Invalid("q must not be empty".into())); } let kind = input_kind(input); if matches!(kind, SearchInputKind::Magnet | SearchInputKind::TorrentUrl) { return Ok(Json(SearchResponse { kind, library: Vec::new(), tmdb: Vec::new(), manual: Some(input.to_owned()), })); } let database = state.database().ok_or(ApiError::Unavailable)?; let tokens = like_tokens(input); // §9.2 keeps the two result sets grouped and the library first, so each // kind lands in its own block: movies, then series, then episodes. let mut library: Vec = if matches!(kind, SearchInputKind::TmdbId) { let tmdb_id = input .strip_prefix("tmdb:") .unwrap_or(input) .trim() .parse::() .map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?; sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens) .fetch_all(database.pool()).await? .into_iter() .map(LibraryResult::Movie) .collect() } else { sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens) .fetch_all(database.pool()).await? .into_iter() .map(LibraryResult::Movie) .collect() }; let series_rows = if matches!(kind, SearchInputKind::TmdbId) { let tmdb_id = input .strip_prefix("tmdb:") .unwrap_or(input) .trim() .parse::() .map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?; sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens) .fetch_all(database.pool()).await? } else { sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens) .fetch_all(database.pool()).await? }; library.extend(series_rows.into_iter().map(LibraryResult::Series)); // §9.2 names the TV case explicitly: `bluey hospital` finds the episode. // Tokens may split across the series and episode titles, so every token // must land in the concatenation — and at least one must match the // episode title on its own, or a series-title-only query would list // every episode the series has. let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens, tokens) .fetch_all(database.pool()) .await?; library.extend(episode_rows.into_iter().map(LibraryResult::Episode)); let tmdb = tmdb_client(&state)?; let results = search_tmdb(&tmdb, kind, input).await?; if matches!(kind, SearchInputKind::ImdbId) { for result in &results { match result { TmdbResult::Movie(movie) => { let tmdb_id = i64::from(movie.tmdb_id); if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id) .fetch_optional(database.pool()).await? { library.push(LibraryResult::Movie(found)); } } TmdbResult::Series(series) => { let tmdb_id = i64::from(series.tmdb_id); if let Some(found) = sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ?"#, tmdb_id) .fetch_optional(database.pool()).await? { library.push(LibraryResult::Series(found)); } } } } } Ok(Json(SearchResponse { kind, library, tmdb: results, manual: None, })) } /// Movie and series TMDB ids live in separate namespaces, so deduplication /// keys on the kind as well as the id. fn already_matched(matches: &[TmdbResult], candidate: &TmdbResult) -> bool { let (is_series, tmdb_id) = match candidate { TmdbResult::Movie(movie) => (false, movie.tmdb_id), TmdbResult::Series(series) => (true, series.tmdb_id), }; matches.iter().any(|existing| match existing { TmdbResult::Movie(movie) => !is_series && movie.tmdb_id == tmdb_id, TmdbResult::Series(series) => is_series && series.tmdb_id == tmdb_id, }) } async fn search_tmdb( tmdb: &arr_meta::TmdbClient, kind: SearchInputKind, input: &str, ) -> Result, ApiError> { let results = match kind { SearchInputKind::TmdbId => { let id = input .strip_prefix("tmdb:") .unwrap_or(input) .trim() .parse::() .map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?; // §9.2: a raw TMDB id resolves against both movie and TV. let mut matches = match tmdb.movie(id).await { Ok(movie) => { let year = movie.year(); vec![TmdbResult::Movie(TmdbMovie { tmdb_id: movie.tmdb_id, title: movie.title, original_title: movie.original_title, original_language: movie.original_language, year, overview: movie.overview, poster_path: movie.poster_path, vote_average: movie.vote_average, vote_count: movie.vote_count, })] } Err(arr_meta::Error::NotFound { .. }) => Vec::new(), Err(error) => return Err(upstream_error(&error)), }; match tmdb.series(id).await { Ok(series) => matches.push(TmdbResult::Series(series.into())), Err(arr_meta::Error::NotFound { .. }) => {} Err(error) => return Err(upstream_error(&error)), } if !input.starts_with("tmdb:") { for movie in tmdb .search_movies(input, None) .await .map_err(|error| upstream_error(&error))? { let candidate = TmdbResult::Movie(movie.into()); if !already_matched(&matches, &candidate) { matches.push(candidate); } } for series in tmdb .search_series(input) .await .map_err(|error| upstream_error(&error))? { let candidate = TmdbResult::Series(series.into()); if !already_matched(&matches, &candidate) { matches.push(candidate); } } } matches } SearchInputKind::ImdbId => { let found = tmdb .find_by_imdb(input) .await .map_err(|error| upstream_error(&error))?; let mut results: Vec = found .movies .into_iter() .map(|movie| TmdbResult::Movie(movie.into())) .collect(); results.extend( found .series .into_iter() .map(|series| TmdbResult::Series(series.into())), ); results } SearchInputKind::Text => { let mut results: Vec = tmdb .search_movies(input, None) .await .map_err(|error| upstream_error(&error))? .into_iter() .map(|movie| TmdbResult::Movie(movie.into())) .collect(); results.extend( tmdb.search_series(input) .await .map_err(|error| upstream_error(&error))? .into_iter() .map(|series| TmdbResult::Series(series.into())), ); results } SearchInputKind::Magnet | SearchInputKind::TorrentUrl => Vec::new(), }; Ok(results) } #[utoipa::path( get, path = "/api/releases", tag = "search", params(ReleasesQuery), responses( (status = 200, body = [ClassifiedRelease]), (status = 404, body = ErrorBody), (status = 422, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn releases( State(state): State, Query(query): Query, ) -> Result>, ApiError> { let mut classified = match (query.movie_id, query.episode_id) { (Some(movie_id), None) => movie_releases(&state, movie_id).await?, (None, Some(episode_id)) => episode_releases(&state, episode_id).await?, _ => { return Err(ApiError::Invalid( "pass exactly one of movie_id and episode_id".into(), )) } }; classified.sort_by_key(|release| (bucket(&release.verdict), -release.score)); Ok(Json(classified)) } async fn movie_releases( state: &AppState, movie_id: i64, ) -> Result, ApiError> { let database = state.database().ok_or(ApiError::Unavailable)?; let movie = sqlx::query!(r#"SELECT title AS "title!: String", tmdb_id AS "tmdb_id!: i64", original_language FROM movies WHERE id = ?"#, movie_id) .fetch_optional(database.pool()).await?.ok_or(ApiError::NotFound)?; let loaded = database .movie_policy(movie_id) .await .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::NotFound)?; let tmdb = tmdb_client(state)? .movie( u32::try_from(movie.tmdb_id) .map_err(|_| ApiError::Invalid("movie has invalid TMDB id".into()))?, ) .await .map_err(|error| upstream_error(&error))?; let request = tmdb.imdb_id.map_or_else( || SearchRequest::Text { query: movie.title.clone(), }, |imdb_id| SearchRequest::Movie { imdb_id }, ); let prowlarr = prowlarr_client(state)?; let indexers = prowlarr .indexers() .await .map_err(|_| ApiError::Unavailable)?; let blacklist = Blacklist::load(database.pool()).await?; let policy = loaded.policy; let overrides = loaded.overrides; let original_language = title_language( movie .original_language .as_deref() .unwrap_or(&tmdb.original_language), &tmdb.origin_countries, ); let mut classified = Vec::new(); for indexer in indexers { let indexer_request = match &request { SearchRequest::Movie { .. } if indexer.capabilities.movie.available && indexer.capabilities.movie.supports_parameter("imdbid") => { request.clone() } SearchRequest::Movie { .. } if indexer.capabilities.search.available => { SearchRequest::Text { query: movie.title.clone(), } } SearchRequest::Text { .. } if indexer.capabilities.search.available => request.clone(), _ => continue, }; match prowlarr.search_indexer(indexer.id, &indexer_request).await { Ok(releases) => { for release in releases { classified.push(classify( release, &policy, &overrides, &original_language, &blacklist, )?); } } Err(error) => { tracing::warn!(indexer_id = indexer.id, %error, "manual release search failed"); } } } Ok(classified) } /// Classified releases for one episode (`DESIGN.md` §6.1, §9.3). /// /// A series with a stored TVDB id is searched by it; `tv_request` falls back /// to a text search built from the title and the `SxxEyy` tag when the id is /// missing or the indexer does not take `tvdbid`. Both widen the result set /// rather than narrowing it, which the buckets already handle. async fn episode_releases( state: &AppState, episode_id: i64, ) -> Result, ApiError> { let database = state.database().ok_or(ApiError::Unavailable)?; let episode = sqlx::query!( r#"SELECT s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language, se.number AS "season_number!: i64", e.number AS "episode_number!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE e.id = ?"#, episode_id ) .fetch_optional(database.pool()) .await? .ok_or(ApiError::EpisodeNotFound)?; let loaded = database .episode_policy(episode_id) .await .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::EpisodeNotFound)?; // §5.2. The language rule is written against the title's original // language, so guessing one would silently change every verdict. let original_language = episode.original_language.as_deref().ok_or_else(|| { ApiError::Invalid("series has no original_language; refresh its metadata first".into()) })?; let original_language = title_language(original_language, &[]); let target = TvTarget { tvdb_id: episode.series_tvdb_id.and_then(|id| u64::try_from(id).ok()), title: episode.series_title, selector: TvSelector::Episode { season: u32::try_from(episode.season_number).unwrap_or_default(), episode: u32::try_from(episode.episode_number).unwrap_or_default(), }, }; let prowlarr = prowlarr_client(state)?; let indexers = prowlarr .indexers() .await .map_err(|_| ApiError::Unavailable)?; let blacklist = Blacklist::load(database.pool()).await?; let mut classified = Vec::new(); for indexer in indexers { let Some(request) = indexer.capabilities.tv_request(&target) else { continue; }; match prowlarr.search_indexer(indexer.id, &request).await { Ok(releases) => { for release in releases { classified.push(classify( release, &loaded.policy, &loaded.overrides, &original_language, &blacklist, )?); } } Err(error) => { tracing::warn!(indexer_id = indexer.id, %error, "manual episode search failed"); } } } Ok(classified) } impl From for TmdbMovie { fn from(movie: arr_meta::MovieSearchResult) -> Self { let year = movie.year(); Self { tmdb_id: movie.tmdb_id, title: movie.title, original_title: movie.original_title, original_language: movie.original_language, year, overview: movie.overview, poster_path: movie.poster_path, vote_average: movie.vote_average, vote_count: movie.vote_count, } } } impl From for TmdbSeries { fn from(series: arr_meta::SeriesSearchResult) -> Self { let year = series.year(); Self { tmdb_id: series.tmdb_id, title: series.title, original_language: series.original_language, year, overview: series.overview, poster_path: series.poster_path, vote_average: series.vote_average, vote_count: series.vote_count, } } } impl From for TmdbSeries { fn from(series: arr_meta::Series) -> Self { let year = series.year(); Self { tmdb_id: series.tmdb_id, title: series.title, original_language: series.original_language, year, overview: series.overview, poster_path: series.poster_path, vote_average: series.vote_average, vote_count: series.vote_count, } } } fn input_kind(input: &str) -> SearchInputKind { let lower = input.to_ascii_lowercase(); if lower.starts_with("magnet:?") { SearchInputKind::Magnet } else if lower.ends_with(".torrent") && (lower.starts_with("http://") || lower.starts_with("https://")) { SearchInputKind::TorrentUrl } else if lower.starts_with("tt") && lower[2..].chars().all(|c| c.is_ascii_digit()) { SearchInputKind::ImdbId } else if lower .strip_prefix("tmdb:") .is_some_and(|id| id.trim().chars().all(|c| c.is_ascii_digit())) || lower.chars().all(|c| c.is_ascii_digit()) { SearchInputKind::TmdbId } else { SearchInputKind::Text } } /// §9.2's example — `bluey hospital` finding the episode — needs word-wise /// matching, not one literal phrase. The query's tokens travel as a JSON /// array the queries walk with `json_each`; LIKE metacharacters are escaped /// here rather than in SQL. fn like_tokens(input: &str) -> serde_json::Value { serde_json::Value::Array( input .split_whitespace() .map(|token| serde_json::Value::String(escape_like(token))) .collect(), ) } fn escape_like(input: &str) -> String { input .replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") } fn prowlarr_client(state: &AppState) -> Result { let upstreams = state.upstreams(); let api_key = upstreams .prowlarr_api_key .clone() .ok_or(ApiError::Unavailable)?; ProwlarrClient::new(upstreams.prowlarr_url.clone(), api_key).map_err(|_| ApiError::Unavailable) } pub(crate) fn tmdb_client(state: &AppState) -> Result { let upstreams = state.upstreams(); let key = upstreams .tmdb_api_key .clone() .ok_or(ApiError::Unavailable)?; arr_meta::TmdbClient::builder(key) .base_url(&upstreams.tmdb_url) .build() .map_err(|_| ApiError::Unavailable) } pub(crate) fn upstream_error(error: &arr_meta::Error) -> ApiError { match error { arr_meta::Error::NotFound { .. } => ApiError::NotFound, _ => ApiError::Unavailable, } } /// Classify one release for the §9.3 buckets. /// /// A blacklisted release (§6.3) is rejected whatever the policy makes of its /// name, and says so: the operator sees why it is not offered rather than a /// row that looks grabbable and silently is not. fn classify( release: SearchRelease, policy: &Policy, overrides: &TitleOverrides, original_language: &Language, blacklist: &Blacklist, ) -> Result { let parsed = arr_parse::parse(&release.name); let evaluation = evaluate( policy, overrides, original_language, Candidate::PreGrab(&parsed), release.size, ); let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) { ("rejected", Some(blacklist::RULE.to_owned())) } else { verdict(&evaluation.verdict) }; let score = score( policy, Candidate::PreGrab(&parsed), release.size.unwrap_or_default(), release.seeders.unwrap_or_default(), ); // A release with no size has nothing to say about its size band, so that // term is dropped rather than scored as if it were at the floor. Every // other term still stands — the resolution rank comes from the name. let terms = ScoreTerms { size: if release.size.is_some() { score.size } else { 0 }, source: score.source, seeders: score.seeders, resolution: score.resolution, }; let score = terms .size .saturating_add(terms.source) .saturating_add(terms.seeders) .saturating_add(terms.resolution); Ok(ClassifiedRelease { indexer_id: release.indexer_id, guid: release.guid, name: release.name, size: release.size, seeders: release.seeders, publish_date: release .publish_date .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .and_then(|duration| { DateTime::::from_timestamp(i64::try_from(duration.as_secs()).ok()?, 0) }) .map(|date| date.to_rfc3339()), download_url: release.download_url, parsed: serde_json::to_value(parsed) .map_err(|error| ApiError::Database(error.to_string()))?, score, score_terms: terms, verdict: verdict.to_owned(), rule, }) } fn verdict(verdict: &Verdict) -> (&'static str, Option) { match verdict { Verdict::Eligible => ("eligible", None), Verdict::Waived(rule) => ("waived", Some(rule_name(rule))), Verdict::Rejected(rule) => ("rejected", Some(rule_name(rule))), } } fn rule_name(rule: &Rule) -> String { match rule { Rule::RequiredAudio => "required_audio".into(), Rule::DubBlacklist(_) => "dub_blacklist".into(), Rule::PortugueseUnverified => "portuguese_unverified".into(), Rule::DolbyVisionProfile(_) => "dolby_vision_profile".into(), Rule::Resolution(_) => "resolution".into(), Rule::Source(_) => "source".into(), Rule::Size => "size".into(), Rule::Other(name) => name.clone(), } } fn bucket(verdict: &str) -> u8 { match verdict { "eligible" => 0, "waived" => 1, _ => 2, } } fn title_language(value: &str, origin_countries: &[String]) -> Language { if value == "pt" { if origin_countries.iter().any(|country| country == "BR") { return Language::PortugueseBrazil; } if origin_countries.iter().any(|country| country == "PT") { return Language::PortuguesePortugal; } } language(value) } #[cfg(test)] mod tests { use arr_core::{HdrRules, PolicyId, RequiredAudio, Resolution, ScoreWeights, SizeBand, Source}; use super::*; use crate::{router, Upstreams}; use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; async fn application( tmdb: &MockServer, prowlarr: &MockServer, ) -> (tempfile::TempDir, AppState, String) { let dir = tempfile::tempdir().expect("tempdir"); let database = arr_db::Db::connect(dir.path().join("arr.db")) .await .expect("database"); database.migrate().await.expect("migrate"); let state = AppState::new( Upstreams::new(prowlarr.uri(), "http://127.0.0.1:1".into()) .with_prowlarr_api_key(Some("prowlarr-key".into())) .with_tmdb_url(tmdb.uri()) .with_tmdb_api_key(Some("tmdb-key".into())), ) .expect("state") .with_database(database); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let address = listener.local_addr().expect("address"); let app = router(state.clone()); tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); (dir, state, format!("http://{address}")) } #[tokio::test] async fn unified_search_groups_library_before_tmdb() { let tmdb = MockServer::start().await; let prowlarr = MockServer::start().await; Mock::given(method("GET")) .and(path("/search/movie")) .and(query_param("query", "Dune")) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{ "id": 693_134, "title": "Dune: Part Two", "original_title": "Dune: Part Two", "original_language": "en", "release_date": "2024-02-27", "poster_path": "/dune-two.jpg", "vote_average": 8.1, "vote_count": 5000 }]})), ) .mount(&tmdb) .await; Mock::given(method("GET")) .and(path("/search/tv")) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})), ) .mount(&tmdb) .await; let (_dir, state, base) = application(&tmdb, &prowlarr).await; sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (438631, 'Dune', 2021, 'en', 2)") .execute(state.database().expect("database").pool()).await.expect("movie"); let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=Dune")) .await .expect("search") .json() .await .expect("json"); assert_eq!(response["kind"], "text"); assert_eq!(response["library"][0]["kind"], "movie"); assert_eq!(response["library"][0]["title"], "Dune"); assert_eq!(response["tmdb"][0]["kind"], "movie"); assert_eq!(response["tmdb"][0]["tmdb_id"], 693_134); // §9.6: both halves carry artwork and a rating, read from stored // columns and TMDB's search body — no upstream call per row. assert_eq!( response["library"][0]["poster_path"], serde_json::Value::Null ); assert_eq!( response["library"][0]["vote_average"], serde_json::Value::Null ); assert_eq!(response["tmdb"][0]["poster_path"], "/dune-two.jpg"); assert_eq!(response["tmdb"][0]["vote_average"], 8.1); assert_eq!(response["tmdb"][0]["vote_count"], 5000); } /// §9.2: the in-library set matches series titles and episode titles, so /// `bluey hospital` finds the episode with its series and `SxxEyy` along /// for context — and plain `bluey` finds the series itself. #[tokio::test] async fn library_results_cover_series_and_episode_titles() { let tmdb = MockServer::start().await; let prowlarr = MockServer::start().await; Mock::given(method("GET")) .and(path("/search/movie")) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})), ) .mount(&tmdb) .await; Mock::given(method("GET")) .and(path("/search/tv")) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{ "id": 82_728, "name": "Bluey", "original_language": "en", "first_air_date": "2018-10-01" }]})), ) .mount(&tmdb) .await; let (_dir, state, base) = application(&tmdb, &prowlarr).await; let pool = state.database().expect("database").pool(); let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'") .fetch_one(pool) .await .expect("TV root"); let series_id: i64 = sqlx::query_scalar( "INSERT INTO series (tmdb_id, title, year, original_language, root_id) VALUES (82728, 'Bluey', 2018, 'en', ?) RETURNING id", ) .bind(root_id) .fetch_one(pool) .await .expect("series"); let season_id: i64 = sqlx::query_scalar( "INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id", ) .bind(series_id) .fetch_one(pool) .await .expect("season"); sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, 'Hospital')") .bind(season_id) .execute(pool) .await .expect("episode"); let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey%20hospital")) .await .expect("search") .json() .await .expect("json"); assert_eq!(response["library"].as_array().expect("library").len(), 1); let episode = &response["library"][0]; assert_eq!(episode["kind"], "episode"); assert_eq!(episode["series_title"], "Bluey"); assert_eq!(episode["tag"], "S01E02"); assert_eq!(episode["title"], "Hospital"); // TMDB carries the TV result below the library set. assert_eq!(response["tmdb"][0]["kind"], "series"); assert_eq!(response["tmdb"][0]["title"], "Bluey"); // §9.2, amended: an episode row surfaces only when the query matches // something beyond the series title. `bluey` alone returns the series // row and no episode below it. let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey")) .await .expect("search") .json() .await .expect("json"); let library = response["library"].as_array().expect("library"); assert_eq!(library.len(), 1); assert_eq!(library[0]["kind"], "series"); assert_eq!(library[0]["title"], "Bluey"); assert_eq!(library[0]["tmdb_id"], 82_728); // An episode-title-only query still finds the episode. let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=hospital")) .await .expect("search") .json() .await .expect("json"); let library = response["library"].as_array().expect("library"); assert_eq!(library.len(), 1); assert_eq!(library[0]["kind"], "episode"); assert_eq!(library[0]["title"], "Hospital"); } #[tokio::test] async fn numeric_titles_survive_a_missing_tmdb_id() { let tmdb = MockServer::start().await; let prowlarr = MockServer::start().await; Mock::given(method("GET")) .and(path("/movie/1917")) .respond_with(ResponseTemplate::new(404)) .mount(&tmdb) .await; Mock::given(method("GET")) .and(path("/tv/1917")) .respond_with(ResponseTemplate::new(404)) .mount(&tmdb) .await; Mock::given(method("GET")) .and(path("/search/movie")) .and(query_param("query", "1917")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "results": [{"id": 530_915, "title": "1917", "original_title": "1917", "original_language": "en", "release_date": "2019-12-25"}] }))) .mount(&tmdb) .await; Mock::given(method("GET")) .and(path("/search/tv")) .respond_with( ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})), ) .mount(&tmdb) .await; let (_dir, state, base) = application(&tmdb, &prowlarr).await; sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (530915, '1917', 2019, 'en', 2)") .execute(state.database().expect("database").pool()).await.expect("movie"); let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=1917")) .await .expect("search") .json() .await .expect("json"); assert_eq!(response["library"][0]["title"], "1917"); assert_eq!(response["tmdb"][0]["title"], "1917"); } /// §9.2: a pasted `tt` id resolves a series exactly as a TMDB id does — /// a hit on TMDB and, when tracked, in the library set too. #[tokio::test] async fn an_imdb_id_resolves_to_a_series() { let tmdb = MockServer::start().await; let prowlarr = MockServer::start().await; Mock::given(method("GET")) .and(path("/find/tt7614372")) .and(query_param("external_source", "imdb_id")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "movie_results": [], "tv_results": [{ "id": 82_728, "name": "Bluey", "original_name": "Bluey", "original_language": "en", "first_air_date": "2018-10-01" }] }))) .mount(&tmdb) .await; let (_dir, state, base) = application(&tmdb, &prowlarr).await; let pool = state.database().expect("database").pool(); let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'") .fetch_one(pool) .await .expect("TV root"); sqlx::query( "INSERT INTO series (tmdb_id, title, year, original_language, root_id) VALUES (82728, 'Bluey', 2018, 'en', ?)", ) .bind(root_id) .execute(pool) .await .expect("series"); let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=tt7614372")) .await .expect("search") .json() .await .expect("json"); assert_eq!(response["kind"], "imdb_id"); assert_eq!(response["library"][0]["kind"], "series"); assert_eq!(response["library"][0]["tmdb_id"], 82_728); assert_eq!(response["tmdb"][0]["kind"], "series"); assert_eq!(response["tmdb"][0]["title"], "Bluey"); } #[tokio::test] async fn manual_releases_are_classified_and_name_rejection_rules() { let tmdb = MockServer::start().await; let prowlarr = MockServer::start().await; Mock::given(method("GET")) .and(path("/movie/693134")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id":693_134, "imdb_id":"tt15239678", "title":"Dune Part Two", "original_title":"Dune Part Two", "original_language":"en", "status":"Released" }))) .mount(&tmdb) .await; Mock::given(method("GET")) .and(path("/api/v1/indexer")) .respond_with( ResponseTemplate::new(200) .set_body_json(serde_json::json!([{"id":7,"name":"tracker","enable":true}])), ) .mount(&prowlarr) .await; Mock::given(method("GET")).and(path("/7/api")).and(query_param("t", "caps")) .respond_with(ResponseTemplate::new(200).set_body_string("")) .mount(&prowlarr).await; Mock::given(method("GET")).and(path("/7/api")).and(query_param("t", "movie")) .respond_with(ResponseTemplate::new(200).set_body_string(r#"Dune.Part.Two.2024.1080p.CAMbadhttps://tracker/bad4000000000Dune.Part.Two.2024.2160p.WEB-DLgoodhttps://tracker/good22000000000"#)) .mount(&prowlarr).await; let (_dir, state, base) = application(&tmdb, &prowlarr).await; sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (693134, 'Dune Part Two', 2024, 'en', 2)") .execute(state.database().expect("database").pool()).await.expect("movie"); sqlx::query( "UPDATE policies SET score_weights = '{\"size_at_target\":0,\"source_tier\":0,\"seeder_doubling\":0,\"resolution_step\":0}'", ) .execute(state.database().expect("database").pool()) .await .expect("score weights"); let response = reqwest::get(format!("{base}/api/releases?movie_id=1")) .await .expect("releases"); assert_eq!(response.status(), 200); let releases: Vec = response.json().await.expect("json"); assert_eq!(releases.len(), 2); assert!(releases .iter() .all(|release| release["verdict"].is_string())); let rejected = releases .iter() .find(|release| release["verdict"] == "rejected") .expect("rejected"); assert_eq!(rejected["rule"], "source"); let eligible = releases .iter() .find(|release| release["guid"] == "good") .expect("eligible"); assert_eq!(eligible["score"], 0); // §6.3: once that release has hard-failed, the manual view must not // keep offering it as a clean match — it is rejected, and says why. blacklist::add( state.database().expect("database").pool(), None, "Dune.Part.Two.2024.2160p.WEB-DL", "dolby_vision_profile", ) .await .expect("blacklist"); let releases: Vec = reqwest::get(format!("{base}/api/releases?movie_id=1")) .await .expect("releases") .json() .await .expect("json"); let blacklisted = releases .iter() .find(|release| release["guid"] == "good") .expect("blacklisted"); assert_eq!(blacklisted["verdict"], "rejected"); assert_eq!(blacklisted["rule"], "blacklisted"); } #[tokio::test] async fn episode_releases_search_by_season_and_episode_tag() { let tmdb = MockServer::start().await; let prowlarr = MockServer::start().await; Mock::given(method("GET")) .and(path("/api/v1/indexer")) .respond_with( ResponseTemplate::new(200) .set_body_json(serde_json::json!([{"id":9,"name":"tracker","enable":true}])), ) .mount(&prowlarr) .await; // No TVDB ID on the series, so the indexer's tvsearch cannot be // addressed by ID and the text fallback carries the SxxEyy tag. Mock::given(method("GET")).and(path("/9/api")).and(query_param("t", "caps")) .respond_with(ResponseTemplate::new(200).set_body_string("")) .mount(&prowlarr).await; Mock::given(method("GET")) .and(path("/9/api")) .and(query_param("t", "search")) .and(query_param("q", "Bluey S01E02")) .respond_with(ResponseTemplate::new(200).set_body_string(r"Bluey.S01E02.1080p.WEB-DLephttps://tracker/ep1500000000")) .mount(&prowlarr).await; let (_dir, state, base) = application(&tmdb, &prowlarr).await; let pool = state.database().expect("database").pool(); let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'") .fetch_one(pool) .await .expect("TV root"); let series_id: i64 = sqlx::query_scalar("INSERT INTO series (tmdb_id, title, year, original_language, root_id) VALUES (82728, 'Bluey', 2018, 'en', ?) RETURNING id") .bind(root_id).fetch_one(pool).await.expect("series"); let season_id: i64 = sqlx::query_scalar( "INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id", ) .bind(series_id) .fetch_one(pool) .await .expect("season"); let episode_id: i64 = sqlx::query_scalar("INSERT INTO episodes (season_id, number, title, wanted) VALUES (?, 2, 'Hospital', 1) RETURNING id") .bind(season_id).fetch_one(pool).await.expect("episode"); let response = reqwest::get(format!("{base}/api/releases?episode_id={episode_id}")) .await .expect("releases"); assert_eq!(response.status(), 200); let releases: Vec = response.json().await.expect("json"); assert_eq!(releases.len(), 1); assert_eq!(releases[0]["guid"], "ep"); assert_eq!(releases[0]["parsed"]["episode"]["kind"], "episodes"); assert_eq!(releases[0]["parsed"]["episode"]["season"], 1); assert_eq!(releases[0]["parsed"]["episode"]["episodes"][0], 2); } #[tokio::test] async fn episode_releases_search_by_tvdb_id_when_the_indexer_takes_one() { let tmdb = MockServer::start().await; let prowlarr = MockServer::start().await; Mock::given(method("GET")) .and(path("/api/v1/indexer")) .respond_with( ResponseTemplate::new(200) .set_body_json(serde_json::json!([{"id":9,"name":"tracker","enable":true}])), ) .mount(&prowlarr) .await; Mock::given(method("GET")).and(path("/9/api")).and(query_param("t", "caps")) .respond_with(ResponseTemplate::new(200).set_body_string("")) .mount(&prowlarr).await; Mock::given(method("GET")) .and(path("/9/api")) .and(query_param("t", "tvsearch")) .and(query_param("tvdbid", "361391")) .and(query_param("season", "1")) .and(query_param("ep", "2")) .respond_with(ResponseTemplate::new(200).set_body_string(r"Bluey.S01E02.1080p.WEB-DLephttps://tracker/ep1500000000")) .mount(&prowlarr).await; let (_dir, state, base) = application(&tmdb, &prowlarr).await; let episode_id = episode_with_tvdb_id(&state, Some(361_391)).await; let response = reqwest::get(format!("{base}/api/releases?episode_id={episode_id}")) .await .expect("releases"); assert_eq!(response.status(), 200); let releases: Vec = response.json().await.expect("json"); assert_eq!(releases.len(), 1); assert_eq!(releases[0]["guid"], "ep"); } /// §6.1: an indexer whose caps do not advertise `tvdbid` still gets /// searched, by text — a missing or unsupported id widens the query, /// it never skips the indexer. #[tokio::test] async fn an_indexer_without_id_search_still_gets_a_text_query() { let tmdb = MockServer::start().await; let prowlarr = MockServer::start().await; Mock::given(method("GET")) .and(path("/api/v1/indexer")) .respond_with( ResponseTemplate::new(200) .set_body_json(serde_json::json!([{"id":9,"name":"tracker","enable":true}])), ) .mount(&prowlarr) .await; Mock::given(method("GET")).and(path("/9/api")).and(query_param("t", "caps")) .respond_with(ResponseTemplate::new(200).set_body_string("")) .mount(&prowlarr).await; Mock::given(method("GET")) .and(path("/9/api")) .and(query_param("t", "search")) .and(query_param("q", "Bluey S01E02")) .respond_with(ResponseTemplate::new(200).set_body_string(r"Bluey.S01E02.1080p.WEB-DLephttps://tracker/ep1500000000")) .mount(&prowlarr).await; let (_dir, state, base) = application(&tmdb, &prowlarr).await; let episode_id = episode_with_tvdb_id(&state, Some(361_391)).await; let response = reqwest::get(format!("{base}/api/releases?episode_id={episode_id}")) .await .expect("releases"); assert_eq!(response.status(), 200); let releases: Vec = response.json().await.expect("json"); assert_eq!(releases.len(), 1); assert_eq!(releases[0]["parsed"]["episode"]["episodes"][0], 2); } async fn episode_with_tvdb_id(state: &crate::state::AppState, tvdb_id: Option) -> i64 { use sqlx::Row as _; let pool = state.database().expect("database").pool(); let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'") .fetch_one(pool) .await .expect("TV root"); let series_id: i64 = sqlx::query( "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id) VALUES (82728, ?, 'Bluey', 2018, 'en', ?)", ) .bind(tvdb_id) .bind(root_id) .execute(pool) .await .expect("series") .last_insert_rowid(); let season_id: i64 = sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id") .bind(series_id) .fetch_one(pool) .await .expect("season") .get(0); sqlx::query_scalar( "INSERT INTO episodes (season_id, number, title, wanted) VALUES (?, 2, 'Hospital', 1) RETURNING id", ) .bind(season_id) .fetch_one(pool) .await .expect("episode") } #[tokio::test] async fn a_manual_search_names_exactly_one_title() { let tmdb = MockServer::start().await; let prowlarr = MockServer::start().await; let (_dir, _state, base) = application(&tmdb, &prowlarr).await; for query in ["", "movie_id=1&episode_id=1"] { let response = reqwest::get(format!("{base}/api/releases?{query}")) .await .expect("releases"); assert_eq!(response.status(), 422, "query: {query}"); } } /// Issue #113: the breakdown is what the UI explains a ranking from, so /// the resolution term has to be visible in it, not folded into a total. #[test] fn the_score_breakdown_carries_the_resolution_term() { let policy = scoring_policy(); let classify_at = |name: &str, size: u64| { let release = SearchRelease { indexer_id: 1, guid: name.into(), name: name.into(), size: Some(size), seeders: Some(20), publish_date: None, download_url: "https://tracker/release".into(), tmdb_id: None, imdb_id: None, }; classify( release, &policy, &TitleOverrides::default(), &Language::Other("en".into()), &Blacklist::default(), ) .expect("classified release") }; let uhd = classify_at("Dune.Part.Two.2024.2160p.WEB-DL", 23 << 30); let hd = classify_at("Dune.Part.Two.2024.1080p.WEB-DL", 8 << 30); assert_eq!( uhd.score_terms.resolution, i64::from(policy.score_weights.resolution_step) ); assert_eq!(hd.score_terms.resolution, 0); // The 4K is over its own target and so scores worse on size, and // still ranks first. assert!(uhd.score_terms.size < hd.score_terms.size); assert!(uhd.score > hd.score); assert_eq!( uhd.score, uhd.score_terms.size + uhd.score_terms.source + uhd.score_terms.seeders + uhd.score_terms.resolution ); } /// The seeded movie policy's scoring numbers (§5.5). fn scoring_policy() -> Policy { Policy { id: PolicyId(1), name: "test".into(), required_audio: RequiredAudio::OriginalLanguage, dub_blacklist: Vec::new(), hdr_rules: HdrRules { rejected_dolby_vision_profiles: Vec::new(), }, size_bands: std::collections::BTreeMap::from([ ( Resolution::R2160p, SizeBand { floor_bytes: 8 << 30, target_bytes: 22 << 30, penalty_points_per_gib_over: 60, }, ), ( Resolution::R1080p, SizeBand { floor_bytes: 3 << 30, target_bytes: 8 << 30, penalty_points_per_gib_over: 60, }, ), ]), resolution_preference: vec![Resolution::R2160p, Resolution::R1080p], source_weights: std::collections::BTreeMap::from([(Source::WebDl, 2)]), score_weights: ScoreWeights::default(), } } #[test] fn releases_without_sizes_skip_the_size_score() { let policy = Policy { id: PolicyId(1), name: "test".into(), required_audio: RequiredAudio::OriginalLanguage, dub_blacklist: Vec::new(), hdr_rules: HdrRules { rejected_dolby_vision_profiles: Vec::new(), }, size_bands: std::collections::BTreeMap::from([( Resolution::R2160p, SizeBand { floor_bytes: 8 << 30, target_bytes: 22 << 30, penalty_points_per_gib_over: 60, }, )]), resolution_preference: vec![Resolution::R2160p], source_weights: std::collections::BTreeMap::from([(Source::WebDl, 2)]), score_weights: ScoreWeights::default(), }; let release = SearchRelease { indexer_id: 1, guid: "release".into(), name: "Dune.Part.Two.2024.2160p.WEB-DL".into(), size: None, seeders: Some(8), publish_date: None, download_url: "https://tracker/release".into(), tmdb_id: None, imdb_id: None, }; let parsed = arr_parse::parse(&release.name); let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8); let classified = classify( release, &policy, &TitleOverrides::default(), &Language::Other("en".into()), &Blacklist::default(), ) .expect("classified release"); assert_eq!( classified.score, core_score .source .saturating_add(core_score.seeders) .saturating_add(core_score.resolution) ); assert_eq!(classified.score_terms.size, 0); assert_eq!(classified.score_terms.resolution, core_score.resolution); assert_ne!(classified.score, core_score.total); } #[test] fn manual_inputs_skip_title_upstreams() { assert!(matches!( input_kind("magnet:?xt=urn:btih:abc"), SearchInputKind::Magnet )); assert!(matches!( input_kind("https://example.test/a.torrent"), SearchInputKind::TorrentUrl )); assert!(matches!(input_kind("tt15239678"), SearchInputKind::ImdbId)); assert!(matches!(input_kind("tmdb:693134"), SearchInputKind::TmdbId)); } }