Merge remote-tracking branch 'origin/main' into issue/34-series-model
This commit is contained in:
+32
@@ -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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO owners (name, ntfy_topic) VALUES (?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1cecbfbb3c147a5d4b41bde85e34ea34d757f7220d4a734c51dd48f7b2347371"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+86
@@ -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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE owners SET name = ?, ntfy_topic = ? WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "82d8b805d307bcc1a202a5edc73e599a0ef3c8d075c7cce8832d581954167dc0"
|
||||
}
|
||||
+32
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+32
@@ -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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM owners WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d5c820b9b78c9901212359a2d57264382b7eb64bceef2c27d7c1de7c19974477"
|
||||
}
|
||||
@@ -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<AppState> {
|
||||
.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))
|
||||
}
|
||||
|
||||
@@ -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<Movie, ApiError> {
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct ListMoviesQuery {
|
||||
/// Restrict to movies tagged with this owner (DESIGN.md §4.3).
|
||||
pub owner_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[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<AppState>) -> Result<Json<Vec<Movie>>, 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<AppState>,
|
||||
Query(query): Query<ListMoviesQuery>,
|
||||
) -> Result<Json<Vec<Movie>>, 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<AppState>) -> Result<Json<AttentionQu
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/movies/{movie_id}/owners", tag = "movies",
|
||||
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||
responses(
|
||||
(status = 200, body = [Owner]),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn list_owners(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Vec<Owner>>, 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<AppState>,
|
||||
Path((movie_id, owner_id)): Path<(i64, i64)>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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<AppState>,
|
||||
Path((movie_id, owner_id)): Path<(i64, i64)>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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::*;
|
||||
|
||||
@@ -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<String>,
|
||||
pub ntfy_topic: Option<String>,
|
||||
}
|
||||
|
||||
async fn load_owner(state: &AppState, id: i64) -> Result<Owner, ApiError> {
|
||||
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<AppState>) -> Result<Json<Vec<Owner>>, 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<AppState>,
|
||||
Json(input): Json<CreateOwner>,
|
||||
) -> Result<(StatusCode, Json<Owner>), 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<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Owner>, 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<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(input): Json<UpdateOwner>,
|
||||
) -> Result<Json<Owner>, 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<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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<serde_json::Value> =
|
||||
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<serde_json::Value> =
|
||||
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<serde_json::Value> =
|
||||
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<serde_json::Value> =
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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<Case> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user