07741c80fe
Every search row — library and TMDB alike — gains a w92 poster thumbnail with a same-size blank fallback, a TMDB rating chip (hidden when there are no votes), and a trailer chip that resolves GET /api/trailer on click only, opening the tab inside the handler. Episode hits carry the series' TMDB id so their chip resolves too.
1469 lines
59 KiB
Rust
1469 lines
59 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 the haystack
|
|
// is both joined and every token must land somewhere in it.
|
|
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 '\') ORDER BY s.title, se.number, e.number, e.id"#, 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");
|
|
|
|
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey"))
|
|
.await
|
|
.expect("search")
|
|
.json()
|
|
.await
|
|
.expect("json");
|
|
assert_eq!(response["library"][0]["kind"], "series");
|
|
assert_eq!(response["library"][0]["title"], "Bluey");
|
|
assert_eq!(response["library"][0]["tmdb_id"], 82_728);
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
}
|