1427 lines
57 KiB
Rust
1427 lines
57 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,
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
/// 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, (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, (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" 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" 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" 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, (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" 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,
|
|
})]
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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"
|
|
}]})),
|
|
)
|
|
.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.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));
|
|
}
|
|
}
|