feat(core): score season packs per episode
A size band describes one episode (DESIGN.md §5.5), so both the target penalty and the floor now compare a release's size divided by the number of episodes it covers. The caller supplies the count — arr-core has no IO. Movies and unknown counts divide by one, so movie scoring is unchanged and pinned by test. Closes #180 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
//! - **Status is derived, never stored** (§4.2). Every series the API returns
|
||||
//! carries a status computed from its episodes at request time.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason};
|
||||
@@ -1360,7 +1360,7 @@ pub async fn episode_releases(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Vec<Release>>, ApiError> {
|
||||
load_episode(&state, id).await?;
|
||||
let episode = load_episode(&state, id).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 episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
@@ -1372,7 +1372,8 @@ pub async fn episode_releases(
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
.ok_or(ApiError::EpisodeNotFound)?
|
||||
.policy;
|
||||
rescore(&mut releases, &policy)?;
|
||||
let lengths = season_lengths(&state, episode.series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths))?;
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
@@ -1407,6 +1408,34 @@ pub async fn grab_episode(
|
||||
|
||||
/// Resolves a season by its number within one series, so the deck is
|
||||
/// addressed the way the UI shows seasons (`/series/{id}/seasons/{n}`).
|
||||
/// Per-season episode counts for one series: the divisor data behind
|
||||
/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no
|
||||
/// revealed episodes counts zero, which `claimed_episode_count` treats as
|
||||
/// unknown.
|
||||
pub(crate) async fn season_lengths(
|
||||
state: &AppState,
|
||||
series_id: i64,
|
||||
) -> Result<BTreeMap<u32, u32>, ApiError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64"
|
||||
FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id
|
||||
WHERE se.series_id = ?
|
||||
GROUP BY se.number"#,
|
||||
series_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
(
|
||||
u32::try_from(row.number).unwrap_or_default(),
|
||||
u32::try_from(row.episodes).unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
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 = ?"#,
|
||||
@@ -1484,7 +1513,8 @@ pub async fn season_releases(
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
.ok_or(ApiError::SeasonNotFound)?
|
||||
.policy;
|
||||
rescore(&mut releases, &policy)?;
|
||||
let lengths = season_lengths(&state, series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths))?;
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user