Compare commits

...

15 Commits

Author SHA1 Message Date
Miguel Palhas d8797e1996 feat(daemon): refresh metadata on add
The metadata lane runs daily, so a series added a moment ago showed no
seasons for up to 24 hours and a movie had no digital release date —
the field §6.2 gates targeted search on.

AppState now carries a MetadataCommand channel alongside the movie,
episode and season ones. Both create handlers send on it after the row
is committed, and a new daemon lane drains it. Its own task rather than
an arm of manual::run: a refresh against TMDB can take a while and must
not sit in front of an operator's manual search.

The add never waits on TMDB and never fails because of it. A refresh
that fails leaves metadata_refreshed_at NULL, which is what the daily
sweep already treats as due, so the title is retried rather than lost.
A command naming a title deleted in between finds no row and does
nothing. METADATA_INTERVAL is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:12:10 +01:00
Miguel Palhas e9806d7a84 Merge origin/main
ci / web (push) Successful in 1m9s
e2e / e2e (push) Successful in 2m3s
ci / rust (push) Successful in 2m12s
2026-08-24 17:21:54 +01:00
Miguel Palhas 70e9e17136 Merge milestone 'Removal and navigation'
Closes #169, #170, #171, #172, #173, #174, #175
2026-08-24 17:21:45 +01:00
Miguel Palhas a6af920e39 Merge #175: removal controls for series, seasons, episodes
Closes #175
2026-08-24 17:14:00 +01:00
Miguel Palhas 7c30928907 feat(web): series, season and episode removal
Series detail exposes DELETE /api/series/{id} through the movie
removePanel, generalised over endpoints and file rollup instead of
copied. Season headers and on-disk episode rows get the settings
armed-delete control wired to #174's file endpoints, worded for what
they do: files go, wanted clears, the row stays listed.

Closes #175

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:09:46 +01:00
Miguel Palhas d5fc790839 docs: drop area/web Fable routing rule
ci / web (push) Successful in 48s
e2e / e2e (push) Successful in 1m36s
ci / rust (push) Successful in 1m42s
The rule justified pinning area/web issues to Fable 5 by saying they go
through the impeccable skill. That skill is symlinked into codex as well,
so it says nothing about which model to use. area/web now routes by
difficulty label like every other area; line 47 already requires
impeccable for anything that decides how something looks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:55:23 +01:00
Miguel Palhas 3dda666273 Merge #174: delete files and untrack a season or episode
Closes #174
2026-08-24 16:45:21 +01:00
Miguel Palhas 64e28e5930 feat: remove files for one season or episode
DELETE /api/series/{id}/seasons/{n}/files and
DELETE /api/episodes/{id}/files unlink what the scope covers, drop the
matching media_files rows and clear wanted, in one action. 204 on
success, 404 for an unknown season or episode, and a scope with nothing
on disk still clears intent.

Season and episode rows stay: TMDB owns that metadata and the next
refresh would recreate them.

The three scopes share one unlink path. A whole series still resolves to
its title folder (§7.4, atomic); a season or episode resolves to the
recorded file and nothing else, so a narrow call cannot reach a sibling.
The intent clear goes through arr_core::tracking::apply_tracked(false),
the same §4.1 rule #171 landed.

An episode whose file just went is set back to 'missing' when it was
'available', matching what a failed import already does. Closes #174.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:43:53 +01:00
Miguel Palhas f3336d066e Merge #172: search returns titles only
Closes #172
2026-08-24 16:40:44 +01:00
Miguel Palhas 27525302b2 Merge #173: library is the homepage
Closes #173
2026-08-24 16:40:38 +01:00
Miguel Palhas 4b89245f00 fix(api): drop episode rows from unified search
Search returns titles only (§9.2, amended): the episode branch of the
library query, its json_each token machinery, and the SPA's episode
row rendering are removed. Deep links to episode releases stay.

