diff --git a/.sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json b/.sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json deleted file mode 100644 index 1813f52..0000000 --- a/.sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-32c562fdb70cabb3265e351583b6ac1579490b29da670fe9062117451911576a.json b/.sqlx/query-32c562fdb70cabb3265e351583b6ac1579490b29da670fe9062117451911576a.json new file mode 100644 index 0000000..52fd192 --- /dev/null +++ b/.sqlx/query-32c562fdb70cabb3265e351583b6ac1579490b29da670fe9062117451911576a.json @@ -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" +} diff --git a/.sqlx/query-3531a16bd038c149465397a70c24c14ed005683c68f06d322409c5c2b39b84d1.json b/.sqlx/query-3531a16bd038c149465397a70c24c14ed005683c68f06d322409c5c2b39b84d1.json new file mode 100644 index 0000000..6209aac --- /dev/null +++ b/.sqlx/query-3531a16bd038c149465397a70c24c14ed005683c68f06d322409c5c2b39b84d1.json @@ -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" +} diff --git a/.sqlx/query-3a02f30ced2765dcdbf7575d680c1b4ab1db895065f65db4a99bb0a4dc6e71ed.json b/.sqlx/query-3a02f30ced2765dcdbf7575d680c1b4ab1db895065f65db4a99bb0a4dc6e71ed.json new file mode 100644 index 0000000..207dad9 --- /dev/null +++ b/.sqlx/query-3a02f30ced2765dcdbf7575d680c1b4ab1db895065f65db4a99bb0a4dc6e71ed.json @@ -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" +} diff --git a/.sqlx/query-58fe68be59f015fd8214306483c0d45fcc4b2ac595d3b04efb224b3e426cc1cf.json b/.sqlx/query-58fe68be59f015fd8214306483c0d45fcc4b2ac595d3b04efb224b3e426cc1cf.json new file mode 100644 index 0000000..e21c346 --- /dev/null +++ b/.sqlx/query-58fe68be59f015fd8214306483c0d45fcc4b2ac595d3b04efb224b3e426cc1cf.json @@ -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" +} diff --git a/.sqlx/query-5b059a2f93a3fb46b6c55e0b665aa55931045af290894e396b3b410123bfd539.json b/.sqlx/query-5b059a2f93a3fb46b6c55e0b665aa55931045af290894e396b3b410123bfd539.json new file mode 100644 index 0000000..be2bede --- /dev/null +++ b/.sqlx/query-5b059a2f93a3fb46b6c55e0b665aa55931045af290894e396b3b410123bfd539.json @@ -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" +} diff --git a/.sqlx/query-6feaa1abb79ce2a0298bef052d3b731e8ccec88228f7a81b2e8ff5cee76f3fa4.json b/.sqlx/query-6feaa1abb79ce2a0298bef052d3b731e8ccec88228f7a81b2e8ff5cee76f3fa4.json new file mode 100644 index 0000000..62a593a --- /dev/null +++ b/.sqlx/query-6feaa1abb79ce2a0298bef052d3b731e8ccec88228f7a81b2e8ff5cee76f3fa4.json @@ -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" +} diff --git a/.sqlx/query-7c638b148b697645d250fe43cf7fcc049e3754e5b1837ff54289e8908523d40c.json b/.sqlx/query-7c638b148b697645d250fe43cf7fcc049e3754e5b1837ff54289e8908523d40c.json new file mode 100644 index 0000000..064d958 --- /dev/null +++ b/.sqlx/query-7c638b148b697645d250fe43cf7fcc049e3754e5b1837ff54289e8908523d40c.json @@ -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" +} diff --git a/.sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json b/.sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json deleted file mode 100644 index bccef76..0000000 --- a/.sqlx/query-80399f9c14b159b5c4883aaeae370a7869d1104dfd80dc3a568ef134c4659ca9.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json b/.sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json deleted file mode 100644 index 904a6d9..0000000 --- a/.sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-90813e43938fcf91b96e444354a5a41b345fec599ba1562ae1fdf617ef49ff54.json b/.sqlx/query-90813e43938fcf91b96e444354a5a41b345fec599ba1562ae1fdf617ef49ff54.json new file mode 100644 index 0000000..efa7d3a --- /dev/null +++ b/.sqlx/query-90813e43938fcf91b96e444354a5a41b345fec599ba1562ae1fdf617ef49ff54.json @@ -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" +} diff --git a/.sqlx/query-97033814ba641d94878f16f23224b49cbad1d65ea0f130d091995bfed87cb844.json b/.sqlx/query-97033814ba641d94878f16f23224b49cbad1d65ea0f130d091995bfed87cb844.json new file mode 100644 index 0000000..bb3138e --- /dev/null +++ b/.sqlx/query-97033814ba641d94878f16f23224b49cbad1d65ea0f130d091995bfed87cb844.json @@ -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" +} diff --git a/.sqlx/query-98af62bdeb2be99ece2ebcb3567773b3af29299658a21681237cfa11cc806a39.json b/.sqlx/query-98af62bdeb2be99ece2ebcb3567773b3af29299658a21681237cfa11cc806a39.json new file mode 100644 index 0000000..7c43374 --- /dev/null +++ b/.sqlx/query-98af62bdeb2be99ece2ebcb3567773b3af29299658a21681237cfa11cc806a39.json @@ -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" +} diff --git a/.sqlx/query-9f2ce3364cd2c227d6dbccf9973d29ca685cdaa1fb2b93124e099b0bbd6a26af.json b/.sqlx/query-9f2ce3364cd2c227d6dbccf9973d29ca685cdaa1fb2b93124e099b0bbd6a26af.json new file mode 100644 index 0000000..401c95f --- /dev/null +++ b/.sqlx/query-9f2ce3364cd2c227d6dbccf9973d29ca685cdaa1fb2b93124e099b0bbd6a26af.json @@ -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" +} diff --git a/.sqlx/query-dca537a0b312f758af06608c729790566082d7db2928220fc2f1509e0015423e.json b/.sqlx/query-dca537a0b312f758af06608c729790566082d7db2928220fc2f1509e0015423e.json new file mode 100644 index 0000000..4da27d2 --- /dev/null +++ b/.sqlx/query-dca537a0b312f758af06608c729790566082d7db2928220fc2f1509e0015423e.json @@ -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" +} diff --git a/.sqlx/query-e5c40363c81cebf7026b49699981218e15b57d565039f3de2d48b320d3cec92b.json b/.sqlx/query-e5c40363c81cebf7026b49699981218e15b57d565039f3de2d48b320d3cec92b.json new file mode 100644 index 0000000..54439ae --- /dev/null +++ b/.sqlx/query-e5c40363c81cebf7026b49699981218e15b57d565039f3de2d48b320d3cec92b.json @@ -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" +} diff --git a/DESIGN.md b/DESIGN.md index 4187cd3..c2b07f4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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`. diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 31a2eb3..2f887c1 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -90,7 +90,9 @@ fn api_router() -> OpenApiRouter { .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"), diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 70c6803..33c82c8 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -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, pub tmdb: Vec, pub manual: Option, @@ -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, } -#[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, - /// The series' rating, out of 10; `null` when TMDB has no votes for it. - pub vote_average: Option, - /// 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 = 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] diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index bace102..2994d7f 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -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 = 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 { + 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, 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 { + 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, + Path((series_id, number)): Path<(i64, i64)>, +) -> Result { + // 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, + Path(id): Path, +) -> Result { + // 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, 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 = + 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 = + 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!( diff --git a/crates/arr-core/src/tracking.rs b/crates/arr-core/src/tracking.rs index e6513ec..9370828 100644 --- a/crates/arr-core/src/tracking.rs +++ b/crates/arr-core/src/tracking.rs @@ -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::>(); - let before = episodes - .iter() - .map(|episode| episode.wanted) - .collect::>(); apply_tracked(false, &mut episodes); - let after = episodes - .iter() - .map(|episode| episode.wanted) - .collect::>(); - 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:?}" + ); } } } diff --git a/web/index.html b/web/index.html index 8891f4c..dd45e56 100644 --- a/web/index.html +++ b/web/index.html @@ -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". -->
-

