Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 070fd3d7ba | |||
| 0b4ded3fe9 | |||
| afc17ca34a | |||
| 6847d25cf5 | |||
| 50056a2bd9 | |||
| c962998a2b | |||
| 815a072ef0 | |||
| 690eaeda5c | |||
| d23ae0ebcf | |||
| efb47d64e7 | |||
| 442ee3b022 | |||
| ac0e80c044 | |||
| 9fc7272765 |
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -94,5 +94,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708"
|
||||
"hash": "06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,5 +8,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b"
|
||||
"hash": "2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -170,7 +170,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -190,5 +190,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2"
|
||||
"hash": "55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5"
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE s.root_id = ?\n ORDER BY mf.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "media_files",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "media_files",
|
||||
"name": "path"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -71,7 +71,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -82,5 +82,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51"
|
||||
"hash": "b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -82,7 +82,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -94,5 +94,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702"
|
||||
"hash": "b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b"
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id\n WHERE m.root_id = ?\n ORDER BY mf.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "media_files",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "media_files",
|
||||
"name": "path"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
|
||||
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -46,5 +46,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
|
||||
"hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -58,5 +58,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
|
||||
"hash": "d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -58,5 +58,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
|
||||
"hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id FROM roots WHERE path = ? AND id <> ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "roots",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59"
|
||||
}
|
||||
@@ -378,6 +378,39 @@ A policy violation found by `ffprobe` is not one thing.
|
||||
|
||||
Neither deletes the torrent. See §7.3.
|
||||
|
||||
**Two hard failures make a decision, and only for 30 days.** A movie, an
|
||||
episode or a season enters the needs-a-decision queue (§9.5) when two grabs
|
||||
against *different* releases hard-failed on it, and both of those failures
|
||||
happened within the last 30 days. One bad torrent is not a decision — a
|
||||
release that hard-failed is blacklisted (§6.3) and the next candidate is
|
||||
grabbed, which is the system working.
|
||||
|
||||
The window is what lets the queue be emptied. Nothing clears a `grabs` row, so
|
||||
without it the queue only ever grows and the one season that wants attention
|
||||
sits behind eight that were dealt with months ago. It is the queue's version of
|
||||
§6.2's "it never gives up entirely, it goes quiet": a target the operator has
|
||||
dealt with stops producing failures and drops out once the last one ages past
|
||||
30 days, while a target that is still broken keeps producing them — the pack
|
||||
guard retries at worst weekly (§6.2) — and stays queued for exactly as long as
|
||||
it is genuinely broken. Nothing is dismissed by hand and no acknowledgement
|
||||
state is stored, so there is no second thing to keep correct.
|
||||
|
||||
**Only a target still waiting for a file is queued.** The count and the window
|
||||
are one rule seen twice — the queue is meant to be visited and emptied — and
|
||||
this is its third face: a failure history queues a target only while that
|
||||
target still has a gap to fill. A movie or an episode is queued while it is
|
||||
`wanted` and not `available`. A season holds no intent of its own (§4.1), so it
|
||||
is queued while at least one of its episodes is still wanted and still without
|
||||
a file. A season pack that hard-failed twice, fell back to per-episode grabbing
|
||||
exactly as §6.2 says it should, and was then fully acquired leaves at once
|
||||
rather than waiting out the 30 days — that is the system working, not a
|
||||
decision. A target that is still broken keeps producing failures and stays.
|
||||
|
||||
The same rule applies on all three lanes and in both readers.
|
||||
`GET /api/queues/attention` (§9.3) and the `ntfy` notification (§9.5) are two
|
||||
views of one queue; filtering differently tells the operator two different
|
||||
stories on two channels.
|
||||
|
||||
## 6. Sourcing
|
||||
|
||||
### 6.1 Prowlarr, per-indexer Torznab
|
||||
@@ -488,7 +521,7 @@ Media kind first, hard audience boundary second, people nowhere.
|
||||
- **Release group is deliberately absent.** It is not a selection criterion and
|
||||
it makes filenames long enough to break a terminal.
|
||||
|
||||
Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy.
|
||||
Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy. Changing a root's path is the same move over every title under it, and it is all or nothing: one folder that cannot move puts back the ones that already did and leaves the root's path alone, so the stored path always describes the disk.
|
||||
|
||||
During transition, write into the existing roots so Jellyfin needs no
|
||||
reconfiguration and new content appears immediately. Radarr will not touch a
|
||||
|
||||
+411
-49
@@ -199,12 +199,12 @@ impl IntoResponse for ApiError {
|
||||
tracing::error!(%error, "API database error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "database error".into())
|
||||
}
|
||||
// The message is the caller's: this variant is returned by the
|
||||
// delete lane and by the relocate lane, and "files not removed"
|
||||
// is a lie about a move that failed.
|
||||
Self::Filesystem(error) => {
|
||||
tracing::error!(%error, "API filesystem error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("files not removed: {error}"),
|
||||
)
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, error.clone())
|
||||
}
|
||||
};
|
||||
(status, Json(ErrorBody { error })).into_response()
|
||||
@@ -570,7 +570,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
|
||||
Ok(metadata) => metadata,
|
||||
// Already gone is the state we wanted.
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
||||
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||
};
|
||||
let removed = if metadata.is_dir() {
|
||||
tokio::fs::remove_dir_all(&target).await
|
||||
@@ -580,7 +580,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
|
||||
match removed {
|
||||
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
||||
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -770,6 +770,12 @@ pub async fn grab(
|
||||
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
|
||||
}
|
||||
|
||||
// The four attention lanes (§9.5). The hard-fail lanes carry §5.7's bar in
|
||||
// full — two failures on *different* releases, both inside
|
||||
// `ATTENTION_WINDOW`, against a target still waiting for a file — so this
|
||||
// endpoint and the daemon's notifier report the same queue. Deliberately not
|
||||
// a doc comment: utoipa would fold it into the OpenAPI description and drift
|
||||
// the committed document.
|
||||
#[utoipa::path(
|
||||
get, path = "/api/queues/attention", tag = "movies",
|
||||
responses(
|
||||
@@ -782,7 +788,7 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
|
||||
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#)
|
||||
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
|
||||
@@ -798,34 +804,75 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
|
||||
/// The TV lanes of the attention queues (§9.5): one entry per series with
|
||||
/// the episodes and seasons that put it there. The two hard-fail conditions
|
||||
/// share a lane; a series arriving through both is merged into one entry.
|
||||
///
|
||||
/// Both hard-fail branches hold to §5.7's bar: two failures on *different*
|
||||
/// releases, both inside `ATTENTION_WINDOW`, against a target still waiting
|
||||
/// for a file. One bad torrent is not a decision (#226), a failure the
|
||||
/// operator already dealt with ages out instead of sitting in the queue
|
||||
/// forever (#226), and a target since acquired leaves at once (#238). Seasons
|
||||
/// hold no intent of their own (§4.1), so the season branch reads liveness
|
||||
/// off its episodes: it is queued while any of them is still wanted and still
|
||||
/// without a file. The daemon's notifier filters identically.
|
||||
async fn tv_attention(
|
||||
state: &AppState,
|
||||
) -> Result<(Vec<SeriesAttention>, Vec<SeriesAttention>), ApiError> {
|
||||
let database = pool(state)?;
|
||||
Ok((
|
||||
tv_no_pt_source_lane(database).await?,
|
||||
tv_hard_fail_lane(database).await?,
|
||||
))
|
||||
}
|
||||
|
||||
let no_pt_rows = sqlx::query!(
|
||||
/// §5.2's no-PT-source lane: wanted, searched episodes on a `kids` root whose
|
||||
/// every candidate release was rejected for language.
|
||||
async fn tv_no_pt_source_lane(
|
||||
database: &sqlx::SqlitePool,
|
||||
) -> Result<Vec<SeriesAttention>, ApiError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||
s.title AS "title!: String", s.year,
|
||||
e.id AS "episode_id!: i64",
|
||||
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
|
||||
FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
JOIN roots root ON root.id = s.root_id
|
||||
WHERE root.audience = 'kids'
|
||||
AND s.blocked = 0
|
||||
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM episode_releases er
|
||||
JOIN releases r ON r.id = er.release_id
|
||||
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
|
||||
)
|
||||
ORDER BY se.number, e.number
|
||||
"#
|
||||
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||
s.title AS "title!: String", s.year,
|
||||
e.id AS "episode_id!: i64",
|
||||
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
|
||||
FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
JOIN roots root ON root.id = s.root_id
|
||||
WHERE root.audience = 'kids'
|
||||
AND s.blocked = 0
|
||||
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM episode_releases er
|
||||
JOIN releases r ON r.id = er.release_id
|
||||
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
|
||||
)
|
||||
ORDER BY se.number, e.number
|
||||
"#
|
||||
)
|
||||
.fetch_all(database)
|
||||
.await?;
|
||||
|
||||
let mut entries: Vec<SeriesAttention> = Vec::new();
|
||||
for row in rows {
|
||||
merge_episode(
|
||||
&mut entries,
|
||||
row.series_id,
|
||||
row.tmdb_id,
|
||||
&row.title,
|
||||
row.year,
|
||||
row.episode_id,
|
||||
row.season_number,
|
||||
row.episode_number,
|
||||
);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// §5.7's hard-fail lane: episodes and seasons two *different* releases failed
|
||||
/// on inside `ATTENTION_WINDOW`, restricted to targets still waiting for a
|
||||
/// file. The season half reads that last condition off its episodes, which is
|
||||
/// where intent lives (§4.1). Both halves merge into one entry per series.
|
||||
async fn tv_hard_fail_lane(database: &sqlx::SqlitePool) -> Result<Vec<SeriesAttention>, ApiError> {
|
||||
let episode_hard_fails = sqlx::query!(
|
||||
r#"
|
||||
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||
@@ -837,9 +884,12 @@ async fn tv_attention(
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
AND e.wanted = 1 AND e.state != 'available'
|
||||
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database)
|
||||
.await?;
|
||||
@@ -852,30 +902,24 @@ async fn tv_attention(
|
||||
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM episodes e
|
||||
WHERE e.season_id = se.id
|
||||
AND e.wanted = 1 AND e.state != 'available'
|
||||
)
|
||||
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number
|
||||
"#
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database)
|
||||
.await?;
|
||||
|
||||
let mut tv_no_pt_source: Vec<SeriesAttention> = Vec::new();
|
||||
for row in no_pt_rows {
|
||||
merge_episode(
|
||||
&mut tv_no_pt_source,
|
||||
row.series_id,
|
||||
row.tmdb_id,
|
||||
&row.title,
|
||||
row.year,
|
||||
row.episode_id,
|
||||
row.season_number,
|
||||
row.episode_number,
|
||||
);
|
||||
}
|
||||
|
||||
let mut tv_needs_decision: Vec<SeriesAttention> = Vec::new();
|
||||
let mut entries: Vec<SeriesAttention> = Vec::new();
|
||||
for row in episode_hard_fails {
|
||||
merge_episode(
|
||||
&mut tv_needs_decision,
|
||||
&mut entries,
|
||||
row.series_id,
|
||||
row.tmdb_id,
|
||||
&row.title,
|
||||
@@ -887,7 +931,7 @@ async fn tv_attention(
|
||||
}
|
||||
for row in season_pack_fails {
|
||||
merge_season(
|
||||
&mut tv_needs_decision,
|
||||
&mut entries,
|
||||
row.series_id,
|
||||
row.tmdb_id,
|
||||
&row.title,
|
||||
@@ -896,8 +940,7 @@ async fn tv_attention(
|
||||
row.season_number,
|
||||
);
|
||||
}
|
||||
|
||||
Ok((tv_no_pt_source, tv_needs_decision))
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// One more qualifying season for its series, creating the series' entry on
|
||||
@@ -1996,10 +2039,328 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A series with one season holding a single wanted, missing episode — the
|
||||
/// least that satisfies §5.7's liveness condition — for exercising the
|
||||
/// season lane on its own.
|
||||
async fn seed_bare_season(pool: &sqlx::SqlitePool, tmdb_id: i64) -> (i64, i64) {
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("kids tv root");
|
||||
let series_id: i64 = sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Rick and Morty', 2013, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(tmdb_id)
|
||||
.bind(root_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("series")
|
||||
.get(0);
|
||||
let season_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO seasons (series_id, number) VALUES (?, 8) RETURNING id",
|
||||
)
|
||||
.bind(series_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("season");
|
||||
sqlx::query(
|
||||
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
||||
VALUES (?, 1, 'Episode 1', 1, 'missing')",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("episode");
|
||||
(series_id, season_id)
|
||||
}
|
||||
|
||||
async fn insert_release(pool: &sqlx::SqlitePool, guid: &str) -> i64 {
|
||||
sqlx::query(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (1, ?, 'release', 1, 'url', '{}', 'eligible') RETURNING id",
|
||||
)
|
||||
.bind(guid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("release")
|
||||
.get(0)
|
||||
}
|
||||
|
||||
/// A hard-failed grab, stamped `age_days` in the past so the §5.7 window
|
||||
/// can be exercised without waiting a month.
|
||||
async fn insert_failed_grab(
|
||||
pool: &sqlx::SqlitePool,
|
||||
release_id: i64,
|
||||
target_kind: &str,
|
||||
target_id: i64,
|
||||
infohash: &str,
|
||||
age_days: i64,
|
||||
) {
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(target_kind)
|
||||
.bind(target_id)
|
||||
.bind(infohash)
|
||||
.bind(format!("-{age_days} days"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("failed grab");
|
||||
}
|
||||
|
||||
/// The seasons `GET /api/queues/attention` currently reports for a series.
|
||||
async fn queued_seasons(base: &str, series_id: i64) -> Vec<i64> {
|
||||
let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention"))
|
||||
.await
|
||||
.expect("queues")
|
||||
.json()
|
||||
.await
|
||||
.expect("queues json");
|
||||
queues["tv_needs_decision"]
|
||||
.as_array()
|
||||
.expect("tv lane")
|
||||
.iter()
|
||||
.filter(|entry| entry["series_id"] == series_id)
|
||||
.flat_map(|entry| {
|
||||
entry["seasons"]
|
||||
.as_array()
|
||||
.expect("seasons")
|
||||
.iter()
|
||||
.map(|season| season["id"].as_i64().expect("season id"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// §5.7: one failed pack is the blacklist working, not a decision. The
|
||||
/// season lane holds to the same two-distinct-releases bar the episode
|
||||
/// lane does (#226).
|
||||
#[tokio::test]
|
||||
async fn a_season_queues_only_on_two_distinct_release_failures() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let (series_id, season_id) = seed_bare_season(pool, 1).await;
|
||||
|
||||
let first = insert_release(pool, "pack-one").await;
|
||||
insert_failed_grab(pool, first, "season", season_id, "hash-one", 0).await;
|
||||
assert!(
|
||||
queued_seasons(&base, series_id).await.is_empty(),
|
||||
"one failed pack is not a decision"
|
||||
);
|
||||
|
||||
// A second failure on the *same* release is still one release.
|
||||
insert_failed_grab(pool, first, "season", season_id, "hash-one-again", 0).await;
|
||||
assert!(
|
||||
queued_seasons(&base, series_id).await.is_empty(),
|
||||
"two grabs of one release are not two releases"
|
||||
);
|
||||
|
||||
let second = insert_release(pool, "pack-two").await;
|
||||
insert_failed_grab(pool, second, "season", season_id, "hash-two", 0).await;
|
||||
assert_eq!(
|
||||
queued_seasons(&base, series_id).await,
|
||||
vec![season_id],
|
||||
"two distinct releases hard-failed: the operator decides"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.7: a failure counts for 30 days. A season the operator has dealt
|
||||
/// with stops failing and leaves the queue; one still breaking keeps
|
||||
/// producing failures and stays (#226).
|
||||
#[tokio::test]
|
||||
async fn a_season_failure_ages_out_of_the_attention_queue() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let (series_id, season_id) = seed_bare_season(pool, 1).await;
|
||||
|
||||
for (guid, hash, age) in [
|
||||
("old-one", "hash-old-one", 40),
|
||||
("old-two", "hash-old-two", 35),
|
||||
] {
|
||||
let release_id = insert_release(pool, guid).await;
|
||||
insert_failed_grab(pool, release_id, "season", season_id, hash, age).await;
|
||||
}
|
||||
assert!(
|
||||
queued_seasons(&base, series_id).await.is_empty(),
|
||||
"failures older than the window are history, not attention"
|
||||
);
|
||||
|
||||
let fresh = insert_release(pool, "new-one").await;
|
||||
insert_failed_grab(pool, fresh, "season", season_id, "hash-new-one", 0).await;
|
||||
assert!(
|
||||
queued_seasons(&base, series_id).await.is_empty(),
|
||||
"one recent failure does not revive two stale ones"
|
||||
);
|
||||
|
||||
let fresher = insert_release(pool, "new-two").await;
|
||||
insert_failed_grab(pool, fresher, "season", season_id, "hash-new-two", 0).await;
|
||||
assert_eq!(
|
||||
queued_seasons(&base, series_id).await,
|
||||
vec![season_id],
|
||||
"still breaking: back in the queue"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.7: the queue only holds targets still waiting for a file. A season
|
||||
/// whose pack failed twice, fell back to per-episode grabbing (§6.2) and
|
||||
/// was then fully acquired is the system working, so it drops out at once
|
||||
/// instead of sitting there for 30 days (#238).
|
||||
#[tokio::test]
|
||||
async fn a_fully_acquired_season_leaves_the_attention_queue() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let (series_id, season_id) = seed_bare_season(pool, 1).await;
|
||||
sqlx::query(
|
||||
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
||||
VALUES (?, 2, 'Episode 2', 1, 'missing')",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("second episode");
|
||||
|
||||
for (guid, hash) in [("pack-one", "hash-one"), ("pack-two", "hash-two")] {
|
||||
let release_id = insert_release(pool, guid).await;
|
||||
insert_failed_grab(pool, release_id, "season", season_id, hash, 0).await;
|
||||
}
|
||||
assert_eq!(
|
||||
queued_seasons(&base, series_id).await,
|
||||
vec![season_id],
|
||||
"two packs failed and episodes are still missing"
|
||||
);
|
||||
|
||||
// Per-episode grabbing got one of the two. Still a gap, still queued.
|
||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1")
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("first episode imported");
|
||||
assert_eq!(
|
||||
queued_seasons(&base, series_id).await,
|
||||
vec![season_id],
|
||||
"one episode still wanted and missing: still broken, still queued"
|
||||
);
|
||||
|
||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?")
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("season imported");
|
||||
assert!(
|
||||
queued_seasons(&base, series_id).await.is_empty(),
|
||||
"every episode acquired: the fallback worked, no decision to make"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.7: the same liveness condition on the movie and episode lanes, so
|
||||
/// `GET /api/queues/attention` reports exactly what the daemon notifies
|
||||
/// on (#238).
|
||||
#[tokio::test]
|
||||
async fn an_acquired_movie_or_episode_leaves_the_attention_queue() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE audience = 'kids'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("kids root");
|
||||
let movie = add_movie(&base, 82728, root_id).await;
|
||||
let movie_id = movie["id"].as_i64().expect("id");
|
||||
for (guid, hash) in [("movie-one", "hash-m1"), ("movie-two", "hash-m2")] {
|
||||
let release_id = insert_release(pool, guid).await;
|
||||
insert_failed_grab(pool, release_id, "movie", movie_id, hash, 0).await;
|
||||
}
|
||||
|
||||
let (series_id, season_id) = seed_bare_season(pool, 99).await;
|
||||
let episode_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
|
||||
.bind(season_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("episode id");
|
||||
for (guid, hash) in [("ep-one", "hash-e1"), ("ep-two", "hash-e2")] {
|
||||
let release_id = insert_release(pool, guid).await;
|
||||
insert_failed_grab(pool, release_id, "episode", episode_id, hash, 0).await;
|
||||
}
|
||||
|
||||
let queues = attention_queues(&base).await;
|
||||
assert_eq!(queues["needs_decision"][0]["id"], movie_id);
|
||||
assert_eq!(
|
||||
queued_episodes(&queues, series_id),
|
||||
vec![episode_id],
|
||||
"still wanted and missing: queued"
|
||||
);
|
||||
|
||||
sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?")
|
||||
.bind(movie_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("movie imported");
|
||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
|
||||
.bind(episode_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("episode imported");
|
||||
|
||||
let queues = attention_queues(&base).await;
|
||||
assert_eq!(
|
||||
queues["needs_decision"].as_array().map(Vec::len),
|
||||
Some(0),
|
||||
"imported from a third release: no decision to make"
|
||||
);
|
||||
assert!(
|
||||
queued_episodes(&queues, series_id).is_empty(),
|
||||
"imported from a third release: no decision to make"
|
||||
);
|
||||
|
||||
// Withdrawing intent empties the lane just the same.
|
||||
sqlx::query("UPDATE movies SET state = 'missing', wanted = 0 WHERE id = ?")
|
||||
.bind(movie_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("movie unwanted");
|
||||
assert_eq!(
|
||||
attention_queues(&base).await["needs_decision"]
|
||||
.as_array()
|
||||
.map(Vec::len),
|
||||
Some(0),
|
||||
"nothing is waiting for a file"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole attention payload.
|
||||
async fn attention_queues(base: &str) -> serde_json::Value {
|
||||
reqwest::get(format!("{base}/api/queues/attention"))
|
||||
.await
|
||||
.expect("queues")
|
||||
.json()
|
||||
.await
|
||||
.expect("queues json")
|
||||
}
|
||||
|
||||
/// The episodes a series contributes to the hard-fail TV lane.
|
||||
fn queued_episodes(queues: &serde_json::Value, series_id: i64) -> Vec<i64> {
|
||||
queues["tv_needs_decision"]
|
||||
.as_array()
|
||||
.expect("tv lane")
|
||||
.iter()
|
||||
.filter(|entry| entry["series_id"] == series_id)
|
||||
.flat_map(|entry| {
|
||||
entry["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.map(|episode| episode["id"].as_i64().expect("episode id"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One series hitting all three §9.5 TV entry conditions: two wanted,
|
||||
/// searched episodes whose every candidate was rejected for language; a
|
||||
/// season pack that hard-failed; and an episode two different releases
|
||||
/// hard-failed on.
|
||||
/// season two different packs hard-failed on; and an episode two different
|
||||
/// releases hard-failed on.
|
||||
async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) {
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
|
||||
@@ -2051,6 +2412,7 @@ mod tests {
|
||||
.expect("episode id");
|
||||
for (kind, guid, suffix) in [
|
||||
("season", "pack", "pack"),
|
||||
("season", "pack-two", "pack2"),
|
||||
("episode", "first", "a"),
|
||||
("episode", "second", "b"),
|
||||
] {
|
||||
|
||||
+143
-32
@@ -1,7 +1,8 @@
|
||||
//! Moving a title between roots (issue #228). Changing a title's `root_id`
|
||||
//! renames its §7.4 folder into the new root and rewrites the `media_files`
|
||||
//! rows to match, so the layout keeps describing the disk and the new root's
|
||||
//! policy (§5.1) applies to a library the files are actually in.
|
||||
//! Moving library files when the layout under them changes: a title changing
|
||||
//! its `root_id` (issue #228), and a root changing its `path` (issue #236).
|
||||
//! Both rename §7.4 folders and rewrite the `media_files` rows to match, so
|
||||
//! the layout keeps describing the disk and the root's policy (§5.1) applies
|
||||
//! to a library the files are actually in.
|
||||
//!
|
||||
//! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this
|
||||
//! is a directory rename, never a copy. Hardlinked files keep their inodes
|
||||
@@ -10,6 +11,16 @@
|
||||
//! Ordering mirrors `remove_library_files`: the disk is touched before the
|
||||
//! row changes, so a failed rename leaves the title where its files actually
|
||||
//! are and the operator can retry.
|
||||
//!
|
||||
//! A root path change is the same move repeated over every title under the
|
||||
//! root, and it is all or nothing. If the seventh of ten folders fails to
|
||||
//! move, the six already renamed are moved back and the root row is left
|
||||
//! alone: a root row carries one path, so a half-moved library would have to
|
||||
//! describe both, and neither the operator nor the next import could tell
|
||||
//! which titles were where. Refusing leaves one answer — everything is still
|
||||
//! at the old path — and the retry is the same request again. A retry after
|
||||
//! an undo that itself failed still converges, because a source that is no
|
||||
//! longer on disk is skipped while its row is still rewritten.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -40,6 +51,15 @@ pub(crate) struct Relocation {
|
||||
rewrites: Vec<(i64, String)>,
|
||||
}
|
||||
|
||||
/// Whether the destination root is a directory that must already be there.
|
||||
/// A title moves into another configured root, which exists; a root moving to
|
||||
/// a new path is moving somewhere that need not exist yet.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Destination {
|
||||
Existing,
|
||||
Create,
|
||||
}
|
||||
|
||||
/// Rename the title's folders into the new root. Called before the row is
|
||||
/// written, and only when the root actually changes.
|
||||
///
|
||||
@@ -62,11 +82,45 @@ pub(crate) async fn relocate_title(
|
||||
let old_root = root_path(state, old_root_id).await?;
|
||||
let new_root = root_path(state, new_root_id).await?;
|
||||
let files = title_files(state, kind, title_id).await?;
|
||||
relocate_files(&files, &old_root, &new_root, Destination::Existing).await
|
||||
}
|
||||
|
||||
/// Rename every title folder under a root into the root's new path, for a
|
||||
/// `PUT /api/roots/{id}` that changes `path` (issue #236).
|
||||
///
|
||||
/// A root with no titles under it — or whose titles have nothing on disk —
|
||||
/// changes path with no filesystem work at all. The new path is created when
|
||||
/// there is something to move into it, since a root is normally pointed at a
|
||||
/// directory that does not exist yet.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The same two as [`relocate_title`], with the whole root's move treated as
|
||||
/// one unit: one folder that cannot move takes the entire change down and
|
||||
/// moves back whatever had already moved.
|
||||
pub(crate) async fn relocate_root(
|
||||
state: &AppState,
|
||||
root_id: i64,
|
||||
old_path: &str,
|
||||
new_path: &str,
|
||||
) -> Result<Relocation, ApiError> {
|
||||
let files = root_files(state, root_id).await?;
|
||||
relocate_files(&files, old_path, new_path, Destination::Create).await
|
||||
}
|
||||
|
||||
/// The one mover both callers share: plan every rename, refuse every
|
||||
/// destination that already exists, then perform them, undoing what was
|
||||
/// performed if one fails.
|
||||
async fn relocate_files(
|
||||
files: &[(i64, String)],
|
||||
old_root: &str,
|
||||
new_root: &str,
|
||||
destination: Destination,
|
||||
) -> Result<Relocation, ApiError> {
|
||||
let mut renames: Vec<PlannedRename> = Vec::new();
|
||||
let mut rewrites: Vec<(i64, String)> = Vec::new();
|
||||
for (file_id, path) in &files {
|
||||
let Some(source) = title_target(&old_root, path) else {
|
||||
for (file_id, path) in files {
|
||||
let Some(source) = title_target(old_root, path) else {
|
||||
// Outside its own root: not ours to move, and the row keeps
|
||||
// pointing at where the file really is.
|
||||
tracing::warn!(%path, %old_root, "media file is outside its root, not moved");
|
||||
@@ -75,7 +129,7 @@ pub(crate) async fn relocate_title(
|
||||
let Some(name) = source.file_name() else {
|
||||
continue;
|
||||
};
|
||||
let destination = std::path::Path::new(&new_root).join(name);
|
||||
let destination = std::path::Path::new(new_root).join(name);
|
||||
if !renames.iter().any(|rename| rename.source == source) {
|
||||
renames.push(PlannedRename {
|
||||
source,
|
||||
@@ -83,9 +137,9 @@ pub(crate) async fn relocate_title(
|
||||
});
|
||||
}
|
||||
let relative = std::path::Path::new(path)
|
||||
.strip_prefix(std::path::Path::new(&old_root))
|
||||
.strip_prefix(std::path::Path::new(old_root))
|
||||
.map_err(|error| ApiError::Filesystem(error.to_string()))?;
|
||||
let rewritten = std::path::Path::new(&new_root).join(relative);
|
||||
let rewritten = std::path::Path::new(new_root).join(relative);
|
||||
let Some(rewritten) = rewritten.to_str() else {
|
||||
return Err(ApiError::Filesystem(format!(
|
||||
"non-UTF-8 path under {new_root}"
|
||||
@@ -95,12 +149,22 @@ pub(crate) async fn relocate_title(
|
||||
}
|
||||
|
||||
// Every destination is checked before anything is renamed, so a conflict
|
||||
// never leaves a half-moved title behind.
|
||||
// never leaves a half-moved library behind. A rename whose source is
|
||||
// already gone is skipped here rather than refused: the perform loop below
|
||||
// skips it too, and refusing it would make the documented retry
|
||||
// impossible. After an undo that itself failed, the folder sits at the
|
||||
// destination while the row still names the source, and every later
|
||||
// attempt would 409 on a conflict with the operator's own half-moved
|
||||
// library.
|
||||
for rename in &renames {
|
||||
match tokio::fs::symlink_metadata(&rename.source).await {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
_ => {}
|
||||
}
|
||||
match tokio::fs::symlink_metadata(&rename.destination).await {
|
||||
Ok(_) => {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"the destination root already has '{}'",
|
||||
"the destination already has '{}'",
|
||||
rename.destination.display()
|
||||
)))
|
||||
}
|
||||
@@ -109,6 +173,14 @@ pub(crate) async fn relocate_title(
|
||||
}
|
||||
}
|
||||
|
||||
if destination == Destination::Create && !renames.is_empty() {
|
||||
if let Err(error) = tokio::fs::create_dir_all(new_root).await {
|
||||
return Err(ApiError::Filesystem(format!(
|
||||
"could not create '{new_root}': {error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut performed: Vec<PlannedRename> = Vec::new();
|
||||
for rename in renames {
|
||||
// A recorded file with nothing on disk: the rows still follow the
|
||||
@@ -123,36 +195,18 @@ pub(crate) async fn relocate_title(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
let failed = ApiError::Filesystem(error.to_string());
|
||||
Relocation {
|
||||
performed,
|
||||
rewrites: Vec::new(),
|
||||
}
|
||||
.undo()
|
||||
.await;
|
||||
return Err(failed);
|
||||
}
|
||||
Err(error) => return Err(failed(&rename, &error, performed).await),
|
||||
}
|
||||
match tokio::fs::rename(&rename.source, &rename.destination).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
source = %rename.source.display(),
|
||||
destination = %rename.destination.display(),
|
||||
"moved title folder between roots"
|
||||
"moved a title folder"
|
||||
);
|
||||
performed.push(rename);
|
||||
}
|
||||
Err(error) => {
|
||||
let failed = ApiError::Filesystem(error.to_string());
|
||||
Relocation {
|
||||
performed,
|
||||
rewrites: Vec::new(),
|
||||
}
|
||||
.undo()
|
||||
.await;
|
||||
return Err(failed);
|
||||
}
|
||||
Err(error) => return Err(failed(&rename, &error, performed).await),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +216,26 @@ pub(crate) async fn relocate_title(
|
||||
})
|
||||
}
|
||||
|
||||
/// One rename failed: move back everything that had already moved and name
|
||||
/// the folder that stopped the move, so the operator knows which title to
|
||||
/// look at before retrying.
|
||||
async fn failed(
|
||||
rename: &PlannedRename,
|
||||
error: &std::io::Error,
|
||||
performed: Vec<PlannedRename>,
|
||||
) -> ApiError {
|
||||
Relocation {
|
||||
performed,
|
||||
rewrites: Vec::new(),
|
||||
}
|
||||
.undo()
|
||||
.await;
|
||||
ApiError::Filesystem(format!(
|
||||
"could not move '{}': {error}",
|
||||
rename.source.display()
|
||||
))
|
||||
}
|
||||
|
||||
impl Relocation {
|
||||
/// Whether anything on disk actually moved — the trigger for the same
|
||||
/// single Jellyfin refresh import performs (§7.5).
|
||||
@@ -224,6 +298,43 @@ async fn root_path(state: &AppState, root_id: i64) -> Result<String, ApiError> {
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Every file the service recorded under a root: the rows of every movie in
|
||||
/// it, and the rows of every episode of every series in it. Ordered so the
|
||||
/// renames happen in a stable order, which is what makes a failure part-way
|
||||
/// through reproducible.
|
||||
async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>, ApiError> {
|
||||
let mut files: Vec<(i64, String)> = sqlx::query!(
|
||||
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
||||
FROM media_files mf
|
||||
JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id
|
||||
WHERE m.root_id = ?
|
||||
ORDER BY mf.id"#,
|
||||
root_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| (row.id, row.path))
|
||||
.collect();
|
||||
files.extend(
|
||||
sqlx::query!(
|
||||
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
||||
FROM media_files mf
|
||||
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE s.root_id = ?
|
||||
ORDER BY mf.id"#,
|
||||
root_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| (row.id, row.path)),
|
||||
);
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Every file the service recorded for the title: a movie's own rows, or the
|
||||
/// rows of every episode below a series.
|
||||
async fn title_files(
|
||||
|
||||
+503
-23
@@ -172,26 +172,74 @@ pub async fn update(
|
||||
let input = parsed(body)?;
|
||||
input.validate().map_err(ApiError::Invalid)?;
|
||||
input.policy_exists(&state).await?;
|
||||
let current = load_root(&state, id).await?;
|
||||
let path = input.path.trim().to_owned();
|
||||
let result = sqlx::query!(
|
||||
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?"#,
|
||||
input.kind,
|
||||
input.audience,
|
||||
path,
|
||||
input.policy_id,
|
||||
id,
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await
|
||||
.map_err(root_conflict)?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::RootNotFound);
|
||||
// A path change moves every §7.4 title folder under this root with the
|
||||
// row (issue #236), the same way changing a title's root moves one
|
||||
// (#228). Disk first, row second: a failed rename leaves the root row
|
||||
// alone, so the operator sees the library where its files actually are
|
||||
// and can retry. A path already taken is refused before any of it, since
|
||||
// the write would fail afterwards anyway.
|
||||
let relocation = if path == current.path {
|
||||
None
|
||||
} else {
|
||||
path_is_free(&state, id, &path).await?;
|
||||
Some(crate::relocate::relocate_root(&state, id, ¤t.path, &path).await?)
|
||||
};
|
||||
let mut transaction = pool(&state)?.begin().await?;
|
||||
let written: Result<(), sqlx::Error> = async {
|
||||
sqlx::query!(
|
||||
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?"#,
|
||||
input.kind,
|
||||
input.audience,
|
||||
path,
|
||||
input.policy_id,
|
||||
id,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
if let Some(relocation) = &relocation {
|
||||
relocation.rewrite_rows(&mut transaction).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
let committed = match written {
|
||||
Ok(()) => transaction.commit().await.map_err(root_conflict),
|
||||
Err(error) => Err(root_conflict(error)),
|
||||
};
|
||||
if let Err(error) = committed {
|
||||
if let Some(relocation) = &relocation {
|
||||
relocation.undo().await;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if relocation
|
||||
.as_ref()
|
||||
.is_some_and(crate::relocate::Relocation::moved_files)
|
||||
{
|
||||
crate::relocate::refresh_jellyfin(&state).await;
|
||||
}
|
||||
Ok(Json(load_root(&state, id).await?))
|
||||
}
|
||||
|
||||
/// The unique index on `path` would catch this after the move; catching it
|
||||
/// first keeps a doomed write from touching the disk at all.
|
||||
async fn path_is_free(state: &AppState, id: i64, path: &str) -> Result<(), ApiError> {
|
||||
let taken: Option<i64> =
|
||||
sqlx::query_scalar!("SELECT id FROM roots WHERE path = ? AND id <> ?", path, id)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?;
|
||||
if taken.is_some() {
|
||||
return Err(ApiError::Conflict(
|
||||
"a root with this path already exists".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A duplicate path or a duplicate (kind, audience) pair is a settings
|
||||
/// mistake the operator can fix, not a server fault.
|
||||
fn root_conflict(error: sqlx::Error) -> ApiError {
|
||||
@@ -258,7 +306,7 @@ mod tests {
|
||||
use crate::{router, AppState, Upstreams};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
async fn application() -> (tempfile::TempDir, String) {
|
||||
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
|
||||
@@ -274,14 +322,14 @@ mod tests {
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
let app = router(state);
|
||||
let app = router(state.clone());
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
||||
(dir, format!("http://{address}"))
|
||||
(dir, state, format!("http://{address}"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn roots_carry_their_policy_name() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
.expect("roots")
|
||||
@@ -325,7 +373,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_root_round_trips_through_create_and_update() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let policy_ids = first_policy_ids(&base).await;
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
@@ -399,7 +447,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let policy_ids = first_policy_ids(&base).await;
|
||||
|
||||
for (mut payload, field) in [
|
||||
@@ -428,7 +476,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_duplicate_path_or_pair_conflicts() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let mut path = root_input(1);
|
||||
path["audience"] = serde_json::json!("kids");
|
||||
path["path"] = serde_json::json!("/mnt/media/movies/kids");
|
||||
@@ -454,7 +502,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_root_with_titles_refuses_to_die() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
.expect("roots")
|
||||
@@ -495,4 +543,436 @@ mod tests {
|
||||
.expect("delete empty root");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) {
|
||||
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
||||
.bind(path.to_str().expect("utf-8 root"))
|
||||
.bind(root_id)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("point the root at the tempdir");
|
||||
}
|
||||
|
||||
async fn add_movie(base: &str, tmdb_id: i64, title: &str, root_id: i64) -> i64 {
|
||||
let created: serde_json::Value = reqwest::Client::new()
|
||||
.post(format!("{base}/api/movies"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": tmdb_id, "title": title, "year": 2024,
|
||||
"original_language": "en", "root_id": root_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create movie")
|
||||
.json()
|
||||
.await
|
||||
.expect("movie json");
|
||||
created["id"].as_i64().expect("movie id")
|
||||
}
|
||||
|
||||
/// One movie's §7.4 folder on disk with a feature in it, and the
|
||||
/// `media_files` row that points at the feature.
|
||||
async fn library_folder(
|
||||
state: &AppState,
|
||||
movie_id: i64,
|
||||
root: &std::path::Path,
|
||||
folder: &str,
|
||||
) -> std::path::PathBuf {
|
||||
let folder = root.join(folder);
|
||||
tokio::fs::create_dir_all(&folder)
|
||||
.await
|
||||
.expect("create title folder");
|
||||
let feature = folder.join("feature.mkv");
|
||||
tokio::fs::write(&feature, b"feature").await.expect("write");
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('movie', ?, ?, 7)",
|
||||
)
|
||||
.bind(movie_id)
|
||||
.bind(feature.to_str().expect("utf-8 path"))
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("media file");
|
||||
folder
|
||||
}
|
||||
|
||||
async fn root_payload(base: &str, id: i64, path: &str) -> serde_json::Value {
|
||||
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
|
||||
.await
|
||||
.expect("root")
|
||||
.json()
|
||||
.await
|
||||
.expect("root json");
|
||||
serde_json::json!({
|
||||
"kind": root["kind"],
|
||||
"audience": root["audience"],
|
||||
"path": path,
|
||||
"policy_id": root["policy_id"],
|
||||
})
|
||||
}
|
||||
|
||||
async fn file_paths(state: &AppState) -> Vec<String> {
|
||||
sqlx::query_scalar("SELECT path FROM media_files ORDER BY id")
|
||||
.fetch_all(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("media files")
|
||||
}
|
||||
|
||||
async fn stored_path(base: &str, id: i64) -> String {
|
||||
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
|
||||
.await
|
||||
.expect("root")
|
||||
.json()
|
||||
.await
|
||||
.expect("root json");
|
||||
root["path"].as_str().expect("path").to_owned()
|
||||
}
|
||||
|
||||
/// Issue #236: changing a root's path renames every §7.4 title folder
|
||||
/// under it into the new path and rewrites the `media_files` rows in the
|
||||
/// same write. Titles under other roots are not this root's business.
|
||||
#[tokio::test]
|
||||
async fn changing_a_root_path_moves_every_title_under_it() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let other = tempfile::tempdir().expect("other root");
|
||||
point_root_at(&state, 1, old.path()).await;
|
||||
point_root_at(&state, 2, other.path()).await;
|
||||
|
||||
let mut folders = Vec::new();
|
||||
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
|
||||
let id = add_movie(&base, tmdb_id, title, 1).await;
|
||||
folders.push(library_folder(&state, id, old.path(), title).await);
|
||||
}
|
||||
let elsewhere = add_movie(&base, 200, "Prisoners", 2).await;
|
||||
let untouched = library_folder(&state, elsewhere, other.path(), "Prisoners").await;
|
||||
|
||||
// The new path need not exist yet — pointing a root somewhere fresh
|
||||
// is the ordinary case.
|
||||
let new = old.path().parent().expect("parent").join("relocated-main");
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("move the root");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
for folder in &folders {
|
||||
assert!(!folder.exists(), "{} left the old path", folder.display());
|
||||
}
|
||||
for title in ["Dune", "Arrival", "Sicario"] {
|
||||
assert!(
|
||||
new.join(title).join("feature.mkv").exists(),
|
||||
"{title} arrived under the new path"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
untouched.join("feature.mkv").exists(),
|
||||
"a title under another root is untouched"
|
||||
);
|
||||
|
||||
let paths = file_paths(&state).await;
|
||||
for path in paths.iter().take(3) {
|
||||
assert!(
|
||||
std::path::Path::new(path).starts_with(&new),
|
||||
"the row follows the file: {path}"
|
||||
);
|
||||
assert!(
|
||||
std::path::Path::new(path).exists(),
|
||||
"the rewritten path describes the disk: {path}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
std::path::Path::new(&paths[3]).starts_with(other.path()),
|
||||
"the other root's row is untouched: {}",
|
||||
paths[3]
|
||||
);
|
||||
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
|
||||
|
||||
tokio::fs::remove_dir_all(&new).await.expect("clean up");
|
||||
}
|
||||
|
||||
/// Issue #236: a root with nothing under it changes path with no
|
||||
/// filesystem work at all — the new path is not even created.
|
||||
#[tokio::test]
|
||||
async fn an_empty_root_changes_path_with_no_filesystem_work() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let home = tempfile::tempdir().expect("home");
|
||||
let new = home.path().join("nothing-here");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("move the root");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
|
||||
assert!(!new.exists(), "nothing was created on disk");
|
||||
assert!(file_paths(&state).await.is_empty());
|
||||
}
|
||||
|
||||
/// Issue #236: a destination already holding a folder of that name is a
|
||||
/// conflict, not an overwrite, and it is caught before anything moves.
|
||||
#[tokio::test]
|
||||
async fn a_squatted_destination_refuses_the_whole_move() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let new = tempfile::tempdir().expect("new root");
|
||||
point_root_at(&state, 1, old.path()).await;
|
||||
|
||||
let first = add_movie(&base, 100, "Dune", 1).await;
|
||||
let second = add_movie(&base, 101, "Arrival", 1).await;
|
||||
let dune = library_folder(&state, first, old.path(), "Dune").await;
|
||||
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
|
||||
tokio::fs::create_dir_all(new.path().join("Arrival"))
|
||||
.await
|
||||
.expect("squatter");
|
||||
|
||||
let before = file_paths(&state).await;
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("move the root");
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
|
||||
assert!(dune.exists(), "not even the first folder moved");
|
||||
assert!(arrival.exists());
|
||||
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
|
||||
assert_eq!(
|
||||
stored_path(&base, 1).await,
|
||||
old.path().to_str().expect("utf-8"),
|
||||
"the root still points where the files are"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration review of the Feedback pass 2 blitz: the retry the module
|
||||
/// documents has to actually converge. An undo is best-effort, so a move
|
||||
/// can fail and leave one folder at the destination with its row still
|
||||
/// naming the source. Every later attempt used to 409 against the
|
||||
/// operator's own half-moved library, and the only way out was moving the
|
||||
/// folder back by hand.
|
||||
#[tokio::test]
|
||||
async fn a_folder_left_at_the_destination_by_a_failed_undo_does_not_block_the_retry() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let new = tempfile::tempdir().expect("new root");
|
||||
point_root_at(&state, 1, old.path()).await;
|
||||
|
||||
let first = add_movie(&base, 100, "Dune", 1).await;
|
||||
let second = add_movie(&base, 101, "Arrival", 1).await;
|
||||
let dune = library_folder(&state, first, old.path(), "Dune").await;
|
||||
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
|
||||
|
||||
// What a failed undo leaves behind: Arrival is already at the new
|
||||
// path, its row still points at the old one, and the root row was
|
||||
// never changed.
|
||||
tokio::fs::create_dir_all(new.path())
|
||||
.await
|
||||
.expect("the new root");
|
||||
tokio::fs::rename(&arrival, new.path().join("Arrival"))
|
||||
.await
|
||||
.expect("strand the folder");
|
||||
assert!(!arrival.exists());
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("retry the move");
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"the stranded folder is the state we wanted, not a conflict"
|
||||
);
|
||||
|
||||
assert!(!dune.exists(), "the folder still on disk moved");
|
||||
assert!(new.path().join("Dune").exists());
|
||||
assert!(new.path().join("Arrival").exists(), "left where it was");
|
||||
let paths = file_paths(&state).await;
|
||||
let root = new.path().to_str().expect("utf-8");
|
||||
assert!(
|
||||
paths.iter().all(|path| path.starts_with(root)),
|
||||
"every row follows the root, the stranded one included: {paths:?}"
|
||||
);
|
||||
assert_eq!(stored_path(&base, 1).await, root);
|
||||
}
|
||||
|
||||
/// Issue #236: the move is all or nothing. A folder that cannot be
|
||||
/// renamed part-way through takes the whole change down: what had already
|
||||
/// moved is moved back, the root row keeps the old path, and the rows
|
||||
/// still describe the disk, so the operator can fix the folder and send
|
||||
/// the same request again.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn one_folder_that_cannot_move_puts_the_others_back() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let home = tempfile::tempdir().expect("home of the new path");
|
||||
let new = home.path().join("relocated-main");
|
||||
point_root_at(&state, 1, old.path()).await;
|
||||
|
||||
let mut folders = Vec::new();
|
||||
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
|
||||
let id = add_movie(&base, tmdb_id, title, 1).await;
|
||||
folders.push(library_folder(&state, id, old.path(), title).await);
|
||||
}
|
||||
// Moving a directory to another parent rewrites its `..`, which needs
|
||||
// write permission on the directory itself: the second title cannot
|
||||
// move, the first already has.
|
||||
let stuck = folders[1].clone();
|
||||
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
|
||||
.await
|
||||
.expect("freeze the second title folder");
|
||||
|
||||
let before = file_paths(&state).await;
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("move the root");
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body: serde_json::Value = response.json().await.expect("error body");
|
||||
assert!(
|
||||
body["error"]
|
||||
.as_str()
|
||||
.expect("text")
|
||||
.contains(stuck.to_str().expect("utf-8")),
|
||||
"the error names the folder that stopped the move: {body}"
|
||||
);
|
||||
|
||||
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
|
||||
.await
|
||||
.expect("thaw the second title folder");
|
||||
for folder in &folders {
|
||||
assert!(
|
||||
folder.join("feature.mkv").exists(),
|
||||
"{} is back where the row says it is",
|
||||
folder.display()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
new.exists(),
|
||||
"the move reached the disk: the new path was created for it"
|
||||
);
|
||||
assert!(
|
||||
!new.join("Dune").exists(),
|
||||
"the folder that had already moved was moved back"
|
||||
);
|
||||
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
|
||||
assert_eq!(
|
||||
stored_path(&base, 1).await,
|
||||
old.path().to_str().expect("utf-8"),
|
||||
"the root still points where the files are"
|
||||
);
|
||||
|
||||
// The same request again, with the folder fixed, is the retry path.
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("retry the move");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
for title in ["Dune", "Arrival", "Sicario"] {
|
||||
assert!(new.join(title).join("feature.mkv").exists());
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #236: a TV root carries series folders, whose files hang off
|
||||
/// episodes rather than off the title row. They move with the root too.
|
||||
#[tokio::test]
|
||||
async fn a_tv_root_moves_its_series_folders() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let home = tempfile::tempdir().expect("home of the new path");
|
||||
let new = home.path().join("relocated-tv");
|
||||
point_root_at(&state, 3, old.path()).await;
|
||||
|
||||
let pool = state.database().expect("database").pool();
|
||||
sqlx::query(
|
||||
"INSERT INTO series (id, tmdb_id, title, year, original_language, root_id)
|
||||
VALUES (1, 82728, 'Bluey', 2018, 'en', 3)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("series");
|
||||
sqlx::query("INSERT INTO seasons (id, series_id, number) VALUES (1, 1, 1)")
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("season");
|
||||
sqlx::query(
|
||||
"INSERT INTO episodes (id, season_id, number, title) VALUES (1, 1, 2, 'Hospital')",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("episode");
|
||||
let season = old
|
||||
.path()
|
||||
.join("Bluey (2018) [tmdbid-82728]")
|
||||
.join("Season 01");
|
||||
tokio::fs::create_dir_all(&season)
|
||||
.await
|
||||
.expect("create season folder");
|
||||
let episode = season.join("Bluey - S01E02.mkv");
|
||||
tokio::fs::write(&episode, b"episode").await.expect("write");
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', 1, ?, 9)",
|
||||
)
|
||||
.bind(episode.to_str().expect("utf-8 path"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("media file");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/3"))
|
||||
.json(&root_payload(&base, 3, new.to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("move the root");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
assert!(
|
||||
new.join("Bluey (2018) [tmdbid-82728]")
|
||||
.join("Season 01")
|
||||
.join("Bluey - S01E02.mkv")
|
||||
.exists(),
|
||||
"the whole series folder moved, season layout intact"
|
||||
);
|
||||
let paths = file_paths(&state).await;
|
||||
assert!(
|
||||
std::path::Path::new(&paths[0]).starts_with(&new)
|
||||
&& std::path::Path::new(&paths[0]).exists(),
|
||||
"the episode row follows the file: {}",
|
||||
paths[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #236: a path another root already holds is refused before the
|
||||
/// disk is touched at all.
|
||||
#[tokio::test]
|
||||
async fn a_path_another_root_holds_is_refused_before_any_move() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let taken = tempfile::tempdir().expect("taken root");
|
||||
point_root_at(&state, 1, old.path()).await;
|
||||
point_root_at(&state, 2, taken.path()).await;
|
||||
let id = add_movie(&base, 100, "Dune", 1).await;
|
||||
let folder = library_folder(&state, id, old.path(), "Dune").await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.put(format!("{base}/api/roots/1"))
|
||||
.json(&root_payload(&base, 1, taken.path().to_str().expect("utf-8")).await)
|
||||
.send()
|
||||
.await
|
||||
.expect("move the root");
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
assert!(folder.exists(), "nothing moved");
|
||||
assert!(!taken.path().join("Dune").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,7 +734,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
|
||||
Ok(metadata) => metadata,
|
||||
// Already gone is the state we wanted.
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
||||
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||
};
|
||||
let removed = if metadata.is_dir() {
|
||||
tokio::fs::remove_dir_all(&target).await
|
||||
@@ -744,7 +744,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
|
||||
match removed {
|
||||
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
||||
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
//! the no-PT-source queue, or hard-failed twice on different releases (the
|
||||
//! same queues `GET /api/queues/attention` reports, §9.3).
|
||||
//!
|
||||
//! §5.7 sets the bar for the hard-fail side: two failures on *different*
|
||||
//! releases, both inside `arr_db::ATTENTION_WINDOW`, against a target still
|
||||
//! waiting for a file. One bad torrent is not a decision, a failure already
|
||||
//! dealt with ages out (#226), and a target that has since been acquired
|
||||
//! leaves at once (#238). The season lane reads that last condition off its
|
||||
//! episodes, which is where intent lives (§4.1). `GET /api/queues/attention`
|
||||
//! filters identically, or the two channels tell the operator different
|
||||
//! stories.
|
||||
//!
|
||||
//! Edge-triggered per title: it notifies once when the title enters either
|
||||
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
|
||||
//! again. A series notifies as its series, never per episode — a broken
|
||||
@@ -34,8 +43,8 @@ struct TvEntry {
|
||||
no_pt_source: Vec<i64>,
|
||||
/// Episodes two different releases hard-failed post-probe (§5.7).
|
||||
hard_failed_episodes: Vec<i64>,
|
||||
/// Seasons whose pack grab hard-failed, sending the season back to
|
||||
/// per-episode grabbing.
|
||||
/// Seasons two different pack releases hard-failed on (§5.7), sending the
|
||||
/// season back to per-episode grabbing.
|
||||
failed_season_packs: Vec<i64>,
|
||||
}
|
||||
|
||||
@@ -59,8 +68,8 @@ impl TvEntry {
|
||||
if !self.failed_season_packs.is_empty() {
|
||||
parts.push(plural(
|
||||
self.failed_season_packs.len(),
|
||||
"season pack hard-failed",
|
||||
"season packs hard-failed",
|
||||
"season hard-failed twice on different packs",
|
||||
"seasons hard-failed twice on different packs",
|
||||
));
|
||||
}
|
||||
parts.join("; ")
|
||||
@@ -134,8 +143,10 @@ impl AttentionAction {
|
||||
AND (SELECT count(DISTINCT g.release_id)
|
||||
FROM grabs g
|
||||
WHERE g.target_kind = 'movie' AND g.target_id = movies.id
|
||||
AND g.state = 'failed') >= 2
|
||||
"#
|
||||
AND g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
@@ -209,9 +220,14 @@ fn tv_entry(
|
||||
|
||||
/// TV roll-up (§9.5): every queued series with what put it there — wanted
|
||||
/// episodes whose every candidate was rejected for language, episodes two
|
||||
/// different releases hard-failed post-probe, and seasons whose pack grab
|
||||
/// hard-failed. One entry per series, so the notification can be one per
|
||||
/// different releases hard-failed post-probe, and seasons two different packs
|
||||
/// hard-failed on. One entry per series, so the notification can be one per
|
||||
/// series however long the broken season is.
|
||||
///
|
||||
/// Both hard-fail lanes carry §5.7's liveness condition: an episode is queued
|
||||
/// only while `wanted` and not `available`, and a season only while at least
|
||||
/// one of its episodes is. A season pack that failed twice and then fell back
|
||||
/// to per-episode grabbing (§6.2) drops out as those episodes land.
|
||||
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
|
||||
let mut tv = HashMap::new();
|
||||
|
||||
@@ -250,10 +266,12 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
AND e.wanted = 1 AND e.state != 'available'
|
||||
GROUP BY s.id, s.title, s.year, e.id
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
@@ -271,8 +289,16 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
|
||||
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM episodes e
|
||||
WHERE e.season_id = se.id
|
||||
AND e.wanted = 1 AND e.state != 'available'
|
||||
)
|
||||
GROUP BY s.id, s.title, s.year, se.id
|
||||
"#
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
@@ -382,6 +408,54 @@ mod tests {
|
||||
series_id
|
||||
}
|
||||
|
||||
/// A wanted, missing episode: the least that keeps its season live for
|
||||
/// §5.7's liveness condition.
|
||||
async fn insert_wanted_episode(database: &Db, season_id: i64, number: i64) -> i64 {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
||||
VALUES (?, ?, ?, 1, 'missing') RETURNING id",
|
||||
)
|
||||
.bind(season_id)
|
||||
.bind(number)
|
||||
.bind(format!("Episode {number}"))
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||
/// stamped `age_days` in the past, so §5.7's window can be exercised
|
||||
/// without waiting a month.
|
||||
async fn insert_aged_failed_grab(
|
||||
database: &Db,
|
||||
target_kind: &str,
|
||||
target_id: i64,
|
||||
release_guid: &str,
|
||||
age_days: i64,
|
||||
) {
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible')
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(release_guid)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(target_kind)
|
||||
.bind(target_id)
|
||||
.bind(format!("hash-{release_guid}"))
|
||||
.bind(format!("-{age_days} days"))
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||
/// standing in for what the import tick leaves behind post-probe.
|
||||
async fn insert_failed_grab(
|
||||
@@ -548,9 +622,10 @@ mod tests {
|
||||
.unwrap();
|
||||
insert_failed_grab(&database, "episode", episode_id, "first").await;
|
||||
insert_failed_grab(&database, "episode", episode_id, "second").await;
|
||||
// The pack's failure sent this season back to per-episode grabbing;
|
||||
// it queues the same series, so it must not double the message.
|
||||
// The packs' failures sent this season back to per-episode grabbing;
|
||||
// they queue the same series, so it must not double the message.
|
||||
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||
insert_failed_grab(&database, "season", season_id, "pack-two").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
@@ -631,4 +706,132 @@ mod tests {
|
||||
assert_eq!(second.len(), 0, "leaves the queue once imported");
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// §5.7: the season lane holds to the same two-distinct-releases bar the
|
||||
/// episode lane does, so one bad pack does not notify (#226).
|
||||
#[tokio::test]
|
||||
async fn one_failed_season_pack_does_not_notify() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
insert_no_pt_source_series(&database, 1, 0).await;
|
||||
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_wanted_episode(&database, season_id, 1).await;
|
||||
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
0,
|
||||
"one failed pack is the blacklist working, not a decision"
|
||||
);
|
||||
|
||||
insert_failed_grab(&database, "season", season_id, "pack-two").await;
|
||||
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
1,
|
||||
"two distinct packs hard-failed: the operator decides"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.7: the queue only holds targets still waiting for a file. A season
|
||||
/// whose packs both hard-failed falls back to per-episode grabbing (§6.2);
|
||||
/// once every episode has landed the system worked, so the season leaves
|
||||
/// the queue at once rather than notifying for 30 days (#238).
|
||||
#[tokio::test]
|
||||
async fn a_fully_acquired_season_leaves_the_queue() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
insert_no_pt_source_series(&database, 1, 0).await;
|
||||
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_wanted_episode(&database, season_id, 1).await;
|
||||
insert_wanted_episode(&database, season_id, 2).await;
|
||||
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||
insert_failed_grab(&database, "season", season_id, "pack-two").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
1,
|
||||
"two packs failed and the season still has episodes missing"
|
||||
);
|
||||
|
||||
// Per-episode grabbing got the first one. Still a gap, still queued.
|
||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1")
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
0,
|
||||
"already notified, and still queued"
|
||||
);
|
||||
|
||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?")
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
0,
|
||||
"every episode acquired: nothing left to decide"
|
||||
);
|
||||
|
||||
// Proof it actually left rather than merely staying quiet: a season
|
||||
// still queued would not notify again on re-entry.
|
||||
sqlx::query("UPDATE episodes SET state = 'missing' WHERE season_id = ? AND number = 2")
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
1,
|
||||
"broken again: re-enters the queue and notifies"
|
||||
);
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// §5.7: a failure counts for 30 days, so a season dealt with leaves the
|
||||
/// queue instead of sitting in it forever (#226).
|
||||
#[tokio::test]
|
||||
async fn season_failures_older_than_the_window_do_not_notify() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
insert_no_pt_source_series(&database, 1, 0).await;
|
||||
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_wanted_episode(&database, season_id, 1).await;
|
||||
insert_aged_failed_grab(&database, "season", season_id, "old-one", 40).await;
|
||||
insert_aged_failed_grab(&database, "season", season_id, "old-two", 35).await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
0,
|
||||
"failures older than the window are history, not attention"
|
||||
);
|
||||
|
||||
insert_aged_failed_grab(&database, "season", season_id, "new-one", 0).await;
|
||||
insert_aged_failed_grab(&database, "season", season_id, "new-two", 0).await;
|
||||
|
||||
assert_eq!(
|
||||
action.tick(&database).await.unwrap().len(),
|
||||
1,
|
||||
"still breaking: back in the queue"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,15 @@ use sqlx::{migrate::MigrateError, SqlitePool};
|
||||
/// The migrations embedded in the binary, so a deploy is one file.
|
||||
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
||||
|
||||
/// §5.7: how long a failed grab keeps counting toward the needs-a-decision
|
||||
/// queue, as a SQLite time modifier.
|
||||
///
|
||||
/// Nothing ever clears a `grabs` row, so without a bound the queue only grows
|
||||
/// and the one season that wants attention sits behind the ones that do not.
|
||||
/// Callers pair it with the `grabbed_at` format:
|
||||
/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`.
|
||||
pub const ATTENTION_WINDOW: &str = "-30 days";
|
||||
|
||||
/// How long a writer waits for the write lock before giving up.
|
||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
|
||||
+20
-12
@@ -218,10 +218,7 @@
|
||||
-->
|
||||
|
||||
<main class="deck" id="movie" hidden aria-label="movie detail">
|
||||
<header class="releases-head">
|
||||
<button type="button" class="control" id="movie-back">back</button>
|
||||
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
|
||||
</header>
|
||||
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
|
||||
|
||||
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
|
||||
<div class="movie-body">
|
||||
@@ -266,12 +263,19 @@
|
||||
<button type="button" class="control" id="movie-sweep">search indexers</button>
|
||||
<button
|
||||
type="button"
|
||||
class="control control-quiet"
|
||||
class="control control-quiet control-icon"
|
||||
id="movie-remove"
|
||||
aria-expanded="false"
|
||||
aria-controls="remove-panel"
|
||||
aria-label="remove from the library"
|
||||
>
|
||||
remove
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M2.8 4.3h10.4" />
|
||||
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
|
||||
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
|
||||
<path d="M6.6 7v4.2" />
|
||||
<path d="M9.4 7v4.2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="remove-panel" id="remove-panel" hidden></div>
|
||||
@@ -470,10 +474,7 @@
|
||||
-->
|
||||
|
||||
<main class="deck releases" id="series" hidden aria-label="series detail">
|
||||
<header class="releases-head">
|
||||
<button type="button" class="control" id="series-back">back</button>
|
||||
<p class="deck-status readout" id="series-status" role="status" hidden></p>
|
||||
</header>
|
||||
<p class="deck-status readout" id="series-status" role="status" hidden></p>
|
||||
|
||||
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
|
||||
<div class="movie-body">
|
||||
@@ -506,12 +507,19 @@
|
||||
<span class="movie-controls-space"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="control control-quiet"
|
||||
class="control control-quiet control-icon"
|
||||
id="series-remove"
|
||||
aria-expanded="false"
|
||||
aria-controls="series-remove-panel"
|
||||
aria-label="remove from the library"
|
||||
>
|
||||
remove
|
||||
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M2.8 4.3h10.4" />
|
||||
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
|
||||
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
|
||||
<path d="M6.6 7v4.2" />
|
||||
<path d="M9.4 7v4.2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="remove-panel" id="series-remove-panel" hidden></div>
|
||||
|
||||
+208
-59
@@ -103,7 +103,7 @@ import {
|
||||
type TvTarget,
|
||||
waiveAndGrabTv,
|
||||
} from "./series";
|
||||
import { armedDelete, settingsMain } from "./settings";
|
||||
import { settingsMain } from "./settings";
|
||||
import "./style.css";
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
@@ -393,14 +393,14 @@ function main() {
|
||||
must<HTMLElement>("#library"),
|
||||
{ kind: "library" },
|
||||
);
|
||||
// no origin click to restore focus to on a deep link — the series
|
||||
// view's back control is the closest stand-in
|
||||
// no origin click to restore focus to on a deep link — the library
|
||||
// rail button is the closest stand-in
|
||||
tvDeck.open({
|
||||
title,
|
||||
sub,
|
||||
seriesId,
|
||||
target,
|
||||
origin: must<HTMLButtonElement>("#series-back"),
|
||||
origin: must<HTMLElement>("#nav-library"),
|
||||
returnTo: must<HTMLElement>("#series"),
|
||||
parentRoute: { kind: "series", seriesId },
|
||||
});
|
||||
@@ -1263,6 +1263,118 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- icon-only controls (#230) ----------------------------------------- */
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
const ICON_PATHS = {
|
||||
trash: [
|
||||
"M2.8 4.3h10.4",
|
||||
"M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2",
|
||||
"M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5",
|
||||
"M6.6 7v4.2",
|
||||
"M9.4 7v4.2",
|
||||
],
|
||||
search: ["M7.2 2.6a4.4 4.4 0 1 1 0 8.8 4.4 4.4 0 0 1 0-8.8Z", "M10.4 10.4l3.2 3.2"],
|
||||
disk: [
|
||||
"M2.5 9.8l1.6-5c.2-.7.8-1.2 1.5-1.2h4.8c.7 0 1.3.5 1.5 1.2l1.6 5",
|
||||
"M2.5 9.8h11v2.5c0 .7-.5 1.2-1.2 1.2H3.7c-.7 0-1.2-.5-1.2-1.2z",
|
||||
"M10.9 11.6h.01",
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */
|
||||
function icon(name: keyof typeof ICON_PATHS): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 16 16");
|
||||
svg.setAttribute("class", "icon");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
for (const d of ICON_PATHS[name]) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("d", d);
|
||||
svg.append(path);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
/** §9.6 provider marks, drawn and shipped inline — never a remote image. */
|
||||
function providerMark(name: "tmdb" | "tvdb" | "imdb" | "rt"): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("class", "icon icon-mark");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
if (name === "rt") {
|
||||
svg.setAttribute("viewBox", "0 0 20 20");
|
||||
const paths = [
|
||||
"M10 6.6c4.1 0 6.8 2.1 6.8 5.2 0 3.3-3 5.7-6.8 5.7s-6.8-2.4-6.8-5.7c0-3.1 2.7-5.2 6.8-5.2Z",
|
||||
"M10 6.6C8.6 7.1 7 6.8 5.9 5.8",
|
||||
"M10 6.6c1.4.5 3 .2 4.1-.8",
|
||||
"M10 6.6c-.3-1.2 0-2.3.9-3.2",
|
||||
];
|
||||
for (const d of paths) {
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("d", d);
|
||||
svg.append(path);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
svg.setAttribute("viewBox", "0 0 44 20");
|
||||
const box = document.createElementNS(SVG_NS, "rect");
|
||||
box.setAttribute("x", "1");
|
||||
box.setAttribute("y", "1.5");
|
||||
box.setAttribute("width", "42");
|
||||
box.setAttribute("height", "17");
|
||||
box.setAttribute("rx", "4");
|
||||
const letters = document.createElementNS(SVG_NS, "text");
|
||||
letters.setAttribute("x", "22");
|
||||
letters.setAttribute("y", "13.2");
|
||||
letters.setAttribute("text-anchor", "middle");
|
||||
letters.setAttribute("font-size", "9");
|
||||
letters.textContent = { tmdb: "TMDB", tvdb: "TVDB", imdb: "IMDb" }[name];
|
||||
svg.append(box, letters);
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings arm-then-confirm, icon-only: a trash glyph resting, the
|
||||
* visible word "confirm" while armed, so an accidental hit never destroys.
|
||||
*/
|
||||
function armedDeleteIcon(label: string, execute: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "control control-quiet control-icon";
|
||||
button.setAttribute("aria-label", label);
|
||||
button.title = label;
|
||||
button.replaceChildren(icon("trash"));
|
||||
let armed = false;
|
||||
let resetTimer: number | undefined;
|
||||
const disarm = () => {
|
||||
armed = false;
|
||||
window.clearTimeout(resetTimer);
|
||||
delete button.dataset.armed;
|
||||
button.setAttribute("aria-label", label);
|
||||
button.replaceChildren(icon("trash"));
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
if (armed) {
|
||||
disarm();
|
||||
button.disabled = true;
|
||||
execute();
|
||||
return;
|
||||
}
|
||||
armed = true;
|
||||
button.dataset.armed = "true";
|
||||
button.textContent = "confirm";
|
||||
button.setAttribute("aria-label", `confirm — ${label}`);
|
||||
resetTimer = window.setTimeout(disarm, 4000);
|
||||
});
|
||||
button.addEventListener("blur", () => {
|
||||
if (armed) {
|
||||
disarm();
|
||||
}
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/* ---- §9.6 painters shared by both detail pages ------------------------- */
|
||||
|
||||
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
|
||||
@@ -1281,20 +1393,21 @@ function starIcon(): SVGSVGElement {
|
||||
}
|
||||
|
||||
/** External links read as quiet controls; they leave the app entirely. */
|
||||
function externalLink(label: string, href: string): HTMLAnchorElement {
|
||||
function externalLink(mark: SVGSVGElement, label: string, href: string): HTMLAnchorElement {
|
||||
const link = document.createElement("a");
|
||||
link.className = "control control-quiet";
|
||||
link.className = "control control-quiet control-icon";
|
||||
link.href = href;
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
link.textContent = label;
|
||||
link.setAttribute("aria-label", label);
|
||||
link.title = label;
|
||||
link.append(mark);
|
||||
return link;
|
||||
}
|
||||
|
||||
function movieMain(views: HideableView[]): MovieView {
|
||||
const view = must<HTMLElement>("#movie");
|
||||
const deckEl = must<HTMLElement>("#deck");
|
||||
const back = must<HTMLButtonElement>("#movie-back");
|
||||
const statusEl = must<HTMLElement>("#movie-status");
|
||||
const hero = must<HTMLElement>("#movie-hero");
|
||||
const poster = must<HTMLImageElement>("#movie-poster");
|
||||
@@ -1414,6 +1527,8 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
}
|
||||
titleEl.textContent = movie.title;
|
||||
yearEl.textContent = movie.year === null ? "" : String(movie.year);
|
||||
remove.setAttribute("aria-label", `remove ${movie.title} from the library`);
|
||||
remove.title = `remove ${movie.title} from the library`;
|
||||
chipsEl.replaceChildren();
|
||||
const root = roots.find((candidate) => candidate.id === movie.root_id);
|
||||
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
|
||||
@@ -1606,13 +1721,26 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
trailer.textContent = "trailer";
|
||||
actionsEl.append(trailer);
|
||||
}
|
||||
actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id)));
|
||||
const linkTitle = current?.title ?? "this movie";
|
||||
actionsEl.append(
|
||||
externalLink(
|
||||
providerMark("tmdb"),
|
||||
`open ${linkTitle} on TMDB`,
|
||||
tmdbMovieLink(detail.tmdb_id),
|
||||
),
|
||||
);
|
||||
if (detail.imdb_id !== null) {
|
||||
actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id)));
|
||||
actionsEl.append(
|
||||
externalLink(providerMark("imdb"), `open ${linkTitle} on IMDb`, imdbLink(detail.imdb_id)),
|
||||
);
|
||||
}
|
||||
if (current !== null) {
|
||||
actionsEl.append(
|
||||
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
|
||||
externalLink(
|
||||
providerMark("rt"),
|
||||
`search Rotten Tomatoes for ${current.title}`,
|
||||
rottenTomatoesSearch(current.title, current.year),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1906,7 +2034,8 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
filesSection.hidden = true;
|
||||
diskRows.replaceChildren();
|
||||
sweep.disabled = false;
|
||||
back.focus();
|
||||
titleEl.setAttribute("tabindex", "-1");
|
||||
titleEl.focus();
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -1929,8 +2058,6 @@ function movieMain(views: HideableView[]): MovieView {
|
||||
target?.focus();
|
||||
}
|
||||
|
||||
back.addEventListener("click", close);
|
||||
|
||||
// capture + stopImmediatePropagation: one Escape steps back one layer —
|
||||
// the library and search decks also listen for Escape on this window
|
||||
window.addEventListener(
|
||||
@@ -3125,7 +3252,6 @@ const SERIES_REFRESH_WAIT_MS = 30_000;
|
||||
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
const view = must<HTMLElement>("#series");
|
||||
const deckEl = must<HTMLElement>("#deck");
|
||||
const back = must<HTMLButtonElement>("#series-back");
|
||||
const hero = must<HTMLElement>("#series-hero");
|
||||
const poster = must<HTMLImageElement>("#series-poster");
|
||||
const titleEl = must<HTMLElement>("#series-title");
|
||||
@@ -3230,6 +3356,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
}
|
||||
titleEl.textContent = current.title;
|
||||
yearEl.textContent = current.year === null ? "" : String(current.year);
|
||||
remove.setAttribute("aria-label", `remove ${current.title} from the library`);
|
||||
remove.title = `remove ${current.title} from the library`;
|
||||
chipsEl.replaceChildren();
|
||||
const root = roots.find((candidate) => candidate.id === current.root_id);
|
||||
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
|
||||
@@ -3332,26 +3460,41 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
trailer.textContent = "trailer";
|
||||
actionsEl.append(trailer);
|
||||
}
|
||||
actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id)));
|
||||
const linkTitle = series?.title ?? "this series";
|
||||
actionsEl.append(
|
||||
externalLink(
|
||||
providerMark("tmdb"),
|
||||
`open ${linkTitle} on TMDB`,
|
||||
tmdbSeriesLink(detail.tmdb_id),
|
||||
),
|
||||
);
|
||||
// a series has no imdb_id in this app; TVDB is its second id (§9.6)
|
||||
if (detail.tvdb_id !== null) {
|
||||
actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id)));
|
||||
actionsEl.append(
|
||||
externalLink(providerMark("tvdb"), `open ${linkTitle} on TVDB`, tvdbLink(detail.tvdb_id)),
|
||||
);
|
||||
}
|
||||
if (series !== null) {
|
||||
actionsEl.append(
|
||||
externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
|
||||
externalLink(
|
||||
providerMark("rt"),
|
||||
`search Rotten Tomatoes for ${series.title}`,
|
||||
rottenTomatoesSearch(series.title, series.year),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
|
||||
const counts = seasonCounts(season);
|
||||
return countsChip(
|
||||
`${counts.available}/${counts.wanted} on disk`,
|
||||
const span = countsChip(
|
||||
`${counts.available}/${counts.wanted}`,
|
||||
counts.available,
|
||||
counts.wanted,
|
||||
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
|
||||
);
|
||||
span.prepend(icon("disk"));
|
||||
return span;
|
||||
}
|
||||
|
||||
function seasonRow(season: ApiSeason): HTMLLIElement {
|
||||
@@ -3418,8 +3561,14 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
|
||||
const deckBtn = document.createElement("button");
|
||||
deckBtn.type = "button";
|
||||
deckBtn.className = "control";
|
||||
deckBtn.textContent = "deck";
|
||||
deckBtn.className = "control control-icon";
|
||||
const deckLabel =
|
||||
season.number === 0
|
||||
? "open the specials release deck"
|
||||
: `open the season ${PAD_TWO(season.number)} release deck`;
|
||||
deckBtn.setAttribute("aria-label", deckLabel);
|
||||
deckBtn.title = deckLabel;
|
||||
deckBtn.append(icon("search"));
|
||||
deckBtn.addEventListener("click", () => {
|
||||
const currentId = seriesId;
|
||||
const currentTitle = series?.title ?? "";
|
||||
@@ -3452,26 +3601,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
// #174: files go, episodes stop being wanted, the season stays listed.
|
||||
// Only offered with files on disk — intent alone is the tracked toggle.
|
||||
if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) {
|
||||
const clear = armedDelete("remove files", () => {
|
||||
const currentId = seriesId;
|
||||
if (currentId === null) {
|
||||
return;
|
||||
}
|
||||
void removeSeasonFiles(currentId, season.number).then((outcome) => {
|
||||
if (outcome.kind === "error") {
|
||||
clear.disabled = false;
|
||||
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
||||
const clear = armedDeleteIcon(
|
||||
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
|
||||
() => {
|
||||
const currentId = seriesId;
|
||||
if (currentId === null) {
|
||||
return;
|
||||
}
|
||||
// the control itself disappears with the files; the tracked
|
||||
// toggle is the season's control that survives the repaint
|
||||
focusKey = `track-${season.number}`;
|
||||
void load();
|
||||
});
|
||||
});
|
||||
clear.setAttribute(
|
||||
"aria-label",
|
||||
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
|
||||
void removeSeasonFiles(currentId, season.number).then((outcome) => {
|
||||
if (outcome.kind === "error") {
|
||||
clear.disabled = false;
|
||||
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
// the control itself disappears with the files; the tracked
|
||||
// toggle is the season's control that survives the repaint
|
||||
focusKey = `track-${season.number}`;
|
||||
void load();
|
||||
});
|
||||
},
|
||||
);
|
||||
line.append(clear);
|
||||
}
|
||||
@@ -3539,21 +3687,20 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
if (aired && onDisk) {
|
||||
// #174: the file goes and the episode stops being wanted; the row
|
||||
// stays listed. Same arm-then-confirm as a settings row.
|
||||
const clear = armedDelete("remove file", () => {
|
||||
void removeEpisodeFiles(episode.id).then((outcome) => {
|
||||
if (outcome.kind === "error") {
|
||||
clear.disabled = false;
|
||||
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
// once missing, the row's want control is what remains to focus
|
||||
focusKey = `want-${episode.id}`;
|
||||
void load();
|
||||
});
|
||||
});
|
||||
clear.setAttribute(
|
||||
"aria-label",
|
||||
const clear = armedDeleteIcon(
|
||||
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`,
|
||||
() => {
|
||||
void removeEpisodeFiles(episode.id).then((outcome) => {
|
||||
if (outcome.kind === "error") {
|
||||
clear.disabled = false;
|
||||
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
// once missing, the row's want control is what remains to focus
|
||||
focusKey = `want-${episode.id}`;
|
||||
void load();
|
||||
});
|
||||
},
|
||||
);
|
||||
actions.append(clear);
|
||||
} else if (!aired) {
|
||||
@@ -3584,8 +3731,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
|
||||
const deckBtn = document.createElement("button");
|
||||
deckBtn.type = "button";
|
||||
deckBtn.className = "control";
|
||||
deckBtn.textContent = "deck";
|
||||
deckBtn.className = "control control-icon";
|
||||
const deckLabel = `open the ${episodeTag(seasonNumber, episode.number)} release deck`;
|
||||
deckBtn.setAttribute("aria-label", deckLabel);
|
||||
deckBtn.title = deckLabel;
|
||||
deckBtn.append(icon("search"));
|
||||
deckBtn.addEventListener("click", () => {
|
||||
const currentId = seriesId;
|
||||
const currentTitle = series?.title ?? "";
|
||||
@@ -3793,7 +3943,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
view.hidden = false;
|
||||
clearRemove();
|
||||
clearRichDetail();
|
||||
back.focus();
|
||||
titleEl.setAttribute("tabindex", "-1");
|
||||
titleEl.focus();
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -3814,8 +3965,6 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
origin?.focus();
|
||||
}
|
||||
|
||||
back.addEventListener("click", close);
|
||||
|
||||
// capture, like every other layer: the tv deck's listener is registered
|
||||
// first, so one Esc steps back one layer
|
||||
window.addEventListener(
|
||||
|
||||
@@ -470,6 +470,62 @@ body {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---- icon-only controls (#230) ---------------------------------------- */
|
||||
|
||||
/* One block, shared: title pages, decks and readouts here, the settings
|
||||
rows via #231. The glyph carries the action, the aria-label carries the
|
||||
meaning, and the geometry stays on the --control-h rail with the chips. */
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
flex: none;
|
||||
height: 0.875rem;
|
||||
width: auto;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.control-icon {
|
||||
min-width: var(--control-h);
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
/* provider wordmark badges (§9.6): the mark is drawn and shipped inline,
|
||||
monochrome on currentColor — never a remote image */
|
||||
.icon-mark {
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.icon-mark rect {
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
/* the mark's letters live in viewBox units, so the size rides the SVG
|
||||
scale as an attribute; only voice and weight come from the tokens */
|
||||
.icon-mark text {
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
font-family: var(--font-readout);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* a readout chip's glyph sits beside its figure, sized to the readout */
|
||||
.chip .icon {
|
||||
height: 0.75rem;
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
/* rows widen every .control's padding; an icon control stays near-square */
|
||||
.season-line .control-icon,
|
||||
.ep-actions .control-icon,
|
||||
.movie-controls .control-icon {
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
/* ---- unified search (§9.2) ------------------------------------------- */
|
||||
|
||||
.rail-search {
|
||||
@@ -528,6 +584,12 @@ body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* movie and series: the hero banner meets the rail — no top padding (#229) */
|
||||
.deck#movie,
|
||||
.deck#series {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.deck-status {
|
||||
margin: 0 0 var(--space-4);
|
||||
color: var(--ink-muted);
|
||||
|
||||
Reference in New Issue
Block a user