feat(api): series, season and episode endpoints (#81)
This commit was merged in pull request #81.
This commit is contained in:
+181
-14
@@ -4,7 +4,7 @@ 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_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest, TvSelector, TvTarget};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -19,9 +19,14 @@ 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: i64,
|
||||
movie_id: Option<i64>,
|
||||
episode_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -203,6 +208,7 @@ async fn search_tmdb(
|
||||
responses(
|
||||
(status = 200, body = [ClassifiedRelease]),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 422, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
@@ -211,16 +217,33 @@ 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 = ?"#, query.movie_id)
|
||||
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(query.movie_id)
|
||||
.movie_policy(movie_id)
|
||||
.await
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let tmdb = tmdb_client(&state)?
|
||||
let tmdb = tmdb_client(state)?
|
||||
.movie(
|
||||
u32::try_from(movie.tmdb_id)
|
||||
.map_err(|_| ApiError::Invalid("movie has invalid TMDB id".into()))?,
|
||||
@@ -233,13 +256,7 @@ pub async fn releases(
|
||||
},
|
||||
|imdb_id| SearchRequest::Movie { imdb_id },
|
||||
);
|
||||
let upstreams = state.upstreams();
|
||||
let api_key = upstreams
|
||||
.prowlarr_api_key
|
||||
.clone()
|
||||
.ok_or(ApiError::Unavailable)?;
|
||||
let prowlarr = ProwlarrClient::new(upstreams.prowlarr_url.clone(), api_key)
|
||||
.map_err(|_| ApiError::Unavailable)?;
|
||||
let prowlarr = prowlarr_client(state)?;
|
||||
let indexers = prowlarr
|
||||
.indexers()
|
||||
.await
|
||||
@@ -282,8 +299,80 @@ pub async fn releases(
|
||||
}
|
||||
}
|
||||
}
|
||||
classified.sort_by_key(|release| (bucket(&release.verdict), -release.score));
|
||||
Ok(Json(classified))
|
||||
Ok(classified)
|
||||
}
|
||||
|
||||
/// Classified releases for one episode (`DESIGN.md` §6.1, §9.3).
|
||||
///
|
||||
/// The series carries no TVDB ID yet, so `tv_request` falls back to a text
|
||||
/// search built from the title and the `SxxEyy` tag. 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.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: None,
|
||||
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 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,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(indexer_id = indexer.id, %error, "manual episode search failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(classified)
|
||||
}
|
||||
|
||||
impl From<arr_meta::MovieSearchResult> for TmdbMovie {
|
||||
@@ -329,6 +418,15 @@ fn escape_like(input: &str) -> String {
|
||||
.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)
|
||||
}
|
||||
|
||||
fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiError> {
|
||||
let upstreams = state.upstreams();
|
||||
let key = upstreams
|
||||
@@ -590,6 +688,75 @@ mod tests {
|
||||
assert_eq!(eligible["score"], 0);
|
||||
}
|
||||
|
||||
#[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 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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releases_without_sizes_skip_the_size_score() {
|
||||
let policy = Policy {
|
||||
|
||||
Reference in New Issue
Block a user