Files
arr/crates/arr-api/src/search.rs
T
Miguel PalhasandClaude Fable 5 917aa4fa76 feat: scale size bands by episode runtime
Implements #209 per §5.5 as amended by #208: a band's floor and target
are rates against a 45-minute reference runtime, scaled by the series'
minutes per episode. A missing or zero runtime applies the bands
unscaled, and movies are never scaled. The runtime is stored on the
series row (new migration), filled on add and by the metadata refresh,
which never blanks a known value against TMDB's frequently-empty
episode_run_time. Composes with #210: allow_below_floor waives against
the scaled floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:45:55 +01:00

1607 lines
64 KiB
Rust

use std::collections::BTreeMap;
use std::time::UNIX_EPOCH;
use arr_core::policy::{evaluate, Candidate};
use arr_core::score::{claimed_episode_count, 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<i64>,
episode_id: Option<i64>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SearchResponse {
pub kind: SearchInputKind,
/// In-library hits, grouped first (§9.2): movies and series by title.
pub library: Vec<LibraryResult>,
pub tmdb: Vec<TmdbResult>,
/// Why the `tmdb` group is what it is. An unreachable TMDB must not cost
/// the operator the library half (issue #168), so the group goes empty
/// while this says why: `"ok"` with an empty group means TMDB answered
/// and found nothing — a different world from `"unavailable"`.
pub tmdb_status: TmdbStatus,
pub manual: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TmdbStatus {
/// The `tmdb` group carries TMDB's answer — possibly an empty one.
Ok,
/// TMDB could not be reached or is unconfigured.
Unavailable,
}
/// 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),
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct LibrarySeries {
pub id: i64,
pub tmdb_id: i64,
pub title: String,
pub year: Option<i64>,
pub original_language: Option<String>,
pub root_id: i64,
pub blocked: bool,
/// Stored artwork (§9.6), so a row renders without a TMDB call.
pub poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>,
}
#[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<i32>,
pub overview: Option<String>,
pub poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>,
/// 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<i32>,
pub overview: Option<String>,
pub poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>,
/// 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<u64>,
pub seeders: Option<u32>,
pub publish_date: Option<String>,
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<String>,
}
#[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<AppState>,
Query(query): Query<SearchQuery>,
) -> Result<Json<SearchResponse>, 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(),
tmdb_status: TmdbStatus::Ok,
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.
let mut library: Vec<LibraryResult> = if matches!(kind, SearchInputKind::TmdbId) {
let tmdb_id = input
.strip_prefix("tmdb:")
.unwrap_or(input)
.trim()
.parse::<i64>()
.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::<i64>()
.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));
// A degraded TMDB must not discard the library results already in hand
// (issue #168): the group goes empty and `tmdb_status` says why, so the
// operator can tell "TMDB found nothing" from "TMDB is unreachable".
let (results, tmdb_status) = match tmdb_client(&state) {
Ok(tmdb) => match search_tmdb(&tmdb, kind, input).await {
Ok(found) => (found, TmdbStatus::Ok),
// TMDB answered and has no such title — an empty group that is
// not a fault.
Err(ApiError::NotFound) => (Vec::new(), TmdbStatus::Ok),
Err(ApiError::Upstream(name)) => {
tracing::warn!(upstream = name, "unified search: upstream unavailable");
(Vec::new(), TmdbStatus::Unavailable)
}
Err(error) => return Err(error),
},
Err(ApiError::Upstream(name)) => {
tracing::warn!(upstream = name, "unified search: upstream unavailable");
(Vec::new(), TmdbStatus::Unavailable)
}
Err(error) => return Err(error),
};
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,
tmdb_status,
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<Vec<TmdbResult>, ApiError> {
let results = match kind {
SearchInputKind::TmdbId => {
let id = input
.strip_prefix("tmdb:")
.unwrap_or(input)
.trim()
.parse::<u32>()
.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<TmdbResult> = 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<TmdbResult> = 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<AppState>,
Query(query): Query<ReleasesQuery>,
) -> Result<Json<Vec<ClassifiedRelease>>, 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<Vec<ClassifiedRelease>, 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::Upstream("prowlarr"))?;
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,
None,
0,
)?);
}
}
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<Vec<ClassifiedRelease>, ApiError> {
let database = state.database().ok_or(ApiError::Unavailable)?;
let episode = sqlx::query!(
r#"SELECT s.id AS "series_id!: i64", 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 season_lengths = crate::series::season_lengths(state, episode.series_id).await?;
let runtime_minutes = crate::series::series_runtime(state, episode.series_id).await?;
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::Upstream("prowlarr"))?;
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,
Some(&season_lengths),
runtime_minutes,
)?);
}
}
Err(error) => {
tracing::warn!(indexer_id = indexer.id, %error, "manual episode search failed");
}
}
}
Ok(classified)
}
impl From<arr_meta::MovieSearchResult> 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<arr_meta::SeriesSearchResult> 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<arr_meta::Series> 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
}
}
/// A multi-word title query 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<ProwlarrClient, ApiError> {
let upstreams = state.upstreams();
let api_key = upstreams
.prowlarr_api_key
.clone()
.ok_or(ApiError::Upstream("prowlarr"))?;
ProwlarrClient::new(upstreams.prowlarr_url.clone(), api_key)
.map_err(|_| ApiError::Upstream("prowlarr"))
}
pub(crate) fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiError> {
let upstreams = state.upstreams();
let key = upstreams
.tmdb_api_key
.clone()
.ok_or(ApiError::Upstream("tmdb"))?;
arr_meta::TmdbClient::builder(key)
.base_url(&upstreams.tmdb_url)
.build()
.map_err(|_| ApiError::Upstream("tmdb"))
}
pub(crate) fn upstream_error(error: &arr_meta::Error) -> ApiError {
match error {
arr_meta::Error::NotFound { .. } => ApiError::NotFound,
_ => ApiError::Upstream("tmdb"),
}
}
/// 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,
season_lengths: Option<&BTreeMap<u32, u32>>,
runtime_minutes: u32,
) -> Result<ClassifiedRelease, ApiError> {
let parsed = arr_parse::parse(&release.name);
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
// divided by how many episodes it covers. `None` is the movie search —
// one release is one film.
let episodes = season_lengths.map_or(1, |lengths| {
claimed_episode_count(parsed.episode.as_ref(), lengths)
});
let evaluation = evaluate(
policy,
overrides,
original_language,
Candidate::PreGrab(&parsed),
release.size,
episodes,
runtime_minutes,
);
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
("rejected", Some(blacklist::RULE.to_owned()))
} else {
verdict_columns(&evaluation.verdict)
};
let score = score(
policy,
Candidate::PreGrab(&parsed),
release.size.unwrap_or_default(),
release.seeders.unwrap_or_default(),
episodes,
runtime_minutes,
);
// 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::<Utc>::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,
})
}
pub(crate) fn verdict_columns(verdict: &Verdict) -> (&'static str, Option<String>) {
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) {
application_with(
tmdb.uri(),
Some("tmdb-key".into()),
Some("prowlarr-key".into()),
prowlarr.uri(),
)
.await
}
/// Like [`application`] but with each upstream knob explicit, so tests
/// can take TMDB or the API keys away.
async fn application_with(
tmdb_url: String,
tmdb_key: Option<String>,
prowlarr_key: Option<String>,
prowlarr_url: String,
) -> (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_url, "http://127.0.0.1:1".into())
.with_prowlarr_api_key(prowlarr_key)
.with_tmdb_url(tmdb_url)
.with_tmdb_api_key(tmdb_key),
)
.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, amended: episode rows never appear, under any query. A query
/// matching an episode title and nothing else returns no rows for that
/// series beyond the series itself, and a query naming both the series
/// and one of its episode titles still surfaces only the series row.
#[tokio::test]
async fn library_results_never_include_episode_rows() {
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"))
.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");
// TMDB carries the TV result below the library set.
assert_eq!(response["tmdb"][0]["kind"], "series");
assert_eq!(response["tmdb"][0]["title"], "Bluey");
// A query matching only the episode's title, not the series title,
// returns no rows for that series — no episode row, and no series
// row either, since the series title alone does not match.
for query in ["hospital", "bluey%20hospital"] {
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q={query}"))
.await
.expect("search")
.json()
.await
.expect("json");
assert!(
response["library"].as_array().expect("library").is_empty(),
"query: {query}"
);
}
}
#[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");
}
/// Issue #168: an unreachable TMDB must not cost the operator the
/// library half. The request succeeds, the tmdb group goes empty, and
/// `tmdb_status` says the upstream — not a clean "nothing found".
#[tokio::test]
async fn an_unreachable_tmdb_still_returns_the_library_half() {
// Port 1 is closed; TMDB is configured but cannot be reached.
let (dir, state, base) = application_with(
"http://127.0.0.1:1".into(),
Some("tmdb-key".into()),
Some("prowlarr-key".into()),
"http://127.0.0.1:1".into(),
)
.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 = reqwest::get(format!("{base}/api/search?q=Dune"))
.await
.expect("search");
assert_eq!(response.status(), 200);
let body: serde_json::Value = response.json().await.expect("json");
assert_eq!(body["library"][0]["kind"], "movie");
assert_eq!(body["library"][0]["title"], "Dune");
assert!(body["tmdb"].as_array().expect("tmdb").is_empty());
assert_eq!(body["tmdb_status"], "unavailable");
drop(dir);
}
/// Issue #168: "TMDB answered and found nothing" must stay
/// distinguishable from "TMDB could not be reached" — both leave the
/// tmdb group empty, only `tmdb_status` tells them apart.
#[tokio::test]
async fn an_empty_tmdb_answer_is_not_an_unavailable_tmdb() {
let tmdb = MockServer::start().await;
let prowlarr = MockServer::start().await;
for endpoint in ["/search/movie", "/search/tv"] {
Mock::given(method("GET"))
.and(path(endpoint))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
)
.mount(&tmdb)
.await;
}
let (_dir, _state, base) = application(&tmdb, &prowlarr).await;
let body: serde_json::Value = reqwest::get(format!("{base}/api/search?q=nonexistent"))
.await
.expect("search")
.json()
.await
.expect("json");
assert_eq!(body["library"].as_array().expect("library").len(), 0);
assert_eq!(body["tmdb"].as_array().expect("tmdb").len(), 0);
assert_eq!(body["tmdb_status"], "ok");
}
/// Issue #168: a Prowlarr fault names Prowlarr, never the database.
#[tokio::test]
async fn a_prowlarr_fault_names_prowlarr() {
let tmdb = 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;
// No Prowlarr API key configured: the client cannot even be built.
let (_dir, state, base) = application_with(
tmdb.uri(),
Some("tmdb-key".into()),
None,
"http://127.0.0.1:1".into(),
)
.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");
let response = reqwest::get(format!("{base}/api/releases?movie_id=1"))
.await
.expect("releases");
assert_eq!(response.status(), 503);
let body: serde_json::Value = response.json().await.expect("json");
assert_eq!(body["error"], "prowlarr unavailable");
}
/// §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("<caps><searching><movie-search available=\"yes\" supportedParams=\"q,imdbid\"/></searching></caps>"))
.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#"<rss><channel><item><title>Dune.Part.Two.2024.1080p.CAM</title><guid>bad</guid><link>https://tracker/bad</link><size>4000000000</size><torznab:attr xmlns:torznab="http://torznab.com/schemas/2015/feed" name="seeders" value="12"/></item><item><title>Dune.Part.Two.2024.2160p.WEB-DL</title><guid>good</guid><link>https://tracker/good</link><size>22000000000</size></item></channel></rss>"#))
.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<serde_json::Value> = 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<serde_json::Value> =
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("<caps><searching><search available=\"yes\" supportedParams=\"q\"/><tv-search available=\"yes\" supportedParams=\"q,tvdbid,season,ep\"/></searching></caps>"))
.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"<rss><channel><item><title>Bluey.S01E02.1080p.WEB-DL</title><guid>ep</guid><link>https://tracker/ep</link><size>1500000000</size></item></channel></rss>"))
.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<serde_json::Value> = 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("<caps><searching><search available=\"yes\" supportedParams=\"q\"/><tv-search available=\"yes\" supportedParams=\"q,tvdbid,season,ep\"/></searching></caps>"))
.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"<rss><channel><item><title>Bluey.S01E02.1080p.WEB-DL</title><guid>ep</guid><link>https://tracker/ep</link><size>1500000000</size></item></channel></rss>"))
.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<serde_json::Value> = 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("<caps><searching><search available=\"yes\" supportedParams=\"q\"/><tv-search available=\"yes\" supportedParams=\"q,season,ep\"/></searching></caps>"))
.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"<rss><channel><item><title>Bluey.S01E02.1080p.WEB-DL</title><guid>ep</guid><link>https://tracker/ep</link><size>1500000000</size></item></channel></rss>"))
.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<serde_json::Value> = 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>) -> 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(),
None,
0,
)
.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, 1, 0);
let classified = classify(
release,
&policy,
&TitleOverrides::default(),
&Language::Other("en".into()),
&Blacklist::default(),
None,
0,
)
.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));
}
}