feat(api): series, season and episode endpoints (#81)
This commit was merged in pull request #81.
This commit is contained in:
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "series_id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "season_id!: i64",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "number!: i64",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title!: String",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "air_date",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wanted!: bool",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "state!: String",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_attempts!: i64",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last_searched_at",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "024a22e1d6974b40f4fdd86e4ad732ce6de6e7c92b93bc3c835cc48a296021fb"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO episodes (season_id, number, title, air_date, wanted) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 5
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "03f037d646959854cbd03404b34f887362dce66d09ac8db2df201dd1dc2199f9"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ? AND owner_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "043325f63ac8a7a9ad127e2230937311a11b8795db4057afc5fef736c5d89e76"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT EXISTS(SELECT 1 FROM episode_releases WHERE episode_id = ? AND release_id = ?) AS 'exists!: bool'",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "exists!: bool",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "1578519edb5411dd191819f5af9f6510115149c13dae9c31cfb39ccd14e549f9"
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"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 = ?",
|
||||||
|
"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": "auto_track!: bool",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "overrides!: serde_json::Value",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "upstream_ended!: bool",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "blocked!: bool",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "1a2db8b6fd48a7f3a32cd2ce33e4dbab9e46e99e004fd5a913e12ac6110071c2"
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "\n SELECT s.overrides AS \"overrides!: String\",\n r.id AS \"root_id!: i64\",\n r.kind AS \"root_kind!: String\",\n r.audience AS \"root_audience!: String\",\n r.path AS \"root_path!: String\",\n p.id AS \"policy_id!: i64\",\n p.name AS \"policy_name!: String\",\n p.required_audio AS \"required_audio!: String\",\n p.dub_blacklist AS \"dub_blacklist!: String\",\n p.hdr_rules AS \"hdr_rules!: String\",\n p.size_bands AS \"size_bands!: String\",\n p.resolution_pref AS \"resolution_pref!: String\",\n p.source_weights AS \"source_weights!: String\",\n p.score_weights AS \"score_weights!: String\"\n FROM 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 r ON r.id = s.root_id\n JOIN policies p ON p.id = r.policy_id\n WHERE e.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": "227701e40f5e500f328082537645f1a0ce0d9b7fe9940e573f50c87209ba10a5"
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "series_id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "season_id!: i64",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "number!: i64",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title!: String",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "air_date",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wanted!: bool",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "state!: String",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_attempts!: i64",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last_searched_at",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3b3165092d403797f233f7a926daab43aea1c78c508790a2cca230ce0cc73b26"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE season_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "5a4efdf9a0c027a675106f4e93b5f6631b09e5a5f4a0f0a079c4b2f309d7d8db"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO title_owners (title_kind, title_id, owner_id) VALUES ('series', ?, ?) ON CONFLICT DO NOTHING",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "5c7868e1c9ac87e3506fdad2ac0603b612cd4de4adfea980b2b06c093786abd3"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM series WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "607707d65c2578a1ee7c9bea0bb53e520e05340d78677e6378c9390006456e94"
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT o.id AS \"id!: i64\", o.name AS \"name!: String\", o.ntfy_topic AS \"ntfy_topic!: String\"\n FROM owners o\n JOIN title_owners t ON t.owner_id = o.id\n WHERE t.title_kind = 'series' AND t.title_id = ?\n ORDER BY o.name",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name!: String",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ntfy_topic!: String",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "68f44b694f26c51f54b8ebb12c3341bf16a61029fb82d621930c137b2895b381"
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"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 = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "series_title!: String",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "original_language",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "season_number!: i64",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "episode_number!: i64",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "6a164386b22fa587458007a843d0bfe7030ef0665dd989c1018dd83e2ec330cd"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO seasons (series_id, number, tracked) VALUES (?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "78f3a0d5c2d1089cd88fa1f4eeee0883c1f7d90f1c345f09af63c4b349d46281"
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"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 = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "series_id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "season_id!: i64",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "number!: i64",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title!: String",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "air_date",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wanted!: bool",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "state!: String",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_attempts!: i64",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last_searched_at",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "7fc4e02be63e223a60b1bf7d78f68aad4106abc0e17f7da6b98efd818ecdc372"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT s.blocked AS \"blocked!: bool\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE e.id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "blocked!: bool",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "8ff544fa49afd3fa76952995b2b0767a8603335eaed198ebd4648d9a38b5984f"
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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": "auto_track!: bool",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "overrides!: serde_json::Value",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "upstream_ended!: bool",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "blocked!: bool",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "950ae08a8a33d4ce3b088ae8ebac734baefdc2e6da90ac144d2dc0466e8cbe26"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT EXISTS(SELECT 1 FROM roots WHERE id = ? AND kind = 'tv') AS 'exists!: bool'",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "exists!: bool",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9a6924f163d5e2b61e77eb80b199008b387a96c251606cfe196a78075c119f8e"
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"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 = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "series_id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "season_id!: i64",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "number!: i64",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title!: String",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "air_date",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wanted!: bool",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "state!: String",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_attempts!: i64",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last_searched_at",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ae6d0463fd1bf373cbc9e0b5bbfc26e7934791024c216f876f940937dde45882"
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id AS \"id!: i64\" FROM seasons WHERE series_id = ? AND number = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE seasons SET tracked = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "b83fbeb18e706c9e8fca723035a1fa3237bd1b8cf1f82b28f9927e231ac97c32"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 9
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "d64c6c62822119a62f77dcd297ad9a96d59840bde46fcf10dcf8870f09ac7954"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "d8b209898cd22a4d322fe80bb8101f27bb69dbd76b748f519ecfd70fed4f44b8"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "series_id!: i64",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "number!: i64",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tracked!: bool",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ecbf65c10aa20bc04c841981e59919485518877b49e9702fc92512f39cc2a72e"
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_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": "f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba"
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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": "auto_track!: bool",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "overrides!: serde_json::Value",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "upstream_ended!: bool",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "blocked!: bool",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "f547ac991490ac5af5c16e707a2b52e83207a194e496dcaecfa253cdc0757463"
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ mod movies;
|
|||||||
mod owners;
|
mod owners;
|
||||||
mod roots;
|
mod roots;
|
||||||
mod search;
|
mod search;
|
||||||
|
mod series;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
@@ -25,7 +26,11 @@ pub use movies::{Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, Relea
|
|||||||
pub use owners::{CreateOwner, Owner, UpdateOwner};
|
pub use owners::{CreateOwner, Owner, UpdateOwner};
|
||||||
pub use roots::Root;
|
pub use roots::Root;
|
||||||
pub use search::{ClassifiedRelease, SearchResponse};
|
pub use search::{ClassifiedRelease, SearchResponse};
|
||||||
pub use state::{AppState, MovieCommand, Upstreams, DEFAULT_TMDB_URL};
|
pub use series::{
|
||||||
|
CreateEpisode, CreateSeason, CreateSeries, Episode, Season, Series, UpdateEpisode,
|
||||||
|
UpdateSeason, UpdateSeries,
|
||||||
|
};
|
||||||
|
pub use state::{AppState, EpisodeCommand, MovieCommand, Upstreams, DEFAULT_TMDB_URL};
|
||||||
|
|
||||||
/// Where the generated document is served, and where `just gen-client` reads
|
/// Where the generated document is served, and where `just gen-client` reads
|
||||||
/// it back from when it is fetched rather than dumped from the binary.
|
/// it back from when it is fetched rather than dumped from the binary.
|
||||||
@@ -46,6 +51,7 @@ pub const DOCS_PATH: &str = "/api/docs";
|
|||||||
tags(
|
tags(
|
||||||
(name = "system", description = "Service health and metadata"),
|
(name = "system", description = "Service health and metadata"),
|
||||||
(name = "movies", description = "Movie library and actions"),
|
(name = "movies", description = "Movie library and actions"),
|
||||||
|
(name = "series", description = "Series, seasons and episodes (DESIGN.md §4.1, §4.2)"),
|
||||||
(name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"),
|
(name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"),
|
||||||
(name = "search", description = "Unified title and release search"),
|
(name = "search", description = "Unified title and release search"),
|
||||||
(name = "roots", description = "Root folders and their policies")
|
(name = "roots", description = "Root folders and their policies")
|
||||||
@@ -65,6 +71,16 @@ fn api_router() -> OpenApiRouter<AppState> {
|
|||||||
.routes(routes!(movies::attention))
|
.routes(routes!(movies::attention))
|
||||||
.routes(routes!(movies::list_owners))
|
.routes(routes!(movies::list_owners))
|
||||||
.routes(routes!(movies::tag_owner, movies::untag_owner))
|
.routes(routes!(movies::tag_owner, movies::untag_owner))
|
||||||
|
.routes(routes!(series::list, series::create))
|
||||||
|
.routes(routes!(series::get, series::update, series::delete))
|
||||||
|
.routes(routes!(series::seasons, series::create_season))
|
||||||
|
.routes(routes!(series::update_season))
|
||||||
|
.routes(routes!(series::get_episode, series::update_episode))
|
||||||
|
.routes(routes!(series::search_episode))
|
||||||
|
.routes(routes!(series::episode_releases))
|
||||||
|
.routes(routes!(series::grab_episode))
|
||||||
|
.routes(routes!(series::list_owners))
|
||||||
|
.routes(routes!(series::tag_owner, series::untag_owner))
|
||||||
.routes(routes!(owners::list, owners::create))
|
.routes(routes!(owners::list, owners::create))
|
||||||
.routes(routes!(owners::get, owners::update, owners::delete))
|
.routes(routes!(owners::get, owners::update, owners::delete))
|
||||||
.routes(routes!(search::search))
|
.routes(routes!(search::search))
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ pub struct ErrorBody {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ApiError {
|
pub enum ApiError {
|
||||||
NotFound,
|
NotFound,
|
||||||
|
SeriesNotFound,
|
||||||
|
SeasonNotFound,
|
||||||
|
EpisodeNotFound,
|
||||||
OwnerNotFound,
|
OwnerNotFound,
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
Invalid(String),
|
Invalid(String),
|
||||||
@@ -96,6 +99,9 @@ impl IntoResponse for ApiError {
|
|||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, error) = match self {
|
let (status, error) = match self {
|
||||||
Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string()),
|
Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string()),
|
||||||
|
Self::SeriesNotFound => (StatusCode::NOT_FOUND, "series not found".to_string()),
|
||||||
|
Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string()),
|
||||||
|
Self::EpisodeNotFound => (StatusCode::NOT_FOUND, "episode not found".to_string()),
|
||||||
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
|
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
|
||||||
Self::Conflict(error) => (StatusCode::CONFLICT, error),
|
Self::Conflict(error) => (StatusCode::CONFLICT, error),
|
||||||
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
|
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
|
||||||
@@ -104,7 +110,7 @@ impl IntoResponse for ApiError {
|
|||||||
"database unavailable".into(),
|
"database unavailable".into(),
|
||||||
),
|
),
|
||||||
Self::Database(error) => {
|
Self::Database(error) => {
|
||||||
tracing::error!(%error, "movie API database error");
|
tracing::error!(%error, "API database error");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "database error".into())
|
(StatusCode::INTERNAL_SERVER_ERROR, "database error".into())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+181
-14
@@ -4,7 +4,7 @@ use arr_core::policy::{evaluate, Candidate};
|
|||||||
use arr_core::score::score;
|
use arr_core::score::score;
|
||||||
use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict};
|
use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict};
|
||||||
use arr_db::policy::language;
|
use arr_db::policy::language;
|
||||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest, TvSelector, TvTarget};
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
@@ -19,9 +19,14 @@ pub struct SearchQuery {
|
|||||||
q: String,
|
q: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which title a manual release search is for. Exactly one of the two.
|
||||||
|
///
|
||||||
|
/// Intent lives at the leaf (`DESIGN.md` §4.1), so the TV side of this is an
|
||||||
|
/// episode even when the release that satisfies it is a season pack.
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
#[derive(Debug, Deserialize, IntoParams)]
|
||||||
pub struct ReleasesQuery {
|
pub struct ReleasesQuery {
|
||||||
movie_id: i64,
|
movie_id: Option<i64>,
|
||||||
|
episode_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
@@ -203,6 +208,7 @@ async fn search_tmdb(
|
|||||||
responses(
|
responses(
|
||||||
(status = 200, body = [ClassifiedRelease]),
|
(status = 200, body = [ClassifiedRelease]),
|
||||||
(status = 404, body = ErrorBody),
|
(status = 404, body = ErrorBody),
|
||||||
|
(status = 422, body = ErrorBody),
|
||||||
(status = 500, body = ErrorBody),
|
(status = 500, body = ErrorBody),
|
||||||
(status = 503, body = ErrorBody)
|
(status = 503, body = ErrorBody)
|
||||||
)
|
)
|
||||||
@@ -211,16 +217,33 @@ pub async fn releases(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<ReleasesQuery>,
|
Query(query): Query<ReleasesQuery>,
|
||||||
) -> Result<Json<Vec<ClassifiedRelease>>, ApiError> {
|
) -> Result<Json<Vec<ClassifiedRelease>>, ApiError> {
|
||||||
|
let mut classified = match (query.movie_id, query.episode_id) {
|
||||||
|
(Some(movie_id), None) => movie_releases(&state, movie_id).await?,
|
||||||
|
(None, Some(episode_id)) => episode_releases(&state, episode_id).await?,
|
||||||
|
_ => {
|
||||||
|
return Err(ApiError::Invalid(
|
||||||
|
"pass exactly one of movie_id and episode_id".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
classified.sort_by_key(|release| (bucket(&release.verdict), -release.score));
|
||||||
|
Ok(Json(classified))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn movie_releases(
|
||||||
|
state: &AppState,
|
||||||
|
movie_id: i64,
|
||||||
|
) -> Result<Vec<ClassifiedRelease>, ApiError> {
|
||||||
let database = state.database().ok_or(ApiError::Unavailable)?;
|
let database = state.database().ok_or(ApiError::Unavailable)?;
|
||||||
let movie = sqlx::query!(r#"SELECT title AS "title!: String", tmdb_id AS "tmdb_id!: i64", original_language FROM movies WHERE id = ?"#, query.movie_id)
|
let movie = sqlx::query!(r#"SELECT title AS "title!: String", tmdb_id AS "tmdb_id!: i64", original_language FROM movies WHERE id = ?"#, movie_id)
|
||||||
.fetch_optional(database.pool()).await?.ok_or(ApiError::NotFound)?;
|
.fetch_optional(database.pool()).await?.ok_or(ApiError::NotFound)?;
|
||||||
let loaded = database
|
let loaded = database
|
||||||
.movie_policy(query.movie_id)
|
.movie_policy(movie_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||||
.ok_or(ApiError::NotFound)?;
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
|
||||||
let tmdb = tmdb_client(&state)?
|
let tmdb = tmdb_client(state)?
|
||||||
.movie(
|
.movie(
|
||||||
u32::try_from(movie.tmdb_id)
|
u32::try_from(movie.tmdb_id)
|
||||||
.map_err(|_| ApiError::Invalid("movie has invalid TMDB id".into()))?,
|
.map_err(|_| ApiError::Invalid("movie has invalid TMDB id".into()))?,
|
||||||
@@ -233,13 +256,7 @@ pub async fn releases(
|
|||||||
},
|
},
|
||||||
|imdb_id| SearchRequest::Movie { imdb_id },
|
|imdb_id| SearchRequest::Movie { imdb_id },
|
||||||
);
|
);
|
||||||
let upstreams = state.upstreams();
|
let prowlarr = prowlarr_client(state)?;
|
||||||
let api_key = upstreams
|
|
||||||
.prowlarr_api_key
|
|
||||||
.clone()
|
|
||||||
.ok_or(ApiError::Unavailable)?;
|
|
||||||
let prowlarr = ProwlarrClient::new(upstreams.prowlarr_url.clone(), api_key)
|
|
||||||
.map_err(|_| ApiError::Unavailable)?;
|
|
||||||
let indexers = prowlarr
|
let indexers = prowlarr
|
||||||
.indexers()
|
.indexers()
|
||||||
.await
|
.await
|
||||||
@@ -282,8 +299,80 @@ pub async fn releases(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
classified.sort_by_key(|release| (bucket(&release.verdict), -release.score));
|
Ok(classified)
|
||||||
Ok(Json(classified))
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
WHERE e.id = ?"#,
|
||||||
|
episode_id
|
||||||
|
)
|
||||||
|
.fetch_optional(database.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::EpisodeNotFound)?;
|
||||||
|
let loaded = database
|
||||||
|
.episode_policy(episode_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||||
|
.ok_or(ApiError::EpisodeNotFound)?;
|
||||||
|
|
||||||
|
// §5.2. The language rule is written against the title's original
|
||||||
|
// language, so guessing one would silently change every verdict.
|
||||||
|
let original_language = episode.original_language.as_deref().ok_or_else(|| {
|
||||||
|
ApiError::Invalid("series has no original_language; refresh its metadata first".into())
|
||||||
|
})?;
|
||||||
|
let original_language = title_language(original_language, &[]);
|
||||||
|
|
||||||
|
let target = TvTarget {
|
||||||
|
tvdb_id: None,
|
||||||
|
title: episode.series_title,
|
||||||
|
selector: TvSelector::Episode {
|
||||||
|
season: u32::try_from(episode.season_number).unwrap_or_default(),
|
||||||
|
episode: u32::try_from(episode.episode_number).unwrap_or_default(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let prowlarr = prowlarr_client(state)?;
|
||||||
|
let indexers = prowlarr
|
||||||
|
.indexers()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Unavailable)?;
|
||||||
|
|
||||||
|
let mut classified = Vec::new();
|
||||||
|
for indexer in indexers {
|
||||||
|
let Some(request) = indexer.capabilities.tv_request(&target) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match prowlarr.search_indexer(indexer.id, &request).await {
|
||||||
|
Ok(releases) => {
|
||||||
|
for release in releases {
|
||||||
|
classified.push(classify(
|
||||||
|
release,
|
||||||
|
&loaded.policy,
|
||||||
|
&loaded.overrides,
|
||||||
|
&original_language,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(indexer_id = indexer.id, %error, "manual episode search failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(classified)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<arr_meta::MovieSearchResult> for TmdbMovie {
|
impl From<arr_meta::MovieSearchResult> for TmdbMovie {
|
||||||
@@ -329,6 +418,15 @@ fn escape_like(input: &str) -> String {
|
|||||||
.replace('_', "\\_")
|
.replace('_', "\\_")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prowlarr_client(state: &AppState) -> Result<ProwlarrClient, ApiError> {
|
||||||
|
let upstreams = state.upstreams();
|
||||||
|
let api_key = upstreams
|
||||||
|
.prowlarr_api_key
|
||||||
|
.clone()
|
||||||
|
.ok_or(ApiError::Unavailable)?;
|
||||||
|
ProwlarrClient::new(upstreams.prowlarr_url.clone(), api_key).map_err(|_| ApiError::Unavailable)
|
||||||
|
}
|
||||||
|
|
||||||
fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiError> {
|
fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiError> {
|
||||||
let upstreams = state.upstreams();
|
let upstreams = state.upstreams();
|
||||||
let key = upstreams
|
let key = upstreams
|
||||||
@@ -590,6 +688,75 @@ mod tests {
|
|||||||
assert_eq!(eligible["score"], 0);
|
assert_eq!(eligible["score"], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn episode_releases_search_by_season_and_episode_tag() {
|
||||||
|
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;
|
||||||
|
// No TVDB ID on the series, so the indexer's tvsearch cannot be
|
||||||
|
// addressed by ID and the text fallback carries the SxxEyy tag.
|
||||||
|
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", "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 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");
|
||||||
|
let episode_id: i64 = 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");
|
||||||
|
|
||||||
|
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");
|
||||||
|
assert_eq!(releases[0]["parsed"]["episode"]["kind"], "episodes");
|
||||||
|
assert_eq!(releases[0]["parsed"]["episode"]["season"], 1);
|
||||||
|
assert_eq!(releases[0]["parsed"]["episode"]["episodes"][0], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_manual_search_names_exactly_one_title() {
|
||||||
|
let tmdb = MockServer::start().await;
|
||||||
|
let prowlarr = MockServer::start().await;
|
||||||
|
let (_dir, _state, base) = application(&tmdb, &prowlarr).await;
|
||||||
|
|
||||||
|
for query in ["", "movie_id=1&episode_id=1"] {
|
||||||
|
let response = reqwest::get(format!("{base}/api/releases?{query}"))
|
||||||
|
.await
|
||||||
|
.expect("releases");
|
||||||
|
assert_eq!(response.status(), 422, "query: {query}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn releases_without_sizes_skip_the_size_score() {
|
fn releases_without_sizes_skip_the_size_score() {
|
||||||
let policy = Policy {
|
let policy = Policy {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,8 @@ pub struct AppState {
|
|||||||
database: Option<Db>,
|
database: Option<Db>,
|
||||||
movie_commands: mpsc::Sender<MovieCommand>,
|
movie_commands: mpsc::Sender<MovieCommand>,
|
||||||
pending_movie_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MovieCommand>>>,
|
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>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Work explicitly requested through the movie API.
|
/// Work explicitly requested through the movie API.
|
||||||
@@ -79,6 +81,16 @@ pub enum MovieCommand {
|
|||||||
Grab { movie_id: i64, release_id: i64 },
|
Grab { movie_id: i64, release_id: i64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Work explicitly requested through the series API.
|
||||||
|
///
|
||||||
|
/// Intent lives at the leaf (`DESIGN.md` §4.1), so a manual action names an
|
||||||
|
/// episode even when the release that satisfies it is a season pack.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EpisodeCommand {
|
||||||
|
Search { episode_id: i64 },
|
||||||
|
Grab { episode_id: i64, release_id: i64 },
|
||||||
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
/// Build the state, including the shared HTTP client.
|
/// Build the state, including the shared HTTP client.
|
||||||
///
|
///
|
||||||
@@ -88,12 +100,15 @@ impl AppState {
|
|||||||
pub fn new(upstreams: Upstreams) -> Result<Self, reqwest::Error> {
|
pub fn new(upstreams: Upstreams) -> Result<Self, reqwest::Error> {
|
||||||
let http = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
|
let http = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
|
||||||
let (movie_commands, pending_movie_commands) = mpsc::channel(64);
|
let (movie_commands, pending_movie_commands) = mpsc::channel(64);
|
||||||
|
let (episode_commands, pending_episode_commands) = mpsc::channel(64);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
http,
|
http,
|
||||||
upstreams: Arc::new(upstreams),
|
upstreams: Arc::new(upstreams),
|
||||||
database: None,
|
database: None,
|
||||||
movie_commands,
|
movie_commands,
|
||||||
pending_movie_commands: Arc::new(tokio::sync::Mutex::new(pending_movie_commands)),
|
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)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +128,15 @@ impl AppState {
|
|||||||
self.pending_movie_commands.lock().await.recv().await
|
self.pending_movie_commands.lock().await.recv().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wait for the next manual episode action in the daemon's reconcile loop.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// If every sender has been dropped.
|
||||||
|
pub async fn next_episode_command(&self) -> Option<EpisodeCommand> {
|
||||||
|
self.pending_episode_commands.lock().await.recv().await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn http(&self) -> &reqwest::Client {
|
pub(crate) fn http(&self) -> &reqwest::Client {
|
||||||
&self.http
|
&self.http
|
||||||
}
|
}
|
||||||
@@ -131,4 +155,11 @@ impl AppState {
|
|||||||
) -> Result<(), mpsc::error::TrySendError<MovieCommand>> {
|
) -> Result<(), mpsc::error::TrySendError<MovieCommand>> {
|
||||||
self.movie_commands.try_send(command)
|
self.movie_commands.try_send(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn send_episode_command(
|
||||||
|
&self,
|
||||||
|
command: EpisodeCommand,
|
||||||
|
) -> Result<(), mpsc::error::TrySendError<EpisodeCommand>> {
|
||||||
|
self.episode_commands.try_send(command)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- The TV counterpart of movie_releases (0003). One release comes back for
|
||||||
|
-- more than one episode — a season pack matches every episode in the season —
|
||||||
|
-- so the release row stays unique by indexer and guid, and each match is a
|
||||||
|
-- row here.
|
||||||
|
CREATE TABLE episode_releases (
|
||||||
|
episode_id INTEGER NOT NULL REFERENCES episodes (id) ON DELETE CASCADE,
|
||||||
|
release_id INTEGER NOT NULL REFERENCES releases (id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (episode_id, release_id)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE INDEX episode_releases_release ON episode_releases (release_id);
|
||||||
@@ -7,7 +7,7 @@ use std::path::Path;
|
|||||||
|
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
|
|
||||||
pub use policy::{MoviePolicy, PolicyColumns, PolicyError};
|
pub use policy::{MoviePolicy, PolicyColumns, PolicyError, TitlePolicy};
|
||||||
|
|
||||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
|
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
|
||||||
use sqlx::{migrate::MigrateError, SqlitePool};
|
use sqlx::{migrate::MigrateError, SqlitePool};
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ pub enum PolicyError {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The effective policy for one movie, plus the root it is attached to.
|
/// The effective policy for one title, plus the root it is attached to.
|
||||||
///
|
///
|
||||||
/// The root's `kind` and `audience` are the Transmission label and the
|
/// The root's `kind` and `audience` are the Transmission label and the
|
||||||
/// on-disk layout (§7.1, §7.4), and they only exist together with the policy,
|
/// on-disk layout (§7.1, §7.4), and they only exist together with the policy,
|
||||||
/// so they are returned together.
|
/// so they are returned together.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct MoviePolicy {
|
pub struct TitlePolicy {
|
||||||
pub policy: Policy,
|
pub policy: Policy,
|
||||||
/// Per-title relaxations and tightenings of the root policy (§5.1).
|
/// Per-title relaxations and tightenings of the root policy (§5.1).
|
||||||
pub overrides: TitleOverrides,
|
pub overrides: TitleOverrides,
|
||||||
@@ -45,6 +45,9 @@ pub struct MoviePolicy {
|
|||||||
pub root_path: String,
|
pub root_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What [`Db::movie_policy`] returned before episodes needed the same shape.
|
||||||
|
pub type MoviePolicy = TitlePolicy;
|
||||||
|
|
||||||
/// The raw policy columns, as the `policies` table stores them (§5.5, §10).
|
/// The raw policy columns, as the `policies` table stores them (§5.5, §10).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PolicyColumns {
|
pub struct PolicyColumns {
|
||||||
@@ -139,7 +142,7 @@ impl Db {
|
|||||||
///
|
///
|
||||||
/// If the query fails, or a policy column does not hold the JSON its
|
/// If the query fails, or a policy column does not hold the JSON its
|
||||||
/// migration promises.
|
/// migration promises.
|
||||||
pub async fn movie_policy(&self, movie_id: i64) -> Result<Option<MoviePolicy>, PolicyError> {
|
pub async fn movie_policy(&self, movie_id: i64) -> Result<Option<TitlePolicy>, PolicyError> {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
SELECT m.overrides AS "overrides!: String",
|
SELECT m.overrides AS "overrides!: String",
|
||||||
@@ -184,7 +187,82 @@ impl Db {
|
|||||||
}
|
}
|
||||||
.to_policy()?;
|
.to_policy()?;
|
||||||
|
|
||||||
Ok(Some(MoviePolicy {
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The policy attached to an episode's series root, with that series'
|
||||||
|
/// overrides.
|
||||||
|
///
|
||||||
|
/// Overrides sit on the series (§5.1): an episode is a leaf carrying
|
||||||
|
/// intent, never its own policy.
|
||||||
|
///
|
||||||
|
/// `None` when the episode does not exist.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// If the query fails, or a policy column does not hold the JSON its
|
||||||
|
/// migration promises.
|
||||||
|
pub async fn episode_policy(
|
||||||
|
&self,
|
||||||
|
episode_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 episodes e
|
||||||
|
JOIN seasons se ON se.id = e.season_id
|
||||||
|
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 e.id = ?
|
||||||
|
"#,
|
||||||
|
episode_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,
|
policy,
|
||||||
overrides: TitleOverrides {
|
overrides: TitleOverrides {
|
||||||
only_4k: overrides.only_4k,
|
only_4k: overrides.only_4k,
|
||||||
|
|||||||
Reference in New Issue
Block a user