Merge remote-tracking branch 'origin/main' into issue/19-ffprobe
This commit is contained in:
+86
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "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 WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title",
|
||||||
|
"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": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "511f13a7ae12a66289d6c7d099cf2c6548e7d9a16d992bfb2a53140445aaf4ce"
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "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 WHERE 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": "546b4c3047094fe0874a1d28d802d5d455e8e652531986724b9109c6d839594f"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 8
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "5b9a8489e7223e9aa2929f68257590f840b75f579aea4fa5ba0f58ffa00d8b3e"
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "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 WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
|
||||||
|
"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": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "67b52bc6c6b748a09dbf5e903990b41857754a136548836fdba8fbd63a8412f7"
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "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",
|
||||||
|
"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": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "8eaf4d28651be8e464cb68037c202fe0324515951be0bddb19b5acf405c8be4f"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT EXISTS(SELECT 1 FROM movie_releases WHERE movie_id = ? AND release_id = ?) AS 'exists!: bool'",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "exists!: bool",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9de2cc5614917f11ada32f9ff0b570f6f8f3dfbf8ae93bc515b087872044dffe"
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "indexer_id!: i64",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "guid!: String",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name!: String",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "size!: i64",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "seeders",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "publish_date",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "download_url!: String",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parsed!: serde_json::Value",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "score",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Float"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "verdict",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rejected_rule",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM movies WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 8
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "e476863873e5574440db796925a94a8cd270a4dedacdfd07bff37c8f455934d9"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT EXISTS(SELECT 1 FROM roots WHERE id = ? AND kind = 'movie') AS 'exists!: bool'",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "exists!: bool",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ee86c3a4d78ef8ec0ca71343c5f1cbe1aea538f04e31b74f1bca0e7bd00c7248"
|
||||||
|
}
|
||||||
Generated
+6
@@ -30,11 +30,15 @@ dependencies = [
|
|||||||
name = "arr-api"
|
name = "arr-api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arr-db",
|
||||||
"axum",
|
"axum",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"utoipa-axum",
|
"utoipa-axum",
|
||||||
"utoipa-scalar",
|
"utoipa-scalar",
|
||||||
@@ -57,9 +61,11 @@ name = "arr-daemon"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arr-api",
|
"arr-api",
|
||||||
|
"arr-db",
|
||||||
"axum",
|
"axum",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -7,16 +7,20 @@ repository.workspace = true
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
arr-db = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
utoipa = { workspace = true }
|
utoipa = { workspace = true }
|
||||||
utoipa-axum = { workspace = true }
|
utoipa-axum = { workspace = true }
|
||||||
utoipa-scalar = { workspace = true }
|
utoipa-scalar = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
tempfile = { workspace = true }
|
||||||
wiremock = { workspace = true }
|
wiremock = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! to compile, and the gate in DESIGN.md §12 fails with it.
|
//! to compile, and the gate in DESIGN.md §12 fails with it.
|
||||||
|
|
||||||
mod health;
|
mod health;
|
||||||
|
mod movies;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
@@ -17,7 +18,8 @@ use utoipa_axum::routes;
|
|||||||
use utoipa_scalar::{Scalar, Servable};
|
use utoipa_scalar::{Scalar, Servable};
|
||||||
|
|
||||||
pub use health::{Check, Health, HealthReport, Status};
|
pub use health::{Check, Health, HealthReport, Status};
|
||||||
pub use state::{AppState, Upstreams, DEFAULT_TMDB_URL};
|
pub use movies::{Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, Release, UpdateMovie};
|
||||||
|
pub use state::{AppState, MovieCommand, Upstreams, DEFAULT_TMDB_URL};
|
||||||
|
|
||||||
/// Where the generated document is served, and where `just gen-client` reads
|
/// Where the generated document is served, and where `just gen-client` reads
|
||||||
/// it back from when it is fetched rather than dumped from the binary.
|
/// it back from when it is fetched rather than dumped from the binary.
|
||||||
@@ -35,13 +37,23 @@ pub const DOCS_PATH: &str = "/api/docs";
|
|||||||
description = "One service in place of Radarr and Sonarr. No authentication: \
|
description = "One service in place of Radarr and Sonarr. No authentication: \
|
||||||
the perimeter is the VPN (DESIGN.md §2).",
|
the perimeter is the VPN (DESIGN.md §2).",
|
||||||
),
|
),
|
||||||
tags((name = "system", description = "Service health and metadata")),
|
tags(
|
||||||
|
(name = "system", description = "Service health and metadata"),
|
||||||
|
(name = "movies", description = "Movie library and actions")
|
||||||
|
),
|
||||||
)]
|
)]
|
||||||
struct ApiDoc;
|
struct ApiDoc;
|
||||||
|
|
||||||
/// Every annotated route, still needing state.
|
/// Every annotated route, still needing state.
|
||||||
fn api_router() -> OpenApiRouter<AppState> {
|
fn api_router() -> OpenApiRouter<AppState> {
|
||||||
OpenApiRouter::with_openapi(ApiDoc::openapi()).routes(routes!(health::health))
|
OpenApiRouter::with_openapi(ApiDoc::openapi())
|
||||||
|
.routes(routes!(health::health))
|
||||||
|
.routes(routes!(movies::list, movies::create))
|
||||||
|
.routes(routes!(movies::get, movies::update, movies::delete))
|
||||||
|
.routes(routes!(movies::search))
|
||||||
|
.routes(routes!(movies::releases))
|
||||||
|
.routes(routes!(movies::grab))
|
||||||
|
.routes(routes!(movies::attention))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The generated `OpenAPI` document.
|
/// The generated `OpenAPI` document.
|
||||||
@@ -242,6 +254,35 @@ mod tests {
|
|||||||
json["components"]["schemas"]["HealthReport"].is_object(),
|
json["components"]["schemas"]["HealthReport"].is_object(),
|
||||||
"the response body schema came along with it: {json}"
|
"the response body schema came along with it: {json}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (path, method) in [
|
||||||
|
("/api/movies", "get"),
|
||||||
|
("/api/movies", "post"),
|
||||||
|
("/api/movies/{movie_id}", "get"),
|
||||||
|
("/api/movies/{movie_id}", "patch"),
|
||||||
|
("/api/movies/{movie_id}", "delete"),
|
||||||
|
("/api/movies/{movie_id}/search", "post"),
|
||||||
|
("/api/movies/{movie_id}/releases", "get"),
|
||||||
|
("/api/movies/{movie_id}/releases/{release_id}/grab", "post"),
|
||||||
|
("/api/queues/attention", "get"),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
json["paths"][path][method].is_object(),
|
||||||
|
"missing {method} {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for schema in [
|
||||||
|
"Movie",
|
||||||
|
"CreateMovie",
|
||||||
|
"UpdateMovie",
|
||||||
|
"Release",
|
||||||
|
"AttentionQueues",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
json["components"]["schemas"][schema].is_object(),
|
||||||
|
"{schema}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -0,0 +1,647 @@
|
|||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::state::{AppState, MovieCommand};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct Movie {
|
||||||
|
pub id: i64,
|
||||||
|
pub tmdb_id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub year: Option<i64>,
|
||||||
|
pub original_language: Option<String>,
|
||||||
|
pub root_id: i64,
|
||||||
|
pub wanted: bool,
|
||||||
|
pub overrides: serde_json::Value,
|
||||||
|
pub state: String,
|
||||||
|
pub blocked: bool,
|
||||||
|
pub search_attempts: i64,
|
||||||
|
pub last_searched_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct CreateMovie {
|
||||||
|
pub tmdb_id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub year: Option<i64>,
|
||||||
|
pub original_language: Option<String>,
|
||||||
|
pub root_id: i64,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub wanted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub blocked: bool,
|
||||||
|
#[serde(default = "empty_overrides")]
|
||||||
|
pub overrides: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateMovie {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub year: Option<Option<i64>>,
|
||||||
|
pub original_language: Option<Option<String>>,
|
||||||
|
pub root_id: Option<i64>,
|
||||||
|
pub wanted: Option<bool>,
|
||||||
|
pub blocked: Option<bool>,
|
||||||
|
pub overrides: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct Release {
|
||||||
|
pub id: i64,
|
||||||
|
pub indexer_id: i64,
|
||||||
|
pub guid: String,
|
||||||
|
pub name: String,
|
||||||
|
pub size: i64,
|
||||||
|
pub seeders: Option<i64>,
|
||||||
|
pub publish_date: Option<String>,
|
||||||
|
pub download_url: String,
|
||||||
|
pub parsed: serde_json::Value,
|
||||||
|
pub score: Option<f64>,
|
||||||
|
pub verdict: Option<String>,
|
||||||
|
pub rejected_rule: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct AttentionQueues {
|
||||||
|
pub no_pt_source: Vec<Movie>,
|
||||||
|
pub needs_decision: Vec<Movie>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct Accepted {
|
||||||
|
pub accepted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct ErrorBody {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ApiError {
|
||||||
|
NotFound,
|
||||||
|
Conflict(String),
|
||||||
|
Invalid(String),
|
||||||
|
Unavailable,
|
||||||
|
Database(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
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::Conflict(error) => (StatusCode::CONFLICT, error),
|
||||||
|
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
|
||||||
|
Self::Unavailable => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"database unavailable".into(),
|
||||||
|
),
|
||||||
|
Self::Database(error) => {
|
||||||
|
tracing::error!(%error, "movie API database error");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "database error".into())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(ErrorBody { error })).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for ApiError {
|
||||||
|
fn from(error: sqlx::Error) -> Self {
|
||||||
|
if matches!(error, sqlx::Error::RowNotFound) {
|
||||||
|
Self::NotFound
|
||||||
|
} else if let sqlx::Error::Database(database) = &error {
|
||||||
|
if database.is_unique_violation() || database.is_foreign_key_violation() {
|
||||||
|
return Self::Conflict(database.message().to_string());
|
||||||
|
}
|
||||||
|
Self::Database(error.to_string())
|
||||||
|
} else {
|
||||||
|
Self::Database(error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_overrides() -> serde_json::Value {
|
||||||
|
serde_json::json!({})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> {
|
||||||
|
let Some(object) = value.as_object() else {
|
||||||
|
return Err(ApiError::Invalid("overrides must be an object".into()));
|
||||||
|
};
|
||||||
|
if object
|
||||||
|
.keys()
|
||||||
|
.any(|key| key != "only_4k" && key != "allow_english_audio")
|
||||||
|
{
|
||||||
|
return Err(ApiError::Invalid(
|
||||||
|
"overrides supports only only_4k and allow_english_audio".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if object.values().any(|value| !value.is_boolean()) {
|
||||||
|
return Err(ApiError::Invalid("override values must be booleans".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool(state: &AppState) -> Result<&sqlx::SqlitePool, ApiError> {
|
||||||
|
state
|
||||||
|
.database()
|
||||||
|
.map(arr_db::Db::pool)
|
||||||
|
.ok_or(ApiError::Unavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_movie(state: &AppState, id: i64) -> Result<Movie, ApiError> {
|
||||||
|
Ok(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 WHERE id = ?"#, id)
|
||||||
|
.fetch_one(pool(state)?)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/movies", tag = "movies",
|
||||||
|
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?;
|
||||||
|
Ok(Json(movies))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/movies", tag = "movies", request_body = CreateMovie,
|
||||||
|
responses(
|
||||||
|
(status = 201, body = Movie),
|
||||||
|
(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<CreateMovie>,
|
||||||
|
) -> Result<(StatusCode, Json<Movie>), ApiError> {
|
||||||
|
if input.title.trim().is_empty() || input.tmdb_id <= 0 {
|
||||||
|
return Err(ApiError::Invalid("tmdb_id and title are required".into()));
|
||||||
|
}
|
||||||
|
validate_overrides(&input.overrides)?;
|
||||||
|
let movie_root = sqlx::query_scalar!(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM roots WHERE id = ? AND kind = 'movie') AS 'exists!: bool'",
|
||||||
|
input.root_id
|
||||||
|
)
|
||||||
|
.fetch_one(pool(&state)?)
|
||||||
|
.await?;
|
||||||
|
if !movie_root {
|
||||||
|
return Err(ApiError::Invalid("root_id must name a movie root".into()));
|
||||||
|
}
|
||||||
|
let overrides = serde_json::to_string(&input.overrides)
|
||||||
|
.map_err(|error| ApiError::Invalid(error.to_string()))?;
|
||||||
|
let title = input.title.trim();
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
input.tmdb_id, title, input.year, input.original_language, input.root_id,
|
||||||
|
input.wanted, input.blocked, overrides
|
||||||
|
)
|
||||||
|
.execute(pool(&state)?)
|
||||||
|
.await?;
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(load_movie(&state, result.last_insert_rowid()).await?),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/movies/{movie_id}", tag = "movies",
|
||||||
|
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||||
|
responses(
|
||||||
|
(status = 200, body = Movie),
|
||||||
|
(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<Movie>, ApiError> {
|
||||||
|
Ok(Json(load_movie(&state, id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
patch, path = "/api/movies/{movie_id}", tag = "movies", request_body = UpdateMovie,
|
||||||
|
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||||
|
responses(
|
||||||
|
(status = 200, body = Movie),
|
||||||
|
(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<UpdateMovie>,
|
||||||
|
) -> Result<Json<Movie>, ApiError> {
|
||||||
|
let current = load_movie(&state, id).await?;
|
||||||
|
let title = input.title.unwrap_or(current.title);
|
||||||
|
if title.trim().is_empty() {
|
||||||
|
return Err(ApiError::Invalid("title cannot be empty".into()));
|
||||||
|
}
|
||||||
|
let overrides = input.overrides.unwrap_or(current.overrides);
|
||||||
|
validate_overrides(&overrides)?;
|
||||||
|
let overrides =
|
||||||
|
serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?;
|
||||||
|
let title = title.trim();
|
||||||
|
let year = input.year.unwrap_or(current.year);
|
||||||
|
let original_language = input.original_language.unwrap_or(current.original_language);
|
||||||
|
let root_id = input.root_id.unwrap_or(current.root_id);
|
||||||
|
if input.root_id.is_some() {
|
||||||
|
let movie_root = sqlx::query_scalar!(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM roots WHERE id = ? AND kind = 'movie') AS 'exists!: bool'",
|
||||||
|
root_id
|
||||||
|
)
|
||||||
|
.fetch_one(pool(&state)?)
|
||||||
|
.await?;
|
||||||
|
if !movie_root {
|
||||||
|
return Err(ApiError::Invalid("root_id must name a movie root".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let wanted = input.wanted.unwrap_or(current.wanted);
|
||||||
|
let blocked = input.blocked.unwrap_or(current.blocked);
|
||||||
|
sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, id)
|
||||||
|
.execute(pool(&state)?)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(load_movie(&state, id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete, path = "/api/movies/{movie_id}", tag = "movies",
|
||||||
|
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||||
|
responses(
|
||||||
|
(status = 204),
|
||||||
|
(status = 404, body = ErrorBody),
|
||||||
|
(status = 409, 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 movies WHERE id = ?", id)
|
||||||
|
.execute(pool(&state)?)
|
||||||
|
.await?;
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(ApiError::NotFound);
|
||||||
|
}
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/movies/{movie_id}/search", tag = "movies",
|
||||||
|
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||||
|
responses(
|
||||||
|
(status = 202, body = Accepted),
|
||||||
|
(status = 404, body = ErrorBody),
|
||||||
|
(status = 409, body = ErrorBody),
|
||||||
|
(status = 500, body = ErrorBody),
|
||||||
|
(status = 503, body = ErrorBody)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn search(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<(StatusCode, Json<Accepted>), ApiError> {
|
||||||
|
let movie = load_movie(&state, id).await?;
|
||||||
|
if movie.blocked {
|
||||||
|
return Err(ApiError::Conflict("movie is blocked".into()));
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.send_movie_command(MovieCommand::Search { movie_id: id })
|
||||||
|
.map_err(|_| ApiError::Unavailable)?;
|
||||||
|
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/movies/{movie_id}/releases", tag = "movies",
|
||||||
|
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||||
|
responses(
|
||||||
|
(status = 200, body = [Release]),
|
||||||
|
(status = 404, body = ErrorBody),
|
||||||
|
(status = 500, body = ErrorBody),
|
||||||
|
(status = 503, body = ErrorBody)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn releases(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<Vec<Release>>, ApiError> {
|
||||||
|
load_movie(&state, id).await?;
|
||||||
|
let releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
|
||||||
|
.fetch_all(pool(&state)?)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(releases))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/movies/{movie_id}/releases/{release_id}/grab", tag = "movies",
|
||||||
|
params(("movie_id" = i64, Path), ("release_id" = i64, Path)),
|
||||||
|
responses(
|
||||||
|
(status = 202, body = Accepted),
|
||||||
|
(status = 404, body = ErrorBody),
|
||||||
|
(status = 500, body = ErrorBody),
|
||||||
|
(status = 503, body = ErrorBody)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn grab(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((movie_id, release_id)): Path<(i64, i64)>,
|
||||||
|
) -> Result<(StatusCode, Json<Accepted>), ApiError> {
|
||||||
|
let exists = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM movie_releases WHERE movie_id = ? AND release_id = ?) AS 'exists!: bool'", movie_id, release_id)
|
||||||
|
.fetch_one(pool(&state)?)
|
||||||
|
.await?;
|
||||||
|
if !exists {
|
||||||
|
return Err(ApiError::NotFound);
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.send_movie_command(MovieCommand::Grab {
|
||||||
|
movie_id,
|
||||||
|
release_id,
|
||||||
|
})
|
||||||
|
.map_err(|_| ApiError::Unavailable)?;
|
||||||
|
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/queues/attention", tag = "movies",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = AttentionQueues),
|
||||||
|
(status = 500, body = ErrorBody),
|
||||||
|
(status = 503, body = ErrorBody)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQueues>, ApiError> {
|
||||||
|
let no_pt_source = 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 WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
|
||||||
|
.fetch_all(pool(&state)?)
|
||||||
|
.await?;
|
||||||
|
let needs_decision = 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 WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#)
|
||||||
|
.fetch_all(pool(&state)?)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(AttentionQueues {
|
||||||
|
no_pt_source,
|
||||||
|
needs_decision,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 add_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,
|
||||||
|
"overrides": {"only_4k": true}
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create movie");
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
response.json().await.expect("movie json")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn crud_preserves_intent_and_overrides() {
|
||||||
|
let (_dir, _state, base) = application().await;
|
||||||
|
let movie = add_movie(&base, 693_134, 2).await;
|
||||||
|
let id = movie["id"].as_i64().expect("id");
|
||||||
|
assert_eq!(movie["wanted"], true);
|
||||||
|
assert_eq!(movie["overrides"]["only_4k"], true);
|
||||||
|
|
||||||
|
let updated: serde_json::Value = reqwest::Client::new()
|
||||||
|
.patch(format!("{base}/api/movies/{id}"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"wanted": false, "blocked": true,
|
||||||
|
"overrides": {"allow_english_audio": true}
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("update")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("updated json");
|
||||||
|
assert_eq!(updated["wanted"], false);
|
||||||
|
assert_eq!(updated["blocked"], true);
|
||||||
|
assert_eq!(updated["overrides"]["allow_english_audio"], true);
|
||||||
|
|
||||||
|
let listed: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/movies"))
|
||||||
|
.await
|
||||||
|
.expect("list")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("list json");
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
|
||||||
|
let deleted = reqwest::Client::new()
|
||||||
|
.delete(format!("{base}/api/movies/{id}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("delete");
|
||||||
|
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||||
|
assert_eq!(
|
||||||
|
reqwest::get(format!("{base}/api/movies/{id}"))
|
||||||
|
.await
|
||||||
|
.expect("get deleted")
|
||||||
|
.status(),
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn release_actions_are_scoped_to_the_movie() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let movie = add_movie(&base, 693_134, 2).await;
|
||||||
|
let movie_id = movie["id"].as_i64().expect("id");
|
||||||
|
let pool = state.database().expect("database").pool();
|
||||||
|
let release_id = sqlx::query("INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict) VALUES (7, 'guid', 'release', 1000, 'url', '{}', 42, 'eligible')")
|
||||||
|
.execute(pool).await.expect("release").last_insert_rowid();
|
||||||
|
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(movie_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("association");
|
||||||
|
let releases: Vec<serde_json::Value> =
|
||||||
|
reqwest::get(format!("{base}/api/movies/{movie_id}/releases"))
|
||||||
|
.await
|
||||||
|
.expect("releases")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("release json");
|
||||||
|
assert_eq!(releases[0]["verdict"], "eligible");
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!(
|
||||||
|
"{base}/api/movies/{movie_id}/releases/{release_id}/grab"
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("grab");
|
||||||
|
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||||
|
assert_eq!(
|
||||||
|
state.next_movie_command().await.expect("command"),
|
||||||
|
MovieCommand::Grab {
|
||||||
|
movie_id,
|
||||||
|
release_id
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_full_action_queue_returns_service_unavailable() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let movie = add_movie(&base, 693_134, 2).await;
|
||||||
|
let movie_id = movie["id"].as_i64().expect("id");
|
||||||
|
for _ in 0..64 {
|
||||||
|
state
|
||||||
|
.send_movie_command(MovieCommand::Search { movie_id })
|
||||||
|
.expect("queue has capacity");
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{base}/api/movies/{movie_id}/search"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("search");
|
||||||
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attention_queues_follow_search_and_failure_state() {
|
||||||
|
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 = 'kids'")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("kids root");
|
||||||
|
let movie = add_movie(&base, 82728, root_id).await;
|
||||||
|
let movie_id = movie["id"].as_i64().expect("id");
|
||||||
|
sqlx::query("UPDATE movies SET search_attempts = 1 WHERE id = ?")
|
||||||
|
.bind(movie_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("record search");
|
||||||
|
|
||||||
|
let mut release_ids = Vec::new();
|
||||||
|
for suffix in ["a", "b"] {
|
||||||
|
let release_id = sqlx::query("INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict, rejected_rule) VALUES (1, ?, ?, 1, 'url', '{}', 'rejected', 'required_audio')")
|
||||||
|
.bind(suffix)
|
||||||
|
.bind(suffix)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("release")
|
||||||
|
.last_insert_rowid();
|
||||||
|
release_ids.push(release_id);
|
||||||
|
sqlx::query("INSERT INTO grabs (release_id, target_kind, target_id, infohash, state) VALUES (?, 'movie', ?, ?, 'failed')")
|
||||||
|
.bind(release_id)
|
||||||
|
.bind(movie_id)
|
||||||
|
.bind(format!("hash-{suffix}"))
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("failed grab");
|
||||||
|
}
|
||||||
|
|
||||||
|
let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention"))
|
||||||
|
.await
|
||||||
|
.expect("queues")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("queues json");
|
||||||
|
assert_eq!(queues["no_pt_source"][0]["id"], movie_id);
|
||||||
|
assert_eq!(queues["needs_decision"][0]["id"], movie_id);
|
||||||
|
|
||||||
|
sqlx::query("UPDATE movies SET blocked = 1 WHERE id = ?")
|
||||||
|
.bind(movie_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("block movie");
|
||||||
|
let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention"))
|
||||||
|
.await
|
||||||
|
.expect("blocked queues")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("blocked queues json");
|
||||||
|
assert_eq!(queues["no_pt_source"].as_array().map(Vec::len), Some(0));
|
||||||
|
let search = reqwest::Client::new()
|
||||||
|
.post(format!("{base}/api/movies/{movie_id}/search"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("blocked search");
|
||||||
|
assert_eq!(search.status(), StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
sqlx::query("UPDATE movies SET blocked = 0 WHERE id = ?")
|
||||||
|
.bind(movie_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("unblock movie");
|
||||||
|
|
||||||
|
sqlx::query("UPDATE releases SET verdict = 'eligible', rejected_rule = NULL WHERE id = ?")
|
||||||
|
.bind(release_ids[0])
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("eligible release");
|
||||||
|
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(movie_id)
|
||||||
|
.bind(release_ids[0])
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("associate release");
|
||||||
|
|
||||||
|
let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention"))
|
||||||
|
.await
|
||||||
|
.expect("queues")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("queues json");
|
||||||
|
assert_eq!(queues["no_pt_source"].as_array().map(Vec::len), Some(0));
|
||||||
|
assert_eq!(queues["needs_decision"][0]["id"], movie_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use arr_db::Db;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
/// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key
|
/// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key
|
||||||
/// is configurable, so this is a constant that tests point elsewhere.
|
/// is configurable, so this is a constant that tests point elsewhere.
|
||||||
pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3";
|
pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3";
|
||||||
@@ -63,6 +66,16 @@ impl Upstreams {
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
upstreams: Arc<Upstreams>,
|
upstreams: Arc<Upstreams>,
|
||||||
|
database: Option<Db>,
|
||||||
|
movie_commands: mpsc::Sender<MovieCommand>,
|
||||||
|
pending_movie_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MovieCommand>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Work explicitly requested through the movie API.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MovieCommand {
|
||||||
|
Search { movie_id: i64 },
|
||||||
|
Grab { movie_id: i64, release_id: i64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -73,12 +86,32 @@ impl AppState {
|
|||||||
/// If the TLS backend cannot be initialised.
|
/// If the TLS backend cannot be initialised.
|
||||||
pub fn new(upstreams: Upstreams) -> Result<Self, reqwest::Error> {
|
pub fn new(upstreams: Upstreams) -> Result<Self, reqwest::Error> {
|
||||||
let http = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
|
let http = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
|
||||||
|
let (movie_commands, pending_movie_commands) = mpsc::channel(64);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
http,
|
http,
|
||||||
upstreams: Arc::new(upstreams),
|
upstreams: Arc::new(upstreams),
|
||||||
|
database: None,
|
||||||
|
movie_commands,
|
||||||
|
pending_movie_commands: Arc::new(tokio::sync::Mutex::new(pending_movie_commands)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach the migrated application database.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_database(mut self, database: Db) -> Self {
|
||||||
|
self.database = Some(database);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for the next manual movie action in the daemon's reconcile loop.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// If every sender has been dropped.
|
||||||
|
pub async fn next_movie_command(&self) -> Option<MovieCommand> {
|
||||||
|
self.pending_movie_commands.lock().await.recv().await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn http(&self) -> &reqwest::Client {
|
pub(crate) fn http(&self) -> &reqwest::Client {
|
||||||
&self.http
|
&self.http
|
||||||
}
|
}
|
||||||
@@ -86,4 +119,15 @@ impl AppState {
|
|||||||
pub(crate) fn upstreams(&self) -> &Upstreams {
|
pub(crate) fn upstreams(&self) -> &Upstreams {
|
||||||
&self.upstreams
|
&self.upstreams
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn database(&self) -> Option<&Db> {
|
||||||
|
self.database.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn send_movie_command(
|
||||||
|
&self,
|
||||||
|
command: MovieCommand,
|
||||||
|
) -> Result<(), mpsc::error::TrySendError<MovieCommand>> {
|
||||||
|
self.movie_commands.try_send(command)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
//! pt-PT versus pt-BR detection (`DESIGN.md` §5.2).
|
||||||
|
//!
|
||||||
|
//! ISO-639-2 has one code, `por`, for both variants, so the variant must be
|
||||||
|
//! recovered from side channels. Three signals, tried in order:
|
||||||
|
//!
|
||||||
|
//! 1. release-name markers (`PT-BR`, `Dublado`, `Nacional` versus `PT-PT`),
|
||||||
|
//! 2. `ffprobe` stream *title* and `handler_name` strings,
|
||||||
|
//! 3. container-level BCP-47 tags (Matroska can carry `pt-BR`).
|
||||||
|
//!
|
||||||
|
//! When none resolve it — including when a signal contradicts itself — the
|
||||||
|
//! track is tagged [`Language::PortugueseUnverified`] and surfaced. Never
|
||||||
|
//! guess: guessing toward `kids` gives a child a Brazilian dub, guessing
|
||||||
|
//! toward `main` rejects a Brazilian film's own audio.
|
||||||
|
|
||||||
|
use arr_parse::LanguageMarker;
|
||||||
|
|
||||||
|
use crate::Language;
|
||||||
|
|
||||||
|
/// Everything available to disambiguate a Portuguese audio track.
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub struct PortugueseEvidence<'a> {
|
||||||
|
/// Language markers parsed from the release name (signal 1).
|
||||||
|
pub name_markers: &'a [LanguageMarker],
|
||||||
|
/// `ffprobe` stream title (signal 2), often literally
|
||||||
|
/// `Portuguese (Brazil)`.
|
||||||
|
pub stream_title: Option<&'a str>,
|
||||||
|
/// `ffprobe` stream `handler_name` (signal 2).
|
||||||
|
pub handler_name: Option<&'a str>,
|
||||||
|
/// Container-level BCP-47 tag (signal 3), e.g. `pt-BR`.
|
||||||
|
pub container_tag: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which variant one signal claims, if any.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum Variant {
|
||||||
|
Portugal,
|
||||||
|
Brazil,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Variant {
|
||||||
|
const fn language(self) -> Language {
|
||||||
|
match self {
|
||||||
|
Self::Portugal => Language::PortuguesePortugal,
|
||||||
|
Self::Brazil => Language::PortugueseBrazil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a `por` audio track to a concrete Portuguese variant, or
|
||||||
|
/// [`Language::PortugueseUnverified`] when no signal settles it.
|
||||||
|
#[must_use]
|
||||||
|
pub fn resolve_portuguese(evidence: PortugueseEvidence<'_>) -> Language {
|
||||||
|
let signals = [
|
||||||
|
from_name_markers(evidence.name_markers),
|
||||||
|
from_stream_strings(evidence.stream_title, evidence.handler_name),
|
||||||
|
evidence.container_tag.and_then(from_bcp47),
|
||||||
|
];
|
||||||
|
signals
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.next()
|
||||||
|
.map_or(Language::PortugueseUnverified, Variant::language)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal 1: explicit variant markers in the release name. A name claiming
|
||||||
|
/// both variants (a dual-dub release) resolves nothing for any one track.
|
||||||
|
fn from_name_markers(markers: &[LanguageMarker]) -> Option<Variant> {
|
||||||
|
let br = markers.contains(&LanguageMarker::PtBr);
|
||||||
|
let pt = markers.contains(&LanguageMarker::PtPt);
|
||||||
|
match (br, pt) {
|
||||||
|
(true, false) => Some(Variant::Brazil),
|
||||||
|
(false, true) => Some(Variant::Portugal),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal 2: free-text stream metadata. Contradictory mentions across the
|
||||||
|
/// title and handler resolve nothing.
|
||||||
|
fn from_stream_strings(title: Option<&str>, handler: Option<&str>) -> Option<Variant> {
|
||||||
|
let mut seen_br = false;
|
||||||
|
let mut seen_pt = false;
|
||||||
|
for text in [title, handler].into_iter().flatten() {
|
||||||
|
let low = text.to_lowercase();
|
||||||
|
seen_br |= ["brazil", "brasil", "pt-br", "pt_br", "ptbr"]
|
||||||
|
.iter()
|
||||||
|
.any(|needle| low.contains(needle));
|
||||||
|
seen_pt |= ["portugal", "pt-pt", "pt_pt", "ptpt", "european"]
|
||||||
|
.iter()
|
||||||
|
.any(|needle| low.contains(needle));
|
||||||
|
}
|
||||||
|
match (seen_br, seen_pt) {
|
||||||
|
(true, false) => Some(Variant::Brazil),
|
||||||
|
(false, true) => Some(Variant::Portugal),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal 3: a BCP-47 tag whose primary subtag is Portuguese and whose
|
||||||
|
/// region subtag names the variant. A bare `pt` resolves nothing.
|
||||||
|
fn from_bcp47(tag: &str) -> Option<Variant> {
|
||||||
|
let mut subtags = tag.split(['-', '_']).map(str::to_lowercase);
|
||||||
|
let primary = subtags.next()?;
|
||||||
|
if primary != "pt" && primary != "por" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for subtag in subtags {
|
||||||
|
match subtag.as_str() {
|
||||||
|
"br" => return Some(Variant::Brazil),
|
||||||
|
"pt" => return Some(Variant::Portugal),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`Language`] a release-name marker claims, if it claims one at all.
|
||||||
|
/// `MULTi` and `DUAL` advertise track counts, not languages. A bare
|
||||||
|
/// `Portuguese` marker has unknown flavour: pre-grab there is no stream or
|
||||||
|
/// container to consult, so it stays unverified.
|
||||||
|
#[must_use]
|
||||||
|
pub fn language_of_marker(marker: LanguageMarker) -> Option<Language> {
|
||||||
|
match marker {
|
||||||
|
LanguageMarker::PtBr => Some(Language::PortugueseBrazil),
|
||||||
|
LanguageMarker::PtPt => Some(Language::PortuguesePortugal),
|
||||||
|
LanguageMarker::Portuguese => Some(Language::PortugueseUnverified),
|
||||||
|
LanguageMarker::English => Some(Language::Other("en".to_owned())),
|
||||||
|
LanguageMarker::French => Some(Language::Other("fr".to_owned())),
|
||||||
|
LanguageMarker::German => Some(Language::Other("de".to_owned())),
|
||||||
|
LanguageMarker::Spanish => Some(Language::Other("es".to_owned())),
|
||||||
|
LanguageMarker::Italian => Some(Language::Other("it".to_owned())),
|
||||||
|
LanguageMarker::Multi | LanguageMarker::Dual => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a language is a resolved Portuguese variant.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_resolved_portuguese(language: &Language) -> bool {
|
||||||
|
matches!(
|
||||||
|
language,
|
||||||
|
Language::PortuguesePortugal | Language::PortugueseBrazil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_marker_resolves_first() {
|
||||||
|
let markers = [LanguageMarker::PtBr];
|
||||||
|
let evidence = PortugueseEvidence {
|
||||||
|
name_markers: &markers,
|
||||||
|
// A contradicting later signal must not win over signal 1.
|
||||||
|
stream_title: Some("Portuguese (Portugal)"),
|
||||||
|
..PortugueseEvidence::default()
|
||||||
|
};
|
||||||
|
assert_eq!(resolve_portuguese(evidence), Language::PortugueseBrazil);
|
||||||
|
|
||||||
|
let markers = [LanguageMarker::PtPt];
|
||||||
|
let evidence = PortugueseEvidence {
|
||||||
|
name_markers: &markers,
|
||||||
|
..PortugueseEvidence::default()
|
||||||
|
};
|
||||||
|
assert_eq!(resolve_portuguese(evidence), Language::PortuguesePortugal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contradictory_name_markers_fall_through() {
|
||||||
|
let markers = [LanguageMarker::PtBr, LanguageMarker::PtPt];
|
||||||
|
let evidence = PortugueseEvidence {
|
||||||
|
name_markers: &markers,
|
||||||
|
stream_title: Some("Portuguese (Brazil)"),
|
||||||
|
..PortugueseEvidence::default()
|
||||||
|
};
|
||||||
|
assert_eq!(resolve_portuguese(evidence), Language::PortugueseBrazil);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_title_resolves_second() {
|
||||||
|
let evidence = PortugueseEvidence {
|
||||||
|
stream_title: Some("Portuguese (Brazil)"),
|
||||||
|
..PortugueseEvidence::default()
|
||||||
|
};
|
||||||
|
assert_eq!(resolve_portuguese(evidence), Language::PortugueseBrazil);
|
||||||
|
|
||||||
|
let evidence = PortugueseEvidence {
|
||||||
|
handler_name: Some("Português (Portugal)"),
|
||||||
|
..PortugueseEvidence::default()
|
||||||
|
};
|
||||||
|
assert_eq!(resolve_portuguese(evidence), Language::PortuguesePortugal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contradictory_stream_strings_fall_through() {
|
||||||
|
let evidence = PortugueseEvidence {
|
||||||
|
stream_title: Some("Portuguese (Brazil)"),
|
||||||
|
handler_name: Some("Portuguese (Portugal)"),
|
||||||
|
container_tag: Some("pt-PT"),
|
||||||
|
..PortugueseEvidence::default()
|
||||||
|
};
|
||||||
|
assert_eq!(resolve_portuguese(evidence), Language::PortuguesePortugal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bcp47_tag_resolves_third() {
|
||||||
|
for (tag, expected) in [
|
||||||
|
("pt-BR", Language::PortugueseBrazil),
|
||||||
|
("pt-PT", Language::PortuguesePortugal),
|
||||||
|
("por-br", Language::PortugueseBrazil),
|
||||||
|
("pt_BR", Language::PortugueseBrazil),
|
||||||
|
] {
|
||||||
|
let evidence = PortugueseEvidence {
|
||||||
|
container_tag: Some(tag),
|
||||||
|
..PortugueseEvidence::default()
|
||||||
|
};
|
||||||
|
assert_eq!(resolve_portuguese(evidence), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_or_foreign_tags_resolve_nothing() {
|
||||||
|
for tag in ["pt", "por", "en-US", "pt-AO"] {
|
||||||
|
let evidence = PortugueseEvidence {
|
||||||
|
container_tag: Some(tag),
|
||||||
|
..PortugueseEvidence::default()
|
||||||
|
};
|
||||||
|
assert_eq!(resolve_portuguese(evidence), Language::PortugueseUnverified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_signal_emits_por_unverified_not_a_guess() {
|
||||||
|
let evidence = PortugueseEvidence {
|
||||||
|
name_markers: &[LanguageMarker::Multi],
|
||||||
|
stream_title: Some("Portuguese"),
|
||||||
|
handler_name: Some("SoundHandler"),
|
||||||
|
container_tag: Some("por"),
|
||||||
|
};
|
||||||
|
assert_eq!(resolve_portuguese(evidence), Language::PortugueseUnverified);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marker_languages_map_without_guessing_portuguese_flavour() {
|
||||||
|
assert_eq!(
|
||||||
|
language_of_marker(LanguageMarker::Portuguese),
|
||||||
|
Some(Language::PortugueseUnverified)
|
||||||
|
);
|
||||||
|
assert_eq!(language_of_marker(LanguageMarker::Multi), None);
|
||||||
|
assert_eq!(language_of_marker(LanguageMarker::Dual), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use std::{collections::BTreeMap, path::PathBuf, time::SystemTime};
|
use std::{collections::BTreeMap, path::PathBuf, time::SystemTime};
|
||||||
|
|
||||||
|
pub mod lang;
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
|
|
||||||
pub use arr_parse::NameClaims as ParsedRelease;
|
pub use arr_parse::NameClaims as ParsedRelease;
|
||||||
@@ -195,6 +196,8 @@ pub struct ProbedMedia {
|
|||||||
pub enum Rule {
|
pub enum Rule {
|
||||||
RequiredAudio,
|
RequiredAudio,
|
||||||
DubBlacklist(Language),
|
DubBlacklist(Language),
|
||||||
|
/// A Portuguese track whose pt-PT / pt-BR flavour no signal resolved.
|
||||||
|
PortugueseUnverified,
|
||||||
DolbyVisionProfile(DolbyVisionProfile),
|
DolbyVisionProfile(DolbyVisionProfile),
|
||||||
Resolution(Resolution),
|
Resolution(Resolution),
|
||||||
Source(Source),
|
Source(Source),
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use arr_parse::NameClaims;
|
use arr_parse::{LanguageMarker, NameClaims};
|
||||||
|
|
||||||
use crate::{MovieOverrides, Policy, ProbedMedia, Resolution, Rule, Source, Verdict};
|
use crate::{
|
||||||
|
lang::{is_resolved_portuguese, language_of_marker},
|
||||||
|
Language, MovieOverrides, Policy, ProbedMedia, RequiredAudio, Resolution, Rule, Source,
|
||||||
|
Verdict,
|
||||||
|
};
|
||||||
|
|
||||||
/// The evidence available while evaluating a candidate.
|
/// The evidence available while evaluating a candidate.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
@@ -48,6 +52,9 @@ pub enum EvaluationPhase {
|
|||||||
pub struct EvaluationContext<'a> {
|
pub struct EvaluationContext<'a> {
|
||||||
pub policy: &'a Policy,
|
pub policy: &'a Policy,
|
||||||
pub overrides: &'a MovieOverrides,
|
pub overrides: &'a MovieOverrides,
|
||||||
|
/// The title's original language from TMDB (`DESIGN.md` §5.2) — a
|
||||||
|
/// distinct input from any track's language.
|
||||||
|
pub original_language: &'a Language,
|
||||||
pub candidate: Candidate<'a>,
|
pub candidate: Candidate<'a>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,14 +96,21 @@ pub struct Evaluation {
|
|||||||
pub fn evaluate(
|
pub fn evaluate(
|
||||||
policy: &Policy,
|
policy: &Policy,
|
||||||
overrides: &MovieOverrides,
|
overrides: &MovieOverrides,
|
||||||
|
original_language: &Language,
|
||||||
candidate: Candidate<'_>,
|
candidate: Candidate<'_>,
|
||||||
) -> Evaluation {
|
) -> Evaluation {
|
||||||
let context = EvaluationContext {
|
let context = EvaluationContext {
|
||||||
policy,
|
policy,
|
||||||
overrides,
|
overrides,
|
||||||
|
original_language,
|
||||||
candidate,
|
candidate,
|
||||||
};
|
};
|
||||||
let rules: [&dyn PolicyRule; 2] = [&ResolutionRule, &SourceRule];
|
let rules: [&dyn PolicyRule; 4] = [
|
||||||
|
&ResolutionRule,
|
||||||
|
&SourceRule,
|
||||||
|
&DubBlacklistRule,
|
||||||
|
&RequiredAudioRule,
|
||||||
|
];
|
||||||
evaluate_rules(&context, &rules)
|
evaluate_rules(&context, &rules)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +200,162 @@ impl PolicyRule for SourceRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one language expression from `DESIGN.md` §5.2:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// reject audio track T if T.lang ∈ dub_blacklist and T.lang ≠ title.original_language
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// A blacklisted dub of a title in another language never counts; a title's
|
||||||
|
/// own soundtrack always escapes its blacklist entry.
|
||||||
|
fn is_blacklisted_dub(context: &EvaluationContext<'_>, language: &Language) -> bool {
|
||||||
|
context.policy.dub_blacklist.contains(language) && language != context.original_language
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Languages the release name claims as audio. `MULTi`/`DUAL` claim extra
|
||||||
|
/// unnamed tracks and map to no language.
|
||||||
|
fn claimed_languages(claims: &NameClaims) -> Vec<Language> {
|
||||||
|
claims
|
||||||
|
.languages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|marker| language_of_marker(*marker))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claims_extra_tracks(claims: &NameClaims) -> bool {
|
||||||
|
claims
|
||||||
|
.languages
|
||||||
|
.iter()
|
||||||
|
.any(|marker| matches!(marker, LanguageMarker::Multi | LanguageMarker::Dual))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rejects candidates whose audio is nothing but blacklisted dubs.
|
||||||
|
///
|
||||||
|
/// A blacklisted track riding along other audio is fine (`DESIGN.md` §5.2:
|
||||||
|
/// "Others may ride along") — whether the rest satisfies the policy is
|
||||||
|
/// [`RequiredAudioRule`]'s question.
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub struct DubBlacklistRule;
|
||||||
|
|
||||||
|
impl PolicyRule for DubBlacklistRule {
|
||||||
|
fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation {
|
||||||
|
let (languages, extra_tracks) = match context.candidate {
|
||||||
|
Candidate::PreGrab(claims) => (claimed_languages(claims), claims_extra_tracks(claims)),
|
||||||
|
Candidate::PostDownload(media) => (
|
||||||
|
media
|
||||||
|
.audio_tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.language.clone())
|
||||||
|
.collect(),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if languages.is_empty() {
|
||||||
|
return RuleEvaluation::Unknown(RuleKind::DubBlacklist);
|
||||||
|
}
|
||||||
|
let all_dubs = languages
|
||||||
|
.iter()
|
||||||
|
.all(|language| is_blacklisted_dub(context, language));
|
||||||
|
if all_dubs && !extra_tracks {
|
||||||
|
let dub = languages[0].clone();
|
||||||
|
return RuleEvaluation::HardFail(Rule::DubBlacklist(dub));
|
||||||
|
}
|
||||||
|
RuleEvaluation::Pass(RuleKind::DubBlacklist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks that a track satisfying the policy's audio requirement exists.
|
||||||
|
///
|
||||||
|
/// The required set is the original language for `main`-style policies, or
|
||||||
|
/// the policy's own list for `kids`-style ones — plus the original language
|
||||||
|
/// when that is already a resolved Portuguese variant (`DESIGN.md` §5.2). A
|
||||||
|
/// blacklisted dub never satisfies the requirement.
|
||||||
|
///
|
||||||
|
/// An unresolved `por-unverified` track that could satisfy the requirement
|
||||||
|
/// abstains pre-grab and soft-fails post-download, so it surfaces as a
|
||||||
|
/// waiver instead of a guess in either direction.
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub struct RequiredAudioRule;
|
||||||
|
|
||||||
|
impl RequiredAudioRule {
|
||||||
|
fn required_set(context: &EvaluationContext<'_>) -> Vec<Language> {
|
||||||
|
match &context.policy.required_audio {
|
||||||
|
RequiredAudio::OriginalLanguage => vec![context.original_language.clone()],
|
||||||
|
RequiredAudio::AnyOf(languages) => {
|
||||||
|
let mut required = languages.clone();
|
||||||
|
if is_resolved_portuguese(context.original_language)
|
||||||
|
&& !required.contains(context.original_language)
|
||||||
|
{
|
||||||
|
required.push(context.original_language.clone());
|
||||||
|
}
|
||||||
|
required
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure(context: &EvaluationContext<'_>) -> RuleEvaluation {
|
||||||
|
match &context.policy.required_audio {
|
||||||
|
// §5.7: no original-language audio at all is useless — hard.
|
||||||
|
RequiredAudio::OriginalLanguage => RuleEvaluation::HardFail(Rule::RequiredAudio),
|
||||||
|
// §5.7: required audio missing but watchable — import, waive.
|
||||||
|
RequiredAudio::AnyOf(_) => RuleEvaluation::SoftFail(Rule::RequiredAudio),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PolicyRule for RequiredAudioRule {
|
||||||
|
fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation {
|
||||||
|
let required = Self::required_set(context);
|
||||||
|
let satisfies = |language: &Language| {
|
||||||
|
required.contains(language) && !is_blacklisted_dub(context, language)
|
||||||
|
};
|
||||||
|
let english = Language::Other("en".to_owned());
|
||||||
|
let unverified_could_satisfy = |languages: &[Language]| {
|
||||||
|
languages.contains(&Language::PortugueseUnverified)
|
||||||
|
&& required.iter().any(is_resolved_portuguese)
|
||||||
|
};
|
||||||
|
|
||||||
|
match context.candidate {
|
||||||
|
Candidate::PreGrab(claims) => {
|
||||||
|
let languages = claimed_languages(claims);
|
||||||
|
if languages.iter().any(satisfies) {
|
||||||
|
return RuleEvaluation::Pass(RuleKind::RequiredAudio);
|
||||||
|
}
|
||||||
|
if context.overrides.allow_english_audio && languages.contains(&english) {
|
||||||
|
return RuleEvaluation::Pass(RuleKind::RequiredAudio);
|
||||||
|
}
|
||||||
|
if languages.is_empty()
|
||||||
|
|| claims_extra_tracks(claims)
|
||||||
|
|| unverified_could_satisfy(&languages)
|
||||||
|
{
|
||||||
|
return RuleEvaluation::Unknown(RuleKind::RequiredAudio);
|
||||||
|
}
|
||||||
|
Self::failure(context)
|
||||||
|
}
|
||||||
|
Candidate::PostDownload(media) => {
|
||||||
|
let languages: Vec<Language> = media
|
||||||
|
.audio_tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.language.clone())
|
||||||
|
.collect();
|
||||||
|
if languages.is_empty() {
|
||||||
|
return RuleEvaluation::HardFail(Rule::RequiredAudio);
|
||||||
|
}
|
||||||
|
if languages.iter().any(satisfies) {
|
||||||
|
return RuleEvaluation::Pass(RuleKind::RequiredAudio);
|
||||||
|
}
|
||||||
|
if unverified_could_satisfy(&languages) {
|
||||||
|
return RuleEvaluation::SoftFail(Rule::PortugueseUnverified);
|
||||||
|
}
|
||||||
|
if context.overrides.allow_english_audio && languages.contains(&english) {
|
||||||
|
return RuleEvaluation::SoftFail(Rule::RequiredAudio);
|
||||||
|
}
|
||||||
|
Self::failure(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -193,14 +363,20 @@ mod tests {
|
|||||||
use arr_parse::{NameClaims, Resolution as ClaimedResolution, Source as ClaimedSource};
|
use arr_parse::{NameClaims, Resolution as ClaimedResolution, Source as ClaimedSource};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{HdrFormat, HdrRules, PolicyId, RequiredAudio};
|
use crate::{AudioTrack, HdrFormat, HdrRules, PolicyId, RequiredAudio, SubtitleTrack};
|
||||||
|
|
||||||
|
fn en() -> Language {
|
||||||
|
Language::Other("en".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `main` root policy from `DESIGN.md` §5.2: require the original
|
||||||
|
/// language, blacklist pt-BR dubs.
|
||||||
fn policy() -> Policy {
|
fn policy() -> Policy {
|
||||||
Policy {
|
Policy {
|
||||||
id: PolicyId(1),
|
id: PolicyId(1),
|
||||||
name: "test".to_owned(),
|
name: "test".to_owned(),
|
||||||
required_audio: RequiredAudio::OriginalLanguage,
|
required_audio: RequiredAudio::OriginalLanguage,
|
||||||
dub_blacklist: Vec::new(),
|
dub_blacklist: vec![Language::PortugueseBrazil],
|
||||||
hdr_rules: HdrRules {
|
hdr_rules: HdrRules {
|
||||||
rejected_dolby_vision_profiles: Vec::new(),
|
rejected_dolby_vision_profiles: Vec::new(),
|
||||||
},
|
},
|
||||||
@@ -210,6 +386,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `kids` root policy from `DESIGN.md` §5.2: require pt-PT (or the
|
||||||
|
/// original language when that is already Portuguese), blacklist pt-BR.
|
||||||
|
fn kids_policy() -> Policy {
|
||||||
|
Policy {
|
||||||
|
required_audio: RequiredAudio::AnyOf(vec![Language::PortuguesePortugal]),
|
||||||
|
..policy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn claims(resolution: Option<ClaimedResolution>, source: Option<ClaimedSource>) -> NameClaims {
|
fn claims(resolution: Option<ClaimedResolution>, source: Option<ClaimedSource>) -> NameClaims {
|
||||||
NameClaims {
|
NameClaims {
|
||||||
resolution,
|
resolution,
|
||||||
@@ -218,18 +403,54 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn track(language: Language) -> AudioTrack {
|
||||||
|
AudioTrack {
|
||||||
|
language,
|
||||||
|
title: None,
|
||||||
|
handler_name: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn probed(resolution: Resolution, source: Option<Source>) -> ProbedMedia {
|
fn probed(resolution: Resolution, source: Option<Source>) -> ProbedMedia {
|
||||||
ProbedMedia {
|
ProbedMedia {
|
||||||
resolution,
|
resolution,
|
||||||
source,
|
source,
|
||||||
hdr: HdrFormat::Sdr,
|
hdr: HdrFormat::Sdr,
|
||||||
audio_tracks: Vec::new(),
|
audio_tracks: vec![track(en())],
|
||||||
subtitle_tracks: Vec::new(),
|
subtitle_tracks: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn probed_audio(tracks: Vec<AudioTrack>) -> ProbedMedia {
|
||||||
|
ProbedMedia {
|
||||||
|
audio_tracks: tracks,
|
||||||
|
..probed(Resolution::R2160p, Some(Source::WebDl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name_claims(markers: &[LanguageMarker]) -> NameClaims {
|
||||||
|
NameClaims {
|
||||||
|
languages: markers.to_vec(),
|
||||||
|
..claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn verdict(policy: &Policy, overrides: &MovieOverrides, candidate: Candidate<'_>) -> Verdict {
|
fn verdict(policy: &Policy, overrides: &MovieOverrides, candidate: Candidate<'_>) -> Verdict {
|
||||||
evaluate(policy, overrides, candidate).verdict
|
evaluate(policy, overrides, &en(), candidate).verdict
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verdict_for(
|
||||||
|
policy: &Policy,
|
||||||
|
original_language: &Language,
|
||||||
|
candidate: Candidate<'_>,
|
||||||
|
) -> Verdict {
|
||||||
|
evaluate(
|
||||||
|
policy,
|
||||||
|
&MovieOverrides::default(),
|
||||||
|
original_language,
|
||||||
|
candidate,
|
||||||
|
)
|
||||||
|
.verdict
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -239,6 +460,7 @@ mod tests {
|
|||||||
let report = evaluate(
|
let report = evaluate(
|
||||||
&policy,
|
&policy,
|
||||||
&MovieOverrides::default(),
|
&MovieOverrides::default(),
|
||||||
|
&en(),
|
||||||
Candidate::PreGrab(&claims),
|
Candidate::PreGrab(&claims),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -248,6 +470,8 @@ mod tests {
|
|||||||
vec![
|
vec![
|
||||||
RuleEvaluation::Unknown(RuleKind::Resolution),
|
RuleEvaluation::Unknown(RuleKind::Resolution),
|
||||||
RuleEvaluation::Unknown(RuleKind::Source),
|
RuleEvaluation::Unknown(RuleKind::Source),
|
||||||
|
RuleEvaluation::Unknown(RuleKind::DubBlacklist),
|
||||||
|
RuleEvaluation::Unknown(RuleKind::RequiredAudio),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -415,6 +639,7 @@ mod tests {
|
|||||||
let report = evaluate(
|
let report = evaluate(
|
||||||
&policy,
|
&policy,
|
||||||
&MovieOverrides::default(),
|
&MovieOverrides::default(),
|
||||||
|
&en(),
|
||||||
Candidate::PostDownload(&media),
|
Candidate::PostDownload(&media),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -438,10 +663,12 @@ mod tests {
|
|||||||
fn hard_failure_wins_over_an_earlier_soft_failure() {
|
fn hard_failure_wins_over_an_earlier_soft_failure() {
|
||||||
let policy = policy();
|
let policy = policy();
|
||||||
let overrides = MovieOverrides::default();
|
let overrides = MovieOverrides::default();
|
||||||
|
let original = en();
|
||||||
let claims = claims(None, None);
|
let claims = claims(None, None);
|
||||||
let context = EvaluationContext {
|
let context = EvaluationContext {
|
||||||
policy: &policy,
|
policy: &policy,
|
||||||
overrides: &overrides,
|
overrides: &overrides,
|
||||||
|
original_language: &original,
|
||||||
candidate: Candidate::PreGrab(&claims),
|
candidate: Candidate::PreGrab(&claims),
|
||||||
};
|
};
|
||||||
let soft = FixedRule {
|
let soft = FixedRule {
|
||||||
@@ -463,10 +690,12 @@ mod tests {
|
|||||||
fn first_failure_of_the_deciding_kind_is_stable() {
|
fn first_failure_of_the_deciding_kind_is_stable() {
|
||||||
let policy = policy();
|
let policy = policy();
|
||||||
let overrides = MovieOverrides::default();
|
let overrides = MovieOverrides::default();
|
||||||
|
let original = en();
|
||||||
let claims = claims(None, None);
|
let claims = claims(None, None);
|
||||||
let context = EvaluationContext {
|
let context = EvaluationContext {
|
||||||
policy: &policy,
|
policy: &policy,
|
||||||
overrides: &overrides,
|
overrides: &overrides,
|
||||||
|
original_language: &original,
|
||||||
candidate: Candidate::PreGrab(&claims),
|
candidate: Candidate::PreGrab(&claims),
|
||||||
};
|
};
|
||||||
let first = FixedRule {
|
let first = FixedRule {
|
||||||
@@ -483,4 +712,225 @@ mod tests {
|
|||||||
Verdict::Rejected(Rule::Other("first".to_owned()))
|
Verdict::Rejected(Rule::Other("first".to_owned()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §5.2 truth table, `main` root: require a track matching the original
|
||||||
|
// language; pt-BR is blacklisted as a dub only.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn main_original_language_track_passes() {
|
||||||
|
let media = probed_audio(vec![track(en())]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&policy(), &en(), Candidate::PostDownload(&media)),
|
||||||
|
Verdict::Eligible
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn main_ptbr_dub_of_a_foreign_title_is_rejected() {
|
||||||
|
let media = probed_audio(vec![track(Language::PortugueseBrazil)]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&policy(), &en(), Candidate::PostDownload(&media)),
|
||||||
|
Verdict::Rejected(Rule::DubBlacklist(Language::PortugueseBrazil))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn main_brazilian_films_own_soundtrack_passes() {
|
||||||
|
let original = Language::PortugueseBrazil;
|
||||||
|
let media = probed_audio(vec![track(Language::PortugueseBrazil)]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&policy(), &original, Candidate::PostDownload(&media)),
|
||||||
|
Verdict::Eligible
|
||||||
|
);
|
||||||
|
|
||||||
|
let claims = name_claims(&[LanguageMarker::PtBr]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&policy(), &original, Candidate::PreGrab(&claims)),
|
||||||
|
Verdict::Eligible
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn main_blacklisted_dub_may_ride_along_original_audio() {
|
||||||
|
let media = probed_audio(vec![track(en()), track(Language::PortugueseBrazil)]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&policy(), &en(), Candidate::PostDownload(&media)),
|
||||||
|
Verdict::Eligible
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn main_missing_original_audio_hard_fails() {
|
||||||
|
let media = probed_audio(vec![track(Language::Other("it".to_owned()))]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&policy(), &en(), Candidate::PostDownload(&media)),
|
||||||
|
Verdict::Rejected(Rule::RequiredAudio)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// §5.2 truth table, `kids` root: require pt-PT, or the original language
|
||||||
|
// when that is already Portuguese; pt-BR never satisfies the requirement.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kids_ptpt_track_passes() {
|
||||||
|
let media = probed_audio(vec![track(en()), track(Language::PortuguesePortugal)]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&kids_policy(), &en(), Candidate::PostDownload(&media)),
|
||||||
|
Verdict::Eligible
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kids_ptbr_dub_never_satisfies() {
|
||||||
|
let media = probed_audio(vec![track(Language::PortugueseBrazil)]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&kids_policy(), &en(), Candidate::PostDownload(&media)),
|
||||||
|
Verdict::Rejected(Rule::DubBlacklist(Language::PortugueseBrazil))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kids_ptbr_alongside_english_still_leaves_the_requirement_unmet() {
|
||||||
|
let media = probed_audio(vec![track(en()), track(Language::PortugueseBrazil)]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&kids_policy(), &en(), Candidate::PostDownload(&media)),
|
||||||
|
Verdict::Waived(Rule::RequiredAudio)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kids_portuguese_original_soundtrack_passes() {
|
||||||
|
for original in [Language::PortuguesePortugal, Language::PortugueseBrazil] {
|
||||||
|
let media = probed_audio(vec![track(original.clone())]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&kids_policy(), &original, Candidate::PostDownload(&media)),
|
||||||
|
Verdict::Eligible
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kids_missing_pt_audio_soft_fails() {
|
||||||
|
let media = probed_audio(vec![track(en())]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&kids_policy(), &en(), Candidate::PostDownload(&media)),
|
||||||
|
Verdict::Waived(Rule::RequiredAudio)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kids_allow_english_audio_grabs_and_imports_with_a_waiver() {
|
||||||
|
let policy = kids_policy();
|
||||||
|
let overrides = MovieOverrides {
|
||||||
|
allow_english_audio: true,
|
||||||
|
..MovieOverrides::default()
|
||||||
|
};
|
||||||
|
let claims = name_claims(&[LanguageMarker::English]);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(&policy, &overrides, &en(), Candidate::PreGrab(&claims)).verdict,
|
||||||
|
Verdict::Eligible
|
||||||
|
);
|
||||||
|
|
||||||
|
let media = probed_audio(vec![track(en())]);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(&policy, &overrides, &en(), Candidate::PostDownload(&media)).verdict,
|
||||||
|
Verdict::Waived(Rule::RequiredAudio)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// §5.2: subtitles carry no blacklist — pt-BR subtitles are always fine.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subtitles_are_never_blacklisted() {
|
||||||
|
let mut media = probed_audio(vec![track(Language::PortuguesePortugal)]);
|
||||||
|
media.subtitle_tracks = vec![SubtitleTrack {
|
||||||
|
language: Language::PortugueseBrazil,
|
||||||
|
}];
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&kids_policy(), &en(), Candidate::PostDownload(&media)),
|
||||||
|
Verdict::Eligible
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&policy(), &en(), Candidate::PostDownload(&media)),
|
||||||
|
Verdict::Rejected(Rule::RequiredAudio)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// §5.2 pre-grab: explicit language markers are hard filters.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_grab_ptbr_marked_release_is_rejected_for_a_foreign_title() {
|
||||||
|
let claims = arr_parse::parse("Movie.2024.1080p.WEB-DL.PT-BR.Dublado-GRP");
|
||||||
|
for policy in [policy(), kids_policy()] {
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&policy, &en(), Candidate::PreGrab(&claims)),
|
||||||
|
Verdict::Rejected(Rule::DubBlacklist(Language::PortugueseBrazil))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_grab_dual_audio_ptbr_release_stays_unknown() {
|
||||||
|
let claims = name_claims(&[LanguageMarker::PtBr, LanguageMarker::Dual]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&policy(), &en(), Candidate::PreGrab(&claims)),
|
||||||
|
Verdict::Eligible
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_grab_foreign_only_markers_fail_the_requirement() {
|
||||||
|
let claims = name_claims(&[LanguageMarker::Italian]);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&policy(), &en(), Candidate::PreGrab(&claims)),
|
||||||
|
Verdict::Rejected(Rule::RequiredAudio)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_for(&kids_policy(), &en(), Candidate::PreGrab(&claims)),
|
||||||
|
Verdict::Waived(Rule::RequiredAudio)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The issue's explicit acceptance case: an unresolvable Portuguese track
|
||||||
|
// is `por-unverified` — surfaced, never a guess in either direction.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unverified_portuguese_track_is_surfaced_not_guessed() {
|
||||||
|
let media = probed_audio(vec![track(Language::PortugueseUnverified)]);
|
||||||
|
|
||||||
|
// kids: could be the required pt-PT — must not reject as a dub.
|
||||||
|
let report = evaluate(
|
||||||
|
&kids_policy(),
|
||||||
|
&MovieOverrides::default(),
|
||||||
|
&en(),
|
||||||
|
Candidate::PostDownload(&media),
|
||||||
|
);
|
||||||
|
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||||
|
|
||||||
|
// main, Brazilian film: could be its own soundtrack — must not pass
|
||||||
|
// as verified either.
|
||||||
|
let report = evaluate(
|
||||||
|
&policy(),
|
||||||
|
&MovieOverrides::default(),
|
||||||
|
&Language::PortugueseBrazil,
|
||||||
|
Candidate::PostDownload(&media),
|
||||||
|
);
|
||||||
|
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pre_grab_bare_portuguese_marker_stays_unknown_for_kids() {
|
||||||
|
let claims = name_claims(&[LanguageMarker::Portuguese]);
|
||||||
|
let report = evaluate(
|
||||||
|
&kids_policy(),
|
||||||
|
&MovieOverrides::default(),
|
||||||
|
&en(),
|
||||||
|
Candidate::PreGrab(&claims),
|
||||||
|
);
|
||||||
|
assert_eq!(report.verdict, Verdict::Eligible);
|
||||||
|
assert_eq!(
|
||||||
|
report.rules[3],
|
||||||
|
RuleEvaluation::Unknown(RuleKind::RequiredAudio)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
arr-api = { workspace = true }
|
arr-api = { workspace = true }
|
||||||
|
arr-db = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod config;
|
|||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use arr_api::{AppState, Upstreams};
|
use arr_api::{AppState, Upstreams};
|
||||||
|
use arr_db::Db;
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
@@ -54,6 +55,10 @@ enum Error {
|
|||||||
Config(#[from] config::ConfigError),
|
Config(#[from] config::ConfigError),
|
||||||
#[error("http client: {0}")]
|
#[error("http client: {0}")]
|
||||||
HttpClient(#[from] reqwest::Error),
|
HttpClient(#[from] reqwest::Error),
|
||||||
|
#[error("database: {0}")]
|
||||||
|
Database(#[from] sqlx::Error),
|
||||||
|
#[error("database migration: {0}")]
|
||||||
|
Migration(#[from] sqlx::migrate::MigrateError),
|
||||||
#[error("bind {addr}: {source}")]
|
#[error("bind {addr}: {source}")]
|
||||||
Bind {
|
Bind {
|
||||||
addr: std::net::SocketAddr,
|
addr: std::net::SocketAddr,
|
||||||
@@ -65,12 +70,15 @@ enum Error {
|
|||||||
|
|
||||||
async fn run() -> Result<(), Error> {
|
async fn run() -> Result<(), Error> {
|
||||||
let config = Config::load()?;
|
let config = Config::load()?;
|
||||||
|
let database = Db::connect(&config.database_path).await?;
|
||||||
|
database.migrate().await?;
|
||||||
|
|
||||||
let state = AppState::new(
|
let state = AppState::new(
|
||||||
Upstreams::new(config.prowlarr_url, config.transmission_url)
|
Upstreams::new(config.prowlarr_url, config.transmission_url)
|
||||||
.with_prowlarr_api_key(config.prowlarr_api_key)
|
.with_prowlarr_api_key(config.prowlarr_api_key)
|
||||||
.with_tmdb_api_key(config.tmdb_api_key),
|
.with_tmdb_api_key(config.tmdb_api_key),
|
||||||
)?;
|
)?
|
||||||
|
.with_database(database);
|
||||||
|
|
||||||
let app = arr_api::router(state).layer(TraceLayer::new_for_http());
|
let app = arr_api::router(state).layer(TraceLayer::new_for_http());
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- A release can be returned for more than one title search. Keep the cached
|
||||||
|
-- release itself unique by indexer/guid and record each movie association.
|
||||||
|
CREATE TABLE movie_releases (
|
||||||
|
movie_id INTEGER NOT NULL REFERENCES movies (id) ON DELETE CASCADE,
|
||||||
|
release_id INTEGER NOT NULL REFERENCES releases (id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (movie_id, release_id)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE INDEX movie_releases_release ON movie_releases (release_id);
|
||||||
Reference in New Issue
Block a user