Merge TV tracking milestone
ci / web (push) Successful in 27s
e2e / e2e (push) Successful in 2m5s
ci / rust (push) Successful in 2m9s

Closes the phase/6-tv gap set: daily series metadata refresh, tracked-season
intent, RSS episode and season-pack matching, season release decks, the TV
attention queue, and the series detail view. 27 issues.

See milestone 'TV tracking'.
This commit is contained in:
Miguel Palhas
2026-08-23 21:10:52 +01:00
112 changed files with 9759 additions and 542 deletions
+11
View File
@@ -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": []
}
+14 -5
View File
@@ -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.
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM episodes WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "3974fc9a150b9d1feb9401f7c8075a774d0570104d0b089fd2e5ed2e85e66f0c"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM seasons WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+6
View File
@@ -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`.
+27 -5
View File
@@ -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
@@ -339,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.
@@ -644,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.
+20 -1
View File
@@ -36,7 +36,9 @@ 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.
@@ -87,6 +89,10 @@ 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))
@@ -307,6 +313,18 @@ 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"),
@@ -326,6 +344,7 @@ mod tests {
"UpdateMovie",
"Release",
"AttentionQueues",
"SeriesAttention",
"Series",
] {
assert!(
+363 -1
View File
@@ -93,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)]
@@ -459,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()?;
@@ -627,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")),
@@ -724,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");
@@ -1183,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)
}
}
+477 -40
View File
@@ -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,23 @@ 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)]
@@ -122,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));
}
}
}
}
}
@@ -159,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
@@ -172,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,
@@ -183,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)
@@ -331,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
@@ -365,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(),
@@ -419,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:?") {
@@ -440,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('\\', "\\\\")
@@ -456,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
@@ -637,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");
@@ -648,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;
@@ -661,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"))
@@ -670,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");
@@ -684,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;
@@ -820,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;
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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)
}
}
+1 -1
View File
@@ -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),
+6 -3
View File
@@ -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,
+7 -1
View File
@@ -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
View File
@@ -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
);
}
}
+40 -1
View File
@@ -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
);
}
}
+90 -6
View File
@@ -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");
}
}
}
+429 -22
View File
@@ -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);
}
}
+7 -5
View File
@@ -908,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,
@@ -1121,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;
};
@@ -1179,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],
@@ -1188,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,
@@ -1208,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(
+58 -26
View File
@@ -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(), &notifier)?;
// 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,
+102 -20
View File
@@ -1,23 +1,33 @@
//! Drains `AppState`'s `MovieCommand` channel — the daemon-side consumer
//! DESIGN.md §6.2 and §9.3 assume exists (issue #107). `Search` resets the
//! movie's backoff and re-runs the targeted-search + grab lane immediately;
//! Drains `AppState`'s three command channels — the daemon-side consumer
//! DESIGN.md §6.2 and §9.3 assume exists (issues #107 and #132). A `Search`
//! resets backoff and re-runs the targeted-search + grab lane immediately;
//! `Grab` sends an already-chosen release straight to Transmission, skipping
//! search.
//! search. Movies, episodes and seasons share one lane because they share
//! one operator waiting on a 202.
use arr_api::{AppState, MovieCommand};
use arr_api::{AppState, EpisodeCommand, MovieCommand, SeasonCommand};
use arr_db::Db;
use tokio::sync::watch;
use crate::grab::{GrabAction, GrabError};
use crate::tv_grab::TvGrabAction;
/// Run until every sender is dropped or `shutdown` fires. `grab` is `None`
/// when Prowlarr or TMDB is not configured (`main.rs` warns about this
/// already for the reconcile lane) — commands are still drained so the
/// channel never fills, they just cannot be acted on.
/// Which channel a drained command came off.
enum Command {
Movie(MovieCommand),
Episode(EpisodeCommand),
Season(SeasonCommand),
}
/// Run until every sender is dropped or `shutdown` fires. Either action is
/// `None` when Prowlarr (or TMDB, for movies) is not configured (`main.rs`
/// warns about this already for the reconcile lanes) — commands are still
/// drained so the channels never fill, they just cannot be acted on.
pub async fn run(
state: AppState,
database: Db,
grab: Option<GrabAction>,
movies: Option<GrabAction>,
tv: Option<TvGrabAction>,
mut shutdown: watch::Receiver<bool>,
) {
loop {
@@ -29,22 +39,54 @@ pub async fn run(
}
continue;
}
command = state.next_movie_command() => command,
command = state.next_movie_command() => command.map(Command::Movie),
command = state.next_episode_command() => command.map(Command::Episode),
command = state.next_season_command() => command.map(Command::Season),
};
let Some(command) = command else {
return;
};
let Some(grab) = &grab else {
tracing::warn!("manual movie command received but Prowlarr or TMDB is not configured");
continue;
};
if let Err(error) = handle(grab, &database, command).await {
tracing::error!(%error, "manual movie command failed");
match command {
Command::Movie(command) => {
let Some(movies) = &movies else {
tracing::warn!(
"manual movie command received but Prowlarr or TMDB is not configured"
);
continue;
};
if let Err(error) = handle_movie(movies, &database, command).await {
tracing::error!(%error, "manual movie command failed");
}
}
Command::Episode(command) => {
let Some(tv) = &tv else {
tracing::warn!(
"manual episode command received but Prowlarr is not configured"
);
continue;
};
if let Err(error) = handle_episode(tv, &database, command).await {
tracing::error!(%error, "manual episode command failed");
}
}
Command::Season(command) => {
let Some(tv) = &tv else {
tracing::warn!("manual season command received but Prowlarr is not configured");
continue;
};
if let Err(error) = handle_season(tv, &database, command).await {
tracing::error!(%error, "manual season command failed");
}
}
}
}
}
async fn handle(grab: &GrabAction, database: &Db, command: MovieCommand) -> Result<(), GrabError> {
async fn handle_movie(
grab: &GrabAction,
database: &Db,
command: MovieCommand,
) -> Result<(), GrabError> {
match command {
MovieCommand::Search { movie_id } => {
grab.search_now(database, movie_id).await?;
@@ -60,6 +102,46 @@ async fn handle(grab: &GrabAction, database: &Db, command: MovieCommand) -> Resu
Ok(())
}
async fn handle_episode(
tv: &TvGrabAction,
database: &Db,
command: EpisodeCommand,
) -> Result<(), GrabError> {
match command {
EpisodeCommand::Search { episode_id } => {
tv.search_episode_now(database, episode_id).await?;
}
EpisodeCommand::Grab {
episode_id,
release_id,
} => {
tv.grab_episode_release_now(database, episode_id, release_id)
.await?;
}
}
Ok(())
}
async fn handle_season(
tv: &TvGrabAction,
database: &Db,
command: SeasonCommand,
) -> Result<(), GrabError> {
match command {
SeasonCommand::Search { season_id } => {
tv.search_season_now(database, season_id).await?;
}
SeasonCommand::Grab {
season_id,
release_id,
} => {
tv.grab_season_release_now(database, season_id, release_id)
.await?;
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
@@ -160,7 +242,7 @@ mod tests {
let transmission = transmission().await;
let grab = grab_action(&indexer, &transmission);
handle(&grab, &database, MovieCommand::Search { movie_id: 1 })
handle_movie(&grab, &database, MovieCommand::Search { movie_id: 1 })
.await
.unwrap();
@@ -218,7 +300,7 @@ mod tests {
.await
.unwrap();
handle(
handle_movie(
&grab,
&database,
MovieCommand::Grab {
+693 -56
View File
@@ -9,19 +9,31 @@
//!
//! `blocked` is honoured the other way round from targeted search (§6.3): it
//! stops a title being searched for, and leaves it matching RSS.
//!
//! One pass serves films and episodes alike: each feed item goes through the
//! movie matcher first, then the episode one (`arr_core::matching`). A
//! single-episode release grabs its wanted, missing episode directly; a
//! season pack only grabs when [`season_grab_mode`] allows packs for that
//! season — the same guard §14 gives targeted search, so a season behaves
//! the same however a release is found (§6.2). Like everything else here,
//! none of it backs off or counts attempts.
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use arr_core::matching::{match_movie, MatchKind, ReleaseIds, WantedMovie};
use arr_core::{Language, MovieId};
use arr_db::{Blacklist, Db, MoviePolicy};
use arr_core::grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
use arr_core::matching::{
match_episode, match_movie, MatchKind, MatchShape, ReleaseIds, WantedEpisode, WantedMovie,
};
use arr_core::{EpisodeId, Language, MovieId};
use arr_db::{Blacklist, Db, MoviePolicy, TitlePolicy};
use arr_dl::TransmissionClient;
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
use crate::grab::{
store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber, SeedingRules,
store_episode_release, store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber,
SeedingRules,
};
use crate::indexers::IndexerDirectory;
use crate::reconcile::{Action, ActionFuture, Outcome};
@@ -51,7 +63,8 @@ impl RssAction {
async fn tick(&self, database: &Db) -> Result<Vec<Outcome>, GrabError> {
let wanted = wanted_movies(database).await?;
if wanted.is_empty() {
let episodes = wanted_episodes(database).await?;
if wanted.is_empty() && episodes.is_empty() {
return Ok(Vec::new());
}
let indexers = self.indexers.searchable().await?;
@@ -62,12 +75,12 @@ impl RssAction {
let releases = self.feeds(&indexers).await;
let blacklist = Blacklist::load(database.pool()).await?;
let winners = self
.match_feeds(database, &wanted, releases, &blacklist)
let (movies, tv) = self
.match_feeds(database, &wanted, &episodes, releases, &blacklist)
.await?;
let mut outcomes = Vec::new();
for (movie, winner) in winners {
for (movie, winner) in movies {
let outcome = self
.grabber
.send_winner(
@@ -86,6 +99,29 @@ impl RssAction {
.await?;
outcomes.extend(outcome);
}
for TvWinner {
grabbable,
scope,
candidate: winner,
} in tv
{
let outcome = self
.grabber
.send_winner(
database,
&GrabTarget {
scope,
title: &grabbable.series_title,
// Same property as the movie lane above.
counts_as_attempt: false,
},
&grabbable.policy,
&blacklist,
winner,
)
.await?;
outcomes.extend(outcome);
}
Ok(outcomes)
}
@@ -116,11 +152,13 @@ impl RssAction {
&self,
database: &Db,
wanted: &[WantedMovie],
episodes: &[WantedEpisodeRow],
releases: Vec<SearchRelease>,
blacklist: &Blacklist,
) -> Result<Vec<(Grabbable, Eligible)>, GrabError> {
) -> Result<(Vec<(Grabbable, Eligible)>, Vec<TvWinner>), GrabError> {
let mut policies: HashMap<i64, Option<Grabbable>> = HashMap::new();
let mut best: HashMap<i64, Eligible> = HashMap::new();
let mut tv = TvCandidates::default();
for release in releases {
let claims = arr_parse::parse(&release.name);
@@ -128,56 +166,239 @@ impl RssAction {
tmdb_id: release.tmdb_id,
imdb_id: release.imdb_id.clone(),
};
let Some(matched) = match_movie(wanted, &ids, &claims) else {
continue;
};
let movie_id = matched.movie.0;
let grabbable = match policies.entry(movie_id) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
entry.insert(load_grabbable(database, wanted, movie_id).await?)
if let Some(matched) = match_movie(wanted, &ids, &claims) {
let movie_id = matched.movie.0;
let grabbable = match policies.entry(movie_id) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
entry.insert(load_grabbable(database, wanted, movie_id).await?)
}
};
let Some(grabbable) = grabbable.as_ref() else {
continue;
};
tracing::info!(
movie_id,
release = release.name,
by = match matched.kind {
MatchKind::TmdbId => "tmdb id",
MatchKind::ImdbId => "imdb id",
MatchKind::TitleAndYear => "title and year",
},
"RSS result matched a wanted title"
);
if let Some(candidate) = store_release(
database,
movie_id,
&release,
&grabbable.policy.policy,
&grabbable.policy.overrides,
&grabbable.language,
blacklist,
)
.await?
{
let incumbent = best.get(&movie_id);
if incumbent.is_none_or(|incumbent| beats(&candidate, incumbent)) {
best.insert(movie_id, candidate);
}
}
};
let Some(grabbable) = grabbable.as_ref() else {
continue;
};
tracing::info!(
movie_id,
release = release.name,
by = match matched.kind {
MatchKind::TmdbId => "tmdb id",
MatchKind::ImdbId => "imdb id",
MatchKind::TitleAndYear => "title and year",
},
"RSS result matched a wanted title"
);
let stored = store_release(
database,
movie_id,
&release,
&grabbable.policy.policy,
&grabbable.policy.overrides,
&grabbable.language,
blacklist,
)
.await?;
let Some(candidate) = stored else {
continue;
};
let incumbent = best.get(&movie_id);
if incumbent.is_none_or(|incumbent| beats(&candidate, incumbent)) {
best.insert(movie_id, candidate);
}
tv.offer(database, episodes, &release, &ids, &claims, blacklist)
.await?;
}
let mut winners: Vec<(Grabbable, Eligible)> = best
let tv_winners = tv.winners(database, episodes).await?;
let mut movies: Vec<(Grabbable, Eligible)> = best
.into_iter()
.filter_map(|(movie_id, candidate)| {
let grabbable = policies.get(&movie_id).and_then(Clone::clone)?;
Some((grabbable, candidate))
})
.collect();
winners.sort_by_key(|(movie, _)| movie.id);
movies.sort_by_key(|(movie, _)| movie.id);
Ok((movies, tv_winners))
}
}
/// The episode lane's state over one feed pass: the grab context per season
/// and the best eligible candidate per wanted episode and per season's pack.
#[derive(Default)]
struct TvCandidates {
policies: HashMap<i64, Option<TvGrabbable>>,
best_single: HashMap<i64, Eligible>,
/// Per season: the best pack alongside the open episodes it covers.
best_pack: HashMap<i64, (Eligible, Vec<i64>)>,
}
impl TvCandidates {
/// Offer one feed item to every wanted episode. A release holds either
/// exactly one episode or whole seasons, so each row it matches lands in
/// exactly one bucket.
async fn offer(
&mut self,
database: &Db,
episodes: &[WantedEpisodeRow],
release: &SearchRelease,
ids: &ReleaseIds,
claims: &arr_parse::NameClaims,
blacklist: &Blacklist,
) -> Result<(), GrabError> {
let mut packs: HashMap<i64, Vec<i64>> = HashMap::new();
let mut single: Option<(i64, MatchKind)> = None;
for row in episodes {
let Some(matched) = match_episode(&row.wanted, ids, claims) else {
continue;
};
match matched.shape {
MatchShape::SeasonPack => {
packs
.entry(row.season_id)
.or_default()
.push(row.wanted.id.0);
}
MatchShape::SingleEpisode => single = Some((row.wanted.id.0, matched.kind)),
}
}
for (season_id, covered) in packs {
let Some(candidate) = self
.store_candidate(database, episodes, season_id, &covered, release, blacklist)
.await?
else {
continue;
};
let incumbent = self.best_pack.get(&season_id);
if incumbent.is_none_or(|(incumbent, _)| beats(&candidate, incumbent)) {
self.best_pack.insert(season_id, (candidate, covered));
}
}
if let Some((episode_id, kind)) = single {
tracing::info!(
episode_id,
release = release.name,
by = tv_match_kind(kind),
"RSS result matched a wanted episode"
);
let Some(row) = episodes.iter().find(|row| row.wanted.id.0 == episode_id) else {
return Ok(());
};
let Some(candidate) = self
.store_candidate(
database,
episodes,
row.season_id,
&[episode_id],
release,
blacklist,
)
.await?
else {
return Ok(());
};
let incumbent = self.best_single.get(&episode_id);
if incumbent.is_none_or(|incumbent| beats(&candidate, incumbent)) {
self.best_single.insert(episode_id, candidate);
}
}
Ok(())
}
/// Classify and store a feed item against the open episodes it covers,
/// loading the season's grab context on first use.
async fn store_candidate(
&mut self,
database: &Db,
episodes: &[WantedEpisodeRow],
season_id: i64,
covered: &[i64],
release: &SearchRelease,
blacklist: &Blacklist,
) -> Result<Option<Eligible>, GrabError> {
let grabbable = match self.policies.entry(season_id) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
entry.insert(load_tv_grabbable(database, episodes, season_id).await?)
}
};
let Some(grabbable) = grabbable.as_ref() else {
return Ok(None);
};
let (_, stored) = store_episode_release(
database,
covered,
release,
&grabbable.policy.policy,
&grabbable.policy.overrides,
&grabbable.language,
blacklist,
)
.await?;
Ok(stored)
}
/// Turn the per-season and per-episode winners into grabs. A season with
/// an eligible pack only takes it when [`season_grab_mode`] allows packs
/// there — the guard targeted search obeys (§6.2, §14) — and when it
/// does, the season's singles stand down. Otherwise each open episode
/// takes its own winner.
async fn winners(
&self,
database: &Db,
episodes: &[WantedEpisodeRow],
) -> Result<Vec<TvWinner>, GrabError> {
let mut winners = Vec::new();
let mut covered_by_packs: HashSet<i64> = HashSet::new();
let mut seasons: Vec<i64> = self.best_pack.keys().copied().collect();
seasons.sort_unstable();
for season_id in seasons {
let Some((candidate, episode_ids)) = self.best_pack.get(&season_id) else {
continue;
};
let Some(grabbable) = self.policies.get(&season_id).and_then(Clone::clone) else {
continue;
};
if !pack_allowed(database, season_id).await? {
tracing::info!(
season_id,
series = grabbable.series_title,
"RSS skips a season pack the season's grab mode refuses"
);
continue;
}
covered_by_packs.extend(episode_ids.iter().copied());
winners.push(TvWinner {
grabbable,
scope: GrabScope::Season {
season_id,
episode_ids: episode_ids.clone(),
},
candidate: candidate.clone(),
});
}
let mut singles: Vec<i64> = self.best_single.keys().copied().collect();
singles.sort_unstable();
for episode_id in singles {
if covered_by_packs.contains(&episode_id) {
continue;
}
let Some(row) = episodes.iter().find(|row| row.wanted.id.0 == episode_id) else {
continue;
};
let Some(grabbable) = self.policies.get(&row.season_id).and_then(Clone::clone) else {
continue;
};
let Some(candidate) = self.best_single.get(&episode_id) else {
continue;
};
winners.push(TvWinner {
grabbable,
scope: GrabScope::Episode { episode_id },
candidate: candidate.clone(),
});
}
Ok(winners)
}
}
@@ -255,6 +476,196 @@ async fn wanted_movies(database: &Db) -> Result<Vec<WantedMovie>, GrabError> {
.collect())
}
/// A matched series season with everything a grab needs loaded.
#[derive(Clone, Debug)]
struct TvGrabbable {
series_title: String,
policy: TitlePolicy,
language: Language,
}
/// One winner of the episode lane: what to grab, for whom, and which
/// release won.
struct TvWinner {
grabbable: TvGrabbable,
scope: GrabScope,
candidate: Eligible,
}
/// A wanted episode with the season context matching and grabbing need.
struct WantedEpisodeRow {
wanted: WantedEpisode,
season_id: i64,
}
fn tv_match_kind(kind: MatchKind) -> &'static str {
match kind {
MatchKind::TmdbId => "tmdb id",
MatchKind::ImdbId => "imdb id",
MatchKind::TitleAndYear => "title",
}
}
/// Every wanted episode still open — wanted, no file on disk, no live grab
/// of its own or of its season. Same shape as the movie list: unlimited,
/// unordered by recency, blocked series included on purpose (§6.3).
async fn wanted_episodes(database: &Db) -> Result<Vec<WantedEpisodeRow>, GrabError> {
let rows = sqlx::query!(
r#"
SELECT e.id AS "id!: i64",
e.number AS "episode!: i64",
se.id AS "season_id!: i64",
se.number AS "season!: i64",
s.tmdb_id AS "series_tmdb_id!: i64",
s.title AS "series_title!: String"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE e.wanted = 1
AND NOT EXISTS (
SELECT 1 FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
)
AND NOT EXISTS (
SELECT 1 FROM grabs g
WHERE g.target_kind = 'episode' AND g.target_id = e.id
AND g.state IN ('sent', 'downloaded', 'imported')
)
AND NOT EXISTS (
SELECT 1 FROM grabs g
WHERE g.target_kind = 'season' AND g.target_id = se.id
AND g.state IN ('sent', 'downloaded', 'imported')
)
ORDER BY e.id
"#
)
.fetch_all(database.pool())
.await?;
Ok(rows
.into_iter()
.map(|row| WantedEpisodeRow {
wanted: WantedEpisode {
id: EpisodeId(row.id),
tmdb_id: u32::try_from(row.series_tmdb_id).ok(),
// The series table carries no `IMDb` id yet, so this lane
// matches on TMDB id or title until one is backfilled.
imdb_id: None,
title: row.series_title,
season: u32::try_from(row.season).unwrap_or_default(),
episode: u32::try_from(row.episode).unwrap_or_default(),
},
season_id: row.season_id,
})
.collect())
}
/// The policy and original language a matched season needs, or `None` when
/// it cannot be evaluated yet. Same refusals as [`load_grabbable`].
async fn load_tv_grabbable(
database: &Db,
episodes: &[WantedEpisodeRow],
season_id: i64,
) -> Result<Option<TvGrabbable>, GrabError> {
let Some(series_title) = episodes
.iter()
.find(|row| row.season_id == season_id)
.map(|row| row.wanted.title.clone())
else {
return Ok(None);
};
let Some(policy) = database.season_policy(season_id).await? else {
return Ok(None);
};
// §5.2, same as films.
let language: Option<String> = sqlx::query_scalar!(
r#"
SELECT s.original_language
FROM seasons se
JOIN series s ON s.id = se.series_id
WHERE se.id = ?
"#,
season_id
)
.fetch_optional(database.pool())
.await?
.flatten();
let Some(language) = language else {
tracing::warn!(
season_id,
series = series_title,
"no original language yet; not grabbing from RSS"
);
return Ok(None);
};
Ok(Some(TvGrabbable {
series_title,
policy,
language: arr_db::policy::language(&language),
}))
}
/// Whether §6.2's guard lets a season pack be grabbed here: exactly what
/// [`season_grab_mode`] requires of targeted search, computed over every
/// episode the season is known to hold (§14, amended in #117).
async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
let episodes = sqlx::query!(
r#"
SELECT e.air_date,
EXISTS (
SELECT 1 FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
) AS "on_disk!: bool"
FROM episodes e
WHERE e.season_id = ?
ORDER BY e.number
"#,
season_id
)
.fetch_all(database.pool())
.await?;
let pack_hard_failed = sqlx::query_scalar!(
r#"SELECT EXISTS (
SELECT 1 FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'
) AS "failed!: bool""#,
season_id
)
.fetch_one(database.pool())
.await?;
Ok(season_grab_mode(&SeasonGrabFacts {
air_dates: &episodes
.iter()
.map(|episode| air_date_time(episode.air_date.as_deref()))
.collect::<Vec<_>>(),
now: std::time::SystemTime::now(),
any_episode_on_disk: episodes.iter().any(|episode| episode.on_disk),
pack_hard_failed,
}) == SeasonGrabMode::SeasonPack)
}
/// An `air_date` as TMDB writes it (`YYYY-MM-DD`), or a full timestamp if
/// one ever arrives that way. Unknown is unaired.
fn air_date_time(value: Option<&str>) -> Option<std::time::SystemTime> {
let value = value?;
let timestamp = if let Ok(date) = value.parse::<chrono::NaiveDate>() {
date.and_time(chrono::NaiveTime::MIN).and_utc().timestamp()
} else {
value
.parse::<chrono::DateTime<chrono::Utc>>()
.ok()?
.timestamp()
};
let seconds = u64::try_from(timestamp.abs()).ok()?;
if timestamp < 0 {
std::time::UNIX_EPOCH.checked_sub(std::time::Duration::from_secs(seconds))
} else {
std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(seconds))
}
}
/// The policy and original language a matched title needs, or `None` when
/// the title cannot be evaluated yet.
async fn load_grabbable(
@@ -312,6 +723,48 @@ mod tests {
/// asked for.
const FEED: &str = include_str!("../tests/fixtures/rss.xml");
/// A season pack and its three episodes, all eligible under the seeded
/// TV main policy (§5.5 bands: 2160p floor 3 GiB, per #95).
const TV_FEED: &str = r#"<rss version="2.0" xmlns:torznab="http://torznab.com/schemas/2015/feed">
<channel>
<item>
<title>Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos.H.265-GROUP</title>
<guid>tv-pack</guid><link>https://indexer.invalid/download/tv-pack</link>
<enclosure url="https://indexer.invalid/download/tv-pack" length="85899345920"
type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="120"/>
</item>
<item>
<title>Fallout.S01E01.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>
<guid>tv-e01</guid><link>https://indexer.invalid/download/tv-e01</link>
<enclosure url="https://indexer.invalid/download/tv-e01" length="10737418240"
type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="80"/>
</item>
<item>
<title>Fallout.S01E02.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>
<guid>tv-e02</guid><link>https://indexer.invalid/download/tv-e02</link>
<enclosure url="https://indexer.invalid/download/tv-e02" length="10737418240"
type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="80"/>
</item>
<item>
<title>Fallout.S01E03.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>
<guid>tv-e03</guid><link>https://indexer.invalid/download/tv-e03</link>
<enclosure url="https://indexer.invalid/download/tv-e03" length="10737418240"
type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="80"/>
</item>
<item>
<title>The.Fallout.2021.S01E03.1080p.WEB-DL-GROUP</title>
<guid>tv-near-miss</guid><link>https://indexer.invalid/download/tv-near-miss</link>
<enclosure url="https://indexer.invalid/download/tv-near-miss" length="10737418240"
type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="80"/>
</item>
</channel>
</rss>"#;
const INDEXERS: [i64; 2] = [7, 9];
/// Enough of Transmission to add a torrent and list nothing back.
@@ -350,9 +803,9 @@ mod tests {
/// Two indexers, both advertising a text search, both serving the same
/// feed to an empty query.
async fn prowlarr() -> MockServer {
async fn prowlarr(feed: &str) -> MockServer {
let server = MockServer::start().await;
let feed = test_downloads::rewrite(FEED, "https://indexer.invalid/download/", &server);
let feed = test_downloads::rewrite(feed, "https://indexer.invalid/download/", &server);
test_downloads::mount(&server).await;
Mock::given(method("GET"))
.and(path("/api/v1/indexer"))
@@ -430,6 +883,59 @@ mod tests {
)
}
/// Fallout S01 with wanted episodes airing at the given dates. Returns
/// the season id and the episode ids in episode order.
async fn wanted_series(database: &Db, air_dates: &[&str]) -> (i64, Vec<i64>) {
sqlx::query(
"INSERT INTO series (tmdb_id, title, year, original_language, root_id)
SELECT 106379, 'Fallout', 2024, 'en', id
FROM roots WHERE kind = 'tv' AND audience = 'main'",
)
.execute(database.pool())
.await
.unwrap();
let season_id: i64 = sqlx::query_scalar(
"INSERT INTO seasons (series_id, number) VALUES (1, 1) RETURNING id",
)
.fetch_one(database.pool())
.await
.unwrap();
let mut episodes = Vec::new();
for (index, air_date) in air_dates.iter().enumerate() {
let id: i64 = sqlx::query_scalar(
"INSERT INTO episodes (season_id, number, title, air_date, wanted)
VALUES (?, ?, ?, ?, 1) RETURNING id",
)
.bind(season_id)
.bind(i64::try_from(index).unwrap() + 1)
.bind(format!("Episode {}", index + 1))
.bind(air_date)
.fetch_one(database.pool())
.await
.unwrap();
episodes.push(id);
}
(season_id, episodes)
}
/// `(target_kind, target_id, state)` for every TV grab that was made.
async fn tv_grabs(database: &Db) -> Vec<(String, i64, String)> {
sqlx::query_as::<_, (String, i64, String)>(
"SELECT target_kind, target_id, state FROM grabs
WHERE target_kind IN ('episode', 'season') ORDER BY target_kind, target_id",
)
.fetch_all(database.pool())
.await
.unwrap()
}
async fn episode_states(database: &Db) -> Vec<String> {
sqlx::query_scalar::<_, String>("SELECT state FROM episodes ORDER BY number")
.fetch_all(database.pool())
.await
.unwrap()
}
/// `(movie title, grabbed release name)` for every grab that was made.
async fn grabbed(database: &Db) -> Vec<(String, String)> {
sqlx::query_as::<_, (String, String)>(
@@ -462,7 +968,7 @@ mod tests {
(438_631, "Dune", 2021, false),
])
.await;
let indexer = prowlarr().await;
let indexer = prowlarr(FEED).await;
let (downloader, fake) = transmission().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -506,7 +1012,7 @@ mod tests {
#[tokio::test]
async fn a_near_miss_is_not_grabbed() {
let (_dir, database) = wanted(&[(9_999_999, "Dune: Part Three", 2024, false)]).await;
let indexer = prowlarr().await;
let indexer = prowlarr(FEED).await;
let (downloader, fake) = transmission().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -525,7 +1031,7 @@ mod tests {
(9_999_999, "Dune: Part Three", 2024, false),
])
.await;
let indexer = prowlarr().await;
let indexer = prowlarr(FEED).await;
let (downloader, _fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -542,7 +1048,7 @@ mod tests {
#[tokio::test]
async fn a_blocked_title_still_matches_rss() {
let (_dir, database) = wanted(&[(693_134, "Dune: Part Two", 2024, true)]).await;
let indexer = prowlarr().await;
let indexer = prowlarr(FEED).await;
let (downloader, _fake) = transmission().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -560,7 +1066,7 @@ mod tests {
(438_631, "Dune", 2021, false),
])
.await;
let indexer = prowlarr().await;
let indexer = prowlarr(FEED).await;
let (downloader, _fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
@@ -582,4 +1088,135 @@ mod tests {
assert_eq!(feeds, 1);
}
}
/// A single-episode release in the feed closes a wanted, missing
/// episode's gap, with no targeted-search attempt spent (§6.2). The
/// season here is airing, so its pack is not eligible ([`season_grab_mode`]).
#[tokio::test]
async fn a_missing_episode_is_grabbed_from_rss() {
let (_dir, database) = wanted(&[]).await;
let (_season_id, episodes) = wanted_series(&database, &["2024-04-11", "2099-01-01"]).await;
let indexer = prowlarr(TV_FEED).await;
let (downloader, fake) = transmission().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
assert_eq!(outcomes.len(), 2);
assert_eq!(
tv_grabs(&database).await,
episodes
.iter()
.map(|id| ("episode".to_owned(), *id, "sent".to_owned()))
.collect::<Vec<_>>(),
"each gap takes its own single; the pack and the near miss stay out"
);
assert_eq!(fake.added.lock().unwrap().len(), 2);
let attempts: Vec<(i64, Option<String>)> =
sqlx::query_as("SELECT search_attempts, last_searched_at FROM episodes ORDER BY id")
.fetch_all(database.pool())
.await
.unwrap();
assert!(attempts
.iter()
.all(|(count, at)| *count == 0 && at.is_none()));
}
/// The acceptance case: one feed pass over a fully aired season with
/// nothing on disk takes the pack alone, and the singles stand down.
#[tokio::test]
async fn a_completed_season_takes_one_pack_from_rss() {
let (_dir, database) = wanted(&[]).await;
let (season_id, _episodes) =
wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await;
// Only the pack is wanted here: strip the singles so the pass has to
// pick between shapes on coverage, not on availability.
let pack_only = TV_FEED.replace(
"Fallout.S01E01.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>",
"Unrelated.S01E01.2160p.WEB-DL-GROUP</title>",
);
let pack_only = pack_only.replace(
"Fallout.S01E02.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>",
"Unrelated.S01E02.2160p.WEB-DL-GROUP</title>",
);
let pack_only = pack_only.replace(
"Fallout.S01E03.2160p.WEB-DL.DDP5.1.H.265-GROUP</title>",
"Unrelated.S01E03.2160p.WEB-DL-GROUP</title>",
);
let indexer = prowlarr(&pack_only).await;
let (downloader, fake) = transmission().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
assert_eq!(outcomes.len(), 1);
assert_eq!(fake.added.lock().unwrap().len(), 1);
assert_eq!(
tv_grabs(&database).await,
vec![("season".to_owned(), season_id, "sent".to_owned())]
);
assert_eq!(
episode_states(&database).await,
vec!["downloading"; 3],
"the pack flips every episode it covers"
);
}
/// §6.2 with #117's guard: an episode already on disk keeps the season
/// per-episode here too — the pack is skipped and the open gaps take
/// their singles.
#[tokio::test]
async fn a_pack_for_a_season_with_a_file_on_disk_is_skipped() {
let (_dir, database) = wanted(&[]).await;
let (_season_id, episodes) =
wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await;
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size)
VALUES ('episode', ?, '/tv/fallout/s01e01.mkv', 1)",
)
.bind(episodes[0])
.execute(database.pool())
.await
.unwrap();
let indexer = prowlarr(TV_FEED).await;
let (downloader, _fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
let grabs = tv_grabs(&database).await;
assert!(
!grabs.iter().any(|(kind, _, _)| kind == "season"),
"no pack over an episode already on disk"
);
// Episode 1 is not wanted any more — it has a file — and the near
// miss never matched, so exactly the two open gaps are filled.
assert_eq!(
grabs,
vec![
("episode".to_owned(), episodes[1], "sent".to_owned()),
("episode".to_owned(), episodes[2], "sent".to_owned()),
]
);
}
/// §6.3: `blocked` stops targeted search for a series and leaves RSS
/// matching on. The one-episode season here is fully aired, so what
/// matches is its pack — the same release targeted search would take.
#[tokio::test]
async fn a_blocked_series_still_matches_rss() {
let (_dir, database) = wanted(&[]).await;
let (season_id, _episodes) = wanted_series(&database, &["2024-04-11"]).await;
sqlx::query("UPDATE series SET blocked = 1")
.execute(database.pool())
.await
.unwrap();
let indexer = prowlarr(TV_FEED).await;
let (downloader, _fake) = transmission().await;
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
assert_eq!(outcomes.len(), 1);
assert_eq!(
tv_grabs(&database).await,
vec![("season".to_owned(), season_id, "sent".to_owned())]
);
}
}
File diff suppressed because it is too large Load Diff
+724 -4
View File
@@ -370,9 +370,7 @@ impl TvGrabAction {
blacklist: &Blacklist,
) -> Result<Vec<TvCandidate>, GrabError> {
let target = TvTarget {
// The series table carries no TVDB id yet; `tv_request` falls
// back to a text search built from the title and the tag.
tvdb_id: None,
tvdb_id: season.series_tvdb_id.and_then(|id| u64::try_from(id).ok()),
title: season.series_title.clone(),
selector,
};
@@ -417,7 +415,7 @@ impl TvGrabAction {
if covered.is_empty() {
continue;
}
let stored = store_episode_release(
let (stored_id, stored) = store_episode_release(
database,
&covered,
&release,
@@ -427,6 +425,22 @@ impl TvGrabAction {
blacklist,
)
.await?;
// A pack for exactly this season belongs on the season deck too
// (§9.3, issue #125) — including when it is rejected, which the
// deck shows greyed out.
if claim
.as_ref()
.is_some_and(|claim| is_this_seasons_pack(claim, season.season_number_u32()))
{
sqlx::query!(
"INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)
ON CONFLICT DO NOTHING",
season.season_id,
stored_id
)
.execute(database.pool())
.await?;
}
candidates.push(TvCandidate {
claim,
eligible: stored,
@@ -434,6 +448,229 @@ impl TvGrabAction {
}
Ok(candidates)
}
/// The manual episode trigger (§6.2, §9.3, issue #132): reset the
/// episode's backoff so selection cannot skip it, then sweep the
/// indexers scoped to this one episode instead of the tick's
/// `EPISODE_SEARCHES_PER_TICK` batch.
///
/// Same shape as the movie trigger: a gap gets search and grab; an
/// already-satisfied episode gets its deck refreshed and no grab; a
/// blocked series is refused outright (§6.3). An unaired episode is not
/// a gap yet — nothing to close until it airs (§6.2).
pub(crate) async fn search_episode_now(
&self,
database: &Db,
episode_id: i64,
) -> Result<(), GrabError> {
let Some(episode) = manual_episode(database, episode_id).await? else {
tracing::info!(
episode_id,
"manual episode search refused: unknown or blocked episode"
);
return Ok(());
};
let Some(original_language) = episode.season.original_language.as_deref() else {
tracing::warn!(
series = episode.season.series_title,
"no original language yet; not searching"
);
return Ok(());
};
let original_language = arr_db::policy::language(original_language);
let Some(loaded) = database.episode_policy(episode_id).await? else {
return Ok(());
};
reset_episode_backoff(database, episode_id).await?;
let blacklist = Blacklist::load(database.pool()).await?;
let episodes = season_episodes(database, episode.season.season_id).await?;
let selector = TvSelector::Episode {
season: episode.season.season_number_u32(),
episode: u32::try_from(episode.number).unwrap_or_default(),
};
if !episode.is_gap() {
let candidates = self
.search(
database,
&episode.season,
&episodes,
selector,
&loaded,
&original_language,
&blacklist,
)
.await?;
record_episode_search(database, &[episode_id]).await?;
tracing::info!(
episode_id,
eligible = candidates.len(),
"episode release deck refreshed; not grabbing a satisfied episode"
);
return Ok(());
}
if !episode.aired() {
tracing::info!(episode_id, "episode has not aired; nothing to search for");
return Ok(());
}
let Some(this_one) = episodes.iter().find(|row| row.id == episode_id) else {
return Ok(());
};
self.grab_episodes(
database,
&episode.season,
&episodes,
std::slice::from_ref(&this_one),
&loaded,
&original_language,
&blacklist,
usize::MAX,
)
.await?;
Ok(())
}
/// The manual one-click episode grab (§9.3, issue #132): the release is
/// already chosen off the episode deck, so this skips search and scoring
/// and sends it straight to Transmission. A manual grab may take a
/// `waived` release (§9.3), never a `rejected` one.
pub(crate) async fn grab_episode_release_now(
&self,
database: &Db,
episode_id: i64,
release_id: i64,
) -> Result<Option<Outcome>, GrabError> {
let Some(loaded) = database.episode_policy(episode_id).await? else {
return Ok(None);
};
let Some(title) = episode_series_title(database, episode_id).await? else {
return Ok(None);
};
let Some(release) = load_episode_release(database, episode_id, release_id).await? else {
return Ok(None);
};
let blacklist = Blacklist::load(database.pool()).await?;
self.grabber
.send_winner(
database,
&GrabTarget {
scope: GrabScope::Episode { episode_id },
title: &title,
counts_as_attempt: false,
},
&loaded,
&blacklist,
release,
)
.await
}
/// The manual season trigger (issue #125's deck, drained per #132):
/// reset every open episode's backoff and run the same pack-or-fall-back
/// lane as the tick, scoped to this one season with no tick budgets.
///
/// A satisfied season — nothing open, or a grab already in flight —
/// gets its deck refreshed and no grab; a blocked series refuses.
pub(crate) async fn search_season_now(
&self,
database: &Db,
season_id: i64,
) -> Result<(), GrabError> {
let Some(season) = manual_season(database, season_id).await? else {
tracing::info!(
season_id,
"manual season search refused: unknown or blocked season"
);
return Ok(());
};
let Some(original_language) = season.original_language.as_deref() else {
tracing::warn!(
series = season.series_title,
"no original language yet; not searching"
);
return Ok(());
};
let original_language = arr_db::policy::language(original_language);
let Some(loaded) = database.season_policy(season_id).await? else {
return Ok(());
};
reset_season_backoff(database, season_id).await?;
let blacklist = Blacklist::load(database.pool()).await?;
if !season.is_gap {
let episodes = season_episodes(database, season_id).await?;
let open: Vec<i64> = episodes
.iter()
.filter(|episode| episode.wanted && !episode.has_file && !episode.in_flight)
.map(|episode| episode.id)
.collect();
let candidates = self
.search(
database,
&season,
&episodes,
TvSelector::Season {
season: season.season_number_u32(),
},
&loaded,
&original_language,
&blacklist,
)
.await?;
record_episode_search(database, &open).await?;
tracing::info!(
season_id,
eligible = candidates.len(),
"season release deck refreshed; not grabbing a satisfied season"
);
return Ok(());
}
self.grab_season(database, &season, &blacklist, usize::MAX)
.await?;
Ok(())
}
/// The manual one-click season grab (§9.3, issue #125): a chosen pack
/// goes straight to Transmission against the season's still-open gaps.
pub(crate) async fn grab_season_release_now(
&self,
database: &Db,
season_id: i64,
release_id: i64,
) -> Result<Option<Outcome>, GrabError> {
let Some(loaded) = database.season_policy(season_id).await? else {
return Ok(None);
};
let Some(title) = season_series_title(database, season_id).await? else {
return Ok(None);
};
let Some(release) = load_season_release(database, season_id, release_id).await? else {
return Ok(None);
};
let episode_ids: Vec<i64> = season_episodes(database, season_id)
.await?
.iter()
.filter(|episode| episode.wanted && !episode.has_file && !episode.in_flight)
.map(|episode| episode.id)
.collect();
let blacklist = Blacklist::load(database.pool()).await?;
self.grabber
.send_winner(
database,
&GrabTarget {
scope: GrabScope::Season {
season_id,
episode_ids,
},
title: &title,
counts_as_attempt: false,
},
&loaded,
&blacklist,
release,
)
.await
}
}
impl Action for TvGrabAction {
@@ -472,6 +709,8 @@ struct PendingSeason {
season_id: i64,
season_number: i64,
series_title: String,
/// §6.1: `t=tvsearch` is addressed by this when present.
series_tvdb_id: Option<i64>,
original_language: Option<String>,
}
@@ -509,6 +748,7 @@ async fn pending_seasons(database: &Db) -> Result<Vec<PendingSeason>, GrabError>
SELECT se.id AS "season_id!: i64",
se.number AS "season_number!: i64",
s.title AS "series_title!: String",
s.tvdb_id AS series_tvdb_id,
s.original_language
FROM seasons se
JOIN series s ON s.id = se.series_id
@@ -544,6 +784,7 @@ async fn pending_seasons(database: &Db) -> Result<Vec<PendingSeason>, GrabError>
season_id: row.season_id,
season_number: row.season_number,
series_title: row.series_title,
series_tvdb_id: row.series_tvdb_id,
original_language: row.original_language,
})
.collect())
@@ -591,6 +832,280 @@ async fn season_episodes(database: &Db, season_id: i64) -> Result<Vec<SeasonEpis
.collect())
}
/// One episode named by a manual command (#132), with the series context a
/// targeted search needs. Same gap facts as [`season_episodes`] plus the
/// lane decision. Blocked refuses upstream of here.
#[derive(Debug)]
struct ManualEpisode {
number: i64,
air_date: Option<String>,
wanted: bool,
has_file: bool,
in_flight: bool,
season: PendingSeason,
}
impl ManualEpisode {
/// Wanted with neither a file nor a grab in flight — a gap the manual
/// trigger may close. Anything else only gets a deck refresh.
fn is_gap(&self) -> bool {
self.wanted && !self.has_file && !self.in_flight
}
/// Unknown is unaired (§6.2): nothing to close until it airs.
fn aired(&self) -> bool {
air_date_time(self.air_date.as_deref())
.is_some_and(|date| date <= std::time::SystemTime::now())
}
}
/// Same columns as [`pending_seasons`], scoped to one id, without the tick's
/// batch limit and without the gap filter — the manual trigger names the
/// season, so what remains decides the lane here instead of hiding it.
async fn manual_episode(
database: &Db,
episode_id: i64,
) -> Result<Option<ManualEpisode>, GrabError> {
let row = sqlx::query!(
r#"
SELECT e.id AS "id!: i64",
e.number AS "number!: i64",
e.air_date,
e.wanted AS "wanted!: bool",
EXISTS (
SELECT 1 FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
) AS "has_file!: bool",
EXISTS (
SELECT 1 FROM grabs g
WHERE g.target_kind = 'episode' AND g.target_id = e.id
AND g.state IN ('sent', 'downloaded', 'imported')
) AS "in_flight!: bool",
se.id AS "season_id!: i64",
se.number AS "season_number!: i64",
s.title AS "series_title!: String",
s.tvdb_id AS series_tvdb_id,
s.original_language
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE e.id = ?
AND s.blocked = 0
"#,
episode_id
)
.fetch_optional(database.pool())
.await?;
Ok(row.map(|row| ManualEpisode {
number: row.number,
air_date: row.air_date,
wanted: row.wanted,
has_file: row.has_file,
in_flight: row.in_flight,
season: PendingSeason {
season_id: row.season_id,
season_number: row.season_number,
series_title: row.series_title,
series_tvdb_id: row.series_tvdb_id,
original_language: row.original_language,
},
}))
}
/// A season addressed by a manual command, with whether it is still a gap.
async fn manual_season(
database: &Db,
season_id: i64,
) -> Result<Option<PendingSeasonGap>, GrabError> {
let row = sqlx::query!(
r#"
SELECT se.id AS "season_id!: i64",
se.number AS "season_number!: i64",
s.title AS "series_title!: String",
s.tvdb_id AS series_tvdb_id,
s.original_language,
(
EXISTS (
SELECT 1 FROM episodes e
WHERE e.season_id = se.id
AND e.wanted = 1
AND NOT EXISTS (
SELECT 1 FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
)
AND NOT EXISTS (
SELECT 1 FROM grabs g
WHERE g.target_kind = 'episode' AND g.target_id = e.id
AND g.state IN ('sent', 'downloaded', 'imported')
)
)
AND NOT EXISTS (
SELECT 1 FROM grabs g
WHERE g.target_kind = 'season' AND g.target_id = se.id
AND g.state IN ('sent', 'downloaded', 'imported')
)
) AS "is_gap!: bool"
FROM seasons se
JOIN series s ON s.id = se.series_id
WHERE se.id = ?
AND s.blocked = 0
"#,
season_id
)
.fetch_optional(database.pool())
.await?;
Ok(row.map(|row| PendingSeasonGap {
season: PendingSeason {
season_id: row.season_id,
season_number: row.season_number,
series_title: row.series_title,
series_tvdb_id: row.series_tvdb_id,
original_language: row.original_language,
},
is_gap: row.is_gap,
}))
}
/// [`PendingSeason`] plus the lane decision for the manual trigger.
struct PendingSeasonGap {
season: PendingSeason,
is_gap: bool,
}
impl std::ops::Deref for PendingSeasonGap {
type Target = PendingSeason;
fn deref(&self) -> &PendingSeason {
&self.season
}
}
/// Unconditional, unlike the tick's backoff gate: the manual trigger's whole
/// point is to ignore the exponential backoff (§6.2).
async fn reset_episode_backoff(database: &Db, episode_id: i64) -> Result<(), GrabError> {
sqlx::query!(
"UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
episode_id
)
.execute(database.pool())
.await?;
Ok(())
}
/// A manual season search is one attempt against every open episode at
/// once, so they all start from zero together.
async fn reset_season_backoff(database: &Db, season_id: i64) -> Result<(), GrabError> {
sqlx::query!(
"UPDATE episodes SET search_attempts = 0, last_searched_at = NULL,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE season_id = ?",
season_id
)
.execute(database.pool())
.await?;
Ok(())
}
async fn episode_series_title(database: &Db, episode_id: i64) -> Result<Option<String>, GrabError> {
let title = sqlx::query_scalar!(
r#"SELECT s.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 e.id = ?"#,
episode_id
)
.fetch_optional(database.pool())
.await?;
Ok(title)
}
async fn season_series_title(database: &Db, season_id: i64) -> Result<Option<String>, GrabError> {
let title = sqlx::query_scalar!(
r#"SELECT s.title AS "title!: String"
FROM seasons se
JOIN series s ON s.id = se.series_id
WHERE se.id = ?"#,
season_id
)
.fetch_optional(database.pool())
.await?;
Ok(title)
}
/// The chosen release, still associated with the episode and not
/// hard-failed — a manual grab may take a `waived` release (§9.3).
async fn load_episode_release(
database: &Db,
episode_id: i64,
release_id: i64,
) -> Result<Option<Eligible>, GrabError> {
let row = sqlx::query!(
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.download_url AS "download_url!: String",
CAST(COALESCE(r.score, 0) AS INTEGER) AS "score!: i64"
FROM releases r
JOIN episode_releases er ON er.release_id = r.id
WHERE er.episode_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')
"#,
episode_id,
release_id
)
.fetch_optional(database.pool())
.await?;
Ok(row.map(|row| Eligible {
id: row.id,
indexer_id: row.indexer_id,
guid: row.guid,
name: row.name,
download_url: row.download_url,
score: row.score,
}))
}
/// The season counterpart: chosen off the season deck (issue #125).
async fn load_season_release(
database: &Db,
season_id: i64,
release_id: i64,
) -> Result<Option<Eligible>, GrabError> {
let row = sqlx::query!(
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.download_url AS "download_url!: String",
CAST(COALESCE(r.score, 0) AS INTEGER) AS "score!: i64"
FROM releases r
JOIN season_releases sr ON sr.release_id = r.id
WHERE sr.season_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')
"#,
season_id,
release_id
)
.fetch_optional(database.pool())
.await?;
Ok(row.map(|row| Eligible {
id: row.id,
indexer_id: row.indexer_id,
guid: row.guid,
name: row.name,
download_url: row.download_url,
score: row.score,
}))
}
/// Whether a season-pack grab for this season already hard-failed — the
/// fall-back-to-per-episode signal.
async fn pack_hard_failed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
@@ -1097,4 +1612,209 @@ mod tests {
.count();
assert_eq!(searches_after_first, searches_after_second);
}
/// Issue #132: an `EpisodeCommand::Search` must do the work, not merely
/// be accepted onto the channel. `search_attempts` starts at 5 (a 7-day
/// backoff, nowhere near elapsed) so a tick would skip this episode;
/// ending at 1 rather than 6 proves the reset happened, and a torrent in
/// Transmission proves the grab did.
#[tokio::test]
async fn a_manual_episode_search_searches_and_grabs_now() {
let (_dir, database, _season_id) =
wanted_season(&["2024-04-11", "2024-04-18", "2024-04-25"]).await;
sqlx::query(
"UPDATE episodes SET search_attempts = 5,
last_searched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-6 days')
WHERE number = 1",
)
.execute(database.pool())
.await
.unwrap();
let episode_id: i64 = sqlx::query_scalar("SELECT id FROM episodes WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
action(&indexer, &downloader)
.search_episode_now(&database, episode_id)
.await
.unwrap();
let sources: Vec<String> = fake
.torrents()
.into_iter()
.map(|torrent| torrent.source)
.collect();
assert_eq!(sources.len(), 1, "{sources:?}");
assert!(sources[0].ends_with("e01.torrent"));
assert_eq!(
grabs(&database).await,
vec![("episode".to_owned(), episode_id, "sent".to_owned())]
);
let attempts: i64 = sqlx::query_scalar("SELECT search_attempts FROM episodes WHERE id = ?")
.bind(episode_id)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(
attempts, 0,
"reset from 5; a completed grab records no further attempt"
);
}
/// Issue #132: an `EpisodeCommand::Grab` sends the already-chosen release
/// straight to Transmission — no indexer search at all.
#[tokio::test]
async fn a_manual_episode_grab_sends_the_chosen_release_without_searching() {
let (_dir, database, _season_id) = wanted_season(&["2024-04-11", "2024-04-18"]).await;
let episode_id: i64 = sqlx::query_scalar("SELECT id FROM episodes WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
VALUES (7, 'chosen', 'Fallout.S01E01.2160p.WEB-DL.DDP5.1', 10737418240,
?, '{}', 900, 'eligible') RETURNING id",
)
.bind(format!("{}/dl/e01.torrent", indexer.uri()))
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query("INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)")
.bind(episode_id)
.bind(release_id)
.execute(database.pool())
.await
.unwrap();
action(&indexer, &downloader)
.grab_episode_release_now(&database, episode_id, release_id)
.await
.unwrap();
assert_eq!(fake.torrents().len(), 1);
assert_eq!(
grabs(&database).await,
vec![("episode".to_owned(), episode_id, "sent".to_owned())]
);
let searched = indexer
.received_requests()
.await
.unwrap()
.iter()
.any(|request| {
request
.url
.query_pairs()
.any(|(name, value)| name == "t" && value == "search")
});
assert!(!searched, "a chosen release must not trigger a search");
}
/// Issue #132: a `SeasonCommand::Search` runs the pack lane now, against
/// the season deck's own table — the backoff reset is what proves it was
/// really drained rather than dropped on the floor.
#[tokio::test]
async fn a_manual_season_search_takes_the_pack_now() {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
sqlx::query(
"UPDATE episodes SET search_attempts = 5,
last_searched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-6 days')",
)
.execute(database.pool())
.await
.unwrap();
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
action(&indexer, &downloader)
.search_season_now(&database, season_id)
.await
.unwrap();
assert_eq!(fake.torrents().len(), 1);
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
assert_eq!(
grabs(&database).await,
vec![("season".to_owned(), season_id, "sent".to_owned())]
);
assert_eq!(
episode_states(&database).await,
vec!["downloading", "downloading", "downloading"]
);
let packs: Vec<String> = sqlx::query_scalar(
"SELECT r.name FROM season_releases sr JOIN releases r ON r.id = sr.release_id",
)
.fetch_all(database.pool())
.await
.unwrap();
assert!(packs.contains(&"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos".to_owned()));
let attempts: Vec<i64> =
sqlx::query_scalar("SELECT search_attempts FROM episodes ORDER BY number")
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(
attempts,
vec![0, 0, 0],
"reset from 5; a completed grab records no further attempt"
);
}
/// Issue #132: a `SeasonCommand::Grab` sends the already-chosen pack
/// straight to Transmission — no indexer search at all.
#[tokio::test]
async fn a_manual_season_grab_sends_the_chosen_pack_without_searching() {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
VALUES (7, 'chosen-pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920,
?, '{}', 900, 'eligible') RETURNING id",
)
.bind(format!("{}/dl/pack.torrent", indexer.uri()))
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
.bind(season_id)
.bind(release_id)
.execute(database.pool())
.await
.unwrap();
action(&indexer, &downloader)
.grab_season_release_now(&database, season_id, release_id)
.await
.unwrap();
assert_eq!(fake.torrents().len(), 1);
assert_eq!(
grabs(&database).await,
vec![("season".to_owned(), season_id, "sent".to_owned())]
);
assert_eq!(
episode_states(&database).await,
vec!["downloading", "downloading", "downloading"]
);
let searched = indexer
.received_requests()
.await
.unwrap()
.iter()
.any(|request| {
request
.url
.query_pairs()
.any(|(name, value)| name == "t" && value == "search")
});
assert!(!searched, "a chosen release must not trigger a search");
}
}
@@ -0,0 +1,6 @@
-- Torznab `t=tvsearch` is addressed by TVDB id (§6.1); the title text query
-- is only the fallback. Nullable, because TMDB does not know one for every
-- series and #121 backfills it on refresh; unique when present so two rows
-- cannot claim the same show.
ALTER TABLE series ADD COLUMN tvdb_id INTEGER;
CREATE UNIQUE INDEX series_tvdb_id_unique ON series (tvdb_id) WHERE tvdb_id IS NOT NULL;
@@ -0,0 +1,10 @@
-- The season counterpart of episode_releases (0006). A targeted season search
-- returns packs the operator has not committed to, which is the wrong grain
-- for a table that links a release to one episode (DESIGN.md §9.3, issue 125).
CREATE TABLE season_releases (
season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE,
release_id INTEGER NOT NULL REFERENCES releases (id) ON DELETE CASCADE,
PRIMARY KEY (season_id, release_id)
) STRICT;
CREATE INDEX season_releases_release ON season_releases (release_id);
@@ -0,0 +1,4 @@
-- §8: the daily series metadata refresh is gated on this stamp, exactly as
-- the movie lane gates on `movies.metadata_refreshed_at` (0009). NULL means
-- never refreshed, which makes a newly added series immediately due.
ALTER TABLE series ADD COLUMN metadata_refreshed_at TEXT;
@@ -0,0 +1,7 @@
-- #122. TMDB deletes and renumbers episodes. An episode that vanished
-- upstream and has no file is deleted outright by the refresh; one that has
-- a file is never deleted — `media_files.path` is UNIQUE and its owner is
-- polymorphic, so dropping the row would orphan a real file — and instead
-- carries this flag as the conflict the operator resolves.
ALTER TABLE episodes ADD COLUMN vanished INTEGER NOT NULL DEFAULT 0
CHECK (vanished IN (0, 1));
@@ -0,0 +1,6 @@
-- #137. TMDB deletes whole seasons as well as episodes (#122). A season that
-- vanished upstream with a file anywhere under it cannot be deleted — the
-- file row would orphan — so it is flagged instead, the same conflict marker
-- episodes carry. Seasons without any file cascade away outright.
ALTER TABLE seasons ADD COLUMN vanished INTEGER NOT NULL DEFAULT 0
CHECK (vanished IN (0, 1));
@@ -0,0 +1,11 @@
-- #153. `episodes.title` accepted the empty string, which TMDB sends for an
-- unaired episode it has not named yet. Empty titles leaked into §9.2's
-- search haystack, §7.4 filenames and the compat shim as if they were real
-- text. Rows already carrying `''` take the same "TBA" placeholder the TMDB
-- boundary now substitutes — #121's guarded update replaces it once TMDB
-- fills the title in.
--
-- A hard CHECK (title <> '') would need a table rebuild with foreign_keys
-- off, which sqlx 0.8's migrator cannot run (it always wraps a migration in
-- a transaction, where that pragma is a no-op); enforcement lives in code.
UPDATE episodes SET title = 'TBA', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE title = '';
+69
View File
@@ -275,6 +275,75 @@ impl Db {
root_path: row.root_path,
}))
}
/// The policy attached to a season's series root, with that series'
/// overrides. Same rule as [`Db::episode_policy`]: overrides sit on the
/// series (§5.1), never on a season.
///
/// `None` when the season does not exist.
///
/// # Errors
///
/// If the query fails, or a policy column does not hold the JSON its
/// migration promises.
pub async fn season_policy(&self, season_id: i64) -> Result<Option<TitlePolicy>, PolicyError> {
let row = sqlx::query!(
r#"
SELECT s.overrides AS "overrides!: String",
r.id AS "root_id!: i64",
r.kind AS "root_kind!: String",
r.audience AS "root_audience!: String",
r.path AS "root_path!: String",
p.id AS "policy_id!: i64",
p.name AS "policy_name!: String",
p.required_audio AS "required_audio!: String",
p.dub_blacklist AS "dub_blacklist!: String",
p.hdr_rules AS "hdr_rules!: String",
p.size_bands AS "size_bands!: String",
p.resolution_pref AS "resolution_pref!: String",
p.source_weights AS "source_weights!: String",
p.score_weights AS "score_weights!: String"
FROM seasons se
JOIN series s ON s.id = se.series_id
JOIN roots r ON r.id = s.root_id
JOIN policies p ON p.id = r.policy_id
WHERE se.id = ?
"#,
season_id
)
.fetch_optional(self.pool())
.await?;
let Some(row) = row else {
return Ok(None);
};
let overrides: OverridesJson = json("overrides", &row.overrides)?;
let policy = PolicyColumns {
id: row.policy_id,
name: row.policy_name,
required_audio: row.required_audio,
dub_blacklist: row.dub_blacklist,
hdr_rules: row.hdr_rules,
size_bands: row.size_bands,
resolution_pref: row.resolution_pref,
source_weights: row.source_weights,
score_weights: row.score_weights,
}
.to_policy()?;
Ok(Some(TitlePolicy {
policy,
overrides: TitleOverrides {
only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio,
},
root_id: row.root_id,
root_kind: row.root_kind,
root_audience: row.root_audience,
root_path: row.root_path,
}))
}
}
/// A BCP-47 tag as the policy columns write it.
+24 -4
View File
@@ -9,8 +9,8 @@ use serde::de::DeserializeOwned;
use crate::cache::Cache;
use crate::error::{Error, Result};
use crate::model::{
Movie, MovieSearchResult, RawFindPage, RawMovie, RawSearchPage, RawSeason, RawSeries,
RawSeriesSearchPage, Season, Series, SeriesSearchResult,
ExternalIds, FindResults, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie,
RawSearchPage, RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult,
};
/// TMDB's v3 API root.
@@ -94,14 +94,20 @@ impl TmdbClient {
/// Resolve an `IMDb` title id through TMDB's external-id index.
///
/// TMDB answers with both kinds in one response, so one call surfaces
/// whichever the id names — the way a raw TMDB id resolves (§9.2).
///
/// # Errors
///
/// Any of [`Error`]; see its variants for what callers should distinguish.
pub async fn find_movie_by_imdb(&self, imdb_id: &str) -> Result<Vec<MovieSearchResult>> {
pub async fn find_by_imdb(&self, imdb_id: &str) -> Result<FindResults> {
let path = format!("find/{}", imdb_id.trim());
let params = [("external_source", "imdb_id".to_owned())];
let page: RawFindPage = self.get_json(&path, &params).await?;
Ok(page.movie_results.into_iter().map(Into::into).collect())
Ok(FindResults {
movies: page.movie_results.into_iter().map(Into::into).collect(),
series: page.tv_results.into_iter().map(Into::into).collect(),
})
}
/// Resolve a TVDB series id through TMDB's external-id index.
@@ -157,6 +163,20 @@ impl TmdbClient {
Ok(raw.into())
}
/// External ids for one TV series, of which the TVDB id is the one this
/// project needs (§6.1).
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
pub async fn series_external_ids(&self, tmdb_id: u32) -> Result<ExternalIds> {
let path = format!("tv/{tmdb_id}/external_ids");
let raw: RawExternalIds = self.get_json(&path, &[]).await?;
Ok(ExternalIds {
tvdb_id: raw.tvdb_id,
})
}
/// Episodes in one TV season.
///
/// # Errors
+4 -1
View File
@@ -23,4 +23,7 @@ mod model;
pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL};
pub use error::{Error, Result};
pub use model::{Episode, Movie, MovieSearchResult, Season, Series, SeriesSearchResult};
pub use model::{
Episode, ExternalIds, FindResults, Movie, MovieSearchResult, Season, Series,
SeriesSearchResult, UNTITLED_EPISODE,
};
+29 -3
View File
@@ -140,6 +140,10 @@ pub struct SeasonSummary {
pub episode_count: u32,
}
/// Placeholder for an episode TMDB has not named yet. #121's refresh replaces
/// it once TMDB fills the title in.
pub const UNTITLED_EPISODE: &str = "TBA";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Season {
pub number: u32,
@@ -218,9 +222,16 @@ pub(crate) struct RawSeries {
external_ids: Option<RawExternalIds>,
}
/// The slice of `/tv/{id}/external_ids` this project reads.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalIds {
/// The Torznab `tvdbid` parameter (§6.1), when TMDB knows one.
pub tvdb_id: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct RawExternalIds {
tvdb_id: Option<u32>,
pub(crate) struct RawExternalIds {
pub(crate) tvdb_id: Option<u32>,
}
#[derive(Debug, Deserialize)]
@@ -278,7 +289,13 @@ impl From<RawSeason> for Season {
.into_iter()
.map(|episode| Episode {
number: episode.episode_number,
title: episode.name,
// TMDB leaves an unaired episode's name empty; a placeholder
// keeps "" out of search, filenames and the compat shim.
title: if episode.name.is_empty() {
UNTITLED_EPISODE.to_owned()
} else {
episode.name
},
air_date: episode.air_date.as_deref().and_then(parse_date),
})
.collect(),
@@ -286,6 +303,15 @@ impl From<RawSeason> for Season {
}
}
/// What one `IMDb` id resolved to. An id names one title, so at most one of
/// the two lists is non-empty — but TMDB answers both kinds in the same
/// response, and both are surfaced rather than filtered here.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FindResults {
pub movies: Vec<MovieSearchResult>,
pub series: Vec<SeriesSearchResult>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawFindPage {
#[serde(default)]
+14
View File
@@ -0,0 +1,14 @@
{
"movie_results": [],
"tv_results": [
{
"id": 82728,
"name": "Bluey",
"original_name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"overview": "The slice-of-life adventures of an Australian cattle dog.",
"poster_path": "/58PmSsz6PEdlVscLE1tRJ7tknU.jpg"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": 82728,
"imdb_id": "tt7614372",
"freebase_id": "/m/0vpgz0b",
"freebase_mid": "/m/0vpgz0b",
"tvdb_id": 361391,
"tvrage_id": null,
"facebook_id": "OfficialBluey",
"instagram_id": "officialblueytv",
"twitter_id": "officialbluey"
}
+96 -1
View File
@@ -7,7 +7,7 @@ use {reqwest as _, serde as _, serde_json as _, thiserror as _, tracing as _};
use std::time::Duration;
use arr_meta::{Error, TmdbClient};
use arr_meta::{Error, TmdbClient, UNTITLED_EPISODE};
use chrono::NaiveDate;
use wiremock::matchers::{header_exists, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -18,6 +18,8 @@ const MOVIE_CIDADE_DE_DEUS: &str = include_str!("fixtures/movie_cidade_de_deus.j
const MOVIE_UNRELEASED: &str = include_str!("fixtures/movie_unreleased.json");
const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only.json");
const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json");
const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json");
const FIND_IMDB_SERIES: &str = include_str!("fixtures/find_imdb_series.json");
fn client(server: &MockServer) -> TmdbClient {
TmdbClient::builder("test-key")
@@ -461,3 +463,96 @@ async fn debug_output_does_not_leak_the_api_key() {
assert!(!rendered.contains("test-key"), "{rendered}");
assert!(rendered.contains("redacted"), "{rendered}");
}
/// TMDB leaves an unaired episode's name as `""`. It must not reach the
/// library as an empty string — search haystacks, §7.4 filenames and the
/// compat shim all treat it as real text (#153) — so it becomes a placeholder
/// that a later refresh replaces once TMDB names it.
#[tokio::test]
async fn season_episode_without_a_name_gets_the_placeholder_title() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/82728/season/1"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"season_number": 1, "episodes": [
{"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"},
{"episode_number": 2, "name": "", "air_date": null}
]}"#,
))
.mount(&server)
.await;
let season = client(&server)
.season(82_728, 1)
.await
.expect("lookup succeeds");
assert_eq!(season.episodes[0].title, "Magic Xylophone");
assert_eq!(season.episodes[1].title, UNTITLED_EPISODE);
}
/// §6.1: `t=tvsearch` is addressed by TVDB id, and `/tv/{id}/external_ids` is
/// where TMDB keeps the mapping.
#[tokio::test]
async fn series_external_ids_carries_the_tvdb_id() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/82728/external_ids"))
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_EXTERNAL_IDS))
.expect(1)
.mount(&server)
.await;
let ids = client(&server)
.series_external_ids(82_728)
.await
.expect("lookup succeeds");
assert_eq!(ids.tvdb_id, Some(361_391));
}
/// §9.2: a pasted `tt` id must resolve a series the same way it resolves a
/// movie. TMDB answers `/find` with both kinds in one response; the `tv_results`
/// half is what this reads.
#[tokio::test]
async fn find_by_imdb_resolves_series() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/find/tt7614372"))
.and(query_param("external_source", "imdb_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(FIND_IMDB_SERIES))
.expect(1)
.mount(&server)
.await;
let results = client(&server)
.find_by_imdb("tt7614372")
.await
.expect("lookup succeeds");
assert!(results.movies.is_empty());
assert_eq!(results.series.len(), 1);
let series = &results.series[0];
assert_eq!(series.tmdb_id, 82_728);
assert_eq!(series.title, "Bluey");
assert_eq!(series.year(), Some(2018));
}
/// A series TMDB has no TVDB id for stays null rather than zero, so callers
/// can tell "unknown" from a real id and fall back to the text query.
#[tokio::test]
async fn series_without_a_tvdb_id_maps_to_none() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/1/external_ids"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id": 1, "tvdb_id": null}"#))
.mount(&server)
.await;
let ids = client(&server)
.series_external_ids(1)
.await
.expect("lookup succeeds");
assert_eq!(ids.tvdb_id, None);
}

Some files were not shown because too many files have changed in this diff Show More