diff --git a/.sqlx/query-0e5facf744fb0766253f352f51dafe5fecb377c6fd6cbeb6c8feb41f8e11ef77.json b/.sqlx/query-0e5facf744fb0766253f352f51dafe5fecb377c6fd6cbeb6c8feb41f8e11ef77.json new file mode 100644 index 0000000..c1ded97 --- /dev/null +++ b/.sqlx/query-0e5facf744fb0766253f352f51dafe5fecb377c6fd6cbeb6c8feb41f8e11ef77.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", name AS \"name!: String\", ntfy_topic AS \"ntfy_topic!: String\" FROM owners ORDER BY name", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "ntfy_topic!: String", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "0e5facf744fb0766253f352f51dafe5fecb377c6fd6cbeb6c8feb41f8e11ef77" +} diff --git a/.sqlx/query-1cecbfbb3c147a5d4b41bde85e34ea34d757f7220d4a734c51dd48f7b2347371.json b/.sqlx/query-1cecbfbb3c147a5d4b41bde85e34ea34d757f7220d4a734c51dd48f7b2347371.json new file mode 100644 index 0000000..4c3a306 --- /dev/null +++ b/.sqlx/query-1cecbfbb3c147a5d4b41bde85e34ea34d757f7220d4a734c51dd48f7b2347371.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO owners (name, ntfy_topic) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "1cecbfbb3c147a5d4b41bde85e34ea34d757f7220d4a734c51dd48f7b2347371" +} diff --git a/.sqlx/query-1d3d03463784627ec59f56a14129ba08b24efbcc69c7f115533c98b309805c71.json b/.sqlx/query-1d3d03463784627ec59f56a14129ba08b24efbcc69c7f115533c98b309805c71.json new file mode 100644 index 0000000..b08ae24 --- /dev/null +++ b/.sqlx/query-1d3d03463784627ec59f56a14129ba08b24efbcc69c7f115533c98b309805c71.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO title_owners (title_kind, title_id, owner_id) VALUES ('movie', ?, ?) ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "1d3d03463784627ec59f56a14129ba08b24efbcc69c7f115533c98b309805c71" +} diff --git a/.sqlx/query-4da547256a5cf365948bdebaeca7353d0ef17c94710a47c5a2fcace1e77d14f7.json b/.sqlx/query-4da547256a5cf365948bdebaeca7353d0ef17c94710a47c5a2fcace1e77d14f7.json new file mode 100644 index 0000000..c299990 --- /dev/null +++ b/.sqlx/query-4da547256a5cf365948bdebaeca7353d0ef17c94710a47c5a2fcace1e77d14f7.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM title_owners WHERE title_kind = 'movie' AND title_id = ? AND owner_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "4da547256a5cf365948bdebaeca7353d0ef17c94710a47c5a2fcace1e77d14f7" +} diff --git a/.sqlx/query-66f37c5521b20548add7978ebd371ea76a77ea85e3cb7c58db93016660b0394c.json b/.sqlx/query-66f37c5521b20548add7978ebd371ea76a77ea85e3cb7c58db93016660b0394c.json new file mode 100644 index 0000000..bd38b91 --- /dev/null +++ b/.sqlx/query-66f37c5521b20548add7978ebd371ea76a77ea85e3cb7c58db93016660b0394c.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "SELECT m.id AS \"id!: i64\", m.tmdb_id AS \"tmdb_id!: i64\", m.title AS \"title!: String\", m.year, m.original_language, m.root_id AS \"root_id!: i64\", m.wanted AS \"wanted!: bool\", m.overrides AS \"overrides!: serde_json::Value\", m.state AS \"state!: String\", m.blocked AS \"blocked!: bool\", m.search_attempts AS \"search_attempts!: i64\", m.last_searched_at FROM movies m JOIN title_owners t ON t.title_kind = 'movie' AND t.title_id = m.id WHERE t.owner_id = ? ORDER BY m.title, m.year, m.id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "root_id!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "wanted!: bool", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "overrides!: serde_json::Value", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "state!: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "blocked!: bool", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "search_attempts!: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 11, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "66f37c5521b20548add7978ebd371ea76a77ea85e3cb7c58db93016660b0394c" +} diff --git a/.sqlx/query-82d8b805d307bcc1a202a5edc73e599a0ef3c8d075c7cce8832d581954167dc0.json b/.sqlx/query-82d8b805d307bcc1a202a5edc73e599a0ef3c8d075c7cce8832d581954167dc0.json new file mode 100644 index 0000000..0875b99 --- /dev/null +++ b/.sqlx/query-82d8b805d307bcc1a202a5edc73e599a0ef3c8d075c7cce8832d581954167dc0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE owners SET name = ?, ntfy_topic = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "82d8b805d307bcc1a202a5edc73e599a0ef3c8d075c7cce8832d581954167dc0" +} diff --git a/.sqlx/query-8ce7b19c7e6524f4d14c92401d4f20c69e83f05f9acb568bc42270c7753b74cd.json b/.sqlx/query-8ce7b19c7e6524f4d14c92401d4f20c69e83f05f9acb568bc42270c7753b74cd.json new file mode 100644 index 0000000..3c6e26e --- /dev/null +++ b/.sqlx/query-8ce7b19c7e6524f4d14c92401d4f20c69e83f05f9acb568bc42270c7753b74cd.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT o.id AS \"id!: i64\", o.name AS \"name!: String\", o.ntfy_topic AS \"ntfy_topic!: String\"\n FROM owners o\n JOIN title_owners t ON t.owner_id = o.id\n WHERE t.title_kind = 'movie' AND t.title_id = ?\n ORDER BY o.name", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "ntfy_topic!: String", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "8ce7b19c7e6524f4d14c92401d4f20c69e83f05f9acb568bc42270c7753b74cd" +} diff --git a/.sqlx/query-bb612df5646dba7ce195aa0a00068df7fa42de0b4322c78cc4cf43a695d16e54.json b/.sqlx/query-bb612df5646dba7ce195aa0a00068df7fa42de0b4322c78cc4cf43a695d16e54.json new file mode 100644 index 0000000..1db00e6 --- /dev/null +++ b/.sqlx/query-bb612df5646dba7ce195aa0a00068df7fa42de0b4322c78cc4cf43a695d16e54.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(SELECT 1 FROM owners WHERE id = ?) AS 'exists!: bool'", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "bb612df5646dba7ce195aa0a00068df7fa42de0b4322c78cc4cf43a695d16e54" +} diff --git a/.sqlx/query-bcec423ef801043a1d1934bb241ffeeabfaa027360e3317100687fbf0334a95d.json b/.sqlx/query-bcec423ef801043a1d1934bb241ffeeabfaa027360e3317100687fbf0334a95d.json new file mode 100644 index 0000000..69f698d --- /dev/null +++ b/.sqlx/query-bcec423ef801043a1d1934bb241ffeeabfaa027360e3317100687fbf0334a95d.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\", name AS \"name!: String\", ntfy_topic AS \"ntfy_topic!: String\" FROM owners WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "ntfy_topic!: String", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "bcec423ef801043a1d1934bb241ffeeabfaa027360e3317100687fbf0334a95d" +} diff --git a/.sqlx/query-d5c820b9b78c9901212359a2d57264382b7eb64bceef2c27d7c1de7c19974477.json b/.sqlx/query-d5c820b9b78c9901212359a2d57264382b7eb64bceef2c27d7c1de7c19974477.json new file mode 100644 index 0000000..80ebf07 --- /dev/null +++ b/.sqlx/query-d5c820b9b78c9901212359a2d57264382b7eb64bceef2c27d7c1de7c19974477.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM owners WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "d5c820b9b78c9901212359a2d57264382b7eb64bceef2c27d7c1de7c19974477" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 6650e2f..062b2b1 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -8,6 +8,7 @@ mod health; mod movies; +mod owners; mod search; mod state; @@ -20,6 +21,7 @@ use utoipa_scalar::{Scalar, Servable}; pub use health::{Check, Health, HealthReport, Status}; pub use movies::{Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, Release, UpdateMovie}; +pub use owners::{CreateOwner, Owner, UpdateOwner}; pub use search::{ClassifiedRelease, SearchResponse}; pub use state::{AppState, MovieCommand, Upstreams, DEFAULT_TMDB_URL}; @@ -42,6 +44,7 @@ pub const DOCS_PATH: &str = "/api/docs"; tags( (name = "system", description = "Service health and metadata"), (name = "movies", description = "Movie library and actions"), + (name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"), (name = "search", description = "Unified title and release search") ), )] @@ -57,6 +60,10 @@ fn api_router() -> OpenApiRouter { .routes(routes!(movies::releases)) .routes(routes!(movies::grab)) .routes(routes!(movies::attention)) + .routes(routes!(movies::list_owners)) + .routes(routes!(movies::tag_owner, movies::untag_owner)) + .routes(routes!(owners::list, owners::create)) + .routes(routes!(owners::get, owners::update, owners::delete)) .routes(routes!(search::search)) .routes(routes!(search::releases)) } diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 2c352dc..3a1fad2 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -1,10 +1,11 @@ -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::Json; use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; +use crate::owners::Owner; use crate::state::{AppState, MovieCommand}; #[derive(Debug, Clone, Serialize, ToSchema)] @@ -84,6 +85,7 @@ pub struct ErrorBody { #[derive(Debug)] pub enum ApiError { NotFound, + OwnerNotFound, Conflict(String), Invalid(String), Unavailable, @@ -94,6 +96,7 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, error) = match self { Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string()), + Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()), Self::Conflict(error) => (StatusCode::CONFLICT, error), Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error), Self::Unavailable => ( @@ -150,7 +153,7 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> { Ok(()) } -fn pool(state: &AppState) -> Result<&sqlx::SqlitePool, ApiError> { +pub(crate) fn pool(state: &AppState) -> Result<&sqlx::SqlitePool, ApiError> { state .database() .map(arr_db::Db::pool) @@ -163,18 +166,34 @@ async fn load_movie(state: &AppState, id: i64) -> Result { .await?) } +#[derive(Debug, Deserialize, IntoParams)] +pub struct ListMoviesQuery { + /// Restrict to movies tagged with this owner (DESIGN.md §4.3). + pub owner_id: Option, +} + #[utoipa::path( get, path = "/api/movies", tag = "movies", + params(ListMoviesQuery), responses( (status = 200, body = [Movie]), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] -pub async fn list(State(state): State) -> Result>, ApiError> { - let movies = 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 FROM movies ORDER BY title, year, id"#) - .fetch_all(pool(&state)?) - .await?; +pub async fn list( + State(state): State, + Query(query): Query, +) -> Result>, ApiError> { + let movies = if let Some(owner_id) = query.owner_id { + sqlx::query_as!(Movie, r#"SELECT m.id AS "id!: i64", m.tmdb_id AS "tmdb_id!: i64", m.title AS "title!: String", m.year, m.original_language, m.root_id AS "root_id!: i64", m.wanted AS "wanted!: bool", m.overrides AS "overrides!: serde_json::Value", m.state AS "state!: String", m.blocked AS "blocked!: bool", m.search_attempts AS "search_attempts!: i64", m.last_searched_at FROM movies m JOIN title_owners t ON t.title_kind = 'movie' AND t.title_id = m.id WHERE t.owner_id = ? ORDER BY m.title, m.year, m.id"#, owner_id) + .fetch_all(pool(&state)?) + .await? + } 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 FROM movies ORDER BY title, year, id"#) + .fetch_all(pool(&state)?) + .await? + }; Ok(Json(movies)) } @@ -407,6 +426,93 @@ pub async fn attention(State(state): State) -> Result, + Path(id): Path, +) -> Result>, ApiError> { + load_movie(&state, id).await?; + let owners = sqlx::query_as!( + Owner, + r#"SELECT o.id AS "id!: i64", o.name AS "name!: String", o.ntfy_topic AS "ntfy_topic!: String" + FROM owners o + JOIN title_owners t ON t.owner_id = o.id + WHERE t.title_kind = 'movie' AND t.title_id = ? + ORDER BY o.name"#, + id + ) + .fetch_all(pool(&state)?) + .await?; + Ok(Json(owners)) +} + +#[utoipa::path( + put, path = "/api/movies/{movie_id}/owners/{owner_id}", tag = "movies", + params(("movie_id" = i64, Path), ("owner_id" = i64, Path)), + responses( + (status = 204), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn tag_owner( + State(state): State, + Path((movie_id, owner_id)): Path<(i64, i64)>, +) -> Result { + load_movie(&state, movie_id).await?; + let owner_exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM owners WHERE id = ?) AS 'exists!: bool'", + owner_id + ) + .fetch_one(pool(&state)?) + .await?; + if !owner_exists { + return Err(ApiError::OwnerNotFound); + } + sqlx::query!( + "INSERT INTO title_owners (title_kind, title_id, owner_id) VALUES ('movie', ?, ?) ON CONFLICT DO NOTHING", + movie_id, owner_id + ) + .execute(pool(&state)?) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + delete, path = "/api/movies/{movie_id}/owners/{owner_id}", tag = "movies", + params(("movie_id" = i64, Path), ("owner_id" = i64, Path)), + responses( + (status = 204), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn untag_owner( + State(state): State, + Path((movie_id, owner_id)): Path<(i64, i64)>, +) -> Result { + load_movie(&state, movie_id).await?; + sqlx::query!( + "DELETE FROM title_owners WHERE title_kind = 'movie' AND title_id = ? AND owner_id = ?", + movie_id, + owner_id + ) + .execute(pool(&state)?) + .await?; + Ok(StatusCode::NO_CONTENT) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/arr-api/src/owners.rs b/crates/arr-api/src/owners.rs new file mode 100644 index 0000000..a99434f --- /dev/null +++ b/crates/arr-api/src/owners.rs @@ -0,0 +1,362 @@ +//! Owner tags: a many-to-many label on titles that drives filtered views and +//! notification routing (`DESIGN.md` §4.3). Movies only for now — series +//! arrives with TV (§13 phase 6); the join table already carries `title_kind` +//! so nothing here changes shape when that lands. + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::movies::{pool, ApiError, ErrorBody}; +use crate::state::AppState; + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct Owner { + pub id: i64, + pub name: String, + pub ntfy_topic: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateOwner { + pub name: String, + pub ntfy_topic: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateOwner { + pub name: Option, + pub ntfy_topic: Option, +} + +async fn load_owner(state: &AppState, id: i64) -> Result { + sqlx::query_as!( + Owner, + r#"SELECT id AS "id!: i64", name AS "name!: String", ntfy_topic AS "ntfy_topic!: String" FROM owners WHERE id = ?"#, + id + ) + .fetch_optional(pool(state)?) + .await? + .ok_or(ApiError::OwnerNotFound) +} + +#[utoipa::path( + get, path = "/api/owners", tag = "owners", + responses( + (status = 200, body = [Owner]), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn list(State(state): State) -> Result>, ApiError> { + let owners = sqlx::query_as!( + Owner, + r#"SELECT id AS "id!: i64", name AS "name!: String", ntfy_topic AS "ntfy_topic!: String" FROM owners ORDER BY name"# + ) + .fetch_all(pool(&state)?) + .await?; + Ok(Json(owners)) +} + +#[utoipa::path( + post, path = "/api/owners", tag = "owners", request_body = CreateOwner, + responses( + (status = 201, body = Owner), + (status = 409, body = ErrorBody), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn create( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), ApiError> { + let name = input.name.trim(); + let ntfy_topic = input.ntfy_topic.trim(); + if name.is_empty() || ntfy_topic.is_empty() { + return Err(ApiError::Invalid("name and ntfy_topic are required".into())); + } + let result = sqlx::query!( + "INSERT INTO owners (name, ntfy_topic) VALUES (?, ?)", + name, + ntfy_topic + ) + .execute(pool(&state)?) + .await?; + Ok(( + StatusCode::CREATED, + Json(load_owner(&state, result.last_insert_rowid()).await?), + )) +} + +#[utoipa::path( + get, path = "/api/owners/{owner_id}", tag = "owners", + params(("owner_id" = i64, Path, description = "Owner row id")), + responses( + (status = 200, body = Owner), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn get( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + Ok(Json(load_owner(&state, id).await?)) +} + +#[utoipa::path( + patch, path = "/api/owners/{owner_id}", tag = "owners", request_body = UpdateOwner, + params(("owner_id" = i64, Path, description = "Owner row id")), + responses( + (status = 200, body = Owner), + (status = 404, body = ErrorBody), + (status = 409, body = ErrorBody), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn update( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, ApiError> { + let current = load_owner(&state, id).await?; + let name = input.name.unwrap_or(current.name); + let ntfy_topic = input.ntfy_topic.unwrap_or(current.ntfy_topic); + let name = name.trim(); + let ntfy_topic = ntfy_topic.trim(); + if name.is_empty() || ntfy_topic.is_empty() { + return Err(ApiError::Invalid("name and ntfy_topic are required".into())); + } + sqlx::query!( + "UPDATE owners SET name = ?, ntfy_topic = ? WHERE id = ?", + name, + ntfy_topic, + id + ) + .execute(pool(&state)?) + .await?; + Ok(Json(load_owner(&state, id).await?)) +} + +#[utoipa::path( + delete, path = "/api/owners/{owner_id}", tag = "owners", + params(("owner_id" = i64, Path, description = "Owner row id")), + responses( + (status = 204), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn delete( + State(state): State, + Path(id): Path, +) -> Result { + let result = sqlx::query!("DELETE FROM owners WHERE id = ?", id) + .execute(pool(&state)?) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::OwnerNotFound); + } + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{router, Upstreams}; + + async fn application() -> (tempfile::TempDir, AppState, String) { + let dir = tempfile::tempdir().expect("tempdir"); + let database = arr_db::Db::connect(dir.path().join("arr.db")) + .await + .expect("connect database"); + database.migrate().await.expect("migrate database"); + let state = AppState::new(Upstreams::new( + "http://127.0.0.1:1".into(), + "http://127.0.0.1:1".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}")) + } + + async fn create_owner(base: &str, name: &str) -> serde_json::Value { + let response = reqwest::Client::new() + .post(format!("{base}/api/owners")) + .json(&serde_json::json!({ "name": name, "ntfy_topic": format!("topic-{name}") })) + .send() + .await + .expect("create owner"); + assert_eq!(response.status(), StatusCode::CREATED); + response.json().await.expect("owner json") + } + + async fn create_movie(base: &str, tmdb_id: i64, root_id: i64) -> serde_json::Value { + let response = reqwest::Client::new() + .post(format!("{base}/api/movies")) + .json(&serde_json::json!({ + "tmdb_id": tmdb_id, "title": "Dune Part Two", "year": 2024, + "original_language": "en", "root_id": root_id, + })) + .send() + .await + .expect("create movie"); + assert_eq!(response.status(), StatusCode::CREATED); + response.json().await.expect("movie json") + } + + #[tokio::test] + async fn crud_round_trips_an_owner() { + let (_dir, _state, base) = application().await; + let owner = create_owner(&base, "miguel").await; + let id = owner["id"].as_i64().expect("id"); + + let updated: serde_json::Value = reqwest::Client::new() + .patch(format!("{base}/api/owners/{id}")) + .json(&serde_json::json!({ "ntfy_topic": "new-topic" })) + .send() + .await + .expect("update") + .json() + .await + .expect("updated json"); + assert_eq!(updated["name"], "miguel"); + assert_eq!(updated["ntfy_topic"], "new-topic"); + + let deleted = reqwest::Client::new() + .delete(format!("{base}/api/owners/{id}")) + .send() + .await + .expect("delete"); + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + assert_eq!( + reqwest::get(format!("{base}/api/owners/{id}")) + .await + .expect("get deleted") + .status(), + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn a_movie_with_two_owners_appears_in_both_filtered_views_but_once_on_disk() { + let (_dir, state, base) = application().await; + let pool = state.database().expect("database").pool(); + let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE audience = 'main'") + .fetch_one(pool) + .await + .expect("main root"); + + let movie = create_movie(&base, 693_134, root_id).await; + let movie_id = movie["id"].as_i64().expect("id"); + let alice = create_owner(&base, "alice").await; + let bob = create_owner(&base, "bob").await; + let alice_id = alice["id"].as_i64().expect("id"); + let bob_id = bob["id"].as_i64().expect("id"); + + for owner_id in [alice_id, bob_id] { + let response = reqwest::Client::new() + .put(format!("{base}/api/movies/{movie_id}/owners/{owner_id}")) + .send() + .await + .expect("tag"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + for owner_id in [alice_id, bob_id] { + let filtered: Vec = + reqwest::get(format!("{base}/api/movies?owner_id={owner_id}")) + .await + .expect("filtered list") + .json() + .await + .expect("filtered json"); + assert_eq!(filtered.len(), 1, "owner {owner_id}"); + assert_eq!(filtered[0]["id"], movie_id); + } + + let on_disk: i64 = sqlx::query_scalar("SELECT count(*) FROM movies WHERE id = ?") + .bind(movie_id) + .fetch_one(pool) + .await + .expect("count"); + assert_eq!(on_disk, 1, "the title exists once regardless of tags"); + + let tags: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/owners")) + .await + .expect("owners of movie") + .json() + .await + .expect("owners json"); + assert_eq!(tags.len(), 2); + + let response = reqwest::Client::new() + .delete(format!("{base}/api/movies/{movie_id}/owners/{alice_id}")) + .send() + .await + .expect("untag"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let alice_view: Vec = + reqwest::get(format!("{base}/api/movies?owner_id={alice_id}")) + .await + .expect("alice view") + .json() + .await + .expect("alice json"); + assert!(alice_view.is_empty()); + + let bob_view: Vec = + reqwest::get(format!("{base}/api/movies?owner_id={bob_id}")) + .await + .expect("bob view") + .json() + .await + .expect("bob json"); + assert_eq!(bob_view.len(), 1); + } + + #[tokio::test] + async fn tagging_a_missing_movie_or_owner_is_not_found() { + let (_dir, state, base) = application().await; + let owner = create_owner(&base, "alice").await; + let owner_id = owner["id"].as_i64().expect("id"); + + let response = reqwest::Client::new() + .put(format!("{base}/api/movies/999/owners/{owner_id}")) + .send() + .await + .expect("tag missing movie"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let pool = state.database().expect("database").pool(); + let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE audience = 'main'") + .fetch_one(pool) + .await + .expect("main root"); + let movie = create_movie(&base, 693_134, root_id).await; + let movie_id = movie["id"].as_i64().expect("id"); + + let response = reqwest::Client::new() + .put(format!("{base}/api/movies/{movie_id}/owners/999")) + .send() + .await + .expect("tag missing owner"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } +} diff --git a/crates/arr-parse/tests/corpus.rs b/crates/arr-parse/tests/corpus.rs new file mode 100644 index 0000000..0ae39e7 --- /dev/null +++ b/crates/arr-parse/tests/corpus.rs @@ -0,0 +1,758 @@ +//! Fixture corpus for `arr-parse` (issue #9): real release names captured +//! from a manual Torznab search against several Prowlarr indexers (1337x, +//! YTS, The Pirate Bay, Portugas), with expected parses pinned from the +//! parser's own (already-tested) output. +//! +//! Deliberately covers every resolution and source tier from `DESIGN.md` +//! §5.5, DV-tagged and DV-untagged 4K releases, multi-audio releases, the +//! pt-BR/pt-PT markers from §5.2, and real-world malformed names that +//! must parse partially rather than fail. + +use arr_parse::{parse, Codec, Edition, HdrMarker, LanguageMarker, NameClaims, Resolution, Source}; +use serde as _; + +struct Case { + name: String, + want: NameClaims, +} + +#[allow(clippy::unnecessary_wraps)] +fn s(v: &str) -> Option { + Some(v.to_string()) +} + +/// Every resolution and every source tier appears at least once here, +/// pulled from a broad search rather than picked to fit. +#[allow(clippy::too_many_lines)] +fn matrix_cases() -> Vec { + vec![ + Case { + name: "Twisters.2024.1080p.BluRay.x264-HiP_EniaHD".into(), + want: NameClaims { + title: s("Twisters"), + year: Some(2024), + resolution: Some(Resolution::P1080), + source: Some(Source::BluRay), + ..NameClaims::default() + }, + }, + Case { + name: "Thine Ears Shall Bleed (2024) 720p WEBRip x264 -YTS".into(), + want: NameClaims { + title: s("Thine Ears Shall Bleed"), + year: Some(2024), + resolution: Some(Resolution::P720), + source: Some(Source::WebRip), + codec: Some(Codec::X264), + ..NameClaims::default() + }, + }, + Case { + name: "Ludwig 2024 S02E01 1080p iP WEB-DL AAC2 0 H 264-RAWR".into(), + want: NameClaims { + title: s("Ludwig"), + year: Some(2024), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + codec: Some(Codec::X264), + group: s("RAWR"), + ..NameClaims::default() + }, + }, + Case { + name: "The Rookie S08E14 720p HDTV x264-SYNCOPY".into(), + want: NameClaims { + title: s("The Rookie"), + resolution: Some(Resolution::P720), + source: Some(Source::Hdtv), + codec: Some(Codec::X264), + group: s("SYNCOPY"), + ..NameClaims::default() + }, + }, + Case { + name: "The King of Queens S03E22 720p BluRay x264-CtrlHD".into(), + want: NameClaims { + title: s("The King of Queens"), + resolution: Some(Resolution::P720), + source: Some(Source::BluRay), + codec: Some(Codec::X264), + group: s("CtrlHD"), + ..NameClaims::default() + }, + }, + Case { + name: "Scream.2022.1080p.Bluray.REMUX.AVC.DTS-HD.MA.7.1-4K4U".into(), + want: NameClaims { + title: s("Scream"), + year: Some(2022), + resolution: Some(Resolution::P1080), + source: Some(Source::Remux), + codec: Some(Codec::X264), + group: s("4K4U"), + ..NameClaims::default() + }, + }, + Case { + name: "28 Years Later 2025 1080p CAM x264-RGB".into(), + want: NameClaims { + title: s("28 Years Later"), + year: Some(2025), + resolution: Some(Resolution::P1080), + source: Some(Source::Cam), + codec: Some(Codec::X264), + group: s("RGB"), + ..NameClaims::default() + }, + }, + Case { + name: "A.Minecraft.Movie.2025.1080p.TeleSync.X264.COLLECTiVE".into(), + want: NameClaims { + title: s("A Minecraft Movie"), + year: Some(2025), + resolution: Some(Resolution::P1080), + source: Some(Source::Telesync), + codec: Some(Codec::X264), + ..NameClaims::default() + }, + }, + Case { + name: "Alita Battle Angel 2019 720p HDTC x264 AC3-ETRG".into(), + want: NameClaims { + title: s("Alita Battle Angel"), + year: Some(2019), + resolution: Some(Resolution::P720), + source: Some(Source::Telecine), + codec: Some(Codec::X264), + group: s("ETRG"), + ..NameClaims::default() + }, + }, + Case { + name: "1917.2019.1080p.SCREENER.DD.2.0.HEVC-DDR[EtHD]".into(), + want: NameClaims { + title: s("1917"), + year: Some(2019), + resolution: Some(Resolution::P1080), + source: Some(Source::Screener), + codec: Some(Codec::X265), + group: s("DDR"), + ..NameClaims::default() + }, + }, + Case { + name: "A.Hidden.Life.2019.DVDSCR.x264-TOPKEK".into(), + want: NameClaims { + title: s("A Hidden Life"), + year: Some(2019), + source: Some(Source::Screener), + codec: Some(Codec::X264), + group: s("TOPKEK"), + ..NameClaims::default() + }, + }, + Case { + name: "Taina.A.Origem.2014.DVDRip.XviD.Nacional.SenhoreSTorrenT".into(), + want: NameClaims { + title: s("Taina A Origem"), + year: Some(2014), + source: Some(Source::Dvd), + codec: Some(Codec::Xvid), + languages: vec![LanguageMarker::PtBr], + ..NameClaims::default() + }, + }, + Case { + // Portugas appends "Portuguese PT-PT" after the real release + // name; both tokens are genuine language markers to this crate. + name: "Sinais.de.Fogo.1995.PAL.DVD.REMUX.MPEG-2.DD.5.1-SANTANA Portuguese PT-PT".into(), + want: NameClaims { + title: s("Sinais de Fogo"), + year: Some(1995), + source: Some(Source::Remux), + languages: vec![LanguageMarker::Portuguese, LanguageMarker::PtPt], + ..NameClaims::default() + }, + }, + Case { + name: "Papamobile (2025) 1080p BRRip 5.1 x264 -YTS".into(), + want: NameClaims { + title: s("Papamobile"), + year: Some(2025), + resolution: Some(Resolution::P1080), + source: Some(Source::BluRay), + codec: Some(Codec::X264), + ..NameClaims::default() + }, + }, + Case { + name: "La caméra de Claire (2017) 720p BRRip x264 -YTS".into(), + want: NameClaims { + title: s("La caméra de Claire"), + year: Some(2017), + resolution: Some(Resolution::P720), + source: Some(Source::BluRay), + codec: Some(Codec::X264), + ..NameClaims::default() + }, + }, + Case { + name: "Vampire.Hunter.D.1985.1080p.BDRip.Dual.Audio.10bits.x265-Rapta".into(), + want: NameClaims { + title: s("Vampire Hunter D"), + year: Some(1985), + resolution: Some(Resolution::P1080), + source: Some(Source::BluRay), + codec: Some(Codec::X265), + languages: vec![LanguageMarker::Dual], + group: s("Rapta"), + ..NameClaims::default() + }, + }, + Case { + name: "Dune.2021.2160p.HMAX.WEB-DL.DDP5.1.Atmos.HDR.HEVC-EVO[TGx]".into(), + want: NameClaims { + title: s("Dune"), + year: Some(2021), + resolution: Some(Resolution::P2160), + source: Some(Source::WebDl), + hdr: vec![HdrMarker::Hdr], + ..NameClaims::default() + }, + }, + Case { + name: "National.Treasure:.Edge.of.History.S01.2160p.WEB-DL.DD+.5.1.Atmos.H.265-NTb" + .into(), + want: NameClaims { + title: s("National Treasure: Edge of History"), + resolution: Some(Resolution::P2160), + source: Some(Source::WebDl), + codec: Some(Codec::X265), + group: s("NTb"), + ..NameClaims::default() + }, + }, + Case { + // DV-untagged 4K: no HDR marker at all despite being a real UHD + // release. + name: "All.Of.Us.Strangers.2023.2160p.4K.WEB.x265.10bit.AAC5.1 [YTS.MX] [88]".into(), + want: NameClaims { + title: s("All Of Us Strangers"), + year: Some(2023), + resolution: Some(Resolution::P2160), + source: Some(Source::WebDl), + codec: Some(Codec::X265), + group: s("YTS.MX"), + ..NameClaims::default() + }, + }, + Case { + name: "Alien.1979.THEATRICAL.PROPER.2160p.BluRay.REMUX.HEVC.DTS-HD.MA.5.1-FGT".into(), + want: NameClaims { + title: s("Alien"), + year: Some(1979), + resolution: Some(Resolution::P2160), + source: Some(Source::Remux), + codec: Some(Codec::X265), + editions: vec![Edition::Theatrical], + group: s("FGT"), + ..NameClaims::default() + }, + }, + Case { + name: "WWE Friday Night Smackdown 2026 08 21 SatFeed 480p HDTV H264 Star [TJET]".into(), + want: NameClaims { + title: s("WWE Friday Night Smackdown"), + year: Some(2026), + resolution: Some(Resolution::P480), + source: Some(Source::Hdtv), + codec: Some(Codec::X264), + group: s("TJET"), + ..NameClaims::default() + }, + }, + Case { + // No source token at all beyond a bare resolution. + name: "Court Cam S08E37 480p x264-mSD".into(), + want: NameClaims { + title: s("Court Cam"), + resolution: Some(Resolution::P480), + codec: Some(Codec::X264), + group: s("mSD"), + ..NameClaims::default() + }, + }, + Case { + // `AUHDTV` is a real tag this crate doesn't recognise — source + // stays unclaimed rather than guessed. + name: "A League Of Their Own UK Road Trip Southeast Asia S01E01 INTERNAL 2160p AUHDTV x265-FaiLED".into(), + want: NameClaims { + title: s("A League Of Their Own UK Road Trip Southeast Asia"), + resolution: Some(Resolution::P2160), + codec: Some(Codec::X265), + group: s("FaiLED"), + ..NameClaims::default() + }, + }, + Case { + name: "The Tale of The Princess Kaguya 2013 BRRip DUAL AUDIO".into(), + want: NameClaims { + title: s("The Tale of The Princess Kaguya"), + year: Some(2013), + source: Some(Source::BluRay), + languages: vec![LanguageMarker::Dual], + ..NameClaims::default() + }, + }, + Case { + name: "Watchmen: Chapter II.2024.2160p.HDR10.BRRip.OPUS5.1.AV1.BluBit".into(), + want: NameClaims { + title: s("Watchmen: Chapter II"), + year: Some(2024), + resolution: Some(Resolution::P2160), + source: Some(Source::BluRay), + codec: Some(Codec::Av1), + hdr: vec![HdrMarker::Hdr10], + ..NameClaims::default() + }, + }, + ] +} + +/// The pt-BR / pt-PT markers from `DESIGN.md` §5.2, pulled mostly from the +/// Portugas indexer. This crate only reports what the name claims; +/// resolving pt-BR vs. pt-PT ambiguity is the policy engine's job. +#[allow(clippy::too_many_lines)] +fn pt_br_cases() -> Vec { + vec![ + Case { + name: "007 Contra Spectre 2015 1080p BluRay DUAL [Dublado] LAPUMiA".into(), + want: NameClaims { + title: s("007 Contra Spectre"), + year: Some(2015), + resolution: Some(Resolution::P1080), + source: Some(Source::BluRay), + languages: vec![LanguageMarker::Dual, LanguageMarker::PtBr], + ..NameClaims::default() + }, + }, + Case { + name: "Afonso.Padilha.Geracoes.2023.1080p.WEB-DL.NACIONAL.5.1-COMANDOPLAY".into(), + want: NameClaims { + title: s("Afonso Padilha Geracoes"), + year: Some(2023), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + languages: vec![LanguageMarker::PtBr], + group: s("COMANDOPLAY"), + ..NameClaims::default() + }, + }, + Case { + // `HDRip` isn't in this crate's source vocabulary — real + // releases do claim tags outside it, and source stays `None`. + name: "A.Culpa.E.Das.Estrelas.2014.DUBLADO.HDRip.XViD-AFTP".into(), + want: NameClaims { + title: s("A Culpa E Das Estrelas"), + year: Some(2014), + codec: Some(Codec::Xvid), + languages: vec![LanguageMarker::PtBr], + group: s("AFTP"), + ..NameClaims::default() + }, + }, + Case { + name: "Marília Mendonça DVD Ao Vivo (2015)".into(), + want: NameClaims { + title: s("Marília Mendonça"), + year: Some(2015), + source: Some(Source::Dvd), + ..NameClaims::default() + }, + }, + Case { + name: "Princesinha.Sofia.Era.Uma.Vez.DVDRip.XviD.MP3.Dublado.PT-BR.avi".into(), + want: NameClaims { + title: s("Princesinha Sofia Era Uma Vez"), + source: Some(Source::Dvd), + codec: Some(Codec::Xvid), + languages: vec![LanguageMarker::PtBr], + ..NameClaims::default() + }, + }, + Case { + name: "A.África.de.José.de.Guimarães.2013.576p.FLMN.WEB-DL.AAC.2.0.H.264-1v0live.mkv Portuguese PT-PT".into(), + want: NameClaims { + title: s("A África de José de Guimarães"), + year: Some(2013), + resolution: Some(Resolution::P576), + source: Some(Source::WebDl), + languages: vec![LanguageMarker::Portuguese, LanguageMarker::PtPt], + ..NameClaims::default() + }, + }, + Case { + name: "Aleixo.PSI.2017.S01.1080i.SICR.HDTV.AAC.2.0.H264-MTRX Portuguese PT-PT".into(), + want: NameClaims { + title: s("Aleixo PSI"), + year: Some(2017), + resolution: Some(Resolution::P1080), + source: Some(Source::Hdtv), + languages: vec![LanguageMarker::Portuguese, LanguageMarker::PtPt], + ..NameClaims::default() + }, + }, + Case { + name: "Camilo.o.Pendura.S01.576p.WEB-DL.AAC.2.0.H.264-1v0live Portuguese PT-PT".into(), + want: NameClaims { + title: s("Camilo o Pendura"), + resolution: Some(Resolution::P576), + source: Some(Source::WebDl), + languages: vec![LanguageMarker::Portuguese, LanguageMarker::PtPt], + ..NameClaims::default() + }, + }, + Case { + name: "Espiao.Nacionalizado.Nosso.1975.576p.TVRip.x264.AAC.2.0-CRVLX Portuguese PT-PT".into(), + want: NameClaims { + title: s("Espiao Nacionalizado Nosso"), + year: Some(1975), + resolution: Some(Resolution::P576), + codec: Some(Codec::X264), + languages: vec![LanguageMarker::Portuguese, LanguageMarker::PtPt], + ..NameClaims::default() + }, + }, + Case { + name: "GOAT.2026.1080p.BluRay.AV1.10bit.HDR.MULTi.Opus-Maghuro.mkv Portuguese PT-PT".into(), + want: NameClaims { + title: s("GOAT"), + year: Some(2026), + resolution: Some(Resolution::P1080), + source: Some(Source::BluRay), + codec: Some(Codec::Av1), + hdr: vec![HdrMarker::Hdr], + languages: vec![ + LanguageMarker::Multi, + LanguageMarker::Portuguese, + LanguageMarker::PtPt, + ], + ..NameClaims::default() + }, + }, + Case { + name: "Lion.Of.Oz.2000.HYBRID.576p.DVDRip.x264.MULTI.DD.2.0-CRVLX Portuguese PT-PT".into(), + want: NameClaims { + title: s("Lion Of Oz"), + year: Some(2000), + resolution: Some(Resolution::P576), + source: Some(Source::Dvd), + codec: Some(Codec::X264), + languages: vec![ + LanguageMarker::Multi, + LanguageMarker::Portuguese, + LanguageMarker::PtPt, + ], + ..NameClaims::default() + }, + }, + ] +} + +/// Multi-audio releases: `MULTi`/`MULTI` and `DUAL`/`DUAL AUDIO` in the +/// wild, including combinations with explicit language names. +fn multi_audio_cases() -> Vec { + vec![ + Case { + name: "Haikyu S04E18 MULTi 480p x264-mSD".into(), + want: NameClaims { + title: s("Haikyu"), + resolution: Some(Resolution::P480), + codec: Some(Codec::X264), + languages: vec![LanguageMarker::Multi], + group: s("mSD"), + ..NameClaims::default() + }, + }, + Case { + name: "113 2025 S01E01 MULTI 1080p WEB X264-HiggsBoson".into(), + want: NameClaims { + title: s("113"), + year: Some(2025), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + codec: Some(Codec::X264), + languages: vec![LanguageMarker::Multi], + group: s("HiggsBoson"), + ..NameClaims::default() + }, + }, + Case { + name: "Absolon 2003 720p WEB-DL x264 Eng Subs Dual Audio Hindi DDP 2.0 English DDP 2.0 Exclusive By Dr-STAR".into(), + want: NameClaims { + title: s("Absolon"), + year: Some(2003), + resolution: Some(Resolution::P720), + source: Some(Source::WebDl), + codec: Some(Codec::X264), + languages: vec![LanguageMarker::English, LanguageMarker::Dual], + group: s("STAR"), + ..NameClaims::default() + }, + }, + Case { + name: "Death Note Relight 2 L's Successors (2008) (1080p BluRay x265 10 bit Dual Audio) [WeSLeY]".into(), + want: NameClaims { + title: s("Death Note Relight 2 L's Successors"), + year: Some(2008), + resolution: Some(Resolution::P1080), + source: Some(Source::BluRay), + codec: Some(Codec::X265), + languages: vec![LanguageMarker::Dual], + group: s("WeSLeY"), + ..NameClaims::default() + }, + }, + Case { + name: "A.Shop.for.Killers.S02.2160p.DSNP.WEB-DL.DUAL.DD+.5.1.DV.HDR10+.H.265-Kitsune".into(), + want: NameClaims { + title: s("A Shop for Killers"), + resolution: Some(Resolution::P2160), + source: Some(Source::WebDl), + codec: Some(Codec::X265), + hdr: vec![HdrMarker::DolbyVision, HdrMarker::Hdr10Plus], + languages: vec![LanguageMarker::Dual], + group: s("Kitsune"), + ..NameClaims::default() + }, + }, + Case { + name: "Avengers.Age.of.Ultron.2015.Multi.UHD.2160p.Blu-Ray.x265.HDR.ATM".into(), + want: NameClaims { + title: s("Avengers Age of Ultron"), + year: Some(2015), + resolution: Some(Resolution::P2160), + source: Some(Source::BluRay), + codec: Some(Codec::X265), + hdr: vec![HdrMarker::Hdr], + languages: vec![LanguageMarker::Multi], + ..NameClaims::default() + }, + }, + ] +} + +/// DV-tagged 4K releases from real UHD uploads (`DESIGN.md` §5.3): every +/// HDR marker shape appearing alongside `DV`, plus AV1/HEVC codec +/// variety and an `IMAX` edition. +#[allow(clippy::too_many_lines)] +fn dv_hdr_cases() -> Vec { + vec![ + Case { + name: "28.Years.Later.The.Bone.Temple.2026.2160p.WEB-DL.DV.HDR10+.DDP5.1.Atmos.MP4-BTM".into(), + want: NameClaims { + title: s("28 Years Later The Bone Temple"), + year: Some(2026), + resolution: Some(Resolution::P2160), + source: Some(Source::WebDl), + hdr: vec![HdrMarker::DolbyVision, HdrMarker::Hdr10Plus], + group: s("BTM"), + ..NameClaims::default() + }, + }, + Case { + // `BRRemux` isn't in this crate's vocabulary; source stays + // unclaimed rather than guessed at. + name: "Ace Ventura - When Nature Calls (1995) 2160p BRRemux h265 DV HDR10 5.1 -3G_Rips".into(), + want: NameClaims { + title: s("Ace Ventura - When Nature Calls"), + year: Some(1995), + resolution: Some(Resolution::P2160), + codec: Some(Codec::X265), + hdr: vec![HdrMarker::DolbyVision, HdrMarker::Hdr10], + ..NameClaims::default() + }, + }, + Case { + name: "After Hours 1985 UHD BluRay 2160p HDR10 DV HEVC FLAC 1.0 x265-E".into(), + want: NameClaims { + title: s("After Hours"), + year: Some(1985), + resolution: Some(Resolution::P2160), + source: Some(Source::BluRay), + codec: Some(Codec::X265), + hdr: vec![HdrMarker::Hdr10, HdrMarker::DolbyVision], + group: s("E"), + ..NameClaims::default() + }, + }, + Case { + name: "Akira.1988.UHD.BluRay.2160p.TrueHD.Atmos.7.1.DV.HEVC.HYBRID.REMUX-FraMeSToR".into(), + want: NameClaims { + title: s("Akira"), + year: Some(1988), + resolution: Some(Resolution::P2160), + source: Some(Source::Remux), + codec: Some(Codec::X265), + hdr: vec![HdrMarker::DolbyVision], + group: s("FraMeSToR"), + ..NameClaims::default() + }, + }, + Case { + name: "Aquaman (2018) IMAX 2160p 4K UHD HDR10 DV Blu ray REMUX Dual Audio [Hindi + English] ESub ~ RemuxDoc".into(), + want: NameClaims { + title: s("Aquaman"), + year: Some(2018), + resolution: Some(Resolution::P2160), + source: Some(Source::Remux), + hdr: vec![HdrMarker::Hdr10, HdrMarker::DolbyVision], + languages: vec![LanguageMarker::Dual, LanguageMarker::English], + editions: vec![Edition::Imax], + ..NameClaims::default() + }, + }, + Case { + name: "Abraham's Boys 2025 UHD BluRay 2160p HDR10+ HEVC DTS HD MA 5.1 x265-E".into(), + want: NameClaims { + title: s("Abraham's Boys"), + year: Some(2025), + resolution: Some(Resolution::P2160), + source: Some(Source::BluRay), + codec: Some(Codec::X265), + hdr: vec![HdrMarker::Hdr10Plus], + group: s("E"), + ..NameClaims::default() + }, + }, + Case { + name: "Sweeney.Todd.The.Demon.Barber.of.Fleet.Street.2007.2160p.UHD.BluRay.Opus.5.1.HDR.AV1-Maghuro.mkv".into(), + want: NameClaims { + title: s("Sweeney Todd The Demon Barber of Fleet Street"), + year: Some(2007), + resolution: Some(Resolution::P2160), + source: Some(Source::BluRay), + codec: Some(Codec::Av1), + hdr: vec![HdrMarker::Hdr], + group: s("Maghuro"), + ..NameClaims::default() + }, + }, + Case { + name: "Come.Play.2020.2160p.WEB-DL.HDR.DTS.5.1.H.265-4K4U".into(), + want: NameClaims { + title: s("Come Play"), + year: Some(2020), + resolution: Some(Resolution::P2160), + source: Some(Source::WebDl), + codec: Some(Codec::X265), + hdr: vec![HdrMarker::Hdr], + group: s("4K4U"), + ..NameClaims::default() + }, + }, + ] +} + +/// Real names that are simply malformed — stray quoting, an HTML entity +/// leaking through, non-media metadata, an escaped apostrophe, multiple +/// spaces. Every one must still parse *partially* rather than fail +/// (`arr_parse::parse` never panics or errors). +fn malformed_cases() -> Vec { + vec![ + Case { + name: "2.Coelhos.2012.BRRip.720p.x264-VTM_SL '(Nacional)'".into(), + want: NameClaims { + title: s("2 Coelhos"), + year: Some(2012), + resolution: Some(Resolution::P720), + source: Some(Source::BluRay), + languages: vec![LanguageMarker::PtBr], + ..NameClaims::default() + }, + }, + Case { + name: "A Busca (2013) BDRip bluray 720p '' Nacional -derew".into(), + want: NameClaims { + title: s("A Busca"), + year: Some(2013), + resolution: Some(Resolution::P720), + source: Some(Source::BluRay), + languages: vec![LanguageMarker::PtBr], + ..NameClaims::default() + }, + }, + Case { + // An unescaped `&` HTML entity, straight from the indexer. + name: + "Bill & Ted's Bogus Journey 1991 UHD BluRay 2160p HDR10 DV HEVC LPCM 2.0 x265-E" + .into(), + want: NameClaims { + title: s("Bill & Ted's Bogus Journey"), + year: Some(1991), + resolution: Some(Resolution::P2160), + source: Some(Source::BluRay), + codec: Some(Codec::X265), + hdr: vec![HdrMarker::Hdr10, HdrMarker::DolbyVision], + group: s("E"), + ..NameClaims::default() + }, + }, + Case { + // Not a movie or TV release at all — a game upload. Degrades + // to a title-only claim rather than failing. + name: "DEMON'S TILT b11419829 MULTi10 GNU/Linux Wine jc141 (Appid=422510)" + .into(), + want: NameClaims { + title: s("DEMON'S TILT b11419829 MULTi10 GNU/Linux Wine jc141 Appid=422510"), + ..NameClaims::default() + }, + }, + Case { + name: "Agenty Bezopasnoj Nacional\\'nosti [RUS][Novyj Disk](2007)".into(), + want: NameClaims { + title: s("Agenty Bezopasnoj Nacional\\'nosti RUS Novyj Disk"), + year: Some(2007), + ..NameClaims::default() + }, + }, + Case { + name: "Punk'd (2003) Season 1 S01 (480i NTSC DVD Remux AC3 2.0)".into(), + want: NameClaims { + title: s("Punk'd"), + year: Some(2003), + resolution: Some(Resolution::P480), + source: Some(Source::Remux), + ..NameClaims::default() + }, + }, + ] +} + +fn all_cases() -> Vec { + let mut cases = matrix_cases(); + cases.extend(pt_br_cases()); + cases.extend(multi_audio_cases()); + cases.extend(dv_hdr_cases()); + cases.extend(malformed_cases()); + cases +} + +#[test] +fn corpus_rows_parse_as_expected() { + let cases = all_cases(); + let mut failures = Vec::new(); + for Case { name, want } in &cases { + let got = parse(name); + if &got != want { + failures.push(format!("{name:?}\n got: {got:?}\n want: {want:?}")); + } + } + assert!( + failures.is_empty(), + "{}/{} mismatches:\n{}", + failures.len(), + cases.len(), + failures.join("\n") + ); +}