Fixes #172
2026-08-24 16:39:48 +01:00
Miguel Palhas 4a611a6794 Merge #171: untracking a season clears wanted
Closes #171
2026-08-24 16:32:02 +01:00
Miguel Palhas 9e003823c6 feat: untracking a season clears its wanted 2026-08-24 16:31:09 +01:00
Miguel Palhas 205855fa7f Merge #170: centre deck column chips in their box
Closes #170
2026-08-24 16:29:25 +01:00
Miguel Palhas fada2ff89a fix(web): centre deck chips in their boxes 2026-08-24 16:28:42 +01:00
39 changed files with 2390 additions and 432 deletions
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"query": "UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "de2153eefd6ec03ceeaf640d599271834cb06c94eda7a9956c5bd7399593c0ae"
"hash": "0516e88e07d3708ba4ccc52d7ba635d45e9d1b2c639b71102e241f50982502eb"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE movies\n SET title = ?, year = ?, original_language = ?, digital_release = ?,\n imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,\n metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? AND (\n title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?\n OR digital_release IS NOT ? OR imdb_id IS NOT ?\n OR poster_path IS NOT ? OR backdrop_path IS NOT ?\n OR vote_average IS NOT ?\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 17
},
"nullable": []
},
"hash": "0f0966881a69a9f4009df7b27ebeadce81719e044c8fb65ba404b45b70bf8d8c"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE movies\n SET title = ?, year = ?, original_language = ?, digital_release = ?,\n imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,\n metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? AND (\n title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?\n OR digital_release IS NOT ? OR imdb_id IS NOT ?\n OR poster_path IS NOT ? OR backdrop_path IS NOT ?\n OR vote_average IS NOT ?\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 17
},
"nullable": []
},
"hash": "462039959030ee881bcce2daf9d7c74f232ec6543787f64efeb53bffa8170113"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT tmdb_id AS \"tmdb_id!: i64\", metadata_refreshed_at\n FROM movies WHERE id = ?",
"describe": {
"columns": [
{
"name": "tmdb_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "metadata_refreshed_at",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8"
}
@@ -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"
}
@@ -0,0 +1,92 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series WHERE id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "tvdb_id",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "root_id!: i64",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "auto_track!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "upstream_ended!: bool",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "metadata_refreshed_at",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "backdrop_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
true,
true,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10"
}
@@ -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"
}
-4
View File
@@ -223,10 +223,6 @@ bogus ID as a control to prove the check discriminates:
accepted. The bogus control (`gpt-5.6-nonesuch`) failed with HTTP 400, and
`claude-bogus-9` was rejected by the CLI.
**`area/web` issues run on Fable 5 regardless of their difficulty label**, since
they go through the `impeccable` skill and the UI is the reason this project
has a frontend at all (see the design section above).
Escalate one tier if a session fails CI twice on the same issue — and escalate
to the *other* model at that tier first, before going up. Never de-escalate
mid-issue.
+9 -1
View File
@@ -40,7 +40,8 @@ pub use series::{
UpdateSeason, UpdateSeries,
};
pub use state::{
AppState, EpisodeCommand, MovieCommand, SeasonCommand, Upstreams, DEFAULT_TMDB_URL,
AppState, EpisodeCommand, MetadataCommand, MovieCommand, SeasonCommand, Upstreams,
DEFAULT_TMDB_URL,
};
pub use trailer::{Trailer, TrailerKind};
@@ -90,7 +91,9 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(series::get, series::update, series::delete))
.routes(routes!(series::seasons, series::create_season))
.routes(routes!(series::update_season))
.routes(routes!(series::delete_season_files))
.routes(routes!(series::get_episode, series::update_episode))
.routes(routes!(series::delete_episode_files))
.routes(routes!(series::search_episode))
.routes(routes!(series::episode_releases))
.routes(routes!(series::grab_episode))
@@ -334,6 +337,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"),
+57 -2
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::owners::Owner;
use crate::state::{AppState, MovieCommand};
use crate::state::{AppState, MetadataCommand, MovieCommand};
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct Movie {
@@ -330,9 +330,18 @@ pub async fn create(
)
.execute(pool(&state)?)
.await?;
let movie_id = result.last_insert_rowid();
// Issue #176: the digital release date §6.2 gates targeted search on
// comes from a refresh, and the metadata lane is daily. Ask it to run
// now. Asynchronous by design — the add is already committed and must
// not wait on, or fail because of, TMDB. If nothing is draining, the
// daily sweep still picks the movie up: `metadata_refreshed_at` is NULL.
if let Err(error) = state.send_metadata_command(MetadataCommand::Movie { movie_id }) {
tracing::warn!(movie_id, %error, "metadata refresh not queued for the new movie");
}
Ok((
StatusCode::CREATED,
Json(load_movie(&state, result.last_insert_rowid()).await?),
Json(load_movie(&state, movie_id).await?),
))
}
@@ -1278,6 +1287,52 @@ mod tests {
assert_eq!(title_target(root, root), None);
}
/// Issue #176: §6.2 gates targeted search on the digital release date,
/// and that only ever arrives from a metadata refresh. Adding a movie
/// queues one rather than waiting for the daily lane. TMDB here is a
/// closed port: the add still returns 201 and the command is still
/// queued.
#[tokio::test]
async fn adding_a_movie_queues_a_refresh_even_with_tmdb_down() {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("connect database");
database.migrate().await.expect("migrate database");
let state = AppState::new(
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
.with_tmdb_url("http://127.0.0.1:1".into())
.with_tmdb_api_key(Some("key".into())),
)
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let served = state.clone();
tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") });
let base = format!("http://{address}");
let movie = add_movie(&base, 693_134, 1).await;
let movie_id = movie["id"].as_i64().expect("id");
assert_eq!(
state.next_metadata_command().await.expect("command"),
MetadataCommand::Movie { movie_id }
);
let refreshed_at: Option<String> =
sqlx::query_scalar("SELECT metadata_refreshed_at FROM movies WHERE id = ?")
.bind(movie_id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert!(
refreshed_at.is_none(),
"an unrefreshed movie stays due for the scheduled sweep"
);
}
#[tokio::test]
async fn release_actions_are_scoped_to_the_movie() {
let (_dir, state, base) = application().await;
+28 -73
View File
@@ -33,8 +33,7 @@ pub struct ReleasesQuery {
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SearchResponse {
pub kind: SearchInputKind,
/// In-library hits, grouped first (§9.2): movies and series by title,
/// episodes by episode title with their series and `SxxEyy` for context.
/// In-library hits, grouped first (§9.2): movies and series by title.
pub library: Vec<LibraryResult>,
pub tmdb: Vec<TmdbResult>,
pub manual: Option<String>,
@@ -47,7 +46,6 @@ pub struct SearchResponse {
pub enum LibraryResult {
Movie(Movie),
Series(LibrarySeries),
Episode(LibraryEpisode),
}
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -65,25 +63,6 @@ pub struct LibrarySeries {
pub vote_average: Option<f64>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct LibraryEpisode {
pub episode_id: i64,
pub series_id: i64,
pub series_title: String,
/// `SxxEyy`, so the episode title reads in context (§9.2).
pub tag: String,
/// The episode title — what the search matched on.
pub title: String,
/// The series' poster — an episode has no artwork of its own worth
/// showing at row size.
pub poster_path: Option<String>,
/// The series' rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>,
/// The series' TMDB id — the episode row's trailer chip resolves through
/// it (#148); an episode has no videos of its own worth listing.
pub series_tmdb_id: i64,
}
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SearchInputKind {
@@ -196,7 +175,7 @@ pub async fn search(
let tokens = like_tokens(input);
// §9.2 keeps the two result sets grouped and the library first, so each
// kind lands in its own block: movies, then series, then episodes.
// kind lands in its own block: movies, then series.
let mut library: Vec<LibraryResult> = if matches!(kind, SearchInputKind::TmdbId) {
let tmdb_id = input
.strip_prefix("tmdb:")
@@ -232,16 +211,6 @@ pub async fn search(
};
library.extend(series_rows.into_iter().map(LibraryResult::Series));
// §9.2 names the TV case explicitly: `bluey hospital` finds the episode.
// Tokens may split across the series and episode titles, so every token
// must land in the concatenation — and at least one must match the
// episode title on its own, or a series-title-only query would list
// every episode the series has.
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average, s.tmdb_id AS "series_tmdb_id!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') AND EXISTS (SELECT 1 FROM json_each(?) token WHERE e.title LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens, tokens)
.fetch_all(database.pool())
.await?;
library.extend(episode_rows.into_iter().map(LibraryResult::Episode));
let tmdb = tmdb_client(&state)?;
let results = search_tmdb(&tmdb, kind, input).await?;
if matches!(kind, SearchInputKind::ImdbId) {
@@ -641,10 +610,9 @@ fn input_kind(input: &str) -> SearchInputKind {
}
}
/// §9.2's example — `bluey hospital` finding the episode — needs word-wise
/// matching, not one literal phrase. The query's tokens travel as a JSON
/// array the queries walk with `json_each`; LIKE metacharacters are escaped
/// here rather than in SQL.
/// A multi-word title query needs word-wise matching, not one literal
/// phrase. The query's tokens travel as a JSON array the queries walk with
/// `json_each`; LIKE metacharacters are escaped here rather than in SQL.
fn like_tokens(input: &str) -> serde_json::Value {
serde_json::Value::Array(
input
@@ -889,11 +857,12 @@ mod tests {
assert_eq!(response["tmdb"][0]["vote_count"], 5000);
}
/// §9.2: the in-library set matches series titles and episode titles, so
/// `bluey hospital` finds the episode with its series and `SxxEyy` along
/// for context — and plain `bluey` finds the series itself.
/// §9.2, amended: episode rows never appear, under any query. A query
/// matching an episode title and nothing else returns no rows for that
/// series beyond the series itself, and a query naming both the series
/// and one of its episode titles still surfaces only the series row.
#[tokio::test]
async fn library_results_cover_series_and_episode_titles() {
async fn library_results_never_include_episode_rows() {
let tmdb = MockServer::start().await;
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
@@ -941,26 +910,6 @@ mod tests {
.await
.expect("episode");
let response: serde_json::Value =
reqwest::get(format!("{base}/api/search?q=bluey%20hospital"))
.await
.expect("search")
.json()
.await
.expect("json");
assert_eq!(response["library"].as_array().expect("library").len(), 1);
let episode = &response["library"][0];
assert_eq!(episode["kind"], "episode");
assert_eq!(episode["series_title"], "Bluey");
assert_eq!(episode["tag"], "S01E02");
assert_eq!(episode["title"], "Hospital");
// TMDB carries the TV result below the library set.
assert_eq!(response["tmdb"][0]["kind"], "series");
assert_eq!(response["tmdb"][0]["title"], "Bluey");
// §9.2, amended: an episode row surfaces only when the query matches
// something beyond the series title. `bluey` alone returns the series
// row and no episode below it.
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=bluey"))
.await
.expect("search")
@@ -971,19 +920,25 @@ mod tests {
assert_eq!(library.len(), 1);
assert_eq!(library[0]["kind"], "series");
assert_eq!(library[0]["title"], "Bluey");
assert_eq!(library[0]["tmdb_id"], 82_728);
// TMDB carries the TV result below the library set.
assert_eq!(response["tmdb"][0]["kind"], "series");
assert_eq!(response["tmdb"][0]["title"], "Bluey");
// An episode-title-only query still finds the episode.
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q=hospital"))
.await
.expect("search")
.json()
.await
.expect("json");
let library = response["library"].as_array().expect("library");
assert_eq!(library.len(), 1);
assert_eq!(library[0]["kind"], "episode");
assert_eq!(library[0]["title"], "Hospital");
// A query matching only the episode's title, not the series title,
// returns no rows for that series — no episode row, and no series
// row either, since the series title alone does not match.
for query in ["hospital", "bluey%20hospital"] {
let response: serde_json::Value = reqwest::get(format!("{base}/api/search?q={query}"))
.await
.expect("search")
.json()
.await
.expect("json");
assert!(
response["library"].as_array().expect("library").is_empty(),
"query: {query}"
);
}
}
#[tokio::test]
File diff suppressed because it is too large Load Diff
+41
View File
@@ -74,6 +74,8 @@ pub struct AppState {
pending_episode_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<EpisodeCommand>>>,
season_commands: mpsc::Sender<SeasonCommand>,
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
metadata_commands: mpsc::Sender<MetadataCommand>,
pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>,
}
/// Work explicitly requested through the movie API.
@@ -104,6 +106,22 @@ pub enum SeasonCommand {
Grab { season_id: i64, release_id: i64 },
}
/// A title whose TMDB metadata should be refreshed now rather than on the
/// daily lane's next tick (issue #176).
///
/// Sent when a title is added: a new series has no seasons at all until a
/// refresh reveals them, and a new movie has no digital release date, so
/// waiting up to a day is the difference between a usable page and an empty
/// one. Unlike the other three commands this is not an operator action, so a
/// full channel is dropped rather than reported — the scheduled sweep still
/// owns the title, because a title that was never refreshed keeps
/// `metadata_refreshed_at` NULL.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetadataCommand {
Series { series_id: i64 },
Movie { movie_id: i64 },
}
impl AppState {
/// Build the state, including the shared HTTP client.
///
@@ -115,6 +133,7 @@ impl AppState {
let (movie_commands, pending_movie_commands) = mpsc::channel(64);
let (episode_commands, pending_episode_commands) = mpsc::channel(64);
let (season_commands, pending_season_commands) = mpsc::channel(64);
let (metadata_commands, pending_metadata_commands) = mpsc::channel(64);
Ok(Self {
http,
upstreams: Arc::new(upstreams),
@@ -125,6 +144,8 @@ impl AppState {
pending_episode_commands: Arc::new(tokio::sync::Mutex::new(pending_episode_commands)),
season_commands,
pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
metadata_commands,
pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)),
})
}
@@ -162,6 +183,16 @@ impl AppState {
self.pending_season_commands.lock().await.recv().await
}
/// Wait for the next on-demand metadata refresh in the daemon's metadata
/// lane.
///
/// # Errors
///
/// If every sender has been dropped.
pub async fn next_metadata_command(&self) -> Option<MetadataCommand> {
self.pending_metadata_commands.lock().await.recv().await
}
pub(crate) fn http(&self) -> &reqwest::Client {
&self.http
}
@@ -194,4 +225,14 @@ impl AppState {
) -> Result<(), mpsc::error::TrySendError<SeasonCommand>> {
self.season_commands.try_send(command)
}
/// Ask the metadata lane to refresh one title now. Best effort by
/// design: an add must not fail because the channel is full or because
/// nothing is draining it, so the caller logs and carries on.
pub(crate) fn send_metadata_command(
&self,
command: MetadataCommand,
) -> Result<(), mpsc::error::TrySendError<MetadataCommand>> {
self.metadata_commands.try_send(command)
}
}
+18 -17
View File
@@ -38,14 +38,16 @@ pub fn apply_auto_track(series: &Series, ever_refreshed: bool, seasons: &mut [Re
/// Applies a season's tracking rule (`DESIGN.md` §4.1) to its
/// already-revealed episodes.
///
/// Turning tracking on marks every revealed episode wanted; future reveals
/// follow while the season stays tracked. Turning it off withdraws nothing:
/// leaf intent is never removed implicitly.
/// Turning tracking on marks every revealed episode wanted, and future
/// reveals follow while the season stays tracked. Turning it off clears
/// `wanted` on all of them: toggling the flag is itself the explicit act,
/// and the product offers no other way to withdraw a season's worth of
/// intent. The rule is idempotent and must run even when the flag already
/// has the value asked for (#171) — an operator re-sending `tracked: false`
/// to an already-untracked season is clearing stranded intent.
pub fn apply_tracked(tracked: bool, episodes: &mut [Episode]) {
if tracked {
for episode in episodes {
episode.wanted = true;
}
for episode in episodes {
episode.wanted = tracked;
}
}
@@ -216,8 +218,12 @@ mod tests {
}
}
/// #171. Toggling the flag is the explicit act, so `false` clears
/// wanted on every revealed episode — including when it is re-sent to a
/// season that was already untracked, which is the only way out for
/// rows stranded by the old ruling.
#[test]
fn tracked_off_withdraws_nothing() {
fn tracked_off_clears_revealed_episodes_wanted() {
let cases: [&[bool]; 4] = [
&[],
&[false],
@@ -236,18 +242,13 @@ mod tests {
)
})
.collect::<Vec<_>>();
let before = episodes
.iter()
.map(|episode| episode.wanted)
.collect::<Vec<_>>();
apply_tracked(false, &mut episodes);
let after = episodes
.iter()
.map(|episode| episode.wanted)
.collect::<Vec<_>>();
assert_eq!(before, after, "tracked off must not touch intent");
assert!(
episodes.iter().all(|episode| !episode.wanted),
"tracked off must clear every revealed episode, input {wanted:?}"
);
}
}
}
+80 -77
View File
@@ -183,7 +183,7 @@ impl GrabAction {
let tmdb_id =
u32::try_from(movie.tmdb_id).map_err(|_| GrabError::InvalidTmdbId(movie.id))?;
let metadata = tmdb.movie(tmdb_id).await?;
let changed = self.store_metadata(database, &movie, &metadata).await?;
let changed = store_movie_metadata(database, movie.id, &metadata).await?;
if changed {
tracing::info!(
movie_id = movie.id,
@@ -219,82 +219,6 @@ impl GrabAction {
))
}
/// Write one refresh's fields to the row, guarded so unchanged data moves
/// nothing. Returns whether anything did.
async fn store_metadata(
&self,
database: &Db,
movie: &PendingMovie,
metadata: &arr_meta::Movie,
) -> Result<bool, GrabError> {
let title = metadata.title.clone();
let year = metadata.year().map(i64::from);
let original_language =
(!metadata.original_language.is_empty()).then_some(metadata.original_language.clone());
let digital_release = metadata.digital_release.map(|date| date.to_string());
// §6.2: the id RSS matching prefers, and the one Torznab movie
// searches take. TMDB does not know one for every title.
let imdb_id = metadata.imdb_id.clone();
// §9.6: these three are the exception to "rich detail is not
// persisted" — pure-SQL views render artwork without a TMDB call.
let poster_path = metadata.poster_path.clone();
let backdrop_path = metadata.backdrop_path.clone();
let vote_average = metadata.vote_average;
let title_ref = title.as_str();
let original_language_ref = original_language.as_deref();
let digital_release_ref = digital_release.as_deref();
let imdb_id_ref = imdb_id.as_deref();
let poster_path_ref = poster_path.as_deref();
let backdrop_path_ref = backdrop_path.as_deref();
let changed = sqlx::query!(
r#"UPDATE movies
SET title = ?, year = ?, original_language = ?, digital_release = ?,
imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,
metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
search_attempts = 0, last_searched_at = NULL,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ? AND (
title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?
OR digital_release IS NOT ? OR imdb_id IS NOT ?
OR poster_path IS NOT ? OR backdrop_path IS NOT ?
OR vote_average IS NOT ?
)"#,
title_ref,
year,
original_language_ref,
digital_release_ref,
imdb_id_ref,
poster_path_ref,
backdrop_path_ref,
vote_average,
movie.id,
title_ref,
year,
original_language_ref,
digital_release_ref,
imdb_id_ref,
poster_path_ref,
backdrop_path_ref,
vote_average,
)
.execute(database.pool())
.await?
.rows_affected()
!= 0;
if !changed {
// Still stamp the refresh even when nothing changed, or the TTL
// gate above never engages and every tick pays for TMDB again.
sqlx::query!(
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
movie.id
)
.execute(database.pool())
.await?;
}
Ok(changed)
}
/// Search every indexer for one title, cache each candidate with its
/// verdict and score (§9.3), and return the eligible ones best first.
///
@@ -999,6 +923,85 @@ async fn pending_movies(database: &Db) -> Result<Vec<PendingMovie>, GrabError> {
.collect())
}
/// Write one refresh's fields to the movie row, guarded so unchanged data
/// moves nothing. Returns whether anything did.
///
/// A free function rather than a [`GrabAction`] method because the metadata
/// lane refreshes a title on demand (issue #176) with nothing but a TMDB
/// client — grabbing needs Prowlarr, refreshing does not.
pub(crate) async fn store_movie_metadata(
database: &Db,
movie_id: i64,
metadata: &arr_meta::Movie,
) -> Result<bool, GrabError> {
let title = metadata.title.clone();
let year = metadata.year().map(i64::from);
let original_language =
(!metadata.original_language.is_empty()).then_some(metadata.original_language.clone());
let digital_release = metadata.digital_release.map(|date| date.to_string());
// §6.2: the id RSS matching prefers, and the one Torznab movie
// searches take. TMDB does not know one for every title.
let imdb_id = metadata.imdb_id.clone();
// §9.6: these three are the exception to "rich detail is not
// persisted" — pure-SQL views render artwork without a TMDB call.
let poster_path = metadata.poster_path.clone();
let backdrop_path = metadata.backdrop_path.clone();
let vote_average = metadata.vote_average;
let title_ref = title.as_str();
let original_language_ref = original_language.as_deref();
let digital_release_ref = digital_release.as_deref();
let imdb_id_ref = imdb_id.as_deref();
let poster_path_ref = poster_path.as_deref();
let backdrop_path_ref = backdrop_path.as_deref();
let changed = sqlx::query!(
r#"UPDATE movies
SET title = ?, year = ?, original_language = ?, digital_release = ?,
imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,
metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
search_attempts = 0, last_searched_at = NULL,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ? AND (
title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?
OR digital_release IS NOT ? OR imdb_id IS NOT ?
OR poster_path IS NOT ? OR backdrop_path IS NOT ?
OR vote_average IS NOT ?
)"#,
title_ref,
year,
original_language_ref,
digital_release_ref,
imdb_id_ref,
poster_path_ref,
backdrop_path_ref,
vote_average,
movie_id,
title_ref,
year,
original_language_ref,
digital_release_ref,
imdb_id_ref,
poster_path_ref,
backdrop_path_ref,
vote_average,
)
.execute(database.pool())
.await?
.rows_affected()
!= 0;
if !changed {
// Still stamp the refresh even when nothing changed, or the TTL
// gate above never engages and every tick pays for TMDB again.
sqlx::query!(
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
movie_id
)
.execute(database.pool())
.await?;
}
Ok(changed)
}
fn search_due(movie: &PendingMovie) -> bool {
backoff_elapsed(movie.search_attempts, movie.last_searched_at.as_deref())
}
+24 -3
View File
@@ -8,6 +8,7 @@ mod import;
mod indexers;
mod jellyfin;
mod manual;
mod metadata;
mod notify;
mod reaper;
pub mod reconcile;
@@ -125,6 +126,10 @@ async fn run() -> Result<(), Error> {
let notifier = Notifier::new(config.ntfy_url.clone())?;
let (reconcile, manual_grab, manual_tv) =
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), &notifier)?;
// Issue #176: the on-demand half of the metadata lane needs its own
// handle — the sweep's `SeriesRefreshAction` is owned by `ReconcileLoop`,
// and the compat shim takes the other clone below.
let metadata_tmdb = tmdb.clone();
// Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and
// needs its own TMDB client for `movie/lookup`.
@@ -162,12 +167,18 @@ async fn run() -> Result<(), Error> {
// in the 64-slot buffer forever. Issue #132: the episode and season
// channels are drained by the same lane.
let mut manual_task = tokio::spawn(manual::run(
state,
database,
state.clone(),
database.clone(),
manual_grab,
manual_tv,
shutdown_rx,
shutdown_rx.clone(),
));
// Issue #176: adding a title queues a refresh here rather than waiting
// for the daily sweep. Its own task, not an arm of `manual::run`: a
// refresh can take a while against TMDB and must not sit in front of an
// operator's manual search.
let mut metadata_task =
tokio::spawn(metadata::run(state, database, metadata_tmdb, shutdown_rx));
let signal_tx = shutdown_tx.clone();
let server = async move {
axum::serve(listener, app)
@@ -184,18 +195,28 @@ async fn run() -> Result<(), Error> {
let _ = shutdown_tx.send(true);
reconcile_task.await?;
manual_task.await?;
metadata_task.await?;
result.map_err(Error::Serve)
}
result = &mut reconcile_task => {
result?;
let _ = shutdown_tx.send(true);
manual_task.await?;
metadata_task.await?;
server.await.map_err(Error::Serve)
}
result = &mut manual_task => {
result?;
let _ = shutdown_tx.send(true);
reconcile_task.await?;
metadata_task.await?;
server.await.map_err(Error::Serve)
}
result = &mut metadata_task => {
result?;
let _ = shutdown_tx.send(true);
reconcile_task.await?;
manual_task.await?;
server.await.map_err(Error::Serve)
}
}
+367
View File
@@ -0,0 +1,367 @@
//! The on-demand half of the metadata lane. See DESIGN.md §8 and issue #176.
//!
//! The scheduled sweep is daily (`Tick::Metadata`), which is right for
//! keeping a library current and wrong for a title added a moment ago: a new
//! series has no seasons at all until a refresh reveals them, and a new movie
//! has no digital release date, which is what §6.2 gates targeted search on.
//! Shortening the tick would not fix either — it would still leave a visible
//! wait and would spend a TMDB call per title per tick.
//!
//! So `POST /api/series` and `POST /api/movies` send a [`MetadataCommand`]
//! after the row is committed, exactly as the manual search and grab
//! endpoints send theirs (issues #107 and #132), and this lane drains it.
//! The add itself never waits on TMDB and never fails because of it; the
//! worst a broken refresh can do is leave `metadata_refreshed_at` NULL, which
//! is what the sweep already treats as due.
use std::sync::Arc;
use arr_api::{AppState, MetadataCommand};
use arr_db::Db;
use arr_meta::TmdbClient;
use tokio::sync::watch;
use crate::grab::{metadata_refresh_due, store_movie_metadata, GrabError};
use crate::series_refresh::SeriesRefreshAction;
/// Run until every sender is dropped or `shutdown` fires.
///
/// `tmdb` is `None` when TMDB is not configured, which already disables the
/// scheduled sweep (`main.rs` warns about it). Commands are still drained so
/// the channel never fills.
pub async fn run(
state: AppState,
database: Db,
tmdb: Option<Arc<TmdbClient>>,
mut shutdown: watch::Receiver<bool>,
) {
let series = tmdb
.as_ref()
.map(|tmdb| SeriesRefreshAction::new(Arc::clone(tmdb)));
loop {
let command = tokio::select! {
biased;
changed = shutdown.changed() => {
if changed.is_err() || *shutdown.borrow() {
return;
}
continue;
}
command = state.next_metadata_command() => command,
};
let Some(command) = command else {
return;
};
let (Some(tmdb), Some(series)) = (tmdb.as_ref(), series.as_ref()) else {
tracing::warn!("metadata refresh requested but TMDB is not configured");
continue;
};
match command {
MetadataCommand::Series { series_id } => {
// A title deleted between the send and the drain is not an
// error: `refresh_now` finds no row and does nothing.
match series.refresh_now(&database, series_id).await {
Ok(_) => {}
// A failed refresh leaves `metadata_refreshed_at` NULL,
// so the daily sweep retries the series. The add stands.
Err(error) => {
tracing::error!(series_id, %error, "on-demand series refresh failed");
}
}
}
MetadataCommand::Movie { movie_id } => {
if let Err(error) = refresh_movie(tmdb, &database, movie_id).await {
tracing::error!(movie_id, %error, "on-demand movie refresh failed");
}
}
}
}
}
/// Refresh one movie now, on the same terms as the series path: a row that
/// is gone is not an error, and the sweep's TTL gate applies so a title
/// something else already refreshed costs no second TMDB call.
async fn refresh_movie(tmdb: &TmdbClient, database: &Db, movie_id: i64) -> Result<(), GrabError> {
let movie = sqlx::query!(
r#"SELECT tmdb_id AS "tmdb_id!: i64", metadata_refreshed_at
FROM movies WHERE id = ?"#,
movie_id
)
.fetch_optional(database.pool())
.await?;
let Some(movie) = movie else {
return Ok(());
};
if !metadata_refresh_due(movie.metadata_refreshed_at.as_deref()) {
return Ok(());
}
let tmdb_id = u32::try_from(movie.tmdb_id).map_err(|_| GrabError::InvalidTmdbId(movie_id))?;
let metadata = tmdb.movie(tmdb_id).await?;
store_movie_metadata(database, movie_id, &metadata).await?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use arr_meta::TmdbClient;
use tempfile::TempDir;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
const MOVIE_METADATA: &str = r#"{
"id": 693134,
"title": "Dune Part Two",
"original_title": "Dune: Part Two",
"original_language": "en",
"origin_country": ["US"],
"release_date": "2024-02-27",
"poster_path": "/dune-two.jpg",
"release_dates": {"results": [{"release_dates": [{
"type": 4, "release_date": "2024-04-16T00:00:00.000Z"
}]}]}
}"#;
fn series_detail() -> serde_json::Value {
serde_json::json!({
"id": 82_728,
"name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"status": "Returning Series",
"seasons": [{"season_number": 1, "episode_count": 2}],
"external_ids": {"tvdb_id": 361_391}
})
}
fn season_detail() -> serde_json::Value {
serde_json::json!({
"season_number": 1,
"episodes": [
{"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"},
{"episode_number": 2, "name": "Hospital", "air_date": "2018-10-02"}
]
})
}
/// A TMDB that answers for the series, and one that answers for the
/// movie. `status` lets a test serve an error instead.
async fn tmdb_series(status: u16) -> MockServer {
let server = MockServer::start().await;
let response = if status == 200 {
ResponseTemplate::new(200).set_body_json(series_detail())
} else {
ResponseTemplate::new(status)
};
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(response)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/tv/82728/season/1"))
.respond_with(ResponseTemplate::new(200).set_body_json(season_detail()))
.mount(&server)
.await;
server
}
async fn tmdb_movie(status: u16) -> MockServer {
let server = MockServer::start().await;
let response = if status == 200 {
ResponseTemplate::new(200).set_body_string(MOVIE_METADATA)
} else {
ResponseTemplate::new(status)
};
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.and(query_param("append_to_response", "release_dates"))
.respond_with(response)
.mount(&server)
.await;
server
}
fn series_action(server: &MockServer) -> SeriesRefreshAction {
SeriesRefreshAction::new(Arc::new(
TmdbClient::builder("key".to_owned())
.base_url(server.uri())
.build()
.unwrap(),
))
}
fn movie_client(server: &MockServer) -> TmdbClient {
TmdbClient::builder("key".to_owned())
.base_url(format!("{}/3/", server.uri()))
.build()
.unwrap()
}
/// A series and a movie as `POST /api/series` and `POST /api/movies`
/// leave them: added, never refreshed.
async fn added_titles() -> (TempDir, Db) {
let directory = tempfile::tempdir().unwrap();
let database = Db::connect(directory.path().join("arr.db")).await.unwrap();
database.migrate().await.unwrap();
sqlx::query(
"INSERT INTO series (tmdb_id, title, root_id, auto_track)
SELECT 82728, 'Bluey', id, 1 FROM roots WHERE kind = 'tv' AND audience = 'main'",
)
.execute(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO movies (tmdb_id, title, year, original_language, root_id)
SELECT 693134, 'Dune Part Two', 2024, 'en', id
FROM roots WHERE kind = 'movie' AND audience = 'main'",
)
.execute(database.pool())
.await
.unwrap();
(directory, database)
}
async fn series_stamp(database: &Db) -> Option<String> {
sqlx::query_scalar("SELECT metadata_refreshed_at FROM series WHERE id = 1")
.fetch_one(database.pool())
.await
.unwrap()
}
async fn movie_stamp(database: &Db) -> Option<String> {
sqlx::query_scalar("SELECT metadata_refreshed_at FROM movies WHERE id = 1")
.fetch_one(database.pool())
.await
.unwrap()
}
/// Issue #176: the whole point. A series added a moment ago has its
/// seasons revealed by the command, not by a tick up to a day away.
#[tokio::test]
async fn an_added_series_is_refreshed_on_demand() {
let (_dir, database) = added_titles().await;
let server = tmdb_series(200).await;
series_action(&server)
.refresh_now(&database, 1)
.await
.unwrap();
let episodes: i64 = sqlx::query_scalar("SELECT count(*) FROM episodes")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(episodes, 2, "the seasons TMDB knows must be revealed");
assert!(series_stamp(&database).await.is_some());
}
/// A movie's digital release date is what §6.2 gates targeted search on,
/// and it only ever arrives from a refresh.
#[tokio::test]
async fn an_added_movie_is_refreshed_on_demand() {
let (_dir, database) = added_titles().await;
let server = tmdb_movie(200).await;
refresh_movie(&movie_client(&server), &database, 1)
.await
.unwrap();
let (digital_release, poster): (Option<String>, Option<String>) =
sqlx::query_as("SELECT digital_release, poster_path FROM movies WHERE id = 1")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(digital_release.as_deref(), Some("2024-04-16"));
assert_eq!(poster.as_deref(), Some("/dune-two.jpg"));
assert!(movie_stamp(&database).await.is_some());
}
/// A failed refresh must not roll the add back and must not stamp the
/// row, or the scheduled sweep would treat the title as done.
#[tokio::test]
async fn a_failed_series_refresh_leaves_the_stamp_null_for_the_sweep() {
let (_dir, database) = added_titles().await;
let server = tmdb_series(500).await;
let result = series_action(&server).refresh_now(&database, 1).await;
assert!(result.is_err());
assert!(series_stamp(&database).await.is_none());
let series: i64 = sqlx::query_scalar("SELECT count(*) FROM series")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(series, 1, "the add is not rolled back");
}
#[tokio::test]
async fn a_failed_movie_refresh_leaves_the_stamp_null_for_the_sweep() {
let (_dir, database) = added_titles().await;
let server = tmdb_movie(500).await;
let result = refresh_movie(&movie_client(&server), &database, 1).await;
assert!(result.is_err());
assert!(movie_stamp(&database).await.is_none());
let movies: i64 = sqlx::query_scalar("SELECT count(*) FROM movies")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(movies, 1, "the add is not rolled back");
}
/// Add then delete before the lane drains: nothing to refresh, and that
/// is not a failure.
#[tokio::test]
async fn a_command_naming_a_deleted_title_is_not_an_error() {
let (_dir, database) = added_titles().await;
let series = tmdb_series(200).await;
let movie = tmdb_movie(200).await;
series_action(&series)
.refresh_now(&database, 404)
.await
.unwrap();
refresh_movie(&movie_client(&movie), &database, 404)
.await
.unwrap();
assert!(series.received_requests().await.unwrap().is_empty());
assert!(movie.received_requests().await.unwrap().is_empty());
}
/// The TTL gate the scheduled sweep uses applies here too: a title the
/// sweep just refreshed does not pay for a second TMDB call.
#[tokio::test]
async fn a_title_the_sweep_just_refreshed_costs_no_second_call() {
let (_dir, database) = added_titles().await;
let series = tmdb_series(200).await;
let movie = tmdb_movie(200).await;
sqlx::query(
"UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
)
.execute(database.pool())
.await
.unwrap();
sqlx::query(
"UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
)
.execute(database.pool())
.await
.unwrap();
series_action(&series)
.refresh_now(&database, 1)
.await
.unwrap();
refresh_movie(&movie_client(&movie), &database, 1)
.await
.unwrap();
assert!(series.received_requests().await.unwrap().is_empty());
assert!(movie.received_requests().await.unwrap().is_empty());
}
}
+33 -1
View File
@@ -46,7 +46,7 @@ fn is_upstream_ended(status: &str) -> bool {
}
#[derive(Debug, thiserror::Error)]
enum RefreshError {
pub(crate) enum RefreshError {
#[error("database: {0}")]
Database(#[from] sqlx::Error),
#[error("tmdb: {0}")]
@@ -101,6 +101,38 @@ impl SeriesRefreshAction {
Ok(outcomes)
}
/// Refresh one series now, for the on-demand metadata lane (issue #176).
///
/// A series deleted between the command being sent and drained is not an
/// error — there is simply nothing to refresh. The sweep's TTL gate
/// applies here too, so a title the sweep already refreshed costs no
/// second TMDB call.
pub(crate) async fn refresh_now(
&self,
database: &Db,
series_id: i64,
) -> Result<Option<Outcome>, RefreshError> {
let stale = sqlx::query_as!(
DueSeries,
r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id,
title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
poster_path, backdrop_path, vote_average
FROM series WHERE id = ?"#,
series_id
)
.fetch_optional(database.pool())
.await?;
let Some(stale) = stale else {
return Ok(None);
};
if !metadata_refresh_due(stale.metadata_refreshed_at.as_deref()) {
return Ok(None);
}
self.refresh_series(database, &stale).await
}
async fn refresh_series(
&self,
database: &Db,
+22
View File
@@ -495,6 +495,28 @@
</div>
</section>
<!-- removal is the one library control a series carries (issue 175):
the same arming panel a movie uses — evidence first, then the
decision — wired to DELETE /api/series/{id} -->
<section class="deck-group" id="series-library" aria-label="library controls">
<header class="deck-head">
<h3 class="deck-label">library</h3>
</header>
<div class="movie-controls">
<span class="movie-controls-space"></span>
<button
type="button"
class="control control-quiet"
id="series-remove"
aria-expanded="false"
aria-controls="series-remove-panel"
>
remove
</button>
</div>
<div class="remove-panel" id="series-remove-panel" hidden></div>
</section>
<ul class="deck-rows seasons" id="rows-seasons"></ul>
</main>
+176 -66
View File
@@ -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;
@@ -258,7 +261,7 @@ function main() {
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
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();
@@ -273,7 +276,9 @@ function main() {
library.open();
break;
}
});
};
movieDetail.setRemoved(openAfterRemoval);
seriesDetail.setRemoved(openAfterRemoval);
refreshQueuesBadge = () => {
void queues.refreshBadge();
};
@@ -563,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");
@@ -590,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;
@@ -972,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,
@@ -1817,15 +1781,24 @@ function movieMain(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");
@@ -1978,6 +1951,27 @@ interface RemoveActions {
removed: () => void;
}
/** The slice of a file the removal panel reads: evidence, nothing else. */
interface RemoveFile {
path: string;
size: number;
}
/**
* What the panel needs to know about a title (issue 175): a movie and a
* series differ only in which endpoints they call, how their files roll up
* to one folder, and how many torrents the §7.3 warning speaks of.
*/
interface RemoveSubject {
title: string;
files: () => Promise<{ kind: "files"; files: RemoveFile[] } | { kind: "error"; detail: string }>;
/** The one folder the delete takes, when the files agree on it. */
folder: (files: RemoveFile[]) => string | null;
remove: () => Promise<ActionOutcome>;
/** What does not happen: seeding continues under its own rule (§7.3). */
seedLine: string;
}
/**
* The removal confirmation (issue 104, simplified by 110): removing a title
* always unlinks its §7.4 folder, so there is one decision, not two.
@@ -1985,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";
@@ -2024,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;
}
@@ -2038,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;
@@ -2051,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();
@@ -2065,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();
@@ -2970,6 +2965,8 @@ interface SeriesView {
returnTo: HTMLElement,
parentRoute: Route,
) => Promise<void>;
/** Where to land once the title is gone — same contract as a movie. */
setRemoved: (handler: (parent: Route) => void) => void;
}
const PAD_TWO = (value: number): string => String(value).padStart(2, "0");
@@ -2990,6 +2987,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const actionsEl = must<HTMLElement>("#series-actions");
const statusEl = must<HTMLElement>("#series-status");
const seasonsList = must<HTMLUListElement>("#rows-seasons");
const remove = must<HTMLButtonElement>("#series-remove");
const removeWrap = must<HTMLElement>("#series-remove-panel");
let roots: Root[] = [];
let series: ApiSeries | null = null;
@@ -3005,6 +3004,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
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;
@@ -3016,6 +3016,59 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
}
}
/* ---- removal confirmation: the movie panel, generalised (issue 175) ---- */
/** Tears the confirmation down without stealing focus from a caller. */
function clearRemove() {
removeWrap.hidden = true;
removeWrap.replaceChildren();
remove.setAttribute("aria-expanded", "false");
}
function closeRemove() {
clearRemove();
remove.focus();
}
function openRemove() {
const current = series;
const id = seriesId;
if (!current || id === null) {
return;
}
const panel = removePanel(
{
title: current.title,
files: () => fetchSeriesFiles(id),
folder: seriesFolder,
remove: () => removeSeries(id),
seedLine: "torrents keep seeding until their tracker rules clear.",
},
{
cancel: closeRemove,
removed: () => {
const parent = parentRoute;
clearRemove();
close();
removed?.(parent);
},
},
);
removeWrap.replaceChildren(panel);
removeWrap.hidden = false;
remove.setAttribute("aria-expanded", "true");
// cancel takes focus, not the destructive action (same as a movie)
panel.querySelector<HTMLElement>(".remove-actions .control:last-child")?.focus();
}
remove.addEventListener("click", () => {
if (removeWrap.hidden) {
openRemove();
} else {
closeRemove();
}
});
function paintHeader() {
const current = series;
if (!current) {
@@ -3242,6 +3295,32 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
);
}
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");
@@ -3304,7 +3383,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
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";
@@ -3434,6 +3531,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
focusKey = null;
deckEl.hidden = true;
view.hidden = false;
clearRemove();
clearRichDetail();
back.focus();
await load();
@@ -3444,6 +3542,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
seriesId = null;
seasons = null;
sequence += 1;
clearRemove();
clearRichDetail();
}
@@ -3463,6 +3562,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
(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();
}
@@ -3470,7 +3574,13 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
true,
);
return { hide, open };
return {
hide,
open,
setRemoved: (handler: (parent: Route) => void) => {
removed = handler;
},
};
}
/* ---- attention queues (§5.2 + §5.7, issue #33) ------------------------ */
+2 -2
View File
@@ -131,7 +131,7 @@ export async function movieFiles(movieId: number): Promise<FilesOutcome> {
* names everything a files-too remove takes. `null` when the files disagree,
* which the panel then says instead of naming one folder falsely.
*/
export function libraryFolder(files: MovieFile[]): string | null {
export function libraryFolder(files: { path: string }[]): string | null {
const folders = new Set(files.map((file) => file.path.slice(0, file.path.lastIndexOf("/"))));
if (folders.size !== 1) {
return null;
@@ -140,7 +140,7 @@ export function libraryFolder(files: MovieFile[]): string | null {
return folder === undefined || folder === "" ? null : folder;
}
export function totalSize(files: MovieFile[]): number {
export function totalSize(files: { size: number }[]): number {
return files.reduce((sum, file) => sum + file.size, 0);
}
+1 -17
View File
@@ -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";
+62
View File
@@ -149,6 +149,68 @@ export async function setEpisodeWanted(episodeId: number, wanted: boolean): Prom
}
}
/* ---- removal (issues 174 + 175) ---------------------------------------- */
/**
* The §7.4 title folder these episode files share. Season subfolders differ
* between files, so the shared folder is the one carrying the `[tmdbid-…]`
* tag every §7.4 title folder name has. `null` when the files disagree,
* which the panel then says instead of naming one folder falsely.
*/
export function seriesFolder(files: { path: string }[]): string | null {
const folders = new Set<string>();
for (const file of files) {
const parts = file.path.split("/");
const titleAt = parts.findIndex((part) => part.includes("[tmdbid-"));
const folder =
titleAt > 0
? parts.slice(0, titleAt + 1).join("/")
: file.path.slice(0, file.path.lastIndexOf("/"));
if (folder === "") {
return null;
}
folders.add(folder);
}
if (folders.size !== 1) {
return null;
}
return [...folders][0] ?? null;
}
/**
* Remove the series from the library. The row and its §7.4 title folder go.
* Torrents keep seeding — the reaper owns that lifecycle (§7.3).
*/
export function removeSeries(seriesId: number): Promise<ActionOutcome> {
return del(`/api/series/${seriesId}`);
}
/**
* #174: the season's files go and its episodes stop being wanted. The season
* stays listed — TMDB owns that metadata and the next refresh would recreate
* it anyway.
*/
export function removeSeasonFiles(seriesId: number, seasonNumber: number): Promise<ActionOutcome> {
return del(`/api/series/${seriesId}/seasons/${seasonNumber}/files`);
}
/** #174, one episode: the file goes and the episode stops being wanted. */
export function removeEpisodeFiles(episodeId: number): Promise<ActionOutcome> {
return del(`/api/episodes/${episodeId}/files`);
}
async function del(url: string): Promise<ActionOutcome> {
try {
const response = await fetch(url, { method: "DELETE" });
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/* ---- manual triggers and decks ---------------------------------------- */
/** §6.2 manual search, one targeted sweep, for a season or an episode. */
+3 -2
View File
@@ -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;
+14 -1
View File
@@ -995,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;
}
@@ -1025,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 {