Hard/soft fail handling and release blacklist (#87)
This commit was merged in pull request #87.
This commit is contained in:
@@ -22,7 +22,9 @@ use utoipa_axum::routes;
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
|
||||
pub use health::{Check, Health, HealthReport, Status};
|
||||
pub use movies::{Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, Release, UpdateMovie};
|
||||
pub use movies::{
|
||||
Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie,
|
||||
};
|
||||
pub use owners::{CreateOwner, Owner, UpdateOwner};
|
||||
pub use roots::Root;
|
||||
pub use search::{ClassifiedRelease, SearchResponse};
|
||||
@@ -67,6 +69,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(movies::get, movies::update, movies::delete))
|
||||
.routes(routes!(movies::search))
|
||||
.routes(routes!(movies::releases))
|
||||
.routes(routes!(movies::files))
|
||||
.routes(routes!(movies::grab))
|
||||
.routes(routes!(movies::attention))
|
||||
.routes(routes!(movies::list_owners))
|
||||
|
||||
@@ -69,6 +69,23 @@ pub struct Release {
|
||||
pub rejected_rule: Option<String>,
|
||||
}
|
||||
|
||||
/// A library file and what it cost to accept it (`DESIGN.md` §5.7).
|
||||
///
|
||||
/// `waiver` names the rule that was relaxed to let a soft fail in. It is the
|
||||
/// difference between a file that satisfies the policy and one that merely
|
||||
/// plays, so it travels with the file everywhere the file does.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct MovieFile {
|
||||
pub id: i64,
|
||||
pub path: String,
|
||||
pub size: i64,
|
||||
/// What `ffprobe` found (§5.6).
|
||||
pub probed: Option<serde_json::Value>,
|
||||
/// The relaxed rule's name, or `null` for a clean import. Shares its
|
||||
/// vocabulary with `Release::rejected_rule`.
|
||||
pub waiver: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct AttentionQueues {
|
||||
pub no_pt_source: Vec<Movie>,
|
||||
@@ -385,6 +402,27 @@ pub async fn releases(
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/movies/{movie_id}/files", tag = "movies",
|
||||
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||
responses(
|
||||
(status = 200, body = [MovieFile]),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn files(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Vec<MovieFile>>, ApiError> {
|
||||
load_movie(&state, id).await?;
|
||||
let files = sqlx::query_as!(MovieFile, r#"SELECT id AS "id!: i64", path AS "path!: String", size AS "size!: i64", probed AS "probed?: serde_json::Value", json_extract(waiver, '$.rule') AS "waiver?: String" FROM media_files WHERE owner_kind = 'movie' AND owner_id = ? ORDER BY path"#, id)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
Ok(Json(files))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/movies/{movie_id}/releases/{release_id}/grab", tag = "movies",
|
||||
params(("movie_id" = i64, Path), ("release_id" = i64, Path)),
|
||||
@@ -563,6 +601,39 @@ mod tests {
|
||||
response.json().await.expect("movie json")
|
||||
}
|
||||
|
||||
/// §5.7: a soft-failed import is imported and waived, and the waiver
|
||||
/// reaches the API — a file that merely plays must never read as a clean
|
||||
/// match.
|
||||
#[tokio::test]
|
||||
async fn a_movie_file_carries_its_waiver() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let movie = add_movie(&base, 693_134, 1).await;
|
||||
let movie_id = movie["id"].as_i64().expect("movie id");
|
||||
let pool = state.database().expect("database").pool();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO media_files (owner_kind, owner_id, path, size, probed, waiver)
|
||||
VALUES ('movie', ?, '/library/dune.mkv', 23622320128,
|
||||
'{"resolution":"1080p"}', '{"rule":"required_audio"}')"#,
|
||||
)
|
||||
.bind(movie_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("media file");
|
||||
|
||||
let files: Vec<serde_json::Value> =
|
||||
reqwest::get(format!("{base}/api/movies/{movie_id}/files"))
|
||||
.await
|
||||
.expect("files")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0]["path"], "/library/dune.mkv");
|
||||
assert_eq!(files[0]["waiver"], "required_audio");
|
||||
assert_eq!(files[0]["probed"]["resolution"], "1080p");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crud_preserves_intent_and_overrides() {
|
||||
let (_dir, _state, base) = application().await;
|
||||
|
||||
@@ -4,6 +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_db::{blacklist, Blacklist};
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest, TvSelector, TvTarget};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
@@ -261,6 +262,7 @@ async fn movie_releases(
|
||||
.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(
|
||||
@@ -291,7 +293,13 @@ async fn movie_releases(
|
||||
match prowlarr.search_indexer(indexer.id, &indexer_request).await {
|
||||
Ok(releases) => {
|
||||
for release in releases {
|
||||
classified.push(classify(release, &policy, &overrides, &original_language)?);
|
||||
classified.push(classify(
|
||||
release,
|
||||
&policy,
|
||||
&overrides,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -350,6 +358,7 @@ async fn episode_releases(
|
||||
.indexers()
|
||||
.await
|
||||
.map_err(|_| ApiError::Unavailable)?;
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
|
||||
let mut classified = Vec::new();
|
||||
for indexer in indexers {
|
||||
@@ -364,6 +373,7 @@ async fn episode_releases(
|
||||
&loaded.policy,
|
||||
&loaded.overrides,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
@@ -446,11 +456,17 @@ fn upstream_error(error: &arr_meta::Error) -> ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
@@ -460,7 +476,11 @@ fn classify(
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size,
|
||||
);
|
||||
let (verdict, rule) = verdict(&evaluation.verdict);
|
||||
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),
|
||||
@@ -686,6 +706,30 @@ mod tests {
|
||||
.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]
|
||||
@@ -796,6 +840,7 @@ mod tests {
|
||||
&policy,
|
||||
&TitleOverrides::default(),
|
||||
&Language::Other("en".into()),
|
||||
&Blacklist::default(),
|
||||
)
|
||||
.expect("classified release");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user