Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf36a4302c | |||
| 06a68372e6 | |||
| 4762f2612f | |||
| 468f31f0b0 | |||
| 571ec42e92 | |||
| db72cb4bcd | |||
| d5013d4ca8 | |||
| 7f9729fb57 | |||
| e335a1be73 | |||
| 82100ef7ab | |||
| 6826170431 | |||
| 4efc7b1833 | |||
| 353608ae63 | |||
| d86d975073 | |||
| 9129728622 | |||
| e4c115803b | |||
| 1d31ea7d1b | |||
| 7f7f26dbc7 | |||
| 11d2952863 | |||
| 00ff4db26a | |||
| 409967eef6 | |||
| 943f18cdf5 | |||
| 1af73caa34 | |||
| 71cbb9269b | |||
| 160c00c77f | |||
| 779d4b8504 | |||
| 64e711feec | |||
| a378b22306 | |||
| 04b2688330 | |||
| 91414a043f | |||
| a28b455fff | |||
| 9df120b92a | |||
| 03e7774869 | |||
| fea1a04c75 | |||
| 3956617d41 | |||
| d8316ae0c8 | |||
| 127338fd4d | |||
| 695f1882be | |||
| 8a77049fdb | |||
| 39e6bce2a0 | |||
| b0f56f59aa | |||
| 1eac85c427 | |||
| 50b68e24b6 | |||
| 69263da4f3 | |||
| b51386e004 | |||
| 17949c0848 | |||
| 4f848c27f0 | |||
| e4c49b75de | |||
| 00d0ec7f16 | |||
| adfc60154d | |||
| 8d79c7c378 | |||
| 34ccbeeb5a | |||
| 043ebffa1c | |||
| 8b9a6d5a16 | |||
| 3bcc612f9a | |||
| 384e260d72 | |||
| 0c58aae707 | |||
| 808145d73b | |||
| 360a4bf43c | |||
| 58f2f8361a | |||
| 9f8d58fd58 | |||
| 237696417b | |||
| e6d3ea4964 | |||
| 8b8e1dcec9 | |||
| bb75750548 | |||
| 24f5295e87 | |||
| 28e7dd9a20 | |||
| e3d41c24e1 | |||
| 8c7c785c74 | |||
| 7632140ef2 | |||
| 8ec1506098 | |||
| def9650489 | |||
| fa3b5226e7 | |||
| 75bf6fb14e | |||
| 42a0895975 | |||
| e8cea4aec5 | |||
| 135d4b8247 | |||
| b801bfbcc3 | |||
| 24f6dab109 | |||
| 93ccea2de0 | |||
| a1971ed82c | |||
| fc93d27f7e | |||
| b911f31b03 | |||
| 9f4943a01b | |||
| 1c9caa7478 | |||
| a5706cf1b4 | |||
| 46de061e68 | |||
| fa3f9cbd91 | |||
| 3bd613ec63 | |||
| 2fca8ec09f | |||
| da42467539 | |||
| 248870bcd0 | |||
| b96fa2486e | |||
| 0c87bf9436 | |||
| 71c2741d08 | |||
| 9fa1f0cc51 | |||
| 951669749b | |||
| 8f8431eed0 | |||
| 401afe5aad | |||
| aa6961f56c | |||
| a5b3680746 | |||
| 532eee7dbe |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"tracker": "gitea",
|
||||
"defaultBranch": "main",
|
||||
"commitScope": "arr",
|
||||
"remoteHost": "gitea",
|
||||
"remoteBaseUrl": "https://git.naps.pt",
|
||||
"buildCommand": "just ci",
|
||||
"contextFiles": ["CLAUDE.md", "DESIGN.md"],
|
||||
"prReviewers": [],
|
||||
"labels": []
|
||||
}
|
||||
@@ -17,8 +17,11 @@ reads the library as derived status, not flags.
|
||||
anywhere.
|
||||
- Structure (pinned by DESIGN.md §9.2): IN LIBRARY group first, ON TMDB below,
|
||||
engraved micro-label headings, machined rows, chips on the right.
|
||||
- Add flow: inline panel under a TMDB row — root radio cards (audience name,
|
||||
policy name, path), main pre-selected, one confirm. Never a separate page.
|
||||
- Add flow: inline panel under a TMDB row of either kind — root radio cards
|
||||
(audience name, policy name, path), main pre-selected, one confirm. Never a
|
||||
separate page. A series adds the one §4.1 add-time question: an auto-track
|
||||
pressed control (default on) with the plain line "future seasons are tracked
|
||||
automatically; past seasons are not", echoed in the added confirmation.
|
||||
- Release deck (§9.3, issue #31): a library row opens the buckets view.
|
||||
ELIGIBLE leads with fixed-width attribute chips in aligned mono columns
|
||||
(rem-width `.cw-*` classes shared by chips and the column header, so the
|
||||
@@ -59,7 +62,13 @@ reads the library as derived status, not flags.
|
||||
- Constraints: 250 ms debounce, AbortController cancellation, token-gated CSS,
|
||||
cyan for interaction only, dead-air rule (no idle animation), no horizontal
|
||||
scroll at any viewport (§9.3 acceptance criterion).
|
||||
- Unified search covers both kinds (issue 130): in-library hits read as
|
||||
whatever they are — a movie row, a series hit (display-only until the
|
||||
detail view of issue 129 exists) and an episode hit rendered as its series,
|
||||
an `SxxEyy` chip and the episode title in sans beside it. TMDB rows show TV
|
||||
alongside film; the "in library" dedup spans movies and series.
|
||||
- Unresolved: releases land in the persisted store only once the daemon
|
||||
consumes search commands (#68-adjacent); TMDB search is movie-only until
|
||||
series land (#39); per-season and per-episode library actions are #39;
|
||||
attention queues surface is #33.
|
||||
consumes search commands (#68-adjacent); episode hits have no link target
|
||||
until issue 129's series detail view exists — they render display-only for
|
||||
now; per-season and per-episode library actions are #39; attention queues
|
||||
surface is #33.
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b"
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.original_language\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE s.blocked = 0\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ORDER BY se.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0a3680a6cf353c210c1609bef5061f5026f61256c901564548d56f816dd4c68d"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0ab0de146b1cc4689359d64b9bc2ca5b86e68b7529b7ff1440015b3c2e3cd975"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO seasons (series_id, number, tracked) VALUES (?, ?, ?) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0aea0f15e8a03a37b5d9d32d3c7caffa70f9ec44f5a5da1dc0560f29f445ceab"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT count(*) AS \"count!: i64\" FROM roots WHERE policy_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "count!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0b17cf3d6cc89efacd65d11d15ee6086f857f6c7efff660ae4a958a9f4ce033a"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE tmdb_id = ? OR title LIKE ? ESCAPE '\\' ORDER BY title, year, id",
|
||||
"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, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY title, year, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -88,5 +88,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "540019d17ceabee5fb13df891a0bd0e5b37f7a94925155e78de98e14d71e1226"
|
||||
"hash": "0e1cffb9b344a41af0cc64bbe79b2bf303aae47593eaf8df5908a1df1480e4f6"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (\n SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT r.id AS \"id!: i64\",\n r.kind AS \"kind!: String\",\n r.audience AS \"audience!: String\",\n r.path AS \"path!: String\",\n p.id AS \"policy_id!: i64\",\n p.name AS \"policy_name!: String\"\n FROM roots r JOIN policies p ON p.id = r.policy_id\n WHERE r.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "kind!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "audience!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "policy_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "policy_name!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "115eae1f44b4eed8ea9e75666f1a03b642654d2d99a23b4319bd018e7389cdff"
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT e.id AS \"episode_id!: i64\", mf.path AS \"path!: String\", mf.size AS \"size!: i64\", mf.probed AS \"probed?: serde_json::Value\", json_extract(mf.waiver, '$.rule') AS \"waiver?: String\" FROM media_files mf JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY mf.path",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "episode_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "size!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "probed?: serde_json::Value",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "17c53bd3b8dee72d62a23d41b7bcd6329037fe678ed628ea3ee92d14c7692540"
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "episode_id!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT EXISTS(\n SELECT 1 FROM media_files f JOIN episodes e ON e.id = f.owner_id\n WHERE f.owner_kind = 'episode' AND e.season_id = ?\n ) AS \"exists!: bool\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "exists!: bool",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO policies (\n name, required_audio, dub_blacklist, hdr_rules,\n size_bands, resolution_pref, source_weights, score_weights\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "20ba3c269b7dab2f6cc624ffec40c454f20b778878f13bba8e4ab261d43c7480"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE seasons SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc"
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_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": "episode_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "episode_number!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.title AS \"title!: String\"\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE se.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "321ce3a18bd627b357ba6a98b958a8efb0c7af09fa6cff4b1f31fb963da58bd5"
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\",\n name AS \"name!: String\",\n required_audio AS \"required_audio!: String\",\n dub_blacklist AS \"dub_blacklist!: String\",\n hdr_rules AS \"hdr_rules!: String\",\n size_bands AS \"size_bands!: String\",\n resolution_pref AS \"resolution_pref!: String\",\n source_weights AS \"source_weights!: String\",\n score_weights AS \"score_weights!: String\"\n FROM policies ORDER BY id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "name!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "required_audio!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "dub_blacklist!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hdr_rules!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "size_bands!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "resolution_pref!: String",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "source_weights!: String",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "score_weights!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3437ff6e5b0262e3a298d3883ef16583c0dd62e9a25e955866c3b8690489443e"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE season_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "37622919b83dba5d63d2420fb0d8036f72347fbbbf8836e1ad495bfa66e6ebfc"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3775aa6dbb8d3ae7de48c3fb5dc81eca64ac5fa5b410cb14d885af2a137693b7"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM episodes WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3974fc9a150b9d1feb9401f7c8075a774d0570104d0b089fd2e5ed2e85e66f0c"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tracked AS \"tracked!: bool\" FROM seasons WHERE series_id = ? AND number = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "tracked!: bool",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3bc011ffdc966aa9ec540286bb728c4adf0257f1ab78a1cd23d27dd4dc2e0be1"
|
||||
}
|
||||
+22
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,38 +19,48 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 8,
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 9,
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -63,12 +73,14 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ae6d0463fd1bf373cbc9e0b5bbfc26e7934791024c216f876f940937dde45882"
|
||||
"hash": "3c4fdf30427dc972e695940a7a98cbc7c1da617502c818e5dbe0f35c87b175d5"
|
||||
}
|
||||
+22
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,38 +19,48 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 8,
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 9,
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -63,12 +73,14 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "024a22e1d6974b40f4fdd86e4ad732ce6de6e7c92b93bc3c835cc48a296021fb"
|
||||
"hash": "3ee558a18edee423eab6fe572f2a6f5bad8db69e0827e620264700f4affb3652"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT e.air_date,\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"on_disk!: bool\"\n FROM episodes e\n WHERE e.season_id = ?\n ORDER BY e.number\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "on_disk!: bool",
|
||||
"ordinal": 1,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3fc7d7e39520dc60fa81da21a4cae0c4a718e95f9718629f0aba9f3d00d179e1"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT EXISTS(\n SELECT 1 FROM media_files\n WHERE owner_kind = 'episode' AND owner_id = ?\n ) AS \"exists!: bool\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "exists!: bool",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "40f68614d733827b00499cb65a4c9b0219c18d2321720c060e1d22bb398d7d3d"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.title AS \"title!: String\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4322e03b45c5a7ec6be51a33107586dc4f7682ab4edbd483d36fedbc985a27f4"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT count(*) AS \"count!: i64\" FROM movies WHERE root_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "count!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4492267683b60d3104c3bf315cca56981adbecbb4722305cdad7241f4f0d8fec"
|
||||
}
|
||||
+19
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id, s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.auto_track AS \"auto_track!: bool\", s.overrides AS \"overrides!: serde_json::Value\", s.upstream_ended AS \"upstream_ended!: bool\", s.blocked AS \"blocked!: bool\" FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -14,43 +14,48 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"name": "tvdb_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 3,
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "root_id!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "auto_track!: bool",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "overrides!: serde_json::Value",
|
||||
"name": "auto_track!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "overrides!: serde_json::Value",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "upstream_ended!: bool",
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 9,
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
@@ -60,6 +65,7 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
@@ -70,5 +76,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f547ac991490ac5af5c16e707a2b52e83207a194e496dcaecfa253cdc0757463"
|
||||
"hash": "45d5b3f5e133289014b2f6e10fc14070b4b4ec2f18d97f7546f3b866004c404b"
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)\n ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5184ffb7e40d1660eb5c78971c278a2b2705c65da7c037e1c5af062bc53e0345"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.tvdb_id AS series_tvdb_id,\n s.original_language,\n (\n EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ) AS \"is_gap!: bool\"\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE se.id = ?\n AND s.blocked = 0\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "series_tvdb_id",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "is_gap!: bool",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "519d77ceec9ce13aa65abed28ea32db6568360e8c3fc7e31e968508d88c1bc67"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\" FROM seasons WHERE series_id = ? ORDER BY number",
|
||||
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\", vanished AS \"vanished!: bool\" FROM seasons WHERE series_id = ? ORDER BY number",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -22,6 +22,11 @@
|
||||
"name": "tracked!: bool",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -31,8 +36,9 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ecbf65c10aa20bc04c841981e59919485518877b49e9702fc92512f39cc2a72e"
|
||||
"hash": "583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM policies WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5cc813fb311c8d582b97320b0bebd17ad32bc3611a5f9dc98944591d9a596f26"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE policies SET\n name = ?, required_audio = ?, dub_blacklist = ?, hdr_rules = ?,\n size_bands = ?, resolution_pref = ?, source_weights = ?,\n score_weights = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 9
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "61a350e4122f566b91d26b7195a0b625b86d84662f9d74ca3b65f60ac1661d61"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE episodes SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "625b669e6a408eec81bbb235fb2eb3922336dfebee6f3fec7987fd538e472c34"
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT name AS \"name!: String\",\n required_audio AS \"required_audio!: String\",\n dub_blacklist AS \"dub_blacklist!: String\",\n hdr_rules AS \"hdr_rules!: String\",\n size_bands AS \"size_bands!: String\",\n resolution_pref AS \"resolution_pref!: String\",\n source_weights AS \"source_weights!: String\",\n score_weights AS \"score_weights!: String\"\n FROM policies WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "name!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "required_audio!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "dub_blacklist!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hdr_rules!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "size_bands!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "resolution_pref!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "source_weights!: String",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "score_weights!: String",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6b34f7c8d536d505c166f5b9b5a4db5fa3008b08e8e173b5ca21f51cca1dd1ee"
|
||||
}
|
||||
+22
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,38 +19,48 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 8,
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 9,
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -63,12 +73,14 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3b3165092d403797f233f7a926daab43aea1c78c508790a2cca230ce0cc73b26"
|
||||
"hash": "6bda2cef202e94b64f7bbcf88bbed823cca790c1439d94fba1c7dcea024d9467"
|
||||
}
|
||||
+19
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series WHERE id = ?",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -14,43 +14,48 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"name": "tvdb_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 3,
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "root_id!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "auto_track!: bool",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "overrides!: serde_json::Value",
|
||||
"name": "auto_track!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "overrides!: serde_json::Value",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "upstream_ended!: bool",
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 9,
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
@@ -60,6 +65,7 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
@@ -70,5 +76,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1a2db8b6fd48a7f3a32cd2ce33e4dbab9e46e99e004fd5a913e12ac6110071c2"
|
||||
"hash": "71534a7c100984bfe082a1348474263fa67c980e532fc037cc77b82c1109ecaa"
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.overrides AS \"overrides!: String\",\n r.id AS \"root_id!: i64\",\n r.kind AS \"root_kind!: String\",\n r.audience AS \"root_audience!: String\",\n r.path AS \"root_path!: String\",\n p.id AS \"policy_id!: i64\",\n p.name AS \"policy_name!: String\",\n p.required_audio AS \"required_audio!: String\",\n p.dub_blacklist AS \"dub_blacklist!: String\",\n p.hdr_rules AS \"hdr_rules!: String\",\n p.size_bands AS \"size_bands!: String\",\n p.resolution_pref AS \"resolution_pref!: String\",\n p.source_weights AS \"source_weights!: String\",\n p.score_weights AS \"score_weights!: String\"\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n JOIN roots r ON r.id = s.root_id\n JOIN policies p ON p.id = r.policy_id\n WHERE se.id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "overrides!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "root_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "root_kind!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "root_audience!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "root_path!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "policy_id!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "policy_name!: String",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "required_audio!: String",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "dub_blacklist!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "hdr_rules!: String",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "size_bands!: String",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "resolution_pref!: String",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "source_weights!: String",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "score_weights!: String",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "729af2ef365c572428b2ef1edcd0e287a243607735185be7d67fe3f7d7c3c506"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_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": "season_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE title LIKE ? ESCAPE '\\' ORDER BY title, year, id",
|
||||
"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, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY title, year, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -88,5 +88,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f7366d2949d5b64b24c4df9415cd4ea2c0a005f8b0f7152f95e0239f6401960b"
|
||||
"hash": "7b6b0d6bbfcd752bd3a9d97f8d64529bd7aab96ad817fdb9ac9829a525a9f1d7"
|
||||
}
|
||||
+19
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series ORDER BY title, year, id",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\" FROM series ORDER BY title, year, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -14,43 +14,48 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"name": "tvdb_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 3,
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "root_id!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "auto_track!: bool",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "overrides!: serde_json::Value",
|
||||
"name": "auto_track!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "overrides!: serde_json::Value",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "upstream_ended!: bool",
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 9,
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
@@ -60,6 +65,7 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
@@ -70,5 +76,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "950ae08a8a33d4ce3b088ae8ebac734baefdc2e6da90ac144d2dc0466e8cbe26"
|
||||
"hash": "7c76ab54e1c32a1e98cfc04ffb709382d09fc1eea9f1ddf3b613446a4c1bdb31"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT m.id AS \"id!: i64\",\n m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\",\n m.year,\n m.original_language,\n m.search_attempts AS \"search_attempts!: i64\",\n m.last_searched_at,\n m.digital_release,\n m.metadata_refreshed_at\n FROM movies m\n WHERE m.id = ?\n AND m.wanted = 1\n AND m.blocked = 0\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'movie' AND f.owner_id = m.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = m.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ",
|
||||
"query": "\n SELECT m.id AS \"id!: i64\",\n m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\",\n m.year,\n m.original_language,\n m.search_attempts AS \"search_attempts!: i64\",\n m.last_searched_at,\n m.digital_release,\n m.metadata_refreshed_at,\n (\n m.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'movie' AND f.owner_id = m.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = m.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ) AS \"is_gap!: i64\"\n FROM movies m\n WHERE m.id = ?\n AND m.blocked = 0\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -47,6 +47,11 @@
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "is_gap!: i64",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -61,8 +66,9 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8fce2d85078e69b81b084aabdc16e99bd7a609452c13502ae6287c489b512797"
|
||||
"hash": "7caab9e1f6676b11d8053d48df86cf3b60362a5c2007793fe1c2ff5a57fa0c40"
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT e.id AS \"id!: i64\",\n e.number AS \"number!: i64\",\n e.air_date,\n e.wanted AS \"wanted!: bool\",\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"has_file!: bool\",\n EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n ) AS \"in_flight!: bool\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.tvdb_id AS series_tvdb_id,\n s.original_language\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?\n AND s.blocked = 0\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "has_file!: bool",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "in_flight!: bool",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "series_tvdb_id",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "80210068b65f85f4e6840a98defa7271c33b1da4ec9f6389b8d7212be80c97ea"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT e.id AS \"id!: i64\",\n e.number AS \"episode!: i64\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ORDER BY e.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "episode!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_tmdb_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "90799bcbc272af8890296ca4f2d90843a8bc71c820b56847e0a86a9497f03952"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
|
||||
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -28,5 +28,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ae085c6b2b9f7b207bdd839384123195a45e264af3fd8e87408b7f65b1e0cc42"
|
||||
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM season_releases WHERE season_id = ? AND release_id = ?) AS 'exists!: bool'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "exists!: bool",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9758f269a51528417e6f75785441ade93fed055b6bcc4b5e7f579be5cc0aa829"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\"\n FROM seasons WHERE series_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "tracked!: bool",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9dbf884481041722d16521ef773d2c5cbb459b97e1a7a093d7c86617312cf289"
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\" FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.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": "blocked!: bool",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9fa86070879694b17a8b42e0717f70bd071b8169e6469f0d568754fc6b26c863"
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", number AS \"number!: i64\",\n title AS \"title!: String\", air_date,\n vanished AS \"vanished!: bool\"\n FROM episodes WHERE season_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a0653d4df0c32346bede52dbc3c6e501b3ef1e0d742e6eb06e1dc2fb4c524fb7"
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "episode_id!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a071c1bedfdd1347713cac523cb1aa9522dc9a07fb5d681655622971398a0ff4"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT r.id AS \"id!: i64\",\n r.indexer_id AS \"indexer_id!: i64\",\n r.guid AS \"guid!: String\",\n r.name AS \"name!: String\",\n r.download_url AS \"download_url!: String\",\n CAST(COALESCE(r.score, 0) AS INTEGER) AS \"score!: i64\"\n FROM releases r\n JOIN episode_releases er ON er.release_id = r.id\n WHERE er.episode_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')\n ",
|
||||
"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": "download_url!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "score!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a7d0c78949266aa91b0e685a804b3d455289dab8a6c505dfb8b388b1e7a8e9cf"
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_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": "episode_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "episode_number!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702"
|
||||
}
|
||||
+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 season_releases sr ON sr.release_id = r.id WHERE sr.season_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": "aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e"
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "episode_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "tag!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Null"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b4074ba8e74383de4a351f1fceae938fa952b522db9798039339b01e284636c2"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM seasons WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506"
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\" FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, s.year, s.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": "blocked!: bool",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c4bf148c4a953467fb16d7fc726e06d96575ba9ff2ef75bc75c32062430a50d7"
|
||||
}
|
||||
+11
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.title AS \"series_title!: String\", s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?",
|
||||
"query": "SELECT s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -9,18 +9,23 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"name": "series_tvdb_id",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 2,
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "episode_number!: i64",
|
||||
"ordinal": 3,
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
@@ -30,9 +35,10 @@
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6a164386b22fa587458007a843d0bfe7030ef0665dd989c1018dd83e2ec330cd"
|
||||
"hash": "c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d"
|
||||
}
|
||||
+17
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, s.root_id AS \"root_id!: i64\",\n r.path AS \"root_path!: String\",\n s.upstream_ended AS \"upstream_ended!: bool\",\n s.created_at AS \"created_at!: String\"\n FROM series s JOIN roots r ON r.id = s.root_id\n WHERE (?1 IS NULL OR s.id = ?1) AND (?2 IS NULL OR s.tmdb_id = ?2)\n ORDER BY s.title, s.year, s.id",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id,\n s.title AS \"title!: String\", s.year, s.root_id AS \"root_id!: i64\",\n r.path AS \"root_path!: String\",\n s.upstream_ended AS \"upstream_ended!: bool\",\n s.created_at AS \"created_at!: String\"\n FROM series s JOIN roots r ON r.id = s.root_id\n WHERE (?1 IS NULL OR s.id = ?1) AND (?2 IS NULL OR s.tmdb_id = ?2)\n ORDER BY s.title, s.year, s.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -14,33 +14,38 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"name": "tvdb_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "root_id!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "root_path!: String",
|
||||
"name": "root_id!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "root_path!: String",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "upstream_ended!: bool",
|
||||
"ordinal": 6,
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "created_at!: String",
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -50,6 +55,7 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
@@ -58,5 +64,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2934322298dad8dfe3583122fc56e10dd8785b11d41338c7936e47e94590ab37"
|
||||
"hash": "c724a7b776e5fcafadce90cb7bd235220cda3d8894d7cb5c2a411e816eb2ce0e"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE episodes SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "caa918385c5caf36e1ebec08b9d4f1f21dacc8cdae322af3d4af82224d5b96c8"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT count(*) AS \"count!: i64\" FROM series WHERE root_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "count!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "cbaa326819ded9bf6e74654b4ef40e9247723f3ce10ca95114bb2bee849957a0"
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.title AS \"title!: String\", s.year, s.original_language, s.root_id AS \"root_id!: i64\", s.blocked AS \"blocked!: bool\" FROM series s WHERE s.tmdb_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": "blocked!: bool",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ccf61e6cd96c0d048e054fdfa06af5a2551a87ccb172c02795e75129e35bfc63"
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT r.id AS \"id!: i64\",\n r.indexer_id AS \"indexer_id!: i64\",\n r.guid AS \"guid!: String\",\n r.name AS \"name!: String\",\n r.download_url AS \"download_url!: String\",\n CAST(COALESCE(r.score, 0) AS INTEGER) AS \"score!: i64\"\n FROM releases r\n JOIN season_releases sr ON sr.release_id = r.id\n WHERE sr.season_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')\n ",
|
||||
"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": "download_url!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "score!: i64",
|
||||
"ordinal": 5,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d102c8a0fde1dbd5a0e34898f01007d7cb65f063274a322702ffcfef133ea454"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE series SET tvdb_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d5c1583352fed8dc5d5aacc480d2e7fe732995590348d9f064690839a52ad7ea"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.original_language\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE se.id = ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d6d54d747b699ed28ebf5eb1ba5e73285778a186fd1da64a91ad18cd1c5c14d5"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d80dc4f116b7f7f45f79d3c3d8650cdd675926151aafa5d7a2377db7e7f3350e"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE seasons SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e1febfbde96af28337c6033b80348356c724775e9df246fc91f3edf899732a90"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO series (tmdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 9
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e69149d2ede23a1fd668de5163aa85192473cbfc4e68656a11c58d66184fdcd0"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE episodes SET title = ?, air_date = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND (title IS NOT ? OR air_date IS NOT ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e6aa8f0ac389fc8295ffd93c6f6ee020daf41a7d0315761e95221b3c49aee375"
|
||||
}
|
||||
+22
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,38 +19,48 @@
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 8,
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 9,
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
@@ -63,12 +73,14 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7fc4e02be63e223a60b1bf7d78f68aad4106abc0e17f7da6b98efd818ecdc372"
|
||||
"hash": "e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187"
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.tvdb_id AS series_tvdb_id,\n s.original_language\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE s.blocked = 0\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ORDER BY se.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "series_tvdb_id",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e8258c109f1d5a3fab62f702fbc274701e8b508de480e03bab593e449ef8d9d4"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM roots WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ec2c22c78df99b937819263742c5a1bd7dc57165d5353c2169210aafe2153a93"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT r.path AS \"path!: String\" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f513c2cc848ab8e42366ad83ad6fdb4d18ad0d6b5083221531fb70962670ef1d"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT blocked AS \"blocked!: bool\" FROM series WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "blocked!: bool",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f6dae636487a0d5eab2ee45c2ae455e065bfb670ce4c39a5bea04ceaff05780f"
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "tmdb_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "tvdb_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "year",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "root_id!: i64",
|
||||
"ordinal": 6,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "auto_track!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "upstream_ended!: bool",
|
||||
"ordinal": 8,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "metadata_refreshed_at",
|
||||
"ordinal": 9,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id FROM policies WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fd07d4f50079cc11154a6b1a605b315d3ce220f99dca9a727bb6e4b2eaa45edb"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE series SET upstream_ended = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ff5878dd2a037eeee877e749d0093554575d37ad27eb1946d1f93a46d6fafa85"
|
||||
}
|
||||
@@ -55,6 +55,12 @@ It is authored once by that skill and then treated as the source of truth;
|
||||
components consume tokens rather than literal values. `.impeccable/live/config.json`
|
||||
points the skill's live browser iteration at `web/index.html`.
|
||||
|
||||
Drive the browser with the `agent-browser` CLI, never `google-chrome --headless`
|
||||
directly. Raw headless Chrome times out on every `http://127.0.0.1` URL in this
|
||||
VM, which reads as "the machine cannot reach localhost" and tempts a session
|
||||
into skipping the visual pass. It is the tool, not the machine: `agent-browser
|
||||
open` and `agent-browser snapshot` against the same URL work.
|
||||
|
||||
`~/tea/arcada` has the same setup and is worth reading for the shape of a
|
||||
finished `design.json`.
|
||||
|
||||
|
||||
@@ -129,10 +129,20 @@ happening right now". It is user-maintained, so it drifts and stops meaning
|
||||
anything.
|
||||
|
||||
Here, intent is `Episode.wanted` and `Movie.wanted` only.
|
||||
`Series.auto_track` is not intent — it is a rule that says "when metadata
|
||||
reveals a new season, mark its episodes wanted". An untracked series where you
|
||||
`Series.auto_track` is not intent — it is a rule one level up: when metadata
|
||||
reveals a new season, that season becomes tracked. An untracked series where you
|
||||
manually marked S02 needs no special case: three wanted episodes, nothing else.
|
||||
|
||||
`Season.tracked` is also a rule, not intent. Turning tracking on marks every
|
||||
already-revealed episode in the season wanted, and keeps marking episodes
|
||||
wanted as metadata reveals them. Turning it off withdraws nothing: leaf intent
|
||||
is never removed implicitly, so anything already marked wanted stays wanted
|
||||
until cleared explicitly.
|
||||
|
||||
Both rules skip season 0. Specials are dozens of undated shorts and recaps
|
||||
that indexers do not carry, so `auto_track` never marks season 0 tracked. See
|
||||
§4.2 for how season 0 stays out of derived status.
|
||||
|
||||
### 4.2 Status is derived, never stored as intent
|
||||
|
||||
Recomputed on metadata refresh and on file change:
|
||||
@@ -150,6 +160,11 @@ behind one toggle. A one-off season grab therefore disappears from the default
|
||||
view by itself once satisfied, and a tracked show reappears by itself when a new
|
||||
season is announced. Nothing to remember to flip.
|
||||
|
||||
Season 0 is invisible to all of it: derived status ignores season 0 episodes
|
||||
entirely, so a manually wanted special cannot pin a series at `incomplete` or
|
||||
hold back `ended`. The accepted consequence is that specials are visible and
|
||||
grabbable in the series detail view, but never affect status.
|
||||
|
||||
### 4.3 People are tags, not paths
|
||||
|
||||
`Owner` is a many-to-many tag on titles. It drives UI filtering, the default
|
||||
@@ -261,6 +276,25 @@ tiebreaker. Seeders are log-scaled and small: enough to complete, past that it
|
||||
does not matter. Telesync, CAM and screener are **hard filters**, not low
|
||||
scores.
|
||||
|
||||
**How resolutions rank against each other.** Size is scored against the band
|
||||
for the release's own resolution, so on its own it says nothing across
|
||||
resolutions: an at-target 4K and an at-target 1080p both score the top of the
|
||||
size term, and a 4K a couple of gigabytes over target loses to a 1080p that is
|
||||
merely on target. That is wrong — `resolution_pref` is an ordered list, and the
|
||||
order is a preference, not just an eligibility filter.
|
||||
|
||||
So each step up `resolution_pref` is worth a fixed number of points. The last
|
||||
entry is worth nothing and every earlier one a step more. A resolution the list
|
||||
does not carry scores nothing rather than being penalised, the same as an
|
||||
unclaimed resolution: no ranking is no opinion.
|
||||
|
||||
The step is sized against the size term, not chosen in isolation. With the
|
||||
seeded 4K band — target 22 GB, 60 points per gigabyte over — a step of 300 is
|
||||
five gigabytes of overshoot: a 4K up to about 27 GB beats an at-target 1080p,
|
||||
and a bloated 40 GB 4K does not. Below target the same arithmetic asks a 4K to
|
||||
be within about four gigabytes of its target to win, which is what keeps an 8 GB
|
||||
4K that looks like mud from beating a good 1080p.
|
||||
|
||||
Exact numbers are policy rows, tuned by hand. The model is the decision.
|
||||
|
||||
### 5.6 Two phases of truth
|
||||
@@ -320,7 +354,12 @@ do ID-based search; some are text-only.
|
||||
|
||||
RSS is an empty-query Torznab call whose results are matched against the wanted
|
||||
list locally. Because its cost does not scale with the wanted list, every wanted
|
||||
item is matched against every RSS result forever.
|
||||
item is matched against every RSS result forever — with one guard: the RSS lane
|
||||
skips a season pack for a season that already has episodes on disk, the same
|
||||
guard §14 applies to re-grabs. Both lanes agree, so a season does not behave
|
||||
differently depending on which lane sees a release first. A pack for a season
|
||||
with nothing on disk stays eligible. When a single episode cannot be found on
|
||||
its own, the escape hatch is the season release deck, not a lane exception.
|
||||
|
||||
Targeted search backs off `1h → 6h → 1d → 3d`, capped at 7d, reset when the
|
||||
title's metadata changes. It never gives up entirely, it goes quiet.
|
||||
@@ -625,5 +664,7 @@ means that pipeline is already proven.
|
||||
"remuxes are bad" to "remux audio needs a downmix".
|
||||
- **Size band numbers** in §5.5 are placeholders pending that test.
|
||||
- **Season-pack re-grab.** When an airing season completes, the episodes are
|
||||
already present individually. Nothing re-grabs the pack. Whether that is ever
|
||||
wanted is unresolved and deliberately deferred.
|
||||
already present individually. Nothing re-grabs the pack, and per §6.2 the
|
||||
RSS lane obeys the same guard — it skips packs for any season with episodes
|
||||
on disk. Whether a re-grab is ever wanted remains unresolved and deliberately
|
||||
deferred.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
mod health;
|
||||
mod movies;
|
||||
mod owners;
|
||||
mod policies;
|
||||
mod roots;
|
||||
mod search;
|
||||
mod series;
|
||||
@@ -26,13 +27,18 @@ pub use movies::{
|
||||
Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie,
|
||||
};
|
||||
pub use owners::{CreateOwner, Owner, UpdateOwner};
|
||||
pub use roots::Root;
|
||||
pub use policies::{
|
||||
HdrRulesSpec, Policy, PolicyInput, RequiredAudioSpec, ScoreWeightsSpec, SizeBandSpec,
|
||||
};
|
||||
pub use roots::{Root, RootInput};
|
||||
pub use search::{ClassifiedRelease, SearchResponse};
|
||||
pub use series::{
|
||||
CreateEpisode, CreateSeason, CreateSeries, Episode, Season, Series, UpdateEpisode,
|
||||
UpdateSeason, UpdateSeries,
|
||||
};
|
||||
pub use state::{AppState, EpisodeCommand, MovieCommand, Upstreams, DEFAULT_TMDB_URL};
|
||||
pub use state::{
|
||||
AppState, EpisodeCommand, MovieCommand, SeasonCommand, Upstreams, DEFAULT_TMDB_URL,
|
||||
};
|
||||
|
||||
/// 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.
|
||||
@@ -55,6 +61,7 @@ pub const DOCS_PATH: &str = "/api/docs";
|
||||
(name = "movies", description = "Movie library and actions"),
|
||||
(name = "series", description = "Series, seasons and episodes (DESIGN.md §4.1, §4.2)"),
|
||||
(name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"),
|
||||
(name = "policies", description = "Quality policies (DESIGN.md §5)"),
|
||||
(name = "search", description = "Unified title and release search"),
|
||||
(name = "roots", description = "Root folders and their policies")
|
||||
),
|
||||
@@ -82,13 +89,20 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(series::search_episode))
|
||||
.routes(routes!(series::episode_releases))
|
||||
.routes(routes!(series::grab_episode))
|
||||
.routes(routes!(series::search_season))
|
||||
.routes(routes!(series::season_releases))
|
||||
.routes(routes!(series::grab_season_release))
|
||||
.routes(routes!(series::files))
|
||||
.routes(routes!(series::list_owners))
|
||||
.routes(routes!(series::tag_owner, series::untag_owner))
|
||||
.routes(routes!(owners::list, owners::create))
|
||||
.routes(routes!(owners::get, owners::update, owners::delete))
|
||||
.routes(routes!(search::search))
|
||||
.routes(routes!(search::releases))
|
||||
.routes(routes!(roots::list))
|
||||
.routes(routes!(roots::list, roots::create))
|
||||
.routes(routes!(roots::get, roots::update, roots::delete))
|
||||
.routes(routes!(policies::list, policies::create))
|
||||
.routes(routes!(policies::get, policies::update, policies::delete))
|
||||
}
|
||||
|
||||
/// The generated `OpenAPI` document.
|
||||
@@ -299,8 +313,25 @@ mod tests {
|
||||
("/api/movies/{movie_id}/search", "post"),
|
||||
("/api/movies/{movie_id}/releases", "get"),
|
||||
("/api/movies/{movie_id}/releases/{release_id}/grab", "post"),
|
||||
(
|
||||
"/api/series/{series_id}/seasons/{season_number}/search",
|
||||
"post",
|
||||
),
|
||||
(
|
||||
"/api/series/{series_id}/seasons/{season_number}/releases",
|
||||
"get",
|
||||
),
|
||||
(
|
||||
"/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab",
|
||||
"post",
|
||||
),
|
||||
("/api/queues/attention", "get"),
|
||||
("/api/series", "get"),
|
||||
("/api/policies", "get"),
|
||||
("/api/policies", "post"),
|
||||
("/api/policies/{policy_id}", "put"),
|
||||
("/api/policies/{policy_id}", "delete"),
|
||||
("/api/roots", "post"),
|
||||
] {
|
||||
assert!(
|
||||
json["paths"][path][method].is_object(),
|
||||
@@ -313,6 +344,7 @@ mod tests {
|
||||
"UpdateMovie",
|
||||
"Release",
|
||||
"AttentionQueues",
|
||||
"SeriesAttention",
|
||||
"Series",
|
||||
] {
|
||||
assert!(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use arr_core::policy::Candidate;
|
||||
use arr_core::score::score;
|
||||
use arr_core::{ParsedRelease, Policy};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
@@ -90,6 +93,41 @@ pub struct MovieFile {
|
||||
pub struct AttentionQueues {
|
||||
pub no_pt_source: Vec<Movie>,
|
||||
pub needs_decision: Vec<Movie>,
|
||||
/// TV side (§9.5): series whose wanted episodes found no Portuguese-audio
|
||||
/// source.
|
||||
pub tv_no_pt_source: Vec<SeriesAttention>,
|
||||
/// TV side: series put here by hard failures on episodes or season packs.
|
||||
pub tv_needs_decision: Vec<SeriesAttention>,
|
||||
}
|
||||
|
||||
/// An episode that put its series in a TV attention queue, named for the
|
||||
/// operator (`SxxEyy` comes from season and episode numbers, #140).
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct QueuedEpisode {
|
||||
pub id: i64,
|
||||
pub season_number: i64,
|
||||
pub episode_number: i64,
|
||||
}
|
||||
|
||||
/// A season whose failed pack put its series in a TV attention queue.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct QueuedSeason {
|
||||
pub id: i64,
|
||||
pub number: i64,
|
||||
}
|
||||
|
||||
/// A series in a TV attention queue, with what put it there. One row per
|
||||
/// series — the notification rolls up the same way (§9.5).
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SeriesAttention {
|
||||
pub series_id: i64,
|
||||
pub tmdb_id: i64,
|
||||
pub title: String,
|
||||
pub year: Option<i64>,
|
||||
/// Episodes that put the series in this queue.
|
||||
pub episodes: Vec<QueuedEpisode>,
|
||||
/// Seasons whose pack grab failed and fell back to per-episode grabbing.
|
||||
pub seasons: Vec<QueuedSeason>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -109,6 +147,8 @@ pub enum ApiError {
|
||||
SeasonNotFound,
|
||||
EpisodeNotFound,
|
||||
OwnerNotFound,
|
||||
PolicyNotFound,
|
||||
RootNotFound,
|
||||
Conflict(String),
|
||||
Invalid(String),
|
||||
Unavailable,
|
||||
@@ -127,6 +167,8 @@ impl IntoResponse for ApiError {
|
||||
Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string()),
|
||||
Self::EpisodeNotFound => (StatusCode::NOT_FOUND, "episode not found".to_string()),
|
||||
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
|
||||
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string()),
|
||||
Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string()),
|
||||
Self::Conflict(error) => (StatusCode::CONFLICT, error),
|
||||
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
|
||||
Self::Unavailable => (
|
||||
@@ -452,7 +494,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
|
||||
/// root, or the file itself when it sits in the root with no folder of its
|
||||
/// own. `None` when the file is not under the root at all, which is the
|
||||
/// guard that keeps a delete inside the library it belongs to.
|
||||
fn title_target(root: &str, file: &str) -> Option<std::path::PathBuf> {
|
||||
pub(crate) fn title_target(root: &str, file: &str) -> Option<std::path::PathBuf> {
|
||||
let root = std::path::Path::new(root);
|
||||
let relative = std::path::Path::new(file).strip_prefix(root).ok()?;
|
||||
let first = relative.components().next()?;
|
||||
@@ -503,12 +545,58 @@ pub async fn releases(
|
||||
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)
|
||||
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN 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?;
|
||||
let policy = state
|
||||
.database()
|
||||
.ok_or(ApiError::Unavailable)?
|
||||
.movie_policy(id)
|
||||
.await
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
.ok_or(ApiError::NotFound)?
|
||||
.policy;
|
||||
rescore(&mut releases, &policy)?;
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
/// Recompute each release's score against the current policy and re-sort
|
||||
/// (`DESIGN.md` §5.5). The stored `score` column is grab-time truth for the
|
||||
/// daemon's winner pick; the deck the user sees must reflect policy tuning
|
||||
/// made since the release was last searched, not whatever was true then.
|
||||
///
|
||||
/// Score magnitudes stay far below `f64`'s 52-bit mantissa (they are sums of
|
||||
/// policy weights in the thousands), so the `i64` -> `f64` cast into the
|
||||
/// column's storage type is exact.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
pub(crate) fn rescore(releases: &mut [Release], policy: &Policy) -> Result<(), ApiError> {
|
||||
let mut totals = Vec::with_capacity(releases.len());
|
||||
for release in releases.iter() {
|
||||
let parsed: ParsedRelease = serde_json::from_value(release.parsed.clone())
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?;
|
||||
let seeders = u32::try_from(release.seeders.unwrap_or(0)).unwrap_or(u32::MAX);
|
||||
let size = u64::try_from(release.size).unwrap_or(0);
|
||||
totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders).total);
|
||||
}
|
||||
let mut indices: Vec<usize> = (0..releases.len()).collect();
|
||||
indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i]));
|
||||
|
||||
for (release, &total) in releases.iter_mut().zip(&totals) {
|
||||
release.score = Some(total as f64);
|
||||
}
|
||||
let sorted: Vec<Release> = indices.into_iter().map(|i| releases[i].clone()).collect();
|
||||
releases.clone_from_slice(&sorted);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bucket(verdict: Option<&str>) -> u8 {
|
||||
match verdict {
|
||||
Some("eligible") => 0,
|
||||
Some("waived") => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/movies/{movie_id}/files", tag = "movies",
|
||||
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||
@@ -574,12 +662,190 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
|
||||
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, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" 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?;
|
||||
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
|
||||
|
||||
Ok(Json(AttentionQueues {
|
||||
no_pt_source,
|
||||
needs_decision,
|
||||
tv_no_pt_source,
|
||||
tv_needs_decision,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The TV lanes of the attention queues (§9.5): one entry per series with
|
||||
/// the episodes and seasons that put it there. The two hard-fail conditions
|
||||
/// share a lane; a series arriving through both is merged into one entry.
|
||||
async fn tv_attention(
|
||||
state: &AppState,
|
||||
) -> Result<(Vec<SeriesAttention>, Vec<SeriesAttention>), ApiError> {
|
||||
let database = pool(state)?;
|
||||
|
||||
let no_pt_rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||
s.title AS "title!: String", s.year,
|
||||
e.id AS "episode_id!: i64",
|
||||
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
|
||||
FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
JOIN roots root ON root.id = s.root_id
|
||||
WHERE root.audience = 'kids'
|
||||
AND s.blocked = 0
|
||||
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM episode_releases er
|
||||
JOIN releases r ON r.id = er.release_id
|
||||
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
|
||||
)
|
||||
ORDER BY se.number, e.number
|
||||
"#
|
||||
)
|
||||
.fetch_all(database)
|
||||
.await?;
|
||||
let episode_hard_fails = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||
s.title AS "title!: String", s.year,
|
||||
g.target_id AS "episode_id!: i64",
|
||||
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
|
||||
FROM grabs g
|
||||
JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#
|
||||
)
|
||||
.fetch_all(database)
|
||||
.await?;
|
||||
let season_pack_fails = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||
s.title AS "title!: String", s.year,
|
||||
g.target_id AS "season_id!: i64", se.number AS "season_number!: i64"
|
||||
FROM grabs g
|
||||
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number
|
||||
"#
|
||||
)
|
||||
.fetch_all(database)
|
||||
.await?;
|
||||
|
||||
let mut tv_no_pt_source: Vec<SeriesAttention> = Vec::new();
|
||||
for row in no_pt_rows {
|
||||
merge_episode(
|
||||
&mut tv_no_pt_source,
|
||||
row.series_id,
|
||||
row.tmdb_id,
|
||||
&row.title,
|
||||
row.year,
|
||||
row.episode_id,
|
||||
row.season_number,
|
||||
row.episode_number,
|
||||
);
|
||||
}
|
||||
|
||||
let mut tv_needs_decision: Vec<SeriesAttention> = Vec::new();
|
||||
for row in episode_hard_fails {
|
||||
merge_episode(
|
||||
&mut tv_needs_decision,
|
||||
row.series_id,
|
||||
row.tmdb_id,
|
||||
&row.title,
|
||||
row.year,
|
||||
row.episode_id,
|
||||
row.season_number,
|
||||
row.episode_number,
|
||||
);
|
||||
}
|
||||
for row in season_pack_fails {
|
||||
merge_season(
|
||||
&mut tv_needs_decision,
|
||||
row.series_id,
|
||||
row.tmdb_id,
|
||||
&row.title,
|
||||
row.year,
|
||||
row.season_id,
|
||||
row.season_number,
|
||||
);
|
||||
}
|
||||
|
||||
Ok((tv_no_pt_source, tv_needs_decision))
|
||||
}
|
||||
|
||||
/// One more qualifying season for its series, creating the series' entry on
|
||||
/// first sight.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn merge_season(
|
||||
entries: &mut Vec<SeriesAttention>,
|
||||
series_id: i64,
|
||||
tmdb_id: i64,
|
||||
title: &str,
|
||||
year: Option<i64>,
|
||||
season_id: i64,
|
||||
season_number: i64,
|
||||
) {
|
||||
match entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.series_id == series_id)
|
||||
{
|
||||
Some(entry) => entry.seasons.push(QueuedSeason {
|
||||
id: season_id,
|
||||
number: season_number,
|
||||
}),
|
||||
None => entries.push(SeriesAttention {
|
||||
series_id,
|
||||
tmdb_id,
|
||||
title: title.to_owned(),
|
||||
year,
|
||||
episodes: Vec::new(),
|
||||
seasons: vec![QueuedSeason {
|
||||
id: season_id,
|
||||
number: season_number,
|
||||
}],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// One more qualifying episode for its series, creating the series' entry on
|
||||
/// first sight.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn merge_episode(
|
||||
entries: &mut Vec<SeriesAttention>,
|
||||
series_id: i64,
|
||||
tmdb_id: i64,
|
||||
title: &str,
|
||||
year: Option<i64>,
|
||||
episode_id: i64,
|
||||
season_number: i64,
|
||||
episode_number: i64,
|
||||
) {
|
||||
let episode = QueuedEpisode {
|
||||
id: episode_id,
|
||||
season_number,
|
||||
episode_number,
|
||||
};
|
||||
if let Some(entry) = entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.series_id == series_id)
|
||||
{
|
||||
entry.episodes.push(episode);
|
||||
return;
|
||||
}
|
||||
entries.push(SeriesAttention {
|
||||
series_id,
|
||||
tmdb_id,
|
||||
title: title.to_owned(),
|
||||
year,
|
||||
episodes: vec![episode],
|
||||
seasons: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/movies/{movie_id}/owners", tag = "movies",
|
||||
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||
@@ -671,6 +937,7 @@ pub async fn untag_owner(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{router, Upstreams};
|
||||
use sqlx::Row;
|
||||
|
||||
async fn application() -> (tempfile::TempDir, AppState, String) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -956,6 +1223,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #114: a deck must score against the current policy, not whatever the
|
||||
/// score was when the release was last searched — tuning must reach what
|
||||
/// the user sees without a re-search.
|
||||
#[tokio::test]
|
||||
async fn the_deck_rescores_against_the_current_policy_not_the_stored_score() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let movie = add_movie(&base, 693_134, 1).await;
|
||||
let movie_id = movie["id"].as_i64().expect("id");
|
||||
let pool = state.database().expect("database").pool();
|
||||
|
||||
let name = "Dune Part Two 2024 1080p BluRay x264-GROUP";
|
||||
let parsed = arr_parse::parse(name);
|
||||
let size_bytes: u64 = 20 * (1 << 30);
|
||||
let seeders: u32 = 50;
|
||||
let size: i64 = i64::try_from(size_bytes).expect("size fits i64");
|
||||
|
||||
let release_id = sqlx::query(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
||||
VALUES (7, 'guid', ?, ?, ?, 'url', ?, -12345, 'eligible')",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(size)
|
||||
.bind(i64::from(seeders))
|
||||
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||
.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 policy = state
|
||||
.database()
|
||||
.expect("database")
|
||||
.movie_policy(movie_id)
|
||||
.await
|
||||
.expect("policy")
|
||||
.expect("movie has a policy")
|
||||
.policy;
|
||||
let expected = arr_core::score::score(
|
||||
&policy,
|
||||
arr_core::policy::Candidate::PreGrab(&parsed),
|
||||
size_bytes,
|
||||
seeders,
|
||||
)
|
||||
.total;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let expected = expected as f64;
|
||||
|
||||
let releases: Vec<serde_json::Value> =
|
||||
reqwest::get(format!("{base}/api/movies/{movie_id}/releases"))
|
||||
.await
|
||||
.expect("releases")
|
||||
.json()
|
||||
.await
|
||||
.expect("release json");
|
||||
|
||||
assert_ne!(releases[0]["score"].as_f64(), Some(-12345.0));
|
||||
assert_eq!(releases[0]["score"].as_f64(), Some(expected));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_full_action_queue_returns_service_unavailable() {
|
||||
let (_dir, state, base) = application().await;
|
||||
@@ -1065,4 +1397,152 @@ mod tests {
|
||||
assert_eq!(queues["no_pt_source"].as_array().map(Vec::len), Some(0));
|
||||
assert_eq!(queues["needs_decision"][0]["id"], movie_id);
|
||||
}
|
||||
|
||||
/// §9.5 TV: the attention queues report series, with the episodes and
|
||||
/// seasons that put each one there.
|
||||
#[tokio::test]
|
||||
async fn attention_queues_report_tv_series() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let (series_id, season_id, episode_id) = seed_queued_series(pool).await;
|
||||
|
||||
let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention"))
|
||||
.await
|
||||
.expect("queues")
|
||||
.json()
|
||||
.await
|
||||
.expect("queues json");
|
||||
let episode_ids: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? ORDER BY number")
|
||||
.bind(season_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("episode ids");
|
||||
|
||||
assert_eq!(queues["tv_no_pt_source"][0]["series_id"], series_id);
|
||||
assert_eq!(
|
||||
queues["tv_no_pt_source"][0]["episodes"],
|
||||
serde_json::json!([
|
||||
{ "id": episode_ids[0], "season_number": 1, "episode_number": 1 },
|
||||
{ "id": episode_ids[1], "season_number": 1, "episode_number": 2 },
|
||||
])
|
||||
);
|
||||
assert_eq!(queues["tv_needs_decision"][0]["series_id"], series_id);
|
||||
assert_eq!(
|
||||
queues["tv_needs_decision"][0]["episodes"],
|
||||
serde_json::json!([
|
||||
{ "id": episode_id, "season_number": 1, "episode_number": 3 },
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
queues["tv_needs_decision"][0]["seasons"],
|
||||
serde_json::json!([{ "id": season_id, "number": 1 }])
|
||||
);
|
||||
|
||||
// Blocking the series empties its no-PT-source lane.
|
||||
sqlx::query("UPDATE series SET blocked = 1 WHERE id = ?")
|
||||
.bind(series_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("block series");
|
||||
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["tv_no_pt_source"].as_array().map(Vec::len), Some(0));
|
||||
assert_eq!(
|
||||
queues["tv_needs_decision"].as_array().map(Vec::len),
|
||||
Some(1),
|
||||
"hard failures queue regardless of blocking"
|
||||
);
|
||||
}
|
||||
|
||||
/// One series hitting all three §9.5 TV entry conditions: two wanted,
|
||||
/// searched episodes whose every candidate was rejected for language; a
|
||||
/// season pack that hard-failed; and an episode two different releases
|
||||
/// hard-failed on.
|
||||
async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) {
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("kids tv root");
|
||||
let series_id: i64 = sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, year, root_id) VALUES (1, 'Bluey', 2018, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(root_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("series")
|
||||
.get(0);
|
||||
let season_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id",
|
||||
)
|
||||
.bind(series_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("season");
|
||||
|
||||
for number in [1, 2] {
|
||||
sqlx::query(
|
||||
"INSERT INTO episodes (season_id, number, title, wanted, state, search_attempts)
|
||||
VALUES (?, ?, ?, 1, 'missing', 1)",
|
||||
)
|
||||
.bind(season_id)
|
||||
.bind(number)
|
||||
.bind(format!("Episode {number}"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("episode");
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
||||
VALUES (?, 3, 'Episode 3', 1, 'missing')",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("episode");
|
||||
let episode_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 3")
|
||||
.bind(season_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("episode id");
|
||||
for (kind, guid, suffix) in [
|
||||
("season", "pack", "pack"),
|
||||
("episode", "first", "a"),
|
||||
("episode", "second", "b"),
|
||||
] {
|
||||
let release_id: i64 = sqlx::query(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (1, ?, 'release', 1, 'url', '{}', 'eligible') RETURNING id",
|
||||
)
|
||||
.bind(guid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("release")
|
||||
.get(0);
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
||||
VALUES (?, ?, ?, ?, 'failed')",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(kind)
|
||||
.bind(if kind == "season" {
|
||||
season_id
|
||||
} else {
|
||||
episode_id
|
||||
})
|
||||
.bind(format!("hash-{suffix}"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("failed grab");
|
||||
}
|
||||
|
||||
(series_id, season_id, episode_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
//! Policy CRUD (`DESIGN.md` §5.1, §10). Policies are DB rows tuned by hand;
|
||||
//! this surface replaces editing SQL inside the container. Payloads mirror
|
||||
//! the JSON columns the migrations store, validated against the shapes
|
||||
//! `arr-core` evaluates — an unknown resolution or a malformed field is a
|
||||
//! 422 naming the field, never a 500.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
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;
|
||||
|
||||
/// Which audio track a release must carry (`DESIGN.md` §5.2), expressed
|
||||
/// against the title's original language rather than a fixed list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(tag = "require", rename_all = "snake_case")]
|
||||
pub enum RequiredAudioSpec {
|
||||
OriginalLanguage,
|
||||
AnyOf { langs: Vec<String> },
|
||||
}
|
||||
|
||||
/// Dolby Vision profiles rejected post-probe (`DESIGN.md` §5.3). Profile
|
||||
/// numbers stay strings to match the stored column.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HdrRulesSpec {
|
||||
#[serde(default)]
|
||||
pub dv_profile_reject: Vec<String>,
|
||||
}
|
||||
|
||||
/// One resolution's size band (`DESIGN.md` §5.5), sizes in gibibytes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SizeBandSpec {
|
||||
pub floor_gib: u64,
|
||||
pub target_gib: u64,
|
||||
pub penalty_points_per_gib_over: i32,
|
||||
}
|
||||
|
||||
/// The scoring weights (`DESIGN.md` §5.5). `resolution_step` defaults to the
|
||||
/// engine's own value when absent, matching how `arr-db` reads older rows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ScoreWeightsSpec {
|
||||
pub size_at_target: i32,
|
||||
pub source_tier: i32,
|
||||
pub seeder_doubling: i32,
|
||||
#[serde(default = "default_resolution_step")]
|
||||
pub resolution_step: i32,
|
||||
}
|
||||
|
||||
fn default_resolution_step() -> i32 {
|
||||
arr_core::ScoreWeights::default().resolution_step
|
||||
}
|
||||
|
||||
/// A full policy document — one row of the `policies` table.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
|
||||
pub struct Policy {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub required_audio: RequiredAudioSpec,
|
||||
pub dub_blacklist: Vec<String>,
|
||||
pub hdr_rules: HdrRulesSpec,
|
||||
pub size_bands: BTreeMap<String, SizeBandSpec>,
|
||||
pub resolution_pref: Vec<String>,
|
||||
pub source_weights: BTreeMap<String, i32>,
|
||||
pub score_weights: ScoreWeightsSpec,
|
||||
}
|
||||
|
||||
/// The payload for creating or replacing a policy. Same shape as [`Policy`]
|
||||
/// minus the id.
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct PolicyInput {
|
||||
pub name: String,
|
||||
pub required_audio: RequiredAudioSpec,
|
||||
#[serde(default)]
|
||||
pub dub_blacklist: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub hdr_rules: HdrRulesSpec,
|
||||
#[serde(default)]
|
||||
pub size_bands: BTreeMap<String, SizeBandSpec>,
|
||||
#[serde(default)]
|
||||
pub resolution_pref: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub source_weights: BTreeMap<String, i32>,
|
||||
pub score_weights: ScoreWeightsSpec,
|
||||
}
|
||||
|
||||
impl PolicyInput {
|
||||
/// Validate against the vocabulary the policy engine knows. Every
|
||||
/// failure names its field so a rejected edit is fixable without
|
||||
/// reading the schema.
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("name: must not be empty".into());
|
||||
}
|
||||
if let RequiredAudioSpec::AnyOf { langs } = &self.required_audio {
|
||||
if langs.is_empty() {
|
||||
return Err("required_audio.langs: any_of needs at least one language".into());
|
||||
}
|
||||
if langs.iter().any(String::is_empty) {
|
||||
return Err("required_audio.langs: language tags must not be empty".into());
|
||||
}
|
||||
}
|
||||
for lang in &self.dub_blacklist {
|
||||
if lang.is_empty() {
|
||||
return Err("dub_blacklist: language tags must not be empty".into());
|
||||
}
|
||||
}
|
||||
for profile in &self.hdr_rules.dv_profile_reject {
|
||||
if profile.parse::<u8>().is_err() {
|
||||
return Err(format!(
|
||||
"hdr_rules.dv_profile_reject: '{profile}' is not a profile number"
|
||||
));
|
||||
}
|
||||
}
|
||||
for resolution in self.size_bands.keys() {
|
||||
if arr_db::policy::resolution_value(resolution).is_none() {
|
||||
return Err(format!("size_bands: unknown resolution '{resolution}'"));
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for resolution in &self.resolution_pref {
|
||||
if arr_db::policy::resolution_value(resolution).is_none() {
|
||||
return Err(format!(
|
||||
"resolution_pref: unknown resolution '{resolution}'"
|
||||
));
|
||||
}
|
||||
if !seen.insert(resolution.as_str()) {
|
||||
return Err(format!("resolution_pref: '{resolution}' appears twice"));
|
||||
}
|
||||
}
|
||||
for source in self.source_weights.keys() {
|
||||
if arr_db::policy::source_value(source).is_none() {
|
||||
return Err(format!("source_weights: unknown source tier '{source}'"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_columns(self, name: String) -> Result<PolicyColumns, ApiError> {
|
||||
fn json(value: impl serde::Serialize) -> Result<String, ApiError> {
|
||||
serde_json::to_string(&value).map_err(|error| {
|
||||
tracing::error!(%error, "policy serialisation failed");
|
||||
ApiError::Database("serialisation failed".into())
|
||||
})
|
||||
}
|
||||
Ok(PolicyColumns {
|
||||
name,
|
||||
required_audio: json(&self.required_audio)?,
|
||||
dub_blacklist: json(self.dub_blacklist)?,
|
||||
hdr_rules: json(self.hdr_rules)?,
|
||||
size_bands: json(self.size_bands)?,
|
||||
resolution_pref: json(self.resolution_pref)?,
|
||||
source_weights: json(self.source_weights)?,
|
||||
score_weights: json(self.score_weights)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A policy row as the table stores it, before JSON parsing.
|
||||
struct PolicyColumns {
|
||||
name: String,
|
||||
required_audio: String,
|
||||
dub_blacklist: String,
|
||||
hdr_rules: String,
|
||||
size_bands: String,
|
||||
resolution_pref: String,
|
||||
source_weights: String,
|
||||
score_weights: String,
|
||||
}
|
||||
|
||||
fn column<T: serde::de::DeserializeOwned>(
|
||||
column: &'static str,
|
||||
value: &str,
|
||||
) -> Result<T, ApiError> {
|
||||
serde_json::from_str(value).map_err(|error| {
|
||||
tracing::error!(column, %error, "policy column holds unexpected JSON");
|
||||
ApiError::Database(format!("policy column {column} is not valid"))
|
||||
})
|
||||
}
|
||||
|
||||
impl PolicyColumns {
|
||||
fn into_policy(self, id: i64) -> Result<Policy, ApiError> {
|
||||
Ok(Policy {
|
||||
id,
|
||||
name: self.name,
|
||||
required_audio: column("required_audio", &self.required_audio)?,
|
||||
dub_blacklist: column("dub_blacklist", &self.dub_blacklist)?,
|
||||
hdr_rules: column("hdr_rules", &self.hdr_rules)?,
|
||||
size_bands: column("size_bands", &self.size_bands)?,
|
||||
resolution_pref: column("resolution_pref", &self.resolution_pref)?,
|
||||
source_weights: column("source_weights", &self.source_weights)?,
|
||||
score_weights: column("score_weights", &self.score_weights)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The extracted body, whether or not it parsed. Both malformed JSON and a
|
||||
/// shape mismatch answer 422 with the parser's own field-naming message.
|
||||
pub(crate) fn parsed<T>(payload: Result<Json<T>, JsonRejection>) -> Result<T, ApiError> {
|
||||
payload
|
||||
.map(|Json(value)| value)
|
||||
.map_err(|rejection| ApiError::Invalid(rejection.body_text()))
|
||||
}
|
||||
|
||||
fn is_unique_violation(error: &sqlx::Error) -> bool {
|
||||
error
|
||||
.as_database_error()
|
||||
.is_some_and(sqlx::error::DatabaseError::is_unique_violation)
|
||||
}
|
||||
|
||||
async fn load_policy(state: &AppState, id: i64) -> Result<Policy, ApiError> {
|
||||
let row = sqlx::query_as!(
|
||||
PolicyColumns,
|
||||
r#"SELECT name AS "name!: String",
|
||||
required_audio AS "required_audio!: String",
|
||||
dub_blacklist AS "dub_blacklist!: String",
|
||||
hdr_rules AS "hdr_rules!: String",
|
||||
size_bands AS "size_bands!: String",
|
||||
resolution_pref AS "resolution_pref!: String",
|
||||
source_weights AS "source_weights!: String",
|
||||
score_weights AS "score_weights!: String"
|
||||
FROM policies WHERE id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.ok_or(ApiError::PolicyNotFound)?;
|
||||
row.into_policy(id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/policies", tag = "policies",
|
||||
responses(
|
||||
(status = 200, body = [Policy]),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Policy>>, ApiError> {
|
||||
let rows = sqlx::query_as!(
|
||||
PolicyRow,
|
||||
r#"SELECT id AS "id!: i64",
|
||||
name AS "name!: String",
|
||||
required_audio AS "required_audio!: String",
|
||||
dub_blacklist AS "dub_blacklist!: String",
|
||||
hdr_rules AS "hdr_rules!: String",
|
||||
size_bands AS "size_bands!: String",
|
||||
resolution_pref AS "resolution_pref!: String",
|
||||
source_weights AS "source_weights!: String",
|
||||
score_weights AS "score_weights!: String"
|
||||
FROM policies ORDER BY id"#
|
||||
)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
let mut policies = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
policies.push(row.into_policy()?);
|
||||
}
|
||||
Ok(Json(policies))
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct PolicyRow {
|
||||
id: i64,
|
||||
name: String,
|
||||
required_audio: String,
|
||||
dub_blacklist: String,
|
||||
hdr_rules: String,
|
||||
size_bands: String,
|
||||
resolution_pref: String,
|
||||
source_weights: String,
|
||||
score_weights: String,
|
||||
}
|
||||
|
||||
impl PolicyRow {
|
||||
fn into_policy(self) -> Result<Policy, ApiError> {
|
||||
PolicyColumns {
|
||||
name: self.name,
|
||||
required_audio: self.required_audio,
|
||||
dub_blacklist: self.dub_blacklist,
|
||||
hdr_rules: self.hdr_rules,
|
||||
size_bands: self.size_bands,
|
||||
resolution_pref: self.resolution_pref,
|
||||
source_weights: self.source_weights,
|
||||
score_weights: self.score_weights,
|
||||
}
|
||||
.into_policy(self.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/policies/{policy_id}", tag = "policies",
|
||||
params(("policy_id" = i64, Path, description = "Policy row id")),
|
||||
responses(
|
||||
(status = 200, body = Policy),
|
||||
(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<Policy>, ApiError> {
|
||||
Ok(Json(load_policy(&state, id).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/policies", tag = "policies", request_body = PolicyInput,
|
||||
responses(
|
||||
(status = 201, body = Policy),
|
||||
(status = 409, body = ErrorBody),
|
||||
(status = 422, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
body: Result<Json<PolicyInput>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<Policy>), ApiError> {
|
||||
let input = parsed(body)?;
|
||||
input.validate().map_err(ApiError::Invalid)?;
|
||||
let name = input.name.trim().to_owned();
|
||||
let columns = input.into_columns(name)?;
|
||||
let result = sqlx::query!(
|
||||
r#"INSERT INTO policies (
|
||||
name, required_audio, dub_blacklist, hdr_rules,
|
||||
size_bands, resolution_pref, source_weights, score_weights
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
columns.name,
|
||||
columns.required_audio,
|
||||
columns.dub_blacklist,
|
||||
columns.hdr_rules,
|
||||
columns.size_bands,
|
||||
columns.resolution_pref,
|
||||
columns.source_weights,
|
||||
columns.score_weights,
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if is_unique_violation(&error) {
|
||||
ApiError::Conflict("a policy with this name already exists".into())
|
||||
} else {
|
||||
ApiError::from(error)
|
||||
}
|
||||
})?;
|
||||
let policy = load_policy(&state, result.last_insert_rowid()).await?;
|
||||
Ok((StatusCode::CREATED, Json(policy)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/policies/{policy_id}", tag = "policies", request_body = PolicyInput,
|
||||
params(("policy_id" = i64, Path, description = "Policy row id")),
|
||||
responses(
|
||||
(status = 200, body = Policy),
|
||||
(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>,
|
||||
body: Result<Json<PolicyInput>, JsonRejection>,
|
||||
) -> Result<Json<Policy>, ApiError> {
|
||||
let input = parsed(body)?;
|
||||
input.validate().map_err(ApiError::Invalid)?;
|
||||
let name = input.name.trim().to_owned();
|
||||
let columns = input.into_columns(name)?;
|
||||
let result = sqlx::query!(
|
||||
r#"UPDATE policies SET
|
||||
name = ?, required_audio = ?, dub_blacklist = ?, hdr_rules = ?,
|
||||
size_bands = ?, resolution_pref = ?, source_weights = ?,
|
||||
score_weights = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?"#,
|
||||
columns.name,
|
||||
columns.required_audio,
|
||||
columns.dub_blacklist,
|
||||
columns.hdr_rules,
|
||||
columns.size_bands,
|
||||
columns.resolution_pref,
|
||||
columns.source_weights,
|
||||
columns.score_weights,
|
||||
id,
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if is_unique_violation(&error) {
|
||||
ApiError::Conflict("a policy with this name already exists".into())
|
||||
} else {
|
||||
ApiError::from(error)
|
||||
}
|
||||
})?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::PolicyNotFound);
|
||||
}
|
||||
Ok(Json(load_policy(&state, id).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/policies/{policy_id}", tag = "policies",
|
||||
params(("policy_id" = i64, Path, description = "Policy 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 references: i64 = sqlx::query_scalar!(
|
||||
r#"SELECT count(*) AS "count!: i64" FROM roots WHERE policy_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool(&state)?)
|
||||
.await?;
|
||||
if references > 0 {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"this policy is attached to {references} root{} — detach it first",
|
||||
if references == 1 { "" } else { "s" }
|
||||
)));
|
||||
}
|
||||
let result = sqlx::query!("DELETE FROM policies WHERE id = ?", id)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::PolicyNotFound);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{router, Upstreams};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
async fn application() -> (tempfile::TempDir, 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);
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
||||
(dir, format!("http://{address}"))
|
||||
}
|
||||
|
||||
fn valid_input(name: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"required_audio": { "require": "any_of", "langs": ["pt-PT"] },
|
||||
"dub_blacklist": ["pt-BR"],
|
||||
"hdr_rules": { "dv_profile_reject": ["5", "7"] },
|
||||
"size_bands": {
|
||||
"2160p": { "floor_gib": 8, "target_gib": 22, "penalty_points_per_gib_over": 60 },
|
||||
"1080p": { "floor_gib": 3, "target_gib": 8, "penalty_points_per_gib_over": 60 }
|
||||
},
|
||||
"resolution_pref": ["2160p", "1080p"],
|
||||
"source_weights": { "WEB-DL": 4, "Remux": 1 },
|
||||
"score_weights": {
|
||||
"size_at_target": 1000, "source_tier": 25,
|
||||
"seeder_doubling": 8, "resolution_step": 300
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn create(base: &str, payload: serde_json::Value) -> reqwest::Response {
|
||||
reqwest::Client::new()
|
||||
.post(format!("{base}/api/policies"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("create policy")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crud_round_trips_a_policy() {
|
||||
let (_dir, base) = application().await;
|
||||
|
||||
let created: serde_json::Value = create(&base, valid_input("test policy"))
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.expect("created json");
|
||||
assert_eq!(created["name"], "test policy");
|
||||
assert_eq!(created["required_audio"]["require"], "any_of");
|
||||
assert_eq!(created["score_weights"]["resolution_step"], 300);
|
||||
let id = created["id"].as_i64().expect("id");
|
||||
|
||||
let listed: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
|
||||
.await
|
||||
.expect("list")
|
||||
.json()
|
||||
.await
|
||||
.expect("list json");
|
||||
assert_eq!(listed.len(), 5, "four seeded policies plus the new one");
|
||||
|
||||
let fetched: serde_json::Value = reqwest::get(format!("{base}/api/policies/{id}"))
|
||||
.await
|
||||
.expect("get")
|
||||
.json()
|
||||
.await
|
||||
.expect("get json");
|
||||
assert_eq!(fetched, created);
|
||||
|
||||
let mut replacement = valid_input("renamed");
|
||||
replacement["resolution_pref"] = serde_json::json!(["1080p"]);
|
||||
let updated: serde_json::Value = reqwest::Client::new()
|
||||
.put(format!("{base}/api/policies/{id}"))
|
||||
.json(&replacement)
|
||||
.send()
|
||||
.await
|
||||
.expect("update")
|
||||
.json()
|
||||
.await
|
||||
.expect("updated json");
|
||||
assert_eq!(updated["name"], "renamed");
|
||||
assert_eq!(updated["resolution_pref"], serde_json::json!(["1080p"]));
|
||||
|
||||
let deleted = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/policies/{id}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete");
|
||||
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||
let gone = reqwest::get(format!("{base}/api/policies/{id}"))
|
||||
.await
|
||||
.expect("get deleted");
|
||||
assert_eq!(gone.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_referenced_policy_refuses_to_die() {
|
||||
let (_dir, base) = application().await;
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
.expect("roots")
|
||||
.json()
|
||||
.await
|
||||
.expect("roots json");
|
||||
let policy_id = roots[0]["policy_id"].as_i64().expect("policy id");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/policies/{policy_id}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete referenced policy");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
let body: serde_json::Value = response.json().await.expect("error body");
|
||||
assert!(
|
||||
body["error"].as_str().expect("error text").contains("root"),
|
||||
"the error names the problem: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_resolution_is_a_422_naming_the_field() {
|
||||
let (_dir, base) = application().await;
|
||||
let mut payload = valid_input("bad bands");
|
||||
payload["size_bands"]["1440p"] = serde_json::json!({ "floor_gib": 2, "target_gib": 6, "penalty_points_per_gib_over": 60 });
|
||||
|
||||
let response = create(&base, payload).await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body: serde_json::Value = response.json().await.expect("error body");
|
||||
let error = body["error"].as_str().expect("error text");
|
||||
assert!(error.contains("size_bands"), "{error}");
|
||||
assert!(error.contains("1440p"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_field_validates_by_name() {
|
||||
let (_dir, base) = application().await;
|
||||
let with = |patch: &dyn Fn(&mut serde_json::Value)| {
|
||||
let mut payload = valid_input("validation probe");
|
||||
patch(&mut payload);
|
||||
payload
|
||||
};
|
||||
let cases: Vec<(serde_json::Value, &str)> = vec![
|
||||
(
|
||||
with(&|payload| payload["name"] = serde_json::json!("")),
|
||||
"name",
|
||||
),
|
||||
(
|
||||
with(&|payload| {
|
||||
payload["required_audio"] =
|
||||
serde_json::json!({ "require": "any_of", "langs": [] });
|
||||
}),
|
||||
"required_audio",
|
||||
),
|
||||
(
|
||||
with(&|payload| {
|
||||
payload["hdr_rules"] = serde_json::json!({ "dv_profile_reject": ["nine"] });
|
||||
}),
|
||||
"dv_profile_reject",
|
||||
),
|
||||
(
|
||||
with(&|payload| {
|
||||
payload["resolution_pref"] = serde_json::json!(["2160p", "2160p"]);
|
||||
}),
|
||||
"resolution_pref",
|
||||
),
|
||||
(
|
||||
with(&|payload| {
|
||||
payload["source_weights"] = serde_json::json!({ "LaserDisc": 3 });
|
||||
}),
|
||||
"source_weights",
|
||||
),
|
||||
];
|
||||
|
||||
for (payload, field) in cases {
|
||||
let response = create(&base, payload).await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body: serde_json::Value = response.json().await.expect("error body");
|
||||
let error = body["error"].as_str().expect("error text");
|
||||
assert!(error.contains(field), "{field}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_json_is_422_not_400_or_500() {
|
||||
let (_dir, base) = application().await;
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{base}/api/policies"))
|
||||
.header("content-type", "application/json")
|
||||
.body("{not json")
|
||||
.send()
|
||||
.await
|
||||
.expect("malformed create");
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let response = client
|
||||
.post(format!("{base}/api/policies"))
|
||||
.json(&serde_json::json!({ "name": 42 }))
|
||||
.send()
|
||||
.await
|
||||
.expect("wrong-shape create");
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_duplicate_name_conflicts() {
|
||||
let (_dir, base) = application().await;
|
||||
let response = create(&base, valid_input("Movies — main")).await;
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
}
|
||||
+425
-10
@@ -1,9 +1,15 @@
|
||||
use axum::extract::State;
|
||||
//! Root folders (`DESIGN.md` §5.1): kind, audience, path and the one policy
|
||||
//! attached. Reads serve the add flow (§9.2); writes are the settings view's.
|
||||
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::movies::{ApiError, ErrorBody};
|
||||
use crate::movies::{pool, ApiError, ErrorBody};
|
||||
use crate::policies::parsed;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// A root folder joined with its policy, enough for the add flow to pre-fill
|
||||
@@ -18,6 +24,67 @@ pub struct Root {
|
||||
pub policy_name: String,
|
||||
}
|
||||
|
||||
/// The payload for creating or replacing a root.
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct RootInput {
|
||||
/// `movie` or `tv` — the Transmission label and layout prefix (§7.1, §7.4).
|
||||
pub kind: String,
|
||||
/// `main` or `kids`.
|
||||
pub audience: String,
|
||||
pub path: String,
|
||||
pub policy_id: i64,
|
||||
}
|
||||
|
||||
impl RootInput {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if self.kind != "movie" && self.kind != "tv" {
|
||||
return Err(format!("kind: '{}' is neither movie nor tv", self.kind));
|
||||
}
|
||||
if self.audience != "main" && self.audience != "kids" {
|
||||
return Err(format!(
|
||||
"audience: '{}' is neither main nor kids",
|
||||
self.audience
|
||||
));
|
||||
}
|
||||
if self.path.trim().is_empty() {
|
||||
return Err("path: must not be empty".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn policy_exists(&self, state: &AppState) -> Result<(), ApiError> {
|
||||
let exists: Option<i64> =
|
||||
sqlx::query_scalar!("SELECT id FROM policies WHERE id = ?", self.policy_id)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?;
|
||||
if exists.is_none() {
|
||||
return Err(ApiError::Invalid(format!(
|
||||
"policy_id: no policy {}",
|
||||
self.policy_id
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_root(state: &AppState, id: i64) -> Result<Root, ApiError> {
|
||||
sqlx::query_as!(
|
||||
Root,
|
||||
r#"SELECT r.id AS "id!: i64",
|
||||
r.kind AS "kind!: String",
|
||||
r.audience AS "audience!: String",
|
||||
r.path AS "path!: String",
|
||||
p.id AS "policy_id!: i64",
|
||||
p.name AS "policy_name!: String"
|
||||
FROM roots r JOIN policies p ON p.id = r.policy_id
|
||||
WHERE r.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.ok_or(ApiError::RootNotFound)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/roots", tag = "roots",
|
||||
responses(
|
||||
@@ -27,23 +94,171 @@ pub struct Root {
|
||||
)
|
||||
)]
|
||||
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Root>>, ApiError> {
|
||||
let database = state.database().ok_or(ApiError::Unavailable)?;
|
||||
let roots = sqlx::query_as!(
|
||||
Root,
|
||||
r#"SELECT r.id AS "id!: i64", r.kind AS "kind!: String", r.audience AS "audience!: String", r.path AS "path!: String", p.id AS "policy_id!: i64", p.name AS "policy_name!: String" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id"#
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
Ok(Json(roots))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/roots/{root_id}", tag = "roots",
|
||||
params(("root_id" = i64, Path, description = "Root row id")),
|
||||
responses(
|
||||
(status = 200, body = Root),
|
||||
(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<Root>, ApiError> {
|
||||
Ok(Json(load_root(&state, id).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/roots", tag = "roots", request_body = RootInput,
|
||||
responses(
|
||||
(status = 201, body = Root),
|
||||
(status = 409, body = ErrorBody),
|
||||
(status = 422, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
body: Result<Json<RootInput>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<Root>), ApiError> {
|
||||
let input = parsed(body)?;
|
||||
input.validate().map_err(ApiError::Invalid)?;
|
||||
input.policy_exists(&state).await?;
|
||||
let path = input.path.trim().to_owned();
|
||||
let result = sqlx::query!(
|
||||
"INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)",
|
||||
input.kind,
|
||||
input.audience,
|
||||
path,
|
||||
input.policy_id,
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await
|
||||
.map_err(root_conflict)?;
|
||||
let root = load_root(&state, result.last_insert_rowid()).await?;
|
||||
Ok((StatusCode::CREATED, Json(root)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/roots/{root_id}", tag = "roots", request_body = RootInput,
|
||||
params(("root_id" = i64, Path, description = "Root row id")),
|
||||
responses(
|
||||
(status = 200, body = Root),
|
||||
(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>,
|
||||
body: Result<Json<RootInput>, JsonRejection>,
|
||||
) -> Result<Json<Root>, ApiError> {
|
||||
let input = parsed(body)?;
|
||||
input.validate().map_err(ApiError::Invalid)?;
|
||||
input.policy_exists(&state).await?;
|
||||
let path = input.path.trim().to_owned();
|
||||
let result = sqlx::query!(
|
||||
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?"#,
|
||||
input.kind,
|
||||
input.audience,
|
||||
path,
|
||||
input.policy_id,
|
||||
id,
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await
|
||||
.map_err(root_conflict)?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::RootNotFound);
|
||||
}
|
||||
Ok(Json(load_root(&state, id).await?))
|
||||
}
|
||||
|
||||
/// A duplicate path or a duplicate (kind, audience) pair is a settings
|
||||
/// mistake the operator can fix, not a server fault.
|
||||
fn root_conflict(error: sqlx::Error) -> ApiError {
|
||||
if error
|
||||
.as_database_error()
|
||||
.is_some_and(sqlx::error::DatabaseError::is_unique_violation)
|
||||
{
|
||||
return ApiError::Conflict(
|
||||
"a root with this path or this kind and audience already exists".into(),
|
||||
);
|
||||
}
|
||||
ApiError::from(error)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/roots/{root_id}", tag = "roots",
|
||||
params(("root_id" = i64, Path, description = "Root 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 movies: i64 = sqlx::query_scalar!(
|
||||
r#"SELECT count(*) AS "count!: i64" FROM movies WHERE root_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool(&state)?)
|
||||
.await?;
|
||||
if movies > 0 {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"this root still owns {movies} movie{} — move them first",
|
||||
if movies == 1 { "" } else { "s" }
|
||||
)));
|
||||
}
|
||||
let series: i64 = sqlx::query_scalar!(
|
||||
r#"SELECT count(*) AS "count!: i64" FROM series WHERE root_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool(&state)?)
|
||||
.await?;
|
||||
if series > 0 {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"this root still owns {series} series — move them first"
|
||||
)));
|
||||
}
|
||||
let result = sqlx::query!("DELETE FROM roots WHERE id = ?", id)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::RootNotFound);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::state::Upstreams;
|
||||
use crate::{router, AppState};
|
||||
use crate::{router, AppState, Upstreams};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
#[tokio::test]
|
||||
async fn roots_carry_their_policy_name() {
|
||||
async fn application() -> (tempfile::TempDir, String) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
@@ -61,8 +276,13 @@ mod tests {
|
||||
let address = listener.local_addr().expect("address");
|
||||
let app = router(state);
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
||||
(dir, format!("http://{address}"))
|
||||
}
|
||||
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("http://{address}/api/roots"))
|
||||
#[tokio::test]
|
||||
async fn roots_carry_their_policy_name() {
|
||||
let (_dir, base) = application().await;
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
.expect("roots")
|
||||
.json()
|
||||
@@ -80,4 +300,199 @@ mod tests {
|
||||
assert_eq!(roots[3]["kind"], "tv");
|
||||
assert_eq!(roots[3]["policy_name"], "TV — kids");
|
||||
}
|
||||
|
||||
fn root_input(policy_id: i64) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"kind": "movie",
|
||||
"audience": "archive",
|
||||
"path": "/mnt/media/movies/archive",
|
||||
"policy_id": policy_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn first_policy_ids(base: &str) -> Vec<i64> {
|
||||
let policies: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
|
||||
.await
|
||||
.expect("policies")
|
||||
.json()
|
||||
.await
|
||||
.expect("policies json");
|
||||
policies
|
||||
.into_iter()
|
||||
.map(|policy| policy["id"].as_i64().expect("policy id"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_root_round_trips_through_create_and_update() {
|
||||
let (_dir, base) = application().await;
|
||||
let policy_ids = first_policy_ids(&base).await;
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
.expect("roots")
|
||||
.json()
|
||||
.await
|
||||
.expect("roots json");
|
||||
|
||||
// the schema only knows movie and tv, main and kids
|
||||
let rejected = reqwest::Client::new()
|
||||
.post(format!("{base}/api/roots"))
|
||||
.json(&root_input(policy_ids[0]))
|
||||
.send()
|
||||
.await
|
||||
.expect("create root");
|
||||
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
// (movie, kids) is taken by a seeded root, so free the pair first
|
||||
let kids_root = roots[1]["id"].as_i64().expect("movie kids root id");
|
||||
let freed = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/roots/{kids_root}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("free the seeded movie kids root");
|
||||
assert_eq!(freed.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let mut payload = root_input(policy_ids[0]);
|
||||
payload["audience"] = serde_json::json!("kids");
|
||||
payload["path"] = serde_json::json!("/mnt/media/movies/archive");
|
||||
let created: serde_json::Value = reqwest::Client::new()
|
||||
.post(format!("{base}/api/roots"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("create root")
|
||||
.json()
|
||||
.await
|
||||
.expect("created json");
|
||||
assert_eq!(created["audience"], "kids");
|
||||
assert_eq!(created["policy_name"], "Movies — main");
|
||||
let id = created["id"].as_i64().expect("id");
|
||||
|
||||
payload["path"] = serde_json::json!("/mnt/media/movies/archive-4k");
|
||||
payload["policy_id"] = policy_ids[1].into();
|
||||
let updated: serde_json::Value = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/{id}"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("update root")
|
||||
.json()
|
||||
.await
|
||||
.expect("updated json");
|
||||
assert_eq!(updated["path"], "/mnt/media/movies/archive-4k");
|
||||
assert_eq!(updated["policy_name"], "Movies — kids");
|
||||
|
||||
let deleted = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/roots/{id}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete root");
|
||||
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||
assert_eq!(
|
||||
reqwest::get(format!("{base}/api/roots/{id}"))
|
||||
.await
|
||||
.expect("get deleted")
|
||||
.status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() {
|
||||
let (_dir, base) = application().await;
|
||||
let policy_ids = first_policy_ids(&base).await;
|
||||
|
||||
for (mut payload, field) in [
|
||||
(root_input(999), "policy_id"),
|
||||
(root_input(policy_ids[0]), "kind"),
|
||||
] {
|
||||
payload["audience"] = serde_json::json!("main");
|
||||
if field == "kind" {
|
||||
payload["kind"] = serde_json::json!("book");
|
||||
payload["path"] = serde_json::json!("/mnt/media/movies/elsewhere");
|
||||
}
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/roots"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("create root");
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body: serde_json::Value = response.json().await.expect("error body");
|
||||
assert!(
|
||||
body["error"].as_str().expect("text").starts_with(field),
|
||||
"{field}: {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_duplicate_path_or_pair_conflicts() {
|
||||
let (_dir, base) = application().await;
|
||||
let mut path = root_input(1);
|
||||
path["audience"] = serde_json::json!("kids");
|
||||
path["path"] = serde_json::json!("/mnt/media/movies/kids");
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/roots"))
|
||||
.json(&path)
|
||||
.send()
|
||||
.await
|
||||
.expect("duplicate path create");
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
|
||||
let mut pair = root_input(1);
|
||||
pair["audience"] = serde_json::json!("main");
|
||||
pair["path"] = serde_json::json!("/mnt/media/movies/somewhere-new");
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/roots"))
|
||||
.json(&pair)
|
||||
.send()
|
||||
.await
|
||||
.expect("duplicate pair create");
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_root_with_titles_refuses_to_die() {
|
||||
let (_dir, base) = application().await;
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
.expect("roots")
|
||||
.json()
|
||||
.await
|
||||
.expect("roots json");
|
||||
let main_root = roots[0]["id"].as_i64().expect("root id");
|
||||
|
||||
let created = reqwest::Client::new()
|
||||
.post(format!("{base}/api/movies"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
|
||||
"original_language": "en", "root_id": main_root,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create movie");
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/roots/{main_root}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete occupied root");
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
let body: serde_json::Value = response.json().await.expect("error body");
|
||||
assert!(
|
||||
body["error"].as_str().expect("text").contains("movie"),
|
||||
"{body}"
|
||||
);
|
||||
|
||||
// an empty root deletes fine
|
||||
let empty_root = roots[3]["id"].as_i64().expect("tv kids root id");
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/roots/{empty_root}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete empty root");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
}
|
||||
|
||||
+603
-46
@@ -33,11 +33,45 @@ pub struct ReleasesQuery {
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SearchResponse {
|
||||
pub kind: SearchInputKind,
|
||||
pub library: Vec<Movie>,
|
||||
pub tmdb: Vec<TmdbMovie>,
|
||||
/// In-library hits, grouped first (§9.2): movies and series by title,
|
||||
/// episodes by episode title with their series and `SxxEyy` for context.
|
||||
pub library: Vec<LibraryResult>,
|
||||
pub tmdb: Vec<TmdbResult>,
|
||||
pub manual: Option<String>,
|
||||
}
|
||||
|
||||
/// One in-library hit. Tagged so a client can branch on what it found
|
||||
/// without re-deriving it from fields.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum LibraryResult {
|
||||
Movie(Movie),
|
||||
Series(LibrarySeries),
|
||||
Episode(LibraryEpisode),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct LibrarySeries {
|
||||
pub id: i64,
|
||||
pub tmdb_id: i64,
|
||||
pub title: String,
|
||||
pub year: Option<i64>,
|
||||
pub original_language: Option<String>,
|
||||
pub root_id: i64,
|
||||
pub blocked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct LibraryEpisode {
|
||||
pub episode_id: i64,
|
||||
pub series_id: i64,
|
||||
pub series_title: String,
|
||||
/// `SxxEyy`, so the episode title reads in context (§9.2).
|
||||
pub tag: String,
|
||||
/// The episode title — what the search matched on.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SearchInputKind {
|
||||
@@ -59,6 +93,40 @@ pub struct TmdbMovie {
|
||||
pub poster_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TmdbResult {
|
||||
Movie(TmdbMovie),
|
||||
Series(TmdbSeries),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct TmdbSeries {
|
||||
pub tmdb_id: u32,
|
||||
pub title: String,
|
||||
pub original_language: String,
|
||||
pub year: Option<i32>,
|
||||
pub overview: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
}
|
||||
|
||||
/// One release's score, kept as its terms so the UI can explain a ranking
|
||||
/// (`DESIGN.md` §5.5) rather than showing a bare number.
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, ToSchema)]
|
||||
pub struct ScoreTerms {
|
||||
/// Distance from the target size for this release's own resolution.
|
||||
/// Zero when the release carries no size, or the policy no band for it.
|
||||
pub size: i64,
|
||||
/// The source-tier tiebreaker.
|
||||
pub source: i64,
|
||||
/// The log-scaled seeder term.
|
||||
pub seeders: i64,
|
||||
/// Where the release sits in the policy's resolution preference. This is
|
||||
/// what makes a 4K release outrank a 1080p one that scores the same
|
||||
/// against its own size band.
|
||||
pub resolution: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ClassifiedRelease {
|
||||
pub indexer_id: i64,
|
||||
@@ -70,6 +138,8 @@ pub struct ClassifiedRelease {
|
||||
pub download_url: String,
|
||||
pub parsed: serde_json::Value,
|
||||
pub score: i64,
|
||||
/// `score` broken into the terms that produced it.
|
||||
pub score_terms: ScoreTerms,
|
||||
pub verdict: String,
|
||||
pub rule: Option<String>,
|
||||
}
|
||||
@@ -103,31 +173,74 @@ pub async fn search(
|
||||
}
|
||||
|
||||
let database = state.database().ok_or(ApiError::Unavailable)?;
|
||||
let mut library = if matches!(kind, SearchInputKind::TmdbId) {
|
||||
let tokens = like_tokens(input);
|
||||
|
||||
// §9.2 keeps the two result sets grouped and the library first, so each
|
||||
// kind lands in its own block: movies, then series, then episodes.
|
||||
let mut library: Vec<LibraryResult> = if matches!(kind, SearchInputKind::TmdbId) {
|
||||
let tmdb_id = input
|
||||
.strip_prefix("tmdb:")
|
||||
.unwrap_or(input)
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
|
||||
let pattern = format!("%{}%", escape_like(input));
|
||||
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, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR title LIKE ? ESCAPE '\' ORDER BY title, year, id"#, tmdb_id, pattern)
|
||||
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, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
.into_iter()
|
||||
.map(LibraryResult::Movie)
|
||||
.collect()
|
||||
} 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, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
.into_iter()
|
||||
.map(LibraryResult::Movie)
|
||||
.collect()
|
||||
};
|
||||
|
||||
let series_rows = if matches!(kind, SearchInputKind::TmdbId) {
|
||||
let tmdb_id = input
|
||||
.strip_prefix("tmdb:")
|
||||
.unwrap_or(input)
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
|
||||
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
} else {
|
||||
let pattern = format!("%{}%", escape_like(input));
|
||||
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, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE title LIKE ? ESCAPE '\' ORDER BY title, year, id"#, pattern)
|
||||
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
};
|
||||
library.extend(series_rows.into_iter().map(LibraryResult::Series));
|
||||
|
||||
// §9.2 names the TV case explicitly: `bluey hospital` finds the episode.
|
||||
// Tokens may split across the series and episode titles, so the haystack
|
||||
// is both joined and every token must land somewhere in it.
|
||||
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
library.extend(episode_rows.into_iter().map(LibraryResult::Episode));
|
||||
|
||||
let tmdb = tmdb_client(&state)?;
|
||||
let results = search_tmdb(&tmdb, kind, input).await?;
|
||||
if matches!(kind, SearchInputKind::ImdbId) {
|
||||
for result in &results {
|
||||
let tmdb_id = i64::from(result.tmdb_id);
|
||||
if let Some(movie) = 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, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id)
|
||||
.fetch_optional(database.pool()).await?
|
||||
{
|
||||
library.push(movie);
|
||||
match result {
|
||||
TmdbResult::Movie(movie) => {
|
||||
let tmdb_id = i64::from(movie.tmdb_id);
|
||||
if let Some(found) = 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, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id)
|
||||
.fetch_optional(database.pool()).await?
|
||||
{
|
||||
library.push(LibraryResult::Movie(found));
|
||||
}
|
||||
}
|
||||
TmdbResult::Series(series) => {
|
||||
let tmdb_id = i64::from(series.tmdb_id);
|
||||
if let Some(found) = sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ?"#, tmdb_id)
|
||||
.fetch_optional(database.pool()).await?
|
||||
{
|
||||
library.push(LibraryResult::Series(found));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,11 +253,24 @@ pub async fn search(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Movie and series TMDB ids live in separate namespaces, so deduplication
|
||||
/// keys on the kind as well as the id.
|
||||
fn already_matched(matches: &[TmdbResult], candidate: &TmdbResult) -> bool {
|
||||
let (is_series, tmdb_id) = match candidate {
|
||||
TmdbResult::Movie(movie) => (false, movie.tmdb_id),
|
||||
TmdbResult::Series(series) => (true, series.tmdb_id),
|
||||
};
|
||||
matches.iter().any(|existing| match existing {
|
||||
TmdbResult::Movie(movie) => !is_series && movie.tmdb_id == tmdb_id,
|
||||
TmdbResult::Series(series) => is_series && series.tmdb_id == tmdb_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn search_tmdb(
|
||||
tmdb: &arr_meta::TmdbClient,
|
||||
kind: SearchInputKind,
|
||||
input: &str,
|
||||
) -> Result<Vec<TmdbMovie>, ApiError> {
|
||||
) -> Result<Vec<TmdbResult>, ApiError> {
|
||||
let results = match kind {
|
||||
SearchInputKind::TmdbId => {
|
||||
let id = input
|
||||
@@ -153,10 +279,11 @@ async fn search_tmdb(
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
|
||||
// §9.2: a raw TMDB id resolves against both movie and TV.
|
||||
let mut matches = match tmdb.movie(id).await {
|
||||
Ok(movie) => {
|
||||
let year = movie.year();
|
||||
vec![TmdbMovie {
|
||||
vec![TmdbResult::Movie(TmdbMovie {
|
||||
tmdb_id: movie.tmdb_id,
|
||||
title: movie.title,
|
||||
original_title: movie.original_title,
|
||||
@@ -164,41 +291,75 @@ async fn search_tmdb(
|
||||
year,
|
||||
overview: movie.overview,
|
||||
poster_path: movie.poster_path,
|
||||
}]
|
||||
})]
|
||||
}
|
||||
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
|
||||
Err(error) => return Err(upstream_error(&error)),
|
||||
};
|
||||
match tmdb.series(id).await {
|
||||
Ok(series) => matches.push(TmdbResult::Series(series.into())),
|
||||
Err(arr_meta::Error::NotFound { .. }) => {}
|
||||
Err(error) => return Err(upstream_error(&error)),
|
||||
}
|
||||
if !input.starts_with("tmdb:") {
|
||||
for movie in tmdb
|
||||
.search_movies(input, None)
|
||||
.await
|
||||
.map_err(|error| upstream_error(&error))?
|
||||
.into_iter()
|
||||
.map(TmdbMovie::from)
|
||||
{
|
||||
if !matches
|
||||
.iter()
|
||||
.any(|candidate| candidate.tmdb_id == movie.tmdb_id)
|
||||
{
|
||||
matches.push(movie);
|
||||
let candidate = TmdbResult::Movie(movie.into());
|
||||
if !already_matched(&matches, &candidate) {
|
||||
matches.push(candidate);
|
||||
}
|
||||
}
|
||||
for series in tmdb
|
||||
.search_series(input)
|
||||
.await
|
||||
.map_err(|error| upstream_error(&error))?
|
||||
{
|
||||
let candidate = TmdbResult::Series(series.into());
|
||||
if !already_matched(&matches, &candidate) {
|
||||
matches.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
matches
|
||||
}
|
||||
SearchInputKind::ImdbId => match tmdb.find_movie_by_imdb(input).await {
|
||||
Ok(movies) => movies.into_iter().map(TmdbMovie::from).collect(),
|
||||
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
|
||||
Err(error) => return Err(upstream_error(&error)),
|
||||
},
|
||||
SearchInputKind::Text => tmdb
|
||||
.search_movies(input, None)
|
||||
.await
|
||||
.map_err(|error| upstream_error(&error))?
|
||||
.into_iter()
|
||||
.map(TmdbMovie::from)
|
||||
.collect(),
|
||||
SearchInputKind::ImdbId => {
|
||||
let found = tmdb
|
||||
.find_by_imdb(input)
|
||||
.await
|
||||
.map_err(|error| upstream_error(&error))?;
|
||||
let mut results: Vec<TmdbResult> = found
|
||||
.movies
|
||||
.into_iter()
|
||||
.map(|movie| TmdbResult::Movie(movie.into()))
|
||||
.collect();
|
||||
results.extend(
|
||||
found
|
||||
.series
|
||||
.into_iter()
|
||||
.map(|series| TmdbResult::Series(series.into())),
|
||||
);
|
||||
results
|
||||
}
|
||||
SearchInputKind::Text => {
|
||||
let mut results: Vec<TmdbResult> = tmdb
|
||||
.search_movies(input, None)
|
||||
.await
|
||||
.map_err(|error| upstream_error(&error))?
|
||||
.into_iter()
|
||||
.map(|movie| TmdbResult::Movie(movie.into()))
|
||||
.collect();
|
||||
results.extend(
|
||||
tmdb.search_series(input)
|
||||
.await
|
||||
.map_err(|error| upstream_error(&error))?
|
||||
.into_iter()
|
||||
.map(|series| TmdbResult::Series(series.into())),
|
||||
);
|
||||
results
|
||||
}
|
||||
SearchInputKind::Magnet | SearchInputKind::TorrentUrl => Vec::new(),
|
||||
};
|
||||
Ok(results)
|
||||
@@ -312,16 +473,17 @@ async fn movie_releases(
|
||||
|
||||
/// Classified releases for one episode (`DESIGN.md` §6.1, §9.3).
|
||||
///
|
||||
/// The series carries no TVDB ID yet, so `tv_request` falls back to a text
|
||||
/// search built from the title and the `SxxEyy` tag. Both widen the result
|
||||
/// set rather than narrowing it, which the buckets already handle.
|
||||
/// A series with a stored TVDB id is searched by it; `tv_request` falls back
|
||||
/// to a text search built from the title and the `SxxEyy` tag when the id is
|
||||
/// missing or the indexer does not take `tvdbid`. Both widen the result set
|
||||
/// rather than narrowing it, which the buckets already handle.
|
||||
async fn episode_releases(
|
||||
state: &AppState,
|
||||
episode_id: i64,
|
||||
) -> Result<Vec<ClassifiedRelease>, ApiError> {
|
||||
let database = state.database().ok_or(ApiError::Unavailable)?;
|
||||
let episode = sqlx::query!(
|
||||
r#"SELECT s.title AS "series_title!: String", s.original_language,
|
||||
r#"SELECT s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language,
|
||||
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
|
||||
FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
@@ -346,7 +508,7 @@ async fn episode_releases(
|
||||
let original_language = title_language(original_language, &[]);
|
||||
|
||||
let target = TvTarget {
|
||||
tvdb_id: None,
|
||||
tvdb_id: episode.series_tvdb_id.and_then(|id| u64::try_from(id).ok()),
|
||||
title: episode.series_title,
|
||||
selector: TvSelector::Episode {
|
||||
season: u32::try_from(episode.season_number).unwrap_or_default(),
|
||||
@@ -400,6 +562,34 @@ impl From<arr_meta::MovieSearchResult> for TmdbMovie {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<arr_meta::SeriesSearchResult> for TmdbSeries {
|
||||
fn from(series: arr_meta::SeriesSearchResult) -> Self {
|
||||
let year = series.year();
|
||||
Self {
|
||||
tmdb_id: series.tmdb_id,
|
||||
title: series.title,
|
||||
original_language: series.original_language,
|
||||
year,
|
||||
overview: series.overview,
|
||||
poster_path: series.poster_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<arr_meta::Series> for TmdbSeries {
|
||||
fn from(series: arr_meta::Series) -> Self {
|
||||
let year = series.year();
|
||||
Self {
|
||||
tmdb_id: series.tmdb_id,
|
||||
title: series.title,
|
||||
original_language: series.original_language,
|
||||
year,
|
||||
overview: series.overview,
|
||||
poster_path: series.poster_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn input_kind(input: &str) -> SearchInputKind {
|
||||
let lower = input.to_ascii_lowercase();
|
||||
if lower.starts_with("magnet:?") {
|
||||
@@ -421,6 +611,19 @@ fn input_kind(input: &str) -> SearchInputKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// §9.2's example — `bluey hospital` finding the episode — needs word-wise
|
||||
/// matching, not one literal phrase. The query's tokens travel as a JSON
|
||||
/// array the queries walk with `json_each`; LIKE metacharacters are escaped
|
||||
/// here rather than in SQL.
|
||||
fn like_tokens(input: &str) -> serde_json::Value {
|
||||
serde_json::Value::Array(
|
||||
input
|
||||
.split_whitespace()
|
||||
.map(|token| serde_json::Value::String(escape_like(token)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_like(input: &str) -> String {
|
||||
input
|
||||
.replace('\\', "\\\\")
|
||||
@@ -437,7 +640,7 @@ fn prowlarr_client(state: &AppState) -> Result<ProwlarrClient, ApiError> {
|
||||
ProwlarrClient::new(upstreams.prowlarr_url.clone(), api_key).map_err(|_| ApiError::Unavailable)
|
||||
}
|
||||
|
||||
fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiError> {
|
||||
pub(crate) fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiError> {
|
||||
let upstreams = state.upstreams();
|
||||
let key = upstreams
|
||||
.tmdb_api_key
|
||||
@@ -487,11 +690,24 @@ fn classify(
|
||||
release.size.unwrap_or_default(),
|
||||
release.seeders.unwrap_or_default(),
|
||||
);
|
||||
let score = if release.size.is_some() {
|
||||
score.total
|
||||
} else {
|
||||
score.source.saturating_add(score.seeders)
|
||||
// A release with no size has nothing to say about its size band, so that
|
||||
// term is dropped rather than scored as if it were at the floor. Every
|
||||
// other term still stands — the resolution rank comes from the name.
|
||||
let terms = ScoreTerms {
|
||||
size: if release.size.is_some() {
|
||||
score.size
|
||||
} else {
|
||||
0
|
||||
},
|
||||
source: score.source,
|
||||
seeders: score.seeders,
|
||||
resolution: score.resolution,
|
||||
};
|
||||
let score = terms
|
||||
.size
|
||||
.saturating_add(terms.source)
|
||||
.saturating_add(terms.seeders)
|
||||
.saturating_add(terms.resolution);
|
||||
Ok(ClassifiedRelease {
|
||||
indexer_id: release.indexer_id,
|
||||
guid: release.guid,
|
||||
@@ -509,6 +725,7 @@ fn classify(
|
||||
parsed: serde_json::to_value(parsed)
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?,
|
||||
score,
|
||||
score_terms: terms,
|
||||
verdict: verdict.to_owned(),
|
||||
rule,
|
||||
})
|
||||
@@ -604,6 +821,13 @@ mod tests {
|
||||
)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/search/tv"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
|
||||
)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
|
||||
sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (438631, 'Dune', 2021, 'en', 2)")
|
||||
.execute(state.database().expect("database").pool()).await.expect("movie");
|
||||
@@ -615,10 +839,92 @@ mod tests {
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["kind"], "text");
|
||||
assert_eq!(response["library"][0]["kind"], "movie");
|
||||
assert_eq!(response["library"][0]["title"], "Dune");
|
||||
assert_eq!(response["tmdb"][0]["kind"], "movie");
|
||||
assert_eq!(response["tmdb"][0]["tmdb_id"], 693_134);
|
||||
}
|
||||
|
||||
/// §9.2: the in-library set matches series titles and episode titles, so
|
||||
/// `bluey hospital` finds the episode with its series and `SxxEyy` along
|
||||
/// for context — and plain `bluey` finds the series itself.
|
||||
#[tokio::test]
|
||||
async fn library_results_cover_series_and_episode_titles() {
|
||||
let tmdb = MockServer::start().await;
|
||||
let prowlarr = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/search/movie"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
|
||||
)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/search/tv"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{
|
||||
"id": 82_728, "name": "Bluey", "original_language": "en",
|
||||
"first_air_date": "2018-10-01"
|
||||
}]})),
|
||||
)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
|
||||
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("TV root");
|
||||
let series_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO series (tmdb_id, title, year, original_language, root_id) VALUES (82728, 'Bluey', 2018, 'en', ?) RETURNING id",
|
||||
)
|
||||
.bind(root_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("series");
|
||||
let season_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id",
|
||||
)
|
||||
.bind(series_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("season");
|
||||
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, 'Hospital')")
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("episode");
|
||||
|
||||
let response: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/search?q=bluey%20hospital"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["library"].as_array().expect("library").len(), 1);
|
||||
let episode = &response["library"][0];
|
||||
assert_eq!(episode["kind"], "episode");
|
||||
assert_eq!(episode["series_title"], "Bluey");
|
||||
assert_eq!(episode["tag"], "S01E02");
|
||||
assert_eq!(episode["title"], "Hospital");
|
||||
// TMDB carries the TV result below the library set.
|
||||
assert_eq!(response["tmdb"][0]["kind"], "series");
|
||||
assert_eq!(response["tmdb"][0]["title"], "Bluey");
|
||||
|
||||
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["library"][0]["kind"], "series");
|
||||
assert_eq!(response["library"][0]["title"], "Bluey");
|
||||
assert_eq!(response["library"][0]["tmdb_id"], 82_728);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn numeric_titles_survive_a_missing_tmdb_id() {
|
||||
let tmdb = MockServer::start().await;
|
||||
@@ -628,6 +934,11 @@ mod tests {
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/1917"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/search/movie"))
|
||||
.and(query_param("query", "1917"))
|
||||
@@ -637,6 +948,13 @@ mod tests {
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/search/tv"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[]})),
|
||||
)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
|
||||
sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (530915, '1917', 2019, 'en', 2)")
|
||||
.execute(state.database().expect("database").pool()).await.expect("movie");
|
||||
@@ -651,6 +969,52 @@ mod tests {
|
||||
assert_eq!(response["tmdb"][0]["title"], "1917");
|
||||
}
|
||||
|
||||
/// §9.2: a pasted `tt` id resolves a series exactly as a TMDB id does —
|
||||
/// a hit on TMDB and, when tracked, in the library set too.
|
||||
#[tokio::test]
|
||||
async fn an_imdb_id_resolves_to_a_series() {
|
||||
let tmdb = MockServer::start().await;
|
||||
let prowlarr = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/find/tt7614372"))
|
||||
.and(query_param("external_source", "imdb_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"movie_results": [],
|
||||
"tv_results": [{
|
||||
"id": 82_728, "name": "Bluey", "original_name": "Bluey",
|
||||
"original_language": "en", "first_air_date": "2018-10-01"
|
||||
}]
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("TV root");
|
||||
sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, year, original_language, root_id) VALUES (82728, 'Bluey', 2018, 'en', ?)",
|
||||
)
|
||||
.bind(root_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("series");
|
||||
|
||||
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=tt7614372"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["kind"], "imdb_id");
|
||||
assert_eq!(response["library"][0]["kind"], "series");
|
||||
assert_eq!(response["library"][0]["tmdb_id"], 82_728);
|
||||
assert_eq!(response["tmdb"][0]["kind"], "series");
|
||||
assert_eq!(response["tmdb"][0]["title"], "Bluey");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_releases_are_classified_and_name_rejection_rules() {
|
||||
let tmdb = MockServer::start().await;
|
||||
@@ -681,7 +1045,7 @@ mod tests {
|
||||
sqlx::query("INSERT INTO movies (tmdb_id, title, year, original_language, root_id) VALUES (693134, 'Dune Part Two', 2024, 'en', 2)")
|
||||
.execute(state.database().expect("database").pool()).await.expect("movie");
|
||||
sqlx::query(
|
||||
"UPDATE policies SET score_weights = '{\"size_at_target\":0,\"source_tier\":0,\"seeder_doubling\":0}'",
|
||||
"UPDATE policies SET score_weights = '{\"size_at_target\":0,\"source_tier\":0,\"seeder_doubling\":0,\"resolution_step\":0}'",
|
||||
)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
@@ -787,6 +1151,112 @@ mod tests {
|
||||
assert_eq!(releases[0]["parsed"]["episode"]["episodes"][0], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn episode_releases_search_by_tvdb_id_when_the_indexer_takes_one() {
|
||||
let tmdb = MockServer::start().await;
|
||||
let prowlarr = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/indexer"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(serde_json::json!([{"id":9,"name":"tracker","enable":true}])),
|
||||
)
|
||||
.mount(&prowlarr)
|
||||
.await;
|
||||
Mock::given(method("GET")).and(path("/9/api")).and(query_param("t", "caps"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("<caps><searching><search available=\"yes\" supportedParams=\"q\"/><tv-search available=\"yes\" supportedParams=\"q,tvdbid,season,ep\"/></searching></caps>"))
|
||||
.mount(&prowlarr).await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/9/api"))
|
||||
.and(query_param("t", "tvsearch"))
|
||||
.and(query_param("tvdbid", "361391"))
|
||||
.and(query_param("season", "1"))
|
||||
.and(query_param("ep", "2"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r"<rss><channel><item><title>Bluey.S01E02.1080p.WEB-DL</title><guid>ep</guid><link>https://tracker/ep</link><size>1500000000</size></item></channel></rss>"))
|
||||
.mount(&prowlarr).await;
|
||||
|
||||
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
|
||||
let episode_id = episode_with_tvdb_id(&state, Some(361_391)).await;
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/releases?episode_id={episode_id}"))
|
||||
.await
|
||||
.expect("releases");
|
||||
assert_eq!(response.status(), 200);
|
||||
let releases: Vec<serde_json::Value> = response.json().await.expect("json");
|
||||
assert_eq!(releases.len(), 1);
|
||||
assert_eq!(releases[0]["guid"], "ep");
|
||||
}
|
||||
|
||||
/// §6.1: an indexer whose caps do not advertise `tvdbid` still gets
|
||||
/// searched, by text — a missing or unsupported id widens the query,
|
||||
/// it never skips the indexer.
|
||||
#[tokio::test]
|
||||
async fn an_indexer_without_id_search_still_gets_a_text_query() {
|
||||
let tmdb = MockServer::start().await;
|
||||
let prowlarr = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/indexer"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(serde_json::json!([{"id":9,"name":"tracker","enable":true}])),
|
||||
)
|
||||
.mount(&prowlarr)
|
||||
.await;
|
||||
Mock::given(method("GET")).and(path("/9/api")).and(query_param("t", "caps"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("<caps><searching><search available=\"yes\" supportedParams=\"q\"/><tv-search available=\"yes\" supportedParams=\"q,season,ep\"/></searching></caps>"))
|
||||
.mount(&prowlarr).await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/9/api"))
|
||||
.and(query_param("t", "search"))
|
||||
.and(query_param("q", "Bluey S01E02"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r"<rss><channel><item><title>Bluey.S01E02.1080p.WEB-DL</title><guid>ep</guid><link>https://tracker/ep</link><size>1500000000</size></item></channel></rss>"))
|
||||
.mount(&prowlarr).await;
|
||||
|
||||
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
|
||||
let episode_id = episode_with_tvdb_id(&state, Some(361_391)).await;
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/releases?episode_id={episode_id}"))
|
||||
.await
|
||||
.expect("releases");
|
||||
assert_eq!(response.status(), 200);
|
||||
let releases: Vec<serde_json::Value> = response.json().await.expect("json");
|
||||
assert_eq!(releases.len(), 1);
|
||||
assert_eq!(releases[0]["parsed"]["episode"]["episodes"][0], 2);
|
||||
}
|
||||
|
||||
async fn episode_with_tvdb_id(state: &crate::state::AppState, tvdb_id: Option<i64>) -> i64 {
|
||||
use sqlx::Row as _;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("TV root");
|
||||
let series_id: i64 = sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id) VALUES (82728, ?, 'Bluey', 2018, 'en', ?)",
|
||||
)
|
||||
.bind(tvdb_id)
|
||||
.bind(root_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("series")
|
||||
.last_insert_rowid();
|
||||
let season_id: i64 =
|
||||
sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id")
|
||||
.bind(series_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("season")
|
||||
.get(0);
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO episodes (season_id, number, title, wanted) VALUES (?, 2, 'Hospital', 1) RETURNING id",
|
||||
)
|
||||
.bind(season_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("episode")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_manual_search_names_exactly_one_title() {
|
||||
let tmdb = MockServer::start().await;
|
||||
@@ -801,6 +1271,88 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #113: the breakdown is what the UI explains a ranking from, so
|
||||
/// the resolution term has to be visible in it, not folded into a total.
|
||||
#[test]
|
||||
fn the_score_breakdown_carries_the_resolution_term() {
|
||||
let policy = scoring_policy();
|
||||
let classify_at = |name: &str, size: u64| {
|
||||
let release = SearchRelease {
|
||||
indexer_id: 1,
|
||||
guid: name.into(),
|
||||
name: name.into(),
|
||||
size: Some(size),
|
||||
seeders: Some(20),
|
||||
publish_date: None,
|
||||
download_url: "https://tracker/release".into(),
|
||||
tmdb_id: None,
|
||||
imdb_id: None,
|
||||
};
|
||||
classify(
|
||||
release,
|
||||
&policy,
|
||||
&TitleOverrides::default(),
|
||||
&Language::Other("en".into()),
|
||||
&Blacklist::default(),
|
||||
)
|
||||
.expect("classified release")
|
||||
};
|
||||
|
||||
let uhd = classify_at("Dune.Part.Two.2024.2160p.WEB-DL", 23 << 30);
|
||||
let hd = classify_at("Dune.Part.Two.2024.1080p.WEB-DL", 8 << 30);
|
||||
|
||||
assert_eq!(
|
||||
uhd.score_terms.resolution,
|
||||
i64::from(policy.score_weights.resolution_step)
|
||||
);
|
||||
assert_eq!(hd.score_terms.resolution, 0);
|
||||
// The 4K is over its own target and so scores worse on size, and
|
||||
// still ranks first.
|
||||
assert!(uhd.score_terms.size < hd.score_terms.size);
|
||||
assert!(uhd.score > hd.score);
|
||||
assert_eq!(
|
||||
uhd.score,
|
||||
uhd.score_terms.size
|
||||
+ uhd.score_terms.source
|
||||
+ uhd.score_terms.seeders
|
||||
+ uhd.score_terms.resolution
|
||||
);
|
||||
}
|
||||
|
||||
/// The seeded movie policy's scoring numbers (§5.5).
|
||||
fn scoring_policy() -> Policy {
|
||||
Policy {
|
||||
id: PolicyId(1),
|
||||
name: "test".into(),
|
||||
required_audio: RequiredAudio::OriginalLanguage,
|
||||
dub_blacklist: Vec::new(),
|
||||
hdr_rules: HdrRules {
|
||||
rejected_dolby_vision_profiles: Vec::new(),
|
||||
},
|
||||
size_bands: std::collections::BTreeMap::from([
|
||||
(
|
||||
Resolution::R2160p,
|
||||
SizeBand {
|
||||
floor_bytes: 8 << 30,
|
||||
target_bytes: 22 << 30,
|
||||
penalty_points_per_gib_over: 60,
|
||||
},
|
||||
),
|
||||
(
|
||||
Resolution::R1080p,
|
||||
SizeBand {
|
||||
floor_bytes: 3 << 30,
|
||||
target_bytes: 8 << 30,
|
||||
penalty_points_per_gib_over: 60,
|
||||
},
|
||||
),
|
||||
]),
|
||||
resolution_preference: vec![Resolution::R2160p, Resolution::R1080p],
|
||||
source_weights: std::collections::BTreeMap::from([(Source::WebDl, 2)]),
|
||||
score_weights: ScoreWeights::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releases_without_sizes_skip_the_size_score() {
|
||||
let policy = Policy {
|
||||
@@ -848,8 +1400,13 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
classified.score,
|
||||
core_score.source.saturating_add(core_score.seeders)
|
||||
core_score
|
||||
.source
|
||||
.saturating_add(core_score.seeders)
|
||||
.saturating_add(core_score.resolution)
|
||||
);
|
||||
assert_eq!(classified.score_terms.size, 0);
|
||||
assert_eq!(classified.score_terms.resolution, core_score.resolution);
|
||||
assert_ne!(classified.score, core_score.total);
|
||||
}
|
||||
|
||||
|
||||
+756
-28
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,8 @@ pub struct AppState {
|
||||
pending_movie_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MovieCommand>>>,
|
||||
episode_commands: mpsc::Sender<EpisodeCommand>,
|
||||
pending_episode_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<EpisodeCommand>>>,
|
||||
season_commands: mpsc::Sender<SeasonCommand>,
|
||||
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
|
||||
}
|
||||
|
||||
/// Work explicitly requested through the movie API.
|
||||
@@ -91,6 +93,17 @@ pub enum EpisodeCommand {
|
||||
Grab { episode_id: i64, release_id: i64 },
|
||||
}
|
||||
|
||||
/// Work explicitly requested through the season deck API (issue #125).
|
||||
///
|
||||
/// A season pack is one torrent for the whole season and the grab targets
|
||||
/// `target_kind = 'season'`, so the manual action names a season — unlike
|
||||
/// [`EpisodeCommand`], where intent lives at the leaf.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SeasonCommand {
|
||||
Search { season_id: i64 },
|
||||
Grab { season_id: i64, release_id: i64 },
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Build the state, including the shared HTTP client.
|
||||
///
|
||||
@@ -101,6 +114,7 @@ impl AppState {
|
||||
let http = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
|
||||
let (movie_commands, pending_movie_commands) = mpsc::channel(64);
|
||||
let (episode_commands, pending_episode_commands) = mpsc::channel(64);
|
||||
let (season_commands, pending_season_commands) = mpsc::channel(64);
|
||||
Ok(Self {
|
||||
http,
|
||||
upstreams: Arc::new(upstreams),
|
||||
@@ -109,6 +123,8 @@ impl AppState {
|
||||
pending_movie_commands: Arc::new(tokio::sync::Mutex::new(pending_movie_commands)),
|
||||
episode_commands,
|
||||
pending_episode_commands: Arc::new(tokio::sync::Mutex::new(pending_episode_commands)),
|
||||
season_commands,
|
||||
pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,6 +153,15 @@ impl AppState {
|
||||
self.pending_episode_commands.lock().await.recv().await
|
||||
}
|
||||
|
||||
/// Wait for the next manual season action in the daemon's reconcile loop.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If every sender has been dropped.
|
||||
pub async fn next_season_command(&self) -> Option<SeasonCommand> {
|
||||
self.pending_season_commands.lock().await.recv().await
|
||||
}
|
||||
|
||||
pub(crate) fn http(&self) -> &reqwest::Client {
|
||||
&self.http
|
||||
}
|
||||
@@ -162,4 +187,11 @@ impl AppState {
|
||||
) -> Result<(), mpsc::error::TrySendError<EpisodeCommand>> {
|
||||
self.episode_commands.try_send(command)
|
||||
}
|
||||
|
||||
pub(crate) fn send_season_command(
|
||||
&self,
|
||||
command: SeasonCommand,
|
||||
) -> Result<(), mpsc::error::TrySendError<SeasonCommand>> {
|
||||
self.season_commands.try_send(command)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ impl SeriesResource {
|
||||
title: row.title.clone(),
|
||||
sort_title: row.title.to_lowercase(),
|
||||
year: row.year.unwrap_or_default(),
|
||||
tvdb_id: 0,
|
||||
tvdb_id: row.tvdb_id.unwrap_or(0),
|
||||
tmdb_id: row.tmdb_id,
|
||||
title_slug: title_slug(&row.title, row.tmdb_id),
|
||||
path: format!("{}/{folder}", row.root_path),
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::CompatState;
|
||||
pub(crate) struct SeriesRow {
|
||||
pub(crate) id: i64,
|
||||
pub(crate) tmdb_id: i64,
|
||||
pub(crate) tvdb_id: Option<i64>,
|
||||
pub(crate) title: String,
|
||||
pub(crate) year: Option<i64>,
|
||||
pub(crate) root_id: i64,
|
||||
@@ -98,10 +99,11 @@ pub(crate) async fn add(
|
||||
|
||||
let mut tx = state.pool().begin().await?;
|
||||
let series_id = sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, year, original_language, root_id, auto_track, upstream_ended) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id",
|
||||
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
|
||||
)
|
||||
.bind(input.tmdb_id)
|
||||
.bind(metadata.tvdb_id.map(i64::from))
|
||||
.bind(input.title.trim())
|
||||
.bind(input.year.or_else(|| metadata.year().map(i64::from)))
|
||||
.bind(&metadata.original_language)
|
||||
@@ -227,7 +229,7 @@ async fn load_series(
|
||||
tmdb_id: Option<i64>,
|
||||
) -> Result<Vec<SeriesRow>, CompatError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||
r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id,
|
||||
s.title AS "title!: String", s.year, s.root_id AS "root_id!: i64",
|
||||
r.path AS "root_path!: String",
|
||||
s.upstream_ended AS "upstream_ended!: bool",
|
||||
@@ -251,6 +253,7 @@ async fn load_series(
|
||||
.map(|row| SeriesRow {
|
||||
id: row.id,
|
||||
tmdb_id: row.tmdb_id,
|
||||
tvdb_id: row.tvdb_id,
|
||||
title: row.title,
|
||||
year: row.year,
|
||||
root_id: row.root_id,
|
||||
|
||||
@@ -15,7 +15,10 @@ pub mod tracking;
|
||||
|
||||
pub use arr_parse::NameClaims as ParsedRelease;
|
||||
pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
|
||||
pub use matching::{match_movie, MatchKind, MovieMatch, ReleaseIds, WantedMovie};
|
||||
pub use matching::{
|
||||
match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds,
|
||||
WantedEpisode, WantedMovie,
|
||||
};
|
||||
pub use score::{Score, ScoreWeights};
|
||||
pub use status::{derive_series_status, SeriesStatus};
|
||||
|
||||
@@ -285,6 +288,9 @@ pub struct Season {
|
||||
pub struct Episode {
|
||||
pub id: EpisodeId,
|
||||
pub season_id: SeasonId,
|
||||
/// The owning season's number, carried so status derivation never needs
|
||||
/// a second slice a caller can forget (#131).
|
||||
pub season_number: u16,
|
||||
pub number: u16,
|
||||
pub title: String,
|
||||
pub air_date: Option<SystemTime>,
|
||||
|
||||
+496
-11
@@ -6,17 +6,22 @@
|
||||
//! nearly free, because the feed is read again ten minutes later and the
|
||||
//! targeted search still runs. So every rule here is deliberately strict:
|
||||
//!
|
||||
//! - an ID the tracker itself supplied beats anything read off the name, and
|
||||
//! an ID that matches nothing wanted ends the comparison rather than
|
||||
//! falling back to the title;
|
||||
//! - an ID the tracker itself supplied beats anything read off the name,
|
||||
//! except a season or episode tag in the name, which makes the release a
|
||||
//! TV one whatever id came with it; and an ID that matches nothing wanted
|
||||
//! ends the comparison rather than falling back to the title;
|
||||
//! - two supplied IDs that disagree about which title this is are an
|
||||
//! ambiguity like any other, and match nothing;
|
||||
//! - a title match needs the years to agree, and a wanted title with a known
|
||||
//! year is never matched by a release that does not state one;
|
||||
//! - anything that matches two wanted titles matches neither.
|
||||
//!
|
||||
//! The TV side ([`match_episode`]) answers a narrower question about one
|
||||
//! release and one wanted episode: is this its file, or a pack for exactly
|
||||
//! the season holding it?
|
||||
|
||||
use crate::MovieId;
|
||||
use arr_parse::NameClaims;
|
||||
use crate::{EpisodeId, MovieId};
|
||||
use arr_parse::{EpisodeClaim, NameClaims};
|
||||
|
||||
/// A wanted title, in the shape matching needs.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -71,6 +76,13 @@ pub fn match_movie(
|
||||
ids: &ReleaseIds,
|
||||
claims: &NameClaims,
|
||||
) -> Option<MovieMatch> {
|
||||
// A season or episode tag makes this a TV release, whatever the title
|
||||
// or the tracker-supplied id says. Movies are the only thing RSS grabs
|
||||
// today (§13, phase 4), and trackers do mislabel ids.
|
||||
if claims.episode.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let imdb_id = ids.imdb_id.as_deref().and_then(imdb_key);
|
||||
if ids.tmdb_id.is_some() || imdb_id.is_some() {
|
||||
// A title matched by both ids is still one title, so each wanted
|
||||
@@ -91,11 +103,6 @@ pub fn match_movie(
|
||||
}));
|
||||
}
|
||||
|
||||
// A season or episode tag makes this a TV release, whatever the title
|
||||
// says. Movies are the only thing RSS grabs today (§13, phase 4).
|
||||
if claims.episode.is_some() {
|
||||
return None;
|
||||
}
|
||||
let title = match_key(claims.title.as_deref()?);
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
@@ -150,6 +157,113 @@ fn unique(mut matches: impl Iterator<Item = MovieMatch>) -> Option<MovieMatch> {
|
||||
matches.next().is_none().then_some(first)
|
||||
}
|
||||
|
||||
/// One wanted episode, in the shape matching needs. The ids are the
|
||||
/// series', not the episode's: trackers attach them to the show.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WantedEpisode {
|
||||
pub id: EpisodeId,
|
||||
/// TMDB id of the series, compared against the one the tracker attached
|
||||
/// to the item.
|
||||
pub tmdb_id: Option<u32>,
|
||||
/// `IMDb` id of the series, as TMDB spells it (`tt11301866`). Absent
|
||||
/// until the metadata refresh has run.
|
||||
pub imdb_id: Option<String>,
|
||||
pub title: String,
|
||||
pub season: u32,
|
||||
pub episode: u32,
|
||||
}
|
||||
|
||||
/// Which file shape satisfied a wanted episode.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MatchShape {
|
||||
/// A release holding exactly this one episode.
|
||||
SingleEpisode,
|
||||
/// A pack for exactly the season this episode belongs to.
|
||||
SeasonPack,
|
||||
}
|
||||
|
||||
/// What tied a release to a wanted episode, and what it is.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct EpisodeMatch {
|
||||
pub episode: EpisodeId,
|
||||
pub kind: MatchKind,
|
||||
pub shape: MatchShape,
|
||||
}
|
||||
|
||||
/// Whether a release satisfies one wanted episode, and as what.
|
||||
///
|
||||
/// Identity is decided as [`match_movie`] decides it — a supplied id that
|
||||
/// matches nothing wanted ends the comparison, a junk id falls through to
|
||||
/// the title — except the title lane compares the series title alone. TV
|
||||
/// release names rarely state a year at all, and when they do it is the
|
||||
/// season's air year rather than the series', so the year rule films live
|
||||
/// by would starve this lane; exact title comparison is what keeps two
|
||||
/// series sharing a word apart.
|
||||
///
|
||||
/// Only two shapes satisfy anything: a file holding exactly this episode
|
||||
/// and a pack for exactly its season. Multi-episode files and multi-season
|
||||
/// packs are left to the operator, who reaches them through the episode and
|
||||
/// season decks (§6.2).
|
||||
#[must_use]
|
||||
pub fn match_episode(
|
||||
wanted: &WantedEpisode,
|
||||
ids: &ReleaseIds,
|
||||
claims: &NameClaims,
|
||||
) -> Option<EpisodeMatch> {
|
||||
let imdb_id = ids.imdb_id.as_deref().and_then(imdb_key);
|
||||
let kind = if ids.tmdb_id.is_some() || imdb_id.is_some() {
|
||||
if ids.tmdb_id.is_some() && wanted.tmdb_id == ids.tmdb_id {
|
||||
MatchKind::TmdbId
|
||||
} else if imdb_id.is_some() && wanted.imdb_id.as_deref().and_then(imdb_key) == imdb_id {
|
||||
MatchKind::ImdbId
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let title = match_key(claims.title.as_deref()?);
|
||||
if title.is_empty() || title != match_key(&wanted.title) {
|
||||
return None;
|
||||
}
|
||||
MatchKind::TitleAndYear
|
||||
};
|
||||
|
||||
// Whatever the name or id says, nothing without a TV tag holds an
|
||||
// episode, and no daily-show air date is a numbered one.
|
||||
let claim = claims.episode.as_ref()?;
|
||||
let shape = if is_this_seasons_pack(claim, wanted.season) {
|
||||
MatchShape::SeasonPack
|
||||
} else if is_single_episode(claim, wanted.season, wanted.episode) {
|
||||
MatchShape::SingleEpisode
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(EpisodeMatch {
|
||||
episode: wanted.id,
|
||||
kind,
|
||||
shape,
|
||||
})
|
||||
}
|
||||
|
||||
/// A pack for exactly this season. Multi-season packs are never selected
|
||||
/// automatically: they drag other seasons in with them.
|
||||
fn is_this_seasons_pack(claim: &EpisodeClaim, season: u32) -> bool {
|
||||
match claim {
|
||||
EpisodeClaim::Season { season: claimed } => *claimed == season,
|
||||
EpisodeClaim::Seasons { first, last } => first == last && *first == season,
|
||||
EpisodeClaim::Episodes { .. } | EpisodeClaim::Daily { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A release holding exactly this one episode. Multi-episode files are left
|
||||
/// to manual grabs: two episodes automatically picking the same torrent
|
||||
/// would fight over one `grabs` row.
|
||||
fn is_single_episode(claim: &EpisodeClaim, season: u32, episode: u32) -> bool {
|
||||
matches!(
|
||||
claim,
|
||||
EpisodeClaim::Episodes { episodes, .. } if episodes.len() == 1
|
||||
) && claim.covers(season, episode)
|
||||
}
|
||||
|
||||
/// The form two titles are compared in: lower case, accents folded to ASCII,
|
||||
/// `&` spelled out, and every run of anything else one space.
|
||||
///
|
||||
@@ -202,7 +316,10 @@ fn fold(character: char) -> Option<&'static str> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{match_key, match_movie, MatchKind, MovieMatch, ReleaseIds, WantedMovie};
|
||||
use super::{
|
||||
match_episode, match_key, match_movie, EpisodeId, EpisodeMatch, MatchKind, MatchShape,
|
||||
MovieMatch, ReleaseIds, WantedEpisode, WantedMovie,
|
||||
};
|
||||
use crate::MovieId;
|
||||
|
||||
fn wanted() -> Vec<WantedMovie> {
|
||||
@@ -314,6 +431,35 @@ mod tests {
|
||||
assert_eq!(match_movie(&wanted, &ReleaseIds::default(), &claims), None);
|
||||
}
|
||||
|
||||
/// The same rule on the id lane (#139): trackers mislabel ids, so a
|
||||
/// season or episode tag in the name vetoes an id that matches too.
|
||||
#[test]
|
||||
fn an_episode_tag_beats_a_matching_supplied_id() {
|
||||
let wanted = vec![WantedMovie {
|
||||
id: MovieId(1),
|
||||
tmdb_id: Some(693_134),
|
||||
imdb_id: Some("tt15239678".to_owned()),
|
||||
title: "Dune: Part Two".to_owned(),
|
||||
year: Some(2024),
|
||||
}];
|
||||
let cases = [
|
||||
("Fallout.S01E03.1080p.WEB-DL", &ids(Some(693_134), None)),
|
||||
("Fallout.S01.1080p.BluRay-GROUP", &ids(Some(693_134), None)),
|
||||
(
|
||||
"Fallout.2024.S01E03.1080p.WEB-DL",
|
||||
&ids(None, Some("tt15239678")),
|
||||
),
|
||||
("Fallout.S01E03.1080p.WEB-DL", &ids(None, Some("15239678"))),
|
||||
];
|
||||
for (name, release_ids) in cases {
|
||||
assert_eq!(
|
||||
match_movie(&wanted, release_ids, &arr_parse::parse(name)),
|
||||
None,
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_wanted_titles_that_both_match_are_an_ambiguity() {
|
||||
let wanted = vec![
|
||||
@@ -440,4 +586,343 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn wanted_episode() -> WantedEpisode {
|
||||
WantedEpisode {
|
||||
id: EpisodeId(7),
|
||||
tmdb_id: Some(934_644),
|
||||
imdb_id: Some("tt11301866".to_owned()),
|
||||
title: "Fallout".to_owned(),
|
||||
season: 1,
|
||||
episode: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn episode_ids(tmdb_id: Option<u32>, imdb_id: Option<&str>) -> ReleaseIds {
|
||||
ids(tmdb_id, imdb_id)
|
||||
}
|
||||
|
||||
/// `(release name, expected shape)` for releases that satisfy the
|
||||
/// wanted `Fallout` S01E03, matched on title alone.
|
||||
#[test]
|
||||
fn real_single_episode_releases_match_their_episode() {
|
||||
let cases = [
|
||||
(
|
||||
"Fallout.2024.S01E03.1080p.WEB-DL.DDP5.1.H.264-GROUP",
|
||||
MatchShape::SingleEpisode,
|
||||
),
|
||||
(
|
||||
"Fallout.S01E03.2160p.WEB-DL.DV.HDR10Plus.HEVC-GROUP",
|
||||
MatchShape::SingleEpisode,
|
||||
),
|
||||
(
|
||||
"Fallout.S01E03.720p.AMZN.WEBRip.DDP5.1.x264-GROUP",
|
||||
MatchShape::SingleEpisode,
|
||||
),
|
||||
];
|
||||
for (name, shape) in cases {
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&wanted_episode(),
|
||||
&ReleaseIds::default(),
|
||||
&arr_parse::parse(name),
|
||||
),
|
||||
Some(EpisodeMatch {
|
||||
episode: EpisodeId(7),
|
||||
kind: MatchKind::TitleAndYear,
|
||||
shape,
|
||||
}),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The same series, offered as a pack for exactly its season.
|
||||
#[test]
|
||||
fn a_pack_for_this_season_matches_every_episode_in_it() {
|
||||
let cases = [
|
||||
(
|
||||
"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos.H.265-GROUP",
|
||||
MatchShape::SeasonPack,
|
||||
),
|
||||
(
|
||||
"Fallout.Season.1.1080p.BluRay.x265-GROUP",
|
||||
MatchShape::SeasonPack,
|
||||
),
|
||||
(
|
||||
"Fallout.S01.1080p.BluRay.x264-GROUP",
|
||||
MatchShape::SeasonPack,
|
||||
),
|
||||
(
|
||||
"Fallout.S01-S01.1080p.BluRay.x264-GROUP",
|
||||
MatchShape::SeasonPack,
|
||||
),
|
||||
];
|
||||
for (name, shape) in cases {
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&wanted_episode(),
|
||||
&ReleaseIds::default(),
|
||||
&arr_parse::parse(name),
|
||||
),
|
||||
Some(EpisodeMatch {
|
||||
episode: EpisodeId(7),
|
||||
kind: MatchKind::TitleAndYear,
|
||||
shape,
|
||||
}),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `COMPLETE` before the season tag used to stay in the title and
|
||||
/// break the exact-title comparison (#135).
|
||||
#[test]
|
||||
fn a_pack_marker_before_the_tag_still_matches() {
|
||||
let wanted = WantedEpisode {
|
||||
id: EpisodeId(10),
|
||||
tmdb_id: None,
|
||||
imdb_id: None,
|
||||
title: "Bluey".to_owned(),
|
||||
season: 1,
|
||||
episode: 3,
|
||||
};
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&wanted,
|
||||
&ReleaseIds::default(),
|
||||
&arr_parse::parse("Bluey.COMPLETE.S01.1080p.WEB-DL-GROUP"),
|
||||
),
|
||||
Some(EpisodeMatch {
|
||||
episode: EpisodeId(10),
|
||||
kind: MatchKind::TitleAndYear,
|
||||
shape: MatchShape::SeasonPack,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// Punctuation, case and accents in the series title do not block the
|
||||
/// match.
|
||||
#[test]
|
||||
fn mangled_series_titles_still_match() {
|
||||
let wanted = WantedEpisode {
|
||||
id: EpisodeId(8),
|
||||
tmdb_id: None,
|
||||
imdb_id: None,
|
||||
title: "Ms. Marvel".to_owned(),
|
||||
season: 1,
|
||||
episode: 3,
|
||||
};
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&wanted,
|
||||
&ReleaseIds::default(),
|
||||
&arr_parse::parse("Ms.Marvel.S01E03.1080p.DSNY.WEB-DL.DDP5.1.Atmos.H.264-GROUP"),
|
||||
)
|
||||
.map(|matched| matched.shape),
|
||||
Some(MatchShape::SingleEpisode)
|
||||
);
|
||||
}
|
||||
|
||||
/// Two series that share a word are different series. Both pairs are
|
||||
/// real shows.
|
||||
#[test]
|
||||
fn another_series_sharing_a_word_is_not_a_match() {
|
||||
let lone_star = WantedEpisode {
|
||||
id: EpisodeId(9),
|
||||
tmdb_id: None,
|
||||
imdb_id: None,
|
||||
title: "9-1-1: Lone Star".to_owned(),
|
||||
season: 4,
|
||||
episode: 1,
|
||||
};
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&lone_star,
|
||||
&ReleaseIds::default(),
|
||||
&arr_parse::parse("9-1-1.S07E01.1080p.AMZN.WEB-DL.DDP5.1.H.264-GROUP"),
|
||||
),
|
||||
None
|
||||
);
|
||||
|
||||
let fallout = wanted_episode();
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&fallout,
|
||||
&ReleaseIds::default(),
|
||||
&arr_parse::parse("The.Fallout.2021.S01E03.1080p.WEB-DL"),
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// A tracker-supplied id identifies the series whatever the name says.
|
||||
#[test]
|
||||
fn a_supplied_id_matches_whatever_the_name_says_tv() {
|
||||
for (tmdb_id, imdb_id) in [(Some(934_644), None), (None, Some("tt11301866"))] {
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&wanted_episode(),
|
||||
&episode_ids(tmdb_id, imdb_id),
|
||||
&arr_parse::parse("Totally.Unrelated.Name.S01.1080p.WEB-DL-GROUP"),
|
||||
),
|
||||
Some(EpisodeMatch {
|
||||
episode: EpisodeId(7),
|
||||
kind: if tmdb_id.is_some() {
|
||||
MatchKind::TmdbId
|
||||
} else {
|
||||
MatchKind::ImdbId
|
||||
},
|
||||
shape: MatchShape::SeasonPack,
|
||||
}),
|
||||
"{tmdb_id:?} {imdb_id:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Torznab definitions spell the same id several ways; all of them are
|
||||
/// the one series.
|
||||
#[test]
|
||||
fn an_imdb_id_matches_however_the_tracker_spells_it_tv() {
|
||||
for spelling in ["tt11301866", "11301866", "tt011301866"] {
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&wanted_episode(),
|
||||
&episode_ids(None, Some(spelling)),
|
||||
&arr_parse::parse("Unrelated.Name.S01E03.1080p.WEB-DL"),
|
||||
)
|
||||
.map(|matched| matched.kind),
|
||||
Some(MatchKind::ImdbId),
|
||||
"{spelling}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Same rule as films: the tracker said which series this is and it is
|
||||
/// not ours, so the title does not get a vote.
|
||||
#[test]
|
||||
fn a_supplied_id_that_is_not_wanted_ends_the_comparison_tv() {
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&wanted_episode(),
|
||||
&episode_ids(Some(1), None),
|
||||
&arr_parse::parse("Fallout.S01E03.1080p.WEB-DL"),
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&wanted_episode(),
|
||||
&episode_ids(None, Some("tt0000001")),
|
||||
&arr_parse::parse("Fallout.S01E03.1080p.WEB-DL"),
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// A placeholder or a non-id is no id at all, so the title lane still
|
||||
/// runs.
|
||||
#[test]
|
||||
fn a_junk_imdb_id_falls_through_to_the_title_tv() {
|
||||
for junk in ["", "tt", "tt0000000", "n/a"] {
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&wanted_episode(),
|
||||
&episode_ids(None, Some(junk)),
|
||||
&arr_parse::parse("Fallout.S01E03.1080p.WEB-DL"),
|
||||
)
|
||||
.map(|matched| matched.shape),
|
||||
Some(MatchShape::SingleEpisode),
|
||||
"{junk}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything that is not this exact episode or this exact season's
|
||||
/// pack satisfies nothing, whoever issued it.
|
||||
#[test]
|
||||
fn every_other_shape_satisfies_nothing() {
|
||||
let refusals = [
|
||||
// Wrong episode, wrong season.
|
||||
"Fallout.S01E04.1080p.WEB-DL-GROUP",
|
||||
"Fallout.S02E03.1080p.WEB-DL-GROUP",
|
||||
// Multi-episode files fight over one grabs row; manual only.
|
||||
"Fallout.S01E02E03.1080p.WEB-DL-GROUP",
|
||||
"Fallout.S01E02-E04.1080p.WEB-DL-GROUP",
|
||||
// Another season's pack, and a multi-season pack.
|
||||
"Fallout.S02.2160p.WEB-DL-GROUP",
|
||||
"Fallout.S01-S03.1080p.BluRay-GROUP",
|
||||
// A daily-show air date is not a numbered episode.
|
||||
"Last.Week.Tonight.With.John.Oliver.2024.05.13.1080p.WEB-DL",
|
||||
// No TV tag at all: a film, not an episode of anything.
|
||||
"A.Complete.Unknown.2024.2160p.WEB-DL-GROUP",
|
||||
];
|
||||
for name in refusals {
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&wanted_episode(),
|
||||
&ReleaseIds::default(),
|
||||
&arr_parse::parse(name),
|
||||
),
|
||||
None,
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A degenerate range means one season's pack, not a multi-season one.
|
||||
#[test]
|
||||
fn a_degenerate_season_range_is_this_seasons_pack() {
|
||||
use super::{is_single_episode, is_this_seasons_pack};
|
||||
use arr_parse::EpisodeClaim::{Daily, Episodes, Season, Seasons};
|
||||
assert!(is_this_seasons_pack(&Seasons { first: 1, last: 1 }, 1));
|
||||
assert!(!is_this_seasons_pack(&Seasons { first: 1, last: 3 }, 1));
|
||||
assert!(!is_this_seasons_pack(
|
||||
&Episodes {
|
||||
season: 1,
|
||||
episodes: vec![3]
|
||||
},
|
||||
1
|
||||
));
|
||||
assert!(!is_this_seasons_pack(&Season { season: 2 }, 1));
|
||||
assert!(!is_this_seasons_pack(
|
||||
&Daily {
|
||||
year: 2024,
|
||||
month: 5,
|
||||
day: 13
|
||||
},
|
||||
1
|
||||
));
|
||||
// A single episode of another season is still not this one.
|
||||
assert!(!is_single_episode(
|
||||
&Episodes {
|
||||
season: 2,
|
||||
episodes: vec![3]
|
||||
},
|
||||
1,
|
||||
3
|
||||
));
|
||||
}
|
||||
|
||||
/// A daily show's own episode, dated but unnumbered, satisfies nothing:
|
||||
/// there is no numbered wanted episode it can be filed against.
|
||||
#[test]
|
||||
fn a_daily_claim_never_satisfies_a_numbered_wanted_episode() {
|
||||
let wanted = WantedEpisode {
|
||||
id: EpisodeId(10),
|
||||
tmdb_id: Some(46_298),
|
||||
imdb_id: None,
|
||||
title: "Last Week Tonight with John Oliver".to_owned(),
|
||||
season: 11,
|
||||
episode: 17,
|
||||
};
|
||||
assert_eq!(
|
||||
match_episode(
|
||||
&wanted,
|
||||
&ReleaseIds::default(),
|
||||
&arr_parse::parse("Last.Week.Tonight.With.John.Oliver.2024.05.13.1080p.WEB-DL"),
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
//! WEB-DL at target beats a 60 GB remux while the remux keeps a score and
|
||||
//! stays eligible — it wins when nothing smaller exists.
|
||||
//!
|
||||
//! Size is scored against the band for the release's own resolution, so it
|
||||
//! says nothing about how resolutions rank against each other. That is the
|
||||
//! resolution-rank term: the policy's `resolution_preference` is ordered, and
|
||||
//! each step up it is worth a fixed number of points.
|
||||
//!
|
||||
//! Below the floor is a hard filter rather than a low score, and lives in
|
||||
//! [`crate::policy::SizeRule`]: unbounded "smaller is better" would otherwise
|
||||
//! select a 3 GB 4K encode that looks like mud.
|
||||
@@ -31,6 +36,18 @@ pub struct ScoreWeights {
|
||||
/// Points per doubling of the seeder count. Log-scaled and small —
|
||||
/// enough seeders to complete matters, past that it does not.
|
||||
pub seeder_doubling: i32,
|
||||
/// Points per step up the policy's `resolution_preference`. The last
|
||||
/// entry is worth nothing and each earlier one a step more, so the list
|
||||
/// that already decides eligibility also decides how resolutions rank
|
||||
/// against each other.
|
||||
///
|
||||
/// Sized against the size term deliberately. With the seeded bands — 4K
|
||||
/// target 22 GiB, 60 points per gibibyte over — 300 points is five
|
||||
/// gibibytes of overshoot: a 4K release up to 27 GiB outranks an
|
||||
/// at-target 1080p, and a bloated 4K past that does not. Below its
|
||||
/// target a 4K has to be within about four gibibytes of it to win, which
|
||||
/// keeps a mud-quality 8 GiB 4K from beating a good 1080p.
|
||||
pub resolution_step: i32,
|
||||
}
|
||||
|
||||
impl Default for ScoreWeights {
|
||||
@@ -39,6 +56,7 @@ impl Default for ScoreWeights {
|
||||
size_at_target: 1000,
|
||||
source_tier: 25,
|
||||
seeder_doubling: 8,
|
||||
resolution_step: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +72,10 @@ pub struct Score {
|
||||
pub source: i64,
|
||||
/// The log-scaled seeder term.
|
||||
pub seeders: i64,
|
||||
/// The resolution-rank term: where the release sits in the policy's
|
||||
/// `resolution_preference`. Zero when the name claims no resolution, or
|
||||
/// claims one the policy does not rank.
|
||||
pub resolution: i64,
|
||||
}
|
||||
|
||||
/// Score a candidate against a policy.
|
||||
@@ -61,23 +83,29 @@ pub struct Score {
|
||||
/// A candidate with no applicable size band — unknown resolution, or a
|
||||
/// resolution the policy carries no band for — scores zero on the dominant
|
||||
/// term rather than being penalised for what the release name failed to say.
|
||||
/// The resolution-rank term treats an unclaimed resolution the same way.
|
||||
#[must_use]
|
||||
pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders: u32) -> Score {
|
||||
let weights = &policy.score_weights;
|
||||
let size = candidate
|
||||
.resolution()
|
||||
let claimed = candidate.resolution();
|
||||
let size = claimed
|
||||
.and_then(|resolution| policy.size_bands.get(&resolution))
|
||||
.map_or(0, |band| size_points(band, weights, size_bytes));
|
||||
let source = candidate
|
||||
.source()
|
||||
.map_or(0, |source| source_points(policy, source));
|
||||
let seeders = seeder_points(weights, seeders);
|
||||
let resolution = claimed.map_or(0, |resolution| resolution_points(policy, resolution));
|
||||
|
||||
Score {
|
||||
total: size.saturating_add(source).saturating_add(seeders),
|
||||
total: size
|
||||
.saturating_add(source)
|
||||
.saturating_add(seeders)
|
||||
.saturating_add(resolution),
|
||||
size,
|
||||
source,
|
||||
seeders,
|
||||
resolution,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +157,24 @@ fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64
|
||||
at_target.saturating_mul(size.saturating_sub(floor)) / span
|
||||
}
|
||||
|
||||
/// The resolution-rank term (`DESIGN.md` §5.5). The policy's
|
||||
/// `resolution_preference` is an ordered list, so it says more than which
|
||||
/// resolutions are eligible: the last entry is worth nothing and every
|
||||
/// earlier one a step more, which is what makes a 4K release outrank a 1080p
|
||||
/// one that scores the same against its own size band.
|
||||
///
|
||||
/// A resolution the list does not carry is worth nothing rather than being
|
||||
/// penalised — it is already ineligible, and no ranking is no opinion.
|
||||
fn resolution_points(policy: &Policy, resolution: Resolution) -> i64 {
|
||||
let preference = &policy.resolution_preference;
|
||||
let Some(rank) = preference.iter().position(|entry| *entry == resolution) else {
|
||||
return 0;
|
||||
};
|
||||
let steps = preference.len().saturating_sub(1).saturating_sub(rank);
|
||||
i64::from(policy.score_weights.resolution_step)
|
||||
.saturating_mul(i64::try_from(steps).unwrap_or(i64::MAX))
|
||||
}
|
||||
|
||||
/// The source tiebreaker. A source the policy does not weight is worth
|
||||
/// nothing rather than being penalised.
|
||||
fn source_points(policy: &Policy, source: Source) -> i64 {
|
||||
@@ -219,6 +265,21 @@ mod tests {
|
||||
score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders)
|
||||
}
|
||||
|
||||
/// The same, at a resolution the caller picks.
|
||||
fn scored_at(
|
||||
resolution: ClaimedResolution,
|
||||
source: ClaimedSource,
|
||||
size_bytes: u64,
|
||||
seeders: u32,
|
||||
) -> Score {
|
||||
let claims = NameClaims {
|
||||
resolution: Some(resolution),
|
||||
source: Some(source),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders)
|
||||
}
|
||||
|
||||
fn size_rule(size_bytes: u64) -> RuleEvaluation {
|
||||
let policy = policy();
|
||||
let overrides = TitleOverrides::default();
|
||||
@@ -362,4 +423,81 @@ mod tests {
|
||||
assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target));
|
||||
assert_eq!(under.size, 0);
|
||||
}
|
||||
|
||||
/// Issue #113, observed live: a 23 GB 4K release ranked below several
|
||||
/// 1080p ones because each was scored against its own size band.
|
||||
#[test]
|
||||
fn a_slightly_over_target_4k_outranks_an_at_target_1080p() {
|
||||
let uhd = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(23), 20);
|
||||
let hd = scored_at(ClaimedResolution::P1080, ClaimedSource::WebDl, gib(8), 20);
|
||||
|
||||
assert!(uhd.size < hd.size);
|
||||
assert!(uhd.total > hd.total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_4k_far_over_target_still_loses_to_an_at_target_1080p() {
|
||||
let bloated = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(40), 20);
|
||||
let hd = scored_at(ClaimedResolution::P1080, ClaimedSource::WebDl, gib(8), 20);
|
||||
|
||||
assert!(bloated.total < hd.total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_preferred_resolution_is_worth_nothing() {
|
||||
let hd = scored_at(ClaimedResolution::P1080, ClaimedSource::WebDl, gib(8), 20);
|
||||
let uhd = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(22), 20);
|
||||
|
||||
assert_eq!(hd.resolution, 0);
|
||||
assert_eq!(
|
||||
uhd.resolution,
|
||||
i64::from(ScoreWeights::default().resolution_step)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_step_up_the_preference_is_worth_the_same() {
|
||||
let mut policy = policy();
|
||||
policy.resolution_preference =
|
||||
vec![Resolution::R2160p, Resolution::R1080p, Resolution::R720p];
|
||||
let step = i64::from(policy.score_weights.resolution_step);
|
||||
let at = |resolution| {
|
||||
let claims = NameClaims {
|
||||
resolution: Some(resolution),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy, Candidate::PreGrab(&claims), gib(8), 0).resolution
|
||||
};
|
||||
|
||||
assert_eq!(at(ClaimedResolution::P2160), 2 * step);
|
||||
assert_eq!(at(ClaimedResolution::P1080), step);
|
||||
assert_eq!(at(ClaimedResolution::P720), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unranked_resolution_scores_no_resolution_term() {
|
||||
// 720p is not in the seeded preference, and an unclaimed resolution
|
||||
// has said nothing at all. Neither is a penalty.
|
||||
let unranked = scored_at(ClaimedResolution::P720, ClaimedSource::WebDl, gib(4), 10);
|
||||
let unclaimed = NameClaims {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10);
|
||||
|
||||
assert_eq!(unranked.resolution, 0);
|
||||
assert_eq!(unclaimed.resolution, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_resolution_term_outweighs_the_tiebreakers() {
|
||||
// A 1080p remux with every seeder in the world must not beat a 4K
|
||||
// WEB-DL sitting at its own target.
|
||||
let uhd = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(22), 5);
|
||||
let hd = scored_at(ClaimedResolution::P1080, ClaimedSource::Remux, gib(8), 4096);
|
||||
|
||||
assert!(hd.source > uhd.source);
|
||||
assert!(hd.seeders > uhd.seeders);
|
||||
assert!(uhd.total > hd.total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,20 @@ pub enum SeriesStatus {
|
||||
}
|
||||
|
||||
/// Derives a series' status at `now` without persisting lifecycle intent.
|
||||
///
|
||||
/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes with
|
||||
/// `season_number` 0 are ignored, so a manually wanted special cannot pin
|
||||
/// the series at `incomplete` or hold back `ended`. The season number rides
|
||||
/// on each episode, so there is no second slice a caller can forget (#131).
|
||||
#[must_use]
|
||||
pub fn derive_series_status(
|
||||
series: &Series,
|
||||
episodes: &[Episode],
|
||||
now: SystemTime,
|
||||
) -> SeriesStatus {
|
||||
let wanted = episodes.iter().filter(|episode| episode.wanted);
|
||||
let wanted = episodes
|
||||
.iter()
|
||||
.filter(|episode| episode.wanted && episode.season_number != 0);
|
||||
let complete = wanted
|
||||
.clone()
|
||||
.all(|episode| episode.state == MediaState::Available);
|
||||
@@ -93,6 +100,7 @@ mod tests {
|
||||
Episode {
|
||||
id: EpisodeId(i64::from(number)),
|
||||
season_id: SeasonId(1),
|
||||
season_number: 1,
|
||||
number,
|
||||
title: format!("Episode {number}"),
|
||||
air_date,
|
||||
@@ -203,4 +211,35 @@ mod tests {
|
||||
SeriesStatus::Airing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_specials_do_not_hold_back_complete_or_ended() {
|
||||
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
||||
let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)];
|
||||
let mut special = episode(1, Some(now - 90 * DAY), MediaState::Missing);
|
||||
special.season_id = SeasonId(9);
|
||||
special.season_number = 0;
|
||||
|
||||
assert_eq!(
|
||||
derive_series_status(&series(true, false), &episodes, now),
|
||||
SeriesStatus::Waiting
|
||||
);
|
||||
|
||||
episodes.push(special);
|
||||
assert_eq!(
|
||||
derive_series_status(&series(true, false), &episodes, now),
|
||||
SeriesStatus::Waiting,
|
||||
"#131: the season number rides on the episode, so the special is \
|
||||
invisible however the caller builds the slice"
|
||||
);
|
||||
assert_eq!(
|
||||
derive_series_status(&series(false, false), &episodes, now),
|
||||
SeriesStatus::Complete,
|
||||
"§4.2: the only gap is a special"
|
||||
);
|
||||
assert_eq!(
|
||||
derive_series_status(&series(false, true), &episodes, now),
|
||||
SeriesStatus::Ended
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,17 @@ pub struct RefreshedSeason {
|
||||
/// Applies a series' tracking rule to newly revealed seasons.
|
||||
///
|
||||
/// Existing seasons keep their tracking rule and leaf-level intent unchanged.
|
||||
/// Season zero is treated like every other newly revealed season.
|
||||
/// Season 0 is never tracked by the rule (`DESIGN.md` §4.1); an operator may
|
||||
/// still track it by hand.
|
||||
pub fn apply_auto_track(series: &Series, seasons: &mut [RefreshedSeason]) {
|
||||
if !series.auto_track {
|
||||
return;
|
||||
}
|
||||
|
||||
for refreshed in seasons.iter_mut().filter(|season| season.is_new) {
|
||||
for refreshed in seasons
|
||||
.iter_mut()
|
||||
.filter(|season| season.is_new && season.season.number != 0)
|
||||
{
|
||||
refreshed.season.tracked = true;
|
||||
for episode in &mut refreshed.episodes {
|
||||
episode.wanted = true;
|
||||
@@ -25,11 +29,25 @@ pub fn apply_auto_track(series: &Series, seasons: &mut [RefreshedSeason]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a season's tracking rule (`DESIGN.md` §4.1) to its
|
||||
/// already-revealed episodes.
|
||||
///
|
||||
/// Turning tracking on marks every revealed episode wanted; future reveals
|
||||
/// follow while the season stays tracked. Turning it off withdraws nothing:
|
||||
/// leaf intent is never removed implicitly.
|
||||
pub fn apply_tracked(tracked: bool, episodes: &mut [Episode]) {
|
||||
if tracked {
|
||||
for episode in episodes {
|
||||
episode.wanted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::SystemTime;
|
||||
|
||||
use super::{apply_auto_track, RefreshedSeason};
|
||||
use super::{apply_auto_track, apply_tracked, RefreshedSeason};
|
||||
use crate::{
|
||||
Episode, EpisodeId, Language, MediaState, RootId, Season, SeasonId, Series, SeriesId,
|
||||
TitleOverrides,
|
||||
@@ -54,6 +72,7 @@ mod tests {
|
||||
Episode {
|
||||
id: EpisodeId(season * 100 + i64::from(number)),
|
||||
season_id: SeasonId(season),
|
||||
season_number: u16::try_from(season).unwrap_or_default(),
|
||||
number,
|
||||
title: format!("Episode {number}"),
|
||||
air_date: Some(SystemTime::UNIX_EPOCH),
|
||||
@@ -119,12 +138,77 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn season_zero_follows_the_same_rule() {
|
||||
fn auto_track_skips_season_zero() {
|
||||
let mut refresh = [refreshed_season(0, true, &[false])];
|
||||
|
||||
apply_auto_track(&series(true), &mut refresh);
|
||||
|
||||
assert!(refresh[0].season.tracked);
|
||||
assert!(refresh[0].episodes[0].wanted);
|
||||
assert!(!refresh[0].season.tracked);
|
||||
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_on_marks_revealed_episodes_wanted() {
|
||||
let cases: [&[bool]; 4] = [
|
||||
&[],
|
||||
&[false],
|
||||
&[true, false, false],
|
||||
&[false, true, false, true],
|
||||
];
|
||||
for wanted in cases {
|
||||
let mut episodes = wanted
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, wanted)| {
|
||||
episode(
|
||||
1,
|
||||
u16::try_from(index + 1).expect("episode number fits"),
|
||||
*wanted,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
apply_tracked(true, &mut episodes);
|
||||
|
||||
assert!(
|
||||
episodes.iter().all(|episode| episode.wanted),
|
||||
"tracked on must mark every revealed episode wanted, input {wanted:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_off_withdraws_nothing() {
|
||||
let cases: [&[bool]; 4] = [
|
||||
&[],
|
||||
&[false],
|
||||
&[true, false, false],
|
||||
&[false, true, false, true],
|
||||
];
|
||||
for wanted in cases {
|
||||
let mut episodes = wanted
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, wanted)| {
|
||||
episode(
|
||||
1,
|
||||
u16::try_from(index + 1).expect("episode number fits"),
|
||||
*wanted,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let before = episodes
|
||||
.iter()
|
||||
.map(|episode| episode.wanted)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
apply_tracked(false, &mut episodes);
|
||||
|
||||
let after = episodes
|
||||
.iter()
|
||||
.map(|episode| episode.wanted)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(before, after, "tracked off must not touch intent");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
//! §9.5 *needs a decision* → the operator alone: a movie entered the
|
||||
//! no-PT-source queue, or hard-failed twice on different releases (the same
|
||||
//! two queues `GET /api/queues/attention` reports, §9.3).
|
||||
//! §9.5 *needs a decision* → the operator alone: a movie or series entered
|
||||
//! the no-PT-source queue, or hard-failed twice on different releases (the
|
||||
//! same queues `GET /api/queues/attention` reports, §9.3).
|
||||
//!
|
||||
//! Edge-triggered: a movie notifies once when it enters either queue, and is
|
||||
//! forgotten once it leaves both, so a future re-entry notifies again. The
|
||||
//! notified set is in-memory and transient — a restart re-notifies whatever
|
||||
//! is currently queued, same tradeoff `ImportAction`'s probe cache makes.
|
||||
//! Edge-triggered per title: it notifies once when the title enters either
|
||||
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
|
||||
//! again. A series notifies as its series, never per episode — a broken
|
||||
//! twenty-episode season is one message, which is the restraint §9.5 exists
|
||||
//! for. The notified set is in-memory and transient — a restart re-notifies
|
||||
//! whatever is currently queued, same tradeoff `ImportAction`'s probe cache
|
||||
//! makes.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
@@ -16,11 +19,67 @@ use tokio::sync::Mutex;
|
||||
use crate::notify::Notifier;
|
||||
use crate::reconcile::{Action, ActionFuture, Outcome};
|
||||
|
||||
/// What is queued. Movies and series have independent id sequences, so the
|
||||
/// kind travels with the id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
enum Subject {
|
||||
Movie(i64),
|
||||
Series(i64),
|
||||
}
|
||||
|
||||
/// One series' standing in the TV queues, rolled up from its episodes.
|
||||
#[derive(Debug, Default)]
|
||||
struct TvEntry {
|
||||
/// Wanted episodes whose every candidate was rejected for language.
|
||||
no_pt_source: Vec<i64>,
|
||||
/// Episodes two different releases hard-failed post-probe (§5.7).
|
||||
hard_failed_episodes: Vec<i64>,
|
||||
/// Seasons whose pack grab hard-failed, sending the season back to
|
||||
/// per-episode grabbing.
|
||||
failed_season_packs: Vec<i64>,
|
||||
}
|
||||
|
||||
impl TvEntry {
|
||||
fn reason(&self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if !self.no_pt_source.is_empty() {
|
||||
parts.push(plural(
|
||||
self.no_pt_source.len(),
|
||||
"episode found no Portuguese-audio source",
|
||||
"episodes found no Portuguese-audio source",
|
||||
));
|
||||
}
|
||||
if !self.hard_failed_episodes.is_empty() {
|
||||
parts.push(plural(
|
||||
self.hard_failed_episodes.len(),
|
||||
"episode hard-failed twice on different releases",
|
||||
"episodes hard-failed twice on different releases",
|
||||
));
|
||||
}
|
||||
if !self.failed_season_packs.is_empty() {
|
||||
parts.push(plural(
|
||||
self.failed_season_packs.len(),
|
||||
"season pack hard-failed",
|
||||
"season packs hard-failed",
|
||||
));
|
||||
}
|
||||
parts.join("; ")
|
||||
}
|
||||
}
|
||||
|
||||
fn plural(count: usize, one: &str, many: &str) -> String {
|
||||
if count == 1 {
|
||||
format!("1 {one}")
|
||||
} else {
|
||||
format!("{count} {many}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AttentionAction {
|
||||
notifier: Notifier,
|
||||
operator_topic: String,
|
||||
notified: Arc<Mutex<HashSet<i64>>>,
|
||||
notified: Arc<Mutex<HashSet<Subject>>>,
|
||||
}
|
||||
|
||||
impl AttentionAction {
|
||||
@@ -34,7 +93,7 @@ impl AttentionAction {
|
||||
}
|
||||
|
||||
async fn tick(&self, database: &Db) -> Result<Vec<Outcome>, sqlx::Error> {
|
||||
let mut queued: HashMap<i64, (String, &'static str)> = HashMap::new();
|
||||
let mut queued: HashMap<Subject, (String, String)> = HashMap::new();
|
||||
|
||||
let no_pt_source = sqlx::query!(
|
||||
r#"
|
||||
@@ -59,10 +118,10 @@ impl AttentionAction {
|
||||
.await?;
|
||||
for row in no_pt_source {
|
||||
queued.insert(
|
||||
row.id,
|
||||
Subject::Movie(row.id),
|
||||
(
|
||||
title_with_year(&row.title, row.year),
|
||||
"no Portuguese-audio source found",
|
||||
"no Portuguese-audio source found".to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -71,7 +130,8 @@ impl AttentionAction {
|
||||
r#"
|
||||
SELECT id AS "id!: i64", title AS "title!: String", year
|
||||
FROM movies
|
||||
WHERE (SELECT count(DISTINCT g.release_id)
|
||||
WHERE movies.wanted = 1 AND movies.state != 'available'
|
||||
AND (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
|
||||
@@ -80,18 +140,25 @@ impl AttentionAction {
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
for row in needs_decision {
|
||||
queued.entry(row.id).or_insert_with(|| {
|
||||
queued.entry(Subject::Movie(row.id)).or_insert_with(|| {
|
||||
(
|
||||
title_with_year(&row.title, row.year),
|
||||
"hard-failed twice on different releases",
|
||||
"hard-failed twice on different releases".to_string(),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
for (series_id, title, year, entry) in queue_tv(database).await? {
|
||||
queued.insert(
|
||||
Subject::Series(series_id),
|
||||
(title_with_year(&title, year), entry.reason()),
|
||||
);
|
||||
}
|
||||
|
||||
let mut notified = self.notified.lock().await;
|
||||
let mut outcomes = Vec::new();
|
||||
for (&movie_id, (title, reason)) in &queued {
|
||||
if !notified.insert(movie_id) {
|
||||
for (subject, (title, reason)) in &queued {
|
||||
if !notified.insert(subject.clone()) {
|
||||
continue;
|
||||
}
|
||||
match self
|
||||
@@ -99,16 +166,22 @@ impl AttentionAction {
|
||||
.send(&self.operator_topic, title, reason)
|
||||
.await
|
||||
{
|
||||
Ok(()) => outcomes.push(Outcome::new(
|
||||
format!("movie {movie_id} needs a decision"),
|
||||
format!("notified operator: {reason}"),
|
||||
)),
|
||||
Ok(()) => {
|
||||
let (kind, id) = match subject {
|
||||
Subject::Movie(id) => ("movie", *id),
|
||||
Subject::Series(id) => ("series", *id),
|
||||
};
|
||||
outcomes.push(Outcome::new(
|
||||
format!("{kind} {id} needs a decision"),
|
||||
format!("notified operator: {reason}"),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, movie_id, "needs-decision notification failed");
|
||||
tracing::warn!(%error, ?subject, "needs-decision notification failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
notified.retain(|movie_id| queued.contains_key(movie_id));
|
||||
notified.retain(|subject| queued.contains_key(subject));
|
||||
|
||||
Ok(outcomes)
|
||||
}
|
||||
@@ -121,6 +194,100 @@ fn title_with_year(title: &str, year: Option<i64>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The series' roll-up entry, created empty on first sight.
|
||||
fn tv_entry(
|
||||
tv: &mut HashMap<i64, (String, Option<i64>, TvEntry)>,
|
||||
series_id: i64,
|
||||
title: String,
|
||||
year: Option<i64>,
|
||||
) -> &mut TvEntry {
|
||||
&mut tv
|
||||
.entry(series_id)
|
||||
.or_insert_with(|| (title, year, TvEntry::default()))
|
||||
.2
|
||||
}
|
||||
|
||||
/// TV roll-up (§9.5): every queued series with what put it there — wanted
|
||||
/// episodes whose every candidate was rejected for language, episodes two
|
||||
/// different releases hard-failed post-probe, and seasons whose pack grab
|
||||
/// hard-failed. One entry per series, so the notification can be one per
|
||||
/// series however long the broken season is.
|
||||
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
|
||||
let mut tv = HashMap::new();
|
||||
|
||||
let tv_no_pt_source = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year,
|
||||
e.id AS "episode_id!: i64"
|
||||
FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
JOIN roots root ON root.id = s.root_id
|
||||
WHERE root.audience = 'kids'
|
||||
AND s.blocked = 0
|
||||
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM episode_releases er
|
||||
JOIN releases r ON r.id = er.release_id
|
||||
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
|
||||
)
|
||||
"#
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
for row in tv_no_pt_source {
|
||||
tv_entry(&mut tv, row.series_id, row.title, row.year)
|
||||
.no_pt_source
|
||||
.push(row.episode_id);
|
||||
}
|
||||
|
||||
let episode_hard_fails = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year,
|
||||
g.target_id AS "episode_id!: i64"
|
||||
FROM grabs g
|
||||
JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND e.wanted = 1 AND e.state != 'available'
|
||||
GROUP BY s.id, s.title, s.year, e.id
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
for row in episode_hard_fails {
|
||||
tv_entry(&mut tv, row.series_id, row.title, row.year)
|
||||
.hard_failed_episodes
|
||||
.push(row.episode_id);
|
||||
}
|
||||
|
||||
let season_pack_fails = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year,
|
||||
g.target_id AS "season_id!: i64"
|
||||
FROM grabs g
|
||||
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
GROUP BY s.id, s.title, s.year, se.id
|
||||
"#
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
for row in season_pack_fails {
|
||||
tv_entry(&mut tv, row.series_id, row.title, row.year)
|
||||
.failed_season_packs
|
||||
.push(row.season_id);
|
||||
}
|
||||
|
||||
Ok(tv
|
||||
.into_iter()
|
||||
.map(|(id, (t, y, e))| (id, t, y, e))
|
||||
.collect())
|
||||
}
|
||||
|
||||
impl Action for AttentionAction {
|
||||
fn name(&self) -> &'static str {
|
||||
"attention"
|
||||
@@ -168,6 +335,94 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A series on the kids TV root with one season, and `count` wanted,
|
||||
/// missing episodes that have been searched. No stored releases: every
|
||||
/// candidate was rejected for language (§5.2's no-PT-source case).
|
||||
async fn insert_no_pt_source_series(database: &Db, tmdb_id: i64, episode_count: usize) -> i64 {
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Bluey', 2018, ?)",
|
||||
)
|
||||
.bind(tmdb_id)
|
||||
.bind(root_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let series_id: i64 = sqlx::query_scalar("SELECT id FROM series WHERE tmdb_id = ?")
|
||||
.bind(tmdb_id)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)")
|
||||
.bind(series_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE series_id = ?")
|
||||
.bind(series_id)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
for number in 0..episode_count {
|
||||
sqlx::query(
|
||||
"INSERT INTO episodes (season_id, number, title, wanted, state, search_attempts)
|
||||
VALUES (?, ?, ?, 1, 'missing', 1)",
|
||||
)
|
||||
.bind(season_id)
|
||||
.bind(i64::try_from(number).unwrap_or(0) + 1)
|
||||
.bind(format!("Episode {number}"))
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
series_id
|
||||
}
|
||||
|
||||
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||
/// standing in for what the import tick leaves behind post-probe.
|
||||
async fn insert_failed_grab(
|
||||
database: &Db,
|
||||
target_kind: &str,
|
||||
target_id: i64,
|
||||
release_guid: &str,
|
||||
) {
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible')
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(release_guid)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
||||
VALUES (?, ?, ?, ?, 'failed')",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(target_kind)
|
||||
.bind(target_id)
|
||||
.bind(format!("hash-{release_guid}"))
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn action(server: &MockServer) -> AttentionAction {
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(server)
|
||||
.await;
|
||||
AttentionAction::new(
|
||||
Notifier::new(server.uri()).unwrap(),
|
||||
"operator-topic".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_movie_entering_the_queue_notifies_the_operator_once() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
@@ -224,4 +479,156 @@ mod tests {
|
||||
assert_eq!(third.len(), 1, "re-enters and notifies again");
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_series_entering_the_queue_notifies_once_per_series() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
// Twenty broken episodes are one notification, not twenty (§9.5).
|
||||
insert_no_pt_source_series(&database, 1, 20).await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
let first = action.tick(&database).await.unwrap();
|
||||
let second = action.tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(first.len(), 1, "one notification for the whole series");
|
||||
assert_eq!(second.len(), 0, "does not repeat while still queued");
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_series_leaving_and_re_entering_the_queue_notifies_again() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
let series_id = insert_no_pt_source_series(&database, 1, 1).await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
action.tick(&database).await.unwrap();
|
||||
|
||||
sqlx::query("UPDATE series SET blocked = 1 WHERE id = ?")
|
||||
.bind(series_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
action.tick(&database).await.unwrap();
|
||||
|
||||
sqlx::query("UPDATE series SET blocked = 0 WHERE id = ?")
|
||||
.bind(series_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let third = action.tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(third.len(), 1, "re-enters and notifies again");
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_episode_hard_failed_twice_notifies_its_series() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
insert_no_pt_source_series(&database, 1, 0).await;
|
||||
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
||||
VALUES (?, 1, 'Episode 0', 1, 'missing')",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let episode_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
|
||||
.bind(season_id)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_failed_grab(&database, "episode", episode_id, "first").await;
|
||||
insert_failed_grab(&database, "episode", episode_id, "second").await;
|
||||
// The pack's failure sent this season back to per-episode grabbing;
|
||||
// it queues the same series, so it must not double the message.
|
||||
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
let outcomes = action.tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
outcomes.len(),
|
||||
1,
|
||||
"both hard-fail conditions roll up to one series notification"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_movie_imported_after_two_hard_fails_leaves_the_queue() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
let movie_id = insert_no_pt_source_movie(&database).await;
|
||||
insert_failed_grab(&database, "movie", movie_id, "first").await;
|
||||
insert_failed_grab(&database, "movie", movie_id, "second").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
let first = action.tick(&database).await.unwrap();
|
||||
assert_eq!(first.len(), 1, "queued while unsatisfied");
|
||||
|
||||
// Imported from a third release: satisfied, no decision needed.
|
||||
sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?")
|
||||
.bind(movie_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let second = action.tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(second.len(), 0, "leaves the queue once imported");
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_episode_imported_after_two_hard_fails_leaves_the_queue() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
insert_no_pt_source_series(&database, 1, 0).await;
|
||||
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
||||
VALUES (?, 1, 'Episode 0', 1, 'missing')",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let episode_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
|
||||
.bind(season_id)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_failed_grab(&database, "episode", episode_id, "first").await;
|
||||
insert_failed_grab(&database, "episode", episode_id, "second").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
let first = action.tick(&database).await.unwrap();
|
||||
assert_eq!(first.len(), 1, "queued while unsatisfied");
|
||||
|
||||
// Imported from a third release: satisfied, no decision needed.
|
||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
|
||||
.bind(episode_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let second = action.tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(second.len(), 0, "leaves the queue once imported");
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
+285
-59
@@ -129,8 +129,9 @@ impl GrabAction {
|
||||
for movie in gaps {
|
||||
let (movie_id, title) = (movie.id, movie.title.clone());
|
||||
let movie = match self.refresh_metadata(database, movie).await {
|
||||
Ok(Some(movie)) => movie,
|
||||
Ok(None) => continue,
|
||||
Ok((movie, true)) => movie,
|
||||
// Not released yet: no targeted search (§6.2).
|
||||
Ok((_, false)) => continue,
|
||||
// A title's metadata is not the rest of the tick's problem,
|
||||
// same as a grab failure below.
|
||||
Err(error) => {
|
||||
@@ -162,17 +163,22 @@ impl GrabAction {
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
/// Refresh one title from TMDB, returning it alongside whether it is
|
||||
/// digitally released (§6.2). Targeted search is gated on that flag; the
|
||||
/// manual deck refresh of an already-available movie is not, since the
|
||||
/// file on disk is proof enough and TMDB does not know a digital date for
|
||||
/// every title (issue #115).
|
||||
async fn refresh_metadata(
|
||||
&self,
|
||||
database: &Db,
|
||||
movie: PendingMovie,
|
||||
) -> Result<Option<PendingMovie>, GrabError> {
|
||||
) -> Result<(PendingMovie, bool), GrabError> {
|
||||
let Some(tmdb) = &self.tmdb else {
|
||||
return Ok(Some(movie));
|
||||
return Ok((movie, true));
|
||||
};
|
||||
if !metadata_refresh_due(movie.metadata_refreshed_at.as_deref()) {
|
||||
let released = is_digitally_released(movie.digital_release.as_deref());
|
||||
return Ok(released.then_some(movie));
|
||||
return Ok((movie, released));
|
||||
}
|
||||
let tmdb_id =
|
||||
u32::try_from(movie.tmdb_id).map_err(|_| GrabError::InvalidTmdbId(movie.id))?;
|
||||
@@ -232,28 +238,31 @@ impl GrabAction {
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
if !metadata.is_digitally_released(chrono::Utc::now().date_naive()) {
|
||||
let released = metadata.is_digitally_released(chrono::Utc::now().date_naive());
|
||||
if !released {
|
||||
tracing::debug!(
|
||||
movie_id = movie.id,
|
||||
"digital release has not happened; skipping targeted search"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(PendingMovie {
|
||||
id: movie.id,
|
||||
tmdb_id: movie.tmdb_id,
|
||||
title,
|
||||
year,
|
||||
original_language,
|
||||
search_attempts: if changed { 0 } else { movie.search_attempts },
|
||||
last_searched_at: if changed {
|
||||
None
|
||||
} else {
|
||||
movie.last_searched_at
|
||||
Ok((
|
||||
PendingMovie {
|
||||
id: movie.id,
|
||||
tmdb_id: movie.tmdb_id,
|
||||
title,
|
||||
year,
|
||||
original_language,
|
||||
search_attempts: if changed { 0 } else { movie.search_attempts },
|
||||
last_searched_at: if changed {
|
||||
None
|
||||
} else {
|
||||
movie.last_searched_at
|
||||
},
|
||||
digital_release,
|
||||
metadata_refreshed_at: None,
|
||||
},
|
||||
digital_release,
|
||||
metadata_refreshed_at: None,
|
||||
}))
|
||||
released,
|
||||
))
|
||||
}
|
||||
|
||||
/// Search every indexer for one title, cache each candidate with its
|
||||
@@ -374,28 +383,91 @@ impl GrabAction {
|
||||
.await
|
||||
}
|
||||
|
||||
/// The manual trigger (§6.2, §9.3, issue #107): reset the backoff so
|
||||
/// `search_due` cannot skip the title, then run the same search-and-grab
|
||||
/// path as a tick, scoped to this one movie instead of the tick's
|
||||
/// Sweep every searchable indexer for one title and upsert the results
|
||||
/// into its release deck (§9.3) without choosing a winner.
|
||||
///
|
||||
/// Issue #115: a manual search on a movie that is already satisfied is an
|
||||
/// upgrade view being refreshed, not a gap being closed. It must re-score
|
||||
/// and re-cache candidates — the same writes as the grab lane — and stop
|
||||
/// there. Only a wanted movie with nothing on disk auto-grabs.
|
||||
async fn refresh_deck(
|
||||
&self,
|
||||
database: &Db,
|
||||
movie: &PendingMovie,
|
||||
indexers: &[i64],
|
||||
) -> Result<(), GrabError> {
|
||||
let Some(loaded) = database.movie_policy(movie.id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
// §5.2: no original language, nothing to evaluate the language rule
|
||||
// against — same refusal as the grab lane.
|
||||
let Some(original_language) = movie.original_language.as_deref() else {
|
||||
tracing::warn!(
|
||||
movie_id = movie.id,
|
||||
title = movie.title,
|
||||
"no original language yet; not searching"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let original_language = arr_db::policy::language(original_language);
|
||||
let blacklist = Blacklist::load(database.pool()).await?;
|
||||
let eligible = self
|
||||
.search(
|
||||
database,
|
||||
movie,
|
||||
indexers,
|
||||
&loaded,
|
||||
&original_language,
|
||||
&blacklist,
|
||||
)
|
||||
.await?;
|
||||
record_search(database, movie.id).await?;
|
||||
tracing::info!(
|
||||
movie_id = movie.id,
|
||||
title = movie.title,
|
||||
eligible = eligible.len(),
|
||||
"release deck refreshed; not grabbing an already-satisfied movie"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The manual trigger (§6.2, §9.3, issues #107 and #115): reset the
|
||||
/// backoff so `search_due` cannot skip the title, then sweep the
|
||||
/// indexers, scoped to this one movie instead of the tick's
|
||||
/// `MOVIES_PER_TICK` batch.
|
||||
///
|
||||
/// What happens to the results depends on the movie, not on the command:
|
||||
/// a gap — wanted, unblocked, nothing on disk, no grab in flight — gets
|
||||
/// search and grab; anything else already satisfied gets its deck
|
||||
/// refreshed and no grab; a blocked movie is refused outright (§6.3).
|
||||
pub(crate) async fn search_now(
|
||||
&self,
|
||||
database: &Db,
|
||||
movie_id: i64,
|
||||
) -> Result<Option<Outcome>, GrabError> {
|
||||
let Some((movie, lane)) = manual_movie(database, movie_id).await? else {
|
||||
tracing::info!(movie_id, "manual search refused: unknown or blocked movie");
|
||||
return Ok(None);
|
||||
};
|
||||
reset_search_backoff(database, movie_id).await?;
|
||||
let Some(movie) = pending_movie(database, movie_id).await? else {
|
||||
let (movie, released) = self.refresh_metadata(database, movie).await?;
|
||||
// The release-date gate belongs to targeted search. A movie that is
|
||||
// already on disk is released whatever TMDB says.
|
||||
if !released && lane == ManualSearch::SearchAndGrab {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(movie) = self.refresh_metadata(database, movie).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
}
|
||||
let searchable = self.indexers.searchable().await?;
|
||||
if searchable.is_empty() {
|
||||
tracing::warn!("no indexer advertises a text search; nothing can be grabbed");
|
||||
return Ok(None);
|
||||
}
|
||||
self.grab_one(database, &movie, &searchable).await
|
||||
match lane {
|
||||
ManualSearch::SearchAndGrab => self.grab_one(database, &movie, &searchable).await,
|
||||
ManualSearch::RefreshDeck => {
|
||||
self.refresh_deck(database, &movie, &searchable).await?;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The manual one-click grab (§9.3, issue #107): the release is already
|
||||
@@ -836,7 +908,7 @@ struct PendingMovie {
|
||||
/// The eligible view of a stored release, ranked for selection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Eligible {
|
||||
id: i64,
|
||||
pub(crate) id: i64,
|
||||
pub(crate) indexer_id: i64,
|
||||
pub(crate) guid: String,
|
||||
pub(crate) name: String,
|
||||
@@ -901,9 +973,24 @@ fn search_due(movie: &PendingMovie) -> bool {
|
||||
backoff_elapsed(movie.search_attempts, movie.last_searched_at.as_deref())
|
||||
}
|
||||
|
||||
/// Same shape as [`pending_movies`], scoped to one id and without the tick's
|
||||
/// batch limit — the manual trigger already named which title to search.
|
||||
async fn pending_movie(database: &Db, movie_id: i64) -> Result<Option<PendingMovie>, GrabError> {
|
||||
/// What a manual search on one movie is allowed to do (issue #115).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ManualSearch {
|
||||
/// The movie is a gap: sweep the indexers and grab the winner.
|
||||
SearchAndGrab,
|
||||
/// The movie is already satisfied: sweep the indexers, refresh the deck
|
||||
/// (§9.3), grab nothing.
|
||||
RefreshDeck,
|
||||
}
|
||||
|
||||
/// Same columns as [`pending_movies`], scoped to one id, without the tick's
|
||||
/// batch limit — the manual trigger already named which title to search — and
|
||||
/// without the gap filter, which decides the lane here instead of hiding the
|
||||
/// movie. `blocked` still refuses (§6.3).
|
||||
async fn manual_movie(
|
||||
database: &Db,
|
||||
movie_id: i64,
|
||||
) -> Result<Option<(PendingMovie, ManualSearch)>, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT m.id AS "id!: i64",
|
||||
@@ -914,36 +1001,48 @@ async fn pending_movie(database: &Db, movie_id: i64) -> Result<Option<PendingMov
|
||||
m.search_attempts AS "search_attempts!: i64",
|
||||
m.last_searched_at,
|
||||
m.digital_release,
|
||||
m.metadata_refreshed_at
|
||||
m.metadata_refreshed_at,
|
||||
(
|
||||
m.wanted = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_files f
|
||||
WHERE f.owner_kind = 'movie' AND f.owner_id = m.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM grabs g
|
||||
WHERE g.target_kind = 'movie' AND g.target_id = m.id
|
||||
AND g.state IN ('sent', 'downloaded', 'imported')
|
||||
)
|
||||
) AS "is_gap!: i64"
|
||||
FROM movies m
|
||||
WHERE m.id = ?
|
||||
AND m.wanted = 1
|
||||
AND m.blocked = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_files f
|
||||
WHERE f.owner_kind = 'movie' AND f.owner_id = m.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM grabs g
|
||||
WHERE g.target_kind = 'movie' AND g.target_id = m.id
|
||||
AND g.state IN ('sent', 'downloaded', 'imported')
|
||||
)
|
||||
"#,
|
||||
movie_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|row| PendingMovie {
|
||||
id: row.id,
|
||||
tmdb_id: row.tmdb_id,
|
||||
title: row.title,
|
||||
year: row.year,
|
||||
original_language: row.original_language,
|
||||
search_attempts: row.search_attempts,
|
||||
last_searched_at: row.last_searched_at,
|
||||
digital_release: row.digital_release,
|
||||
metadata_refreshed_at: row.metadata_refreshed_at,
|
||||
Ok(row.map(|row| {
|
||||
let lane = if row.is_gap == 1 {
|
||||
ManualSearch::SearchAndGrab
|
||||
} else {
|
||||
ManualSearch::RefreshDeck
|
||||
};
|
||||
(
|
||||
PendingMovie {
|
||||
id: row.id,
|
||||
tmdb_id: row.tmdb_id,
|
||||
title: row.title,
|
||||
year: row.year,
|
||||
original_language: row.original_language,
|
||||
search_attempts: row.search_attempts,
|
||||
last_searched_at: row.last_searched_at,
|
||||
digital_release: row.digital_release,
|
||||
metadata_refreshed_at: row.metadata_refreshed_at,
|
||||
},
|
||||
lane,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1022,7 +1121,7 @@ pub(crate) fn backoff_elapsed(search_attempts: i64, last_searched_at: Option<&st
|
||||
last_searched_at.with_timezone(&chrono::Utc) + backoff <= chrono::Utc::now()
|
||||
}
|
||||
|
||||
fn metadata_refresh_due(metadata_refreshed_at: Option<&str>) -> bool {
|
||||
pub(crate) fn metadata_refresh_due(metadata_refreshed_at: Option<&str>) -> bool {
|
||||
let Some(refreshed_at) = metadata_refreshed_at else {
|
||||
return true;
|
||||
};
|
||||
@@ -1080,7 +1179,9 @@ pub(crate) async fn store_release(
|
||||
}
|
||||
|
||||
/// The TV counterpart: one release row, associated with every episode the
|
||||
/// claim covers — a season pack matches the whole season.
|
||||
/// claim covers — a season pack matches the whole season. Returns the
|
||||
/// release id alongside the eligible view; the id is what links a season
|
||||
/// pack into `season_releases`, which lists rejected candidates too.
|
||||
pub(crate) async fn store_episode_release(
|
||||
database: &Db,
|
||||
episode_ids: &[i64],
|
||||
@@ -1089,7 +1190,7 @@ pub(crate) async fn store_episode_release(
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
) -> Result<(i64, Option<Eligible>), GrabError> {
|
||||
let (release_id, eligible) = classify_and_store(
|
||||
database,
|
||||
release,
|
||||
@@ -1109,7 +1210,7 @@ pub(crate) async fn store_episode_release(
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
}
|
||||
Ok(eligible)
|
||||
Ok((release_id, eligible))
|
||||
}
|
||||
|
||||
async fn classify_and_store(
|
||||
@@ -2055,6 +2156,131 @@ mod tests {
|
||||
assert_eq!(rule.as_deref(), Some("blacklisted"));
|
||||
}
|
||||
|
||||
/// A movie that is already on disk. `wanted` stays set: §9.3's upgrade
|
||||
/// flow is exactly a satisfied movie still looking for something better.
|
||||
async fn available_movie() -> (tempfile::TempDir, Db) {
|
||||
let (dir, database) = wanted_movie().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size)
|
||||
VALUES ('movie', 1, '/mnt/media/movies/Dune Part Two (2024).mkv', 1)",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE movies SET state = 'available' WHERE id = 1")
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
(dir, database)
|
||||
}
|
||||
|
||||
async fn deck(database: &Db) -> Vec<(String, String)> {
|
||||
sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT r.name, r.verdict
|
||||
FROM releases r JOIN movie_releases mr ON mr.release_id = r.id
|
||||
WHERE mr.movie_id = 1 ORDER BY r.guid",
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Issue #115: a manual search on an available movie sweeps the indexers
|
||||
/// and refreshes the deck §9.3 reads, and grabs nothing. The old code
|
||||
/// loaded the movie through the gap filter, so this was a silent no-op.
|
||||
#[tokio::test]
|
||||
async fn a_manual_search_on_an_available_movie_refreshes_the_deck() {
|
||||
let (_dir, database) = available_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
let outcome = action(&indexer, &downloader)
|
||||
.search_now(&database, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.is_none(), "an available movie must not auto-grab");
|
||||
assert_eq!(targeted_searches(&indexer).await, 1);
|
||||
assert_eq!(deck(&database).await.len(), 3, "every candidate is cached");
|
||||
assert!(fake.torrents().is_empty());
|
||||
assert!(grabs(&database).await.is_empty());
|
||||
}
|
||||
|
||||
/// The digital-release gate belongs to targeted search (§6.2). A movie
|
||||
/// with a file on disk is released whatever TMDB knows, so it must not
|
||||
/// silence the manual deck refresh.
|
||||
#[tokio::test]
|
||||
async fn an_available_movie_refreshes_its_deck_before_the_digital_release() {
|
||||
let (_dir, database) = available_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let metadata = tmdb(UNRELEASED_METADATA).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
|
||||
action_with_tmdb(&indexer, &downloader, &metadata)
|
||||
.search_now(&database, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(deck(&database).await.len(), 3);
|
||||
}
|
||||
|
||||
/// A grab already in flight is not a gap either: refresh the deck, do not
|
||||
/// send a second torrent.
|
||||
#[tokio::test]
|
||||
async fn a_manual_search_with_a_grab_in_flight_does_not_grab_again() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let action = action(&indexer, &downloader);
|
||||
action.tick(&database).await.unwrap();
|
||||
assert_eq!(grabs(&database).await.len(), 1);
|
||||
|
||||
action.search_now(&database, 1).await.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert_eq!(grabs(&database).await.len(), 1);
|
||||
}
|
||||
|
||||
/// The gap lane is unchanged: a wanted movie with nothing on disk still
|
||||
/// searches and grabs from the manual trigger.
|
||||
#[tokio::test]
|
||||
async fn a_manual_search_on_a_wanted_movie_still_grabs() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
let outcome = action(&indexer, &downloader)
|
||||
.search_now(&database, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.is_some());
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
assert_eq!(grabs(&database).await.len(), 1);
|
||||
}
|
||||
|
||||
/// §6.3: blocked refuses the manual trigger too, deck refresh included.
|
||||
#[tokio::test]
|
||||
async fn a_manual_search_on_a_blocked_movie_calls_no_indexer() {
|
||||
let (_dir, database) = available_movie().await;
|
||||
sqlx::query("UPDATE movies SET blocked = 1")
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
let outcome = action(&indexer, &downloader)
|
||||
.search_now(&database, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.is_none());
|
||||
assert_eq!(targeted_searches(&indexer).await, 0);
|
||||
assert!(deck(&database).await.is_empty());
|
||||
assert!(fake.torrents().is_empty());
|
||||
}
|
||||
|
||||
/// §6.3: `blocked` stops targeted search for a title.
|
||||
#[tokio::test]
|
||||
async fn a_blocked_title_is_not_searched() {
|
||||
|
||||
@@ -12,6 +12,7 @@ mod notify;
|
||||
mod reaper;
|
||||
pub mod reconcile;
|
||||
mod rss;
|
||||
mod series_refresh;
|
||||
mod tv_grab;
|
||||
mod web;
|
||||
|
||||
@@ -31,6 +32,7 @@ use notify::Notifier;
|
||||
use reaper::ReaperAction;
|
||||
use reconcile::{ReconcileLoop, Tick};
|
||||
use rss::RssAction;
|
||||
use series_refresh::SeriesRefreshAction;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tv_grab::TvGrabAction;
|
||||
|
||||
@@ -121,7 +123,7 @@ async fn run() -> Result<(), Error> {
|
||||
None
|
||||
};
|
||||
let notifier = Notifier::new(config.ntfy_url.clone())?;
|
||||
let (reconcile, manual_grab) =
|
||||
let (reconcile, manual_grab, manual_tv) =
|
||||
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), ¬ifier)?;
|
||||
|
||||
// Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and
|
||||
@@ -155,10 +157,17 @@ async fn run() -> Result<(), Error> {
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||
let mut reconcile_task = tokio::spawn(reconcile.run(shutdown_rx.clone()));
|
||||
// Issue #107: nothing else drains `AppState`'s `MovieCommand` channel, so
|
||||
// the manual-search and manual-grab endpoints were a no-op — the command
|
||||
// sat in the 64-slot buffer forever.
|
||||
let mut manual_task = tokio::spawn(manual::run(state, database, manual_grab, shutdown_rx));
|
||||
// Issue #107: nothing else drains `AppState`'s command channels, so the
|
||||
// manual-search and manual-grab endpoints were a no-op — the command sat
|
||||
// in the 64-slot buffer forever. Issue #132: the episode and season
|
||||
// channels are drained by the same lane.
|
||||
let mut manual_task = tokio::spawn(manual::run(
|
||||
state,
|
||||
database,
|
||||
manual_grab,
|
||||
manual_tv,
|
||||
shutdown_rx,
|
||||
));
|
||||
let signal_tx = shutdown_tx.clone();
|
||||
let server = async move {
|
||||
axum::serve(listener, app)
|
||||
@@ -196,17 +205,18 @@ async fn run() -> Result<(), Error> {
|
||||
/// Prowlarr key and grab needs TMDB as well; a lane whose upstream is not
|
||||
/// configured stays unregistered rather than failing every tick.
|
||||
///
|
||||
/// Also returns a second, independent `GrabAction` for `manual::run` (issue
|
||||
/// #107) — the manual trigger needs the same search-and-grab path on demand
|
||||
/// rather than on the reconcile tick's schedule, and `ReconcileLoop::register`
|
||||
/// takes ownership of the one it ticks.
|
||||
/// Also returns independent action instances for `manual::run` (issues #107
|
||||
/// and #132) — the manual trigger needs the same search-and-grab paths on
|
||||
/// demand rather than on the reconcile tick's schedule, and
|
||||
/// `ReconcileLoop::register` takes ownership of the ones it ticks. The movie
|
||||
/// one needs TMDB too; TV grabbing does not (§6.2).
|
||||
fn reconcile_loop(
|
||||
database: &Db,
|
||||
config: &Config,
|
||||
transmission: &arr_dl::TransmissionClient,
|
||||
tmdb: Option<&Arc<TmdbClient>>,
|
||||
notifier: &Notifier,
|
||||
) -> Result<(ReconcileLoop, Option<GrabAction>), Error> {
|
||||
) -> Result<(ReconcileLoop, Option<GrabAction>, Option<TvGrabAction>), Error> {
|
||||
let reconcile = ReconcileLoop::new(database.clone());
|
||||
let seeding = SeedingRules::new(
|
||||
SeedingLimits {
|
||||
@@ -233,7 +243,7 @@ fn reconcile_loop(
|
||||
.map(|key| arr_indexer::ProwlarrClient::new(config.prowlarr_url.clone(), key))
|
||||
.transpose()?;
|
||||
|
||||
let (mut reconcile, manual_grab) = register_movie_grab(
|
||||
let (reconcile, manual_grab) = register_movie_grab(
|
||||
reconcile,
|
||||
prowlarr.as_ref(),
|
||||
transmission,
|
||||
@@ -241,20 +251,8 @@ fn reconcile_loop(
|
||||
&seeding,
|
||||
tmdb,
|
||||
);
|
||||
// TV grabbing needs no TMDB at grab time: air dates are already on the
|
||||
// episode rows, which is the same gate the digital release date is for
|
||||
// movies (§6.2).
|
||||
if let Some(prowlarr) = prowlarr.as_ref() {
|
||||
reconcile = reconcile.register(
|
||||
Tick::Reconcile,
|
||||
TvGrabAction::new(
|
||||
prowlarr.clone(),
|
||||
transmission.clone(),
|
||||
config.download_dir.clone(),
|
||||
seeding.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
let (mut reconcile, manual_tv) =
|
||||
register_tv_grab(reconcile, prowlarr.as_ref(), transmission, config, &seeding);
|
||||
// RSS needs no TMDB: it matches what the feeds already carry against the
|
||||
// wanted list (§6.2).
|
||||
if let Some(prowlarr) = prowlarr {
|
||||
@@ -268,6 +266,13 @@ fn reconcile_loop(
|
||||
),
|
||||
);
|
||||
}
|
||||
// §8: metadata refresh is its own daily lane, staggered against the
|
||||
// other ticks. Without TMDB there is nothing to refresh from.
|
||||
if let Some(tmdb) = tmdb {
|
||||
reconcile = reconcile.register(Tick::Metadata, SeriesRefreshAction::new(Arc::clone(tmdb)));
|
||||
} else {
|
||||
tracing::warn!("TMDB is not configured: series metadata refresh is disabled");
|
||||
}
|
||||
let jellyfin = jellyfin::JellyfinClient::new(
|
||||
config.jellyfin_url.clone(),
|
||||
config.jellyfin_api_key.clone(),
|
||||
@@ -313,7 +318,34 @@ fn reconcile_loop(
|
||||
}
|
||||
|
||||
let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone()));
|
||||
Ok((reconcile, manual_grab))
|
||||
Ok((reconcile, manual_grab, manual_tv))
|
||||
}
|
||||
|
||||
/// Register the TV grab lane on `reconcile` and hand back a second,
|
||||
/// independent instance for `manual::run` (issue #132). `None` when Prowlarr
|
||||
/// is not configured. TV grabbing needs no TMDB at grab time: air dates are
|
||||
/// already on the episode rows, which is the same gate the digital release
|
||||
/// date is for movies (§6.2).
|
||||
fn register_tv_grab(
|
||||
mut reconcile: ReconcileLoop,
|
||||
prowlarr: Option<&arr_indexer::ProwlarrClient>,
|
||||
transmission: &arr_dl::TransmissionClient,
|
||||
config: &Config,
|
||||
seeding: &SeedingRules,
|
||||
) -> (ReconcileLoop, Option<TvGrabAction>) {
|
||||
let Some(prowlarr) = prowlarr else {
|
||||
return (reconcile, None);
|
||||
};
|
||||
let tv_grab_action = || {
|
||||
TvGrabAction::new(
|
||||
prowlarr.clone(),
|
||||
transmission.clone(),
|
||||
config.download_dir.clone(),
|
||||
seeding.clone(),
|
||||
)
|
||||
};
|
||||
reconcile = reconcile.register(Tick::Reconcile, tv_grab_action());
|
||||
(reconcile, Some(tv_grab_action()))
|
||||
}
|
||||
|
||||
/// Register the movie grab lane on `reconcile` and hand back a second,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user