Merge milestone 'Removal and navigation'
Closes #169, #170, #171, #172, #173, #174, #175
This commit is contained in:
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (\n SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "32c562fdb70cabb3265e351583b6ac1579490b29da670fe9062117451911576a"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (\n SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3531a16bd038c149465397a70c24c14ed005683c68f06d322409c5c2b39b84d1"
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3a02f30ced2765dcdbf7575d680c1b4ab1db895065f65db4a99bb0a4dc6e71ed"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE episodes\n SET wanted = ?,\n state = CASE WHEN state = 'available' THEN 'missing' ELSE state END,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "58fe68be59f015fd8214306483c0d45fcc4b2ac595d3b04efb224b3e426cc1cf"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT r.path AS \"path!: String\"\n FROM roots r\n JOIN series s ON s.root_id = r.id\n JOIN seasons se ON se.series_id = s.id\n JOIN episodes e ON e.season_id = se.id\n WHERE e.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5b059a2f93a3fb46b6c55e0b665aa55931045af290894e396b3b410123bfd539"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (\n SELECT e.id FROM episodes e WHERE e.season_id = ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6feaa1abb79ce2a0298bef052d3b731e8ccec88228f7a81b2e8ff5cee76f3fa4"
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7c638b148b697645d250fe43cf7fcc049e3754e5b1837ff54289e8908523d40c"
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average, s.tmdb_id AS \"series_tmdb_id!: i64\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "episode_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "tag!: String",
|
||||
"ordinal": 3,
|
||||
"type_info": "Null"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "poster_path",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vote_average",
|
||||
"ordinal": 6,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "series_tmdb_id!: i64",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4"
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "90813e43938fcf91b96e444354a5a41b345fec599ba1562ae1fdf617ef49ff54"
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "97033814ba641d94878f16f23224b49cbad1d65ea0f130d091995bfed87cb844"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT r.path AS \"path!: String\"\n FROM roots r\n JOIN series s ON s.root_id = r.id\n JOIN seasons se ON se.series_id = s.id\n WHERE se.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "98af62bdeb2be99ece2ebcb3567773b3af29299658a21681237cfa11cc806a39"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9f2ce3364cd2c227d6dbccf9973d29ca685cdaa1fb2b93124e099b0bbd6a26af"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dca537a0b312f758af06608c729790566082d7db2928220fc2f1509e0015423e"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n WHERE e.season_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e5c40363c81cebf7026b49699981218e15b57d565039f3de2d48b320d3cec92b"
|
||||
}
|
||||
@@ -139,9 +139,12 @@ manually marked S02 needs no special case: three wanted episodes, nothing else.
|
||||
|
||||
`Season.tracked` is also a rule, not intent. Turning tracking on marks every
|
||||
already-revealed episode in the season wanted, and keeps marking episodes
|
||||
wanted as metadata reveals them. Turning it off withdraws nothing: leaf intent
|
||||
is never removed implicitly, so anything already marked wanted stays wanted
|
||||
until cleared explicitly.
|
||||
wanted as metadata reveals them. Turning it off clears `wanted` on every
|
||||
episode of that season: toggling the flag is itself the explicit act, and the
|
||||
product offers no other way to withdraw a season's worth of intent. Without
|
||||
that, untracking leaves a series with every season off and every episode still
|
||||
wanted, pinned at `incomplete` with no way back. Marking individual episodes
|
||||
wanted on a season that was never tracked is unaffected by any of this.
|
||||
|
||||
Both rules skip season 0. Specials are dozens of undated shorts and recaps
|
||||
that indexers do not carry, so `auto_track` never marks season 0 tracked. See
|
||||
@@ -485,12 +488,10 @@ lower-priority crate** (§9.4), not the primary shape.
|
||||
|
||||
### 9.2 Unified search
|
||||
|
||||
One box. Two grouped result sets: **in library** first (title match, and for TV
|
||||
also episode title, so `bluey hospital` finds the episode), **on TMDB** below.
|
||||
An episode row surfaces only when the query matches something beyond the series
|
||||
title: a query satisfied by the series title alone returns the series row and
|
||||
nothing below it. Enter on a TMDB result opens the add flow with root and
|
||||
policy pre-filled.
|
||||
One box. Two grouped result sets: **in library** first, **on TMDB** below. Both
|
||||
sets are titles — series and movies. Episode rows never appear, under any
|
||||
query, and there are no season rows. Enter on a TMDB result opens the add flow
|
||||
with root and policy pre-filled.
|
||||
|
||||
The same box accepts a raw TMDB or IMDb ID, and a pasted magnet or `.torrent`,
|
||||
which skips to the manual-grab flow.
|
||||
@@ -597,6 +598,22 @@ Out of scope here, because they are the adjacent scope most likely to creep:
|
||||
watch providers, recommendations or similar titles, collections, person pages
|
||||
inside the app, review text.
|
||||
|
||||
### 9.7 The shell
|
||||
|
||||
**The library is the homepage.** `/` renders the library view described in
|
||||
§9.6. It is what the operator opens the app to look at, so it is what the app
|
||||
opens on.
|
||||
|
||||
**The signal chain is a settings section.** The four upstreams — tmdb,
|
||||
prowlarr, arr, transmission — and their lamps live inside `/settings`, and have
|
||||
no route of their own. Per-upstream health is something you check when
|
||||
something is wrong, not a homepage.
|
||||
|
||||
**The master lamp stays on the rail**, so the at-a-glance read that the chain
|
||||
is healthy survives the move and no navigation is needed to get it.
|
||||
|
||||
**The wordmark is a link home.** `arr` on the rail navigates to `/`.
|
||||
|
||||
## 10. Persistence
|
||||
|
||||
SQLite via `sqlx`, compile-time-checked queries, migrations in `arr-db`.
|
||||
|
||||
@@ -90,7 +90,9 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(series::get, series::update, series::delete))
|
||||
.routes(routes!(series::seasons, series::create_season))
|
||||
.routes(routes!(series::update_season))
|
||||
.routes(routes!(series::delete_season_files))
|
||||
.routes(routes!(series::get_episode, series::update_episode))
|
||||
.routes(routes!(series::delete_episode_files))
|
||||
.routes(routes!(series::search_episode))
|
||||
.routes(routes!(series::episode_releases))
|
||||
.routes(routes!(series::grab_episode))
|
||||
@@ -334,6 +336,11 @@ mod tests {
|
||||
"/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab",
|
||||
"post",
|
||||
),
|
||||
(
|
||||
"/api/series/{series_id}/seasons/{season_number}/files",
|
||||
"delete",
|
||||
),
|
||||
("/api/episodes/{episode_id}/files", "delete"),
|
||||
("/api/queues/attention", "get"),
|
||||
("/api/trailer", "get"),
|
||||
("/api/series", "get"),
|
||||
|
||||
@@ -33,8 +33,7 @@ pub struct ReleasesQuery {
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SearchResponse {
|
||||
pub kind: SearchInputKind,
|
||||
/// In-library hits, grouped first (§9.2): movies and series by title,
|
||||
/// episodes by episode title with their series and `SxxEyy` for context.
|
||||
/// In-library hits, grouped first (§9.2): movies and series by title.
|
||||
pub library: Vec<LibraryResult>,
|
||||
pub tmdb: Vec<TmdbResult>,
|
||||
pub manual: Option<String>,
|
||||
@@ -47,7 +46,6 @@ pub struct SearchResponse {
|
||||
pub enum LibraryResult {
|
||||
Movie(Movie),
|
||||
Series(LibrarySeries),
|
||||
Episode(LibraryEpisode),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -65,25 +63,6 @@ pub struct LibrarySeries {
|
||||
pub vote_average: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct LibraryEpisode {
|
||||
pub episode_id: i64,
|
||||
pub series_id: i64,
|
||||
pub series_title: String,
|
||||
/// `SxxEyy`, so the episode title reads in context (§9.2).
|
||||
pub tag: String,
|
||||
/// The episode title — what the search matched on.
|
||||
pub title: String,
|
||||
/// The series' poster — an episode has no artwork of its own worth
|
||||
/// showing at row size.
|
||||
pub poster_path: Option<String>,
|
||||
/// The series' rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
/// The series' TMDB id — the episode row's trailer chip resolves through
|
||||
/// it (#148); an episode has no videos of its own worth listing.
|
||||
pub series_tmdb_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SearchInputKind {
|
||||
@@ -196,7 +175,7 @@ pub async fn search(
|
||||
let tokens = like_tokens(input);
|
||||
|
||||
// §9.2 keeps the two result sets grouped and the library first, so each
|
||||
// kind lands in its own block: movies, then series, then episodes.
|
||||
// kind lands in its own block: movies, then series.
|
||||
let mut library: Vec<LibraryResult> = if matches!(kind, SearchInputKind::TmdbId) {
|
||||
let tmdb_id = input
|
||||
.strip_prefix("tmdb:")
|
||||
@@ -232,16 +211,6 @@ pub async fn search(
|
||||
};
|
||||
library.extend(series_rows.into_iter().map(LibraryResult::Series));
|
||||
|
||||
// §9.2 names the TV case explicitly: `bluey hospital` finds the episode.
|
||||
// Tokens may split across the series and episode titles, so every token
|
||||
// must land in the concatenation — and at least one must match the
|
||||
// episode title on its own, or a series-title-only query would list
|
||||
// every episode the series has.
|
||||
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens, tokens)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
library.extend(episode_rows.into_iter().map(LibraryResult::Episode));
|
||||
|
||||
let tmdb = tmdb_client(&state)?;
|
||||
let results = search_tmdb(&tmdb, kind, input).await?;
|
||||
if matches!(kind, SearchInputKind::ImdbId) {
|
||||
@@ -641,10 +610,9 @@ fn input_kind(input: &str) -> SearchInputKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// §9.2's example — `bluey hospital` finding the episode — needs word-wise
|
||||
/// matching, not one literal phrase. The query's tokens travel as a JSON
|
||||
/// array the queries walk with `json_each`; LIKE metacharacters are escaped
|
||||
/// here rather than in SQL.
|
||||
/// A multi-word title query needs word-wise matching, not one literal
|
||||
/// phrase. The query's tokens travel as a JSON array the queries walk with
|
||||
/// `json_each`; LIKE metacharacters are escaped here rather than in SQL.
|
||||
fn like_tokens(input: &str) -> serde_json::Value {
|
||||
serde_json::Value::Array(
|
||||
input
|
||||
@@ -889,11 +857,12 @@ mod tests {
|
||||
assert_eq!(response["tmdb"][0]["vote_count"], 5000);
|
||||
}
|
||||
|
||||
/// §9.2: the in-library set matches series titles and episode titles, so
|
||||
/// `bluey hospital` finds the episode with its series and `SxxEyy` along
|
||||
/// for context — and plain `bluey` finds the series itself.
|
||||
/// §9.2, amended: episode rows never appear, under any query. A query
|
||||
/// matching an episode title and nothing else returns no rows for that
|
||||
/// series beyond the series itself, and a query naming both the series
|
||||
/// and one of its episode titles still surfaces only the series row.
|
||||
#[tokio::test]
|
||||
async fn library_results_cover_series_and_episode_titles() {
|
||||
async fn library_results_never_include_episode_rows() {
|
||||
let tmdb = MockServer::start().await;
|
||||
let prowlarr = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
@@ -941,26 +910,6 @@ mod tests {
|
||||
.await
|
||||
.expect("episode");
|
||||
|
||||
let response: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/search?q=bluey%20hospital"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["library"].as_array().expect("library").len(), 1);
|
||||
let episode = &response["library"][0];
|
||||
assert_eq!(episode["kind"], "episode");
|
||||
assert_eq!(episode["series_title"], "Bluey");
|
||||
assert_eq!(episode["tag"], "S01E02");
|
||||
assert_eq!(episode["title"], "Hospital");
|
||||
// TMDB carries the TV result below the library set.
|
||||
assert_eq!(response["tmdb"][0]["kind"], "series");
|
||||
assert_eq!(response["tmdb"][0]["title"], "Bluey");
|
||||
|
||||
// §9.2, amended: an episode row surfaces only when the query matches
|
||||
// something beyond the series title. `bluey` alone returns the series
|
||||
// row and no episode below it.
|
||||
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey"))
|
||||
.await
|
||||
.expect("search")
|
||||
@@ -971,19 +920,25 @@ mod tests {
|
||||
assert_eq!(library.len(), 1);
|
||||
assert_eq!(library[0]["kind"], "series");
|
||||
assert_eq!(library[0]["title"], "Bluey");
|
||||
assert_eq!(library[0]["tmdb_id"], 82_728);
|
||||
// TMDB carries the TV result below the library set.
|
||||
assert_eq!(response["tmdb"][0]["kind"], "series");
|
||||
assert_eq!(response["tmdb"][0]["title"], "Bluey");
|
||||
|
||||
// An episode-title-only query still finds the episode.
|
||||
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=hospital"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
let library = response["library"].as_array().expect("library");
|
||||
assert_eq!(library.len(), 1);
|
||||
assert_eq!(library[0]["kind"], "episode");
|
||||
assert_eq!(library[0]["title"], "Hospital");
|
||||
// A query matching only the episode's title, not the series title,
|
||||
// returns no rows for that series — no episode row, and no series
|
||||
// row either, since the series title alone does not match.
|
||||
for query in ["hospital", "bluey%20hospital"] {
|
||||
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q={query}"))
|
||||
.await
|
||||
.expect("search")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert!(
|
||||
response["library"].as_array().expect("library").is_empty(),
|
||||
"query: {query}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+762
-56
@@ -16,7 +16,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arr_core::tracking::{apply_auto_track, RefreshedSeason};
|
||||
use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason};
|
||||
use arr_core::{
|
||||
derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId,
|
||||
SeriesStatus, TitleOverrides,
|
||||
@@ -153,9 +153,11 @@ pub struct CreateEpisode {
|
||||
/// Both fields of a season a person can set by hand.
|
||||
///
|
||||
/// Turning `tracked` on marks every already-revealed episode wanted, and
|
||||
/// episodes revealed later follow while it stays on; turning it off withdraws
|
||||
/// nothing (§4.1). `wanted` is the one-click "grab this season", and writes
|
||||
/// intent onto every episode already in it — the two are deliberately
|
||||
/// episodes revealed later follow while it stays on; turning it off clears
|
||||
/// `wanted` on all of them (§4.1, #171) — idempotently, so re-sending
|
||||
/// `tracked: false` to an already-untracked season still clears its
|
||||
/// stranded wanted rows. `wanted` is the one-click "grab this season", and
|
||||
/// writes intent onto every episode already in it — the two are deliberately
|
||||
/// separate (§4.1).
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateSeason {
|
||||
@@ -558,19 +560,12 @@ pub async fn delete(
|
||||
// The row is loaded first so a missing series is 404 before anything
|
||||
// touches the disk.
|
||||
load_series_row(&state, id).await?;
|
||||
remove_library_files(&state, id).await?;
|
||||
// `media_files.path` is UNIQUE and the owner is polymorphic, so nothing
|
||||
// cascades from the seasons and episodes rows (which the series row's
|
||||
// delete does): leaving the rows behind would block re-importing the
|
||||
// same paths after a re-add. Owner tags go with the title they tagged.
|
||||
sqlx::query!(
|
||||
"DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (
|
||||
SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id
|
||||
WHERE se.series_id = ?)",
|
||||
id
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
// The whole-title scope of the same removal the season and episode
|
||||
// endpoints use (#174). The intent clear it performs is redundant here —
|
||||
// the episode rows go with the series row below — but sharing one path
|
||||
// is what keeps the three scopes from drifting apart.
|
||||
remove_scope_files(&state, FileScope::Series(id)).await?;
|
||||
// Owner tags go with the title they tagged.
|
||||
sqlx::query!(
|
||||
"DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?",
|
||||
id
|
||||
@@ -586,44 +581,55 @@ pub async fn delete(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Unlink everything this series put under its root. Mirrors the movie
|
||||
/// handler in `movies.rs`.
|
||||
/// What one removal call covers. The series variant is the whole title; the
|
||||
/// other two are the sub-series scopes #174 adds, and they must never widen
|
||||
/// past themselves — removing one episode leaves its siblings and the rest
|
||||
/// of the season on disk.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum FileScope {
|
||||
/// Series row id.
|
||||
Series(i64),
|
||||
/// Season row id, not its number.
|
||||
Season(i64),
|
||||
/// Episode row id.
|
||||
Episode(i64),
|
||||
}
|
||||
|
||||
/// Unlink what this scope put under its series' root. Mirrors the movie
|
||||
/// handler in `movies.rs`, and is the only unlink path below a series (#174).
|
||||
///
|
||||
/// The service knows only what it wrote (§2), so the targets come from
|
||||
/// `media_files`, never from a scan and never from re-deriving the §7.4 name
|
||||
/// — a series renamed after import would derive a folder that does not exist
|
||||
/// while the real one stayed. Each episode file resolves to its title folder,
|
||||
/// which makes the delete atomic (§7.4): season subfolders, sidecar subtitles
|
||||
/// and artwork go with it.
|
||||
/// while the real one stayed.
|
||||
///
|
||||
/// What a target *is* depends on the scope. A whole series resolves each file
|
||||
/// to its title folder, which makes that delete atomic (§7.4): season
|
||||
/// subfolders, sidecar subtitles and artwork go with it. A season or a single
|
||||
/// episode resolves to the recorded file and nothing else — the title folder
|
||||
/// holds the siblings this call must not touch, and a season subfolder would
|
||||
/// have to be re-derived to be named, which §2 forbids. Sidecars beside a
|
||||
/// removed episode therefore stay; they are not rows this service wrote.
|
||||
///
|
||||
/// The torrent is untouched (§7.3). It keeps seeding under its own rule and
|
||||
/// the reaper deletes it; a hardlinked file loses only its library name.
|
||||
///
|
||||
/// Failure leaves the database alone, so the operator sees the series still
|
||||
/// there and can retry rather than losing the record of what is on disk.
|
||||
async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> {
|
||||
let root = sqlx::query_scalar!(
|
||||
r#"SELECT r.path AS "path!: String" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool(state)?)
|
||||
.await?;
|
||||
let paths = sqlx::query_scalar!(
|
||||
r#"SELECT 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
|
||||
WHERE se.series_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
/// Failure leaves the database alone, so the operator sees the files still
|
||||
/// recorded and can retry rather than losing the record of what is on disk.
|
||||
async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(), ApiError> {
|
||||
let root = scope_root(state, scope).await?;
|
||||
let paths = scope_paths(state, scope).await?;
|
||||
|
||||
let mut targets: Vec<std::path::PathBuf> = Vec::new();
|
||||
for path in &paths {
|
||||
let Some(target) = crate::movies::title_target(&root, path) else {
|
||||
let resolved = match scope {
|
||||
FileScope::Series(_) => crate::movies::title_target(&root, path),
|
||||
FileScope::Season(_) | FileScope::Episode(_) => contained_file(&root, path),
|
||||
};
|
||||
let Some(target) = resolved else {
|
||||
// Outside its own root: not ours to delete. The row still goes,
|
||||
// so the operator sees the series leave and the file stay.
|
||||
// so the operator sees the file leave the library and stay on
|
||||
// disk.
|
||||
tracing::warn!(%path, %root, "media file is outside its root, not deleted");
|
||||
continue;
|
||||
};
|
||||
@@ -653,6 +659,274 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The library root the scope's series sits on. Every unlink is measured
|
||||
/// against it, so it is looked up rather than assumed.
|
||||
async fn scope_root(state: &AppState, scope: FileScope) -> Result<String, ApiError> {
|
||||
Ok(match scope {
|
||||
FileScope::Series(id) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT r.path AS "path!: String" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool(state)?)
|
||||
.await?
|
||||
}
|
||||
FileScope::Season(id) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT r.path AS "path!: String"
|
||||
FROM roots r
|
||||
JOIN series s ON s.root_id = r.id
|
||||
JOIN seasons se ON se.series_id = s.id
|
||||
WHERE se.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool(state)?)
|
||||
.await?
|
||||
}
|
||||
FileScope::Episode(id) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT r.path AS "path!: String"
|
||||
FROM roots r
|
||||
JOIN series s ON s.root_id = r.id
|
||||
JOIN seasons se ON se.series_id = s.id
|
||||
JOIN episodes e ON e.season_id = se.id
|
||||
WHERE e.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool(state)?)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Every file this service recorded for the scope, and nothing else. The
|
||||
/// `WHERE` clause is the whole guard against a narrow call widening.
|
||||
async fn scope_paths(state: &AppState, scope: FileScope) -> Result<Vec<String>, ApiError> {
|
||||
Ok(match scope {
|
||||
FileScope::Series(id) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT 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
|
||||
WHERE se.series_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
}
|
||||
FileScope::Season(id) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT mf.path AS "path!: String"
|
||||
FROM media_files mf
|
||||
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
|
||||
WHERE e.season_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
}
|
||||
FileScope::Episode(id) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT path AS "path!: String"
|
||||
FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The one recorded file, when it really sits inside the root. `None` when it
|
||||
/// does not, which is the guard that keeps a sub-series delete inside the
|
||||
/// library it belongs to.
|
||||
///
|
||||
/// Unlike [`crate::movies::title_target`] this keeps the whole relative path
|
||||
/// rather than its first component, so it can only ever name the file the row
|
||||
/// records. Every component must be a plain name: one `..` anywhere would
|
||||
/// climb back out of the root it just proved it was under.
|
||||
fn contained_file(root: &str, file: &str) -> Option<std::path::PathBuf> {
|
||||
let root = std::path::Path::new(root);
|
||||
let relative = std::path::Path::new(file).strip_prefix(root).ok()?;
|
||||
if relative.as_os_str().is_empty() {
|
||||
// The root itself is never a file of ours.
|
||||
return None;
|
||||
}
|
||||
if !relative
|
||||
.components()
|
||||
.all(|component| matches!(component, std::path::Component::Normal(_)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(root.join(relative))
|
||||
}
|
||||
|
||||
/// Removes the files a scope below a series covers, drops their `media_files`
|
||||
/// rows and clears the intent behind them (#174).
|
||||
///
|
||||
/// One action, both halves: the operator asked for removal and un-wanting
|
||||
/// together, not two controls to remember to use in order.
|
||||
///
|
||||
/// The season and episode rows themselves stay. TMDB owns that metadata and
|
||||
/// the next refresh would recreate them, which is what separates this from
|
||||
/// `DELETE /api/series/{id}`.
|
||||
///
|
||||
/// Disk first, database second, so a filesystem failure leaves the rows
|
||||
/// describing what is still there.
|
||||
async fn remove_scope_files(state: &AppState, scope: FileScope) -> Result<(), ApiError> {
|
||||
remove_library_files(state, scope).await?;
|
||||
|
||||
// One transaction: the file rows and the intent they carried land
|
||||
// together or not at all.
|
||||
let mut transaction = pool(state)?.begin().await?;
|
||||
// `media_files.path` is UNIQUE and the owner is polymorphic, so nothing
|
||||
// cascades and nothing here deletes an owning row: leaving these behind
|
||||
// would block re-importing the same path after a re-grab.
|
||||
match scope {
|
||||
FileScope::Series(id) => {
|
||||
sqlx::query!(
|
||||
"DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (
|
||||
SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id
|
||||
WHERE se.series_id = ?)",
|
||||
id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
FileScope::Season(id) => {
|
||||
sqlx::query!(
|
||||
"DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (
|
||||
SELECT e.id FROM episodes e WHERE e.season_id = ?)",
|
||||
id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
FileScope::Episode(id) => {
|
||||
sqlx::query!(
|
||||
"DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
|
||||
id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// The intent goes through `arr_core::tracking::apply_tracked` with
|
||||
// `false`, the same §4.1 rule an untracked season runs (#171), so both
|
||||
// paths agree on what clearing intent means. An episode-scoped call
|
||||
// hands it a slice of one: the rule cannot reach a sibling it was not
|
||||
// given.
|
||||
let rows = match scope {
|
||||
FileScope::Series(id) => {
|
||||
sqlx::query_as!(
|
||||
EpisodeRow,
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?
|
||||
}
|
||||
FileScope::Season(id) => {
|
||||
sqlx::query_as!(
|
||||
EpisodeRow,
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?
|
||||
}
|
||||
FileScope::Episode(id) => {
|
||||
sqlx::query_as!(
|
||||
EpisodeRow,
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let mut episodes: Vec<_> = rows.iter().map(core_episode).collect();
|
||||
apply_tracked(false, &mut episodes);
|
||||
for episode in &episodes {
|
||||
// `available` was true of an episode with a file. It no longer has
|
||||
// one, so it goes back to `missing` the way a failed import already
|
||||
// puts it — `wanted` is now 0, so this opens no gap. `downloading`
|
||||
// is left alone: that grab is still in flight.
|
||||
sqlx::query!(
|
||||
"UPDATE episodes
|
||||
SET wanted = ?,
|
||||
state = CASE WHEN state = 'available' THEN 'missing' ELSE state END,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
episode.wanted,
|
||||
episode.id.0
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/series/{series_id}/seasons/{season_number}/files", tag = "series",
|
||||
params(
|
||||
("series_id" = i64, Path, description = "Series row id"),
|
||||
("season_number" = i64, Path, description = "Season number, not its row id")
|
||||
),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn delete_season_files(
|
||||
State(state): State<AppState>,
|
||||
Path((series_id, number)): Path<(i64, i64)>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// Resolved first so an unknown series or season is 404 before anything
|
||||
// touches the disk. A season with nothing on disk is not an error — the
|
||||
// call still clears intent.
|
||||
load_series_row(&state, series_id).await?;
|
||||
let season_id = sqlx::query_scalar!(
|
||||
r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#,
|
||||
series_id,
|
||||
number
|
||||
)
|
||||
.fetch_optional(pool(&state)?)
|
||||
.await?
|
||||
.ok_or(ApiError::SeasonNotFound)?;
|
||||
|
||||
remove_scope_files(&state, FileScope::Season(season_id)).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/episodes/{episode_id}/files", tag = "series",
|
||||
params(("episode_id" = i64, Path, description = "Episode row id")),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn delete_episode_files(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// 404 before the disk, and an episode with no file still clears intent.
|
||||
load_episode(&state, id).await?;
|
||||
remove_scope_files(&state, FileScope::Episode(id)).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, ApiError> {
|
||||
let seasons = sqlx::query!(
|
||||
// §9.6: newest-first, so season 0 lands last under plain numeric
|
||||
@@ -870,24 +1144,40 @@ pub async fn update_season(
|
||||
.ok_or(ApiError::SeasonNotFound)?;
|
||||
let season_id = season.id;
|
||||
|
||||
// One transaction: the flag write and the episode intent it implies
|
||||
// land together or not at all (#171).
|
||||
let mut transaction = pool(&state)?.begin().await?;
|
||||
|
||||
if let Some(tracked) = input.tracked {
|
||||
sqlx::query!(
|
||||
"UPDATE seasons SET tracked = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||
tracked,
|
||||
season_id
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
// §4.1, as `arr_core::tracking::apply_tracked` decides it: turning
|
||||
// tracking on marks every already-revealed episode wanted; turning it
|
||||
// off withdraws nothing. Only the off -> on transition writes intent.
|
||||
if tracked && !season.tracked {
|
||||
// tracking on marks every already-revealed episode wanted; turning
|
||||
// it off clears them all, season 0 included. The rule runs even when
|
||||
// the flag already holds this value (#171): re-sending `false` to an
|
||||
// untracked season is how stranded wanted rows get cleared.
|
||||
let rows = sqlx::query_as!(
|
||||
EpisodeRow,
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?"#,
|
||||
season_id
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
let mut episodes: Vec<_> = rows.iter().map(core_episode).collect();
|
||||
apply_tracked(tracked, &mut episodes);
|
||||
for episode in &episodes {
|
||||
sqlx::query!(
|
||||
"UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE season_id = ?",
|
||||
true,
|
||||
season_id
|
||||
"UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||
episode.wanted,
|
||||
episode.id.0
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
@@ -899,9 +1189,10 @@ pub async fn update_season(
|
||||
wanted,
|
||||
season_id
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
|
||||
let seasons = load_seasons(&state, series_id).await?;
|
||||
seasons
|
||||
@@ -1768,8 +2059,12 @@ mod tests {
|
||||
assert_eq!(episode["wanted"], false);
|
||||
}
|
||||
|
||||
/// #171. The §4.1 rule runs whenever the flag is sent: off clears
|
||||
/// wanted — including on a season that was already untracked, which is
|
||||
/// how rows stranded by the old ruling get out — and on marks every
|
||||
/// revealed episode wanted.
|
||||
#[tokio::test]
|
||||
async fn tracking_a_season_marks_revealed_episodes_and_off_withdraws_nothing() {
|
||||
async fn untracking_a_season_clears_its_episodes_wanted() {
|
||||
async fn patch_season(
|
||||
base: &str,
|
||||
series_id: i64,
|
||||
@@ -1803,6 +2098,26 @@ mod tests {
|
||||
assert_eq!(season["tracked"], false);
|
||||
assert_eq!(season["episodes"][0]["wanted"], false);
|
||||
|
||||
// A season stranded by the old ruling: untracked, yet its episodes
|
||||
// are all wanted. Re-sending the flag it already holds must still
|
||||
// do the work (#171).
|
||||
let updated = patch_season(&base, series_id, serde_json::json!({"wanted": true})).await;
|
||||
assert!(updated["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.all(|episode| episode["wanted"] == true));
|
||||
let updated = patch_season(&base, series_id, serde_json::json!({"tracked": false})).await;
|
||||
assert_eq!(updated["tracked"], false);
|
||||
assert!(
|
||||
updated["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.all(|episode| episode["wanted"] == false),
|
||||
"#171: clearing an already-untracked season is the way out"
|
||||
);
|
||||
|
||||
let updated = patch_season(&base, series_id, serde_json::json!({"tracked": true})).await;
|
||||
assert_eq!(updated["tracked"], true);
|
||||
assert!(
|
||||
@@ -1814,7 +2129,6 @@ mod tests {
|
||||
"§4.1: turning tracked on marks every revealed episode wanted"
|
||||
);
|
||||
|
||||
// Turning it off withdraws nothing.
|
||||
let updated = patch_season(&base, series_id, serde_json::json!({"tracked": false})).await;
|
||||
assert_eq!(updated["tracked"], false);
|
||||
assert!(
|
||||
@@ -1822,11 +2136,31 @@ mod tests {
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.all(|episode| episode["wanted"] == true),
|
||||
"§4.1: leaf intent is never removed implicitly"
|
||||
.all(|episode| episode["wanted"] == false),
|
||||
"§4.1: turning tracked off clears every revealed episode"
|
||||
);
|
||||
}
|
||||
|
||||
/// #171. Untracking a season with no episodes touches nothing and is not
|
||||
/// an error.
|
||||
#[tokio::test]
|
||||
async fn untracking_an_empty_season_is_a_no_op() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let season = add_season(&base, series_id, 1, serde_json::json!([])).await;
|
||||
assert_eq!(season["tracked"], false);
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}/seasons/1"))
|
||||
.json(&serde_json::json!({"tracked": false}))
|
||||
.send()
|
||||
.await
|
||||
.expect("update season");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listed_series_carry_a_derived_status() {
|
||||
let (_dir, state, base) = application().await;
|
||||
@@ -2382,6 +2716,378 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Puts one episode file plus a sidecar into a §7.4 season folder and
|
||||
/// records the file. Returns the file's path.
|
||||
async fn episode_file_on_disk(
|
||||
state: &AppState,
|
||||
episode_id: i64,
|
||||
root: &std::path::Path,
|
||||
season: i64,
|
||||
name: &str,
|
||||
) -> std::path::PathBuf {
|
||||
let folder = root
|
||||
.join("Bluey (2018) [tmdbid-82728]")
|
||||
.join(format!("Season {season:02}"));
|
||||
tokio::fs::create_dir_all(&folder)
|
||||
.await
|
||||
.expect("create season folder");
|
||||
let file = folder.join(name);
|
||||
tokio::fs::write(&file, b"episode").await.expect("write");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)",
|
||||
)
|
||||
.bind(episode_id)
|
||||
.bind(file.to_str().expect("utf-8 path"))
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("media file");
|
||||
file
|
||||
}
|
||||
|
||||
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 wanted_of(state: &AppState, episode_id: i64) -> bool {
|
||||
sqlx::query_scalar::<_, bool>("SELECT wanted FROM episodes WHERE id = ?")
|
||||
.bind(episode_id)
|
||||
.fetch_one(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("wanted")
|
||||
}
|
||||
|
||||
/// #174: one call unlinks the season's files, drops their `media_files`
|
||||
/// rows and clears the intent behind them. The season and episode rows
|
||||
/// stay — TMDB owns that metadata — and the next season is untouched.
|
||||
#[tokio::test]
|
||||
async fn removing_a_season_takes_its_files_and_its_wanted() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
point_root_at(&state, root_id, root.path()).await;
|
||||
|
||||
let first = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "The Magic Xylophone"},
|
||||
{"number": 2, "title": "Hospital"}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
2,
|
||||
serde_json::json!([{"number": 1, "title": "Dance Mode"}]),
|
||||
)
|
||||
.await;
|
||||
let s01e01 = first["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.find(|episode| episode["number"] == 1)
|
||||
.expect("s01e01")["id"]
|
||||
.as_i64()
|
||||
.expect("id");
|
||||
let s01e02 = first["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.find(|episode| episode["number"] == 2)
|
||||
.expect("s01e02")["id"]
|
||||
.as_i64()
|
||||
.expect("id");
|
||||
let s02e01 = second["episodes"][0]["id"].as_i64().expect("id");
|
||||
|
||||
let one = episode_file_on_disk(&state, s01e01, root.path(), 1, "Bluey - S01E01.mkv").await;
|
||||
let two = episode_file_on_disk(&state, s01e02, root.path(), 1, "Bluey - S01E02.mkv").await;
|
||||
let other =
|
||||
episode_file_on_disk(&state, s02e01, root.path(), 2, "Bluey - S02E01.mkv").await;
|
||||
|
||||
let pool = state.database().expect("database").pool();
|
||||
for episode in [s01e01, s01e02, s02e01] {
|
||||
sqlx::query("UPDATE episodes SET wanted = 1, state = 'available' WHERE id = ?")
|
||||
.bind(episode)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("seed wanted");
|
||||
}
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/series/{series_id}/seasons/1/files"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete season files");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
assert!(!one.exists(), "the season's files are gone from disk");
|
||||
assert!(!two.exists(), "the season's files are gone from disk");
|
||||
assert!(other.exists(), "another season's file is not in scope");
|
||||
|
||||
let rows: Vec<String> =
|
||||
sqlx::query_scalar("SELECT path FROM media_files WHERE owner_kind = 'episode'")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("files");
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![other.to_str().expect("utf-8").to_string()],
|
||||
"only the season's rows go"
|
||||
);
|
||||
|
||||
assert!(!wanted_of(&state, s01e01).await, "intent cleared");
|
||||
assert!(!wanted_of(&state, s01e02).await, "across the whole season");
|
||||
assert!(wanted_of(&state, s02e01).await, "and nowhere else");
|
||||
|
||||
let state_of: String = sqlx::query_scalar("SELECT state FROM episodes WHERE id = ?")
|
||||
.bind(s01e01)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("state");
|
||||
assert_eq!(
|
||||
state_of, "missing",
|
||||
"an episode with no file is not available"
|
||||
);
|
||||
|
||||
let seasons: i64 = sqlx::query_scalar("SELECT count(*) FROM seasons WHERE series_id = ?")
|
||||
.bind(series_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seasons");
|
||||
let episodes: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM episodes e JOIN seasons se ON se.id = e.season_id
|
||||
WHERE se.series_id = ?",
|
||||
)
|
||||
.bind(series_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("episodes");
|
||||
assert_eq!(seasons, 2, "TMDB owns the season rows, so they stay");
|
||||
assert_eq!(episodes, 3, "and the episode rows with them");
|
||||
}
|
||||
|
||||
/// #174: the narrow scope really is narrow. Removing one episode leaves
|
||||
/// its sibling's file, row and intent exactly as they were, and leaves
|
||||
/// the season folder standing.
|
||||
#[tokio::test]
|
||||
async fn removing_one_episode_leaves_its_siblings_alone() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
point_root_at(&state, root_id, root.path()).await;
|
||||
|
||||
let season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "The Magic Xylophone"},
|
||||
{"number": 2, "title": "Hospital"}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let episodes = season["episodes"].as_array().expect("episodes");
|
||||
let first = episodes
|
||||
.iter()
|
||||
.find(|episode| episode["number"] == 1)
|
||||
.expect("s01e01")["id"]
|
||||
.as_i64()
|
||||
.expect("id");
|
||||
let second = episodes
|
||||
.iter()
|
||||
.find(|episode| episode["number"] == 2)
|
||||
.expect("s01e02")["id"]
|
||||
.as_i64()
|
||||
.expect("id");
|
||||
|
||||
let one = episode_file_on_disk(&state, first, root.path(), 1, "Bluey - S01E01.mkv").await;
|
||||
let two = episode_file_on_disk(&state, second, root.path(), 1, "Bluey - S01E02.mkv").await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
for episode in [first, second] {
|
||||
sqlx::query("UPDATE episodes SET wanted = 1 WHERE id = ?")
|
||||
.bind(episode)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("seed wanted");
|
||||
}
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/episodes/{first}/files"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete episode files");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
assert!(!one.exists(), "the episode's file is gone");
|
||||
assert!(two.exists(), "its sibling's file is not in scope");
|
||||
assert!(
|
||||
two.parent().expect("season folder").exists(),
|
||||
"and neither is the season folder around them"
|
||||
);
|
||||
assert!(!wanted_of(&state, first).await, "intent cleared for it");
|
||||
assert!(wanted_of(&state, second).await, "and not for its sibling");
|
||||
|
||||
let remaining: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT owner_id FROM media_files WHERE owner_kind = 'episode'")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("files");
|
||||
assert_eq!(remaining, vec![second], "only the episode's row goes");
|
||||
}
|
||||
|
||||
/// #174: removal is not conditional on there being anything to remove.
|
||||
/// A scope with no files still clears intent, and still answers 204.
|
||||
#[tokio::test]
|
||||
async fn removing_a_scope_with_no_files_still_clears_intent() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
point_root_at(&state, root_id, root.path()).await;
|
||||
|
||||
let season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([{"number": 1, "title": "The Magic Xylophone"}]),
|
||||
)
|
||||
.await;
|
||||
let episode_id = season["episodes"][0]["id"].as_i64().expect("id");
|
||||
sqlx::query("UPDATE episodes SET wanted = 1 WHERE id = ?")
|
||||
.bind(episode_id)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("seed wanted");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/series/{series_id}/seasons/1/files"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete season files");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
assert!(!wanted_of(&state, episode_id).await, "intent still cleared");
|
||||
|
||||
// And again on the episode, which now has neither file nor intent.
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/episodes/{episode_id}/files"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete episode files");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
/// #174: an unknown scope is 404, and an unknown series is 404 even for
|
||||
/// a season number that exists under some other series.
|
||||
#[tokio::test]
|
||||
async fn removing_files_from_an_unknown_scope_is_a_404() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([{"number": 1, "title": "The Magic Xylophone"}]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
for path in [
|
||||
format!("api/series/{series_id}/seasons/9/files"),
|
||||
"api/series/999/seasons/1/files".to_string(),
|
||||
"api/episodes/999/files".to_string(),
|
||||
] {
|
||||
let response = client
|
||||
.delete(format!("{base}/{path}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete");
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #174 reuses the §7.4 containment guard: a recorded path that is not
|
||||
/// under the series' root is left on disk, whatever the row says. Its
|
||||
/// row still goes, so the library stops claiming the file.
|
||||
#[tokio::test]
|
||||
async fn an_episode_file_outside_its_root_is_never_unlinked() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
point_root_at(&state, root_id, root.path()).await;
|
||||
|
||||
let season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([{"number": 1, "title": "The Magic Xylophone"}]),
|
||||
)
|
||||
.await;
|
||||
let episode_id = season["episodes"][0]["id"].as_i64().expect("id");
|
||||
|
||||
let elsewhere = tempfile::tempdir().expect("elsewhere");
|
||||
let stray = elsewhere.path().join("not-ours.mkv");
|
||||
tokio::fs::write(&stray, b"stray").await.expect("write");
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 5)",
|
||||
)
|
||||
.bind(episode_id)
|
||||
.bind(stray.to_str().expect("utf-8 path"))
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("media file");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/episodes/{episode_id}/files"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete episode files");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
assert!(
|
||||
stray.exists(),
|
||||
"a path outside the root is not ours to delete"
|
||||
);
|
||||
}
|
||||
|
||||
/// The sub-series guard keeps the whole relative path, so it can only
|
||||
/// name the recorded file — and one `..` anywhere is enough to refuse.
|
||||
#[test]
|
||||
fn a_contained_file_is_the_recorded_path_under_the_root() {
|
||||
let root = "/mnt/media/tv/main";
|
||||
assert_eq!(
|
||||
contained_file(
|
||||
root,
|
||||
"/mnt/media/tv/main/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv"
|
||||
),
|
||||
Some(std::path::PathBuf::from(
|
||||
"/mnt/media/tv/main/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv"
|
||||
))
|
||||
);
|
||||
assert_eq!(contained_file(root, "/mnt/media/tv/kids/other.mkv"), None);
|
||||
assert_eq!(contained_file(root, root), None);
|
||||
assert_eq!(
|
||||
contained_file(root, "/mnt/media/tv/main/../kids/other.mkv"),
|
||||
None,
|
||||
"one `..` climbs back out of the root it was under"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_dates_parse_as_dates_and_as_timestamps() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -38,14 +38,16 @@ pub fn apply_auto_track(series: &Series, ever_refreshed: bool, seasons: &mut [Re
|
||||
/// Applies a season's tracking rule (`DESIGN.md` §4.1) to its
|
||||
/// already-revealed episodes.
|
||||
///
|
||||
/// Turning tracking on marks every revealed episode wanted; future reveals
|
||||
/// follow while the season stays tracked. Turning it off withdraws nothing:
|
||||
/// leaf intent is never removed implicitly.
|
||||
/// Turning tracking on marks every revealed episode wanted, and future
|
||||
/// reveals follow while the season stays tracked. Turning it off clears
|
||||
/// `wanted` on all of them: toggling the flag is itself the explicit act,
|
||||
/// and the product offers no other way to withdraw a season's worth of
|
||||
/// intent. The rule is idempotent and must run even when the flag already
|
||||
/// has the value asked for (#171) — an operator re-sending `tracked: false`
|
||||
/// to an already-untracked season is clearing stranded intent.
|
||||
pub fn apply_tracked(tracked: bool, episodes: &mut [Episode]) {
|
||||
if tracked {
|
||||
for episode in episodes {
|
||||
episode.wanted = true;
|
||||
}
|
||||
for episode in episodes {
|
||||
episode.wanted = tracked;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,8 +218,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #171. Toggling the flag is the explicit act, so `false` clears
|
||||
/// wanted on every revealed episode — including when it is re-sent to a
|
||||
/// season that was already untracked, which is the only way out for
|
||||
/// rows stranded by the old ruling.
|
||||
#[test]
|
||||
fn tracked_off_withdraws_nothing() {
|
||||
fn tracked_off_clears_revealed_episodes_wanted() {
|
||||
let cases: [&[bool]; 4] = [
|
||||
&[],
|
||||
&[false],
|
||||
@@ -236,18 +242,13 @@ mod tests {
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let before = episodes
|
||||
.iter()
|
||||
.map(|episode| episode.wanted)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
apply_tracked(false, &mut episodes);
|
||||
|
||||
let after = episodes
|
||||
.iter()
|
||||
.map(|episode| episode.wanted)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(before, after, "tracked off must not touch intent");
|
||||
assert!(
|
||||
episodes.iter().all(|episode| !episode.wanted),
|
||||
"tracked off must clear every revealed episode, input {wanted:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+97
-66
@@ -63,12 +63,18 @@
|
||||
nothing needs them. The rail control carries an amber entry count,
|
||||
refreshed with the health poll; amber is attention, cyan stays the
|
||||
interaction hue.
|
||||
SHELL (§9.7, issue 173): the library is the homepage — "/" renders the
|
||||
library deck, and the wordmark on the rail is a link home. The signal
|
||||
chain above is no longer a route: it lives as the first section of
|
||||
/settings, its lamps still driven by the health poll, and the master
|
||||
lamp stays on the rail so the one-glance read survives the move. Where
|
||||
earlier notes here say "returns to the board", read "the library".
|
||||
-->
|
||||
<header class="rail">
|
||||
<div class="rail-inner">
|
||||
<div class="rail-id">
|
||||
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h1 class="wordmark">arr</h1>
|
||||
<a class="wordmark" id="wordmark" href="/">arr</a>
|
||||
</div>
|
||||
<nav class="rail-nav" aria-label="views">
|
||||
<button type="button" class="control" id="nav-library" aria-pressed="false">
|
||||
@@ -361,6 +367,74 @@
|
||||
</header>
|
||||
<p class="deck-status readout" id="settings-status" role="status" hidden></p>
|
||||
|
||||
<section class="deck-group" id="group-chain" aria-labelledby="label-chain">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label" id="label-chain">signal chain</h3>
|
||||
</header>
|
||||
<section class="chain" aria-label="signal chain">
|
||||
<article class="module area-tmdb" data-check="tmdb">
|
||||
<header class="module-head">
|
||||
<span class="lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h2 class="module-name">tmdb</h2>
|
||||
<span class="module-status readout" data-role="status">probing</span>
|
||||
</header>
|
||||
<p class="module-role">metadata in</p>
|
||||
<p class="module-detail readout" data-role="detail">titles, languages, release dates</p>
|
||||
</article>
|
||||
|
||||
<div class="bus area-t1" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="tmdb" aria-hidden="true">
|
||||
<line x1="0" y1="4" x2="32" y2="4" />
|
||||
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<article class="module area-prowlarr" data-check="prowlarr">
|
||||
<header class="module-head">
|
||||
<span class="lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h2 class="module-name">prowlarr</h2>
|
||||
<span class="module-status readout" data-role="status">probing</span>
|
||||
</header>
|
||||
<p class="module-role">indexers in</p>
|
||||
<p class="module-detail readout" data-role="detail">torznab search and rss</p>
|
||||
</article>
|
||||
|
||||
<div class="bus area-t2" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="prowlarr" aria-hidden="true">
|
||||
<line x1="0" y1="4" x2="32" y2="4" />
|
||||
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<article class="module module-master area-master" data-check="master">
|
||||
<header class="module-head">
|
||||
<span class="lamp lamp-big" data-state="probing" aria-hidden="true"></span>
|
||||
<h2 class="module-name">arr</h2>
|
||||
<span class="module-status readout" data-role="status">probing</span>
|
||||
</header>
|
||||
<p class="module-role">reconcile core</p>
|
||||
<p class="module-detail readout" data-role="detail">decide · find · fetch · import</p>
|
||||
</article>
|
||||
|
||||
<div class="bus area-out" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="master" aria-hidden="true">
|
||||
<line x1="0" y1="4" x2="32" y2="4" />
|
||||
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<article class="module area-transmission" data-check="transmission">
|
||||
<header class="module-head">
|
||||
<span class="lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h2 class="module-name">transmission</h2>
|
||||
<span class="module-status readout" data-role="status">probing</span>
|
||||
</header>
|
||||
<p class="module-role">torrents out</p>
|
||||
<p class="module-detail readout" data-role="detail">rpc · grab and seed</p>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="deck-group" id="group-roots" hidden aria-labelledby="label-roots">
|
||||
<header class="deck-head bucket-head">
|
||||
<h3 class="deck-label" id="label-roots">roots</h3>
|
||||
@@ -421,6 +495,28 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- removal is the one library control a series carries (issue 175):
|
||||
the same arming panel a movie uses — evidence first, then the
|
||||
decision — wired to DELETE /api/series/{id} -->
|
||||
<section class="deck-group" id="series-library" aria-label="library controls">
|
||||
<header class="deck-head">
|
||||
<h3 class="deck-label">library</h3>
|
||||
</header>
|
||||
<div class="movie-controls">
|
||||
<span class="movie-controls-space"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="control control-quiet"
|
||||
id="series-remove"
|
||||
aria-expanded="false"
|
||||
aria-controls="series-remove-panel"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="remove-panel" id="series-remove-panel" hidden></div>
|
||||
</section>
|
||||
|
||||
<ul class="deck-rows seasons" id="rows-seasons"></ul>
|
||||
</main>
|
||||
|
||||
@@ -437,70 +533,5 @@
|
||||
<div id="tv-buckets"></div>
|
||||
</main>
|
||||
|
||||
<main class="board">
|
||||
<section class="chain" aria-label="signal chain">
|
||||
<article class="module area-tmdb" data-check="tmdb">
|
||||
<header class="module-head">
|
||||
<span class="lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h2 class="module-name">tmdb</h2>
|
||||
<span class="module-status readout" data-role="status">probing</span>
|
||||
</header>
|
||||
<p class="module-role">metadata in</p>
|
||||
<p class="module-detail readout" data-role="detail">titles, languages, release dates</p>
|
||||
</article>
|
||||
|
||||
<div class="bus area-t1" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="tmdb" aria-hidden="true">
|
||||
<line x1="0" y1="4" x2="32" y2="4" />
|
||||
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<article class="module area-prowlarr" data-check="prowlarr">
|
||||
<header class="module-head">
|
||||
<span class="lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h2 class="module-name">prowlarr</h2>
|
||||
<span class="module-status readout" data-role="status">probing</span>
|
||||
</header>
|
||||
<p class="module-role">indexers in</p>
|
||||
<p class="module-detail readout" data-role="detail">torznab search and rss</p>
|
||||
</article>
|
||||
|
||||
<div class="bus area-t2" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="prowlarr" aria-hidden="true">
|
||||
<line x1="0" y1="4" x2="32" y2="4" />
|
||||
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<article class="module module-master area-master" data-check="master">
|
||||
<header class="module-head">
|
||||
<span class="lamp lamp-big" data-state="probing" aria-hidden="true"></span>
|
||||
<h2 class="module-name">arr</h2>
|
||||
<span class="module-status readout" data-role="status">probing</span>
|
||||
</header>
|
||||
<p class="module-role">reconcile core</p>
|
||||
<p class="module-detail readout" data-role="detail">decide · find · fetch · import</p>
|
||||
</article>
|
||||
|
||||
<div class="bus area-out" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="master" aria-hidden="true">
|
||||
<line x1="0" y1="4" x2="32" y2="4" />
|
||||
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<article class="module area-transmission" data-check="transmission">
|
||||
<header class="module-head">
|
||||
<span class="lamp" data-state="probing" aria-hidden="true"></span>
|
||||
<h2 class="module-name">transmission</h2>
|
||||
<span class="module-status readout" data-role="status">probing</span>
|
||||
</header>
|
||||
<p class="module-role">torrents out</p>
|
||||
<p class="module-detail readout" data-role="detail">rpc · grab and seed</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+223
-133
@@ -32,6 +32,7 @@ import {
|
||||
type SeriesAttention,
|
||||
} from "./queues";
|
||||
import {
|
||||
type ActionOutcome,
|
||||
bucketOf,
|
||||
type FilesOutcome,
|
||||
formatAudio,
|
||||
@@ -44,7 +45,6 @@ import {
|
||||
formatSweepAge,
|
||||
grabRelease,
|
||||
libraryFolder,
|
||||
type MovieFile,
|
||||
type MovieRelease,
|
||||
movieFiles,
|
||||
movieReleases,
|
||||
@@ -65,7 +65,6 @@ import {
|
||||
addSeries,
|
||||
allRoots,
|
||||
fetchMovie,
|
||||
type LibraryEpisodeHit,
|
||||
type LibraryMovie,
|
||||
type LibrarySeriesHit,
|
||||
parseManualInput,
|
||||
@@ -88,16 +87,20 @@ import {
|
||||
fileAttributeTags,
|
||||
formatAirDate,
|
||||
isUnaired,
|
||||
removeEpisodeFiles,
|
||||
removeSeasonFiles,
|
||||
removeSeries,
|
||||
type SeriesMetadata,
|
||||
seasonCounts,
|
||||
seasonTarget,
|
||||
seriesFolder,
|
||||
seriesMetadata,
|
||||
setEpisodeWanted,
|
||||
setSeasonTracked,
|
||||
type TvTarget,
|
||||
waiveAndGrabTv,
|
||||
} from "./series";
|
||||
import { settingsMain } from "./settings";
|
||||
import { armedDelete, settingsMain } from "./settings";
|
||||
import "./style.css";
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
@@ -123,7 +126,7 @@ function moduleRefs(name: string): ModuleRefs {
|
||||
const status = root?.querySelector<HTMLElement>('[data-role="status"]');
|
||||
const detail = root?.querySelector<HTMLElement>('[data-role="detail"]');
|
||||
if (!lamp || !status || !detail) {
|
||||
throw new Error(`board is missing the ${name} module`);
|
||||
throw new Error(`signal chain is missing the ${name} module`);
|
||||
}
|
||||
return { lamp, status, detail, defaultDetail: detail.textContent ?? "" };
|
||||
}
|
||||
@@ -138,13 +141,13 @@ function setModule(refs: ModuleRefs, state: string, tone: Tone, word: string, de
|
||||
function must<T extends Element>(selector: string): T {
|
||||
const element = document.querySelector<T>(selector);
|
||||
if (!element) {
|
||||
throw new Error(`board markup is missing ${selector}`);
|
||||
throw new Error(`markup is missing ${selector}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const board = must<HTMLElement>(".board");
|
||||
const chain = must<HTMLElement>(".chain");
|
||||
const masterLamp = must<HTMLElement>("#master-lamp");
|
||||
const version = must<HTMLElement>("#version");
|
||||
|
||||
@@ -185,13 +188,13 @@ function main() {
|
||||
function render(probe: Probe) {
|
||||
if (!powered) {
|
||||
powered = true;
|
||||
board.classList.add("powered");
|
||||
chain.classList.add("powered");
|
||||
}
|
||||
|
||||
if (probe.kind === "unreachable") {
|
||||
masterLamp.dataset.state = "unreachable";
|
||||
version.textContent = "v —";
|
||||
// the outage detail lives on the board's arr module — the header has no verdict line
|
||||
// the outage detail lives on the chain's arr module — the header has no verdict line
|
||||
setModule(master, "unreachable", "fault", "down", probe.detail);
|
||||
for (const refs of Object.values(checks)) {
|
||||
setModule(refs, "off", "idle", "no signal");
|
||||
@@ -249,15 +252,16 @@ function main() {
|
||||
// the tv deck registers its Escape handler before the series view does,
|
||||
// so one Esc always steps back from the innermost layer first
|
||||
const tvDeck = tvReleasesMain();
|
||||
const seriesDetail = seriesMain(board, tvDeck, views);
|
||||
const movieDetail = movieMain(board, views);
|
||||
const library = libraryMain(board, movieDetail, seriesDetail, views);
|
||||
const queues = queuesMain(board, movieDetail, views);
|
||||
const settings = settingsMain(board, views);
|
||||
const seriesDetail = seriesMain(tvDeck, views);
|
||||
const movieDetail = movieMain(views);
|
||||
const library = libraryMain(movieDetail, seriesDetail, views);
|
||||
const goHome = () => library.open();
|
||||
const queues = queuesMain(movieDetail, views, goHome);
|
||||
const settings = settingsMain(views, goHome);
|
||||
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
|
||||
const search = searchMain(board, movieDetail, seriesDetail, views);
|
||||
const search = searchMain(movieDetail, seriesDetail, views, goHome);
|
||||
// a removed title must not survive on the surface the page opened over
|
||||
movieDetail.setRemoved((parent) => {
|
||||
const openAfterRemoval = (parent: Route) => {
|
||||
switch (parent.kind) {
|
||||
case "library":
|
||||
library.open();
|
||||
@@ -269,10 +273,12 @@ function main() {
|
||||
search.restore(parent.query);
|
||||
break;
|
||||
default:
|
||||
search.showIdle();
|
||||
library.open();
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
movieDetail.setRemoved(openAfterRemoval);
|
||||
seriesDetail.setRemoved(openAfterRemoval);
|
||||
refreshQueuesBadge = () => {
|
||||
void queues.refreshBadge();
|
||||
};
|
||||
@@ -282,11 +288,10 @@ function main() {
|
||||
// and back/forward reach the same view a click would have.
|
||||
async function applyRoute(route: Route) {
|
||||
switch (route.kind) {
|
||||
case "board":
|
||||
search.showIdle();
|
||||
break;
|
||||
case "library":
|
||||
library.open();
|
||||
// §9.7: "/" is the library — clear any stale query so the rail
|
||||
// input matches the route on a cold load or back-navigation
|
||||
search.showIdle();
|
||||
break;
|
||||
case "queues":
|
||||
queues.open();
|
||||
@@ -401,6 +406,15 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// §9.7: the wordmark is a link home — intercepted so it navigates the
|
||||
// SPA instead of reloading, but still a real anchor for the keyboard
|
||||
const wordmark = must<HTMLAnchorElement>("#wordmark");
|
||||
wordmark.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
navigate({ kind: "library" });
|
||||
search.showIdle();
|
||||
});
|
||||
|
||||
window.addEventListener("popstate", () => {
|
||||
void applyRoute(currentRoute());
|
||||
});
|
||||
@@ -425,17 +439,17 @@ interface DeckRefs {
|
||||
}
|
||||
|
||||
interface SearchView {
|
||||
/** Restores the board with the search box empty — the "/" route. */
|
||||
/** Restores the library with the search box empty — the "/" route. */
|
||||
showIdle: () => void;
|
||||
/** Restores the deck for `query` — the "/search?q=" route. */
|
||||
restore: (query: string) => void;
|
||||
}
|
||||
|
||||
function searchMain(
|
||||
board: HTMLElement,
|
||||
movieDetail: MovieView,
|
||||
seriesDetail: SeriesView,
|
||||
views: HideableView[],
|
||||
goHome: () => void,
|
||||
): SearchView {
|
||||
const input = must<HTMLInputElement>("#search");
|
||||
const hint = must<HTMLElement>("#search-hint");
|
||||
@@ -492,21 +506,17 @@ function searchMain(
|
||||
let timer: number | undefined;
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
function showBoard() {
|
||||
/** A cleared search lands on the library — the "/" route (§9.7). */
|
||||
function showHome() {
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
for (const view of views) {
|
||||
view.hide();
|
||||
}
|
||||
refs.deck.hidden = true;
|
||||
board.hidden = false;
|
||||
goHome();
|
||||
}
|
||||
|
||||
function showDeck() {
|
||||
for (const view of views) {
|
||||
view.hide();
|
||||
}
|
||||
board.hidden = true;
|
||||
refs.deck.hidden = false;
|
||||
}
|
||||
|
||||
@@ -558,17 +568,13 @@ function searchMain(
|
||||
return;
|
||||
}
|
||||
|
||||
// §9.2: in-library hits read as whatever they are — a movie, a series,
|
||||
// or an episode with its series and SxxEyy for context
|
||||
// §9.2: in-library hits read as whatever they are — a movie or a series.
|
||||
const libraryMovies = response.library.filter(
|
||||
(hit): hit is LibraryMovie => hit.kind === "movie",
|
||||
);
|
||||
const librarySeries = response.library.filter(
|
||||
(hit): hit is LibrarySeriesHit => hit.kind === "series",
|
||||
);
|
||||
const libraryEpisodes = response.library.filter(
|
||||
(hit): hit is LibraryEpisodeHit => hit.kind === "episode",
|
||||
);
|
||||
const inLibrary = new Set([...libraryMovies, ...librarySeries].map((title) => title.tmdb_id));
|
||||
if (response.library.length === 0 && response.tmdb.length === 0) {
|
||||
setStatus("no matches in library or on tmdb");
|
||||
@@ -585,9 +591,6 @@ function searchMain(
|
||||
for (const series of librarySeries) {
|
||||
refs.groups.library.rows.append(librarySeriesRow(series, roots, openSeries));
|
||||
}
|
||||
for (const episode of libraryEpisodes) {
|
||||
refs.groups.library.rows.append(episodeRow(episode, openSeries));
|
||||
}
|
||||
}
|
||||
if (response.tmdb.length > 0) {
|
||||
refs.groups.tmdb.section.hidden = false;
|
||||
@@ -602,8 +605,8 @@ function searchMain(
|
||||
window.clearTimeout(timer);
|
||||
const query = input.value.trim();
|
||||
if (query === "") {
|
||||
navigate({ kind: "board" });
|
||||
showBoard();
|
||||
navigate({ kind: "library" });
|
||||
showHome();
|
||||
clearGroups();
|
||||
setStatus(null);
|
||||
return;
|
||||
@@ -632,8 +635,8 @@ function searchMain(
|
||||
}
|
||||
} else if (event.key === "Escape") {
|
||||
input.value = "";
|
||||
navigate({ kind: "board" });
|
||||
showBoard();
|
||||
navigate({ kind: "library" });
|
||||
showHome();
|
||||
clearGroups();
|
||||
setStatus(null);
|
||||
}
|
||||
@@ -658,8 +661,8 @@ function searchMain(
|
||||
input.select();
|
||||
} else if (event.key === "Escape" && !refs.deck.hidden) {
|
||||
input.value = "";
|
||||
navigate({ kind: "board" });
|
||||
showBoard();
|
||||
navigate({ kind: "library" });
|
||||
showHome();
|
||||
clearGroups();
|
||||
setStatus(null);
|
||||
input.focus();
|
||||
@@ -690,7 +693,7 @@ function searchMain(
|
||||
|
||||
function showIdle() {
|
||||
input.value = "";
|
||||
showBoard();
|
||||
showHome();
|
||||
clearGroups();
|
||||
setStatus(null);
|
||||
}
|
||||
@@ -967,40 +970,6 @@ function librarySeriesRow(
|
||||
return item;
|
||||
}
|
||||
|
||||
/** An in-library episode hit: its series, the `SxxEyy` tag, then the title. */
|
||||
function episodeRow(
|
||||
episode: LibraryEpisodeHit,
|
||||
open: (id: number, origin: HTMLElement) => void,
|
||||
): HTMLLIElement {
|
||||
const { item, row, body, chips } = richRow();
|
||||
chips.append(
|
||||
chip(episode.tag, (span) => {
|
||||
span.setAttribute("aria-label", `${episode.series_title} ${episode.tag}, ${episode.title}`);
|
||||
}),
|
||||
);
|
||||
// the episode title is a name, not a readout: sans, never mono
|
||||
const title = document.createElement("span");
|
||||
title.className = "row-sub";
|
||||
title.textContent = episode.title;
|
||||
chips.append(title);
|
||||
const rating = ratingChip(episode.vote_average);
|
||||
if (rating !== null) {
|
||||
chips.append(rating);
|
||||
}
|
||||
chips.append(trailerChip("tv", episode.series_tmdb_id));
|
||||
const affordance = document.createElement("span");
|
||||
affordance.className = "row-add readout";
|
||||
affordance.textContent = "episodes";
|
||||
chips.append(affordance);
|
||||
body.append(rowTitle(episode.series_title, null), chips);
|
||||
row.append(rowPoster(episode.poster_path, episode.series_title), body);
|
||||
row.addEventListener("click", () => {
|
||||
open(episode.series_id, row);
|
||||
});
|
||||
item.append(row);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** A TMDB hit of either kind — the row opens the add flow (§9.2). */
|
||||
function tmdbRow(
|
||||
hit: TmdbMovie | TmdbSeries,
|
||||
@@ -1309,7 +1278,7 @@ function externalLink(label: string, href: string): HTMLAnchorElement {
|
||||
return link;
|
||||
}
|
||||
|
||||
function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
|
||||
function movieMain(views: HideableView[]): MovieView {
|
||||
const view = must<HTMLElement>("#movie");
|
||||
const deckEl = must<HTMLElement>("#deck");
|
||||
const back = must<HTMLButtonElement>("#movie-back");
|
||||
@@ -1360,7 +1329,7 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
|
||||
let removed: ((parent: Route) => void) | null = null;
|
||||
let origin: HTMLElement | null = null;
|
||||
let returnTo: HTMLElement | null = null;
|
||||
let parentRoute: Route = { kind: "board" };
|
||||
let parentRoute: Route = { kind: "library" };
|
||||
const dom: BucketsDom = { eligible, waived: collapsed.waived, rejected: collapsed.rejected };
|
||||
const actions: ReleaseActions = {
|
||||
reload: () => reloadReleases(),
|
||||
@@ -1812,15 +1781,24 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
|
||||
if (!movie) {
|
||||
return;
|
||||
}
|
||||
const panel = removePanel(movie, {
|
||||
cancel: closeRemove,
|
||||
removed: () => {
|
||||
const parent = parentRoute;
|
||||
clearRemove();
|
||||
close();
|
||||
removed?.(parent);
|
||||
const panel = removePanel(
|
||||
{
|
||||
title: movie.title,
|
||||
files: () => movieFiles(movie.id),
|
||||
folder: libraryFolder,
|
||||
remove: () => removeMovie(movie.id),
|
||||
seedLine: "the torrent keeps seeding until its tracker rule clears.",
|
||||
},
|
||||
});
|
||||
{
|
||||
cancel: closeRemove,
|
||||
removed: () => {
|
||||
const parent = parentRoute;
|
||||
clearRemove();
|
||||
close();
|
||||
removed?.(parent);
|
||||
},
|
||||
},
|
||||
);
|
||||
removeWrap.replaceChildren(panel);
|
||||
removeWrap.hidden = false;
|
||||
remove.setAttribute("aria-expanded", "true");
|
||||
@@ -1907,7 +1885,6 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
|
||||
origin = from;
|
||||
returnTo = container;
|
||||
parentRoute = parent;
|
||||
board.hidden = true;
|
||||
deckEl.hidden = true;
|
||||
view.hidden = false;
|
||||
clearBuckets(dom);
|
||||
@@ -1974,6 +1951,27 @@ interface RemoveActions {
|
||||
removed: () => void;
|
||||
}
|
||||
|
||||
/** The slice of a file the removal panel reads: evidence, nothing else. */
|
||||
interface RemoveFile {
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the panel needs to know about a title (issue 175): a movie and a
|
||||
* series differ only in which endpoints they call, how their files roll up
|
||||
* to one folder, and how many torrents the §7.3 warning speaks of.
|
||||
*/
|
||||
interface RemoveSubject {
|
||||
title: string;
|
||||
files: () => Promise<{ kind: "files"; files: RemoveFile[] } | { kind: "error"; detail: string }>;
|
||||
/** The one folder the delete takes, when the files agree on it. */
|
||||
folder: (files: RemoveFile[]) => string | null;
|
||||
remove: () => Promise<ActionOutcome>;
|
||||
/** What does not happen: seeding continues under its own rule (§7.3). */
|
||||
seedLine: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The removal confirmation (issue 104, simplified by 110): removing a title
|
||||
* always unlinks its §7.4 folder, so there is one decision, not two.
|
||||
@@ -1981,15 +1979,16 @@ interface RemoveActions {
|
||||
* It names the §7.4 folder it would unlink rather than promising in the
|
||||
* abstract — the service knows only what it wrote (§2), so the file list is
|
||||
* the whole truth about what disappears. And it says what does not happen:
|
||||
* the torrent keeps seeding under its own rule (§7.3).
|
||||
* seeding continues under its own rule (§7.3).
|
||||
*/
|
||||
function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement {
|
||||
function removePanel(subject: RemoveSubject, actions: RemoveActions): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "remove-body";
|
||||
panel.setAttribute("role", "group");
|
||||
panel.setAttribute("aria-label", `remove ${movie.title}`);
|
||||
panel.setAttribute("aria-label", `remove ${subject.title}`);
|
||||
|
||||
let files: MovieFile[] | null = null;
|
||||
let files: RemoveFile[] | null = null;
|
||||
let folderNamed = false;
|
||||
|
||||
const evidence = document.createElement("p");
|
||||
evidence.className = "remove-evidence readout dim";
|
||||
@@ -2020,8 +2019,7 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement {
|
||||
const hasFiles = files !== null && files.length > 0;
|
||||
panel.dataset.armed = String(hasFiles);
|
||||
if (hasFiles) {
|
||||
note.textContent =
|
||||
"deletes the folder above. the torrent keeps seeding until its tracker rule clears.";
|
||||
note.textContent = `${folderNamed ? "deletes the folder above." : "deletes the files above."} ${subject.seedLine}`;
|
||||
note.dataset.tone = "warn";
|
||||
return;
|
||||
}
|
||||
@@ -2034,7 +2032,7 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement {
|
||||
cancel.disabled = true;
|
||||
delete note.dataset.tone;
|
||||
note.textContent = "removing title and files…";
|
||||
void removeMovie(movie.id).then((outcome) => {
|
||||
void subject.remove().then((outcome) => {
|
||||
if (outcome.kind === "done") {
|
||||
actions.removed();
|
||||
return;
|
||||
@@ -2047,7 +2045,7 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement {
|
||||
});
|
||||
});
|
||||
|
||||
void movieFiles(movie.id).then((outcome) => {
|
||||
void subject.files().then((outcome) => {
|
||||
if (outcome.kind === "error") {
|
||||
evidence.textContent = `files unreadable — ${outcome.detail}`;
|
||||
paint();
|
||||
@@ -2061,7 +2059,8 @@ function removePanel(movie: LibraryMovie, actions: RemoveActions): HTMLElement {
|
||||
}
|
||||
const count = `${files.length} ${files.length === 1 ? "file" : "files"}`;
|
||||
evidence.textContent = `${count} · ${formatSize(totalSize(files))}`;
|
||||
const folder = libraryFolder(files);
|
||||
const folder = subject.folder(files);
|
||||
folderNamed = folder !== null;
|
||||
path.hidden = false;
|
||||
path.textContent = folder ?? files.map((file) => file.path).join("\n");
|
||||
paint();
|
||||
@@ -2373,7 +2372,6 @@ function loadLibraryShape(): LibraryShape {
|
||||
}
|
||||
|
||||
function libraryMain(
|
||||
board: HTMLElement,
|
||||
movieDetail: MovieView,
|
||||
seriesDetail: SeriesView,
|
||||
views: HideableView[],
|
||||
@@ -2523,7 +2521,6 @@ function libraryMain(
|
||||
for (const sibling of views) {
|
||||
sibling.hide();
|
||||
}
|
||||
board.hidden = true;
|
||||
deck.hidden = true;
|
||||
view.hidden = false;
|
||||
nav.setAttribute("aria-pressed", "true");
|
||||
@@ -2536,19 +2533,12 @@ function libraryMain(
|
||||
sequence += 1;
|
||||
}
|
||||
|
||||
function close() {
|
||||
hide();
|
||||
board.hidden = false;
|
||||
nav.focus();
|
||||
}
|
||||
|
||||
// §9.7: the library is the homepage — the bottom layer. There is nothing
|
||||
// beneath it, so the nav control never toggles off and Esc has no handler.
|
||||
nav.addEventListener("click", () => {
|
||||
if (view.hidden) {
|
||||
navigate({ kind: "library" });
|
||||
open();
|
||||
} else {
|
||||
navigate({ kind: "board" });
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2557,19 +2547,6 @@ function libraryMain(
|
||||
render();
|
||||
});
|
||||
|
||||
// capture, like the release deck: one Esc steps back one layer
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key === "Escape" && !view.hidden) {
|
||||
event.stopImmediatePropagation();
|
||||
navigate({ kind: "board" });
|
||||
close();
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
return { hide, open };
|
||||
}
|
||||
|
||||
@@ -2951,7 +2928,7 @@ function tvReleasesMain(): TvReleasesView {
|
||||
|
||||
function close() {
|
||||
const current = request;
|
||||
navigate(current?.parentRoute ?? { kind: "board" });
|
||||
navigate(current?.parentRoute ?? { kind: "library" });
|
||||
hide();
|
||||
if (current) {
|
||||
current.returnTo.hidden = false;
|
||||
@@ -2988,11 +2965,13 @@ interface SeriesView {
|
||||
returnTo: HTMLElement,
|
||||
parentRoute: Route,
|
||||
) => Promise<void>;
|
||||
/** Where to land once the title is gone — same contract as a movie. */
|
||||
setRemoved: (handler: (parent: Route) => void) => void;
|
||||
}
|
||||
|
||||
const PAD_TWO = (value: number): string => String(value).padStart(2, "0");
|
||||
|
||||
function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
const view = must<HTMLElement>("#series");
|
||||
const deckEl = must<HTMLElement>("#deck");
|
||||
const back = must<HTMLButtonElement>("#series-back");
|
||||
@@ -3008,6 +2987,8 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
const actionsEl = must<HTMLElement>("#series-actions");
|
||||
const statusEl = must<HTMLElement>("#series-status");
|
||||
const seasonsList = must<HTMLUListElement>("#rows-seasons");
|
||||
const remove = must<HTMLButtonElement>("#series-remove");
|
||||
const removeWrap = must<HTMLElement>("#series-remove-panel");
|
||||
|
||||
let roots: Root[] = [];
|
||||
let series: ApiSeries | null = null;
|
||||
@@ -3023,6 +3004,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
let focusKey: string | null = null;
|
||||
// guards a stale fetch from painting over a newer view
|
||||
let sequence = 0;
|
||||
let removed: ((parent: Route) => void) | null = null;
|
||||
|
||||
function setStatus(text: string | null, tone?: "fault") {
|
||||
statusEl.hidden = text === null;
|
||||
@@ -3034,6 +3016,59 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- removal confirmation: the movie panel, generalised (issue 175) ---- */
|
||||
|
||||
/** Tears the confirmation down without stealing focus from a caller. */
|
||||
function clearRemove() {
|
||||
removeWrap.hidden = true;
|
||||
removeWrap.replaceChildren();
|
||||
remove.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
function closeRemove() {
|
||||
clearRemove();
|
||||
remove.focus();
|
||||
}
|
||||
|
||||
function openRemove() {
|
||||
const current = series;
|
||||
const id = seriesId;
|
||||
if (!current || id === null) {
|
||||
return;
|
||||
}
|
||||
const panel = removePanel(
|
||||
{
|
||||
title: current.title,
|
||||
files: () => fetchSeriesFiles(id),
|
||||
folder: seriesFolder,
|
||||
remove: () => removeSeries(id),
|
||||
seedLine: "torrents keep seeding until their tracker rules clear.",
|
||||
},
|
||||
{
|
||||
cancel: closeRemove,
|
||||
removed: () => {
|
||||
const parent = parentRoute;
|
||||
clearRemove();
|
||||
close();
|
||||
removed?.(parent);
|
||||
},
|
||||
},
|
||||
);
|
||||
removeWrap.replaceChildren(panel);
|
||||
removeWrap.hidden = false;
|
||||
remove.setAttribute("aria-expanded", "true");
|
||||
// cancel takes focus, not the destructive action (same as a movie)
|
||||
panel.querySelector<HTMLElement>(".remove-actions .control:last-child")?.focus();
|
||||
}
|
||||
|
||||
remove.addEventListener("click", () => {
|
||||
if (removeWrap.hidden) {
|
||||
openRemove();
|
||||
} else {
|
||||
closeRemove();
|
||||
}
|
||||
});
|
||||
|
||||
function paintHeader() {
|
||||
const current = series;
|
||||
if (!current) {
|
||||
@@ -3260,6 +3295,32 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
);
|
||||
}
|
||||
line.append(space, deckBtn);
|
||||
// #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");
|
||||
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`,
|
||||
);
|
||||
line.append(clear);
|
||||
}
|
||||
item.append(line);
|
||||
|
||||
const episodes = document.createElement("ul");
|
||||
@@ -3322,7 +3383,25 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
const onDisk = episode.state === "available";
|
||||
|
||||
if (aired && onDisk) {
|
||||
// present and correct: nothing to decide here
|
||||
// #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",
|
||||
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`,
|
||||
);
|
||||
actions.append(clear);
|
||||
} else if (!aired) {
|
||||
const note = document.createElement("span");
|
||||
note.className = "readout dim ep-unaired";
|
||||
@@ -3450,9 +3529,9 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
parentRoute = parent;
|
||||
expanded = new Set();
|
||||
focusKey = null;
|
||||
board.hidden = true;
|
||||
deckEl.hidden = true;
|
||||
view.hidden = false;
|
||||
clearRemove();
|
||||
clearRichDetail();
|
||||
back.focus();
|
||||
await load();
|
||||
@@ -3463,6 +3542,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
seriesId = null;
|
||||
seasons = null;
|
||||
sequence += 1;
|
||||
clearRemove();
|
||||
clearRichDetail();
|
||||
}
|
||||
|
||||
@@ -3482,6 +3562,11 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
(event) => {
|
||||
if (event.key === "Escape" && !view.hidden) {
|
||||
event.stopImmediatePropagation();
|
||||
// the confirmation is the innermost layer: Esc abandons it first
|
||||
if (!removeWrap.hidden) {
|
||||
closeRemove();
|
||||
return;
|
||||
}
|
||||
navigate(parentRoute);
|
||||
close();
|
||||
}
|
||||
@@ -3489,7 +3574,13 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
true,
|
||||
);
|
||||
|
||||
return { hide, open };
|
||||
return {
|
||||
hide,
|
||||
open,
|
||||
setRemoved: (handler: (parent: Route) => void) => {
|
||||
removed = handler;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- attention queues (§5.2 + §5.7, issue #33) ------------------------ */
|
||||
@@ -3507,7 +3598,7 @@ interface QueueGroup {
|
||||
rows: HTMLUListElement;
|
||||
}
|
||||
|
||||
function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableView[]): QueuesView {
|
||||
function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () => void): QueuesView {
|
||||
const view = must<HTMLElement>("#queues");
|
||||
const deck = must<HTMLElement>("#deck");
|
||||
const nav = must<HTMLButtonElement>("#nav-queues");
|
||||
@@ -3649,7 +3740,6 @@ function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableV
|
||||
for (const sibling of views) {
|
||||
sibling.hide();
|
||||
}
|
||||
board.hidden = true;
|
||||
deck.hidden = true;
|
||||
view.hidden = false;
|
||||
nav.setAttribute("aria-pressed", "true");
|
||||
@@ -3664,7 +3754,7 @@ function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableV
|
||||
|
||||
function close() {
|
||||
hide();
|
||||
board.hidden = false;
|
||||
goHome();
|
||||
nav.focus();
|
||||
}
|
||||
|
||||
@@ -3673,7 +3763,7 @@ function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableV
|
||||
navigate({ kind: "queues" });
|
||||
open();
|
||||
} else {
|
||||
navigate({ kind: "board" });
|
||||
navigate({ kind: "library" });
|
||||
close();
|
||||
}
|
||||
});
|
||||
@@ -3684,7 +3774,7 @@ function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableV
|
||||
(event) => {
|
||||
if (event.key === "Escape" && !view.hidden) {
|
||||
event.stopImmediatePropagation();
|
||||
navigate({ kind: "board" });
|
||||
navigate({ kind: "library" });
|
||||
close();
|
||||
}
|
||||
},
|
||||
|
||||
+2
-2
@@ -131,7 +131,7 @@ export async function movieFiles(movieId: number): Promise<FilesOutcome> {
|
||||
* names everything a files-too remove takes. `null` when the files disagree,
|
||||
* which the panel then says instead of naming one folder falsely.
|
||||
*/
|
||||
export function libraryFolder(files: MovieFile[]): string | null {
|
||||
export function libraryFolder(files: { path: string }[]): string | null {
|
||||
const folders = new Set(files.map((file) => file.path.slice(0, file.path.lastIndexOf("/"))));
|
||||
if (folders.size !== 1) {
|
||||
return null;
|
||||
@@ -140,7 +140,7 @@ export function libraryFolder(files: MovieFile[]): string | null {
|
||||
return folder === undefined || folder === "" ? null : folder;
|
||||
}
|
||||
|
||||
export function totalSize(files: MovieFile[]): number {
|
||||
export function totalSize(files: { size: number }[]): number {
|
||||
return files.reduce((sum, file) => sum + file.size, 0);
|
||||
}
|
||||
|
||||
|
||||
+4
-9
@@ -3,7 +3,6 @@
|
||||
// state passed to pushState, so a manually edited or copy-pasted URL works.
|
||||
|
||||
export type Route =
|
||||
| { kind: "board" }
|
||||
| { kind: "library" }
|
||||
| { kind: "queues" }
|
||||
| { kind: "settings" }
|
||||
@@ -16,9 +15,6 @@ export type Route =
|
||||
|
||||
export function parseRoute(url: URL): Route {
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 1 && segments[0] === "library") {
|
||||
return { kind: "library" };
|
||||
}
|
||||
if (segments.length === 1 && segments[0] === "queues") {
|
||||
return { kind: "queues" };
|
||||
}
|
||||
@@ -27,7 +23,7 @@ export function parseRoute(url: URL): Route {
|
||||
}
|
||||
if (segments.length === 1 && segments[0] === "search") {
|
||||
const query = url.searchParams.get("q") ?? "";
|
||||
return query === "" ? { kind: "board" } : { kind: "search", query };
|
||||
return query === "" ? { kind: "library" } : { kind: "search", query };
|
||||
}
|
||||
if (segments.length === 2 && segments[0] === "movies") {
|
||||
const movieId = Number(segments[1]);
|
||||
@@ -70,15 +66,14 @@ export function parseRoute(url: URL): Route {
|
||||
return { kind: "episodeReleases", episodeId };
|
||||
}
|
||||
}
|
||||
return { kind: "board" };
|
||||
// §9.7: the library is the homepage — "/" and every unrecognised path land here
|
||||
return { kind: "library" };
|
||||
}
|
||||
|
||||
export function routePath(route: Route): string {
|
||||
switch (route.kind) {
|
||||
case "board":
|
||||
return "/";
|
||||
case "library":
|
||||
return "/library";
|
||||
return "/";
|
||||
case "queues":
|
||||
return "/queues";
|
||||
case "settings":
|
||||
|
||||
+1
-17
@@ -36,23 +36,7 @@ export interface LibrarySeriesHit {
|
||||
vote_average: number | null;
|
||||
}
|
||||
|
||||
export interface LibraryEpisodeHit {
|
||||
kind: "episode";
|
||||
episode_id: number;
|
||||
series_id: number;
|
||||
series_title: string;
|
||||
/** `SxxEyy`, for context next to the episode title. */
|
||||
tag: string;
|
||||
/** The episode title — what the search matched on. */
|
||||
title: string;
|
||||
/** The series' poster — an episode has no artwork of its own worth showing at row size. */
|
||||
poster_path: string | null;
|
||||
vote_average: number | null;
|
||||
/** The series' TMDB id — what the row's trailer chip resolves through. */
|
||||
series_tmdb_id: number;
|
||||
}
|
||||
|
||||
export type LibraryResult = LibraryMovie | LibrarySeriesHit | LibraryEpisodeHit;
|
||||
export type LibraryResult = LibraryMovie | LibrarySeriesHit;
|
||||
|
||||
export interface TmdbMovie {
|
||||
kind: "movie";
|
||||
|
||||
@@ -149,6 +149,68 @@ export async function setEpisodeWanted(episodeId: number, wanted: boolean): Prom
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- removal (issues 174 + 175) ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* The §7.4 title folder these episode files share. Season subfolders differ
|
||||
* between files, so the shared folder is the one carrying the `[tmdbid-…]`
|
||||
* tag every §7.4 title folder name has. `null` when the files disagree,
|
||||
* which the panel then says instead of naming one folder falsely.
|
||||
*/
|
||||
export function seriesFolder(files: { path: string }[]): string | null {
|
||||
const folders = new Set<string>();
|
||||
for (const file of files) {
|
||||
const parts = file.path.split("/");
|
||||
const titleAt = parts.findIndex((part) => part.includes("[tmdbid-"));
|
||||
const folder =
|
||||
titleAt > 0
|
||||
? parts.slice(0, titleAt + 1).join("/")
|
||||
: file.path.slice(0, file.path.lastIndexOf("/"));
|
||||
if (folder === "") {
|
||||
return null;
|
||||
}
|
||||
folders.add(folder);
|
||||
}
|
||||
if (folders.size !== 1) {
|
||||
return null;
|
||||
}
|
||||
return [...folders][0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the series from the library. The row and its §7.4 title folder go.
|
||||
* Torrents keep seeding — the reaper owns that lifecycle (§7.3).
|
||||
*/
|
||||
export function removeSeries(seriesId: number): Promise<ActionOutcome> {
|
||||
return del(`/api/series/${seriesId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* #174: the season's files go and its episodes stop being wanted. The season
|
||||
* stays listed — TMDB owns that metadata and the next refresh would recreate
|
||||
* it anyway.
|
||||
*/
|
||||
export function removeSeasonFiles(seriesId: number, seasonNumber: number): Promise<ActionOutcome> {
|
||||
return del(`/api/series/${seriesId}/seasons/${seasonNumber}/files`);
|
||||
}
|
||||
|
||||
/** #174, one episode: the file goes and the episode stops being wanted. */
|
||||
export function removeEpisodeFiles(episodeId: number): Promise<ActionOutcome> {
|
||||
return del(`/api/episodes/${episodeId}/files`);
|
||||
}
|
||||
|
||||
async function del(url: string): Promise<ActionOutcome> {
|
||||
try {
|
||||
const response = await fetch(url, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
return { kind: "error", detail: await errorDetail(response) };
|
||||
}
|
||||
return { kind: "done" };
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- manual triggers and decks ---------------------------------------- */
|
||||
|
||||
/** §6.2 manual search, one targeted sweep, for a season or an episode. */
|
||||
|
||||
+7
-7
@@ -188,9 +188,10 @@ function listValue(input: HTMLInputElement): string[] {
|
||||
/**
|
||||
* First click arms the destructive action, second confirms. The armed state
|
||||
* clears on blur or after a few seconds, so an accidental double click
|
||||
* never deletes.
|
||||
* never deletes. Shared with the series detail rows — one confirmation
|
||||
* idiom for row-level destruction, not one per page.
|
||||
*/
|
||||
function armedDelete(label: string, execute: () => void): HTMLButtonElement {
|
||||
export function armedDelete(label: string, execute: () => void): HTMLButtonElement {
|
||||
const button = el("button", "control control-quiet readout", label);
|
||||
button.type = "button";
|
||||
let armed = false;
|
||||
@@ -226,7 +227,7 @@ interface SettingsView {
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
export function settingsMain(board: HTMLElement, views: { hide: () => void }[]): SettingsView {
|
||||
export function settingsMain(views: { hide: () => void }[], goHome: () => void): SettingsView {
|
||||
const view = must<HTMLElement>("#settings");
|
||||
const deck = must<HTMLElement>("#deck");
|
||||
const nav = must<HTMLButtonElement>("#nav-settings");
|
||||
@@ -705,7 +706,6 @@ export function settingsMain(board: HTMLElement, views: { hide: () => void }[]):
|
||||
for (const sibling of views) {
|
||||
sibling.hide();
|
||||
}
|
||||
board.hidden = true;
|
||||
deck.hidden = true;
|
||||
view.hidden = false;
|
||||
nav.setAttribute("aria-pressed", "true");
|
||||
@@ -720,7 +720,7 @@ export function settingsMain(board: HTMLElement, views: { hide: () => void }[]):
|
||||
|
||||
function close() {
|
||||
hide();
|
||||
board.hidden = false;
|
||||
goHome();
|
||||
nav.focus();
|
||||
}
|
||||
|
||||
@@ -729,7 +729,7 @@ export function settingsMain(board: HTMLElement, views: { hide: () => void }[]):
|
||||
navigate({ kind: "settings" });
|
||||
open();
|
||||
} else {
|
||||
navigate({ kind: "board" });
|
||||
navigate({ kind: "library" });
|
||||
close();
|
||||
}
|
||||
});
|
||||
@@ -748,7 +748,7 @@ export function settingsMain(board: HTMLElement, views: { hide: () => void }[]):
|
||||
(event) => {
|
||||
if (event.key === "Escape" && !view.hidden) {
|
||||
event.stopImmediatePropagation();
|
||||
navigate({ kind: "board" });
|
||||
navigate({ kind: "library" });
|
||||
close();
|
||||
}
|
||||
},
|
||||
|
||||
+27
-27
@@ -156,6 +156,8 @@ body {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* §9.7: the wordmark is a link home — quiet at rest, cyan on approach
|
||||
like every other interactive control */
|
||||
.wordmark {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
@@ -163,6 +165,13 @@ body {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wordmark:hover,
|
||||
.wordmark:focus-visible {
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
|
||||
.rail-meta {
|
||||
@@ -247,44 +256,26 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.board.powered .lamp {
|
||||
.chain.powered .lamp {
|
||||
animation: lamp-strike 420ms var(--ease-out) both;
|
||||
animation-delay: calc(var(--strike, 0) * 120ms);
|
||||
}
|
||||
|
||||
.board.powered .lamp[data-state="off"] {
|
||||
.chain.powered .lamp[data-state="off"] {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lamp[data-state="probing"],
|
||||
.board.powered .lamp {
|
||||
.chain.powered .lamp {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- board and chain ------------------------------------------------- */
|
||||
|
||||
.board {
|
||||
width: 100%;
|
||||
max-width: 68rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-12) var(--space-6);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* the deck swaps in over the board; flex would beat [hidden] otherwise */
|
||||
.board[hidden] {
|
||||
display: none;
|
||||
}
|
||||
/* ---- signal chain (§9.7: a settings section, no route of its own) ----- */
|
||||
|
||||
.chain {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chain {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2.5rem 1.2fr 2.5rem 1fr;
|
||||
grid-template-areas:
|
||||
@@ -1004,6 +995,17 @@ body {
|
||||
border-color: var(--signal-warn);
|
||||
}
|
||||
|
||||
/* the settings arm-then-confirm, shared with season and episode rows: an
|
||||
armed control turns caution amber until it disarms or fires */
|
||||
.control[data-armed="true"] {
|
||||
color: var(--signal-warn);
|
||||
border-color: oklch(from var(--signal-warn) l c h / 55%);
|
||||
}
|
||||
|
||||
.control[data-armed="true"]:hover:not(:disabled) {
|
||||
border-color: var(--signal-warn);
|
||||
}
|
||||
|
||||
.bucket-head {
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1034,7 +1036,9 @@ body {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.25rem;
|
||||
/* single nowrap line: line-height equal to the box height centres it,
|
||||
and keeps ellipsis + text-align working, which flex would break */
|
||||
line-height: var(--control-h);
|
||||
}
|
||||
|
||||
.cw-score {
|
||||
@@ -1811,10 +1815,6 @@ body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.board {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.chain {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
|
||||
Reference in New Issue
Block a user