feat(api): season search, deck and grab endpoints

This commit is contained in:
Miguel Palhas
2026-08-23 16:49:42 +01:00
parent 42a0895975
commit 75bf6fb14e
3 changed files with 295 additions and 2 deletions
+18 -1
View File
@@ -36,7 +36,9 @@ pub use series::{
CreateEpisode, CreateSeason, CreateSeries, Episode, Season, Series, UpdateEpisode,
UpdateSeason, UpdateSeries,
};
pub use state::{AppState, EpisodeCommand, MovieCommand, Upstreams, DEFAULT_TMDB_URL};
pub use state::{
AppState, EpisodeCommand, MovieCommand, SeasonCommand, Upstreams, DEFAULT_TMDB_URL,
};
/// Where the generated document is served, and where `just gen-client` reads
/// it back from when it is fetched rather than dumped from the binary.
@@ -87,6 +89,9 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(series::search_episode))
.routes(routes!(series::episode_releases))
.routes(routes!(series::grab_episode))
.routes(routes!(series::search_season))
.routes(routes!(series::season_releases))
.routes(routes!(series::grab_season_release))
.routes(routes!(series::list_owners))
.routes(routes!(series::tag_owner, series::untag_owner))
.routes(routes!(owners::list, owners::create))
@@ -307,6 +312,18 @@ mod tests {
("/api/movies/{movie_id}/search", "post"),
("/api/movies/{movie_id}/releases", "get"),
("/api/movies/{movie_id}/releases/{release_id}/grab", "post"),
(
"/api/series/{series_id}/seasons/{season_number}/search",
"post",
),
(
"/api/series/{series_id}/seasons/{season_number}/releases",
"get",
),
(
"/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab",
"post",
),
("/api/queues/attention", "get"),
("/api/series", "get"),
("/api/policies", "get"),
+245 -1
View File
@@ -32,7 +32,7 @@ use utoipa::{IntoParams, ToSchema};
use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release};
use crate::owners::Owner;
use crate::search::tmdb_client;
use crate::state::{AppState, EpisodeCommand};
use crate::state::{AppState, EpisodeCommand, SeasonCommand};
/// A series with the status derived from its episodes (§4.2).
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -974,6 +974,120 @@ pub async fn grab_episode(
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
}
/// Resolves a season by its number within one series, so the deck is
/// addressed the way the UI shows seasons (`/series/{id}/seasons/{n}`).
async fn load_season_id(state: &AppState, series_id: i64, number: i64) -> Result<i64, ApiError> {
sqlx::query_scalar!(
r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#,
series_id,
number
)
.fetch_optional(pool(state)?)
.await?
.ok_or(ApiError::SeasonNotFound)
}
#[utoipa::path(
post, path = "/api/series/{series_id}/seasons/{season_number}/search", tag = "series",
params(
("series_id" = i64, Path, description = "Series row id"),
("season_number" = i64, Path, description = "Season number, not its row id")
),
responses(
(status = 202, body = Accepted),
(status = 404, body = ErrorBody),
(status = 409, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn search_season(
State(state): State<AppState>,
Path((series_id, number)): Path<(i64, i64)>,
) -> Result<(StatusCode, Json<Accepted>), ApiError> {
load_series_row(&state, series_id).await?;
let season_id = load_season_id(&state, series_id, number).await?;
// §6.3. `blocked` stops targeted search for the whole series.
let blocked = sqlx::query_scalar!(
r#"SELECT blocked AS "blocked!: bool" FROM series WHERE id = ?"#,
series_id
)
.fetch_one(pool(&state)?)
.await?;
if blocked {
return Err(ApiError::Conflict("series is blocked".into()));
}
state
.send_season_command(SeasonCommand::Search { season_id })
.map_err(|_| ApiError::Unavailable)?;
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
}
#[utoipa::path(
get, path = "/api/series/{series_id}/seasons/{season_number}/releases", tag = "series",
params(
("series_id" = i64, Path, description = "Series row id"),
("season_number" = i64, Path, description = "Season number, not its row id")
),
responses(
(status = 200, body = [Release]),
(status = 404, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn season_releases(
State(state): State<AppState>,
Path((series_id, number)): Path<(i64, i64)>,
) -> Result<Json<Vec<Release>>, ApiError> {
load_series_row(&state, series_id).await?;
let season_id = load_season_id(&state, series_id, number).await?;
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id)
.fetch_all(pool(&state)?)
.await?;
let policy = state
.database()
.ok_or(ApiError::Unavailable)?
.season_policy(season_id)
.await
.map_err(|error| ApiError::Database(error.to_string()))?
.ok_or(ApiError::SeasonNotFound)?
.policy;
rescore(&mut releases, &policy)?;
Ok(Json(releases))
}
#[utoipa::path(
post, path = "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", tag = "series",
params(("series_id" = i64, Path), ("season_number" = i64, Path), ("release_id" = i64, Path)),
responses(
(status = 202, body = Accepted),
(status = 404, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn grab_season_release(
State(state): State<AppState>,
Path((series_id, number, release_id)): Path<(i64, i64, i64)>,
) -> Result<(StatusCode, Json<Accepted>), ApiError> {
load_series_row(&state, series_id).await?;
let season_id = load_season_id(&state, series_id, number).await?;
let exists = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM season_releases WHERE season_id = ? AND release_id = ?) AS 'exists!: bool'", season_id, release_id)
.fetch_one(pool(&state)?)
.await?;
if !exists {
return Err(ApiError::SeasonNotFound);
}
state
.send_season_command(SeasonCommand::Grab {
season_id,
release_id,
})
.map_err(|_| ApiError::Unavailable)?;
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
}
#[utoipa::path(
get, path = "/api/series/{series_id}/owners", tag = "series",
params(("series_id" = i64, Path, description = "Series row id")),
@@ -1422,6 +1536,136 @@ mod tests {
assert_eq!(response.status(), StatusCode::CONFLICT);
}
/// Issue #125: seasons get the same §9.3 surface as movies and episodes —
/// a targeted search, a deck scoped to one season and rescored against
/// the current policy, and a grab by release id.
#[tokio::test]
async fn manual_season_actions_are_scoped_and_rescore_the_deck() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]),
)
.await;
let response = reqwest::Client::new()
.post(format!("{base}/api/series/{series_id}/seasons/1/search"))
.send()
.await
.expect("search");
assert_eq!(response.status(), StatusCode::ACCEPTED);
let season_id = season["id"].as_i64().expect("season id");
assert_eq!(
state.next_season_command().await.expect("command"),
SeasonCommand::Search { season_id }
);
// A stale stored score proves the deck is rescored at read time,
// like the movie deck (#114).
let pool = state.database().expect("database").pool();
let name = "Bluey S01 1080p WEB-DL x264-GROUP";
let parsed = arr_parse::parse(name);
let release_id = sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
VALUES (7, 'pack', ?, 1000, 50, 'url', ?, -12345, 'eligible') RETURNING id",
)
.bind(name)
.bind(serde_json::to_string(&parsed).expect("parsed json"))
.fetch_one(pool)
.await
.expect("release");
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
.bind(season_id)
.bind(release_id)
.execute(pool)
.await
.expect("association");
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/series/{series_id}/seasons/1/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(releases.len(), 1);
assert_eq!(releases[0]["verdict"], "eligible");
assert_ne!(
releases[0]["score"].as_f64(),
Some(-12345.0),
"the deck reflects the current policy, not the stored score"
);
let response = reqwest::Client::new()
.post(format!(
"{base}/api/series/{series_id}/seasons/1/releases/{release_id}/grab"
))
.send()
.await
.expect("grab");
assert_eq!(response.status(), StatusCode::ACCEPTED);
assert_eq!(
state.next_season_command().await.expect("command"),
SeasonCommand::Grab {
season_id,
release_id
}
);
// A release belonging to another season is not grabbable through
// this one, and an unknown season number is not another season.
let other = sqlx::query_scalar::<_, i64>("INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) VALUES (7, 'other', 'other', 1, 'url', '{}', 'eligible') RETURNING id")
.fetch_one(pool)
.await
.expect("other release");
let response = reqwest::Client::new()
.post(format!(
"{base}/api/series/{series_id}/seasons/1/releases/{other}/grab"
))
.send()
.await
.expect("grab");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = reqwest::get(format!("{base}/api/series/{series_id}/seasons/2/releases"))
.await
.expect("unknown season");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_blocked_series_refuses_a_season_search() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
add_season(
&base,
series_id,
1,
serde_json::json!([{"number": 1, "title": "Pilot"}]),
)
.await;
reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"blocked": true}))
.send()
.await
.expect("block the series");
let response = reqwest::Client::new()
.post(format!("{base}/api/series/{series_id}/seasons/1/search"))
.send()
.await
.expect("blocked search");
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn owner_tags_filter_the_series_list() {
let (_dir, state, base) = application().await;
+32
View File
@@ -72,6 +72,8 @@ pub struct AppState {
pending_movie_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MovieCommand>>>,
episode_commands: mpsc::Sender<EpisodeCommand>,
pending_episode_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<EpisodeCommand>>>,
season_commands: mpsc::Sender<SeasonCommand>,
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
}
/// Work explicitly requested through the movie API.
@@ -91,6 +93,17 @@ pub enum EpisodeCommand {
Grab { episode_id: i64, release_id: i64 },
}
/// Work explicitly requested through the season deck API (issue #125).
///
/// A season pack is one torrent for the whole season and the grab targets
/// `target_kind = 'season'`, so the manual action names a season — unlike
/// [`EpisodeCommand`], where intent lives at the leaf.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeasonCommand {
Search { season_id: i64 },
Grab { season_id: i64, release_id: i64 },
}
impl AppState {
/// Build the state, including the shared HTTP client.
///
@@ -101,6 +114,7 @@ impl AppState {
let http = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
let (movie_commands, pending_movie_commands) = mpsc::channel(64);
let (episode_commands, pending_episode_commands) = mpsc::channel(64);
let (season_commands, pending_season_commands) = mpsc::channel(64);
Ok(Self {
http,
upstreams: Arc::new(upstreams),
@@ -109,6 +123,8 @@ impl AppState {
pending_movie_commands: Arc::new(tokio::sync::Mutex::new(pending_movie_commands)),
episode_commands,
pending_episode_commands: Arc::new(tokio::sync::Mutex::new(pending_episode_commands)),
season_commands,
pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
})
}
@@ -137,6 +153,15 @@ impl AppState {
self.pending_episode_commands.lock().await.recv().await
}
/// Wait for the next manual season action in the daemon's reconcile loop.
///
/// # Errors
///
/// If every sender has been dropped.
pub async fn next_season_command(&self) -> Option<SeasonCommand> {
self.pending_season_commands.lock().await.recv().await
}
pub(crate) fn http(&self) -> &reqwest::Client {
&self.http
}
@@ -162,4 +187,11 @@ impl AppState {
) -> Result<(), mpsc::error::TrySendError<EpisodeCommand>> {
self.episode_commands.try_send(command)
}
pub(crate) fn send_season_command(
&self,
command: SeasonCommand,
) -> Result<(), mpsc::error::TrySendError<SeasonCommand>> {
self.season_commands.try_send(command)
}
}