arr

+ arr
+
+
+

signal chain

+
+
+
+
+ +

tmdb

+ probing +
+

metadata in

+

titles, languages, release dates

+
+ + + +
+
+ +

prowlarr

+ probing +
+

indexers in

+

torznab search and rss

+
+ + + +
+
+ +

arr

+ probing +
+

reconcile core

+

decide · find · fetch · import

+
+ + + +
+
+ +

transmission

+ probing +
+

torrents out

+

rpc · grab and seed

+
+
+
+ + +
+
+

library

+
+
+ + +
+ +
+
    @@ -437,70 +533,5 @@
    -
    -
    -
    -
    - -

    tmdb

    - probing -
    -

    metadata in

    -

    titles, languages, release dates

    -
    - - - -
    -
    - -

    prowlarr

    - probing -
    -

    indexers in

    -

    torznab search and rss

    -
    - - - -
    -
    - -

    arr

    - probing -
    -

    reconcile core

    -

    decide · find · fetch · import

    -
    - - - -
    -
    - -

    transmission

    - probing -
    -

    torrents out

    -

    rpc · grab and seed

    -
    -
    -
    - diff --git a/web/src/main.ts b/web/src/main.ts index 38bc21b..b1d6a33 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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('[data-role="status"]'); const detail = root?.querySelector('[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(selector: string): T { const element = document.querySelector(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(".board"); + const chain = must(".chain"); const masterLamp = must("#master-lamp"); const version = must("#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("#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("#search"); const hint = must("#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("#movie"); const deckEl = must("#deck"); const back = must("#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; + /** 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; + /** 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("#series"); const deckEl = must("#deck"); const back = must("#series-back"); @@ -3008,6 +2987,8 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV const actionsEl = must("#series-actions"); const statusEl = must("#series-status"); const seasonsList = must("#rows-seasons"); + const remove = must("#series-remove"); + const removeWrap = must("#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(".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("#queues"); const deck = must("#deck"); const nav = must("#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(); } }, diff --git a/web/src/releases.ts b/web/src/releases.ts index 1bb6480..2e74b21 100644 --- a/web/src/releases.ts +++ b/web/src/releases.ts @@ -131,7 +131,7 @@ export async function movieFiles(movieId: number): Promise { * 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); } diff --git a/web/src/router.ts b/web/src/router.ts index 1f4f1c3..582defd 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -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": diff --git a/web/src/search.ts b/web/src/search.ts index 2ca9f23..896454c 100644 --- a/web/src/search.ts +++ b/web/src/search.ts @@ -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"; diff --git a/web/src/series.ts b/web/src/series.ts index 7fb7b2d..f5dbedc 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -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(); + 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 { + 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 { + 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 { + return del(`/api/episodes/${episodeId}/files`); +} + +async function del(url: string): Promise { + 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. */ diff --git a/web/src/settings.ts b/web/src/settings.ts index d6d8b77..f0736d4 100644 --- a/web/src/settings.ts +++ b/web/src/settings.ts @@ -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("#settings"); const deck = must("#deck"); const nav = must("#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(); } }, diff --git a/web/src/style.css b/web/src/style.css index 93e5fc6..f44f7ca 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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: