Files
arr/crates/arr-api/src/search.rs
T
Miguel Palhas 2c293e7f60 fix(api): episode rows need a token beyond series title
Per amended DESIGN.md 9.2, an episode row now also requires at
least one query token to match the episode title on its own, so
a series-title-only query lists the series and no episodes.
2026-08-24 11:59:18 +01:00

1488 lines
60 KiB
Rust

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<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,
/// episodes by episode title with their series and `SxxEyy` for context.
pub library: Vec<LibraryResult>,
pub tmdb: Vec<TmdbResult>,
pub manual: Option<String>,
}
/// 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<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, 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<String>,
/// The series' rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>,
/// 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<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(),
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<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));
// §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<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::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<Vec<ClassifiedRelease>, 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<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
}
}
/// §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<ProwlarrClient, ApiError> {
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<arr_meta::TmdbClient, ApiError> {
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<ClassifiedRelease, ApiError> {
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::<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,
})
}
fn verdict(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) {
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("<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(),
)
.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));
}
}