diff --git a/.sqlx/query-024a22e1d6974b40f4fdd86e4ad732ce6de6e7c92b93bc3c835cc48a296021fb.json b/.sqlx/query-024a22e1d6974b40f4fdd86e4ad732ce6de6e7c92b93bc3c835cc48a296021fb.json new file mode 100644 index 0000000..78c5238 --- /dev/null +++ b/.sqlx/query-024a22e1d6974b40f4fdd86e4ad732ce6de6e7c92b93bc3c835cc48a296021fb.json @@ -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" +} diff --git a/.sqlx/query-03f037d646959854cbd03404b34f887362dce66d09ac8db2df201dd1dc2199f9.json b/.sqlx/query-03f037d646959854cbd03404b34f887362dce66d09ac8db2df201dd1dc2199f9.json new file mode 100644 index 0000000..30ea34f --- /dev/null +++ b/.sqlx/query-03f037d646959854cbd03404b34f887362dce66d09ac8db2df201dd1dc2199f9.json @@ -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" +} diff --git a/.sqlx/query-043325f63ac8a7a9ad127e2230937311a11b8795db4057afc5fef736c5d89e76.json b/.sqlx/query-043325f63ac8a7a9ad127e2230937311a11b8795db4057afc5fef736c5d89e76.json new file mode 100644 index 0000000..bd2d5d9 --- /dev/null +++ b/.sqlx/query-043325f63ac8a7a9ad127e2230937311a11b8795db4057afc5fef736c5d89e76.json @@ -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" +} diff --git a/.sqlx/query-1578519edb5411dd191819f5af9f6510115149c13dae9c31cfb39ccd14e549f9.json b/.sqlx/query-1578519edb5411dd191819f5af9f6510115149c13dae9c31cfb39ccd14e549f9.json new file mode 100644 index 0000000..477aac0 --- /dev/null +++ b/.sqlx/query-1578519edb5411dd191819f5af9f6510115149c13dae9c31cfb39ccd14e549f9.json @@ -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" +} diff --git a/.sqlx/query-1a2db8b6fd48a7f3a32cd2ce33e4dbab9e46e99e004fd5a913e12ac6110071c2.json b/.sqlx/query-1a2db8b6fd48a7f3a32cd2ce33e4dbab9e46e99e004fd5a913e12ac6110071c2.json new file mode 100644 index 0000000..33dbc30 --- /dev/null +++ b/.sqlx/query-1a2db8b6fd48a7f3a32cd2ce33e4dbab9e46e99e004fd5a913e12ac6110071c2.json @@ -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" +} diff --git a/.sqlx/query-227701e40f5e500f328082537645f1a0ce0d9b7fe9940e573f50c87209ba10a5.json b/.sqlx/query-227701e40f5e500f328082537645f1a0ce0d9b7fe9940e573f50c87209ba10a5.json new file mode 100644 index 0000000..a9247cb --- /dev/null +++ b/.sqlx/query-227701e40f5e500f328082537645f1a0ce0d9b7fe9940e573f50c87209ba10a5.json @@ -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" +} diff --git a/.sqlx/query-3b3165092d403797f233f7a926daab43aea1c78c508790a2cca230ce0cc73b26.json b/.sqlx/query-3b3165092d403797f233f7a926daab43aea1c78c508790a2cca230ce0cc73b26.json new file mode 100644 index 0000000..7d131e4 --- /dev/null +++ b/.sqlx/query-3b3165092d403797f233f7a926daab43aea1c78c508790a2cca230ce0cc73b26.json @@ -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" +} diff --git a/.sqlx/query-5a4efdf9a0c027a675106f4e93b5f6631b09e5a5f4a0f0a079c4b2f309d7d8db.json b/.sqlx/query-5a4efdf9a0c027a675106f4e93b5f6631b09e5a5f4a0f0a079c4b2f309d7d8db.json new file mode 100644 index 0000000..96867c2 --- /dev/null +++ b/.sqlx/query-5a4efdf9a0c027a675106f4e93b5f6631b09e5a5f4a0f0a079c4b2f309d7d8db.json @@ -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" +} diff --git a/.sqlx/query-5c7868e1c9ac87e3506fdad2ac0603b612cd4de4adfea980b2b06c093786abd3.json b/.sqlx/query-5c7868e1c9ac87e3506fdad2ac0603b612cd4de4adfea980b2b06c093786abd3.json new file mode 100644 index 0000000..3c54652 --- /dev/null +++ b/.sqlx/query-5c7868e1c9ac87e3506fdad2ac0603b612cd4de4adfea980b2b06c093786abd3.json @@ -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" +} diff --git a/.sqlx/query-607707d65c2578a1ee7c9bea0bb53e520e05340d78677e6378c9390006456e94.json b/.sqlx/query-607707d65c2578a1ee7c9bea0bb53e520e05340d78677e6378c9390006456e94.json new file mode 100644 index 0000000..d4f63b9 --- /dev/null +++ b/.sqlx/query-607707d65c2578a1ee7c9bea0bb53e520e05340d78677e6378c9390006456e94.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM series WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "607707d65c2578a1ee7c9bea0bb53e520e05340d78677e6378c9390006456e94" +} diff --git a/.sqlx/query-68f44b694f26c51f54b8ebb12c3341bf16a61029fb82d621930c137b2895b381.json b/.sqlx/query-68f44b694f26c51f54b8ebb12c3341bf16a61029fb82d621930c137b2895b381.json new file mode 100644 index 0000000..09cbcd8 --- /dev/null +++ b/.sqlx/query-68f44b694f26c51f54b8ebb12c3341bf16a61029fb82d621930c137b2895b381.json @@ -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" +} diff --git a/.sqlx/query-6a164386b22fa587458007a843d0bfe7030ef0665dd989c1018dd83e2ec330cd.json b/.sqlx/query-6a164386b22fa587458007a843d0bfe7030ef0665dd989c1018dd83e2ec330cd.json new file mode 100644 index 0000000..9bf668d --- /dev/null +++ b/.sqlx/query-6a164386b22fa587458007a843d0bfe7030ef0665dd989c1018dd83e2ec330cd.json @@ -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" +} diff --git a/.sqlx/query-78f3a0d5c2d1089cd88fa1f4eeee0883c1f7d90f1c345f09af63c4b349d46281.json b/.sqlx/query-78f3a0d5c2d1089cd88fa1f4eeee0883c1f7d90f1c345f09af63c4b349d46281.json new file mode 100644 index 0000000..d1babf5 --- /dev/null +++ b/.sqlx/query-78f3a0d5c2d1089cd88fa1f4eeee0883c1f7d90f1c345f09af63c4b349d46281.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO seasons (series_id, number, tracked) VALUES (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "78f3a0d5c2d1089cd88fa1f4eeee0883c1f7d90f1c345f09af63c4b349d46281" +} diff --git a/.sqlx/query-7fc4e02be63e223a60b1bf7d78f68aad4106abc0e17f7da6b98efd818ecdc372.json b/.sqlx/query-7fc4e02be63e223a60b1bf7d78f68aad4106abc0e17f7da6b98efd818ecdc372.json new file mode 100644 index 0000000..cc1bb05 --- /dev/null +++ b/.sqlx/query-7fc4e02be63e223a60b1bf7d78f68aad4106abc0e17f7da6b98efd818ecdc372.json @@ -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" +} diff --git a/.sqlx/query-8ff544fa49afd3fa76952995b2b0767a8603335eaed198ebd4648d9a38b5984f.json b/.sqlx/query-8ff544fa49afd3fa76952995b2b0767a8603335eaed198ebd4648d9a38b5984f.json new file mode 100644 index 0000000..e9d2d84 --- /dev/null +++ b/.sqlx/query-8ff544fa49afd3fa76952995b2b0767a8603335eaed198ebd4648d9a38b5984f.json @@ -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" +} diff --git a/.sqlx/query-950ae08a8a33d4ce3b088ae8ebac734baefdc2e6da90ac144d2dc0466e8cbe26.json b/.sqlx/query-950ae08a8a33d4ce3b088ae8ebac734baefdc2e6da90ac144d2dc0466e8cbe26.json new file mode 100644 index 0000000..72c9654 --- /dev/null +++ b/.sqlx/query-950ae08a8a33d4ce3b088ae8ebac734baefdc2e6da90ac144d2dc0466e8cbe26.json @@ -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" +} diff --git a/.sqlx/query-9a6924f163d5e2b61e77eb80b199008b387a96c251606cfe196a78075c119f8e.json b/.sqlx/query-9a6924f163d5e2b61e77eb80b199008b387a96c251606cfe196a78075c119f8e.json new file mode 100644 index 0000000..a518075 --- /dev/null +++ b/.sqlx/query-9a6924f163d5e2b61e77eb80b199008b387a96c251606cfe196a78075c119f8e.json @@ -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" +} diff --git a/.sqlx/query-ae6d0463fd1bf373cbc9e0b5bbfc26e7934791024c216f876f940937dde45882.json b/.sqlx/query-ae6d0463fd1bf373cbc9e0b5bbfc26e7934791024c216f876f940937dde45882.json new file mode 100644 index 0000000..00a8bfb --- /dev/null +++ b/.sqlx/query-ae6d0463fd1bf373cbc9e0b5bbfc26e7934791024c216f876f940937dde45882.json @@ -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" +} diff --git a/.sqlx/query-b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30.json b/.sqlx/query-b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30.json new file mode 100644 index 0000000..b776aee --- /dev/null +++ b/.sqlx/query-b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30.json @@ -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" +} diff --git a/.sqlx/query-b83fbeb18e706c9e8fca723035a1fa3237bd1b8cf1f82b28f9927e231ac97c32.json b/.sqlx/query-b83fbeb18e706c9e8fca723035a1fa3237bd1b8cf1f82b28f9927e231ac97c32.json new file mode 100644 index 0000000..388c789 --- /dev/null +++ b/.sqlx/query-b83fbeb18e706c9e8fca723035a1fa3237bd1b8cf1f82b28f9927e231ac97c32.json @@ -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" +} diff --git a/.sqlx/query-d64c6c62822119a62f77dcd297ad9a96d59840bde46fcf10dcf8870f09ac7954.json b/.sqlx/query-d64c6c62822119a62f77dcd297ad9a96d59840bde46fcf10dcf8870f09ac7954.json new file mode 100644 index 0000000..3932213 --- /dev/null +++ b/.sqlx/query-d64c6c62822119a62f77dcd297ad9a96d59840bde46fcf10dcf8870f09ac7954.json @@ -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" +} diff --git a/.sqlx/query-d8b209898cd22a4d322fe80bb8101f27bb69dbd76b748f519ecfd70fed4f44b8.json b/.sqlx/query-d8b209898cd22a4d322fe80bb8101f27bb69dbd76b748f519ecfd70fed4f44b8.json new file mode 100644 index 0000000..6a8b3cb --- /dev/null +++ b/.sqlx/query-d8b209898cd22a4d322fe80bb8101f27bb69dbd76b748f519ecfd70fed4f44b8.json @@ -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" +} diff --git a/.sqlx/query-e69149d2ede23a1fd668de5163aa85192473cbfc4e68656a11c58d66184fdcd0.json b/.sqlx/query-e69149d2ede23a1fd668de5163aa85192473cbfc4e68656a11c58d66184fdcd0.json new file mode 100644 index 0000000..ad53459 --- /dev/null +++ b/.sqlx/query-e69149d2ede23a1fd668de5163aa85192473cbfc4e68656a11c58d66184fdcd0.json @@ -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" +} diff --git a/.sqlx/query-ecbf65c10aa20bc04c841981e59919485518877b49e9702fc92512f39cc2a72e.json b/.sqlx/query-ecbf65c10aa20bc04c841981e59919485518877b49e9702fc92512f39cc2a72e.json new file mode 100644 index 0000000..f7e58b3 --- /dev/null +++ b/.sqlx/query-ecbf65c10aa20bc04c841981e59919485518877b49e9702fc92512f39cc2a72e.json @@ -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" +} diff --git a/.sqlx/query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json b/.sqlx/query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json new file mode 100644 index 0000000..4e5f6a1 --- /dev/null +++ b/.sqlx/query-f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba.json @@ -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" +} diff --git a/.sqlx/query-f547ac991490ac5af5c16e707a2b52e83207a194e496dcaecfa253cdc0757463.json b/.sqlx/query-f547ac991490ac5af5c16e707a2b52e83207a194e496dcaecfa253cdc0757463.json new file mode 100644 index 0000000..9c387f1 --- /dev/null +++ b/.sqlx/query-f547ac991490ac5af5c16e707a2b52e83207a194e496dcaecfa253cdc0757463.json @@ -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" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index f565f46..3606b27 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -11,6 +11,7 @@ mod movies; mod owners; mod roots; mod search; +mod series; mod state; 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 roots::Root; 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 /// 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( (name = "system", description = "Service health and metadata"), (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 = "search", description = "Unified title and release search"), (name = "roots", description = "Root folders and their policies") @@ -65,6 +71,16 @@ fn api_router() -> OpenApiRouter { .routes(routes!(movies::attention)) .routes(routes!(movies::list_owners)) .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::get, owners::update, owners::delete)) .routes(routes!(search::search)) diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 3a1fad2..7a87365 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -85,6 +85,9 @@ pub struct ErrorBody { #[derive(Debug)] pub enum ApiError { NotFound, + SeriesNotFound, + SeasonNotFound, + EpisodeNotFound, OwnerNotFound, Conflict(String), Invalid(String), @@ -96,6 +99,9 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, error) = match self { Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string()), + Self::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::Conflict(error) => (StatusCode::CONFLICT, error), Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error), @@ -104,7 +110,7 @@ impl IntoResponse for ApiError { "database unavailable".into(), ), Self::Database(error) => { - tracing::error!(%error, "movie API database error"); + tracing::error!(%error, "API database error"); (StatusCode::INTERNAL_SERVER_ERROR, "database error".into()) } }; diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 2d6154b..a2b0cab 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -4,7 +4,7 @@ use arr_core::policy::{evaluate, Candidate}; use arr_core::score::score; use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict}; 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::Json; use chrono::{DateTime, Utc}; @@ -19,9 +19,14 @@ pub struct SearchQuery { 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)] pub struct ReleasesQuery { - movie_id: i64, + movie_id: Option, + episode_id: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] @@ -203,6 +208,7 @@ async fn search_tmdb( responses( (status = 200, body = [ClassifiedRelease]), (status = 404, body = ErrorBody), + (status = 422, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) @@ -211,16 +217,33 @@ pub async fn releases( State(state): State, Query(query): Query, ) -> Result>, 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, ApiError> { 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)?; let loaded = database - .movie_policy(query.movie_id) + .movie_policy(movie_id) .await .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::NotFound)?; - let tmdb = tmdb_client(&state)? + let tmdb = tmdb_client(state)? .movie( u32::try_from(movie.tmdb_id) .map_err(|_| ApiError::Invalid("movie has invalid TMDB id".into()))?, @@ -233,13 +256,7 @@ pub async fn releases( }, |imdb_id| SearchRequest::Movie { imdb_id }, ); - let upstreams = state.upstreams(); - 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 prowlarr = prowlarr_client(state)?; let indexers = prowlarr .indexers() .await @@ -282,8 +299,80 @@ pub async fn releases( } } } - classified.sort_by_key(|release| (bucket(&release.verdict), -release.score)); - Ok(Json(classified)) + Ok(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, 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 for TmdbMovie { @@ -329,6 +418,15 @@ fn escape_like(input: &str) -> String { .replace('_', "\\_") } +fn prowlarr_client(state: &AppState) -> Result { + 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 { let upstreams = state.upstreams(); let key = upstreams @@ -590,6 +688,75 @@ mod tests { 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("")) + .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"Bluey.S01E02.1080p.WEB-DLephttps://tracker/ep1500000000")) + .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 = 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] fn releases_without_sizes_skip_the_size_score() { let policy = Policy { diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs new file mode 100644 index 0000000..d0eadb8 --- /dev/null +++ b/crates/arr-api/src/series.rs @@ -0,0 +1,1422 @@ +//! The TV side of the library API. See DESIGN.md §4, §4.1 and §4.2. +//! +//! Nothing here is generic over media kind (§11): movies are concrete in +//! `movies.rs` and series are concrete here. What the two do share is the +//! policy loader, the release table and the owner tags, which already exist. +//! +//! Two rules shape the endpoints: +//! +//! - **Intent lives at the leaf** (§4.1). `wanted` is set on an episode. +//! `auto_track` on the series and `tracked` on the season are rules that +//! decide what happens to episodes a metadata refresh reveals; neither is +//! intent, and neither is read to answer "is this wanted". +//! - **Status is derived, never stored** (§4.2). Every series the API returns +//! carries a status computed from its episodes at request time. + +use std::collections::HashMap; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use arr_core::tracking::{apply_auto_track, RefreshedSeason}; +use arr_core::{ + derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId, + SeriesStatus, TitleOverrides, +}; +use arr_db::policy::language; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::Json; +use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::movies::{pool, Accepted, ApiError, ErrorBody, Release}; +use crate::owners::Owner; +use crate::state::{AppState, EpisodeCommand}; + +/// A series with the status derived from its episodes (§4.2). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct Series { + pub id: i64, + pub tmdb_id: i64, + pub title: String, + pub year: Option, + pub original_language: Option, + pub root_id: i64, + /// §4.1. A rule about seasons metadata reveals, not intent. + pub auto_track: bool, + pub overrides: serde_json::Value, + /// Whether the show finished upstream, which `ended` is derived from. + pub upstream_ended: bool, + pub blocked: bool, + /// `airing`, `incomplete`, `waiting`, `complete` or `ended` (§4.2). + pub status: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateSeries { + pub tmdb_id: i64, + pub title: String, + pub year: Option, + pub original_language: Option, + pub root_id: i64, + #[serde(default)] + pub auto_track: bool, + #[serde(default)] + pub blocked: bool, + #[serde(default)] + pub upstream_ended: bool, + #[serde(default = "empty_overrides")] + pub overrides: serde_json::Value, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateSeries { + pub title: Option, + pub year: Option>, + pub original_language: Option>, + pub root_id: Option, + pub auto_track: Option, + pub blocked: Option, + pub upstream_ended: Option, + pub overrides: Option, +} + +/// A season and every episode in it, which is how the UI reads it. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct Season { + pub id: i64, + pub series_id: i64, + pub number: i64, + /// §4.1. Whether new episodes of this season arrive wanted. + pub tracked: bool, + pub episodes: Vec, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct Episode { + pub id: i64, + pub season_id: i64, + pub number: i64, + pub title: String, + pub air_date: Option, + /// §4.1. The only intent in the TV aggregate. + pub wanted: bool, + pub state: String, + pub search_attempts: i64, + pub last_searched_at: Option, +} + +/// A season revealed by metadata, with the episodes it holds. +/// +/// The series' `auto_track` decides whether those episodes arrive wanted +/// (§4.1); the request never says so directly. +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateSeason { + pub number: i64, + #[serde(default)] + pub episodes: Vec, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateEpisode { + pub number: i64, + pub title: String, + /// ISO-8601 date, as TMDB gives it. + pub air_date: Option, +} + +/// Both fields of a season a person can set by hand. +/// +/// `tracked` changes the rule for episodes not yet revealed. `wanted` is the +/// one-click "grab this season", and writes intent onto every episode already +/// in it — the two are deliberately separate (§4.1). +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateSeason { + pub tracked: Option, + pub wanted: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateEpisode { + pub wanted: Option, +} + +fn empty_overrides() -> serde_json::Value { + serde_json::json!({}) +} + +fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> { + let Some(object) = value.as_object() else { + return Err(ApiError::Invalid("overrides must be an object".into())); + }; + if object + .keys() + .any(|key| key != "only_4k" && key != "allow_english_audio") + { + return Err(ApiError::Invalid( + "overrides supports only only_4k and allow_english_audio".into(), + )); + } + if object.values().any(|value| !value.is_boolean()) { + return Err(ApiError::Invalid("override values must be booleans".into())); + } + Ok(()) +} + +/// The stored columns, before the derived status is attached. +struct SeriesRow { + id: i64, + tmdb_id: i64, + title: String, + year: Option, + original_language: Option, + root_id: i64, + auto_track: bool, + overrides: serde_json::Value, + upstream_ended: bool, + blocked: bool, +} + +struct EpisodeRow { + series_id: i64, + id: i64, + season_id: i64, + number: i64, + title: String, + air_date: Option, + wanted: bool, + state: String, + search_attempts: i64, + last_searched_at: Option, +} + +fn media_state(value: &str) -> MediaState { + match value { + "downloading" => MediaState::Downloading, + "available" => MediaState::Available, + _ => MediaState::Missing, + } +} + +/// An `air_date` as TMDB writes it, or as a full timestamp if one ever +/// arrives that way. Anything else is treated as unknown, which §4.2 already +/// has a meaning for: it cannot pull a series into `airing`. +fn air_date(value: Option<&str>) -> Option { + let value = value?; + let timestamp = if let Ok(date) = value.parse::() { + date.and_time(NaiveTime::MIN).and_utc().timestamp() + } else { + value.parse::>().ok()?.timestamp() + }; + let seconds = u64::try_from(timestamp.abs()).ok()?; + if timestamp < 0 { + UNIX_EPOCH.checked_sub(Duration::from_secs(seconds)) + } else { + UNIX_EPOCH.checked_add(Duration::from_secs(seconds)) + } +} + +fn core_series(row: &SeriesRow) -> arr_core::Series { + arr_core::Series { + id: SeriesId(row.id), + tmdb_id: u64::try_from(row.tmdb_id).unwrap_or_default(), + title: row.title.clone(), + year: row + .year + .and_then(|year| u16::try_from(year).ok()) + .unwrap_or_default(), + original_language: row + .original_language + .as_deref() + .map_or(Language::Other(String::new()), language), + root_id: RootId(row.root_id), + auto_track: row.auto_track, + overrides: TitleOverrides::default(), + upstream_ended: row.upstream_ended, + blocked: row.blocked, + } +} + +fn core_episode(row: &EpisodeRow) -> arr_core::Episode { + arr_core::Episode { + id: EpisodeId(row.id), + season_id: SeasonId(row.season_id), + number: u16::try_from(row.number).unwrap_or_default(), + title: row.title.clone(), + air_date: air_date(row.air_date.as_deref()), + wanted: row.wanted, + state: media_state(&row.state), + search_attempts: u32::try_from(row.search_attempts).unwrap_or_default(), + last_searched_at: None, + } +} + +fn status_name(status: SeriesStatus) -> &'static str { + match status { + SeriesStatus::Airing => "airing", + SeriesStatus::Incomplete => "incomplete", + SeriesStatus::Waiting => "waiting", + SeriesStatus::Complete => "complete", + SeriesStatus::Ended => "ended", + } +} + +fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime) -> Series { + Series { + id: row.id, + tmdb_id: row.tmdb_id, + title: row.title.clone(), + year: row.year, + original_language: row.original_language.clone(), + root_id: row.root_id, + auto_track: row.auto_track, + overrides: row.overrides.clone(), + upstream_ended: row.upstream_ended, + blocked: row.blocked, + status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(), + } +} + +/// Every episode in the library, keyed by the series it belongs to. +/// +/// One query rather than one per series: the whole table is a few thousand +/// rows for a single household (§10), and the status of every listed series +/// needs all of them anyway. +async fn episodes_by_series( + state: &AppState, +) -> Result>, ApiError> { + let rows = sqlx::query_as!( + EpisodeRow, + r#"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 + FROM episodes e JOIN seasons se ON se.id = e.season_id"# + ) + .fetch_all(pool(state)?) + .await?; + + let mut grouped: HashMap> = HashMap::new(); + for row in &rows { + grouped + .entry(row.series_id) + .or_default() + .push(core_episode(row)); + } + Ok(grouped) +} + +async fn load_series_row(state: &AppState, id: i64) -> Result { + sqlx::query_as!(SeriesRow, 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", 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 = ?"#, id) + .fetch_optional(pool(state)?) + .await? + .ok_or(ApiError::SeriesNotFound) +} + +async fn load_series(state: &AppState, id: i64) -> Result { + let row = load_series_row(state, id).await?; + let episodes = sqlx::query_as!( + EpisodeRow, + r#"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 + FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?"#, + id + ) + .fetch_all(pool(state)?) + .await?; + let episodes: Vec<_> = episodes.iter().map(core_episode).collect(); + Ok(with_status(&row, &episodes, SystemTime::now())) +} + +async fn require_tv_root(state: &AppState, root_id: i64) -> Result<(), ApiError> { + let tv_root = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM roots WHERE id = ? AND kind = 'tv') AS 'exists!: bool'", + root_id + ) + .fetch_one(pool(state)?) + .await?; + if tv_root { + Ok(()) + } else { + Err(ApiError::Invalid("root_id must name a TV root".into())) + } +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct ListSeriesQuery { + /// Restrict to series tagged with this owner (DESIGN.md §4.3). + pub owner_id: Option, +} + +#[utoipa::path( + get, path = "/api/series", tag = "series", + params(ListSeriesQuery), + responses( + (status = 200, body = [Series]), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn list( + State(state): State, + Query(query): Query, +) -> Result>, ApiError> { + let rows = if let Some(owner_id) = query.owner_id { + sqlx::query_as!(SeriesRow, 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.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"#, owner_id) + .fetch_all(pool(&state)?) + .await? + } else { + sqlx::query_as!(SeriesRow, 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", 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"#) + .fetch_all(pool(&state)?) + .await? + }; + + let episodes = episodes_by_series(&state).await?; + let now = SystemTime::now(); + let empty = Vec::new(); + Ok(Json( + rows.iter() + .map(|row| with_status(row, episodes.get(&row.id).unwrap_or(&empty), now)) + .collect(), + )) +} + +#[utoipa::path( + post, path = "/api/series", tag = "series", request_body = CreateSeries, + responses( + (status = 201, body = Series), + (status = 409, body = ErrorBody), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn create( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), ApiError> { + if input.title.trim().is_empty() || input.tmdb_id <= 0 { + return Err(ApiError::Invalid("tmdb_id and title are required".into())); + } + validate_overrides(&input.overrides)?; + require_tv_root(&state, input.root_id).await?; + let overrides = serde_json::to_string(&input.overrides) + .map_err(|error| ApiError::Invalid(error.to_string()))?; + let title = input.title.trim(); + let result = sqlx::query!( + "INSERT INTO series (tmdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + input.tmdb_id, title, input.year, input.original_language, input.root_id, + input.auto_track, input.upstream_ended, input.blocked, overrides + ) + .execute(pool(&state)?) + .await?; + Ok(( + StatusCode::CREATED, + Json(load_series(&state, result.last_insert_rowid()).await?), + )) +} + +#[utoipa::path( + get, path = "/api/series/{series_id}", tag = "series", + params(("series_id" = i64, Path, description = "Series row id")), + responses( + (status = 200, body = Series), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn get( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + Ok(Json(load_series(&state, id).await?)) +} + +#[utoipa::path( + patch, path = "/api/series/{series_id}", tag = "series", request_body = UpdateSeries, + params(("series_id" = i64, Path, description = "Series row id")), + responses( + (status = 200, body = Series), + (status = 404, body = ErrorBody), + (status = 409, body = ErrorBody), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn update( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, ApiError> { + let current = load_series_row(&state, id).await?; + let title = input.title.unwrap_or(current.title); + if title.trim().is_empty() { + return Err(ApiError::Invalid("title cannot be empty".into())); + } + let overrides = input.overrides.unwrap_or(current.overrides); + validate_overrides(&overrides)?; + let overrides = + serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?; + let title = title.trim(); + let year = input.year.unwrap_or(current.year); + let original_language = input.original_language.unwrap_or(current.original_language); + let root_id = input.root_id.unwrap_or(current.root_id); + if input.root_id.is_some() { + require_tv_root(&state, root_id).await?; + } + let auto_track = input.auto_track.unwrap_or(current.auto_track); + let upstream_ended = input.upstream_ended.unwrap_or(current.upstream_ended); + let blocked = input.blocked.unwrap_or(current.blocked); + sqlx::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 = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id) + .execute(pool(&state)?) + .await?; + Ok(Json(load_series(&state, id).await?)) +} + +#[utoipa::path( + delete, path = "/api/series/{series_id}", tag = "series", + params(("series_id" = i64, Path, description = "Series row id")), + responses( + (status = 204), + (status = 404, body = ErrorBody), + (status = 409, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn delete( + State(state): State, + Path(id): Path, +) -> Result { + let result = sqlx::query!("DELETE FROM series WHERE id = ?", id) + .execute(pool(&state)?) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::SeriesNotFound); + } + Ok(StatusCode::NO_CONTENT) +} + +async fn load_seasons(state: &AppState, series_id: i64) -> Result, ApiError> { + let seasons = sqlx::query!( + r#"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"#, + series_id + ) + .fetch_all(pool(state)?) + .await?; + let episodes = sqlx::query_as!( + EpisodeRow, + r#"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 + FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number, e.number"#, + series_id + ) + .fetch_all(pool(state)?) + .await?; + + Ok(seasons + .into_iter() + .map(|season| Season { + id: season.id, + series_id: season.series_id, + number: season.number, + tracked: season.tracked, + episodes: episodes + .iter() + .filter(|episode| episode.season_id == season.id) + .map(|episode| Episode { + id: episode.id, + season_id: episode.season_id, + number: episode.number, + title: episode.title.clone(), + air_date: episode.air_date.clone(), + wanted: episode.wanted, + state: episode.state.clone(), + search_attempts: episode.search_attempts, + last_searched_at: episode.last_searched_at.clone(), + }) + .collect(), + }) + .collect()) +} + +#[utoipa::path( + get, path = "/api/series/{series_id}/seasons", tag = "series", + params(("series_id" = i64, Path, description = "Series row id")), + responses( + (status = 200, body = [Season]), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn seasons( + State(state): State, + Path(series_id): Path, +) -> Result>, ApiError> { + load_series_row(&state, series_id).await?; + Ok(Json(load_seasons(&state, series_id).await?)) +} + +/// Records a season metadata has revealed, applying the series' tracking rule. +#[utoipa::path( + post, path = "/api/series/{series_id}/seasons", tag = "series", + request_body = CreateSeason, + params(("series_id" = i64, Path, description = "Series row id")), + responses( + (status = 201, body = Season), + (status = 404, body = ErrorBody), + (status = 409, body = ErrorBody), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn create_season( + State(state): State, + Path(series_id): Path, + Json(input): Json, +) -> Result<(StatusCode, Json), ApiError> { + let series = load_series_row(&state, series_id).await?; + if input.number < 0 { + return Err(ApiError::Invalid("season number cannot be negative".into())); + } + if input.episodes.iter().any(|episode| episode.number < 0) { + return Err(ApiError::Invalid( + "episode number cannot be negative".into(), + )); + } + let mut numbers: Vec = input + .episodes + .iter() + .map(|episode| episode.number) + .collect(); + numbers.sort_unstable(); + if numbers.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(ApiError::Invalid( + "episode numbers must be unique within the season".into(), + )); + } + + // §4.1. The request does not say whether the episodes are wanted; the + // series' auto_track rule does, through the one function that owns it. + let mut revealed = [RefreshedSeason { + season: arr_core::Season { + id: SeasonId(0), + series_id: SeriesId(series_id), + number: u16::try_from(input.number).unwrap_or_default(), + tracked: false, + }, + episodes: input + .episodes + .iter() + .map(|episode| arr_core::Episode { + id: EpisodeId(0), + season_id: SeasonId(0), + number: u16::try_from(episode.number).unwrap_or_default(), + title: episode.title.clone(), + air_date: air_date(episode.air_date.as_deref()), + wanted: false, + state: MediaState::Missing, + search_attempts: 0, + last_searched_at: None, + }) + .collect(), + is_new: true, + }]; + apply_auto_track(&core_series(&series), &mut revealed); + let [revealed] = revealed; + + // One transaction: a rejected episode must not leave the season behind, + // or the retry that fixes the request collides with it instead. + let mut transaction = pool(&state)?.begin().await?; + let season_id = sqlx::query!( + "INSERT INTO seasons (series_id, number, tracked) VALUES (?, ?, ?)", + series_id, + input.number, + revealed.season.tracked + ) + .execute(&mut *transaction) + .await? + .last_insert_rowid(); + + for (episode, source) in revealed.episodes.iter().zip(&input.episodes) { + sqlx::query!( + "INSERT INTO episodes (season_id, number, title, air_date, wanted) VALUES (?, ?, ?, ?, ?)", + season_id, + source.number, + source.title, + source.air_date, + episode.wanted + ) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + + let seasons = load_seasons(&state, series_id).await?; + let season = seasons + .into_iter() + .find(|season| season.id == season_id) + .ok_or(ApiError::SeasonNotFound)?; + Ok((StatusCode::CREATED, Json(season))) +} + +#[utoipa::path( + patch, path = "/api/series/{series_id}/seasons/{season_number}", tag = "series", + request_body = UpdateSeason, + params( + ("series_id" = i64, Path, description = "Series row id"), + ("season_number" = i64, Path, description = "Season number, not its row id") + ), + responses( + (status = 200, body = Season), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn update_season( + State(state): State, + Path((series_id, number)): Path<(i64, i64)>, + Json(input): Json, +) -> Result, ApiError> { + load_series_row(&state, series_id).await?; + let season_id = sqlx::query_scalar!( + r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#, + series_id, + number + ) + .fetch_optional(pool(&state)?) + .await? + .ok_or(ApiError::SeasonNotFound)?; + + if let Some(tracked) = input.tracked { + sqlx::query!( + "UPDATE seasons SET tracked = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + tracked, + season_id + ) + .execute(pool(&state)?) + .await?; + } + // §4.1. Marking a season wanted is intent written onto its episodes, so + // an untracked series with one wanted season needs no special case. + if let Some(wanted) = input.wanted { + sqlx::query!( + "UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE season_id = ?", + wanted, + season_id + ) + .execute(pool(&state)?) + .await?; + } + + let seasons = load_seasons(&state, series_id).await?; + seasons + .into_iter() + .find(|season| season.id == season_id) + .map(Json) + .ok_or(ApiError::SeasonNotFound) +} + +async fn load_episode(state: &AppState, id: i64) -> Result { + let row = sqlx::query_as!( + EpisodeRow, + r#"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 + FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?"#, + id + ) + .fetch_optional(pool(state)?) + .await? + .ok_or(ApiError::EpisodeNotFound)?; + Ok(Episode { + id: row.id, + season_id: row.season_id, + number: row.number, + title: row.title, + air_date: row.air_date, + wanted: row.wanted, + state: row.state, + search_attempts: row.search_attempts, + last_searched_at: row.last_searched_at, + }) +} + +#[utoipa::path( + get, path = "/api/episodes/{episode_id}", tag = "series", + params(("episode_id" = i64, Path, description = "Episode row id")), + responses( + (status = 200, body = Episode), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn get_episode( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + Ok(Json(load_episode(&state, id).await?)) +} + +/// Sets the only intent the TV aggregate carries (§4.1). +#[utoipa::path( + patch, path = "/api/episodes/{episode_id}", tag = "series", + request_body = UpdateEpisode, + params(("episode_id" = i64, Path, description = "Episode row id")), + responses( + (status = 200, body = Episode), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn update_episode( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, ApiError> { + load_episode(&state, id).await?; + if let Some(wanted) = input.wanted { + sqlx::query!( + "UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + wanted, + id + ) + .execute(pool(&state)?) + .await?; + } + Ok(Json(load_episode(&state, id).await?)) +} + +#[utoipa::path( + post, path = "/api/episodes/{episode_id}/search", tag = "series", + params(("episode_id" = i64, Path, description = "Episode row id")), + responses( + (status = 202, body = Accepted), + (status = 404, body = ErrorBody), + (status = 409, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn search_episode( + State(state): State, + Path(id): Path, +) -> Result<(StatusCode, Json), ApiError> { + load_episode(&state, id).await?; + // §6.3. `blocked` stops targeted search for the whole series. + let blocked = sqlx::query_scalar!( + r#"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 = ?"#, + id + ) + .fetch_one(pool(&state)?) + .await?; + if blocked { + return Err(ApiError::Conflict("series is blocked".into())); + } + state + .send_episode_command(EpisodeCommand::Search { episode_id: id }) + .map_err(|_| ApiError::Unavailable)?; + Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) +} + +#[utoipa::path( + get, path = "/api/episodes/{episode_id}/releases", tag = "series", + params(("episode_id" = i64, Path, description = "Episode row id")), + responses( + (status = 200, body = [Release]), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn episode_releases( + State(state): State, + Path(id): Path, +) -> Result>, ApiError> { + load_episode(&state, id).await?; + let releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN 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"#, id) + .fetch_all(pool(&state)?) + .await?; + Ok(Json(releases)) +} + +#[utoipa::path( + post, path = "/api/episodes/{episode_id}/releases/{release_id}/grab", tag = "series", + params(("episode_id" = i64, Path), ("release_id" = i64, Path)), + responses( + (status = 202, body = Accepted), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn grab_episode( + State(state): State, + Path((episode_id, release_id)): Path<(i64, i64)>, +) -> Result<(StatusCode, Json), ApiError> { + let exists = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM episode_releases WHERE episode_id = ? AND release_id = ?) AS 'exists!: bool'", episode_id, release_id) + .fetch_one(pool(&state)?) + .await?; + if !exists { + return Err(ApiError::EpisodeNotFound); + } + state + .send_episode_command(EpisodeCommand::Grab { + episode_id, + release_id, + }) + .map_err(|_| ApiError::Unavailable)?; + Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) +} + +#[utoipa::path( + get, path = "/api/series/{series_id}/owners", tag = "series", + params(("series_id" = i64, Path, description = "Series row id")), + responses( + (status = 200, body = [Owner]), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn list_owners( + State(state): State, + Path(id): Path, +) -> Result>, ApiError> { + load_series_row(&state, id).await?; + let owners = sqlx::query_as!( + Owner, + r#"SELECT o.id AS "id!: i64", o.name AS "name!: String", o.ntfy_topic AS "ntfy_topic!: String" + FROM owners o + JOIN title_owners t ON t.owner_id = o.id + WHERE t.title_kind = 'series' AND t.title_id = ? + ORDER BY o.name"#, + id + ) + .fetch_all(pool(&state)?) + .await?; + Ok(Json(owners)) +} + +#[utoipa::path( + put, path = "/api/series/{series_id}/owners/{owner_id}", tag = "series", + params(("series_id" = i64, Path), ("owner_id" = i64, Path)), + responses( + (status = 204), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn tag_owner( + State(state): State, + Path((series_id, owner_id)): Path<(i64, i64)>, +) -> Result { + load_series_row(&state, series_id).await?; + let owner_exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM owners WHERE id = ?) AS 'exists!: bool'", + owner_id + ) + .fetch_one(pool(&state)?) + .await?; + if !owner_exists { + return Err(ApiError::OwnerNotFound); + } + sqlx::query!( + "INSERT INTO title_owners (title_kind, title_id, owner_id) VALUES ('series', ?, ?) ON CONFLICT DO NOTHING", + series_id, owner_id + ) + .execute(pool(&state)?) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + delete, path = "/api/series/{series_id}/owners/{owner_id}", tag = "series", + params(("series_id" = i64, Path), ("owner_id" = i64, Path)), + responses( + (status = 204), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn untag_owner( + State(state): State, + Path((series_id, owner_id)): Path<(i64, i64)>, +) -> Result { + load_series_row(&state, series_id).await?; + sqlx::query!( + "DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ? AND owner_id = ?", + series_id, + owner_id + ) + .execute(pool(&state)?) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{router, Upstreams}; + + async fn application() -> (tempfile::TempDir, AppState, String) { + let dir = tempfile::tempdir().expect("tempdir"); + let database = arr_db::Db::connect(dir.path().join("arr.db")) + .await + .expect("connect database"); + database.migrate().await.expect("migrate database"); + let state = AppState::new(Upstreams::new( + "http://127.0.0.1:1".into(), + "http://127.0.0.1:1".into(), + )) + .expect("state") + .with_database(database); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = listener.local_addr().expect("address"); + let app = router(state.clone()); + tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + (dir, state, format!("http://{address}")) + } + + async fn tv_root(state: &AppState, audience: &str) -> i64 { + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = ?") + .bind(audience) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("TV root") + } + + async fn add_series(base: &str, root_id: i64, auto_track: bool) -> serde_json::Value { + let response = reqwest::Client::new() + .post(format!("{base}/api/series")) + .json(&serde_json::json!({ + "tmdb_id": 82_728, "title": "Bluey", "year": 2018, + "original_language": "en", "root_id": root_id, + "auto_track": auto_track + })) + .send() + .await + .expect("create series"); + assert_eq!(response.status(), StatusCode::CREATED); + response.json().await.expect("series json") + } + + async fn add_season( + base: &str, + series_id: i64, + number: i64, + episodes: serde_json::Value, + ) -> serde_json::Value { + let response = reqwest::Client::new() + .post(format!("{base}/api/series/{series_id}/seasons")) + .json(&serde_json::json!({ "number": number, "episodes": episodes })) + .send() + .await + .expect("create season"); + assert_eq!(response.status(), StatusCode::CREATED); + response.json().await.expect("season json") + } + + #[tokio::test] + async fn series_must_sit_on_a_tv_root() { + let (_dir, state, base) = application().await; + let movie_root: i64 = + sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'movie' LIMIT 1") + .fetch_one(state.database().expect("database").pool()) + .await + .expect("movie root"); + + let response = reqwest::Client::new() + .post(format!("{base}/api/series")) + .json(&serde_json::json!({ + "tmdb_id": 82_728, "title": "Bluey", "root_id": movie_root + })) + .send() + .await + .expect("create series"); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn auto_track_decides_whether_a_new_season_arrives_wanted() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "kids").await; + + let tracked = add_series(&base, root_id, true).await; + let season = add_season( + &base, + tracked["id"].as_i64().expect("id"), + 1, + serde_json::json!([ + {"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"}, + {"number": 2, "title": "Hospital", "air_date": "2018-10-02"} + ]), + ) + .await; + assert_eq!(season["tracked"], true); + assert!( + season["episodes"] + .as_array() + .expect("episodes") + .iter() + .all(|episode| episode["wanted"] == true), + "§4.1: auto_track marks the episodes of a revealed season wanted" + ); + + let untracked_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO series (tmdb_id, title, root_id, auto_track) VALUES (1668, 'Friends', ?, 0) RETURNING id", + ) + .bind(root_id) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("untracked series"); + let season = add_season( + &base, + untracked_id, + 2, + serde_json::json!([{"number": 1, "title": "The One", "air_date": "1995-09-21"}]), + ) + .await; + assert_eq!(season["tracked"], false); + assert_eq!(season["episodes"][0]["wanted"], false); + } + + #[tokio::test] + async fn a_rejected_season_leaves_nothing_behind_to_retry_over() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + + let response = reqwest::Client::new() + .post(format!("{base}/api/series/{series_id}/seasons")) + .json(&serde_json::json!({"number": 1, "episodes": [ + {"number": 1, "title": "One"}, + {"number": 1, "title": "One again"} + ]})) + .send() + .await + .expect("create season"); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let seasons: i64 = sqlx::query_scalar("SELECT count(*) FROM seasons") + .fetch_one(state.database().expect("database").pool()) + .await + .expect("count seasons"); + assert_eq!(seasons, 0, "the season number stays free for the retry"); + + let season = add_season( + &base, + series_id, + 1, + serde_json::json!([{"number": 1, "title": "One"}]), + ) + .await; + assert_eq!(season["number"], 1); + } + + #[tokio::test] + async fn intent_is_set_on_seasons_and_on_single_episodes() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + let season = add_season( + &base, + series_id, + 2, + serde_json::json!([ + {"number": 1, "title": "One", "air_date": "2020-01-01"}, + {"number": 2, "title": "Two", "air_date": "2020-01-08"} + ]), + ) + .await; + assert_eq!(season["episodes"][0]["wanted"], false); + + // The whole season, in one click. + let updated: serde_json::Value = reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}/seasons/2")) + .json(&serde_json::json!({"wanted": true})) + .send() + .await + .expect("update season") + .json() + .await + .expect("season json"); + assert!(updated["episodes"] + .as_array() + .expect("episodes") + .iter() + .all(|episode| episode["wanted"] == true)); + assert_eq!( + updated["tracked"], false, + "§4.1: wanting a season is not tracking the series" + ); + + // And one episode on its own. + let episode_id = updated["episodes"][1]["id"].as_i64().expect("episode id"); + let episode: serde_json::Value = reqwest::Client::new() + .patch(format!("{base}/api/episodes/{episode_id}")) + .json(&serde_json::json!({"wanted": false})) + .send() + .await + .expect("update episode") + .json() + .await + .expect("episode json"); + assert_eq!(episode["wanted"], false); + } + + #[tokio::test] + async fn listed_series_carry_a_derived_status() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + assert_eq!( + series["status"], "complete", + "nothing wanted is nothing missing" + ); + + let season = add_season( + &base, + series_id, + 1, + serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]), + ) + .await; + let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id"); + reqwest::Client::new() + .patch(format!("{base}/api/episodes/{episode_id}")) + .json(&serde_json::json!({"wanted": true})) + .send() + .await + .expect("want the episode"); + + let listed: Vec = reqwest::get(format!("{base}/api/series")) + .await + .expect("list") + .json() + .await + .expect("list json"); + assert_eq!(listed.len(), 1); + assert_eq!( + listed[0]["status"], "incomplete", + "§4.2: an aired wanted episode with no file" + ); + + sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?") + .bind(episode_id) + .execute(state.database().expect("database").pool()) + .await + .expect("import the episode"); + let after: serde_json::Value = reqwest::get(format!("{base}/api/series/{series_id}")) + .await + .expect("get") + .json() + .await + .expect("series json"); + assert_eq!(after["status"], "complete"); + } + + #[tokio::test] + async fn manual_episode_actions_are_scoped_and_respect_blocked() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + let season = add_season( + &base, + series_id, + 1, + serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]), + ) + .await; + let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id"); + + let response = reqwest::Client::new() + .post(format!("{base}/api/episodes/{episode_id}/search")) + .send() + .await + .expect("search"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + state.next_episode_command().await.expect("command"), + EpisodeCommand::Search { episode_id } + ); + + let pool = state.database().expect("database").pool(); + let release_id = sqlx::query_scalar::<_, i64>("INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict) VALUES (7, 'guid', 'Bluey S01E01 1080p WEB-DL', 1000, 'url', '{}', 42, 'eligible') RETURNING id") + .fetch_one(pool) + .await + .expect("release"); + sqlx::query("INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)") + .bind(episode_id) + .bind(release_id) + .execute(pool) + .await + .expect("association"); + + let releases: Vec = + reqwest::get(format!("{base}/api/episodes/{episode_id}/releases")) + .await + .expect("releases") + .json() + .await + .expect("releases json"); + assert_eq!(releases[0]["verdict"], "eligible"); + + let response = reqwest::Client::new() + .post(format!( + "{base}/api/episodes/{episode_id}/releases/{release_id}/grab" + )) + .send() + .await + .expect("grab"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + state.next_episode_command().await.expect("command"), + EpisodeCommand::Grab { + episode_id, + release_id + } + ); + + // A release belonging to another episode is not grabbable through + // this one. + let other = sqlx::query_scalar::<_, i64>("INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) VALUES (7, 'other', 'other', 1, 'url', '{}', 'eligible') RETURNING id") + .fetch_one(pool) + .await + .expect("other release"); + let response = reqwest::Client::new() + .post(format!( + "{base}/api/episodes/{episode_id}/releases/{other}/grab" + )) + .send() + .await + .expect("grab"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + reqwest::Client::new() + .patch(format!("{base}/api/series/{series_id}")) + .json(&serde_json::json!({"blocked": true})) + .send() + .await + .expect("block the series"); + let response = reqwest::Client::new() + .post(format!("{base}/api/episodes/{episode_id}/search")) + .send() + .await + .expect("blocked search"); + assert_eq!(response.status(), StatusCode::CONFLICT); + } + + #[tokio::test] + async fn owner_tags_filter_the_series_list() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "kids").await; + let series = add_series(&base, root_id, true).await; + let series_id = series["id"].as_i64().expect("id"); + let owner_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO owners (name, ntfy_topic) VALUES ('kid', 'arr-kid') RETURNING id", + ) + .fetch_one(state.database().expect("database").pool()) + .await + .expect("owner"); + + let empty: Vec = + reqwest::get(format!("{base}/api/series?owner_id={owner_id}")) + .await + .expect("filtered list") + .json() + .await + .expect("json"); + assert!(empty.is_empty()); + + let tagged = reqwest::Client::new() + .put(format!("{base}/api/series/{series_id}/owners/{owner_id}")) + .send() + .await + .expect("tag"); + assert_eq!(tagged.status(), StatusCode::NO_CONTENT); + + let filtered: Vec = + reqwest::get(format!("{base}/api/series?owner_id={owner_id}")) + .await + .expect("filtered list") + .json() + .await + .expect("json"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0]["id"], series_id); + + let owners: Vec = + reqwest::get(format!("{base}/api/series/{series_id}/owners")) + .await + .expect("owners") + .json() + .await + .expect("json"); + assert_eq!(owners.len(), 1); + + reqwest::Client::new() + .delete(format!("{base}/api/series/{series_id}/owners/{owner_id}")) + .send() + .await + .expect("untag"); + let owners: Vec = + reqwest::get(format!("{base}/api/series/{series_id}/owners")) + .await + .expect("owners") + .json() + .await + .expect("json"); + assert!(owners.is_empty()); + } + + #[tokio::test] + async fn deleting_a_series_takes_its_seasons_and_episodes() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, true).await; + let series_id = series["id"].as_i64().expect("id"); + add_season( + &base, + series_id, + 1, + serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]), + ) + .await; + + let response = reqwest::Client::new() + .delete(format!("{base}/api/series/{series_id}")) + .send() + .await + .expect("delete"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let episodes: i64 = sqlx::query_scalar("SELECT count(*) FROM episodes") + .fetch_one(state.database().expect("database").pool()) + .await + .expect("count episodes"); + assert_eq!(episodes, 0); + } + + #[test] + fn air_dates_parse_as_dates_and_as_timestamps() { + assert_eq!( + air_date(Some("1970-01-02")), + Some(UNIX_EPOCH + Duration::from_hours(24)) + ); + assert_eq!( + air_date(Some("1970-01-02T00:00:00Z")), + Some(UNIX_EPOCH + Duration::from_hours(24)) + ); + assert_eq!(air_date(Some("not a date")), None); + assert_eq!(air_date(None), None); + } +} diff --git a/crates/arr-api/src/state.rs b/crates/arr-api/src/state.rs index 76ea5fd..4e3c26c 100644 --- a/crates/arr-api/src/state.rs +++ b/crates/arr-api/src/state.rs @@ -70,6 +70,8 @@ pub struct AppState { database: Option, movie_commands: mpsc::Sender, pending_movie_commands: Arc>>, + episode_commands: mpsc::Sender, + pending_episode_commands: Arc>>, } /// Work explicitly requested through the movie API. @@ -79,6 +81,16 @@ pub enum MovieCommand { 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 { /// Build the state, including the shared HTTP client. /// @@ -88,12 +100,15 @@ impl AppState { pub fn new(upstreams: Upstreams) -> Result { 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); Ok(Self { http, upstreams: Arc::new(upstreams), database: None, 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 } + /// 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 { + self.pending_episode_commands.lock().await.recv().await + } + pub(crate) fn http(&self) -> &reqwest::Client { &self.http } @@ -131,4 +155,11 @@ impl AppState { ) -> Result<(), mpsc::error::TrySendError> { self.movie_commands.try_send(command) } + + pub(crate) fn send_episode_command( + &self, + command: EpisodeCommand, + ) -> Result<(), mpsc::error::TrySendError> { + self.episode_commands.try_send(command) + } } diff --git a/crates/arr-db/migrations/0006_episode_releases.sql b/crates/arr-db/migrations/0006_episode_releases.sql new file mode 100644 index 0000000..89fa6a7 --- /dev/null +++ b/crates/arr-db/migrations/0006_episode_releases.sql @@ -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); diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index 139f2e2..97c8cda 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -7,7 +7,7 @@ use std::path::Path; 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::{migrate::MigrateError, SqlitePool}; diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index 9111d0d..0449a40 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -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 /// on-disk layout (§7.1, §7.4), and they only exist together with the policy, /// so they are returned together. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct MoviePolicy { +pub struct TitlePolicy { pub policy: Policy, /// Per-title relaxations and tightenings of the root policy (§5.1). pub overrides: TitleOverrides, @@ -45,6 +45,9 @@ pub struct MoviePolicy { 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). #[derive(Debug, Clone, PartialEq, Eq)] pub struct PolicyColumns { @@ -139,7 +142,7 @@ impl Db { /// /// If the query fails, or a policy column does not hold the JSON its /// migration promises. - pub async fn movie_policy(&self, movie_id: i64) -> Result, PolicyError> { + pub async fn movie_policy(&self, movie_id: i64) -> Result, PolicyError> { let row = sqlx::query!( r#" SELECT m.overrides AS "overrides!: String", @@ -184,7 +187,82 @@ impl Db { } .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, 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, overrides: TitleOverrides { only_4k: overrides.only_4k,