Merge the Subtitles milestone
ci / web (push) Successful in 42s
ci / rust (push) Failing after 2m18s
e2e / e2e (push) Failing after 3m36s

DESIGN.md §15 end to end: provider search and download, embedded-track
extraction, translation behind four feature-gated backends, `alass` sync,
sidecar naming and the `.mt` machine-made segment, per-provider and
per-translator daily budgets, and the subtitle lane in /settings.

One sidecar per language, enforced in the schema (#222), so a forced track
never consumes the language's only slot. The OpenAI-compatible backend's
base URL and model are database rows, not bootstrap config (#220) — that
backend is any endpoint speaking the shape, `llama.cpp` included, and an
endpoint that needs no key is a valid configuration.

41 issues, plus #255 landing the provider reorder control on the shared
icon and control vocabulary.

`just ci` green: 778 tests. Migrations 0024, 0025 and 0027-0029 apply out
of order against a database that already has 0030-0032 — verified against
a database built from main's migration set, which is the shape production
is in.

CI's `rust` job is red on main independently of this merge; see #256.
This commit is contained in:
Miguel Palhas
2026-08-25 22:40:29 +01:00
140 changed files with 23208 additions and 179 deletions
+14 -2
View File
@@ -13,6 +13,7 @@ on:
- 'crates/arr-indexer/**'
- 'crates/arr-meta/**'
- 'crates/arr-probe/**'
- 'crates/arr-subs/**'
- 'crates/arr-api/**'
- 'crates/arr-daemon/**'
- '.gitea/workflows/e2e.yml'
@@ -70,10 +71,21 @@ jobs:
curl -LsSf https://get.nexte.st/latest/linux \
| tar zxf - -C "$HOME/.cargo/bin"
- name: alass
# No distro package; the subtitle sync path (DESIGN.md §15) shells
# out to a binary literally named `alass`, which the crate is not.
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v alass >/dev/null || {
cargo install alass-cli --locked
ln -sf "$HOME/.cargo/bin/alass-cli" "$HOME/.cargo/bin/alass"
}
- name: build daemon
# The harness spawns this binary; building it here keeps the compile
# out of the first test's boot window.
run: cargo build -p arr-daemon --bin arr
# out of the first test's boot window. `translate-command` is the
# subtitle e2e scenarios' stub-script translation backend.
run: cargo build -p arr-daemon --bin arr --features translate-command
- name: e2e
env:
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
"query": "SELECT id AS \"id!: i64\", path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
"describe": {
"columns": [
{
@@ -34,5 +34,5 @@
false
]
},
"hash": "3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa"
"hash": "028ba37575a1081f7678fd8649227f39c29afc1883e3a3607260455d1a088143"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT r.name AS \"name!: String\",\n r.parsed AS \"parsed!: serde_json::Value\"\n FROM episodes e\n JOIN grabs g ON g.target_kind = 'season' AND g.target_id = e.season_id\n JOIN releases r ON r.id = g.release_id\n WHERE e.id = ?\n ORDER BY g.imported_at DESC, g.id DESC LIMIT 1",
"describe": {
"columns": [
{
"name": "name!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "name"
}
}
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "parsed"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "05b5757f0fb7e6ab1176387854a14fc8db793871193b6f16e3719d73040e8ce8"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_attempts\n (media_file_id, language, state, attempts, last_attempt_at, last_failure)\n VALUES (?, ?, ?, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)\n ON CONFLICT (media_file_id, language) DO UPDATE\n SET state = excluded.state,\n attempts = subtitle_attempts.attempts + 1,\n last_attempt_at = excluded.last_attempt_at,\n last_failure = excluded.last_failure,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "08c356bfcd7f7cd7a4bac0bf0ce44ce3276577cb18f32b0c01839150e7827bca"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM episodes WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "0b40dbc80628244531a044e872238e356ab6bc66567c72008cff506e595932b1"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT wanted_languages FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "wanted_languages",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "wanted_languages"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "0f94b0f839cfcbe4a70ee5be81f452c88dc610d05042d4af811963c0cbc6113e"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM series WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "2084928704dbb8a2c81692b9aa3b6edff64745945ea4e88d70bcb139e272c857"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(\n SELECT 1 FROM subtitle_files\n WHERE media_file_id = ? AND language = ? AND forced = 0\n ) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "233bae03912e50e6bcde4b24a9d7fbe256dbf626e4e236a06ba08a00643566d3"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT sr.tmdb_id AS \"tmdb_id!: i64\",\n s.number AS \"season!: i64\",\n e.number AS \"episode!: i64\"\n FROM episodes e\n JOIN seasons s ON s.id = e.season_id\n JOIN series sr ON sr.id = s.series_id\n WHERE e.id = ?",
"describe": {
"columns": [
{
"name": "tmdb_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tmdb_id"
}
}
},
{
"name": "season!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episode!: i64",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "268cb11282b16494330c1655ba3dfcb4f2506686822711f3c6489c8ddd42b0e7"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT sf.id AS \"id!: i64\", sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n WHERE mf.owner_kind = 'movie' AND mf.owner_id = ? AND sf.path IS NOT NULL",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "295476f5ee76f552a0d0727fca01f7602111b1dc5614bf2f8c8626744ecb22db"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT wanted_languages AS \"wanted_languages!: String\" FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "wanted_languages!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "wanted_languages"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "2afeefde38499d722f329d1d472bc89524ce6498ccd4098ba97979e0621b840b"
}
@@ -0,0 +1,122 @@
{
"db_name": "SQLite",
"query": "SELECT m.id AS \"movie_id!: i64\", m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\", m.year, m.poster_path,\n mf.id AS \"media_file_id!: i64\", sa.language AS \"language!: String\",\n sa.state AS \"state!: arr_db::SubtitleState\", sa.last_failure\n FROM subtitle_attempts sa\n JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'movie'\n JOIN movies m ON m.id = mf.owner_id\n WHERE sa.state IN ('failed', 'capped', 'unavailable')\n ORDER BY m.title, sa.language",
"describe": {
"columns": [
{
"name": "movie_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "year"
}
}
},
{
"name": "poster_path",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "poster_path"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "language!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "language"
}
}
},
{
"name": "state!: arr_db::SubtitleState",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "state"
}
}
},
{
"name": "last_failure",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_failure"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
true
]
},
"hash": "2b67abe4db9e00517fecfa53fbad5bbbc633a9f537eaaaa9d46a445d8c25eb2c"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE subtitle_attempts\n SET state = 'wanted',\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE media_file_id = ? AND language = ? AND state = 'satisfied'",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "31bed4c9097d6ea2ed189de48440bd0d882873d27450e5d177baed65c74fd7de"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\",\n path AS \"path!: String\",\n size AS \"size!: i64\",\n owner_kind AS \"owner_kind!: String\",\n owner_id AS \"owner_id!: i64\"\n FROM media_files WHERE id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
},
{
"name": "size!: i64",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "size"
}
}
},
{
"name": "owner_kind!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "owner_kind"
}
}
},
{
"name": "owner_id!: i64",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "owner_id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "32443dc6e5bdd414340443c5d527b2578bcdeebd053b667db35423d829d4bf10"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_attempts (media_file_id, language)\n SELECT id, ? FROM media_files WHERE probed IS NOT NULL\n ON CONFLICT (media_file_id, language) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "35ace0417f613340f82522549d70d0dffeb680afb57511cb17fa15b3a571b974"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_files\n (media_file_id, language, origin, provider, candidate_id, engine,\n forced, sdh, synced, sync_rejected, path)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (path) DO NOTHING\n ON CONFLICT (media_file_id, language, forced, sdh)\n WHERE origin = 'embedded' DO NOTHING\n RETURNING id AS \"id!: i64\"",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
}
],
"parameters": {
"Right": 11
},
"nullable": [
null
]
},
"hash": "3d48b78768bf8dc2e337d0935889a5654089f38bb7ee2a15b7e19f4ed5540262"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT r.name AS \"name!: String\",\n r.parsed AS \"parsed!: serde_json::Value\"\n FROM grabs g JOIN releases r ON r.id = g.release_id\n WHERE g.target_kind = ? AND g.target_id = ?\n ORDER BY g.imported_at DESC, g.id DESC LIMIT 1",
"describe": {
"columns": [
{
"name": "name!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "name"
}
}
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "parsed"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false
]
},
"hash": "432cf38775bc4d7dee1dd9d009e06638ea57f805127f7634f34b8189e831f461"
}
@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT a.media_file_id AS \"media_file_id!: i64\",\n a.language AS \"language!: String\",\n a.state AS \"state!: SubtitleState\",\n a.attempts AS \"attempts!: i64\",\n a.last_attempt_at,\n a.last_failure,\n f.path AS \"media_file_path!: String\"\n FROM subtitle_attempts a\n JOIN media_files f ON f.id = a.media_file_id\n WHERE a.state IN ('wanted', 'failed')\n ORDER BY f.created_at DESC, a.last_attempt_at, a.language\n LIMIT ?",
"describe": {
"columns": [
{
"name": "media_file_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "media_file_id"
}
}
},
{
"name": "language!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "language"
}
}
},
{
"name": "state!: SubtitleState",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "state"
}
}
},
{
"name": "attempts!: i64",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "attempts"
}
}
},
{
"name": "last_attempt_at",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_attempt_at"
}
}
},
{
"name": "last_failure",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_failure"
}
}
},
{
"name": "media_file_path!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
true,
false
]
},
"hash": "47ad9162ba373dfbdf061898f5f148e65de261b494453804db912a919a78c2c5"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT tmdb_id AS \"tmdb_id!: i64\" FROM movies WHERE id = ?",
"describe": {
"columns": [
{
"name": "tmdb_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "4f4f037c956ac6b246503354ff9b59db59e36f9a6d59adc0753b95662c9c630a"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT sf.id AS \"id!: i64\", sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\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 = ? AND sf.path IS NOT NULL",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "5153426ad8b36b6a2b8c17942dfedf02b1e1f91482c8784357d2ef61c9d160c1"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\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 = ? AND sf.path IS NOT NULL",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "5f3a663eef0318b38cd864a2e06befa20fba22fc2d281295aed81a851a35ea07"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_attempts (media_file_id, language)\n VALUES (?, ?)\n ON CONFLICT (media_file_id, language) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "6236e1be0e268c678271dd72be52454347da6637f0bf3ecb52dda82cddfe459a"
}
@@ -0,0 +1,158 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\",\n media_file_id AS \"media_file_id!: i64\",\n language AS \"language!: String\",\n origin AS \"origin!: SubtitleOrigin\",\n provider,\n candidate_id,\n engine,\n forced AS \"forced!: bool\",\n sdh AS \"sdh!: bool\",\n synced AS \"synced!: bool\",\n sync_rejected AS \"sync_rejected!: bool\",\n path\n FROM subtitle_files\n WHERE media_file_id = ?\n ORDER BY language, id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "media_file_id"
}
}
},
{
"name": "language!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "language"
}
}
},
{
"name": "origin!: SubtitleOrigin",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "origin"
}
}
},
{
"name": "provider",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "provider"
}
}
},
{
"name": "candidate_id",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "candidate_id"
}
}
},
{
"name": "engine",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "engine"
}
}
},
{
"name": "forced!: bool",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "forced"
}
}
},
{
"name": "sdh!: bool",
"ordinal": 8,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "sdh"
}
}
},
{
"name": "synced!: bool",
"ordinal": 9,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "synced"
}
}
},
{
"name": "sync_rejected!: bool",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "sync_rejected"
}
}
},
{
"name": "path",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
false,
false,
false,
false,
true
]
},
"hash": "62d625c322c8c64b48317cdbf466d74043708d8e12997e0efec23a18eaaaaace"
}
@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT m.id AS \"movie_id!: i64\", m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\", m.year, m.poster_path,\n mf.id AS \"media_file_id!: i64\", sf.language AS \"language!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'movie'\n JOIN movies m ON m.id = mf.owner_id\n WHERE sf.sync_rejected = 1\n ORDER BY m.title, sf.language",
"describe": {
"columns": [
{
"name": "movie_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "year"
}
}
},
{
"name": "poster_path",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "poster_path"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "language!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "language"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "69f34468a61979b85b7513056d58e34c743af3ae62d5327d4dea49fe7586a85b"
}
@@ -0,0 +1,158 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, s.poster_path,\n e.id AS \"episode_id!: i64\", se.number AS \"season_number!: i64\",\n e.number AS \"episode_number!: i64\", mf.id AS \"media_file_id!: i64\",\n sa.language AS \"language!: String\",\n sa.state AS \"state!: arr_db::SubtitleState\", sa.last_failure\n FROM subtitle_attempts sa\n JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'episode'\n JOIN episodes e ON e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE sa.state IN ('failed', 'capped', 'unavailable')\n ORDER BY s.title, se.number, e.number, sa.language",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "year"
}
}
},
{
"name": "poster_path",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "poster_path"
}
}
},
{
"name": "episode_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episode_number!: i64",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 8,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "language!: String",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "language"
}
}
},
{
"name": "state!: arr_db::SubtitleState",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "state"
}
}
},
{
"name": "last_failure",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_failure"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "6cdb9a9344bbab557433da35471968b6e5cf96b5b220ffb41f10b6d851884803"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_budget_spend (kind, name, day, spent)\n SELECT ?1, ?2, strftime('%Y-%m-%d', 'now'), ?3\n WHERE ?3 <= ?4\n ON CONFLICT (kind, name, day) DO UPDATE\n SET spent = subtitle_budget_spend.spent + excluded.spent\n WHERE subtitle_budget_spend.spent + excluded.spent <= ?4\n RETURNING spent AS \"spent!: i64\"",
"describe": {
"columns": [
{
"name": "spent!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_budget_spend",
"name": "spent"
}
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false
]
},
"hash": "6e27f3a4c642ac324ef5ae2017a54b93f1d95aa54c46ff7e169278a295d1a2cc"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM subtitle_attempts WHERE language = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "74178ac3ec41aa8a5ce164103a8d8301cb555eea2fa5d655a7365fd02c557cce"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT wanted_languages AS \"wanted_languages!: String\",\n providers_enabled AS \"providers_enabled!: String\",\n translation_engine AS \"translation_engine: String\",\n provider_daily_budgets AS \"provider_daily_budgets!: String\",\n translator_daily_budgets AS \"translator_daily_budgets!: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "wanted_languages!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "wanted_languages"
}
}
},
{
"name": "providers_enabled!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "providers_enabled"
}
}
},
{
"name": "translation_engine: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translation_engine"
}
}
},
{
"name": "provider_daily_budgets!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "provider_daily_budgets"
}
}
},
{
"name": "translator_daily_budgets!: String",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translator_daily_budgets"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "77e55f2aa534468cf6e599d0c01679653553a68f2f1b9b5516f5d2fb42974b05"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT remote_command_timeout_seconds AS \"remote_command_timeout_seconds!: i64\",\n openai_base_url AS \"openai_base_url: String\",\n openai_model AS \"openai_model: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "remote_command_timeout_seconds!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "remote_command_timeout_seconds"
}
}
},
{
"name": "openai_base_url: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "openai_base_url"
}
}
},
{
"name": "openai_model: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "openai_model"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
true,
true
]
},
"hash": "7c501e25106ea8d4ad9f28ca904c446551c6afd68d85e2231742bebd215a1a4b"
}
@@ -0,0 +1,110 @@
{
"db_name": "SQLite",
"query": "SELECT wanted_languages AS \"wanted_languages!: String\",\n providers_enabled AS \"providers_enabled!: String\",\n translation_engine AS \"translation_engine: String\",\n provider_daily_budgets AS \"provider_daily_budgets!: String\",\n translator_daily_budgets AS \"translator_daily_budgets!: String\",\n remote_command_timeout_seconds AS \"remote_command_timeout_seconds!: i64\",\n openai_base_url AS \"openai_base_url: String\",\n openai_model AS \"openai_model: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "wanted_languages!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "wanted_languages"
}
}
},
{
"name": "providers_enabled!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "providers_enabled"
}
}
},
{
"name": "translation_engine: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translation_engine"
}
}
},
{
"name": "provider_daily_budgets!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "provider_daily_budgets"
}
}
},
{
"name": "translator_daily_budgets!: String",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translator_daily_budgets"
}
}
},
{
"name": "remote_command_timeout_seconds!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "remote_command_timeout_seconds"
}
}
},
{
"name": "openai_base_url: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "openai_base_url"
}
}
},
{
"name": "openai_model: String",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "openai_model"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false,
false,
false,
true,
true
]
},
"hash": "82bb36910db14d5471117285ef2d3a9f8efefc5d400a8f94e1f4aed321284504"
}
@@ -0,0 +1,134 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, s.poster_path,\n e.id AS \"episode_id!: i64\", se.number AS \"season_number!: i64\",\n e.number AS \"episode_number!: i64\", mf.id AS \"media_file_id!: i64\",\n sf.language AS \"language!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'episode'\n JOIN episodes e ON e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE sf.sync_rejected = 1\n ORDER BY s.title, se.number, e.number, sf.language",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "year"
}
}
},
{
"name": "poster_path",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "poster_path"
}
}
},
{
"name": "episode_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episode_number!: i64",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 8,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "language!: String",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "language"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
false,
false
]
},
"hash": "8543e4ad8c59e83911688ceace72f66d360240a45eee05aaf15dab233e22e7e6"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE subtitle_files\n SET synced = ?, sync_rejected = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "874079e2ed732468a1f249e6aa026753c78042e164d11eb7cc014c9500211b64"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM subtitle_files WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "90480a6b6e724d7e4d05239799ad2f586c57deea100320fe433da007396f1e48"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT media_file_id AS \"media_file_id!: i64\",\n language AS \"language!: String\",\n path\n FROM subtitle_files WHERE id = ?",
"describe": {
"columns": [
{
"name": "media_file_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "media_file_id"
}
}
},
{
"name": "language!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "language"
}
}
},
{
"name": "path",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true
]
},
"hash": "9091de460ba1cb47c9faa24fe37fee03615e7b23b10ceef88ee12c8deb0dbaaf"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT sf.id AS \"id!: i64\", sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE s.root_id = ? AND sf.path IS NOT NULL\n ORDER BY sf.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "92a3d21df1ee60492a1e0742f1425c73a16ee7dd38bfdf2d7166db95d663dbf4"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE subtitle_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "9b129ff8a5951c9dfcb9778da0174ac66d4307936323b1eff9440adb4c34b893"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT spent AS \"spent!: i64\" FROM subtitle_budget_spend\n WHERE kind = ? AND name = ? AND day = strftime('%Y-%m-%d', 'now')",
"describe": {
"columns": [
{
"name": "spent!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_budget_spend",
"name": "spent"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "9ee9ff74406e333df50076d1c456f0a9672173af252f36e5d75d2a9df99ab68d"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT sf.id AS \"id!: i64\", sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id\n WHERE m.root_id = ? AND sf.path IS NOT NULL\n ORDER BY sf.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "a1256d6eec88a6ce5c308a7b96d95b8b43100b1f4d8ac4cabc7c3bef9fefd745"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?",
"describe": {
"columns": [
{
@@ -34,5 +34,5 @@
false
]
},
"hash": "89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc"
"hash": "a2023f024d53aa5f139da347f22af38d57c6f846bb8eff74958fd4d1299268f8"
}
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT media_file_id AS \"media_file_id!: i64\",\n language AS \"language!: String\",\n state AS \"state!: SubtitleState\",\n attempts AS \"attempts!: i64\",\n last_attempt_at,\n last_failure\n FROM subtitle_attempts\n WHERE media_file_id = ?\n ORDER BY language",
"describe": {
"columns": [
{
"name": "media_file_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "media_file_id"
}
}
},
{
"name": "language!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "language"
}
}
},
{
"name": "state!: SubtitleState",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "state"
}
}
},
{
"name": "attempts!: i64",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "attempts"
}
}
},
{
"name": "last_attempt_at",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_attempt_at"
}
}
},
{
"name": "last_failure",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_failure"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
true
]
},
"hash": "adf45101bc3ba20d35b59794053029e89d22ada5c7ffecd47187ec34261bc223"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT probed FROM media_files WHERE id = ?",
"describe": {
"columns": [
{
"name": "probed",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "probed"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "af10ccd655f39235f525a531e38850b2e60ff713a038ee1e7c04c616b4613b52"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n WHERE e.season_id = ? AND sf.path IS NOT NULL",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "b2d937e5b8381de44fc6c1915e3deb24236c2c8afa25ffec1c7bbe519aac8803"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT providers_enabled AS \"providers_enabled!: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "providers_enabled!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "providers_enabled"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "bc6dfe5a5cc639a64e3020c348a2dc74b4298101f8c29d9f8e6397cf79bb83d7"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_attempts (media_file_id, language, state)\n VALUES (?, ?, 'satisfied')\n ON CONFLICT (media_file_id, language) DO UPDATE\n SET state = 'satisfied',\n last_failure = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "c5a9e43cfeb315e70bbb603f22acc519d7df3cd0b4d606d0d9056414ac6c503b"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM movies WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "d81ec62e6c4879d08ce3551c0c71df012bed05ac2a37933adc2deb6552eeeac1"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT path AS \"path!: String\" FROM subtitle_files\n WHERE media_file_id = ? AND language = ? AND path IS NOT NULL",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
true
]
},
"hash": "e17b71f67d8b79c54d2d6f32d5e4197d77faac1763b360e8e609b0b9ba5820f1"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM subtitle_files WHERE path = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "e3b82dc7b2a3b28753c84864239699cb2007a8101cccc22d223d1f587a5533f8"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM subtitle_files WHERE path = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "e5b075d0b7fe5410d3e55d5daf7837d0a9398b1954e713e80a1d82c0a7e172a6"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT translation_engine AS \"translation_engine: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "translation_engine: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translation_engine"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
true
]
},
"hash": "e7a0291c2fb8cfe59a02ffef11d9657ea6a40e4079573f569573075a205c50e7"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM media_files WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "e8066bed6fb53ff90d78f47bb1c9ce8160918d4b35764fd988e5e1dd6c101089"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE subtitle_settings SET\n wanted_languages = ?, providers_enabled = ?, translation_engine = ?,\n provider_daily_budgets = ?, translator_daily_budgets = ?,\n remote_command_timeout_seconds = ?,\n openai_base_url = ?, openai_model = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = 1",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "eab2c4e7c21cd5d22a583c36dd2d79590e9ce55801c4b87b8db1b90f7b317225"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"media_file_id!: i64\", e.id AS \"episode_id!: i64\"\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 = ?\n ORDER BY mf.path",
"describe": {
"columns": [
{
"name": "media_file_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "episode_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "f4c3eb506289667ace56da5d28c046163d5f12bd1dca53d3d6fc559f6e9ce393"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n WHERE mf.owner_kind = 'episode' AND mf.owner_id = ? AND sf.path IS NOT NULL",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "fa35de1c7e62c68dd1049d7e7e08c825d3ca31eb631744a57594aa2fc08fb945"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM media_files\n WHERE owner_kind = ? AND owner_id = ? ORDER BY path",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "fcba9e1f708e6bf2d8471d0c7d26be0b5ad8a30fa042434ffaaff8c69ccf31de"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM subtitle_files\n WHERE media_file_id = ? AND language = ? AND origin = 'embedded'\n AND forced = ? AND sdh = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false
]
},
"hash": "fe15c4a3774e663b03455300d0baec1dadc6cf995c20df803bc4bf49d1cc7c77"
}
+1
View File
@@ -18,6 +18,7 @@ crates/
├── arr-indexer/ Torznab via Prowlarr
├── arr-dl/ Transmission RPC
├── arr-probe/ ffprobe wrapper
├── arr-subs/ subtitle providers, translation, sync
├── arr-db/ sqlx + migrations
├── arr-api/ axum + OpenAPI
├── arr-compat/ Radarr/Sonarr v3 shim for Jellyseerr
Generated
+110
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -35,6 +41,7 @@ dependencies = [
"arr-indexer",
"arr-meta",
"arr-parse",
"arr-subs",
"axum",
"chrono",
"reqwest",
@@ -88,6 +95,7 @@ dependencies = [
"arr-meta",
"arr-parse",
"arr-probe",
"arr-subs",
"axum",
"base64",
"chrono",
@@ -139,12 +147,14 @@ dependencies = [
name = "arr-e2e"
version = "0.1.0"
dependencies = [
"arr-db",
"arr-dl",
"arr-indexer",
"arr-meta",
"chrono",
"reqwest",
"serde_json",
"sqlx",
"tempfile",
"tokio",
"uuid",
@@ -194,11 +204,31 @@ dependencies = [
"arr-core",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "arr-subs"
version = "0.1.0"
dependencies = [
"arr-core",
"arr-parse",
"chardetng",
"encoding_rs",
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tokio",
"tracing",
"wiremock",
"zip",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@@ -366,6 +396,17 @@ dependencies = [
"rand_core",
]
[[package]]
name = "chardetng"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13de944a44b5064ee5d3a5ceccc49a41bfec50f2580e66f82e87703acdb88b53"
dependencies = [
"cfg-if",
"encoding_rs",
"memchr",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -423,6 +464,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.13"
@@ -531,6 +581,15 @@ dependencies = [
"serde",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -579,6 +638,16 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "flume"
version = "0.12.0"
@@ -1228,6 +1297,16 @@ dependencies = [
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.2"
@@ -1746,6 +1825,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
@@ -2305,6 +2390,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -2814,6 +2905,25 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.23"
+4
View File
@@ -20,6 +20,7 @@ arr-indexer = { path = "crates/arr-indexer" }
arr-meta = { path = "crates/arr-meta" }
arr-parse = { path = "crates/arr-parse" }
arr-probe = { path = "crates/arr-probe" }
arr-subs = { path = "crates/arr-subs" }
# Runtime and HTTP
axum = "0.8"
@@ -47,13 +48,16 @@ quick-xml = { version = "0.37", features = ["serialize"] }
toml = "0.8"
# Odds and ends
chardetng = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
clap = { version = "4.5", features = ["derive", "env"] }
encoding_rs = "0.8"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
uuid = { version = "1", features = ["v4", "serde"] }
zip = { version = "8", default-features = false, features = ["deflate-flate2-zlib-rs"] }
# Test-only
tempfile = "3"
+105 -1
View File
@@ -780,6 +780,7 @@ arr-meta TMDB client
arr-indexer Torznab via Prowlarr
arr-dl Transmission RPC
arr-probe ffprobe wrapper
arr-subs subtitle providers, translation, sync
arr-db sqlx + migrations
arr-api axum + OpenAPI
arr-compat Radarr/Sonarr v3 shim for Jellyseerr
@@ -862,7 +863,7 @@ previous one.
grabbing, derived status.
7. **Owners and notifications** — tags, per-person ntfy topics, filtered views.
8. **Jellyseerr compat** — `arr-compat`.
9. **Subtitles** — replaces Bazarr. Out of scope for this document.
9. **Subtitles** — replaces Bazarr. See §15.
Movies before TV because TV adds season packs, air-date calendars and
per-episode state on top of an otherwise identical pipeline. Doing it second
@@ -882,3 +883,106 @@ means that pipeline is already proven.
RSS lane obeys the same guard — it skips packs for any season with episodes
on disk. Whether a re-grab is ever wanted remains unresolved and deliberately
deferred.
## 15. Subtitles
Replaces Bazarr. Phase 9 in §13; the `arr-subs` crate in §11.
**Wanted set.** Global, not per root. Two languages are separately wanted for
every media file: Portuguese — pt-PT preferred, pt-BR accepted — and English.
A file is satisfied for a language when a subtitle in it exists, embedded or as
a sidecar. This is deliberately unlike §5.2's audio rules, which attach to a
root: subtitles carry no blacklist there and none here. pt-BR subtitles are
always fine.
**Embedded tracks.** An embedded subtitle track satisfies its language.
Text-format tracks (`subrip`, `ass`, `mov_text`) are additionally extracted to
a sidecar SRT, because an extracted track is a legal translation source.
Image-format tracks (PGS on BluRay, VobSub on DVD) carry bitmaps, not text:
they satisfy viewing but can never feed a translator, and arr does not OCR
them. `arr-probe` already reports subtitle tracks with resolved languages; the
format is the new fact it must carry.
**Providers.** OpenSubtitles.com and Podnapisi, behind one trait.
**Ranking.** A `moviehash` match wins outright. Then an exact release-name
match, then same release group or same source, then uploader rating and
download count as tiebreakers. Every rejected candidate names the rule that
killed it, so the manual view described in §9.3 works unchanged for subtitles.
**Forced and SDH.** A forced track covers only foreign-language lines and
on-screen signs; it never satisfies a want and arr never goes looking for one.
An SDH track is complete and satisfies, ranked below a plain subtitle.
Neither gets its own sidecar name. A language is satisfied by exactly one
sidecar, so `<video>.<lang>.srt` needs no segment distinguishing forced from
plain from SDH — where a plain subtitle exists for a language, the forced one
is ignored rather than kept beside it. The database says the same thing: one
sidecar row per (media file, language).
**Translation.** When no provider has a wanted language, arr translates
immediately — there is no waiting window. The source is an existing subtitle:
a downloaded one, or one extracted from a text-format embedded track. Being
able to translate from an embedded track is a deliberate improvement on
Bazarr, which cannot.
**Translation backends.** Pluggable, each behind its own cargo feature: an
OpenAI-compatible HTTP endpoint, DeepL, Google Translate, and a generic remote
command driven by a configured template (`ssh box claude -p` is one instance
of that template, not a backend of its own). The engine in use is a database
setting, so switching does not need a rebuild when the feature is compiled in.
Subtitles are sent in batches of cues; a reply whose cue count or numbering
does not match the batch is rejected. Timing data never leaves arr.
**No upgrade loop.** Once a language is satisfied — by a machine translation
too — arr stops working on it. A real subtitle appearing later does not
replace anything. Replacement is a manual action from the UI. This is §5.4's
rule applied to subtitles.
**On disk.** Sidecars live next to the video inside the §7.4 title folder,
named `<video basename>.<lang>.srt`, e.g.
`… - [2160p][WEB-DL][HDR10].pt-PT.srt`. A machine translation carries an extra
`.mt` segment: `… [HDR10].pt-PT.mt.srt`. That keeps §7.4's audit-by-`ls`
property — with the app stopped, the filename says which subtitles are
machine-made. Folder-level delete stays atomic because sidecars are inside the
folder.
`.mt` is the only optional segment. One language, one sidecar: a second
subtitle for a language arr already has is refused, and replacing one is the
manual delete-then-fetch §15's no-upgrade rule already describes.
**Sync.** `alass` runs on every fetched and every translated subtitle. It is a
single small binary invoked like `ffprobe`, so it costs nothing at rest. It
reports no confidence value, so its output is accepted unless it is
implausible — a shift beyond 60 seconds, or cues lost — in which case the
unsynced original is kept and the file is flagged.
**Configuration.** Provider credentials and translator API keys are bootstrap
config or environment, per §10 — a secret never becomes a database row. Wanted
languages, chosen engine, per-provider enable and the daily budgets are
database rows edited from `/settings` without a restart.
So is everything needed to point the OpenAI-compatible backend somewhere else:
its **base URL and its model name are database rows too**, not bootstrap
config. That backend is not "OpenAI" — it is any endpoint speaking that shape,
`llama.cpp` and a local gateway included, and which one is in use is a thing to
try and change, not a property of the deployment fixed at start-up. Only the
API key stays in the environment, and an endpoint that needs no key is a valid
configuration.
**Budgets.** A token bucket per provider and per translator, with a configured
daily allowance. The reconcile loop spends it newest-import-first, so enabling
this on an existing library drains the backlog over days instead of hitting
every rate limit at once. Being at the cap is a visible queue state, not an
error.
**Notifications.** No new event classes. §9.5 stands: subtitle fetches never
notify, and a provider or translator being unreachable folds into the existing
"Broken" message to the operator alone.
**Non-goals**, stated here so they do not creep back: OCR of image-based
tracks, transcribing audio when no subtitle exists anywhere, adopting subtitle
files already on disk that arr did not write (§2 already says the service
knows only what it put there — which does mean arr may fetch a second copy
alongside one Bazarr left), and any background loop that upgrades a subtitle
in place.
+8 -4
View File
@@ -15,17 +15,21 @@ fmt-check:
fmt:
cargo fmt --all
# Clippy, warnings denied.
# Clippy, warnings denied. `--all-features` matters: every subtitle
# translation backend sits behind a default-off cargo feature, so without it
# the gate never compiles a line of them (#191, #192).
lint:
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
# Unused workspace dependencies. Stable toolchain, unlike cargo-udeps.
deps:
cargo machete
# Unit and integration tests, excluding the e2e crate.
# Unit and integration tests, excluding the e2e crate. `--all-features` for
# the same reason `lint` needs it: the translation backends are feature-gated
# and would otherwise never run.
test:
cargo nextest run --workspace --exclude arr-e2e
cargo nextest run --workspace --exclude arr-e2e --all-features
# End-to-end tests. Needs a Transmission container (`just e2e-up`); not part
# of the push gate. Never point this at the production Transmission LXC.
+1
View File
@@ -12,6 +12,7 @@ arr-db = { workspace = true }
arr-indexer = { workspace = true }
arr-meta = { workspace = true }
arr-parse = { workspace = true }
arr-subs = { workspace = true }
axum = { workspace = true }
chrono = { workspace = true }
reqwest = { workspace = true }
+368 -6
View File
@@ -6,11 +6,15 @@
//! the body carries the verdict, so a degraded service can still explain
//! itself to the UI instead of looking like a fourth outage.
use std::ffi::OsStr;
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;
use arr_subs::{binary_present, translate as backend_error};
use crate::state::AppState;
/// Whether the service as a whole can do its job.
@@ -68,6 +72,44 @@ impl Check {
}
}
/// One enabled subtitle provider's verdict (#200).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
pub struct ProviderCheck {
/// The provider's id as settings name it.
pub id: String,
pub status: Status,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
/// The subtitle lane's verdicts (DESIGN.md §15, #200). Only what is actually
/// in use appears here: providers nobody enabled and engines nobody selected
/// cannot be broken.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
pub struct SubtitleHealth {
/// One entry per provider the settings enable, in that order.
pub providers: Vec<ProviderCheck>,
/// The selected translation engine, or `None` when none is chosen —
/// which is a configuration state, not an outage.
#[serde(skip_serializing_if = "Option::is_none")]
pub translation: Option<Check>,
/// The `alass` sync binary, present at its configured path.
pub alass: Check,
/// The `ffmpeg` extraction binary, present at its configured path.
pub ffmpeg: Check,
}
impl SubtitleHealth {
/// Every verdict the lane carries, for the overall status.
fn statuses(&self) -> impl Iterator<Item = Status> + '_ {
self.providers
.iter()
.map(|provider| provider.status)
.chain(self.translation.iter().map(|check| check.status))
.chain([self.alass.status, self.ffmpeg.status])
}
}
/// The body of `GET /api/health`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
pub struct HealthReport {
@@ -78,9 +120,11 @@ pub struct HealthReport {
pub prowlarr: Check,
pub transmission: Check,
pub tmdb: Check,
pub subtitles: SubtitleHealth,
}
/// Report reachability of Prowlarr, Transmission and TMDB.
/// Report reachability of Prowlarr, Transmission and TMDB, plus the
/// subtitle upstreams (#200).
#[utoipa::path(
get,
path = "/api/health",
@@ -90,17 +134,19 @@ pub struct HealthReport {
),
)]
pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
// Three independent network probes; serialising them would make the
// endpoint as slow as the sum of the timeouts.
let (prowlarr, transmission, tmdb) = tokio::join!(
// Independent network probes; serialising them would make the endpoint
// as slow as the sum of the timeouts.
let (prowlarr, transmission, tmdb, subtitles) = tokio::join!(
probe_prowlarr(&state),
probe_transmission(&state),
probe_tmdb(&state),
probe_subtitles(&state)
);
let status = if [prowlarr.status, transmission.status, tmdb.status]
.iter()
.all(|s| *s == Status::Ok)
.into_iter()
.chain(subtitles.statuses())
.all(|check| check == Status::Ok)
{
Health::Ok
} else {
@@ -113,6 +159,7 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
prowlarr,
transmission,
tmdb,
subtitles,
})
}
@@ -181,3 +228,318 @@ async fn probe_tmdb(state: &AppState) -> Check {
fn describe(err: reqwest::Error) -> String {
err.without_url().to_string()
}
// ---- the subtitle lane (DESIGN.md §15, #200) -----------------------------
/// Probe every subtitle lamp: enabled providers, the selected engine, and
/// the two binaries. The settings row decides what is *in use* — a provider
/// nobody enabled or an engine nobody selected is not probed at all, so it
/// cannot fail a lamp.
async fn probe_subtitles(state: &AppState) -> SubtitleHealth {
let settings = match state.database() {
Some(_) => crate::subtitle_settings::load(state).await.ok(),
None => None,
};
// No readable row means nothing is known to be in use; the lamps stay
// quiet rather than failing on data the operator has not entered yet.
let enabled = settings
.as_ref()
.map(|settings| settings.providers_enabled.as_slice())
.unwrap_or_default();
let engine = settings
.as_ref()
.and_then(|settings| settings.translation_engine.as_deref());
let mut probes = Vec::with_capacity(enabled.len());
for id in enabled {
probes.push(probe_provider(state, id).await);
}
let translation = match engine {
Some(engine) => Some(probe_engine(state, engine).await),
None => None,
};
SubtitleHealth {
providers: probes,
translation,
alass: binary_check("alass", state.syncer().binary_path()),
ffmpeg: binary_check("ffmpeg", state.ffmpeg_binary()),
}
}
/// Reachable and credentials accepted, per enabled provider (#200).
///
/// A provider that is enabled but was never attached is unconfigured, not
/// unreachable: its credentials are bootstrap config that this deployment
/// simply does not have.
async fn probe_provider(state: &AppState, id: &str) -> ProviderCheck {
let check = match state.subtitle_provider(id) {
Some(provider) => check_from(provider.probe().await),
// Only reachable for a provider that needs credentials: Podnapisi is
// anonymous (#215) and is always attached, so it never lands here.
None => Check::unconfigured(format!(
"{id} is enabled but has no credentials — they are bootstrap config, not a setting"
)),
};
ProviderCheck {
id: id.to_owned(),
status: check.status,
detail: check.detail,
}
}
/// Reachable, credentials accepted — for the remote-command backend,
/// "reachable" means the command ran and exited cleanly (#200).
async fn probe_engine(state: &AppState, engine: &str) -> Check {
match state.translation_backend(engine) {
Some(backend) => backend_check(backend.probe().await),
None => Check::unconfigured(format!(
"'{engine}' is selected but unavailable in this build"
)),
}
}
/// One probe verdict, whatever kind of upstream produced it. A refused key
/// gets its own wording because it never resolves by retrying. The two error
/// enums — providers' and backends' — carry the same shape for this purpose.
fn check_from(result: Result<(), arr_subs::Error>) -> Check {
match result {
Ok(()) => Check::ok(),
Err(arr_subs::Error::Unauthorized { .. }) => Check::unreachable("credentials refused"),
Err(error) => Check::unreachable(error.to_string()),
}
}
fn backend_check(result: Result<(), backend_error::Error>) -> Check {
match result {
Ok(()) => Check::ok(),
Err(backend_error::Error::Unauthorized { .. }) => Check::unreachable("credentials refused"),
Err(error) => Check::unreachable(error.to_string()),
}
}
/// A binary lamp (#200): present and executable at its configured path.
/// Nothing is spawned — the endpoint is polled, and starting `ffmpeg` per
/// poll would be neither cheap nor side-effect-free.
fn binary_check(name: &str, binary: &OsStr) -> Check {
if binary_present(binary) {
Check::ok()
} else {
Check::unreachable(format!("{name} not found at {}", binary.to_string_lossy()))
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arr_db::Db;
use arr_subs::translate as backend;
use arr_subs::{
Backend, CandidateId, DownloadFuture, Provider, ProviderId, SearchFuture, SearchRequest,
Syncer,
};
use crate::{router, AppState, Upstreams};
/// A provider whose lamp is what `lamp` says — the probe under test is
/// ours, so the stub never touches the network.
#[derive(Debug)]
struct StubProvider {
id: &'static str,
up: bool,
}
impl Provider for StubProvider {
fn id(&self) -> ProviderId {
ProviderId::new(self.id)
}
fn search<'a>(&'a self, _request: &'a SearchRequest) -> SearchFuture<'a> {
Box::pin(async move { Ok(Vec::new()) })
}
fn download<'a>(&'a self, _id: &'a CandidateId) -> DownloadFuture<'a> {
unreachable!("health probes never download");
}
fn probe(&self) -> arr_subs::ProbeFuture<'_> {
let result = if self.up {
Ok(())
} else {
Err(arr_subs::Error::Unauthorized {
provider: ProviderId::new(self.id),
})
};
Box::pin(async move { result })
}
}
/// Same idea for the selected engine; the closure rebuilds its error per
/// call because probing borrows.
#[derive(Debug)]
struct StubBackend {
#[allow(dead_code)]
detail: &'static str,
}
impl Backend for StubBackend {
fn id(&self) -> backend::BackendId {
backend::BackendId::new("openai")
}
fn supports(&self, _target: &arr_core::Language) -> bool {
true
}
fn translate<'a>(&'a self, _batch: &'a backend::Batch) -> backend::TranslateFuture<'a> {
unreachable!("health probes never translate");
}
fn probe(&self) -> backend::ProbeFuture<'_> {
Box::pin(async move {
Err(backend::Error::Transport {
backend: backend::BackendId::new("openai"),
source: "connection refused".into(),
})
})
}
}
/// Serve the app on an ephemeral port and return its base URL.
async fn serve(state: AppState) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
format!("http://{address}")
}
/// An app with a migrated database and both binaries somewhere findable.
/// The three classic upstreams point at a dead port; these tests read the
/// subtitle lamps, not theirs.
async fn application() -> (tempfile::TempDir, AppState) {
let dir = tempfile::tempdir().expect("tempdir");
let database = Db::connect(dir.path().join("arr.db"))
.await
.expect("connect");
database.migrate().await.expect("migrate");
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state")
.with_database(database)
.with_syncer(Syncer::new().with_binary("sh"))
.with_ffmpeg_binary("sh");
(dir, state)
}
async fn report(state: AppState) -> serde_json::Value {
let base = serve(state).await;
reqwest::get(format!("{base}/api/health"))
.await
.expect("request health")
.json()
.await
.expect("health body")
}
/// Replace the seeded enabled set. Runtime-checked rather than going
/// through the settings API, whose validation refuses engines this test
/// binary has not compiled in.
async fn set_enabled(state: &AppState, providers: &str, engine: Option<&str>) {
sqlx::query("UPDATE subtitle_settings SET providers_enabled = ?, translation_engine = ?")
.bind(providers)
.bind(engine)
.execute(state.database().expect("database").pool())
.await
.expect("settings update");
}
#[tokio::test]
async fn without_a_settings_row_the_lane_is_quiet_and_binaries_still_checked() {
// No database attached: nothing is known to be in use, so nothing
// may fail a lamp — but a missing binary is a fact about the host.
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state")
.with_syncer(Syncer::new().with_binary("sh"))
.with_ffmpeg_binary("/nowhere/ffmpeg");
let body = report(state).await;
assert_eq!(body["subtitles"]["providers"], serde_json::json!([]));
assert!(body["subtitles"]["translation"].is_null());
assert_eq!(body["subtitles"]["alass"]["status"], "ok");
assert_eq!(body["subtitles"]["ffmpeg"]["status"], "unreachable");
assert_eq!(body["status"], "degraded");
}
#[tokio::test]
async fn an_enabled_provider_without_credentials_is_unconfigured() {
let (_dir, state) = application().await;
// The seed row enables opensubtitles and podnapisi; none are attached.
let body = report(state).await;
assert_eq!(
body["subtitles"]["providers"],
serde_json::json!([
{ "id": "opensubtitles", "status": "unconfigured",
"detail": "opensubtitles is enabled but has no credentials — they are bootstrap config, not a setting" },
{ "id": "podnapisi", "status": "unconfigured",
"detail": "podnapisi is enabled but has no credentials — they are bootstrap config, not a setting" },
])
);
assert_eq!(body["status"], "degraded");
}
#[tokio::test]
async fn an_attached_provider_lamp_follows_its_probe() {
let (_dir, state) = application().await;
set_enabled(&state, r#"["ok","bad"]"#, None).await;
let state = state.with_subtitle_providers(vec![
Arc::new(StubProvider { id: "ok", up: true }),
Arc::new(StubProvider {
id: "bad",
up: false,
}),
]);
let body = report(state).await;
assert_eq!(body["subtitles"]["providers"][0]["id"], "ok");
assert_eq!(body["subtitles"]["providers"][0]["status"], "ok");
assert_eq!(body["subtitles"]["providers"][1]["id"], "bad");
assert_eq!(body["subtitles"]["providers"][1]["status"], "unreachable");
assert_eq!(
body["subtitles"]["providers"][1]["detail"],
"credentials refused"
);
}
#[tokio::test]
async fn a_selected_engine_is_probed_only_when_selected() {
let (_dir, state) = application().await;
let body = report(state.clone()).await;
assert!(body["subtitles"]["translation"].is_null(), "{body}");
set_enabled(&state, "[]", Some("openai")).await;
// Selected but never attached: unavailable in this deployment.
let body = report(state).await;
assert_eq!(body["subtitles"]["translation"]["status"], "unconfigured");
}
#[tokio::test]
async fn an_unreachable_engine_fails_the_whole_report() {
let (_dir, state) = application().await;
set_enabled(&state, "[]", Some("openai")).await;
let state = state.with_translation_backends(vec![Arc::new(StubBackend {
detail: "connection refused",
})]);
let body = report(state).await;
assert_eq!(body["subtitles"]["translation"]["status"], "unreachable");
assert_eq!(body["status"], "degraded");
}
}
+50 -2
View File
@@ -18,6 +18,8 @@ mod roots;
mod search;
mod series;
mod state;
mod subtitle_settings;
mod subtitles;
mod trailer;
use axum::routing::get;
@@ -46,6 +48,13 @@ pub use state::{
AppState, EpisodeCommand, MetadataCommand, MovieCommand, SeasonCommand, Upstreams,
DEFAULT_TMDB_URL,
};
pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput};
pub use subtitles::{
EpisodeSubtitleGaps, EpisodeSubtitleStatus, MissingSubtitle, MovieSubtitleGaps,
SeasonSubtitleGaps, SeriesSubtitleGaps, Subtitle, SubtitleCandidate, SubtitleGap,
SubtitleGrabInput, SubtitleProviderError, SubtitleQueue, SubtitleSearchInput,
SubtitleSearchResults, SubtitleStatus, SubtitleTranslateInput,
};
pub use trailer::{Trailer, TrailerKind};
/// Where the generated document is served, and where `just gen-client` reads
@@ -71,7 +80,8 @@ pub const DOCS_PATH: &str = "/api/docs";
(name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"),
(name = "policies", description = "Quality policies (DESIGN.md §5)"),
(name = "search", description = "Unified title and release search"),
(name = "roots", description = "Root folders and their policies")
(name = "roots", description = "Root folders and their policies"),
(name = "subtitles", description = "Subtitles and their configuration (DESIGN.md §15)")
),
)]
struct ApiDoc;
@@ -118,6 +128,18 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(roots::get, roots::update, roots::delete))
.routes(routes!(policies::list, policies::create))
.routes(routes!(policies::get, policies::update, policies::delete))
.routes(routes!(subtitle_settings::get, subtitle_settings::update))
.routes(routes!(subtitles::list_for_media_file))
.routes(routes!(subtitles::list_for_movie))
.routes(routes!(subtitles::list_for_episode))
.routes(routes!(subtitles::status_for_movie))
.routes(routes!(subtitles::status_for_episode))
.routes(routes!(subtitles::status_for_series))
.routes(routes!(subtitles::search))
.routes(routes!(subtitles::grab))
.routes(routes!(subtitles::translate))
.routes(routes!(subtitles::delete))
.routes(routes!(subtitles::queue))
}
/// The generated `OpenAPI` document.
@@ -213,7 +235,11 @@ mod tests {
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
)
.expect("state");
.expect("state")
// The subtitle binaries default to `PATH`; pin them to something
// every machine has so this test stays about the classic upstreams.
.with_syncer(arr_subs::Syncer::new().with_binary("sh"))
.with_ffmpeg_binary("sh");
let body = report(state).await;
assert_eq!(body["status"], "ok");
@@ -359,6 +385,22 @@ mod tests {
("/api/policies/{policy_id}", "put"),
("/api/policies/{policy_id}", "delete"),
("/api/roots", "post"),
("/api/settings/subtitles", "get"),
("/api/settings/subtitles", "put"),
("/api/media-files/{media_file_id}/subtitles", "get"),
("/api/movies/{movie_id}/subtitles", "get"),
("/api/episodes/{episode_id}/subtitles", "get"),
("/api/movies/{movie_id}/subtitles/status", "get"),
("/api/episodes/{episode_id}/subtitles/status", "get"),
("/api/series/{series_id}/subtitles/status", "get"),
("/api/media-files/{media_file_id}/subtitles/search", "post"),
("/api/media-files/{media_file_id}/subtitles/grab", "post"),
(
"/api/media-files/{media_file_id}/subtitles/translate",
"post",
),
("/api/subtitles/{subtitle_id}", "delete"),
("/api/queues/subtitles", "get"),
] {
assert!(
json["paths"][path][method].is_object(),
@@ -373,6 +415,12 @@ mod tests {
"AttentionQueues",
"SeriesAttention",
"Series",
"Subtitle",
"SubtitleCandidate",
"SubtitleSearchResults",
"SubtitleStatus",
"MissingSubtitle",
"EpisodeSubtitleStatus",
] {
assert!(
json["components"]["schemas"][schema].is_object(),
+117 -14
View File
@@ -180,6 +180,11 @@ pub struct Accepted {
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ErrorBody {
pub error: String,
/// A machine-readable discriminant, set only where a client needs to
/// branch on the failure rather than display it (issue #221). `None`
/// everywhere else — the message is for the operator, not the client.
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
}
#[derive(Debug)]
@@ -191,6 +196,22 @@ pub enum ApiError {
OwnerNotFound,
PolicyNotFound,
RootNotFound,
/// A `media_files` row that is not there. Named apart from
/// [`Self::NotFound`] because the subtitle surface (§15) is keyed on
/// files, not on titles, and "movie not found" would misdirect.
MediaFileNotFound,
SubtitleNotFound,
/// A subtitle provider or translation backend could not do what a manual
/// action asked (§15). Carries the message because two providers and
/// four engines are configurable at once and an unnamed failure is
/// unactionable.
SubtitleUpstream(String),
/// A grab named a `candidate_id` the provider no longer recognises
/// (issue #221): a search's results outlive the search itself only in
/// the client's memory, and the provider can expire one at will. Kept
/// apart from [`Self::SubtitleUpstream`] so the panel can offer "search
/// again" from a `code`, not from matching the message text.
SubtitleCandidateExpired,
/// The §9.6 chip outcome: the title exists upstream but has no trailer.
/// Ordinary, so it must stay distinguishable from an upstream failure.
NoTrailer,
@@ -211,38 +232,60 @@ pub enum ApiError {
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error) = match self {
Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string()),
Self::SeriesNotFound => (StatusCode::NOT_FOUND, "series not found".to_string()),
Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string()),
Self::EpisodeNotFound => (StatusCode::NOT_FOUND, "episode not found".to_string()),
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string()),
Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string()),
Self::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string()),
Self::Conflict(error) => (StatusCode::CONFLICT, error),
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
let (status, error, code) = match self {
Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string(), None),
Self::SeriesNotFound => (StatusCode::NOT_FOUND, "series not found".to_string(), None),
Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string(), None),
Self::EpisodeNotFound => (StatusCode::NOT_FOUND, "episode not found".to_string(), None),
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string(), None),
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string(), None),
Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string(), None),
Self::MediaFileNotFound => (
StatusCode::NOT_FOUND,
"media file not found".to_string(),
None,
),
Self::SubtitleNotFound => (
StatusCode::NOT_FOUND,
"subtitle not found".to_string(),
None,
),
Self::SubtitleUpstream(error) => (StatusCode::SERVICE_UNAVAILABLE, error, None),
Self::SubtitleCandidateExpired => (
StatusCode::NOT_FOUND,
"candidate no longer exists — search again".to_string(),
Some("candidate_expired".to_string()),
),
Self::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string(), None),
Self::Conflict(error) => (StatusCode::CONFLICT, error, None),
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error, None),
Self::Unavailable => (
StatusCode::SERVICE_UNAVAILABLE,
"database unavailable".into(),
None,
),
Self::Upstream(name) => (
StatusCode::SERVICE_UNAVAILABLE,
format!("{name} unavailable"),
None,
),
Self::Database(error) => {
tracing::error!(%error, "API database error");
(StatusCode::INTERNAL_SERVER_ERROR, "database error".into())
(
StatusCode::INTERNAL_SERVER_ERROR,
"database error".into(),
None,
)
}
// The message is the caller's: this variant is returned by the
// delete lane and by the relocate lane, and "files not removed"
// is a lie about a move that failed.
Self::Filesystem(error) => {
tracing::error!(%error, "API filesystem error");
(StatusCode::INTERNAL_SERVER_ERROR, error.clone())
(StatusCode::INTERNAL_SERVER_ERROR, error.clone(), None)
}
};
(status, Json(ErrorBody { error })).into_response()
(status, Json(ErrorBody { error, code })).into_response()
}
}
@@ -1704,6 +1747,66 @@ mod tests {
);
}
/// Issue #237: the folder rename #228 wired up already carries a sidecar
/// with it, but the `subtitle_files` row pointing at the old path did not
/// follow — it went stale silently, and `DELETE /api/subtitles/{id}`
/// (#218) would then report success while leaving the real file on disk.
#[tokio::test]
async fn changing_root_moves_the_subtitle_row_with_its_sidecar() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = library_on_disk(&state, id, source.path()).await;
point_root_at(&state, 2, destination.path()).await;
let pool = state.database().expect("database").pool();
let media_file_id: i64 = sqlx::query_scalar(
"SELECT id FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
)
.bind(id)
.fetch_one(pool)
.await
.expect("media file id");
let sidecar = folder.join("dune.pt.srt");
sqlx::query(
"INSERT INTO subtitle_files (media_file_id, language, origin, provider, path)
VALUES (?, 'pt-PT', 'provider', 'opensubtitles', ?)",
)
.bind(media_file_id)
.bind(sidecar.to_str().expect("utf-8 path"))
.execute(pool)
.await
.expect("subtitle file");
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], 2);
let path: String =
sqlx::query_scalar("SELECT path FROM subtitle_files WHERE media_file_id = ?")
.bind(media_file_id)
.fetch_one(pool)
.await
.expect("subtitle file row");
assert!(
std::path::Path::new(&path).starts_with(destination.path()),
"the subtitle row follows the file: {path}"
);
assert!(
std::path::Path::new(&path).exists(),
"the rewritten subtitle path describes the disk"
);
}
/// Reported in production: moving a title into a root whose directory has
/// never been written to failed with
/// `could not move '...The Batman...': No such file or directory`, naming
+149 -51
View File
@@ -1,9 +1,12 @@
//! Moving library files when the layout under them changes: a title changing
//! its `root_id` (issue #228), and a root changing its `path` (issue #236).
//! Both rename §7.4 folders and rewrite the `media_files` rows to match, so
//! the layout keeps describing the disk. This module only moves things: the
//! stored verdicts a root change invalidates (§5.1) are re-derived by the
//! calling handler through `reclassify`, after the row commits.
//! Both rename §7.4 folders and rewrite the `media_files` and `subtitle_files`
//! rows to match, so the layout keeps describing the disk. Sidecars (§15) live
//! inside the same folder as the video, so the rename already carries them —
//! only their rows need rewriting (issue #237). This module only moves
//! things: the stored verdicts a root change invalidates (§5.1) are
//! re-derived by the calling handler through `reclassify`, after the row
//! commits.
//!
//! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this
//! is a directory rename, never a copy. Hardlinked files keep their inodes
@@ -35,6 +38,15 @@ pub(crate) enum TitleKind {
Series,
}
/// Which table a rewritten row belongs to — `rewrite_rows` needs this to
/// target the right `UPDATE`, since a `media_files` id and a `subtitle_files`
/// id share no namespace.
#[derive(Debug, Clone, Copy)]
enum FileTable {
Media,
Subtitle,
}
/// One rename from the old root into the new one: a §7.4 title folder, or a
/// loose file sitting straight in the root.
#[derive(Debug)]
@@ -49,7 +61,7 @@ struct PlannedRename {
#[derive(Debug)]
pub(crate) struct Relocation {
performed: Vec<PlannedRename>,
rewrites: Vec<(i64, String)>,
rewrites: Vec<(FileTable, i64, String)>,
/// Every directory level this request materialised for the new root,
/// deepest first. `create_dir_all` can make more than one — moving a
/// root to `/mnt/media-v2/tv/kids` when `/mnt/media-v2` is all that
@@ -112,13 +124,13 @@ pub(crate) async fn relocate_root(
/// destination that already exists, then perform them, undoing what was
/// performed if one fails.
async fn relocate_files(
files: &[(i64, String)],
files: &[(FileTable, i64, String)],
old_root: &str,
new_root: &str,
) -> Result<Relocation, ApiError> {
let mut renames: Vec<PlannedRename> = Vec::new();
let mut rewrites: Vec<(i64, String)> = Vec::new();
for (file_id, path) in files {
let mut rewrites: Vec<(FileTable, i64, String)> = Vec::new();
for (table, file_id, path) in files {
let Some(source) = title_target(old_root, path) else {
// Outside its own root: not ours to move, and the row keeps
// pointing at where the file really is.
@@ -144,7 +156,7 @@ async fn relocate_files(
"non-UTF-8 path under {new_root}"
)));
};
rewrites.push((*file_id, rewritten.to_owned()));
rewrites.push((*table, *file_id, rewritten.to_owned()));
}
// Every destination is checked before anything is renamed, so a conflict
@@ -275,21 +287,34 @@ impl Relocation {
!self.performed.is_empty()
}
/// Point the `media_files` rows at the new root, inside the caller's
/// transaction so they land together with the `root_id` change or not at
/// all.
/// Point the `media_files` and `subtitle_files` rows at the new root,
/// inside the caller's transaction so they land together with the
/// `root_id` change or not at all.
pub(crate) async fn rewrite_rows(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
) -> Result<(), sqlx::Error> {
for (file_id, path) in &self.rewrites {
sqlx::query!(
"UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
path,
file_id
)
.execute(&mut **transaction)
.await?;
for (table, file_id, path) in &self.rewrites {
match table {
FileTable::Media => {
sqlx::query!(
"UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
path,
file_id
)
.execute(&mut **transaction)
.await?;
}
FileTable::Subtitle => {
sqlx::query!(
"UPDATE subtitle_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
path,
file_id
)
.execute(&mut **transaction)
.await?;
}
}
}
Ok(())
}
@@ -346,12 +371,17 @@ async fn root_path(state: &AppState, root_id: i64) -> Result<String, ApiError> {
.await?)
}
/// Every file the service recorded under a root: the rows of every movie in
/// it, and the rows of every episode of every series in it. Ordered so the
/// Every file the service recorded under a root: the `media_files` rows of
/// every movie and episode in it, and the `subtitle_files` sidecars (§15) on
/// those files — an embedded track has no `path` and nothing on disk to
/// carry, so it is excluded rather than rewritten to nothing. Ordered so the
/// renames happen in a stable order, which is what makes a failure part-way
/// through reproducible.
async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>, ApiError> {
let mut files: Vec<(i64, String)> = sqlx::query!(
async fn root_files(
state: &AppState,
root_id: i64,
) -> Result<Vec<(FileTable, i64, String)>, ApiError> {
let mut files: Vec<(FileTable, i64, String)> = sqlx::query!(
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
FROM media_files mf
JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id
@@ -362,7 +392,7 @@ async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path))
.map(|row| (FileTable::Media, row.id, row.path))
.collect();
files.extend(
sqlx::query!(
@@ -378,41 +408,109 @@ async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path)),
.map(|row| (FileTable::Media, row.id, row.path)),
);
files.extend(
sqlx::query!(
r#"SELECT sf.id AS "id!: i64", sf.path AS "path!: String"
FROM subtitle_files sf
JOIN media_files mf ON mf.id = sf.media_file_id
JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id
WHERE m.root_id = ? AND sf.path IS NOT NULL
ORDER BY sf.id"#,
root_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (FileTable::Subtitle, row.id, row.path)),
);
files.extend(
sqlx::query!(
r#"SELECT sf.id AS "id!: i64", sf.path AS "path!: String"
FROM subtitle_files sf
JOIN media_files mf ON mf.id = sf.media_file_id
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE s.root_id = ? AND sf.path IS NOT NULL
ORDER BY sf.id"#,
root_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (FileTable::Subtitle, row.id, row.path)),
);
Ok(files)
}
/// Every file the service recorded for the title: a movie's own rows, or the
/// rows of every episode below a series.
/// rows of every episode below a series — plus the `subtitle_files` sidecars
/// (§15) on those files. An embedded track has no `path` and nothing on disk
/// to carry, so it is excluded rather than rewritten to nothing.
async fn title_files(
state: &AppState,
kind: TitleKind,
title_id: i64,
) -> Result<Vec<(i64, String)>, ApiError> {
) -> Result<Vec<(FileTable, i64, String)>, ApiError> {
Ok(match kind {
TitleKind::Movie => sqlx::query!(
r#"SELECT id AS "id!: i64", path AS "path!: String"
FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?"#,
title_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path))
.collect(),
TitleKind::Series => sqlx::query!(
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
FROM media_files mf
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
WHERE se.series_id = ?"#,
title_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path))
.collect(),
TitleKind::Movie => {
let mut files: Vec<(FileTable, i64, String)> = sqlx::query!(
r#"SELECT id AS "id!: i64", path AS "path!: String"
FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?"#,
title_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (FileTable::Media, row.id, row.path))
.collect();
files.extend(
sqlx::query!(
r#"SELECT sf.id AS "id!: i64", sf.path AS "path!: String"
FROM subtitle_files sf
JOIN media_files mf ON mf.id = sf.media_file_id
WHERE mf.owner_kind = 'movie' AND mf.owner_id = ? AND sf.path IS NOT NULL"#,
title_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (FileTable::Subtitle, row.id, row.path)),
);
files
}
TitleKind::Series => {
let mut files: Vec<(FileTable, i64, String)> = sqlx::query!(
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
FROM media_files mf
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
WHERE se.series_id = ?"#,
title_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (FileTable::Media, row.id, row.path))
.collect();
files.extend(
sqlx::query!(
r#"SELECT sf.id AS "id!: i64", sf.path AS "path!: String"
FROM subtitle_files sf
JOIN media_files mf ON mf.id = sf.media_file_id
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 = ? AND sf.path IS NOT NULL"#,
title_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (FileTable::Subtitle, row.id, row.path)),
);
files
}
})
}
+201 -3
View File
@@ -708,8 +708,9 @@ enum FileScope {
/// 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.
/// have to be re-derived to be named, which §2 forbids. Subtitle sidecars are
/// rows this service wrote too (#186), so they are resolved and unlinked the
/// same way as the video they sit beside (#218).
///
/// 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.
@@ -718,7 +719,8 @@ enum FileScope {
/// 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 paths = scope_paths(state, scope).await?;
paths.extend(subtitle_paths(state, scope).await?);
let mut targets: Vec<std::path::PathBuf> = Vec::new();
for path in &paths {
@@ -838,6 +840,50 @@ async fn scope_paths(state: &AppState, scope: FileScope) -> Result<Vec<String>,
})
}
/// Every subtitle sidecar (#186) this service wrote for the scope. Mirrors
/// [`scope_paths`]: same owner filter, same guard against widening. An
/// embedded track has no `path` — it never touched disk — and is excluded.
async fn subtitle_paths(state: &AppState, scope: FileScope) -> Result<Vec<String>, ApiError> {
Ok(match scope {
FileScope::Series(id) => {
sqlx::query_scalar!(
r#"SELECT sf.path AS "path!: String"
FROM subtitle_files sf
JOIN media_files mf ON mf.id = sf.media_file_id
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 = ? AND sf.path IS NOT NULL"#,
id
)
.fetch_all(pool(state)?)
.await?
}
FileScope::Season(id) => {
sqlx::query_scalar!(
r#"SELECT sf.path AS "path!: String"
FROM subtitle_files sf
JOIN media_files mf ON mf.id = sf.media_file_id
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
WHERE e.season_id = ? AND sf.path IS NOT NULL"#,
id
)
.fetch_all(pool(state)?)
.await?
}
FileScope::Episode(id) => {
sqlx::query_scalar!(
r#"SELECT sf.path AS "path!: String"
FROM subtitle_files sf
JOIN media_files mf ON mf.id = sf.media_file_id
WHERE mf.owner_kind = 'episode' AND mf.owner_id = ? AND sf.path IS NOT NULL"#,
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.
@@ -3675,6 +3721,42 @@ mod tests {
file
}
/// Same as [`episode_file_on_disk`], plus a §15 sidecar next to it with a
/// `subtitle_files` row pointing at the video's `media_files` row (#218).
async fn episode_file_with_sidecar_on_disk(
state: &AppState,
episode_id: i64,
root: &std::path::Path,
season: i64,
name: &str,
) -> (std::path::PathBuf, std::path::PathBuf) {
let file = episode_file_on_disk(state, episode_id, root, season, name).await;
let pool = state.database().expect("database").pool();
let media_file_id: i64 = sqlx::query_scalar(
"SELECT id FROM media_files WHERE owner_kind = 'episode' AND owner_id = ? AND path = ?",
)
.bind(episode_id)
.bind(file.to_str().expect("utf-8 path"))
.fetch_one(pool)
.await
.expect("media file id");
let sidecar = file.with_extension("pt-PT.srt");
tokio::fs::write(&sidecar, b"subs")
.await
.expect("write sidecar");
sqlx::query(
"INSERT INTO subtitle_files (media_file_id, language, origin, provider, path)
VALUES (?, 'pt-PT', 'provider', 'opensubtitles', ?)",
)
.bind(media_file_id)
.bind(sidecar.to_str().expect("utf-8 path"))
.execute(pool)
.await
.expect("subtitle file");
(file, sidecar)
}
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"))
@@ -3877,6 +3959,122 @@ mod tests {
assert_eq!(remaining, vec![second], "only the episode's row goes");
}
/// #218: a season-scoped delete unlinks its subtitle sidecars too — the
/// `subtitle_files` rows go with the video's `media_files` row, but the
/// files on disk do not follow without this.
#[tokio::test]
async fn removing_a_season_takes_its_subtitle_sidecars() {
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"}]),
)
.await;
let second = add_season(
&base,
series_id,
2,
serde_json::json!([{"number": 1, "title": "Dance Mode"}]),
)
.await;
let s01e01 = first["episodes"][0]["id"].as_i64().expect("id");
let s02e01 = second["episodes"][0]["id"].as_i64().expect("id");
let (_, in_scope_sidecar) =
episode_file_with_sidecar_on_disk(&state, s01e01, root.path(), 1, "Bluey - S01E01.mkv")
.await;
let (_, other_sidecar) =
episode_file_with_sidecar_on_disk(&state, s02e01, root.path(), 2, "Bluey - S02E01.mkv")
.await;
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!(!in_scope_sidecar.exists(), "its sidecar is gone with it");
assert!(
other_sidecar.exists(),
"another season's sidecar is not in scope"
);
let pool = state.database().expect("database").pool();
let orphans: i64 = sqlx::query_scalar("SELECT count(*) FROM subtitle_files")
.fetch_one(pool)
.await
.expect("count subtitle rows");
assert_eq!(
orphans, 1,
"only the deleted season's subtitle row cascades"
);
}
/// #218: the same fix at the narrower episode scope, leaving a sibling
/// episode's sidecar untouched.
#[tokio::test]
async fn removing_one_episode_takes_its_subtitle_sidecar() {
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 (_, first_sidecar) =
episode_file_with_sidecar_on_disk(&state, first, root.path(), 1, "Bluey - S01E01.mkv")
.await;
let (_, second_sidecar) =
episode_file_with_sidecar_on_disk(&state, second, root.path(), 1, "Bluey - S01E02.mkv")
.await;
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!(!first_sidecar.exists(), "the episode's sidecar is gone");
assert!(
second_sidecar.exists(),
"its sibling's sidecar is not in scope"
);
}
/// #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]
+118 -1
View File
@@ -1,10 +1,12 @@
//! What the API needs to answer a request: one HTTP client and the addresses
//! of the three upstreams the service cannot work without (DESIGN.md §3).
use std::sync::Arc;
use std::ffi::{OsStr, OsString};
use std::sync::{atomic::AtomicU64, Arc};
use std::time::Duration;
use arr_db::Db;
use arr_subs::{Backend, OpenAiEndpoint, Provider, Syncer};
use tokio::sync::mpsc;
use crate::jellyfin::JellyfinClient;
@@ -13,6 +15,10 @@ use crate::jellyfin::JellyfinClient;
/// is configurable, so this is a constant that tests point elsewhere.
pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3";
/// The `ffmpeg` invoked when nothing else is configured — same default as
/// `arr-probe`'s extractor, which is where it is actually run.
pub const DEFAULT_FFMPEG_BINARY: &str = "ffmpeg";
/// How long an upstream has to answer a probe before it counts as
/// unreachable. Health is polled by a human waiting on a page.
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
@@ -78,7 +84,21 @@ pub struct AppState {
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>>>,
subtitle_providers: Arc<Vec<Arc<dyn Provider>>>,
translation_backends: Arc<Vec<Arc<dyn Backend>>>,
/// The remote-command backend's live timeout, in milliseconds (#219).
/// `None` unless the daemon compiled and configured that backend; the
/// settings API writes it on every edit of the row.
command_timeout: Option<Arc<AtomicU64>>,
/// Where the OpenAI-compatible backend points and which model it names
/// (#220). `None` unless that backend is compiled in and wired up; the
/// settings API repoints it on every edit, and the health lamp probes
/// whatever it currently holds.
openai_endpoint: Option<OpenAiEndpoint>,
/// The configured `ffmpeg` binary, for the health lamps (#200).
ffmpeg_binary: OsString,
jellyfin: Option<JellyfinClient>,
syncer: Syncer,
}
/// Work explicitly requested through the movie API.
@@ -149,7 +169,13 @@ impl AppState {
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)),
subtitle_providers: Arc::new(Vec::new()),
translation_backends: Arc::new(Vec::new()),
command_timeout: None,
openai_endpoint: None,
ffmpeg_binary: DEFAULT_FFMPEG_BINARY.into(),
jellyfin: None,
syncer: Syncer::default(),
})
}
@@ -160,6 +186,48 @@ impl AppState {
self
}
/// Attach the subtitle providers this deployment has credentials for
/// (`DESIGN.md` §15).
///
/// Which of them a search actually runs is the `providers_enabled`
/// setting, read per request; this is the narrower fact of which ones
/// exist at all, because credentials are bootstrap config and never
/// reach the database (§10).
#[must_use]
pub fn with_subtitle_providers(mut self, providers: Vec<Arc<dyn Provider>>) -> Self {
self.subtitle_providers = Arc::new(providers);
self
}
/// Attach the translation backends this binary compiled in (§15).
///
/// Empty when no `translate-*` cargo feature is on, which is the default
/// — a manual translation then fails with a message saying so rather
/// than silently doing nothing.
#[must_use]
pub fn with_translation_backends(mut self, backends: Vec<Arc<dyn Backend>>) -> Self {
self.translation_backends = Arc::new(backends);
self
}
/// Attach the cell the remote-command backend re-reads per batch (#219),
/// so an edit of `remote_command_timeout_seconds` reaches it without a
/// restart. Absent when that backend is not configured.
#[must_use]
pub fn with_command_timeout(mut self, timeout: Arc<AtomicU64>) -> Self {
self.command_timeout = Some(timeout);
self
}
/// Attach the cell the OpenAI-compatible backend re-reads per request
/// (#220), so an edit of `openai_base_url` or `openai_model` reaches it
/// — and the health lamp probes it — without a restart.
#[must_use]
pub fn with_openai_endpoint(mut self, endpoint: OpenAiEndpoint) -> Self {
self.openai_endpoint = Some(endpoint);
self
}
/// Attach the Jellyfin client, so a manual subtitle grab or translation
/// can trigger the same library refresh import does (§7.5, §15).
#[must_use]
@@ -172,6 +240,55 @@ impl AppState {
self.jellyfin.as_ref()
}
pub(crate) fn subtitle_provider(&self, id: &str) -> Option<&Arc<dyn Provider>> {
self.subtitle_providers
.iter()
.find(|provider| provider.id().as_str() == id)
}
pub(crate) fn subtitle_providers(&self) -> &[Arc<dyn Provider>] {
&self.subtitle_providers
}
pub(crate) fn translation_backend(&self, id: &str) -> Option<&Arc<dyn Backend>> {
self.translation_backends
.iter()
.find(|backend| backend.id().as_str() == id)
}
pub(crate) fn command_timeout(&self) -> Option<&Arc<AtomicU64>> {
self.command_timeout.as_ref()
}
pub(crate) fn openai_endpoint(&self) -> Option<&OpenAiEndpoint> {
self.openai_endpoint.as_ref()
}
/// Attach the `alass` binary this deployment runs (§15). Defaults to
/// resolving `alass` from `PATH`.
#[must_use]
pub fn with_syncer(mut self, syncer: Syncer) -> Self {
self.syncer = syncer;
self
}
/// Attach the configured `ffmpeg` binary, for the health lamps (#200).
/// Defaults to resolving `ffmpeg` from `PATH`.
#[must_use]
pub fn with_ffmpeg_binary(mut self, binary: impl Into<OsString>) -> Self {
self.ffmpeg_binary = binary.into();
self
}
/// The configured `ffmpeg` binary.
pub(crate) fn ffmpeg_binary(&self) -> &OsStr {
&self.ffmpeg_binary
}
pub(crate) fn syncer(&self) -> &Syncer {
&self.syncer
}
/// Wait for the next manual movie action in the daemon's reconcile loop.
///
/// # Errors
+761
View File
@@ -0,0 +1,761 @@
//! The runtime-editable half of subtitle configuration (`DESIGN.md` §15,
//! §10, issue #198). Provider credentials, translator keys, the
//! remote-command template and the `alass`/`ffmpeg` paths never reach here —
//! those are config/env, per §10, and this surface would leak them into a
//! `sqlite3 .backup` on a timer if it did.
//!
//! A single row rather than a CRUD collection: the wanted set, enabled
//! providers and translation engine are global, not per root (§15).
use std::collections::BTreeMap;
use axum::extract::rejection::JsonRejection;
use axum::extract::State;
use axum::Json;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::movies::{pool, ApiError, ErrorBody};
use crate::policies::parsed;
use crate::state::AppState;
/// The subtitle settings row, plus which translation engines this binary
/// actually has compiled in.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SubtitleSettings {
pub wanted_languages: Vec<String>,
pub providers_enabled: Vec<String>,
pub translation_engine: Option<String>,
pub provider_daily_budgets: BTreeMap<String, u32>,
pub translator_daily_budgets: BTreeMap<String, u32>,
pub remote_command_timeout_seconds: u32,
/// Where the OpenAI-compatible backend points (#220). `null` means its
/// own default, `https://api.openai.com/v1/` — that backend is anything
/// speaking the shape, so a `llama.cpp` address belongs here. Not a
/// secret: the API key stays in the environment (§10).
pub openai_base_url: Option<String>,
/// The model that backend names. `null` means its own default.
pub openai_model: Option<String>,
/// Engines DESIGN.md §15 knows about that this binary compiled in.
/// `translation_engine` is always a member of this list or `null` — a
/// backend whose cargo feature is missing is never selectable.
pub available_engines: Vec<String>,
}
/// The payload for replacing the settings row. Same shape as
/// [`SubtitleSettings`] minus `available_engines`, which is a fact about the
/// binary, not something an operator sets.
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct SubtitleSettingsInput {
pub wanted_languages: Vec<String>,
pub providers_enabled: Vec<String>,
pub translation_engine: Option<String>,
#[serde(default)]
pub provider_daily_budgets: BTreeMap<String, u32>,
#[serde(default)]
pub translator_daily_budgets: BTreeMap<String, u32>,
pub remote_command_timeout_seconds: u32,
#[serde(default)]
pub openai_base_url: Option<String>,
#[serde(default)]
pub openai_model: Option<String>,
}
impl SubtitleSettingsInput {
/// Validate against the vocabulary the running binary actually knows.
/// Every failure names its field so a rejected edit is fixable without
/// reading the schema.
fn validate(&self) -> Result<(), String> {
if self.wanted_languages.is_empty() {
return Err("wanted_languages: must not be empty".into());
}
if self.wanted_languages.iter().any(String::is_empty) {
return Err("wanted_languages: language tags must not be empty".into());
}
let mut seen = std::collections::BTreeSet::new();
for lang in &self.wanted_languages {
if !seen.insert(lang.as_str()) {
return Err(format!("wanted_languages: '{lang}' appears twice"));
}
}
if self.providers_enabled.iter().any(String::is_empty) {
return Err("providers_enabled: provider ids must not be empty".into());
}
let mut seen = std::collections::BTreeSet::new();
for provider in &self.providers_enabled {
if !seen.insert(provider.as_str()) {
return Err(format!("providers_enabled: '{provider}' appears twice"));
}
}
if let Some(engine) = &self.translation_engine {
if !arr_subs::ENGINES.contains(&engine.as_str()) {
return Err(format!(
"translation_engine: '{engine}' is not a known engine"
));
}
if !arr_subs::compiled_engines().contains(&engine.as_str()) {
return Err(format!(
"translation_engine: '{engine}' is not compiled into this binary"
));
}
}
if self.remote_command_timeout_seconds == 0 {
return Err("remote_command_timeout_seconds: must be greater than zero".into());
}
// #220: an unparseable base URL is rejected here rather than at the
// next translation, where it would surface as an engine that has
// quietly stopped working. Validated whether or not this build
// compiled the backend in — the column exists either way.
if let Some(base_url) = blank_to_none(self.openai_base_url.as_deref()) {
arr_subs::OpenAiEndpoint::new(Some(base_url), None).map_err(|error| {
// The backend's own Display talks about a reply that came
// back wrong; here nothing was ever sent, so only the
// reason belongs in the message.
let reason = match &error {
arr_subs::translate::Error::Malformed { detail, .. } => detail.clone(),
other => other.to_string(),
};
format!("openai_base_url: {reason}")
})?;
}
Ok(())
}
fn into_columns(self) -> Result<SettingsColumns, ApiError> {
fn json(value: impl serde::Serialize) -> Result<String, ApiError> {
serde_json::to_string(&value).map_err(|error| {
tracing::error!(%error, "subtitle settings serialisation failed");
ApiError::Database("serialisation failed".into())
})
}
Ok(SettingsColumns {
wanted_languages: json(&self.wanted_languages)?,
providers_enabled: json(&self.providers_enabled)?,
translation_engine: self.translation_engine,
provider_daily_budgets: json(&self.provider_daily_budgets)?,
translator_daily_budgets: json(&self.translator_daily_budgets)?,
remote_command_timeout_seconds: i64::from(self.remote_command_timeout_seconds),
// An empty field means "use the backend's default", which is the
// NULL the migration describes — not an endpoint named "".
openai_base_url: blank_to_none(self.openai_base_url.as_deref()).map(str::to_owned),
openai_model: blank_to_none(self.openai_model.as_deref()).map(str::to_owned),
})
}
}
/// The row as the table stores it, before JSON parsing.
struct SettingsColumns {
wanted_languages: String,
providers_enabled: String,
translation_engine: Option<String>,
provider_daily_budgets: String,
translator_daily_budgets: String,
remote_command_timeout_seconds: i64,
openai_base_url: Option<String>,
openai_model: Option<String>,
}
/// A field the operator left empty is absent, not an empty setting.
fn blank_to_none(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn column<T: serde::de::DeserializeOwned>(
column: &'static str,
value: &str,
) -> Result<T, ApiError> {
serde_json::from_str(value).map_err(|error| {
tracing::error!(column, %error, "subtitle settings column holds unexpected JSON");
ApiError::Database(format!("subtitle settings column {column} is not valid"))
})
}
impl SettingsColumns {
fn into_settings(self) -> Result<SubtitleSettings, ApiError> {
Ok(SubtitleSettings {
wanted_languages: column("wanted_languages", &self.wanted_languages)?,
providers_enabled: column("providers_enabled", &self.providers_enabled)?,
translation_engine: self.translation_engine,
provider_daily_budgets: column("provider_daily_budgets", &self.provider_daily_budgets)?,
translator_daily_budgets: column(
"translator_daily_budgets",
&self.translator_daily_budgets,
)?,
remote_command_timeout_seconds: u32::try_from(self.remote_command_timeout_seconds)
.unwrap_or(0),
openai_base_url: self.openai_base_url,
openai_model: self.openai_model,
available_engines: arr_subs::compiled_engines()
.into_iter()
.map(str::to_owned)
.collect(),
})
}
}
/// Read the row and parse it. Shared with the health lamps (#200), which
/// need the enabled set and the chosen engine but not the budgets.
pub(crate) async fn load(state: &AppState) -> Result<SubtitleSettings, ApiError> {
let row = sqlx::query_as!(
SettingsColumns,
r#"SELECT wanted_languages AS "wanted_languages!: String",
providers_enabled AS "providers_enabled!: String",
translation_engine AS "translation_engine: String",
provider_daily_budgets AS "provider_daily_budgets!: String",
translator_daily_budgets AS "translator_daily_budgets!: String",
remote_command_timeout_seconds AS "remote_command_timeout_seconds!: i64",
openai_base_url AS "openai_base_url: String",
openai_model AS "openai_model: String"
FROM subtitle_settings WHERE id = 1"#
)
.fetch_one(pool(state)?)
.await?;
row.into_settings()
}
#[utoipa::path(
get, path = "/api/settings/subtitles", tag = "subtitles",
responses(
(status = 200, body = SubtitleSettings),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn get(State(state): State<AppState>) -> Result<Json<SubtitleSettings>, ApiError> {
Ok(Json(load(&state).await?))
}
#[utoipa::path(
put, path = "/api/settings/subtitles", tag = "subtitles", request_body = SubtitleSettingsInput,
responses(
(status = 200, body = SubtitleSettings),
(status = 422, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn update(
State(state): State<AppState>,
body: Result<Json<SubtitleSettingsInput>, JsonRejection>,
) -> Result<Json<SubtitleSettings>, ApiError> {
let input = parsed(body)?;
input.validate().map_err(ApiError::Invalid)?;
let new_wanted = input.wanted_languages.clone();
let columns = input.into_columns()?;
let timeout_seconds = columns.remote_command_timeout_seconds;
let openai_base_url = columns.openai_base_url.clone();
let openai_model = columns.openai_model.clone();
let previous_wanted: String =
sqlx::query_scalar!("SELECT wanted_languages FROM subtitle_settings WHERE id = 1")
.fetch_one(pool(&state)?)
.await?;
let dropped: Vec<String> = column::<Vec<String>>("wanted_languages", &previous_wanted)?
.into_iter()
.filter(|language| !new_wanted.contains(language))
.collect();
let mut transaction = pool(&state)?.begin().await?;
sqlx::query!(
r#"UPDATE subtitle_settings SET
wanted_languages = ?, providers_enabled = ?, translation_engine = ?,
provider_daily_budgets = ?, translator_daily_budgets = ?,
remote_command_timeout_seconds = ?,
openai_base_url = ?, openai_model = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = 1"#,
columns.wanted_languages,
columns.providers_enabled,
columns.translation_engine,
columns.provider_daily_budgets,
columns.translator_daily_budgets,
columns.remote_command_timeout_seconds,
columns.openai_base_url,
columns.openai_model,
)
.execute(&mut *transaction)
.await?;
// #224: a dropped language's attempt bookkeeping (backoff counter,
// `last_failure`) must not resurrect if the language is re-added later.
// Subtitle files stay — only the attempt rows are wanted-set-scoped.
for language in &dropped {
sqlx::query!("DELETE FROM subtitle_attempts WHERE language = ?", language)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
// Issue #219: the row alone never reaches the running backend. Push it
// into the cell the remote-command translator re-reads per batch; the
// cell counts milliseconds, the row counts seconds.
if let Some(timeout) = state.command_timeout() {
timeout.store(
u64::try_from(timeout_seconds)
.unwrap_or(u64::MAX)
.saturating_mul(1_000),
std::sync::atomic::Ordering::Relaxed,
);
}
// #220: the same path for the OpenAI-compatible backend. `validate` has
// already parsed the base URL, so this cannot fail for a reason the
// operator has not been told about.
if let Some(endpoint) = state.openai_endpoint() {
if let Err(error) = endpoint.set(openai_base_url.as_deref(), openai_model.as_deref()) {
tracing::error!(%error, "validated openai endpoint failed to apply");
}
}
Ok(Json(load(&state).await?))
}
#[cfg(test)]
mod tests {
use axum::http::StatusCode;
use crate::{router, AppState, Upstreams};
async fn application() -> (tempfile::TempDir, String) {
let (dir, base, _timeout) = application_with_timeout().await;
(dir, base)
}
/// The same app, with the remote-command backend's live timeout cell
/// attached — what the daemon wires up when that backend is configured.
async fn application_with_timeout() -> (
tempfile::TempDir,
String,
std::sync::Arc<std::sync::atomic::AtomicU64>,
) {
use std::sync::{atomic::AtomicU64, Arc};
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 timeout = Arc::new(AtomicU64::new(30_000));
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state")
.with_database(database)
.with_command_timeout(timeout.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let app = router(state);
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
(dir, format!("http://{address}"), timeout)
}
/// The same app, with the OpenAI-compatible backend's live endpoint
/// attached — what the daemon wires up when that backend is compiled in.
async fn application_with_openai() -> (tempfile::TempDir, String, arr_subs::OpenAiEndpoint) {
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 endpoint = arr_subs::OpenAiEndpoint::new(None, None).expect("defaults resolve");
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state")
.with_database(database)
.with_openai_endpoint(endpoint.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let app = router(state);
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
(dir, format!("http://{address}"), endpoint)
}
/// The same app, with the underlying pool exposed so a test can seed or
/// inspect rows the API surface does not read back directly.
async fn application_with_pool() -> (tempfile::TempDir, String, sqlx::SqlitePool) {
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 pool = database.pool().clone();
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let app = router(state);
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
(dir, format!("http://{address}"), pool)
}
fn valid_input() -> serde_json::Value {
serde_json::json!({
"wanted_languages": ["pt-PT", "en"],
"providers_enabled": ["opensubtitles", "podnapisi"],
"translation_engine": null,
"provider_daily_budgets": { "opensubtitles": 100 },
"translator_daily_budgets": {},
"remote_command_timeout_seconds": 45
})
}
#[tokio::test]
async fn the_seeded_row_reads_back_with_the_engines_this_build_compiled() {
let (_dir, base) = application().await;
let settings: serde_json::Value = reqwest::get(format!("{base}/api/settings/subtitles"))
.await
.expect("get settings")
.json()
.await
.expect("settings json");
assert_eq!(
settings["wanted_languages"],
serde_json::json!(["pt-PT", "en"])
);
assert_eq!(
settings["providers_enabled"],
serde_json::json!(["opensubtitles", "podnapisi"])
);
assert!(settings["translation_engine"].is_null());
// `available_engines` is a fact about the build, not about the seed
// row, so it tracks the `translate-*` features rather than a literal.
assert_eq!(
settings["available_engines"],
serde_json::json!(arr_subs::compiled_engines())
);
}
#[tokio::test]
async fn the_settings_round_trip_through_a_put() {
let (_dir, base) = application().await;
let updated: serde_json::Value = reqwest::Client::new()
.put(format!("{base}/api/settings/subtitles"))
.json(&valid_input())
.send()
.await
.expect("put settings")
.json()
.await
.expect("updated json");
assert_eq!(
updated["provider_daily_budgets"],
serde_json::json!({ "opensubtitles": 100 })
);
assert_eq!(updated["remote_command_timeout_seconds"], 45);
let refetched: serde_json::Value = reqwest::get(format!("{base}/api/settings/subtitles"))
.await
.expect("get settings")
.json()
.await
.expect("settings json");
assert_eq!(refetched, updated);
}
/// #224: dropping a language from `wanted_languages` must clear its
/// `subtitle_attempts` rows — otherwise re-adding it later resurrects a
/// stale backoff counter as though the attempts had just happened.
#[tokio::test]
async fn dropping_a_language_clears_its_attempt_rows() {
let (_dir, base, pool) = application_with_pool().await;
sqlx::query(
"INSERT INTO media_files (id, owner_kind, owner_id, path, size)
VALUES (1, 'movie', 1, 'x.mkv', 1)",
)
.execute(&pool)
.await
.expect("media file");
sqlx::query(
"INSERT INTO subtitle_attempts
(media_file_id, language, state, attempts, last_attempt_at, last_failure)
VALUES (1, 'en', 'failed', 3, '2024-01-01T00:00:00Z', 'no provider match')",
)
.execute(&pool)
.await
.expect("dropped-language attempt");
sqlx::query(
"INSERT INTO subtitle_attempts (media_file_id, language, state) VALUES (1, 'pt-PT', 'wanted')",
)
.execute(&pool)
.await
.expect("kept-language attempt");
let mut payload = valid_input();
payload["wanted_languages"] = serde_json::json!(["pt-PT"]);
reqwest::Client::new()
.put(format!("{base}/api/settings/subtitles"))
.json(&payload)
.send()
.await
.expect("put settings")
.error_for_status()
.expect("valid input accepted");
let remaining: Vec<String> =
sqlx::query_scalar("SELECT language FROM subtitle_attempts ORDER BY language")
.fetch_all(&pool)
.await
.expect("attempts");
assert_eq!(remaining, vec!["pt-PT".to_string()]);
}
/// Issue #219: the row alone never reaches the running backend, so a PUT
/// must push its value into the cell the command translator re-reads.
#[tokio::test]
async fn a_put_updates_the_live_command_timeout() {
let (_dir, base, timeout) = application_with_timeout().await;
assert_eq!(
timeout.load(std::sync::atomic::Ordering::Relaxed),
30_000,
"seeded from the row at startup"
);
reqwest::Client::new()
.put(format!("{base}/api/settings/subtitles"))
.json(&valid_input())
.send()
.await
.expect("put settings")
.error_for_status()
.expect("valid input accepted");
assert_eq!(
timeout.load(std::sync::atomic::Ordering::Relaxed),
45_000,
"the edited value reaches the running backend"
);
}
/// #220: the two OpenAI-compatible endpoint fields are ordinary settings
/// — they round-trip, and an omitted or empty one reads back as `null`,
/// which the backend takes as "use your own default".
#[tokio::test]
async fn the_openai_endpoint_round_trips_and_blanks_read_back_null() {
let (_dir, base) = application().await;
let client = reqwest::Client::new();
let seeded: serde_json::Value = reqwest::get(format!("{base}/api/settings/subtitles"))
.await
.expect("get settings")
.json()
.await
.expect("settings json");
assert!(seeded["openai_base_url"].is_null());
assert!(seeded["openai_model"].is_null());
let mut payload = valid_input();
payload["openai_base_url"] = serde_json::json!("http://127.0.0.1:8080/v1");
payload["openai_model"] = serde_json::json!("qwen2.5:7b");
let updated: serde_json::Value = client
.put(format!("{base}/api/settings/subtitles"))
.json(&payload)
.send()
.await
.expect("put settings")
.json()
.await
.expect("updated json");
assert_eq!(updated["openai_base_url"], "http://127.0.0.1:8080/v1");
assert_eq!(updated["openai_model"], "qwen2.5:7b");
// An emptied field means "back to the default", not an endpoint
// named "" — the settings form sends an empty input, not a null.
payload["openai_base_url"] = serde_json::json!("");
payload["openai_model"] = serde_json::json!(" ");
let cleared: serde_json::Value = client
.put(format!("{base}/api/settings/subtitles"))
.json(&payload)
.send()
.await
.expect("put settings")
.json()
.await
.expect("updated json");
assert!(cleared["openai_base_url"].is_null());
assert!(cleared["openai_model"].is_null());
}
/// #220: a base URL that does not parse is a 422 naming the field, the
/// same shape `translation_engine` already rejects with.
#[tokio::test]
async fn a_base_url_that_does_not_parse_is_a_422_naming_the_field() {
let (_dir, base) = application().await;
let mut payload = valid_input();
payload["openai_base_url"] = serde_json::json!("not a url");
let response = reqwest::Client::new()
.put(format!("{base}/api/settings/subtitles"))
.json(&payload)
.send()
.await
.expect("put settings");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body: serde_json::Value = response.json().await.expect("error json");
assert_eq!(
body["error"], "openai_base_url: bad base URL: relative URL without a base",
"the message names the field and the reason, not a reply that never came"
);
}
/// #220, the point of the issue: the row alone never reaches the running
/// backend, so a PUT must repoint the cell it re-reads per request —
/// which is also what the health lamp probes.
#[tokio::test]
async fn a_put_repoints_the_live_openai_endpoint() {
let (_dir, base, endpoint) = application_with_openai().await;
assert_eq!(endpoint.base_url(), arr_subs::OPENAI_DEFAULT_BASE_URL);
assert_eq!(endpoint.model(), arr_subs::OPENAI_DEFAULT_MODEL);
let mut payload = valid_input();
payload["openai_base_url"] = serde_json::json!("http://127.0.0.1:8080/v1");
payload["openai_model"] = serde_json::json!("qwen2.5:7b");
reqwest::Client::new()
.put(format!("{base}/api/settings/subtitles"))
.json(&payload)
.send()
.await
.expect("put settings")
.error_for_status()
.expect("valid input accepted");
assert_eq!(endpoint.base_url(), "http://127.0.0.1:8080/v1/");
assert_eq!(endpoint.model(), "qwen2.5:7b");
}
/// Whether a known engine is selectable depends on which `translate-*`
/// features this binary was built with, so the test asks the build rather
/// than assuming. With no feature on, every engine is uncompiled and must
/// be refused; with all of them on there is nothing to refuse, and the
/// complementary truth — a compiled engine is accepted — is what holds.
#[tokio::test]
async fn an_uncompiled_engine_is_a_422_naming_the_field() {
let compiled = arr_subs::compiled_engines();
let Some(uncompiled) = arr_subs::ENGINES
.iter()
.find(|engine| !compiled.contains(*engine))
else {
return a_compiled_engine_is_accepted().await;
};
let (_dir, base) = application().await;
let mut payload = valid_input();
payload["translation_engine"] = serde_json::json!(uncompiled);
let response = reqwest::Client::new()
.put(format!("{base}/api/settings/subtitles"))
.json(&payload)
.send()
.await
.expect("put settings");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body: serde_json::Value = response.json().await.expect("error body");
assert!(
body["error"]
.as_str()
.expect("error text")
.contains("translation_engine"),
"{body}"
);
}
/// The other side of the feature gate: an engine this binary *did*
/// compile in is selectable. Called directly when no engine is uncompiled.
async fn a_compiled_engine_is_accepted() {
let compiled = arr_subs::compiled_engines();
let Some(engine) = compiled.first() else {
return;
};
let (_dir, base) = application().await;
let mut payload = valid_input();
payload["translation_engine"] = serde_json::json!(engine);
let response = reqwest::Client::new()
.put(format!("{base}/api/settings/subtitles"))
.json(&payload)
.send()
.await
.expect("put settings");
assert_eq!(response.status(), StatusCode::OK, "{engine} is compiled in");
}
#[tokio::test]
async fn an_unknown_engine_name_is_a_422() {
let (_dir, base) = application().await;
let mut payload = valid_input();
payload["translation_engine"] = serde_json::json!("bing-translate");
let response = reqwest::Client::new()
.put(format!("{base}/api/settings/subtitles"))
.json(&payload)
.send()
.await
.expect("put settings");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn every_field_validates_by_name() {
let (_dir, base) = application().await;
let with = |patch: &dyn Fn(&mut serde_json::Value)| {
let mut payload = valid_input();
patch(&mut payload);
payload
};
let cases: Vec<(serde_json::Value, &str)> = vec![
(
with(&|payload| payload["wanted_languages"] = serde_json::json!([])),
"wanted_languages",
),
(
with(&|payload| {
payload["wanted_languages"] = serde_json::json!(["pt-PT", "pt-PT"]);
}),
"wanted_languages",
),
(
with(&|payload| {
payload["providers_enabled"] = serde_json::json!(["opensubtitles", ""]);
}),
"providers_enabled",
),
(
with(&|payload| payload["remote_command_timeout_seconds"] = serde_json::json!(0)),
"remote_command_timeout_seconds",
),
];
for (payload, field) in cases {
let response = reqwest::Client::new()
.put(format!("{base}/api/settings/subtitles"))
.json(&payload)
.send()
.await
.expect("put settings");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body: serde_json::Value = response.json().await.expect("error body");
let error = body["error"].as_str().expect("error text");
assert!(error.contains(field), "{field}: {error}");
}
}
#[tokio::test]
async fn malformed_json_is_422_not_400_or_500() {
let (_dir, base) = application().await;
let response = reqwest::Client::new()
.put(format!("{base}/api/settings/subtitles"))
.header("content-type", "application/json")
.body("{not json")
.send()
.await
.expect("malformed put");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
}
File diff suppressed because it is too large Load Diff
+29
View File
@@ -97,6 +97,21 @@ pub fn episode_file_name(
}
}
/// A subtitle sidecar's filename (§15): the video's name, the language, and
/// `srt` — `… [2160p][WEB-DL][HDR10].pt-PT.srt`. A machine translation
/// carries an extra `.mt` segment so `ls` says which subtitles arr made.
#[must_use]
pub fn subtitle_name(video_name: &str, language: &Language, machine_translated: bool) -> String {
let stem = video_name
.rsplit_once('.')
.map_or(video_name, |(stem, _)| stem);
if machine_translated {
format!("{stem}.{language}.mt.srt")
} else {
format!("{stem}.{language}.srt")
}
}
/// The §7.4 attribute tags, in a fixed order: resolution, source, HDR,
/// Portuguese audio.
///
@@ -215,6 +230,20 @@ mod tests {
);
}
/// The §15 sidecar names: plain for a real subtitle, `.mt` for arr's own.
#[test]
fn subtitle_sidecars_carry_the_language_and_the_mt_marker() {
let video = "Dune Part Two (2024) [tmdbid-693134] - [2160p][WEB-DL][HDR10].mkv";
assert_eq!(
subtitle_name(video, &Language::PortuguesePortugal, false),
"Dune Part Two (2024) [tmdbid-693134] - [2160p][WEB-DL][HDR10].pt-PT.srt"
);
assert_eq!(
subtitle_name(video, &Language::PortugueseBrazil, true),
"Dune Part Two (2024) [tmdbid-693134] - [2160p][WEB-DL][HDR10].pt-BR.mt.srt"
);
}
/// The kids audit surface: a pt-PT track is tagged, SDR is not.
#[test]
fn portuguese_audio_is_tagged_and_sdr_is_not() {
+53
View File
@@ -11,6 +11,7 @@ pub mod matching;
pub mod policy;
pub mod score;
pub mod status;
pub mod subs;
pub mod tracking;
pub use arr_parse::NameClaims as ParsedRelease;
@@ -24,6 +25,10 @@ pub use matching::{
};
pub use score::{Score, ScoreWeights};
pub use status::{derive_series_status, SeriesStatus};
pub use subs::{
rank as rank_subtitles, RankedSubtitle, SubtitleCandidate, SubtitleRule, SubtitleTarget,
SubtitleVerdict,
};
macro_rules! id_type {
($name:ident) => {
@@ -318,9 +323,57 @@ pub struct AudioTrack {
pub handler_name: Option<String>,
}
/// How an embedded subtitle track is encoded (DESIGN.md §15).
///
/// The split that matters is text versus bitmap: a text track extracts to a
/// sidecar SRT and can feed a translator, a bitmap one satisfies its language
/// for viewing and nothing more. There is no OCR.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SubtitleCodec {
/// `subrip`, the format sidecars are written in.
SubRip,
/// Advanced `SubStation` Alpha, and SSA with it.
Ass,
/// MP4's timed text.
MovText,
/// Presentation graphics — the bitmap track on `BluRay`.
Pgs,
/// `VobSub` — the bitmap track on DVD.
VobSub,
/// Anything else `ffprobe` names that is not one of the above.
Other,
}
impl fmt::Display for SubtitleCodec {
/// `ffprobe`'s codec name, so the probe column spells what the file said.
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::SubRip => "subrip",
Self::Ass => "ass",
Self::MovText => "mov_text",
Self::Pgs => "hdmv_pgs_subtitle",
Self::VobSub => "dvd_subtitle",
Self::Other => "other",
})
}
}
impl SubtitleCodec {
/// §15: only these extract to SRT and may become a translation source.
#[must_use]
pub const fn is_text(self) -> bool {
matches!(self, Self::SubRip | Self::Ass | Self::MovText)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubtitleTrack {
pub language: Language,
pub codec: SubtitleCodec,
/// Foreign lines and on-screen signs only. Never satisfies a want (§15).
pub forced: bool,
/// Complete, with sound descriptions. Satisfies, ranked below plain (§15).
pub sdh: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
+3
View File
@@ -1241,6 +1241,9 @@ mod tests {
let mut media = probed_audio(vec![track(Language::PortuguesePortugal)]);
media.subtitle_tracks = vec![SubtitleTrack {
language: Language::PortugueseBrazil,
codec: crate::SubtitleCodec::SubRip,
forced: false,
sdh: false,
}];
assert_eq!(
verdict_for(&kids_policy(), &en(), Candidate::PostDownload(&media)),
+345
View File
@@ -0,0 +1,345 @@
//! Ranking subtitle candidates against a media file (`DESIGN.md` §15).
//!
//! Pure and IO-free, alongside the release scoring in §5.5: the input is the
//! facts already known about the file plus a list of candidates as reported
//! by a provider, the output is those candidates ordered best first, each
//! carrying the verdict that put it there.
//!
//! The verdict vocabulary mirrors the release path — [`SubtitleVerdict`]
//! carries `Eligible` or `Rejected` a named [`SubtitleRule`], not a string
//! built for a log line — so §9.3's manual view needs no second concept for
//! subtitles. There is no `waived` bucket: nothing about a subtitle is worth
//! overriding by hand.
use crate::Source;
/// What is known about the file a subtitle candidate is ranked against.
#[derive(Clone, Copy, Debug, Default)]
pub struct SubtitleTarget<'a> {
/// The file's own `moviehash`, when computed.
pub moviehash: Option<&'a str>,
/// The exact name of the release that produced the file.
pub release_name: Option<&'a str>,
/// The release group claimed by that release name.
pub release_group: Option<&'a str>,
/// The source tier claimed by that release name.
pub source: Option<Source>,
}
/// One subtitle candidate, as reported by a provider.
#[derive(Clone, Copy, Debug, Default)]
pub struct SubtitleCandidate<'a> {
/// Covers foreign-language lines and on-screen signs only; never
/// satisfies a want (§15's "Forced and SDH").
pub forced: bool,
/// Complete and satisfies a want, but ranks below a plain subtitle for
/// the same language.
pub hearing_impaired: bool,
pub moviehash: Option<&'a str>,
pub release_name: Option<&'a str>,
pub release_group: Option<&'a str>,
pub source: Option<Source>,
pub uploader_rating: f64,
pub download_count: u64,
}
/// Why a subtitle candidate was rejected.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SubtitleRule {
/// A forced track covers foreign-language lines only and cannot satisfy
/// a want (§15).
Forced,
}
impl SubtitleRule {
/// The stable name shared by the API and UI, so one rule reads the same
/// everywhere (mirrors [`crate::Rule::name`]).
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Self::Forced => "forced",
}
}
}
/// The verdict for one subtitle candidate.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SubtitleVerdict {
Eligible,
Rejected(SubtitleRule),
}
/// One candidate paired with the verdict that placed it in the ranking.
#[derive(Clone, Copy, Debug)]
pub struct RankedSubtitle<'a> {
/// Position of this candidate in the slice handed to [`rank`]. Ranking
/// reorders, and a candidate carries no identity of its own, so this is
/// how a caller maps a result back to the provider candidate it came
/// from — and therefore to the id it must ask the provider to download.
pub index: usize,
pub candidate: SubtitleCandidate<'a>,
pub verdict: SubtitleVerdict,
}
/// The ordering key for an eligible candidate, most significant field first.
/// Every field is a boolean win or an integer tiebreaker, so `Ord` alone
/// gives the ranking — no float, no partial order.
type TierKey = (bool, bool, bool, bool, i64, u64);
fn tier_key(candidate: &SubtitleCandidate<'_>, target: &SubtitleTarget<'_>) -> TierKey {
let plain = !candidate.hearing_impaired;
let moviehash_match = matches_ci(candidate.moviehash, target.moviehash);
let exact_release_match = matches_ci(candidate.release_name, target.release_name);
let group_or_source_match = matches_ci(candidate.release_group, target.release_group)
|| matches_source(candidate.source, target.source);
// Scaled to hundredths so the tiebreaker is an integer, not a float.
// Ratings sit in 0..10, nowhere near i64's range.
#[allow(clippy::cast_possible_truncation)]
let rating = (candidate.uploader_rating * 100.0).round() as i64;
(
plain,
moviehash_match,
exact_release_match,
group_or_source_match,
rating,
candidate.download_count,
)
}
fn matches_ci(candidate: Option<&str>, target: Option<&str>) -> bool {
match (candidate, target) {
(Some(candidate), Some(target)) => candidate.eq_ignore_ascii_case(target),
_ => false,
}
}
fn matches_source(candidate: Option<Source>, target: Option<Source>) -> bool {
matches!((candidate, target), (Some(candidate), Some(target)) if candidate == target)
}
/// Rank candidates against a target, best first.
///
/// A forced candidate is rejected outright and sorts after every eligible
/// one, in the order it was given (§15: it can never satisfy a want). Among
/// eligible candidates, a plain subtitle always outranks a hearing-impaired
/// one for the same language; within that split, a `moviehash` match wins
/// outright, then an exact release-name match, then a shared release group
/// or source, then uploader rating and download count as tiebreakers.
#[must_use]
pub fn rank<'a>(
target: &SubtitleTarget<'_>,
candidates: &[SubtitleCandidate<'a>],
) -> Vec<RankedSubtitle<'a>> {
let mut ranked: Vec<RankedSubtitle<'a>> = candidates
.iter()
.enumerate()
.map(|(index, candidate)| RankedSubtitle {
index,
candidate: *candidate,
verdict: if candidate.forced {
SubtitleVerdict::Rejected(SubtitleRule::Forced)
} else {
SubtitleVerdict::Eligible
},
})
.collect();
ranked.sort_by(|a, b| match (a.verdict, b.verdict) {
(SubtitleVerdict::Rejected(_), SubtitleVerdict::Rejected(_)) => std::cmp::Ordering::Equal,
(SubtitleVerdict::Rejected(_), SubtitleVerdict::Eligible) => std::cmp::Ordering::Greater,
(SubtitleVerdict::Eligible, SubtitleVerdict::Rejected(_)) => std::cmp::Ordering::Less,
(SubtitleVerdict::Eligible, SubtitleVerdict::Eligible) => {
tier_key(&b.candidate, target).cmp(&tier_key(&a.candidate, target))
}
});
ranked
}
#[cfg(test)]
mod tests {
use super::*;
fn target() -> SubtitleTarget<'static> {
SubtitleTarget {
moviehash: Some("abc123"),
release_name: Some("Movie.2024.1080p.WEB-DL-GROUP"),
release_group: Some("GROUP"),
source: Some(Source::WebDl),
}
}
fn plain() -> SubtitleCandidate<'static> {
SubtitleCandidate {
uploader_rating: 5.0,
download_count: 100,
..Default::default()
}
}
#[test]
fn ranking_reports_where_each_candidate_came_from() {
// Ranking reorders, and a candidate carries no id of its own, so the
// index is the only way back to the provider candidate — and so to
// the id the provider is asked to download.
let weak = plain();
let strong = SubtitleCandidate {
moviehash: Some("abc123"),
..plain()
};
let ranked = rank(&target(), &[weak, strong]);
assert_eq!(ranked[0].index, 1);
assert_eq!(ranked[1].index, 0);
}
#[test]
fn a_rejected_candidate_still_reports_its_index() {
let forced = SubtitleCandidate {
forced: true,
..plain()
};
let ranked = rank(&target(), &[forced, plain()]);
assert_eq!(ranked[0].index, 1);
assert_eq!(ranked[1].index, 0);
assert_eq!(
ranked[1].verdict,
SubtitleVerdict::Rejected(SubtitleRule::Forced)
);
}
#[test]
fn a_moviehash_match_wins_outright_over_every_other_tier() {
let hash_match = SubtitleCandidate {
moviehash: Some("abc123"),
uploader_rating: 0.0,
download_count: 0,
..plain()
};
let everything_else = SubtitleCandidate {
release_name: Some("Movie.2024.1080p.WEB-DL-GROUP"),
release_group: Some("GROUP"),
source: Some(Source::WebDl),
uploader_rating: 10.0,
download_count: 1_000_000,
..plain()
};
let ranked = rank(&target(), &[everything_else, hash_match]);
assert!(matches!(ranked[0].candidate.moviehash, Some("abc123")));
assert_eq!(ranked[0].verdict, SubtitleVerdict::Eligible);
}
#[test]
fn an_exact_release_name_match_outranks_group_or_source_alone() {
let exact_name = SubtitleCandidate {
release_name: Some("Movie.2024.1080p.WEB-DL-GROUP"),
..plain()
};
let group_only = SubtitleCandidate {
release_group: Some("GROUP"),
uploader_rating: 10.0,
download_count: 1_000_000,
..plain()
};
let ranked = rank(&target(), &[group_only, exact_name]);
assert_eq!(
ranked[0].candidate.release_name,
Some("Movie.2024.1080p.WEB-DL-GROUP")
);
}
#[test]
fn same_source_alone_outranks_no_match_at_all() {
let same_source = SubtitleCandidate {
source: Some(Source::WebDl),
..plain()
};
let no_match = SubtitleCandidate {
uploader_rating: 10.0,
download_count: 1_000_000,
..plain()
};
let ranked = rank(&target(), &[no_match, same_source]);
assert_eq!(ranked[0].candidate.source, Some(Source::WebDl));
}
#[test]
#[allow(clippy::float_cmp)]
fn rating_then_downloads_break_ties() {
let low = SubtitleCandidate {
uploader_rating: 3.0,
download_count: 50,
..plain()
};
let high_rating = SubtitleCandidate {
uploader_rating: 8.0,
download_count: 10,
..plain()
};
let ranked = rank(&target(), &[low, high_rating]);
assert_eq!(ranked[0].candidate.uploader_rating, 8.0);
let same_rating_more_downloads = SubtitleCandidate {
uploader_rating: 8.0,
download_count: 9_000,
..plain()
};
let ranked = rank(&target(), &[high_rating, same_rating_more_downloads]);
assert_eq!(ranked[0].candidate.download_count, 9_000);
}
#[test]
fn a_forced_candidate_is_never_eligible() {
let forced = SubtitleCandidate {
forced: true,
moviehash: Some("abc123"),
..plain()
};
let ranked = rank(&target(), &[forced]);
assert_eq!(
ranked[0].verdict,
SubtitleVerdict::Rejected(SubtitleRule::Forced)
);
assert_eq!(SubtitleRule::Forced.name(), "forced");
}
#[test]
fn an_sdh_candidate_ranks_below_any_plain_candidate() {
let sdh_with_hash_match = SubtitleCandidate {
hearing_impaired: true,
moviehash: Some("abc123"),
uploader_rating: 10.0,
download_count: 1_000_000,
..plain()
};
let plain_with_nothing = SubtitleCandidate {
uploader_rating: 0.0,
download_count: 0,
..plain()
};
let ranked = rank(&target(), &[sdh_with_hash_match, plain_with_nothing]);
assert!(!ranked[0].candidate.hearing_impaired);
assert!(ranked[1].candidate.hearing_impaired);
}
#[test]
fn rejected_candidates_sort_after_every_eligible_one_in_input_order() {
let forced_a = SubtitleCandidate {
forced: true,
download_count: 1,
..plain()
};
let forced_b = SubtitleCandidate {
forced: true,
download_count: 2,
..plain()
};
let eligible = plain();
let ranked = rank(&target(), &[forced_a, eligible, forced_b]);
assert_eq!(ranked[0].verdict, SubtitleVerdict::Eligible);
assert_eq!(ranked[1].candidate.download_count, 1);
assert_eq!(ranked[2].candidate.download_count, 2);
}
}
+11
View File
@@ -10,6 +10,16 @@ publish = false
name = "arr"
path = "src/main.rs"
# Forwards straight to `arr-subs`' own features (DESIGN.md §15): which
# translators a build ships is a compile-time choice, off by default, same as
# the crate that implements them.
[features]
default = []
translate-openai = ["arr-subs/translate-openai"]
translate-deepl = ["arr-subs/translate-deepl"]
translate-google = ["arr-subs/translate-google"]
translate-command = ["arr-subs/translate-command"]
[dependencies]
arr-api = { workspace = true }
arr-compat = { workspace = true }
@@ -20,6 +30,7 @@ arr-indexer = { workspace = true }
arr-meta = { workspace = true }
arr-parse = { workspace = true }
arr-probe = { workspace = true }
arr-subs = { workspace = true }
axum = { workspace = true }
chrono = { workspace = true }
include_dir = { workspace = true }
+204 -13
View File
@@ -1,5 +1,5 @@
//! §9.5 *broken* → the operator alone: Prowlarr, Transmission or TMDB
//! unreachable.
//! unreachable, or a subtitle lamp failing (#200).
//!
//! Edge-triggered: notifies once when an upstream stops answering, and
//! silently re-arms once it answers again. There is no "fixed" notification —
@@ -29,52 +29,121 @@ pub struct Upstreams {
pub tmdb_api_key: Option<String>,
}
/// The subtitle upstreams (#200): the providers and engines this deployment
/// has credentials for, and the two binaries with their configured paths.
///
/// What is *in use* is read from the settings row per tick; this only holds
/// what could ever answer.
#[derive(Debug, Clone)]
pub struct SubtitleUpstreams {
pub providers: Vec<Arc<dyn arr_subs::Provider>>,
pub backends: Vec<Arc<dyn arr_subs::Backend>>,
pub alass_path: std::path::PathBuf,
pub ffmpeg_path: std::path::PathBuf,
}
impl SubtitleUpstreams {
/// The lamps as `(name, reachable)` pairs. Only what the settings row
/// has in use is probed — a provider nobody enabled cannot be broken.
async fn probe(&self, database: &Db) -> Vec<(String, bool)> {
let (providers_enabled, engine) = match crate::subtitles::load_settings(database).await {
Ok(settings) => (settings.providers_enabled, settings.translation_engine),
// An unreadable row says nothing about any upstream; skipping the
// whole lane beats notifying on our own database.
Err(error) => {
tracing::warn!(%error, "subtitle settings unreadable; subtitle lamps skipped");
return Vec::new();
}
};
let mut lamps = Vec::new();
for id in &providers_enabled {
let reachable = match self
.providers
.iter()
.find(|p| p.id().as_str() == id.as_str())
{
Some(provider) => provider.probe().await.is_ok(),
None => false,
};
lamps.push((id.clone(), reachable));
}
if let Some(engine) = engine {
let reachable = match self.backends.iter().find(|b| b.id().as_str() == engine) {
Some(backend) => backend.probe().await.is_ok(),
None => false,
};
lamps.push((engine, reachable));
}
lamps.push((
"alass".to_owned(),
arr_subs::binary_present(std::ffi::OsStr::new(&self.alass_path)),
));
lamps.push((
"ffmpeg".to_owned(),
arr_subs::binary_present(std::ffi::OsStr::new(&self.ffmpeg_path)),
));
lamps
}
}
#[derive(Debug)]
pub struct BrokenAction {
http: Client,
upstreams: Upstreams,
subtitles: SubtitleUpstreams,
notifier: Notifier,
operator_topic: String,
/// Which upstreams are currently notified as broken. Transient — a
/// restart re-probes and re-notifies whatever is still down.
broken: Arc<Mutex<HashSet<&'static str>>>,
broken: Arc<Mutex<HashSet<String>>>,
}
impl BrokenAction {
#[must_use]
pub fn new(upstreams: Upstreams, notifier: Notifier, operator_topic: String) -> Self {
pub fn new(
upstreams: Upstreams,
subtitles: SubtitleUpstreams,
notifier: Notifier,
operator_topic: String,
) -> Self {
Self {
http: Client::new(),
upstreams,
subtitles,
notifier,
operator_topic,
broken: Arc::new(Mutex::new(HashSet::new())),
}
}
async fn tick(&self) -> Vec<Outcome> {
let (prowlarr, transmission, tmdb) = tokio::join!(
async fn tick(&self, database: &Db) -> Vec<Outcome> {
let (prowlarr, transmission, tmdb, subtitles) = tokio::join!(
self.probe_prowlarr(),
self.probe_transmission(),
self.probe_tmdb(),
self.subtitles.probe(database),
);
let mut outcomes = Vec::new();
outcomes.extend(self.notify_transition("prowlarr", prowlarr).await);
outcomes.extend(self.notify_transition("transmission", transmission).await);
outcomes.extend(self.notify_transition("tmdb", tmdb).await);
for (name, reachable) in subtitles {
outcomes.extend(self.notify_transition(&name, reachable).await);
}
outcomes
}
/// `reachable` is `true` when the upstream answered, or when it needs no
/// key and none is configured (not an outage — see `probe_tmdb`).
async fn notify_transition(&self, name: &'static str, reachable: bool) -> Option<Outcome> {
async fn notify_transition(&self, name: &str, reachable: bool) -> Option<Outcome> {
let mut broken = self.broken.lock().await;
if reachable {
broken.remove(name);
return None;
}
if !broken.insert(name) {
if !broken.insert(name.to_owned()) {
return None;
}
match self
@@ -153,8 +222,8 @@ impl Action for BrokenAction {
"broken"
}
fn run<'a>(&'a self, _database: &'a Db) -> ActionFuture<'a> {
Box::pin(async move { Ok(self.tick().await) })
fn run<'a>(&'a self, database: &'a Db) -> ActionFuture<'a> {
Box::pin(async move { Ok(self.tick(database).await) })
}
}
@@ -176,6 +245,32 @@ mod tests {
}
}
/// A migrated database whose settings enable nothing — the classic
/// upstreams under test here must not share the tick with subtitle
/// lamps the seed row would otherwise put in use.
async fn database() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let db = Db::connect(dir.path().join("broken-test.db"))
.await
.unwrap();
db.migrate().await.unwrap();
sqlx::query("UPDATE subtitle_settings SET providers_enabled = '[]'")
.execute(db.pool())
.await
.unwrap();
(dir, db)
}
fn subtitles() -> SubtitleUpstreams {
SubtitleUpstreams {
providers: Vec::new(),
backends: Vec::new(),
// Present on every machine that runs these tests.
alass_path: "sh".into(),
ffmpeg_path: "sh".into(),
}
}
#[tokio::test]
async fn an_unreachable_upstream_notifies_once_then_re_arms() {
let prowlarr = MockServer::start().await;
@@ -192,14 +287,16 @@ mod tests {
.mount(&ntfy)
.await;
let (_dir, db) = database().await;
let action = BrokenAction::new(
upstreams(prowlarr.uri(), transmission.uri()),
subtitles(),
Notifier::new(ntfy.uri()).unwrap(),
"operator-topic".to_string(),
);
let first = action.tick().await;
let second = action.tick().await;
let first = action.tick(&db).await;
let second = action.tick(&db).await;
assert_eq!(first.len(), 1, "notifies on the tick it goes unreachable");
assert_eq!(second.len(), 0, "does not repeat while still broken");
@@ -209,12 +306,106 @@ mod tests {
.respond_with(ResponseTemplate::new(200))
.mount(&prowlarr)
.await;
let recovered = action.tick().await;
let recovered = action.tick(&db).await;
assert_eq!(recovered.len(), 0, "recovery is silent, no fourth event");
// Take it down again: a fresh outage re-arms and notifies again.
prowlarr.reset().await;
let broken_again = action.tick().await;
let broken_again = action.tick(&db).await;
assert_eq!(broken_again.len(), 1, "re-arms after recovering");
}
/// #200: an enabled provider nobody configured is a broken lamp like any
/// other, and it notifies once — then stays quiet while it stays broken.
#[tokio::test]
async fn an_enabled_but_missing_subtitle_provider_notifies_once() {
let ntfy = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&ntfy)
.await;
let dir = tempfile::tempdir().unwrap();
let db = Db::connect(dir.path().join("broken-subs.db"))
.await
.unwrap();
db.migrate().await.unwrap();
// The seed row already enables opensubtitles and podnapisi; none are
// attached to this action.
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ping"))
.respond_with(ResponseTemplate::new(200))
.mount(&prowlarr)
.await;
let transmission = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(409))
.mount(&transmission)
.await;
let action = BrokenAction::new(
upstreams(prowlarr.uri(), transmission.uri()),
subtitles(),
Notifier::new(ntfy.uri()).unwrap(),
"operator-topic".to_string(),
);
let first = action.tick(&db).await;
let second = action.tick(&db).await;
assert_eq!(first.len(), 2, "one lamp per missing provider");
assert_eq!(second.len(), 0, "does not repeat while still broken");
// Attaching nothing but disabling them silences the lamps.
sqlx::query("UPDATE subtitle_settings SET providers_enabled = '[]'")
.execute(db.pool())
.await
.unwrap();
let third = action.tick(&db).await;
assert_eq!(third.len(), 0, "a disabled provider cannot be broken");
}
/// #200: binaries are judged at their configured paths.
#[tokio::test]
async fn a_missing_binary_is_a_broken_lamp() {
let ntfy = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&ntfy)
.await;
let (_dir, db) = database().await;
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ping"))
.respond_with(ResponseTemplate::new(200))
.mount(&prowlarr)
.await;
let transmission = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(409))
.mount(&transmission)
.await;
let subs = SubtitleUpstreams {
alass_path: "/nowhere/alass".into(),
..subtitles()
};
let action = BrokenAction::new(
upstreams(prowlarr.uri(), transmission.uri()),
subs,
Notifier::new(ntfy.uri()).unwrap(),
"operator-topic".to_string(),
);
let first = action.tick(&db).await;
assert_eq!(first.len(), 1, "only the missing binary notifies");
assert!(
first
.iter()
.any(|outcome| format!("{outcome:?}").contains("alass")),
"{first:?}"
);
}
}
+287 -1
View File
@@ -30,6 +30,32 @@ pub const ENV_JELLYFIN_URL: &str = "ARR_JELLYFIN_URL";
pub const ENV_JELLYFIN_API_KEY: &str = "ARR_JELLYFIN_API_KEY";
pub const ENV_NTFY_URL: &str = "ARR_NTFY_URL";
pub const ENV_NTFY_OPERATOR_TOPIC: &str = "ARR_NTFY_OPERATOR_TOPIC";
// DESIGN.md §15 subtitle bootstrap. Credentials are secrets (env-only,
// below); base URLs, the remote-command template and the binary paths may
// also sit in the config file.
pub const ENV_OPENSUBTITLES_API_KEY: &str = "ARR_OPENSUBTITLES_API_KEY";
pub const ENV_OPENSUBTITLES_USERNAME: &str = "ARR_OPENSUBTITLES_USERNAME";
pub const ENV_OPENSUBTITLES_PASSWORD: &str = "ARR_OPENSUBTITLES_PASSWORD";
// Podnapisi takes no credentials (#215): its search and download are
// unauthenticated, so there is nothing here for an operator to set.
// The OpenAI-compatible backend's base URL and model are `subtitle_settings`
// rows, not bootstrap keys (#220): that backend is anything speaking the
// shape, and which endpoint is in use is something the operator changes from
// `/settings`. Only the key is here, because §10 keeps secrets out of the
// database — and an endpoint needing no key at all is valid.
pub const ENV_TRANSLATE_OPENAI_API_KEY: &str = "ARR_TRANSLATE_OPENAI_API_KEY";
pub const ENV_TRANSLATE_DEEPL_API_KEY: &str = "ARR_TRANSLATE_DEEPL_API_KEY";
pub const ENV_TRANSLATE_DEEPL_BASE_URL: &str = "ARR_TRANSLATE_DEEPL_BASE_URL";
pub const ENV_TRANSLATE_GOOGLE_API_KEY: &str = "ARR_TRANSLATE_GOOGLE_API_KEY";
pub const ENV_TRANSLATE_GOOGLE_BASE_URL: &str = "ARR_TRANSLATE_GOOGLE_BASE_URL";
pub const ENV_TRANSLATE_COMMAND_TEMPLATE: &str = "ARR_TRANSLATE_COMMAND_TEMPLATE";
pub const ENV_ALASS_PATH: &str = "ARR_ALASS_PATH";
pub const ENV_FFMPEG_PATH: &str = "ARR_FFMPEG_PATH";
/// E2E seam only, env-only, same shape as [`ENV_TMDB_URL`]: `None` means
/// Podnapisi's real address. Kept out of the config file for the same
/// reason `tmdb_url` is — DESIGN.md §10 has no business exposing a seam that
/// only a test harness uses.
pub const ENV_PODNAPISI_URL: &str = "ARR_PODNAPISI_URL";
pub const DEFAULT_BIND_ADDR: &str = "0.0.0.0:7878";
pub const DEFAULT_DATABASE_PATH: &str = "arr.db";
@@ -45,7 +71,8 @@ pub const DEFAULT_SEED_RATIO_LIMIT: f64 = 1.0;
pub const DEFAULT_SEED_IDLE_LIMIT_MINUTES: u64 = 4320;
pub const DEFAULT_JELLYFIN_URL: &str = "http://localhost:8096";
pub const DEFAULT_NTFY_URL: &str = "http://localhost";
pub const DEFAULT_ALASS_PATH: &str = "alass";
pub const DEFAULT_FFMPEG_PATH: &str = "ffmpeg";
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("io: {0}")]
@@ -92,6 +119,16 @@ struct ConfigFile {
ntfy_url: Option<String>,
#[serde(default)]
ntfy_operator_topic: Option<String>,
#[serde(default)]
translate_deepl_base_url: Option<String>,
#[serde(default)]
translate_google_base_url: Option<String>,
#[serde(default)]
translate_command_template: Option<String>,
#[serde(default)]
alass_path: Option<PathBuf>,
#[serde(default)]
ffmpeg_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
@@ -129,8 +166,20 @@ pub struct EnvOverrides {
pub tmdb_url: Option<String>,
pub jellyfin_url: Option<String>,
pub jellyfin_api_key: Option<String>,
pub podnapisi_url: Option<String>,
pub ntfy_url: Option<String>,
pub ntfy_operator_topic: Option<String>,
pub opensubtitles_api_key: Option<String>,
pub opensubtitles_username: Option<String>,
pub opensubtitles_password: Option<String>,
pub translate_openai_api_key: Option<String>,
pub translate_deepl_api_key: Option<String>,
pub translate_deepl_base_url: Option<String>,
pub translate_google_api_key: Option<String>,
pub translate_google_base_url: Option<String>,
pub translate_command_template: Option<String>,
pub alass_path: Option<String>,
pub ffmpeg_path: Option<String>,
}
impl EnvOverrides {
@@ -150,8 +199,20 @@ impl EnvOverrides {
tmdb_url: std::env::var(ENV_TMDB_URL).ok(),
jellyfin_url: std::env::var(ENV_JELLYFIN_URL).ok(),
jellyfin_api_key: std::env::var(ENV_JELLYFIN_API_KEY).ok(),
podnapisi_url: std::env::var(ENV_PODNAPISI_URL).ok(),
ntfy_url: std::env::var(ENV_NTFY_URL).ok(),
ntfy_operator_topic: std::env::var(ENV_NTFY_OPERATOR_TOPIC).ok(),
opensubtitles_api_key: std::env::var(ENV_OPENSUBTITLES_API_KEY).ok(),
opensubtitles_username: std::env::var(ENV_OPENSUBTITLES_USERNAME).ok(),
opensubtitles_password: std::env::var(ENV_OPENSUBTITLES_PASSWORD).ok(),
translate_openai_api_key: std::env::var(ENV_TRANSLATE_OPENAI_API_KEY).ok(),
translate_deepl_api_key: std::env::var(ENV_TRANSLATE_DEEPL_API_KEY).ok(),
translate_deepl_base_url: std::env::var(ENV_TRANSLATE_DEEPL_BASE_URL).ok(),
translate_google_api_key: std::env::var(ENV_TRANSLATE_GOOGLE_API_KEY).ok(),
translate_google_base_url: std::env::var(ENV_TRANSLATE_GOOGLE_BASE_URL).ok(),
translate_command_template: std::env::var(ENV_TRANSLATE_COMMAND_TEMPLATE).ok(),
alass_path: std::env::var(ENV_ALASS_PATH).ok(),
ffmpeg_path: std::env::var(ENV_FFMPEG_PATH).ok(),
}
}
}
@@ -177,11 +238,92 @@ pub struct Config {
pub tmdb_url: Option<String>,
pub jellyfin_url: String,
pub jellyfin_api_key: Option<String>,
/// E2E seam only, env-only. `None` means Podnapisi's built-in default
/// address; see [`ENV_PODNAPISI_URL`].
pub podnapisi_url: Option<String>,
pub ntfy_url: String,
/// The operator's ntfy topic (DESIGN.md §9.5) for *needs a decision* and
/// *broken*. `None` means those two notifications are skipped — there is
/// no sensible default topic name to fall back to.
pub ntfy_operator_topic: Option<String>,
/// §15. `None` means OpenSubtitles.com search runs unauthenticated,
/// which its API allows at a lower rate.
pub opensubtitles_api_key: Option<String>,
pub opensubtitles_username: Option<String>,
pub opensubtitles_password: Option<String>,
/// §15. `None` is valid: `llama.cpp` and a local gateway serve without
/// authentication. Where that backend points and which model it names
/// are `subtitle_settings` rows, read at start-up and re-read on every
/// edit (#220), not bootstrap config.
pub translate_openai_api_key: Option<String>,
pub translate_deepl_api_key: Option<String>,
/// `None` means the backend's own built-in default when it lands (#192).
pub translate_deepl_base_url: Option<String>,
pub translate_google_api_key: Option<String>,
/// `None` means the backend's own built-in default when it lands (#193).
pub translate_google_base_url: Option<String>,
/// §15. The generic remote-command backend's invocation template, e.g.
/// `ssh box claude -p`. `None` means that backend is unconfigured.
pub translate_command_template: Option<String>,
/// §15. `alass` runs on every fetched and every translated subtitle; a
/// bare name resolves through `PATH`, matching `arr-probe`'s `ffprobe`.
pub alass_path: PathBuf,
/// §15. Extracts text-format embedded tracks to sidecar SRTs.
pub ffmpeg_path: PathBuf,
}
/// §15/§10 subtitle bootstrap: provider and translator credentials, base
/// URLs, the remote-command template, and binary paths. Resolved separately
/// from [`Config::resolve`] for the same reason `arr-daemon::api_state` is
/// split out of `run` — one field per provider or backend, and `resolve` is
/// already at the too-many-lines limit.
struct SubtitleBootstrap {
opensubtitles_api_key: Option<String>,
opensubtitles_username: Option<String>,
opensubtitles_password: Option<String>,
translate_openai_api_key: Option<String>,
translate_deepl_api_key: Option<String>,
translate_deepl_base_url: Option<String>,
translate_google_api_key: Option<String>,
translate_google_base_url: Option<String>,
translate_command_template: Option<String>,
alass_path: PathBuf,
ffmpeg_path: PathBuf,
}
fn resolve_subtitle_bootstrap(env: &EnvOverrides, file: &ConfigFile) -> SubtitleBootstrap {
SubtitleBootstrap {
opensubtitles_api_key: env.opensubtitles_api_key.clone(),
opensubtitles_username: env.opensubtitles_username.clone(),
opensubtitles_password: env.opensubtitles_password.clone(),
translate_openai_api_key: env.translate_openai_api_key.clone(),
translate_deepl_api_key: env.translate_deepl_api_key.clone(),
translate_deepl_base_url: env
.translate_deepl_base_url
.clone()
.or_else(|| file.translate_deepl_base_url.clone()),
translate_google_api_key: env.translate_google_api_key.clone(),
translate_google_base_url: env
.translate_google_base_url
.clone()
.or_else(|| file.translate_google_base_url.clone()),
translate_command_template: env
.translate_command_template
.clone()
.or_else(|| file.translate_command_template.clone()),
alass_path: env
.alass_path
.clone()
.map(PathBuf::from)
.or_else(|| file.alass_path.clone())
.unwrap_or_else(|| PathBuf::from(DEFAULT_ALASS_PATH)),
ffmpeg_path: env
.ffmpeg_path
.clone()
.map(PathBuf::from)
.or_else(|| file.ffmpeg_path.clone())
.unwrap_or_else(|| PathBuf::from(DEFAULT_FFMPEG_PATH)),
}
}
impl Config {
@@ -197,6 +339,7 @@ impl Config {
Some(path) => ConfigFile::load(Path::new(path))?,
None => ConfigFile::default(),
};
let subtitles = resolve_subtitle_bootstrap(&env, &file);
let bind_addr = match &env.bind_addr {
Some(raw) => parse_bind_addr(raw, ENV_BIND_ADDR)?,
@@ -258,11 +401,23 @@ impl Config {
.or(file.jellyfin_url)
.unwrap_or_else(|| DEFAULT_JELLYFIN_URL.to_string()),
jellyfin_api_key: env.jellyfin_api_key,
podnapisi_url: env.podnapisi_url,
ntfy_url: env
.ntfy_url
.or(file.ntfy_url)
.unwrap_or_else(|| DEFAULT_NTFY_URL.to_string()),
ntfy_operator_topic: env.ntfy_operator_topic.or(file.ntfy_operator_topic),
opensubtitles_api_key: subtitles.opensubtitles_api_key,
opensubtitles_username: subtitles.opensubtitles_username,
opensubtitles_password: subtitles.opensubtitles_password,
translate_openai_api_key: subtitles.translate_openai_api_key,
translate_deepl_api_key: subtitles.translate_deepl_api_key,
translate_deepl_base_url: subtitles.translate_deepl_base_url,
translate_google_api_key: subtitles.translate_google_api_key,
translate_google_base_url: subtitles.translate_google_base_url,
translate_command_template: subtitles.translate_command_template,
alass_path: subtitles.alass_path,
ffmpeg_path: subtitles.ffmpeg_path,
})
}
}
@@ -302,12 +457,17 @@ mod tests {
DEFAULT_SEED_IDLE_LIMIT_MINUTES
);
assert_eq!(config.jellyfin_url, DEFAULT_JELLYFIN_URL);
assert_eq!(config.podnapisi_url, None);
assert!(config.tracker_seeding.is_empty());
assert_eq!(config.ntfy_url, DEFAULT_NTFY_URL);
assert_eq!(config.ntfy_operator_topic, None);
assert_eq!(config.prowlarr_api_key, None);
assert_eq!(config.tmdb_api_key, None);
assert_eq!(config.jellyfin_api_key, None);
assert_eq!(config.opensubtitles_api_key, None);
assert_eq!(config.translate_command_template, None);
assert_eq!(config.alass_path, PathBuf::from(DEFAULT_ALASS_PATH));
assert_eq!(config.ffmpeg_path, PathBuf::from(DEFAULT_FFMPEG_PATH));
}
#[test]
@@ -376,6 +536,101 @@ prowlarr_url = "http://prowlarr.internal:9696"
assert_eq!(config.jellyfin_api_key.as_deref(), Some("secret-3"));
}
/// §15: `OpenSubtitles` and every translator's credentials are secrets,
/// so this mirrors [`secrets_come_only_from_env`] for them. Podnapisi has
/// no entry: it takes no credentials at all (#215).
#[test]
fn subtitle_secrets_come_only_from_env() {
let env = EnvOverrides {
opensubtitles_api_key: Some("os-key".into()),
opensubtitles_username: Some("os-user".into()),
opensubtitles_password: Some("os-pass".into()),
translate_openai_api_key: Some("oa-key".into()),
translate_deepl_api_key: Some("dl-key".into()),
translate_google_api_key: Some("gg-key".into()),
..EnvOverrides::default()
};
let config = Config::resolve(env).unwrap();
assert_eq!(config.opensubtitles_api_key.as_deref(), Some("os-key"));
assert_eq!(config.opensubtitles_username.as_deref(), Some("os-user"));
assert_eq!(config.opensubtitles_password.as_deref(), Some("os-pass"));
assert_eq!(config.translate_openai_api_key.as_deref(), Some("oa-key"));
assert_eq!(config.translate_deepl_api_key.as_deref(), Some("dl-key"));
assert_eq!(config.translate_google_api_key.as_deref(), Some("gg-key"));
}
#[test]
fn a_subtitle_secret_in_the_config_file_is_a_parse_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(&path, "opensubtitles_api_key = \"leaked\"\n").unwrap();
let env = EnvOverrides {
config_file: Some(path.to_string_lossy().into_owned()),
..EnvOverrides::default()
};
assert!(matches!(
Config::resolve(env),
Err(ConfigError::TomlDecode(_))
));
}
/// §15: base URLs, the remote-command template and the binary paths are
/// not secrets, so the file, the environment and their precedence all
/// apply the same way they do for `jellyfin_url` and friends.
#[test]
fn subtitle_non_secret_bootstrap_comes_from_file_or_env() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(
&path,
r#"
translate_command_template = "ssh box claude -p"
alass_path = "/usr/local/bin/alass"
"#,
)
.unwrap();
let env = EnvOverrides {
config_file: Some(path.to_string_lossy().into_owned()),
..EnvOverrides::default()
};
let config = Config::resolve(env.clone()).unwrap();
assert_eq!(
config.translate_command_template.as_deref(),
Some("ssh box claude -p")
);
assert_eq!(config.alass_path, PathBuf::from("/usr/local/bin/alass"));
// ffmpeg_path was not set anywhere, so it still defaults.
assert_eq!(config.ffmpeg_path, PathBuf::from(DEFAULT_FFMPEG_PATH));
let config = Config::resolve(EnvOverrides {
ffmpeg_path: Some("/opt/bin/ffmpeg".into()),
..env
})
.unwrap();
assert_eq!(config.ffmpeg_path, PathBuf::from("/opt/bin/ffmpeg"));
}
/// #220 retired `translate_openai_base_url` and `translate_openai_model`
/// — both are `subtitle_settings` rows now. `deny_unknown_fields` turns
/// a config file still carrying them into a parse error, so an operator
/// who upgrades sees the move rather than a setting silently ignored.
#[test]
fn the_retired_openai_endpoint_keys_are_a_parse_error() {
for key in ["translate_openai_base_url", "translate_openai_model"] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(&path, format!("{key} = \"x\"\n")).unwrap();
let env = EnvOverrides {
config_file: Some(path.to_string_lossy().into_owned()),
..EnvOverrides::default()
};
assert!(
matches!(Config::resolve(env), Err(ConfigError::TomlDecode(_))),
"{key} must no longer be accepted"
);
}
}
#[test]
fn tmdb_url_is_an_env_only_seam() {
let config = Config::resolve(EnvOverrides::default()).unwrap();
@@ -401,6 +656,37 @@ prowlarr_url = "http://prowlarr.internal:9696"
));
}
/// Same env-only seam as `tmdb_url`, for the same reason: `arr-e2e` needs
/// to point Podnapisi at a `wiremock` fake without a live-tracker risk
/// creeping into the config file (DESIGN.md §15, §10).
#[test]
fn podnapisi_url_is_an_env_only_seam() {
let config = Config::resolve(EnvOverrides::default()).unwrap();
assert_eq!(config.podnapisi_url, None);
let env = EnvOverrides {
podnapisi_url: Some("http://127.0.0.1:9/subtitles".into()),
..EnvOverrides::default()
};
let config = Config::resolve(env).unwrap();
assert_eq!(
config.podnapisi_url.as_deref(),
Some("http://127.0.0.1:9/subtitles")
);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("arr.toml");
std::fs::write(&path, "podnapisi_url = \"http://127.0.0.1:9/subtitles\"\n").unwrap();
let env = EnvOverrides {
config_file: Some(path.to_string_lossy().into_owned()),
..EnvOverrides::default()
};
assert!(matches!(
Config::resolve(env),
Err(ConfigError::TomlDecode(_))
));
}
#[test]
fn a_secret_in_the_config_file_is_a_parse_error() {
let dir = tempfile::tempdir().unwrap();
+8 -1
View File
@@ -1219,7 +1219,14 @@ fn probed_json(media: &ProbedMedia) -> serde_json::Value {
"sub_tracks": media
.subtitle_tracks
.iter()
.map(|track| serde_json::json!({ "language": track.language.to_string() }))
.map(|track| {
serde_json::json!({
"language": track.language.to_string(),
"codec": track.codec.to_string(),
"forced": track.forced,
"sdh": track.sdh,
})
})
.collect::<Vec<_>>(),
})
}
+338 -25
View File
@@ -13,11 +13,12 @@ mod reaper;
pub mod reconcile;
mod rss;
mod series_refresh;
mod subtitles;
mod tv_grab;
mod web;
use std::process::ExitCode;
use std::sync::Arc;
use std::sync::{atomic::AtomicU64, Arc};
use arr_api::{AppState, Upstreams};
use arr_compat::CompatState;
@@ -33,6 +34,7 @@ use reaper::ReaperAction;
use reconcile::{ReconcileLoop, Tick};
use rss::RssAction;
use series_refresh::SeriesRefreshAction;
use subtitles::SubtitleAction;
use tower_http::trace::TraceLayer;
use tv_grab::TvGrabAction;
@@ -107,6 +109,44 @@ enum Error {
BackgroundTask(#[from] tokio::task::JoinError),
}
/// Everything the HTTP layer needs, assembled from config.
///
/// Split out of [`run`] because it grows a line per upstream the API learns
/// to talk to, and `run` is already at the too-many-lines limit.
fn api_state(
config: &Config,
database: &Db,
jellyfin: arr_api::jellyfin::JellyfinClient,
translators: &Translators,
) -> Result<AppState, Error> {
let mut upstreams =
Upstreams::new(config.prowlarr_url.clone(), config.transmission_url.clone())
.with_prowlarr_api_key(config.prowlarr_api_key.clone())
.with_tmdb_api_key(config.tmdb_api_key.clone());
if let Some(tmdb_url) = config.tmdb_url.clone() {
upstreams = upstreams.with_tmdb_url(tmdb_url);
}
let mut state = AppState::new(upstreams)?
.with_database(database.clone())
.with_subtitle_providers(subtitle_providers(
config.opensubtitles_api_key.clone(),
config.opensubtitles_username.clone(),
config.opensubtitles_password.clone(),
config.podnapisi_url.clone(),
))
.with_translation_backends(translators.backends.clone())
.with_jellyfin(jellyfin)
.with_syncer(arr_subs::Syncer::new().with_binary(config.alass_path.clone()));
if let Some(timeout) = &translators.command_timeout {
state = state.with_command_timeout(Arc::clone(timeout));
}
if let Some(endpoint) = &translators.openai_endpoint {
state = state.with_openai_endpoint(endpoint.clone());
}
Ok(state)
}
async fn run() -> Result<(), Error> {
let config = Config::load()?;
let database = Db::connect(&config.database_path).await?;
@@ -124,8 +164,19 @@ async fn run() -> Result<(), Error> {
};
let notifier = Notifier::new(config.ntfy_url.clone())?;
let api_jellyfin = jellyfin_client(&config)?;
let (reconcile, manual_grab, manual_tv) =
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), &notifier)?;
// Built once and shared: the API's translate handler and the reconcile
// lane must see the same backends, or the command translator's live
// timeout cell (#219) would fork.
let translators = translation_backends(&config);
seed_translator_settings(&database, &translators).await?;
let (reconcile, manual_grab, manual_tv) = reconcile_loop(
&database,
&config,
&transmission,
tmdb.as_ref(),
&notifier,
&translators,
)?;
// 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.
@@ -138,15 +189,7 @@ async fn run() -> Result<(), Error> {
compat = compat.with_tmdb(tmdb);
}
let mut upstreams = Upstreams::new(config.prowlarr_url, config.transmission_url)
.with_prowlarr_api_key(config.prowlarr_api_key)
.with_tmdb_api_key(config.tmdb_api_key);
if let Some(tmdb_url) = config.tmdb_url {
upstreams = upstreams.with_tmdb_url(tmdb_url);
}
let state = AppState::new(upstreams)?
.with_database(database.clone())
.with_jellyfin(api_jellyfin);
let state = api_state(&config, &database, api_jellyfin, &translators)?;
let app = arr_api::router(state.clone())
.merge(arr_compat::router(compat))
@@ -224,6 +267,48 @@ async fn run() -> Result<(), Error> {
}
}
/// Seed the translators' live settings from the row, so a restart does not
/// fall back to compiled-in defaults until the next settings edit (issues
/// #219 and #220): the command translator's timeout, and where the
/// OpenAI-compatible backend points plus which model it names.
///
/// The row is guaranteed to exist — migration `0025` seeds it — and the
/// timeout is guaranteed positive by its column CHECK. The two `OpenAI`
/// columns are nullable, and NULL means the backend's own default.
async fn seed_translator_settings(database: &Db, translators: &Translators) -> Result<(), Error> {
if translators.command_timeout.is_none() && translators.openai_endpoint.is_none() {
return Ok(());
}
let row = sqlx::query!(
r#"SELECT remote_command_timeout_seconds AS "remote_command_timeout_seconds!: i64",
openai_base_url AS "openai_base_url: String",
openai_model AS "openai_model: String"
FROM subtitle_settings WHERE id = 1"#
)
.fetch_one(database.pool())
.await?;
if let Some(cell) = &translators.command_timeout {
cell.store(
u64::try_from(row.remote_command_timeout_seconds)
.unwrap_or(u64::MAX)
.saturating_mul(1_000),
std::sync::atomic::Ordering::Relaxed,
);
}
if let Some(endpoint) = &translators.openai_endpoint {
// A row that does not parse must not stop the daemon booting: the
// API validates on write, so this only fires for a hand-edited
// database. The backend stays at its default and the health lamp
// says so.
if let Err(error) =
endpoint.set(row.openai_base_url.as_deref(), row.openai_model.as_deref())
{
tracing::warn!(%error, "stored OpenAI endpoint is unusable; keeping the default");
}
}
Ok(())
}
/// Wire the reconcile lanes (DESIGN.md §8). Grab and RSS both need a
/// Prowlarr key and grab needs TMDB as well; a lane whose upstream is not
/// configured stays unregistered rather than failing every tick.
@@ -239,6 +324,7 @@ fn reconcile_loop(
transmission: &arr_dl::TransmissionClient,
tmdb: Option<&Arc<TmdbClient>>,
notifier: &Notifier,
translators: &Translators,
) -> Result<(ReconcileLoop, Option<GrabAction>, Option<TvGrabAction>), Error> {
let reconcile = ReconcileLoop::new(database.clone());
let seeding = SeedingRules::new(
@@ -310,6 +396,12 @@ fn reconcile_loop(
),
);
// §15: subtitle gaps are reconciled from the same rows the API writes.
reconcile = reconcile.register(
Tick::Reconcile,
subtitle_action(config, notifier, translators)?,
);
// §9.5 *needs a decision* and *broken* both go to the operator alone;
// without a topic configured there is nowhere to send them.
if let Some(operator_topic) = &config.ntfy_operator_topic {
@@ -317,19 +409,12 @@ fn reconcile_loop(
Tick::Reconcile,
AttentionAction::new(notifier.clone(), operator_topic.clone()),
);
let broken_upstreams = broken::Upstreams {
prowlarr_url: config.prowlarr_url.clone(),
prowlarr_api_key: config.prowlarr_api_key.clone(),
transmission_url: config.transmission_url.clone(),
tmdb_url: config
.tmdb_url
.clone()
.unwrap_or_else(|| arr_api::DEFAULT_TMDB_URL.to_string()),
tmdb_api_key: config.tmdb_api_key.clone(),
};
reconcile = reconcile.register(
Tick::Reconcile,
BrokenAction::new(broken_upstreams, notifier.clone(), operator_topic.clone()),
reconcile = register_broken(
reconcile,
config,
translators,
notifier,
operator_topic.clone(),
);
} else {
tracing::warn!(
@@ -341,6 +426,48 @@ fn reconcile_loop(
Ok((reconcile, manual_grab, manual_tv))
}
/// The *broken* lane (#200 included): Prowlarr, Transmission and TMDB, plus
/// the subtitle lamps — an enabled provider, the selected engine, or a
/// missing `alass`/`ffmpeg` all fold into the same operator message.
fn register_broken(
reconcile: ReconcileLoop,
config: &Config,
translators: &Translators,
notifier: &Notifier,
operator_topic: String,
) -> ReconcileLoop {
let broken_upstreams = broken::Upstreams {
prowlarr_url: config.prowlarr_url.clone(),
prowlarr_api_key: config.prowlarr_api_key.clone(),
transmission_url: config.transmission_url.clone(),
tmdb_url: config
.tmdb_url
.clone()
.unwrap_or_else(|| arr_api::DEFAULT_TMDB_URL.to_string()),
tmdb_api_key: config.tmdb_api_key.clone(),
};
let broken_subtitles = broken::SubtitleUpstreams {
providers: subtitle_providers(
config.opensubtitles_api_key.clone(),
config.opensubtitles_username.clone(),
config.opensubtitles_password.clone(),
config.podnapisi_url.clone(),
),
backends: translators.backends.clone(),
alass_path: config.alass_path.clone(),
ffmpeg_path: config.ffmpeg_path.clone(),
};
reconcile.register(
Tick::Reconcile,
BrokenAction::new(
broken_upstreams,
broken_subtitles,
notifier.clone(),
operator_topic,
),
)
}
/// Register the TV grab lane on `reconcile` and hand back a second,
/// independent instance for `manual::run` (issue #132). `None` when Prowlarr
/// is not configured. TV grabbing needs no TMDB at grab time: air dates are
@@ -452,3 +579,189 @@ fn jellyfin_client(config: &Config) -> Result<arr_api::jellyfin::JellyfinClient,
config.jellyfin_api_key.clone(),
)?)
}
/// The §15 reconcile lane: closes subtitle gaps from the attempt rows.
///
/// Takes the same [`Translators`] the API is given, built once at startup, so
/// a translation behaves identically whether the reconcile sweep or the manual
/// endpoint asked for it. Which of the offered backends actually runs is the
/// `translation_engine` database setting, read per translation; with none
/// compiled in the translate step records "not compiled" and backs off rather
/// than failing obscurely.
fn subtitle_action(
config: &Config,
notifier: &Notifier,
translators: &Translators,
) -> Result<SubtitleAction, Error> {
let action = SubtitleAction::new(
subtitle_providers(
config.opensubtitles_api_key.clone(),
config.opensubtitles_username.clone(),
config.opensubtitles_password.clone(),
config.podnapisi_url.clone(),
),
translators.backends.clone(),
arr_subs::Syncer::new().with_binary(config.alass_path.clone()),
arr_probe::Extractor::new().with_binary(config.ffmpeg_path.clone()),
jellyfin_client(config)?,
);
Ok(match &config.ntfy_operator_topic {
Some(topic) => action.with_notifier(notifier.clone(), topic.clone()),
None => action,
})
}
/// The subtitle providers this deployment can reach (DESIGN.md §15).
///
/// Credentials are bootstrap config and never reach the database (§10), so
/// which providers *exist* is decided here, once, at startup; which of them a
/// search *runs* is the `providers_enabled` setting the API reads per
/// request. OpenSubtitles.com needs a registered API key to be called at all,
/// so without one it is not offered; Podnapisi is anonymous and always is.
fn subtitle_providers(
opensubtitles_api_key: Option<String>,
username: Option<String>,
password: Option<String>,
podnapisi_url: Option<String>,
) -> Vec<std::sync::Arc<dyn arr_subs::Provider>> {
let mut providers: Vec<std::sync::Arc<dyn arr_subs::Provider>> = Vec::new();
// `podnapisi_url` is the e2e-only seam (`ARR_PODNAPISI_URL`); absent, this
// is the real Podnapisi.net.
let mut builder = arr_subs::Podnapisi::builder();
if let Some(url) = podnapisi_url {
builder = builder.base_url(url);
}
match builder.build() {
Ok(podnapisi) => providers.push(std::sync::Arc::new(podnapisi)),
Err(error) => tracing::warn!(%error, "Podnapisi not available"),
}
let Some(api_key) = opensubtitles_api_key else {
tracing::info!("no OpenSubtitles.com API key configured; that provider is off");
return providers;
};
match arr_subs::OpenSubtitles::new(arr_subs::OpenSubtitlesConfig {
api_key,
username,
password,
}) {
Ok(opensubtitles) => providers.push(std::sync::Arc::new(opensubtitles)),
Err(error) => tracing::warn!(%error, "OpenSubtitles.com not available"),
}
providers
}
/// The translation backends this deployment can offer, built once at startup
/// and shared by the API and the reconcile lane (DESIGN.md §15, issue #216).
///
/// Which cargo features this binary was built with decides what could ever
/// be here (`compiled_engines`); credentials decide what actually is, same
/// split `subtitle_providers` makes for search. Which one of these a
/// translation *uses* is the `translation_engine` database setting, read per
/// request (#198) — this only decides which ids exist to be picked.
///
/// When the remote-command backend is one of them, its live timeout cell
/// rides along (#219): the API writes it on every settings edit, so the row
/// reaches the running process without a restart.
struct Translators {
backends: Vec<std::sync::Arc<dyn arr_subs::Backend>>,
command_timeout: Option<Arc<AtomicU64>>,
/// The OpenAI-compatible backend's live endpoint (#220), when that
/// backend is compiled in. Its base URL and model are database rows, so
/// this rides along the same way the command timeout does.
openai_endpoint: Option<arr_subs::OpenAiEndpoint>,
}
#[cfg_attr(
not(any(
feature = "translate-openai",
feature = "translate-deepl",
feature = "translate-google",
feature = "translate-command"
)),
allow(unused_variables, unused_mut)
)]
fn translation_backends(config: &Config) -> Translators {
let mut backends: Vec<std::sync::Arc<dyn arr_subs::Backend>> = Vec::new();
let mut command_timeout: Option<Arc<AtomicU64>> = None;
let mut openai_endpoint: Option<arr_subs::OpenAiEndpoint> = None;
#[cfg(feature = "translate-openai")]
{
// Built at the backend's own defaults and repointed from the
// settings row a moment later (#220). Never gated on the API key:
// `llama.cpp` serves without authentication, so a base URL and no
// key is a valid configuration (DESIGN.md §15).
let openai_config = arr_subs::OpenAiConfig {
api_key: config.translate_openai_api_key.clone(),
};
match arr_subs::OpenAi::new(openai_config) {
Ok(backend) => {
openai_endpoint = Some(backend.endpoint());
backends.push(std::sync::Arc::new(backend));
}
Err(error) => tracing::warn!(%error, "OpenAI-compatible translator not available"),
}
}
#[cfg(feature = "translate-deepl")]
{
if let Some(auth_key) = config.translate_deepl_api_key.clone() {
let deepl_config = arr_subs::DeepLConfig { auth_key };
let backend = match &config.translate_deepl_base_url {
Some(base_url) => arr_subs::DeepL::with_base_url(deepl_config, base_url),
None => arr_subs::DeepL::new(deepl_config),
};
match backend {
Ok(backend) => backends.push(std::sync::Arc::new(backend)),
Err(error) => tracing::warn!(%error, "DeepL not available"),
}
} else {
tracing::info!("no DeepL auth key configured; that translator is off");
}
}
#[cfg(feature = "translate-google")]
{
if let Some(api_key) = config.translate_google_api_key.clone() {
let google_config = arr_subs::GoogleConfig { api_key };
let backend = match &config.translate_google_base_url {
Some(base_url) => arr_subs::Google::with_base_url(google_config, base_url),
None => arr_subs::Google::new(google_config),
};
match backend {
Ok(backend) => backends.push(std::sync::Arc::new(backend)),
Err(error) => tracing::warn!(%error, "Google Translate not available"),
}
} else {
tracing::info!("no Google Translate API key configured; that translator is off");
}
}
#[cfg(feature = "translate-command")]
{
if let Some(template) = config.translate_command_template.clone() {
let command_config = arr_subs::CommandConfig {
template,
timeout: arr_subs::COMMAND_DEFAULT_TIMEOUT,
};
match arr_subs::Command::new(command_config) {
Ok(backend) => {
command_timeout = Some(backend.timeout_cell());
backends.push(std::sync::Arc::new(backend));
}
Err(error) => tracing::warn!(%error, "remote-command translator not available"),
}
} else {
tracing::info!("no remote-command template configured; that translator is off");
}
}
Translators {
backends,
command_timeout,
openai_endpoint,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
-- #186. What subtitles exist for a media file, and what arr has tried per
-- wanted language (DESIGN.md §15). Settings rows (#198) and the budget
-- buckets (#197) are deliberately absent.
-- One row per subtitle arr knows about. An embedded track that was never
-- extracted has no `path`: it is recorded because it satisfies a language,
-- not because there is a file. Everything else is a sidecar next to the
-- video (§15, §7.4), so the path is the row's identity on disk.
CREATE TABLE subtitle_files (
id INTEGER PRIMARY KEY,
media_file_id INTEGER NOT NULL REFERENCES media_files (id) ON DELETE CASCADE,
-- As `arr_core::Language` spells it: `pt-PT`, `pt-BR`, `en`.
language TEXT NOT NULL,
origin TEXT NOT NULL
CHECK (origin IN ('embedded', 'extracted', 'provider', 'translated')),
-- §15. The named provider a fetch came from, and the candidate id it was
-- ranked as, so a re-fetch of the same candidate is recognisable.
provider TEXT,
candidate_id TEXT,
-- §15. The translation backend that produced it, when machine made.
engine TEXT,
-- §15. A forced track never satisfies a want; an SDH one does, ranked
-- below a plain subtitle. Both are facts about the file, so both are
-- stored and the ranking rule stays in `arr-core`.
forced INTEGER NOT NULL DEFAULT 0 CHECK (forced IN (0, 1)),
sdh INTEGER NOT NULL DEFAULT 0 CHECK (sdh IN (0, 1)),
-- §15. `alass` reports no confidence, so its output is accepted unless
-- implausible. `synced` is an accepted shift; `sync_rejected` means the
-- unsynced original was kept and this file is flagged. Never both.
synced INTEGER NOT NULL DEFAULT 0 CHECK (synced IN (0, 1)),
sync_rejected INTEGER NOT NULL DEFAULT 0 CHECK (sync_rejected IN (0, 1)),
path TEXT UNIQUE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK ((path IS NULL) = (origin = 'embedded')),
CHECK ((provider IS NOT NULL) = (origin = 'provider')),
CHECK (candidate_id IS NULL OR provider IS NOT NULL),
CHECK ((engine IS NOT NULL) = (origin = 'translated')),
CHECK (NOT (synced = 1 AND sync_rejected = 1))
) STRICT;
-- §15's satisfaction check is "is there a subtitle in this language", asked
-- per file per tick.
CREATE INDEX subtitle_files_media_file ON subtitle_files (media_file_id, language);
-- An embedded track is identified by what it satisfies, not by a file, so a
-- second probe of the same video re-records it rather than duplicating it.
CREATE UNIQUE INDEX subtitle_files_embedded
ON subtitle_files (media_file_id, language, forced, sdh)
WHERE origin = 'embedded';
-- One row per (media file, wanted language). What the reconcile loop reads to
-- decide whether a language is a gap, what backs a failing provider off, and
-- what the missing-subtitles queue renders.
--
-- `capped` is a state and not an error (§15): being at a daily allowance is
-- something the queue shows. The allowance itself is #197 and not here.
-- `unavailable` is the end of the line — no provider has it and there is no
-- text source to translate from, so retrying costs budget for nothing.
CREATE TABLE subtitle_attempts (
media_file_id INTEGER NOT NULL REFERENCES media_files (id) ON DELETE CASCADE,
language TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'wanted'
CHECK (state IN ('wanted', 'satisfied', 'failed', 'capped', 'unavailable')),
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
last_attempt_at TEXT,
-- Why the last attempt failed, for the queue and for backoff. Cleared
-- when an attempt succeeds.
last_failure TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (media_file_id, language),
CHECK ((attempts = 0) = (last_attempt_at IS NULL))
) STRICT;
-- §8, §15. The work list is every language that is not satisfied yet, least
-- recently attempted first within it; the ordering across files is
-- newest-import-first and comes from the join to `media_files`.
CREATE INDEX subtitle_attempts_pending
ON subtitle_attempts (state, last_attempt_at)
WHERE state <> 'satisfied';
@@ -0,0 +1,37 @@
-- #198. The runtime-editable half of subtitle configuration (DESIGN.md §15,
-- §10). The other half — provider credentials, translator keys, the
-- remote-command template, the alass and ffmpeg binary paths — is
-- config/env only and never reaches this table.
--
-- One row: the wanted set, enabled providers and translation engine are
-- global, not per root (§15), so there is nothing to key this on.
CREATE TABLE subtitle_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
-- Language tags as `arr_core::Language` spells them, e.g. `pt-PT`, `en`.
wanted_languages TEXT NOT NULL DEFAULT '["pt-PT","en"]'
CHECK (json_valid(wanted_languages)),
-- Provider ids in search order (DESIGN.md §15's ranking runs per
-- provider before it runs per candidate). Absent from the array means
-- disabled.
providers_enabled TEXT NOT NULL DEFAULT '["opensubtitles","podnapisi"]'
CHECK (json_valid(providers_enabled)),
-- The compiled-in backend in use, or NULL when translation is off. Not
-- checked against the running binary's features here — the API does
-- that (#198) — so a rebuild with a different feature set never leaves
-- an unreadable row.
translation_engine TEXT
CHECK (translation_engine IN ('openai', 'deepl', 'google', 'command')),
-- Provider id / engine name -> a daily allowance (§15's token buckets,
-- #197). A missing key means no allowance configured yet, not zero.
provider_daily_budgets TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(provider_daily_budgets)),
translator_daily_budgets TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(translator_daily_budgets)),
-- Only the remote-command backend needs one of these: the HTTP backends
-- carry their own client timeout.
remote_command_timeout_seconds INTEGER NOT NULL DEFAULT 30
CHECK (remote_command_timeout_seconds > 0),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT;
INSERT INTO subtitle_settings (id) VALUES (1);
@@ -0,0 +1,19 @@
-- #197. Token bucket state for §15's provider and translator budgets. The
-- allowance itself is `subtitle_settings.provider_daily_budgets` /
-- `translator_daily_budgets` (#198); this table only tracks what has already
-- been spent today, so a bucket with no allowance configured reads as
-- unlimited rather than zero (0025's own comment on that column).
--
-- One row per (kind, name, day); a new day is a fresh row rather than a
-- reset column, so the refill is "today has no row yet" and needs no cron.
CREATE TABLE subtitle_budget_spend (
kind TEXT NOT NULL CHECK (kind IN ('provider', 'translator')),
name TEXT NOT NULL,
-- UTC calendar day, `YYYY-MM-DD`.
day TEXT NOT NULL,
-- A provider unit is one download; a translator unit is one character of
-- source text sent (DESIGN.md §15: translators bill per character).
spent INTEGER NOT NULL DEFAULT 0 CHECK (spent >= 0),
PRIMARY KEY (kind, name, day)
) STRICT;
@@ -0,0 +1,17 @@
-- #220. Where the OpenAI-compatible translation backend points, and which
-- model it names (DESIGN.md §15). That backend is not "OpenAI" — it is
-- anything speaking that request shape, `llama.cpp` and a local gateway
-- included — so which endpoint and which model are in use is something the
-- operator tries and changes, not a property of the deployment fixed at
-- start-up. Both replace bootstrap keys #216 added as an explicit stopgap.
--
-- Only the API key stays in the environment (§10): it is a secret, and an
-- endpoint that needs no key at all is a valid configuration.
ALTER TABLE subtitle_settings
-- NULL means the backend's own default, `https://api.openai.com/v1/`.
ADD COLUMN openai_base_url TEXT;
ALTER TABLE subtitle_settings
-- NULL means the backend's own default model.
ADD COLUMN openai_model TEXT;
@@ -0,0 +1,41 @@
-- #222. DESIGN.md §15, as amended: a language is satisfied by exactly one
-- sidecar. No filename segment distinguishes forced from plain from SDH, so
-- two sidecar rows for one (media file, language) describe two files
-- competing for one name. The schema says so now rather than leaving it to
-- the API's path check.
--
-- Embedded rows are deliberately untouched. They describe tracks inside the
-- video, not files on disk, and several can legitimately coexist for one
-- language — a plain track and a forced one, say. Their key stays
-- `(media_file_id, language, forced, sdh)`.
-- A database that predates the constraint may already hold a duplicate from
-- a manual grab that beat the path check: a fetched `.pt-PT.srt` beside a
-- translated `.pt-PT.mt.srt`, for instance. Resolve rather than fail. Of the
-- rows for one (media file, language): keep a real subtitle over a machine
-- translation, and of two of the same kind the newest.
--
-- The files stay on disk. Deleting a viewer's subtitle during a migration is
-- worse than leaving an orphan, and the manual delete (#218, #223) cleans one
-- up on request.
DELETE FROM subtitle_files
WHERE path IS NOT NULL
AND id NOT IN (
SELECT id
FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY media_file_id, language
ORDER BY (origin = 'translated') ASC,
created_at DESC,
id DESC
) AS place
FROM subtitle_files
WHERE path IS NOT NULL
)
WHERE place = 1
);
CREATE UNIQUE INDEX subtitle_files_one_sidecar
ON subtitle_files (media_file_id, language)
WHERE path IS NOT NULL;
+7
View File
@@ -7,9 +7,16 @@ use std::path::Path;
pub mod blacklist;
pub mod policy;
pub mod subtitle_budget;
pub mod subtitles;
pub use blacklist::Blacklist;
pub use policy::{MoviePolicy, PolicyColumns, PolicyError, TitlePolicy};
pub use subtitle_budget::BudgetKind;
pub use subtitles::{
NewSubtitleFile, PendingSubtitle, SubtitleAttempt, SubtitleFile, SubtitleOrigin, SubtitleState,
SubtitleSync,
};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::{migrate::MigrateError, SqlitePool};
+222
View File
@@ -0,0 +1,222 @@
//! Token buckets for §15's provider and translator daily budgets (#197).
//!
//! One row per (kind, name, today). The allowance lives in
//! `subtitle_settings` (#198) and is passed in by the caller rather than
//! read here, because the reconcile loop already loads settings once per
//! tick; this module only tracks and gates spend against it.
//!
//! [`try_spend`] is the single gate, and it is the only one: check and
//! increment happen in one statement, so concurrent closes racing the same
//! bucket (§8 runs up to four at once) cannot both slip a spend past the cap
//! between a read and a write. [`spent_today`] is read-only, kept apart for
//! callers that only need to report what a bucket holds.
use sqlx::SqlitePool;
/// Which bucket a spend counts against.
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
#[sqlx(rename_all = "lowercase")]
pub enum BudgetKind {
/// One unit per download (DESIGN.md §15's example: a single-digit daily
/// download cap on `OpenSubtitles`' free tier).
Provider,
/// One unit per character of source text sent, not per call (DESIGN.md
/// §15: translators bill per character).
Translator,
}
/// What has already been spent from `kind`/`name`'s bucket today.
///
/// # Errors
///
/// If the query fails.
pub async fn spent_today(
pool: &SqlitePool,
kind: BudgetKind,
name: &str,
) -> Result<i64, sqlx::Error> {
Ok(sqlx::query_scalar!(
r#"SELECT spent AS "spent!: i64" FROM subtitle_budget_spend
WHERE kind = ? AND name = ? AND day = strftime('%Y-%m-%d', 'now')"#,
kind,
name
)
.fetch_optional(pool)
.await?
.unwrap_or(0))
}
/// Try to spend `amount` from `kind`/`name`'s bucket today, atomically.
///
/// `None` allowance means unlimited (§10): always spends, and nothing is
/// recorded — there is no cap to account spend against. With an allowance,
/// the insert-or-increment and the cap check are one statement, so a spend
/// that would cross `allowance` neither applies nor is recorded.
///
/// # Errors
///
/// If the query fails.
pub async fn try_spend(
pool: &SqlitePool,
kind: BudgetKind,
name: &str,
amount: i64,
allowance: Option<i64>,
) -> Result<bool, sqlx::Error> {
let Some(allowance) = allowance else {
return Ok(true);
};
let spent = sqlx::query_scalar!(
r#"INSERT INTO subtitle_budget_spend (kind, name, day, spent)
SELECT ?1, ?2, strftime('%Y-%m-%d', 'now'), ?3
WHERE ?3 <= ?4
ON CONFLICT (kind, name, day) DO UPDATE
SET spent = subtitle_budget_spend.spent + excluded.spent
WHERE subtitle_budget_spend.spent + excluded.spent <= ?4
RETURNING spent AS "spent!: i64""#,
kind,
name,
amount,
allowance
)
.fetch_optional(pool)
.await?;
Ok(spent.is_some())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::{spent_today, try_spend, BudgetKind};
use crate::Db;
async fn database() -> (Db, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let database = Db::connect(dir.path().join("test.db")).await.unwrap();
database.migrate().await.unwrap();
(database, dir)
}
#[tokio::test]
async fn an_unconfigured_allowance_is_unlimited_and_untracked() {
let (db, _dir) = database().await;
let spent = try_spend(db.pool(), BudgetKind::Provider, "opensubtitles", 1, None)
.await
.unwrap();
assert!(spent);
assert_eq!(
spent_today(db.pool(), BudgetKind::Provider, "opensubtitles")
.await
.unwrap(),
0,
"nothing to cap means nothing to account"
);
}
#[tokio::test]
async fn spend_accumulates_until_the_allowance_is_reached() {
let (db, _dir) = database().await;
let allowance = Some(5);
for _ in 0..5 {
assert!(try_spend(
db.pool(),
BudgetKind::Provider,
"opensubtitles",
1,
allowance
)
.await
.unwrap());
}
assert_eq!(
spent_today(db.pool(), BudgetKind::Provider, "opensubtitles")
.await
.unwrap(),
5
);
let refused = try_spend(
db.pool(),
BudgetKind::Provider,
"opensubtitles",
1,
allowance,
)
.await
.unwrap();
assert!(!refused, "the sixth call is over the cap");
assert_eq!(
spent_today(db.pool(), BudgetKind::Provider, "opensubtitles")
.await
.unwrap(),
5,
"a refused spend is not recorded"
);
}
#[tokio::test]
async fn a_spend_that_would_cross_the_cap_is_refused_whole() {
let (db, _dir) = database().await;
let allowance = Some(100);
// A translator bucket counts characters, not calls, so one spend can
// be far larger than what remains.
assert!(
try_spend(db.pool(), BudgetKind::Translator, "deepl", 90, allowance)
.await
.unwrap()
);
let refused = try_spend(db.pool(), BudgetKind::Translator, "deepl", 20, allowance)
.await
.unwrap();
assert!(!refused, "90 + 20 would cross 100");
assert_eq!(
spent_today(db.pool(), BudgetKind::Translator, "deepl")
.await
.unwrap(),
90,
"the refused spend left the bucket untouched"
);
assert!(
try_spend(db.pool(), BudgetKind::Translator, "deepl", 10, allowance)
.await
.unwrap()
);
}
#[tokio::test]
async fn buckets_are_independent_per_kind_and_name() {
let (db, _dir) = database().await;
let allowance = Some(1);
assert!(try_spend(
db.pool(),
BudgetKind::Provider,
"opensubtitles",
1,
allowance
)
.await
.unwrap());
// Same name, other kind: untouched.
assert_eq!(
spent_today(db.pool(), BudgetKind::Translator, "opensubtitles")
.await
.unwrap(),
0
);
// Same kind, other name: untouched.
assert_eq!(
spent_today(db.pool(), BudgetKind::Provider, "podnapisi")
.await
.unwrap(),
0
);
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,10 +14,12 @@ tokio = { workspace = true }
wiremock = { workspace = true }
[dev-dependencies]
arr-db = { workspace = true }
arr-dl = { workspace = true }
arr-indexer = { workspace = true }
arr-meta = { workspace = true }
chrono = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
[lints]
Binary file not shown.
Binary file not shown.
+73 -7
View File
@@ -12,9 +12,11 @@
// Dev-dependencies belong to the integration tests; the lib's own test build
// links them without using them. Same per-target quirk as arr-meta.
#[cfg(test)]
use {arr_dl as _, arr_indexer as _, arr_meta as _, chrono as _, uuid as _};
use {
arr_db as _, arr_dl as _, arr_indexer as _, arr_meta as _, chrono as _, sqlx as _, uuid as _,
};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
@@ -36,6 +38,24 @@ pub mod fixtures {
pub const PROWLARR_MOVIE_SEARCH: &str = include_str!("../fixtures/prowlarr-movie-search.xml");
pub const TMDB_CONFIGURATION: &str = include_str!("../fixtures/tmdb-configuration.json");
pub const TMDB_MOVIE_DUNE: &str = include_str!("../fixtures/tmdb-movie-dune.json");
/// A zipped SRT with one cue at 00:00:01 — plausibly close to a fetched
/// subtitle's real timing for [`crate::probe_movie_fixture`]'s
/// three-second clip.
pub const PODNAPISI_PLAUSIBLE_ZIP: &[u8] =
include_bytes!("../fixtures/podnapisi-plausible.zip");
/// A zipped SRT timed five minutes in — implausible against the same
/// three-second clip, so `alass` must reject it (DESIGN.md §15).
pub const PODNAPISI_FARFETCHED_ZIP: &[u8] =
include_bytes!("../fixtures/podnapisi-farfetched.zip");
}
/// The clip the subtitle scenarios probe and sync against: three seconds,
/// a real English `subrip` track, real audio — the same fixture
/// `arr-probe`'s own extraction tests use, so a real `ffmpeg` extraction and
/// a real `alass` sync both have something genuine to work with.
#[must_use]
pub fn probe_movie_fixture() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../arr-probe/tests/fixtures/movie-1080p.mkv")
}
/// The enabled indexer in [`fixtures::PROWLARR_INDEXERS`].
@@ -172,6 +192,8 @@ impl FakeTmdb {
pub struct Daemon {
child: Child,
base_url: String,
database_path: PathBuf,
media_root: PathBuf,
// Owns the database and media root for the child's lifetime.
_dir: tempfile::TempDir,
}
@@ -185,35 +207,75 @@ impl Daemon {
/// Panics when the binary cannot be built or spawned, or when the health
/// endpoint does not answer within the boot timeout.
pub async fn spawn(prowlarr_url: &str, tmdb_url: &str, transmission_url: &str) -> Self {
Self::spawn_with_env(prowlarr_url, tmdb_url, transmission_url, &[]).await
}
/// Same as [`Self::spawn`], plus extra environment variables for the
/// child — the subtitle seams (`ARR_PODNAPISI_URL`,
/// `ARR_TRANSLATE_COMMAND_TEMPLATE`, ...) that only a test harness sets.
///
/// # Panics
///
/// Panics when the binary cannot be built or spawned, or when the health
/// endpoint does not answer within the boot timeout.
pub async fn spawn_with_env(
prowlarr_url: &str,
tmdb_url: &str,
transmission_url: &str,
extra_env: &[(&str, &str)],
) -> Self {
let dir = tempfile::tempdir().expect("create daemon tempdir");
let media_root = dir.path().join("media");
std::fs::create_dir(&media_root).expect("create media root");
let port = free_port();
let base_url = format!("http://127.0.0.1:{port}");
let database_path = dir.path().join("arr.db");
let child = Command::new(daemon_binary())
let mut command = Command::new(daemon_binary());
command
.env_remove("ARR_CONFIG_FILE")
.env("ARR_BIND_ADDR", format!("127.0.0.1:{port}"))
.env("ARR_DATABASE_PATH", dir.path().join("arr.db"))
.env("ARR_DATABASE_PATH", &database_path)
.env("ARR_MEDIA_ROOT", &media_root)
.env("ARR_PROWLARR_URL", prowlarr_url)
.env("ARR_PROWLARR_API_KEY", API_KEY)
.env("ARR_TMDB_URL", tmdb_url)
.env("ARR_TMDB_API_KEY", API_KEY)
.env("ARR_TRANSMISSION_URL", transmission_url)
.stdin(Stdio::null())
.spawn()
.expect("spawn arr daemon");
.stdin(Stdio::null());
for (key, value) in extra_env {
command.env(key, value);
}
let child = command.spawn().expect("spawn arr daemon");
let mut daemon = Self {
child,
base_url,
database_path,
media_root,
_dir: dir,
};
daemon.wait_until_healthy().await;
daemon
}
/// The child's SQLite database file. Subtitle scenarios have no HTTP
/// route to adopt an already-imported media file (that pipeline is its
/// own seam, DESIGN.md §12), so tests seed one directly here — the same
/// file the daemon's own reconcile loop reads and writes, WAL mode
/// making the two connections safe to interleave.
#[must_use]
pub fn database_path(&self) -> &Path {
&self.database_path
}
/// The child's media root (`ARR_MEDIA_ROOT`), for tests that plant a
/// video file for the reconcile loop to find.
#[must_use]
pub fn media_root(&self) -> &Path {
&self.media_root
}
/// The daemon's API base URL, such as `http://127.0.0.1:41234`.
#[must_use]
pub fn base_url(&self) -> &str {
@@ -275,6 +337,10 @@ fn daemon_binary() -> &'static PathBuf {
let mut build = Command::new(env!("CARGO"));
build
.args(["build", "-p", "arr-daemon", "--bin", "arr"])
// The remote-command translation backend (DESIGN.md §15, issue
// #193) is behind its own cargo feature; the subtitle e2e
// scenarios need it compiled in to drive a stub script.
.args(["--features", "translate-command"])
.args(["--message-format", "json-render-diagnostics"])
.stderr(Stdio::inherit());
// The test process carries the per-crate vars cargo set for *this*
+1 -1
View File
@@ -8,7 +8,7 @@
// The harness library links these; this test target does not use them all
// directly. Same per-target quirk as arr-meta's tests.
use {tempfile as _, wiremock as _};
use {arr_db as _, sqlx as _, tempfile as _, wiremock as _};
use std::path::PathBuf;
+430
View File
@@ -0,0 +1,430 @@
//! The subtitle path end to end (DESIGN.md §15, issue #205), cross-process
//! like the rest of this crate: the real `arr` binary, real `ffmpeg` and real
//! `alass`. Only the HTTP boundary — Podnapisi — and the remote-command
//! translation backend are stubbed, the same rule §12 already applies to
//! Prowlarr and TMDB: a live tracker or a live translation API never runs in
//! a test.
//!
//! There is no HTTP route that adopts an already-imported file (that
//! pipeline is its own seam — grab, download, import), so every scenario
//! here seeds a `media_files` row directly against the daemon's own SQLite
//! file, the same fixture shape the daemon's in-process subtitle tests use.
// Same per-target quirk as `e2e.rs`: dev-dependencies used only by this
// integration target still link into the crate's own test build.
use {arr_db as _, arr_dl as _, arr_indexer as _, arr_meta as _, chrono as _};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use arr_e2e::{fixtures, probe_movie_fixture, Daemon};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Neither Prowlarr, TMDB nor Transmission matter to a subtitle scenario;
/// every test points them at an address nothing answers on rather than
/// standing up fakes it never calls.
const UNUSED_UPSTREAM: &str = "http://127.0.0.1:1";
/// Insert a movie, an imported grab and a media file directly into the
/// daemon's database, then copy `clip` into the daemon's media root next to
/// it. Returns the media file id and the video's path on disk.
async fn seed_movie_file(
database: &arr_db::Db,
media_root: &Path,
tmdb_id: i64,
release_name: &str,
probed: Option<&serde_json::Value>,
) -> (i64, PathBuf) {
let pool = database.pool();
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'movie' AND audience = 'main'")
.fetch_one(pool)
.await
.expect("migrations seed the main movie root");
let movie_id: i64 = sqlx::query_scalar(
"INSERT INTO movies (tmdb_id, title, root_id) VALUES (?, ?, ?) RETURNING id",
)
.bind(tmdb_id)
.bind(format!("Movie {tmdb_id}"))
.bind(root_id)
.fetch_one(pool)
.await
.expect("insert movie");
let folder = media_root.join(format!("Movie {tmdb_id} (2024) - [1080p]"));
std::fs::create_dir_all(&folder).expect("create title folder");
let video = folder.join(format!("Movie {tmdb_id} (2024) - [1080p].mkv"));
std::fs::copy(probe_movie_fixture(), &video).expect("copy the real probe clip");
let size = i64::try_from(std::fs::metadata(&video).expect("clip metadata").len())
.expect("clip size fits in i64");
let media_file_id: i64 = sqlx::query_scalar(
"INSERT INTO media_files (owner_kind, owner_id, path, size, probed)
VALUES ('movie', ?, ?, ?, ?) RETURNING id",
)
.bind(movie_id)
.bind(video.to_string_lossy().into_owned())
.bind(size)
.bind(probed.map(ToString::to_string))
.fetch_one(pool)
.await
.expect("insert media file");
// A release and an imported grab, so `Target::release_name` resolves to
// `release_name` — Podnapisi searches nothing without one.
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (1, ?, ?, 1000000, 'http://example.invalid/download', '{}', 'eligible')
RETURNING id",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(release_name)
.fetch_one(pool)
.await
.expect("insert release");
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, imported_at)
VALUES (?, 'movie', ?, ?, 'imported', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
)
.bind(release_id)
.bind(movie_id)
.bind(uuid::Uuid::new_v4().to_string())
.execute(pool)
.await
.expect("insert grab");
(media_file_id, video)
}
/// `PUT /api/settings/subtitles`, the operator surface the reconcile loop
/// reads every tick (DESIGN.md §15, #198).
async fn configure_subtitles(
base: &str,
wanted_languages: &[&str],
providers_enabled: &[&str],
translation_engine: Option<&str>,
) {
let response = reqwest::Client::new()
.put(format!("{base}/api/settings/subtitles"))
.json(&serde_json::json!({
"wanted_languages": wanted_languages,
"providers_enabled": providers_enabled,
"translation_engine": translation_engine,
"provider_daily_budgets": {},
"translator_daily_budgets": {},
"remote_command_timeout_seconds": 30,
}))
.send()
.await
.expect("put subtitle settings");
assert_eq!(
response.status(),
reqwest::StatusCode::OK,
"subtitle settings accepted: {}",
response.text().await.unwrap_or_default()
);
}
/// Poll `/api/media-files/{id}/subtitles` until a row of the given `origin`
/// shows up — the reconcile lane closes a gap from a detached task, so its
/// effects land some time after the tick that started it (§8, §15).
async fn wait_for_subtitle(base: &str, media_file_id: i64, origin: &str) -> serde_json::Value {
for _ in 0..200 {
let subtitles: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/media-files/{media_file_id}/subtitles"))
.await
.expect("list subtitles")
.json()
.await
.expect("subtitles json");
if let Some(found) = subtitles
.iter()
.find(|subtitle| subtitle["origin"] == origin)
{
return found.clone();
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
panic!("no {origin} subtitle appeared for media file {media_file_id} in time");
}
/// A remote-command translation backend stub (DESIGN.md §15, issue #193):
/// discards whatever batch it is fed and always answers with the same
/// translated cue. Every scenario here feeds it exactly one embedded English
/// cue ("hello", numbered 1, from the committed probe fixture), so a fixed
/// reply exercises the real IPC without needing a JSON parser in `sh`.
fn write_stub_translator(dir: &Path) -> PathBuf {
let script = dir.join("stub-translate.sh");
std::fs::write(
&script,
"#!/bin/sh\ncat >/dev/null\nprintf '[{\"number\":1,\"text\":\"Ola\"}]'\n",
)
.expect("write stub translator");
let mut permissions = std::fs::metadata(&script)
.expect("stub translator metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&script, permissions).expect("make stub translator executable");
script
}
async fn connect(daemon: &Daemon) -> arr_db::Db {
arr_db::Db::connect(daemon.database_path())
.await
.expect("connect to the daemon's own database")
}
/// Where DESIGN.md §15 puts a sidecar for `video`, e.g. `<stem>.pt-PT.srt`.
fn sidecar_path(video: &Path, suffix: &str) -> PathBuf {
let stem = video
.file_stem()
.and_then(|stem| stem.to_str())
.expect("the seeded video path has a UTF-8 stem");
video.with_file_name(format!("{stem}.{suffix}"))
}
/// A media file with no subtitles gets one fetched from a provider, synced
/// by real `alass`, and written under the §15 sidecar name — and once
/// satisfied, the loop never asks the provider again.
#[tokio::test]
async fn a_missing_subtitle_is_fetched_synced_and_named() {
let podnapisi = MockServer::start().await;
let pid = "movie-one";
Mock::given(method("GET"))
.and(path("/subtitles/search/advanced"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
serde_json::json!({
"data": [{
"id": pid,
"language": "pt-PT",
"flags": [],
"releases": ["Movie.One.2024.1080p.WEB-DL-GROUP"],
"custom_releases": []
}],
"page": 1,
"all_pages": 1
})
.to_string(),
),
)
.mount(&podnapisi)
.await;
Mock::given(method("GET"))
.and(path(format!("/subtitles/{pid}/download")))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(fixtures::PODNAPISI_PLAUSIBLE_ZIP, "application/zip"),
)
.mount(&podnapisi)
.await;
let podnapisi_url = format!("{}/subtitles", podnapisi.uri());
let daemon = Daemon::spawn_with_env(
UNUSED_UPSTREAM,
UNUSED_UPSTREAM,
UNUSED_UPSTREAM,
&[("ARR_PODNAPISI_URL", &podnapisi_url)],
)
.await;
let database = connect(&daemon).await;
let (media_file_id, video) = seed_movie_file(
&database,
daemon.media_root(),
900_001,
"Movie.One.2024.1080p.WEB-DL-GROUP",
Some(&serde_json::json!({ "sub_tracks": [] })),
)
.await;
configure_subtitles(daemon.base_url(), &["pt-PT"], &["podnapisi"], None).await;
let subtitle = wait_for_subtitle(daemon.base_url(), media_file_id, "provider").await;
assert_eq!(subtitle["language"], "pt-PT");
assert_eq!(subtitle["provider"], "podnapisi");
assert_eq!(
subtitle["sync"], "synced",
"real alass produced a plausible shift"
);
let expected_path = sidecar_path(&video, "pt-PT.srt");
assert_eq!(
subtitle["path"].as_str(),
Some(expected_path.to_string_lossy()).as_deref()
);
assert!(
expected_path.exists(),
"sidecar written at the §15 name: {}",
expected_path.display()
);
// §15: once satisfied, the loop never searches this language again.
// Wait past one more 30-second reconcile tick and confirm nothing moved.
tokio::time::sleep(Duration::from_secs(35)).await;
let requests = podnapisi
.received_requests()
.await
.expect("request recording is on by default");
assert_eq!(
requests.len(),
3,
"a pt-PT want searches both pt-PT and pt-BR (§15's substitution), plus one \
download, and never again once satisfied: {requests:?}"
);
}
/// A file whose only English text lives in an embedded track gets that track
/// extracted by real `ffmpeg` and translated into a `.mt` sidecar — the
/// improvement over Bazarr §15 calls out explicitly.
#[tokio::test]
async fn an_embedded_track_is_extracted_and_translated() {
let scripts = tempfile::tempdir().expect("tempdir for the stub translator");
let template = write_stub_translator(scripts.path());
let daemon = Daemon::spawn_with_env(
UNUSED_UPSTREAM,
UNUSED_UPSTREAM,
UNUSED_UPSTREAM,
&[(
"ARR_TRANSLATE_COMMAND_TEMPLATE",
template.to_str().expect("utf8 path"),
)],
)
.await;
let database = connect(&daemon).await;
let (media_file_id, video) = seed_movie_file(
&database,
daemon.media_root(),
900_002,
"Movie.Two.2024.1080p.WEB-DL-GROUP",
Some(&serde_json::json!({ "sub_tracks": [
{ "language": "en", "codec": "subrip", "forced": false, "sdh": false }
] })),
)
.await;
// No provider enabled: §15 translates immediately once nothing else has
// the language, no waiting window.
configure_subtitles(daemon.base_url(), &["pt-PT"], &[], Some("command")).await;
let translated = wait_for_subtitle(daemon.base_url(), media_file_id, "translated").await;
assert_eq!(translated["language"], "pt-PT");
assert_eq!(translated["engine"], "command");
let subtitles: Vec<serde_json::Value> = reqwest::get(format!(
"{}/api/media-files/{media_file_id}/subtitles",
daemon.base_url()
))
.await
.expect("list subtitles")
.json()
.await
.expect("subtitles json");
let extracted = subtitles
.iter()
.find(|subtitle| subtitle["origin"] == "extracted")
.expect("the embedded English track was extracted as the translation source");
assert_eq!(extracted["language"], "en");
let sidecar = sidecar_path(&video, "pt-PT.mt.srt");
let content = tokio::fs::read_to_string(&sidecar)
.await
.expect("read the translated sidecar");
assert!(
content.contains("Ola"),
"the stub's translation reached the sidecar: {content}"
);
}
/// A subtitle whose sync is implausible — real `alass` correlating a
/// five-minutes-off cue against a three-second clip — keeps the unsynced
/// original and is flagged, rather than silently writing a bad shift.
#[tokio::test]
async fn an_implausible_sync_keeps_the_original_and_is_flagged() {
let podnapisi = MockServer::start().await;
let pid = "movie-three";
Mock::given(method("GET"))
.and(path("/subtitles/search/advanced"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
serde_json::json!({
"data": [{
"id": pid,
"language": "pt-PT",
"flags": [],
"releases": ["Movie.Three.2024.1080p.WEB-DL-GROUP"],
"custom_releases": []
}],
"page": 1,
"all_pages": 1
})
.to_string(),
),
)
.mount(&podnapisi)
.await;
Mock::given(method("GET"))
.and(path(format!("/subtitles/{pid}/download")))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(fixtures::PODNAPISI_FARFETCHED_ZIP, "application/zip"),
)
.mount(&podnapisi)
.await;
let podnapisi_url = format!("{}/subtitles", podnapisi.uri());
let daemon = Daemon::spawn_with_env(
UNUSED_UPSTREAM,
UNUSED_UPSTREAM,
UNUSED_UPSTREAM,
&[("ARR_PODNAPISI_URL", &podnapisi_url)],
)
.await;
let database = connect(&daemon).await;
let (media_file_id, video) = seed_movie_file(
&database,
daemon.media_root(),
900_003,
"Movie.Three.2024.1080p.WEB-DL-GROUP",
Some(&serde_json::json!({ "sub_tracks": [] })),
)
.await;
configure_subtitles(daemon.base_url(), &["pt-PT"], &["podnapisi"], None).await;
let subtitle = wait_for_subtitle(daemon.base_url(), media_file_id, "provider").await;
assert_eq!(subtitle["sync"], "rejected");
let sidecar = sidecar_path(&video, "pt-PT.srt");
let content = tokio::fs::read_to_string(&sidecar)
.await
.expect("read the unsynced sidecar");
assert!(
content.contains("completely unrelated dialogue") && content.contains("00:05:00"),
"the unsynced original is kept verbatim, timings untouched: {content}"
);
let queue: serde_json::Value =
reqwest::get(format!("{}/api/queues/subtitles", daemon.base_url()))
.await
.expect("get subtitle queue")
.json()
.await
.expect("queue json");
let movie_gaps = queue["movies"]
.as_array()
.expect("movies array")
.iter()
.find(|movie| movie["media_file_id"] == media_file_id)
.expect("the rejected sync surfaces in the missing-subtitles queue (#202)");
assert!(
movie_gaps["gaps"]
.as_array()
.expect("gaps array")
.iter()
.any(|gap| gap["reason"] == "sync_rejected"),
"{movie_gaps}"
);
}
+1
View File
@@ -15,6 +15,7 @@ tokio.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
[lints]
+15
View File
@@ -94,6 +94,21 @@ pub enum Error {
/// among them.
#[error("no video file among the candidates")]
NoCandidates,
/// `ffmpeg` failed extracting a subtitle track — a missing or bitmap
/// stream included, which is how §15's "never extract an image track"
/// surfaces when it is asked for anyway.
#[error("ffmpeg failed extracting subtitle stream {stream} from {path}: {stderr}")]
Extract {
/// The video that was read.
path: PathBuf,
/// The subtitle stream that was mapped.
stream: usize,
/// Exit status, absent when the process was killed by a signal.
status: Option<i32>,
/// `ffmpeg`'s own diagnostics, truncated to something loggable.
stderr: String,
},
}
impl Error {
+169
View File
@@ -0,0 +1,169 @@
//! Extracting an embedded text subtitle track to a sidecar SRT (§15).
//!
//! One `ffmpeg` invocation per track, spawned and left to die like `ffprobe`:
//! no long-lived process, nothing to clean up at rest. The output is forced
//! to SRT whatever the track's own format — `ass` and `mov_text` included —
//! because §15's sidecars and every translation backend downstream speak SRT.
use std::{
ffi::OsString,
path::{Path, PathBuf},
process::Stdio,
time::Duration,
};
use tokio::process::Command;
use crate::error::{Error, Result};
/// The binary invoked when nothing else is configured.
pub const DEFAULT_BINARY: &str = "ffmpeg";
/// How long one extraction may take. Generous: a full-length PGS-to-SRT
/// conversion of a 60 GB remux over a network mount is slow.
pub const DEFAULT_EXTRACTION_TIMEOUT: Duration = Duration::from_secs(300);
/// How much of `ffmpeg`'s stderr is kept in an error.
const STDERR_LIMIT: usize = 512;
/// Extracts embedded subtitle tracks.
#[derive(Clone, Debug)]
pub struct Extractor {
binary: OsString,
timeout: Duration,
}
impl Default for Extractor {
fn default() -> Self {
Self {
binary: DEFAULT_BINARY.into(),
timeout: DEFAULT_EXTRACTION_TIMEOUT,
}
}
}
impl Extractor {
/// An extractor that runs `ffmpeg` from `PATH`.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Run a specific binary instead of whatever `PATH` resolves to.
#[must_use]
pub fn with_binary(mut self, binary: impl Into<OsString>) -> Self {
self.binary = binary.into();
self
}
/// Change how long one extraction may take.
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Write one embedded subtitle track out as an SRT sidecar.
///
/// `subtitle_stream` is the track's position among the file's subtitle
/// streams — the index into `ProbedMedia::subtitle_tracks`, not the
/// container's absolute stream id. Only text codecs (`is_text`) extract;
/// asking for a bitmap track fails in `ffmpeg`, so callers decide from
/// [`SubtitleCodec::is_text`] first.
///
/// The write lands whole or not at all: `ffmpeg` writes beside the target
/// under a dot-name and the result is renamed into place, so Jellyfin
/// never sees a half-written sidecar.
///
/// # Errors
///
/// [`Error::Spawn`] when `ffmpeg` is not installed,
/// [`Error::Extract`] when it exits non-zero — which includes mapping a
/// stream that does not exist or carries bitmaps — and
/// [`Error::Timeout`] / [`Error::Io`] on the usual failures.
pub async fn extract_srt(
&self,
video: &Path,
subtitle_stream: usize,
destination: &Path,
) -> Result<()> {
if let Some(parent) = destination.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|source| Error::Io {
path: parent.to_path_buf(),
source,
})?;
}
let temp = partial_name(destination);
let result = self.run(video, subtitle_stream, &temp).await;
if result.is_err() {
let _ = tokio::fs::remove_file(&temp).await;
return result;
}
tokio::fs::rename(&temp, destination)
.await
.map_err(|source| Error::Io {
path: destination.to_path_buf(),
source,
})
}
/// Spawn `ffmpeg` and wait for it to die.
async fn run(&self, video: &Path, subtitle_stream: usize, temp: &Path) -> Result<()> {
let map = format!("0:s:{subtitle_stream}");
let mut command = Command::new(&self.binary);
command
.args(["-nostdin", "-v", "error", "-i"])
.arg(video)
.args(["-map", &map, "-c:s", "srt", "-f", "srt"])
.arg(temp)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
let child = command.spawn().map_err(|source| Error::Spawn {
binary: self.binary.to_string_lossy().into_owned(),
source,
})?;
let output = match tokio::time::timeout(self.timeout, child.wait_with_output()).await {
Ok(output) => output.map_err(|source| Error::Io {
path: video.to_path_buf(),
source,
})?,
Err(_) => {
return Err(Error::Timeout {
path: video.to_path_buf(),
})
}
};
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr)
.trim()
.chars()
.take(STDERR_LIMIT)
.collect();
Err(Error::Extract {
path: video.to_path_buf(),
stream: subtitle_stream,
status: output.status.code(),
stderr,
})
}
}
/// The dot-name `ffmpeg` writes before the rename into place.
fn partial_name(destination: &Path) -> PathBuf {
let mut name = destination.file_name().unwrap_or_default().to_os_string();
name.push(".partial");
let mut partial = destination.to_path_buf();
partial.set_file_name(name);
partial
}
+7 -3
View File
@@ -53,13 +53,17 @@ impl Stream {
}
}
/// Stream flags. Only `attached_pic` is read: an MP3 or FLAC with embedded
/// artwork reports it as a video stream, and taking that for video would make
/// an audio file look importable.
/// Stream flags. `attached_pic` stops an MP3 or FLAC's embedded artwork
/// passing for video; `forced` and `hearing_impaired` are §15's track
/// dispositions.
#[derive(Debug, Default, Deserialize)]
pub(crate) struct Disposition {
#[serde(default)]
pub attached_pic: u8,
#[serde(default)]
pub forced: u8,
#[serde(rename = "hearing_impaired", default)]
pub hearing_impaired: u8,
}
/// Stream metadata. Matroska writes `HANDLER_NAME`, MP4 writes `handler_name`,
+8
View File
@@ -23,11 +23,19 @@ use std::{
use tokio::process::Command;
pub mod error;
mod extract;
mod ffprobe;
mod language;
mod model;
// `unused_crate_dependencies` is a per-target lint and the library's own test
// target links the dev-dependencies without using them. The real uses are in
// `tests/`.
#[cfg(test)]
use tempfile as _;
pub use error::{Error, Result};
pub use extract::{Extractor, DEFAULT_EXTRACTION_TIMEOUT};
pub use model::{FeatureSelection, ProbedFile, RuntimeMatch};
/// The binary invoked when nothing else is configured.
+44 -1
View File
@@ -2,7 +2,10 @@
use std::{path::PathBuf, time::Duration};
use arr_core::{AudioTrack, DolbyVisionProfile, HdrFormat, ProbedMedia, Resolution, SubtitleTrack};
use arr_core::{
AudioTrack, DolbyVisionProfile, HdrFormat, ProbedMedia, Resolution, SubtitleCodec,
SubtitleTrack,
};
use crate::{
error::{Error, Result},
@@ -148,6 +151,23 @@ fn subtitle_track(stream: &Stream) -> SubtitleTrack {
stream.tags.get("title"),
stream.tags.get("handler_name"),
),
codec: subtitle_codec(stream.codec_name.as_deref()),
forced: stream.disposition.forced != 0,
sdh: stream.disposition.hearing_impaired != 0,
}
}
/// `ffprobe`'s codec names for the formats §15 splits on. Bitmap tracks
/// (PGS, `VobSub`) satisfy viewing and never extract; anything unrecognised is
/// [`SubtitleCodec::Other`], which extracts nothing either.
fn subtitle_codec(codec_name: Option<&str>) -> SubtitleCodec {
match codec_name {
Some("subrip" | "srt") => SubtitleCodec::SubRip,
Some("ass" | "ssa") => SubtitleCodec::Ass,
Some("mov_text") => SubtitleCodec::MovText,
Some("hdmv_pgs_subtitle") => SubtitleCodec::Pgs,
Some("dvd_subtitle") => SubtitleCodec::VobSub,
_ => SubtitleCodec::Other,
}
}
@@ -248,6 +268,8 @@ mod tests {
use std::time::Duration;
use super::timecode;
use crate::model::subtitle_codec;
use arr_core::SubtitleCodec;
#[test]
fn matroska_timecodes_parse() {
@@ -257,4 +279,25 @@ mod tests {
);
assert_eq!(timecode("nonsense"), None);
}
/// The §15 split, spelled exactly as ffprobe spells the codec names.
#[test]
fn text_and_bitmap_codecs_are_told_apart() {
assert_eq!(subtitle_codec(Some("subrip")), SubtitleCodec::SubRip);
assert_eq!(subtitle_codec(Some("ass")), SubtitleCodec::Ass);
assert_eq!(subtitle_codec(Some("mov_text")), SubtitleCodec::MovText);
assert_eq!(
subtitle_codec(Some("hdmv_pgs_subtitle")),
SubtitleCodec::Pgs
);
assert_eq!(subtitle_codec(Some("dvd_subtitle")), SubtitleCodec::VobSub);
assert_eq!(subtitle_codec(Some("dvb_subtitle")), SubtitleCodec::Other);
assert_eq!(subtitle_codec(None), SubtitleCodec::Other);
// Only the three text formats may become a translation source.
assert!(SubtitleCodec::SubRip.is_text());
assert!(!SubtitleCodec::Pgs.is_text());
assert!(!SubtitleCodec::VobSub.is_text());
assert!(!SubtitleCodec::Other.is_text());
}
}
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More