Compare commits
194 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab4f95b166 | |||
| afe167ca7b | |||
| a9848fddce | |||
| 4d347cd285 | |||
| dbce23f570 | |||
| 706a0ec978 | |||
| a2240debfd | |||
| 4cec2b6ed5 | |||
| 3f4c1ade3b | |||
| 9d2b818c52 | |||
| 5610e5976d | |||
| 58ad90951c | |||
| d3613c6081 | |||
| 61d84ff51b | |||
| 640bda3128 | |||
| 120416aabe | |||
| 46554cfc75 | |||
| 42c1cd4dce | |||
| 77bf409c5d | |||
| eb21897c3d | |||
| a5b2131d4c | |||
| 5ba49edfd4 | |||
| ad36a0aa83 | |||
| 49d23e071a | |||
| 54438bb6ad | |||
| adb61dda3b | |||
| dfa4656509 | |||
| bd5131f424 | |||
| 656ca6c259 | |||
| 425d154d54 | |||
| 0eec97a7af | |||
| 7798cbc3e6 | |||
| f3266a712b | |||
| eeada0ef65 | |||
| 06f8a6f0ea | |||
| dee7340a01 | |||
| c9e44b9c03 | |||
| e446da5858 | |||
| 4f2e443045 | |||
| a97795a997 | |||
| 17ad82f541 | |||
| 73d56fe821 | |||
| 1bd482db2a | |||
| e157917797 | |||
| eeaad52257 | |||
| 4ba921b45a | |||
| a2b76bd634 | |||
| 20a3b2847b | |||
| 86af84be6d | |||
| a3186b2305 | |||
| 1afd8c1998 | |||
| 1e778afd18 | |||
| c9369d1c4f | |||
| 34e5663920 | |||
| 8947febbff | |||
| bd5b00b14a | |||
| 90efeaa442 | |||
| 5d80177622 | |||
| 9e445d0398 | |||
| 1e30c49a72 | |||
| 55373d228c | |||
| c78c73ec6c | |||
| d7e9f710e0 | |||
| 097e081f4d | |||
| 1126a52bbb | |||
| 2c5cd85499 | |||
| a45d8ff86a | |||
| abad9b9cfd | |||
| e467bd47fb | |||
| 5aa914f81c | |||
| e8bc766d4b | |||
| c6906bffae | |||
| d358c58844 | |||
| 3122d5b0a0 | |||
| 29c31beceb | |||
| ab001b512f | |||
| 84dc5ba27b | |||
| 60257f3177 | |||
| b73a58d1a4 | |||
| 80101e789b | |||
| 73d3f04398 | |||
| 5f1fffdd97 | |||
| 687c0c106f | |||
| 5bc9022046 | |||
| 8043ef614a | |||
| 228db06d83 | |||
| 783a6ba760 | |||
| a4422e26e5 | |||
| a836967e32 | |||
| 8c6d4ca577 | |||
| be7fa87e74 | |||
| 8c5613b247 | |||
| 2357e72113 | |||
| 7b4def4516 | |||
| 3cc9ab4aff | |||
| baea1dddbb | |||
| fe00220c5b | |||
| bc43084f74 | |||
| 71c161eff7 | |||
| 6ee79519ca | |||
| e47d2c920b | |||
| 5fc6854705 | |||
| da1a932459 | |||
| 6d665e7c2f | |||
| 9f0a7de37a | |||
| e0e7ddf6de | |||
| 78aabe99e5 | |||
| a62a8de8fc | |||
| b3cca4f692 | |||
| e338a76c66 | |||
| 47c7ef6682 | |||
| a780b49ab3 | |||
| fb40b35156 | |||
| 4003c3a5a0 | |||
| 9ca3c3ce43 | |||
| 100a869c89 | |||
| d6fdd3850f | |||
| 22faad66ca | |||
| e850ddff81 | |||
| 218c83589e | |||
| 3a7d80c295 | |||
| 4d84625332 | |||
| f49507b7d1 | |||
| 9a21649afa | |||
| 2fa74137f9 | |||
| 7b4cff1874 | |||
| 4110555183 | |||
| 987bfa2864 | |||
| 5ee5c56b88 | |||
| e684813d0c | |||
| e5092034d3 | |||
| c2ca25895f | |||
| e49bc2736d | |||
| f1a58c0810 | |||
| 999cb8b08c | |||
| 0d91beff14 | |||
| ae604e53d5 | |||
| 07921c9e1e | |||
| 8b92a68edd | |||
| 4af6e0a083 | |||
| 8baef11c0e | |||
| 910d28f639 | |||
| f06e0e94bc | |||
| 635a651bee | |||
| c6cfdff2c9 | |||
| f773489fd7 | |||
| db9ae271cb | |||
| 0f0d56ef0e | |||
| dd1e5d02a3 | |||
| f84b04536a | |||
| 3e5fdc6908 | |||
| 0c07fbaed5 | |||
| ef0fac4933 | |||
| 82620e940c | |||
| 8efb9cbf23 | |||
| 26bd25e2f6 | |||
| 63472d99cf | |||
| 182285c356 | |||
| faa7a0c056 | |||
| 449750e426 | |||
| 0c9b9fb796 | |||
| 8450b06e76 | |||
| b9f4ee98d5 | |||
| 529d7a4ee4 | |||
| ee3e2ba4b4 | |||
| f617403912 | |||
| da0c30c606 | |||
| 6ec4703edb | |||
| 8311307453 | |||
| d8acf30fb7 | |||
| 81dd606414 | |||
| 5d7d881ab9 | |||
| 94d9fc8b89 | |||
| 2cee177940 | |||
| 340c113007 | |||
| 0bdf0103bd | |||
| e852f42a9c | |||
| 17d40f3e71 | |||
| 802d2a1208 | |||
| ed1a74e03f | |||
| 1900a82d55 | |||
| 39ddf65afa | |||
| e55ce05880 | |||
| cd304d552c | |||
| cdd6133bce | |||
| 44c7013e49 | |||
| b0e3905486 | |||
| 3d2b816f9f | |||
| c3bd009442 | |||
| 151135b545 | |||
| 89154614e1 | |||
| 8b83ac9511 | |||
| 695cb69f2f | |||
| 1401e9e00f |
@@ -119,3 +119,69 @@ jobs:
|
||||
- name: design tokens
|
||||
if: steps.probe.outputs.present == 'true'
|
||||
run: pnpm -C web run check-tokens
|
||||
|
||||
# Build the production image here rather than on the Dokploy host: the gate
|
||||
# above has to pass first, the layer cache lives in the registry instead of
|
||||
# being thrown away every deploy, and Dokploy's job shrinks to a pull.
|
||||
#
|
||||
# This job is also the deploy trigger. The Gitea push webhook fires the
|
||||
# instant the commit lands, long before the image exists, so autoDeploy on
|
||||
# the Dokploy application must stay off — the deploy is the last step here.
|
||||
image:
|
||||
needs: [rust, web]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
IMAGE: git.naps.pt/yolo/arr
|
||||
DOKPLOY_APP_ID: uEaWG5JDpVIrJloG5rMAP
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Docker CLI and buildx
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ca-certificates curl gnupg
|
||||
. /etc/os-release
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL "https://download.docker.com/linux/$ID/gpg" \
|
||||
-o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc]" \
|
||||
"https://download.docker.com/linux/$ID $VERSION_CODENAME stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-plugin
|
||||
docker version
|
||||
|
||||
- name: Registry login
|
||||
run: |
|
||||
printf '%s' "${{ secrets.REGISTRY_TOKEN }}" \
|
||||
| docker login git.naps.pt -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Buildx builder
|
||||
# The default docker driver cannot export a cache. docker-container can.
|
||||
run: docker buildx create --name arr --driver docker-container --use
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
docker buildx build \
|
||||
--tag "$IMAGE:sha-$GITHUB_SHA" \
|
||||
--tag "$IMAGE:main" \
|
||||
--cache-from "type=registry,ref=$IMAGE:buildcache" \
|
||||
--cache-to "type=registry,ref=$IMAGE:buildcache,mode=max" \
|
||||
--push .
|
||||
|
||||
- name: Deploy
|
||||
# Pinned to the commit sha, not :main — swarm does not reliably re-pull
|
||||
# an unchanged tag name, and an older sha is the rollback.
|
||||
run: |
|
||||
# The package is public (org yolo is public), so Dokploy pulls with
|
||||
# no credentials and the registry fields stay null.
|
||||
curl -fsS -X POST https://dokploy.n62.casa/api/application.update \
|
||||
-H "x-api-key: ${{ secrets.DOKPLOY_API_KEY }}" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"applicationId\":\"$DOKPLOY_APP_ID\",\"dockerImage\":\"$IMAGE:sha-$GITHUB_SHA\"}"
|
||||
curl -fsS -X POST https://dokploy.n62.casa/api/application.deploy \
|
||||
-H "x-api-key: ${{ secrets.DOKPLOY_API_KEY }}" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"applicationId\":\"$DOKPLOY_APP_ID\",\"title\":\"ci ${GITHUB_SHA:0:8}\"}"
|
||||
|
||||
+40
-12
@@ -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'
|
||||
@@ -25,15 +26,6 @@ jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
transmission:
|
||||
image: linuxserver/transmission:latest
|
||||
env:
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
ports:
|
||||
- 9091:9091
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -70,12 +62,48 @@ 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: qBittorrent
|
||||
# Not a `services:` container: qBittorrent has no environment
|
||||
# variable for the WebUI password and prints a random one per boot,
|
||||
# so the only scriptable login is a config seeded before first start,
|
||||
# which a service container cannot be given.
|
||||
run: |
|
||||
mkdir -p /tmp/qbt/config/qBittorrent
|
||||
cat > /tmp/qbt/config/qBittorrent/qBittorrent.conf <<'CONF'
|
||||
[Preferences]
|
||||
WebUI\Port=8080
|
||||
WebUI\AuthSubnetWhitelistEnabled=true
|
||||
WebUI\AuthSubnetWhitelist=0.0.0.0/0
|
||||
WebUI\CSRFProtection=false
|
||||
WebUI\HostHeaderValidation=false
|
||||
Downloads\SavePath=/downloads
|
||||
CONF
|
||||
docker run -d --name arr-e2e-qbittorrent --network host \
|
||||
-e PUID=1000 -e PGID=1000 -e WEBUI_PORT=8080 \
|
||||
-v /tmp/qbt/config:/config \
|
||||
linuxserver/qbittorrent:latest
|
||||
for _ in $(seq 1 60); do
|
||||
curl -sf http://127.0.0.1:8080/api/v2/app/version && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- 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:
|
||||
TRANSMISSION_RPC_URL: http://transmission:9091/transmission/rpc
|
||||
QBITTORRENT_URL: http://127.0.0.1:8080
|
||||
run: cargo nextest run -p arr-e2e
|
||||
|
||||
+45
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"generatedAt": "2026-08-22T00:00:00.000Z",
|
||||
"generatedAt": "2026-08-26T00:00:00.000Z",
|
||||
"title": "Design System: arr — Master Control",
|
||||
"extensions": {
|
||||
"colorMeta": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"oklch(0.8 0.015 250)"
|
||||
]
|
||||
},
|
||||
"status-channel": {
|
||||
"status-airing": {
|
||||
"role": "tertiary",
|
||||
"displayName": "Channel Violet",
|
||||
"canonical": "oklch(0.78 0.14 305)",
|
||||
@@ -106,6 +106,20 @@
|
||||
"oklch(0.78 0.14 305)",
|
||||
"oklch(0.86 0.09 305)"
|
||||
]
|
||||
},
|
||||
"score-heat": {
|
||||
"role": "tertiary",
|
||||
"displayName": "Score Heat",
|
||||
"canonical": "oklch(0.8 0.17 150)",
|
||||
"tonalRamp": [
|
||||
"oklch(0.68 0.19 25)",
|
||||
"oklch(0.7 0.185 46)",
|
||||
"oklch(0.72 0.175 67)",
|
||||
"oklch(0.75 0.165 88)",
|
||||
"oklch(0.77 0.16 108)",
|
||||
"oklch(0.79 0.165 129)",
|
||||
"oklch(0.8 0.17 150)"
|
||||
]
|
||||
}
|
||||
},
|
||||
"typographyMeta": {
|
||||
@@ -202,11 +216,27 @@
|
||||
{
|
||||
"name": "Status chip",
|
||||
"kind": "custom",
|
||||
"refersTo": "status-channel",
|
||||
"refersTo": "status-airing",
|
||||
"description": "Library status (§4.2): airing / incomplete / waiting / complete / ended. Informational, not severity — colour marks activity in one channel-violet family (airing brightest, incomplete, waiting dimmest), satisfaction is neutral (complete bright ink, ended faint). The mono word is always present.",
|
||||
"html": "<span class=\"chip readout\" data-status=\"airing\">airing</span>",
|
||||
"css": ".chip[data-status=\"airing\"] { color: var(--status-airing); border-color: oklch(from var(--status-airing) l c h / 45%); } .chip[data-status=\"incomplete\"] { color: var(--signal-warn); border-color: oklch(from var(--signal-warn) l c h / 55%); } .chip[data-status=\"complete\"], .chip[data-status=\"ended\"] { color: var(--signal-ok); border-color: oklch(from var(--signal-ok) l c h / 45%); } .chip[data-counts=\"complete\"] { color: var(--signal-ok); } .chip[data-counts=\"partial\"] { color: var(--signal-warn); }"
|
||||
},
|
||||
{
|
||||
"name": "Media state chip",
|
||||
"kind": "custom",
|
||||
"refersTo": "signal-ok",
|
||||
"description": "§4.2's per-title state chip. Every state keeps the word, with one drawn exemption: `available` renders as a check glyph instead of the word `available` — a shape, not only a hue, so it survives a greyscale read the rule otherwise protects. The word moves to the chip's aria-label (exposed via role=\"img\", since a bare span's aria-label is not reliably read).",
|
||||
"html": "<span class=\"chip readout\" role=\"img\" aria-label=\"available\" data-movie-state=\"available\"><svg viewBox=\"0 0 16 16\" width=\"12\" height=\"12\"><path d=\"M3.2 8.4l3 3 6.6-6.8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg></span>",
|
||||
"css": ".chip[data-movie-state=\"available\"] { color: var(--signal-ok); border-color: oklch(from var(--signal-ok) l c h / 45%); } .chip[data-movie-state=\"missing\"][data-wanted=\"true\"] { color: var(--signal-warn); border-color: oklch(from var(--signal-warn) l c h / 55%); } .chip[data-movie-state=\"downloading\"] { color: var(--status-airing); border-color: oklch(from var(--status-airing) l c h / 45%); }"
|
||||
},
|
||||
{
|
||||
"name": "Score chip",
|
||||
"kind": "custom",
|
||||
"refersTo": "score-heat",
|
||||
"description": "Issue 111: the release deck's score column (§9.3) reads on a list-relative heat scale — seven stops from the lowest-scoring candidate on screen (red) to the highest (green) — rather than a flat eligible-green fill. Every other column chip stays the plain neutral Chip.",
|
||||
"html": "<span class=\"chip readout cw cw-score\" data-score=\"6\" aria-label=\"score 128\">128</span>",
|
||||
"css": ".cw-score[data-score=\"0\"] { color: var(--score-0); border-color: oklch(from var(--score-0) l c h / 45%); } .cw-score[data-score=\"6\"] { color: var(--score-6); border-color: oklch(from var(--score-6) l c h / 45%); }"
|
||||
},
|
||||
{
|
||||
"name": "Module (channel strip)",
|
||||
"kind": "card",
|
||||
@@ -290,6 +320,16 @@
|
||||
"name": "The Dead-Air Rule",
|
||||
"body": "When nothing is flowing, nothing animates. Pulse means probing; data-state=\"off\" is dark and still. Motion that misreports activity is a truth bug.",
|
||||
"section": "motion"
|
||||
},
|
||||
{
|
||||
"name": "The Check-Glyph Exemption",
|
||||
"body": "The one state chip drawn instead of worded: `available` is a check glyph, because a drawn shape survives the greyscale read the Word-Beside-The-Lamp doctrine otherwise protects with text alone. The word is not deleted — it moves to the chip's aria-label. No other state gets this treatment; `incomplete` and `waiting` have no unambiguous glyph, so they keep the word on the chip.",
|
||||
"section": "colors"
|
||||
},
|
||||
{
|
||||
"name": "The List-Relative Heat Rule",
|
||||
"body": "The release deck's score chip (issue 111) is not an absolute quality scale — its seven stops are relative to the lowest and highest score on screen right now. Colour there answers \"which of these\", not \"how good is this release in general\".",
|
||||
"section": "colors"
|
||||
}
|
||||
],
|
||||
"dos": [
|
||||
@@ -297,7 +337,8 @@
|
||||
"Do keep every control ≥44px, focus rings cyan and visible, and reduced-motion honored.",
|
||||
"Do keep muted text at or above 4.5:1 on panel ground (--ink-muted floor).",
|
||||
"Do self-host every asset; the SPA is embedded in the binary and must work with no network.",
|
||||
"Do take every colour, spacing and font-size from the :root tokens; the token check (web/scripts/check-tokens.mjs) fails CI on literals."
|
||||
"Do take every colour, spacing and font-size from the :root tokens; the token check (web/scripts/check-tokens.mjs) fails CI on literals.",
|
||||
"Do suppress a redundant status chip where another control on the same row already implies the exact same state one-to-one — the missing chip disappears from episode rows because the want button only ever renders for that state."
|
||||
],
|
||||
"donts": [
|
||||
"Colour state chips on the ramp — `complete` and `ended` green, `incomplete` and a partial count amber, `airing` and `downloading` violet, `waiting` and `untracked` neutral. The word stays in every chip, so state survives with colour removed.",
|
||||
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+21
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT g.target_id AS \"season_id!: i64\",\n r.name AS \"name!: String\",\n g.infohash AS \"infohash!: String\",\n g.failed_at,\n g.grabbed_at AS \"grabbed_at!: String\"\n FROM grabs g\n JOIN releases r ON r.id = g.release_id\n JOIN seasons s ON s.id = g.target_id\n WHERE g.target_kind = 'season'\n AND g.state = 'failed'\n AND s.series_id = ?\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = s.id AND e.wanted\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n )\n ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id",
|
||||
"query": "SELECT g.target_id AS \"season_id!: i64\",\n r.name AS \"name!: String\",\n g.infohash AS \"infohash!: String\",\n g.failed_at,\n g.grabbed_at AS \"grabbed_at!: String\"\n FROM grabs g\n JOIN releases r ON r.id = g.release_id\n JOIN seasons s ON s.id = g.target_id\n WHERE g.target_kind = 'season'\n AND g.state = 'failed'\n AND s.series_id = ?\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = s.id AND e.wanted\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n )\n -- A later attempt owns the headline while it is still in\n -- flight. Keep the failed grab recorded; it is simply no\n -- longer the current explanation for the gap.\n AND NOT EXISTS (\n SELECT 1 FROM grabs newer\n WHERE newer.target_kind = 'season'\n AND newer.target_id = g.target_id\n -- 'vanished' is not a live attempt either: the\n -- torrent left qBittorrent, so letting it take the\n -- headline would leave the season saying nothing at\n -- all about a gap that still exists.\n AND newer.state NOT IN ('failed', 'vanished')\n AND (newer.grabbed_at > coalesce(g.failed_at, g.grabbed_at)\n OR (newer.grabbed_at = coalesce(g.failed_at, g.grabbed_at)\n AND newer.id > g.id))\n )\n ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -70,5 +70,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe"
|
||||
"hash": "0cfce0fe064cccb1dceb285a50fa77d990b116a95b987e604913cd8953da36ad"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+21
@@ -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"
|
||||
}
|
||||
+21
@@ -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"
|
||||
}
|
||||
+50
@@ -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"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+122
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+74
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
|
||||
"query": "SELECT count(*) AS \"failures!: i64\",\n max(coalesce(failed_at, grabbed_at)) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -24,5 +24,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019"
|
||||
"hash": "3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
+98
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+158
@@ -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"
|
||||
}
|
||||
+98
@@ -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"
|
||||
}
|
||||
+158
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM subtitle_attempts WHERE language = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "74178ac3ec41aa8a5ce164103a8d8301cb555eea2fa5d655a7365fd02c557cce"
|
||||
}
|
||||
+74
@@ -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"
|
||||
}
|
||||
+50
@@ -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"
|
||||
}
|
||||
+110
@@ -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"
|
||||
}
|
||||
+134
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM subtitle_files WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "90480a6b6e724d7e4d05239799ad2f586c57deea100320fe433da007396f1e48"
|
||||
}
|
||||
+50
@@ -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"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+86
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
|
||||
"query": "SELECT count(*) AS \"failures!: i64\",\n max(coalesce(failed_at, grabbed_at)) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -24,5 +24,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162"
|
||||
"hash": "c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5"
|
||||
}
|
||||
+21
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+21
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+21
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
@@ -16,8 +16,9 @@ crates/
|
||||
├── arr-parse/ release name parsing (no IO)
|
||||
├── arr-meta/ TMDB client
|
||||
├── arr-indexer/ Torznab via Prowlarr
|
||||
├── arr-dl/ Transmission RPC
|
||||
├── arr-dl/ qBittorrent WebUI API
|
||||
├── 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
|
||||
@@ -243,8 +244,9 @@ mid-issue.
|
||||
|
||||
- **Prowlarr** — `prowlarr` container on the Dokploy host, port 9696. Owns
|
||||
tracker auth, FlareSolverr and the Cardigann definitions. Not replaced.
|
||||
- **Transmission** — native in LXC 130 at `10.6.10.45:9091`, RPC
|
||||
unauthenticated. Download dir `/mnt/media/transmission/complete`.
|
||||
- **qBittorrent** — `qbittorrent.n62.casa`, WebUI API v2, login required
|
||||
(`ARR_QBITTORRENT_USERNAME` / `ARR_QBITTORRENT_PASSWORD`). Download dir
|
||||
`/mnt/media/qbittorrent/complete`.
|
||||
- **Jellyfin** — native in LXC at `10.6.10.18:8096`. Library roots under
|
||||
`/mnt/media-v2`.
|
||||
- **Media** — ZFS, single dataset, bind-mounted as `/mnt/media`. Downloads and
|
||||
|
||||
Generated
+60
-3
@@ -32,9 +32,12 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"arr-core",
|
||||
"arr-db",
|
||||
"arr-dl",
|
||||
"arr-indexer",
|
||||
"arr-meta",
|
||||
"arr-parse",
|
||||
"arr-probe",
|
||||
"arr-subs",
|
||||
"axum",
|
||||
"chrono",
|
||||
"reqwest",
|
||||
@@ -88,8 +91,8 @@ dependencies = [
|
||||
"arr-meta",
|
||||
"arr-parse",
|
||||
"arr-probe",
|
||||
"arr-subs",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"include_dir",
|
||||
"mime_guess",
|
||||
@@ -125,10 +128,10 @@ dependencies = [
|
||||
name = "arr-dl"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1 0.10.7",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"url",
|
||||
@@ -139,12 +142,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 +199,30 @@ 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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
@@ -366,6 +390,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"
|
||||
@@ -531,6 +566,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"
|
||||
@@ -1498,6 +1542,7 @@ dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -1506,6 +1551,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -1688,6 +1734,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
@@ -1887,7 +1944,7 @@ dependencies = [
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"sha1",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"sqlx-core",
|
||||
"thiserror",
|
||||
|
||||
+5
-2
@@ -20,13 +20,13 @@ 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"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
base64 = "0.22"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
|
||||
|
||||
# Persistence
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono", "json"] }
|
||||
@@ -47,8 +47,11 @@ 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"
|
||||
sha1 = "0.10"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
@@ -27,7 +27,7 @@ allowed to be narrow.
|
||||
Explicitly out of scope, permanently unless stated:
|
||||
|
||||
- **Authentication.** The perimeter is a VPN plus Authelia at the proxy. The
|
||||
service binds without auth, same trust model as the existing Transmission RPC.
|
||||
service binds without auth, same trust model as the rest of the stack.
|
||||
- **Library migration or filesystem scan.** The service knows only what it put
|
||||
on disk. Adopting the pre-existing library, if ever wanted, is a one-off
|
||||
script against both APIs, not a feature.
|
||||
@@ -46,7 +46,7 @@ Explicitly out of scope, permanently unless stated:
|
||||
```
|
||||
┌──────────────┐
|
||||
TMDB ──────▶│ │
|
||||
│ arr │────▶ Transmission RPC 10.6.10.45:9091
|
||||
│ arr │────▶ qBittorrent WebUI qbittorrent.n62.casa
|
||||
Prowlarr ──────▶│ │
|
||||
(Torznab) │ (this) │────▶ ffprobe local subprocess
|
||||
│ │
|
||||
@@ -61,8 +61,9 @@ Everything already exists except `arr`. Prowlarr keeps owning tracker auth,
|
||||
Cloudflare bypass via FlareSolverr, rate limiting and the Cardigann
|
||||
definitions — replacing it buys nothing.
|
||||
|
||||
Transmission runs natively in its own LXC (VMID 130, `10.6.10.45`), RPC
|
||||
unauthenticated, download dir `/mnt/media/transmission/complete`.
|
||||
qBittorrent is reached at `qbittorrent.n62.casa`, WebUI API v2, download dir
|
||||
`/mnt/media/qbittorrent/complete`. Its WebUI requires a login, so arr carries
|
||||
credentials — the one upstream that does.
|
||||
|
||||
## 4. Domain model
|
||||
|
||||
@@ -174,6 +175,13 @@ not yet found, violet (`--status-airing`) for downloading, neutral for
|
||||
untracked. The word stays present in every chip, so state survives with colour
|
||||
removed.
|
||||
|
||||
One state is exempt: a file being on disk is drawn as a check glyph rather
|
||||
than the word `available`, with the word on the chip's `aria-label`. A drawn
|
||||
check is a shape, not only a hue, so it survives the greyscale read the rule
|
||||
exists to protect. The exemption is that narrow — every other state, and every
|
||||
derived-status chip above, keeps its word, because no unambiguous glyph stands
|
||||
in for `incomplete` or `waiting`.
|
||||
|
||||
Season 0 is invisible to all of it: derived status ignores season 0 episodes
|
||||
entirely, so a manually wanted special cannot pin a series at `incomplete` or
|
||||
hold back `ended`. The accepted consequence is that specials are visible and
|
||||
@@ -422,13 +430,13 @@ guard retries at worst weekly (§6.2) — and stays queued for exactly as long a
|
||||
it is genuinely broken. Nothing is dismissed by hand and no acknowledgement
|
||||
state is stored, so there is no second thing to keep correct.
|
||||
|
||||
**Only a target still waiting for a file is queued.** The count and the window
|
||||
are one rule seen twice — the queue is meant to be visited and emptied — and
|
||||
this is its third face: a failure history queues a target only while that
|
||||
target still has a gap to fill. A movie or an episode is queued while it is
|
||||
`wanted` and not `available`. A season holds no intent of its own (§4.1), so it
|
||||
is queued while at least one of its episodes is still wanted and still without
|
||||
a file. A season pack that hard-failed twice, fell back to per-episode grabbing
|
||||
**Only a target still waiting for a file is queued.** The count and the
|
||||
window already express one rule — bounded attention — and this is another
|
||||
face of it: a failure history queues a target only while that target still
|
||||
has a gap to fill. A movie or an episode is queued while it is `wanted` and
|
||||
not `available`. A season holds no intent of its own (§4.1), so it is queued
|
||||
while at least one of its episodes is still `wanted` and not `available`. A
|
||||
season pack that hard-failed twice, fell back to per-episode grabbing
|
||||
exactly as §6.2 says it should, and was then fully acquired leaves at once
|
||||
rather than waiting out the 30 days — that is the system working, not a
|
||||
decision. A target that is still broken keeps producing failures and stays.
|
||||
@@ -480,6 +488,22 @@ its own, the escape hatch is the season release deck, not a lane exception.
|
||||
Targeted search backs off `1h → 6h → 1d → 3d`, capped at 7d, reset when the
|
||||
title's metadata changes. It never gives up entirely, it goes quiet.
|
||||
|
||||
**The ladder runs from the failure, not the grab.** A failed season-pack grab
|
||||
quiets the pack lane on that same curve, and the rung is measured from the
|
||||
moment the grab entered `failed` — `grabs.failed_at`, the column §5.7's
|
||||
attention window reads — not from when it was sent. The two are usually
|
||||
minutes apart, but a torrent can stall on a dead swarm for five weeks before
|
||||
`ffprobe` condemns it at import. Measured from the grab, the whole ladder has
|
||||
already elapsed by the time the failure lands, so the lane retries the source
|
||||
that just failed at once, which is the one thing the backoff exists to
|
||||
prevent. The ladder's job is to stay off a source that has recently failed,
|
||||
and "recently" can only mean recently failed.
|
||||
|
||||
One anchor covers both features. §5.7's window and this ladder ask the same
|
||||
question of the same event and read the same column; a target still broken
|
||||
keeps producing fresh failures, and each one both re-arms this backoff and
|
||||
holds the target in the attention queue.
|
||||
|
||||
**Do not search before the release exists.** TMDB carries release dates; a
|
||||
movie with no digital release date gets zero targeted searches. This is the
|
||||
single largest source of wasted queries in Radarr and it is free to avoid.
|
||||
@@ -494,11 +518,20 @@ leaving RSS matching on.
|
||||
|
||||
## 7. Download and import
|
||||
|
||||
### 7.1 Transmission
|
||||
### 7.1 qBittorrent
|
||||
|
||||
RPC at `10.6.10.45:9091`, no credentials. Labels are `movies-main`, `tv-kids`
|
||||
and so on — enough to find things in Transmission's own UI, and distinct from
|
||||
Radarr's existing `radarr`/`sonarr` labels so both stacks can run side by side.
|
||||
WebUI API v2 at `qbittorrent.n62.casa`. Labels are qBittorrent **tags** —
|
||||
`movies-main`, `tv-kids` and so on — enough to find things in qBittorrent's own
|
||||
UI, and distinct from Radarr's existing `radarr`/`sonarr` labels so both stacks
|
||||
can run side by side. Tags rather than a category because a category also
|
||||
governs the save path, and arr owns that.
|
||||
|
||||
`torrents/add` answers `Ok.` and nothing else: no hash, no name, and no signal
|
||||
that the torrent was already there. arr therefore derives the v1 infohash from
|
||||
the magnet or the `.torrent` bytes before the call, and looks the torrent up by
|
||||
it. That is also what makes an add idempotent across a restart (§8) — the same
|
||||
release resolves to the same hash, and a second add is recognised as the
|
||||
duplicate it is.
|
||||
|
||||
### 7.2 Hardlink
|
||||
|
||||
@@ -517,9 +550,16 @@ at 4K is 40-80 GB per title.
|
||||
The torrent and the library entry are separate state machines.
|
||||
|
||||
Seeding obligation is **per tracker**, configured locally because Prowlarr does
|
||||
not expose tracker rules — `ratio` and `min_seed_time`. Set `seedRatioLimit`
|
||||
and `seedIdleLimit` on the torrent at add time and let Transmission enforce
|
||||
them. A reaper deletes torrents Transmission reports as done seeding.
|
||||
not expose tracker rules — `ratio` and `min_seed_time`. Set `ratioLimit` and
|
||||
`inactiveSeedingTimeLimit` on the torrent at add time, with
|
||||
`shareLimitAction` set to stop rather than delete, and let qBittorrent enforce
|
||||
them. A reaper deletes torrents qBittorrent stopped on a limit arr set.
|
||||
|
||||
Stopped-on-a-limit, not merely stopped: a torrent the operator paused by hand
|
||||
looks identical in the state field alone, and the reaper deletes data. So the
|
||||
ratio and idle limits are checked against the torrent's own counters, and a
|
||||
torrent stopped by a *global* limit reads as still seeding — the safe direction
|
||||
to be wrong in.
|
||||
|
||||
Consequently a hard-failed release is blacklisted and never imported, but its
|
||||
torrent keeps seeding until the obligation clears. Nothing is deleted early to
|
||||
@@ -548,7 +588,12 @@ Media kind first, hard audience boundary second, people nowhere.
|
||||
- **Release group is deliberately absent.** It is not a selection criterion and
|
||||
it makes filenames long enough to break a terminal.
|
||||
|
||||
Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy. Changing a root's path is the same move over every title under it, and it is all or nothing: one folder that cannot move puts back the ones that already did and leaves the root's path alone, so the stored path always describes the disk.
|
||||
Changing a title's root relocates its title folder into the new root; roots
|
||||
are assumed to share one filesystem, so the move is a rename, never a copy.
|
||||
Changing a root's path is the same move over every title under it, and it
|
||||
is all or nothing: one folder that cannot move puts back the ones that
|
||||
already did and leaves the root's path alone, so the stored path always
|
||||
describes the disk.
|
||||
|
||||
During transition, write into the existing roots so Jellyfin needs no
|
||||
reconfiguration and new content appears immediately. Radarr will not touch a
|
||||
@@ -571,11 +616,12 @@ state and act on the gap.
|
||||
|
||||
This is idempotent and crash-safe by construction. Kill the process mid-grab and
|
||||
the next tick recomputes the same gap and continues. A job table would need
|
||||
retry counts, dead-lettering and reconciliation against Transmission anyway,
|
||||
because Transmission is an external system that changes underneath the app.
|
||||
retry counts, dead-lettering and reconciliation against qBittorrent anyway,
|
||||
because qBittorrent is an external system that changes underneath the app.
|
||||
|
||||
Transient state — a search in flight, download progress — is in memory and
|
||||
rebuilt from Transmission on startup.
|
||||
rebuilt from qBittorrent on startup. Where that state is ever seen is §9.8:
|
||||
inline on the row that owns the item, never persisted.
|
||||
|
||||
Ticks are staggered: reconcile every 30 s, RSS every 10 min, metadata refresh
|
||||
daily, reaper every 5 min.
|
||||
@@ -658,8 +704,8 @@ notifying on everything and being muted within a week.
|
||||
|
||||
- **Imported** → to the title's owners. The only good-news notification.
|
||||
- **Needs a decision** → to the operator alone. Entered the no-PT-source queue,
|
||||
or hard-failed twice on different releases.
|
||||
- **Broken** → to the operator alone. Prowlarr, Transmission or TMDB
|
||||
or the needs-a-decision queue (§5.7).
|
||||
- **Broken** → to the operator alone. Prowlarr, qBittorrent or TMDB
|
||||
unreachable, disk full.
|
||||
|
||||
Not notified: grabs, searches, downloads starting or finishing, soft fails.
|
||||
@@ -713,7 +759,7 @@ inside the app, review text.
|
||||
opens on.
|
||||
|
||||
**The signal chain is a settings section.** The four upstreams — tmdb,
|
||||
prowlarr, arr, transmission — and their lamps live inside `/settings`, and have
|
||||
prowlarr, arr, qbittorrent — and their lamps live inside `/settings`, and have
|
||||
no route of their own. Per-upstream health is something you check when
|
||||
something is wrong, not a homepage.
|
||||
|
||||
@@ -722,6 +768,37 @@ is healthy survives the move and no navigation is needed to get it.
|
||||
|
||||
**The wordmark is a link home.** `arr` on the rail navigates to `/`.
|
||||
|
||||
**The rail composition is fixed.** From left to right it carries the master
|
||||
lamp and wordmark, a warning mark only while queues need attention, the centred
|
||||
search, the version readout and the settings cog.
|
||||
|
||||
### 9.8 Downloads on the row
|
||||
|
||||
§8 keeps download progress as transient state; nothing in §9 so far says where
|
||||
it is seen. Without a rule here, a season pack downloading and one that never
|
||||
started look identical.
|
||||
|
||||
**Progress is inline, on the row that owns the item** — a movie row, a season
|
||||
row for its pack, an episode row. There is no downloads page and no count in
|
||||
the rail: a download is an attribute of the thing being downloaded, not a place
|
||||
to visit.
|
||||
|
||||
Alongside progress the same row carries the other states a torrent can be in:
|
||||
**seeding** under §7.3's obligation, **stalled**, **errored**.
|
||||
|
||||
**A torrent arr did not grab is never shown.** §2 already rules the service
|
||||
knows only what it put on disk; qBittorrent's own UI lists the rest.
|
||||
|
||||
Nothing is persisted — no progress column, no new table. The snapshot comes
|
||||
from qBittorrent and dies with the process (§8), refreshed by the UI's normal
|
||||
polling cadence at roughly 15 s rather than SSE.
|
||||
|
||||
At phone width only active-grab progress survives; seeding and stalled shed
|
||||
with the row's other secondary chips.
|
||||
|
||||
Out of scope here: any change to what the reconcile loop does, and any new
|
||||
state machine — §7.3's two lifecycles stay as they are.
|
||||
|
||||
## 10. Persistence
|
||||
|
||||
SQLite via `sqlx`, compile-time-checked queries, migrations in `arr-db`.
|
||||
@@ -731,8 +808,10 @@ service.
|
||||
|
||||
Policy lives in the database, not a config file — size targets and DV rules get
|
||||
tuned by hand during testing and a restart-to-reload loop gets old immediately.
|
||||
Only bootstrap settings (bind address, Prowlarr URL, Transmission URL, TMDB key,
|
||||
media root, TMDB response cache directory) come from config/env.
|
||||
Only bootstrap settings (bind address, Prowlarr URL, qBittorrent URL and
|
||||
login, TMDB key, media root, TMDB response cache directory) come from
|
||||
config/env. The qBittorrent password is a secret, so it is env-only and has no
|
||||
config-file field.
|
||||
|
||||
Backup is `sqlite3 .backup` on a timer.
|
||||
|
||||
@@ -746,8 +825,9 @@ arr-core domain types, policy engine, scoring no IO, no heavy deps
|
||||
arr-parse release name parsing no IO
|
||||
arr-meta TMDB client
|
||||
arr-indexer Torznab via Prowlarr
|
||||
arr-dl Transmission RPC
|
||||
arr-dl qBittorrent WebUI API
|
||||
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
|
||||
@@ -802,8 +882,10 @@ and `target/`, keyed on `Cargo.lock` plus `rust-toolchain.toml`. Never build
|
||||
|
||||
- Prowlarr and TMDB — `wiremock` with recorded real responses as fixtures.
|
||||
Never live: trackers rate-limit, and it would leak credentials into CI.
|
||||
- Transmission — a real container as a CI service. RPC semantics are the most
|
||||
likely source of surprise and it starts in a second.
|
||||
- qBittorrent — a real container, started with a seeded config because the
|
||||
image prints a random WebUI password per boot. Its API semantics are the most
|
||||
likely source of surprise: `torrents/add` reports nothing back, and required
|
||||
parameters have changed between major versions.
|
||||
- `ffprobe` — tiny committed clips, a few KB each. The Jellyfin LXC already has
|
||||
the right fixtures at `/srv/jellyfin-test`, including a real **DV Profile 5**
|
||||
clip. That is the test proving the policy engine rejects Profile 5 and accepts
|
||||
@@ -830,7 +912,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
|
||||
@@ -850,3 +932,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, 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.
|
||||
|
||||
+46
-2
@@ -8,17 +8,61 @@ RUN pnpm -C web install --frozen-lockfile --config.dangerouslyAllowAllBuilds=tru
|
||||
COPY web ./web
|
||||
RUN pnpm -C web build
|
||||
|
||||
FROM rust:1-bookworm AS build
|
||||
# Every translation backend is a default-off cargo feature (DESIGN.md §15), so
|
||||
# a build without these ships an image that cannot translate at all. All four
|
||||
# go in: which one is in use is a database setting, and the point of the
|
||||
# feature switches is the build, not the deployment.
|
||||
#
|
||||
# The dependency build and the workspace build are split with cargo-chef so a
|
||||
# source-only change reuses the dependency layer. The two `cargo` invocations
|
||||
# must carry identical flags or the cook output is not reusable.
|
||||
FROM lukemathwalker/cargo-chef:latest-rust-1-bookworm AS chef
|
||||
WORKDIR /src
|
||||
ENV ARR_FEATURES=translate-openai,translate-deepl,translate-google,translate-command
|
||||
|
||||
FROM chef AS planner
|
||||
COPY . .
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM chef AS build
|
||||
COPY --from=planner /src/recipe.json recipe.json
|
||||
# cargo-chef's skeleton keeps build scripts, and arr-daemon's panics in release
|
||||
# when it can find no SPA bundle. A stub satisfies it; the real bundle arrives
|
||||
# below and the changed ARR_WEB_DIST reruns only that one build script.
|
||||
RUN mkdir -p /stub/dist && printf '<!doctype html>stub\n' > /stub/dist/index.html
|
||||
ENV ARR_WEB_DIST=/stub/dist
|
||||
RUN cargo chef cook --release -p arr-daemon --features "$ARR_FEATURES" \
|
||||
--recipe-path recipe.json
|
||||
COPY . .
|
||||
COPY --from=web /src/web/dist ./web/dist
|
||||
ENV ARR_WEB_DIST=/src/web/dist
|
||||
RUN cargo build --release -p arr-daemon
|
||||
RUN cargo build --release -p arr-daemon --features "$ARR_FEATURES"
|
||||
|
||||
# §15 runs `alass` over every fetched and every translated subtitle, and it is
|
||||
# not in Debian — a release binary is the only way in. Its own stage so the
|
||||
# runtime image needs no download tool, and so a source change does not refetch
|
||||
# it. The digest is pinned: an unverified binary from a release page is not
|
||||
# something to run over the library. The asset is x86-64 only, which is what
|
||||
# this image targets.
|
||||
FROM debian:bookworm-slim AS alass
|
||||
ARG ALASS_VERSION=2.0.0
|
||||
ARG ALASS_SHA256=7bd0b9ae7e035d3ba940eacffb21243614df36231d47f21f0b4ce42001ab7fcd
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN curl -fsSL -o /usr/local/bin/alass \
|
||||
"https://github.com/kaegi/alass/releases/download/v${ALASS_VERSION}/alass-linux64" \
|
||||
&& echo "${ALASS_SHA256} /usr/local/bin/alass" | sha256sum -c - \
|
||||
&& chmod +x /usr/local/bin/alass \
|
||||
&& alass --version
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
# ffmpeg covers both `ffprobe` for arr-probe and the audio extraction `alass`
|
||||
# shells out to.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=alass /usr/local/bin/alass /usr/local/bin/alass
|
||||
COPY --from=build /src/target/release/arr /usr/local/bin/arr
|
||||
ENV ARR_BIND_ADDR=0.0.0.0:7878 \
|
||||
ARR_DATABASE_PATH=/data/arr.db
|
||||
|
||||
@@ -15,32 +15,54 @@ 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.
|
||||
# End-to-end tests. Needs a qBittorrent container (`just e2e-up`); not part
|
||||
# of the push gate. Never point this at the production qBittorrent.
|
||||
e2e:
|
||||
cargo nextest run -p arr-e2e
|
||||
|
||||
# Throwaway local Transmission for `just e2e`, mirroring the CI service.
|
||||
# Throwaway local qBittorrent for `just e2e`, mirroring the CI container.
|
||||
#
|
||||
# The config is seeded before first start because qBittorrent prints a random
|
||||
# WebUI password per boot and has no environment variable to set one; the
|
||||
# subnet whitelist is what lets the tests talk to it without credentials.
|
||||
e2e-up:
|
||||
docker run --rm -d --name arr-e2e-transmission \
|
||||
-e PUID=1000 -e PGID=1000 -p 9091:9091 \
|
||||
linuxserver/transmission:latest
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/arr-e2e-qbt/config/qBittorrent
|
||||
cat > /tmp/arr-e2e-qbt/config/qBittorrent/qBittorrent.conf <<'CONF'
|
||||
[Preferences]
|
||||
WebUI\Port=8080
|
||||
WebUI\AuthSubnetWhitelistEnabled=true
|
||||
WebUI\AuthSubnetWhitelist=0.0.0.0/0
|
||||
WebUI\CSRFProtection=false
|
||||
WebUI\HostHeaderValidation=false
|
||||
Downloads\SavePath=/downloads
|
||||
CONF
|
||||
docker run --rm -d --name arr-e2e-qbittorrent \
|
||||
-e PUID=1000 -e PGID=1000 -e WEBUI_PORT=8080 -p 8080:8080 \
|
||||
-v /tmp/arr-e2e-qbt/config:/config \
|
||||
linuxserver/qbittorrent:latest
|
||||
until curl -sf http://127.0.0.1:8080/api/v2/app/version >/dev/null; do sleep 1; done
|
||||
|
||||
# Stop and discard the local Transmission container.
|
||||
# Stop and discard the local qBittorrent container.
|
||||
e2e-down:
|
||||
docker stop arr-e2e-transmission
|
||||
docker stop arr-e2e-qbittorrent
|
||||
|
||||
# Frontend lint and typecheck. No-op until the SPA exists (issue #6). Probes
|
||||
# for package.json, not the directory: `just gen-client` writes into web/ and
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ manual search buries the decision under the release name column.
|
||||
- Self-hosted behind VPN + Authelia; the app itself has no auth layer.
|
||||
- API-first: the SPA is one client of the OpenAPI-described HTTP API, no
|
||||
privileged path.
|
||||
- Coexists with Prowlarr (indexers), Transmission (downloads), Jellyfin
|
||||
- Coexists with Prowlarr (indexers), qBittorrent (downloads), Jellyfin
|
||||
(playback), Jellyseerr (requests), ntfy (notifications).
|
||||
|
||||
## Capabilities and Constraints
|
||||
|
||||
@@ -12,7 +12,7 @@ This is that loop, once, in Rust.
|
||||
|
||||
- **Prowlarr** stays — it owns tracker auth, Cloudflare bypass and the indexer
|
||||
definitions, and replacing it buys nothing.
|
||||
- **Transmission** stays.
|
||||
- **qBittorrent** stays.
|
||||
- **Jellyseerr** stays, talking to a thin Radarr-compatible shim.
|
||||
|
||||
## Status
|
||||
|
||||
@@ -8,10 +8,13 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
arr-core = { workspace = true }
|
||||
arr-dl = { workspace = true }
|
||||
arr-db = { workspace = true }
|
||||
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 }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! The live download snapshot, joined to arr-owned grabs only.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use arr_dl::{Torrent, TorrentState};
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::movies::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
const SNAPSHOT_TTL: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DownloadPhase {
|
||||
Downloading,
|
||||
Stalled,
|
||||
Errored,
|
||||
Seeding,
|
||||
Queued,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct Download {
|
||||
pub target_kind: String,
|
||||
pub target_id: i64,
|
||||
pub infohash: String,
|
||||
pub name: String,
|
||||
pub phase: DownloadPhase,
|
||||
pub progress: f64,
|
||||
pub download_rate: u64,
|
||||
pub eta: Option<i64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/downloads",
|
||||
tag = "system",
|
||||
responses(
|
||||
(status = 200, description = "Live downloads started by arr", body = [Download]),
|
||||
(status = 503, description = "qBittorrent or database unavailable", body = crate::movies::ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn list(State(state): State<AppState>) -> Result<Json<Vec<Download>>, ApiError> {
|
||||
let database = state.database().ok_or(ApiError::Unavailable)?;
|
||||
let qbit = state.qbit().ok_or(ApiError::Upstream("qbit"))?;
|
||||
|
||||
let torrents = {
|
||||
let mut cache = state.download_snapshot().write().await;
|
||||
if let Some((at, torrents)) = cache.as_ref() {
|
||||
if at.elapsed() < SNAPSHOT_TTL {
|
||||
torrents.clone()
|
||||
} else {
|
||||
let torrents = qbit
|
||||
.list_torrents()
|
||||
.await
|
||||
.map_err(|_| ApiError::Upstream("qbit"))?;
|
||||
*cache = Some((Instant::now(), torrents.clone()));
|
||||
torrents
|
||||
}
|
||||
} else {
|
||||
let torrents = qbit
|
||||
.list_torrents()
|
||||
.await
|
||||
.map_err(|_| ApiError::Upstream("qbit"))?;
|
||||
*cache = Some((Instant::now(), torrents.clone()));
|
||||
torrents
|
||||
}
|
||||
};
|
||||
|
||||
let grabs: Vec<Grab> =
|
||||
sqlx::query_as("SELECT target_kind, target_id, infohash FROM grabs ORDER BY id")
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
|
||||
let mut downloads = Vec::new();
|
||||
for grab in grabs {
|
||||
let Some(torrent) = torrents
|
||||
.iter()
|
||||
.find(|torrent| torrent.hash.eq_ignore_ascii_case(&grab.infohash))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
downloads.push(Download {
|
||||
target_kind: grab.target_kind,
|
||||
target_id: grab.target_id,
|
||||
infohash: torrent.hash.clone(),
|
||||
name: torrent.name.clone(),
|
||||
phase: phase(torrent),
|
||||
progress: torrent.progress,
|
||||
download_rate: torrent.download_rate,
|
||||
eta: torrent.eta,
|
||||
error: torrent.error.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(downloads))
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct Grab {
|
||||
target_kind: String,
|
||||
target_id: i64,
|
||||
infohash: String,
|
||||
}
|
||||
|
||||
fn phase(torrent: &Torrent) -> DownloadPhase {
|
||||
if torrent.error.is_some() {
|
||||
DownloadPhase::Errored
|
||||
} else {
|
||||
match torrent.state {
|
||||
TorrentState::Seeding => DownloadPhase::Seeding,
|
||||
// qBittorrent's own verdict, not a zero rate: a torrent between
|
||||
// peers reads zero for a poll or two and finishes fine, and
|
||||
// §9.8's stalled chip is for the case that never does.
|
||||
TorrentState::Downloading if torrent.is_stalled => DownloadPhase::Stalled,
|
||||
TorrentState::Downloading => DownloadPhase::Downloading,
|
||||
_ => DownloadPhase::Queued,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use arr_db::Db;
|
||||
use axum::extract::State;
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use super::{list, phase, DownloadPhase};
|
||||
use crate::state::{AppState, Upstreams};
|
||||
|
||||
#[test]
|
||||
fn torrent_states_become_the_download_phases() {
|
||||
let torrent =
|
||||
|state: arr_dl::TorrentState, rate: u64, error: Option<&str>| arr_dl::Torrent {
|
||||
name: "name".into(),
|
||||
hash: "hash".into(),
|
||||
state,
|
||||
progress: 0.5,
|
||||
download_rate: rate,
|
||||
eta: None,
|
||||
error: error.map(str::to_owned),
|
||||
is_stalled: rate == 0,
|
||||
download_dir: PathBuf::from("/downloads"),
|
||||
labels: Vec::new(),
|
||||
is_finished: false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
phase(&torrent(arr_dl::TorrentState::Downloading, 10, None)),
|
||||
DownloadPhase::Downloading
|
||||
);
|
||||
assert_eq!(
|
||||
phase(&torrent(arr_dl::TorrentState::Downloading, 0, None)),
|
||||
DownloadPhase::Stalled
|
||||
);
|
||||
assert_eq!(
|
||||
phase(&torrent(arr_dl::TorrentState::Seeding, 0, None)),
|
||||
DownloadPhase::Seeding
|
||||
);
|
||||
assert_eq!(
|
||||
phase(&torrent(
|
||||
arr_dl::TorrentState::Downloading,
|
||||
10,
|
||||
Some("disk full")
|
||||
)),
|
||||
DownloadPhase::Errored
|
||||
);
|
||||
assert_eq!(
|
||||
phase(&torrent(arr_dl::TorrentState::QueuedToDownload, 0, None)),
|
||||
DownloadPhase::Queued
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn endpoint_joins_only_arr_grabs_and_reuses_the_snapshot() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v2/torrents/info"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([{
|
||||
"hash": "ABC", "name": "grabbed", "state": "downloading",
|
||||
"progress": 0.4, "dlspeed": 123, "eta": 60,
|
||||
"save_path": "/downloads", "tags": ""
|
||||
}])))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let directory = tempfile::tempdir().expect("tempdir");
|
||||
let database = Db::connect(directory.path().join("arr.db"))
|
||||
.await
|
||||
.expect("database");
|
||||
database.migrate().await.expect("migrate");
|
||||
sqlx::query(
|
||||
"INSERT INTO releases (id, indexer_id, guid, name, size, download_url, parsed)
|
||||
VALUES (1, 1, 'guid', 'release', 1, 'magnet:?x', '{}')",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.expect("release");
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash)
|
||||
VALUES (1, 'movie', 42, 'abc')",
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.expect("grab");
|
||||
|
||||
let state = AppState::new(Upstreams::new("unused".into(), server.uri()))
|
||||
.expect("state")
|
||||
.with_database(database)
|
||||
.with_qbit(arr_dl::QbitClient::new(&server.uri()).expect("client"));
|
||||
let first = list(State(state.clone())).await.expect("first").0;
|
||||
let second = list(State(state)).await.expect("second").0;
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(first[0].target_kind, "movie");
|
||||
assert_eq!(first[0].target_id, 42);
|
||||
assert_eq!(first[0].infohash, "ABC");
|
||||
assert_eq!(first[0].phase, DownloadPhase::Downloading);
|
||||
assert_eq!(first[0].download_rate, 123);
|
||||
assert_eq!(second.len(), 1);
|
||||
}
|
||||
}
|
||||
+376
-19
@@ -1,16 +1,20 @@
|
||||
//! `GET /api/health` — is each of the three upstreams answering.
|
||||
//!
|
||||
//! The three probed here are the ones DESIGN.md §9.5 calls "Broken": without
|
||||
//! Prowlarr nothing is found, without Transmission nothing is fetched, and
|
||||
//! Prowlarr nothing is found, without qBittorrent nothing is fetched, and
|
||||
//! without TMDB nothing is identified. The endpoint always answers `200` —
|
||||
//! 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 {
|
||||
@@ -76,11 +118,13 @@ pub struct HealthReport {
|
||||
#[schema(example = "0.1.0")]
|
||||
pub version: String,
|
||||
pub prowlarr: Check,
|
||||
pub transmission: Check,
|
||||
pub qbit: Check,
|
||||
pub tmdb: Check,
|
||||
pub subtitles: SubtitleHealth,
|
||||
}
|
||||
|
||||
/// Report reachability of Prowlarr, Transmission and TMDB.
|
||||
/// Report reachability of Prowlarr, qBittorrent 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, qbit, tmdb, subtitles) = tokio::join!(
|
||||
probe_prowlarr(&state),
|
||||
probe_transmission(&state),
|
||||
probe_qbit(&state),
|
||||
probe_tmdb(&state),
|
||||
probe_subtitles(&state)
|
||||
);
|
||||
|
||||
let status = if [prowlarr.status, transmission.status, tmdb.status]
|
||||
.iter()
|
||||
.all(|s| *s == Status::Ok)
|
||||
let status = if [prowlarr.status, qbit.status, tmdb.status]
|
||||
.into_iter()
|
||||
.chain(subtitles.statuses())
|
||||
.all(|check| check == Status::Ok)
|
||||
{
|
||||
Health::Ok
|
||||
} else {
|
||||
@@ -111,8 +157,9 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
|
||||
status,
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
prowlarr,
|
||||
transmission,
|
||||
qbit,
|
||||
tmdb,
|
||||
subtitles,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,17 +181,16 @@ async fn probe_prowlarr(state: &AppState) -> Check {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transmission answers an RPC call without a session id with `409` plus the
|
||||
/// id to retry with. That is a live daemon, so it counts as reachable.
|
||||
async fn probe_transmission(state: &AppState) -> Check {
|
||||
let request = state
|
||||
.http()
|
||||
.post(&state.upstreams().transmission_url)
|
||||
.json(&serde_json::json!({ "method": "session-get" }));
|
||||
/// A live `WebUI` answers `/api/v2/app/version`, and answers `403` when the
|
||||
/// probe carries no session. Both are a running daemon, which is all this
|
||||
/// lamp claims; whether arr's credentials work shows up on the first grab.
|
||||
async fn probe_qbit(state: &AppState) -> Check {
|
||||
let base = state.upstreams().qbittorrent_url.trim_end_matches('/');
|
||||
let request = state.http().get(format!("{base}/api/v2/app/version"));
|
||||
match request.send().await {
|
||||
Ok(response)
|
||||
if response.status().is_success()
|
||||
|| response.status() == reqwest::StatusCode::CONFLICT =>
|
||||
|| response.status() == reqwest::StatusCode::FORBIDDEN =>
|
||||
{
|
||||
Check::ok()
|
||||
}
|
||||
@@ -181,3 +227,314 @@ 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),
|
||||
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; none is 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" },
|
||||
])
|
||||
);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+82
-46
@@ -6,18 +6,23 @@
|
||||
//! carrying a `#[utoipa::path]` annotation. A handler added without one fails
|
||||
//! to compile, and the gate in DESIGN.md §12 fails with it.
|
||||
|
||||
mod downloads;
|
||||
mod health;
|
||||
pub mod jellyfin;
|
||||
mod metadata;
|
||||
mod movies;
|
||||
mod owners;
|
||||
mod policies;
|
||||
#[cfg(test)]
|
||||
mod privilege;
|
||||
mod reclassify;
|
||||
mod relocate;
|
||||
mod roots;
|
||||
mod search;
|
||||
mod series;
|
||||
mod state;
|
||||
mod subtitle_settings;
|
||||
mod subtitles;
|
||||
mod trailer;
|
||||
|
||||
use axum::routing::get;
|
||||
@@ -27,6 +32,7 @@ use utoipa_axum::router::OpenApiRouter;
|
||||
use utoipa_axum::routes;
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
|
||||
pub use downloads::{Download, DownloadPhase};
|
||||
pub use health::{Check, Health, HealthReport, Status};
|
||||
pub use metadata::{MetadataTrailer, MovieMetadata, SeriesMetadata};
|
||||
pub use movies::{
|
||||
@@ -46,6 +52,13 @@ pub use state::{
|
||||
AppState, EpisodeCommand, MetadataCommand, MovieCommand, SeasonCommand, Upstreams,
|
||||
DEFAULT_TMDB_URL,
|
||||
};
|
||||
pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput};
|
||||
pub use subtitles::{
|
||||
EmbeddedTrack, EpisodeSubtitleGaps, EpisodeSubtitleStatus, MissingSubtitle, MovieSubtitleGaps,
|
||||
SeasonSubtitleGaps, SeriesSubtitleGaps, Subtitle, SubtitleCandidate, SubtitleExtractInput,
|
||||
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 +84,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;
|
||||
@@ -80,6 +94,7 @@ struct ApiDoc;
|
||||
fn api_router() -> OpenApiRouter<AppState> {
|
||||
OpenApiRouter::with_openapi(ApiDoc::openapi())
|
||||
.routes(routes!(health::health))
|
||||
.routes(routes!(downloads::list))
|
||||
.routes(routes!(movies::list, movies::create))
|
||||
.routes(routes!(movies::get, movies::update, movies::delete))
|
||||
.routes(routes!(movies::search))
|
||||
@@ -118,6 +133,19 @@ 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::extract))
|
||||
.routes(routes!(subtitles::translate))
|
||||
.routes(routes!(subtitles::delete))
|
||||
.routes(routes!(subtitles::queue))
|
||||
}
|
||||
|
||||
/// The generated `OpenAPI` document.
|
||||
@@ -149,8 +177,8 @@ mod tests {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// A Prowlarr that answers `/ping`, and a Transmission that answers an
|
||||
/// RPC call the way a real one does when it has no session id yet.
|
||||
/// A Prowlarr that answers `/ping`, and a qBittorrent that answers the
|
||||
/// version call the way a real one does for an unauthenticated caller.
|
||||
async fn upstreams_up() -> (MockServer, MockServer) {
|
||||
let prowlarr = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
@@ -161,16 +189,14 @@ mod tests {
|
||||
.mount(&prowlarr)
|
||||
.await;
|
||||
|
||||
let transmission = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/transmission/rpc"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(409).insert_header("X-Transmission-Session-Id", "abc"),
|
||||
)
|
||||
.mount(&transmission)
|
||||
let qbit = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v2/app/version"))
|
||||
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
|
||||
.mount(&qbit)
|
||||
.await;
|
||||
|
||||
(prowlarr, transmission)
|
||||
(prowlarr, qbit)
|
||||
}
|
||||
|
||||
/// Serve the app on an ephemeral port and return its base URL. The server
|
||||
@@ -197,7 +223,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_upstreams_up_is_ok() {
|
||||
let (prowlarr, transmission) = upstreams_up().await;
|
||||
let (prowlarr, qbit) = upstreams_up().await;
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/configuration"))
|
||||
@@ -206,31 +232,28 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let state = AppState::new(
|
||||
Upstreams::new(
|
||||
prowlarr.uri(),
|
||||
format!("{}/transmission/rpc", transmission.uri()),
|
||||
)
|
||||
.with_tmdb_url(tmdb.uri())
|
||||
.with_tmdb_api_key(Some("key".into())),
|
||||
Upstreams::new(prowlarr.uri(), qbit.uri())
|
||||
.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");
|
||||
assert_eq!(body["prowlarr"]["status"], "ok");
|
||||
assert_eq!(body["transmission"]["status"], "ok");
|
||||
assert_eq!(body["qbit"]["status"], "ok");
|
||||
assert_eq!(body["tmdb"]["status"], "ok");
|
||||
assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_tmdb_key_is_unconfigured_not_an_outage() {
|
||||
let (prowlarr, transmission) = upstreams_up().await;
|
||||
let state = AppState::new(Upstreams::new(
|
||||
prowlarr.uri(),
|
||||
format!("{}/transmission/rpc", transmission.uri()),
|
||||
))
|
||||
.expect("state");
|
||||
let (prowlarr, qbit) = upstreams_up().await;
|
||||
let state = AppState::new(Upstreams::new(prowlarr.uri(), qbit.uri())).expect("state");
|
||||
|
||||
let body = report(state).await;
|
||||
assert_eq!(body["tmdb"]["status"], "unconfigured");
|
||||
@@ -239,20 +262,17 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unreachable_upstream_degrades_the_service() {
|
||||
let (_prowlarr, transmission) = upstreams_up().await;
|
||||
let (_prowlarr, qbit) = upstreams_up().await;
|
||||
|
||||
// Port 1 is privileged and nothing binds it, so the probe gets a
|
||||
// refused connection immediately instead of waiting out the timeout.
|
||||
let state = AppState::new(Upstreams::new(
|
||||
"http://127.0.0.1:1".into(),
|
||||
format!("{}/transmission/rpc", transmission.uri()),
|
||||
))
|
||||
.expect("state");
|
||||
let state =
|
||||
AppState::new(Upstreams::new("http://127.0.0.1:1".into(), qbit.uri())).expect("state");
|
||||
|
||||
let body = report(state).await;
|
||||
assert_eq!(body["status"], "degraded");
|
||||
assert_eq!(body["prowlarr"]["status"], "unreachable");
|
||||
assert_eq!(body["transmission"]["status"], "ok");
|
||||
assert_eq!(body["qbit"]["status"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -263,13 +283,9 @@ mod tests {
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&prowlarr)
|
||||
.await;
|
||||
let transmission = MockServer::start().await;
|
||||
let qbit = MockServer::start().await;
|
||||
|
||||
let state = AppState::new(Upstreams::new(
|
||||
prowlarr.uri(),
|
||||
format!("{}/transmission/rpc", transmission.uri()),
|
||||
))
|
||||
.expect("state");
|
||||
let state = AppState::new(Upstreams::new(prowlarr.uri(), qbit.uri())).expect("state");
|
||||
|
||||
let body = report(state).await;
|
||||
assert_eq!(body["prowlarr"]["status"], "unreachable");
|
||||
@@ -278,7 +294,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_tmdb_probe_never_echoes_the_api_key() {
|
||||
let (prowlarr, transmission) = upstreams_up().await;
|
||||
let (prowlarr, qbit) = upstreams_up().await;
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/configuration"))
|
||||
@@ -287,12 +303,9 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let state = AppState::new(
|
||||
Upstreams::new(
|
||||
prowlarr.uri(),
|
||||
format!("{}/transmission/rpc", transmission.uri()),
|
||||
)
|
||||
.with_tmdb_url(tmdb.uri())
|
||||
.with_tmdb_api_key(Some("super-secret".into())),
|
||||
Upstreams::new(prowlarr.uri(), qbit.uri())
|
||||
.with_tmdb_url(tmdb.uri())
|
||||
.with_tmdb_api_key(Some("super-secret".into())),
|
||||
)
|
||||
.expect("state");
|
||||
|
||||
@@ -359,6 +372,23 @@ 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/extract", "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 +403,12 @@ mod tests {
|
||||
"AttentionQueues",
|
||||
"SeriesAttention",
|
||||
"Series",
|
||||
"Subtitle",
|
||||
"SubtitleCandidate",
|
||||
"SubtitleSearchResults",
|
||||
"SubtitleStatus",
|
||||
"MissingSubtitle",
|
||||
"EpisodeSubtitleStatus",
|
||||
] {
|
||||
assert!(
|
||||
json["components"]["schemas"][schema].is_object(),
|
||||
|
||||
+191
-22
@@ -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,122 @@ 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
|
||||
/// a folder that was sitting exactly where the operator left it. A root is
|
||||
/// a database row and nothing makes its directory exist; `rename` reports
|
||||
/// a missing destination parent as the same `NotFound` as a missing
|
||||
/// source. Every earlier test pointed the destination at a `tempdir`,
|
||||
/// which is why the assumption was never exercised.
|
||||
#[tokio::test]
|
||||
async fn a_root_whose_directory_does_not_exist_yet_still_receives_the_title() {
|
||||
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 parent = tempfile::tempdir().expect("media tree");
|
||||
let folder = library_on_disk(&state, id, source.path()).await;
|
||||
|
||||
// Configured, but never written to: the row exists, the folder does not.
|
||||
let destination = parent.path().join("movies").join("kids");
|
||||
assert!(!destination.exists());
|
||||
point_root_at(&state, 2, &destination).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/movies/{id}"))
|
||||
.json(&serde_json::json!({"root_id": 2}))
|
||||
.send()
|
||||
.await
|
||||
.expect("move root");
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"a root that has no folder yet is not a reason to refuse the move"
|
||||
);
|
||||
|
||||
assert!(!folder.exists(), "the folder left the old root");
|
||||
assert!(
|
||||
destination
|
||||
.join("Dune Part Two (2024) [tmdbid-693134]")
|
||||
.join("Dune Part Two (2024) [tmdbid-693134] - [2160p].mkv")
|
||||
.exists(),
|
||||
"the feature arrived in a root that had to be created for it"
|
||||
);
|
||||
|
||||
let path: String = sqlx::query_scalar(
|
||||
"SELECT path FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("media file row");
|
||||
assert!(
|
||||
std::path::Path::new(&path).starts_with(&destination),
|
||||
"the row follows the file: {path}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #228: a title with nothing on disk changes root with no
|
||||
/// filesystem work at all — the seeded root paths do not even exist.
|
||||
#[tokio::test]
|
||||
@@ -1780,22 +1939,25 @@ mod tests {
|
||||
|
||||
/// Issue #228: if the rename fails, the row must not change — the
|
||||
/// operator sees the title where its files actually are and can retry.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn a_failed_rename_leaves_the_row_alone() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if !crate::privilege::mode_bits_bind().await {
|
||||
crate::privilege::skipped_because_privileged("a_failed_rename_leaves_the_row_alone");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
// A destination whose parent does not exist makes the rename itself
|
||||
// fail while the collision pre-check still passes.
|
||||
point_root_at(
|
||||
&state,
|
||||
2,
|
||||
&destination.path().join("missing").join("library"),
|
||||
)
|
||||
.await;
|
||||
point_root_at(&state, 2, destination.path()).await;
|
||||
tokio::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o555))
|
||||
.await
|
||||
.expect("freeze the title folder");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/movies/{id}"))
|
||||
@@ -1804,7 +1966,14 @@ mod tests {
|
||||
.await
|
||||
.expect("move root");
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body: serde_json::Value = response.json().await.expect("error body");
|
||||
let error = body["error"].as_str().expect("error text");
|
||||
assert!(error.contains(folder.to_str().expect("utf-8 source")));
|
||||
assert!(error.contains(destination.path().to_str().expect("utf-8 destination")));
|
||||
|
||||
tokio::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755))
|
||||
.await
|
||||
.expect("thaw the title folder");
|
||||
assert!(folder.exists(), "the folder never left the old root");
|
||||
let (root_id, path): (i64, String) = sqlx::query_as(
|
||||
"SELECT m.root_id, f.path FROM movies m
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Whether mode bits actually constrain this process.
|
||||
//!
|
||||
//! Four tests force a filesystem failure by freezing a directory to `0o555`
|
||||
//! and asserting the handler reports it. Mode bits do not constrain a
|
||||
//! privileged user, so under `root` — which is what the CI container runs
|
||||
//! as — the operation succeeds and the assertion fails for a reason that has
|
||||
//! nothing to do with the code under test.
|
||||
//!
|
||||
//! The probe asks the filesystem rather than asking for the uid: what the
|
||||
//! tests depend on is the refusal, not the identity, and a container can
|
||||
//! hold `CAP_DAC_OVERRIDE` without being uid 0.
|
||||
|
||||
/// True when a read-only directory refuses a write to this process.
|
||||
pub(crate) async fn mode_bits_bind() -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let probe = tempfile::tempdir().expect("probe root");
|
||||
let frozen = probe.path().join("frozen");
|
||||
tokio::fs::create_dir(&frozen).await.expect("probe folder");
|
||||
tokio::fs::set_permissions(&frozen, std::fs::Permissions::from_mode(0o555))
|
||||
.await
|
||||
.expect("freeze the probe folder");
|
||||
let refused = tokio::fs::write(frozen.join("probe"), b"x").await.is_err();
|
||||
// `TempDir::drop` needs the write back to remove the tree
|
||||
tokio::fs::set_permissions(&frozen, std::fs::Permissions::from_mode(0o755))
|
||||
.await
|
||||
.expect("thaw the probe folder");
|
||||
refused
|
||||
}
|
||||
|
||||
/// Announces a test that cannot run here, on stderr, which `nextest` prints
|
||||
/// when the run is given `--no-capture` and swallows otherwise — the same
|
||||
/// deal every other skipped case in a Rust suite gets.
|
||||
pub(crate) fn skipped_because_privileged(test: &str) {
|
||||
eprintln!("{test}: skipped — this process overrides mode bits, so the failure it forces cannot happen");
|
||||
}
|
||||
+161
-66
@@ -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
|
||||
@@ -60,15 +72,6 @@ pub(crate) struct Relocation {
|
||||
created_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Whether the destination root is a directory that must already be there.
|
||||
/// A title moves into another configured root, which exists; a root moving to
|
||||
/// a new path is moving somewhere that need not exist yet.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Destination {
|
||||
Existing,
|
||||
Create,
|
||||
}
|
||||
|
||||
/// Rename the title's folders into the new root. Called before the row is
|
||||
/// written, and only when the root actually changes.
|
||||
///
|
||||
@@ -91,7 +94,7 @@ pub(crate) async fn relocate_title(
|
||||
let old_root = root_path(state, old_root_id).await?;
|
||||
let new_root = root_path(state, new_root_id).await?;
|
||||
let files = title_files(state, kind, title_id).await?;
|
||||
relocate_files(&files, &old_root, &new_root, Destination::Existing).await
|
||||
relocate_files(&files, &old_root, &new_root).await
|
||||
}
|
||||
|
||||
/// Rename every title folder under a root into the root's new path, for a
|
||||
@@ -114,21 +117,20 @@ pub(crate) async fn relocate_root(
|
||||
new_path: &str,
|
||||
) -> Result<Relocation, ApiError> {
|
||||
let files = root_files(state, root_id).await?;
|
||||
relocate_files(&files, old_path, new_path, Destination::Create).await
|
||||
relocate_files(&files, old_path, new_path).await
|
||||
}
|
||||
|
||||
/// The one mover both callers share: plan every rename, refuse every
|
||||
/// destination that already exists, then perform them, undoing what was
|
||||
/// performed if one fails.
|
||||
async fn relocate_files(
|
||||
files: &[(i64, String)],
|
||||
files: &[(FileTable, i64, String)],
|
||||
old_root: &str,
|
||||
new_root: &str,
|
||||
destination: Destination,
|
||||
) -> Result<Relocation, ApiError> {
|
||||
let mut renames: Vec<PlannedRename> = Vec::new();
|
||||
let mut rewrites: Vec<(i64, String)> = Vec::new();
|
||||
for (file_id, path) in files {
|
||||
let 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.
|
||||
@@ -154,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
|
||||
@@ -187,7 +189,10 @@ async fn relocate_files(
|
||||
// that already existed on disk. Recorded before `create_dir_all`, since
|
||||
// afterwards there is no way to tell which levels it made.
|
||||
let mut created_dirs: Vec<PathBuf> = Vec::new();
|
||||
if destination == Destination::Create && !renames.is_empty() {
|
||||
// A configured root need not exist on disk. Without creating it here,
|
||||
// `rename` reports its missing parent as the same `NotFound` as a missing
|
||||
// source and sends the operator looking at the wrong end of the move.
|
||||
if !renames.is_empty() {
|
||||
created_dirs = missing_levels(std::path::Path::new(new_root)).await;
|
||||
if let Err(error) = tokio::fs::create_dir_all(new_root).await {
|
||||
return Err(ApiError::Filesystem(format!(
|
||||
@@ -265,9 +270,13 @@ async fn failed(
|
||||
}
|
||||
.undo()
|
||||
.await;
|
||||
// Name both ends. `rename` returns NotFound for a missing destination
|
||||
// parent as readily as for a missing source, and naming only the source
|
||||
// sent an operator looking for a file that was sitting where they left it.
|
||||
ApiError::Filesystem(format!(
|
||||
"could not move '{}': {error}",
|
||||
rename.source.display()
|
||||
"could not move '{}' to '{}': {error}",
|
||||
rename.source.display(),
|
||||
rename.destination.display()
|
||||
))
|
||||
}
|
||||
|
||||
@@ -278,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(())
|
||||
}
|
||||
@@ -349,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
|
||||
@@ -365,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!(
|
||||
@@ -381,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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub struct Root {
|
||||
/// The payload for creating or replacing a root.
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct RootInput {
|
||||
/// `movie` or `tv` — the Transmission label and layout prefix (§7.1, §7.4).
|
||||
/// `movie` or `tv` — the qBittorrent label and layout prefix (§7.1, §7.4).
|
||||
pub kind: String,
|
||||
/// `main` or `kids`.
|
||||
pub audience: String,
|
||||
@@ -1048,6 +1048,13 @@ mod tests {
|
||||
async fn one_folder_that_cannot_move_puts_the_others_back() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if !crate::privilege::mode_bits_bind().await {
|
||||
crate::privilege::skipped_because_privileged(
|
||||
"one_folder_that_cannot_move_puts_the_others_back",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let home = tempfile::tempdir().expect("home of the new path");
|
||||
@@ -1125,6 +1132,13 @@ mod tests {
|
||||
async fn a_new_root_that_already_existed_survives_a_failed_move() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if !crate::privilege::mode_bits_bind().await {
|
||||
crate::privilege::skipped_because_privileged(
|
||||
"a_new_root_that_already_existed_survives_a_failed_move",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let new = tempfile::tempdir().expect("new root, already there");
|
||||
@@ -1393,6 +1407,13 @@ mod tests {
|
||||
async fn a_failed_move_removes_every_level_it_created() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if !crate::privilege::mode_bits_bind().await {
|
||||
crate::privilege::skipped_because_privileged(
|
||||
"a_failed_move_removes_every_level_it_created",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let (_dir, state, base) = application().await;
|
||||
let old = tempfile::tempdir().expect("old root");
|
||||
let home = tempfile::tempdir().expect("home of the new path");
|
||||
|
||||
+309
-18
@@ -65,9 +65,11 @@ pub struct Series {
|
||||
pub metadata_refreshed_at: Option<String>,
|
||||
/// `airing`, `incomplete`, `waiting`, `complete` or `ended` (§4.2).
|
||||
pub status: String,
|
||||
/// Episodes currently marked wanted (§4.1 — the only intent).
|
||||
pub wanted_episodes: i64,
|
||||
/// Wanted episodes already on disk.
|
||||
/// Episodes this series accounts for: wanted, on disk, or both (§4.1).
|
||||
/// A season grabbed once and then untracked keeps its files counted —
|
||||
/// nothing wanted is not the same as nothing there.
|
||||
pub total_episodes: i64,
|
||||
/// Counted episodes already on disk.
|
||||
pub available_episodes: i64,
|
||||
}
|
||||
|
||||
@@ -317,10 +319,15 @@ fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime)
|
||||
// too. A series reading `complete` next to `42/52 eps` is the confusion
|
||||
// this avoids. The season number rides on each episode (#131), so no
|
||||
// parallel slice can be forgotten.
|
||||
let wanted = episodes
|
||||
.iter()
|
||||
.filter(|episode| episode.wanted && episode.season_number != 0);
|
||||
let available = wanted
|
||||
//
|
||||
// The denominator is wanted-or-on-disk, not wanted alone. Untracking a
|
||||
// season clears `wanted` on every episode (§4.1) while the files stay,
|
||||
// so counting intent alone reads `0/0 eps` next to a season of green
|
||||
// check glyphs.
|
||||
let counted = episodes.iter().filter(|episode| {
|
||||
episode.season_number != 0 && (episode.wanted || episode.state == MediaState::Available)
|
||||
});
|
||||
let available = counted
|
||||
.clone()
|
||||
.filter(|episode| episode.state == MediaState::Available);
|
||||
Series {
|
||||
@@ -339,7 +346,7 @@ fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime)
|
||||
vote_average: row.vote_average,
|
||||
metadata_refreshed_at: row.metadata_refreshed_at.clone(),
|
||||
status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(),
|
||||
wanted_episodes: i64::try_from(wanted.count()).unwrap_or(i64::MAX),
|
||||
total_episodes: i64::try_from(counted.count()).unwrap_or(i64::MAX),
|
||||
available_episodes: i64::try_from(available.count()).unwrap_or(i64::MAX),
|
||||
}
|
||||
}
|
||||
@@ -708,8 +715,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 +726,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 +847,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.
|
||||
@@ -1602,7 +1655,7 @@ pub async fn season_releases(
|
||||
/// A grab for this season that downloaded in full and was then condemned at
|
||||
/// import (#227, §5.7).
|
||||
///
|
||||
/// The torrent stays at 100% in Transmission — §7.3 leaves that lifecycle to
|
||||
/// The torrent stays at 100% in qBittorrent — §7.3 leaves that lifecycle to
|
||||
/// the reaper — the release is blacklisted and every episode it was covering
|
||||
/// reopens as a gap. Nothing on screen connected the two, so the season read
|
||||
/// `0/10` as though no grab had ever been tried. Every fact is already
|
||||
@@ -1652,6 +1705,22 @@ async fn season_import_failures(
|
||||
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
|
||||
)
|
||||
)
|
||||
-- A later attempt owns the headline while it is still in
|
||||
-- flight. Keep the failed grab recorded; it is simply no
|
||||
-- longer the current explanation for the gap.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM grabs newer
|
||||
WHERE newer.target_kind = 'season'
|
||||
AND newer.target_id = g.target_id
|
||||
-- 'vanished' is not a live attempt either: the
|
||||
-- torrent left qBittorrent, so letting it take the
|
||||
-- headline would leave the season saying nothing at
|
||||
-- all about a gap that still exists.
|
||||
AND newer.state NOT IN ('failed', 'vanished')
|
||||
AND (newer.grabbed_at > coalesce(g.failed_at, g.grabbed_at)
|
||||
OR (newer.grabbed_at = coalesce(g.failed_at, g.grabbed_at)
|
||||
AND newer.id > g.id))
|
||||
)
|
||||
ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id"#,
|
||||
series_id
|
||||
)
|
||||
@@ -1725,7 +1794,7 @@ pub struct SeasonPackState {
|
||||
/// #227. The last pack that downloaded in full and was then condemned at
|
||||
/// import, while the season is still waiting for a file. A deck that
|
||||
/// cannot say this leaves the season reading as though nothing was ever
|
||||
/// tried, with the torrent still sitting at 100% in Transmission.
|
||||
/// tried, with the torrent still sitting at 100% in qBittorrent.
|
||||
pub import_failure: Option<ImportFailure>,
|
||||
}
|
||||
|
||||
@@ -1770,9 +1839,12 @@ pub async fn season_pack_state(
|
||||
.await?;
|
||||
// The same failed-pack tally the grab lane backs off on (#181), read
|
||||
// here so the deck can name the window instead of guessing at one.
|
||||
// `last_failed_at` really is the failure time (#245): `reopens_at` and
|
||||
// `pack_retry_at` below hand it to the deck, so an alias holding a grab
|
||||
// time would put a grab under a name §5.7 gave to something else.
|
||||
let failed = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(grabbed_at) AS "last_failed_at?: String"
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
|
||||
FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||
season_id
|
||||
@@ -2054,6 +2126,7 @@ mod tests {
|
||||
/// season read `0/10` with nothing anywhere saying a grab had been tried.
|
||||
/// The season row, its deck and the deck's blacklisted release now each
|
||||
/// carry the failure and the reason it failed on.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
#[tokio::test]
|
||||
async fn an_abandoned_pack_is_visible_on_the_season_and_its_deck() {
|
||||
let (_dir, state, base) = application().await;
|
||||
@@ -2112,6 +2185,49 @@ mod tests {
|
||||
.await
|
||||
.expect("failed pack grab");
|
||||
|
||||
// A newer attempt supersedes the old failure while it is in flight.
|
||||
let newer_name = "Rick.And.Morty.S08.720p.WEB-DL.x264-NEW";
|
||||
let newer_release_id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, 'new-pack', ?, 1000, 'url', ?, 'eligible') RETURNING id",
|
||||
)
|
||||
.bind(newer_name)
|
||||
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("newer release");
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
VALUES (?, 'season', ?, 'new123', 'sent', '2026-01-03T00:00:00.000Z')",
|
||||
)
|
||||
.bind(newer_release_id)
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("newer in-flight grab");
|
||||
|
||||
let seasons = seasons_json(&base, series_id).await;
|
||||
assert!(
|
||||
seasons[0]["import_failure"].is_null(),
|
||||
"a newer in-flight grab supersedes the old failure"
|
||||
);
|
||||
let pack_state: serde_json::Value = reqwest::get(format!(
|
||||
"{base}/api/series/{series_id}/seasons/8/pack-state"
|
||||
))
|
||||
.await
|
||||
.expect("pack state")
|
||||
.json()
|
||||
.await
|
||||
.expect("pack state json");
|
||||
assert!(
|
||||
pack_state["import_failure"].is_null(),
|
||||
"the pack-state endpoint uses the same supersession rule"
|
||||
);
|
||||
sqlx::query("DELETE FROM grabs WHERE infohash = 'new123'")
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("remove test attempt");
|
||||
|
||||
// The failure with no blacklist row yet: still a failure, and the
|
||||
// reason is simply not known. Rows written before the blacklist
|
||||
// carried one read this way and must keep rendering.
|
||||
@@ -3000,7 +3116,7 @@ mod tests {
|
||||
listed[0]["status"], "incomplete",
|
||||
"§4.2: an aired wanted episode with no file"
|
||||
);
|
||||
assert_eq!(listed[0]["wanted_episodes"], 1);
|
||||
assert_eq!(listed[0]["total_episodes"], 1);
|
||||
assert_eq!(listed[0]["available_episodes"], 0);
|
||||
|
||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
|
||||
@@ -3080,8 +3196,11 @@ mod tests {
|
||||
.await
|
||||
.expect("release");
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
VALUES (?, 'season', ?, 'hash', 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
|
||||
grabbed_at, failed_at)
|
||||
VALUES (?, 'season', ?, 'hash', 'failed',
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(season_id)
|
||||
@@ -3097,6 +3216,26 @@ mod tests {
|
||||
"the deck offers a date, not just a closed door"
|
||||
);
|
||||
|
||||
// §6.2/#245: push the grab five weeks back and leave the failure
|
||||
// where it is. The deck reads the failure, so the window it names is
|
||||
// unmoved — anchored on the grab it would have expired long ago and
|
||||
// the deck would claim the lane was open.
|
||||
sqlx::query(
|
||||
"UPDATE grabs SET grabbed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-35 days')
|
||||
WHERE target_kind = 'season' AND target_id = ?",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("age the grab");
|
||||
let stalled = state_of(url.clone()).await;
|
||||
assert_eq!(stalled["lane"], "per_episode");
|
||||
assert_eq!(stalled["reason"], "pack_backoff");
|
||||
assert_eq!(
|
||||
stalled["pack_retry_at"], quiet["pack_retry_at"],
|
||||
"the window is anchored on the failure, so aging the grab moves nothing"
|
||||
);
|
||||
|
||||
// §14 outranks it: a pack would re-import what is on disk, so
|
||||
// clearing the failure would not earn a pack anyway.
|
||||
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, '/library/e01.mkv', 1)")
|
||||
@@ -3649,6 +3788,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"))
|
||||
@@ -3851,6 +4026,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]
|
||||
|
||||
+160
-5
@@ -1,11 +1,16 @@
|
||||
//! 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::time::Duration;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::sync::{atomic::AtomicU64, Arc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use arr_db::Db;
|
||||
use arr_dl::QbitClient;
|
||||
use arr_probe::Extractor;
|
||||
use arr_subs::{Backend, OpenAiEndpoint, Provider, Syncer};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::jellyfin::JellyfinClient;
|
||||
|
||||
@@ -13,6 +18,12 @@ 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";
|
||||
|
||||
type DownloadSnapshot = Arc<RwLock<Option<(Instant, Vec<arr_dl::Torrent>)>>>;
|
||||
|
||||
/// 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);
|
||||
@@ -22,7 +33,7 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
pub struct Upstreams {
|
||||
pub prowlarr_url: String,
|
||||
pub prowlarr_api_key: Option<String>,
|
||||
pub transmission_url: String,
|
||||
pub qbittorrent_url: String,
|
||||
pub tmdb_url: String,
|
||||
pub tmdb_api_key: Option<String>,
|
||||
}
|
||||
@@ -30,11 +41,11 @@ pub struct Upstreams {
|
||||
impl Upstreams {
|
||||
/// Every upstream at its documented default, no keys.
|
||||
#[must_use]
|
||||
pub fn new(prowlarr_url: String, transmission_url: String) -> Self {
|
||||
pub fn new(prowlarr_url: String, qbittorrent_url: String) -> Self {
|
||||
Self {
|
||||
prowlarr_url,
|
||||
prowlarr_api_key: None,
|
||||
transmission_url,
|
||||
qbittorrent_url,
|
||||
tmdb_url: DEFAULT_TMDB_URL.to_string(),
|
||||
tmdb_api_key: None,
|
||||
}
|
||||
@@ -78,7 +89,28 @@ 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,
|
||||
/// Runs that same `ffmpeg` to write an embedded text track out as a
|
||||
/// sidecar (§15, #260). Derived from `ffmpeg_binary` rather than set on
|
||||
/// its own, so the lamp and the extraction can never disagree about
|
||||
/// which binary this deployment has.
|
||||
extractor: Extractor,
|
||||
jellyfin: Option<JellyfinClient>,
|
||||
syncer: Syncer,
|
||||
qbit: Option<QbitClient>,
|
||||
download_snapshot: DownloadSnapshot,
|
||||
}
|
||||
|
||||
/// Work explicitly requested through the movie API.
|
||||
@@ -149,7 +181,16 @@ 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(),
|
||||
extractor: Extractor::default(),
|
||||
jellyfin: None,
|
||||
syncer: Syncer::default(),
|
||||
qbit: None,
|
||||
download_snapshot: Arc::new(RwLock::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,6 +201,56 @@ impl AppState {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach the shared qBittorrent client used by the API's live download
|
||||
/// snapshot endpoint.
|
||||
#[must_use]
|
||||
pub fn with_qbit(mut self, qbit: QbitClient) -> Self {
|
||||
self.qbit = Some(qbit);
|
||||
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 +263,62 @@ 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 — the health lamps (#200)
|
||||
/// probe it and the extract lane (#260) runs it. Defaults to resolving
|
||||
/// `ffmpeg` from `PATH`.
|
||||
#[must_use]
|
||||
pub fn with_ffmpeg_binary(mut self, binary: impl Into<OsString>) -> Self {
|
||||
let binary = binary.into();
|
||||
self.extractor = Extractor::new().with_binary(binary.clone());
|
||||
self.ffmpeg_binary = binary;
|
||||
self
|
||||
}
|
||||
|
||||
/// The configured `ffmpeg` binary.
|
||||
pub(crate) fn ffmpeg_binary(&self) -> &OsStr {
|
||||
&self.ffmpeg_binary
|
||||
}
|
||||
|
||||
pub(crate) fn syncer(&self) -> &Syncer {
|
||||
&self.syncer
|
||||
}
|
||||
|
||||
pub(crate) fn extractor(&self) -> &Extractor {
|
||||
&self.extractor
|
||||
}
|
||||
|
||||
/// Wait for the next manual movie action in the daemon's reconcile loop.
|
||||
///
|
||||
/// # Errors
|
||||
@@ -221,6 +368,14 @@ impl AppState {
|
||||
self.database.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn qbit(&self) -> Option<&QbitClient> {
|
||||
self.qbit.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn download_snapshot(&self) -> &DownloadSnapshot {
|
||||
&self.download_snapshot
|
||||
}
|
||||
|
||||
pub(crate) fn send_movie_command(
|
||||
&self,
|
||||
command: MovieCommand,
|
||||
|
||||
@@ -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"],
|
||||
"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"])
|
||||
);
|
||||
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
@@ -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() {
|
||||
|
||||
@@ -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) => {
|
||||
@@ -252,7 +257,7 @@ pub enum MediaState {
|
||||
Missing,
|
||||
Downloading,
|
||||
Available,
|
||||
/// #108: `wanted` was cleared after the grab vanished from Transmission.
|
||||
/// #108: `wanted` was cleared after the grab vanished from qBittorrent.
|
||||
/// Distinct from `Missing` so it does not read as an open gap.
|
||||
Parked,
|
||||
}
|
||||
@@ -318,9 +323,74 @@ 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)
|
||||
}
|
||||
|
||||
/// The inverse of [`Display`](fmt::Display): read back a codec that was
|
||||
/// written out under its `ffprobe` name. `ffprobe`'s own aliases are
|
||||
/// accepted alongside, so a column written by an older probe still
|
||||
/// resolves. Anything unrecognised is [`Self::Other`], which extracts
|
||||
/// nothing — the same answer as a bitmap track.
|
||||
#[must_use]
|
||||
pub fn from_probe_name(name: &str) -> Self {
|
||||
match name {
|
||||
"subrip" | "srt" => Self::SubRip,
|
||||
"ass" | "ssa" => Self::Ass,
|
||||
"mov_text" => Self::MovText,
|
||||
"hdmv_pgs_subtitle" => Self::Pgs,
|
||||
"dvd_subtitle" => Self::VobSub,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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)]
|
||||
@@ -437,7 +507,7 @@ pub struct MovieOwner {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DolbyVisionProfile, HdrFormat, Language, Rule, Verdict};
|
||||
use super::{DolbyVisionProfile, HdrFormat, Language, Rule, SubtitleCodec, Verdict};
|
||||
|
||||
#[test]
|
||||
fn portuguese_variants_are_distinct() {
|
||||
@@ -459,6 +529,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_names_round_trip_through_the_codec() {
|
||||
for codec in [
|
||||
SubtitleCodec::SubRip,
|
||||
SubtitleCodec::Ass,
|
||||
SubtitleCodec::MovText,
|
||||
SubtitleCodec::Pgs,
|
||||
SubtitleCodec::VobSub,
|
||||
] {
|
||||
assert_eq!(SubtitleCodec::from_probe_name(&codec.to_string()), codec);
|
||||
}
|
||||
assert_eq!(SubtitleCodec::from_probe_name("srt"), SubtitleCodec::SubRip);
|
||||
assert_eq!(SubtitleCodec::from_probe_name("ssa"), SubtitleCodec::Ass);
|
||||
assert_eq!(
|
||||
SubtitleCodec::from_probe_name("dvb_subtitle"),
|
||||
SubtitleCodec::Other
|
||||
);
|
||||
assert!(!SubtitleCodec::from_probe_name("other").is_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_eligible_verdicts_name_the_rule() {
|
||||
let rule = Rule::DolbyVisionProfile(DolbyVisionProfile {
|
||||
|
||||
@@ -237,6 +237,16 @@ impl PolicyRule for SourceRule {
|
||||
/// automatically and its import is recorded as a §5.7 waiver. This mirrors
|
||||
/// [`ResolutionRule`], where an override moves a failure between hard and
|
||||
/// soft and never makes the rule stop applying.
|
||||
///
|
||||
/// **The floor only ever rejects pre-grab.** It exists to keep the ranking
|
||||
/// from picking mud when something better is in the same search (§5.5), and
|
||||
/// that job is finished once a release is grabbed: at import there is
|
||||
/// nothing left to choose between, so a hard fail there condemns a file
|
||||
/// already on disk and blacklists the release under §5.7. Size is also not
|
||||
/// post-download evidence — §5.6 gives `ffprobe` real audio, HDR, codec and
|
||||
/// duration, and the size was on the release before the grab. So after
|
||||
/// download the floor is a soft fail whatever the overrides say: the file
|
||||
/// imports and carries a waiver naming the floor it missed.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SizeRule;
|
||||
|
||||
@@ -254,7 +264,10 @@ impl PolicyRule for SizeRule {
|
||||
context.runtime_minutes,
|
||||
) {
|
||||
None => RuleEvaluation::Unknown(RuleKind::Size),
|
||||
Some(true) if context.overrides.allow_below_floor => {
|
||||
Some(true)
|
||||
if context.overrides.allow_below_floor
|
||||
|| context.candidate.phase() == EvaluationPhase::PostDownload =>
|
||||
{
|
||||
RuleEvaluation::SoftFail(Rule::Size)
|
||||
}
|
||||
Some(true) => RuleEvaluation::HardFail(Rule::Size),
|
||||
@@ -752,6 +765,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The floor is a selection filter (§5.5), so it only rejects while
|
||||
/// there is still a selection to make. A pack accepted pre-grab on its
|
||||
/// per-episode average carries episodes that individually fall short;
|
||||
/// hard-failing them at import blacklists a release the operator chose
|
||||
/// and reopens every episode, over a number that was known before the
|
||||
/// grab. Post-download the floor waives instead, with no override.
|
||||
#[test]
|
||||
fn below_the_floor_after_download_waives_rather_than_condemning_the_release() {
|
||||
let policy = banded_policy();
|
||||
let overrides = TitleOverrides::default();
|
||||
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
|
||||
|
||||
// Pre-grab the same size is still a rejection: nothing is on disk
|
||||
// yet and a better candidate may be one row down.
|
||||
assert_eq!(
|
||||
evaluate(
|
||||
&policy,
|
||||
&overrides,
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(1 << 30),
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Rejected(Rule::Size)
|
||||
);
|
||||
|
||||
let media = probed(Resolution::R1080p, Some(Source::WebDl));
|
||||
assert_eq!(
|
||||
evaluate(
|
||||
&policy,
|
||||
&overrides,
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
Some(1 << 30),
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Waived(Rule::Size)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_below_floor_says_nothing_about_a_release_that_clears_the_floor() {
|
||||
let overrides = TitleOverrides {
|
||||
@@ -1241,6 +1298,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)),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -36,7 +47,6 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
|
||||
|
||||
+215
-25
@@ -1,5 +1,5 @@
|
||||
//! §9.5 *broken* → the operator alone: Prowlarr, Transmission or TMDB
|
||||
//! unreachable.
|
||||
//! §9.5 *broken* → the operator alone: Prowlarr, qBittorrent or TMDB
|
||||
//! 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 —
|
||||
@@ -24,57 +24,126 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
pub struct Upstreams {
|
||||
pub prowlarr_url: String,
|
||||
pub prowlarr_api_key: Option<String>,
|
||||
pub transmission_url: String,
|
||||
pub qbittorrent_url: String,
|
||||
pub tmdb_url: String,
|
||||
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, qbit, tmdb, subtitles) = tokio::join!(
|
||||
self.probe_prowlarr(),
|
||||
self.probe_transmission(),
|
||||
self.probe_qbit(),
|
||||
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("qbit", qbit).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
|
||||
@@ -109,13 +178,13 @@ impl BrokenAction {
|
||||
matches!(request.send().await, Ok(response) if response.status().is_success())
|
||||
}
|
||||
|
||||
/// Transmission answers an RPC call without a session id with `409` plus
|
||||
/// qBittorrent answers an RPC call without a session id with `409` plus
|
||||
/// the id to retry with. That is a live daemon, so it counts as
|
||||
/// reachable.
|
||||
async fn probe_transmission(&self) -> bool {
|
||||
async fn probe_qbit(&self) -> bool {
|
||||
let request = self
|
||||
.http
|
||||
.post(&self.upstreams.transmission_url)
|
||||
.post(&self.upstreams.qbittorrent_url)
|
||||
.timeout(PROBE_TIMEOUT)
|
||||
.json(&serde_json::json!({ "method": "session-get" }));
|
||||
matches!(
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,24 +235,50 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn upstreams(prowlarr_url: String, transmission_url: String) -> Upstreams {
|
||||
fn upstreams(prowlarr_url: String, qbittorrent_url: String) -> Upstreams {
|
||||
Upstreams {
|
||||
prowlarr_url,
|
||||
prowlarr_api_key: None,
|
||||
transmission_url,
|
||||
qbittorrent_url,
|
||||
tmdb_url: "http://127.0.0.1:1".to_string(),
|
||||
tmdb_api_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
// No mock mounted: every request 404s, which counts as unreachable.
|
||||
let transmission = MockServer::start().await;
|
||||
let qbit = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(409))
|
||||
.mount(&transmission)
|
||||
.mount(&qbit)
|
||||
.await;
|
||||
|
||||
let ntfy = 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()),
|
||||
upstreams(prowlarr.uri(), qbit.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,105 @@ 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 enables OpenSubtitles, which is not 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 qbit = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(409))
|
||||
.mount(&qbit)
|
||||
.await;
|
||||
|
||||
let action = BrokenAction::new(
|
||||
upstreams(prowlarr.uri(), qbit.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(), 1, "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 qbit = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(409))
|
||||
.mount(&qbit)
|
||||
.await;
|
||||
|
||||
let subs = SubtitleUpstreams {
|
||||
alass_path: "/nowhere/alass".into(),
|
||||
..subtitles()
|
||||
};
|
||||
let action = BrokenAction::new(
|
||||
upstreams(prowlarr.uri(), qbit.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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+295
-17
@@ -20,7 +20,12 @@ pub const ENV_DATABASE_PATH: &str = "ARR_DATABASE_PATH";
|
||||
pub const ENV_MEDIA_ROOT: &str = "ARR_MEDIA_ROOT";
|
||||
pub const ENV_PROWLARR_URL: &str = "ARR_PROWLARR_URL";
|
||||
pub const ENV_PROWLARR_API_KEY: &str = "ARR_PROWLARR_API_KEY";
|
||||
pub const ENV_TRANSMISSION_URL: &str = "ARR_TRANSMISSION_URL";
|
||||
pub const ENV_QBITTORRENT_URL: &str = "ARR_QBITTORRENT_URL";
|
||||
// The WebUI password is a secret, so both halves are env-only (§10) and
|
||||
// neither has a `ConfigFile` field. Leaving them unset is valid: it is the
|
||||
// right shape for an instance that bypasses authentication for arr's address.
|
||||
pub const ENV_QBITTORRENT_USERNAME: &str = "ARR_QBITTORRENT_USERNAME";
|
||||
pub const ENV_QBITTORRENT_PASSWORD: &str = "ARR_QBITTORRENT_PASSWORD";
|
||||
pub const ENV_DOWNLOAD_DIR: &str = "ARR_DOWNLOAD_DIR";
|
||||
pub const ENV_SEED_RATIO_LIMIT: &str = "ARR_SEED_RATIO_LIMIT";
|
||||
pub const ENV_SEED_IDLE_LIMIT_MINUTES: &str = "ARR_SEED_IDLE_LIMIT_MINUTES";
|
||||
@@ -30,22 +35,42 @@ 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";
|
||||
// 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";
|
||||
|
||||
pub const DEFAULT_BIND_ADDR: &str = "0.0.0.0:7878";
|
||||
pub const DEFAULT_DATABASE_PATH: &str = "arr.db";
|
||||
pub const DEFAULT_MEDIA_ROOT: &str = "/mnt/media";
|
||||
pub const DEFAULT_PROWLARR_URL: &str = "http://localhost:9696";
|
||||
pub const DEFAULT_TRANSMISSION_URL: &str = "http://localhost:9091/transmission/rpc";
|
||||
/// Transmission's own view of the download directory (DESIGN.md §3). It
|
||||
pub const DEFAULT_QBITTORRENT_URL: &str = "http://localhost:8080";
|
||||
/// qBittorrent's own view of the download directory (DESIGN.md §3). It
|
||||
/// shares the media dataset with the library so hardlinks work (§7.2).
|
||||
pub const DEFAULT_DOWNLOAD_DIR: &str = "/mnt/media/transmission/complete";
|
||||
pub const DEFAULT_DOWNLOAD_DIR: &str = "/mnt/media/qbittorrent/complete";
|
||||
/// Seeding obligation defaults (§7.3), applied to every torrent at add time
|
||||
/// and enforced by Transmission. Per-tracker rules are issue #25.
|
||||
/// and enforced by qBittorrent. Per-tracker rules are issue #25.
|
||||
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}")]
|
||||
@@ -77,7 +102,7 @@ struct ConfigFile {
|
||||
#[serde(default)]
|
||||
prowlarr_url: Option<String>,
|
||||
#[serde(default)]
|
||||
transmission_url: Option<String>,
|
||||
qbittorrent_url: Option<String>,
|
||||
#[serde(default)]
|
||||
download_dir: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
@@ -92,6 +117,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)]
|
||||
@@ -121,7 +156,9 @@ pub struct EnvOverrides {
|
||||
pub media_root: Option<String>,
|
||||
pub prowlarr_url: Option<String>,
|
||||
pub prowlarr_api_key: Option<String>,
|
||||
pub transmission_url: Option<String>,
|
||||
pub qbittorrent_url: Option<String>,
|
||||
pub qbittorrent_username: Option<String>,
|
||||
pub qbittorrent_password: Option<String>,
|
||||
pub download_dir: Option<String>,
|
||||
pub seed_ratio_limit: Option<String>,
|
||||
pub seed_idle_limit_minutes: Option<String>,
|
||||
@@ -131,6 +168,17 @@ pub struct EnvOverrides {
|
||||
pub jellyfin_api_key: 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 {
|
||||
@@ -142,7 +190,9 @@ impl EnvOverrides {
|
||||
media_root: std::env::var(ENV_MEDIA_ROOT).ok(),
|
||||
prowlarr_url: std::env::var(ENV_PROWLARR_URL).ok(),
|
||||
prowlarr_api_key: std::env::var(ENV_PROWLARR_API_KEY).ok(),
|
||||
transmission_url: std::env::var(ENV_TRANSMISSION_URL).ok(),
|
||||
qbittorrent_url: std::env::var(ENV_QBITTORRENT_URL).ok(),
|
||||
qbittorrent_username: std::env::var(ENV_QBITTORRENT_USERNAME).ok(),
|
||||
qbittorrent_password: std::env::var(ENV_QBITTORRENT_PASSWORD).ok(),
|
||||
download_dir: std::env::var(ENV_DOWNLOAD_DIR).ok(),
|
||||
seed_ratio_limit: std::env::var(ENV_SEED_RATIO_LIMIT).ok(),
|
||||
seed_idle_limit_minutes: std::env::var(ENV_SEED_IDLE_LIMIT_MINUTES).ok(),
|
||||
@@ -152,6 +202,17 @@ impl EnvOverrides {
|
||||
jellyfin_api_key: std::env::var(ENV_JELLYFIN_API_KEY).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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,8 +225,13 @@ pub struct Config {
|
||||
pub media_root: PathBuf,
|
||||
pub prowlarr_url: String,
|
||||
pub prowlarr_api_key: Option<String>,
|
||||
pub transmission_url: String,
|
||||
/// Where Transmission puts completed downloads, in Transmission's own
|
||||
pub qbittorrent_url: String,
|
||||
/// The `WebUI` login. `None` for an instance that bypasses authentication
|
||||
/// for arr's address; a username without a password (or the reverse) is
|
||||
/// treated as no credentials at all.
|
||||
pub qbittorrent_username: Option<String>,
|
||||
pub qbittorrent_password: Option<String>,
|
||||
/// Where qBittorrent puts completed downloads, in qBittorrent's own
|
||||
/// namespace (§7.1).
|
||||
pub download_dir: PathBuf,
|
||||
pub seed_ratio_limit: f64,
|
||||
@@ -182,6 +248,84 @@ pub struct Config {
|
||||
/// *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 +341,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)?,
|
||||
@@ -231,10 +376,12 @@ impl Config {
|
||||
.or(file.prowlarr_url)
|
||||
.unwrap_or_else(|| DEFAULT_PROWLARR_URL.to_string()),
|
||||
prowlarr_api_key: env.prowlarr_api_key,
|
||||
transmission_url: env
|
||||
.transmission_url
|
||||
.or(file.transmission_url)
|
||||
.unwrap_or_else(|| DEFAULT_TRANSMISSION_URL.to_string()),
|
||||
qbittorrent_url: env
|
||||
.qbittorrent_url
|
||||
.or(file.qbittorrent_url)
|
||||
.unwrap_or_else(|| DEFAULT_QBITTORRENT_URL.to_string()),
|
||||
qbittorrent_username: env.qbittorrent_username,
|
||||
qbittorrent_password: env.qbittorrent_password,
|
||||
download_dir: env
|
||||
.download_dir
|
||||
.map(PathBuf::from)
|
||||
@@ -263,6 +410,17 @@ impl Config {
|
||||
.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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -287,6 +445,28 @@ fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, ConfigErr
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// §10 keeps secrets out of the config file, so the `WebUI` login is
|
||||
/// env-only and absent by default — which is the shape an instance that
|
||||
/// bypasses authentication for arr's address wants.
|
||||
#[test]
|
||||
fn the_qbittorrent_login_is_env_only_and_optional() {
|
||||
let config = Config::resolve(EnvOverrides::default()).unwrap();
|
||||
assert_eq!(config.qbittorrent_username, None);
|
||||
assert_eq!(config.qbittorrent_password, None);
|
||||
|
||||
let config = Config::resolve(EnvOverrides {
|
||||
qbittorrent_username: Some("arr".into()),
|
||||
qbittorrent_password: Some("secret".into()),
|
||||
..EnvOverrides::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(config.qbittorrent_username.as_deref(), Some("arr"));
|
||||
|
||||
let error = toml::from_str::<ConfigFile>("qbittorrent_password = \"secret\"")
|
||||
.expect_err("the file has no field for it");
|
||||
assert!(error.to_string().contains("qbittorrent_password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_environment_resolves_to_defaults() {
|
||||
let config = Config::resolve(EnvOverrides::default()).unwrap();
|
||||
@@ -294,7 +474,7 @@ mod tests {
|
||||
assert_eq!(config.database_path, PathBuf::from(DEFAULT_DATABASE_PATH));
|
||||
assert_eq!(config.media_root, PathBuf::from(DEFAULT_MEDIA_ROOT));
|
||||
assert_eq!(config.prowlarr_url, DEFAULT_PROWLARR_URL);
|
||||
assert_eq!(config.transmission_url, DEFAULT_TRANSMISSION_URL);
|
||||
assert_eq!(config.qbittorrent_url, DEFAULT_QBITTORRENT_URL);
|
||||
assert_eq!(config.download_dir, PathBuf::from(DEFAULT_DOWNLOAD_DIR));
|
||||
assert!((config.seed_ratio_limit - DEFAULT_SEED_RATIO_LIMIT).abs() < f64::EPSILON);
|
||||
assert_eq!(
|
||||
@@ -308,6 +488,10 @@ mod tests {
|
||||
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]
|
||||
@@ -345,7 +529,7 @@ prowlarr_url = "http://prowlarr.internal:9696"
|
||||
assert_eq!(config.media_root, PathBuf::from("/tank/media"));
|
||||
assert_eq!(config.prowlarr_url, "http://prowlarr.internal:9696");
|
||||
// Untouched fields still default.
|
||||
assert_eq!(config.transmission_url, DEFAULT_TRANSMISSION_URL);
|
||||
assert_eq!(config.qbittorrent_url, DEFAULT_QBITTORRENT_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -376,6 +560,100 @@ 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.
|
||||
#[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();
|
||||
|
||||
+109
-228
@@ -1,5 +1,5 @@
|
||||
//! The grab pipeline: close the "wanted movie, no file" gap by searching,
|
||||
//! scoring, picking a winner and sending it to Transmission. See DESIGN.md
|
||||
//! scoring, picking a winner and sending it to qBittorrent. See DESIGN.md
|
||||
//! §5.4, §6.2, §7.1, §7.3 and §8.
|
||||
//!
|
||||
//! There is no grab delay and there is no job queue. The gap is recomputed
|
||||
@@ -7,7 +7,7 @@
|
||||
//! restarting converges instead of double-grabbing:
|
||||
//!
|
||||
//! - a title with a live `grabs` row is not a gap, so it is never re-searched;
|
||||
//! - Transmission's `torrent-add` is keyed on the infohash, so re-sending the
|
||||
//! - qBittorrent's `torrent-add` is keyed on the infohash, so re-sending the
|
||||
//! same release returns the torrent that is already there rather than a
|
||||
//! second one;
|
||||
//! - the `grabs` row is written from that response, so a crash between the add
|
||||
@@ -22,7 +22,7 @@ use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::score::{claimed_episode_count, score};
|
||||
use arr_core::{Language, Policy, TitleOverrides, Verdict};
|
||||
use arr_db::{blacklist, Blacklist, Db, MoviePolicy};
|
||||
use arr_dl::{AddTorrent, TorrentSource, TransmissionClient};
|
||||
use arr_dl::{AddTorrent, QbitClient, TorrentSource};
|
||||
use arr_indexer::{Download, ProwlarrClient, SearchRelease, SearchRequest};
|
||||
use arr_meta::TmdbClient;
|
||||
|
||||
@@ -37,7 +37,7 @@ const MOVIES_PER_TICK: i64 = 5;
|
||||
|
||||
/// Seeding obligations, per tracker in principle (§7.3) and per install in
|
||||
/// practice until issue #25 gives them a home. Both are set on the torrent at
|
||||
/// add time and enforced by Transmission.
|
||||
/// add time and enforced by qBittorrent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SeedingLimits {
|
||||
pub ratio: f64,
|
||||
@@ -77,8 +77,8 @@ pub enum GrabError {
|
||||
Metadata(#[from] arr_meta::Error),
|
||||
#[error("movie {0} has an invalid TMDB id")]
|
||||
InvalidTmdbId(i64),
|
||||
#[error("transmission: {0}")]
|
||||
Transmission(#[from] arr_dl::Error),
|
||||
#[error("qbit: {0}")]
|
||||
Qbit(#[from] arr_dl::Error),
|
||||
#[error("download link: {0}")]
|
||||
Download(#[from] arr_indexer::DownloadError),
|
||||
#[error("release {name}: {source}")]
|
||||
@@ -102,13 +102,13 @@ impl GrabAction {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
prowlarr: ProwlarrClient,
|
||||
transmission: TransmissionClient,
|
||||
qbit: QbitClient,
|
||||
download_dir: PathBuf,
|
||||
seeding: SeedingRules,
|
||||
) -> Self {
|
||||
Self {
|
||||
indexers: IndexerDirectory::new(prowlarr.clone()),
|
||||
grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding),
|
||||
grabber: Grabber::new(prowlarr.clone(), qbit, download_dir, seeding),
|
||||
prowlarr,
|
||||
tmdb: None,
|
||||
}
|
||||
@@ -427,7 +427,7 @@ impl GrabAction {
|
||||
|
||||
/// The manual one-click grab (§9.3, issue #107): the release is already
|
||||
/// chosen, so this skips search and scoring and sends it straight to
|
||||
/// Transmission.
|
||||
/// qBittorrent.
|
||||
pub(crate) async fn grab_release_now(
|
||||
&self,
|
||||
database: &Db,
|
||||
@@ -460,17 +460,17 @@ impl GrabAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sending a chosen release to Transmission and recording the grab.
|
||||
/// Sending a chosen release to qBittorrent and recording the grab.
|
||||
///
|
||||
/// Targeted search and RSS (§6.2) differ in how a title is chosen and in
|
||||
/// whether a failed attempt counts toward a backoff; from the winning
|
||||
/// release onward they are the same writes, so they share this.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Grabber {
|
||||
/// Resolves the winner's indexer link before it is sent on: Transmission
|
||||
/// Resolves the winner's indexer link before it is sent on: qBittorrent
|
||||
/// cannot reach Prowlarr and arr can (issue #100).
|
||||
prowlarr: ProwlarrClient,
|
||||
transmission: TransmissionClient,
|
||||
qbit: QbitClient,
|
||||
download_dir: PathBuf,
|
||||
seeding: SeedingRules,
|
||||
}
|
||||
@@ -532,22 +532,22 @@ impl GrabScope {
|
||||
impl Grabber {
|
||||
pub(crate) fn new(
|
||||
prowlarr: ProwlarrClient,
|
||||
transmission: TransmissionClient,
|
||||
qbit: QbitClient,
|
||||
download_dir: PathBuf,
|
||||
seeding: SeedingRules,
|
||||
) -> Self {
|
||||
Self {
|
||||
prowlarr,
|
||||
transmission,
|
||||
qbit,
|
||||
download_dir,
|
||||
seeding,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move grabs Transmission reports as complete out of `sent`, whatever
|
||||
/// Move grabs qBittorrent reports as complete out of `sent`, whatever
|
||||
/// they target.
|
||||
///
|
||||
/// Transmission is authoritative and its view is rebuilt on every tick
|
||||
/// qBittorrent is authoritative and its view is rebuilt on every tick
|
||||
/// rather than cached (§8), so this is also what reconstructs in-flight
|
||||
/// state after a restart. Both grab actions call it; whichever runs first
|
||||
/// does the work and the other finds nothing.
|
||||
@@ -564,7 +564,7 @@ impl Grabber {
|
||||
}
|
||||
|
||||
let torrents: HashMap<String, f64> = self
|
||||
.transmission
|
||||
.qbit
|
||||
.list_torrents()
|
||||
.await?
|
||||
.into_iter()
|
||||
@@ -603,7 +603,7 @@ impl Grabber {
|
||||
Ok(outcomes)
|
||||
}
|
||||
|
||||
/// §86/#108: a `sent` grab whose infohash Transmission no longer reports
|
||||
/// §86/#108: a `sent` grab whose infohash qBittorrent no longer reports
|
||||
/// — removed by hand, not a policy failure. Marked `vanished` rather than
|
||||
/// `failed` so it does not feed the `needs_decision` queue (attention.rs).
|
||||
/// Nothing is blacklisted, since the release itself never failed policy,
|
||||
@@ -624,10 +624,10 @@ impl Grabber {
|
||||
grab_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
"torrent vanished from Transmission; target parked"
|
||||
"torrent vanished from qBittorrent; target parked"
|
||||
);
|
||||
Ok(Outcome::new(
|
||||
format!("grab {grab_id} sent, torrent vanished from Transmission"),
|
||||
format!("grab {grab_id} sent, torrent vanished from qBittorrent"),
|
||||
format!("parked {target_kind} {target_id}"),
|
||||
))
|
||||
}
|
||||
@@ -646,12 +646,12 @@ impl Grabber {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the winner's indexer link and add it to Transmission.
|
||||
/// Resolve the winner's indexer link and add it to qBittorrent.
|
||||
///
|
||||
/// The link is resolved here rather than passed on, because Transmission
|
||||
/// The link is resolved here rather than passed on, because qBittorrent
|
||||
/// has no route to Prowlarr and cannot follow its redirect to a magnet
|
||||
/// (issue #100).
|
||||
async fn send_to_transmission(
|
||||
async fn send_to_qbit(
|
||||
&self,
|
||||
winner: &Eligible,
|
||||
loaded: &MoviePolicy,
|
||||
@@ -659,7 +659,7 @@ impl Grabber {
|
||||
let seeding = self.seeding.for_indexer(winner.indexer_id);
|
||||
let source = torrent_source(self.prowlarr.download(&winner.download_url).await?);
|
||||
Ok(self
|
||||
.transmission
|
||||
.qbit
|
||||
.add_torrent(AddTorrent {
|
||||
source,
|
||||
label: label(loaded),
|
||||
@@ -670,10 +670,10 @@ impl Grabber {
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Add the winning release to Transmission and record the grab.
|
||||
/// Add the winning release to qBittorrent and record the grab.
|
||||
///
|
||||
/// The search still counts as an attempt (§6.2) on every exit that is
|
||||
/// not a completed grab — a Transmission error or a blacklisted-infohash
|
||||
/// not a completed grab — a qBittorrent error or a blacklisted-infohash
|
||||
/// drop must not leave the same release to repeat next tick with no
|
||||
/// backoff.
|
||||
pub(crate) async fn send_winner(
|
||||
@@ -684,7 +684,7 @@ impl Grabber {
|
||||
blacklist: &Blacklist,
|
||||
winner: Eligible,
|
||||
) -> Result<Option<Outcome>, GrabError> {
|
||||
let added = match self.send_to_transmission(&winner, loaded).await {
|
||||
let added = match self.send_to_qbit(&winner, loaded).await {
|
||||
Ok(added) => added,
|
||||
Err(error) => {
|
||||
self.record_attempt(database, target).await?;
|
||||
@@ -694,7 +694,7 @@ impl Grabber {
|
||||
let infohash = added.hash.to_ascii_lowercase();
|
||||
|
||||
// §6.3's second key. A `.torrent` link hides its infohash until
|
||||
// Transmission has fetched it, so the same blacklisted torrent can
|
||||
// qBittorrent has fetched it, so the same blacklisted torrent can
|
||||
// reach here under a new name.
|
||||
if blacklist.blocks_infohash(&infohash) {
|
||||
self.record_attempt(database, target).await?;
|
||||
@@ -782,7 +782,7 @@ impl Grabber {
|
||||
/// Undo a grab whose infohash turned out to be blacklisted (§6.3).
|
||||
///
|
||||
/// The name is added to the blacklist so the next tick stops at the cheap
|
||||
/// check instead of paying Transmission again, and no `grabs` row is
|
||||
/// check instead of paying qBittorrent again, and no `grabs` row is
|
||||
/// written, which leaves the title a gap for the next candidate.
|
||||
async fn drop_blacklisted_torrent(
|
||||
&self,
|
||||
@@ -822,7 +822,7 @@ impl Grabber {
|
||||
} else {
|
||||
// This tick added it seconds ago, so it carries no seeding
|
||||
// obligation and has nothing on disk worth keeping.
|
||||
self.transmission.remove_torrent(added.id, true).await?;
|
||||
self.qbit.remove_torrent(&added.hash, true).await?;
|
||||
tracing::warn!(
|
||||
title = target.title,
|
||||
release = release_name,
|
||||
@@ -1509,7 +1509,7 @@ pub(crate) async fn park_target(
|
||||
}
|
||||
|
||||
/// `movies-main`, `tv-kids` (§7.1). Distinct from Radarr's own labels, so
|
||||
/// both stacks can run against one Transmission.
|
||||
/// both stacks can run against one qBittorrent.
|
||||
fn label(loaded: &MoviePolicy) -> String {
|
||||
label_for_root(&loaded.root_kind, &loaded.root_audience)
|
||||
}
|
||||
@@ -1519,7 +1519,7 @@ pub(crate) fn label_for_root(kind: &str, audience: &str) -> String {
|
||||
format!("{kind}-{audience}")
|
||||
}
|
||||
|
||||
/// A resolved download, in the shape Transmission takes it.
|
||||
/// A resolved download, in the shape qBittorrent takes it.
|
||||
fn torrent_source(download: Download) -> TorrentSource {
|
||||
match download {
|
||||
Download::Magnet(uri) => TorrentSource::Magnet(uri),
|
||||
@@ -1572,6 +1572,18 @@ pub(crate) mod test_downloads {
|
||||
feed.replace(links, &format!("{}{PREFIX}", server.uri()))
|
||||
}
|
||||
|
||||
/// The infohash the mock indexer's magnet for `name` carries.
|
||||
///
|
||||
/// Deterministic and distinct per release, so duplicates still collapse
|
||||
/// and a test can blacklist a hash before the tick that would grab it.
|
||||
pub(crate) fn infohash_for(name: &str) -> String {
|
||||
let mut hash: u64 = 5381;
|
||||
for byte in name.as_bytes() {
|
||||
hash = hash.wrapping_mul(33) ^ u64::from(*byte);
|
||||
}
|
||||
format!("{hash:040x}")
|
||||
}
|
||||
|
||||
/// Prowlarr's live behaviour: the download endpoint 302s to a magnet.
|
||||
pub(crate) async fn mount(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
@@ -1592,16 +1604,9 @@ pub(crate) mod test_downloads {
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
// Deterministic stand-in for the infohash the tracker would give,
|
||||
// and distinct per release so duplicates still collapse.
|
||||
let mut hash: u64 = 5381;
|
||||
for byte in name.as_bytes() {
|
||||
hash = hash.wrapping_mul(33) ^ u64::from(*byte);
|
||||
}
|
||||
ResponseTemplate::new(302).insert_header(
|
||||
"location",
|
||||
format!("magnet:?xt=urn:btih:{hash:040x}&dn={name}"),
|
||||
)
|
||||
let hash = super::test_downloads::infohash_for(&name);
|
||||
ResponseTemplate::new(302)
|
||||
.insert_header("location", format!("magnet:?xt=urn:btih:{hash}&dn={name}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1609,127 +1614,16 @@ pub(crate) mod test_downloads {
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use base64::Engine as _;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use crate::qbit_fake::FakeQbit;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A Transmission that dedupes on the infohash, like the real one: the
|
||||
/// same source added twice is one torrent and a `torrent-duplicate`
|
||||
/// response. That is the property the restart case leans on.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct FakeTransmission {
|
||||
torrents: Arc<Mutex<Vec<FakeTorrent>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct FakeTorrent {
|
||||
id: i64,
|
||||
hash: String,
|
||||
source: String,
|
||||
labels: Vec<String>,
|
||||
progress: f64,
|
||||
}
|
||||
|
||||
impl FakeTransmission {
|
||||
fn torrents(&self) -> Vec<FakeTorrent> {
|
||||
self.torrents.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn complete_all(&self) {
|
||||
for torrent in self.torrents.lock().unwrap().iter_mut() {
|
||||
torrent.progress = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&self, arguments: &Value) -> ResponseTemplate {
|
||||
// A magnet arrives as `filename`, a torrent body as base64
|
||||
// `metainfo` — either identifies the torrent for the fake.
|
||||
let source = arguments["filename"]
|
||||
.as_str()
|
||||
.or_else(|| arguments["metainfo"].as_str())
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let labels: Vec<String> = arguments["labels"]
|
||||
.as_array()
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut torrents = self.torrents.lock().unwrap();
|
||||
|
||||
if let Some(existing) = torrents.iter().find(|torrent| torrent.source == source) {
|
||||
return success(&json!({"torrent-duplicate": {
|
||||
"id": existing.id, "name": existing.source, "hashString": existing.hash
|
||||
}}));
|
||||
}
|
||||
|
||||
let id = i64::try_from(torrents.len()).unwrap() + 1;
|
||||
// Deterministic stand-in for the real infohash, which likewise
|
||||
// comes out the same for the same torrent.
|
||||
let hash = format!("{:040x}", id * 7);
|
||||
torrents.push(FakeTorrent {
|
||||
id,
|
||||
hash: hash.clone(),
|
||||
source: source.clone(),
|
||||
labels,
|
||||
progress: 0.0,
|
||||
});
|
||||
success(&json!({"torrent-added": {"id": id, "name": source, "hashString": hash}}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Respond for FakeTransmission {
|
||||
fn respond(&self, request: &Request) -> ResponseTemplate {
|
||||
let body: Value = serde_json::from_slice(&request.body).unwrap();
|
||||
let arguments = &body["arguments"];
|
||||
match body["method"].as_str().unwrap_or_default() {
|
||||
"torrent-add" => self.add(arguments),
|
||||
"torrent-get" => {
|
||||
let torrents: Vec<Value> = self
|
||||
.torrents()
|
||||
.into_iter()
|
||||
.map(|torrent| {
|
||||
json!({
|
||||
"id": torrent.id, "name": torrent.source,
|
||||
"hashString": torrent.hash, "status": 4,
|
||||
"percentDone": torrent.progress,
|
||||
"downloadDir": "/downloads", "labels": torrent.labels
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
success(&json!({"torrents": torrents}))
|
||||
}
|
||||
"torrent-remove" => {
|
||||
let removed: Vec<i64> = arguments["ids"]
|
||||
.as_array()
|
||||
.map(|ids| ids.iter().filter_map(serde_json::Value::as_i64).collect())
|
||||
.unwrap_or_default();
|
||||
self.torrents
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|torrent| !removed.contains(&torrent.id));
|
||||
success(&json!({}))
|
||||
}
|
||||
_ => success(&json!({})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn success(arguments: &Value) -> ResponseTemplate {
|
||||
ResponseTemplate::new(200).set_body_json(json!({
|
||||
"result": "success", "arguments": arguments
|
||||
}))
|
||||
}
|
||||
|
||||
const RSS: &str = r#"<rss><channel>
|
||||
<item>
|
||||
<title>Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos</title>
|
||||
@@ -1846,14 +1740,8 @@ mod tests {
|
||||
server
|
||||
}
|
||||
|
||||
async fn transmission() -> (MockServer, FakeTransmission) {
|
||||
let server = MockServer::start().await;
|
||||
let fake = FakeTransmission::default();
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(fake.clone())
|
||||
.mount(&server)
|
||||
.await;
|
||||
(server, fake)
|
||||
async fn qbit() -> (MockServer, FakeQbit) {
|
||||
FakeQbit::start().await
|
||||
}
|
||||
|
||||
async fn wanted_movie() -> (tempfile::TempDir, Db) {
|
||||
@@ -1871,11 +1759,11 @@ mod tests {
|
||||
(dir, database)
|
||||
}
|
||||
|
||||
fn action(prowlarr: &MockServer, transmission: &MockServer) -> GrabAction {
|
||||
fn action(prowlarr: &MockServer, qbit: &MockServer) -> GrabAction {
|
||||
GrabAction::new(
|
||||
ProwlarrClient::new(prowlarr.uri(), "key").unwrap(),
|
||||
TransmissionClient::new(&transmission.uri()).unwrap(),
|
||||
PathBuf::from("/mnt/media/transmission/complete"),
|
||||
QbitClient::new(&qbit.uri()).unwrap(),
|
||||
PathBuf::from("/mnt/media/qbittorrent/complete"),
|
||||
SeedingRules::new(
|
||||
SeedingLimits {
|
||||
ratio: 1.5,
|
||||
@@ -1888,10 +1776,10 @@ mod tests {
|
||||
|
||||
fn action_with_tmdb(
|
||||
prowlarr: &MockServer,
|
||||
transmission: &MockServer,
|
||||
qbit: &MockServer,
|
||||
metadata: &MockServer,
|
||||
) -> GrabAction {
|
||||
action(prowlarr, transmission).with_tmdb(Arc::new(
|
||||
action(prowlarr, qbit).with_tmdb(Arc::new(
|
||||
TmdbClient::builder("key")
|
||||
.base_url(format!("{}/3/", metadata.uri()))
|
||||
.build()
|
||||
@@ -1945,7 +1833,7 @@ mod tests {
|
||||
async fn a_wanted_movie_ends_with_one_torrent_and_one_grab() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1971,10 +1859,10 @@ mod tests {
|
||||
async fn a_restart_mid_flight_does_not_grab_twice() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
// The crash: Transmission has the torrent, the database does not know.
|
||||
// The crash: qBittorrent has the torrent, the database does not know.
|
||||
sqlx::query("DELETE FROM grabs")
|
||||
.execute(database.pool())
|
||||
.await
|
||||
@@ -1996,7 +1884,7 @@ mod tests {
|
||||
async fn a_second_tick_grabs_nothing_new() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
let action = action(&indexer, &downloader);
|
||||
|
||||
action.tick(&database).await.unwrap();
|
||||
@@ -2013,26 +1901,17 @@ mod tests {
|
||||
async fn the_label_and_both_seed_limits_are_set_at_add_time() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
assert_eq!(fake.torrents()[0].labels, vec!["movies-main".to_owned()]);
|
||||
let add = downloader
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter_map(|request| serde_json::from_slice::<Value>(&request.body).ok())
|
||||
.find(|body| body["method"] == "torrent-add")
|
||||
.expect("torrent-add");
|
||||
assert_eq!(add["arguments"]["seedRatioLimit"], json!(1.5));
|
||||
assert_eq!(add["arguments"]["seedIdleLimit"], json!(60));
|
||||
assert_eq!(add["arguments"]["seedRatioMode"], json!(1));
|
||||
assert_eq!(add["arguments"]["seedIdleMode"], json!(1));
|
||||
let added = &fake.torrents()[0];
|
||||
assert_eq!(added.labels, vec!["movies-main".to_owned()]);
|
||||
assert!((added.ratio_limit - 1.5).abs() < f64::EPSILON);
|
||||
assert_eq!(added.idle_limit_minutes, 60);
|
||||
assert_eq!(
|
||||
add["arguments"]["download-dir"],
|
||||
json!("/mnt/media/transmission/complete")
|
||||
added.save_path,
|
||||
String::from("/mnt/media/qbittorrent/complete")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2058,7 +1937,7 @@ mod tests {
|
||||
async fn every_candidate_is_recorded_with_its_verdict() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -2081,13 +1960,13 @@ mod tests {
|
||||
assert_eq!(attempts, 0);
|
||||
}
|
||||
|
||||
/// Transmission is authoritative (§8): a completed torrent moves its grab
|
||||
/// qBittorrent is authoritative (§8): a completed torrent moves its grab
|
||||
/// out of `sent` without the process having watched it happen.
|
||||
#[tokio::test]
|
||||
async fn a_completed_torrent_moves_its_grab_to_downloaded() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
let action = action(&indexer, &downloader);
|
||||
|
||||
action.tick(&database).await.unwrap();
|
||||
@@ -2099,14 +1978,14 @@ mod tests {
|
||||
}
|
||||
|
||||
/// #108, overriding §86: a torrent removed by hand — gone from
|
||||
/// Transmission before it finished — parks the movie instead of
|
||||
/// qBittorrent before it finished — parks the movie instead of
|
||||
/// reopening the gap, and is marked `vanished` rather than `failed` so
|
||||
/// it never counts toward the `needs_decision` queue (attention.rs).
|
||||
#[tokio::test]
|
||||
async fn a_torrent_removed_by_hand_parks_the_movie() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
fake.torrents.lock().unwrap().clear();
|
||||
@@ -2131,7 +2010,7 @@ mod tests {
|
||||
async fn a_parked_movie_is_not_regrabbed() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
let action = action(&indexer, &downloader);
|
||||
|
||||
action.tick(&database).await.unwrap();
|
||||
@@ -2154,7 +2033,7 @@ mod tests {
|
||||
async fn a_rewanted_movie_can_regrab_the_same_infohash() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
let action = action(&indexer, &downloader);
|
||||
|
||||
action.tick(&database).await.unwrap();
|
||||
@@ -2189,7 +2068,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -2212,7 +2091,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -2240,7 +2119,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -2253,23 +2132,24 @@ mod tests {
|
||||
assert_eq!(rule.as_deref(), Some("blacklisted"));
|
||||
}
|
||||
|
||||
/// §6.3's second key. A `.torrent` link hides its infohash until
|
||||
/// Transmission has fetched it, so the blacklisted torrent is only
|
||||
/// recognised after the add — and must not leave a grab behind.
|
||||
/// §6.3's second key. The infohash is only known once the indexer link
|
||||
/// has been resolved, which happens at grab time, so a blacklisted
|
||||
/// torrent under a new release name is recognised after the add — and
|
||||
/// must not leave a grab behind.
|
||||
#[tokio::test]
|
||||
async fn a_blacklisted_infohash_never_becomes_a_grab() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
// What the fake hands back for the first torrent it accepts.
|
||||
// The infohash the winning release's magnet carries.
|
||||
blacklist::add(
|
||||
database.pool(),
|
||||
Some(&format!("{:040x}", 7)),
|
||||
Some(&test_downloads::infohash_for("good.torrent")),
|
||||
"Some.Older.Name.Of.The.Same.Torrent",
|
||||
"required_audio",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -2279,7 +2159,7 @@ mod tests {
|
||||
"a torrent added this tick and then found blacklisted is removed"
|
||||
);
|
||||
// Recorded under its new name, so the next tick stops before paying
|
||||
// Transmission again.
|
||||
// qBittorrent again.
|
||||
let blacklist = Blacklist::load(database.pool()).await.unwrap();
|
||||
assert!(blacklist.blocks_name("Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos"));
|
||||
// And the cached row stops reading eligible straight away, so §9.3's
|
||||
@@ -2329,7 +2209,7 @@ mod tests {
|
||||
async fn a_manual_search_on_an_available_movie_refreshes_the_deck() {
|
||||
let (_dir, database) = available_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let outcome = action(&indexer, &downloader)
|
||||
.search_now(&database, 1)
|
||||
@@ -2351,7 +2231,7 @@ mod tests {
|
||||
let (_dir, database) = available_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let metadata = tmdb(UNRELEASED_METADATA).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
action_with_tmdb(&indexer, &downloader, &metadata)
|
||||
.search_now(&database, 1)
|
||||
@@ -2367,7 +2247,7 @@ mod tests {
|
||||
async fn a_manual_search_with_a_grab_in_flight_does_not_grab_again() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
let action = action(&indexer, &downloader);
|
||||
action.tick(&database).await.unwrap();
|
||||
assert_eq!(grabs(&database).await.len(), 1);
|
||||
@@ -2384,7 +2264,7 @@ mod tests {
|
||||
async fn a_manual_search_on_a_wanted_movie_still_grabs() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let outcome = action(&indexer, &downloader)
|
||||
.search_now(&database, 1)
|
||||
@@ -2405,7 +2285,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let outcome = action(&indexer, &downloader)
|
||||
.search_now(&database, 1)
|
||||
@@ -2427,7 +2307,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -2441,7 +2321,7 @@ mod tests {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let metadata = tmdb(UNRELEASED_METADATA).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
let action = action_with_tmdb(&indexer, &downloader, &metadata);
|
||||
|
||||
for _ in 0..3 {
|
||||
@@ -2461,7 +2341,7 @@ mod tests {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = empty_prowlarr().await;
|
||||
let metadata = tmdb(RELEASED_METADATA).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
let action = action_with_tmdb(&indexer, &downloader, &metadata);
|
||||
|
||||
action.tick(&database).await.unwrap();
|
||||
@@ -2513,7 +2393,7 @@ mod tests {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = empty_prowlarr().await;
|
||||
let metadata = tmdb(RELEASED_METADATA).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
action_with_tmdb(&indexer, &downloader, &metadata)
|
||||
.tick(&database)
|
||||
@@ -2568,7 +2448,7 @@ mod tests {
|
||||
"vote_average": 8.152,"#,
|
||||
);
|
||||
let metadata = tmdb(&artwork_metadata).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
action_with_tmdb(&indexer, &downloader, &metadata)
|
||||
.tick(&database)
|
||||
@@ -2629,7 +2509,7 @@ mod tests {
|
||||
"vote_average": 8.152,"#,
|
||||
)
|
||||
};
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await)
|
||||
.tick(&database)
|
||||
@@ -2690,7 +2570,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let indexer = empty_prowlarr().await;
|
||||
let metadata = tmdb(&RELEASED_METADATA.replace("Dune Part Two", "Dune: Part Two")).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
action_with_tmdb(&indexer, &downloader, &metadata)
|
||||
.tick(&database)
|
||||
@@ -2713,7 +2593,7 @@ mod tests {
|
||||
async fn indexer_discovery_is_cached_across_ticks() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
let action = action(&indexer, &downloader);
|
||||
|
||||
action.tick(&database).await.unwrap();
|
||||
@@ -2744,7 +2624,7 @@ mod tests {
|
||||
)
|
||||
.mount(&indexer)
|
||||
.await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let mut action = action(&indexer, &downloader);
|
||||
action.indexers.discovery_timeout = Duration::from_millis(50);
|
||||
@@ -2761,7 +2641,7 @@ mod tests {
|
||||
assert!(fake.torrents().is_empty());
|
||||
}
|
||||
|
||||
/// §100: Transmission has no route to the indexer, so a download link
|
||||
/// §100: qBittorrent has no route to the indexer, so a download link
|
||||
/// that answers with the torrent itself is forwarded as inline metainfo
|
||||
/// and the link never leaves arr.
|
||||
#[tokio::test]
|
||||
@@ -2779,13 +2659,14 @@ mod tests {
|
||||
)
|
||||
.mount(&indexer)
|
||||
.await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
let sent = base64::engine::general_purpose::STANDARD
|
||||
.decode(&fake.torrents()[0].source)
|
||||
.expect("the torrent is sent as base64 metainfo");
|
||||
let sent = fake.torrents()[0]
|
||||
.metainfo
|
||||
.clone()
|
||||
.expect("the torrent file is sent inline");
|
||||
assert_eq!(sent, TORRENT);
|
||||
let fetched_with_key = indexer
|
||||
.received_requests()
|
||||
@@ -2798,12 +2679,12 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A link that redirects to a magnet — Prowlarr's usual answer — reaches
|
||||
/// Transmission as the magnet, which it can act on without the indexer.
|
||||
/// qBittorrent as the magnet, which it can act on without the indexer.
|
||||
#[tokio::test]
|
||||
async fn a_link_that_redirects_to_a_magnet_is_sent_as_the_magnet() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
|
||||
+142
-78
@@ -5,7 +5,7 @@
|
||||
//! The torrent's own files are never moved, renamed or deleted — the torrent
|
||||
//! and the library entry are separate lifecycles (§7.3). A hard-failed
|
||||
//! release is blacklisted and its grab marked failed, but the torrent keeps
|
||||
//! seeding until Transmission's own limits clear it.
|
||||
//! seeding until qBittorrent's own limits clear it.
|
||||
//!
|
||||
//! Everything here is idempotent from domain rows (§8): a grab in
|
||||
//! `downloaded` with no import recorded is the gap, and re-running any prefix
|
||||
@@ -22,7 +22,7 @@ use arr_core::layout;
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::{ProbedMedia, Rule, Source, Verdict};
|
||||
use arr_db::Db;
|
||||
use arr_dl::TransmissionClient;
|
||||
use arr_dl::QbitClient;
|
||||
use arr_probe::Prober;
|
||||
|
||||
use crate::notify::Notifier;
|
||||
@@ -36,8 +36,8 @@ pub enum ImportError {
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("policy: {0}")]
|
||||
Policy(#[from] arr_db::PolicyError),
|
||||
#[error("transmission: {0}")]
|
||||
Transmission(#[from] arr_dl::Error),
|
||||
#[error("qbit: {0}")]
|
||||
Qbit(#[from] arr_dl::Error),
|
||||
#[error("probe: {0}")]
|
||||
Probe(#[from] arr_probe::Error),
|
||||
#[error("blocking task: {0}")]
|
||||
@@ -73,7 +73,7 @@ enum ProbeOutcome {
|
||||
/// the §7.4 layout.
|
||||
#[derive(Debug)]
|
||||
pub struct ImportAction {
|
||||
transmission: TransmissionClient,
|
||||
qbit: QbitClient,
|
||||
prober: Prober,
|
||||
jellyfin: JellyfinClient,
|
||||
notifier: Notifier,
|
||||
@@ -96,7 +96,7 @@ pub struct ImportAction {
|
||||
probed: std::sync::Arc<tokio::sync::Mutex<HashMap<PathBuf, ProbeOutcome>>>,
|
||||
}
|
||||
|
||||
/// A movie grab Transmission finished downloading, not yet imported.
|
||||
/// A movie grab qBittorrent finished downloading, not yet imported.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PendingImport {
|
||||
grab_id: i64,
|
||||
@@ -112,14 +112,14 @@ struct PendingImport {
|
||||
impl ImportAction {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
transmission: TransmissionClient,
|
||||
qbit: QbitClient,
|
||||
prober: Prober,
|
||||
jellyfin: JellyfinClient,
|
||||
notifier: Notifier,
|
||||
operator_topic: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
transmission,
|
||||
qbit,
|
||||
prober,
|
||||
jellyfin,
|
||||
notifier,
|
||||
@@ -388,7 +388,7 @@ impl ImportAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// The torrent's files as safe local paths, or `None` when Transmission
|
||||
/// The torrent's files as safe local paths, or `None` when qBittorrent
|
||||
/// no longer has the torrent.
|
||||
///
|
||||
/// Torrent-declared names are untrusted input: an absolute or
|
||||
@@ -399,13 +399,13 @@ impl ImportAction {
|
||||
grab_id: i64,
|
||||
infohash: &str,
|
||||
) -> Result<Option<Vec<PathBuf>>, ImportError> {
|
||||
let Some(content) = self.transmission.torrent_content(infohash).await? else {
|
||||
// Gone from Transmission — the caller marks the grab vanished
|
||||
let Some(content) = self.qbit.torrent_content(infohash).await? else {
|
||||
// Gone from qBittorrent — the caller marks the grab vanished
|
||||
// and parks the target (#108).
|
||||
tracing::warn!(
|
||||
grab_id,
|
||||
infohash,
|
||||
"downloaded grab has no torrent in Transmission; not importing"
|
||||
"downloaded grab has no torrent in qBittorrent; not importing"
|
||||
);
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -671,7 +671,7 @@ impl ImportAction {
|
||||
))
|
||||
}
|
||||
|
||||
/// §86/#108: a `downloaded` grab whose torrent Transmission no longer
|
||||
/// §86/#108: a `downloaded` grab whose torrent qBittorrent no longer
|
||||
/// reports — removed by hand, not a policy failure. Marked `vanished`
|
||||
/// rather than `failed` so it does not feed the `needs_decision` queue
|
||||
/// (attention.rs). Nothing is blacklisted, since the release itself
|
||||
@@ -692,11 +692,11 @@ impl ImportAction {
|
||||
movie_id = pending.movie_id,
|
||||
title = pending.title,
|
||||
release = pending.release_name,
|
||||
"torrent vanished from Transmission; movie parked"
|
||||
"torrent vanished from qBittorrent; movie parked"
|
||||
);
|
||||
Ok(Outcome::new(
|
||||
format!(
|
||||
"grab {} downloaded, torrent vanished from Transmission",
|
||||
"grab {} downloaded, torrent vanished from qBittorrent",
|
||||
pending.grab_id
|
||||
),
|
||||
format!("parked movie {}", pending.movie_id),
|
||||
@@ -726,11 +726,11 @@ impl ImportAction {
|
||||
grab_id = pending.grab_id,
|
||||
series = pending.series_title,
|
||||
release = pending.release_name,
|
||||
"torrent vanished from Transmission; target parked"
|
||||
"torrent vanished from qBittorrent; target parked"
|
||||
);
|
||||
Ok(Outcome::new(
|
||||
format!(
|
||||
"grab {} downloaded, torrent vanished from Transmission",
|
||||
"grab {} downloaded, torrent vanished from qBittorrent",
|
||||
pending.grab_id
|
||||
),
|
||||
format!("parked {target_kind} {target_id}"),
|
||||
@@ -897,7 +897,7 @@ async fn record_import(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The gap, straight out of the domain rows (§8): a movie grab Transmission
|
||||
/// The gap, straight out of the domain rows (§8): a movie grab qBittorrent
|
||||
/// finished that no import has settled.
|
||||
async fn pending_imports(database: &Db) -> Result<Vec<PendingImport>, ImportError> {
|
||||
let rows = sqlx::query!(
|
||||
@@ -935,7 +935,7 @@ async fn pending_imports(database: &Db) -> Result<Vec<PendingImport>, ImportErro
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// A TV grab Transmission finished downloading — one episode or a season
|
||||
/// A TV grab qBittorrent finished downloading — one episode or a season
|
||||
/// pack — not yet imported.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PendingTvImport {
|
||||
@@ -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<_>>(),
|
||||
})
|
||||
}
|
||||
@@ -1390,18 +1397,39 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
async fn harness(media_json: &str) -> Harness {
|
||||
harness_with(
|
||||
media_json,
|
||||
json!([
|
||||
{"name": "Dune/Dune.mkv", "length": 13, "bytesCompleted": 13},
|
||||
{"name": "Dune/Dune.nfo", "length": 10, "bytesCompleted": 10}
|
||||
]),
|
||||
)
|
||||
.await
|
||||
/// A qBittorrent holding exactly one torrent, `INFOHASH`, with `files`
|
||||
/// under `save_path`.
|
||||
async fn qbit(save_path: &str, files: &[(&str, u64)]) -> MockServer {
|
||||
let server = MockServer::start().await;
|
||||
let torrent = json!({
|
||||
"hash": INFOHASH, "name": RELEASE_NAME, "state": "uploading",
|
||||
"progress": 1.0, "save_path": save_path, "tags": "movies-main",
|
||||
"ratio": 0.1, "ratio_limit": 1.5, "seeding_time": 1,
|
||||
"seeding_time_limit": -1, "inactive_seeding_time_limit": -1,
|
||||
"last_activity": 0
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v2/torrents/info"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([torrent])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let files: Vec<serde_json::Value> = files
|
||||
.iter()
|
||||
.map(|(name, size)| json!({"name": name, "size": size}))
|
||||
.collect();
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v2/torrents/files"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(files))
|
||||
.mount(&server)
|
||||
.await;
|
||||
server
|
||||
}
|
||||
|
||||
async fn harness_with(media_json: &str, files: serde_json::Value) -> Harness {
|
||||
async fn harness(media_json: &str) -> Harness {
|
||||
harness_with(media_json, &[("Dune/Dune.mkv", 13), ("Dune/Dune.nfo", 10)]).await
|
||||
}
|
||||
|
||||
async fn harness_with(media_json: &str, files: &[(&str, u64)]) -> Harness {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let downloads = dir.path().join("downloads");
|
||||
let library = dir.path().join("library");
|
||||
@@ -1446,18 +1474,7 @@ mod tests {
|
||||
.unwrap();
|
||||
insert_owner(&database, "movie", 1, "Alice", "alice-topic").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"result": "success",
|
||||
"arguments": {"torrents": [{
|
||||
"hashString": INFOHASH,
|
||||
"downloadDir": downloads.to_string_lossy(),
|
||||
"files": files
|
||||
}]}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let server = qbit(&downloads.to_string_lossy(), files).await;
|
||||
|
||||
let jellyfin_server = start_jellyfin_server().await;
|
||||
let ntfy_server = start_ntfy_server().await;
|
||||
@@ -1466,7 +1483,7 @@ mod tests {
|
||||
let jellyfin = JellyfinClient::new(jellyfin_server.uri(), None).unwrap();
|
||||
let notifier = Notifier::new(ntfy_server.uri()).unwrap();
|
||||
let action = ImportAction::new(
|
||||
TransmissionClient::new(&server.uri()).unwrap(),
|
||||
QbitClient::new(&server.uri()).unwrap(),
|
||||
prober,
|
||||
jellyfin,
|
||||
notifier,
|
||||
@@ -1720,7 +1737,7 @@ mod tests {
|
||||
assert!(outcomes.is_empty());
|
||||
}
|
||||
|
||||
/// #108, overriding §86: a `downloaded` grab whose torrent Transmission
|
||||
/// #108, overriding §86: a `downloaded` grab whose torrent qBittorrent
|
||||
/// no longer reports — removed by hand, not a policy failure — is marked
|
||||
/// `vanished` and parks the movie (`wanted` cleared) instead of
|
||||
/// reopening it as a gap, without touching the blacklist.
|
||||
@@ -1757,17 +1774,15 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"result": "success",
|
||||
"arguments": {"torrents": []}
|
||||
})))
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v2/torrents/info"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let jellyfin_server = start_jellyfin_server().await;
|
||||
let ntfy_server = start_ntfy_server().await;
|
||||
let action = ImportAction::new(
|
||||
TransmissionClient::new(&server.uri()).unwrap(),
|
||||
QbitClient::new(&server.uri()).unwrap(),
|
||||
Prober::new().with_binary(fake_ffprobe(dir.path(), HDR10_PROBE)),
|
||||
JellyfinClient::new(jellyfin_server.uri(), None).unwrap(),
|
||||
Notifier::new(ntfy_server.uri()).unwrap(),
|
||||
@@ -1802,11 +1817,11 @@ mod tests {
|
||||
async fn hostile_torrent_paths_never_leave_the_download_root() {
|
||||
let h = harness_with(
|
||||
HDR10_PROBE,
|
||||
json!([
|
||||
{"name": "../outside.mkv", "length": 13, "bytesCompleted": 13},
|
||||
{"name": "/tmp/absolute.mkv", "length": 13, "bytesCompleted": 13},
|
||||
{"name": "Dune/Dune.mkv", "length": 13, "bytesCompleted": 13}
|
||||
]),
|
||||
&[
|
||||
("../outside.mkv", 13),
|
||||
("/tmp/absolute.mkv", 13),
|
||||
("Dune/Dune.mkv", 13),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1825,11 +1840,7 @@ mod tests {
|
||||
/// hard fail, not an escape.
|
||||
#[tokio::test]
|
||||
async fn a_torrent_of_only_hostile_paths_hard_fails() {
|
||||
let h = harness_with(
|
||||
HDR10_PROBE,
|
||||
json!([{"name": "../../etc/passwd", "length": 13, "bytesCompleted": 13}]),
|
||||
)
|
||||
.await;
|
||||
let h = harness_with(HDR10_PROBE, &[("../../etc/passwd", 13)]).await;
|
||||
|
||||
let outcomes = h.action.tick(&h.database).await.unwrap();
|
||||
|
||||
@@ -1878,7 +1889,7 @@ mod tests {
|
||||
std::fs::set_permissions(&script, permissions).unwrap();
|
||||
|
||||
let action = ImportAction::new(
|
||||
TransmissionClient::new("http://127.0.0.1:1").unwrap(),
|
||||
QbitClient::new("http://127.0.0.1:1").unwrap(),
|
||||
Prober::new().with_binary(&script),
|
||||
JellyfinClient::new("http://127.0.0.1:1", None).unwrap(),
|
||||
Notifier::new("http://127.0.0.1:1").unwrap(),
|
||||
@@ -1925,7 +1936,7 @@ mod tests {
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&script, permissions).unwrap();
|
||||
let action = ImportAction::new(
|
||||
TransmissionClient::new("http://127.0.0.1:1").unwrap(),
|
||||
QbitClient::new("http://127.0.0.1:1").unwrap(),
|
||||
Prober::new().with_binary(&script),
|
||||
JellyfinClient::new("http://127.0.0.1:1", None).unwrap(),
|
||||
Notifier::new("http://127.0.0.1:1").unwrap(),
|
||||
@@ -1973,6 +1984,18 @@ mod tests {
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// The same file, one gigabyte instead of ten: under the 2160p floor.
|
||||
/// A pack accepted pre-grab on its per-episode average carries episodes
|
||||
/// like this whenever one runs short.
|
||||
const TV_THIN_PROBE: &str = r#"{
|
||||
"format": {"format_name": "matroska,webm", "duration": "3300.0", "size": "1073741824"},
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": "hevc", "width": 3840, "height": 1600,
|
||||
"color_transfer": "smpte2084"},
|
||||
{"codec_type": "audio", "codec_name": "eac3", "tags": {"language": "eng"}}
|
||||
]
|
||||
}"#;
|
||||
|
||||
const PACK_RELEASE_NAME: &str = "Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos";
|
||||
|
||||
struct TvHarness {
|
||||
@@ -2051,21 +2074,14 @@ mod tests {
|
||||
.unwrap();
|
||||
insert_owner(&database, "series", 1, "Bob", "bob-topic").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"result": "success",
|
||||
"arguments": {"torrents": [{
|
||||
"hashString": INFOHASH,
|
||||
"downloadDir": downloads.to_string_lossy(),
|
||||
"files": [
|
||||
{"name": "Fallout.S01/Fallout.S01E01.mkv", "length": 2, "bytesCompleted": 2},
|
||||
{"name": "Fallout.S01/Fallout.S01E02.mkv", "length": 2, "bytesCompleted": 2}
|
||||
]
|
||||
}]}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let server = qbit(
|
||||
&downloads.to_string_lossy(),
|
||||
&[
|
||||
("Fallout.S01/Fallout.S01E01.mkv", 2),
|
||||
("Fallout.S01/Fallout.S01E02.mkv", 2),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let jellyfin_server = start_jellyfin_server().await;
|
||||
let ntfy_server = start_ntfy_server().await;
|
||||
|
||||
@@ -2073,7 +2089,7 @@ mod tests {
|
||||
let jellyfin = JellyfinClient::new(jellyfin_server.uri(), None).unwrap();
|
||||
let notifier = Notifier::new(ntfy_server.uri()).unwrap();
|
||||
let action = ImportAction::new(
|
||||
TransmissionClient::new(&server.uri()).unwrap(),
|
||||
QbitClient::new(&server.uri()).unwrap(),
|
||||
prober,
|
||||
jellyfin,
|
||||
notifier,
|
||||
@@ -2186,6 +2202,54 @@ mod tests {
|
||||
assert_eq!(grab_state, "imported");
|
||||
}
|
||||
|
||||
/// §5.5's floor is a selection filter, so it condemns nothing after the
|
||||
/// download. A pack whose episodes come in under the band imports and
|
||||
/// records the waiver: blacklisting it would throw away a file already
|
||||
/// on disk over a number that was on the release before the grab, and
|
||||
/// §6.3 would then outlast any override the operator later writes.
|
||||
#[tokio::test]
|
||||
async fn a_pack_under_the_floor_imports_with_a_waiver_rather_than_blacklisting() {
|
||||
let h = tv_harness(TV_THIN_PROBE).await;
|
||||
|
||||
h.action.tick(&h.database).await.unwrap();
|
||||
|
||||
let blacklisted: i64 = sqlx::query_scalar("SELECT count(*) FROM blacklist")
|
||||
.fetch_one(h.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(blacklisted, 0, "the floor blacklists nothing post-download");
|
||||
let waivers: Vec<Option<String>> =
|
||||
sqlx::query_scalar("SELECT waiver FROM media_files ORDER BY path")
|
||||
.fetch_all(h.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
waivers,
|
||||
vec![
|
||||
Some(r#"{"rule":"size"}"#.to_owned()),
|
||||
Some(r#"{"rule":"size"}"#.to_owned())
|
||||
],
|
||||
"§5.7: import it, record the waiver, surface it"
|
||||
);
|
||||
let states: Vec<(String, bool)> =
|
||||
sqlx::query_as("SELECT state, wanted FROM episodes ORDER BY number")
|
||||
.fetch_all(h.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
states,
|
||||
vec![
|
||||
("available".to_owned(), true),
|
||||
("available".to_owned(), true)
|
||||
]
|
||||
);
|
||||
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs")
|
||||
.fetch_one(h.database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(grab_state, "imported");
|
||||
}
|
||||
|
||||
/// The third acceptance case, import side: a pack whose file hard-fails
|
||||
/// blacklists that release and reopens the episodes — it does not
|
||||
/// blacklist or block the season.
|
||||
|
||||
+355
-63
@@ -9,15 +9,18 @@ mod indexers;
|
||||
mod manual;
|
||||
mod metadata;
|
||||
mod notify;
|
||||
#[cfg(test)]
|
||||
mod qbit_fake;
|
||||
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 +36,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;
|
||||
|
||||
@@ -90,8 +94,8 @@ enum Error {
|
||||
Tmdb(#[from] arr_meta::Error),
|
||||
#[error("prowlarr client: {0}")]
|
||||
Prowlarr(#[from] arr_indexer::Error),
|
||||
#[error("transmission client: {0}")]
|
||||
Transmission(#[from] arr_dl::Error),
|
||||
#[error("qbit client: {0}")]
|
||||
Qbit(#[from] arr_dl::Error),
|
||||
#[error("jellyfin client: {0}")]
|
||||
Jellyfin(#[from] arr_api::jellyfin::Error),
|
||||
#[error("ntfy client: {0}")]
|
||||
@@ -107,12 +111,62 @@ 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,
|
||||
qbit: &arr_dl::QbitClient,
|
||||
) -> Result<AppState, Error> {
|
||||
let mut upstreams = Upstreams::new(config.prowlarr_url.clone(), config.qbittorrent_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_qbit(qbit.clone())
|
||||
.with_subtitle_providers(subtitle_providers(
|
||||
config.opensubtitles_api_key.clone(),
|
||||
config.opensubtitles_username.clone(),
|
||||
config.opensubtitles_password.clone(),
|
||||
))
|
||||
.with_translation_backends(translators.backends.clone())
|
||||
.with_jellyfin(jellyfin)
|
||||
.with_syncer(arr_subs::Syncer::new().with_binary(config.alass_path.clone()))
|
||||
// The health lamp and the extract lane (#260) run the same binary,
|
||||
// so the API is told which one this deployment has rather than
|
||||
// resolving `ffmpeg` from `PATH` behind the operator's back.
|
||||
.with_ffmpeg_binary(config.ffmpeg_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)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn run() -> Result<(), Error> {
|
||||
let config = Config::load()?;
|
||||
let database = Db::connect(&config.database_path).await?;
|
||||
database.migrate().await?;
|
||||
|
||||
let transmission = arr_dl::TransmissionClient::new(&config.transmission_url)?;
|
||||
let qbit = match (&config.qbittorrent_username, &config.qbittorrent_password) {
|
||||
(Some(username), Some(password)) => arr_dl::QbitClient::with_credentials(
|
||||
&config.qbittorrent_url,
|
||||
username.clone(),
|
||||
password.clone(),
|
||||
),
|
||||
_ => arr_dl::QbitClient::new(&config.qbittorrent_url),
|
||||
}?;
|
||||
let tmdb = if let Some(key) = &config.tmdb_api_key {
|
||||
let mut client = TmdbClient::builder(key.clone());
|
||||
if let Some(url) = &config.tmdb_url {
|
||||
@@ -124,11 +178,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(), ¬ifier)?;
|
||||
// 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.
|
||||
// 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,
|
||||
&qbit,
|
||||
tmdb.as_ref(),
|
||||
¬ifier,
|
||||
&translators,
|
||||
)?;
|
||||
let metadata_tmdb = tmdb.clone();
|
||||
|
||||
// Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and
|
||||
@@ -138,16 +200,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, &qbit)?;
|
||||
let app = arr_api::router(state.clone())
|
||||
.merge(arr_compat::router(compat))
|
||||
.fallback(web::serve)
|
||||
@@ -224,6 +277,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.
|
||||
@@ -236,9 +331,10 @@ async fn run() -> Result<(), Error> {
|
||||
fn reconcile_loop(
|
||||
database: &Db,
|
||||
config: &Config,
|
||||
transmission: &arr_dl::TransmissionClient,
|
||||
qbit: &arr_dl::QbitClient,
|
||||
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(
|
||||
@@ -266,27 +362,16 @@ fn reconcile_loop(
|
||||
.map(|key| arr_indexer::ProwlarrClient::new(config.prowlarr_url.clone(), key))
|
||||
.transpose()?;
|
||||
|
||||
let (reconcile, manual_grab) = register_movie_grab(
|
||||
reconcile,
|
||||
prowlarr.as_ref(),
|
||||
transmission,
|
||||
config,
|
||||
&seeding,
|
||||
tmdb,
|
||||
);
|
||||
let (reconcile, manual_grab) =
|
||||
register_movie_grab(reconcile, prowlarr.as_ref(), qbit, config, &seeding, tmdb);
|
||||
let (mut reconcile, manual_tv) =
|
||||
register_tv_grab(reconcile, prowlarr.as_ref(), transmission, config, &seeding);
|
||||
register_tv_grab(reconcile, prowlarr.as_ref(), qbit, config, &seeding);
|
||||
// RSS needs no TMDB: it matches what the feeds already carry against the
|
||||
// wanted list (§6.2).
|
||||
if let Some(prowlarr) = prowlarr {
|
||||
reconcile = reconcile.register(
|
||||
Tick::Rss,
|
||||
RssAction::new(
|
||||
prowlarr,
|
||||
transmission.clone(),
|
||||
config.download_dir.clone(),
|
||||
seeding,
|
||||
),
|
||||
RssAction::new(prowlarr, qbit.clone(), config.download_dir.clone(), seeding),
|
||||
);
|
||||
}
|
||||
// §8: metadata refresh is its own daily lane, staggered against the
|
||||
@@ -302,7 +387,7 @@ fn reconcile_loop(
|
||||
reconcile = reconcile.register(
|
||||
Tick::Reconcile,
|
||||
ImportAction::new(
|
||||
transmission.clone(),
|
||||
qbit.clone(),
|
||||
arr_probe::Prober::new(),
|
||||
jellyfin,
|
||||
notifier.clone(),
|
||||
@@ -310,6 +395,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 +408,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!(
|
||||
@@ -337,10 +421,51 @@ fn reconcile_loop(
|
||||
);
|
||||
}
|
||||
|
||||
let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone()));
|
||||
let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(qbit.clone()));
|
||||
Ok((reconcile, manual_grab, manual_tv))
|
||||
}
|
||||
|
||||
/// The *broken* lane (#200 included): Prowlarr, qBittorrent 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(),
|
||||
qbittorrent_url: config.qbittorrent_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(),
|
||||
),
|
||||
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
|
||||
@@ -349,7 +474,7 @@ fn reconcile_loop(
|
||||
fn register_tv_grab(
|
||||
mut reconcile: ReconcileLoop,
|
||||
prowlarr: Option<&arr_indexer::ProwlarrClient>,
|
||||
transmission: &arr_dl::TransmissionClient,
|
||||
qbit: &arr_dl::QbitClient,
|
||||
config: &Config,
|
||||
seeding: &SeedingRules,
|
||||
) -> (ReconcileLoop, Option<TvGrabAction>) {
|
||||
@@ -359,7 +484,7 @@ fn register_tv_grab(
|
||||
let tv_grab_action = || {
|
||||
TvGrabAction::new(
|
||||
prowlarr.clone(),
|
||||
transmission.clone(),
|
||||
qbit.clone(),
|
||||
config.download_dir.clone(),
|
||||
seeding.clone(),
|
||||
)
|
||||
@@ -377,7 +502,7 @@ fn register_tv_grab(
|
||||
fn register_movie_grab(
|
||||
mut reconcile: ReconcileLoop,
|
||||
prowlarr: Option<&arr_indexer::ProwlarrClient>,
|
||||
transmission: &arr_dl::TransmissionClient,
|
||||
qbit: &arr_dl::QbitClient,
|
||||
config: &Config,
|
||||
seeding: &SeedingRules,
|
||||
tmdb: Option<&Arc<TmdbClient>>,
|
||||
@@ -388,29 +513,23 @@ fn register_movie_grab(
|
||||
};
|
||||
reconcile = reconcile.register(
|
||||
Tick::Reconcile,
|
||||
movie_grab_action(prowlarr, transmission, config, seeding, tmdb),
|
||||
movie_grab_action(prowlarr, qbit, config, seeding, tmdb),
|
||||
);
|
||||
let manual_grab = Some(movie_grab_action(
|
||||
prowlarr,
|
||||
transmission,
|
||||
config,
|
||||
seeding,
|
||||
tmdb,
|
||||
));
|
||||
let manual_grab = Some(movie_grab_action(prowlarr, qbit, config, seeding, tmdb));
|
||||
(reconcile, manual_grab)
|
||||
}
|
||||
|
||||
/// The movie grab lane, built fresh for each caller.
|
||||
fn movie_grab_action(
|
||||
prowlarr: &arr_indexer::ProwlarrClient,
|
||||
transmission: &arr_dl::TransmissionClient,
|
||||
qbit: &arr_dl::QbitClient,
|
||||
config: &Config,
|
||||
seeding: &SeedingRules,
|
||||
tmdb: &Arc<TmdbClient>,
|
||||
) -> GrabAction {
|
||||
GrabAction::new(
|
||||
prowlarr.clone(),
|
||||
transmission.clone(),
|
||||
qbit.clone(),
|
||||
config.download_dir.clone(),
|
||||
seeding.clone(),
|
||||
)
|
||||
@@ -452,3 +571,176 @@ 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(),
|
||||
),
|
||||
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.
|
||||
fn subtitle_providers(
|
||||
opensubtitles_api_key: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Vec<std::sync::Arc<dyn arr_subs::Provider>> {
|
||||
let mut providers: Vec<std::sync::Arc<dyn arr_subs::Provider>> = Vec::new();
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Drains `AppState`'s three command channels — the daemon-side consumer
|
||||
//! DESIGN.md §6.2 and §9.3 assume exists (issues #107 and #132). A `Search`
|
||||
//! resets backoff and re-runs the targeted-search + grab lane immediately;
|
||||
//! `Grab` sends an already-chosen release straight to Transmission, skipping
|
||||
//! `Grab` sends an already-chosen release straight to qBittorrent, skipping
|
||||
//! search. Movies, episodes and seasons share one lane because they share
|
||||
//! one operator waiting on a 202.
|
||||
|
||||
@@ -149,7 +149,7 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use arr_db::Db;
|
||||
use arr_dl::TransmissionClient;
|
||||
use arr_dl::QbitClient;
|
||||
use arr_indexer::ProwlarrClient;
|
||||
use wiremock::matchers::{method, path, path_regex, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
@@ -172,23 +172,15 @@ mod tests {
|
||||
(dir, database)
|
||||
}
|
||||
|
||||
async fn transmission() -> MockServer {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"result": "success",
|
||||
"arguments": {"torrent-added": {"id": 1, "name": "x", "hashString": "aaaa"}}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
server
|
||||
async fn qbit() -> MockServer {
|
||||
crate::qbit_fake::FakeQbit::start().await.0
|
||||
}
|
||||
|
||||
fn grab_action(indexer: &MockServer, transmission: &MockServer) -> GrabAction {
|
||||
fn grab_action(indexer: &MockServer, qbit: &MockServer) -> GrabAction {
|
||||
GrabAction::new(
|
||||
ProwlarrClient::new(indexer.uri(), "key").unwrap(),
|
||||
TransmissionClient::new(&transmission.uri()).unwrap(),
|
||||
PathBuf::from("/mnt/media/transmission/complete"),
|
||||
QbitClient::new(&qbit.uri()).unwrap(),
|
||||
PathBuf::from("/mnt/media/qbittorrent/complete"),
|
||||
SeedingRules::new(
|
||||
SeedingLimits {
|
||||
ratio: 1.5,
|
||||
@@ -239,8 +231,8 @@ mod tests {
|
||||
)
|
||||
.mount(&indexer)
|
||||
.await;
|
||||
let transmission = transmission().await;
|
||||
let grab = grab_action(&indexer, &transmission);
|
||||
let qbit = qbit().await;
|
||||
let grab = grab_action(&indexer, &qbit);
|
||||
|
||||
handle_movie(&grab, &database, MovieCommand::Search { movie_id: 1 })
|
||||
.await
|
||||
@@ -269,7 +261,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Issue #107: a `Grab` command sends the already-chosen release straight
|
||||
/// to Transmission — no indexer search at all.
|
||||
/// to qBittorrent — no indexer search at all.
|
||||
#[tokio::test]
|
||||
async fn a_grab_command_sends_the_chosen_release_without_searching() {
|
||||
let (_dir, database) = database_with_wanted_movie().await;
|
||||
@@ -282,8 +274,8 @@ mod tests {
|
||||
))
|
||||
.mount(&indexer)
|
||||
.await;
|
||||
let transmission = transmission().await;
|
||||
let grab = grab_action(&indexer, &transmission);
|
||||
let qbit = qbit().await;
|
||||
let grab = grab_action(&indexer, &qbit);
|
||||
|
||||
let release_id = sqlx::query(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! An in-memory qBittorrent for the daemon's tests.
|
||||
//!
|
||||
//! Shared rather than per-module because every lane that grabs needs the same
|
||||
//! two behaviours to be right, and both are easy to fake wrongly: `torrents/add`
|
||||
//! answers `Ok.` with no hash, so the fake has to derive the same infohash the
|
||||
//! client did, and adding the same source twice has to collapse to one torrent
|
||||
//! or the restart and re-grab tests prove nothing.
|
||||
|
||||
#![allow(clippy::unwrap_used, dead_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use arr_dl::TorrentSource;
|
||||
use serde_json::{json, Value};
|
||||
use wiremock::matchers::any;
|
||||
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct FakeTorrent {
|
||||
pub hash: String,
|
||||
pub name: String,
|
||||
/// The magnet URI, or `<inline .torrent>` for a torrent sent as bytes.
|
||||
pub source: String,
|
||||
/// The bytes of a torrent sent inline, so a test can prove arr forwarded
|
||||
/// the file rather than the indexer link.
|
||||
pub metainfo: Option<Vec<u8>>,
|
||||
pub labels: Vec<String>,
|
||||
pub save_path: String,
|
||||
pub progress: f64,
|
||||
pub state: String,
|
||||
pub ratio_limit: f64,
|
||||
pub idle_limit_minutes: i64,
|
||||
/// `(path, size)`, relative to `save_path`.
|
||||
pub files: Vec<(String, u64)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct FakeQbit {
|
||||
pub torrents: Arc<Mutex<Vec<FakeTorrent>>>,
|
||||
/// Every source ever added, in order, kept across removals.
|
||||
pub added: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl FakeQbit {
|
||||
/// Start a mock server backed by a fresh fake.
|
||||
pub(crate) async fn start() -> (MockServer, Self) {
|
||||
let server = MockServer::start().await;
|
||||
let fake = Self::default();
|
||||
Mock::given(any())
|
||||
.respond_with(fake.clone())
|
||||
.mount(&server)
|
||||
.await;
|
||||
(server, fake)
|
||||
}
|
||||
|
||||
pub(crate) fn torrents(&self) -> Vec<FakeTorrent> {
|
||||
self.torrents.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn complete_all(&self) {
|
||||
for torrent in self.torrents.lock().unwrap().iter_mut() {
|
||||
torrent.progress = 1.0;
|
||||
torrent.state = "uploading".into();
|
||||
}
|
||||
}
|
||||
|
||||
/// Give every torrent the same file list and download directory, for the
|
||||
/// import lane.
|
||||
pub(crate) fn set_contents(&self, save_path: &str, files: &[(&str, u64)]) {
|
||||
for torrent in self.torrents.lock().unwrap().iter_mut() {
|
||||
torrent.save_path = save_path.to_owned();
|
||||
torrent.files = files
|
||||
.iter()
|
||||
.map(|(path, size)| ((*path).to_owned(), *size))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&self, request: &Request) -> ResponseTemplate {
|
||||
let parts = multipart(request);
|
||||
let (source, name, metainfo) = if let Some(urls) = parts.get("urls") {
|
||||
let uri = String::from_utf8_lossy(urls).trim().to_owned();
|
||||
let name = uri.split_once("dn=").map_or_else(
|
||||
|| uri.clone(),
|
||||
|(_, rest)| rest.split('&').next().unwrap_or_default().to_owned(),
|
||||
);
|
||||
(TorrentSource::Magnet(uri), name, None)
|
||||
} else {
|
||||
let bytes = parts.get("torrents").cloned().unwrap_or_default();
|
||||
(
|
||||
TorrentSource::Metainfo(bytes.clone()),
|
||||
"<inline .torrent>".to_owned(),
|
||||
Some(bytes),
|
||||
)
|
||||
};
|
||||
let Ok(hash) = arr_dl::infohash(&source) else {
|
||||
// The real client refuses these before the call, so reaching here
|
||||
// means the fixture is malformed.
|
||||
return ResponseTemplate::new(415).set_body_string("invalid torrent");
|
||||
};
|
||||
let display = match &source {
|
||||
TorrentSource::Magnet(uri) => uri.clone(),
|
||||
TorrentSource::Metainfo(_) => "<inline .torrent>".to_owned(),
|
||||
};
|
||||
|
||||
self.added.lock().unwrap().push(display.clone());
|
||||
let mut torrents = self.torrents.lock().unwrap();
|
||||
if !torrents.iter().any(|torrent| torrent.hash == hash) {
|
||||
torrents.push(FakeTorrent {
|
||||
hash,
|
||||
name,
|
||||
source: display,
|
||||
metainfo,
|
||||
labels: text(&parts, "tags")
|
||||
.split(',')
|
||||
.filter(|tag| !tag.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect(),
|
||||
save_path: text(&parts, "savepath"),
|
||||
progress: 0.0,
|
||||
state: "downloading".into(),
|
||||
ratio_limit: -1.0,
|
||||
idle_limit_minutes: -1,
|
||||
files: Vec::new(),
|
||||
});
|
||||
}
|
||||
ResponseTemplate::new(200).set_body_string("Ok.")
|
||||
}
|
||||
|
||||
fn info(&self, request: &Request) -> ResponseTemplate {
|
||||
let wanted = query(request, "hashes");
|
||||
let torrents: Vec<Value> = self
|
||||
.torrents()
|
||||
.into_iter()
|
||||
.filter(|torrent| {
|
||||
wanted
|
||||
.as_ref()
|
||||
.is_none_or(|hash| torrent.hash.eq_ignore_ascii_case(hash))
|
||||
})
|
||||
.map(|torrent| {
|
||||
json!({
|
||||
"hash": torrent.hash,
|
||||
"name": torrent.name,
|
||||
"state": torrent.state,
|
||||
"progress": torrent.progress,
|
||||
"dlspeed": 0,
|
||||
"save_path": torrent.save_path,
|
||||
"tags": torrent.labels.join(","),
|
||||
"ratio": 0.0,
|
||||
"ratio_limit": torrent.ratio_limit,
|
||||
"seeding_time": 0,
|
||||
"seeding_time_limit": -1,
|
||||
"inactive_seeding_time_limit": torrent.idle_limit_minutes,
|
||||
"last_activity": 0
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ResponseTemplate::new(200).set_body_json(torrents)
|
||||
}
|
||||
|
||||
fn files(&self, request: &Request) -> ResponseTemplate {
|
||||
let hash = query(request, "hash").unwrap_or_default();
|
||||
let files: Vec<Value> = self
|
||||
.torrents()
|
||||
.into_iter()
|
||||
.find(|torrent| torrent.hash.eq_ignore_ascii_case(&hash))
|
||||
.map(|torrent| {
|
||||
torrent
|
||||
.files
|
||||
.into_iter()
|
||||
.map(|(path, size)| json!({"name": path, "size": size}))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ResponseTemplate::new(200).set_body_json(files)
|
||||
}
|
||||
|
||||
fn mutate(&self, path: &str, request: &Request) -> ResponseTemplate {
|
||||
let form = urlencoded(request);
|
||||
let hashes = form.get("hashes").cloned().unwrap_or_default();
|
||||
for torrent in self.torrents.lock().unwrap().iter_mut() {
|
||||
if !hashes
|
||||
.split('|')
|
||||
.any(|hash| hash.eq_ignore_ascii_case(&torrent.hash))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match path {
|
||||
"addTags" => {
|
||||
for tag in form.get("tags").cloned().unwrap_or_default().split(',') {
|
||||
if !tag.is_empty() && !torrent.labels.iter().any(|held| held == tag) {
|
||||
torrent.labels.push(tag.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
"setLocation" => {
|
||||
torrent.save_path = form.get("location").cloned().unwrap_or_default();
|
||||
}
|
||||
"setShareLimits" => {
|
||||
torrent.ratio_limit = form
|
||||
.get("ratioLimit")
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(-1.0);
|
||||
torrent.idle_limit_minutes = form
|
||||
.get("inactiveSeedingTimeLimit")
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(-1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ResponseTemplate::new(200).set_body_string("")
|
||||
}
|
||||
|
||||
fn delete(&self, request: &Request) -> ResponseTemplate {
|
||||
let form = urlencoded(request);
|
||||
let hashes = form.get("hashes").cloned().unwrap_or_default();
|
||||
self.torrents.lock().unwrap().retain(|torrent| {
|
||||
!hashes
|
||||
.split('|')
|
||||
.any(|hash| hash.eq_ignore_ascii_case(&torrent.hash))
|
||||
});
|
||||
ResponseTemplate::new(200).set_body_string("")
|
||||
}
|
||||
}
|
||||
|
||||
impl Respond for FakeQbit {
|
||||
fn respond(&self, request: &Request) -> ResponseTemplate {
|
||||
let path = request.url.path().to_owned();
|
||||
match path.rsplit('/').next().unwrap_or_default() {
|
||||
"add" => self.add(request),
|
||||
"info" => self.info(request),
|
||||
"files" => self.files(request),
|
||||
"delete" => self.delete(request),
|
||||
endpoint @ ("addTags" | "setLocation" | "setShareLimits") => {
|
||||
self.mutate(endpoint, request)
|
||||
}
|
||||
_ => ResponseTemplate::new(200).set_body_string("Ok."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query(request: &Request, key: &str) -> Option<String> {
|
||||
request
|
||||
.url
|
||||
.query_pairs()
|
||||
.find(|(name, _)| name == key)
|
||||
.map(|(_, value)| value.into_owned())
|
||||
}
|
||||
|
||||
fn urlencoded(request: &Request) -> HashMap<String, String> {
|
||||
String::from_utf8_lossy(&request.body)
|
||||
.split('&')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.map(|(key, value)| (decode(key), decode(value)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decode(input: &str) -> String {
|
||||
let bytes = input.replace('+', " ").into_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut at = 0;
|
||||
while at < bytes.len() {
|
||||
if bytes[at] == b'%' && at + 2 < bytes.len() {
|
||||
if let Some(byte) = std::str::from_utf8(&bytes[at + 1..at + 3])
|
||||
.ok()
|
||||
.and_then(|pair| u8::from_str_radix(pair, 16).ok())
|
||||
{
|
||||
out.push(byte);
|
||||
at += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[at]);
|
||||
at += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
fn text(parts: &HashMap<String, Vec<u8>>, key: &str) -> String {
|
||||
parts
|
||||
.get(key)
|
||||
.map(|bytes| String::from_utf8_lossy(bytes).into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The named parts of a `multipart/form-data` body.
|
||||
fn multipart(request: &Request) -> HashMap<String, Vec<u8>> {
|
||||
let Some(boundary) = request
|
||||
.headers
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split("boundary=").nth(1))
|
||||
.map(|value| format!("--{}", value.trim_matches('"')))
|
||||
else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
let mut parts = HashMap::new();
|
||||
for section in split(&request.body, boundary.as_bytes()).skip(1) {
|
||||
let Some(header_end) = find(section, b"\r\n\r\n") else {
|
||||
continue;
|
||||
};
|
||||
let headers = String::from_utf8_lossy(§ion[..header_end]);
|
||||
let Some(name) = headers
|
||||
.split("name=\"")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('"').next())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let body = §ion[header_end + 4..];
|
||||
let body = body.strip_suffix(b"\r\n").unwrap_or(body);
|
||||
parts.insert(name.to_owned(), body.to_vec());
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn split<'a>(haystack: &'a [u8], needle: &'a [u8]) -> impl Iterator<Item = &'a [u8]> {
|
||||
let mut rest = haystack;
|
||||
std::iter::from_fn(move || {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(at) = find(rest, needle) {
|
||||
let (section, tail) = rest.split_at(at);
|
||||
rest = &tail[needle.len()..];
|
||||
Some(section)
|
||||
} else {
|
||||
let section = rest;
|
||||
rest = &[];
|
||||
Some(section)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
@@ -1,36 +1,36 @@
|
||||
//! Removes arr torrents only after Transmission says their seeding obligation
|
||||
//! Removes arr torrents only after qBittorrent says their seeding obligation
|
||||
//! is complete. The library/import state is deliberately not consulted (§7.3).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use arr_db::Db;
|
||||
use arr_dl::TransmissionClient;
|
||||
use arr_dl::QbitClient;
|
||||
|
||||
use crate::grab::label_for_root;
|
||||
use crate::reconcile::{Action, ActionFuture, Outcome};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReaperAction {
|
||||
transmission: TransmissionClient,
|
||||
qbit: QbitClient,
|
||||
}
|
||||
|
||||
impl ReaperAction {
|
||||
#[must_use]
|
||||
pub fn new(transmission: TransmissionClient) -> Self {
|
||||
Self { transmission }
|
||||
pub fn new(qbit: QbitClient) -> Self {
|
||||
Self { qbit }
|
||||
}
|
||||
|
||||
async fn tick(&self, labels: &HashSet<String>) -> Result<Vec<Outcome>, arr_dl::Error> {
|
||||
let torrents = self.transmission.list_torrents().await?;
|
||||
let torrents = self.qbit.list_torrents().await?;
|
||||
let mut outcomes = Vec::new();
|
||||
for torrent in torrents {
|
||||
if !torrent.is_finished || !torrent.labels.iter().any(|label| labels.contains(label)) {
|
||||
continue;
|
||||
}
|
||||
self.transmission.remove_torrent(torrent.id, true).await?;
|
||||
self.qbit.remove_torrent(&torrent.hash, true).await?;
|
||||
outcomes.push(Outcome::new(
|
||||
format!("torrent {} finished seeding", torrent.hash),
|
||||
format!("removed torrent {} and its download data", torrent.id),
|
||||
format!("removed torrent {} and its download data", torrent.name),
|
||||
));
|
||||
}
|
||||
Ok(outcomes)
|
||||
@@ -64,85 +64,114 @@ mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use wiremock::matchers::any;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Records what was asked to be deleted, so a test can assert the reaper
|
||||
/// touched one torrent and not its neighbours.
|
||||
#[derive(Clone)]
|
||||
struct Transmission {
|
||||
torrents: Value,
|
||||
removed: Arc<Mutex<Vec<Value>>>,
|
||||
}
|
||||
struct Deletions(Arc<Mutex<Vec<String>>>);
|
||||
|
||||
impl Respond for Transmission {
|
||||
impl Respond for Deletions {
|
||||
fn respond(&self, request: &Request) -> ResponseTemplate {
|
||||
let body: Value = serde_json::from_slice(&request.body).unwrap();
|
||||
let arguments = match body["method"].as_str().unwrap() {
|
||||
"torrent-get" => json!({"torrents": self.torrents}),
|
||||
"torrent-remove" => {
|
||||
self.removed.lock().unwrap().push(body["arguments"].clone());
|
||||
json!({})
|
||||
}
|
||||
method => panic!("unexpected method {method}"),
|
||||
};
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("x-transmission-session-id", "session")
|
||||
.set_body_json(json!({"result": "success", "arguments": arguments}))
|
||||
let body = String::from_utf8_lossy(&request.body).into_owned();
|
||||
self.0.lock().unwrap().push(body);
|
||||
ResponseTemplate::new(200).set_body_string("")
|
||||
}
|
||||
}
|
||||
|
||||
/// A torrent qBittorrent stopped on the ratio limit arr set on it, which
|
||||
/// is what "finished seeding" means to the reaper.
|
||||
fn torrent(id: i64, label: &str, finished: bool) -> Value {
|
||||
json!({
|
||||
"id": id, "name": format!("torrent-{id}"), "hashString": format!("hash-{id}"),
|
||||
"status": if finished { 0 } else { 6 }, "percentDone": 1.0,
|
||||
"downloadDir": "/downloads", "labels": [label], "isFinished": finished
|
||||
"hash": format!("hash-{id}"), "name": format!("torrent-{id}"),
|
||||
"state": if finished { "stoppedUP" } else { "uploading" },
|
||||
"progress": 1.0, "save_path": "/downloads", "tags": label,
|
||||
"ratio": 2.0, "ratio_limit": 1.5,
|
||||
"seeding_time": 10, "seeding_time_limit": -1,
|
||||
"inactive_seeding_time_limit": -1, "last_activity": 0
|
||||
})
|
||||
}
|
||||
|
||||
async fn qbit(server: &MockServer, torrents: Value) -> Arc<Mutex<Vec<String>>> {
|
||||
let deleted = Arc::new(Mutex::new(Vec::new()));
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v2/torrents/info"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(torrents))
|
||||
.mount(server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v2/torrents/delete"))
|
||||
.respond_with(Deletions(Arc::clone(&deleted)))
|
||||
.mount(server)
|
||||
.await;
|
||||
deleted
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removes_only_finished_arr_torrents_with_data() {
|
||||
let server = MockServer::start().await;
|
||||
let removed = Arc::new(Mutex::new(Vec::new()));
|
||||
Mock::given(any())
|
||||
.respond_with(Transmission {
|
||||
torrents: json!([
|
||||
torrent(1, "movies-main", false),
|
||||
torrent(2, "movies-main", true),
|
||||
torrent(3, "radarr", true)
|
||||
]),
|
||||
removed: Arc::clone(&removed),
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
let action = ReaperAction::new(TransmissionClient::new(&server.uri()).unwrap());
|
||||
let deleted = qbit(
|
||||
&server,
|
||||
json!([
|
||||
torrent(1, "movies-main", false),
|
||||
torrent(2, "movies-main", true),
|
||||
torrent(3, "radarr", true)
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let action = ReaperAction::new(QbitClient::new(&server.uri()).unwrap());
|
||||
|
||||
let labels = HashSet::from(["movies-main".to_owned(), "movies-kids".to_owned()]);
|
||||
let outcomes = action.tick(&labels).await.unwrap();
|
||||
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
assert_eq!(
|
||||
*removed.lock().unwrap(),
|
||||
[json!({"ids": [2], "delete-local-data": true})]
|
||||
let deleted = deleted.lock().unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(
|
||||
deleted[0].contains("hashes=hash-2"),
|
||||
"asked: {}",
|
||||
deleted[0]
|
||||
);
|
||||
assert!(
|
||||
deleted[0].contains("deleteFiles=true"),
|
||||
"asked: {}",
|
||||
deleted[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finished_torrent_does_not_need_a_grab_or_import_row() {
|
||||
let server = MockServer::start().await;
|
||||
let removed = Arc::new(Mutex::new(Vec::new()));
|
||||
Mock::given(any())
|
||||
.respond_with(Transmission {
|
||||
torrents: json!([torrent(9, "movies-kids", true)]),
|
||||
removed: Arc::clone(&removed),
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
let action = ReaperAction::new(TransmissionClient::new(&server.uri()).unwrap());
|
||||
let deleted = qbit(&server, json!([torrent(9, "movies-kids", true)])).await;
|
||||
let action = ReaperAction::new(QbitClient::new(&server.uri()).unwrap());
|
||||
|
||||
let labels = HashSet::from(["movies-kids".to_owned()]);
|
||||
action.tick(&labels).await.unwrap();
|
||||
|
||||
assert_eq!(removed.lock().unwrap().len(), 1);
|
||||
assert_eq!(deleted.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_torrent_stopped_by_hand_is_left_alone() {
|
||||
let server = MockServer::start().await;
|
||||
let deleted = qbit(
|
||||
&server,
|
||||
json!([{
|
||||
"hash": "hash-4", "name": "paused by the operator", "state": "stoppedUP",
|
||||
"progress": 1.0, "save_path": "/downloads", "tags": "movies-main",
|
||||
"ratio": 0.2, "ratio_limit": 1.5,
|
||||
"seeding_time": 10, "seeding_time_limit": -1,
|
||||
"inactive_seeding_time_limit": -1, "last_activity": 0
|
||||
}]),
|
||||
)
|
||||
.await;
|
||||
let action = ReaperAction::new(QbitClient::new(&server.uri()).unwrap());
|
||||
|
||||
let labels = HashSet::from(["movies-main".to_owned()]);
|
||||
assert!(action.tick(&labels).await.unwrap().is_empty());
|
||||
assert!(deleted.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,11 +287,11 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct FakeTransmission {
|
||||
struct FakeQbit {
|
||||
torrents: Arc<Mutex<HashMap<i64, String>>>,
|
||||
}
|
||||
|
||||
impl FakeTransmission {
|
||||
impl FakeQbit {
|
||||
async fn find_or_add(&self, movie_id: i64) -> (String, bool) {
|
||||
let mut torrents = self.torrents.lock().await;
|
||||
if let Some(hash) = torrents.get(&movie_id) {
|
||||
@@ -310,7 +310,7 @@ mod tests {
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FakeGrabAction {
|
||||
transmission: FakeTransmission,
|
||||
qbit: FakeQbit,
|
||||
release_id: i64,
|
||||
fail_after_add: bool,
|
||||
}
|
||||
@@ -356,11 +356,11 @@ mod tests {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let (infohash, added) = self.transmission.find_or_add(movie_id).await;
|
||||
let (infohash, added) = self.qbit.find_or_add(movie_id).await;
|
||||
if added && self.fail_after_add {
|
||||
return Err(Box::new(io::Error::new(
|
||||
io::ErrorKind::Interrupted,
|
||||
"simulated process death after Transmission accepted the torrent",
|
||||
"simulated process death after qBittorrent accepted the torrent",
|
||||
)) as ActionError);
|
||||
}
|
||||
|
||||
@@ -448,25 +448,25 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn restart_converges_without_duplicate_external_work() {
|
||||
let (_directory, database, release_id) = seeded_database().await;
|
||||
let transmission = FakeTransmission::default();
|
||||
let qbit = FakeQbit::default();
|
||||
|
||||
let interrupted = ReconcileLoop::new(database.clone()).register(
|
||||
Tick::Reconcile,
|
||||
FakeGrabAction {
|
||||
transmission: transmission.clone(),
|
||||
qbit: qbit.clone(),
|
||||
release_id,
|
||||
fail_after_add: true,
|
||||
},
|
||||
);
|
||||
let first = interrupted.run_tick(Tick::Reconcile).await;
|
||||
assert_eq!(first.failures, 1);
|
||||
assert_eq!(transmission.len().await, 1);
|
||||
assert_eq!(qbit.len().await, 1);
|
||||
drop(interrupted);
|
||||
|
||||
let restarted = ReconcileLoop::new(database.clone()).register(
|
||||
Tick::Reconcile,
|
||||
FakeGrabAction {
|
||||
transmission: transmission.clone(),
|
||||
qbit: qbit.clone(),
|
||||
release_id,
|
||||
fail_after_add: false,
|
||||
},
|
||||
@@ -476,7 +476,7 @@ mod tests {
|
||||
|
||||
assert_eq!(recovered.actions_taken, 1);
|
||||
assert_eq!(stable.actions_taken, 0);
|
||||
assert_eq!(transmission.len().await, 1);
|
||||
assert_eq!(qbit.len().await, 1);
|
||||
let grabs: i64 = sqlx::query_scalar("SELECT count(*) FROM grabs")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
|
||||
@@ -28,7 +28,7 @@ use arr_core::matching::{
|
||||
};
|
||||
use arr_core::{EpisodeId, Language, MovieId};
|
||||
use arr_db::{Blacklist, Db, MoviePolicy, TitlePolicy};
|
||||
use arr_dl::TransmissionClient;
|
||||
use arr_dl::QbitClient;
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
||||
|
||||
use crate::grab::{
|
||||
@@ -50,13 +50,13 @@ impl RssAction {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
prowlarr: ProwlarrClient,
|
||||
transmission: TransmissionClient,
|
||||
qbit: QbitClient,
|
||||
download_dir: PathBuf,
|
||||
seeding: SeedingRules,
|
||||
) -> Self {
|
||||
Self {
|
||||
indexers: IndexerDirectory::new(prowlarr.clone()),
|
||||
grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding),
|
||||
grabber: Grabber::new(prowlarr.clone(), qbit, download_dir, seeding),
|
||||
prowlarr,
|
||||
}
|
||||
}
|
||||
@@ -625,9 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
// §6.2's ladder runs from the failure, not the grab (#245), with
|
||||
// `grabbed_at` as the fallback for rows older than #239's column.
|
||||
let failed_packs = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(grabbed_at) AS "last_failed_at?: String"
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
|
||||
FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||
season_id
|
||||
@@ -712,15 +714,15 @@ async fn load_grabbable(
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path, query_param, query_param_is_missing};
|
||||
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use super::*;
|
||||
use crate::grab::{test_downloads, SeedingLimits};
|
||||
use arr_dl::TransmissionClient;
|
||||
use crate::qbit_fake::FakeQbit;
|
||||
use arr_dl::QbitClient;
|
||||
|
||||
/// The recorded feed: two releases of one wanted title, one that only a
|
||||
/// TMDB id identifies, one near miss, one TV item and one film nobody
|
||||
@@ -771,40 +773,6 @@ mod tests {
|
||||
|
||||
const INDEXERS: [i64; 2] = [7, 9];
|
||||
|
||||
/// Enough of Transmission to add a torrent and list nothing back.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct FakeTransmission {
|
||||
added: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Respond for FakeTransmission {
|
||||
fn respond(&self, request: &Request) -> ResponseTemplate {
|
||||
let body: Value = serde_json::from_slice(&request.body).unwrap();
|
||||
match body["method"].as_str().unwrap_or_default() {
|
||||
"torrent-add" => {
|
||||
let source = body["arguments"]["filename"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let mut added = self.added.lock().unwrap();
|
||||
added.push(source.clone());
|
||||
let id = i64::try_from(added.len()).unwrap();
|
||||
success(&json!({"torrent-added": {
|
||||
"id": id, "name": source, "hashString": format!("{id:040x}")
|
||||
}}))
|
||||
}
|
||||
"torrent-get" => success(&json!({"torrents": []})),
|
||||
_ => success(&json!({})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn success(arguments: &Value) -> ResponseTemplate {
|
||||
ResponseTemplate::new(200).set_body_json(json!({
|
||||
"result": "success", "arguments": arguments
|
||||
}))
|
||||
}
|
||||
|
||||
/// Two indexers, both advertising a text search, both serving the same
|
||||
/// feed to an empty query.
|
||||
async fn prowlarr(feed: &str) -> MockServer {
|
||||
@@ -840,14 +808,8 @@ mod tests {
|
||||
server
|
||||
}
|
||||
|
||||
async fn transmission() -> (MockServer, FakeTransmission) {
|
||||
let server = MockServer::start().await;
|
||||
let fake = FakeTransmission::default();
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(fake.clone())
|
||||
.mount(&server)
|
||||
.await;
|
||||
(server, fake)
|
||||
async fn qbit() -> (MockServer, FakeQbit) {
|
||||
FakeQbit::start().await
|
||||
}
|
||||
|
||||
/// `(tmdb_id, title, year, blocked)`.
|
||||
@@ -872,11 +834,11 @@ mod tests {
|
||||
(dir, database)
|
||||
}
|
||||
|
||||
fn action(prowlarr: &MockServer, transmission: &MockServer) -> RssAction {
|
||||
fn action(prowlarr: &MockServer, qbit: &MockServer) -> RssAction {
|
||||
RssAction::new(
|
||||
ProwlarrClient::new(prowlarr.uri(), "key").unwrap(),
|
||||
TransmissionClient::new(&transmission.uri()).unwrap(),
|
||||
PathBuf::from("/mnt/media/transmission/complete"),
|
||||
QbitClient::new(&qbit.uri()).unwrap(),
|
||||
PathBuf::from("/mnt/media/qbittorrent/complete"),
|
||||
SeedingRules::new(
|
||||
SeedingLimits {
|
||||
ratio: 1.0,
|
||||
@@ -973,7 +935,7 @@ mod tests {
|
||||
])
|
||||
.await;
|
||||
let indexer = prowlarr(FEED).await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1017,7 +979,7 @@ mod tests {
|
||||
async fn a_near_miss_is_not_grabbed() {
|
||||
let (_dir, database) = wanted(&[(9_999_999, "Dune: Part Three", 2024, false)]).await;
|
||||
let indexer = prowlarr(FEED).await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1036,7 +998,7 @@ mod tests {
|
||||
])
|
||||
.await;
|
||||
let indexer = prowlarr(FEED).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1053,7 +1015,7 @@ mod tests {
|
||||
async fn a_blocked_title_still_matches_rss() {
|
||||
let (_dir, database) = wanted(&[(693_134, "Dune: Part Two", 2024, true)]).await;
|
||||
let indexer = prowlarr(FEED).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1071,7 +1033,7 @@ mod tests {
|
||||
])
|
||||
.await;
|
||||
let indexer = prowlarr(FEED).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1101,7 +1063,7 @@ mod tests {
|
||||
let (_dir, database) = wanted(&[]).await;
|
||||
let (_season_id, episodes) = wanted_series(&database, &["2024-04-11", "2099-01-01"]).await;
|
||||
let indexer = prowlarr(TV_FEED).await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1147,7 +1109,7 @@ mod tests {
|
||||
"Unrelated.S01E03.2160p.WEB-DL-GROUP</title>",
|
||||
);
|
||||
let indexer = prowlarr(&pack_only).await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1164,6 +1126,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// §6.2, issue #245: the RSS lane reads the same ladder, anchored on the
|
||||
/// failure. A pack sent five weeks ago and condemned at import ten
|
||||
/// minutes ago holds the lane shut, where anchoring on the grab would
|
||||
/// have handed it the very release class that just failed.
|
||||
#[tokio::test]
|
||||
async fn a_pack_that_stalled_for_weeks_stays_backed_off_on_rss() {
|
||||
let (_dir, database) = wanted(&[]).await;
|
||||
let (season_id, episodes) =
|
||||
wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await;
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
|
||||
'https://tracker/oldpack.torrent', '{}', 'eligible')
|
||||
RETURNING id",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
|
||||
grabbed_at, failed_at)
|
||||
VALUES (?, 'season', ?, 'dead', 'failed',
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-35 days'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-10 minutes'))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(season_id)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr(TV_FEED).await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
let sent: Vec<(String, i64, String)> = tv_grabs(&database)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|(_, _, state)| state == "sent")
|
||||
.collect();
|
||||
assert!(
|
||||
!sent.iter().any(|(kind, _, _)| kind == "season"),
|
||||
"the failure is ten minutes old, so the pack lane is shut: {sent:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
sent,
|
||||
vec![
|
||||
("episode".to_owned(), episodes[0], "sent".to_owned()),
|
||||
("episode".to_owned(), episodes[1], "sent".to_owned()),
|
||||
("episode".to_owned(), episodes[2], "sent".to_owned()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// §6.2 with #117's guard: an episode already on disk keeps the season
|
||||
/// per-episode here too — the pack is skipped and the open gaps take
|
||||
/// their singles.
|
||||
@@ -1181,7 +1197,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr(TV_FEED).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1213,7 +1229,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr(TV_FEED).await;
|
||||
let (downloader, _fake) = transmission().await;
|
||||
let (downloader, _fake) = qbit().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ use std::path::PathBuf;
|
||||
use arr_core::grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
|
||||
use arr_core::Language;
|
||||
use arr_db::{Blacklist, Db, TitlePolicy};
|
||||
use arr_dl::TransmissionClient;
|
||||
use arr_dl::QbitClient;
|
||||
use arr_indexer::{ProwlarrClient, SearchRelease, TvSelector, TvTarget};
|
||||
use arr_parse::EpisodeClaim;
|
||||
|
||||
@@ -47,13 +47,13 @@ impl TvGrabAction {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
prowlarr: ProwlarrClient,
|
||||
transmission: TransmissionClient,
|
||||
qbit: QbitClient,
|
||||
download_dir: PathBuf,
|
||||
seeding: SeedingRules,
|
||||
) -> Self {
|
||||
Self {
|
||||
indexers: IndexerDirectory::new(prowlarr.clone()),
|
||||
grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding),
|
||||
grabber: Grabber::new(prowlarr.clone(), qbit, download_dir, seeding),
|
||||
prowlarr,
|
||||
}
|
||||
}
|
||||
@@ -544,7 +544,7 @@ impl TvGrabAction {
|
||||
|
||||
/// The manual one-click episode grab (§9.3, issue #132): the release is
|
||||
/// already chosen off the episode deck, so this skips search and scoring
|
||||
/// and sends it straight to Transmission. A manual grab may take a
|
||||
/// and sends it straight to qBittorrent. A manual grab may take a
|
||||
/// `waived` release (§9.3), never a `rejected` one.
|
||||
pub(crate) async fn grab_episode_release_now(
|
||||
&self,
|
||||
@@ -645,7 +645,7 @@ impl TvGrabAction {
|
||||
}
|
||||
|
||||
/// The manual one-click season grab (§9.3, issue #125): a chosen pack
|
||||
/// goes straight to Transmission against the season's still-open gaps.
|
||||
/// goes straight to qBittorrent against the season's still-open gaps.
|
||||
pub(crate) async fn grab_season_release_now(
|
||||
&self,
|
||||
database: &Db,
|
||||
@@ -1139,12 +1139,15 @@ async fn record_pack_search(database: &Db, season_id: i64) -> Result<(), GrabErr
|
||||
/// Whether failed season-pack grabs still hold this season off the pack
|
||||
/// lane. §6.2: a failure quiets the pack search on the shared backoff curve
|
||||
/// (each failed grab is one attempt), it never disables it. Anchored on the
|
||||
/// latest failed grab's `grabbed_at`, not `failed_at` — moving §6.2's retry
|
||||
/// cadence to failure time is its own decision, not #239's.
|
||||
/// latest `failed_at` (#245), the same anchor §5.7's window uses: a torrent
|
||||
/// can stall for weeks before `ffprobe` condemns it, and measured from the
|
||||
/// grab the whole ladder would already have elapsed when the failure lands.
|
||||
/// `grabbed_at` is the fallback for rows written before #239 added the
|
||||
/// column.
|
||||
async fn pack_backoff_active(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(grabbed_at) AS "last_failed_at?: String"
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
|
||||
FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||
season_id
|
||||
@@ -1220,89 +1223,16 @@ fn air_date_time(value: Option<&str>) -> Option<std::time::SystemTime> {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use crate::grab::{test_downloads, SeedingLimits};
|
||||
use crate::qbit_fake::FakeQbit;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A Transmission that dedupes on the infohash, like the real one.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct FakeTransmission {
|
||||
torrents: Arc<Mutex<Vec<FakeTorrent>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct FakeTorrent {
|
||||
id: i64,
|
||||
hash: String,
|
||||
source: String,
|
||||
progress: f64,
|
||||
}
|
||||
|
||||
impl FakeTransmission {
|
||||
fn torrents(&self) -> Vec<FakeTorrent> {
|
||||
self.torrents.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn add(&self, arguments: &Value) -> ResponseTemplate {
|
||||
let source = arguments["filename"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let mut torrents = self.torrents.lock().unwrap();
|
||||
if let Some(existing) = torrents.iter().find(|torrent| torrent.source == source) {
|
||||
return success(&json!({"torrent-duplicate": {
|
||||
"id": existing.id, "name": existing.source, "hashString": existing.hash
|
||||
}}));
|
||||
}
|
||||
let id = i64::try_from(torrents.len()).unwrap() + 1;
|
||||
let hash = format!("{:040x}", id * 7);
|
||||
torrents.push(FakeTorrent {
|
||||
id,
|
||||
hash: hash.clone(),
|
||||
source: source.clone(),
|
||||
progress: 0.0,
|
||||
});
|
||||
success(&json!({"torrent-added": {"id": id, "name": source, "hashString": hash}}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Respond for FakeTransmission {
|
||||
fn respond(&self, request: &Request) -> ResponseTemplate {
|
||||
let body: Value = serde_json::from_slice(&request.body).unwrap();
|
||||
match body["method"].as_str().unwrap_or_default() {
|
||||
"torrent-add" => self.add(&body["arguments"]),
|
||||
"torrent-get" => {
|
||||
let torrents: Vec<Value> = self
|
||||
.torrents()
|
||||
.into_iter()
|
||||
.map(|torrent| {
|
||||
json!({
|
||||
"id": torrent.id, "name": torrent.source,
|
||||
"hashString": torrent.hash, "status": 4,
|
||||
"percentDone": torrent.progress,
|
||||
"downloadDir": "/downloads", "labels": []
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
success(&json!({"torrents": torrents}))
|
||||
}
|
||||
_ => success(&json!({})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn success(arguments: &Value) -> ResponseTemplate {
|
||||
ResponseTemplate::new(200).set_body_json(json!({
|
||||
"result": "success", "arguments": arguments
|
||||
}))
|
||||
}
|
||||
|
||||
/// A season pack and its three episodes, all eligible under the seeded
|
||||
/// TV main policy (§5.5 bands: 2160p floor 3 GiB, per #95).
|
||||
const TV_RSS: &str = r#"<rss><channel>
|
||||
@@ -1361,14 +1291,8 @@ mod tests {
|
||||
server
|
||||
}
|
||||
|
||||
async fn transmission() -> (MockServer, FakeTransmission) {
|
||||
let server = MockServer::start().await;
|
||||
let fake = FakeTransmission::default();
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(fake.clone())
|
||||
.mount(&server)
|
||||
.await;
|
||||
(server, fake)
|
||||
async fn qbit() -> (MockServer, FakeQbit) {
|
||||
FakeQbit::start().await
|
||||
}
|
||||
|
||||
/// Fallout S01 with three wanted episodes at the given air dates.
|
||||
@@ -1406,11 +1330,11 @@ mod tests {
|
||||
(dir, database, season_id)
|
||||
}
|
||||
|
||||
fn action(prowlarr: &MockServer, transmission: &MockServer) -> TvGrabAction {
|
||||
fn action(prowlarr: &MockServer, qbit: &MockServer) -> TvGrabAction {
|
||||
TvGrabAction::new(
|
||||
ProwlarrClient::new(prowlarr.uri(), "key").unwrap(),
|
||||
TransmissionClient::new(&transmission.uri()).unwrap(),
|
||||
PathBuf::from("/mnt/media/transmission/complete"),
|
||||
QbitClient::new(&qbit.uri()).unwrap(),
|
||||
PathBuf::from("/mnt/media/qbittorrent/complete"),
|
||||
SeedingRules::new(
|
||||
SeedingLimits {
|
||||
ratio: 1.5,
|
||||
@@ -1445,7 +1369,7 @@ mod tests {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let outcomes = action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1470,7 +1394,7 @@ mod tests {
|
||||
let (_dir, database, _season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-18", "2999-01-01"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1497,8 +1421,17 @@ mod tests {
|
||||
/// release on the blacklist and the season falls back to per-episode —
|
||||
/// the pack is not tried again and the episodes are not written off.
|
||||
/// Seed what the import tick leaves behind after a pack fails: one
|
||||
/// `failed` season grab per (infohash, age) pair.
|
||||
/// `failed` season grab per (infohash, age) pair, grabbed and failed at
|
||||
/// the same age, which is the usual case — the two are minutes apart.
|
||||
async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) {
|
||||
let pairs: Vec<(&str, &str)> = ages.iter().map(|age| (*age, *age)).collect();
|
||||
stalled_failed_packs(database, season_id, &pairs).await;
|
||||
}
|
||||
|
||||
/// The same seed, but with the grab and the failure at different ages —
|
||||
/// the #245 case, where a torrent stalls for weeks before `ffprobe`
|
||||
/// condemns it at import.
|
||||
async fn stalled_failed_packs(database: &Db, season_id: i64, ages: &[(&str, &str)]) {
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
|
||||
@@ -1508,16 +1441,19 @@ mod tests {
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
for (index, age) in ages.iter().enumerate() {
|
||||
for (index, (grabbed_age, failed_age)) in ages.iter().enumerate() {
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
|
||||
grabbed_at, failed_at)
|
||||
VALUES (?, 'season', ?, ?, 'failed',
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(season_id)
|
||||
.bind(format!("dead{index}"))
|
||||
.bind(age)
|
||||
.bind(grabbed_age)
|
||||
.bind(failed_age)
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1532,7 +1468,7 @@ mod tests {
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(&database, season_id, &["-10 minutes"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1564,7 +1500,7 @@ mod tests {
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(&database, season_id, &["-2 hours"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1576,6 +1512,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// §6.2, issue #245: the ladder runs from the failure, not the grab. A
|
||||
/// pack sent five weeks ago and condemned by `ffprobe` ten minutes ago
|
||||
/// is one minute into a 1h window, not five weeks past it — the lane
|
||||
/// stays quiet and the episodes carry the season instead.
|
||||
#[tokio::test]
|
||||
async fn a_pack_that_stalled_for_weeks_backs_off_from_the_failure() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
stalled_failed_packs(&database, season_id, &[("-35 days", "-10 minutes")]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
let sources: Vec<String> = fake
|
||||
.torrents()
|
||||
.into_iter()
|
||||
.map(|torrent| torrent.source)
|
||||
.collect();
|
||||
assert!(
|
||||
sources
|
||||
.iter()
|
||||
.all(|source| !source.ends_with("pack.torrent")),
|
||||
"the grab is five weeks old but the failure is ten minutes old: {sources:?}"
|
||||
);
|
||||
assert_eq!(sources.len(), 3, "{sources:?}");
|
||||
}
|
||||
|
||||
/// Repeated failures ride the capped curve: five failed packs mean a 7d
|
||||
/// window — still closed at 6d, open at 8d. Quiet, never off.
|
||||
#[tokio::test]
|
||||
@@ -1588,7 +1552,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
action(&indexer, &downloader).tick(&inside.1).await.unwrap();
|
||||
assert!(
|
||||
fake.torrents()
|
||||
@@ -1604,7 +1568,7 @@ mod tests {
|
||||
&["-30 days", "-25 days", "-20 days", "-12 days", "-8 days"],
|
||||
)
|
||||
.await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
action(&indexer, &downloader)
|
||||
.tick(&elapsed.1)
|
||||
.await
|
||||
@@ -1621,7 +1585,7 @@ mod tests {
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
failed_packs(&database, season_id, &["-10 minutes"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.search_season_now(&database, season_id)
|
||||
@@ -1658,7 +1622,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
|
||||
@@ -1681,7 +1645,7 @@ mod tests {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
let stamp = |database: Db| async move {
|
||||
sqlx::query_scalar::<_, Option<String>>(
|
||||
@@ -1745,7 +1709,7 @@ mod tests {
|
||||
)
|
||||
.mount(&indexer)
|
||||
.await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
let action = action(&indexer, &downloader);
|
||||
|
||||
action.tick(&database).await.unwrap();
|
||||
@@ -1780,7 +1744,7 @@ mod tests {
|
||||
/// be accepted onto the channel. `search_attempts` starts at 5 (a 7-day
|
||||
/// backoff, nowhere near elapsed) so a tick would skip this episode;
|
||||
/// ending at 1 rather than 6 proves the reset happened, and a torrent in
|
||||
/// Transmission proves the grab did.
|
||||
/// qBittorrent proves the grab did.
|
||||
#[tokio::test]
|
||||
async fn a_manual_episode_search_searches_and_grabs_now() {
|
||||
let (_dir, database, _season_id) =
|
||||
@@ -1798,7 +1762,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.search_episode_now(&database, episode_id)
|
||||
@@ -1828,7 +1792,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Issue #132: an `EpisodeCommand::Grab` sends the already-chosen release
|
||||
/// straight to Transmission — no indexer search at all.
|
||||
/// straight to qBittorrent — no indexer search at all.
|
||||
#[tokio::test]
|
||||
async fn a_manual_episode_grab_sends_the_chosen_release_without_searching() {
|
||||
let (_dir, database, _season_id) = wanted_season(&["2024-04-11", "2024-04-18"]).await;
|
||||
@@ -1837,7 +1801,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
|
||||
VALUES (7, 'chosen', 'Fallout.S01E01.2160p.WEB-DL.DDP5.1', 10737418240,
|
||||
@@ -1893,7 +1857,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
|
||||
action(&indexer, &downloader)
|
||||
.search_season_now(&database, season_id)
|
||||
@@ -1930,13 +1894,13 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Issue #132: a `SeasonCommand::Grab` sends the already-chosen pack
|
||||
/// straight to Transmission — no indexer search at all.
|
||||
/// straight to qBittorrent — no indexer search at all.
|
||||
#[tokio::test]
|
||||
async fn a_manual_season_grab_sends_the_chosen_pack_without_searching() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
let (downloader, fake) = qbit().await;
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict)
|
||||
VALUES (7, 'chosen-pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920,
|
||||
|
||||
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user