Compare commits
222 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 591cf27dc5 | |||
| 9e445d0398 | |||
| 1e30c49a72 | |||
| 55373d228c | |||
| c78c73ec6c | |||
| 58a45fc98e | |||
| a0dc07f085 | |||
| de6c35cce3 | |||
| a6720f4c06 | |||
| 9a413b10ed | |||
| bbb6d2f4a4 | |||
| c454b3ec11 | |||
| c4d4ade4da | |||
| cfa0eaa77d | |||
| 955a0f309c | |||
| df1af383a3 | |||
| 7df83133b3 | |||
| 5974d43785 | |||
| 43e65514ed | |||
| a42f3970d2 | |||
| 2a58a103d3 | |||
| 528aadf59c | |||
| 070fd3d7ba | |||
| 0a0c6359a4 | |||
| 0b4ded3fe9 | |||
| 6fa88b8f1f | |||
| afc17ca34a | |||
| 6847d25cf5 | |||
| 50056a2bd9 | |||
| c962998a2b | |||
| 815a072ef0 | |||
| 690eaeda5c | |||
| d23ae0ebcf | |||
| 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
|
- name: design tokens
|
||||||
if: steps.probe.outputs.present == 'true'
|
if: steps.probe.outputs.present == 'true'
|
||||||
run: pnpm -C web run check-tokens
|
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}\"}"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ on:
|
|||||||
- 'crates/arr-indexer/**'
|
- 'crates/arr-indexer/**'
|
||||||
- 'crates/arr-meta/**'
|
- 'crates/arr-meta/**'
|
||||||
- 'crates/arr-probe/**'
|
- 'crates/arr-probe/**'
|
||||||
|
- 'crates/arr-subs/**'
|
||||||
- 'crates/arr-api/**'
|
- 'crates/arr-api/**'
|
||||||
- 'crates/arr-daemon/**'
|
- 'crates/arr-daemon/**'
|
||||||
- '.gitea/workflows/e2e.yml'
|
- '.gitea/workflows/e2e.yml'
|
||||||
@@ -70,10 +71,21 @@ jobs:
|
|||||||
curl -LsSf https://get.nexte.st/latest/linux \
|
curl -LsSf https://get.nexte.st/latest/linux \
|
||||||
| tar zxf - -C "$HOME/.cargo/bin"
|
| tar zxf - -C "$HOME/.cargo/bin"
|
||||||
|
|
||||||
|
- name: alass
|
||||||
|
# No distro package; the subtitle sync path (DESIGN.md §15) shells
|
||||||
|
# out to a binary literally named `alass`, which the crate is not.
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
command -v alass >/dev/null || {
|
||||||
|
cargo install alass-cli --locked
|
||||||
|
ln -sf "$HOME/.cargo/bin/alass-cli" "$HOME/.cargo/bin/alass"
|
||||||
|
}
|
||||||
|
|
||||||
- name: build daemon
|
- name: build daemon
|
||||||
# The harness spawns this binary; building it here keeps the compile
|
# The harness spawns this binary; building it here keeps the compile
|
||||||
# out of the first test's boot window.
|
# out of the first test's boot window. `translate-command` is the
|
||||||
run: cargo build -p arr-daemon --bin arr
|
# subtitle e2e scenarios' stub-script translation backend.
|
||||||
|
run: cargo build -p arr-daemon --bin arr --features translate-command
|
||||||
|
|
||||||
- name: e2e
|
- name: e2e
|
||||||
env:
|
env:
|
||||||
|
|||||||
+45
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 2,
|
"schemaVersion": 2,
|
||||||
"generatedAt": "2026-08-22T00:00:00.000Z",
|
"generatedAt": "2026-08-26T00:00:00.000Z",
|
||||||
"title": "Design System: arr — Master Control",
|
"title": "Design System: arr — Master Control",
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"colorMeta": {
|
"colorMeta": {
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
"oklch(0.8 0.015 250)"
|
"oklch(0.8 0.015 250)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"status-channel": {
|
"status-airing": {
|
||||||
"role": "tertiary",
|
"role": "tertiary",
|
||||||
"displayName": "Channel Violet",
|
"displayName": "Channel Violet",
|
||||||
"canonical": "oklch(0.78 0.14 305)",
|
"canonical": "oklch(0.78 0.14 305)",
|
||||||
@@ -106,6 +106,20 @@
|
|||||||
"oklch(0.78 0.14 305)",
|
"oklch(0.78 0.14 305)",
|
||||||
"oklch(0.86 0.09 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": {
|
"typographyMeta": {
|
||||||
@@ -202,11 +216,27 @@
|
|||||||
{
|
{
|
||||||
"name": "Status chip",
|
"name": "Status chip",
|
||||||
"kind": "custom",
|
"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.",
|
"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>",
|
"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); }"
|
"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)",
|
"name": "Module (channel strip)",
|
||||||
"kind": "card",
|
"kind": "card",
|
||||||
@@ -290,6 +320,16 @@
|
|||||||
"name": "The Dead-Air Rule",
|
"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.",
|
"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"
|
"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": [
|
"dos": [
|
||||||
@@ -297,7 +337,8 @@
|
|||||||
"Do keep every control ≥44px, focus rings cyan and visible, and reduced-motion honored.",
|
"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 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 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": [
|
"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.",
|
"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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -34,5 +34,5 @@
|
|||||||
false
|
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"
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
|
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -94,5 +94,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708"
|
"hash": "06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d"
|
||||||
}
|
}
|
||||||
+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",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -58,5 +58,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e"
|
"hash": "0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d"
|
||||||
}
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT required_audio AS \"required_audio!: String\",\n dub_blacklist AS \"dub_blacklist!: String\",\n hdr_rules AS \"hdr_rules!: String\",\n size_bands AS \"size_bands!: String\",\n resolution_pref AS \"resolution_pref!: String\",\n source_weights AS \"source_weights!: String\",\n score_weights AS \"score_weights!: String\"\n FROM policies WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "required_audio!: String",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "required_audio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dub_blacklist!: String",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "dub_blacklist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hdr_rules!: String",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "hdr_rules"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "size_bands!: String",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "size_bands"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resolution_pref!: String",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "resolution_pref"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source_weights!: String",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "source_weights"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "score_weights!: String",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "score_weights"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "100e5e9297a9a917eb107c673ac492efe0df0123d9def212d84b88e57aaa0484"
|
||||||
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id AS \"id!: i64\" FROM movies WHERE root_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "movies",
|
||||||
|
"name": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2bb8e5739bd8d16bc7c5a08f5530839c26fbbea836aca6a55a43d75210ae4368"
|
||||||
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
|
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -46,5 +46,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be"
|
"hash": "35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46"
|
||||||
}
|
}
|
||||||
+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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019"
|
"hash": "3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
|
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -190,5 +190,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614"
|
"hash": "3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0"
|
||||||
}
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -94,5 +94,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c"
|
"hash": "4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db"
|
||||||
}
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"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 -- 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 Transmission, 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": [
|
||||||
|
{
|
||||||
|
"name": "season_id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "grabs",
|
||||||
|
"name": "target_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name!: String",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "releases",
|
||||||
|
"name": "name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "infohash!: String",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "grabs",
|
||||||
|
"name": "infohash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "failed_at",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "grabs",
|
||||||
|
"name": "failed_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "grabbed_at!: String",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "grabs",
|
||||||
|
"name": "grabbed_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "594d1391c0e9d8a0dded5a354e0dc438bfdbac096ef23b5c3b816afdc03684ae"
|
||||||
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -58,5 +58,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86"
|
"hash": "6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8"
|
||||||
}
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+9
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -134,6 +134,12 @@
|
|||||||
"name": "rejected_rule"
|
"name": "rejected_rule"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "blacklist_reason?: String",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Null",
|
||||||
|
"origin": "Expression"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -151,8 +157,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f"
|
"hash": "7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f"
|
||||||
}
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+9
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -134,6 +134,12 @@
|
|||||||
"name": "rejected_rule"
|
"name": "rejected_rule"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "blacklist_reason?: String",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Null",
|
||||||
|
"origin": "Expression"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -151,8 +157,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e"
|
"hash": "8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759"
|
||||||
}
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+15
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT normalised_name AS \"normalised_name!: String\", infohash FROM blacklist",
|
"query": "SELECT normalised_name AS \"normalised_name!: String\",\n infohash,\n reason AS \"reason!: String\"\n FROM blacklist\n ORDER BY id",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -24,6 +24,17 @@
|
|||||||
"name": "infohash"
|
"name": "infohash"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reason!: String",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "blacklist",
|
||||||
|
"name": "reason"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -31,8 +42,9 @@
|
|||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
true
|
true,
|
||||||
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97"
|
"hash": "939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c"
|
||||||
}
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+9
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -134,6 +134,12 @@
|
|||||||
"name": "rejected_rule"
|
"name": "rejected_rule"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "blacklist_reason?: String",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Null",
|
||||||
|
"origin": "Expression"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -151,8 +157,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba"
|
"hash": "9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389"
|
||||||
}
|
}
|
||||||
+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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -34,5 +34,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc"
|
"hash": "a2023f024d53aa5f139da347f22af38d57c6f846bb8eff74958fd4d1299268f8"
|
||||||
}
|
}
|
||||||
+4
-4
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT id FROM roots WHERE path = ? AND id <> ?",
|
"query": "SELECT id AS \"id!: i64\" FROM roots WHERE policy_id = ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id!: i64",
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"type_info": "Integer",
|
"type_info": "Integer",
|
||||||
"origin": {
|
"origin": {
|
||||||
@@ -16,11 +16,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 2
|
"Right": 1
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59"
|
"hash": "a28caf97dfb6a7e6b69543df0693e15c746702c36a0d4068fa70d28b950e7562"
|
||||||
}
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id AS \"id!: i64\" FROM roots\n WHERE CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END = ?\n AND (? IS NULL OR id <> ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "roots",
|
||||||
|
"name": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bda8991590c009ca7084fed36fae088f1b6ae12ad95f972724a417711ad37fa1"
|
||||||
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "UPDATE grabs SET state = 'failed' WHERE id = ?",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 1
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294"
|
|
||||||
}
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162"
|
"hash": "c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5"
|
||||||
}
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id AS \"id!: i64\" FROM series WHERE root_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "cc5686ba4766a79d333e98ddcce232fc8a45e31c1a74ae487198b2d286b81e96"
|
||||||
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -82,5 +82,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42"
|
"hash": "dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777"
|
||||||
}
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE grabs\n SET state = 'failed',\n failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1"
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ crates/
|
|||||||
├── arr-indexer/ Torznab via Prowlarr
|
├── arr-indexer/ Torznab via Prowlarr
|
||||||
├── arr-dl/ Transmission RPC
|
├── arr-dl/ Transmission RPC
|
||||||
├── arr-probe/ ffprobe wrapper
|
├── arr-probe/ ffprobe wrapper
|
||||||
|
├── arr-subs/ subtitle providers, translation, sync
|
||||||
├── arr-db/ sqlx + migrations
|
├── arr-db/ sqlx + migrations
|
||||||
├── arr-api/ axum + OpenAPI
|
├── arr-api/ axum + OpenAPI
|
||||||
├── arr-compat/ Radarr/Sonarr v3 shim for Jellyseerr
|
├── arr-compat/ Radarr/Sonarr v3 shim for Jellyseerr
|
||||||
|
|||||||
Generated
+45
@@ -32,9 +32,12 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"arr-core",
|
"arr-core",
|
||||||
"arr-db",
|
"arr-db",
|
||||||
|
"arr-dl",
|
||||||
"arr-indexer",
|
"arr-indexer",
|
||||||
"arr-meta",
|
"arr-meta",
|
||||||
"arr-parse",
|
"arr-parse",
|
||||||
|
"arr-probe",
|
||||||
|
"arr-subs",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -88,6 +91,7 @@ dependencies = [
|
|||||||
"arr-meta",
|
"arr-meta",
|
||||||
"arr-parse",
|
"arr-parse",
|
||||||
"arr-probe",
|
"arr-probe",
|
||||||
|
"arr-subs",
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -139,12 +143,14 @@ dependencies = [
|
|||||||
name = "arr-e2e"
|
name = "arr-e2e"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arr-db",
|
||||||
"arr-dl",
|
"arr-dl",
|
||||||
"arr-indexer",
|
"arr-indexer",
|
||||||
"arr-meta",
|
"arr-meta",
|
||||||
"chrono",
|
"chrono",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -194,11 +200,30 @@ dependencies = [
|
|||||||
"arr-core",
|
"arr-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "assert-json-diff"
|
name = "assert-json-diff"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -366,6 +391,17 @@ dependencies = [
|
|||||||
"rand_core",
|
"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]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -531,6 +567,15 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ arr-indexer = { path = "crates/arr-indexer" }
|
|||||||
arr-meta = { path = "crates/arr-meta" }
|
arr-meta = { path = "crates/arr-meta" }
|
||||||
arr-parse = { path = "crates/arr-parse" }
|
arr-parse = { path = "crates/arr-parse" }
|
||||||
arr-probe = { path = "crates/arr-probe" }
|
arr-probe = { path = "crates/arr-probe" }
|
||||||
|
arr-subs = { path = "crates/arr-subs" }
|
||||||
|
|
||||||
# Runtime and HTTP
|
# Runtime and HTTP
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
@@ -47,8 +48,10 @@ quick-xml = { version = "0.37", features = ["serialize"] }
|
|||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
|
||||||
# Odds and ends
|
# Odds and ends
|
||||||
|
chardetng = "1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
|
encoding_rs = "0.8"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|||||||
@@ -174,6 +174,13 @@ not yet found, violet (`--status-airing`) for downloading, neutral for
|
|||||||
untracked. The word stays present in every chip, so state survives with colour
|
untracked. The word stays present in every chip, so state survives with colour
|
||||||
removed.
|
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
|
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
|
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
|
hold back `ended`. The accepted consequence is that specials are visible and
|
||||||
@@ -199,6 +206,26 @@ fake profile per root and ignoring whatever it sends.
|
|||||||
Per-title `overrides` relax the root policy for one title. Same mechanism in
|
Per-title `overrides` relax the root policy for one title. Same mechanism in
|
||||||
both directions — `only_4k` tightens, `allow_english_audio` loosens.
|
both directions — `only_4k` tightens, `allow_english_audio` loosens.
|
||||||
|
|
||||||
|
**Stored verdicts follow the effective policy.** A release's verdict is
|
||||||
|
stamped by the search that found it, and both §9.3's deck and the daemon's
|
||||||
|
manual-grab gate read that stored column. So the four operator actions that
|
||||||
|
change a title's effective policy — editing its overrides, moving it to a
|
||||||
|
root with a different policy, pointing a root at a different policy, and
|
||||||
|
editing the contents of a policy some root points at — re-derive the stored
|
||||||
|
verdicts of everything they touch, inline in the same request. The operator
|
||||||
|
is never left reading a verdict computed under a policy that no longer
|
||||||
|
applies.
|
||||||
|
|
||||||
|
The fourth is the widest: a repoint moves one library, a policy edit moves
|
||||||
|
every library sharing the policy. Inline still holds there. Re-evaluation is
|
||||||
|
pure and in-memory, a row whose verdict does not move is not rewritten, and
|
||||||
|
the ceiling is the whole database rather than something that grows with the
|
||||||
|
number of roots — roots partition titles, and a title has exactly one root.
|
||||||
|
Measured over 2000 titles and 10 000 stored releases split across two roots
|
||||||
|
sharing one policy: 0.36 s when the edit moves no verdict, 2.7 s in the
|
||||||
|
pathological case where it moves all 10 000. A rename changes no rule and
|
||||||
|
re-derives nothing.
|
||||||
|
|
||||||
### 5.2 Language
|
### 5.2 Language
|
||||||
|
|
||||||
Requires a concept the release name does not carry: the **original language of
|
Requires a concept the release name does not carry: the **original language of
|
||||||
@@ -385,6 +412,13 @@ happened within the last 30 days. One bad torrent is not a decision — a
|
|||||||
release that hard-failed is blacklisted (§6.3) and the next candidate is
|
release that hard-failed is blacklisted (§6.3) and the next candidate is
|
||||||
grabbed, which is the system working.
|
grabbed, which is the system working.
|
||||||
|
|
||||||
|
The window runs from the failure, not the grab. The two are usually minutes
|
||||||
|
apart, but a torrent can sit stalling on a dead swarm for five weeks before
|
||||||
|
`ffprobe` finally condemns it — and that failure is fresh evidence the target
|
||||||
|
is broken now, not history. Measured from the grab it would be born outside
|
||||||
|
the window and a genuinely broken target could never surface. So `grabs`
|
||||||
|
records `failed_at` alongside `grabbed_at`, and the window reads it.
|
||||||
|
|
||||||
The window is what lets the queue be emptied. Nothing clears a `grabs` row, so
|
The window is what lets the queue be emptied. Nothing clears a `grabs` row, so
|
||||||
without it the queue only ever grows and the one season that wants attention
|
without it the queue only ever grows and the one season that wants attention
|
||||||
sits behind eight that were dealt with months ago. It is the queue's version of
|
sits behind eight that were dealt with months ago. It is the queue's version of
|
||||||
@@ -395,6 +429,22 @@ 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
|
it is genuinely broken. Nothing is dismissed by hand and no acknowledgement
|
||||||
state is stored, so there is no second thing to keep correct.
|
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 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.
|
||||||
|
|
||||||
|
The same rule applies on all three lanes and in both readers.
|
||||||
|
`GET /api/queues/attention` (§9.3) and the `ntfy` notification (§9.5) are two
|
||||||
|
views of one queue; filtering differently tells the operator two different
|
||||||
|
stories on two channels.
|
||||||
|
|
||||||
## 6. Sourcing
|
## 6. Sourcing
|
||||||
|
|
||||||
### 6.1 Prowlarr, per-indexer Torznab
|
### 6.1 Prowlarr, per-indexer Torznab
|
||||||
@@ -437,6 +487,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
|
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.
|
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
|
**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
|
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.
|
single largest source of wasted queries in Radarr and it is free to avoid.
|
||||||
@@ -505,7 +571,12 @@ Media kind first, hard audience boundary second, people nowhere.
|
|||||||
- **Release group is deliberately absent.** It is not a selection criterion and
|
- **Release group is deliberately absent.** It is not a selection criterion and
|
||||||
it makes filenames long enough to break a terminal.
|
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
|
During transition, write into the existing roots so Jellyfin needs no
|
||||||
reconfiguration and new content appears immediately. Radarr will not touch a
|
reconfiguration and new content appears immediately. Radarr will not touch a
|
||||||
@@ -532,7 +603,8 @@ retry counts, dead-lettering and reconciliation against Transmission anyway,
|
|||||||
because Transmission is an external system that changes underneath the app.
|
because Transmission is an external system that changes underneath the app.
|
||||||
|
|
||||||
Transient state — a search in flight, download progress — is in memory and
|
Transient state — a search in flight, download progress — is in memory and
|
||||||
rebuilt from Transmission on startup.
|
rebuilt from Transmission 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
|
Ticks are staggered: reconcile every 30 s, RSS every 10 min, metadata refresh
|
||||||
daily, reaper every 5 min.
|
daily, reaper every 5 min.
|
||||||
@@ -615,7 +687,7 @@ notifying on everything and being muted within a week.
|
|||||||
|
|
||||||
- **Imported** → to the title's owners. The only good-news notification.
|
- **Imported** → to the title's owners. The only good-news notification.
|
||||||
- **Needs a decision** → to the operator alone. Entered the no-PT-source queue,
|
- **Needs a decision** → to the operator alone. Entered the no-PT-source queue,
|
||||||
or hard-failed twice on different releases.
|
or the needs-a-decision queue (§5.7).
|
||||||
- **Broken** → to the operator alone. Prowlarr, Transmission or TMDB
|
- **Broken** → to the operator alone. Prowlarr, Transmission or TMDB
|
||||||
unreachable, disk full.
|
unreachable, disk full.
|
||||||
|
|
||||||
@@ -679,6 +751,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 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; Transmission's own UI lists the rest.
|
||||||
|
|
||||||
|
Nothing is persisted — no progress column, no new table. The snapshot comes
|
||||||
|
from Transmission 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
|
## 10. Persistence
|
||||||
|
|
||||||
SQLite via `sqlx`, compile-time-checked queries, migrations in `arr-db`.
|
SQLite via `sqlx`, compile-time-checked queries, migrations in `arr-db`.
|
||||||
@@ -705,6 +808,7 @@ arr-meta TMDB client
|
|||||||
arr-indexer Torznab via Prowlarr
|
arr-indexer Torznab via Prowlarr
|
||||||
arr-dl Transmission RPC
|
arr-dl Transmission RPC
|
||||||
arr-probe ffprobe wrapper
|
arr-probe ffprobe wrapper
|
||||||
|
arr-subs subtitle providers, translation, sync
|
||||||
arr-db sqlx + migrations
|
arr-db sqlx + migrations
|
||||||
arr-api axum + OpenAPI
|
arr-api axum + OpenAPI
|
||||||
arr-compat Radarr/Sonarr v3 shim for Jellyseerr
|
arr-compat Radarr/Sonarr v3 shim for Jellyseerr
|
||||||
@@ -787,7 +891,7 @@ previous one.
|
|||||||
grabbing, derived status.
|
grabbing, derived status.
|
||||||
7. **Owners and notifications** — tags, per-person ntfy topics, filtered views.
|
7. **Owners and notifications** — tags, per-person ntfy topics, filtered views.
|
||||||
8. **Jellyseerr compat** — `arr-compat`.
|
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
|
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
|
per-episode state on top of an otherwise identical pipeline. Doing it second
|
||||||
@@ -807,3 +911,106 @@ means that pipeline is already proven.
|
|||||||
RSS lane obeys the same guard — it skips packs for any season with episodes
|
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
|
on disk. Whether a re-grab is ever wanted remains unresolved and deliberately
|
||||||
deferred.
|
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
|
COPY web ./web
|
||||||
RUN pnpm -C web build
|
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
|
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 . .
|
||||||
COPY --from=web /src/web/dist ./web/dist
|
COPY --from=web /src/web/dist ./web/dist
|
||||||
ENV ARR_WEB_DIST=/src/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
|
FROM debian:bookworm-slim
|
||||||
|
# ffmpeg covers both `ffprobe` for arr-probe and the audio extraction `alass`
|
||||||
|
# shells out to.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
COPY --from=build /src/target/release/arr /usr/local/bin/arr
|
||||||
ENV ARR_BIND_ADDR=0.0.0.0:7878 \
|
ENV ARR_BIND_ADDR=0.0.0.0:7878 \
|
||||||
ARR_DATABASE_PATH=/data/arr.db
|
ARR_DATABASE_PATH=/data/arr.db
|
||||||
|
|||||||
@@ -15,17 +15,21 @@ fmt-check:
|
|||||||
fmt:
|
fmt:
|
||||||
cargo fmt --all
|
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:
|
lint:
|
||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
# Unused workspace dependencies. Stable toolchain, unlike cargo-udeps.
|
# Unused workspace dependencies. Stable toolchain, unlike cargo-udeps.
|
||||||
deps:
|
deps:
|
||||||
cargo machete
|
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:
|
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
|
# 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.
|
# of the push gate. Never point this at the production Transmission LXC.
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
arr-core = { workspace = true }
|
arr-core = { workspace = true }
|
||||||
|
arr-dl = { workspace = true }
|
||||||
arr-db = { workspace = true }
|
arr-db = { workspace = true }
|
||||||
arr-indexer = { workspace = true }
|
arr-indexer = { workspace = true }
|
||||||
arr-meta = { workspace = true }
|
arr-meta = { workspace = true }
|
||||||
arr-parse = { workspace = true }
|
arr-parse = { workspace = true }
|
||||||
|
arr-probe = { workspace = true }
|
||||||
|
arr-subs = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
//! 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 = "Transmission 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 transmission = state
|
||||||
|
.transmission()
|
||||||
|
.ok_or(ApiError::Upstream("transmission"))?;
|
||||||
|
|
||||||
|
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 = transmission
|
||||||
|
.list_torrents()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Upstream("transmission"))?;
|
||||||
|
*cache = Some((Instant::now(), torrents.clone()));
|
||||||
|
torrents
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let torrents = transmission
|
||||||
|
.list_torrents()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Upstream("transmission"))?;
|
||||||
|
*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,
|
||||||
|
// Transmission'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::{body_partial_json, method};
|
||||||
|
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 {
|
||||||
|
id: 1,
|
||||||
|
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("POST"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(409).insert_header("x-transmission-session-id", "session"),
|
||||||
|
)
|
||||||
|
.up_to_n_times(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(body_partial_json(json!({"method": "torrent-get"})))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"result": "success",
|
||||||
|
"arguments": {"torrents": [{
|
||||||
|
"id": 1, "name": "grabbed", "hashString": "ABC",
|
||||||
|
"status": 4, "percentDone": 0.4, "rateDownload": 123,
|
||||||
|
"eta": 60, "errorString": "", "downloadDir": "/downloads",
|
||||||
|
"labels": [], "isFinished": false
|
||||||
|
}]}
|
||||||
|
})))
|
||||||
|
.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_transmission(arr_dl::TransmissionClient::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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,15 @@
|
|||||||
//! the body carries the verdict, so a degraded service can still explain
|
//! the body carries the verdict, so a degraded service can still explain
|
||||||
//! itself to the UI instead of looking like a fourth outage.
|
//! itself to the UI instead of looking like a fourth outage.
|
||||||
|
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use arr_subs::{binary_present, translate as backend_error};
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
/// Whether the service as a whole can do its job.
|
/// 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`.
|
/// The body of `GET /api/health`.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
|
||||||
pub struct HealthReport {
|
pub struct HealthReport {
|
||||||
@@ -78,9 +120,11 @@ pub struct HealthReport {
|
|||||||
pub prowlarr: Check,
|
pub prowlarr: Check,
|
||||||
pub transmission: Check,
|
pub transmission: Check,
|
||||||
pub tmdb: Check,
|
pub tmdb: Check,
|
||||||
|
pub subtitles: SubtitleHealth,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Report reachability of Prowlarr, Transmission and TMDB.
|
/// Report reachability of Prowlarr, Transmission and TMDB, plus the
|
||||||
|
/// subtitle upstreams (#200).
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/health",
|
path = "/api/health",
|
||||||
@@ -90,17 +134,19 @@ pub struct HealthReport {
|
|||||||
),
|
),
|
||||||
)]
|
)]
|
||||||
pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
|
pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
|
||||||
// Three independent network probes; serialising them would make the
|
// Independent network probes; serialising them would make the endpoint
|
||||||
// endpoint as slow as the sum of the timeouts.
|
// as slow as the sum of the timeouts.
|
||||||
let (prowlarr, transmission, tmdb) = tokio::join!(
|
let (prowlarr, transmission, tmdb, subtitles) = tokio::join!(
|
||||||
probe_prowlarr(&state),
|
probe_prowlarr(&state),
|
||||||
probe_transmission(&state),
|
probe_transmission(&state),
|
||||||
probe_tmdb(&state),
|
probe_tmdb(&state),
|
||||||
|
probe_subtitles(&state)
|
||||||
);
|
);
|
||||||
|
|
||||||
let status = if [prowlarr.status, transmission.status, tmdb.status]
|
let status = if [prowlarr.status, transmission.status, tmdb.status]
|
||||||
.iter()
|
.into_iter()
|
||||||
.all(|s| *s == Status::Ok)
|
.chain(subtitles.statuses())
|
||||||
|
.all(|check| check == Status::Ok)
|
||||||
{
|
{
|
||||||
Health::Ok
|
Health::Ok
|
||||||
} else {
|
} else {
|
||||||
@@ -113,6 +159,7 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
|
|||||||
prowlarr,
|
prowlarr,
|
||||||
transmission,
|
transmission,
|
||||||
tmdb,
|
tmdb,
|
||||||
|
subtitles,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,3 +228,314 @@ async fn probe_tmdb(state: &AppState) -> Check {
|
|||||||
fn describe(err: reqwest::Error) -> String {
|
fn describe(err: reqwest::Error) -> String {
|
||||||
err.without_url().to_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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,18 +6,23 @@
|
|||||||
//! carrying a `#[utoipa::path]` annotation. A handler added without one fails
|
//! carrying a `#[utoipa::path]` annotation. A handler added without one fails
|
||||||
//! to compile, and the gate in DESIGN.md §12 fails with it.
|
//! to compile, and the gate in DESIGN.md §12 fails with it.
|
||||||
|
|
||||||
|
mod downloads;
|
||||||
mod health;
|
mod health;
|
||||||
pub mod jellyfin;
|
pub mod jellyfin;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
mod movies;
|
mod movies;
|
||||||
mod owners;
|
mod owners;
|
||||||
mod policies;
|
mod policies;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod privilege;
|
||||||
mod reclassify;
|
mod reclassify;
|
||||||
mod relocate;
|
mod relocate;
|
||||||
mod roots;
|
mod roots;
|
||||||
mod search;
|
mod search;
|
||||||
mod series;
|
mod series;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod subtitle_settings;
|
||||||
|
mod subtitles;
|
||||||
mod trailer;
|
mod trailer;
|
||||||
|
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
@@ -27,6 +32,7 @@ use utoipa_axum::router::OpenApiRouter;
|
|||||||
use utoipa_axum::routes;
|
use utoipa_axum::routes;
|
||||||
use utoipa_scalar::{Scalar, Servable};
|
use utoipa_scalar::{Scalar, Servable};
|
||||||
|
|
||||||
|
pub use downloads::{Download, DownloadPhase};
|
||||||
pub use health::{Check, Health, HealthReport, Status};
|
pub use health::{Check, Health, HealthReport, Status};
|
||||||
pub use metadata::{MetadataTrailer, MovieMetadata, SeriesMetadata};
|
pub use metadata::{MetadataTrailer, MovieMetadata, SeriesMetadata};
|
||||||
pub use movies::{
|
pub use movies::{
|
||||||
@@ -46,6 +52,13 @@ pub use state::{
|
|||||||
AppState, EpisodeCommand, MetadataCommand, MovieCommand, SeasonCommand, Upstreams,
|
AppState, EpisodeCommand, MetadataCommand, MovieCommand, SeasonCommand, Upstreams,
|
||||||
DEFAULT_TMDB_URL,
|
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};
|
pub use trailer::{Trailer, TrailerKind};
|
||||||
|
|
||||||
/// Where the generated document is served, and where `just gen-client` reads
|
/// 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 = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"),
|
||||||
(name = "policies", description = "Quality policies (DESIGN.md §5)"),
|
(name = "policies", description = "Quality policies (DESIGN.md §5)"),
|
||||||
(name = "search", description = "Unified title and release search"),
|
(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;
|
struct ApiDoc;
|
||||||
@@ -80,6 +94,7 @@ struct ApiDoc;
|
|||||||
fn api_router() -> OpenApiRouter<AppState> {
|
fn api_router() -> OpenApiRouter<AppState> {
|
||||||
OpenApiRouter::with_openapi(ApiDoc::openapi())
|
OpenApiRouter::with_openapi(ApiDoc::openapi())
|
||||||
.routes(routes!(health::health))
|
.routes(routes!(health::health))
|
||||||
|
.routes(routes!(downloads::list))
|
||||||
.routes(routes!(movies::list, movies::create))
|
.routes(routes!(movies::list, movies::create))
|
||||||
.routes(routes!(movies::get, movies::update, movies::delete))
|
.routes(routes!(movies::get, movies::update, movies::delete))
|
||||||
.routes(routes!(movies::search))
|
.routes(routes!(movies::search))
|
||||||
@@ -118,6 +133,19 @@ fn api_router() -> OpenApiRouter<AppState> {
|
|||||||
.routes(routes!(roots::get, roots::update, roots::delete))
|
.routes(routes!(roots::get, roots::update, roots::delete))
|
||||||
.routes(routes!(policies::list, policies::create))
|
.routes(routes!(policies::list, policies::create))
|
||||||
.routes(routes!(policies::get, policies::update, policies::delete))
|
.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.
|
/// The generated `OpenAPI` document.
|
||||||
@@ -213,7 +241,11 @@ mod tests {
|
|||||||
.with_tmdb_url(tmdb.uri())
|
.with_tmdb_url(tmdb.uri())
|
||||||
.with_tmdb_api_key(Some("key".into())),
|
.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;
|
let body = report(state).await;
|
||||||
assert_eq!(body["status"], "ok");
|
assert_eq!(body["status"], "ok");
|
||||||
@@ -359,6 +391,23 @@ mod tests {
|
|||||||
("/api/policies/{policy_id}", "put"),
|
("/api/policies/{policy_id}", "put"),
|
||||||
("/api/policies/{policy_id}", "delete"),
|
("/api/policies/{policy_id}", "delete"),
|
||||||
("/api/roots", "post"),
|
("/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!(
|
assert!(
|
||||||
json["paths"][path][method].is_object(),
|
json["paths"][path][method].is_object(),
|
||||||
@@ -373,6 +422,12 @@ mod tests {
|
|||||||
"AttentionQueues",
|
"AttentionQueues",
|
||||||
"SeriesAttention",
|
"SeriesAttention",
|
||||||
"Series",
|
"Series",
|
||||||
|
"Subtitle",
|
||||||
|
"SubtitleCandidate",
|
||||||
|
"SubtitleSearchResults",
|
||||||
|
"SubtitleStatus",
|
||||||
|
"MissingSubtitle",
|
||||||
|
"EpisodeSubtitleStatus",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
json["components"]["schemas"][schema].is_object(),
|
json["components"]["schemas"][schema].is_object(),
|
||||||
|
|||||||
+576
-82
@@ -76,7 +76,42 @@ pub struct Release {
|
|||||||
pub parsed: serde_json::Value,
|
pub parsed: serde_json::Value,
|
||||||
pub score: Option<f64>,
|
pub score: Option<f64>,
|
||||||
pub verdict: Option<String>,
|
pub verdict: Option<String>,
|
||||||
|
// The rule behind the verdict: the one that killed a `rejected` row, or
|
||||||
|
// the one a `waived` row relaxed (#211). Null on an `eligible` row, and
|
||||||
|
// on a `waived` row stored before migration 0032, which could not record
|
||||||
|
// it. A plain comment, not a doc comment: doc comments here become
|
||||||
|
// OpenAPI descriptions and would put the generated client in web/ out of
|
||||||
|
// date, which #227 and #232 own.
|
||||||
pub rejected_rule: Option<String>,
|
pub rejected_rule: Option<String>,
|
||||||
|
// #227: what the blacklist recorded this release as failing on, when
|
||||||
|
// `rejected_rule` is `blacklisted`. Null on every other row, and on a
|
||||||
|
// blacklisted row whose blacklist entry has since gone. A size rejection
|
||||||
|
// is a policy opinion the operator can relax; a corrupt or mismatched
|
||||||
|
// release is not, and a bare `blacklisted` reads the same for both.
|
||||||
|
// Plain comment for the same reason as the field above.
|
||||||
|
pub blacklist_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill in [`Release::blacklist_reason`] for every deck row the blacklist
|
||||||
|
/// holds (#227, §6.3).
|
||||||
|
///
|
||||||
|
/// The blacklist is keyed on the *normalised* name, which SQL cannot compute,
|
||||||
|
/// so the match happens here over the whole table — a handful of rows, the
|
||||||
|
/// same reasoning as [`arr_db::blacklist::Blacklist`] itself.
|
||||||
|
pub(crate) async fn attach_blacklist_reasons(
|
||||||
|
pool: &sqlx::SqlitePool,
|
||||||
|
releases: &mut [Release],
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
if releases.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let blacklist = arr_db::blacklist::Blacklist::load(pool).await?;
|
||||||
|
for release in releases.iter_mut() {
|
||||||
|
release.blacklist_reason = blacklist
|
||||||
|
.reason_for_candidate(&release.name, &release.download_url)
|
||||||
|
.map(str::to_owned);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A library file and what it cost to accept it (`DESIGN.md` §5.7).
|
/// A library file and what it cost to accept it (`DESIGN.md` §5.7).
|
||||||
@@ -145,6 +180,11 @@ pub struct Accepted {
|
|||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
pub struct ErrorBody {
|
pub struct ErrorBody {
|
||||||
pub error: String,
|
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)]
|
#[derive(Debug)]
|
||||||
@@ -156,6 +196,22 @@ pub enum ApiError {
|
|||||||
OwnerNotFound,
|
OwnerNotFound,
|
||||||
PolicyNotFound,
|
PolicyNotFound,
|
||||||
RootNotFound,
|
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.
|
/// The §9.6 chip outcome: the title exists upstream but has no trailer.
|
||||||
/// Ordinary, so it must stay distinguishable from an upstream failure.
|
/// Ordinary, so it must stay distinguishable from an upstream failure.
|
||||||
NoTrailer,
|
NoTrailer,
|
||||||
@@ -176,38 +232,60 @@ pub enum ApiError {
|
|||||||
|
|
||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, error) = match self {
|
let (status, error, code) = match self {
|
||||||
Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string()),
|
Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string(), None),
|
||||||
Self::SeriesNotFound => (StatusCode::NOT_FOUND, "series not found".to_string()),
|
Self::SeriesNotFound => (StatusCode::NOT_FOUND, "series not found".to_string(), None),
|
||||||
Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string()),
|
Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string(), None),
|
||||||
Self::EpisodeNotFound => (StatusCode::NOT_FOUND, "episode not found".to_string()),
|
Self::EpisodeNotFound => (StatusCode::NOT_FOUND, "episode not found".to_string(), None),
|
||||||
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
|
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string(), None),
|
||||||
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string()),
|
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string(), None),
|
||||||
Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string()),
|
Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string(), None),
|
||||||
Self::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string()),
|
Self::MediaFileNotFound => (
|
||||||
Self::Conflict(error) => (StatusCode::CONFLICT, error),
|
StatusCode::NOT_FOUND,
|
||||||
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
|
"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 => (
|
Self::Unavailable => (
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"database unavailable".into(),
|
"database unavailable".into(),
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
Self::Upstream(name) => (
|
Self::Upstream(name) => (
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
format!("{name} unavailable"),
|
format!("{name} unavailable"),
|
||||||
|
None,
|
||||||
),
|
),
|
||||||
Self::Database(error) => {
|
Self::Database(error) => {
|
||||||
tracing::error!(%error, "API database error");
|
tracing::error!(%error, "API database error");
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "database error".into())
|
|
||||||
}
|
|
||||||
Self::Filesystem(error) => {
|
|
||||||
tracing::error!(%error, "API filesystem error");
|
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("files not removed: {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(), None)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
(status, Json(ErrorBody { error })).into_response()
|
(status, Json(ErrorBody { error, code })).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +553,10 @@ pub async fn update(
|
|||||||
{
|
{
|
||||||
crate::relocate::refresh_jellyfin(&state).await;
|
crate::relocate::refresh_jellyfin(&state).await;
|
||||||
}
|
}
|
||||||
if overrides_changed {
|
// A root change swaps the effective policy the same way an overrides
|
||||||
|
// edit does (§5.1), so both re-derive; `relocation` is `Some` exactly
|
||||||
|
// when the root changed.
|
||||||
|
if overrides_changed || relocation.is_some() {
|
||||||
crate::reclassify::movie(&state, id).await?;
|
crate::reclassify::movie(&state, id).await?;
|
||||||
}
|
}
|
||||||
Ok(Json(load_movie(&state, id).await?))
|
Ok(Json(load_movie(&state, id).await?))
|
||||||
@@ -570,7 +651,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
|
|||||||
Ok(metadata) => metadata,
|
Ok(metadata) => metadata,
|
||||||
// Already gone is the state we wanted.
|
// Already gone is the state we wanted.
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||||
};
|
};
|
||||||
let removed = if metadata.is_dir() {
|
let removed = if metadata.is_dir() {
|
||||||
tokio::fs::remove_dir_all(&target).await
|
tokio::fs::remove_dir_all(&target).await
|
||||||
@@ -580,7 +661,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
|
|||||||
match removed {
|
match removed {
|
||||||
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
|
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -641,7 +722,7 @@ pub async fn releases(
|
|||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> Result<Json<Vec<Release>>, ApiError> {
|
) -> Result<Json<Vec<Release>>, ApiError> {
|
||||||
load_movie(&state, id).await?;
|
load_movie(&state, id).await?;
|
||||||
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
|
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
|
||||||
.fetch_all(pool(&state)?)
|
.fetch_all(pool(&state)?)
|
||||||
.await?;
|
.await?;
|
||||||
let policy = state
|
let policy = state
|
||||||
@@ -653,6 +734,7 @@ pub async fn releases(
|
|||||||
.ok_or(ApiError::NotFound)?
|
.ok_or(ApiError::NotFound)?
|
||||||
.policy;
|
.policy;
|
||||||
rescore(&mut releases, &policy, None, 0)?;
|
rescore(&mut releases, &policy, None, 0)?;
|
||||||
|
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
|
||||||
Ok(Json(releases))
|
Ok(Json(releases))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,6 +852,12 @@ pub async fn grab(
|
|||||||
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
|
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The four attention lanes (§9.5). The hard-fail lanes carry §5.7's bar in
|
||||||
|
// full — two failures on *different* releases, both inside
|
||||||
|
// `ATTENTION_WINDOW`, against a target still waiting for a file — so this
|
||||||
|
// endpoint and the daemon's notifier report the same queue. Deliberately not
|
||||||
|
// a doc comment: utoipa would fold it into the OpenAPI description and drift
|
||||||
|
// the committed document.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get, path = "/api/queues/attention", tag = "movies",
|
get, path = "/api/queues/attention", tag = "movies",
|
||||||
responses(
|
responses(
|
||||||
@@ -782,7 +870,7 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
|
|||||||
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
|
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
|
||||||
.fetch_all(pool(&state)?)
|
.fetch_all(pool(&state)?)
|
||||||
.await?;
|
.await?;
|
||||||
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW)
|
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW)
|
||||||
.fetch_all(pool(&state)?)
|
.fetch_all(pool(&state)?)
|
||||||
.await?;
|
.await?;
|
||||||
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
|
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
|
||||||
@@ -800,37 +888,73 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
|
|||||||
/// share a lane; a series arriving through both is merged into one entry.
|
/// share a lane; a series arriving through both is merged into one entry.
|
||||||
///
|
///
|
||||||
/// Both hard-fail branches hold to §5.7's bar: two failures on *different*
|
/// Both hard-fail branches hold to §5.7's bar: two failures on *different*
|
||||||
/// releases, both inside `ATTENTION_WINDOW`. One bad torrent is not a
|
/// releases, both inside `ATTENTION_WINDOW`, against a target still waiting
|
||||||
/// decision, and a failure the operator already dealt with ages out instead
|
/// for a file. One bad torrent is not a decision (#226), a failure the
|
||||||
/// of sitting in the queue forever (#226).
|
/// operator already dealt with ages out instead of sitting in the queue
|
||||||
|
/// forever (#226), and a target since acquired leaves at once (#238). Seasons
|
||||||
|
/// hold no intent of their own (§4.1), so the season branch reads liveness
|
||||||
|
/// off its episodes: it is queued while any of them is still wanted and still
|
||||||
|
/// without a file. The daemon's notifier filters identically.
|
||||||
async fn tv_attention(
|
async fn tv_attention(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> Result<(Vec<SeriesAttention>, Vec<SeriesAttention>), ApiError> {
|
) -> Result<(Vec<SeriesAttention>, Vec<SeriesAttention>), ApiError> {
|
||||||
let database = pool(state)?;
|
let database = pool(state)?;
|
||||||
|
Ok((
|
||||||
|
tv_no_pt_source_lane(database).await?,
|
||||||
|
tv_hard_fail_lane(database).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
let no_pt_rows = sqlx::query!(
|
/// §5.2's no-PT-source lane: wanted, searched episodes on a `kids` root whose
|
||||||
|
/// every candidate release was rejected for language.
|
||||||
|
async fn tv_no_pt_source_lane(
|
||||||
|
database: &sqlx::SqlitePool,
|
||||||
|
) -> Result<Vec<SeriesAttention>, ApiError> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||||
s.title AS "title!: String", s.year,
|
s.title AS "title!: String", s.year,
|
||||||
e.id AS "episode_id!: i64",
|
e.id AS "episode_id!: i64",
|
||||||
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
|
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
|
||||||
FROM episodes e
|
FROM episodes e
|
||||||
JOIN seasons se ON se.id = e.season_id
|
JOIN seasons se ON se.id = e.season_id
|
||||||
JOIN series s ON s.id = se.series_id
|
JOIN series s ON s.id = se.series_id
|
||||||
JOIN roots root ON root.id = s.root_id
|
JOIN roots root ON root.id = s.root_id
|
||||||
WHERE root.audience = 'kids'
|
WHERE root.audience = 'kids'
|
||||||
AND s.blocked = 0
|
AND s.blocked = 0
|
||||||
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
|
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM episode_releases er
|
SELECT 1 FROM episode_releases er
|
||||||
JOIN releases r ON r.id = er.release_id
|
JOIN releases r ON r.id = er.release_id
|
||||||
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
|
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
|
||||||
)
|
)
|
||||||
ORDER BY se.number, e.number
|
ORDER BY se.number, e.number
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.fetch_all(database)
|
.fetch_all(database)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let mut entries: Vec<SeriesAttention> = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
merge_episode(
|
||||||
|
&mut entries,
|
||||||
|
row.series_id,
|
||||||
|
row.tmdb_id,
|
||||||
|
&row.title,
|
||||||
|
row.year,
|
||||||
|
row.episode_id,
|
||||||
|
row.season_number,
|
||||||
|
row.episode_number,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5.7's hard-fail lane: episodes and seasons two *different* releases failed
|
||||||
|
/// on inside `ATTENTION_WINDOW`, restricted to targets still waiting for a
|
||||||
|
/// file. The season half reads that last condition off its episodes, which is
|
||||||
|
/// where intent lives (§4.1). Both halves merge into one entry per series.
|
||||||
|
async fn tv_hard_fail_lane(database: &sqlx::SqlitePool) -> Result<Vec<SeriesAttention>, ApiError> {
|
||||||
let episode_hard_fails = sqlx::query!(
|
let episode_hard_fails = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
|
||||||
@@ -842,7 +966,8 @@ async fn tv_attention(
|
|||||||
JOIN seasons se ON se.id = e.season_id
|
JOIN seasons se ON se.id = e.season_id
|
||||||
JOIN series s ON s.id = se.series_id
|
JOIN series s ON s.id = se.series_id
|
||||||
WHERE g.state = 'failed'
|
WHERE g.state = 'failed'
|
||||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||||
|
AND e.wanted = 1 AND e.state != 'available'
|
||||||
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
|
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
|
||||||
HAVING count(DISTINCT g.release_id) >= 2
|
HAVING count(DISTINCT g.release_id) >= 2
|
||||||
"#,
|
"#,
|
||||||
@@ -859,7 +984,12 @@ async fn tv_attention(
|
|||||||
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
||||||
JOIN series s ON s.id = se.series_id
|
JOIN series s ON s.id = se.series_id
|
||||||
WHERE g.state = 'failed'
|
WHERE g.state = 'failed'
|
||||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM episodes e
|
||||||
|
WHERE e.season_id = se.id
|
||||||
|
AND e.wanted = 1 AND e.state != 'available'
|
||||||
|
)
|
||||||
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number
|
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number
|
||||||
HAVING count(DISTINCT g.release_id) >= 2
|
HAVING count(DISTINCT g.release_id) >= 2
|
||||||
"#,
|
"#,
|
||||||
@@ -868,24 +998,10 @@ async fn tv_attention(
|
|||||||
.fetch_all(database)
|
.fetch_all(database)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut tv_no_pt_source: Vec<SeriesAttention> = Vec::new();
|
let mut entries: Vec<SeriesAttention> = Vec::new();
|
||||||
for row in no_pt_rows {
|
|
||||||
merge_episode(
|
|
||||||
&mut tv_no_pt_source,
|
|
||||||
row.series_id,
|
|
||||||
row.tmdb_id,
|
|
||||||
&row.title,
|
|
||||||
row.year,
|
|
||||||
row.episode_id,
|
|
||||||
row.season_number,
|
|
||||||
row.episode_number,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tv_needs_decision: Vec<SeriesAttention> = Vec::new();
|
|
||||||
for row in episode_hard_fails {
|
for row in episode_hard_fails {
|
||||||
merge_episode(
|
merge_episode(
|
||||||
&mut tv_needs_decision,
|
&mut entries,
|
||||||
row.series_id,
|
row.series_id,
|
||||||
row.tmdb_id,
|
row.tmdb_id,
|
||||||
&row.title,
|
&row.title,
|
||||||
@@ -897,7 +1013,7 @@ async fn tv_attention(
|
|||||||
}
|
}
|
||||||
for row in season_pack_fails {
|
for row in season_pack_fails {
|
||||||
merge_season(
|
merge_season(
|
||||||
&mut tv_needs_decision,
|
&mut entries,
|
||||||
row.series_id,
|
row.series_id,
|
||||||
row.tmdb_id,
|
row.tmdb_id,
|
||||||
&row.title,
|
&row.title,
|
||||||
@@ -906,8 +1022,7 @@ async fn tv_attention(
|
|||||||
row.season_number,
|
row.season_number,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Ok(entries)
|
||||||
Ok((tv_no_pt_source, tv_needs_decision))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One more qualifying season for its series, creating the series' entry on
|
/// One more qualifying season for its series, creating the series' entry on
|
||||||
@@ -1267,6 +1382,73 @@ mod tests {
|
|||||||
.expect("releases json");
|
.expect("releases json");
|
||||||
assert_eq!(releases.len(), 1);
|
assert_eq!(releases.len(), 1);
|
||||||
assert_eq!(releases[0]["verdict"], "waived");
|
assert_eq!(releases[0]["verdict"], "waived");
|
||||||
|
// #211: and it says which rule the waiver relaxed, rather than
|
||||||
|
// sitting in the deck as a bare `waived` beside rejections that each
|
||||||
|
// name their own.
|
||||||
|
assert_eq!(releases[0]["rejected_rule"], "size");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #241: moving a title to a root with a different policy re-derives its
|
||||||
|
/// stored verdicts (§5.1) — an English-audio release a `main` root found
|
||||||
|
/// eligible is only a waiver under the `kids` root it moved to.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn moving_a_movie_to_another_root_rederives_its_verdicts() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let pool = state.database().expect("database").pool();
|
||||||
|
let created: serde_json::Value = reqwest::Client::new()
|
||||||
|
.post(format!("{base}/api/movies"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
|
||||||
|
"original_language": "en", "root_id": 1, "overrides": {}
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create movie")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("movie json");
|
||||||
|
let movie_id = created["id"].as_i64().expect("id");
|
||||||
|
|
||||||
|
let name = "Dune Part Two 2024 1080p WEB-DL ENGLISH x264-GROUP";
|
||||||
|
let parsed = arr_parse::parse(name);
|
||||||
|
let size = 6_i64 * (1 << 30);
|
||||||
|
let release_id = sqlx::query_scalar::<_, i64>(
|
||||||
|
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
||||||
|
VALUES (7, 'g', ?, ?, 40, 'url', ?, 0, 'eligible') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(size)
|
||||||
|
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("release");
|
||||||
|
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(movie_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("association");
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.patch(format!("{base}/api/movies/{movie_id}"))
|
||||||
|
.json(&serde_json::json!({"root_id": 2}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("move root");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let releases: Vec<serde_json::Value> =
|
||||||
|
reqwest::get(format!("{base}/api/movies/{movie_id}/releases"))
|
||||||
|
.await
|
||||||
|
.expect("releases")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("releases json");
|
||||||
|
assert_eq!(releases.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
releases[0]["verdict"], "waived",
|
||||||
|
"kids requires pt-PT, English is a soft fail: {releases:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A blacklisted release (§6.3) is not a policy opinion, so no override
|
/// A blacklisted release (§6.3) is not a policy opinion, so no override
|
||||||
@@ -1314,6 +1496,26 @@ mod tests {
|
|||||||
.expect("releases json");
|
.expect("releases json");
|
||||||
assert_eq!(releases[0]["verdict"], "rejected");
|
assert_eq!(releases[0]["verdict"], "rejected");
|
||||||
assert_eq!(releases[0]["rejected_rule"], "blacklisted");
|
assert_eq!(releases[0]["rejected_rule"], "blacklisted");
|
||||||
|
// #227: a row the blacklist has no entry for keeps rendering — the
|
||||||
|
// rule is all the record holds, and no reason is invented for it.
|
||||||
|
assert_eq!(releases[0]["blacklist_reason"], serde_json::Value::Null);
|
||||||
|
|
||||||
|
// With the entry, the row says what it was blacklisted for: a
|
||||||
|
// corrupt file is not the same decision as a size rejection.
|
||||||
|
arr_db::blacklist::add(pool, None, name, "no original-language audio")
|
||||||
|
.await
|
||||||
|
.expect("blacklist");
|
||||||
|
let releases: Vec<serde_json::Value> =
|
||||||
|
reqwest::get(format!("{base}/api/movies/{movie_id}/releases"))
|
||||||
|
.await
|
||||||
|
.expect("releases")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("releases json");
|
||||||
|
assert_eq!(
|
||||||
|
releases[0]["blacklist_reason"],
|
||||||
|
"no original-language audio"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1545,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
|
/// Issue #228: a title with nothing on disk changes root with no
|
||||||
/// filesystem work at all — the seeded root paths do not even exist.
|
/// filesystem work at all — the seeded root paths do not even exist.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1621,22 +1939,25 @@ mod tests {
|
|||||||
|
|
||||||
/// Issue #228: if the rename fails, the row must not change — the
|
/// Issue #228: if the rename fails, the row must not change — the
|
||||||
/// operator sees the title where its files actually are and can retry.
|
/// operator sees the title where its files actually are and can retry.
|
||||||
|
#[cfg(unix)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_failed_rename_leaves_the_row_alone() {
|
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 (_dir, state, base) = application().await;
|
||||||
let movie = add_movie(&base, 693_134, 1).await;
|
let movie = add_movie(&base, 693_134, 1).await;
|
||||||
let id = movie["id"].as_i64().expect("id");
|
let id = movie["id"].as_i64().expect("id");
|
||||||
let source = tempfile::tempdir().expect("source root");
|
let source = tempfile::tempdir().expect("source root");
|
||||||
let destination = tempfile::tempdir().expect("destination root");
|
let destination = tempfile::tempdir().expect("destination root");
|
||||||
let folder = library_on_disk(&state, id, source.path()).await;
|
let folder = library_on_disk(&state, id, source.path()).await;
|
||||||
// A destination whose parent does not exist makes the rename itself
|
point_root_at(&state, 2, destination.path()).await;
|
||||||
// fail while the collision pre-check still passes.
|
tokio::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o555))
|
||||||
point_root_at(
|
.await
|
||||||
&state,
|
.expect("freeze the title folder");
|
||||||
2,
|
|
||||||
&destination.path().join("missing").join("library"),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
.patch(format!("{base}/api/movies/{id}"))
|
.patch(format!("{base}/api/movies/{id}"))
|
||||||
@@ -1645,7 +1966,14 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("move root");
|
.expect("move root");
|
||||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
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");
|
assert!(folder.exists(), "the folder never left the old root");
|
||||||
let (root_id, path): (i64, String) = sqlx::query_as(
|
let (root_id, path): (i64, String) = sqlx::query_as(
|
||||||
"SELECT m.root_id, f.path FROM movies m
|
"SELECT m.root_id, f.path FROM movies m
|
||||||
@@ -1880,7 +2208,7 @@ mod tests {
|
|||||||
.expect("release")
|
.expect("release")
|
||||||
.last_insert_rowid();
|
.last_insert_rowid();
|
||||||
release_ids.push(release_id);
|
release_ids.push(release_id);
|
||||||
sqlx::query("INSERT INTO grabs (release_id, target_kind, target_id, infohash, state) VALUES (?, 'movie', ?, ?, 'failed')")
|
sqlx::query("INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, failed_at) VALUES (?, 'movie', ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))")
|
||||||
.bind(release_id)
|
.bind(release_id)
|
||||||
.bind(movie_id)
|
.bind(movie_id)
|
||||||
.bind(format!("hash-{suffix}"))
|
.bind(format!("hash-{suffix}"))
|
||||||
@@ -2006,8 +2334,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A series with one empty season, for exercising the season lane on its
|
/// A series with one season holding a single wanted, missing episode — the
|
||||||
/// own.
|
/// least that satisfies §5.7's liveness condition — for exercising the
|
||||||
|
/// season lane on its own.
|
||||||
async fn seed_bare_season(pool: &sqlx::SqlitePool, tmdb_id: i64) -> (i64, i64) {
|
async fn seed_bare_season(pool: &sqlx::SqlitePool, tmdb_id: i64) -> (i64, i64) {
|
||||||
let root_id: i64 =
|
let root_id: i64 =
|
||||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
|
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
|
||||||
@@ -2031,6 +2360,14 @@ mod tests {
|
|||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
.expect("season");
|
.expect("season");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
||||||
|
VALUES (?, 1, 'Episode 1', 1, 'missing')",
|
||||||
|
)
|
||||||
|
.bind(season_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("episode");
|
||||||
(series_id, season_id)
|
(series_id, season_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2046,8 +2383,9 @@ mod tests {
|
|||||||
.get(0)
|
.get(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A hard-failed grab, stamped `age_days` in the past so the §5.7 window
|
/// A hard-failed grab, its failure stamped `age_days` in the past so the
|
||||||
/// can be exercised without waiting a month.
|
/// §5.7 window — which runs from the failure — can be exercised without
|
||||||
|
/// waiting a month.
|
||||||
async fn insert_failed_grab(
|
async fn insert_failed_grab(
|
||||||
pool: &sqlx::SqlitePool,
|
pool: &sqlx::SqlitePool,
|
||||||
release_id: i64,
|
release_id: i64,
|
||||||
@@ -2057,14 +2395,17 @@ mod tests {
|
|||||||
age_days: i64,
|
age_days: i64,
|
||||||
) {
|
) {
|
||||||
sqlx::query(
|
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 (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
VALUES (?, ?, ?, ?, 'failed',
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?),
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||||
)
|
)
|
||||||
.bind(release_id)
|
.bind(release_id)
|
||||||
.bind(target_kind)
|
.bind(target_kind)
|
||||||
.bind(target_id)
|
.bind(target_id)
|
||||||
.bind(infohash)
|
.bind(infohash)
|
||||||
.bind(format!("-{age_days} days"))
|
.bind(format!("-{age_days} days"))
|
||||||
|
.bind(format!("-{age_days} days"))
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.expect("failed grab");
|
.expect("failed grab");
|
||||||
@@ -2162,6 +2503,159 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §5.7: the queue only holds targets still waiting for a file. A season
|
||||||
|
/// whose pack failed twice, fell back to per-episode grabbing (§6.2) and
|
||||||
|
/// was then fully acquired is the system working, so it drops out at once
|
||||||
|
/// instead of sitting there for 30 days (#238).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_fully_acquired_season_leaves_the_attention_queue() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let pool = state.database().expect("database").pool();
|
||||||
|
let (series_id, season_id) = seed_bare_season(pool, 1).await;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
||||||
|
VALUES (?, 2, 'Episode 2', 1, 'missing')",
|
||||||
|
)
|
||||||
|
.bind(season_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("second episode");
|
||||||
|
|
||||||
|
for (guid, hash) in [("pack-one", "hash-one"), ("pack-two", "hash-two")] {
|
||||||
|
let release_id = insert_release(pool, guid).await;
|
||||||
|
insert_failed_grab(pool, release_id, "season", season_id, hash, 0).await;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
queued_seasons(&base, series_id).await,
|
||||||
|
vec![season_id],
|
||||||
|
"two packs failed and episodes are still missing"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-episode grabbing got one of the two. Still a gap, still queued.
|
||||||
|
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1")
|
||||||
|
.bind(season_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("first episode imported");
|
||||||
|
assert_eq!(
|
||||||
|
queued_seasons(&base, series_id).await,
|
||||||
|
vec![season_id],
|
||||||
|
"one episode still wanted and missing: still broken, still queued"
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?")
|
||||||
|
.bind(season_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("season imported");
|
||||||
|
assert!(
|
||||||
|
queued_seasons(&base, series_id).await.is_empty(),
|
||||||
|
"every episode acquired: the fallback worked, no decision to make"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5.7: the same liveness condition on the movie and episode lanes, so
|
||||||
|
/// `GET /api/queues/attention` reports exactly what the daemon notifies
|
||||||
|
/// on (#238).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_acquired_movie_or_episode_leaves_the_attention_queue() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let pool = state.database().expect("database").pool();
|
||||||
|
let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE audience = 'kids'")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("kids root");
|
||||||
|
let movie = add_movie(&base, 82728, root_id).await;
|
||||||
|
let movie_id = movie["id"].as_i64().expect("id");
|
||||||
|
for (guid, hash) in [("movie-one", "hash-m1"), ("movie-two", "hash-m2")] {
|
||||||
|
let release_id = insert_release(pool, guid).await;
|
||||||
|
insert_failed_grab(pool, release_id, "movie", movie_id, hash, 0).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (series_id, season_id) = seed_bare_season(pool, 99).await;
|
||||||
|
let episode_id: i64 =
|
||||||
|
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
|
||||||
|
.bind(season_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("episode id");
|
||||||
|
for (guid, hash) in [("ep-one", "hash-e1"), ("ep-two", "hash-e2")] {
|
||||||
|
let release_id = insert_release(pool, guid).await;
|
||||||
|
insert_failed_grab(pool, release_id, "episode", episode_id, hash, 0).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let queues = attention_queues(&base).await;
|
||||||
|
assert_eq!(queues["needs_decision"][0]["id"], movie_id);
|
||||||
|
assert_eq!(
|
||||||
|
queued_episodes(&queues, series_id),
|
||||||
|
vec![episode_id],
|
||||||
|
"still wanted and missing: queued"
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?")
|
||||||
|
.bind(movie_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("movie imported");
|
||||||
|
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
|
||||||
|
.bind(episode_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("episode imported");
|
||||||
|
|
||||||
|
let queues = attention_queues(&base).await;
|
||||||
|
assert_eq!(
|
||||||
|
queues["needs_decision"].as_array().map(Vec::len),
|
||||||
|
Some(0),
|
||||||
|
"imported from a third release: no decision to make"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
queued_episodes(&queues, series_id).is_empty(),
|
||||||
|
"imported from a third release: no decision to make"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Withdrawing intent empties the lane just the same.
|
||||||
|
sqlx::query("UPDATE movies SET state = 'missing', wanted = 0 WHERE id = ?")
|
||||||
|
.bind(movie_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("movie unwanted");
|
||||||
|
assert_eq!(
|
||||||
|
attention_queues(&base).await["needs_decision"]
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(0),
|
||||||
|
"nothing is waiting for a file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole attention payload.
|
||||||
|
async fn attention_queues(base: &str) -> serde_json::Value {
|
||||||
|
reqwest::get(format!("{base}/api/queues/attention"))
|
||||||
|
.await
|
||||||
|
.expect("queues")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("queues json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The episodes a series contributes to the hard-fail TV lane.
|
||||||
|
fn queued_episodes(queues: &serde_json::Value, series_id: i64) -> Vec<i64> {
|
||||||
|
queues["tv_needs_decision"]
|
||||||
|
.as_array()
|
||||||
|
.expect("tv lane")
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry["series_id"] == series_id)
|
||||||
|
.flat_map(|entry| {
|
||||||
|
entry["episodes"]
|
||||||
|
.as_array()
|
||||||
|
.expect("episodes")
|
||||||
|
.iter()
|
||||||
|
.map(|episode| episode["id"].as_i64().expect("episode id"))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// One series hitting all three §9.5 TV entry conditions: two wanted,
|
/// One series hitting all three §9.5 TV entry conditions: two wanted,
|
||||||
/// searched episodes whose every candidate was rejected for language; a
|
/// searched episodes whose every candidate was rejected for language; a
|
||||||
/// season two different packs hard-failed on; and an episode two different
|
/// season two different packs hard-failed on; and an episode two different
|
||||||
@@ -2231,8 +2725,8 @@ mod tests {
|
|||||||
.expect("release")
|
.expect("release")
|
||||||
.get(0);
|
.get(0);
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, failed_at)
|
||||||
VALUES (?, ?, ?, ?, 'failed')",
|
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
||||||
)
|
)
|
||||||
.bind(release_id)
|
.bind(release_id)
|
||||||
.bind(kind)
|
.bind(kind)
|
||||||
|
|||||||
@@ -173,6 +173,33 @@ struct PolicyColumns {
|
|||||||
score_weights: String,
|
score_weights: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything in a policy row that a verdict depends on — the name is the
|
||||||
|
/// one column that does not.
|
||||||
|
#[derive(PartialEq, Eq)]
|
||||||
|
struct PolicyRules {
|
||||||
|
required_audio: String,
|
||||||
|
dub_blacklist: String,
|
||||||
|
hdr_rules: String,
|
||||||
|
size_bands: String,
|
||||||
|
resolution_pref: String,
|
||||||
|
source_weights: String,
|
||||||
|
score_weights: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PolicyColumns {
|
||||||
|
fn rules(&self) -> PolicyRules {
|
||||||
|
PolicyRules {
|
||||||
|
required_audio: self.required_audio.clone(),
|
||||||
|
dub_blacklist: self.dub_blacklist.clone(),
|
||||||
|
hdr_rules: self.hdr_rules.clone(),
|
||||||
|
size_bands: self.size_bands.clone(),
|
||||||
|
resolution_pref: self.resolution_pref.clone(),
|
||||||
|
source_weights: self.source_weights.clone(),
|
||||||
|
score_weights: self.score_weights.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn column<T: serde::de::DeserializeOwned>(
|
fn column<T: serde::de::DeserializeOwned>(
|
||||||
column: &'static str,
|
column: &'static str,
|
||||||
value: &str,
|
value: &str,
|
||||||
@@ -376,6 +403,8 @@ pub async fn update(
|
|||||||
input.validate().map_err(ApiError::Invalid)?;
|
input.validate().map_err(ApiError::Invalid)?;
|
||||||
let name = input.name.trim().to_owned();
|
let name = input.name.trim().to_owned();
|
||||||
let columns = input.into_columns(name)?;
|
let columns = input.into_columns(name)?;
|
||||||
|
let after = columns.rules();
|
||||||
|
let before = stored_rules(&state, id).await?;
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query!(
|
||||||
r#"UPDATE policies SET
|
r#"UPDATE policies SET
|
||||||
name = ?, required_audio = ?, dub_blacklist = ?, hdr_rules = ?,
|
name = ?, required_audio = ?, dub_blacklist = ?, hdr_rules = ?,
|
||||||
@@ -405,9 +434,44 @@ pub async fn update(
|
|||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(ApiError::PolicyNotFound);
|
return Err(ApiError::PolicyNotFound);
|
||||||
}
|
}
|
||||||
|
// Every verdict stored under every root pointing here was reached under
|
||||||
|
// the rules this write just replaced; §9.3's deck and the grab gate both
|
||||||
|
// read them (`reclassify`). A rename leaves the rules alone, so it walks
|
||||||
|
// nothing.
|
||||||
|
if before.is_none_or(|before| before != after) {
|
||||||
|
crate::reclassify::policy(&state, id).await?;
|
||||||
|
}
|
||||||
Ok(Json(load_policy(&state, id).await?))
|
Ok(Json(load_policy(&state, id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The rule columns of a policy as they stand, or `None` when there is no
|
||||||
|
/// such row. Compared against what the write is about to store, so an edit
|
||||||
|
/// that only moves the name does not re-derive a library.
|
||||||
|
async fn stored_rules(state: &AppState, id: i64) -> Result<Option<PolicyRules>, ApiError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"SELECT required_audio AS "required_audio!: String",
|
||||||
|
dub_blacklist AS "dub_blacklist!: String",
|
||||||
|
hdr_rules AS "hdr_rules!: String",
|
||||||
|
size_bands AS "size_bands!: String",
|
||||||
|
resolution_pref AS "resolution_pref!: String",
|
||||||
|
source_weights AS "source_weights!: String",
|
||||||
|
score_weights AS "score_weights!: String"
|
||||||
|
FROM policies WHERE id = ?"#,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(pool(state)?)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| PolicyRules {
|
||||||
|
required_audio: row.required_audio,
|
||||||
|
dub_blacklist: row.dub_blacklist,
|
||||||
|
hdr_rules: row.hdr_rules,
|
||||||
|
size_bands: row.size_bands,
|
||||||
|
resolution_pref: row.resolution_pref,
|
||||||
|
source_weights: row.source_weights,
|
||||||
|
score_weights: row.score_weights,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
delete, path = "/api/policies/{policy_id}", tag = "policies",
|
delete, path = "/api/policies/{policy_id}", tag = "policies",
|
||||||
params(("policy_id" = i64, Path, description = "Policy row id")),
|
params(("policy_id" = i64, Path, description = "Policy row id")),
|
||||||
@@ -450,7 +514,7 @@ mod tests {
|
|||||||
use crate::{router, Upstreams};
|
use crate::{router, Upstreams};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
async fn application() -> (tempfile::TempDir, String) {
|
async fn application() -> (tempfile::TempDir, AppState, String) {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||||
.await
|
.await
|
||||||
@@ -466,9 +530,9 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("bind");
|
.expect("bind");
|
||||||
let address = listener.local_addr().expect("address");
|
let address = listener.local_addr().expect("address");
|
||||||
let app = router(state);
|
let app = router(state.clone());
|
||||||
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
||||||
(dir, format!("http://{address}"))
|
(dir, state, format!("http://{address}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn valid_input(name: &str) -> serde_json::Value {
|
fn valid_input(name: &str) -> serde_json::Value {
|
||||||
@@ -501,7 +565,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn crud_round_trips_a_policy() {
|
async fn crud_round_trips_a_policy() {
|
||||||
let (_dir, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
|
|
||||||
let created: serde_json::Value = create(&base, valid_input("test policy"))
|
let created: serde_json::Value = create(&base, valid_input("test policy"))
|
||||||
.await
|
.await
|
||||||
@@ -557,7 +621,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_referenced_policy_refuses_to_die() {
|
async fn a_referenced_policy_refuses_to_die() {
|
||||||
let (_dir, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||||
.await
|
.await
|
||||||
.expect("roots")
|
.expect("roots")
|
||||||
@@ -582,7 +646,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn an_unknown_resolution_is_a_422_naming_the_field() {
|
async fn an_unknown_resolution_is_a_422_naming_the_field() {
|
||||||
let (_dir, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
let mut payload = valid_input("bad bands");
|
let mut payload = valid_input("bad bands");
|
||||||
payload["size_bands"]["1440p"] = serde_json::json!({ "floor_gib": 2, "target_gib": 6, "penalty_points_per_gib_over": 60 });
|
payload["size_bands"]["1440p"] = serde_json::json!({ "floor_gib": 2, "target_gib": 6, "penalty_points_per_gib_over": 60 });
|
||||||
|
|
||||||
@@ -596,7 +660,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn every_field_validates_by_name() {
|
async fn every_field_validates_by_name() {
|
||||||
let (_dir, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
let with = |patch: &dyn Fn(&mut serde_json::Value)| {
|
let with = |patch: &dyn Fn(&mut serde_json::Value)| {
|
||||||
let mut payload = valid_input("validation probe");
|
let mut payload = valid_input("validation probe");
|
||||||
patch(&mut payload);
|
patch(&mut payload);
|
||||||
@@ -645,7 +709,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn malformed_json_is_422_not_400_or_500() {
|
async fn malformed_json_is_422_not_400_or_500() {
|
||||||
let (_dir, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
.post(format!("{base}/api/policies"))
|
.post(format!("{base}/api/policies"))
|
||||||
@@ -667,8 +731,267 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_duplicate_name_conflicts() {
|
async fn a_duplicate_name_conflicts() {
|
||||||
let (_dir, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
let response = create(&base, valid_input("Movies — main")).await;
|
let response = create(&base, valid_input("Movies — main")).await;
|
||||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn policy_id_named(base: &str, name: &str) -> i64 {
|
||||||
|
let policies: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
|
||||||
|
.await
|
||||||
|
.expect("policies")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("policies json");
|
||||||
|
policies
|
||||||
|
.iter()
|
||||||
|
.find(|policy| policy["name"] == name)
|
||||||
|
.and_then(|policy| policy["id"].as_i64())
|
||||||
|
.expect("policy id")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A movie under `root_id`, with one English 1080p release stamped
|
||||||
|
/// `verdict` as a search would have stamped it.
|
||||||
|
async fn movie_with_release(
|
||||||
|
state: &AppState,
|
||||||
|
base: &str,
|
||||||
|
root_id: i64,
|
||||||
|
tmdb_id: i64,
|
||||||
|
verdict: &str,
|
||||||
|
) -> i64 {
|
||||||
|
let movie: serde_json::Value = reqwest::Client::new()
|
||||||
|
.post(format!("{base}/api/movies"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"tmdb_id": tmdb_id, "title": format!("Title {tmdb_id}"), "year": 2024,
|
||||||
|
"original_language": "en", "root_id": root_id, "overrides": {}
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create movie")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("movie json");
|
||||||
|
let movie_id = movie["id"].as_i64().expect("movie id");
|
||||||
|
let name = format!("Title {tmdb_id} 2024 1080p WEB-DL ENGLISH x264-GROUP");
|
||||||
|
let release_id = stamped_release(state, &name, 6_i64 * (1 << 30), verdict).await;
|
||||||
|
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(movie_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("movie association");
|
||||||
|
movie_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One release row, verdict stamped by hand. `waived` carries no rule,
|
||||||
|
/// which the `releases` CHECK allows — only `rejected` needs one.
|
||||||
|
async fn stamped_release(state: &AppState, name: &str, size: i64, verdict: &str) -> i64 {
|
||||||
|
let parsed = arr_parse::parse(name);
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
||||||
|
VALUES (7, ?, ?, ?, 40, 'url', ?, 0, ?) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(name)
|
||||||
|
.bind(size)
|
||||||
|
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||||
|
.bind(verdict)
|
||||||
|
.fetch_one(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("release")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A series under `root_id` with a ten-episode season 9 pack stamped
|
||||||
|
/// `waived`. Under its own policy the pack is eligible, so the stamp
|
||||||
|
/// only survives if nothing re-derived it.
|
||||||
|
async fn series_with_waived_pack(state: &AppState, base: &str, root_id: i64) -> i64 {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let series: serde_json::Value = client
|
||||||
|
.post(format!("{base}/api/series"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"tmdb_id": 82_728, "title": "Bluey", "year": 2018,
|
||||||
|
"original_language": "en", "root_id": root_id,
|
||||||
|
"auto_track": false
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create series")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("series json");
|
||||||
|
let series_id = series["id"].as_i64().expect("series id");
|
||||||
|
let episodes: Vec<serde_json::Value> = (1..=10)
|
||||||
|
.map(|number| {
|
||||||
|
serde_json::json!({
|
||||||
|
"number": number, "title": format!("Episode {number}"),
|
||||||
|
"air_date": "2025-01-01"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let season: serde_json::Value = client
|
||||||
|
.post(format!("{base}/api/series/{series_id}/seasons"))
|
||||||
|
.json(&serde_json::json!({"number": 9, "episodes": episodes}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create season")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("season json");
|
||||||
|
let season_id = season["id"].as_i64().expect("season id");
|
||||||
|
// Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band.
|
||||||
|
let release_id = stamped_release(
|
||||||
|
state,
|
||||||
|
"Bluey S09 1080p WEB-DL ENGLISH x264-GROUP",
|
||||||
|
15_i64 * (1 << 30),
|
||||||
|
"waived",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(season_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("season association");
|
||||||
|
series_id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verdict_at(url: String) -> serde_json::Value {
|
||||||
|
let releases: Vec<serde_json::Value> = reqwest::get(url)
|
||||||
|
.await
|
||||||
|
.expect("releases")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("releases json");
|
||||||
|
releases[0]["verdict"].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn movie_verdict(base: &str, movie_id: i64) -> serde_json::Value {
|
||||||
|
verdict_at(format!("{base}/api/movies/{movie_id}/releases")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root_id_of(state: &AppState, kind: &str, audience: &str) -> i64 {
|
||||||
|
sqlx::query_scalar("SELECT id FROM roots WHERE kind = ? AND audience = ?")
|
||||||
|
.bind(kind)
|
||||||
|
.bind(audience)
|
||||||
|
.fetch_one(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("root id")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same policy document with a different required-audio rule, so an
|
||||||
|
/// English release that was eligible becomes a waiver.
|
||||||
|
fn requires_portuguese(name: &str) -> serde_json::Value {
|
||||||
|
let mut payload = valid_input(name);
|
||||||
|
payload["required_audio"] = serde_json::json!({ "require": "any_of", "langs": ["pt-PT"] });
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #246: editing a policy re-derives the stored verdicts of every title
|
||||||
|
/// under every root pointing at it (§5.1) — both libraries when two
|
||||||
|
/// roots share it, and nothing under a root that does not.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn editing_a_policy_rederives_every_root_that_points_at_it() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let shared = policy_id_named(&base, "Movies — main").await;
|
||||||
|
let main_root = root_id_of(&state, "movie", "main").await;
|
||||||
|
let kids_root = root_id_of(&state, "movie", "kids").await;
|
||||||
|
|
||||||
|
// Two roots on one policy: the edit has two libraries to reach
|
||||||
|
// rather than one. The path stays, so nothing on disk moves.
|
||||||
|
let response = client
|
||||||
|
.put(format!("{base}/api/roots/{kids_root}"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"kind": "movie", "audience": "kids",
|
||||||
|
"path": "/mnt/media/movies/kids", "policy_id": shared,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("repoint kids root");
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"{:?}",
|
||||||
|
response.text().await
|
||||||
|
);
|
||||||
|
|
||||||
|
let here = movie_with_release(&state, &base, main_root, 693_134, "eligible").await;
|
||||||
|
let there = movie_with_release(&state, &base, kids_root, 27_205, "eligible").await;
|
||||||
|
// On the TV main root, a different policy, so the edit must not
|
||||||
|
// reach it. Stamped against its own policy's answer, so a walk that
|
||||||
|
// did reach it would show.
|
||||||
|
let tv_root = root_id_of(&state, "tv", "main").await;
|
||||||
|
let series_id = series_with_waived_pack(&state, &base, tv_root).await;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.put(format!("{base}/api/policies/{shared}"))
|
||||||
|
.json(&requires_portuguese("Movies — main"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("edit policy");
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"{:?}",
|
||||||
|
response.text().await
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
movie_verdict(&base, here).await,
|
||||||
|
"waived",
|
||||||
|
"the first root's library re-derives"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
movie_verdict(&base, there).await,
|
||||||
|
"waived",
|
||||||
|
"and so does the second root's, sharing the policy"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_at(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
|
||||||
|
"waived",
|
||||||
|
"a root on another policy keeps the verdict it was stamped with"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renaming a policy changes no rule, so it re-derives nothing — the
|
||||||
|
/// stamped verdict survives even though the rules would not produce it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn renaming_a_policy_leaves_verdicts_alone() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let kids = policy_id_named(&base, "Movies — kids").await;
|
||||||
|
let kids_root = root_id_of(&state, "movie", "kids").await;
|
||||||
|
|
||||||
|
// Write the rules through the API once, so the rename that follows
|
||||||
|
// stores byte-identical rule columns and the only change is the name.
|
||||||
|
let response = client
|
||||||
|
.put(format!("{base}/api/policies/{kids}"))
|
||||||
|
.json(&valid_input("Movies — kids"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("normalise policy");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// Deliberately the wrong answer: these rules make an English release
|
||||||
|
// a waiver, so a re-derivation would move this row.
|
||||||
|
let movie = movie_with_release(&state, &base, kids_root, 157_336, "eligible").await;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.put(format!("{base}/api/policies/{kids}"))
|
||||||
|
.json(&valid_input("Movies — children"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("rename policy");
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"{:?}",
|
||||||
|
response.text().await
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
movie_verdict(&base, movie).await,
|
||||||
|
"eligible",
|
||||||
|
"a rename touches no rule, so it walks nothing"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
//! Stored verdicts, re-derived when a title's overrides change (§9.3).
|
//! Stored verdicts, re-derived when a title's effective policy changes:
|
||||||
|
//! an overrides edit (§9.3), a move to a root with a different policy, a
|
||||||
|
//! root pointed at a different policy, or an edit to the contents of a
|
||||||
|
//! policy some root points at (§5.1).
|
||||||
//!
|
//!
|
||||||
//! A release's verdict is stamped once, by the search that found it. Both the
|
//! A release's verdict is stamped once, by the search that found it. Both the
|
||||||
//! deck and the daemon's manual-grab gate read that stored column, so an
|
//! deck and the daemon's manual-grab gate read that stored column, so an
|
||||||
//! override written from the deck's one-click waive would change nothing
|
//! override written from the deck's one-click waive would change nothing
|
||||||
//! until the next sweep — the row the operator just acted on would keep
|
//! until the next sweep — the row the operator just acted on would keep
|
||||||
//! reading `rejected` and the grab would be refused.
|
//! reading `rejected` and the grab would be refused. A root move and a
|
||||||
|
//! policy repoint invalidate the column the same way, just wider: nothing
|
||||||
|
//! else re-reads it, so the write that changed the effective policy is the
|
||||||
|
//! only place the correction can happen. A policy edit invalidates it wider
|
||||||
|
//! still: every root pointing at that policy, not just one.
|
||||||
//!
|
//!
|
||||||
//! So the rules run again here, over the releases already attached to the
|
//! So the rules run again here, over the releases already attached to the
|
||||||
//! title. This is the same correction the daemon makes when a grab turns out
|
//! title. This is the same correction the daemon makes when a grab turns out
|
||||||
@@ -141,6 +148,60 @@ pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiEr
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-evaluate every release of every title under one root, for a
|
||||||
|
/// `PUT /api/roots/{id}` that pointed the root at a different policy (§5.1).
|
||||||
|
///
|
||||||
|
/// Title by title through [`movie`] and [`series`], so the skip rules — no
|
||||||
|
/// stored original language, the blacklist (§6.3) — stay in one place. Rows
|
||||||
|
/// whose verdict the new policy does not move are left unwritten, so the
|
||||||
|
/// usual case (most of a library re-evaluates to the same answer) costs
|
||||||
|
/// reads, not writes.
|
||||||
|
pub(crate) async fn root(state: &AppState, root_id: i64) -> Result<(), ApiError> {
|
||||||
|
let movie_ids = sqlx::query_scalar!(
|
||||||
|
r#"SELECT id AS "id!: i64" FROM movies WHERE root_id = ?"#,
|
||||||
|
root_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool(state)?)
|
||||||
|
.await?;
|
||||||
|
for movie_id in movie_ids {
|
||||||
|
movie(state, movie_id).await?;
|
||||||
|
}
|
||||||
|
let series_ids = sqlx::query_scalar!(
|
||||||
|
r#"SELECT id AS "id!: i64" FROM series WHERE root_id = ?"#,
|
||||||
|
root_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool(state)?)
|
||||||
|
.await?;
|
||||||
|
for series_id in series_ids {
|
||||||
|
series(state, series_id).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-evaluate every release of every title under every root that points at
|
||||||
|
/// one policy, for a `PUT /api/policies/{id}` that changed its rules (§5.1).
|
||||||
|
///
|
||||||
|
/// Root by root through [`root`], which is title by title through [`movie`]
|
||||||
|
/// and [`series`]: one walker, one set of skip rules, one place that decides
|
||||||
|
/// a row does not need rewriting.
|
||||||
|
///
|
||||||
|
/// This is the widest of the four re-derivations — a root repoint moves one
|
||||||
|
/// library, a policy edit moves every library sharing the policy — but the
|
||||||
|
/// ceiling is the whole database rather than something that grows with it,
|
||||||
|
/// since roots partition titles and a title has exactly one root.
|
||||||
|
pub(crate) async fn policy(state: &AppState, policy_id: i64) -> Result<(), ApiError> {
|
||||||
|
let root_ids = sqlx::query_scalar!(
|
||||||
|
r#"SELECT id AS "id!: i64" FROM roots WHERE policy_id = ?"#,
|
||||||
|
policy_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool(state)?)
|
||||||
|
.await?;
|
||||||
|
for root_id in root_ids {
|
||||||
|
root(state, root_id).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn apply(
|
async fn apply(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -173,11 +234,11 @@ async fn apply(
|
|||||||
episodes,
|
episodes,
|
||||||
runtime_minutes,
|
runtime_minutes,
|
||||||
);
|
);
|
||||||
// `releases` allows a rule name only on a rejected row
|
// A waiver records the rule it relaxed, the same as a rejection
|
||||||
// (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`),
|
// (#211). Migration 0032 relaxed `releases` to
|
||||||
// which is also how the daemon writes a waiver.
|
// `CHECK (verdict != 'rejected' OR rejected_rule IS NOT NULL)` so it
|
||||||
|
// can, and the daemon writes waivers the same way.
|
||||||
let (verdict, rule) = verdict_columns(&evaluation.verdict);
|
let (verdict, rule) = verdict_columns(&evaluation.verdict);
|
||||||
let rule = if verdict == "rejected" { rule } else { None };
|
|
||||||
if release.verdict.as_deref() == Some(verdict) && release.rejected_rule == rule {
|
if release.verdict.as_deref() == Some(verdict) && release.rejected_rule == rule {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+227
-71
@@ -1,8 +1,12 @@
|
|||||||
//! Moving library files when the layout under them changes: a title changing
|
//! 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).
|
//! 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
|
//! Both rename §7.4 folders and rewrite the `media_files` and `subtitle_files`
|
||||||
//! the layout keeps describing the disk and the root's policy (§5.1) applies
|
//! rows to match, so the layout keeps describing the disk. Sidecars (§15) live
|
||||||
//! to a library the files are actually in.
|
//! 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
|
//! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this
|
||||||
//! is a directory rename, never a copy. Hardlinked files keep their inodes
|
//! is a directory rename, never a copy. Hardlinked files keep their inodes
|
||||||
@@ -34,6 +38,15 @@ pub(crate) enum TitleKind {
|
|||||||
Series,
|
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
|
/// One rename from the old root into the new one: a §7.4 title folder, or a
|
||||||
/// loose file sitting straight in the root.
|
/// loose file sitting straight in the root.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -48,16 +61,15 @@ struct PlannedRename {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct Relocation {
|
pub(crate) struct Relocation {
|
||||||
performed: Vec<PlannedRename>,
|
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
|
||||||
/// Whether the destination root is a directory that must already be there.
|
/// root to `/mnt/media-v2/tv/kids` when `/mnt/media-v2` is all that
|
||||||
/// A title moves into another configured root, which exists; a root moving to
|
/// exists creates both `tv` and `kids` — and [`Self::undo`] removes all
|
||||||
/// a new path is moving somewhere that need not exist yet.
|
/// of them, so a failed move leaves nothing behind (#244). Levels that
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
/// were already on disk are never in this list and are never touched. A
|
||||||
enum Destination {
|
/// move that commits never calls `undo` and the directories stay.
|
||||||
Existing,
|
created_dirs: Vec<PathBuf>,
|
||||||
Create,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rename the title's folders into the new root. Called before the row is
|
/// Rename the title's folders into the new root. Called before the row is
|
||||||
@@ -82,7 +94,7 @@ pub(crate) async fn relocate_title(
|
|||||||
let old_root = root_path(state, old_root_id).await?;
|
let old_root = root_path(state, old_root_id).await?;
|
||||||
let new_root = root_path(state, new_root_id).await?;
|
let new_root = root_path(state, new_root_id).await?;
|
||||||
let files = title_files(state, kind, title_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
|
/// Rename every title folder under a root into the root's new path, for a
|
||||||
@@ -105,21 +117,20 @@ pub(crate) async fn relocate_root(
|
|||||||
new_path: &str,
|
new_path: &str,
|
||||||
) -> Result<Relocation, ApiError> {
|
) -> Result<Relocation, ApiError> {
|
||||||
let files = root_files(state, root_id).await?;
|
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
|
/// The one mover both callers share: plan every rename, refuse every
|
||||||
/// destination that already exists, then perform them, undoing what was
|
/// destination that already exists, then perform them, undoing what was
|
||||||
/// performed if one fails.
|
/// performed if one fails.
|
||||||
async fn relocate_files(
|
async fn relocate_files(
|
||||||
files: &[(i64, String)],
|
files: &[(FileTable, i64, String)],
|
||||||
old_root: &str,
|
old_root: &str,
|
||||||
new_root: &str,
|
new_root: &str,
|
||||||
destination: Destination,
|
|
||||||
) -> Result<Relocation, ApiError> {
|
) -> Result<Relocation, ApiError> {
|
||||||
let mut renames: Vec<PlannedRename> = Vec::new();
|
let mut renames: Vec<PlannedRename> = Vec::new();
|
||||||
let mut rewrites: Vec<(i64, String)> = Vec::new();
|
let mut rewrites: Vec<(FileTable, i64, String)> = Vec::new();
|
||||||
for (file_id, path) in files {
|
for (table, file_id, path) in files {
|
||||||
let Some(source) = title_target(old_root, path) else {
|
let Some(source) = title_target(old_root, path) else {
|
||||||
// Outside its own root: not ours to move, and the row keeps
|
// Outside its own root: not ours to move, and the row keeps
|
||||||
// pointing at where the file really is.
|
// pointing at where the file really is.
|
||||||
@@ -145,12 +156,22 @@ async fn relocate_files(
|
|||||||
"non-UTF-8 path under {new_root}"
|
"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
|
// Every destination is checked before anything is renamed, so a conflict
|
||||||
// never leaves a half-moved library behind.
|
// never leaves a half-moved library behind. A rename whose source is
|
||||||
|
// already gone is skipped here rather than refused: the perform loop below
|
||||||
|
// skips it too, and refusing it would make the documented retry
|
||||||
|
// impossible. After an undo that itself failed, the folder sits at the
|
||||||
|
// destination while the row still names the source, and every later
|
||||||
|
// attempt would 409 on a conflict with the operator's own half-moved
|
||||||
|
// library.
|
||||||
for rename in &renames {
|
for rename in &renames {
|
||||||
|
match tokio::fs::symlink_metadata(&rename.source).await {
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
match tokio::fs::symlink_metadata(&rename.destination).await {
|
match tokio::fs::symlink_metadata(&rename.destination).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
return Err(ApiError::Conflict(format!(
|
return Err(ApiError::Conflict(format!(
|
||||||
@@ -163,7 +184,16 @@ async fn relocate_files(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if destination == Destination::Create && !renames.is_empty() {
|
// Tracked only for the levels this call is the one to create, so a
|
||||||
|
// failed move can remove them again without ever touching a directory
|
||||||
|
// 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();
|
||||||
|
// 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 {
|
if let Err(error) = tokio::fs::create_dir_all(new_root).await {
|
||||||
return Err(ApiError::Filesystem(format!(
|
return Err(ApiError::Filesystem(format!(
|
||||||
"could not create '{new_root}': {error}"
|
"could not create '{new_root}': {error}"
|
||||||
@@ -185,7 +215,7 @@ async fn relocate_files(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(error) => return Err(failed(&rename, &error, performed).await),
|
Err(error) => return Err(failed(&rename, &error, performed, created_dirs).await),
|
||||||
}
|
}
|
||||||
match tokio::fs::rename(&rename.source, &rename.destination).await {
|
match tokio::fs::rename(&rename.source, &rename.destination).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -196,33 +226,57 @@ async fn relocate_files(
|
|||||||
);
|
);
|
||||||
performed.push(rename);
|
performed.push(rename);
|
||||||
}
|
}
|
||||||
Err(error) => return Err(failed(&rename, &error, performed).await),
|
Err(error) => return Err(failed(&rename, &error, performed, created_dirs).await),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Relocation {
|
Ok(Relocation {
|
||||||
performed,
|
performed,
|
||||||
rewrites,
|
rewrites,
|
||||||
|
created_dirs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One rename failed: move back everything that had already moved and name
|
/// The levels of `path` that are not on disk, deepest first — exactly what a
|
||||||
/// the folder that stopped the move, so the operator knows which title to
|
/// following `create_dir_all` will materialise. The walk stops at the first
|
||||||
/// look at before retrying.
|
/// ancestor that exists, so nothing already there is ever listed.
|
||||||
|
async fn missing_levels(path: &std::path::Path) -> Vec<PathBuf> {
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
for ancestor in path.ancestors() {
|
||||||
|
// `ancestors` ends in an empty path for a relative input; there is
|
||||||
|
// no level above that to create.
|
||||||
|
if ancestor.as_os_str().is_empty() || tokio::fs::symlink_metadata(ancestor).await.is_ok() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
missing.push(ancestor.to_path_buf());
|
||||||
|
}
|
||||||
|
missing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One rename failed: move back everything that had already moved, remove
|
||||||
|
/// every directory level this request created, and name the folder that
|
||||||
|
/// stopped the move so the operator knows which title to look at before
|
||||||
|
/// retrying.
|
||||||
async fn failed(
|
async fn failed(
|
||||||
rename: &PlannedRename,
|
rename: &PlannedRename,
|
||||||
error: &std::io::Error,
|
error: &std::io::Error,
|
||||||
performed: Vec<PlannedRename>,
|
performed: Vec<PlannedRename>,
|
||||||
|
created_dirs: Vec<PathBuf>,
|
||||||
) -> ApiError {
|
) -> ApiError {
|
||||||
Relocation {
|
Relocation {
|
||||||
performed,
|
performed,
|
||||||
rewrites: Vec::new(),
|
rewrites: Vec::new(),
|
||||||
|
created_dirs,
|
||||||
}
|
}
|
||||||
.undo()
|
.undo()
|
||||||
.await;
|
.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!(
|
ApiError::Filesystem(format!(
|
||||||
"could not move '{}': {error}",
|
"could not move '{}' to '{}': {error}",
|
||||||
rename.source.display()
|
rename.source.display(),
|
||||||
|
rename.destination.display()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,21 +287,34 @@ impl Relocation {
|
|||||||
!self.performed.is_empty()
|
!self.performed.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Point the `media_files` rows at the new root, inside the caller's
|
/// Point the `media_files` and `subtitle_files` rows at the new root,
|
||||||
/// transaction so they land together with the `root_id` change or not at
|
/// inside the caller's transaction so they land together with the
|
||||||
/// all.
|
/// `root_id` change or not at all.
|
||||||
pub(crate) async fn rewrite_rows(
|
pub(crate) async fn rewrite_rows(
|
||||||
&self,
|
&self,
|
||||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
) -> Result<(), sqlx::Error> {
|
) -> Result<(), sqlx::Error> {
|
||||||
for (file_id, path) in &self.rewrites {
|
for (table, file_id, path) in &self.rewrites {
|
||||||
sqlx::query!(
|
match table {
|
||||||
"UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
FileTable::Media => {
|
||||||
path,
|
sqlx::query!(
|
||||||
file_id
|
"UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||||
)
|
path,
|
||||||
.execute(&mut **transaction)
|
file_id
|
||||||
.await?;
|
)
|
||||||
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -266,6 +333,22 @@ impl Relocation {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// `remove_dir` rather than `remove_dir_all`: it only succeeds on an
|
||||||
|
// empty directory, so anything unexpected left inside it — this
|
||||||
|
// request's own undo failing, say — is a reason to leave it alone.
|
||||||
|
// Deepest first, since a parent cannot go while its child is there;
|
||||||
|
// the first level that will not go stops the walk, because every
|
||||||
|
// level above it now has content and refusing is the right answer.
|
||||||
|
for directory in &self.created_dirs {
|
||||||
|
if let Err(error) = tokio::fs::remove_dir(directory).await {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %directory.display(),
|
||||||
|
%error,
|
||||||
|
"could not remove a directory created for a move that did not complete"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,12 +371,17 @@ async fn root_path(state: &AppState, root_id: i64) -> Result<String, ApiError> {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every file the service recorded under a root: the rows of every movie in
|
/// Every file the service recorded under a root: the `media_files` rows of
|
||||||
/// it, and the rows of every episode of every series in it. Ordered so the
|
/// 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
|
/// renames happen in a stable order, which is what makes a failure part-way
|
||||||
/// through reproducible.
|
/// through reproducible.
|
||||||
async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>, ApiError> {
|
async fn root_files(
|
||||||
let mut files: Vec<(i64, String)> = sqlx::query!(
|
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"
|
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
||||||
FROM media_files mf
|
FROM media_files mf
|
||||||
JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id
|
JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id
|
||||||
@@ -304,7 +392,7 @@ async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>
|
|||||||
.fetch_all(pool(state)?)
|
.fetch_all(pool(state)?)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| (row.id, row.path))
|
.map(|row| (FileTable::Media, row.id, row.path))
|
||||||
.collect();
|
.collect();
|
||||||
files.extend(
|
files.extend(
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
@@ -320,41 +408,109 @@ async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>
|
|||||||
.fetch_all(pool(state)?)
|
.fetch_all(pool(state)?)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.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)
|
Ok(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every file the service recorded for the title: a movie's own rows, or the
|
/// 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(
|
async fn title_files(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
kind: TitleKind,
|
kind: TitleKind,
|
||||||
title_id: i64,
|
title_id: i64,
|
||||||
) -> Result<Vec<(i64, String)>, ApiError> {
|
) -> Result<Vec<(FileTable, i64, String)>, ApiError> {
|
||||||
Ok(match kind {
|
Ok(match kind {
|
||||||
TitleKind::Movie => sqlx::query!(
|
TitleKind::Movie => {
|
||||||
r#"SELECT id AS "id!: i64", path AS "path!: String"
|
let mut files: Vec<(FileTable, i64, String)> = sqlx::query!(
|
||||||
FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?"#,
|
r#"SELECT id AS "id!: i64", path AS "path!: String"
|
||||||
title_id
|
FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?"#,
|
||||||
)
|
title_id
|
||||||
.fetch_all(pool(state)?)
|
)
|
||||||
.await?
|
.fetch_all(pool(state)?)
|
||||||
.into_iter()
|
.await?
|
||||||
.map(|row| (row.id, row.path))
|
.into_iter()
|
||||||
.collect(),
|
.map(|row| (FileTable::Media, row.id, row.path))
|
||||||
TitleKind::Series => sqlx::query!(
|
.collect();
|
||||||
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
files.extend(
|
||||||
FROM media_files mf
|
sqlx::query!(
|
||||||
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
|
r#"SELECT sf.id AS "id!: i64", sf.path AS "path!: String"
|
||||||
JOIN seasons se ON se.id = e.season_id
|
FROM subtitle_files sf
|
||||||
WHERE se.series_id = ?"#,
|
JOIN media_files mf ON mf.id = sf.media_file_id
|
||||||
title_id
|
WHERE mf.owner_kind = 'movie' AND mf.owner_id = ? AND sf.path IS NOT NULL"#,
|
||||||
)
|
title_id
|
||||||
.fetch_all(pool(state)?)
|
)
|
||||||
.await?
|
.fetch_all(pool(state)?)
|
||||||
.into_iter()
|
.await?
|
||||||
.map(|row| (row.id, row.path))
|
.into_iter()
|
||||||
.collect(),
|
.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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+614
-17
@@ -67,6 +67,20 @@ impl RootInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strips a trailing separator so `/mnt/media` and `/mnt/media/` name the
|
||||||
|
/// same root — otherwise a `PUT` that only adds or drops a trailing slash
|
||||||
|
/// takes the relocation branch and every planned destination conflicts with
|
||||||
|
/// its own source. Leaves a bare `/` alone.
|
||||||
|
fn normalize_path(path: &str) -> String {
|
||||||
|
let trimmed = path.trim();
|
||||||
|
let stripped = trimmed.trim_end_matches('/');
|
||||||
|
if stripped.is_empty() {
|
||||||
|
"/".to_owned()
|
||||||
|
} else {
|
||||||
|
stripped.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_root(state: &AppState, id: i64) -> Result<Root, ApiError> {
|
async fn load_root(state: &AppState, id: i64) -> Result<Root, ApiError> {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Root,
|
Root,
|
||||||
@@ -137,7 +151,8 @@ pub async fn create(
|
|||||||
let input = parsed(body)?;
|
let input = parsed(body)?;
|
||||||
input.validate().map_err(ApiError::Invalid)?;
|
input.validate().map_err(ApiError::Invalid)?;
|
||||||
input.policy_exists(&state).await?;
|
input.policy_exists(&state).await?;
|
||||||
let path = input.path.trim().to_owned();
|
let path = normalize_path(&input.path);
|
||||||
|
path_is_free(&state, None, &path).await?;
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query!(
|
||||||
"INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)",
|
"INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)",
|
||||||
input.kind,
|
input.kind,
|
||||||
@@ -173,18 +188,26 @@ pub async fn update(
|
|||||||
input.validate().map_err(ApiError::Invalid)?;
|
input.validate().map_err(ApiError::Invalid)?;
|
||||||
input.policy_exists(&state).await?;
|
input.policy_exists(&state).await?;
|
||||||
let current = load_root(&state, id).await?;
|
let current = load_root(&state, id).await?;
|
||||||
let path = input.path.trim().to_owned();
|
let path = normalize_path(&input.path);
|
||||||
|
// #244: rows written before #243 can hold a trailing separator, so the
|
||||||
|
// stored value is normalised too. Comparing a normalised payload against
|
||||||
|
// a raw stored value means the row can never compare equal: every edit,
|
||||||
|
// policy changes included, takes the relocation branch, and there each
|
||||||
|
// planned destination is its own source. The normalised value is what
|
||||||
|
// `relocate_root` gets as well, so no planned path carries a doubled
|
||||||
|
// separator.
|
||||||
|
let current_path = normalize_path(¤t.path);
|
||||||
// A path change moves every §7.4 title folder under this root with the
|
// A path change moves every §7.4 title folder under this root with the
|
||||||
// row (issue #236), the same way changing a title's root moves one
|
// row (issue #236), the same way changing a title's root moves one
|
||||||
// (#228). Disk first, row second: a failed rename leaves the root row
|
// (#228). Disk first, row second: a failed rename leaves the root row
|
||||||
// alone, so the operator sees the library where its files actually are
|
// alone, so the operator sees the library where its files actually are
|
||||||
// and can retry. A path already taken is refused before any of it, since
|
// and can retry. A path already taken is refused before any of it, since
|
||||||
// the write would fail afterwards anyway.
|
// the write would fail afterwards anyway.
|
||||||
let relocation = if path == current.path {
|
let relocation = if path == current_path {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
path_is_free(&state, id, &path).await?;
|
path_is_free(&state, Some(id), &path).await?;
|
||||||
Some(crate::relocate::relocate_root(&state, id, ¤t.path, &path).await?)
|
Some(crate::relocate::relocate_root(&state, id, ¤t_path, &path).await?)
|
||||||
};
|
};
|
||||||
let mut transaction = pool(&state)?.begin().await?;
|
let mut transaction = pool(&state)?.begin().await?;
|
||||||
let written: Result<(), sqlx::Error> = async {
|
let written: Result<(), sqlx::Error> = async {
|
||||||
@@ -222,16 +245,34 @@ pub async fn update(
|
|||||||
{
|
{
|
||||||
crate::relocate::refresh_jellyfin(&state).await;
|
crate::relocate::refresh_jellyfin(&state).await;
|
||||||
}
|
}
|
||||||
|
// Verdicts already stored under this root were reached under the old
|
||||||
|
// policy; §9.3's deck and the grab gate both read them (`reclassify`).
|
||||||
|
if input.policy_id != current.policy_id {
|
||||||
|
crate::reclassify::root(&state, id).await?;
|
||||||
|
}
|
||||||
Ok(Json(load_root(&state, id).await?))
|
Ok(Json(load_root(&state, id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The unique index on `path` would catch this after the move; catching it
|
/// The unique index on `path` would catch this after the move; catching it
|
||||||
/// first keeps a doomed write from touching the disk at all.
|
/// first keeps a doomed write from touching the disk at all. `except` is the
|
||||||
async fn path_is_free(state: &AppState, id: i64, path: &str) -> Result<(), ApiError> {
|
/// row being updated, or `None` when creating.
|
||||||
let taken: Option<i64> =
|
///
|
||||||
sqlx::query_scalar!("SELECT id FROM roots WHERE path = ? AND id <> ?", path, id)
|
/// The stored side is normalised in SQL, mirroring [`normalize_path`], so
|
||||||
.fetch_optional(pool(state)?)
|
/// `/mnt/media/x` and `/mnt/media/x/` cannot be two roots for one directory
|
||||||
.await?;
|
/// (#244). The unique index cannot see that — it compares the raw strings —
|
||||||
|
/// and migration 0031 leaves any pair that already collides alone rather
|
||||||
|
/// than failing to apply, so such a row can still be on disk.
|
||||||
|
async fn path_is_free(state: &AppState, except: Option<i64>, path: &str) -> Result<(), ApiError> {
|
||||||
|
let taken: Option<i64> = sqlx::query_scalar!(
|
||||||
|
r#"SELECT id AS "id!: i64" FROM roots
|
||||||
|
WHERE CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END = ?
|
||||||
|
AND (? IS NULL OR id <> ?)"#,
|
||||||
|
path,
|
||||||
|
except,
|
||||||
|
except
|
||||||
|
)
|
||||||
|
.fetch_optional(pool(state)?)
|
||||||
|
.await?;
|
||||||
if taken.is_some() {
|
if taken.is_some() {
|
||||||
return Err(ApiError::Conflict(
|
return Err(ApiError::Conflict(
|
||||||
"a root with this path already exists".into(),
|
"a root with this path already exists".into(),
|
||||||
@@ -371,6 +412,192 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn policy_id_named(base: &str, name: &str) -> i64 {
|
||||||
|
let policies: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
|
||||||
|
.await
|
||||||
|
.expect("policies")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("policies json");
|
||||||
|
policies
|
||||||
|
.iter()
|
||||||
|
.find(|policy| policy["name"] == name)
|
||||||
|
.and_then(|policy| policy["id"].as_i64())
|
||||||
|
.expect("policy id")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stamped_eligible_release(state: &AppState, name: &str, size: i64) -> i64 {
|
||||||
|
let parsed = arr_parse::parse(name);
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
||||||
|
VALUES (7, ?, ?, ?, 40, 'url', ?, 0, 'eligible') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(name)
|
||||||
|
.bind(size)
|
||||||
|
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||||
|
.fetch_one(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("release")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A movie under the movie main root, its release stamped eligible.
|
||||||
|
async fn movie_with_eligible_release(state: &AppState, base: &str) -> i64 {
|
||||||
|
let movie: serde_json::Value = reqwest::Client::new()
|
||||||
|
.post(format!("{base}/api/movies"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
|
||||||
|
"original_language": "en", "root_id": 1, "overrides": {}
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create movie")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("movie json");
|
||||||
|
let movie_id = movie["id"].as_i64().expect("movie id");
|
||||||
|
let release_id = stamped_eligible_release(
|
||||||
|
state,
|
||||||
|
"Dune Part Two 2024 1080p WEB-DL ENGLISH x264-GROUP",
|
||||||
|
6_i64 * (1 << 30),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(movie_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("movie association");
|
||||||
|
movie_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A series under the given TV root, with a season 9 pack stamped
|
||||||
|
/// eligible. Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band.
|
||||||
|
async fn series_with_eligible_pack(state: &AppState, base: &str, root_id: i64) -> i64 {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let series: serde_json::Value = client
|
||||||
|
.post(format!("{base}/api/series"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"tmdb_id": 82_728, "title": "Bluey", "year": 2018,
|
||||||
|
"original_language": "en", "root_id": root_id,
|
||||||
|
"auto_track": false
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create series")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("series json");
|
||||||
|
let series_id = series["id"].as_i64().expect("series id");
|
||||||
|
let episodes: Vec<serde_json::Value> = (1..=10)
|
||||||
|
.map(|number| {
|
||||||
|
serde_json::json!({
|
||||||
|
"number": number, "title": format!("Episode {number}"),
|
||||||
|
"air_date": "2025-01-01"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let season: serde_json::Value = client
|
||||||
|
.post(format!("{base}/api/series/{series_id}/seasons"))
|
||||||
|
.json(&serde_json::json!({"number": 9, "episodes": episodes}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create season")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("season json");
|
||||||
|
let season_id = season["id"].as_i64().expect("season id");
|
||||||
|
let release_id = stamped_eligible_release(
|
||||||
|
state,
|
||||||
|
"Bluey S09 1080p WEB-DL ENGLISH x264-GROUP",
|
||||||
|
15_i64 * (1 << 30),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(season_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("season association");
|
||||||
|
series_id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verdict_of(url: String) -> serde_json::Value {
|
||||||
|
let releases: Vec<serde_json::Value> = reqwest::get(url)
|
||||||
|
.await
|
||||||
|
.expect("releases")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("releases json");
|
||||||
|
releases[0]["verdict"].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #241: `PUT /api/roots/{id}` writing a different `policy_id`
|
||||||
|
/// re-derives the stored verdicts of every title under the root (§5.1)
|
||||||
|
/// — and of nothing outside it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repointing_a_root_at_another_policy_rederives_its_library() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let pool = state.database().expect("database").pool();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let movie_id = movie_with_eligible_release(&state, &base).await;
|
||||||
|
let tv_main: i64 =
|
||||||
|
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("tv root");
|
||||||
|
let series_id = series_with_eligible_pack(&state, &base, tv_main).await;
|
||||||
|
|
||||||
|
// Point the movie main root at the kids policy; the path stays, so
|
||||||
|
// nothing on disk is touched.
|
||||||
|
let kids_policy = policy_id_named(&base, "Movies — kids").await;
|
||||||
|
let response = client
|
||||||
|
.put(format!("{base}/api/roots/1"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"kind": "movie", "audience": "main",
|
||||||
|
"path": "/mnt/media/movies/main", "policy_id": kids_policy
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("repoint movie root");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
verdict_of(format!("{base}/api/movies/{movie_id}/releases")).await,
|
||||||
|
"waived",
|
||||||
|
"kids requires pt-PT, English is a soft fail"
|
||||||
|
);
|
||||||
|
// The TV root was not repointed, so its library is untouched.
|
||||||
|
assert_eq!(
|
||||||
|
verdict_of(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
|
||||||
|
"eligible"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now the TV root; the series' pack re-derives too.
|
||||||
|
let tv_kids_policy = policy_id_named(&base, "TV — kids").await;
|
||||||
|
let response = client
|
||||||
|
.put(format!("{base}/api/roots/{tv_main}"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"kind": "tv", "audience": "main",
|
||||||
|
"path": "/mnt/media/tv/main", "policy_id": tv_kids_policy
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("repoint tv root");
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"{:?}",
|
||||||
|
response.text().await
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
verdict_of(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
|
||||||
|
"waived",
|
||||||
|
"kids requires pt-PT, English is a soft fail"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_root_round_trips_through_create_and_update() {
|
async fn a_root_round_trips_through_create_and_update() {
|
||||||
let (_dir, _state, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
@@ -544,6 +771,17 @@ mod tests {
|
|||||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A path written straight into the row, no normalisation — how a root
|
||||||
|
/// created before #243 could end up holding a trailing separator.
|
||||||
|
async fn point_root_at_raw(state: &AppState, root_id: i64, path: &str) {
|
||||||
|
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
||||||
|
.bind(path)
|
||||||
|
.bind(root_id)
|
||||||
|
.execute(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("store the raw path");
|
||||||
|
}
|
||||||
|
|
||||||
async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) {
|
async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) {
|
||||||
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
||||||
.bind(path.to_str().expect("utf-8 root"))
|
.bind(path.to_str().expect("utf-8 root"))
|
||||||
@@ -747,6 +985,59 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Integration review of the Feedback pass 2 blitz: the retry the module
|
||||||
|
/// documents has to actually converge. An undo is best-effort, so a move
|
||||||
|
/// can fail and leave one folder at the destination with its row still
|
||||||
|
/// naming the source. Every later attempt used to 409 against the
|
||||||
|
/// operator's own half-moved library, and the only way out was moving the
|
||||||
|
/// folder back by hand.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_folder_left_at_the_destination_by_a_failed_undo_does_not_block_the_retry() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let old = tempfile::tempdir().expect("old root");
|
||||||
|
let new = tempfile::tempdir().expect("new root");
|
||||||
|
point_root_at(&state, 1, old.path()).await;
|
||||||
|
|
||||||
|
let first = add_movie(&base, 100, "Dune", 1).await;
|
||||||
|
let second = add_movie(&base, 101, "Arrival", 1).await;
|
||||||
|
let dune = library_folder(&state, first, old.path(), "Dune").await;
|
||||||
|
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
|
||||||
|
|
||||||
|
// What a failed undo leaves behind: Arrival is already at the new
|
||||||
|
// path, its row still points at the old one, and the root row was
|
||||||
|
// never changed.
|
||||||
|
tokio::fs::create_dir_all(new.path())
|
||||||
|
.await
|
||||||
|
.expect("the new root");
|
||||||
|
tokio::fs::rename(&arrival, new.path().join("Arrival"))
|
||||||
|
.await
|
||||||
|
.expect("strand the folder");
|
||||||
|
assert!(!arrival.exists());
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.put(format!("{base}/api/roots/1"))
|
||||||
|
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("retry the move");
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"the stranded folder is the state we wanted, not a conflict"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!dune.exists(), "the folder still on disk moved");
|
||||||
|
assert!(new.path().join("Dune").exists());
|
||||||
|
assert!(new.path().join("Arrival").exists(), "left where it was");
|
||||||
|
let paths = file_paths(&state).await;
|
||||||
|
let root = new.path().to_str().expect("utf-8");
|
||||||
|
assert!(
|
||||||
|
paths.iter().all(|path| path.starts_with(root)),
|
||||||
|
"every row follows the root, the stranded one included: {paths:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(stored_path(&base, 1).await, root);
|
||||||
|
}
|
||||||
|
|
||||||
/// Issue #236: the move is all or nothing. A folder that cannot be
|
/// Issue #236: the move is all or nothing. A folder that cannot be
|
||||||
/// renamed part-way through takes the whole change down: what had already
|
/// renamed part-way through takes the whole change down: what had already
|
||||||
/// moved is moved back, the root row keeps the old path, and the rows
|
/// moved is moved back, the root row keeps the old path, and the rows
|
||||||
@@ -757,6 +1048,13 @@ mod tests {
|
|||||||
async fn one_folder_that_cannot_move_puts_the_others_back() {
|
async fn one_folder_that_cannot_move_puts_the_others_back() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
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 (_dir, state, base) = application().await;
|
||||||
let old = tempfile::tempdir().expect("old root");
|
let old = tempfile::tempdir().expect("old root");
|
||||||
let home = tempfile::tempdir().expect("home of the new path");
|
let home = tempfile::tempdir().expect("home of the new path");
|
||||||
@@ -804,12 +1102,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
new.exists(),
|
!new.exists(),
|
||||||
"the move reached the disk: the new path was created for it"
|
"the directory created for the failed move was removed, since it never held anything"
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!new.join("Dune").exists(),
|
|
||||||
"the folder that had already moved was moved back"
|
|
||||||
);
|
);
|
||||||
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
|
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -831,6 +1125,116 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Issue #243: a directory a failed move did not create is not this
|
||||||
|
/// request's to remove, even though it sits at the same new root path.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
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");
|
||||||
|
point_root_at(&state, 1, old.path()).await;
|
||||||
|
|
||||||
|
let id = add_movie(&base, 100, "Dune", 1).await;
|
||||||
|
let stuck = library_folder(&state, id, old.path(), "Dune").await;
|
||||||
|
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
|
||||||
|
.await
|
||||||
|
.expect("freeze the title folder");
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.put(format!("{base}/api/roots/1"))
|
||||||
|
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("move the root");
|
||||||
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
|
||||||
|
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.await
|
||||||
|
.expect("thaw the title folder");
|
||||||
|
assert!(
|
||||||
|
new.path().exists(),
|
||||||
|
"a directory this request did not create is left alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #243: a path differing only by a trailing separator is the same
|
||||||
|
/// root, not a self-conflict — `PUT` with it is a no-op.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_trailing_separator_is_not_a_path_change() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let old = tempfile::tempdir().expect("old root");
|
||||||
|
point_root_at(&state, 1, old.path()).await;
|
||||||
|
let id = add_movie(&base, 100, "Dune", 1).await;
|
||||||
|
let folder = library_folder(&state, id, old.path(), "Dune").await;
|
||||||
|
|
||||||
|
let mut trailing = old.path().to_str().expect("utf-8").to_owned();
|
||||||
|
trailing.push('/');
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.put(format!("{base}/api/roots/1"))
|
||||||
|
.json(&root_payload(&base, 1, &trailing).await)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("update root with a trailing separator");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
assert!(folder.exists(), "nothing on disk moved");
|
||||||
|
assert_eq!(
|
||||||
|
stored_path(&base, 1).await,
|
||||||
|
old.path().to_str().expect("utf-8"),
|
||||||
|
"the stored path keeps no trailing separator"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #243: `create` normalises the same way, so a trailing separator
|
||||||
|
/// cannot dodge the duplicate-path check.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_normalises_a_trailing_separator_before_checking_for_conflicts() {
|
||||||
|
let (_dir, _state, base) = application().await;
|
||||||
|
let policy_ids = first_policy_ids(&base).await;
|
||||||
|
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||||
|
.await
|
||||||
|
.expect("roots")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("roots json");
|
||||||
|
let existing_path = roots[0]["path"].as_str().expect("path").to_owned();
|
||||||
|
|
||||||
|
// Free the (movie, kids) pair so the conflict below is the path
|
||||||
|
// check alone, not the (kind, audience) uniqueness check.
|
||||||
|
let kids_root = roots[1]["id"].as_i64().expect("movie kids root id");
|
||||||
|
let freed = reqwest::Client::new()
|
||||||
|
.delete(format!("{base}/api/roots/{kids_root}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("free the seeded movie kids root");
|
||||||
|
assert_eq!(freed.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let mut payload = root_input(policy_ids[0]);
|
||||||
|
payload["audience"] = serde_json::json!("kids");
|
||||||
|
payload["path"] = serde_json::json!(format!("{existing_path}/"));
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{base}/api/roots"))
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create root with a trailing separator");
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"a trailing separator does not dodge the duplicate-path check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Issue #236: a TV root carries series folders, whose files hang off
|
/// Issue #236: a TV root carries series folders, whose files hang off
|
||||||
/// episodes rather than off the title row. They move with the root too.
|
/// episodes rather than off the title row. They move with the root too.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -900,6 +1304,199 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Issue #244: a root *stored* with a trailing separator — creatable
|
||||||
|
/// through the API at any point before #243 — could not be edited at
|
||||||
|
/// all. The payload was normalised and the stored value was not, so no
|
||||||
|
/// payload compared equal: every edit took the relocation branch, where
|
||||||
|
/// every planned destination is its own source and the pre-check 409s.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_root_stored_with_a_trailing_separator_can_still_be_edited() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let old = tempfile::tempdir().expect("old root");
|
||||||
|
let unslashed = old.path().to_str().expect("utf-8").to_owned();
|
||||||
|
point_root_at_raw(&state, 1, &format!("{unslashed}/")).await;
|
||||||
|
let id = add_movie(&base, 100, "Dune", 1).await;
|
||||||
|
let folder = library_folder(&state, id, old.path(), "Dune").await;
|
||||||
|
|
||||||
|
// What the settings view sends back: the path exactly as stored,
|
||||||
|
// separator included, with only the policy changed.
|
||||||
|
let kids_policy = policy_id_named(&base, "Movies — kids").await;
|
||||||
|
let mut payload = root_payload(&base, 1, &format!("{unslashed}/")).await;
|
||||||
|
payload["policy_id"] = kids_policy.into();
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.put(format!("{base}/api/roots/1"))
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("edit the root");
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"{:?}",
|
||||||
|
response.text().await
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(folder.join("feature.mkv").exists(), "nothing on disk moved");
|
||||||
|
assert_eq!(
|
||||||
|
stored_path(&base, 1).await,
|
||||||
|
unslashed,
|
||||||
|
"the row is left normalised, so the next edit compares equal too"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the same payload without the separator is not a relocation
|
||||||
|
// either.
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.put(format!("{base}/api/roots/1"))
|
||||||
|
.json(&root_payload(&base, 1, &unslashed).await)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("edit the root again");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert!(folder.join("feature.mkv").exists(), "still nothing moved");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #244: a real path change from a root stored with a trailing
|
||||||
|
/// separator plans from the normalised value, so neither a destination
|
||||||
|
/// nor a rewritten row carries a doubled separator.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn relocating_a_slash_stored_root_plans_no_doubled_separator() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let old = tempfile::tempdir().expect("old root");
|
||||||
|
let home = tempfile::tempdir().expect("home of the new path");
|
||||||
|
let new = home.path().join("relocated-main");
|
||||||
|
let unslashed = old.path().to_str().expect("utf-8").to_owned();
|
||||||
|
point_root_at_raw(&state, 1, &format!("{unslashed}/")).await;
|
||||||
|
let id = add_movie(&base, 100, "Dune", 1).await;
|
||||||
|
let folder = library_folder(&state, id, old.path(), "Dune").await;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.put(format!("{base}/api/roots/1"))
|
||||||
|
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("move the root");
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"{:?}",
|
||||||
|
response.text().await
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!folder.exists(), "the folder left the old path");
|
||||||
|
assert!(new.join("Dune").join("feature.mkv").exists());
|
||||||
|
let paths = file_paths(&state).await;
|
||||||
|
assert!(
|
||||||
|
!paths[0].contains("//"),
|
||||||
|
"no doubled separator in the rewritten row: {}",
|
||||||
|
paths[0]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
std::path::Path::new(&paths[0]).exists(),
|
||||||
|
"the rewritten path describes the disk: {}",
|
||||||
|
paths[0]
|
||||||
|
);
|
||||||
|
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #244: `create_dir_all` can materialise more than one level for
|
||||||
|
/// a root path pointed somewhere fresh. A failed move removes every
|
||||||
|
/// level it created, not only the leaf — and still nothing that was
|
||||||
|
/// already on disk.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
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");
|
||||||
|
// Three levels below a directory that is already there.
|
||||||
|
let top = home.path().join("media-v2");
|
||||||
|
let new = top.join("tv").join("kids");
|
||||||
|
point_root_at(&state, 1, old.path()).await;
|
||||||
|
|
||||||
|
let id = add_movie(&base, 100, "Dune", 1).await;
|
||||||
|
let stuck = library_folder(&state, id, old.path(), "Dune").await;
|
||||||
|
// Moving a directory to another parent rewrites its `..`, which
|
||||||
|
// needs write permission on the directory itself.
|
||||||
|
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
|
||||||
|
.await
|
||||||
|
.expect("freeze the title folder");
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.put(format!("{base}/api/roots/1"))
|
||||||
|
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("move the root");
|
||||||
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
|
||||||
|
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.await
|
||||||
|
.expect("thaw the title folder");
|
||||||
|
assert!(
|
||||||
|
!top.exists(),
|
||||||
|
"every level the failed move created is gone, not only the leaf"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
home.path().exists(),
|
||||||
|
"the level that was already there is left alone"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stuck.join("feature.mkv").exists(),
|
||||||
|
"the folder is still where the row says it is"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stored_path(&base, 1).await,
|
||||||
|
old.path().to_str().expect("utf-8")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #244: two roots naming one directory, differing only by a
|
||||||
|
/// trailing separator, are not two roots. The unique index compares raw
|
||||||
|
/// strings and cannot see it, so the check normalises both sides.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_slash_stored_path_is_not_free_for_another_root() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
point_root_at_raw(&state, 2, "/mnt/media/movies/archive/").await;
|
||||||
|
|
||||||
|
// An update onto the stripped form of a path another root holds.
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.put(format!("{base}/api/roots/1"))
|
||||||
|
.json(&root_payload(&base, 1, "/mnt/media/movies/archive").await)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("update onto the other root's path");
|
||||||
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
// And a create, which the unique index would have let through.
|
||||||
|
let policy_ids = first_policy_ids(&base).await;
|
||||||
|
let mut payload = root_input(policy_ids[0]);
|
||||||
|
payload["kind"] = serde_json::json!("tv");
|
||||||
|
payload["audience"] = serde_json::json!("main");
|
||||||
|
payload["path"] = serde_json::json!("/mnt/media/movies/archive");
|
||||||
|
let deleted = reqwest::Client::new()
|
||||||
|
.delete(format!("{base}/api/roots/3"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("free the (tv, main) pair");
|
||||||
|
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{base}/api/roots"))
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create onto the other root's path");
|
||||||
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
/// Issue #236: a path another root already holds is refused before the
|
/// Issue #236: a path another root already holds is refused before the
|
||||||
/// disk is touched at all.
|
/// disk is touched at all.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ pub struct ClassifiedRelease {
|
|||||||
pub score_terms: ScoreTerms,
|
pub score_terms: ScoreTerms,
|
||||||
pub verdict: String,
|
pub verdict: String,
|
||||||
pub rule: Option<String>,
|
pub rule: Option<String>,
|
||||||
|
/// What the blacklist recorded this release as failing on (#227, §6.3),
|
||||||
|
/// when `rule` is `blacklisted`. `None` on every other row. A policy
|
||||||
|
/// rejection and a bad release both read as `blacklisted` without it.
|
||||||
|
pub blacklist_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -730,6 +734,11 @@ fn classify(
|
|||||||
episodes,
|
episodes,
|
||||||
runtime_minutes,
|
runtime_minutes,
|
||||||
);
|
);
|
||||||
|
// #227: the reason the blacklist holds is what tells a policy rejection
|
||||||
|
// the operator can relax from a release that should never be retried.
|
||||||
|
let blacklist_reason = blacklist
|
||||||
|
.reason_for_candidate(&release.name, &release.download_url)
|
||||||
|
.map(str::to_owned);
|
||||||
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
|
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
|
||||||
("rejected", Some(blacklist::RULE.to_owned()))
|
("rejected", Some(blacklist::RULE.to_owned()))
|
||||||
} else {
|
} else {
|
||||||
@@ -781,6 +790,7 @@ fn classify(
|
|||||||
score_terms: terms,
|
score_terms: terms,
|
||||||
verdict: verdict.to_owned(),
|
verdict: verdict.to_owned(),
|
||||||
rule,
|
rule,
|
||||||
|
blacklist_reason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+612
-25
@@ -29,7 +29,9 @@ use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::{IntoParams, ToSchema};
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
|
||||||
use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release};
|
use crate::movies::{
|
||||||
|
attach_blacklist_reasons, pool, rescore, Accepted, ApiError, ErrorBody, Release,
|
||||||
|
};
|
||||||
use crate::owners::Owner;
|
use crate::owners::Owner;
|
||||||
use crate::search::tmdb_client;
|
use crate::search::tmdb_client;
|
||||||
use crate::state::{AppState, EpisodeCommand, MetadataCommand, SeasonCommand};
|
use crate::state::{AppState, EpisodeCommand, MetadataCommand, SeasonCommand};
|
||||||
@@ -63,9 +65,11 @@ pub struct Series {
|
|||||||
pub metadata_refreshed_at: Option<String>,
|
pub metadata_refreshed_at: Option<String>,
|
||||||
/// `airing`, `incomplete`, `waiting`, `complete` or `ended` (§4.2).
|
/// `airing`, `incomplete`, `waiting`, `complete` or `ended` (§4.2).
|
||||||
pub status: String,
|
pub status: String,
|
||||||
/// Episodes currently marked wanted (§4.1 — the only intent).
|
/// Episodes this series accounts for: wanted, on disk, or both (§4.1).
|
||||||
pub wanted_episodes: i64,
|
/// A season grabbed once and then untracked keeps its files counted —
|
||||||
/// Wanted episodes already on disk.
|
/// nothing wanted is not the same as nothing there.
|
||||||
|
pub total_episodes: i64,
|
||||||
|
/// Counted episodes already on disk.
|
||||||
pub available_episodes: i64,
|
pub available_episodes: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +114,10 @@ pub struct Season {
|
|||||||
/// which is why the row still exists. A conflict for the operator to
|
/// which is why the row still exists. A conflict for the operator to
|
||||||
/// resolve; nothing was deleted from disk.
|
/// resolve; nothing was deleted from disk.
|
||||||
pub vanished: bool,
|
pub vanished: bool,
|
||||||
|
/// #227. The last season pack that downloaded in full and was condemned
|
||||||
|
/// at import, while the season is still waiting for a file. `None` when
|
||||||
|
/// no pack was abandoned, or when the gap has since been filled.
|
||||||
|
pub import_failure: Option<ImportFailure>,
|
||||||
pub episodes: Vec<Episode>,
|
pub episodes: Vec<Episode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,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
|
// 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
|
// this avoids. The season number rides on each episode (#131), so no
|
||||||
// parallel slice can be forgotten.
|
// parallel slice can be forgotten.
|
||||||
let wanted = episodes
|
//
|
||||||
.iter()
|
// The denominator is wanted-or-on-disk, not wanted alone. Untracking a
|
||||||
.filter(|episode| episode.wanted && episode.season_number != 0);
|
// season clears `wanted` on every episode (§4.1) while the files stay,
|
||||||
let available = wanted
|
// 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()
|
.clone()
|
||||||
.filter(|episode| episode.state == MediaState::Available);
|
.filter(|episode| episode.state == MediaState::Available);
|
||||||
Series {
|
Series {
|
||||||
@@ -333,7 +346,7 @@ fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime)
|
|||||||
vote_average: row.vote_average,
|
vote_average: row.vote_average,
|
||||||
metadata_refreshed_at: row.metadata_refreshed_at.clone(),
|
metadata_refreshed_at: row.metadata_refreshed_at.clone(),
|
||||||
status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(),
|
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),
|
available_episodes: i64::try_from(available.count()).unwrap_or(i64::MAX),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -627,7 +640,10 @@ pub async fn update(
|
|||||||
{
|
{
|
||||||
crate::relocate::refresh_jellyfin(&state).await;
|
crate::relocate::refresh_jellyfin(&state).await;
|
||||||
}
|
}
|
||||||
if overrides_changed {
|
// A root change swaps the effective policy the same way an overrides
|
||||||
|
// edit does (§5.1), so both re-derive; `relocation` is `Some` exactly
|
||||||
|
// when the root changed.
|
||||||
|
if overrides_changed || relocation.is_some() {
|
||||||
crate::reclassify::series(&state, id).await?;
|
crate::reclassify::series(&state, id).await?;
|
||||||
}
|
}
|
||||||
Ok(Json(load_series(&state, id).await?))
|
Ok(Json(load_series(&state, id).await?))
|
||||||
@@ -699,8 +715,9 @@ enum FileScope {
|
|||||||
/// subfolders, sidecar subtitles and artwork go with it. A season or a single
|
/// 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
|
/// 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
|
/// 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
|
/// have to be re-derived to be named, which §2 forbids. Subtitle sidecars are
|
||||||
/// removed episode therefore stay; they are not rows this service wrote.
|
/// 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 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.
|
/// the reaper deletes it; a hardlinked file loses only its library name.
|
||||||
@@ -709,7 +726,8 @@ enum FileScope {
|
|||||||
/// recorded and can retry rather than losing the record of what is on disk.
|
/// 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> {
|
async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(), ApiError> {
|
||||||
let root = scope_root(state, scope).await?;
|
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();
|
let mut targets: Vec<std::path::PathBuf> = Vec::new();
|
||||||
for path in &paths {
|
for path in &paths {
|
||||||
@@ -734,7 +752,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
|
|||||||
Ok(metadata) => metadata,
|
Ok(metadata) => metadata,
|
||||||
// Already gone is the state we wanted.
|
// Already gone is the state we wanted.
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||||
};
|
};
|
||||||
let removed = if metadata.is_dir() {
|
let removed = if metadata.is_dir() {
|
||||||
tokio::fs::remove_dir_all(&target).await
|
tokio::fs::remove_dir_all(&target).await
|
||||||
@@ -744,7 +762,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
|
|||||||
match removed {
|
match removed {
|
||||||
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
|
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -829,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
|
/// 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
|
/// does not, which is the guard that keeps a sub-series delete inside the
|
||||||
/// library it belongs to.
|
/// library it belongs to.
|
||||||
@@ -1036,6 +1098,8 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
|
|||||||
.fetch_all(pool(state)?)
|
.fetch_all(pool(state)?)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let mut failures = season_import_failures(state, series_id).await?;
|
||||||
|
|
||||||
Ok(seasons
|
Ok(seasons
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|season| Season {
|
.map(|season| Season {
|
||||||
@@ -1044,6 +1108,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
|
|||||||
number: season.number,
|
number: season.number,
|
||||||
tracked: season.tracked,
|
tracked: season.tracked,
|
||||||
vanished: season.vanished,
|
vanished: season.vanished,
|
||||||
|
import_failure: failures.remove(&season.id),
|
||||||
episodes: episodes
|
episodes: episodes
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|episode| episode.season_id == season.id)
|
.filter(|episode| episode.season_id == season.id)
|
||||||
@@ -1413,7 +1478,7 @@ pub async fn episode_releases(
|
|||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> Result<Json<Vec<Release>>, ApiError> {
|
) -> Result<Json<Vec<Release>>, ApiError> {
|
||||||
let episode = load_episode(&state, id).await?;
|
let episode = load_episode(&state, id).await?;
|
||||||
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
|
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
|
||||||
.fetch_all(pool(&state)?)
|
.fetch_all(pool(&state)?)
|
||||||
.await?;
|
.await?;
|
||||||
let policy = state
|
let policy = state
|
||||||
@@ -1427,6 +1492,7 @@ pub async fn episode_releases(
|
|||||||
let lengths = season_lengths(&state, episode.series_id).await?;
|
let lengths = season_lengths(&state, episode.series_id).await?;
|
||||||
let runtime = series_runtime(&state, episode.series_id).await?;
|
let runtime = series_runtime(&state, episode.series_id).await?;
|
||||||
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
|
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
|
||||||
|
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
|
||||||
Ok(Json(releases))
|
Ok(Json(releases))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1568,7 +1634,7 @@ pub async fn season_releases(
|
|||||||
) -> Result<Json<Vec<Release>>, ApiError> {
|
) -> Result<Json<Vec<Release>>, ApiError> {
|
||||||
load_series_row(&state, series_id).await?;
|
load_series_row(&state, series_id).await?;
|
||||||
let season_id = load_season_id(&state, series_id, number).await?;
|
let season_id = load_season_id(&state, series_id, number).await?;
|
||||||
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id)
|
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id)
|
||||||
.fetch_all(pool(&state)?)
|
.fetch_all(pool(&state)?)
|
||||||
.await?;
|
.await?;
|
||||||
let policy = state
|
let policy = state
|
||||||
@@ -1582,9 +1648,108 @@ pub async fn season_releases(
|
|||||||
let lengths = season_lengths(&state, series_id).await?;
|
let lengths = season_lengths(&state, series_id).await?;
|
||||||
let runtime = series_runtime(&state, series_id).await?;
|
let runtime = series_runtime(&state, series_id).await?;
|
||||||
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
|
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
|
||||||
|
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
|
||||||
Ok(Json(releases))
|
Ok(Json(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 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
|
||||||
|
/// recorded; this is the row that carries them out.
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct ImportFailure {
|
||||||
|
/// The blacklisted release name, as the indexer spelled it.
|
||||||
|
pub release: String,
|
||||||
|
/// What the blacklist recorded it as failing on — a policy rule name the
|
||||||
|
/// operator can relax, or a sentence about the release itself. `None`
|
||||||
|
/// when no blacklist row answers to either key, which is possible after a
|
||||||
|
/// blacklist row is cleared by hand; the failure is still true.
|
||||||
|
pub reason: Option<String>,
|
||||||
|
/// When the grab entered `failed`, RFC3339. `None` on a row that failed
|
||||||
|
/// before migration 0030 gave the column a value.
|
||||||
|
pub failed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent abandoned pack per season of one series (#227).
|
||||||
|
///
|
||||||
|
/// Only seasons still waiting for a file are answered: once the gap is
|
||||||
|
/// filled, the failure is history and the season has nothing to explain. The
|
||||||
|
/// blacklist reason is matched in memory because its name key is normalised,
|
||||||
|
/// which SQL cannot compute.
|
||||||
|
async fn season_import_failures(
|
||||||
|
state: &AppState,
|
||||||
|
series_id: i64,
|
||||||
|
) -> Result<HashMap<i64, ImportFailure>, ApiError> {
|
||||||
|
let pool = pool(state)?;
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"SELECT g.target_id AS "season_id!: i64",
|
||||||
|
r.name AS "name!: String",
|
||||||
|
g.infohash AS "infohash!: String",
|
||||||
|
g.failed_at,
|
||||||
|
g.grabbed_at AS "grabbed_at!: String"
|
||||||
|
FROM grabs g
|
||||||
|
JOIN releases r ON r.id = g.release_id
|
||||||
|
JOIN seasons s ON s.id = g.target_id
|
||||||
|
WHERE g.target_kind = 'season'
|
||||||
|
AND g.state = 'failed'
|
||||||
|
AND s.series_id = ?
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM episodes e
|
||||||
|
WHERE e.season_id = s.id AND e.wanted
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM media_files f
|
||||||
|
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 Transmission, 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
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
let blacklist = arr_db::blacklist::Blacklist::load(pool).await?;
|
||||||
|
// Ascending order, so the last row written for a season wins.
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let reason = blacklist
|
||||||
|
.reason_for_infohash(&row.infohash)
|
||||||
|
.or_else(|| blacklist.reason_for_name(&row.name))
|
||||||
|
.map(str::to_owned);
|
||||||
|
(
|
||||||
|
row.season_id,
|
||||||
|
ImportFailure {
|
||||||
|
release: row.name,
|
||||||
|
reason,
|
||||||
|
failed_at: row.failed_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Which lane a season's missing episodes take (#182, §6.2).
|
/// Which lane a season's missing episodes take (#182, §6.2).
|
||||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -1626,6 +1791,11 @@ pub struct SeasonPackState {
|
|||||||
/// When a pack sweep for this season last completed. `None` means no
|
/// When a pack sweep for this season last completed. `None` means no
|
||||||
/// pack search has ever run, so an empty deck is pending, not settled.
|
/// pack search has ever run, so an empty deck is pending, not settled.
|
||||||
pub last_pack_search_at: Option<String>,
|
pub last_pack_search_at: Option<String>,
|
||||||
|
/// #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.
|
||||||
|
pub import_failure: Option<ImportFailure>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -1669,9 +1839,12 @@ pub async fn season_pack_state(
|
|||||||
.await?;
|
.await?;
|
||||||
// The same failed-pack tally the grab lane backs off on (#181), read
|
// 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.
|
// 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!(
|
let failed = sqlx::query!(
|
||||||
r#"SELECT count(*) AS "failures!: i64",
|
r#"SELECT count(*) AS "failures!: i64",
|
||||||
max(grabbed_at) AS "last_failed_at?: String"
|
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
|
||||||
FROM grabs
|
FROM grabs
|
||||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||||
season_id
|
season_id
|
||||||
@@ -1717,6 +1890,9 @@ pub async fn season_pack_state(
|
|||||||
pack_failures: failed.failures,
|
pack_failures: failed.failures,
|
||||||
pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()),
|
pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()),
|
||||||
last_pack_search_at,
|
last_pack_search_at,
|
||||||
|
import_failure: season_import_failures(&state, series_id)
|
||||||
|
.await?
|
||||||
|
.remove(&season_id),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1936,6 +2112,177 @@ mod tests {
|
|||||||
response.json().await.expect("season json")
|
response.json().await.expect("season json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn seasons_json(base: &str, series_id: i64) -> Vec<serde_json::Value> {
|
||||||
|
reqwest::get(format!("{base}/api/series/{series_id}/seasons"))
|
||||||
|
.await
|
||||||
|
.expect("seasons")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("seasons json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #227, the operator's own report: a pack downloaded in full, §5.7
|
||||||
|
/// condemned it at import, every episode went back to `missing`, and the
|
||||||
|
/// 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;
|
||||||
|
let root_id = tv_root(&state, "main").await;
|
||||||
|
let series = add_series(&base, root_id, false).await;
|
||||||
|
let series_id = series["id"].as_i64().expect("id");
|
||||||
|
let episodes: Vec<serde_json::Value> = (1..=10)
|
||||||
|
.map(|number| {
|
||||||
|
serde_json::json!({
|
||||||
|
"number": number, "title": format!("Episode {number}"),
|
||||||
|
"air_date": "2025-01-01"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let season = add_season(&base, series_id, 8, serde_json::json!(episodes)).await;
|
||||||
|
let season_id = season["id"].as_i64().expect("season id");
|
||||||
|
assert!(
|
||||||
|
season["import_failure"].is_null(),
|
||||||
|
"nothing has been grabbed yet"
|
||||||
|
);
|
||||||
|
// §4.1: tracking the season is what makes its episodes wanted, and a
|
||||||
|
// season with no intent has no gap to explain.
|
||||||
|
let tracked = reqwest::Client::new()
|
||||||
|
.patch(format!("{base}/api/series/{series_id}/seasons/8"))
|
||||||
|
.json(&serde_json::json!({"tracked": true}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("track the season");
|
||||||
|
assert_eq!(tracked.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let pool = state.database().expect("database").pool();
|
||||||
|
let name = "Rick.And.Morty.S08.1080p.WEB-DL.x264-GROUP";
|
||||||
|
let parsed = arr_parse::parse(name);
|
||||||
|
let release_id = sqlx::query_scalar::<_, i64>(
|
||||||
|
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
||||||
|
VALUES (7, 'pack', ?, 1000, 50, 'url', ?, 0, 'eligible') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("release");
|
||||||
|
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(season_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("association");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at, failed_at)
|
||||||
|
VALUES (?, 'season', ?, 'abc123', 'failed', '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z')",
|
||||||
|
)
|
||||||
|
.bind(release_id)
|
||||||
|
.bind(season_id)
|
||||||
|
.execute(pool)
|
||||||
|
.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.
|
||||||
|
let seasons = seasons_json(&base, series_id).await;
|
||||||
|
let row = &seasons[0]["import_failure"];
|
||||||
|
assert_eq!(row["release"], name);
|
||||||
|
assert_eq!(row["reason"], serde_json::Value::Null);
|
||||||
|
assert_eq!(row["failed_at"], "2026-01-02T00:00:00.000Z");
|
||||||
|
|
||||||
|
arr_db::blacklist::add(pool, Some("ABC123"), name, "size")
|
||||||
|
.await
|
||||||
|
.expect("blacklist");
|
||||||
|
|
||||||
|
let seasons = seasons_json(&base, series_id).await;
|
||||||
|
assert_eq!(seasons[0]["import_failure"]["reason"], "size");
|
||||||
|
|
||||||
|
// The deck the season row leads to says the same thing.
|
||||||
|
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_eq!(pack_state["import_failure"]["release"], name);
|
||||||
|
assert_eq!(pack_state["import_failure"]["reason"], "size");
|
||||||
|
|
||||||
|
// §6.3: the release itself is rejected in the deck, and now names
|
||||||
|
// what it was blacklisted for — a size rejection the operator can
|
||||||
|
// relax, not a corrupt file they should leave alone.
|
||||||
|
let releases: Vec<serde_json::Value> =
|
||||||
|
reqwest::get(format!("{base}/api/series/{series_id}/seasons/8/releases"))
|
||||||
|
.await
|
||||||
|
.expect("releases")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("releases json");
|
||||||
|
assert_eq!(releases.len(), 1);
|
||||||
|
assert_eq!(releases[0]["blacklist_reason"], "size");
|
||||||
|
|
||||||
|
// Once the gap is filled the failure is history: the season has
|
||||||
|
// nothing left to explain and stops saying it.
|
||||||
|
for episode in seasons[0]["episodes"].as_array().expect("episodes") {
|
||||||
|
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 1)")
|
||||||
|
.bind(episode["id"].as_i64().expect("episode id"))
|
||||||
|
.bind(format!("/library/e{}.mkv", episode["number"]))
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("file on disk");
|
||||||
|
}
|
||||||
|
let seasons = seasons_json(&base, series_id).await;
|
||||||
|
assert!(seasons[0]["import_failure"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
/// The production case behind #210: every pack of a season is under
|
/// The production case behind #210: every pack of a season is under
|
||||||
/// §5.5's per-episode floor, so the deck holds three candidates and
|
/// §5.5's per-episode floor, so the deck holds three candidates and
|
||||||
/// nothing is grabbable. Writing `allow_below_floor` turns the
|
/// nothing is grabbable. Writing `allow_below_floor` turns the
|
||||||
@@ -2011,9 +2358,10 @@ mod tests {
|
|||||||
releases[0]["verdict"], "waived",
|
releases[0]["verdict"], "waived",
|
||||||
"a waived grab stays a waiver; nothing here makes it eligible"
|
"a waived grab stays a waiver; nothing here makes it eligible"
|
||||||
);
|
);
|
||||||
// The row's rule name goes with the rejection; what survives is the
|
// #211: the waiver keeps the rule it relaxed, so the deck names it
|
||||||
// dashed `waived` verdict the deck reads (§9.3).
|
// the way it names a rejection (§9.3) instead of showing a bare
|
||||||
assert!(releases[0]["rejected_rule"].is_null());
|
// `waived`.
|
||||||
|
assert_eq!(releases[0]["rejected_rule"], "size");
|
||||||
|
|
||||||
// The grab the deck's one click sends is now accepted.
|
// The grab the deck's one click sends is now accepted.
|
||||||
let response = reqwest::Client::new()
|
let response = reqwest::Client::new()
|
||||||
@@ -2043,6 +2391,70 @@ mod tests {
|
|||||||
assert_eq!(releases[0]["rejected_rule"], "size");
|
assert_eq!(releases[0]["rejected_rule"], "size");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #241: moving a series to a root with a different policy re-derives
|
||||||
|
/// the stored verdicts of its whole deck (§5.1), season packs included.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn moving_a_series_to_another_root_rederives_its_verdicts() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let main_root = tv_root(&state, "main").await;
|
||||||
|
let kids_root = tv_root(&state, "kids").await;
|
||||||
|
let series = add_series(&base, main_root, false).await;
|
||||||
|
let series_id = series["id"].as_i64().expect("id");
|
||||||
|
let episodes: Vec<serde_json::Value> = (1..=10)
|
||||||
|
.map(|number| {
|
||||||
|
serde_json::json!({
|
||||||
|
"number": number, "title": format!("Episode {number}"),
|
||||||
|
"air_date": "2025-01-01"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let season = add_season(&base, series_id, 9, serde_json::json!(episodes)).await;
|
||||||
|
let season_id = season["id"].as_i64().expect("season id");
|
||||||
|
|
||||||
|
// Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band.
|
||||||
|
let pool = state.database().expect("database").pool();
|
||||||
|
let name = "Bluey S09 1080p WEB-DL ENGLISH x264-GROUP";
|
||||||
|
let parsed = arr_parse::parse(name);
|
||||||
|
let size = 15_i64 * (1 << 30);
|
||||||
|
let release_id = sqlx::query_scalar::<_, i64>(
|
||||||
|
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
||||||
|
VALUES (7, 'pack', ?, ?, 50, 'url', ?, 0, 'eligible') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(size)
|
||||||
|
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("release");
|
||||||
|
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(season_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("association");
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.patch(format!("{base}/api/series/{series_id}"))
|
||||||
|
.json(&serde_json::json!({"root_id": kids_root}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("move root");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let releases: Vec<serde_json::Value> =
|
||||||
|
reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases"))
|
||||||
|
.await
|
||||||
|
.expect("releases")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("releases json");
|
||||||
|
assert_eq!(releases.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
releases[0]["verdict"], "waived",
|
||||||
|
"kids requires pt-PT, English is a soft fail: {releases:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn overrides_reject_a_key_the_policy_engine_has_no_rule_for() {
|
async fn overrides_reject_a_key_the_policy_engine_has_no_rule_for() {
|
||||||
let (_dir, state, base) = application().await;
|
let (_dir, state, base) = application().await;
|
||||||
@@ -2704,7 +3116,7 @@ mod tests {
|
|||||||
listed[0]["status"], "incomplete",
|
listed[0]["status"], "incomplete",
|
||||||
"§4.2: an aired wanted episode with no file"
|
"§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);
|
assert_eq!(listed[0]["available_episodes"], 0);
|
||||||
|
|
||||||
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
|
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
|
||||||
@@ -2784,8 +3196,11 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("release");
|
.expect("release");
|
||||||
sqlx::query(
|
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,
|
||||||
VALUES (?, 'season', ?, 'hash', 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
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(release_id)
|
||||||
.bind(season_id)
|
.bind(season_id)
|
||||||
@@ -2801,6 +3216,26 @@ mod tests {
|
|||||||
"the deck offers a date, not just a closed door"
|
"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
|
// §14 outranks it: a pack would re-import what is on disk, so
|
||||||
// clearing the failure would not earn a pack anyway.
|
// 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)")
|
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, '/library/e01.mkv', 1)")
|
||||||
@@ -3353,6 +3788,42 @@ mod tests {
|
|||||||
file
|
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) {
|
async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) {
|
||||||
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
||||||
.bind(path.to_str().expect("utf-8 root"))
|
.bind(path.to_str().expect("utf-8 root"))
|
||||||
@@ -3555,6 +4026,122 @@ mod tests {
|
|||||||
assert_eq!(remaining, vec![second], "only the episode's row goes");
|
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.
|
/// #174: removal is not conditional on there being anything to remove.
|
||||||
/// A scope with no files still clears intent, and still answers 204.
|
/// A scope with no files still clears intent, and still answers 204.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+157
-2
@@ -1,11 +1,16 @@
|
|||||||
//! What the API needs to answer a request: one HTTP client and the addresses
|
//! 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).
|
//! of the three upstreams the service cannot work without (DESIGN.md §3).
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::ffi::{OsStr, OsString};
|
||||||
use std::time::Duration;
|
use std::sync::{atomic::AtomicU64, Arc};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use arr_db::Db;
|
use arr_db::Db;
|
||||||
|
use arr_dl::TransmissionClient;
|
||||||
|
use arr_probe::Extractor;
|
||||||
|
use arr_subs::{Backend, OpenAiEndpoint, Provider, Syncer};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::jellyfin::JellyfinClient;
|
use crate::jellyfin::JellyfinClient;
|
||||||
|
|
||||||
@@ -13,6 +18,12 @@ use crate::jellyfin::JellyfinClient;
|
|||||||
/// is configurable, so this is a constant that tests point elsewhere.
|
/// is configurable, so this is a constant that tests point elsewhere.
|
||||||
pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3";
|
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
|
/// How long an upstream has to answer a probe before it counts as
|
||||||
/// unreachable. Health is polled by a human waiting on a page.
|
/// unreachable. Health is polled by a human waiting on a page.
|
||||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
@@ -78,7 +89,28 @@ pub struct AppState {
|
|||||||
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
|
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
|
||||||
metadata_commands: mpsc::Sender<MetadataCommand>,
|
metadata_commands: mpsc::Sender<MetadataCommand>,
|
||||||
pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<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>,
|
jellyfin: Option<JellyfinClient>,
|
||||||
|
syncer: Syncer,
|
||||||
|
transmission: Option<TransmissionClient>,
|
||||||
|
download_snapshot: DownloadSnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Work explicitly requested through the movie API.
|
/// 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)),
|
pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
|
||||||
metadata_commands,
|
metadata_commands,
|
||||||
pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_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,
|
jellyfin: None,
|
||||||
|
syncer: Syncer::default(),
|
||||||
|
transmission: None,
|
||||||
|
download_snapshot: Arc::new(RwLock::new(None)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +201,56 @@ impl AppState {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach the shared Transmission client used by the API's live download
|
||||||
|
/// snapshot endpoint.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_transmission(mut self, transmission: TransmissionClient) -> Self {
|
||||||
|
self.transmission = Some(transmission);
|
||||||
|
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
|
/// Attach the Jellyfin client, so a manual subtitle grab or translation
|
||||||
/// can trigger the same library refresh import does (§7.5, §15).
|
/// can trigger the same library refresh import does (§7.5, §15).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -172,6 +263,62 @@ impl AppState {
|
|||||||
self.jellyfin.as_ref()
|
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.
|
/// Wait for the next manual movie action in the daemon's reconcile loop.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -221,6 +368,14 @@ impl AppState {
|
|||||||
self.database.as_ref()
|
self.database.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn transmission(&self) -> Option<&TransmissionClient> {
|
||||||
|
self.transmission.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn download_snapshot(&self) -> &DownloadSnapshot {
|
||||||
|
&self.download_snapshot
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn send_movie_command(
|
pub(crate) fn send_movie_command(
|
||||||
&self,
|
&self,
|
||||||
command: MovieCommand,
|
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,
|
/// The §7.4 attribute tags, in a fixed order: resolution, source, HDR,
|
||||||
/// Portuguese audio.
|
/// 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.
|
/// The kids audit surface: a pt-PT track is tagged, SDR is not.
|
||||||
#[test]
|
#[test]
|
||||||
fn portuguese_audio_is_tagged_and_sdr_is_not() {
|
fn portuguese_audio_is_tagged_and_sdr_is_not() {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user