mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd156e142 | ||
|
|
21f2b6b95c | ||
|
|
10554444a9 | ||
|
|
ce026e2eb8 | ||
|
|
ff770940ca | ||
|
|
340853477f | ||
|
|
816a735cde | ||
|
|
d1cdaaeb5e | ||
|
|
a1367f431d | ||
|
|
a4086f5e06 | ||
|
|
0d02c6db47 | ||
|
|
7c457ff83e | ||
|
|
00f3fa565c | ||
|
|
7fdaf3f67d | ||
|
|
e9f3b9a3ad | ||
|
|
8eb9bd5c21 | ||
|
|
a4d587037d | ||
|
|
1747b3466e | ||
|
|
81a057c8a0 | ||
|
|
91b1064689 | ||
|
|
c9a2b5eccf | ||
|
|
28969ac828 | ||
|
|
2f5d3dde7d | ||
|
|
2d949d4e7c | ||
|
|
100e0dd9f8 | ||
|
|
ceaccfd29f | ||
|
|
6bab9989ab | ||
|
|
f8d3f990ca | ||
|
|
30d7f228be | ||
|
|
b291df5cc9 |
@@ -70,7 +70,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata for ${{ matrix.target }} image
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
env:
|
||||
# Annotate both the per-platform manifests and the multi-arch image
|
||||
# index. The index level is what manifest-list consumers (Renovate's
|
||||
@@ -96,11 +96,11 @@ jobs:
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Build and push ${{ matrix.target }} Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
@@ -134,14 +134,14 @@ jobs:
|
||||
LEGACY_NAME: calibre-web-automated-book-downloader
|
||||
steps:
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Create legacy aliases
|
||||
run: |
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.14"
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.14"
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.14"
|
||||
@@ -81,10 +81,10 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Build shelfmark-lite image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
target: shelfmark-lite
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
@@ -25,14 +25,14 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3
|
||||
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
relevant: ${{ steps.filter.outputs.relevant }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v4.0.1
|
||||
- uses: dorny/paths-filter@v4.0.2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ COPY src/frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Use python-slim as the base image
|
||||
FROM python:3.14.6-slim@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1 AS base
|
||||
FROM python:3.14.6-slim@sha256:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6 AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 /uv /uvx /bin/
|
||||
|
||||
|
||||
+60
-53
@@ -1,72 +1,79 @@
|
||||
[
|
||||
{ "language": "English", "code": "en" },
|
||||
{ "language": "Chinese", "code": "zh" },
|
||||
{ "language": "Russian", "code": "ru" },
|
||||
{ "language": "Spanish", "code": "es" },
|
||||
{ "language": "French", "code": "fr" },
|
||||
{ "language": "German", "code": "de" },
|
||||
{ "language": "Italian", "code": "it" },
|
||||
{ "language": "Portuguese", "code": "pt" },
|
||||
{ "language": "Polish", "code": "pl" },
|
||||
{ "language": "Bulgarian", "code": "bg" },
|
||||
{ "language": "Dutch", "code": "nl" },
|
||||
{ "language": "Japanese", "code": "ja" },
|
||||
{ "language": "Arabic", "code": "ar" },
|
||||
{ "language": "Hebrew", "code": "he" },
|
||||
{ "language": "Turkish", "code": "tr" },
|
||||
{ "language": "Hungarian", "code": "hu" },
|
||||
{ "language": "Latin", "code": "la" },
|
||||
{ "language": "Czech", "code": "cs" },
|
||||
{ "language": "Korean", "code": "ko" },
|
||||
{ "language": "Ukrainian", "code": "uk" },
|
||||
{ "language": "Indonesian", "code": "id" },
|
||||
{ "language": "Romanian", "code": "ro" },
|
||||
{ "language": "Swedish", "code": "sv" },
|
||||
{ "language": "Greek", "code": "el" },
|
||||
{ "language": "Lithuanian", "code": "lt" },
|
||||
{ "language": "Bangla", "code": "bn" },
|
||||
{ "language": "Traditional Chinese", "code": "zh‑Hant" },
|
||||
{ "language": "Afrikaans", "code": "af" },
|
||||
{ "language": "Catalan", "code": "ca" },
|
||||
{ "language": "Danish", "code": "da" },
|
||||
{ "language": "Thai", "code": "th" },
|
||||
{ "language": "Hindi", "code": "hi" },
|
||||
{ "language": "Irish", "code": "ga" },
|
||||
{ "language": "Latvian", "code": "lv" },
|
||||
{ "language": "English", "code": "en", "aliases": ["eng"] },
|
||||
{ "language": "Chinese", "code": "zh", "aliases": ["chi", "zho"] },
|
||||
{ "language": "Russian", "code": "ru", "aliases": ["rus"] },
|
||||
{ "language": "Spanish", "code": "es", "aliases": ["spa"] },
|
||||
{ "language": "French", "code": "fr", "aliases": ["fra", "fre"] },
|
||||
{ "language": "German", "code": "de", "aliases": ["deu", "ger"] },
|
||||
{ "language": "Italian", "code": "it", "aliases": ["ita"] },
|
||||
{ "language": "Portuguese", "code": "pt", "aliases": ["por"] },
|
||||
{ "language": "Polish", "code": "pl", "aliases": ["pol"] },
|
||||
{ "language": "Bulgarian", "code": "bg", "aliases": ["bul"] },
|
||||
{ "language": "Dutch", "code": "nl", "aliases": ["dut", "nld"] },
|
||||
{ "language": "Japanese", "code": "ja", "aliases": ["jap", "jpn"] },
|
||||
{ "language": "Arabic", "code": "ar", "aliases": ["ara"] },
|
||||
{ "language": "Hebrew", "code": "he", "aliases": ["heb"] },
|
||||
{ "language": "Turkish", "code": "tr", "aliases": ["tur"] },
|
||||
{ "language": "Hungarian", "code": "hu", "aliases": ["hun"] },
|
||||
{ "language": "Latin", "code": "la", "aliases": ["lat"] },
|
||||
{ "language": "Czech", "code": "cs", "aliases": ["ces", "cze"] },
|
||||
{ "language": "Korean", "code": "ko", "aliases": ["kor"] },
|
||||
{ "language": "Ukrainian", "code": "uk", "aliases": ["ukr"] },
|
||||
{ "language": "Indonesian", "code": "id", "aliases": ["ind"] },
|
||||
{ "language": "Romanian", "code": "ro", "aliases": ["rom", "ron"] },
|
||||
{ "language": "Swedish", "code": "sv", "aliases": ["swe"] },
|
||||
{ "language": "Greek", "code": "el", "aliases": ["ell", "gre"] },
|
||||
{ "language": "Lithuanian", "code": "lt", "aliases": ["lit"] },
|
||||
{ "language": "Bangla", "code": "bn", "aliases": ["ben", "bengali"] },
|
||||
{ "language": "Traditional Chinese", "code": "zh-Hant", "aliases": ["zh‑Hant"] },
|
||||
{ "language": "Afrikaans", "code": "af", "aliases": ["afr"] },
|
||||
{ "language": "Catalan", "code": "ca", "aliases": ["cat"] },
|
||||
{ "language": "Danish", "code": "da", "aliases": ["dan"] },
|
||||
{ "language": "Thai", "code": "th", "aliases": ["tha"] },
|
||||
{ "language": "Hindi", "code": "hi", "aliases": ["hin"] },
|
||||
{ "language": "Irish", "code": "ga", "aliases": ["gle"] },
|
||||
{ "language": "Latvian", "code": "lv", "aliases": ["lav"] },
|
||||
{ "language": "Tibetan", "code": "bo" },
|
||||
{ "language": "Kannada", "code": "kn" },
|
||||
{ "language": "Serbian", "code": "sr" },
|
||||
{ "language": "Persian", "code": "fa" },
|
||||
{ "language": "Croatian", "code": "hr" },
|
||||
{ "language": "Kannada", "code": "kn", "aliases": ["kan"] },
|
||||
{ "language": "Serbian", "code": "sr", "aliases": ["srp"] },
|
||||
{ "language": "Persian", "code": "fa", "aliases": ["farsi", "fas", "per"] },
|
||||
{ "language": "Croatian", "code": "hr", "aliases": ["hrv"] },
|
||||
{ "language": "Slovak", "code": "sk" },
|
||||
{ "language": "Javanese", "code": "jv" },
|
||||
{ "language": "Vietnamese", "code": "vi" },
|
||||
{ "language": "Urdu", "code": "ur" },
|
||||
{ "language": "Finnish", "code": "fi" },
|
||||
{ "language": "Norwegian", "code": "no" },
|
||||
{ "language": "Javanese", "code": "jv", "aliases": ["jav"] },
|
||||
{ "language": "Vietnamese", "code": "vi", "aliases": ["vie"] },
|
||||
{ "language": "Urdu", "code": "ur", "aliases": ["urd"] },
|
||||
{ "language": "Finnish", "code": "fi", "aliases": ["fin"] },
|
||||
{ "language": "Norwegian", "code": "no", "aliases": ["nor"] },
|
||||
{ "language": "Kinyarwanda", "code": "rw" },
|
||||
{ "language": "Tamil", "code": "ta" },
|
||||
{ "language": "Tamil", "code": "ta", "aliases": ["tam"] },
|
||||
{ "language": "Belarusian", "code": "be" },
|
||||
{ "language": "Kazakh", "code": "kk" },
|
||||
{ "language": "Mongolian", "code": "mn" },
|
||||
{ "language": "Georgian", "code": "ka" },
|
||||
{ "language": "Slovenian", "code": "sl" },
|
||||
{ "language": "Slovenian", "code": "sl", "aliases": ["slv"] },
|
||||
{ "language": "Esperanto", "code": "eo" },
|
||||
{ "language": "Galician", "code": "gl" },
|
||||
{ "language": "Marathi", "code": "mr" },
|
||||
{ "language": "Filipino", "code": "fil" },
|
||||
{ "language": "Gujarati", "code": "gu" },
|
||||
{ "language": "Malayalam", "code": "ml" },
|
||||
{ "language": "Marathi", "code": "mr", "aliases": ["mar"] },
|
||||
{ "language": "Filipino", "code": "fil", "aliases": ["tagalog", "tgl"] },
|
||||
{ "language": "Gujarati", "code": "gu", "aliases": ["guj"] },
|
||||
{ "language": "Malayalam", "code": "ml", "aliases": ["mal"] },
|
||||
{ "language": "Kyrgyz", "code": "ky" },
|
||||
{ "language": "Azerbaijani", "code": "az" },
|
||||
{ "language": "Quechua", "code": "qu" },
|
||||
{ "language": "Swahili", "code": "sw" },
|
||||
{ "language": "Bashkir", "code": "ba" },
|
||||
{ "language": "Punjabi", "code": "pa" },
|
||||
{ "language": "Malay", "code": "ms" },
|
||||
{ "language": "Telugu", "code": "te" },
|
||||
{ "language": "Punjabi", "code": "pa", "aliases": ["pan"] },
|
||||
{ "language": "Malay", "code": "ms", "aliases": ["may", "msa"] },
|
||||
{ "language": "Telugu", "code": "te", "aliases": ["tel"] },
|
||||
{ "language": "Albanian", "code": "sq" },
|
||||
{ "language": "Uyghur", "code": "ug" },
|
||||
{ "language": "Armenian", "code": "hy" },
|
||||
{ "language": "Shan", "code": "shn" }
|
||||
{ "language": "Shan", "code": "shn" },
|
||||
{ "language": "Bosnian", "code": "bs", "aliases": ["bos"] },
|
||||
{ "language": "Burmese", "code": "my", "aliases": ["bur", "mya"] },
|
||||
{ "language": "Estonian", "code": "et", "aliases": ["est"] },
|
||||
{ "language": "Icelandic", "code": "is", "aliases": ["ice", "isl"] },
|
||||
{ "language": "Manx", "code": "gv", "aliases": ["glv"] },
|
||||
{ "language": "Scottish Gaelic", "code": "gd", "aliases": ["gla"] },
|
||||
{ "language": "Sanskrit", "code": "sa", "aliases": ["san"] }
|
||||
]
|
||||
|
||||
@@ -432,8 +432,8 @@ The release source tab to open by default in the release modal for audiobooks. U
|
||||
| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` |
|
||||
| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` |
|
||||
| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` |
|
||||
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
|
||||
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
|
||||
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
|
||||
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
|
||||
| `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` |
|
||||
| `BOOKLORE_HOST` | Base URL of your Grimmory instance | string | _none_ |
|
||||
| `BOOKLORE_USERNAME` | Grimmory account username | string | _none_ |
|
||||
@@ -454,8 +454,8 @@ The release source tab to open by default in the release modal for audiobooks. U
|
||||
| `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` |
|
||||
| `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ |
|
||||
| `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` |
|
||||
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` |
|
||||
| `HARDLINK_TORRENTS_AUDIOBOOK` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `true` |
|
||||
| `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` |
|
||||
| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ |
|
||||
@@ -499,7 +499,7 @@ Choose how downloaded book files are named and organized.
|
||||
|
||||
**Naming Template**
|
||||
|
||||
Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author} - {Title} ({Year})`
|
||||
@@ -508,7 +508,7 @@ Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename wi
|
||||
|
||||
**Path Template**
|
||||
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author}/{Title} ({Year})`
|
||||
@@ -709,7 +709,7 @@ Choose how downloaded audiobook files are named and organized.
|
||||
|
||||
**Naming Template**
|
||||
|
||||
Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author} - {Title}`
|
||||
@@ -718,7 +718,7 @@ Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename wi
|
||||
|
||||
**Path Template**
|
||||
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author}/{Title}/{Title}`
|
||||
@@ -1211,6 +1211,7 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480
|
||||
| `PROWLARR_API_KEY` | Found in Prowlarr: Settings > General > API Key | string (secret) | _none_ |
|
||||
| `PROWLARR_INDEXERS` | Select which indexers to search. 📚 = has book categories. Leave empty to search all. | string (comma-separated) | _empty list_ |
|
||||
| `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
|
||||
| `PROWLARR_COLLAPSE_DUPLICATES` | Collapse a release that several indexer entries returned down to a single row, keeping the entry with the best Prowlarr priority. Turn this off to see every entry that carried it, which is what makes results from filter-specific entries (freeleech and the like) visible. | boolean | `true` |
|
||||
| `PROWLARR_USE_SEED_PREFERENCES` | Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client | boolean | `false` |
|
||||
|
||||
<details>
|
||||
@@ -1263,6 +1264,15 @@ Automatically retry search without category filtering if no results are found
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `PROWLARR_COLLAPSE_DUPLICATES`
|
||||
|
||||
**Show one row per release**
|
||||
|
||||
Collapse a release that several indexer entries returned down to a single row, keeping the entry with the best Prowlarr priority. Turn this off to see every entry that carried it, which is what makes results from filter-specific entries (freeleech and the like) visible.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
#### `PROWLARR_USE_SEED_PREFERENCES`
|
||||
|
||||
**Use Prowlarr seed preferences**
|
||||
@@ -1395,9 +1405,11 @@ Delay between requests in seconds to avoid rate limiting (0-10).
|
||||
| `IRC_SERVER` | IRC server hostname | string | _none_ |
|
||||
| `IRC_PORT` | IRC server port (usually 6697 for TLS, 6667 for plain) | number | `6697` |
|
||||
| `IRC_USE_TLS` | Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't support TLS. | boolean | `true` |
|
||||
| `IRC_CHANNEL` | Channel name without the # prefix | string | _none_ |
|
||||
| `IRC_CHANNEL` | Channel name without the # prefix. Used for all searches unless a separate audiobook channel is configured below. | string | _none_ |
|
||||
| `IRC_NICK` | Your IRC nickname (required). Must be unique on the IRC network. | string | _none_ |
|
||||
| `IRC_SEARCH_BOT` | The search bot to address queries to (required). | string | _none_ |
|
||||
| `IRC_SEARCH_BOT` | The search bot to address queries to (required). Searches are sent as "@<bot> <query>". | string | _none_ |
|
||||
| `IRC_AUDIOBOOK_CHANNEL` | Optional. Channel name (without the # prefix) to use for audiobook searches. Leave blank to use the main channel above for audiobooks too. | string | _none_ |
|
||||
| `IRC_AUDIOBOOK_SEARCH_BOT` | Optional. Search bot for the audiobook channel. Leave blank to reuse the main search bot above. Only used when an audiobook channel is set. | string | _none_ |
|
||||
| `IRC_CACHE_TTL` | How long to keep cached search results before they expire. | string (choice) | `2592000` |
|
||||
|
||||
<details>
|
||||
@@ -1435,7 +1447,7 @@ Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't
|
||||
|
||||
**Channel**
|
||||
|
||||
Channel name without the # prefix
|
||||
Channel name without the # prefix. Used for all searches unless a separate audiobook channel is configured below.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
@@ -1455,12 +1467,30 @@ Your IRC nickname (required). Must be unique on the IRC network.
|
||||
|
||||
**Search bot**
|
||||
|
||||
The search bot to address queries to (required). Searches are sent as "@<bot> <query>". Without it, queries would be posted unaddressed to the channel.
|
||||
The search bot to address queries to (required). Searches are sent as "@<bot> <query>".
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
- **Required:** Yes
|
||||
|
||||
#### `IRC_AUDIOBOOK_CHANNEL`
|
||||
|
||||
**Audiobook channel**
|
||||
|
||||
Optional. Channel name (without the # prefix) to use for audiobook searches. Leave blank to use the main channel above for audiobooks too.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `IRC_AUDIOBOOK_SEARCH_BOT`
|
||||
|
||||
**Audiobook search bot**
|
||||
|
||||
Optional. Search bot for the audiobook channel. Leave blank to reuse the main search bot above. Only used when an audiobook channel is set.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `IRC_CACHE_TTL`
|
||||
|
||||
**Cache Duration**
|
||||
@@ -1481,6 +1511,7 @@ How long to keep cached search results before they expire.
|
||||
| `QBITTORRENT_URL` | Web UI URL of your qBittorrent instance | string | _none_ |
|
||||
| `QBITTORRENT_USERNAME` | qBittorrent Web UI username | string | _none_ |
|
||||
| `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ |
|
||||
| `QBITTORRENT_API_KEY` | Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set. | string (secret) | _none_ |
|
||||
| `QBITTORRENT_CATEGORY` | Category to assign to book downloads in qBittorrent | string | `books` |
|
||||
| `QBITTORRENT_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ |
|
||||
| `QBITTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ |
|
||||
@@ -1500,9 +1531,11 @@ How long to keep cached search results before they expire.
|
||||
| `RTORRENT_URL` | XML-RPC URL of your rTorrent instance | string | _none_ |
|
||||
| `RTORRENT_USERNAME` | HTTP Basic auth username (if authentication enabled) | string | _none_ |
|
||||
| `RTORRENT_PASSWORD` | HTTP Basic auth password | string (secret) | _none_ |
|
||||
| `RTORRENT_LABEL` | Label to assign to book downloads in rTorrent | string | `cwabd` |
|
||||
| `RTORRENT_LABEL` | Label to assign to ebook downloads in rTorrent | string | `cwabd` |
|
||||
| `RTORRENT_AUDIOBOOK_LABEL` | Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set) | string | _none_ |
|
||||
| `RTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
|
||||
| `PROWLARR_TORRENT_ACTION` | Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding | string (choice) | `keep` |
|
||||
| `PROWLARR_TORRENT_ACTION` | Choose whether to keep, remove, or move the torrent to another category or label after import | string (choice) | `keep` |
|
||||
| `PROWLARR_TORRENT_POST_IMPORT_CATEGORY` | Category or label to assign after a successful import | string | _empty string_ |
|
||||
| `PROWLARR_USENET_CLIENT` | Choose which usenet client to use | string (choice) | _empty string_ |
|
||||
| `NZBGET_URL` | URL of your NZBGet instance | string | _none_ |
|
||||
| `NZBGET_USERNAME` | NZBGet control username | string | `nzbget` |
|
||||
@@ -1555,6 +1588,15 @@ qBittorrent Web UI password
|
||||
- **Type:** string (secret)
|
||||
- **Default:** _none_
|
||||
|
||||
#### `QBITTORRENT_API_KEY`
|
||||
|
||||
**API Key**
|
||||
|
||||
Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set.
|
||||
|
||||
- **Type:** string (secret)
|
||||
- **Default:** _none_
|
||||
|
||||
#### `QBITTORRENT_CATEGORY`
|
||||
|
||||
**Book Category**
|
||||
@@ -1730,11 +1772,20 @@ HTTP Basic auth password
|
||||
|
||||
**Book Label**
|
||||
|
||||
Label to assign to book downloads in rTorrent
|
||||
Label to assign to ebook downloads in rTorrent
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `cwabd`
|
||||
|
||||
#### `RTORRENT_AUDIOBOOK_LABEL`
|
||||
|
||||
**Audiobook Label**
|
||||
|
||||
Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set)
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `RTORRENT_DOWNLOAD_DIR`
|
||||
|
||||
**Download Directory**
|
||||
@@ -1748,11 +1799,20 @@ Server-side directory where torrents are downloaded (optional, uses rTorrent def
|
||||
|
||||
**Torrent Completion Action**
|
||||
|
||||
Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding
|
||||
Choose whether to keep, remove, or move the torrent to another category or label after import
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `keep`
|
||||
- **Options:** `keep` (Keep), `remove` (Remove)
|
||||
- **Options:** `keep` (Keep), `remove` (Remove), `change_category` (Change Category)
|
||||
|
||||
#### `PROWLARR_TORRENT_POST_IMPORT_CATEGORY`
|
||||
|
||||
**Post-Import Category**
|
||||
|
||||
Category or label to assign after a successful import
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `PROWLARR_USENET_CLIENT`
|
||||
|
||||
@@ -2011,6 +2071,7 @@ Default sort order for Google Books search results.
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `DIRECT_DOWNLOAD_ENABLED` | Show Direct Download in release-source lists and allow Direct mode searches. Add your own mirror URLs in the Mirrors tab before using it. | boolean | `false` |
|
||||
| `DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH` | When language metadata is missing or unknown, parse the distant path (file path shown in search results) for language tags like [BD FR] or [En]. Also enables local language filtering so lgli files without AA language metadata are not excluded before the distant path can be checked. | boolean | `false` |
|
||||
| `AA_DONATOR_KEY` | Enables fast download access on AA. Get this from your donator account page. | string (secret) | _none_ |
|
||||
| `FAST_SOURCES_DISPLAY` | Always tried first, no waiting or bypass required. | JSON array | _see UI for defaults_ |
|
||||
| `SOURCE_PRIORITY` | Fallback sources, may have waiting. Requires bypasser. Drag to reorder. | JSON array | _see UI for defaults_ |
|
||||
@@ -2038,6 +2099,15 @@ Show Direct Download in release-source lists and allow Direct mode searches. Add
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH`
|
||||
|
||||
**Detect Language From Distant Path**
|
||||
|
||||
When language metadata is missing or unknown, parse the distant path (file path shown in search results) for language tags like [BD FR] or [En]. Also enables local language filtering so lgli files without AA language metadata are not excluded before the distant path can be checked.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `AA_DONATOR_KEY`
|
||||
|
||||
**Account Donator Key**
|
||||
|
||||
+16
-2
@@ -30,7 +30,19 @@ Configure in **Settings → Security → Authentication Method → OIDC**.
|
||||
| Auto-Provision Users | Create accounts on first login | `true` |
|
||||
| Login Button Label | Custom text for the sign-in button | — |
|
||||
|
||||
Use **Test Connection** to verify discovery and client configuration before attempting login.
|
||||
Use **Test Connection** to verify discovery, client configuration, and the provider's token signing keys (JWKS) before attempting login.
|
||||
|
||||
> **Authentik users:** make sure your provider has a **Signing Key** selected (e.g. the default self-signed certificate). Without one, Authentik serves an empty JWKS document and every login fails with an OIDC callback error, even though the discovery document looks healthy.
|
||||
|
||||
## Account Linking
|
||||
|
||||
On login, Shelfmark matches the OIDC identity to a user account in this order:
|
||||
|
||||
1. **OIDC subject** — a user who has logged in through this provider before.
|
||||
2. **Email** — a local account with the same (unique) email address. This only happens when the provider also asserts `email_verified: true` for the address; an unverified email would let anyone claim a local account by registering its address at the IdP.
|
||||
3. Otherwise, a new account is created when **Auto-Provision Users** is enabled (username conflicts get a numeric suffix), or the login is rejected with "Account not found" when it is disabled.
|
||||
|
||||
If the `email_verified` claim is missing or `false`, email linking is silently skipped — a common surprise when the address was never verified at the identity provider (e.g. Keycloak's **Email verified** toggle on the user, or Authentik accounts created without email verification). Make sure the `email` scope is requested and the address is marked verified in your IdP.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -46,6 +58,8 @@ If `DISABLE_LOCAL_AUTH` and `OIDC_AUTO_REDIRECT` are both enabled, users are red
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **No token signing keys (empty JWKS)** — The provider's JWKS endpoint returned no keys, so ID tokens can't be verified. In Authentik this happens when the provider has no **Signing Key** selected; pick one (e.g. the default self-signed certificate) and try again.
|
||||
- **Issuer validation failed** — The issuer in the token doesn't match the discovery document. Check your provider's external URL / issuer configuration.
|
||||
- **Callback URL mismatch** — Reverse proxy isn't forwarding `X-Forwarded-Proto` or `X-Forwarded-Host`, so the constructed callback URL doesn't match what's registered in the provider.
|
||||
- **Account not found** — Auto-provision is disabled and the user hasn't been pre-created by an admin.
|
||||
- **Account not found** — Auto-provision is disabled and the user hasn't been pre-created by an admin. If you pre-created the account with a matching email, see [Account Linking](#account-linking): the provider must send `email_verified: true` for linking to happen.
|
||||
- **Login created a duplicate account instead of linking to my local one** — Email linking requires a verified email; see [Account Linking](#account-linking). With `DEBUG=true`, the log notes when linking is skipped because the address isn't verified.
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@ dependencies = [
|
||||
"psutil",
|
||||
"emoji",
|
||||
"rarfile",
|
||||
"qbittorrent-api",
|
||||
"qbittorrent-api>=2026.5.3",
|
||||
"transmission-rpc",
|
||||
"authlib>=1.7.2,<1.8",
|
||||
"apprise>=1.12.0",
|
||||
@@ -29,7 +29,7 @@ dependencies = [
|
||||
browser = [
|
||||
"pyvirtualdisplay",
|
||||
"pyautogui",
|
||||
"seleniumbase==4.50.5",
|
||||
"seleniumbase==4.51.8",
|
||||
"python-xlib",
|
||||
]
|
||||
|
||||
@@ -40,7 +40,7 @@ dev = [
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"ruff==0.15.20",
|
||||
"ruff==0.16.0",
|
||||
"vulture>=2.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ def check_oidc_connection(
|
||||
response.raise_for_status()
|
||||
document = response.json()
|
||||
|
||||
required_fields = ["issuer", "authorization_endpoint", "token_endpoint"]
|
||||
required_fields = ["issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"]
|
||||
missing_fields = [field for field in required_fields if field not in document]
|
||||
if missing_fields:
|
||||
return {
|
||||
@@ -123,6 +123,24 @@ def check_oidc_connection(
|
||||
"message": f"Discovery document missing fields: {', '.join(missing_fields)}",
|
||||
}
|
||||
|
||||
# Logins verify the ID token against the provider's JWKS, so an empty key
|
||||
# set (e.g. an Authentik provider with no Signing Key selected) means every
|
||||
# login will fail even though discovery looks healthy.
|
||||
jwks_uri = str(document["jwks_uri"])
|
||||
jwks_response = requests.get(jwks_uri, timeout=10, verify=get_ssl_verify(jwks_uri))
|
||||
jwks_response.raise_for_status()
|
||||
jwks_document = jwks_response.json()
|
||||
jwks_keys = jwks_document.get("keys") if isinstance(jwks_document, dict) else None
|
||||
if not jwks_keys:
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
"Discovery document is valid, but the provider returned no token "
|
||||
"signing keys (empty JWKS), so logins will fail. If you use "
|
||||
"Authentik, select a Signing Key in the provider settings."
|
||||
),
|
||||
}
|
||||
|
||||
return {"success": True, "message": f"Connected to {document['issuer']}"}
|
||||
except Exception as exc:
|
||||
logger.exception("OIDC connection test failed")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Core settings registration and derived configuration values."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -15,6 +14,7 @@ from shelfmark.config.download_settings_handlers import (
|
||||
check_books_destination,
|
||||
)
|
||||
from shelfmark.config.email_settings import check_email_connection
|
||||
from shelfmark.core.languages import supported_book_languages
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.settings_registry import (
|
||||
ActionButton,
|
||||
@@ -143,11 +143,8 @@ for key in ["CONFIG_DIR", "LOG_DIR", "TMP_DIR", "INGEST_DIR", "DEBUG", "DOCKERMO
|
||||
if hasattr(env, key):
|
||||
logger.debug(" %s: %s", key, getattr(env, key))
|
||||
|
||||
# Load supported book languages from data file
|
||||
# Path is relative to the package root, not this file
|
||||
_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
|
||||
with (_DATA_DIR / "book-languages.json").open() as file:
|
||||
_SUPPORTED_BOOK_LANGUAGE = json.load(file)
|
||||
# Selectable book languages, without the resolution aliases clients do not need.
|
||||
_SUPPORTED_BOOK_LANGUAGE = supported_book_languages()
|
||||
|
||||
# Directory settings
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
@@ -1012,7 +1009,7 @@ def download_settings() -> list[SettingsField]:
|
||||
key="TEMPLATE_RENAME",
|
||||
label="Naming Template",
|
||||
description=(
|
||||
"Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} "
|
||||
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
||||
"(source filename without extension). Universal adds: {Series}, "
|
||||
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
|
||||
@@ -1031,7 +1028,7 @@ def download_settings() -> list[SettingsField]:
|
||||
key="TEMPLATE_ORGANIZE",
|
||||
label="Path Template",
|
||||
description=(
|
||||
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, "
|
||||
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, "
|
||||
"{OriginalName} (source filename without extension). Universal adds: {Series}, "
|
||||
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
|
||||
@@ -1294,7 +1291,7 @@ def download_settings() -> list[SettingsField]:
|
||||
key="TEMPLATE_AUDIOBOOK_RENAME",
|
||||
label="Naming Template",
|
||||
description=(
|
||||
"Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} "
|
||||
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
||||
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
|
||||
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
|
||||
@@ -1311,7 +1308,7 @@ def download_settings() -> list[SettingsField]:
|
||||
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
|
||||
label="Path Template",
|
||||
description=(
|
||||
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, "
|
||||
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, "
|
||||
"{OriginalName} (source filename without extension), {Series}, {SeriesPosition}, "
|
||||
"{Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
|
||||
|
||||
@@ -149,7 +149,7 @@ def _find_existing_alias_user(
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return sorted(candidates, key=lambda user: int(user.get("id") or 0))[0]
|
||||
return min(candidates, key=lambda user: int(user.get("id") or 0), default=None)
|
||||
|
||||
|
||||
def _resolve_create_username(
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Canonical language resolution shared by every release source.
|
||||
|
||||
Release sources report a language in whatever shape their upstream uses: a
|
||||
two-letter code, an ISO 639-2 three-letter code in either the bibliographic or
|
||||
terminological form, or an English name. They all need the same ISO 639-1 code
|
||||
out the other side, so the aliases live in one place (``data/book-languages.json``)
|
||||
and adding a language means editing one file.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
LANGUAGE_DATA_PATH = Path(__file__).resolve().parents[1].parent / "data" / "book-languages.json"
|
||||
|
||||
# Values a source uses to mean "we could not tell".
|
||||
LANGUAGE_PLACEHOLDERS = frozenset({"", "-", "--", "unknown", "unk", "n/a", "na", "none", "null"})
|
||||
|
||||
_ALIAS_TO_CODE: dict[str, str] | None = None
|
||||
_CODE_TO_NAME: dict[str, str] | None = None
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
# Separators that stand in for the hyphen in a subtag. The dashes turn up in
|
||||
# codes copied from web pages -- "zh‑Hant" used U+2011, which renders close
|
||||
# enough to both a hyphen and an underscore to go unnoticed -- and the
|
||||
# underscore is the spelling Direct Download accepted before this module existed.
|
||||
_SUBTAG_SEPARATORS = dict.fromkeys(map(ord, "‐‑‒–—―−﹘﹣-_"), "-")
|
||||
|
||||
|
||||
def _fold(value: str) -> str:
|
||||
"""Casefold, strip accents, and normalize subtag separators, so 'Español'
|
||||
and 'espanol', or 'zh-Hant', 'zh‑Hant' and 'zh_Hant', all match."""
|
||||
decomposed = unicodedata.normalize("NFKD", value).translate(_SUBTAG_SEPARATORS)
|
||||
stripped = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
|
||||
return " ".join(stripped.split()).casefold()
|
||||
|
||||
|
||||
def _load() -> tuple[dict[str, str], dict[str, str]]:
|
||||
global _ALIAS_TO_CODE, _CODE_TO_NAME
|
||||
|
||||
if _ALIAS_TO_CODE is not None and _CODE_TO_NAME is not None:
|
||||
return _ALIAS_TO_CODE, _CODE_TO_NAME
|
||||
|
||||
with _LOCK:
|
||||
if _ALIAS_TO_CODE is not None and _CODE_TO_NAME is not None:
|
||||
return _ALIAS_TO_CODE, _CODE_TO_NAME
|
||||
|
||||
alias_to_code: dict[str, str] = {}
|
||||
code_to_name: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
raw = json.loads(LANGUAGE_DATA_PATH.read_text(encoding="utf-8"))
|
||||
except OSError, ValueError:
|
||||
logger.exception("Failed to load language data from %s", LANGUAGE_DATA_PATH)
|
||||
raw = []
|
||||
|
||||
if not isinstance(raw, list):
|
||||
logger.warning("Language data at %s is not a list", LANGUAGE_DATA_PATH)
|
||||
raw = []
|
||||
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
code = str(item.get("code") or "").strip()
|
||||
name = str(item.get("language") or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
|
||||
code_to_name.setdefault(code, name or code)
|
||||
|
||||
for candidate in (code, name, *(item.get("aliases") or [])):
|
||||
folded = _fold(str(candidate))
|
||||
if folded and folded not in LANGUAGE_PLACEHOLDERS:
|
||||
alias_to_code.setdefault(folded, code)
|
||||
|
||||
_ALIAS_TO_CODE = alias_to_code
|
||||
_CODE_TO_NAME = code_to_name
|
||||
return alias_to_code, code_to_name
|
||||
|
||||
|
||||
def normalize_language(value: object) -> str | None:
|
||||
"""Resolve any known spelling of a language to its ISO 639-1 code.
|
||||
|
||||
Accepts a two-letter code, an ISO 639-2 three-letter code in either the
|
||||
bibliographic or terminological form, or an English name. Returns None for
|
||||
anything unrecognised or for the placeholders a source uses to say it does
|
||||
not know, so callers can treat "no language" uniformly.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
folded = _fold(str(value))
|
||||
if not folded or folded in LANGUAGE_PLACEHOLDERS:
|
||||
return None
|
||||
|
||||
alias_to_code, _ = _load()
|
||||
return alias_to_code.get(folded)
|
||||
|
||||
|
||||
def language_name(code: str | None) -> str | None:
|
||||
"""Return the English name for a language code, or None if unknown."""
|
||||
if not code:
|
||||
return None
|
||||
|
||||
_, code_to_name = _load()
|
||||
return code_to_name.get(str(code).strip())
|
||||
|
||||
|
||||
def language_alias_map() -> dict[str, str]:
|
||||
"""Every known alias mapped to its code, for callers doing their own matching.
|
||||
|
||||
Direct Download scans free-text paths and needs the whole alias set up front
|
||||
to look for, rather than resolving one candidate at a time.
|
||||
"""
|
||||
alias_to_code, _ = _load()
|
||||
return dict(alias_to_code)
|
||||
|
||||
|
||||
def supported_book_languages() -> list[dict[str, str]]:
|
||||
"""The selectable languages, as ``{"language": ..., "code": ...}``.
|
||||
|
||||
Aliases are an implementation detail of resolution, so they are left out of
|
||||
what the settings dropdown and the API hand to clients.
|
||||
"""
|
||||
_, code_to_name = _load()
|
||||
return [{"language": name, "code": code} for code, name in code_to_name.items()]
|
||||
|
||||
|
||||
def known_language_codes() -> frozenset[str]:
|
||||
"""Every ISO 639-1 code the bundled language data defines."""
|
||||
_, code_to_name = _load()
|
||||
return frozenset(code_to_name)
|
||||
@@ -108,6 +108,9 @@ class DownloadTask:
|
||||
retry_expected_hash: str | None = None # Optional torrent hash used to match client downloads
|
||||
retry_ratio_limit: float | None = None # Optional post-download seeding ratio
|
||||
retry_seeding_time_limit_minutes: int | None = None # Optional post-download seeding time limit
|
||||
retry_source_context: dict[str, Any] = field(
|
||||
default_factory=dict
|
||||
) # Source-private context for retry/re-resolution
|
||||
can_retry_without_staged_source: bool = (
|
||||
True # Whether the source can restart without a preserved staged file
|
||||
)
|
||||
@@ -116,6 +119,7 @@ class DownloadTask:
|
||||
series_name: str | None = None
|
||||
series_position: float | None = None # Float for novellas (e.g., 1.5)
|
||||
subtitle: str | None = None # Book subtitle for naming templates
|
||||
language: str | None = None # Release language code for the {Language} template variable
|
||||
|
||||
# Hardlinking support
|
||||
original_download_path: str | None = None # Path in download client (for hardlinking)
|
||||
|
||||
@@ -4,6 +4,7 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.languages import LANGUAGE_PLACEHOLDERS, normalize_language
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -19,6 +20,7 @@ KNOWN_TOKENS = [
|
||||
"primarytitle",
|
||||
"originalname",
|
||||
"partnumber",
|
||||
"language",
|
||||
"subtitle",
|
||||
"author",
|
||||
"series",
|
||||
@@ -66,6 +68,33 @@ def format_series_position(position: str | float | None) -> str:
|
||||
return str(position)
|
||||
|
||||
|
||||
def normalize_language_code(language: str | None) -> str:
|
||||
"""Resolve a release language to the single spelling used in a path.
|
||||
|
||||
Sources report the same language in different shapes: "en", "eng", "English".
|
||||
All of them have to collapse to one code, or the editions they identify end
|
||||
up in separate folders, which is the collision this token exists to prevent.
|
||||
Placeholder values render empty so `{ (Language)}` disappears entirely
|
||||
rather than labelling a folder "(unknown)".
|
||||
|
||||
A language the bundled data does not know is kept, casefolded, rather than
|
||||
dropped: it still separates editions, and it cannot collide with a resolved
|
||||
code precisely because nothing resolves it.
|
||||
"""
|
||||
if not language:
|
||||
return ""
|
||||
|
||||
resolved = normalize_language(language)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
|
||||
normalized = " ".join(str(language).split()).strip().casefold()
|
||||
if normalized in LANGUAGE_PLACEHOLDERS:
|
||||
return ""
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def derive_primary_title(title: str | None, subtitle: str | None) -> str:
|
||||
"""Return the title without an explicit subtitle suffix when possible."""
|
||||
title_value = " ".join(str(title or "").split()).strip()
|
||||
@@ -91,8 +120,7 @@ PAD_NUMBERS_PATTERN = re.compile(r"\d+")
|
||||
|
||||
def natural_sort_key(path: str | Path) -> str:
|
||||
"""Generate a sort key with padded numbers for natural sorting."""
|
||||
filename = Path(path).name.lower()
|
||||
return PAD_NUMBERS_PATTERN.sub(lambda m: m.group().zfill(9), filename)
|
||||
return PAD_NUMBERS_PATTERN.sub(lambda m: m.group().zfill(9), str(path).lower())
|
||||
|
||||
|
||||
def assign_part_numbers(
|
||||
|
||||
@@ -33,6 +33,11 @@ logger = setup_logger(__name__)
|
||||
oauth = OAuth()
|
||||
_RETURN_TO_SESSION_KEY = "oidc_return_to"
|
||||
_OIDC_CLIENT_ERRORS = (OAuthError, OSError, RuntimeError, TypeError, ValueError)
|
||||
_EMPTY_JWKS_MESSAGE = (
|
||||
"Authentication failed: the identity provider returned no token signing keys "
|
||||
"(empty JWKS). If you use Authentik, select a Signing Key in the provider "
|
||||
"settings and try again."
|
||||
)
|
||||
|
||||
|
||||
class _ClaimsMappingLike(Protocol):
|
||||
@@ -121,6 +126,17 @@ def _normalize_return_to(raw_return_to: object) -> str | None:
|
||||
return urlunsplit(("", "", path, parsed.query, parsed.fragment))
|
||||
|
||||
|
||||
def _idp_jwks_has_no_keys(client: Any) -> bool:
|
||||
"""Return True when the IdP's JWKS document verifiably contains no signing keys."""
|
||||
try:
|
||||
jwk_set = client.fetch_jwk_set(force=True)
|
||||
except (*_OIDC_CLIENT_ERRORS, KeyError):
|
||||
return False
|
||||
if not isinstance(jwk_set, Mapping):
|
||||
return False
|
||||
return not jwk_set.get("keys")
|
||||
|
||||
|
||||
def _get_pending_return_to(*, clear: bool = False) -> str | None:
|
||||
"""Read the pending post-login target from the session."""
|
||||
raw_return_to = (
|
||||
@@ -274,6 +290,17 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
return redirect(
|
||||
_login_error_url(f"OIDC token claim validation failed: {claim_name}")
|
||||
)
|
||||
except KeyError, ValueError:
|
||||
# An IdP serving an empty JWKS document (e.g. an Authentik provider
|
||||
# with no Signing Key selected) surfaces as KeyError('keys') while
|
||||
# importing the key set. Test Connection only validates discovery,
|
||||
# so this is the first place the misconfiguration becomes visible.
|
||||
if _idp_jwks_has_no_keys(client):
|
||||
logger.exception(
|
||||
"OIDC callback failed: the IdP JWKS document contains no signing keys"
|
||||
)
|
||||
return redirect(_login_error_url(_EMPTY_JWKS_MESSAGE))
|
||||
raise
|
||||
claims = _normalize_claims(token.get("userinfo"))
|
||||
|
||||
# If userinfo is missing or claims are too sparse, request it explicitly.
|
||||
@@ -306,6 +333,12 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
is_admin = admin_group in groups
|
||||
|
||||
allow_email_link = bool(user_info.get("email")) and _is_email_verified(claims)
|
||||
if user_info.get("email") and not allow_email_link:
|
||||
logger.debug(
|
||||
"OIDC email %s is not marked verified by the IdP; skipping "
|
||||
"email-based account linking",
|
||||
user_info["email"],
|
||||
)
|
||||
user = provision_oidc_user(
|
||||
user_db,
|
||||
user_info,
|
||||
|
||||
@@ -325,6 +325,19 @@ class DownloadClient(ABC):
|
||||
|
||||
"""
|
||||
|
||||
def set_category(self, download_id: str, category: str) -> bool:
|
||||
"""Update a download's category or label when supported by the client.
|
||||
|
||||
Args:
|
||||
download_id: The client-specific download ID.
|
||||
category: Category or label to assign.
|
||||
|
||||
Returns:
|
||||
True if the category was updated, otherwise False.
|
||||
|
||||
"""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where files were downloaded.
|
||||
@@ -359,10 +372,13 @@ class DownloadClient(ABC):
|
||||
|
||||
# Client registry: protocol -> list of client classes
|
||||
_CLIENTS: dict[str, list[type[DownloadClient]]] = {}
|
||||
ClientType = TypeVar("ClientType", bound=DownloadClient)
|
||||
_BUILTIN_CLIENT_MODULES = (
|
||||
"shelfmark.download.clients.alldebrid",
|
||||
"shelfmark.download.clients.deluge",
|
||||
"shelfmark.download.clients.nzbget",
|
||||
"shelfmark.download.clients.qbittorrent",
|
||||
"shelfmark.download.clients.realdebrid",
|
||||
"shelfmark.download.clients.rtorrent",
|
||||
"shelfmark.download.clients.sabnzbd",
|
||||
"shelfmark.download.clients.transmission",
|
||||
@@ -383,7 +399,7 @@ def _ensure_builtin_clients_registered() -> None:
|
||||
|
||||
def register_client(
|
||||
protocol: str,
|
||||
) -> Callable[[type[DownloadClient]], type[DownloadClient]]:
|
||||
) -> Callable[[type[ClientType]], type[ClientType]]:
|
||||
"""Register a download client for a protocol.
|
||||
|
||||
Multiple clients can be registered for the same protocol.
|
||||
@@ -399,7 +415,7 @@ def register_client(
|
||||
|
||||
"""
|
||||
|
||||
def decorator(cls: type[DownloadClient]) -> type[DownloadClient]:
|
||||
def decorator(cls: type[ClientType]) -> type[ClientType]:
|
||||
if protocol not in _CLIENTS:
|
||||
_CLIENTS[protocol] = []
|
||||
_CLIENTS[protocol].append(cls)
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
"""AllDebrid debrid service client for Shelfmark.
|
||||
|
||||
Routes magnet links through the AllDebrid API (v4/v4.1) to download
|
||||
torrent content via AllDebrid's CDN infrastructure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, NoReturn
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.config.env import TMP_DIR
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadState,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.download.clients._coercion import config_text
|
||||
from shelfmark.download.http import download_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_API_BASE = "https://api.alldebrid.com/v4"
|
||||
_AGENT = "shelfmark"
|
||||
|
||||
_ALLDEBRID_CLIENT_ERRORS = (
|
||||
AttributeError,
|
||||
OSError,
|
||||
requests.exceptions.RequestException,
|
||||
RuntimeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
)
|
||||
|
||||
# AllDebrid magnet status codes (from API v4.1 documentation).
|
||||
_STATUS_DOWNLOADING = frozenset({0, 1, 2, 3})
|
||||
_STATUS_READY = 4
|
||||
|
||||
# Timeouts and retry limits for API calls.
|
||||
_API_TIMEOUT = 30
|
||||
_STATUS_TIMEOUT = 15
|
||||
_DELAYED_POLL_INTERVAL = 5
|
||||
_DELAYED_POLL_MAX_ATTEMPTS = 12
|
||||
|
||||
# File extensions recognised as book or audiobook content.
|
||||
_BOOK_EXTENSIONS = (
|
||||
".aac",
|
||||
".azw",
|
||||
".azw3",
|
||||
".cbr",
|
||||
".cbz",
|
||||
".djvu",
|
||||
".doc",
|
||||
".docx",
|
||||
".epub",
|
||||
".fb2",
|
||||
".flac",
|
||||
".lit",
|
||||
".m4a",
|
||||
".m4b",
|
||||
".mobi",
|
||||
".mp3",
|
||||
".ogg",
|
||||
".opus",
|
||||
".pdf",
|
||||
".rtf",
|
||||
".txt",
|
||||
".wma",
|
||||
)
|
||||
|
||||
|
||||
def _flatten_magnet_files(
|
||||
entries: list[dict[str, Any]],
|
||||
prefix: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Flatten AllDebrid's nested file tree into a list of file dicts.
|
||||
|
||||
AllDebrid returns files with ``"n"`` (name), ``"s"`` (size),
|
||||
``"l"`` (link), and ``"e"`` (children) keys. Directories use
|
||||
``"e"`` to nest their contents.
|
||||
|
||||
Returns:
|
||||
List of ``{"filename": ..., "size": ..., "link": ...}`` dicts.
|
||||
|
||||
"""
|
||||
flat: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
name = entry.get("n", "")
|
||||
if "e" in entry:
|
||||
flat.extend(
|
||||
_flatten_magnet_files(entry["e"], prefix=f"{prefix}{name}/"),
|
||||
)
|
||||
elif entry.get("l"):
|
||||
flat.append(
|
||||
{
|
||||
"filename": f"{prefix}{name}",
|
||||
"size": entry.get("s", 0),
|
||||
"link": entry["l"],
|
||||
}
|
||||
)
|
||||
return flat
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DownloadState:
|
||||
"""Internal mutable state for an in-progress AllDebrid download."""
|
||||
|
||||
magnet_id: str
|
||||
name: str
|
||||
target_dir: Path
|
||||
phase: str = "uploading"
|
||||
error_message: str | None = None
|
||||
progress: float = 0.0
|
||||
download_thread: threading.Thread | None = None
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class AllDebridClient(DownloadClient):
|
||||
"""AllDebrid debrid service client.
|
||||
|
||||
Downloads torrent content by uploading magnet links to AllDebrid,
|
||||
waiting for the torrent to complete on their servers, then fetching
|
||||
the resulting files via direct HTTP download from AllDebrid's CDN.
|
||||
|
||||
API documentation: https://docs.alldebrid.com/
|
||||
"""
|
||||
|
||||
protocol = "torrent"
|
||||
name = "alldebrid"
|
||||
|
||||
_downloads: ClassVar[dict[str, _DownloadState]] = {}
|
||||
_downloads_lock = threading.Lock()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._api_key = config_text(config.get("ALLDEBRID_API_KEY", ""))
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
"""Return Authorization header dict for API requests."""
|
||||
return {"Authorization": f"Bearer {self._api_key}"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DownloadClient interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Return True when AllDebrid is selected and an API key exists."""
|
||||
client = config_text(config.get("PROWLARR_TORRENT_CLIENT", ""))
|
||||
api_key = config_text(config.get("ALLDEBRID_API_KEY", ""))
|
||||
return client == "alldebrid" and bool(api_key)
|
||||
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Validate the API key and check Premium subscription status."""
|
||||
if not self._api_key:
|
||||
return False, "AllDebrid API Key is required"
|
||||
try:
|
||||
url = f"{_API_BASE}/user"
|
||||
resp = requests.get(
|
||||
url,
|
||||
headers=self._auth_headers(),
|
||||
timeout=_STATUS_TIMEOUT,
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status") != "success":
|
||||
err = data.get("error", {}).get("message", "API error")
|
||||
return False, f"AllDebrid error: {err}"
|
||||
user = data.get("data", {}).get("user", {})
|
||||
username = user.get("username", "Unknown")
|
||||
if not user.get("isPremium", False):
|
||||
return (
|
||||
False,
|
||||
f"AllDebrid user '{username}' does not have a Premium subscription",
|
||||
)
|
||||
except _ALLDEBRID_CLIENT_ERRORS as e:
|
||||
return False, f"Connection failed: {e}"
|
||||
else:
|
||||
return True, f"Connected to AllDebrid as '{username}' (Premium)"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""Upload a magnet link to AllDebrid and return the magnet ID."""
|
||||
if not self._api_key:
|
||||
msg = "AllDebrid API key is not configured"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
magnet_link = url
|
||||
if not magnet_link.startswith("magnet:") and expected_hash:
|
||||
magnet_link = f"magnet:?xt=urn:btih:{expected_hash}"
|
||||
|
||||
api_url = f"{_API_BASE}/magnet/upload"
|
||||
try:
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
headers=self._auth_headers(),
|
||||
data={"magnets[]": magnet_link},
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(api_url),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status") != "success":
|
||||
code = data.get("error", {}).get("code", "UNKNOWN")
|
||||
msg = f"AllDebrid upload failed: {code}"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
magnets = data.get("data", {}).get("magnets", [])
|
||||
if not magnets:
|
||||
msg = "No magnet returned from AllDebrid"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
info = magnets[0]
|
||||
if info.get("error"):
|
||||
code = info["error"].get("code", "UNKNOWN")
|
||||
msg = f"AllDebrid magnet error: {code}"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
magnet_id = str(info.get("id", ""))
|
||||
if not magnet_id:
|
||||
msg = "No magnet ID returned from AllDebrid"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
target_dir = TMP_DIR / f"alldebrid_{magnet_id}"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
state = _DownloadState(
|
||||
magnet_id=magnet_id,
|
||||
name=name,
|
||||
target_dir=target_dir,
|
||||
phase="waiting_ad",
|
||||
)
|
||||
with self._downloads_lock:
|
||||
self._downloads[magnet_id] = state
|
||||
|
||||
logger.info(
|
||||
"Added torrent to AllDebrid: ID %s (%s)",
|
||||
magnet_id,
|
||||
name,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to upload magnet to AllDebrid")
|
||||
raise
|
||||
|
||||
else:
|
||||
return magnet_id
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""Poll AllDebrid for magnet status and drive the download."""
|
||||
state = self._ensure_state(download_id)
|
||||
|
||||
# Return cached terminal / in-flight states immediately.
|
||||
with state.lock:
|
||||
if state.phase == "error":
|
||||
return DownloadStatus.error(
|
||||
state.error_message or "AllDebrid error",
|
||||
)
|
||||
if state.phase == "complete":
|
||||
return DownloadStatus(
|
||||
progress=100.0,
|
||||
state=DownloadState.COMPLETE,
|
||||
message="Complete",
|
||||
complete=True,
|
||||
file_path=str(state.target_dir),
|
||||
)
|
||||
if state.phase == "downloading_http":
|
||||
return DownloadStatus(
|
||||
progress=state.progress,
|
||||
state=DownloadState.DOWNLOADING,
|
||||
message="Downloading files via HTTP...",
|
||||
complete=False,
|
||||
file_path=None,
|
||||
)
|
||||
|
||||
# Ask AllDebrid for the current magnet status.
|
||||
try:
|
||||
status_url = f"{_API_BASE.replace('/v4', '/v4.1')}/magnet/status"
|
||||
resp = requests.post(
|
||||
status_url,
|
||||
headers=self._auth_headers(),
|
||||
data={"id": download_id},
|
||||
timeout=_STATUS_TIMEOUT,
|
||||
verify=get_ssl_verify(status_url),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status") != "success":
|
||||
err = data.get("error", {}).get("message", "Status failed")
|
||||
return DownloadStatus.error(
|
||||
f"AllDebrid status error: {err}",
|
||||
)
|
||||
|
||||
mag = self._extract_magnet_info(data)
|
||||
return self._handle_magnet_status(mag, state)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Error checking AllDebrid status for %s",
|
||||
download_id,
|
||||
)
|
||||
return DownloadStatus.error(str(e))
|
||||
|
||||
def remove(
|
||||
self,
|
||||
download_id: str,
|
||||
*,
|
||||
delete_files: bool = False,
|
||||
) -> bool:
|
||||
"""Delete the magnet from AllDebrid and clean up local files."""
|
||||
try:
|
||||
url = f"{_API_BASE}/magnet/delete"
|
||||
requests.post(
|
||||
url,
|
||||
headers=self._auth_headers(),
|
||||
data={"id": download_id},
|
||||
timeout=_STATUS_TIMEOUT,
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
except _ALLDEBRID_CLIENT_ERRORS as e:
|
||||
logger.warning("Failed to delete magnet from AllDebrid: %s", e)
|
||||
|
||||
with self._downloads_lock:
|
||||
state = self._downloads.pop(download_id, None)
|
||||
|
||||
if state and state.target_dir.exists():
|
||||
shutil.rmtree(state.target_dir, ignore_errors=True)
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Return the local directory containing downloaded files."""
|
||||
with self._downloads_lock:
|
||||
state = self._downloads.get(download_id)
|
||||
if state and state.phase == "complete":
|
||||
return str(state.target_dir)
|
||||
target_dir = TMP_DIR / f"alldebrid_{download_id}"
|
||||
if target_dir.exists():
|
||||
return str(target_dir)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_state(self, download_id: str) -> _DownloadState:
|
||||
"""Get or create download state for the given magnet ID."""
|
||||
with self._downloads_lock:
|
||||
state = self._downloads.get(download_id)
|
||||
if state:
|
||||
return state
|
||||
|
||||
target_dir = TMP_DIR / f"alldebrid_{download_id}"
|
||||
state = _DownloadState(
|
||||
magnet_id=download_id,
|
||||
name=f"Download {download_id}",
|
||||
target_dir=target_dir,
|
||||
phase="waiting_ad",
|
||||
)
|
||||
with self._downloads_lock:
|
||||
self._downloads[download_id] = state
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def _extract_magnet_info(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Extract magnet info dict from a status API response."""
|
||||
mag_data = data.get("data", {}).get("magnets", {})
|
||||
if isinstance(mag_data, list) and mag_data:
|
||||
return mag_data[0]
|
||||
if isinstance(mag_data, dict):
|
||||
return mag_data
|
||||
return {}
|
||||
|
||||
def _handle_magnet_status(
|
||||
self,
|
||||
mag: dict[str, Any],
|
||||
state: _DownloadState,
|
||||
) -> DownloadStatus:
|
||||
"""Map AllDebrid magnet status to a DownloadStatus."""
|
||||
status_code = mag.get("statusCode")
|
||||
|
||||
if status_code in _STATUS_DOWNLOADING:
|
||||
size = mag.get("size", 0)
|
||||
downloaded = mag.get("downloaded", 0)
|
||||
pct = (downloaded / size * 100.0) if size > 0 else 0.0
|
||||
return DownloadStatus(
|
||||
progress=pct * 0.5,
|
||||
state=DownloadState.DOWNLOADING,
|
||||
message=(f"AllDebrid downloading torrent ({mag.get('filename', state.name)})"),
|
||||
complete=False,
|
||||
file_path=None,
|
||||
download_speed=mag.get("downloadSpeed", 0),
|
||||
)
|
||||
|
||||
if status_code == _STATUS_READY or mag.get("ready", False):
|
||||
self._maybe_start_download_thread(state)
|
||||
return DownloadStatus(
|
||||
progress=50.0,
|
||||
state=DownloadState.DOWNLOADING,
|
||||
message="AllDebrid ready, retrieving files...",
|
||||
complete=False,
|
||||
file_path=None,
|
||||
)
|
||||
|
||||
# Terminal error from AllDebrid.
|
||||
error_txt = mag.get("error", {}).get("message") or f"AllDebrid status code {status_code}"
|
||||
with state.lock:
|
||||
state.phase = "error"
|
||||
state.error_message = error_txt
|
||||
return DownloadStatus.error(error_txt)
|
||||
|
||||
def _maybe_start_download_thread(self, state: _DownloadState) -> None:
|
||||
"""Spawn a background thread to unlock and download files."""
|
||||
with state.lock:
|
||||
already_running = state.phase in (
|
||||
"unlocking",
|
||||
"downloading_http",
|
||||
"complete",
|
||||
)
|
||||
thread_alive = state.download_thread is not None and state.download_thread.is_alive()
|
||||
if already_running or thread_alive:
|
||||
return
|
||||
state.phase = "unlocking"
|
||||
t = threading.Thread(
|
||||
target=self._process_and_download,
|
||||
args=(state,),
|
||||
daemon=True,
|
||||
)
|
||||
state.download_thread = t
|
||||
t.start()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Link unlocking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _unlock_file_link(self, link: str) -> str:
|
||||
"""Resolve an AllDebrid file link to a direct CDN download URL.
|
||||
|
||||
AllDebrid's ``/v4/magnet/files`` endpoint returns virtual links
|
||||
(``alldebrid.com/f/...``) that must be converted to direct CDN
|
||||
URLs via ``/v4/link/unlock``.
|
||||
|
||||
Strategy:
|
||||
1. If the link is already a CDN URL (``/dl/``), return it.
|
||||
2. ``POST /v4/link/unlock`` with Bearer auth (primary).
|
||||
3. ``GET /v4/link/unlock`` with query parameters (fallback).
|
||||
4. Append ``apikey=`` to ``alldebrid.com/f/`` links
|
||||
(last-resort fallback for ghost-cached torrents).
|
||||
|
||||
"""
|
||||
# 1. Already a direct CDN link.
|
||||
if "/dl/" in link:
|
||||
return link
|
||||
|
||||
headers = self._auth_headers()
|
||||
unlock_url = f"{_API_BASE}/link/unlock"
|
||||
err_msg = "Unknown unlock error"
|
||||
|
||||
# 2. POST unlock (primary method).
|
||||
try:
|
||||
resp = requests.post(
|
||||
unlock_url,
|
||||
headers=headers,
|
||||
data={"link": link},
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(unlock_url),
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
body = resp.json()
|
||||
if body.get("status") == "success":
|
||||
direct = self._resolve_unlock_data(
|
||||
body.get("data", {}),
|
||||
headers,
|
||||
)
|
||||
if direct:
|
||||
return direct
|
||||
err_msg = body.get("error", {}).get(
|
||||
"message",
|
||||
"Unlock failed",
|
||||
)
|
||||
except _ALLDEBRID_CLIENT_ERRORS as e:
|
||||
logger.debug("POST unlock exception: %s", e)
|
||||
|
||||
# 3. GET unlock fallback with URL-encoded link.
|
||||
try:
|
||||
encoded = quote(link, safe="")
|
||||
get_url = (
|
||||
f"{_API_BASE}/link/unlock?agent={_AGENT}&apikey={self._api_key}&link={encoded}"
|
||||
)
|
||||
resp = requests.get(
|
||||
get_url,
|
||||
headers=headers,
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(get_url),
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
body = resp.json()
|
||||
if body.get("status") == "success":
|
||||
direct = body.get("data", {}).get("link")
|
||||
if direct:
|
||||
return direct
|
||||
err_msg = body.get("error", {}).get("message", err_msg)
|
||||
except _ALLDEBRID_CLIENT_ERRORS as e:
|
||||
logger.debug("GET unlock exception: %s", e)
|
||||
|
||||
# 4. Last-resort: append apikey to alldebrid.com/f/ links.
|
||||
if "alldebrid.com/f/" in link:
|
||||
logger.info(
|
||||
"Using apikey fallback for AllDebrid file link: %s",
|
||||
link,
|
||||
)
|
||||
if "apikey=" not in link:
|
||||
sep = "&" if "?" in link else "?"
|
||||
return f"{link}{sep}apikey={self._api_key}"
|
||||
return link
|
||||
|
||||
logger.error(
|
||||
"AllDebrid unlock failed for '%s': %s",
|
||||
link,
|
||||
err_msg,
|
||||
)
|
||||
msg = f"AllDebrid unlock failed: {err_msg}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def _resolve_unlock_data(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
headers: dict[str, str],
|
||||
) -> str | None:
|
||||
"""Extract the direct link from unlock response data.
|
||||
|
||||
Handles the *delayed link* flow where AllDebrid returns a
|
||||
``delayed`` ID instead of an immediate download link.
|
||||
"""
|
||||
# Delayed link: poll until the CDN file is ready.
|
||||
if "delayed" in data:
|
||||
delayed_id = data["delayed"]
|
||||
logger.info(
|
||||
"AllDebrid link delayed (ID %s), polling...",
|
||||
delayed_id,
|
||||
)
|
||||
delayed_url = f"{_API_BASE}/link/delayed"
|
||||
for _ in range(_DELAYED_POLL_MAX_ATTEMPTS):
|
||||
time.sleep(_DELAYED_POLL_INTERVAL)
|
||||
try:
|
||||
resp = requests.post(
|
||||
delayed_url,
|
||||
headers=headers,
|
||||
data={"id": delayed_id},
|
||||
timeout=_STATUS_TIMEOUT,
|
||||
verify=get_ssl_verify(delayed_url),
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
body = resp.json()
|
||||
d = body.get("data", {})
|
||||
if body.get("status") == "success" and d.get("status") == 2 and d.get("link"):
|
||||
return d["link"]
|
||||
except _ALLDEBRID_CLIENT_ERRORS as e:
|
||||
logger.debug("Delayed poll exception: %s", e)
|
||||
|
||||
return data.get("link")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File download pipeline
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _process_and_download(self, state: _DownloadState) -> None:
|
||||
"""Fetch the file list, unlock links, and download via HTTP.
|
||||
|
||||
Runs in a background thread spawned by ``_maybe_start_download_thread``.
|
||||
"""
|
||||
try:
|
||||
files = self._fetch_file_list(state.magnet_id)
|
||||
relevant = [f for f in files if f["filename"].lower().endswith(_BOOK_EXTENSIONS)]
|
||||
if not relevant:
|
||||
relevant = files
|
||||
|
||||
with state.lock:
|
||||
state.phase = "downloading_http"
|
||||
|
||||
total = len(relevant)
|
||||
for idx, file_info in enumerate(relevant):
|
||||
direct_link = self._unlock_file_link(file_info["link"])
|
||||
|
||||
rel_path = Path(file_info["filename"])
|
||||
dest = state.target_dir / rel_path
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(
|
||||
"Downloading AllDebrid file %d/%d: %s",
|
||||
idx + 1,
|
||||
total,
|
||||
rel_path,
|
||||
)
|
||||
|
||||
buf = download_url(
|
||||
direct_link,
|
||||
referer="https://alldebrid.com/",
|
||||
)
|
||||
if not buf:
|
||||
msg = f"Failed to download from {direct_link}"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
with dest.open("wb") as fh:
|
||||
fh.write(buf.getvalue())
|
||||
|
||||
with state.lock:
|
||||
state.progress = 50.0 + (idx + 1) / total * 50.0
|
||||
|
||||
with state.lock:
|
||||
state.phase = "complete"
|
||||
state.progress = 100.0
|
||||
|
||||
logger.info(
|
||||
"AllDebrid download complete for ID %s at %s",
|
||||
state.magnet_id,
|
||||
state.target_dir,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error in AllDebrid download for ID %s",
|
||||
state.magnet_id,
|
||||
)
|
||||
with state.lock:
|
||||
state.phase = "error"
|
||||
state.error_message = str(
|
||||
state.error_message or "Download failed",
|
||||
)
|
||||
|
||||
def _fetch_file_list(
|
||||
self,
|
||||
magnet_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Retrieve and flatten the file tree for a magnet."""
|
||||
url = f"{_API_BASE}/magnet/files"
|
||||
resp = requests.post(
|
||||
url,
|
||||
headers=self._auth_headers(),
|
||||
data={"id[]": magnet_id},
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status") != "success":
|
||||
msg = f"Failed to list magnet files: {data.get('error')}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
magnets = data.get("data", {}).get("magnets", [])
|
||||
if not magnets:
|
||||
msg = "No magnet files returned"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
files = _flatten_magnet_files(magnets[0].get("files", []))
|
||||
if not files:
|
||||
msg = "No files found in torrent"
|
||||
raise RuntimeError(msg)
|
||||
return files
|
||||
@@ -220,6 +220,17 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
configured = config.get(COMPLETED_PATH_TIMEOUT_SETTING, fallback)
|
||||
return _coerce_completed_path_timeout_seconds(configured, fallback)
|
||||
|
||||
def _refresh_download_request_after_add_failure(
|
||||
self,
|
||||
*,
|
||||
task: DownloadTask,
|
||||
request: DownloadRequest,
|
||||
error: Exception,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> DownloadRequest | None:
|
||||
"""Give source handlers one chance to refresh stale resolved download data."""
|
||||
return None
|
||||
|
||||
def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> str | None:
|
||||
"""Get audiobook category if configured and applicable, else None for default."""
|
||||
if not is_audiobook(task.content_type):
|
||||
@@ -273,17 +284,47 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
)
|
||||
|
||||
elif protocol == "torrent":
|
||||
if config.get("PROWLARR_TORRENT_ACTION", "keep") != "remove":
|
||||
torrent_action = config.get("PROWLARR_TORRENT_ACTION", "keep")
|
||||
if torrent_action == "remove":
|
||||
try:
|
||||
client.remove(download_id, delete_files=False)
|
||||
except _CLIENT_CLEANUP_ERRORS as e:
|
||||
logger.warning(
|
||||
"Failed to remove torrent %s from %s: %s",
|
||||
download_id,
|
||||
getattr(client, "name", "client"),
|
||||
e,
|
||||
)
|
||||
return
|
||||
|
||||
if torrent_action != "change_category":
|
||||
return
|
||||
|
||||
post_import_category = normalize_optional_text(
|
||||
config.get("PROWLARR_TORRENT_POST_IMPORT_CATEGORY", "")
|
||||
)
|
||||
if post_import_category is None:
|
||||
return
|
||||
|
||||
try:
|
||||
client.remove(download_id, delete_files=False)
|
||||
category_updated = client.set_category(download_id, post_import_category)
|
||||
except _CLIENT_CLEANUP_ERRORS as e:
|
||||
logger.warning(
|
||||
"Failed to remove torrent %s from %s: %s",
|
||||
"Failed to set post-import category for torrent %s in %s: %s",
|
||||
download_id,
|
||||
getattr(client, "name", "client"),
|
||||
e,
|
||||
)
|
||||
return
|
||||
|
||||
if not category_updated:
|
||||
# Clients that cannot label torrents (debrid services) return False here,
|
||||
# and the ones that can already log the specific failure themselves.
|
||||
logger.debug(
|
||||
"Post-import category not applied to torrent %s in %s",
|
||||
download_id,
|
||||
getattr(client, "name", "client"),
|
||||
)
|
||||
|
||||
def _remove_usenet_download(
|
||||
self,
|
||||
@@ -791,20 +832,38 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
status_callback("downloading", "Resuming existing download")
|
||||
else:
|
||||
# No existing download - add new
|
||||
status_callback("resolving", f"Sending to {client.name}")
|
||||
try:
|
||||
download_id = client.add_download(
|
||||
url=request.url,
|
||||
name=request.release_name,
|
||||
category=category,
|
||||
expected_hash=request.expected_hash,
|
||||
seeding_time_limit=request.seeding_time_limit,
|
||||
ratio_limit=request.ratio_limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to add to %s", client.name)
|
||||
status_callback("error", f"Failed to add to {client.name}: {e}")
|
||||
return None
|
||||
refresh_attempted = False
|
||||
while True:
|
||||
status_callback("resolving", f"Sending to {client.name}")
|
||||
try:
|
||||
download_id = client.add_download(
|
||||
url=request.url,
|
||||
name=request.release_name,
|
||||
category=category,
|
||||
expected_hash=request.expected_hash,
|
||||
seeding_time_limit=request.seeding_time_limit,
|
||||
ratio_limit=request.ratio_limit,
|
||||
)
|
||||
except Exception as e:
|
||||
if not refresh_attempted:
|
||||
refresh_attempted = True
|
||||
refreshed_request = self._refresh_download_request_after_add_failure(
|
||||
task=task,
|
||||
request=request,
|
||||
error=e,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
if (
|
||||
refreshed_request is not None
|
||||
and refreshed_request.protocol == request.protocol
|
||||
):
|
||||
request = refreshed_request
|
||||
continue
|
||||
|
||||
logger.exception("Failed to add to %s", client.name)
|
||||
status_callback("error", f"Failed to add to {client.name}: {e}")
|
||||
return None
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"Added to %s: %s for '%s'", client.name, download_id, request.release_name
|
||||
|
||||
@@ -227,10 +227,10 @@ class DelugeClient(DownloadClient):
|
||||
|
||||
return self._rpc_call("daemon.info")
|
||||
|
||||
def _try_set_label(self, torrent_id: str, label: str) -> None:
|
||||
def _try_set_label(self, torrent_id: str, label: str) -> bool:
|
||||
"""Best-effort label assignment (requires Deluge Label plugin)."""
|
||||
if not label:
|
||||
return
|
||||
return False
|
||||
|
||||
try:
|
||||
# label.add will error if the plugin is unavailable or the label exists.
|
||||
@@ -240,6 +240,9 @@ class DelugeClient(DownloadClient):
|
||||
self._rpc_call("label.set_torrent", torrent_id, label)
|
||||
except _DELUGE_CLIENT_ERRORS as e:
|
||||
logger.debug("Could not set Deluge label '%s' for %s: %s", label, torrent_id, e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
@@ -277,7 +280,10 @@ class DelugeClient(DownloadClient):
|
||||
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
if not torrent_info.is_magnet and not torrent_info.torrent_data:
|
||||
_raise_runtime_error("Failed to fetch torrent file")
|
||||
message = "Failed to fetch torrent file"
|
||||
if torrent_info.fetch_error:
|
||||
message = f"{message}: {torrent_info.fetch_error}"
|
||||
_raise_runtime_error(message)
|
||||
|
||||
options: dict[str, Any] = {}
|
||||
if self._download_dir:
|
||||
@@ -419,6 +425,15 @@ class DelugeClient(DownloadClient):
|
||||
else:
|
||||
return False
|
||||
|
||||
def set_category(self, download_id: str, category: str) -> bool:
|
||||
"""Assign a label to a torrent using Deluge's Label plugin."""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
return self._try_set_label(download_id, category)
|
||||
except _DELUGE_CLIENT_ERRORS as e:
|
||||
self._log_error("set_category", e)
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Return the resolved download path for a Deluge torrent."""
|
||||
try:
|
||||
|
||||
@@ -182,15 +182,15 @@ class QBittorrentClient(DownloadClient):
|
||||
params = {"hash": torrent_hash}
|
||||
|
||||
try:
|
||||
self._client.auth_log_in()
|
||||
self._ensure_authenticated()
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
# Re-authenticate and retry once on 403
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
|
||||
logger.debug(
|
||||
"qBittorrent returned 403 for properties; re-authenticating and retrying"
|
||||
)
|
||||
self._client.auth_log_in()
|
||||
self._ensure_authenticated()
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
@@ -244,6 +244,7 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
username = config_text(config.get("QBITTORRENT_USERNAME", ""))
|
||||
password = config_text(config.get("QBITTORRENT_PASSWORD", ""))
|
||||
self._api_key = config_text(config.get("QBITTORRENT_API_KEY", ""))
|
||||
|
||||
# qbittorrent-api accepts either a full URL or host:port; prefer the normalized URL
|
||||
# for consistency.
|
||||
@@ -251,12 +252,28 @@ class QBittorrentClient(DownloadClient):
|
||||
host=self._base_url,
|
||||
username=username,
|
||||
password=password,
|
||||
api_key=self._api_key or None,
|
||||
VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(self._base_url),
|
||||
)
|
||||
self._category = config_text(config.get("QBITTORRENT_CATEGORY", "books"))
|
||||
self._download_dir = config_text(config.get("QBITTORRENT_DOWNLOAD_DIR", ""))
|
||||
self._tags = _normalize_tags(config.get("QBITTORRENT_TAG", []))
|
||||
|
||||
@property
|
||||
def _can_reauthenticate(self) -> bool:
|
||||
"""Whether a 403 is worth retrying; a bearer token cannot be refreshed like a session."""
|
||||
return not self._api_key
|
||||
|
||||
def _ensure_authenticated(self) -> None:
|
||||
"""Authenticate the underlying HTTP session before it is used directly.
|
||||
|
||||
API keys (qBittorrent 5.2.0+) are sent as a bearer header on every request and
|
||||
have no login endpoint, so there is no session to establish up front.
|
||||
"""
|
||||
if self._api_key:
|
||||
return
|
||||
self._client.auth_log_in()
|
||||
|
||||
def _get_torrents_info(
|
||||
self, torrent_hash: str | None = None, category: str | None = None
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
@@ -276,8 +293,7 @@ class QBittorrentClient(DownloadClient):
|
||||
url = f"{self._base_url}/api/v2/torrents/info"
|
||||
|
||||
def do_request(params: dict[str, str]) -> requests.Response:
|
||||
# Ensure session is authenticated before using it directly
|
||||
self._client.auth_log_in()
|
||||
self._ensure_authenticated()
|
||||
return self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
def parse_response(
|
||||
@@ -285,9 +301,9 @@ class QBittorrentClient(DownloadClient):
|
||||
*,
|
||||
request_params: dict[str, str],
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
|
||||
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
|
||||
self._client.auth_log_in()
|
||||
self._ensure_authenticated()
|
||||
response = self._client._session.get(url, params=request_params, timeout=10)
|
||||
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
@@ -409,7 +425,7 @@ class QBittorrentClient(DownloadClient):
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Test connection to qBittorrent."""
|
||||
try:
|
||||
self._client.auth_log_in()
|
||||
self._ensure_authenticated()
|
||||
api_version = self._client.app.web_api_version
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
return False, f"Connection failed: {e!s}"
|
||||
@@ -513,7 +529,10 @@ class QBittorrentClient(DownloadClient):
|
||||
# watching for the new torrent to appear.
|
||||
expected_hash = self._discover_added_torrent_hash(name, category, known_hashes)
|
||||
if not expected_hash:
|
||||
_raise_runtime_error("Could not determine torrent hash from URL")
|
||||
message = "Could not determine torrent hash from URL"
|
||||
if torrent_info.fetch_error:
|
||||
message = f"{message} (torrent file fetch failed: {torrent_info.fetch_error})"
|
||||
_raise_runtime_error(message)
|
||||
|
||||
# Some qBittorrent-compatible clients return HTTP 200 with an empty body
|
||||
# instead of qBittorrent's literal "Ok." response. Prefer verifying that
|
||||
@@ -654,6 +673,26 @@ class QBittorrentClient(DownloadClient):
|
||||
else:
|
||||
return True
|
||||
|
||||
def set_category(self, download_id: str, category: str) -> bool:
|
||||
"""Assign a category to a torrent in qBittorrent."""
|
||||
try:
|
||||
try:
|
||||
self._client.torrents_create_category(name=category)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
if "Conflict" not in type(e).__name__ and "409" not in str(e):
|
||||
logger.debug("Could not create category '%s': %s", category, e)
|
||||
|
||||
self._client.torrents_set_category(
|
||||
torrent_hashes=download_id,
|
||||
category=category,
|
||||
)
|
||||
logger.info("Set qBittorrent category for %s to '%s'", download_id, category)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
self._log_error("set_category", e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where torrent files are located.
|
||||
|
||||
@@ -725,11 +764,11 @@ class QBittorrentClient(DownloadClient):
|
||||
import os
|
||||
|
||||
def get_with_auth(url: str, params: dict[str, str]) -> requests.Response:
|
||||
self._client.auth_log_in()
|
||||
self._ensure_authenticated()
|
||||
resp = self._client._session.get(url, params=params, timeout=10)
|
||||
if resp.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
if resp.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
|
||||
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
|
||||
self._client.auth_log_in()
|
||||
self._ensure_authenticated()
|
||||
resp = self._client._session.get(url, params=params, timeout=10)
|
||||
return resp
|
||||
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Real-Debrid debrid service client for Shelfmark.
|
||||
|
||||
Routes magnet links through the Real-Debrid REST API (v1.0) to download
|
||||
torrent content via Real-Debrid's CDN infrastructure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, NoReturn
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.config.env import TMP_DIR
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadState,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.download.clients._coercion import config_text
|
||||
from shelfmark.download.http import download_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_API_BASE = "https://api.real-debrid.com/rest/1.0"
|
||||
|
||||
_REALDEBRID_CLIENT_ERRORS = (
|
||||
AttributeError,
|
||||
OSError,
|
||||
requests.exceptions.RequestException,
|
||||
RuntimeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
)
|
||||
|
||||
# Real-Debrid torrent status values.
|
||||
_STATUS_DOWNLOADING = frozenset(
|
||||
{
|
||||
"magnet_conversion",
|
||||
"waiting_files_selection",
|
||||
"downloading",
|
||||
"compressing",
|
||||
"uploading",
|
||||
}
|
||||
)
|
||||
_STATUS_READY = "downloaded"
|
||||
_STATUS_ERROR = frozenset({"error", "virus", "dead"})
|
||||
|
||||
# Timeouts for API calls.
|
||||
_API_TIMEOUT = 30
|
||||
_STATUS_TIMEOUT = 15
|
||||
|
||||
# File extensions recognised as book or audiobook content.
|
||||
_BOOK_EXTENSIONS = (
|
||||
".aac",
|
||||
".azw",
|
||||
".azw3",
|
||||
".cbr",
|
||||
".cbz",
|
||||
".djvu",
|
||||
".doc",
|
||||
".docx",
|
||||
".epub",
|
||||
".fb2",
|
||||
".flac",
|
||||
".lit",
|
||||
".m4a",
|
||||
".m4b",
|
||||
".mobi",
|
||||
".mp3",
|
||||
".ogg",
|
||||
".opus",
|
||||
".pdf",
|
||||
".rtf",
|
||||
".txt",
|
||||
".wma",
|
||||
)
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DownloadState:
|
||||
"""Internal mutable state for an in-progress Real-Debrid download."""
|
||||
|
||||
torrent_id: str
|
||||
name: str
|
||||
target_dir: Path
|
||||
phase: str = "uploading"
|
||||
error_message: str | None = None
|
||||
progress: float = 0.0
|
||||
download_thread: threading.Thread | None = None
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class RealDebridClient(DownloadClient):
|
||||
"""Real-Debrid debrid service client.
|
||||
|
||||
Downloads torrent content by uploading magnet links to Real-Debrid,
|
||||
selecting all files for download on their servers, then unrestricting
|
||||
and fetching the resulting files via direct HTTP download from
|
||||
Real-Debrid's CDN.
|
||||
|
||||
API documentation: https://api.real-debrid.com/
|
||||
"""
|
||||
|
||||
protocol = "torrent"
|
||||
name = "realdebrid"
|
||||
|
||||
_downloads: ClassVar[dict[str, _DownloadState]] = {}
|
||||
_downloads_lock = threading.Lock()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._api_key = config_text(config.get("REALDEBRID_API_KEY", ""))
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
"""Return Authorization header dict for API requests."""
|
||||
return {"Authorization": f"Bearer {self._api_key}"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DownloadClient interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Return True when Real-Debrid is selected and an API key exists."""
|
||||
client = config_text(config.get("PROWLARR_TORRENT_CLIENT", ""))
|
||||
api_key = config_text(config.get("REALDEBRID_API_KEY", ""))
|
||||
return client == "realdebrid" and bool(api_key)
|
||||
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Validate the API key and check Premium subscription status."""
|
||||
if not self._api_key:
|
||||
return False, "Real-Debrid API Key is required"
|
||||
try:
|
||||
url = f"{_API_BASE}/user"
|
||||
resp = requests.get(
|
||||
url,
|
||||
headers=self._auth_headers(),
|
||||
timeout=_STATUS_TIMEOUT,
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
user = resp.json()
|
||||
username = user.get("username", "Unknown")
|
||||
account_type = user.get("type", "free")
|
||||
if account_type != "premium":
|
||||
return (
|
||||
False,
|
||||
f"Real-Debrid user '{username}' does not have "
|
||||
f"a Premium subscription (type: {account_type})",
|
||||
)
|
||||
except _REALDEBRID_CLIENT_ERRORS as e:
|
||||
return False, f"Connection failed: {e}"
|
||||
else:
|
||||
return True, f"Connected to Real-Debrid as '{username}' (Premium)"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""Upload a magnet link to Real-Debrid and select all files."""
|
||||
if not self._api_key:
|
||||
msg = "Real-Debrid API key is not configured"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
magnet_link = url
|
||||
if not magnet_link.startswith("magnet:") and expected_hash:
|
||||
magnet_link = f"magnet:?xt=urn:btih:{expected_hash}"
|
||||
|
||||
add_url = f"{_API_BASE}/torrents/addMagnet"
|
||||
try:
|
||||
resp = requests.post(
|
||||
add_url,
|
||||
headers=self._auth_headers(),
|
||||
data={"magnet": magnet_link},
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(add_url),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
torrent_id = str(data.get("id", ""))
|
||||
if not torrent_id:
|
||||
msg = "No torrent ID returned from Real-Debrid"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
# Select all files so Real-Debrid starts downloading the torrent
|
||||
select_url = f"{_API_BASE}/torrents/selectFiles/{torrent_id}"
|
||||
sel_resp = requests.post(
|
||||
select_url,
|
||||
headers=self._auth_headers(),
|
||||
data={"files": "all"},
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(select_url),
|
||||
)
|
||||
sel_resp.raise_for_status()
|
||||
|
||||
target_dir = TMP_DIR / f"realdebrid_{torrent_id}"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
state = _DownloadState(
|
||||
torrent_id=torrent_id,
|
||||
name=name,
|
||||
target_dir=target_dir,
|
||||
phase="waiting_rd",
|
||||
)
|
||||
with self._downloads_lock:
|
||||
self._downloads[torrent_id] = state
|
||||
|
||||
logger.info(
|
||||
"Added torrent to Real-Debrid: ID %s (%s)",
|
||||
torrent_id,
|
||||
name,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to upload magnet to Real-Debrid")
|
||||
raise
|
||||
|
||||
else:
|
||||
return torrent_id
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""Poll Real-Debrid for torrent status and drive the download."""
|
||||
state = self._ensure_state(download_id)
|
||||
|
||||
# Return cached terminal / in-flight states immediately.
|
||||
with state.lock:
|
||||
if state.phase == "error":
|
||||
return DownloadStatus.error(
|
||||
state.error_message or "Real-Debrid error",
|
||||
)
|
||||
if state.phase == "complete":
|
||||
return DownloadStatus(
|
||||
progress=100.0,
|
||||
state=DownloadState.COMPLETE,
|
||||
message="Complete",
|
||||
complete=True,
|
||||
file_path=str(state.target_dir),
|
||||
)
|
||||
if state.phase == "downloading_http":
|
||||
return DownloadStatus(
|
||||
progress=state.progress,
|
||||
state=DownloadState.DOWNLOADING,
|
||||
message="Downloading files via HTTP...",
|
||||
complete=False,
|
||||
file_path=None,
|
||||
)
|
||||
|
||||
# Query Real-Debrid for torrent info.
|
||||
try:
|
||||
info_url = f"{_API_BASE}/torrents/info/{download_id}"
|
||||
resp = requests.get(
|
||||
info_url,
|
||||
headers=self._auth_headers(),
|
||||
timeout=_STATUS_TIMEOUT,
|
||||
verify=get_ssl_verify(info_url),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
info = resp.json()
|
||||
return self._handle_torrent_info(info, state)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Error checking Real-Debrid status for %s",
|
||||
download_id,
|
||||
)
|
||||
return DownloadStatus.error(str(e))
|
||||
|
||||
def remove(
|
||||
self,
|
||||
download_id: str,
|
||||
*,
|
||||
delete_files: bool = False,
|
||||
) -> bool:
|
||||
"""Delete the torrent from Real-Debrid and clean up local files."""
|
||||
try:
|
||||
url = f"{_API_BASE}/torrents/delete/{download_id}"
|
||||
requests.delete(
|
||||
url,
|
||||
headers=self._auth_headers(),
|
||||
timeout=_STATUS_TIMEOUT,
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
except _REALDEBRID_CLIENT_ERRORS as e:
|
||||
logger.warning("Failed to delete torrent from Real-Debrid: %s", e)
|
||||
|
||||
with self._downloads_lock:
|
||||
state = self._downloads.pop(download_id, None)
|
||||
|
||||
if state and state.target_dir.exists():
|
||||
shutil.rmtree(state.target_dir, ignore_errors=True)
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Return the local directory containing downloaded files."""
|
||||
with self._downloads_lock:
|
||||
state = self._downloads.get(download_id)
|
||||
if state and state.phase == "complete":
|
||||
return str(state.target_dir)
|
||||
target_dir = TMP_DIR / f"realdebrid_{download_id}"
|
||||
if target_dir.exists():
|
||||
return str(target_dir)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_state(self, download_id: str) -> _DownloadState:
|
||||
"""Get or create download state for the given torrent ID."""
|
||||
with self._downloads_lock:
|
||||
state = self._downloads.get(download_id)
|
||||
if state:
|
||||
return state
|
||||
|
||||
target_dir = TMP_DIR / f"realdebrid_{download_id}"
|
||||
state = _DownloadState(
|
||||
torrent_id=download_id,
|
||||
name=f"Download {download_id}",
|
||||
target_dir=target_dir,
|
||||
phase="waiting_rd",
|
||||
)
|
||||
with self._downloads_lock:
|
||||
self._downloads[download_id] = state
|
||||
return state
|
||||
|
||||
def _handle_torrent_info(
|
||||
self,
|
||||
info: dict[str, Any],
|
||||
state: _DownloadState,
|
||||
) -> DownloadStatus:
|
||||
"""Map Real-Debrid torrent info to a DownloadStatus."""
|
||||
status = info.get("status", "")
|
||||
|
||||
if status in _STATUS_DOWNLOADING:
|
||||
progress = float(info.get("progress", 0.0))
|
||||
speed = int(info.get("speed", 0))
|
||||
filename = info.get("filename", state.name)
|
||||
return DownloadStatus(
|
||||
progress=progress * 0.5,
|
||||
state=DownloadState.DOWNLOADING,
|
||||
message=f"Real-Debrid downloading torrent ({filename})",
|
||||
complete=False,
|
||||
file_path=None,
|
||||
download_speed=speed,
|
||||
)
|
||||
|
||||
if status == _STATUS_READY:
|
||||
links = info.get("links", [])
|
||||
files = info.get("files", [])
|
||||
self._maybe_start_download_thread(state, links, files)
|
||||
return DownloadStatus(
|
||||
progress=50.0,
|
||||
state=DownloadState.DOWNLOADING,
|
||||
message="Real-Debrid ready, retrieving files...",
|
||||
complete=False,
|
||||
file_path=None,
|
||||
)
|
||||
|
||||
# Terminal error from Real-Debrid.
|
||||
error_txt = f"Real-Debrid status error: {status}"
|
||||
with state.lock:
|
||||
state.phase = "error"
|
||||
state.error_message = error_txt
|
||||
return DownloadStatus.error(error_txt)
|
||||
|
||||
def _maybe_start_download_thread(
|
||||
self,
|
||||
state: _DownloadState,
|
||||
links: list[str],
|
||||
files: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Spawn a background thread to unrestrict and download files."""
|
||||
with state.lock:
|
||||
already_running = state.phase in (
|
||||
"unrestricting",
|
||||
"downloading_http",
|
||||
"complete",
|
||||
)
|
||||
thread_alive = state.download_thread is not None and state.download_thread.is_alive()
|
||||
if already_running or thread_alive:
|
||||
return
|
||||
state.phase = "unrestricting"
|
||||
t = threading.Thread(
|
||||
target=self._process_and_download,
|
||||
args=(state, links, files),
|
||||
daemon=True,
|
||||
)
|
||||
state.download_thread = t
|
||||
t.start()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File download pipeline
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _process_and_download(
|
||||
self,
|
||||
state: _DownloadState,
|
||||
links: list[str],
|
||||
files: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Unrestrict links and download files via HTTP.
|
||||
|
||||
Runs in a background thread spawned by ``_maybe_start_download_thread``.
|
||||
"""
|
||||
try:
|
||||
if not links:
|
||||
msg = "No download links returned by Real-Debrid"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
# Match selected files with links
|
||||
selected_files = [f for f in files if f.get("selected") == 1]
|
||||
|
||||
# Filter relevant ebook / audiobook files
|
||||
relevant_indices: list[int] = []
|
||||
for i, f_info in enumerate(selected_files):
|
||||
path_str = f_info.get("path", "").lower()
|
||||
if path_str.endswith(_BOOK_EXTENSIONS):
|
||||
relevant_indices.append(i)
|
||||
|
||||
if not relevant_indices:
|
||||
relevant_indices = list(range(len(links)))
|
||||
|
||||
with state.lock:
|
||||
state.phase = "downloading_http"
|
||||
|
||||
total = len(relevant_indices)
|
||||
for idx, rel_idx in enumerate(relevant_indices):
|
||||
if rel_idx >= len(links):
|
||||
continue
|
||||
link = links[rel_idx]
|
||||
|
||||
# Unrestrict the Real-Debrid link to get direct CDN download URL
|
||||
unrestrict_url = f"{_API_BASE}/unrestrict/link"
|
||||
unl_resp = requests.post(
|
||||
unrestrict_url,
|
||||
headers=self._auth_headers(),
|
||||
data={"link": link},
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(unrestrict_url),
|
||||
)
|
||||
unl_resp.raise_for_status()
|
||||
unl_data = unl_resp.json()
|
||||
|
||||
direct_url = unl_data.get("download")
|
||||
filename = unl_data.get("filename")
|
||||
if not direct_url:
|
||||
msg = f"Failed to unrestrict Real-Debrid link: {link}"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
# Determine relative file path
|
||||
if rel_idx < len(selected_files):
|
||||
rel_path_str = selected_files[rel_idx].get("path", "").lstrip("/")
|
||||
rel_path = Path(rel_path_str)
|
||||
else:
|
||||
rel_path = Path(filename or f"file_{idx + 1}")
|
||||
|
||||
dest = state.target_dir / rel_path
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(
|
||||
"Downloading Real-Debrid file %d/%d: %s",
|
||||
idx + 1,
|
||||
total,
|
||||
rel_path,
|
||||
)
|
||||
|
||||
buf = download_url(
|
||||
direct_url,
|
||||
referer="https://real-debrid.com/",
|
||||
)
|
||||
if not buf:
|
||||
msg = f"Failed to download from {direct_url}"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
with dest.open("wb") as fh:
|
||||
fh.write(buf.getvalue())
|
||||
|
||||
with state.lock:
|
||||
state.progress = 50.0 + (idx + 1) / total * 50.0
|
||||
|
||||
with state.lock:
|
||||
state.phase = "complete"
|
||||
state.progress = 100.0
|
||||
|
||||
logger.info(
|
||||
"Real-Debrid download complete for ID %s at %s",
|
||||
state.torrent_id,
|
||||
state.target_dir,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error in Real-Debrid download for ID %s",
|
||||
state.torrent_id,
|
||||
)
|
||||
with state.lock:
|
||||
state.phase = "error"
|
||||
state.error_message = str(
|
||||
state.error_message or "Download failed",
|
||||
)
|
||||
@@ -47,7 +47,13 @@ class _RTorrentLoadProtocol(Protocol):
|
||||
def start(self, target: str, url: str, commands: str) -> object: ...
|
||||
|
||||
|
||||
class _RTorrentCustom1Protocol(Protocol):
|
||||
def set(self, download_id: str, value: str) -> object: ...
|
||||
|
||||
|
||||
class _RTorrentDownloadProtocol(Protocol):
|
||||
custom1: _RTorrentCustom1Protocol
|
||||
|
||||
def multicall2(self, *args: object) -> list[list[Any]]: ...
|
||||
|
||||
def delete_tied(self, download_id: str) -> object: ...
|
||||
@@ -206,7 +212,10 @@ class RTorrentClient(DownloadClient):
|
||||
# watching for the new download to appear.
|
||||
torrent_hash = self._discover_added_torrent_hash(name, label, known_hashes)
|
||||
if not torrent_hash:
|
||||
_raise_runtime_error("Could not determine torrent hash from URL")
|
||||
message = "Could not determine torrent hash from URL"
|
||||
if torrent_info.fetch_error:
|
||||
message = f"{message} (torrent file fetch failed: {torrent_info.fetch_error})"
|
||||
_raise_runtime_error(message)
|
||||
|
||||
logger.debug("Added torrent to rTorrent: %s", torrent_hash)
|
||||
|
||||
@@ -329,12 +338,14 @@ class RTorrentClient(DownloadClient):
|
||||
|
||||
"""
|
||||
try:
|
||||
# rtorrent is somehow case sensitive and requires uppercase hashes for look
|
||||
torrent_hash = download_id.upper()
|
||||
if delete_files:
|
||||
self._rpc.d.delete_tied(download_id)
|
||||
self._rpc.d.erase(download_id)
|
||||
self._rpc.d.delete_tied(torrent_hash)
|
||||
self._rpc.d.erase(torrent_hash)
|
||||
else:
|
||||
self._rpc.d.stop(download_id)
|
||||
self._rpc.d.erase(download_id)
|
||||
self._rpc.d.stop(torrent_hash)
|
||||
self._rpc.d.erase(torrent_hash)
|
||||
|
||||
logger.info(
|
||||
"Removed torrent from rTorrent: %s%s",
|
||||
@@ -348,6 +359,19 @@ class RTorrentClient(DownloadClient):
|
||||
else:
|
||||
return True
|
||||
|
||||
def set_category(self, download_id: str, category: str) -> bool:
|
||||
"""Assign a label to a torrent using rTorrent's custom1 field."""
|
||||
try:
|
||||
# rtorrent is somehow case sensitive and requires uppercase hashes for look
|
||||
self._rpc.d.custom1.set(download_id.upper(), category)
|
||||
logger.info("Set rTorrent label for %s to '%s'", download_id, category)
|
||||
except _RTORRENT_CLIENT_ERRORS as e:
|
||||
error_type = type(e).__name__
|
||||
logger.exception("rTorrent set_category failed (%s)", error_type)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where torrent files are located.
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) -
|
||||
raw_url = _resolve_string_setting(current_values, config.get, "QBITTORRENT_URL")
|
||||
username = _resolve_string_setting(current_values, config.get, "QBITTORRENT_USERNAME")
|
||||
password = _resolve_string_setting(current_values, config.get, "QBITTORRENT_PASSWORD")
|
||||
api_key = _resolve_string_setting(current_values, config.get, "QBITTORRENT_API_KEY")
|
||||
|
||||
if not raw_url:
|
||||
return {"success": False, "message": "qBittorrent URL is required"}
|
||||
@@ -174,6 +175,7 @@ def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) -
|
||||
host=url,
|
||||
username=username,
|
||||
password=password,
|
||||
api_key=api_key or None,
|
||||
VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(url),
|
||||
)
|
||||
client.auth_log_in()
|
||||
@@ -181,9 +183,18 @@ def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) -
|
||||
except ImportError:
|
||||
return {"success": False, "message": "qbittorrent-api package not installed"}
|
||||
except _QBITTORRENT_SETTINGS_ERRORS as e:
|
||||
if isinstance(e, _QBittorrentLoginFailed):
|
||||
# LoginFailed carries no message of its own, so name the rejected credential.
|
||||
rejected = "API key" if api_key else "username or password"
|
||||
return {"success": False, "message": f"qBittorrent rejected the {rejected}"}
|
||||
return {"success": False, "message": f"Connection failed: {e!s}"}
|
||||
else:
|
||||
return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"}
|
||||
# Both credentials can be set at once, so name the one that actually authenticated.
|
||||
used = " using the API key" if api_key else ""
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Connected to qBittorrent (API v{api_version}){used}",
|
||||
}
|
||||
|
||||
|
||||
def _test_transmission_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
@@ -520,6 +531,40 @@ def _test_sabnzbd_connection(current_values: dict[str, Any] | None = None) -> di
|
||||
return {"success": True, "message": f"Connected to SABnzbd {version}"}
|
||||
|
||||
|
||||
def _test_alldebrid_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Test the AllDebrid API connection using current form values."""
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.download.clients.alldebrid import AllDebridClient
|
||||
|
||||
current_values = current_values or {}
|
||||
api_key = _resolve_string_setting(current_values, config.get, "ALLDEBRID_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
return {"success": False, "message": "AllDebrid API Key is required"}
|
||||
|
||||
client = AllDebridClient()
|
||||
client._api_key = api_key
|
||||
success, message = client.test_connection()
|
||||
return {"success": success, "message": message}
|
||||
|
||||
|
||||
def _test_realdebrid_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Test the Real-Debrid API connection using current form values."""
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.download.clients.realdebrid import RealDebridClient
|
||||
|
||||
current_values = current_values or {}
|
||||
api_key = _resolve_string_setting(current_values, config.get, "REALDEBRID_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
return {"success": False, "message": "Real-Debrid API Key is required"}
|
||||
|
||||
client = RealDebridClient()
|
||||
client._api_key = api_key
|
||||
success, message = client.test_connection()
|
||||
return {"success": success, "message": message}
|
||||
|
||||
|
||||
# ==================== Download Clients Tab ====================
|
||||
|
||||
|
||||
@@ -544,13 +589,45 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
description="Choose which torrent client to use",
|
||||
options=[
|
||||
{"value": "", "label": "None"},
|
||||
{"value": "alldebrid", "label": "AllDebrid"},
|
||||
{"value": "qbittorrent", "label": "qBittorrent"},
|
||||
{"value": "realdebrid", "label": "Real-Debrid"},
|
||||
{"value": "transmission", "label": "Transmission"},
|
||||
{"value": "deluge", "label": "Deluge"},
|
||||
{"value": "rtorrent", "label": "rTorrent"},
|
||||
],
|
||||
default="",
|
||||
),
|
||||
# --- AllDebrid Settings ---
|
||||
PasswordField(
|
||||
key="ALLDEBRID_API_KEY",
|
||||
label="API Key",
|
||||
description="AllDebrid API Key (apiv4) from your AllDebrid account settings",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "alldebrid"},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_alldebrid",
|
||||
label="Test Connection",
|
||||
description="Verify your AllDebrid configuration",
|
||||
style="primary",
|
||||
callback=_test_alldebrid_connection,
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "alldebrid"},
|
||||
),
|
||||
# --- Real-Debrid Settings ---
|
||||
PasswordField(
|
||||
key="REALDEBRID_API_KEY",
|
||||
label="API Key",
|
||||
description="Real-Debrid API Key (Secret Token) from your Real-Debrid account settings",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "realdebrid"},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_realdebrid",
|
||||
label="Test Connection",
|
||||
description="Verify your Real-Debrid configuration",
|
||||
style="primary",
|
||||
callback=_test_realdebrid_connection,
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "realdebrid"},
|
||||
),
|
||||
# --- qBittorrent Settings ---
|
||||
TextField(
|
||||
key="QBITTORRENT_URL",
|
||||
@@ -572,6 +649,12 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
description="qBittorrent Web UI password",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
PasswordField(
|
||||
key="QBITTORRENT_API_KEY",
|
||||
label="API Key",
|
||||
description="Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set.",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_qbittorrent",
|
||||
label="Test Connection",
|
||||
@@ -771,14 +854,23 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
SelectField(
|
||||
key="PROWLARR_TORRENT_ACTION",
|
||||
label="Torrent Completion Action",
|
||||
description="Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding",
|
||||
description="Choose whether to keep, remove, or move the torrent to another category or label after import",
|
||||
options=[
|
||||
{"value": "keep", "label": "Keep"},
|
||||
{"value": "remove", "label": "Remove"},
|
||||
{"value": "change_category", "label": "Change Category"},
|
||||
],
|
||||
default="keep",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
|
||||
),
|
||||
TextField(
|
||||
key="PROWLARR_TORRENT_POST_IMPORT_CATEGORY",
|
||||
label="Post-Import Category",
|
||||
description="Category or label to assign after a successful import",
|
||||
placeholder="imported",
|
||||
default="",
|
||||
show_when={"field": "PROWLARR_TORRENT_ACTION", "value": "change_category"},
|
||||
),
|
||||
# --- Usenet Client Selection ---
|
||||
HeadingField(
|
||||
key="usenet_heading",
|
||||
|
||||
@@ -5,8 +5,10 @@ from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from binascii import Error as BinasciiError
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
from urllib.parse import ParseResult, parse_qs, urljoin, urlparse
|
||||
|
||||
import requests
|
||||
@@ -36,6 +38,15 @@ _TORRENT_FETCH_ERRORS = (
|
||||
_TORRENT_PARSE_ERRORS = (IndexError, KeyError, TypeError, ValueError)
|
||||
_TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS = ("PROWLARR_URL", "NEWZNAB_URL")
|
||||
|
||||
# Successful torrent fetches are reused for a short window so one add attempt
|
||||
# hits the download link only once. Tracker download links (e.g. private
|
||||
# trackers behind Prowlarr's proxy) can be slow, rate-limited, or single-use,
|
||||
# and both find_existing() and add_download() resolve the same URL (#1111).
|
||||
_TORRENT_FETCH_CACHE_TTL_SECONDS = 120.0
|
||||
_TORRENT_FETCH_CACHE_MAX_ENTRIES = 8
|
||||
_torrent_fetch_cache_lock = Lock()
|
||||
_torrent_fetch_cache: dict[str, tuple[float, TorrentInfo]] = {}
|
||||
|
||||
type BencodeValue = dict[str | bytes, BencodeValue] | list[BencodeValue] | int | bytes | str
|
||||
|
||||
|
||||
@@ -55,6 +66,9 @@ class TorrentInfo:
|
||||
magnet_url: str | None = None
|
||||
"""The actual magnet URL, if available."""
|
||||
|
||||
fetch_error: str | None = None
|
||||
"""Why fetching the .torrent URL failed, or None if it succeeded/was skipped."""
|
||||
|
||||
def with_info_hash(self, info_hash: str | None) -> TorrentInfo:
|
||||
"""Return a copy with the info_hash replaced when provided."""
|
||||
if info_hash:
|
||||
@@ -63,6 +77,7 @@ class TorrentInfo:
|
||||
torrent_data=self.torrent_data,
|
||||
is_magnet=self.is_magnet,
|
||||
magnet_url=self.magnet_url,
|
||||
fetch_error=self.fetch_error,
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -97,6 +112,48 @@ def extract_torrent_info(
|
||||
if not fetch_torrent:
|
||||
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||
|
||||
info = _get_cached_torrent_fetch(url)
|
||||
if info is None:
|
||||
info = _fetch_torrent_info(url)
|
||||
if info.fetch_error is None:
|
||||
_store_cached_torrent_fetch(url, info)
|
||||
|
||||
return info.with_info_hash(info.info_hash or expected_hash)
|
||||
|
||||
|
||||
def _get_cached_torrent_fetch(url: str) -> TorrentInfo | None:
|
||||
with _torrent_fetch_cache_lock:
|
||||
entry = _torrent_fetch_cache.get(url)
|
||||
if entry is None:
|
||||
return None
|
||||
fetched_at, info = entry
|
||||
if time.monotonic() - fetched_at > _TORRENT_FETCH_CACHE_TTL_SECONDS:
|
||||
del _torrent_fetch_cache[url]
|
||||
return None
|
||||
logger.debug("Reusing recently fetched torrent data for: %s...", url[:80])
|
||||
return info
|
||||
|
||||
|
||||
def _store_cached_torrent_fetch(url: str, info: TorrentInfo) -> None:
|
||||
with _torrent_fetch_cache_lock:
|
||||
_torrent_fetch_cache[url] = (time.monotonic(), info)
|
||||
while len(_torrent_fetch_cache) > _TORRENT_FETCH_CACHE_MAX_ENTRIES:
|
||||
oldest_url = min(_torrent_fetch_cache, key=lambda key: _torrent_fetch_cache[key][0])
|
||||
del _torrent_fetch_cache[oldest_url]
|
||||
|
||||
|
||||
def clear_torrent_fetch_cache() -> None:
|
||||
"""Drop all cached torrent fetches (used by tests)."""
|
||||
with _torrent_fetch_cache_lock:
|
||||
_torrent_fetch_cache.clear()
|
||||
|
||||
|
||||
def _fetch_torrent_info(url: str) -> TorrentInfo:
|
||||
"""Fetch a .torrent URL and parse out the info_hash and raw torrent data.
|
||||
|
||||
On failure, the returned TorrentInfo carries the reason in `fetch_error`
|
||||
so callers can surface it instead of a generic hash error.
|
||||
"""
|
||||
# A release source can legitimately hand us a download URL on a different
|
||||
# origin than the configured Prowlarr/Newznab endpoint (e.g. a direct
|
||||
# tracker link, or Prowlarr reached through a separate proxy), and a trusted
|
||||
@@ -145,18 +202,20 @@ def extract_torrent_info(
|
||||
redirect_url = resolve_url(current_url, resp.headers.get("Location", ""))
|
||||
if redirect_url.startswith("magnet:"):
|
||||
logger.debug("Download URL redirected to magnet link")
|
||||
info_hash = extract_hash_from_magnet(redirect_url)
|
||||
if not info_hash and expected_hash:
|
||||
info_hash = expected_hash
|
||||
return TorrentInfo(
|
||||
info_hash=info_hash,
|
||||
info_hash=extract_hash_from_magnet(redirect_url),
|
||||
torrent_data=None,
|
||||
is_magnet=True,
|
||||
magnet_url=redirect_url,
|
||||
)
|
||||
if redirects_remaining <= 0:
|
||||
logger.debug("Too many redirects fetching torrent file: %s...", url[:80])
|
||||
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||
logger.warning("Too many redirects fetching torrent file: %s...", url[:80])
|
||||
return TorrentInfo(
|
||||
info_hash=None,
|
||||
torrent_data=None,
|
||||
is_magnet=False,
|
||||
fetch_error="too many redirects",
|
||||
)
|
||||
redirects_remaining -= 1
|
||||
logger.debug("Following redirect to: %s...", redirect_url[:80])
|
||||
current_url = redirect_url
|
||||
@@ -170,25 +229,22 @@ def extract_torrent_info(
|
||||
text_content = torrent_data.decode("utf-8", errors="ignore").strip()
|
||||
if text_content.startswith("magnet:"):
|
||||
logger.debug("Download URL returned magnet link as response body")
|
||||
info_hash = extract_hash_from_magnet(text_content)
|
||||
if not info_hash and expected_hash:
|
||||
info_hash = expected_hash
|
||||
return TorrentInfo(
|
||||
info_hash=info_hash,
|
||||
info_hash=extract_hash_from_magnet(text_content),
|
||||
torrent_data=None,
|
||||
is_magnet=True,
|
||||
magnet_url=text_content,
|
||||
)
|
||||
|
||||
info_hash = extract_info_hash_from_torrent(torrent_data) or expected_hash
|
||||
info_hash = extract_info_hash_from_torrent(torrent_data)
|
||||
if info_hash:
|
||||
logger.debug("Extracted hash from torrent file: %s", info_hash)
|
||||
else:
|
||||
logger.warning("Could not extract hash from torrent file")
|
||||
return TorrentInfo(info_hash=info_hash, torrent_data=torrent_data, is_magnet=False)
|
||||
except _TORRENT_FETCH_ERRORS as e:
|
||||
logger.debug("Could not fetch torrent file: %s", e)
|
||||
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||
logger.warning("Could not fetch torrent file: %s", e)
|
||||
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False, fetch_error=str(e))
|
||||
|
||||
|
||||
def _is_trusted_torrent_fetch_url(url: str) -> bool:
|
||||
|
||||
@@ -390,6 +390,30 @@ class TransmissionClient(DownloadClient):
|
||||
else:
|
||||
return True
|
||||
|
||||
def _get_torrent_labels(self, download_id: str) -> list[str]:
|
||||
"""Return a torrent's current labels, preserving their order."""
|
||||
torrent = self._client.get_torrent(download_id)
|
||||
raw_labels = getattr(torrent, "labels", None) or []
|
||||
return [str(label) for label in raw_labels if str(label)]
|
||||
|
||||
def set_category(self, download_id: str, category: str) -> bool:
|
||||
"""Add the post-import label to a torrent, keeping labels set elsewhere."""
|
||||
try:
|
||||
existing_labels = self._get_torrent_labels(download_id)
|
||||
if category in existing_labels:
|
||||
logger.debug(
|
||||
"Transmission torrent %s already has label '%s'", download_id, category
|
||||
)
|
||||
return True
|
||||
|
||||
self._client.change_torrent(ids=download_id, labels=[*existing_labels, category])
|
||||
logger.info("Added Transmission label '%s' to %s", category, download_id)
|
||||
except _TRANSMISSION_CLIENT_ERRORS as e:
|
||||
self._log_error("set_category", e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where torrent files are located.
|
||||
|
||||
|
||||
@@ -170,16 +170,19 @@ def _build_retry_resolution_fields(
|
||||
retry_download_url = normalize_optional_text(release_data.get("download_url"))
|
||||
protocol = normalize_optional_text(release_data.get("protocol"))
|
||||
source = normalize_optional_text(release_data.get("source"))
|
||||
retry_source_context: dict[str, Any] = {}
|
||||
if source is not None:
|
||||
handler = get_handler(source)
|
||||
source_retry_fields = handler.build_retry_resolution_fields(release_data)
|
||||
retry_download_url = (
|
||||
normalize_optional_text(source_retry_fields.get("retry_download_url"))
|
||||
or retry_download_url
|
||||
)
|
||||
protocol = (
|
||||
normalize_optional_text(source_retry_fields.get("retry_download_protocol")) or protocol
|
||||
)
|
||||
if "retry_download_url" in source_retry_fields:
|
||||
retry_download_url = normalize_optional_text(
|
||||
source_retry_fields.get("retry_download_url")
|
||||
)
|
||||
if "retry_download_protocol" in source_retry_fields:
|
||||
protocol = normalize_optional_text(source_retry_fields.get("retry_download_protocol"))
|
||||
raw_retry_source_context = source_retry_fields.get("retry_source_context")
|
||||
if isinstance(raw_retry_source_context, dict):
|
||||
retry_source_context = dict(raw_retry_source_context)
|
||||
|
||||
ratio_limit = _optional_number(release_data.get("ratio_limit"))
|
||||
if ratio_limit is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False):
|
||||
@@ -202,6 +205,7 @@ def _build_retry_resolution_fields(
|
||||
),
|
||||
"retry_ratio_limit": ratio_limit,
|
||||
"retry_seeding_time_limit_minutes": seeding_time_limit_minutes,
|
||||
"retry_source_context": retry_source_context,
|
||||
"can_retry_without_staged_source": True,
|
||||
}
|
||||
|
||||
@@ -247,6 +251,7 @@ def queue_release(
|
||||
series_name = release_data.get("series_name") or extra.get("series_name")
|
||||
series_position = release_data.get("series_position") or extra.get("series_position")
|
||||
subtitle = release_data.get("subtitle") or extra.get("subtitle")
|
||||
language = release_data.get("language") or extra.get("language")
|
||||
|
||||
books_output_mode = (
|
||||
str(config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder")
|
||||
@@ -281,6 +286,7 @@ def queue_release(
|
||||
series_name=series_name,
|
||||
series_position=series_position,
|
||||
subtitle=subtitle,
|
||||
language=language,
|
||||
search_mode=search_mode,
|
||||
output_mode=output_mode,
|
||||
output_args=output_args,
|
||||
@@ -400,6 +406,7 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
|
||||
search_mode = normalized_search_mode or None
|
||||
|
||||
raw_output_args = getattr(task, "output_args", None)
|
||||
raw_retry_source_context = getattr(task, "retry_source_context", None)
|
||||
|
||||
return {
|
||||
"task_id": getattr(task, "task_id", None),
|
||||
@@ -415,6 +422,7 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
|
||||
"series_name": getattr(task, "series_name", None),
|
||||
"series_position": getattr(task, "series_position", None),
|
||||
"subtitle": getattr(task, "subtitle", None),
|
||||
"language": getattr(task, "language", None),
|
||||
"search_mode": search_mode,
|
||||
"output_mode": getattr(task, "output_mode", None),
|
||||
"output_args": dict(raw_output_args) if isinstance(raw_output_args, dict) else {},
|
||||
@@ -428,6 +436,9 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
|
||||
"retry_expected_hash": getattr(task, "retry_expected_hash", None),
|
||||
"retry_ratio_limit": getattr(task, "retry_ratio_limit", None),
|
||||
"retry_seeding_time_limit_minutes": getattr(task, "retry_seeding_time_limit_minutes", None),
|
||||
"retry_source_context": (
|
||||
dict(raw_retry_source_context) if isinstance(raw_retry_source_context, dict) else {}
|
||||
),
|
||||
"can_retry_without_staged_source": bool(
|
||||
getattr(task, "can_retry_without_staged_source", True)
|
||||
),
|
||||
@@ -453,6 +464,7 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
|
||||
search_mode = None
|
||||
|
||||
output_args = payload.get("output_args")
|
||||
retry_source_context = payload.get("retry_source_context")
|
||||
|
||||
return DownloadTask(
|
||||
task_id=task_id,
|
||||
@@ -468,6 +480,7 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
|
||||
series_name=normalize_optional_text(payload.get("series_name")),
|
||||
series_position=_optional_number(payload.get("series_position")),
|
||||
subtitle=normalize_optional_text(payload.get("subtitle")),
|
||||
language=normalize_optional_text(payload.get("language")),
|
||||
search_mode=search_mode,
|
||||
output_mode=normalize_optional_text(payload.get("output_mode")),
|
||||
output_args=dict(output_args) if isinstance(output_args, dict) else {},
|
||||
@@ -483,6 +496,9 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
|
||||
retry_seeding_time_limit_minutes=_optional_positive_int(
|
||||
payload.get("retry_seeding_time_limit_minutes")
|
||||
),
|
||||
retry_source_context=(
|
||||
dict(retry_source_context) if isinstance(retry_source_context, dict) else {}
|
||||
),
|
||||
can_retry_without_staged_source=bool(payload.get("can_retry_without_staged_source", True)),
|
||||
)
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ def _build_custom_script_payload(
|
||||
"series_name": context.task.series_name,
|
||||
"series_position": context.task.series_position,
|
||||
"subtitle": context.task.subtitle,
|
||||
"language": context.task.language,
|
||||
"original_download_path": context.task.original_download_path,
|
||||
},
|
||||
"output": {
|
||||
|
||||
@@ -12,6 +12,7 @@ from shelfmark.core.naming import (
|
||||
assign_part_numbers,
|
||||
build_library_path,
|
||||
derive_primary_title,
|
||||
normalize_language_code,
|
||||
parse_naming_template,
|
||||
sanitize_filename,
|
||||
)
|
||||
@@ -63,6 +64,7 @@ def build_metadata_dict(task: DownloadTask) -> dict:
|
||||
"Year": task.year,
|
||||
"Series": task.series_name,
|
||||
"SeriesPosition": task.series_position,
|
||||
"Language": normalize_language_code(task.language),
|
||||
"User": task.username,
|
||||
}
|
||||
|
||||
|
||||
@@ -24,12 +24,12 @@ Dataclass representing a book from a metadata provider:
|
||||
```python
|
||||
@dataclass
|
||||
class BookMetadata:
|
||||
provider: str # Internal provider name (e.g., "hardcover")
|
||||
provider_id: str # ID in that provider's system
|
||||
provider: str # Internal provider name (e.g., "hardcover")
|
||||
provider_id: str # ID in that provider's system
|
||||
title: str
|
||||
|
||||
# Optional fields
|
||||
provider_display_name: str # Human-readable name (e.g., "Hardcover")
|
||||
provider_display_name: str # Human-readable name (e.g., "Hardcover")
|
||||
authors: List[str]
|
||||
isbn_10: str
|
||||
isbn_13: str
|
||||
@@ -39,7 +39,7 @@ class BookMetadata:
|
||||
publish_year: int
|
||||
language: str
|
||||
genres: List[str]
|
||||
source_url: str # Link to book on provider's site
|
||||
source_url: str # Link to book on provider's site
|
||||
display_fields: List[DisplayField] # Provider-specific display data
|
||||
```
|
||||
|
||||
@@ -50,9 +50,9 @@ Provider-specific metadata for UI cards (ratings, page counts, reader counts, et
|
||||
```python
|
||||
@dataclass
|
||||
class DisplayField:
|
||||
label: str # e.g., "Rating", "Pages", "Readers"
|
||||
value: str # e.g., "4.5", "496", "8,041"
|
||||
icon: str # Icon name: "star", "book", "users", "editions"
|
||||
label: str # e.g., "Rating", "Pages", "Readers"
|
||||
value: str # e.g., "4.5", "496", "8,041"
|
||||
icon: str # Icon name: "star", "book", "users", "editions"
|
||||
```
|
||||
|
||||
### MetadataSearchOptions
|
||||
@@ -64,7 +64,7 @@ Unified search options that work across all providers:
|
||||
class MetadataSearchOptions:
|
||||
query: str
|
||||
search_type: SearchType = SearchType.GENERAL # GENERAL, TITLE, AUTHOR, ISBN
|
||||
language: str = None # ISO 639-1 code (e.g., "en")
|
||||
language: str = None # ISO 639-1 code (e.g., "en")
|
||||
sort: SortOrder = SortOrder.RELEVANCE
|
||||
limit: int = 40
|
||||
page: int = 1
|
||||
@@ -88,10 +88,10 @@ All providers must implement this interface:
|
||||
|
||||
```python
|
||||
class MetadataProvider(ABC):
|
||||
name: str # Internal identifier
|
||||
display_name: str # Human-readable name
|
||||
requires_auth: bool # True if API key required
|
||||
supported_sorts: List[SortOrder] # Supported sort options
|
||||
name: str # Internal identifier
|
||||
display_name: str # Human-readable name
|
||||
requires_auth: bool # True if API key required
|
||||
supported_sorts: List[SortOrder] # Supported sort options
|
||||
|
||||
@abstractmethod
|
||||
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
|
||||
@@ -121,9 +121,9 @@ class MetadataProvider(ABC):
|
||||
```python
|
||||
from shelfmark.metadata_providers import register_provider
|
||||
|
||||
|
||||
@register_provider("my_provider")
|
||||
class MyProvider(MetadataProvider):
|
||||
...
|
||||
class MyProvider(MetadataProvider): ...
|
||||
```
|
||||
|
||||
### Getting Providers
|
||||
@@ -281,11 +281,13 @@ from shelfmark.config.env import (
|
||||
METADATA_CACHE_BOOK_TTL,
|
||||
)
|
||||
|
||||
|
||||
@cacheable(ttl=METADATA_CACHE_SEARCH_TTL, key_prefix="myprovider:search")
|
||||
def _search_cached(self, cache_key: str, options: MetadataSearchOptions):
|
||||
# Cached search implementation
|
||||
pass
|
||||
|
||||
|
||||
@cacheable(ttl=METADATA_CACHE_BOOK_TTL, key_prefix="myprovider:book")
|
||||
def get_book(self, book_id: str):
|
||||
# Cached book lookup
|
||||
@@ -302,6 +304,7 @@ from shelfmark.metadata_providers.openlibrary import RateLimiter
|
||||
# 90 requests per 60 seconds
|
||||
rate_limiter = RateLimiter(max_requests=90, window_seconds=60)
|
||||
|
||||
|
||||
def make_request(self):
|
||||
rate_limiter.wait_if_needed() # Blocks if rate limited
|
||||
# ... make request
|
||||
|
||||
@@ -163,6 +163,7 @@ class SortOption:
|
||||
|
||||
label: str # Display label in the sort dropdown
|
||||
sort_key: str # Field to sort by on the Release object
|
||||
default_direction: Literal["asc", "desc"] = "desc" # Which way "best first" runs
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -261,7 +262,12 @@ def serialize_column_config(config: ReleaseColumnConfig) -> dict[str, Any]:
|
||||
# Include extra sort options (sort entries not tied to a column)
|
||||
if config.extra_sort_options:
|
||||
result["extra_sort_options"] = [
|
||||
{"label": opt.label, "sort_key": opt.sort_key} for opt in config.extra_sort_options
|
||||
{
|
||||
"label": opt.label,
|
||||
"sort_key": opt.sort_key,
|
||||
"default_direction": opt.default_direction,
|
||||
}
|
||||
for opt in config.extra_sort_options
|
||||
]
|
||||
|
||||
# Include action button if specified (replaces default expand search)
|
||||
|
||||
@@ -9,6 +9,7 @@ if TYPE_CHECKING:
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.languages import normalize_language
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.release_sources import (
|
||||
ColumnAlign,
|
||||
@@ -43,41 +44,6 @@ def _coerce_positive_int(value: object, default: int) -> int:
|
||||
|
||||
|
||||
# Map language names to ISO 639-1 codes (matching frontend color maps)
|
||||
LANGUAGE_MAP = {
|
||||
"english": "en",
|
||||
"spanish": "es",
|
||||
"french": "fr",
|
||||
"german": "de",
|
||||
"italian": "it",
|
||||
"portuguese": "pt",
|
||||
"russian": "ru",
|
||||
"japanese": "ja",
|
||||
"chinese": "zh",
|
||||
"dutch": "nl",
|
||||
"swedish": "sv",
|
||||
"norwegian": "no",
|
||||
"danish": "da",
|
||||
"finnish": "fi",
|
||||
"polish": "pl",
|
||||
"czech": "cs",
|
||||
"hungarian": "hu",
|
||||
"korean": "ko",
|
||||
"arabic": "ar",
|
||||
"hebrew": "he",
|
||||
"turkish": "tr",
|
||||
"greek": "el",
|
||||
"hindi": "hi",
|
||||
"thai": "th",
|
||||
"vietnamese": "vi",
|
||||
"indonesian": "id",
|
||||
"ukrainian": "uk",
|
||||
"romanian": "ro",
|
||||
"bulgarian": "bg",
|
||||
"catalan": "ca",
|
||||
"croatian": "hr",
|
||||
"slovenian": "sl",
|
||||
"serbian": "sr",
|
||||
}
|
||||
|
||||
|
||||
def _split_title_and_author(raw_title: str) -> tuple[str, str | None]:
|
||||
@@ -119,8 +85,9 @@ def _map_language(language: str) -> str | None:
|
||||
if not language:
|
||||
return None
|
||||
|
||||
lang_lower = language.lower().strip()
|
||||
return LANGUAGE_MAP.get(lang_lower, lang_lower)
|
||||
# Fall back to the raw value so an unrecognised language is still shown
|
||||
# rather than silently dropped from the release row.
|
||||
return normalize_language(language) or language.lower().strip()
|
||||
|
||||
|
||||
def _parse_bitrate_to_kbps(bitrate: str | None) -> int | None:
|
||||
|
||||
@@ -18,6 +18,7 @@ from bs4.element import NavigableString
|
||||
|
||||
from shelfmark.config.env import DEBUG_SKIP_SOURCES, TMP_DIR
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.languages import language_alias_map
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask, SearchFilters, build_filename
|
||||
from shelfmark.core.utils import CONTENT_TYPES, get_aa_content_type_dir
|
||||
@@ -267,7 +268,7 @@ def _fold_text(value: str) -> str:
|
||||
|
||||
|
||||
def _language_alias_to_code() -> dict[str, str]:
|
||||
"""Build alias→code map from bundled language metadata (lazy, cached)."""
|
||||
"""Alias to code map, delegating to the shared language data."""
|
||||
global _LANGUAGE_ALIAS_TO_CODE
|
||||
cached = _LANGUAGE_ALIAS_TO_CODE
|
||||
if cached is not None:
|
||||
@@ -278,35 +279,7 @@ def _language_alias_to_code() -> dict[str, str]:
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
mapping: dict[str, str] = {}
|
||||
data_path = Path(__file__).resolve().parents[2] / "data" / "book-languages.json"
|
||||
|
||||
try:
|
||||
raw = json.loads(data_path.read_text(encoding="utf-8"))
|
||||
except OSError, ValueError, TypeError:
|
||||
_LANGUAGE_ALIAS_TO_CODE = {}
|
||||
return _LANGUAGE_ALIAS_TO_CODE
|
||||
|
||||
if not isinstance(raw, list):
|
||||
_LANGUAGE_ALIAS_TO_CODE = {}
|
||||
return _LANGUAGE_ALIAS_TO_CODE
|
||||
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
code = _normalize_language_token(str(item.get("code", "")))
|
||||
name = _normalize_language_token(str(item.get("language", "")))
|
||||
if not code:
|
||||
continue
|
||||
mapping.setdefault(code, code)
|
||||
mapping.setdefault(code.replace("-", "_"), code)
|
||||
mapping.setdefault(code.split("-")[0], code)
|
||||
mapping.setdefault(_fold_text(code), code)
|
||||
if name:
|
||||
mapping.setdefault(name, code)
|
||||
mapping.setdefault(_fold_text(name), code)
|
||||
|
||||
_LANGUAGE_ALIAS_TO_CODE = mapping
|
||||
_LANGUAGE_ALIAS_TO_CODE = language_alias_map()
|
||||
return _LANGUAGE_ALIAS_TO_CODE
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ logger = setup_logger(__name__)
|
||||
# Timing
|
||||
SOCKET_TIMEOUT = 300.0 # 5 minutes - long because we wait for DCC offers
|
||||
RECV_BUFFER = 4096
|
||||
# How often a deadline-bound read wakes up to re-check the clock
|
||||
POLL_INTERVAL = 2.0
|
||||
|
||||
# IRC channel user prefixes that indicate elevated status (ops, voice, etc.)
|
||||
# These are the download bots/servers
|
||||
@@ -248,11 +250,22 @@ class IRCClient:
|
||||
|
||||
# 366 = RPL_ENDOFNAMES - channel join is complete
|
||||
if msg.command == "366":
|
||||
logger.info(
|
||||
"Joined #%s - %s servers online",
|
||||
channel,
|
||||
len(self.online_servers),
|
||||
)
|
||||
if not self.online_servers:
|
||||
# Joining a channel that doesn't exist on this network
|
||||
# silently creates an empty one, so an empty name list is
|
||||
# the only hint that the channel name is wrong.
|
||||
logger.warning(
|
||||
"Joined #%s but no servers are online - the channel may "
|
||||
"be empty or not exist on %s",
|
||||
channel,
|
||||
self.server,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Joined #%s - %s servers online",
|
||||
channel,
|
||||
len(self.online_servers),
|
||||
)
|
||||
return
|
||||
|
||||
# Check for errors (e.g., banned, channel doesn't exist)
|
||||
@@ -296,27 +309,47 @@ class IRCClient:
|
||||
data = f"{message}\r\n".encode()
|
||||
self._socket.sendall(data)
|
||||
|
||||
def _recv_lines(self) -> Iterator[str]:
|
||||
"""Receive and yield complete CRLF-delimited IRC lines."""
|
||||
sock = self._require_socket()
|
||||
while True:
|
||||
# Check if we have a complete line in buffer
|
||||
while "\r\n" in self._buffer:
|
||||
line, self._buffer = self._buffer.split("\r\n", 1)
|
||||
if line:
|
||||
yield line
|
||||
def _recv_lines(self, deadline: float | None = None) -> Iterator[str]:
|
||||
"""Receive and yield complete CRLF-delimited IRC lines.
|
||||
|
||||
# Read more data
|
||||
try:
|
||||
data = sock.recv(RECV_BUFFER)
|
||||
if not data:
|
||||
return # Connection closed
|
||||
self._buffer += data.decode("utf-8", errors="replace")
|
||||
except TimeoutError:
|
||||
continue # Keep waiting
|
||||
except OSError as e:
|
||||
logger.warning("Socket error: %s", e)
|
||||
return # Connection error
|
||||
A deadline stops the read once it passes, even if nothing ever arrives.
|
||||
Callers time out by watching the messages they receive, so on a channel
|
||||
with no traffic at all there is nothing to watch: the recv would just
|
||||
keep blocking for SOCKET_TIMEOUT and retrying forever.
|
||||
"""
|
||||
sock = self._require_socket()
|
||||
original_timeout = sock.gettimeout()
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Check if we have a complete line in buffer
|
||||
while "\r\n" in self._buffer:
|
||||
line, self._buffer = self._buffer.split("\r\n", 1)
|
||||
if line:
|
||||
yield line
|
||||
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
return
|
||||
# Wake up often enough to notice the deadline pass
|
||||
sock.settimeout(min(remaining, POLL_INTERVAL))
|
||||
|
||||
# Read more data
|
||||
try:
|
||||
data = sock.recv(RECV_BUFFER)
|
||||
if not data:
|
||||
return # Connection closed
|
||||
self._buffer += data.decode("utf-8", errors="replace")
|
||||
except TimeoutError:
|
||||
continue # Keep waiting (the deadline is re-checked above)
|
||||
except OSError as e:
|
||||
logger.warning("Socket error: %s", e)
|
||||
return # Connection error
|
||||
finally:
|
||||
if deadline is not None:
|
||||
with suppress(OSError):
|
||||
sock.settimeout(original_timeout)
|
||||
|
||||
def _parse_message(self, line: str) -> IRCMessage:
|
||||
"""Parse an IRC message line into components.
|
||||
@@ -427,9 +460,14 @@ class IRCClient:
|
||||
return False
|
||||
return True
|
||||
|
||||
def read_messages(self, *, auto_handle: bool = True) -> Iterator[IRCMessage]:
|
||||
def read_messages(
|
||||
self,
|
||||
*,
|
||||
auto_handle: bool = True,
|
||||
deadline: float | None = None,
|
||||
) -> Iterator[IRCMessage]:
|
||||
"""Read and yield IRC messages, optionally auto-handling PING/VERSION."""
|
||||
for line in self._recv_lines():
|
||||
for line in self._recv_lines(deadline):
|
||||
msg = self._parse_message(line)
|
||||
|
||||
# Auto-handle certain events
|
||||
@@ -453,13 +491,9 @@ class IRCClient:
|
||||
) -> DCCOffer | None:
|
||||
"""Wait for a DCC SEND offer. Returns None on timeout or no results."""
|
||||
target_event = IRCEvent.SEARCH_RESULT if result_type else IRCEvent.BOOK_RESULT
|
||||
start = time.time()
|
||||
|
||||
for msg in self.read_messages():
|
||||
if time.time() - start > timeout:
|
||||
logger.warning("Timeout waiting for DCC offer")
|
||||
return None
|
||||
deadline = time.time() + timeout
|
||||
|
||||
for msg in self.read_messages(deadline=deadline):
|
||||
if msg.event == target_event:
|
||||
if not self._is_allowed_dcc_sender(msg, expected_senders):
|
||||
continue
|
||||
@@ -491,6 +525,8 @@ class IRCClient:
|
||||
count = match.group(1)
|
||||
logger.info("Found %s matches", count)
|
||||
|
||||
if time.time() >= deadline:
|
||||
logger.warning("Timeout waiting for DCC offer")
|
||||
return None
|
||||
|
||||
@property
|
||||
|
||||
@@ -72,7 +72,10 @@ def irc_settings() -> list[SettingsField]:
|
||||
key="IRC_CHANNEL",
|
||||
label="Channel",
|
||||
placeholder="e.g. ebooks",
|
||||
description="Channel name without the # prefix",
|
||||
description=(
|
||||
"Channel name without the # prefix. Used for all searches unless a "
|
||||
"separate audiobook channel is configured below."
|
||||
),
|
||||
required=True,
|
||||
env_supported=True,
|
||||
),
|
||||
@@ -95,6 +98,38 @@ def irc_settings() -> list[SettingsField]:
|
||||
required=True,
|
||||
env_supported=True,
|
||||
),
|
||||
HeadingField(
|
||||
key="audiobook_heading",
|
||||
title="Audiobooks",
|
||||
description=(
|
||||
"Some networks index audiobooks in a separate channel from ebooks "
|
||||
"(for example #ebooks for ebooks and #bookz for audiobooks). "
|
||||
"Configure that channel here to search it for audiobook requests. "
|
||||
"Leave these blank to search the main channel above for both."
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
key="IRC_AUDIOBOOK_CHANNEL",
|
||||
label="Audiobook channel",
|
||||
placeholder="e.g. bookz",
|
||||
description=(
|
||||
"Optional. Channel name (without the # prefix) to use for audiobook "
|
||||
"searches. Leave blank to use the main channel above for audiobooks too."
|
||||
),
|
||||
required=False,
|
||||
env_supported=True,
|
||||
),
|
||||
TextField(
|
||||
key="IRC_AUDIOBOOK_SEARCH_BOT",
|
||||
label="Audiobook search bot",
|
||||
placeholder="e.g. search",
|
||||
description=(
|
||||
"Optional. Search bot for the audiobook channel. Leave blank to reuse "
|
||||
"the main search bot above. Only used when an audiobook channel is set."
|
||||
),
|
||||
required=False,
|
||||
env_supported=True,
|
||||
),
|
||||
HeadingField(
|
||||
key="cache_heading",
|
||||
title="Search Cache",
|
||||
|
||||
@@ -240,6 +240,19 @@ class IRCReleaseSource(ReleaseSource):
|
||||
nick = _config_text("IRC_NICK")
|
||||
search_bot = _config_text("IRC_SEARCH_BOT")
|
||||
|
||||
# Audiobooks may be indexed in a separate channel from ebooks on some networks
|
||||
# (e.g. #ebooks for ebooks, #bookz for audiobooks). When an audiobook channel is
|
||||
# configured and an audiobook was requested, route the search there (with its own
|
||||
# search bot if set). Otherwise fall back to the main channel/bot, which keeps the
|
||||
# single-channel networks that index both formats working unchanged.
|
||||
if is_audiobook(content_type):
|
||||
audiobook_channel = _config_text("IRC_AUDIOBOOK_CHANNEL")
|
||||
if audiobook_channel:
|
||||
channel = audiobook_channel
|
||||
audiobook_search_bot = _config_text("IRC_AUDIOBOOK_SEARCH_BOT")
|
||||
if audiobook_search_bot:
|
||||
search_bot = audiobook_search_bot
|
||||
|
||||
# Never post an unaddressed query to the channel. A bare book title looks like
|
||||
# spam to everyone else in the channel and gets the nick banned. Searches must
|
||||
# be addressed to a search bot ("@<bot> <query>").
|
||||
|
||||
@@ -158,36 +158,55 @@ class ProwlarrClient:
|
||||
logger.info("Prowlarr connection successful: version %s", version)
|
||||
return True, f"Connected to Prowlarr {version}"
|
||||
|
||||
def get_indexers(self) -> list[dict[str, Any]]:
|
||||
"""Get all configured indexers."""
|
||||
def get_indexers(self, *, raise_on_error: bool = False) -> list[dict[str, Any]]:
|
||||
"""Get all configured indexers.
|
||||
|
||||
Args:
|
||||
raise_on_error: When True, propagate API failures instead of
|
||||
returning an empty list. Callers that must distinguish
|
||||
"no indexers" from "the request failed" should set this.
|
||||
|
||||
"""
|
||||
try:
|
||||
return _normalize_json_object_list(
|
||||
self._request("GET", "/api/v1/indexer"),
|
||||
context="Prowlarr indexer list",
|
||||
)
|
||||
except _PROWLARR_CLIENT_ERRORS:
|
||||
if raise_on_error:
|
||||
raise
|
||||
logger.exception("Failed to get indexers")
|
||||
return []
|
||||
|
||||
def get_enabled_indexers_detailed(self) -> list[dict[str, Any]]:
|
||||
def get_enabled_indexers_detailed(
|
||||
self, *, raise_on_error: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get enabled indexers, including implementation metadata.
|
||||
|
||||
Note: Prowlarr indexer "name" is user-configurable; prefer
|
||||
"implementation"/"implementationName" for stable identification.
|
||||
"""
|
||||
indexers = self.get_indexers()
|
||||
indexers = self.get_indexers(raise_on_error=raise_on_error)
|
||||
return [idx for idx in indexers if idx.get("enable", False)]
|
||||
|
||||
def get_enriched_indexer_ids(self, *, restrict_to: list[int] | None = None) -> list[int]:
|
||||
def get_enriched_indexer_ids(
|
||||
self,
|
||||
*,
|
||||
restrict_to: list[int] | None = None,
|
||||
indexers: list[dict[str, Any]] | None = None,
|
||||
) -> list[int]:
|
||||
"""Return enabled indexer IDs that benefit from extra Torznab handling.
|
||||
|
||||
Args:
|
||||
restrict_to: Optional list of candidate indexer IDs to consider.
|
||||
indexers: Optional already-fetched enabled indexer list, so callers
|
||||
that need the full records for other reasons can avoid a second
|
||||
round trip.
|
||||
|
||||
"""
|
||||
enriched_ids: list[int] = []
|
||||
|
||||
for idx in self.get_enabled_indexers_detailed():
|
||||
for idx in indexers if indexers is not None else self.get_enabled_indexers_detailed():
|
||||
idx_id_int = coerce_int_like(idx.get("id"))
|
||||
if idx_id_int is None:
|
||||
continue
|
||||
@@ -214,10 +233,17 @@ class ProwlarrClient:
|
||||
|
||||
Prowlarr exposes seedTime in minutes, which is also the unit expected by
|
||||
torrent clients.
|
||||
|
||||
Raises:
|
||||
requests.exceptions.RequestException (and other client errors) when
|
||||
the indexer list cannot be fetched. An empty dict strictly means
|
||||
"no share limits are configured", never "the request failed" -
|
||||
callers rely on this to avoid silently dropping seed limits.
|
||||
|
||||
"""
|
||||
settings_by_indexer: dict[int, IndexerSeedSettings] = {}
|
||||
|
||||
for idx in self.get_enabled_indexers_detailed():
|
||||
for idx in self.get_enabled_indexers_detailed(raise_on_error=True):
|
||||
idx_id_int = coerce_int_like(idx.get("id"))
|
||||
if idx_id_int is None:
|
||||
continue
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Prowlarr download handler - resolves releases and delegates lifecycle to shared clients."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import normalize_optional_text
|
||||
from shelfmark.core.search_plan import build_release_search_plan
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
get_client,
|
||||
@@ -23,9 +28,13 @@ from shelfmark.download.clients.base_handler import (
|
||||
DownloadRequest,
|
||||
ExternalClientHandler,
|
||||
)
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
from shelfmark.release_sources import register_handler
|
||||
from shelfmark.release_sources.prowlarr.cache import get_release, remove_release
|
||||
from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient
|
||||
from shelfmark.release_sources.prowlarr.cache import cache_release, get_release, remove_release
|
||||
from shelfmark.release_sources.prowlarr.source import ProwlarrSource
|
||||
from shelfmark.release_sources.prowlarr.utils import (
|
||||
build_source_id,
|
||||
coerce_int_like,
|
||||
get_preferred_download_url,
|
||||
get_protocol,
|
||||
@@ -37,6 +46,16 @@ if TYPE_CHECKING:
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Errors that ProwlarrClient can raise when fetching indexer settings.
|
||||
_SEED_SETTINGS_FALLBACK_ERRORS = (
|
||||
requests.exceptions.RequestException,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ProwlarrHandler",
|
||||
"POLL_INTERVAL",
|
||||
@@ -49,6 +68,11 @@ __all__ = [
|
||||
POLL_INTERVAL = _DEFAULT_POLL_INTERVAL
|
||||
COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL
|
||||
COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS
|
||||
EXPIRED_LINK_REFRESH_ERROR = (
|
||||
"The indexer download link expired and the release could not be refreshed. "
|
||||
"Search again for a fresh result."
|
||||
)
|
||||
HASH_DETECTION_ERROR = "Could not determine torrent hash from URL"
|
||||
|
||||
|
||||
def _coerce_positive_minutes(raw_minutes: object) -> int | None:
|
||||
@@ -62,6 +86,47 @@ def _coerce_positive_minutes(raw_minutes: object) -> int | None:
|
||||
class ProwlarrHandler(ExternalClientHandler):
|
||||
"""Handler for Prowlarr downloads via configured torrent or usenet client."""
|
||||
|
||||
@staticmethod
|
||||
def _build_prowlarr_client() -> ProwlarrClient | None:
|
||||
"""Build a ProwlarrClient from config, or None if not configured."""
|
||||
raw_url = config.get("PROWLARR_URL", "")
|
||||
raw_api_key = config.get("PROWLARR_API_KEY", "")
|
||||
url = normalize_optional_text(raw_url) if isinstance(raw_url, str) else None
|
||||
api_key = normalize_optional_text(raw_api_key) if isinstance(raw_api_key, str) else None
|
||||
if not url or not api_key:
|
||||
return None
|
||||
normalized_url = normalize_http_url(url)
|
||||
if not normalized_url:
|
||||
return None
|
||||
return ProwlarrClient(normalized_url, api_key)
|
||||
|
||||
def _fetch_seed_settings_fallback(self, raw_indexer_id: object) -> IndexerSeedSettings | None:
|
||||
"""Fetch share limits for one indexer directly from Prowlarr.
|
||||
|
||||
Used when the cached release is missing its search-time seed-limit
|
||||
enrichment so that transient failures during search cannot cause a
|
||||
torrent to be added without its configured share limits.
|
||||
"""
|
||||
indexer_id = coerce_int_like(raw_indexer_id)
|
||||
if indexer_id is None:
|
||||
return None
|
||||
|
||||
client = self._build_prowlarr_client()
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
settings = client.get_indexer_seed_settings(restrict_to=[indexer_id])
|
||||
except _SEED_SETTINGS_FALLBACK_ERRORS:
|
||||
logger.warning(
|
||||
"Grab-time seed settings fallback failed for indexerId=%s",
|
||||
indexer_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
return settings.get(indexer_id)
|
||||
|
||||
def _get_client(self, protocol: str) -> DownloadClient | None:
|
||||
"""Compatibility shim so module-level patching still works in tests."""
|
||||
return get_client(protocol)
|
||||
@@ -81,18 +146,30 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
|
||||
def build_retry_resolution_fields(self, release_data: dict[str, Any]) -> dict[str, Any]:
|
||||
source_id = normalize_optional_text(release_data.get("source_id"))
|
||||
if source_id is None:
|
||||
return {}
|
||||
extra = release_data.get("extra")
|
||||
if not isinstance(extra, dict):
|
||||
extra = {}
|
||||
|
||||
prowlarr_result = get_release(source_id)
|
||||
if prowlarr_result is None:
|
||||
return {}
|
||||
retry_source_context: dict[str, Any] = {}
|
||||
indexer_id = release_data.get("indexer_id") or extra.get("indexer_id")
|
||||
if indexer_id is not None:
|
||||
retry_source_context["indexer_id"] = indexer_id
|
||||
|
||||
indexer = normalize_optional_text(release_data.get("indexer") or extra.get("indexer"))
|
||||
if indexer is not None and indexer.lower() != "unknown":
|
||||
retry_source_context["indexer"] = indexer
|
||||
|
||||
info_url = normalize_optional_text(release_data.get("info_url") or extra.get("info_url"))
|
||||
if info_url is not None:
|
||||
retry_source_context["info_url"] = info_url
|
||||
|
||||
if source_id is not None:
|
||||
retry_source_context["source_id"] = source_id
|
||||
|
||||
return {
|
||||
"retry_download_url": normalize_optional_text(
|
||||
get_preferred_download_url(prowlarr_result)
|
||||
),
|
||||
"retry_download_protocol": normalize_optional_text(get_protocol(prowlarr_result)),
|
||||
"retry_download_url": None,
|
||||
"retry_download_protocol": None,
|
||||
"retry_source_context": retry_source_context,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -139,13 +216,12 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
# Look up the cached release
|
||||
prowlarr_result = get_release(task.task_id)
|
||||
if not prowlarr_result:
|
||||
restored_request = self._restore_download_request_from_task(task)
|
||||
if restored_request is None:
|
||||
logger.warning("Release cache miss: %s", task.task_id)
|
||||
status_callback("error", "Release not found in cache (may have expired)")
|
||||
logger.info("Prowlarr release cache miss, refreshing: %s", task.task_id)
|
||||
prowlarr_result = self._refresh_release(task)
|
||||
if prowlarr_result is None:
|
||||
logger.warning("Prowlarr release refresh failed: %s", task.task_id)
|
||||
status_callback("error", EXPIRED_LINK_REFRESH_ERROR)
|
||||
return None
|
||||
logger.info("Restored Prowlarr download request for retry: %s", task.task_id)
|
||||
return restored_request
|
||||
|
||||
# Extract download URL
|
||||
download_url = get_preferred_download_url(prowlarr_result)
|
||||
@@ -171,6 +247,28 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
seeding_time_limit = _coerce_positive_minutes(raw_configured_seed_time)
|
||||
ratio_limit = float(raw_configured_ratio) if raw_configured_ratio is not None else None
|
||||
|
||||
# Fallback: search-time enrichment can be missing when the indexer
|
||||
# settings fetch transiently failed during the search (#795).
|
||||
# Re-resolve the limits from Prowlarr at grab time so torrents are
|
||||
# never sent to the client without their configured share limits.
|
||||
if seeding_time_limit is None and ratio_limit is None and protocol == "torrent":
|
||||
fallback = self._fetch_seed_settings_fallback(prowlarr_result.get("indexerId"))
|
||||
if fallback:
|
||||
seeding_time_limit = _coerce_positive_minutes(
|
||||
fallback.get("seeding_time_limit_minutes")
|
||||
)
|
||||
raw_ratio = fallback.get("ratio_limit")
|
||||
ratio_limit = float(raw_ratio) if raw_ratio is not None else None
|
||||
|
||||
if seeding_time_limit is None and ratio_limit is None and protocol == "torrent":
|
||||
logger.warning(
|
||||
"Prowlarr seed preferences are enabled but no share limits "
|
||||
"could be resolved for release '%s' (indexerId=%s); the "
|
||||
"torrent will use the client's global limits",
|
||||
release_name,
|
||||
prowlarr_result.get("indexerId"),
|
||||
)
|
||||
|
||||
return DownloadRequest(
|
||||
url=download_url,
|
||||
protocol=protocol,
|
||||
@@ -180,6 +278,92 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
ratio_limit=ratio_limit,
|
||||
)
|
||||
|
||||
def _refresh_release(self, task: DownloadTask) -> dict[str, Any] | None:
|
||||
"""Re-query Prowlarr and cache the exact original release if it still exists."""
|
||||
title = normalize_optional_text(task.title)
|
||||
if title is None:
|
||||
return None
|
||||
|
||||
context = getattr(task, "retry_source_context", None)
|
||||
if not isinstance(context, dict):
|
||||
context = {}
|
||||
|
||||
indexer = normalize_optional_text(context.get("indexer"))
|
||||
book = BookMetadata(
|
||||
provider="shelfmark",
|
||||
provider_id=task.task_id,
|
||||
title=title,
|
||||
authors=[task.author] if task.author else [],
|
||||
search_title=title,
|
||||
search_author=task.author,
|
||||
)
|
||||
plan = build_release_search_plan(
|
||||
book,
|
||||
indexers=[indexer] if indexer is not None else None,
|
||||
)
|
||||
|
||||
source = ProwlarrSource()
|
||||
results = source.search(book, plan, content_type=task.content_type or "ebook")
|
||||
for release in results:
|
||||
raw_release = get_release(release.source_id)
|
||||
if raw_release is None:
|
||||
continue
|
||||
if not self._raw_release_matches_task(raw_release, task.task_id):
|
||||
continue
|
||||
|
||||
cache_release(task.task_id, raw_release)
|
||||
logger.info("Refreshed Prowlarr release: %s", task.task_id)
|
||||
return raw_release
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _raw_release_matches_task(raw_release: dict[str, Any], task_id: str) -> bool:
|
||||
wanted = normalize_optional_text(task_id)
|
||||
if wanted is None:
|
||||
return False
|
||||
|
||||
bare = [
|
||||
identity
|
||||
for identity in (
|
||||
normalize_optional_text(raw_release.get("guid")),
|
||||
normalize_optional_text(raw_release.get("infoUrl")),
|
||||
)
|
||||
if identity is not None
|
||||
]
|
||||
|
||||
identities = [*bare, build_source_id(raw_release)]
|
||||
indexer_id = coerce_int_like(raw_release.get("indexerId"))
|
||||
if indexer_id is not None:
|
||||
identities.extend(f"{indexer_id}:{identity}" for identity in bare)
|
||||
|
||||
return wanted in identities
|
||||
|
||||
def _refresh_download_request_after_add_failure(
|
||||
self,
|
||||
*,
|
||||
task: DownloadTask,
|
||||
request: DownloadRequest,
|
||||
error: Exception,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> DownloadRequest | None:
|
||||
"""Refresh once when a cached Prowlarr torrent proxy URL has expired."""
|
||||
if request.protocol != "torrent":
|
||||
return None
|
||||
if HASH_DETECTION_ERROR not in str(error):
|
||||
return None
|
||||
|
||||
parsed = urlparse(request.url)
|
||||
if parsed.scheme.lower() not in {"http", "https"}:
|
||||
return None
|
||||
|
||||
logger.info("Refreshing stale Prowlarr torrent URL for %s", task.task_id)
|
||||
remove_release(task.task_id)
|
||||
refreshed_request = self._resolve_download(task, status_callback)
|
||||
if refreshed_request is None:
|
||||
raise RuntimeError(EXPIRED_LINK_REFRESH_ERROR) from error
|
||||
return refreshed_request
|
||||
|
||||
def _on_download_complete(self, task: DownloadTask) -> None:
|
||||
"""Remove completed release from the Prowlarr cache."""
|
||||
remove_release(task.task_id)
|
||||
|
||||
@@ -190,6 +190,18 @@ def prowlarr_config_settings() -> list[SettingsField]:
|
||||
description="Automatically retry search without category filtering if no results are found",
|
||||
show_when={"field": "PROWLARR_ENABLED", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="PROWLARR_COLLAPSE_DUPLICATES",
|
||||
label="Show one row per release",
|
||||
default=True,
|
||||
description=(
|
||||
"Collapse a release that several indexer entries returned down to a single row, "
|
||||
"keeping the entry with the best Prowlarr priority. Turn this off to see every "
|
||||
"entry that carried it, which is what makes results from filter-specific entries "
|
||||
"(freeleech and the like) visible."
|
||||
),
|
||||
show_when={"field": "PROWLARR_ENABLED", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="PROWLARR_USE_SEED_PREFERENCES",
|
||||
label="Use Prowlarr seed preferences",
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
import re
|
||||
import time
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, ClassVar, NoReturn
|
||||
|
||||
import requests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.languages import normalize_language
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import normalize_optional_text
|
||||
from shelfmark.core.search_plan import ReleaseSearchVariant
|
||||
@@ -30,6 +34,7 @@ from shelfmark.release_sources import (
|
||||
from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient
|
||||
from shelfmark.release_sources.prowlarr.cache import cache_release
|
||||
from shelfmark.release_sources.prowlarr.utils import (
|
||||
build_source_id,
|
||||
coerce_float_like,
|
||||
coerce_int_like,
|
||||
get_protocol,
|
||||
@@ -41,6 +46,14 @@ _SIZE_UNIT_BASE = 1024
|
||||
_TWO_FORMATS = 2
|
||||
_PROWLARR_SOURCE_ERRORS = (AttributeError, OSError, RuntimeError, TypeError, ValueError)
|
||||
|
||||
# Prowlarr indexer priority is 1-50 and lower is preferred; unknown sorts last.
|
||||
_UNRANKED_INDEXER_RANK = 51
|
||||
|
||||
# Errors that can surface from ProwlarrClient.get_indexer_seed_settings(). The
|
||||
# client raises requests exceptions (subclasses of OSError via IOError lineage
|
||||
# is not guaranteed), so include RequestException explicitly.
|
||||
_PROWLARR_SEED_SETTINGS_ERRORS = (*_PROWLARR_SOURCE_ERRORS, requests.exceptions.RequestException)
|
||||
|
||||
|
||||
def _raise_timeout_error(message: str) -> NoReturn:
|
||||
raise TimeoutError(message)
|
||||
@@ -61,6 +74,118 @@ def _coerce_indexer_id(value: object) -> int | None:
|
||||
return coerce_int_like(value)
|
||||
|
||||
|
||||
def _identity_text(value: object) -> str | None:
|
||||
"""Trimmed text for an identity field, or None when there is nothing usable."""
|
||||
if isinstance(value, str):
|
||||
return value.strip() or None
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
def _release_identity(result: dict) -> str | None:
|
||||
"""Identify the underlying release, independent of which indexer surfaced it.
|
||||
|
||||
Strong identifiers only. Title is deliberately excluded because matching on
|
||||
it here would merge two genuinely different releases that happen to share a
|
||||
name, and every caller of this either drops or overwrites a row on a match.
|
||||
Returns None when nothing identifies the result.
|
||||
"""
|
||||
for field in ("guid", "downloadUrl", "magnetUrl", "infoUrl"):
|
||||
identity = _identity_text(result.get(field))
|
||||
if identity is not None:
|
||||
return identity
|
||||
return None
|
||||
|
||||
|
||||
def _result_dedup_key(result: dict) -> tuple[int | None, str] | None:
|
||||
"""Dedup key for a raw Prowlarr result, or None if it cannot be identified.
|
||||
|
||||
One tracker is often configured in Prowlarr as several indexer entries that
|
||||
differ only by a server-side search filter, say a "freeleech only" entry
|
||||
alongside an unfiltered one. Those entries return the same guid for the same
|
||||
torrent, so keying on the guid alone throws away the filtered entry's copy
|
||||
and with it the only signal that the release matched the filter. Including
|
||||
the indexer id keeps the entries distinct.
|
||||
|
||||
Title is an acceptable last resort here, unlike in _release_identity, because
|
||||
the indexer id is part of the key: it only ever collapses a literal repeat
|
||||
from one indexer, never two rows from different entries.
|
||||
"""
|
||||
identity = _release_identity(result) or _identity_text(result.get("title"))
|
||||
if identity is None:
|
||||
return None
|
||||
return (_coerce_indexer_id(result.get("indexerId")), identity)
|
||||
|
||||
|
||||
def _build_indexer_priority(indexers: list[dict]) -> dict[int, int]:
|
||||
"""Map indexer id to the priority configured in Prowlarr. Lower is preferred.
|
||||
|
||||
Users already rank their indexers in Prowlarr, and on trackers configured as
|
||||
several entries that ranking is usually the meaningful one: a "freeleech
|
||||
only" entry is typically given a better priority than the unfiltered entry
|
||||
beside it. Reusing it avoids asking for the same ordering a second time.
|
||||
"""
|
||||
priority: dict[int, int] = {}
|
||||
for indexer in indexers:
|
||||
indexer_id = _coerce_indexer_id(indexer.get("id"))
|
||||
if indexer_id is None:
|
||||
continue
|
||||
rank = coerce_int_like(indexer.get("priority"))
|
||||
if rank is not None:
|
||||
priority[indexer_id] = rank
|
||||
|
||||
return priority
|
||||
|
||||
|
||||
def _rank_for_indexer_id(indexer_id: object, priority: dict[int, int]) -> int:
|
||||
"""Preference rank for an indexer id. Lower wins, unknown ranks last."""
|
||||
coerced = _coerce_indexer_id(indexer_id)
|
||||
if coerced is None:
|
||||
return _UNRANKED_INDEXER_RANK
|
||||
return priority.get(coerced, _UNRANKED_INDEXER_RANK)
|
||||
|
||||
|
||||
def _indexer_rank(result: dict, priority: dict[int, int]) -> int:
|
||||
"""Preference rank of the indexer that surfaced a raw result."""
|
||||
return _rank_for_indexer_id(result.get("indexerId"), priority)
|
||||
|
||||
|
||||
def _release_indexer_rank(release: Release, priority: dict[int, int]) -> int:
|
||||
"""Preference rank of the indexer that surfaced a converted release."""
|
||||
return _rank_for_indexer_id(release.extra.get("indexer_id"), priority)
|
||||
|
||||
|
||||
def _collapse_duplicate_indexer_results(
|
||||
results: list[dict], priority: dict[int, int]
|
||||
) -> list[dict]:
|
||||
"""Reduce a release to a single row, keeping the preferred indexer entry.
|
||||
|
||||
Opt-in behaviour for users who want one row per torrent. Ties keep the
|
||||
result that was queried first, and the winner holds the loser's position so
|
||||
the overall result order stays stable.
|
||||
"""
|
||||
position_by_identity: dict[str, int] = {}
|
||||
kept: list[dict] = []
|
||||
|
||||
for result in results:
|
||||
identity = _release_identity(result)
|
||||
if identity is None:
|
||||
kept.append(result)
|
||||
continue
|
||||
|
||||
existing_position = position_by_identity.get(identity)
|
||||
if existing_position is None:
|
||||
position_by_identity[identity] = len(kept)
|
||||
kept.append(result)
|
||||
continue
|
||||
|
||||
if _indexer_rank(result, priority) < _indexer_rank(kept[existing_position], priority):
|
||||
kept[existing_position] = result
|
||||
|
||||
return kept
|
||||
|
||||
|
||||
def _parse_size(size_bytes: int | None) -> str | None:
|
||||
"""Convert bytes to human-readable size string."""
|
||||
if size_bytes is None or size_bytes <= 0:
|
||||
@@ -102,50 +227,6 @@ AUDIOBOOK_FORMATS = ["m4b", "mp3", "m4a", "flac", "ogg", "wma", "aac", "wav", "o
|
||||
# Combined list for format detection (audiobook formats first for priority)
|
||||
ALL_BOOK_FORMATS = AUDIOBOOK_FORMATS + EBOOK_FORMATS
|
||||
|
||||
# Map 3-char MAM language codes to 2-char ISO codes used by frontend color maps
|
||||
MAM_LANGUAGE_MAP = {
|
||||
"eng": "en",
|
||||
"ita": "it",
|
||||
"spa": "es",
|
||||
"fra": "fr",
|
||||
"fre": "fr",
|
||||
"ger": "de",
|
||||
"deu": "de",
|
||||
"por": "pt",
|
||||
"rus": "ru",
|
||||
"jpn": "ja",
|
||||
"jap": "ja",
|
||||
"chi": "zh",
|
||||
"zho": "zh",
|
||||
"dut": "nl",
|
||||
"nld": "nl",
|
||||
"swe": "sv",
|
||||
"nor": "no",
|
||||
"dan": "da",
|
||||
"fin": "fi",
|
||||
"pol": "pl",
|
||||
"cze": "cs",
|
||||
"ces": "cs",
|
||||
"hun": "hu",
|
||||
"kor": "ko",
|
||||
"ara": "ar",
|
||||
"heb": "he",
|
||||
"tur": "tr",
|
||||
"gre": "el",
|
||||
"ell": "el",
|
||||
"hin": "hi",
|
||||
"tha": "th",
|
||||
"vie": "vi",
|
||||
"ind": "id",
|
||||
"ukr": "uk",
|
||||
"rom": "ro",
|
||||
"ron": "ro",
|
||||
"bul": "bg",
|
||||
"cat": "ca",
|
||||
"hrv": "hr",
|
||||
"slv": "sl",
|
||||
"srp": "sr",
|
||||
}
|
||||
|
||||
# Backend safeguard: cap total Prowlarr search time per request.
|
||||
PROWLARR_SEARCH_TIMEOUT_SECONDS = 120.0
|
||||
@@ -193,8 +274,9 @@ def _extract_mam_language(raw_title: str) -> str | None:
|
||||
|
||||
for token in tokens:
|
||||
lang_code = token.lower()
|
||||
if lang_code in MAM_LANGUAGE_MAP:
|
||||
return MAM_LANGUAGE_MAP[lang_code]
|
||||
resolved = normalize_language(lang_code)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
|
||||
return None
|
||||
|
||||
@@ -379,8 +461,7 @@ def _prowlarr_result_to_release(
|
||||
formats_display = _formats_display(formats)
|
||||
language_detected = _extract_mam_language(str(raw_title or ""))
|
||||
|
||||
# Build the source_id from GUID or generate from indexer + title
|
||||
source_id = result.get("guid") or f"{indexer}:{hash(raw_title)}"
|
||||
source_id = build_source_id(result)
|
||||
|
||||
# Cache the raw Prowlarr result so handler can look it up by source_id
|
||||
cache_release(source_id, result)
|
||||
@@ -443,6 +524,36 @@ def _prowlarr_result_to_release(
|
||||
)
|
||||
|
||||
|
||||
# Last successfully fetched per-indexer share limits. Used as a fallback when
|
||||
# a transient Prowlarr API failure prevents fetching fresh settings during a
|
||||
# search, so results are never silently cached without seed limits (#795).
|
||||
_seed_settings_lock = Lock()
|
||||
_last_known_seed_settings: dict[int, IndexerSeedSettings] = {}
|
||||
|
||||
|
||||
def _fetch_indexer_seed_settings(
|
||||
client: ProwlarrClient,
|
||||
indexer_ids: list[int] | None,
|
||||
) -> dict[int, IndexerSeedSettings]:
|
||||
"""Fetch per-indexer share limits, falling back to last-known-good on failure."""
|
||||
try:
|
||||
fetched = client.get_indexer_seed_settings(restrict_to=indexer_ids)
|
||||
except _PROWLARR_SEED_SETTINGS_ERRORS:
|
||||
with _seed_settings_lock:
|
||||
fallback = dict(_last_known_seed_settings)
|
||||
logger.warning(
|
||||
"Failed to fetch Prowlarr indexer seed settings; "
|
||||
"falling back to last known settings for %s indexer(s)",
|
||||
len(fallback),
|
||||
exc_info=True,
|
||||
)
|
||||
return fallback
|
||||
|
||||
with _seed_settings_lock:
|
||||
_last_known_seed_settings.update(fetched)
|
||||
return fetched
|
||||
|
||||
|
||||
def _apply_indexer_seed_settings(
|
||||
result: dict,
|
||||
indexer_seed_settings: dict[int, IndexerSeedSettings],
|
||||
@@ -574,6 +685,11 @@ class ProwlarrSource(ReleaseSource):
|
||||
],
|
||||
extra_sort_options=[
|
||||
SortOption(label="Peers", sort_key="seeders"),
|
||||
SortOption(
|
||||
label="Indexer priority",
|
||||
sort_key="extra.indexer_priority",
|
||||
default_direction="asc",
|
||||
),
|
||||
],
|
||||
grid_template="minmax(0,2fr) minmax(140px,1fr) 50px 50px 90px 80px",
|
||||
leading_cell=LeadingCellConfig(
|
||||
@@ -779,11 +895,15 @@ class ProwlarrSource(ReleaseSource):
|
||||
try:
|
||||
auto_expand_enabled = config.get("PROWLARR_AUTO_EXPAND", False)
|
||||
deadline = time.monotonic() + PROWLARR_SEARCH_TIMEOUT_SECONDS
|
||||
enabled_indexers = client.get_enabled_indexers_detailed()
|
||||
indexer_priority = _build_indexer_priority(enabled_indexers)
|
||||
# Some indexers benefit from title+author queries and extra format detection.
|
||||
enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids)
|
||||
enriched_indexer_ids = client.get_enriched_indexer_ids(
|
||||
restrict_to=indexer_ids, indexers=enabled_indexers
|
||||
)
|
||||
enriched_indexer_ids_set = set(enriched_indexer_ids)
|
||||
indexer_seed_settings = (
|
||||
client.get_indexer_seed_settings(restrict_to=indexer_ids)
|
||||
_fetch_indexer_seed_settings(client, indexer_ids)
|
||||
if config.get("PROWLARR_USE_SEED_PREFERENCES", False)
|
||||
else {}
|
||||
)
|
||||
@@ -821,7 +941,7 @@ class ProwlarrSource(ReleaseSource):
|
||||
|
||||
return results
|
||||
|
||||
seen_keys: set[str] = set()
|
||||
seen_keys: set[tuple[int | None, str]] = set()
|
||||
all_results: list[dict] = []
|
||||
|
||||
for idx, variant in enumerate(variants, start=1):
|
||||
@@ -849,18 +969,22 @@ class ProwlarrSource(ReleaseSource):
|
||||
self.last_search_type = "expanded"
|
||||
|
||||
for r in raw_results:
|
||||
key = (
|
||||
r.get("guid")
|
||||
or r.get("downloadUrl")
|
||||
or r.get("magnetUrl")
|
||||
or r.get("infoUrl")
|
||||
or f"{r.get('indexerId')}:{r.get('title')}"
|
||||
)
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
key = _result_dedup_key(r)
|
||||
if key is not None:
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
all_results.append(r)
|
||||
|
||||
if config.get("PROWLARR_COLLAPSE_DUPLICATES", True):
|
||||
before_collapse = len(all_results)
|
||||
all_results = _collapse_duplicate_indexer_results(all_results, indexer_priority)
|
||||
if len(all_results) != before_collapse:
|
||||
logger.debug(
|
||||
"Prowlarr: collapsed %s duplicate result(s) across indexer entries",
|
||||
before_collapse - len(all_results),
|
||||
)
|
||||
|
||||
results: list[Release] = []
|
||||
enriched_source_ids: set[str] = set()
|
||||
|
||||
@@ -879,13 +1003,19 @@ class ProwlarrSource(ReleaseSource):
|
||||
content_type,
|
||||
enable_format_detection=is_enriched,
|
||||
)
|
||||
if idx_id_int is not None and idx_id_int in indexer_priority:
|
||||
release.extra["indexer_priority"] = indexer_priority[idx_id_int]
|
||||
results.append(release)
|
||||
|
||||
if is_enriched:
|
||||
enriched_source_ids.add(release.source_id)
|
||||
|
||||
# Sort results: enriched indexers first, then others
|
||||
results.sort(key=lambda r: 0 if r.source_id in enriched_source_ids else 1)
|
||||
results.sort(
|
||||
key=lambda r: (
|
||||
_release_indexer_rank(r, indexer_priority),
|
||||
0 if r.source_id in enriched_source_ids else 1,
|
||||
)
|
||||
)
|
||||
|
||||
if results:
|
||||
torrent_count = sum(1 for r in results if r.protocol == ReleaseProtocol.TORRENT)
|
||||
|
||||
@@ -32,6 +32,28 @@ def coerce_int_like(value: object) -> int | None:
|
||||
return int(normalized)
|
||||
|
||||
|
||||
def build_source_id(result: dict) -> str:
|
||||
"""Build the Release.source_id for a raw Prowlarr result.
|
||||
|
||||
Qualified by the indexer id because one tracker is often configured in
|
||||
Prowlarr as several indexer entries that differ only by a server-side search
|
||||
filter, and those entries return the same guid for the same torrent. Without
|
||||
the qualifier the entries collide in the release cache and a grab routes
|
||||
through whichever entry happened to cache last.
|
||||
"""
|
||||
guid = result.get("guid")
|
||||
if guid:
|
||||
base = str(guid)
|
||||
else:
|
||||
indexer = result.get("indexer", "Unknown")
|
||||
base = f"{indexer}:{hash(result.get('title', 'Unknown'))}"
|
||||
|
||||
indexer_id = coerce_int_like(result.get("indexerId"))
|
||||
if indexer_id is None:
|
||||
return base
|
||||
return f"{indexer_id}:{base}"
|
||||
|
||||
|
||||
def coerce_float_like(value: object) -> float | None:
|
||||
"""Return a float for float-like config/API values, else None."""
|
||||
if isinstance(value, bool):
|
||||
|
||||
Generated
+762
-401
File diff suppressed because it is too large
Load Diff
@@ -17,24 +17,24 @@
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"knip": "^6.23.0",
|
||||
"oxfmt": "^0.57.0",
|
||||
"oxlint": "^1.72.0",
|
||||
"oxlint-tsgolint": "^0.23.0",
|
||||
"knip": "^6.27.0",
|
||||
"oxfmt": "^0.59.0",
|
||||
"oxlint": "^1.74.0",
|
||||
"oxlint-tsgolint": "^0.25.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.0",
|
||||
"vitest": "^4.1.9"
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1098,6 +1098,9 @@ function App() {
|
||||
series_name: book.series_name,
|
||||
series_position: book.series_position,
|
||||
subtitle: book.subtitle,
|
||||
// From the release, never the book: book.language is the provider's
|
||||
// canonical edition, which would mislabel a translated release.
|
||||
language: release.language ?? undefined,
|
||||
};
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -382,7 +382,11 @@ export const ReleaseCell = ({
|
||||
className={`flex items-center ${alignClass} gap-1.5 truncate text-xs text-gray-600 dark:text-gray-300`}
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${dotColor}`} title={protocolLabel} />
|
||||
<span className="truncate">{displayValue}</span>
|
||||
{/* Titled because one tracker can appear as several indexer entries whose
|
||||
names share a prefix, and truncation would make the rows look identical */}
|
||||
<span className="truncate" title={displayValue}>
|
||||
{displayValue}
|
||||
</span>
|
||||
{peers && <span className="shrink-0 text-gray-400 dark:text-gray-500">{peers}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -956,7 +956,7 @@ const ReleaseModalSession = ({
|
||||
const fromExtra = (columnConfig.extra_sort_options || []).map((opt) => ({
|
||||
label: opt.label,
|
||||
sortKey: opt.sort_key,
|
||||
defaultDirection: 'desc' as const, // Extra sort options are typically numeric (e.g., peers)
|
||||
defaultDirection: opt.default_direction ?? ('desc' as const),
|
||||
}));
|
||||
return [...fromColumns, ...fromExtra];
|
||||
}, [sortableColumns, columnConfig.extra_sort_options]);
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
normalizeUserOverrideSections,
|
||||
UserOverridesSections,
|
||||
} from './users';
|
||||
import type { PerUserSettings } from './users/types';
|
||||
import { UserAccountCardContent, UserEditActions, UserIdentityHeader } from './users/UserCard';
|
||||
import { useUserOverridesState } from './users/useUserOverridesState';
|
||||
|
||||
@@ -153,7 +152,7 @@ const SelfSettingsModalSession = ({
|
||||
normalizeUserOverrideSections(context.visibleUserSettingsSections, 'self'),
|
||||
);
|
||||
applyUserOverridesContext({
|
||||
settings: (context.user.settings || {}) as PerUserSettings,
|
||||
settings: context.user.settings || {},
|
||||
userOverridableKeys: context.userOverridableKeys || [],
|
||||
});
|
||||
setEditPassword('');
|
||||
|
||||
@@ -146,7 +146,7 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
|
||||
if (fullUser.settings) {
|
||||
userSettings = {
|
||||
...fullUser.settings,
|
||||
} as PerUserSettings;
|
||||
};
|
||||
}
|
||||
const userOverridableSettings = new Set<string>();
|
||||
|
||||
@@ -162,7 +162,7 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
|
||||
userSettings = {
|
||||
...userSettings,
|
||||
...deliveryResult.value.userOverrides,
|
||||
} as PerUserSettings;
|
||||
};
|
||||
}
|
||||
deliveryResult.value.keys.forEach((key) => userOverridableSettings.add(key));
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
|
||||
userSettings = {
|
||||
...userSettings,
|
||||
...searchResult.value.userOverrides,
|
||||
} as PerUserSettings;
|
||||
};
|
||||
}
|
||||
searchResult.value.keys.forEach((key) => userOverridableSettings.add(key));
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
|
||||
userSettings = {
|
||||
...userSettings,
|
||||
...notificationResult.value.userOverrides,
|
||||
} as PerUserSettings;
|
||||
};
|
||||
}
|
||||
notificationResult.value.keys.forEach((key) => userOverridableSettings.add(key));
|
||||
}
|
||||
|
||||
@@ -500,6 +500,7 @@ export type DownloadReleasePayload = {
|
||||
series_name?: string;
|
||||
series_position?: number;
|
||||
subtitle?: string;
|
||||
language?: string; // Release language code, for the {Language} naming variable
|
||||
search_author?: string;
|
||||
search_mode?: 'direct' | 'universal';
|
||||
};
|
||||
|
||||
@@ -48,4 +48,47 @@ describe('namingTemplatePreview', () => {
|
||||
expect(preview.unknownTokens).toEqual(['NotAThing']);
|
||||
expect(preview.value).toBe('Arthur Conan Doyle');
|
||||
});
|
||||
|
||||
it('offers Language as a core variable for both content types', () => {
|
||||
const language = NAMING_TEMPLATE_TOKENS.find((token) => token.token === 'Language');
|
||||
|
||||
expect(language?.group).toBe('Core');
|
||||
expect(language?.audiobookOnly).toBeFalsy();
|
||||
});
|
||||
|
||||
it('separates translated editions into their own folder', () => {
|
||||
const template = '{Author}/{Title}{ (Language)}';
|
||||
|
||||
const swedish = renderNamingTemplate(
|
||||
template,
|
||||
{
|
||||
...SAMPLE_NAMING_METADATA,
|
||||
Author: 'Andy Weir',
|
||||
Title: 'Project Hail Mary',
|
||||
Language: 'sv',
|
||||
},
|
||||
{ allowPathSeparators: true },
|
||||
);
|
||||
const english = renderNamingTemplate(
|
||||
template,
|
||||
{ ...SAMPLE_NAMING_METADATA, Author: 'Andy Weir', Title: 'Project Hail Mary', Language: '' },
|
||||
{ allowPathSeparators: true },
|
||||
);
|
||||
|
||||
expect(swedish.value).toBe('Andy Weir/Project Hail Mary (sv)');
|
||||
expect(english.value).toBe('Andy Weir/Project Hail Mary');
|
||||
expect(swedish.value).not.toBe(english.value);
|
||||
});
|
||||
|
||||
it('keeps the picker and the known-token list in lockstep', () => {
|
||||
// KNOWN_TOKENS is a hand-maintained duplicate of the Python list. A token
|
||||
// added to the picker but not to it would render as an unknown variable.
|
||||
for (const token of NAMING_TEMPLATE_TOKENS) {
|
||||
const preview = renderNamingTemplate(`{${token.token}}`, SAMPLE_NAMING_METADATA, {
|
||||
allowPathSeparators: true,
|
||||
});
|
||||
|
||||
expect(preview.unknownTokens, `${token.token} is missing from KNOWN_TOKENS`).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,4 +138,29 @@ describe('requestPayload utilities', () => {
|
||||
);
|
||||
expect(getRequestSuccessMessage(payloadUntitled)).toBe('Request submitted: Untitled');
|
||||
});
|
||||
|
||||
it('takes the language from the release, not the book', () => {
|
||||
// book.language is the metadata provider's canonical edition. Using it would
|
||||
// label a Swedish release "en" and put both editions back in one folder.
|
||||
const data = buildReleaseDataFromMetadataRelease(
|
||||
{ ...baseBook, language: 'en' },
|
||||
{ ...baseRelease, language: 'sv' },
|
||||
'ebook',
|
||||
);
|
||||
|
||||
expect(data.language).toBe('sv');
|
||||
});
|
||||
|
||||
it('leaves language undefined when the release has none', () => {
|
||||
const data = buildReleaseDataFromMetadataRelease(baseBook, baseRelease, 'ebook');
|
||||
|
||||
expect(data.language).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the book language when browsing a source directly', () => {
|
||||
// In direct mode the book record IS the release record.
|
||||
const data = buildReleaseDataFromDirectBook({ ...baseBook, language: 'de' });
|
||||
|
||||
expect(data.language).toBe('de');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -414,6 +414,7 @@ export interface LeadingCellConfig {
|
||||
export interface ExtraSortOption {
|
||||
label: string; // Display label in the sort dropdown
|
||||
sort_key: string; // Field to sort by on the Release object
|
||||
default_direction?: 'asc' | 'desc'; // Which way "best first" runs (defaults to desc)
|
||||
}
|
||||
|
||||
export interface SourceActionButton {
|
||||
|
||||
@@ -48,6 +48,13 @@ export const NAMING_TEMPLATE_TOKENS: NamingTemplateToken[] = [
|
||||
value: '1902',
|
||||
group: 'Core',
|
||||
},
|
||||
{
|
||||
token: 'Language',
|
||||
label: 'Language',
|
||||
description: 'Release language code, so translations do not share a folder',
|
||||
value: 'en',
|
||||
group: 'Core',
|
||||
},
|
||||
{
|
||||
token: 'User',
|
||||
label: 'User',
|
||||
@@ -98,6 +105,7 @@ const KNOWN_TOKENS = [
|
||||
'primarytitle',
|
||||
'originalname',
|
||||
'partnumber',
|
||||
'language',
|
||||
'subtitle',
|
||||
'author',
|
||||
'series',
|
||||
|
||||
@@ -90,6 +90,7 @@ export const buildReleaseDataFromMetadataRelease = (
|
||||
series_position: book.series_position,
|
||||
series_count: book.series_count,
|
||||
subtitle: book.subtitle,
|
||||
language: release.language,
|
||||
...(isSourceBackedReleaseContext ? { search_mode: 'direct' as const } : {}),
|
||||
};
|
||||
};
|
||||
@@ -106,6 +107,8 @@ export const buildReleaseDataFromDirectBook = (book: Book) => {
|
||||
size: book.size,
|
||||
preview: book.preview,
|
||||
content_type: 'ebook' as const,
|
||||
// Browsing a source directly means the book record IS the release record.
|
||||
language: book.language,
|
||||
search_mode: 'direct' as const,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -169,6 +169,7 @@ uv run pytest tests/ --cov=shelfmark -m "not integration"
|
||||
```python
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestMyFeature:
|
||||
def test_something(self, monkeypatch):
|
||||
# Mock config values
|
||||
@@ -188,6 +189,7 @@ class TestMyFeature:
|
||||
import pytest
|
||||
from .conftest import APIClient, DownloadTracker
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestMyEndpoint:
|
||||
def test_endpoint_works(self, protected_api_client: APIClient):
|
||||
|
||||
@@ -53,7 +53,7 @@ def test_generated_env_docs_include_custom_component_value_fields() -> None:
|
||||
|
||||
assert (
|
||||
"| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: "
|
||||
"{Author}, {Title}, {Year}, {User}, {OriginalName} "
|
||||
"{Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
||||
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
|
||||
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix:"
|
||||
) in docs
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests for the OIDC Test Connection handler."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from shelfmark.config.security_handlers import check_oidc_connection
|
||||
|
||||
DISCOVERY_URL = "https://auth.example.com/.well-known/openid-configuration"
|
||||
|
||||
DISCOVERY_DOCUMENT = {
|
||||
"issuer": "https://auth.example.com",
|
||||
"authorization_endpoint": "https://auth.example.com/authorize",
|
||||
"token_endpoint": "https://auth.example.com/token",
|
||||
"jwks_uri": "https://auth.example.com/jwks",
|
||||
}
|
||||
|
||||
|
||||
def _mock_response(payload):
|
||||
response = MagicMock()
|
||||
response.json.return_value = payload
|
||||
response.raise_for_status.return_value = None
|
||||
return response
|
||||
|
||||
|
||||
def _run_check(responses):
|
||||
"""Run check_oidc_connection with requests.get returning the given responses."""
|
||||
with (
|
||||
patch("requests.get", side_effect=responses) as mock_get,
|
||||
patch("shelfmark.config.security_handlers.get_ssl_verify", return_value=True),
|
||||
):
|
||||
result = check_oidc_connection(
|
||||
load_security_config=lambda: {"OIDC_DISCOVERY_URL": DISCOVERY_URL},
|
||||
current_values={},
|
||||
logger=MagicMock(),
|
||||
)
|
||||
return result, mock_get
|
||||
|
||||
|
||||
class TestCheckOIDCConnection:
|
||||
def test_succeeds_when_discovery_and_jwks_are_valid(self):
|
||||
responses = [
|
||||
_mock_response(DISCOVERY_DOCUMENT),
|
||||
_mock_response({"keys": [{"kty": "RSA", "kid": "abc"}]}),
|
||||
]
|
||||
result, mock_get = _run_check(responses)
|
||||
assert result["success"] is True
|
||||
assert "Connected to" in result["message"]
|
||||
jwks_call = mock_get.call_args_list[1]
|
||||
assert jwks_call.args[0] == DISCOVERY_DOCUMENT["jwks_uri"]
|
||||
|
||||
def test_fails_with_signing_key_guidance_when_jwks_is_empty(self):
|
||||
responses = [
|
||||
_mock_response(DISCOVERY_DOCUMENT),
|
||||
_mock_response({}),
|
||||
]
|
||||
result, _ = _run_check(responses)
|
||||
assert result["success"] is False
|
||||
assert "no token signing keys" in result["message"]
|
||||
assert "Signing Key" in result["message"]
|
||||
|
||||
def test_fails_with_signing_key_guidance_when_jwks_keys_list_is_empty(self):
|
||||
responses = [
|
||||
_mock_response(DISCOVERY_DOCUMENT),
|
||||
_mock_response({"keys": []}),
|
||||
]
|
||||
result, _ = _run_check(responses)
|
||||
assert result["success"] is False
|
||||
assert "no token signing keys" in result["message"]
|
||||
|
||||
def test_fails_when_discovery_document_missing_jwks_uri(self):
|
||||
document = {k: v for k, v in DISCOVERY_DOCUMENT.items() if k != "jwks_uri"}
|
||||
responses = [_mock_response(document)]
|
||||
result, _ = _run_check(responses)
|
||||
assert result["success"] is False
|
||||
assert "jwks_uri" in result["message"]
|
||||
|
||||
def test_fails_when_jwks_request_errors(self):
|
||||
jwks_response = MagicMock()
|
||||
jwks_response.raise_for_status.side_effect = RuntimeError("boom")
|
||||
responses = [_mock_response(DISCOVERY_DOCUMENT), jwks_response]
|
||||
result, _ = _run_check(responses)
|
||||
assert result["success"] is False
|
||||
assert "Connection failed" in result["message"]
|
||||
@@ -1,9 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
TOR_SCRIPT_PATH = Path(__file__).resolve().parents[2] / "tor.sh"
|
||||
|
||||
IPTABLES_BLOCK_START = 'echo "[*] Setting up iptables rules..."'
|
||||
IPTABLES_BLOCK_END = 'echo "[✓] Transparent Tor routing enabled."'
|
||||
|
||||
|
||||
def _generated_tor_healthcheck_script() -> str:
|
||||
script = TOR_SCRIPT_PATH.read_text()
|
||||
@@ -14,17 +25,208 @@ def _generated_tor_healthcheck_script() -> str:
|
||||
|
||||
|
||||
def _tor_script_rule_lines() -> list[str]:
|
||||
return [
|
||||
line.strip()
|
||||
for line in TOR_SCRIPT_PATH.read_text().splitlines()
|
||||
if line.strip().startswith("iptables ")
|
||||
]
|
||||
"""Return the iptables invocations in tor.sh, in file order.
|
||||
|
||||
Rules that are applied on a best-effort basis are written as
|
||||
``if iptables ...; then``, so the shell wrapper is stripped to keep the
|
||||
ordering assertions below comparing rules rather than syntax.
|
||||
"""
|
||||
lines = []
|
||||
for raw_line in TOR_SCRIPT_PATH.read_text().splitlines():
|
||||
line = raw_line.strip().removeprefix("if ")
|
||||
if line.startswith("iptables "):
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def _line_index(lines: list[str], needle: str) -> int:
|
||||
return next(index for index, line in enumerate(lines) if needle in line)
|
||||
|
||||
|
||||
def _tor_iptables_block() -> str:
|
||||
"""Extract the firewall setup section of tor.sh so it can be executed."""
|
||||
script = TOR_SCRIPT_PATH.read_text()
|
||||
start = script.index(IPTABLES_BLOCK_START)
|
||||
end = script.index(IPTABLES_BLOCK_END)
|
||||
return script[start:end]
|
||||
|
||||
|
||||
# Verbatim stderr of an iptables build whose kernel lacks the xt_owner module,
|
||||
# as reported from Synology DSM in issue #1150.
|
||||
MISSING_XT_OWNER_STDERR = (
|
||||
"Warning: Extension owner revision 0 not supported, missing kernel module?\n"
|
||||
"iptables: No chain/target/match by that name.\n"
|
||||
)
|
||||
|
||||
IPTABLES_STUB = f"""#!/bin/bash
|
||||
if [ "$FAKE_OWNER_MATCH_SUPPORTED" != "1" ]; then
|
||||
case "$*" in
|
||||
*--uid-owner*)
|
||||
printf '%s' {MISSING_XT_OWNER_STDERR!r} >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
echo "$*" >> "$FAKE_IPTABLES_LOG"
|
||||
exit 0
|
||||
"""
|
||||
|
||||
# tor.sh resolves the Tor uid with `id -u debian-tor`; that account only exists
|
||||
# inside the image, so stub it out with the uid the Debian tor package uses.
|
||||
ID_STUB = """#!/bin/bash
|
||||
echo 107
|
||||
"""
|
||||
|
||||
|
||||
def _run_tor_iptables_block(tmp_path: Path, *, owner_match_supported: bool):
|
||||
"""Run tor.sh's firewall block against a fake iptables.
|
||||
|
||||
Returns the completed process plus the rules the fake iptables accepted.
|
||||
"""
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
for name, source in (("iptables", IPTABLES_STUB), ("id", ID_STUB)):
|
||||
stub = bin_dir / name
|
||||
stub.write_text(source)
|
||||
stub.chmod(0o755)
|
||||
|
||||
applied_rules_log = tmp_path / "applied-rules.log"
|
||||
# tor.sh runs under `set -e`, which is what turns a rejected rule into a
|
||||
# container restart loop, so reproduce that here.
|
||||
result = subprocess.run(
|
||||
["bash", "-c", "set -e\n" + _tor_iptables_block()],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={
|
||||
"PATH": f"{bin_dir}:/usr/bin:/bin",
|
||||
"FAKE_IPTABLES_LOG": str(applied_rules_log),
|
||||
"FAKE_OWNER_MATCH_SUPPORTED": "1" if owner_match_supported else "0",
|
||||
},
|
||||
)
|
||||
applied_rules = applied_rules_log.read_text().splitlines() if applied_rules_log.exists() else []
|
||||
return result, applied_rules
|
||||
|
||||
|
||||
requires_bash = pytest.mark.skipif(
|
||||
shutil.which("bash") is None, reason="bash is required to execute tor.sh"
|
||||
)
|
||||
|
||||
# The watchdog shells out to `timeout`, which is not present on stock macOS.
|
||||
requires_timeout = pytest.mark.skipif(
|
||||
shutil.which("timeout") is None, reason="coreutils timeout is required"
|
||||
)
|
||||
|
||||
SUPERVISORCTL_STUB = """#!/bin/bash
|
||||
# Emulates `supervisorctl status tor`. Normally reports FAKE_TOR_STATE, but when
|
||||
# FAKE_TOR_FAIL_FIRST is set the first call reports a transient failure so the
|
||||
# watchdog's retry behaviour can be exercised.
|
||||
if [ -n "$FAKE_TOR_FAIL_FIRST" ] && [ ! -f "$FAKE_TOR_CALL_MARKER" ]; then
|
||||
touch "$FAKE_TOR_CALL_MARKER"
|
||||
echo "tor FATAL Exited too quickly"
|
||||
exit 3
|
||||
fi
|
||||
echo "tor $FAKE_TOR_STATE pid 42, uptime 0:01:00"
|
||||
"""
|
||||
|
||||
|
||||
def _watchdog_path_env(bin_dir: Path) -> str:
|
||||
"""PATH exposing the stubs plus the real bash/timeout the watchdog calls."""
|
||||
parts = [str(bin_dir)]
|
||||
for tool in ("bash", "timeout"):
|
||||
resolved = shutil.which(tool)
|
||||
if resolved:
|
||||
parts.append(str(Path(resolved).parent))
|
||||
parts += ["/usr/bin", "/bin"]
|
||||
return ":".join(parts)
|
||||
|
||||
|
||||
def _run_tor_watchdog(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
tor_state: str = "RUNNING",
|
||||
bootstrapped: bool = True,
|
||||
trans_port_open: bool = True,
|
||||
fail_first: bool = False,
|
||||
run_for: float = 2.0,
|
||||
):
|
||||
"""Run tor.sh's watchdog against a fake Tor and see if it resets the container.
|
||||
|
||||
Returns ``(reset_signal, stdout)`` where ``reset_signal`` is the signal the
|
||||
stand-in for PID 1 received, or ``None`` if it was left alone.
|
||||
"""
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
supervisorctl = bin_dir / "supervisorctl"
|
||||
supervisorctl.write_text(SUPERVISORCTL_STUB)
|
||||
supervisorctl.chmod(0o755)
|
||||
|
||||
notices_log = tmp_path / "notices.log"
|
||||
notices_log.write_text(
|
||||
"Bootstrapped 100%: Done\n" if bootstrapped else "Bootstrapped 45%: Loading\n"
|
||||
)
|
||||
|
||||
script = tmp_path / "tor_healthcheck.sh"
|
||||
script.write_text(_generated_tor_healthcheck_script())
|
||||
script.chmod(0o755)
|
||||
|
||||
listener = socket.socket()
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
trans_port = listener.getsockname()[1]
|
||||
if trans_port_open:
|
||||
listener.listen(5)
|
||||
else:
|
||||
# Closing it leaves a port nothing is listening on, which is what a dead
|
||||
# or wedged Tor looks like from the outside.
|
||||
listener.close()
|
||||
|
||||
# Stands in for PID 1: the watchdog signals it instead of dumb-init.
|
||||
container_process = subprocess.Popen(
|
||||
["sleep", "120"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
env = {
|
||||
"PATH": _watchdog_path_env(bin_dir),
|
||||
"TOR_CHECK_INTERVAL": "0.2",
|
||||
"TOR_NOTICES_LOG": str(notices_log),
|
||||
"TOR_TRANS_PORT": str(trans_port),
|
||||
"TOR_CONTAINER_PID": str(container_process.pid),
|
||||
"FAKE_TOR_STATE": tor_state,
|
||||
"FAKE_TOR_CALL_MARKER": str(tmp_path / "supervisorctl.called"),
|
||||
}
|
||||
if fail_first:
|
||||
env["FAKE_TOR_FAIL_FIRST"] = "1"
|
||||
|
||||
watchdog = subprocess.Popen(
|
||||
["bash", str(script)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
try:
|
||||
container_process.wait(timeout=run_for)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
reset_signal = (
|
||||
-container_process.returncode if container_process.poll() is not None else None
|
||||
)
|
||||
finally:
|
||||
# Only the watchdog gets its own session; signalling the group is how we
|
||||
# reap the `sleep` and `timeout` children it spawns.
|
||||
with suppress(ProcessLookupError, PermissionError):
|
||||
os.killpg(os.getpgid(watchdog.pid), signal.SIGKILL)
|
||||
with suppress(ProcessLookupError):
|
||||
container_process.kill()
|
||||
with suppress(OSError):
|
||||
listener.close()
|
||||
container_process.wait()
|
||||
stdout = watchdog.communicate()[0] or ""
|
||||
|
||||
return reset_signal, stdout
|
||||
|
||||
|
||||
def test_tor_nat_rules_bypass_private_networks_before_tcp_redirect():
|
||||
lines = _tor_script_rule_lines()
|
||||
tcp_redirect_index = _line_index(lines, "--syn -j REDIRECT --to-ports 9040")
|
||||
@@ -63,3 +265,119 @@ def test_tor_healthcheck_uses_local_tor_state_without_clear_net_probe():
|
||||
assert "curl " not in healthcheck_script
|
||||
assert "supervisorctl status tor" in healthcheck_script
|
||||
assert "Bootstrapped 100%" in healthcheck_script
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_tor_iptables_setup_survives_kernel_without_owner_match(tmp_path):
|
||||
"""Regression test for issue #1150.
|
||||
|
||||
Synology (and other NAS/embedded) kernels ship without xt_owner. The owner
|
||||
exemption is a best-effort optimisation, so a kernel that rejects it must
|
||||
not abort tor.sh and put the container into a restart loop.
|
||||
"""
|
||||
result, _ = _run_tor_iptables_block(tmp_path, owner_match_supported=False)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
"tor.sh aborted on a kernel without xt_owner:\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_tor_routing_still_applied_when_owner_match_unsupported(tmp_path):
|
||||
"""Degrading past the owner rule must not skip the rules that torify traffic."""
|
||||
_, applied_rules = _run_tor_iptables_block(tmp_path, owner_match_supported=False)
|
||||
|
||||
assert any("--syn -j REDIRECT --to-ports 9040" in rule for rule in applied_rules)
|
||||
for cidr in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"):
|
||||
assert any(f"-d {cidr} -j RETURN" in rule for rule in applied_rules)
|
||||
for protocol in ("udp", "tcp"):
|
||||
assert any(f"-p {protocol} --dport 53" in rule for rule in applied_rules)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_tor_warns_when_owner_match_unsupported(tmp_path):
|
||||
"""The degraded path has to be visible in the logs, not silent."""
|
||||
result, _ = _run_tor_iptables_block(tmp_path, owner_match_supported=False)
|
||||
|
||||
assert "owner" in result.stdout.lower()
|
||||
assert "[!]" in result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_tor_exempts_own_traffic_when_owner_match_supported(tmp_path):
|
||||
"""On a normal kernel the exemption must still be installed."""
|
||||
result, applied_rules = _run_tor_iptables_block(tmp_path, owner_match_supported=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
owner_rules = [rule for rule in applied_rules if "--uid-owner" in rule]
|
||||
assert len(owner_rules) == 1
|
||||
assert "-j RETURN" in owner_rules[0]
|
||||
|
||||
owner_index = applied_rules.index(owner_rules[0])
|
||||
redirect_index = next(
|
||||
index for index, rule in enumerate(applied_rules) if "--syn -j REDIRECT" in rule
|
||||
)
|
||||
assert owner_index < redirect_index
|
||||
|
||||
|
||||
@requires_bash
|
||||
@requires_timeout
|
||||
def test_tor_watchdog_resets_container_when_tor_dies(tmp_path):
|
||||
"""A Tor that is no longer running must take the container down with it."""
|
||||
reset_signal, stdout = _run_tor_watchdog(tmp_path, tor_state="STOPPED")
|
||||
|
||||
assert reset_signal == signal.SIGTERM, f"container was not reset:\n{stdout}"
|
||||
|
||||
|
||||
@requires_bash
|
||||
@requires_timeout
|
||||
def test_tor_watchdog_resets_container_when_tor_stops_accepting_connections(tmp_path):
|
||||
"""Tor can be 'running' yet unusable; the container must still be reset."""
|
||||
reset_signal, stdout = _run_tor_watchdog(tmp_path, tor_state="RUNNING", trans_port_open=False)
|
||||
|
||||
assert reset_signal == signal.SIGTERM, f"container was not reset:\n{stdout}"
|
||||
|
||||
|
||||
@requires_bash
|
||||
@requires_timeout
|
||||
def test_tor_watchdog_leaves_healthy_tor_alone(tmp_path):
|
||||
"""A healthy Tor must never be reset, no matter how many cycles run."""
|
||||
reset_signal, stdout = _run_tor_watchdog(tmp_path)
|
||||
|
||||
assert reset_signal is None, f"healthy Tor was reset:\n{stdout}"
|
||||
|
||||
|
||||
@requires_bash
|
||||
@requires_timeout
|
||||
def test_tor_watchdog_retries_once_before_resetting(tmp_path):
|
||||
"""A single transient failure is retried, not escalated to a reset."""
|
||||
reset_signal, stdout = _run_tor_watchdog(tmp_path, fail_first=True)
|
||||
|
||||
assert reset_signal is None, f"transient failure caused a reset:\n{stdout}"
|
||||
assert "Healthcheck failed (1/2)" in stdout
|
||||
assert "Tor recovered." in stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
@requires_timeout
|
||||
def test_tor_watchdog_waits_for_first_bootstrap_before_policing(tmp_path):
|
||||
"""A slow first bootstrap must not be mistaken for a failure.
|
||||
|
||||
Otherwise every user on a slow link would be reset into a restart loop
|
||||
before Tor ever had a chance to come up.
|
||||
"""
|
||||
reset_signal, stdout = _run_tor_watchdog(
|
||||
tmp_path, tor_state="STOPPED", bootstrapped=False, trans_port_open=False
|
||||
)
|
||||
|
||||
assert reset_signal is None, f"reset during initial bootstrap:\n{stdout}"
|
||||
assert "Waiting for initial Tor bootstrap" in stdout
|
||||
|
||||
|
||||
def test_tor_watchdog_escalates_to_container_reset_not_in_place_restart():
|
||||
"""The watchdog must signal PID 1 rather than bounce Tor under supervisor."""
|
||||
healthcheck_script = _generated_tor_healthcheck_script()
|
||||
|
||||
assert "kill -TERM" in healthcheck_script
|
||||
assert "supervisorctl restart" not in healthcheck_script
|
||||
|
||||
@@ -29,6 +29,16 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_torrent_fetch_cache():
|
||||
"""Keep the shared torrent fetch cache from leaking between tests."""
|
||||
from shelfmark.download.clients.torrent_utils import clear_torrent_fetch_cache
|
||||
|
||||
clear_torrent_fetch_cache()
|
||||
yield
|
||||
clear_torrent_fetch_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_prowlarr_result():
|
||||
"""Sample Prowlarr API search result."""
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
{
|
||||
"_comment": "Frozen snapshot of the per-source language handling as it was before consolidation into shelfmark.core.languages. prowlarr_three_letter and audiobookbay_names were explicit maps; direct_download_derived is what its loader built from the data file, including the underscore spelling of a hyphenated code. Every alias here must still resolve to the same code. The one deliberate change: Traditional Chinese was canonically 'zh‑Hant' with a U+2011 non-breaking hyphen and is now the ASCII 'zh-Hant', so expectations for it name the new code while the old spelling remains a resolvable alias.",
|
||||
"audiobookbay_names": {
|
||||
"afrikaans": "af",
|
||||
"arabic": "ar",
|
||||
"bangla": "bn",
|
||||
"bengali": "bn",
|
||||
"bosnian": "bs",
|
||||
"bulgarian": "bg",
|
||||
"burmese": "my",
|
||||
"catalan": "ca",
|
||||
"chinese": "zh",
|
||||
"croatian": "hr",
|
||||
"czech": "cs",
|
||||
"danish": "da",
|
||||
"dutch": "nl",
|
||||
"english": "en",
|
||||
"estonian": "et",
|
||||
"farsi": "fa",
|
||||
"filipino": "fil",
|
||||
"finnish": "fi",
|
||||
"french": "fr",
|
||||
"german": "de",
|
||||
"greek": "el",
|
||||
"gujarati": "gu",
|
||||
"hebrew": "he",
|
||||
"hindi": "hi",
|
||||
"hungarian": "hu",
|
||||
"icelandic": "is",
|
||||
"indonesian": "id",
|
||||
"irish": "ga",
|
||||
"italian": "it",
|
||||
"japanese": "ja",
|
||||
"javanese": "jv",
|
||||
"kannada": "kn",
|
||||
"korean": "ko",
|
||||
"latin": "la",
|
||||
"latvian": "lv",
|
||||
"lithuanian": "lt",
|
||||
"malay": "ms",
|
||||
"malayalam": "ml",
|
||||
"manx": "gv",
|
||||
"marathi": "mr",
|
||||
"norwegian": "no",
|
||||
"persian": "fa",
|
||||
"polish": "pl",
|
||||
"portuguese": "pt",
|
||||
"punjabi": "pa",
|
||||
"romanian": "ro",
|
||||
"russian": "ru",
|
||||
"sanskrit": "sa",
|
||||
"scottish gaelic": "gd",
|
||||
"serbian": "sr",
|
||||
"slovenian": "sl",
|
||||
"spanish": "es",
|
||||
"swedish": "sv",
|
||||
"tagalog": "fil",
|
||||
"tamil": "ta",
|
||||
"telugu": "te",
|
||||
"thai": "th",
|
||||
"turkish": "tr",
|
||||
"ukrainian": "uk",
|
||||
"urdu": "ur",
|
||||
"vietnamese": "vi"
|
||||
},
|
||||
"direct_download_derived": {
|
||||
"af": "af",
|
||||
"afrikaans": "af",
|
||||
"albanian": "sq",
|
||||
"ar": "ar",
|
||||
"arabic": "ar",
|
||||
"armenian": "hy",
|
||||
"az": "az",
|
||||
"azerbaijani": "az",
|
||||
"ba": "ba",
|
||||
"bangla": "bn",
|
||||
"bashkir": "ba",
|
||||
"be": "be",
|
||||
"belarusian": "be",
|
||||
"bg": "bg",
|
||||
"bn": "bn",
|
||||
"bo": "bo",
|
||||
"bulgarian": "bg",
|
||||
"ca": "ca",
|
||||
"catalan": "ca",
|
||||
"chinese": "zh",
|
||||
"croatian": "hr",
|
||||
"cs": "cs",
|
||||
"czech": "cs",
|
||||
"da": "da",
|
||||
"danish": "da",
|
||||
"de": "de",
|
||||
"dutch": "nl",
|
||||
"el": "el",
|
||||
"en": "en",
|
||||
"english": "en",
|
||||
"eo": "eo",
|
||||
"es": "es",
|
||||
"esperanto": "eo",
|
||||
"fa": "fa",
|
||||
"fi": "fi",
|
||||
"fil": "fil",
|
||||
"filipino": "fil",
|
||||
"finnish": "fi",
|
||||
"fr": "fr",
|
||||
"french": "fr",
|
||||
"ga": "ga",
|
||||
"galician": "gl",
|
||||
"georgian": "ka",
|
||||
"german": "de",
|
||||
"gl": "gl",
|
||||
"greek": "el",
|
||||
"gu": "gu",
|
||||
"gujarati": "gu",
|
||||
"he": "he",
|
||||
"hebrew": "he",
|
||||
"hi": "hi",
|
||||
"hindi": "hi",
|
||||
"hr": "hr",
|
||||
"hu": "hu",
|
||||
"hungarian": "hu",
|
||||
"hy": "hy",
|
||||
"id": "id",
|
||||
"indonesian": "id",
|
||||
"irish": "ga",
|
||||
"it": "it",
|
||||
"italian": "it",
|
||||
"ja": "ja",
|
||||
"japanese": "ja",
|
||||
"javanese": "jv",
|
||||
"jv": "jv",
|
||||
"ka": "ka",
|
||||
"kannada": "kn",
|
||||
"kazakh": "kk",
|
||||
"kinyarwanda": "rw",
|
||||
"kk": "kk",
|
||||
"kn": "kn",
|
||||
"ko": "ko",
|
||||
"korean": "ko",
|
||||
"ky": "ky",
|
||||
"kyrgyz": "ky",
|
||||
"la": "la",
|
||||
"latin": "la",
|
||||
"latvian": "lv",
|
||||
"lithuanian": "lt",
|
||||
"lt": "lt",
|
||||
"lv": "lv",
|
||||
"malay": "ms",
|
||||
"malayalam": "ml",
|
||||
"marathi": "mr",
|
||||
"ml": "ml",
|
||||
"mn": "mn",
|
||||
"mongolian": "mn",
|
||||
"mr": "mr",
|
||||
"ms": "ms",
|
||||
"nl": "nl",
|
||||
"no": "no",
|
||||
"norwegian": "no",
|
||||
"pa": "pa",
|
||||
"persian": "fa",
|
||||
"pl": "pl",
|
||||
"polish": "pl",
|
||||
"portuguese": "pt",
|
||||
"pt": "pt",
|
||||
"punjabi": "pa",
|
||||
"qu": "qu",
|
||||
"quechua": "qu",
|
||||
"ro": "ro",
|
||||
"romanian": "ro",
|
||||
"ru": "ru",
|
||||
"russian": "ru",
|
||||
"rw": "rw",
|
||||
"serbian": "sr",
|
||||
"shan": "shn",
|
||||
"shn": "shn",
|
||||
"sk": "sk",
|
||||
"sl": "sl",
|
||||
"slovak": "sk",
|
||||
"slovenian": "sl",
|
||||
"spanish": "es",
|
||||
"sq": "sq",
|
||||
"sr": "sr",
|
||||
"sv": "sv",
|
||||
"sw": "sw",
|
||||
"swahili": "sw",
|
||||
"swedish": "sv",
|
||||
"ta": "ta",
|
||||
"tamil": "ta",
|
||||
"te": "te",
|
||||
"telugu": "te",
|
||||
"th": "th",
|
||||
"thai": "th",
|
||||
"tibetan": "bo",
|
||||
"tr": "tr",
|
||||
"traditional chinese": "zh-Hant",
|
||||
"turkish": "tr",
|
||||
"ug": "ug",
|
||||
"uk": "uk",
|
||||
"ukrainian": "uk",
|
||||
"ur": "ur",
|
||||
"urdu": "ur",
|
||||
"uyghur": "ug",
|
||||
"vi": "vi",
|
||||
"vietnamese": "vi",
|
||||
"zh": "zh",
|
||||
"zh‑hant": "zh-Hant"
|
||||
},
|
||||
"prowlarr_three_letter": {
|
||||
"afr": "af",
|
||||
"ara": "ar",
|
||||
"ben": "bn",
|
||||
"bos": "bs",
|
||||
"bul": "bg",
|
||||
"bur": "my",
|
||||
"cat": "ca",
|
||||
"ces": "cs",
|
||||
"chi": "zh",
|
||||
"cze": "cs",
|
||||
"dan": "da",
|
||||
"deu": "de",
|
||||
"dut": "nl",
|
||||
"ell": "el",
|
||||
"eng": "en",
|
||||
"est": "et",
|
||||
"fas": "fa",
|
||||
"fin": "fi",
|
||||
"fra": "fr",
|
||||
"fre": "fr",
|
||||
"ger": "de",
|
||||
"gla": "gd",
|
||||
"gle": "ga",
|
||||
"glv": "gv",
|
||||
"gre": "el",
|
||||
"guj": "gu",
|
||||
"heb": "he",
|
||||
"hin": "hi",
|
||||
"hrv": "hr",
|
||||
"hun": "hu",
|
||||
"ice": "is",
|
||||
"ind": "id",
|
||||
"isl": "is",
|
||||
"ita": "it",
|
||||
"jap": "ja",
|
||||
"jav": "jv",
|
||||
"jpn": "ja",
|
||||
"kan": "kn",
|
||||
"kor": "ko",
|
||||
"lat": "la",
|
||||
"lav": "lv",
|
||||
"lit": "lt",
|
||||
"mal": "ml",
|
||||
"mar": "mr",
|
||||
"may": "ms",
|
||||
"msa": "ms",
|
||||
"mya": "my",
|
||||
"nld": "nl",
|
||||
"nor": "no",
|
||||
"pan": "pa",
|
||||
"per": "fa",
|
||||
"pol": "pl",
|
||||
"por": "pt",
|
||||
"rom": "ro",
|
||||
"ron": "ro",
|
||||
"rus": "ru",
|
||||
"san": "sa",
|
||||
"slv": "sl",
|
||||
"spa": "es",
|
||||
"srp": "sr",
|
||||
"swe": "sv",
|
||||
"tam": "ta",
|
||||
"tel": "te",
|
||||
"tgl": "fil",
|
||||
"tha": "th",
|
||||
"tur": "tr",
|
||||
"ukr": "uk",
|
||||
"urd": "ur",
|
||||
"vie": "vi",
|
||||
"zho": "zh"
|
||||
}
|
||||
}
|
||||
@@ -131,3 +131,46 @@ def test_frontend_dist_resolves_from_repo_root(main_module):
|
||||
|
||||
assert main_module.PROJECT_ROOT == expected_project_root
|
||||
assert main_module.FRONTEND_DIST == expected_project_root / "frontend-dist"
|
||||
|
||||
|
||||
def test_config_endpoint_serves_languages_without_resolution_aliases(main_module, client):
|
||||
"""book_languages is a client contract, not a dump of the language data file.
|
||||
|
||||
data/book-languages.json also carries the aliases used to resolve a source's
|
||||
spelling of a language to a code. Those are server-side only: the frontend
|
||||
Language type is {code, language}, and shipping the aliases inflated every
|
||||
config response by around 40%.
|
||||
"""
|
||||
_set_session(client, user_id="reader-1", db_user_id=1, is_admin=False)
|
||||
|
||||
with (
|
||||
patch("shelfmark.config.env._is_config_dir_writable", return_value=True),
|
||||
patch("shelfmark.core.onboarding.is_onboarding_complete", return_value=True),
|
||||
):
|
||||
resp = client.get("/api/config")
|
||||
|
||||
assert resp.status_code == 200
|
||||
languages = resp.get_json()["book_languages"]
|
||||
|
||||
assert languages, "no languages served"
|
||||
offending = [entry for entry in languages if set(entry) != {"code", "language"}]
|
||||
assert offending == [], f"unexpected keys leaked to clients: {offending[:3]}"
|
||||
|
||||
|
||||
def test_language_data_file_is_only_read_by_the_shared_module(main_module):
|
||||
"""Reading data/book-languages.json anywhere else reintroduces the drift the
|
||||
shared module exists to prevent, and bypasses the alias handling."""
|
||||
del main_module
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
allowed = {Path("shelfmark/core/languages.py")}
|
||||
|
||||
offenders = []
|
||||
for path in (repo_root / "shelfmark").rglob("*.py"):
|
||||
relative = path.relative_to(repo_root)
|
||||
if relative in allowed:
|
||||
continue
|
||||
if "book-languages" in path.read_text(encoding="utf-8"):
|
||||
offenders.append(str(relative))
|
||||
|
||||
assert offenders == [], f"should use shelfmark.core.languages instead: {offenders}"
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Tests for the {Language} template variable.
|
||||
|
||||
Different-language editions of one book resolve to the same title, so without a
|
||||
language token they render to the same path and land in one folder. Audiobookshelf
|
||||
treats a folder as exactly one library item, so the two editions become a single
|
||||
book with both files as tracks (calibrain/shelfmark#1138).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.naming import (
|
||||
KNOWN_TOKENS,
|
||||
normalize_language_code,
|
||||
parse_naming_template,
|
||||
)
|
||||
from shelfmark.download.orchestrator import (
|
||||
_restore_task_from_retry_payload,
|
||||
serialize_task_for_retry,
|
||||
)
|
||||
from shelfmark.download.postprocess.transfer import build_metadata_dict
|
||||
|
||||
|
||||
class TestLanguageInKnownTokens:
|
||||
def test_language_in_known_tokens(self):
|
||||
assert "language" in KNOWN_TOKENS
|
||||
|
||||
def test_language_token_parsed(self):
|
||||
assert parse_naming_template("{Language}", {"Language": "sv"}) == "sv"
|
||||
|
||||
def test_language_token_case_insensitive(self):
|
||||
assert parse_naming_template("{language}", {"Language": "sv"}) == "sv"
|
||||
|
||||
|
||||
class TestKnownTokensOrdering:
|
||||
"""find_placeholder() does a substring find over KNOWN_TOKENS in list order.
|
||||
|
||||
Nothing else guards this contract, so a future token added in the wrong
|
||||
position would silently shadow an existing one.
|
||||
"""
|
||||
|
||||
def test_tokens_are_ordered_longest_first(self):
|
||||
lengths = [len(token) for token in KNOWN_TOKENS]
|
||||
assert lengths == sorted(lengths, reverse=True)
|
||||
|
||||
def test_no_token_is_shadowed_by_an_earlier_substring(self):
|
||||
for shorter_index, shorter in enumerate(KNOWN_TOKENS):
|
||||
for longer_index, longer in enumerate(KNOWN_TOKENS):
|
||||
if shorter is longer or shorter not in longer:
|
||||
continue
|
||||
assert shorter_index > longer_index, (
|
||||
f"{shorter!r} precedes {longer!r} and would shadow it"
|
||||
)
|
||||
|
||||
|
||||
class TestLanguageTemplateSubstitution:
|
||||
"""The acceptance cases from the issue."""
|
||||
|
||||
TEMPLATE = "{Author}/{Title}{ (Language)}/{Author} - {Title}"
|
||||
BASE = {"Author": "Andy Weir", "Title": "Project Hail Mary"}
|
||||
|
||||
def test_translated_edition_gets_its_own_folder(self):
|
||||
result = parse_naming_template(
|
||||
self.TEMPLATE, {**self.BASE, "Language": "sv"}, allow_path_separators=True
|
||||
)
|
||||
assert result == "Andy Weir/Project Hail Mary (sv)/Andy Weir - Project Hail Mary"
|
||||
|
||||
def test_untagged_edition_is_unchanged(self):
|
||||
result = parse_naming_template(
|
||||
self.TEMPLATE, {**self.BASE, "Language": None}, allow_path_separators=True
|
||||
)
|
||||
assert result == "Andy Weir/Project Hail Mary/Andy Weir - Project Hail Mary"
|
||||
|
||||
def test_the_two_editions_do_not_collide(self):
|
||||
english = parse_naming_template(
|
||||
self.TEMPLATE, {**self.BASE, "Language": None}, allow_path_separators=True
|
||||
)
|
||||
swedish = parse_naming_template(
|
||||
self.TEMPLATE, {**self.BASE, "Language": "sv"}, allow_path_separators=True
|
||||
)
|
||||
assert english != swedish
|
||||
|
||||
def test_language_as_a_leading_folder(self):
|
||||
result = parse_naming_template(
|
||||
"{Language/}{Author}/{Title}",
|
||||
{**self.BASE, "Language": "sv"},
|
||||
allow_path_separators=True,
|
||||
)
|
||||
assert result == "sv/Andy Weir/Project Hail Mary"
|
||||
|
||||
def test_language_in_a_filename_template(self):
|
||||
result = parse_naming_template(
|
||||
"{Author} - {Title}{ (Language)}", {**self.BASE, "Language": "sv"}
|
||||
)
|
||||
assert result == "Andy Weir - Project Hail Mary (sv)"
|
||||
|
||||
def test_language_is_sanitized(self):
|
||||
result = parse_naming_template("{Title}{ (Language)}", {"Title": "Book", "Language": "s/v"})
|
||||
assert "/" not in result
|
||||
|
||||
|
||||
class TestNormalizeLanguageCode:
|
||||
def test_lowercases(self):
|
||||
assert normalize_language_code("EN") == "en"
|
||||
assert normalize_language_code("Sv") == "sv"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert normalize_language_code(" sv ") == "sv"
|
||||
|
||||
def test_placeholders_render_empty(self):
|
||||
for placeholder in ("unknown", "unk", "n/a", "na", "-", "--", "none", "null", ""):
|
||||
assert normalize_language_code(placeholder) == "", placeholder
|
||||
|
||||
def test_placeholders_are_matched_case_insensitively(self):
|
||||
assert normalize_language_code("Unknown") == ""
|
||||
|
||||
def test_none_renders_empty(self):
|
||||
assert normalize_language_code(None) == ""
|
||||
|
||||
|
||||
class TestBuildMetadataWithLanguage:
|
||||
def test_language_reaches_the_template_metadata(self):
|
||||
task = DownloadTask(task_id="t", source="prowlarr", title="Book", language="sv")
|
||||
assert build_metadata_dict(task)["Language"] == "sv"
|
||||
|
||||
def test_language_is_normalized_on_the_way_out(self):
|
||||
task = DownloadTask(task_id="t", source="prowlarr", title="Book", language="SV")
|
||||
assert build_metadata_dict(task)["Language"] == "sv"
|
||||
|
||||
def test_placeholder_language_does_not_reach_the_path(self):
|
||||
# Anna's Archive reports the literal string "unknown" when it cannot tell.
|
||||
task = DownloadTask(task_id="t", source="direct", title="Book", language="unknown")
|
||||
assert build_metadata_dict(task)["Language"] == ""
|
||||
|
||||
def test_missing_language_renders_empty(self):
|
||||
task = DownloadTask(task_id="t", source="prowlarr", title="Book")
|
||||
assert build_metadata_dict(task)["Language"] == ""
|
||||
|
||||
|
||||
class TestLanguageSurvivesRetry:
|
||||
"""DownloadTask is not rebuilt from dataclasses.fields(), so each of the
|
||||
three orchestrator sites has to carry the field explicitly."""
|
||||
|
||||
def test_roundtrip_preserves_language(self):
|
||||
task = DownloadTask(task_id="t", source="prowlarr", title="Book", language="sv")
|
||||
restored = _restore_task_from_retry_payload(serialize_task_for_retry(task))
|
||||
assert restored is not None
|
||||
assert restored.language == "sv"
|
||||
|
||||
def test_legacy_payload_without_language_restores_cleanly(self):
|
||||
task = DownloadTask(task_id="t", source="prowlarr", title="Book", language="sv")
|
||||
payload = serialize_task_for_retry(task)
|
||||
del payload["language"]
|
||||
|
||||
restored = _restore_task_from_retry_payload(payload)
|
||||
|
||||
assert restored is not None
|
||||
assert restored.language is None
|
||||
|
||||
|
||||
class TestEverySpellingCollapsesToOneFolder:
|
||||
"""Sources report the same language differently; if the token rendered each
|
||||
spelling verbatim they would land in separate folders, which is the exact
|
||||
collision this token exists to prevent (reported on PR #1142)."""
|
||||
|
||||
TEMPLATE = "{Author}/{Title}{ (Language)}/{Title}"
|
||||
|
||||
def _folder(self, language):
|
||||
task = DownloadTask(
|
||||
task_id="t", source="prowlarr", title="Dune", author="Frank Herbert", language=language
|
||||
)
|
||||
return parse_naming_template(
|
||||
self.TEMPLATE, build_metadata_dict(task), allow_path_separators=True
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"spellings",
|
||||
[
|
||||
("en", "eng", "English", "english", "ENG", " Eng "),
|
||||
("sv", "swe", "Swedish"),
|
||||
("de", "ger", "deu", "German"),
|
||||
("ml", "mal", "Malayalam"),
|
||||
("fa", "per", "fas", "Farsi", "Persian"),
|
||||
],
|
||||
)
|
||||
def test_all_spellings_of_a_language_share_one_folder(self, spellings):
|
||||
rendered = {self._folder(spelling) for spelling in spellings}
|
||||
assert len(rendered) == 1, f"{spellings} produced {sorted(rendered)}"
|
||||
|
||||
def test_a_language_we_cannot_resolve_is_kept_rather_than_dropped(self):
|
||||
# It still separates editions, and cannot collide with a resolved code
|
||||
# precisely because nothing resolves to it.
|
||||
assert "klingon" in self._folder("Klingon")
|
||||
|
||||
def test_different_languages_still_get_different_folders(self):
|
||||
assert self._folder("English") != self._folder("Swedish")
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Tests for the shared language resolution used by every release source."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.languages import (
|
||||
LANGUAGE_DATA_PATH,
|
||||
known_language_codes,
|
||||
language_alias_map,
|
||||
language_name,
|
||||
normalize_language,
|
||||
supported_book_languages,
|
||||
)
|
||||
|
||||
BASELINE = json.loads(
|
||||
(Path(__file__).parent / "fixtures" / "language_alias_baseline.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestBaselineEquivalence:
|
||||
"""Every alias the per-source maps used to handle must still resolve the same.
|
||||
|
||||
These maps lived in prowlarr/source.py and audiobookbay/source.py before they
|
||||
were consolidated here. The fixture is a frozen snapshot taken before the
|
||||
move, so a regression shows up as a concrete alias rather than a vague
|
||||
behaviour change.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("alias", "expected"), sorted(BASELINE["prowlarr_three_letter"].items())
|
||||
)
|
||||
def test_prowlarr_three_letter_aliases_unchanged(self, alias, expected):
|
||||
assert normalize_language(alias) == expected
|
||||
|
||||
@pytest.mark.parametrize(("alias", "expected"), sorted(BASELINE["audiobookbay_names"].items()))
|
||||
def test_audiobookbay_names_unchanged(self, alias, expected):
|
||||
assert normalize_language(alias) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("alias", "expected"), sorted(BASELINE["direct_download_derived"].items())
|
||||
)
|
||||
def test_direct_download_derived_aliases_unchanged(self, alias, expected):
|
||||
# Direct Download built its aliases from the data file rather than a
|
||||
# literal map, so consolidating silently dropped the spellings it
|
||||
# derived -- notably the underscore form of a hyphenated code.
|
||||
assert normalize_language(alias) == expected
|
||||
|
||||
|
||||
class TestNormalizeLanguage:
|
||||
def test_accepts_two_letter_codes(self):
|
||||
assert normalize_language("en") == "en"
|
||||
assert normalize_language("sv") == "sv"
|
||||
|
||||
def test_accepts_three_letter_codes_in_both_iso_639_2_forms(self):
|
||||
# Bibliographic and terminological forms differ for these.
|
||||
assert normalize_language("ger") == normalize_language("deu") == "de"
|
||||
assert normalize_language("fre") == normalize_language("fra") == "fr"
|
||||
assert normalize_language("per") == normalize_language("fas") == "fa"
|
||||
assert normalize_language("ice") == normalize_language("isl") == "is"
|
||||
assert normalize_language("may") == normalize_language("msa") == "ms"
|
||||
|
||||
def test_accepts_english_names(self):
|
||||
assert normalize_language("Swedish") == "sv"
|
||||
assert normalize_language("Scottish Gaelic") == "gd"
|
||||
|
||||
def test_is_case_and_whitespace_insensitive(self):
|
||||
assert normalize_language(" ENG ") == "en"
|
||||
assert normalize_language("sWeDiSh") == "sv"
|
||||
|
||||
def test_returns_none_for_placeholders(self):
|
||||
for placeholder in ("unknown", "unk", "n/a", "na", "-", "--", "none", "null", "", " "):
|
||||
assert normalize_language(placeholder) is None, placeholder
|
||||
|
||||
def test_returns_none_for_unknown_values(self):
|
||||
assert normalize_language("xyz") is None
|
||||
assert normalize_language("Klingon") is None
|
||||
|
||||
def test_returns_none_for_none(self):
|
||||
assert normalize_language(None) is None
|
||||
|
||||
|
||||
class TestLanguageData:
|
||||
def test_every_alias_resolves_to_a_known_code(self):
|
||||
codes = known_language_codes()
|
||||
assert set(language_alias_map().values()) <= codes
|
||||
|
||||
def test_codes_are_ascii(self):
|
||||
# "zh-Hant" once used a U+2011 non-breaking hyphen, which silently
|
||||
# defeats any comparison against the normal spelling.
|
||||
entries = json.loads(LANGUAGE_DATA_PATH.read_text(encoding="utf-8"))
|
||||
assert [e["code"] for e in entries if not e["code"].isascii()] == []
|
||||
|
||||
def test_codes_are_unique(self):
|
||||
entries = json.loads(LANGUAGE_DATA_PATH.read_text(encoding="utf-8"))
|
||||
codes = [e["code"] for e in entries]
|
||||
assert len(codes) == len(set(codes))
|
||||
|
||||
def test_language_name_round_trips(self):
|
||||
assert language_name("sv") == "Swedish"
|
||||
assert language_name("ml") == "Malayalam"
|
||||
assert language_name("zzz") is None
|
||||
assert language_name(None) is None
|
||||
|
||||
|
||||
class TestMyAnonamouseCoverage:
|
||||
"""MyAnonamouse offers 62 languages and Prowlarr passes its code through
|
||||
untransformed, so every one has to resolve here or the language is lost."""
|
||||
|
||||
# Observed in live MyAnonamouse data via Prowlarr.
|
||||
OBSERVED = {"ENG": "en", "SWE": "sv", "MAL": "ml"}
|
||||
|
||||
@pytest.mark.parametrize(("tag", "expected"), sorted(OBSERVED.items()))
|
||||
def test_observed_tags_resolve(self, tag, expected):
|
||||
assert normalize_language(tag) == expected
|
||||
|
||||
def test_every_offered_language_resolves(self):
|
||||
# Names as MyAnonamouse's own searchLanguages selector lists them.
|
||||
offered = [
|
||||
"English",
|
||||
"Afrikaans",
|
||||
"Arabic",
|
||||
"Bengali",
|
||||
"Bosnian",
|
||||
"Bulgarian",
|
||||
"Burmese",
|
||||
"Catalan",
|
||||
"Chinese",
|
||||
"Croatian",
|
||||
"Czech",
|
||||
"Danish",
|
||||
"Dutch",
|
||||
"Estonian",
|
||||
"Farsi",
|
||||
"Finnish",
|
||||
"French",
|
||||
"German",
|
||||
"Greek",
|
||||
"Gujarati",
|
||||
"Hebrew",
|
||||
"Hindi",
|
||||
"Hungarian",
|
||||
"Icelandic",
|
||||
"Indonesian",
|
||||
"Irish",
|
||||
"Italian",
|
||||
"Japanese",
|
||||
"Javanese",
|
||||
"Kannada",
|
||||
"Korean",
|
||||
"Lithuanian",
|
||||
"Latin",
|
||||
"Latvian",
|
||||
"Malay",
|
||||
"Malayalam",
|
||||
"Manx",
|
||||
"Marathi",
|
||||
"Norwegian",
|
||||
"Polish",
|
||||
"Portuguese",
|
||||
"Punjabi",
|
||||
"Romanian",
|
||||
"Russian",
|
||||
"Scottish Gaelic",
|
||||
"Sanskrit",
|
||||
"Serbian",
|
||||
"Slovenian",
|
||||
"Spanish",
|
||||
"Swedish",
|
||||
"Tagalog",
|
||||
"Tamil",
|
||||
"Telugu",
|
||||
"Thai",
|
||||
"Turkish",
|
||||
"Ukrainian",
|
||||
"Urdu",
|
||||
"Vietnamese",
|
||||
]
|
||||
unresolved = [name for name in offered if normalize_language(name) is None]
|
||||
assert unresolved == []
|
||||
|
||||
|
||||
class TestSupportedBookLanguages:
|
||||
"""What the settings dropdown and /api/config expose to clients."""
|
||||
|
||||
def test_exposes_only_the_fields_clients_declare(self):
|
||||
# The frontend Language type is {code, language}. Aliases are an
|
||||
# implementation detail and would bloat every /api/config response.
|
||||
entries = supported_book_languages()
|
||||
assert entries
|
||||
assert all(set(e) == {"code", "language"} for e in entries)
|
||||
|
||||
def test_covers_every_known_code(self):
|
||||
assert {e["code"] for e in supported_book_languages()} == set(known_language_codes())
|
||||
|
||||
|
||||
class TestLegacyTraditionalChineseCode:
|
||||
"""Traditional Chinese was stored with a U+2011 non-breaking hyphen.
|
||||
|
||||
The canonical code is now the ASCII spelling, but anything persisted
|
||||
earlier still carries U+2011, so both have to resolve to the same language
|
||||
or those users lose their selection (reported on PR #1142).
|
||||
"""
|
||||
|
||||
LEGACY = "zh\u2011Hant"
|
||||
CANONICAL = "zh-Hant"
|
||||
|
||||
def test_the_legacy_spelling_still_resolves(self):
|
||||
assert normalize_language(self.LEGACY) == self.CANONICAL
|
||||
|
||||
def test_both_spellings_are_the_same_language(self):
|
||||
assert normalize_language(self.LEGACY) == normalize_language(self.CANONICAL)
|
||||
|
||||
def test_the_legacy_spelling_really_does_use_a_different_character(self):
|
||||
# Guards the test itself: if this ever became a plain hyphen the two
|
||||
# cases above would pass for the wrong reason.
|
||||
assert self.LEGACY != self.CANONICAL
|
||||
assert not self.LEGACY.isascii()
|
||||
|
||||
@pytest.mark.parametrize("dash", ["-", "\u2010", "\u2011", "\u2012", "\u2013", "\u2014"])
|
||||
def test_any_dash_variant_resolves(self, dash):
|
||||
assert normalize_language(f"zh{dash}Hant") == self.CANONICAL
|
||||
|
||||
|
||||
class TestCodesDoNotShadowEachOther:
|
||||
"""A code must never resolve to a different language than itself.
|
||||
|
||||
'zh' and 'zh-Hant' are distinct entries; registering the base of a
|
||||
hyphenated code as an alias made 'zh' resolve correctly only because
|
||||
Chinese happens to appear first in the data file.
|
||||
"""
|
||||
|
||||
def test_chinese_does_not_resolve_to_traditional_chinese(self):
|
||||
assert normalize_language("zh") == "zh"
|
||||
assert normalize_language("zh-Hant") == "zh-Hant"
|
||||
|
||||
def test_every_code_resolves_to_itself(self):
|
||||
for code in known_language_codes():
|
||||
assert normalize_language(code) == code, f"{code} resolved elsewhere"
|
||||
|
||||
|
||||
class TestSubtagSeparators:
|
||||
"""The U+2011 in the old Traditional Chinese code renders close enough to
|
||||
both a hyphen and an underscore that either is a plausible thing to type."""
|
||||
|
||||
@pytest.mark.parametrize("separator", ["-", "_", "‐", "‑", "–", "—"])
|
||||
def test_any_separator_spelling_resolves(self, separator):
|
||||
assert normalize_language(f"zh{separator}Hant") == "zh-Hant"
|
||||
|
||||
def test_separators_do_not_merge_unrelated_codes(self):
|
||||
# Folding a separator must not make one language answer to another.
|
||||
assert normalize_language("zh") == "zh"
|
||||
assert normalize_language("en_GB") is None
|
||||
@@ -413,6 +413,43 @@ class TestOIDCCallbackEndpoint:
|
||||
assert error is not None
|
||||
assert "issuer validation failed" in error
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_redirects_with_signing_key_guidance_on_empty_jwks(
|
||||
self, mock_get_client, client
|
||||
):
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_access_token.side_effect = KeyError("keys")
|
||||
fake_client.fetch_jwk_set.return_value = {}
|
||||
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
||||
|
||||
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
||||
error = _get_oidc_error(resp)
|
||||
assert error is not None
|
||||
assert "no token signing keys" in error
|
||||
assert "Signing Key" in error
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_uses_generic_error_when_jwks_has_keys(self, mock_get_client, client):
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_access_token.side_effect = KeyError("keys")
|
||||
fake_client.fetch_jwk_set.return_value = {"keys": [{"kty": "RSA", "kid": "abc"}]}
|
||||
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
||||
|
||||
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
||||
error = _get_oidc_error(resp)
|
||||
assert error == "Authentication failed"
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_uses_generic_error_when_jwks_diagnosis_fails(self, mock_get_client, client):
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_access_token.side_effect = KeyError("keys")
|
||||
fake_client.fetch_jwk_set.side_effect = RuntimeError("jwks fetch failed")
|
||||
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
||||
|
||||
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
||||
error = _get_oidc_error(resp)
|
||||
assert error == "Authentication failed"
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_redirects_when_auto_provision_disabled_and_no_email_match(
|
||||
self, mock_get_client, client
|
||||
|
||||
@@ -43,10 +43,10 @@ class TestNaturalSortKey:
|
||||
"file10.mp3",
|
||||
]
|
||||
|
||||
def test_uses_filename_only(self):
|
||||
def test_uses_full_path(self):
|
||||
files = [Path("/z/dir/file1.mp3"), Path("/a/dir/file2.mp3")]
|
||||
sorted_files = sorted(files, key=natural_sort_key)
|
||||
assert sorted_files[0].name == "file1.mp3"
|
||||
assert sorted_files == [Path("/a/dir/file2.mp3"), Path("/z/dir/file1.mp3")]
|
||||
|
||||
|
||||
class TestAssignPartNumbers:
|
||||
@@ -72,6 +72,23 @@ class TestAssignPartNumbers:
|
||||
(Path("Chapter 10.mp3"), "03"),
|
||||
]
|
||||
|
||||
def test_nested_folders_are_numbered_in_folder_order(self):
|
||||
files = [
|
||||
Path("06_Side 6/002.mp3"),
|
||||
Path("01_Side 1/001.mp3"),
|
||||
Path("00_Introduction/001_About.mp3"),
|
||||
Path("06_Side 6/001.mp3"),
|
||||
Path("01_Side 1/002.mp3"),
|
||||
]
|
||||
|
||||
assert assign_part_numbers(files) == [
|
||||
(Path("00_Introduction/001_About.mp3"), "01"),
|
||||
(Path("01_Side 1/001.mp3"), "02"),
|
||||
(Path("01_Side 1/002.mp3"), "03"),
|
||||
(Path("06_Side 6/001.mp3"), "04"),
|
||||
(Path("06_Side 6/002.mp3"), "05"),
|
||||
]
|
||||
|
||||
def test_custom_zero_padding(self):
|
||||
files = [Path("a.mp3"), Path("b.mp3")]
|
||||
assert assign_part_numbers(files, zero_pad_width=3) == [
|
||||
@@ -190,7 +207,10 @@ class TestEdgeCases:
|
||||
def test_identical_filenames_different_dirs(self):
|
||||
files = [Path("/dir2/track.mp3"), Path("/dir1/track.mp3")]
|
||||
result = assign_part_numbers(files)
|
||||
assert len(result) == 2
|
||||
assert result == [
|
||||
(Path("/dir1/track.mp3"), "01"),
|
||||
(Path("/dir2/track.mp3"), "02"),
|
||||
]
|
||||
|
||||
def test_unicode_filenames(self):
|
||||
files = [Path("日本語タイトル 02.mp3"), Path("日本語タイトル 01.mp3")]
|
||||
|
||||
@@ -205,7 +205,7 @@ def test_download_task_rejects_unavailable_source_before_handler(monkeypatch):
|
||||
orchestrator.get_handler.assert_not_called()
|
||||
|
||||
|
||||
def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
|
||||
def test_queue_release_persists_prowlarr_retry_context_without_download_url(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
@@ -227,6 +227,7 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
|
||||
"protocol": "torrent",
|
||||
"indexer": "MyIndexer",
|
||||
"extra": {
|
||||
"indexer_id": 12,
|
||||
"configured_ratio_limit": 1.25,
|
||||
"configured_seed_time_minutes": 90,
|
||||
"info_hash": "ABC123",
|
||||
@@ -239,14 +240,23 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
|
||||
assert success is True
|
||||
assert error is None
|
||||
task = captured["task"]
|
||||
assert task.retry_download_url == "magnet:?xt=urn:btih:abc123"
|
||||
assert task.retry_download_protocol == "torrent"
|
||||
assert task.retry_download_url is None
|
||||
assert task.retry_download_protocol is None
|
||||
assert task.retry_source_context == {
|
||||
"source_id": "prowlarr-release-1",
|
||||
"indexer": "MyIndexer",
|
||||
"indexer_id": 12,
|
||||
}
|
||||
assert task.retry_release_name == "Queued Prowlarr Release"
|
||||
assert task.retry_expected_hash == "ABC123"
|
||||
assert task.retry_ratio_limit == 1.25
|
||||
assert task.retry_seeding_time_limit_minutes == 90
|
||||
assert task.can_retry_without_staged_source is True
|
||||
|
||||
payload = orchestrator.serialize_task_for_retry(task)
|
||||
assert payload["retry_download_url"] is None
|
||||
assert payload["retry_source_context"] == task.retry_source_context
|
||||
|
||||
|
||||
def test_queue_release_prefers_configured_seed_time_minutes_for_retry(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
@@ -377,3 +387,58 @@ def test_queue_release_returns_error_for_operational_queue_failure(monkeypatch):
|
||||
|
||||
assert success is False
|
||||
assert error == "Error queueing release: queue offline"
|
||||
|
||||
|
||||
def _queue_and_capture(monkeypatch, release_data):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(orchestrator.config, "get", lambda key, default=None, user_id=None: default)
|
||||
monkeypatch.setattr(
|
||||
orchestrator.book_queue, "add", lambda task: captured.setdefault("task", task) or True
|
||||
)
|
||||
monkeypatch.setattr(orchestrator, "ws_manager", None)
|
||||
|
||||
success, error = orchestrator.queue_release(release_data, user_id=1, username="alice")
|
||||
|
||||
assert success is True, error
|
||||
return captured["task"]
|
||||
|
||||
|
||||
def test_queue_release_carries_top_level_language(monkeypatch):
|
||||
task = _queue_and_capture(
|
||||
monkeypatch,
|
||||
{
|
||||
"source": "prowlarr",
|
||||
"source_id": "release-sv",
|
||||
"title": "Project Hail Mary",
|
||||
"language": "sv",
|
||||
},
|
||||
)
|
||||
|
||||
assert task.language == "sv"
|
||||
|
||||
|
||||
def test_queue_release_falls_back_to_language_in_extra(monkeypatch):
|
||||
# direct_download sets language inside extra as well as top level.
|
||||
task = _queue_and_capture(
|
||||
monkeypatch,
|
||||
{
|
||||
"source": "direct_download",
|
||||
"source_id": "release-de",
|
||||
"title": "Project Hail Mary",
|
||||
"extra": {"language": "de"},
|
||||
},
|
||||
)
|
||||
|
||||
assert task.language == "de"
|
||||
|
||||
|
||||
def test_queue_release_without_language_leaves_it_unset(monkeypatch):
|
||||
task = _queue_and_capture(
|
||||
monkeypatch,
|
||||
{"source": "prowlarr", "source_id": "release-none", "title": "Project Hail Mary"},
|
||||
)
|
||||
|
||||
assert task.language is None
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_wait_for_dcc_ignores_unexpected_sender(monkeypatch) -> None:
|
||||
event=IRCEvent.BOOK_RESULT,
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(client, "read_messages", lambda: iter(messages))
|
||||
monkeypatch.setattr(client, "read_messages", lambda **_: iter(messages))
|
||||
|
||||
offer = client.wait_for_dcc(timeout=1.0, result_type=False)
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_wait_for_dcc_uses_expected_sender_over_online_server_list(monkeypatch)
|
||||
event=IRCEvent.BOOK_RESULT,
|
||||
)
|
||||
]
|
||||
monkeypatch.setattr(client, "read_messages", lambda: iter(messages))
|
||||
monkeypatch.setattr(client, "read_messages", lambda **_: iter(messages))
|
||||
|
||||
offer = client.wait_for_dcc(
|
||||
timeout=1.0,
|
||||
@@ -76,7 +76,7 @@ def test_wait_for_dcc_ignores_unsafe_offer_and_keeps_waiting(monkeypatch) -> Non
|
||||
event=IRCEvent.BOOK_RESULT,
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(client, "read_messages", lambda: iter(messages))
|
||||
monkeypatch.setattr(client, "read_messages", lambda **_: iter(messages))
|
||||
|
||||
offer = client.wait_for_dcc(timeout=1.0, result_type=False)
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""A silent channel must still time out.
|
||||
|
||||
An empty or misnamed channel sends nothing at all, so a timeout that is only
|
||||
checked when a message arrives never fires and the search hangs forever.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.release_sources.irc.client import IRCClient
|
||||
|
||||
|
||||
class _SilentSocket:
|
||||
"""A socket that never delivers a line, only recv timeouts."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.timeout: float | None = 300.0
|
||||
self.recv_calls = 0
|
||||
|
||||
def gettimeout(self) -> float | None:
|
||||
return self.timeout
|
||||
|
||||
def settimeout(self, value: float | None) -> None:
|
||||
self.timeout = value
|
||||
|
||||
def recv(self, _bufsize: int) -> bytes:
|
||||
self.recv_calls += 1
|
||||
# A real socket blocks for the full timeout before raising; sleep a
|
||||
# sliver of it so the test stays fast but the deadline still advances.
|
||||
time.sleep(min(self.timeout or 0.0, 0.01))
|
||||
raise TimeoutError
|
||||
|
||||
|
||||
def test_wait_for_dcc_times_out_on_silent_channel() -> None:
|
||||
client = IRCClient(nick="reader", server="irc.example.test", port=6697)
|
||||
sock = _SilentSocket()
|
||||
client._socket = sock
|
||||
client._connected = True
|
||||
|
||||
start = time.time()
|
||||
offer = client.wait_for_dcc(timeout=0.5, result_type=True)
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert offer is None
|
||||
assert elapsed == pytest.approx(0.5, abs=0.5)
|
||||
assert sock.recv_calls > 0
|
||||
# The long connection-wide timeout is put back for the next caller
|
||||
assert sock.timeout == 300.0
|
||||
|
||||
|
||||
def test_recv_lines_caps_socket_timeout_at_the_deadline() -> None:
|
||||
client = IRCClient(nick="reader", server="irc.example.test", port=6697)
|
||||
sock = _SilentSocket()
|
||||
client._socket = sock
|
||||
client._connected = True
|
||||
|
||||
lines = list(client._recv_lines(deadline=time.time() + 0.05))
|
||||
|
||||
assert lines == []
|
||||
# Never waits past the deadline on a single recv
|
||||
assert sock.timeout == 300.0
|
||||
@@ -161,6 +161,145 @@ def test_search_no_dcc_offer_releases_connection_and_caches_empty_result(monkeyp
|
||||
]
|
||||
|
||||
|
||||
def test_audiobook_search_routes_to_configured_audiobook_channel_and_bot(monkeypatch):
|
||||
"""An audiobook request uses the audiobook channel/bot when configured."""
|
||||
import shelfmark.release_sources.irc.source as irc_source
|
||||
|
||||
source = IRCReleaseSource()
|
||||
connection_kwargs: dict[str, object] = {}
|
||||
cache_calls: list[dict[str, object]] = []
|
||||
|
||||
class FakeClient:
|
||||
online_servers: set[str] = set()
|
||||
|
||||
def send_message(self, channel: str, message: str) -> None:
|
||||
self.channel = channel
|
||||
self.message = message
|
||||
|
||||
def wait_for_dcc(
|
||||
self, *, timeout: float, result_type: bool, expected_senders: object = None
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
client = FakeClient()
|
||||
|
||||
monkeypatch.setattr(
|
||||
irc_source,
|
||||
"_config_text",
|
||||
lambda key: {
|
||||
"IRC_SERVER": "irc.example.net",
|
||||
"IRC_CHANNEL": "ebooks",
|
||||
"IRC_NICK": "tester",
|
||||
"IRC_SEARCH_BOT": "search",
|
||||
"IRC_AUDIOBOOK_CHANNEL": "bookz",
|
||||
"IRC_AUDIOBOOK_SEARCH_BOT": "audiosearch",
|
||||
}.get(key, ""),
|
||||
)
|
||||
irc_source._recent_message_sends.clear()
|
||||
|
||||
monkeypatch.setattr(source, "is_available", lambda: True)
|
||||
monkeypatch.setattr(irc_source, "_enforce_rate_limit", lambda: None)
|
||||
monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.irc.cache.get_cached_results",
|
||||
lambda cache_key, *_args, **_kwargs: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.irc.cache.cache_results",
|
||||
lambda cache_key, title, releases, *, online_servers=None: cache_calls.append(
|
||||
{"cache_key": cache_key}
|
||||
),
|
||||
)
|
||||
|
||||
def fake_get_connection(**kwargs: object) -> FakeClient:
|
||||
connection_kwargs.update(kwargs)
|
||||
return client
|
||||
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
|
||||
fake_get_connection,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.irc.connection_manager.connection_manager.release_connection",
|
||||
lambda _client: None,
|
||||
)
|
||||
|
||||
book = BookMetadata(provider="hardcover", provider_id="ab", title="Audio Book")
|
||||
plan = SimpleNamespace(primary_query="Audio Book")
|
||||
|
||||
source.search(book, plan, content_type="audiobook")
|
||||
|
||||
# Joined the audiobook channel, addressed the audiobook search bot, and the cache
|
||||
# key is namespaced to the audiobook channel (so it won't collide with the ebook one).
|
||||
assert connection_kwargs["channel"] == "bookz"
|
||||
assert client.channel == "#bookz"
|
||||
assert client.message == "@audiosearch Audio Book"
|
||||
assert cache_calls == [{"cache_key": "irc.example.net:bookz:audio book"}]
|
||||
|
||||
|
||||
def test_audiobook_search_reuses_main_bot_when_only_channel_configured(monkeypatch):
|
||||
"""With an audiobook channel but no audiobook bot, reuse the main search bot."""
|
||||
import shelfmark.release_sources.irc.source as irc_source
|
||||
|
||||
source = IRCReleaseSource()
|
||||
|
||||
class FakeClient:
|
||||
online_servers: set[str] = set()
|
||||
|
||||
def send_message(self, channel: str, message: str) -> None:
|
||||
self.channel = channel
|
||||
self.message = message
|
||||
|
||||
def wait_for_dcc(
|
||||
self, *, timeout: float, result_type: bool, expected_senders: object = None
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
client = FakeClient()
|
||||
|
||||
monkeypatch.setattr(
|
||||
irc_source,
|
||||
"_config_text",
|
||||
lambda key: {
|
||||
"IRC_SERVER": "irc.example.net",
|
||||
"IRC_CHANNEL": "ebooks",
|
||||
"IRC_NICK": "tester",
|
||||
"IRC_SEARCH_BOT": "search",
|
||||
"IRC_AUDIOBOOK_CHANNEL": "bookz",
|
||||
"IRC_AUDIOBOOK_SEARCH_BOT": "",
|
||||
}.get(key, ""),
|
||||
)
|
||||
irc_source._recent_message_sends.clear()
|
||||
|
||||
monkeypatch.setattr(source, "is_available", lambda: True)
|
||||
monkeypatch.setattr(irc_source, "_enforce_rate_limit", lambda: None)
|
||||
monkeypatch.setattr(irc_source, "_emit_status", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.irc.cache.get_cached_results",
|
||||
lambda cache_key, *_args, **_kwargs: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.irc.cache.cache_results",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.irc.connection_manager.connection_manager.get_connection",
|
||||
lambda **_kwargs: client,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.irc.connection_manager.connection_manager.release_connection",
|
||||
lambda _client: None,
|
||||
)
|
||||
|
||||
book = BookMetadata(provider="hardcover", provider_id="ab2", title="Audio Book")
|
||||
plan = SimpleNamespace(primary_query="Audio Book")
|
||||
|
||||
source.search(book, plan, content_type="audiobook")
|
||||
|
||||
assert client.channel == "#bookz"
|
||||
assert client.message == "@search Audio Book"
|
||||
|
||||
|
||||
def test_search_without_search_bot_never_posts_to_channel(monkeypatch):
|
||||
"""A bare (unaddressed) query must never reach the channel; refuse to connect."""
|
||||
import shelfmark.release_sources.irc.source as irc_source
|
||||
|
||||
@@ -15,6 +15,17 @@ def make_config_getter(values):
|
||||
return getter
|
||||
|
||||
|
||||
def test_set_category_uses_label_plugin(monkeypatch):
|
||||
from shelfmark.download.clients.deluge import DelugeClient
|
||||
|
||||
client = DelugeClient.__new__(DelugeClient)
|
||||
monkeypatch.setattr(client, "_ensure_connected", MagicMock())
|
||||
monkeypatch.setattr(client, "_try_set_label", MagicMock(return_value=True))
|
||||
|
||||
assert client.set_category("abc123", "imported") is True
|
||||
client._try_set_label.assert_called_once_with("abc123", "imported")
|
||||
|
||||
|
||||
class TestDelugeClientAddDownload:
|
||||
"""Tests for DelugeClient.add_download()."""
|
||||
|
||||
|
||||
@@ -520,7 +520,7 @@ class TestCacheFailures:
|
||||
|
||||
assert result is None
|
||||
assert recorder.had_error
|
||||
assert "not found in cache" in recorder.last_message.lower()
|
||||
assert "could not be refreshed" in recorder.last_message
|
||||
|
||||
def test_release_missing_download_url(self, handler, recorder, cancel_flag, sample_task):
|
||||
"""Handler should error when release has no download URL."""
|
||||
|
||||
+479
-13
@@ -15,8 +15,10 @@ from shelfmark.download.clients import (
|
||||
DownloadState,
|
||||
DownloadStatus,
|
||||
)
|
||||
from shelfmark.release_sources import Release, ReleaseProtocol
|
||||
from shelfmark.release_sources.prowlarr.cache import cache_release, remove_release
|
||||
from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler
|
||||
from shelfmark.release_sources.prowlarr.utils import get_protocol
|
||||
from shelfmark.release_sources.prowlarr.utils import build_source_id, get_protocol
|
||||
|
||||
|
||||
class ProgressRecorder:
|
||||
@@ -103,13 +105,19 @@ class TestProwlarrHandlerDownloadErrors:
|
||||
assert result is None
|
||||
assert recorder.last_status == "error"
|
||||
assert recorder.last_message is not None
|
||||
assert "cache" in recorder.last_message.lower()
|
||||
assert "could not be refreshed" in recorder.last_message
|
||||
|
||||
def test_resolve_download_uses_task_retry_fields_when_cache_is_missing(self):
|
||||
"""Generic retry fields should let restarts recover without the in-memory cache."""
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value=None,
|
||||
def test_download_fails_clearly_when_cache_miss_cannot_refresh(self):
|
||||
"""Prowlarr retry URLs are not durable; cache misses must refresh by identity."""
|
||||
with (
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(
|
||||
@@ -122,14 +130,245 @@ class TestProwlarrHandlerDownloadErrors:
|
||||
retry_seeding_time_limit_minutes=60,
|
||||
retry_ratio_limit=1.5,
|
||||
)
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
request = handler._resolve_download(task, lambda *_: None)
|
||||
request = handler._resolve_download(task, recorder.status_callback)
|
||||
|
||||
assert request is not None
|
||||
assert request.url == "magnet:?xt=urn:btih:abc123"
|
||||
assert request.protocol == "torrent"
|
||||
assert request.seeding_time_limit == 60
|
||||
assert request.ratio_limit == 1.5
|
||||
assert request is None
|
||||
assert recorder.last_status == "error"
|
||||
assert recorder.last_message is not None
|
||||
assert "could not be refreshed" in recorder.last_message
|
||||
|
||||
def test_cache_miss_re_resolves_and_uses_fresh_download_url(self):
|
||||
"""Cache miss should re-query Prowlarr and use a fresh exact-match URL."""
|
||||
task_id = "fresh-guid-1"
|
||||
fresh_url = "https://prowlarr.example.com/download/fresh-token"
|
||||
|
||||
def mock_search(*_args, **_kwargs):
|
||||
cache_release(
|
||||
task_id,
|
||||
{
|
||||
"guid": task_id,
|
||||
"protocol": "torrent",
|
||||
"downloadUrl": fresh_url,
|
||||
"title": "Fresh Release",
|
||||
},
|
||||
)
|
||||
return [
|
||||
Release(
|
||||
source="prowlarr",
|
||||
source_id=task_id,
|
||||
title="Fresh Release",
|
||||
info_url="https://tracker.example.com/release/1",
|
||||
protocol=ReleaseProtocol.TORRENT,
|
||||
)
|
||||
]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.name = "qbittorrent"
|
||||
mock_client.find_existing.return_value = None
|
||||
mock_client.add_download.return_value = "download_id"
|
||||
|
||||
remove_release(task_id)
|
||||
try:
|
||||
with (
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
|
||||
side_effect=mock_search,
|
||||
) as mock_search_method,
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_client",
|
||||
return_value=mock_client,
|
||||
),
|
||||
patch.object(ProwlarrHandler, "_poll_and_complete", return_value=None),
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(
|
||||
task_id=task_id,
|
||||
source="prowlarr",
|
||||
title="Fresh Book",
|
||||
retry_source_context={"indexer": "MyIndexer"},
|
||||
)
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
handler.download(
|
||||
task=task,
|
||||
cancel_flag=Event(),
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
mock_search_method.assert_called_once()
|
||||
assert mock_client.add_download.call_args.kwargs["url"] == fresh_url
|
||||
finally:
|
||||
remove_release(task_id)
|
||||
|
||||
def test_stale_cached_url_add_failure_re_resolves_once_and_succeeds(self):
|
||||
"""Expired cached proxy URL should be refreshed once after qBittorrent hash failure."""
|
||||
task_id = "stale-guid-1"
|
||||
stale_url = "https://prowlarr.example.com/download/stale-token"
|
||||
fresh_url = "https://prowlarr.example.com/download/fresh-token"
|
||||
|
||||
def mock_search(*_args, **_kwargs):
|
||||
cache_release(
|
||||
task_id,
|
||||
{
|
||||
"guid": task_id,
|
||||
"protocol": "torrent",
|
||||
"downloadUrl": fresh_url,
|
||||
"title": "Fresh Release",
|
||||
},
|
||||
)
|
||||
return [
|
||||
Release(
|
||||
source="prowlarr",
|
||||
source_id=task_id,
|
||||
title="Fresh Release",
|
||||
protocol=ReleaseProtocol.TORRENT,
|
||||
)
|
||||
]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.name = "qbittorrent"
|
||||
mock_client.find_existing.return_value = None
|
||||
mock_client.add_download.side_effect = [
|
||||
RuntimeError("Could not determine torrent hash from URL"),
|
||||
"download_id",
|
||||
]
|
||||
|
||||
cache_release(
|
||||
task_id,
|
||||
{
|
||||
"guid": task_id,
|
||||
"protocol": "torrent",
|
||||
"downloadUrl": stale_url,
|
||||
"title": "Stale Release",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with (
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
|
||||
side_effect=mock_search,
|
||||
) as mock_search_method,
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_client",
|
||||
return_value=mock_client,
|
||||
),
|
||||
patch.object(ProwlarrHandler, "_poll_and_complete", return_value=None),
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(task_id=task_id, source="prowlarr", title="Stale Book")
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
handler.download(
|
||||
task=task,
|
||||
cancel_flag=Event(),
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
assert mock_client.add_download.call_count == 2
|
||||
assert mock_client.add_download.call_args_list[0].kwargs["url"] == stale_url
|
||||
assert mock_client.add_download.call_args_list[1].kwargs["url"] == fresh_url
|
||||
mock_search_method.assert_called_once()
|
||||
finally:
|
||||
remove_release(task_id)
|
||||
|
||||
def test_refresh_without_exact_match_fails_clearly(self):
|
||||
"""Refresh must not pick a different Prowlarr result when identity differs."""
|
||||
task_id = "missing-guid-1"
|
||||
other_id = "other-guid-1"
|
||||
|
||||
def mock_search(*_args, **_kwargs):
|
||||
cache_release(
|
||||
other_id,
|
||||
{
|
||||
"guid": other_id,
|
||||
"protocol": "torrent",
|
||||
"downloadUrl": "https://prowlarr.example.com/download/other",
|
||||
"title": "Other Release",
|
||||
},
|
||||
)
|
||||
return [
|
||||
Release(
|
||||
source="prowlarr",
|
||||
source_id=other_id,
|
||||
title="Other Release",
|
||||
protocol=ReleaseProtocol.TORRENT,
|
||||
)
|
||||
]
|
||||
|
||||
remove_release(task_id)
|
||||
remove_release(other_id)
|
||||
try:
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
|
||||
side_effect=mock_search,
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(task_id=task_id, source="prowlarr", title="Missing Book")
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
request = handler._resolve_download(task, recorder.status_callback)
|
||||
|
||||
assert request is None
|
||||
assert recorder.last_status == "error"
|
||||
assert recorder.last_message is not None
|
||||
assert "could not be refreshed" in recorder.last_message
|
||||
finally:
|
||||
remove_release(task_id)
|
||||
remove_release(other_id)
|
||||
|
||||
def test_magnet_result_does_not_trigger_prowlarr_url_refresh(self):
|
||||
"""Magnet failures should not be treated as expired Prowlarr proxy URLs."""
|
||||
task_id = "magnet-guid-1"
|
||||
magnet = "magnet:?xt=urn:btih:abc123&dn=test"
|
||||
mock_client = MagicMock()
|
||||
mock_client.name = "qbittorrent"
|
||||
mock_client.find_existing.return_value = None
|
||||
mock_client.add_download.side_effect = RuntimeError(
|
||||
"Could not determine torrent hash from URL"
|
||||
)
|
||||
|
||||
cache_release(
|
||||
task_id,
|
||||
{
|
||||
"guid": task_id,
|
||||
"protocol": "torrent",
|
||||
"downloadUrl": "https://prowlarr.example.com/download/stale-token",
|
||||
"magnetUrl": magnet,
|
||||
"title": "Magnet Release",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with (
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search",
|
||||
return_value=[],
|
||||
) as mock_search_method,
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_client",
|
||||
return_value=mock_client,
|
||||
),
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(task_id=task_id, source="prowlarr", title="Magnet Book")
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
result = handler.download(
|
||||
task=task,
|
||||
cancel_flag=Event(),
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert mock_client.add_download.call_count == 1
|
||||
assert mock_client.add_download.call_args.kwargs["url"] == magnet
|
||||
mock_search_method.assert_not_called()
|
||||
finally:
|
||||
remove_release(task_id)
|
||||
|
||||
def test_download_fails_without_download_url(self):
|
||||
"""Test that download fails when release has no download URL."""
|
||||
@@ -316,6 +555,124 @@ class TestProwlarrHandlerSeedCriteria:
|
||||
assert request.seeding_time_limit is None
|
||||
assert request.ratio_limit is None
|
||||
|
||||
def test_resolve_download_falls_back_to_prowlarr_when_enrichment_missing(self):
|
||||
"""Regression test for #795: when search-time enrichment is missing,
|
||||
share limits are re-resolved from Prowlarr at grab time."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_indexer_seed_settings.return_value = {
|
||||
5: {"seeding_time_limit_minutes": 4320, "ratio_limit": 1.0}
|
||||
}
|
||||
|
||||
def config_get(key, default=None):
|
||||
return True if key == "PROWLARR_USE_SEED_PREFERENCES" else default
|
||||
|
||||
with (
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value={
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
"indexerId": 5,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.config.get",
|
||||
side_effect=config_get,
|
||||
),
|
||||
patch.object(
|
||||
ProwlarrHandler,
|
||||
"_build_prowlarr_client",
|
||||
return_value=mock_client,
|
||||
),
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(
|
||||
task_id="seed-time-fallback",
|
||||
source="prowlarr",
|
||||
title="Test Book",
|
||||
)
|
||||
|
||||
request = handler._resolve_download(task, lambda *_: None)
|
||||
|
||||
assert request is not None
|
||||
assert request.seeding_time_limit == 4320
|
||||
assert request.ratio_limit == 1.0
|
||||
mock_client.get_indexer_seed_settings.assert_called_once_with(restrict_to=[5])
|
||||
|
||||
def test_resolve_download_fallback_failure_leaves_limits_unset(self):
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_indexer_seed_settings.side_effect = RuntimeError("prowlarr down")
|
||||
|
||||
def config_get(key, default=None):
|
||||
return True if key == "PROWLARR_USE_SEED_PREFERENCES" else default
|
||||
|
||||
with (
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value={
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
"indexerId": 5,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.config.get",
|
||||
side_effect=config_get,
|
||||
),
|
||||
patch.object(
|
||||
ProwlarrHandler,
|
||||
"_build_prowlarr_client",
|
||||
return_value=mock_client,
|
||||
),
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(
|
||||
task_id="seed-time-fallback-failure",
|
||||
source="prowlarr",
|
||||
title="Test Book",
|
||||
)
|
||||
|
||||
request = handler._resolve_download(task, lambda *_: None)
|
||||
|
||||
assert request is not None
|
||||
assert request.seeding_time_limit is None
|
||||
assert request.ratio_limit is None
|
||||
|
||||
def test_resolve_download_skips_fallback_when_enrichment_present(self):
|
||||
with (
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value={
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
"indexerId": 5,
|
||||
"configuredSeedTimeMinutes": 7200,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.config.get",
|
||||
side_effect=lambda key, default=None: (
|
||||
True if key == "PROWLARR_USE_SEED_PREFERENCES" else default
|
||||
),
|
||||
),
|
||||
patch.object(ProwlarrHandler, "_build_prowlarr_client") as mock_builder,
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(
|
||||
task_id="seed-time-no-fallback",
|
||||
source="prowlarr",
|
||||
title="Test Book",
|
||||
)
|
||||
|
||||
request = handler._resolve_download(task, lambda *_: None)
|
||||
|
||||
assert request is not None
|
||||
assert request.seeding_time_limit == 7200
|
||||
mock_builder.assert_not_called()
|
||||
|
||||
def test_download_passes_seed_limits_to_client(self):
|
||||
mock_client = MagicMock()
|
||||
mock_client.name = "qbittorrent"
|
||||
@@ -1099,6 +1456,61 @@ class TestProwlarrHandlerFileStaging:
|
||||
|
||||
|
||||
class TestProwlarrHandlerPostProcessCleanup:
|
||||
def test_torrent_change_category_sets_post_import_category(self):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(task_id="torrent-category", source="prowlarr", title="Test")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.name = "qbittorrent"
|
||||
mock_client.set_category.return_value = True
|
||||
handler._cleanup_refs[task.task_id] = (mock_client, "abc123", "torrent")
|
||||
|
||||
config_values = {
|
||||
"PROWLARR_TORRENT_ACTION": "change_category",
|
||||
"PROWLARR_TORRENT_POST_IMPORT_CATEGORY": "imported",
|
||||
}
|
||||
with patch(
|
||||
"shelfmark.download.clients.base_handler.config.get",
|
||||
side_effect=lambda key, default="": config_values.get(key, default),
|
||||
):
|
||||
handler.post_process_cleanup(task, success=True)
|
||||
|
||||
mock_client.set_category.assert_called_once_with("abc123", "imported")
|
||||
mock_client.remove.assert_not_called()
|
||||
|
||||
def test_torrent_keep_does_not_change_category(self):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(task_id="torrent-no-category", source="prowlarr", title="Test")
|
||||
|
||||
mock_client = MagicMock()
|
||||
handler._cleanup_refs[task.task_id] = (mock_client, "abc123", "torrent")
|
||||
|
||||
with patch("shelfmark.download.clients.base_handler.config.get", return_value="keep"):
|
||||
handler.post_process_cleanup(task, success=True)
|
||||
|
||||
mock_client.set_category.assert_not_called()
|
||||
mock_client.remove.assert_not_called()
|
||||
|
||||
def test_torrent_change_category_ignores_empty_category(self):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(task_id="torrent-empty-category", source="prowlarr", title="Test")
|
||||
|
||||
mock_client = MagicMock()
|
||||
handler._cleanup_refs[task.task_id] = (mock_client, "abc123", "torrent")
|
||||
|
||||
config_values = {
|
||||
"PROWLARR_TORRENT_ACTION": "change_category",
|
||||
"PROWLARR_TORRENT_POST_IMPORT_CATEGORY": "",
|
||||
}
|
||||
with patch(
|
||||
"shelfmark.download.clients.base_handler.config.get",
|
||||
side_effect=lambda key, default="": config_values.get(key, default),
|
||||
):
|
||||
handler.post_process_cleanup(task, success=True)
|
||||
|
||||
mock_client.set_category.assert_not_called()
|
||||
mock_client.remove.assert_not_called()
|
||||
|
||||
def test_usenet_move_triggers_client_cleanup(self):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(task_id="cleanup-test", source="prowlarr", title="Test")
|
||||
@@ -1227,3 +1639,57 @@ class TestProwlarrHandlerPostProcessCleanup:
|
||||
assert args[1] == "nzbget"
|
||||
assert args[2] == "123"
|
||||
assert str(args[3]) == "delete failed"
|
||||
|
||||
|
||||
class TestRawReleaseMatchesTask:
|
||||
"""Refreshing a stale release has to find it by whichever id form the task holds."""
|
||||
|
||||
RAW = {
|
||||
"guid": "https://tracker.example/torrent/555",
|
||||
"infoUrl": "https://tracker.example/details/555",
|
||||
"indexerId": 25,
|
||||
}
|
||||
|
||||
def test_matches_the_indexer_qualified_source_id(self):
|
||||
assert ProwlarrHandler._raw_release_matches_task(
|
||||
self.RAW, "25:https://tracker.example/torrent/555"
|
||||
)
|
||||
|
||||
def test_still_matches_a_bare_guid_from_a_task_queued_before_the_change(self):
|
||||
assert ProwlarrHandler._raw_release_matches_task(
|
||||
self.RAW, "https://tracker.example/torrent/555"
|
||||
)
|
||||
|
||||
def test_still_matches_a_bare_info_url(self):
|
||||
assert ProwlarrHandler._raw_release_matches_task(
|
||||
self.RAW, "https://tracker.example/details/555"
|
||||
)
|
||||
|
||||
def test_does_not_match_the_same_guid_from_a_different_indexer_entry(self):
|
||||
assert not ProwlarrHandler._raw_release_matches_task(
|
||||
self.RAW, "10:https://tracker.example/torrent/555"
|
||||
)
|
||||
|
||||
def test_does_not_match_an_unrelated_release(self):
|
||||
assert not ProwlarrHandler._raw_release_matches_task(self.RAW, "25:something-else")
|
||||
|
||||
|
||||
class TestRawReleaseMatchesTaskEdgeCases:
|
||||
"""Matching the wrong release here grabs the wrong torrent."""
|
||||
|
||||
def test_blank_task_id_never_matches(self):
|
||||
raw = {"guid": "g", "infoUrl": "i", "indexerId": 25}
|
||||
|
||||
assert not ProwlarrHandler._raw_release_matches_task(raw, "")
|
||||
assert not ProwlarrHandler._raw_release_matches_task(raw, " ")
|
||||
assert not ProwlarrHandler._raw_release_matches_task(raw, None)
|
||||
|
||||
def test_a_release_with_no_identifiers_does_not_match_a_blank_task_id(self):
|
||||
assert not ProwlarrHandler._raw_release_matches_task({}, None)
|
||||
assert not ProwlarrHandler._raw_release_matches_task({}, "")
|
||||
|
||||
def test_matches_a_qualified_id_built_from_a_guidless_release(self):
|
||||
raw = {"indexerId": 25, "indexer": "MAM", "title": "Dune"}
|
||||
built = build_source_id(raw)
|
||||
|
||||
assert ProwlarrHandler._raw_release_matches_task(raw, built)
|
||||
|
||||
@@ -126,7 +126,7 @@ class TestHandlerCacheOperations:
|
||||
|
||||
assert result is None
|
||||
assert recorder.last_status == "error"
|
||||
assert "cache" in recorder.last_message.lower()
|
||||
assert "could not be refreshed" in recorder.last_message
|
||||
|
||||
def test_download_fails_without_download_url(self):
|
||||
"""Test that download fails when release has no download URL."""
|
||||
|
||||
@@ -5,6 +5,7 @@ These tests mock the qbittorrentapi library to test the client logic
|
||||
without requiring a running qBittorrent instance.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -58,6 +59,20 @@ def create_mock_session_response(torrents, status_code=200):
|
||||
return mock_response
|
||||
|
||||
|
||||
def test_set_category_creates_and_assigns_category():
|
||||
from shelfmark.download.clients.qbittorrent import QBittorrentClient
|
||||
|
||||
client = QBittorrentClient.__new__(QBittorrentClient)
|
||||
client._client = MagicMock()
|
||||
|
||||
assert client.set_category("abc123", "imported") is True
|
||||
client._client.torrents_create_category.assert_called_once_with(name="imported")
|
||||
client._client.torrents_set_category.assert_called_once_with(
|
||||
torrent_hashes="abc123",
|
||||
category="imported",
|
||||
)
|
||||
|
||||
|
||||
class TestQBittorrentClientIsConfigured:
|
||||
"""Tests for QBittorrentClient.is_configured()."""
|
||||
|
||||
@@ -179,6 +194,76 @@ class TestQBittorrentClientTestConnection:
|
||||
assert "401" in message or "failed" in message.lower()
|
||||
|
||||
|
||||
class TestQBittorrentClientApiKeyAuth:
|
||||
"""Tests for API key authentication (qBittorrent 5.2.0+)."""
|
||||
|
||||
API_KEY = "qbt_0123456789abcdefghijklmnopqr"
|
||||
|
||||
@contextmanager
|
||||
def _build_client(self, monkeypatch, api_key, mock_client_instance=None):
|
||||
"""Construct the client against a stubbed qbittorrentapi, yielding it and the stub."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_API_KEY": api_key,
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance or MagicMock())
|
||||
|
||||
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
|
||||
importlib.reload(qb_module)
|
||||
yield qb_module.QBittorrentClient(), mock_client_class
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_key", "expected_kwarg"),
|
||||
[(API_KEY, API_KEY), ("", None)],
|
||||
)
|
||||
def test_api_key_forwarded_to_qbittorrentapi(self, monkeypatch, api_key, expected_kwarg):
|
||||
"""A configured key is handed to qbittorrent-api; without one it falls back to password."""
|
||||
with self._build_client(monkeypatch, api_key) as (_client, mock_client_class):
|
||||
assert mock_client_class.call_args.kwargs["api_key"] == expected_kwarg
|
||||
|
||||
@pytest.mark.parametrize(("api_key", "logs_in"), [(API_KEY, False), ("", True)])
|
||||
def test_direct_requests_log_in_only_for_cookie_auth(self, monkeypatch, api_key, logs_in):
|
||||
"""Bearer auth is stateless, so only cookie auth needs a login before direct calls."""
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
[MockTorrent(progress=0.5, state="downloading")], status_code=200
|
||||
)
|
||||
|
||||
with self._build_client(monkeypatch, api_key, mock_client_instance) as (client, _):
|
||||
status = client.get_status("abc123")
|
||||
|
||||
assert status.progress == 50.0
|
||||
assert mock_client_instance.auth_log_in.called is logs_in
|
||||
|
||||
@pytest.mark.parametrize(("api_key", "expected_requests"), [(API_KEY, 1), ("", 2)])
|
||||
def test_403_retried_only_when_a_login_can_refresh_it(
|
||||
self, monkeypatch, api_key, expected_requests
|
||||
):
|
||||
"""A bearer token cannot be refreshed, so re-issuing a 403 would just waste a request."""
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
[], status_code=403
|
||||
)
|
||||
|
||||
with self._build_client(monkeypatch, api_key, mock_client_instance) as (client, _):
|
||||
status = client.get_status("abc123")
|
||||
|
||||
assert status.state_value == "error"
|
||||
assert mock_client_instance._session.get.call_count == expected_requests
|
||||
|
||||
|
||||
class TestQBittorrentClientGetStatus:
|
||||
"""Tests for QBittorrentClient.get_status()."""
|
||||
|
||||
@@ -903,6 +988,61 @@ class TestQBittorrentClientAddDownload:
|
||||
"http://tracker.example/download/book.torrent", "Test Download"
|
||||
)
|
||||
|
||||
def test_add_download_error_includes_fetch_failure_reason(self, monkeypatch):
|
||||
"""Surface why the .torrent prefetch failed instead of only the hash error.
|
||||
|
||||
Regression for #1111: a Prowlarr proxy fetch that fails (e.g. HTTP 500
|
||||
because the tracker rejected the request) was reported as a bare
|
||||
"Could not determine torrent hash from URL", hiding the actual cause.
|
||||
"""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "books",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.time.sleep", lambda _seconds: None
|
||||
)
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response([])
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
|
||||
importlib.reload(qb_module)
|
||||
|
||||
with patch(
|
||||
"shelfmark.download.clients.qbittorrent.extract_torrent_info",
|
||||
autospec=True,
|
||||
) as mock_extract:
|
||||
mock_extract.return_value = TorrentInfo(
|
||||
info_hash=None,
|
||||
torrent_data=None,
|
||||
is_magnet=False,
|
||||
magnet_url=None,
|
||||
fetch_error="500 Server Error: Internal Server Error for url: http://prowlarr:9696/26/download",
|
||||
)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
client.add_download(
|
||||
"http://prowlarr:9696/26/download?apikey=key&link=token",
|
||||
"Test Download",
|
||||
)
|
||||
|
||||
assert "Could not determine torrent hash from URL" in str(exc_info.value)
|
||||
assert "500 Server Error" in str(exc_info.value)
|
||||
|
||||
def test_add_download_creates_category(self, monkeypatch):
|
||||
"""Test that add_download creates category if needed."""
|
||||
config_values = {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""qBittorrent download client settings fields and test-connection callback."""
|
||||
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from qbittorrentapi import LoginFailed
|
||||
|
||||
from shelfmark.core.settings_registry import PasswordField
|
||||
|
||||
API_KEY = "qbt_0123456789abcdefghijklmnopqr"
|
||||
|
||||
|
||||
def make_config_getter(values):
|
||||
"""Create a config.get function that returns values from a dict."""
|
||||
|
||||
def getter(key, default=""):
|
||||
return values.get(key, default)
|
||||
|
||||
return getter
|
||||
|
||||
|
||||
def _get_field(fields, key):
|
||||
"""Find a field by key."""
|
||||
return next((f for f in fields if f.key == key), None)
|
||||
|
||||
|
||||
def fake_qbittorrentapi(*, web_api_version="2.15.1", reject_auth=False, captured=None):
|
||||
"""Build a stand-in qbittorrentapi module recording Client kwargs into `captured`."""
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
if captured is not None:
|
||||
captured.update(kwargs)
|
||||
self.app = types.SimpleNamespace(web_api_version=web_api_version)
|
||||
|
||||
def auth_log_in(self):
|
||||
if reject_auth:
|
||||
raise LoginFailed
|
||||
|
||||
module = types.ModuleType("qbittorrentapi")
|
||||
module.Client = FakeClient
|
||||
return module
|
||||
|
||||
|
||||
def _run_test_connection(monkeypatch, current_values, fake_module):
|
||||
"""Invoke the Test Connection callback against a stubbed qbittorrentapi."""
|
||||
from shelfmark.core.config import config as config_obj
|
||||
from shelfmark.download.clients import settings as settings_module
|
||||
|
||||
monkeypatch.setattr(config_obj, "get", make_config_getter(current_values))
|
||||
monkeypatch.setattr(settings_module, "get_ssl_verify", lambda _url: True)
|
||||
|
||||
with patch.dict("sys.modules", {"qbittorrentapi": fake_module}):
|
||||
return settings_module._test_qbittorrent_connection(current_values=current_values)
|
||||
|
||||
|
||||
def test_api_key_field_is_registered():
|
||||
"""The API key is offered alongside the other qBittorrent credentials."""
|
||||
from shelfmark.download.clients.settings import prowlarr_clients_settings
|
||||
|
||||
field = _get_field(prowlarr_clients_settings(), "QBITTORRENT_API_KEY")
|
||||
|
||||
assert isinstance(field, PasswordField)
|
||||
assert field.show_when == {"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_key", "expected_kwarg", "expected_suffix"),
|
||||
[(API_KEY, API_KEY, " using the API key"), ("", None, "")],
|
||||
)
|
||||
def test_settings_test_connection_forwards_api_key(
|
||||
monkeypatch, api_key, expected_kwarg, expected_suffix
|
||||
):
|
||||
"""The Test Connection button authenticates with the key when set, and says which it used."""
|
||||
captured = {}
|
||||
result = _run_test_connection(
|
||||
monkeypatch,
|
||||
{
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_API_KEY": api_key,
|
||||
},
|
||||
fake_qbittorrentapi(captured=captured),
|
||||
)
|
||||
|
||||
assert captured["api_key"] == expected_kwarg
|
||||
assert result == {
|
||||
"success": True,
|
||||
"message": f"Connected to qBittorrent (API v2.15.1){expected_suffix}",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_key", "rejected"),
|
||||
[(API_KEY, "API key"), ("", "username or password")],
|
||||
)
|
||||
def test_settings_test_connection_names_rejected_credential(monkeypatch, api_key, rejected):
|
||||
"""LoginFailed carries no message of its own, so the callback names the credential."""
|
||||
result = _run_test_connection(
|
||||
monkeypatch,
|
||||
{
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_API_KEY": api_key,
|
||||
},
|
||||
fake_qbittorrentapi(reject_auth=True),
|
||||
)
|
||||
|
||||
assert result == {"success": False, "message": f"qBittorrent rejected the {rejected}"}
|
||||
@@ -27,6 +27,17 @@ def create_mock_xmlrpc_module():
|
||||
return mock_module
|
||||
|
||||
|
||||
def test_set_category_updates_custom1_label():
|
||||
from shelfmark.download.clients.rtorrent import RTorrentClient
|
||||
|
||||
client = RTorrentClient.__new__(RTorrentClient)
|
||||
client._rpc = MagicMock()
|
||||
|
||||
# rTorrent lookups are case sensitive and info hashes reach us lowercase.
|
||||
assert client.set_category("abc123", "imported") is True
|
||||
client._rpc.d.custom1.set.assert_called_once_with("ABC123", "imported")
|
||||
|
||||
|
||||
class TestRTorrentClientIsConfigured:
|
||||
"""Tests for RTorrentClient.is_configured()."""
|
||||
|
||||
@@ -769,8 +780,9 @@ class TestRTorrentClientRemove:
|
||||
result = client.remove("abc123def456", delete_files=False)
|
||||
|
||||
assert result is True
|
||||
mock_rpc.d.stop.assert_called_once_with("abc123def456")
|
||||
mock_rpc.d.erase.assert_called_once_with("abc123def456")
|
||||
# rTorrent lookups are case sensitive and info hashes reach us lowercase.
|
||||
mock_rpc.d.stop.assert_called_once_with("ABC123DEF456")
|
||||
mock_rpc.d.erase.assert_called_once_with("ABC123DEF456")
|
||||
|
||||
def test_remove_with_files(self, monkeypatch):
|
||||
"""Test torrent removal with file deletion."""
|
||||
@@ -803,8 +815,8 @@ class TestRTorrentClientRemove:
|
||||
result = client.remove("abc123def456", delete_files=True)
|
||||
|
||||
assert result is True
|
||||
mock_rpc.d.delete_tied.assert_called_once_with("abc123def456")
|
||||
mock_rpc.d.erase.assert_called_once_with("abc123def456")
|
||||
mock_rpc.d.delete_tied.assert_called_once_with("ABC123DEF456")
|
||||
mock_rpc.d.erase.assert_called_once_with("ABC123DEF456")
|
||||
|
||||
def test_remove_failure(self, monkeypatch):
|
||||
"""Test failed torrent removal."""
|
||||
|
||||
+564
-10
@@ -11,11 +11,22 @@ from shelfmark.metadata_providers import BookMetadata
|
||||
from shelfmark.release_sources.prowlarr.api import ProwlarrClient
|
||||
from shelfmark.release_sources.prowlarr.source import (
|
||||
ProwlarrSource,
|
||||
_build_indexer_priority,
|
||||
_collapse_duplicate_indexer_results,
|
||||
_detect_content_type_from_categories,
|
||||
_extract_format,
|
||||
_extract_mam_language,
|
||||
_fetch_indexer_seed_settings,
|
||||
_last_known_seed_settings,
|
||||
_parse_size,
|
||||
_release_identity,
|
||||
_result_dedup_key,
|
||||
)
|
||||
from shelfmark.release_sources.prowlarr.utils import (
|
||||
build_source_id,
|
||||
get_protocol_display,
|
||||
sanitize_download_url,
|
||||
)
|
||||
from shelfmark.release_sources.prowlarr.utils import get_protocol_display, sanitize_download_url
|
||||
|
||||
|
||||
class _AvailableSource:
|
||||
@@ -234,7 +245,7 @@ class FakeTorznabClient:
|
||||
self.search_results = search_results or []
|
||||
self.seed_settings = seed_settings or {}
|
||||
|
||||
def get_enabled_indexers_detailed(self):
|
||||
def get_enabled_indexers_detailed(self, *, raise_on_error=False):
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -263,8 +274,8 @@ class FakeTorznabClient:
|
||||
self.queries.append(query)
|
||||
return self.search_results
|
||||
|
||||
def get_enriched_indexer_ids(self, restrict_to=None):
|
||||
del restrict_to
|
||||
def get_enriched_indexer_ids(self, restrict_to=None, indexers=None):
|
||||
del restrict_to, indexers
|
||||
return []
|
||||
|
||||
def get_indexer_seed_settings(self, restrict_to=None):
|
||||
@@ -278,7 +289,7 @@ class TestProwlarrIndexerSeedSettings:
|
||||
monkeypatch.setattr(
|
||||
client,
|
||||
"get_enabled_indexers_detailed",
|
||||
lambda: [
|
||||
lambda *, raise_on_error=False: [
|
||||
{
|
||||
"id": 13,
|
||||
"protocol": "torrent",
|
||||
@@ -517,8 +528,12 @@ class TestProwlarrLocalizedQueries:
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert captured_tasks[0].retry_download_url == secret_download_url
|
||||
assert captured_tasks[0].retry_download_protocol == "usenet"
|
||||
assert captured_tasks[0].retry_download_url is None
|
||||
assert captured_tasks[0].retry_download_protocol is None
|
||||
assert captured_tasks[0].retry_source_context == {
|
||||
"source_id": "secret-prowlarr-release",
|
||||
"info_url": "secret-prowlarr-release",
|
||||
}
|
||||
assert "retry_download_url" not in orchestrator._task_to_dict(captured_tasks[0])
|
||||
|
||||
def test_search_uses_localized_titles_when_available(self, monkeypatch):
|
||||
@@ -640,7 +655,7 @@ class TestProwlarrLocalizedQueries:
|
||||
source = ProwlarrSource()
|
||||
|
||||
class FailingClient:
|
||||
def get_enabled_indexers_detailed(self):
|
||||
def get_enabled_indexers_detailed(self, *, raise_on_error=False):
|
||||
raise RuntimeError("indexers unavailable")
|
||||
|
||||
monkeypatch.setattr(source, "_get_client", lambda: FailingClient())
|
||||
@@ -655,7 +670,7 @@ class TestProwlarrLocalizedQueries:
|
||||
source = ProwlarrSource()
|
||||
|
||||
class FailingClient:
|
||||
def get_enabled_indexers_detailed(self):
|
||||
def get_enabled_indexers_detailed(self, *, raise_on_error=False):
|
||||
raise RuntimeError("indexers unavailable")
|
||||
|
||||
assert source._resolve_indexer_ids_from_names(FailingClient(), ["Alpha"]) is None
|
||||
@@ -664,7 +679,546 @@ class TestProwlarrLocalizedQueries:
|
||||
source = ProwlarrSource()
|
||||
|
||||
class FailingClient:
|
||||
def get_enabled_indexers_detailed(self):
|
||||
def get_enabled_indexers_detailed(self, *, raise_on_error=False):
|
||||
raise RuntimeError("indexers unavailable")
|
||||
|
||||
assert source._get_search_indexer_ids(FailingClient(), None, [7000]) == []
|
||||
|
||||
|
||||
class TestFetchIndexerSeedSettingsFallback:
|
||||
"""Regression tests for #795: transient Prowlarr API failures must not
|
||||
silently strip per-indexer share limits from search results."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_last_known_seed_settings(self):
|
||||
_last_known_seed_settings.clear()
|
||||
yield
|
||||
_last_known_seed_settings.clear()
|
||||
|
||||
def test_success_updates_last_known_good(self):
|
||||
class Client:
|
||||
def get_indexer_seed_settings(self, restrict_to=None):
|
||||
del restrict_to
|
||||
return {13: {"seeding_time_limit_minutes": 4320, "ratio_limit": 1.0}}
|
||||
|
||||
fetched = _fetch_indexer_seed_settings(Client(), None)
|
||||
|
||||
assert fetched == {13: {"seeding_time_limit_minutes": 4320, "ratio_limit": 1.0}}
|
||||
assert _last_known_seed_settings == fetched
|
||||
|
||||
def test_failure_falls_back_to_last_known_good(self):
|
||||
_last_known_seed_settings[13] = {"seeding_time_limit_minutes": 4320}
|
||||
|
||||
class FailingClient:
|
||||
def get_indexer_seed_settings(self, restrict_to=None):
|
||||
del restrict_to
|
||||
raise RuntimeError("indexers unavailable")
|
||||
|
||||
fetched = _fetch_indexer_seed_settings(FailingClient(), None)
|
||||
|
||||
assert fetched == {13: {"seeding_time_limit_minutes": 4320}}
|
||||
# The fallback must be a copy so callers cannot mutate the cache.
|
||||
fetched[99] = {"ratio_limit": 2.0}
|
||||
assert 99 not in _last_known_seed_settings
|
||||
|
||||
def test_failure_with_no_history_returns_empty(self):
|
||||
class FailingClient:
|
||||
def get_indexer_seed_settings(self, restrict_to=None):
|
||||
del restrict_to
|
||||
raise RuntimeError("indexers unavailable")
|
||||
|
||||
assert _fetch_indexer_seed_settings(FailingClient(), None) == {}
|
||||
|
||||
|
||||
class TestMamLanguageCoverage:
|
||||
"""MyAnonamouse offers 62 languages; an unmapped code is dropped entirely,
|
||||
which would leave {Language} empty and different-language editions colliding."""
|
||||
|
||||
def test_unmapped_language_is_dropped_not_passed_through(self):
|
||||
# Documents why coverage matters: there is no raw fallback.
|
||||
assert _extract_mam_language("Some Book [XYZ / EPUB]") is None
|
||||
|
||||
def test_maps_the_common_three_letter_codes(self):
|
||||
cases = {
|
||||
"ENG": "en",
|
||||
"SWE": "sv",
|
||||
"GER": "de",
|
||||
"DEU": "de",
|
||||
"FRE": "fr",
|
||||
"FRA": "fr",
|
||||
"CZE": "cs",
|
||||
"CES": "cs",
|
||||
}
|
||||
for tag, expected in cases.items():
|
||||
assert _extract_mam_language(f"Book [{tag} / EPUB]") == expected, tag
|
||||
|
||||
def test_maps_languages_added_for_mam_parity(self):
|
||||
cases = {
|
||||
"LAT": "la",
|
||||
"PER": "fa",
|
||||
"FAS": "fa",
|
||||
"TAM": "ta",
|
||||
"URD": "ur",
|
||||
"EST": "et",
|
||||
"ICE": "is",
|
||||
"ISL": "is",
|
||||
"GLE": "ga",
|
||||
"TGL": "fil",
|
||||
"BEN": "bn",
|
||||
"BOS": "bs",
|
||||
"SAN": "sa",
|
||||
"GLA": "gd",
|
||||
"GLV": "gv",
|
||||
"MAY": "ms",
|
||||
"MSA": "ms",
|
||||
"BUR": "my",
|
||||
"MYA": "my",
|
||||
}
|
||||
for tag, expected in cases.items():
|
||||
assert _extract_mam_language(f"Book [{tag} / M4B]") == expected, tag
|
||||
|
||||
|
||||
class _MultiIndexerClient:
|
||||
"""Torznab client where each indexer entry returns its own result set.
|
||||
|
||||
Models one tracker configured in Prowlarr as several entries differing by a
|
||||
server-side search filter, so the same guid comes back from more than one.
|
||||
"""
|
||||
|
||||
def __init__(self, results_by_indexer: dict[int, list[dict]], priorities=None):
|
||||
self.results_by_indexer = results_by_indexer
|
||||
self.priorities = priorities or {}
|
||||
|
||||
def get_enabled_indexers_detailed(self, *, raise_on_error=False):
|
||||
del raise_on_error
|
||||
return [
|
||||
{
|
||||
"id": indexer_id,
|
||||
"enable": True,
|
||||
"priority": self.priorities.get(indexer_id, 25),
|
||||
"capabilities": {
|
||||
"categories": [
|
||||
{"id": 7000, "subCategories": []},
|
||||
{"id": 3030, "subCategories": []},
|
||||
]
|
||||
},
|
||||
}
|
||||
for indexer_id in sorted(self.results_by_indexer)
|
||||
]
|
||||
|
||||
def torznab_search(
|
||||
self,
|
||||
*,
|
||||
indexer_id: int,
|
||||
query: str,
|
||||
categories=None,
|
||||
search_type="book",
|
||||
limit=100,
|
||||
offset=0,
|
||||
):
|
||||
del query, categories, search_type, limit, offset
|
||||
return self.results_by_indexer.get(indexer_id, [])
|
||||
|
||||
def get_enriched_indexer_ids(self, restrict_to=None, indexers=None):
|
||||
del restrict_to, indexers
|
||||
return []
|
||||
|
||||
def get_indexer_seed_settings(self, restrict_to=None):
|
||||
del restrict_to
|
||||
return {}
|
||||
|
||||
|
||||
def _mam_result(indexer_id: int, indexer: str, guid: str, *, freeleech: bool = False) -> dict:
|
||||
return {
|
||||
"guid": guid,
|
||||
"title": "Dune",
|
||||
"indexerId": indexer_id,
|
||||
"indexer": indexer,
|
||||
"protocol": "torrent",
|
||||
"size": 1048576,
|
||||
"seeders": 10,
|
||||
"leechers": 1,
|
||||
"categories": [{"id": 7020}],
|
||||
"downloadVolumeFactor": 0.0 if freeleech else 1.0,
|
||||
"infoUrl": f"https://tracker.example/{guid}",
|
||||
}
|
||||
|
||||
|
||||
class TestIndexerAwareDeduplication:
|
||||
"""One tracker as several Prowlarr entries must not collapse to one row (#1137).
|
||||
|
||||
These assert the split itself, so they turn PROWLARR_COLLAPSE_DUPLICATES off:
|
||||
it ships on, which keeps the release list as it was before #1137 for everyone
|
||||
who has not asked for the per-entry rows.
|
||||
"""
|
||||
|
||||
SPLIT = {"PROWLARR_COLLAPSE_DUPLICATES": False}
|
||||
|
||||
ONLY_ACTIVE = 10
|
||||
FREELEECH = 25
|
||||
|
||||
def _search(self, monkeypatch, results_by_indexer, config_values=None, priorities=None):
|
||||
import shelfmark.release_sources.prowlarr.source as prowlarr_source
|
||||
from shelfmark.core.search_plan import build_release_search_plan
|
||||
|
||||
values = {"PROWLARR_INDEXERS": "", "PROWLARR_AUTO_EXPAND": False}
|
||||
values.update(config_values or {})
|
||||
monkeypatch.setattr(
|
||||
prowlarr_source.config, "get", lambda key, default=None: values.get(key, default)
|
||||
)
|
||||
|
||||
source = ProwlarrSource()
|
||||
monkeypatch.setattr(
|
||||
source, "_get_client", lambda: _MultiIndexerClient(results_by_indexer, priorities)
|
||||
)
|
||||
|
||||
book = BookMetadata(
|
||||
provider="hardcover", provider_id="123", title="Dune", authors=["Frank Herbert"]
|
||||
)
|
||||
plan = build_release_search_plan(book, languages=["en"])
|
||||
return source.search(book, plan)
|
||||
|
||||
def test_same_guid_from_two_indexer_entries_both_survive(self, monkeypatch):
|
||||
shared_guid = "https://tracker.example/torrent/555"
|
||||
releases = self._search(
|
||||
monkeypatch,
|
||||
{
|
||||
self.ONLY_ACTIVE: [_mam_result(self.ONLY_ACTIVE, "MyAnonamouse", shared_guid)],
|
||||
self.FREELEECH: [
|
||||
_mam_result(
|
||||
self.FREELEECH, "MyAnonamouse - Freeleech", shared_guid, freeleech=True
|
||||
)
|
||||
],
|
||||
},
|
||||
config_values=self.SPLIT,
|
||||
)
|
||||
|
||||
assert len(releases) == 2
|
||||
assert {r.indexer for r in releases} == {"MyAnonamouse", "MyAnonamouse - Freeleech"}
|
||||
assert [r.extra["freeleech"] for r in releases].count(True) == 1
|
||||
|
||||
def test_surviving_rows_have_distinct_source_ids(self, monkeypatch):
|
||||
shared_guid = "https://tracker.example/torrent/555"
|
||||
releases = self._search(
|
||||
monkeypatch,
|
||||
{
|
||||
self.ONLY_ACTIVE: [_mam_result(self.ONLY_ACTIVE, "MyAnonamouse", shared_guid)],
|
||||
self.FREELEECH: [
|
||||
_mam_result(self.FREELEECH, "MyAnonamouse - Freeleech", shared_guid)
|
||||
],
|
||||
},
|
||||
config_values=self.SPLIT,
|
||||
)
|
||||
|
||||
source_ids = [r.source_id for r in releases]
|
||||
assert len(source_ids) == 2
|
||||
assert len(set(source_ids)) == 2
|
||||
|
||||
def test_source_ids_resolve_to_their_own_indexer_entry(self, monkeypatch):
|
||||
from shelfmark.release_sources.prowlarr.cache import get_release
|
||||
|
||||
shared_guid = "https://tracker.example/torrent/555"
|
||||
releases = self._search(
|
||||
monkeypatch,
|
||||
{
|
||||
self.ONLY_ACTIVE: [_mam_result(self.ONLY_ACTIVE, "MyAnonamouse", shared_guid)],
|
||||
self.FREELEECH: [
|
||||
_mam_result(self.FREELEECH, "MyAnonamouse - Freeleech", shared_guid)
|
||||
],
|
||||
},
|
||||
config_values=self.SPLIT,
|
||||
)
|
||||
|
||||
assert len(releases) == 2
|
||||
cached_indexer_ids = []
|
||||
for release in releases:
|
||||
cached = get_release(release.source_id)
|
||||
assert cached is not None
|
||||
assert cached["indexerId"] == release.extra["indexer_id"]
|
||||
cached_indexer_ids.append(cached["indexerId"])
|
||||
|
||||
assert sorted(cached_indexer_ids) == [self.ONLY_ACTIVE, self.FREELEECH]
|
||||
|
||||
def test_repeat_of_same_result_from_one_indexer_still_collapses(self, monkeypatch):
|
||||
duplicate = _mam_result(self.ONLY_ACTIVE, "MyAnonamouse", "https://tracker.example/t/1")
|
||||
releases = self._search(monkeypatch, {self.ONLY_ACTIVE: [duplicate, dict(duplicate)]})
|
||||
|
||||
assert len(releases) == 1
|
||||
|
||||
|
||||
class TestBuildSourceId:
|
||||
"""source_id keys the release cache, so it must survive shared guids."""
|
||||
|
||||
def test_same_guid_from_two_indexer_entries_gets_two_ids(self):
|
||||
guid = "https://tracker.example/torrent/555"
|
||||
|
||||
assert build_source_id({"guid": guid, "indexerId": 10}) != build_source_id(
|
||||
{"guid": guid, "indexerId": 25}
|
||||
)
|
||||
|
||||
def test_same_indexer_and_guid_is_stable(self):
|
||||
result = {"guid": "https://tracker.example/torrent/555", "indexerId": 10}
|
||||
|
||||
assert build_source_id(result) == build_source_id(dict(result))
|
||||
|
||||
def test_falls_back_to_the_bare_guid_without_an_indexer_id(self):
|
||||
guid = "https://tracker.example/torrent/555"
|
||||
|
||||
assert build_source_id({"guid": guid}) == guid
|
||||
|
||||
def test_falls_back_to_indexer_and_title_without_a_guid(self):
|
||||
source_id = build_source_id({"indexerId": 10, "indexer": "MAM", "title": "Dune"})
|
||||
|
||||
assert source_id.startswith("10:MAM:")
|
||||
|
||||
|
||||
class TestCollapseDuplicatesSetting:
|
||||
"""One-row-per-release collapse, on by default, resolved by Prowlarr's priority."""
|
||||
|
||||
ONLY_ACTIVE = 10
|
||||
FREELEECH = 25
|
||||
|
||||
def _search_collapsed(self, monkeypatch, priorities):
|
||||
shared_guid = "https://tracker.example/torrent/555"
|
||||
return TestIndexerAwareDeduplication()._search(
|
||||
monkeypatch,
|
||||
{
|
||||
self.ONLY_ACTIVE: [_mam_result(self.ONLY_ACTIVE, "MyAnonamouse", shared_guid)],
|
||||
self.FREELEECH: [
|
||||
_mam_result(self.FREELEECH, "MyAnonamouse - Freeleech", shared_guid)
|
||||
],
|
||||
},
|
||||
config_values={"PROWLARR_COLLAPSE_DUPLICATES": True},
|
||||
priorities=priorities,
|
||||
)
|
||||
|
||||
def test_collapse_keeps_the_better_prowlarr_priority(self, monkeypatch):
|
||||
releases = self._search_collapsed(monkeypatch, {self.FREELEECH: 20, self.ONLY_ACTIVE: 24})
|
||||
|
||||
assert len(releases) == 1
|
||||
assert releases[0].indexer == "MyAnonamouse - Freeleech"
|
||||
|
||||
def test_collapse_honours_the_reverse_priority(self, monkeypatch):
|
||||
releases = self._search_collapsed(monkeypatch, {self.FREELEECH: 30, self.ONLY_ACTIVE: 24})
|
||||
|
||||
assert len(releases) == 1
|
||||
assert releases[0].indexer == "MyAnonamouse"
|
||||
|
||||
def test_equal_priority_keeps_the_first_queried(self, monkeypatch):
|
||||
releases = self._search_collapsed(monkeypatch, {self.FREELEECH: 25, self.ONLY_ACTIVE: 25})
|
||||
|
||||
assert len(releases) == 1
|
||||
assert releases[0].indexer == "MyAnonamouse"
|
||||
|
||||
def _search_shared_guid(self, monkeypatch, config_values):
|
||||
return TestIndexerAwareDeduplication()._search(
|
||||
monkeypatch,
|
||||
{
|
||||
self.ONLY_ACTIVE: [
|
||||
_mam_result(self.ONLY_ACTIVE, "MyAnonamouse", "https://tracker.example/t/9")
|
||||
],
|
||||
self.FREELEECH: [
|
||||
_mam_result(self.FREELEECH, "MAM - Freeleech", "https://tracker.example/t/9")
|
||||
],
|
||||
},
|
||||
config_values=config_values,
|
||||
priorities={self.FREELEECH: 20, self.ONLY_ACTIVE: 24},
|
||||
)
|
||||
|
||||
def test_collapse_is_on_when_the_setting_is_untouched(self, monkeypatch):
|
||||
"""An upgrading user who sets nothing keeps the single row they had before #1137."""
|
||||
releases = self._search_shared_guid(monkeypatch, None)
|
||||
|
||||
assert len(releases) == 1
|
||||
assert releases[0].indexer == "MAM - Freeleech"
|
||||
|
||||
def test_opting_out_keeps_every_indexer_entry(self, monkeypatch):
|
||||
releases = self._search_shared_guid(monkeypatch, {"PROWLARR_COLLAPSE_DUPLICATES": False})
|
||||
|
||||
assert len(releases) == 2
|
||||
|
||||
def test_the_settings_field_and_the_search_fallback_agree(self):
|
||||
"""The field default is what governs in production; the search fallback only
|
||||
applies to an unregistered key. They have to say the same thing.
|
||||
"""
|
||||
from shelfmark.release_sources.prowlarr.settings import prowlarr_config_settings
|
||||
|
||||
field = next(
|
||||
f for f in prowlarr_config_settings() if f.key == "PROWLARR_COLLAPSE_DUPLICATES"
|
||||
)
|
||||
|
||||
assert field.default is True
|
||||
|
||||
|
||||
class TestBuildIndexerPriority:
|
||||
"""The priority NUMBER from Prowlarr is the rank; the id is only the key."""
|
||||
|
||||
def test_reads_the_priority_number_per_indexer(self):
|
||||
priority = _build_indexer_priority([{"id": 25, "priority": 20}, {"id": 10, "priority": 24}])
|
||||
|
||||
assert priority == {25: 20, 10: 24}
|
||||
|
||||
def test_a_lower_number_is_preferred(self):
|
||||
priority = _build_indexer_priority([{"id": 25, "priority": 20}, {"id": 10, "priority": 24}])
|
||||
|
||||
assert priority[25] < priority[10]
|
||||
|
||||
def test_coerces_string_ids_and_priorities(self):
|
||||
assert _build_indexer_priority([{"id": "25", "priority": "20"}]) == {25: 20}
|
||||
|
||||
def test_skips_records_without_a_usable_id_or_priority(self):
|
||||
assert (
|
||||
_build_indexer_priority([{"priority": 20}, {"id": "abc", "priority": 20}, {"id": 7}])
|
||||
== {}
|
||||
)
|
||||
|
||||
def test_empty_input_yields_no_preferences(self):
|
||||
assert _build_indexer_priority([]) == {}
|
||||
|
||||
|
||||
class TestDedupEdgeCases:
|
||||
"""Malformed and partial results must never silently lose a row."""
|
||||
|
||||
def test_unidentifiable_results_are_all_kept(self, monkeypatch):
|
||||
blank = {"indexerId": 10, "indexer": "MAM", "protocol": "torrent"}
|
||||
releases = TestIndexerAwareDeduplication()._search(
|
||||
monkeypatch, {10: [dict(blank), dict(blank)]}
|
||||
)
|
||||
|
||||
assert len(releases) == 2
|
||||
|
||||
def test_dedup_key_is_none_when_nothing_identifies_the_result(self):
|
||||
assert _result_dedup_key({"indexerId": 10}) is None
|
||||
assert _result_dedup_key({"guid": " ", "title": " "}) is None
|
||||
|
||||
def test_dedup_falls_back_to_title_within_one_indexer(self):
|
||||
first = {"indexerId": 10, "title": "Dune"}
|
||||
second = {"indexerId": 10, "title": "Dune"}
|
||||
assert _result_dedup_key(first) == _result_dedup_key(second)
|
||||
|
||||
def test_same_title_from_two_indexers_is_not_deduped(self):
|
||||
assert _result_dedup_key({"indexerId": 10, "title": "Dune"}) != _result_dedup_key(
|
||||
{"indexerId": 25, "title": "Dune"}
|
||||
)
|
||||
|
||||
def test_identity_ignores_whitespace_only_fields(self):
|
||||
assert _release_identity({"guid": " ", "downloadUrl": "https://x/1"}) == "https://x/1"
|
||||
|
||||
def test_identity_accepts_a_numeric_guid(self):
|
||||
assert _release_identity({"guid": 12345}) == "12345"
|
||||
|
||||
def test_identity_is_none_without_a_strong_identifier(self):
|
||||
assert _release_identity({"title": "Dune", "indexerId": 10}) is None
|
||||
|
||||
|
||||
class TestCollapseEdgeCases:
|
||||
"""Collapse discards rows, so it must only ever merge on a strong identifier."""
|
||||
|
||||
def test_unidentifiable_results_are_never_collapsed(self):
|
||||
blank = {"indexerId": 10, "title": "Dune"}
|
||||
kept = _collapse_duplicate_indexer_results([dict(blank), dict(blank)], {})
|
||||
|
||||
assert len(kept) == 2
|
||||
|
||||
def test_same_title_different_torrents_are_not_collapsed(self):
|
||||
results = [
|
||||
{"indexerId": 10, "guid": "guid-a", "title": "Dune"},
|
||||
{"indexerId": 25, "guid": "guid-b", "title": "Dune"},
|
||||
]
|
||||
|
||||
assert len(_collapse_duplicate_indexer_results(results, {})) == 2
|
||||
|
||||
def test_collapse_preserves_the_position_of_the_row_it_replaces(self):
|
||||
results = [
|
||||
{"indexerId": 10, "guid": "shared"},
|
||||
{"indexerId": 99, "guid": "other"},
|
||||
{"indexerId": 25, "guid": "shared"},
|
||||
]
|
||||
kept = _collapse_duplicate_indexer_results(results, {25: 0, 10: 1})
|
||||
|
||||
assert [r["indexerId"] for r in kept] == [25, 99]
|
||||
|
||||
def test_results_without_an_indexer_id_still_collapse_on_guid(self):
|
||||
results = [{"guid": "shared"}, {"guid": "shared"}]
|
||||
|
||||
assert len(_collapse_duplicate_indexer_results(results, {})) == 1
|
||||
|
||||
|
||||
class TestIndexerPrioritySorting:
|
||||
"""Results are listed by the indexer priority configured in Prowlarr."""
|
||||
|
||||
ONLY_ACTIVE = 10
|
||||
FREELEECH = 25
|
||||
OTHER = 99
|
||||
|
||||
def _search(self, monkeypatch, priorities):
|
||||
return TestIndexerAwareDeduplication()._search(
|
||||
monkeypatch,
|
||||
{
|
||||
self.ONLY_ACTIVE: [_mam_result(self.ONLY_ACTIVE, "MyAnonamouse", "https://t/a")],
|
||||
self.FREELEECH: [
|
||||
_mam_result(self.FREELEECH, "MyAnonamouse - Freeleech", "https://t/b")
|
||||
],
|
||||
self.OTHER: [_mam_result(self.OTHER, "Some Other Tracker", "https://t/c")],
|
||||
},
|
||||
priorities=priorities,
|
||||
)
|
||||
|
||||
def test_results_follow_the_priority_numbers(self, monkeypatch):
|
||||
releases = self._search(
|
||||
monkeypatch, {self.FREELEECH: 20, self.ONLY_ACTIVE: 24, self.OTHER: 50}
|
||||
)
|
||||
|
||||
assert [r.extra["indexer_id"] for r in releases] == [
|
||||
self.FREELEECH,
|
||||
self.ONLY_ACTIVE,
|
||||
self.OTHER,
|
||||
]
|
||||
|
||||
def test_reversing_the_numbers_reverses_the_list(self, monkeypatch):
|
||||
releases = self._search(
|
||||
monkeypatch, {self.FREELEECH: 50, self.ONLY_ACTIVE: 24, self.OTHER: 20}
|
||||
)
|
||||
|
||||
assert [r.extra["indexer_id"] for r in releases] == [
|
||||
self.OTHER,
|
||||
self.ONLY_ACTIVE,
|
||||
self.FREELEECH,
|
||||
]
|
||||
|
||||
def test_equal_priorities_keep_query_order(self, monkeypatch):
|
||||
releases = self._search(
|
||||
monkeypatch, {self.FREELEECH: 25, self.ONLY_ACTIVE: 25, self.OTHER: 25}
|
||||
)
|
||||
|
||||
assert [r.extra["indexer_id"] for r in releases] == [
|
||||
self.ONLY_ACTIVE,
|
||||
self.FREELEECH,
|
||||
self.OTHER,
|
||||
]
|
||||
|
||||
|
||||
class TestIndexerPrioritySortOption:
|
||||
"""The UI needs the priority on the row to offer an explicit sort on it."""
|
||||
|
||||
def test_releases_carry_their_prowlarr_priority(self, monkeypatch):
|
||||
releases = TestIndexerPrioritySorting()._search(monkeypatch, {10: 24, 25: 20, 99: 50})
|
||||
|
||||
by_indexer = {r.extra["indexer_id"]: r.extra.get("indexer_priority") for r in releases}
|
||||
assert by_indexer == {10: 24, 25: 20, 99: 50}
|
||||
|
||||
def test_priority_is_absent_when_prowlarr_reports_none(self, monkeypatch):
|
||||
releases = TestIndexerPrioritySorting()._search(monkeypatch, {})
|
||||
|
||||
assert all(r.extra.get("indexer_priority") == 25 for r in releases)
|
||||
|
||||
def test_sort_option_is_offered_lowest_first(self):
|
||||
from shelfmark.release_sources import serialize_column_config
|
||||
|
||||
source = ProwlarrSource()
|
||||
serialized = serialize_column_config(source.get_column_config())
|
||||
options = {o["label"]: o for o in serialized["extra_sort_options"]}
|
||||
|
||||
assert options["Indexer priority"]["sort_key"] == "extra.indexer_priority"
|
||||
assert options["Indexer priority"]["default_direction"] == "asc"
|
||||
assert options["Peers"]["default_direction"] == "desc"
|
||||
|
||||
@@ -668,3 +668,133 @@ class TestExtractHashFromMagnet:
|
||||
magnet = f"magnet:?xt=urn:btmh:{b32}&dn=test"
|
||||
result = extract_hash_from_magnet(magnet)
|
||||
assert result == digest.hex()
|
||||
|
||||
|
||||
class TestTorrentFetchCache:
|
||||
"""Tests for reusing a fetched .torrent across find_existing/add_download (#1111)."""
|
||||
|
||||
@staticmethod
|
||||
def _valid_torrent():
|
||||
info_dict = {
|
||||
b"name": b"book.txt",
|
||||
b"length": 100,
|
||||
b"piece length": 16384,
|
||||
b"pieces": b"\x00" * 20,
|
||||
}
|
||||
torrent_data = bencode_encode({b"info": info_dict})
|
||||
info_hash = hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower()
|
||||
return torrent_data, info_hash
|
||||
|
||||
def test_repeated_url_reuses_fetched_torrent_data(self, monkeypatch):
|
||||
"""The same download URL is fetched once per add attempt, not once per caller.
|
||||
|
||||
find_existing() and add_download() both resolve the request URL; private
|
||||
tracker links behind Prowlarr's proxy can be rate-limited or single-use,
|
||||
so the second fetch must be served from the cache.
|
||||
"""
|
||||
torrent_data, info_hash = self._valid_torrent()
|
||||
response = MagicMock(status_code=200, content=torrent_data)
|
||||
response.raise_for_status = MagicMock()
|
||||
mock_get = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
||||
|
||||
url = "https://prowlarr.example/26/download?apikey=secret&link=token"
|
||||
first = extract_torrent_info(url, fetch_torrent=True)
|
||||
second = extract_torrent_info(url, fetch_torrent=True)
|
||||
|
||||
mock_get.assert_called_once()
|
||||
assert first.info_hash == info_hash
|
||||
assert second.info_hash == info_hash
|
||||
assert second.torrent_data == torrent_data
|
||||
|
||||
def test_failed_fetch_is_not_cached_and_records_reason(self, monkeypatch):
|
||||
"""Fetch failures are retried on the next call and expose the reason."""
|
||||
import requests as requests_module
|
||||
|
||||
mock_get = MagicMock(
|
||||
side_effect=requests_module.exceptions.HTTPError(
|
||||
"500 Server Error: Internal Server Error for url: https://prowlarr.example/26/download"
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
||||
|
||||
url = "https://prowlarr.example/26/download?apikey=secret&link=token"
|
||||
first = extract_torrent_info(url, fetch_torrent=True)
|
||||
second = extract_torrent_info(url, fetch_torrent=True)
|
||||
|
||||
assert mock_get.call_count == 2
|
||||
assert first.fetch_error is not None
|
||||
assert "500 Server Error" in first.fetch_error
|
||||
assert first.info_hash is None
|
||||
assert second.fetch_error is not None
|
||||
|
||||
def test_fetch_failure_still_falls_back_to_expected_hash(self, monkeypatch):
|
||||
"""A known infohash keeps working when the prefetch fails."""
|
||||
import requests as requests_module
|
||||
|
||||
mock_get = MagicMock(side_effect=requests_module.exceptions.ConnectionError("boom"))
|
||||
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
||||
|
||||
known_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
||||
result = extract_torrent_info(
|
||||
"https://tracker.example/download/book.torrent",
|
||||
fetch_torrent=True,
|
||||
expected_hash=known_hash,
|
||||
)
|
||||
|
||||
assert result.info_hash == known_hash
|
||||
assert result.fetch_error is not None
|
||||
assert "boom" in result.fetch_error
|
||||
|
||||
def test_expected_hash_applies_to_cached_hashless_result(self, monkeypatch):
|
||||
"""A cached fetch without a hash still honors a caller's expected_hash."""
|
||||
response = MagicMock(status_code=200, content=b"<html>not a torrent</html>")
|
||||
response.raise_for_status = MagicMock()
|
||||
mock_get = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
||||
|
||||
url = "https://tracker.example/download/book.torrent"
|
||||
known_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
||||
first = extract_torrent_info(url, fetch_torrent=True)
|
||||
second = extract_torrent_info(url, fetch_torrent=True, expected_hash=known_hash)
|
||||
|
||||
mock_get.assert_called_once()
|
||||
assert first.info_hash is None
|
||||
assert second.info_hash == known_hash
|
||||
|
||||
def test_cache_expires_after_ttl(self, monkeypatch):
|
||||
"""Stale cache entries are refetched instead of reused."""
|
||||
torrent_data, _ = self._valid_torrent()
|
||||
response = MagicMock(status_code=200, content=torrent_data)
|
||||
response.raise_for_status = MagicMock()
|
||||
mock_get = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
||||
|
||||
clock = {"now": 1000.0}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.torrent_utils.time.monotonic",
|
||||
lambda: clock["now"],
|
||||
)
|
||||
|
||||
url = "https://tracker.example/download/book.torrent"
|
||||
extract_torrent_info(url, fetch_torrent=True)
|
||||
clock["now"] += 121.0
|
||||
extract_torrent_info(url, fetch_torrent=True)
|
||||
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
def test_magnet_redirect_result_is_reused(self, monkeypatch):
|
||||
"""A URL that redirects to a magnet link is also only resolved once."""
|
||||
magnet = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=test"
|
||||
response = MagicMock(status_code=302, headers={"Location": magnet})
|
||||
mock_get = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
||||
|
||||
url = "https://tracker.example/download/book.torrent"
|
||||
first = extract_torrent_info(url, fetch_torrent=True)
|
||||
second = extract_torrent_info(url, fetch_torrent=True)
|
||||
|
||||
mock_get.assert_called_once()
|
||||
assert first.is_magnet is True
|
||||
assert second.magnet_url == magnet
|
||||
assert second.info_hash == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
||||
|
||||
@@ -68,6 +68,50 @@ def create_mock_transmission_rpc_module():
|
||||
return mock_module
|
||||
|
||||
|
||||
def _transmission_client_with_labels(labels):
|
||||
from shelfmark.download.clients.transmission import TransmissionClient
|
||||
|
||||
client = TransmissionClient.__new__(TransmissionClient)
|
||||
client._client = MagicMock()
|
||||
client._client.get_torrent.return_value = types.SimpleNamespace(labels=labels)
|
||||
return client
|
||||
|
||||
|
||||
def test_set_category_appends_to_existing_labels():
|
||||
client = _transmission_client_with_labels(["books", "seeding"])
|
||||
|
||||
assert client.set_category("abc123", "imported") is True
|
||||
client._client.change_torrent.assert_called_once_with(
|
||||
ids="abc123",
|
||||
labels=["books", "seeding", "imported"],
|
||||
)
|
||||
|
||||
|
||||
def test_set_category_adds_label_when_torrent_has_none():
|
||||
client = _transmission_client_with_labels([])
|
||||
|
||||
assert client.set_category("abc123", "imported") is True
|
||||
client._client.change_torrent.assert_called_once_with(
|
||||
ids="abc123",
|
||||
labels=["imported"],
|
||||
)
|
||||
|
||||
|
||||
def test_set_category_is_a_noop_when_label_already_present():
|
||||
client = _transmission_client_with_labels(["books", "imported"])
|
||||
|
||||
assert client.set_category("abc123", "imported") is True
|
||||
client._client.change_torrent.assert_not_called()
|
||||
|
||||
|
||||
def test_set_category_does_not_clobber_labels_when_lookup_fails():
|
||||
client = _transmission_client_with_labels([])
|
||||
client._client.get_torrent.side_effect = ValueError("boom")
|
||||
|
||||
assert client.set_category("abc123", "imported") is False
|
||||
client._client.change_torrent.assert_not_called()
|
||||
|
||||
|
||||
class TestTransmissionClientIsConfigured:
|
||||
"""Tests for TransmissionClient.is_configured()."""
|
||||
|
||||
|
||||
@@ -129,57 +129,76 @@ EOF
|
||||
cat <<'HC' > /app/tor_healthcheck.sh
|
||||
#!/bin/bash
|
||||
|
||||
# Function to dynamically wait for Tor bootstrap
|
||||
wait_for_tor() {
|
||||
echo "$(date): Waiting for Tor to finish bootstrapping..."
|
||||
# Tor watchdog.
|
||||
#
|
||||
# Once the NAT rules are live every process in the container is pinned to Tor,
|
||||
# so a Tor that has died or wedged takes the whole app down with it and cannot
|
||||
# reliably be repaired in place. When Tor stops being usable, tear the container
|
||||
# down instead: the configured Docker restart policy brings it back and tor.sh
|
||||
# rebuilds torrc, supervisor and the iptables rules from scratch.
|
||||
|
||||
> /var/log/tor/notices.log 2>/dev/null || true
|
||||
CHECK_INTERVAL=${TOR_CHECK_INTERVAL:-30}
|
||||
# Strikes before the container is reset. 2 means "one retry": a single failed
|
||||
# probe is tolerated, a second consecutive failure resets.
|
||||
MAX_FAILURES=${TOR_MAX_FAILURES:-2}
|
||||
NOTICES_LOG=${TOR_NOTICES_LOG:-/var/log/tor/notices.log}
|
||||
TRANS_PORT=${TOR_TRANS_PORT:-9040}
|
||||
# PID 1 is dumb-init, which tears the container down when it is signalled.
|
||||
CONTAINER_PID=${TOR_CONTAINER_PID:-1}
|
||||
|
||||
sleep 10
|
||||
tor_process_running() {
|
||||
supervisorctl status tor 2>/dev/null | grep -q "RUNNING"
|
||||
}
|
||||
|
||||
TIMEOUT=300
|
||||
ELAPSED=0
|
||||
while [ $ELAPSED -lt $TIMEOUT ]; do
|
||||
if grep -q "Bootstrapped 100%" /var/log/tor/notices.log 2>/dev/null; then
|
||||
echo "$(date): Tor bootstrap complete."
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
ELAPSED=$((ELAPSED + 5))
|
||||
# Show progress
|
||||
CURRENT=$(tail -n 1 /var/log/tor/notices.log 2>/dev/null | grep -oP 'Bootstrapped \d+%' || echo "waiting...")
|
||||
echo "$(date): Bootstrap progress: $CURRENT ($ELAPSED/${TIMEOUT}s)"
|
||||
done
|
||||
tor_has_bootstrapped() {
|
||||
grep -q "Bootstrapped 100%" "$NOTICES_LOG" 2>/dev/null
|
||||
}
|
||||
|
||||
echo "$(date): WARNING - Tor bootstrap timed out after ${TIMEOUT}s"
|
||||
return 1
|
||||
# Liveness probe for "Tor is still running but no longer usable". Deliberately a
|
||||
# loopback check against Tor's own TransPort: probing the clear net from here
|
||||
# would leak and defeat the point of the proxy.
|
||||
tor_accepts_connections() {
|
||||
timeout 5 bash -c "exec 3<>/dev/tcp/127.0.0.1/${TRANS_PORT}" 2>/dev/null
|
||||
}
|
||||
|
||||
tor_is_healthy() {
|
||||
supervisorctl status tor | grep -q "RUNNING" &&
|
||||
grep -q "Bootstrapped 100%" /var/log/tor/notices.log 2>/dev/null
|
||||
tor_process_running && tor_has_bootstrapped && tor_accepts_connections
|
||||
}
|
||||
|
||||
reset_container() {
|
||||
echo "$(date): Tor still unhealthy after ${MAX_FAILURES} checks - resetting container."
|
||||
kill -TERM "$CONTAINER_PID" 2>/dev/null
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Never police Tor before it has bootstrapped once. A first bootstrap can
|
||||
# legitimately take minutes on a slow link, and mistaking that for a failure
|
||||
# would reset the container into an endless restart loop.
|
||||
echo "$(date): Waiting for initial Tor bootstrap before monitoring..."
|
||||
while ! tor_has_bootstrapped; do
|
||||
sleep "$CHECK_INTERVAL"
|
||||
done
|
||||
echo "$(date): Tor bootstrapped - watchdog active (every ${CHECK_INTERVAL}s, ${MAX_FAILURES} strikes)."
|
||||
|
||||
FAIL_COUNT=0
|
||||
while true; do
|
||||
if tor_is_healthy; then
|
||||
if [ "$FAIL_COUNT" -ne 0 ]; then
|
||||
echo "$(date): Tor recovered."
|
||||
fi
|
||||
FAIL_COUNT=0
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT+1))
|
||||
echo "$(date): Healthcheck failed (Count: $FAIL_COUNT)"
|
||||
echo "$(date): Healthcheck failed (${FAIL_COUNT}/${MAX_FAILURES})"
|
||||
|
||||
if [ "$FAIL_COUNT" -ge "$MAX_FAILURES" ]; then
|
||||
reset_container
|
||||
fi
|
||||
|
||||
echo "$(date): Retrying in ${CHECK_INTERVAL}s before resetting..."
|
||||
fi
|
||||
|
||||
# If failed 3 times in a row, restart Tor
|
||||
if [ "$FAIL_COUNT" -ge 3 ]; then
|
||||
echo "$(date): restart trigger - Restarting Tor..."
|
||||
supervisorctl restart tor
|
||||
FAIL_COUNT=0
|
||||
|
||||
# Wait for it to come back using the dynamic check
|
||||
wait_for_tor
|
||||
fi
|
||||
|
||||
sleep 30
|
||||
sleep "$CHECK_INTERVAL"
|
||||
done
|
||||
HC
|
||||
chmod +x /app/tor_healthcheck.sh
|
||||
@@ -221,8 +240,23 @@ TOR_UID=$(id -u debian-tor)
|
||||
# Allow loopback
|
||||
iptables -t nat -A OUTPUT -o lo -j RETURN
|
||||
|
||||
# Allow Tor itself to reach the network
|
||||
iptables -t nat -A OUTPUT -m owner --uid-owner "$TOR_UID" -j RETURN
|
||||
# Allow Tor itself to reach the network.
|
||||
#
|
||||
# The owner match needs the xt_owner kernel module, which some NAS and embedded
|
||||
# kernels (Synology DSM in particular) do not ship. There iptables rejects the
|
||||
# rule with "Extension owner revision 0 not supported, missing kernel module?",
|
||||
# and under `set -e` that aborted the whole script before any routing rule was
|
||||
# installed, leaving the container in a restart loop. Treat the exemption as
|
||||
# best-effort so those hosts keep working as they did before it was introduced.
|
||||
if iptables -t nat -A OUTPUT -m owner --uid-owner "$TOR_UID" -j RETURN 2>/dev/null; then
|
||||
echo "[✓] Tor process (uid $TOR_UID) exempted from transparent redirect."
|
||||
else
|
||||
echo "[!] Warning: this kernel has no iptables owner match (xt_owner module)."
|
||||
echo "[!] Continuing without the Tor process exemption. Tor keeps using the"
|
||||
echo "[!] connections it opened while bootstrapping, so traffic is still"
|
||||
echo "[!] routed through Tor, but if Tor loses its guard relays it may need"
|
||||
echo "[!] a container restart to recover."
|
||||
fi
|
||||
|
||||
# For UDP DNS queries
|
||||
iptables -t nat -A OUTPUT -p udp --dport 53 ! -d 127.0.0.1 -j DNAT --to-destination 127.0.0.1:53
|
||||
|
||||
@@ -101,11 +101,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.6.17"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -143,43 +143,37 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
version = "3.4.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -360,11 +354,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.29.6"
|
||||
version = "3.32.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/c8/35bdf04fb30755e2ed758f877edf3eb4a243c2463d3a258cc28b18b7a6e2/filelock-3.29.6.tar.gz", hash = "sha256:895c532ef3f4ef04972b9446a8c4e2931a5c399ff3c4be4c9369f2639b80f793", size = 70301, upload-time = "2026-07-06T23:08:08.577Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/49/7467c2946ccd9617f7da38187071bdc45bb9a95df51f4d63d6622432ce4e/filelock-3.29.6-py3-none-any.whl", hash = "sha256:14d5f5597d2e0c4dbd774cfb6d8132da1db44da83732aab679d54f7dcf97ab65", size = 45478, upload-time = "2026-07-06T23:08:07.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -416,7 +410,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gevent"
|
||||
version = "26.5.0"
|
||||
version = "26.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" },
|
||||
@@ -424,26 +418,24 @@ dependencies = [
|
||||
{ name = "zope-event" },
|
||||
{ name = "zope-interface" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/cb/98aa3a299e2fc4a2372b5d124863e02965b64579ffc29fe54d0641e65b2f/gevent-26.5.0.tar.gz", hash = "sha256:1655eb04c1e20d71b2aa4a3c7528162dd58ff6cc46a037af1f01f534c80fefba", size = 6712354, upload-time = "2026-05-20T21:22:45.132Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/5c/92002455a57cb3634383e2b822e3bccf409f43cde34528e46428971475cf/gevent-26.7.0.tar.gz", hash = "sha256:5b333a556e38a302b1b8c80525bef16d437e16f1e7767947789406841856a102", size = 6729213, upload-time = "2026-07-22T20:16:04.713Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/55/7d98d3888e7bb9ad4656420dec69232ecbbea48792aff9295d0ad7cf8435/gevent-26.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:75a0050e4b87f08ddee7e56f59e6014cd7fcdc3153046c09a847940515d12c85", size = 2968223, upload-time = "2026-05-20T20:13:17.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/b4/e8e116fcbcb9dc0bf3acc50037f86e1204c217c8ed5defde68be11b3aab6/gevent-26.5.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:fd1a0b83a04e19378d9466ae0ee2b5937cf1d7fbfdcb916b2aea82179a208574", size = 1793926, upload-time = "2026-05-20T21:17:34.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/07/7b267e9754b661defb93542e97731a4df21f8a40dc0f6c853faa717cf124/gevent-26.5.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:4c964c15076e76391d523ec24202f579a2535f7e301a40efb1656ae046d3eb69", size = 1887632, upload-time = "2026-05-20T21:16:04.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/50/b47d29e99449bd13b557ffa451401dc13d397a9923f562ef90a4e8514502/gevent-26.5.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:45d5438d1c84da5df7e832434627624709543630977332bb4e2d05ecca362cc9", size = 1838688, upload-time = "2026-05-20T21:30:57.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/eb/5b54ccff11bc7d7bebd40a24571ccc115d5cdae4f6c32ab457b43b436e42/gevent-26.5.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:354f35924113abc954819216c2a6ee16751958c615681e0490946e31b437bd2f", size = 2120351, upload-time = "2026-05-20T20:35:32.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/70/30fd325c30e04b1e5174c61945e17421d53ddb2450366cc52cef234f8c4b/gevent-26.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a47cd2d32f6404212d374ad8014a3491d7477dcf0cc09c5a2308ad6d325fd663", size = 1806684, upload-time = "2026-05-20T21:16:43.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/e8/fbf911ac3f9524ecfaed174d100fde671904ab8db92ceaf07faaebd13386/gevent-26.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:032157cebdedb84f2f52cdd980f2f5f2623eed6a8f083aadf44b44c47f628642", size = 2146606, upload-time = "2026-05-20T20:43:32.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/4d/284fcbbfde66fd978c2980c1fbe0eabd586af6e4b728649e9cf459e8b38f/gevent-26.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:9c414935ba5fc88359110968851d3616f119082c937390d00a1c0f4f59be814f", size = 1722497, upload-time = "2026-05-20T20:16:44.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d2/9f66eb53434704402be0ba733bf3320bf589671a4b76fac52a7d6077e972/gevent-26.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:2a0f5993a04b95a35b3a118b1a58ba272833f9b547b774001dea29f90620882f", size = 1574249, upload-time = "2026-05-20T20:15:50.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/d5/b4c50adb761878e3c96642b9f79bf44cee3120f3df55cd40876f51d89866/gevent-26.5.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2e117df896a2660c9ebd4e2b5afc02dfd6e2ddf9b495e787e67c72d105432b09", size = 2971993, upload-time = "2026-05-20T20:12:50.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/83/71c2a945e80198422d1d93dbe67355f249fb456b451bf9201199d3ef6a1a/gevent-26.5.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:af5ffe9c11ffb8a39b6bef2e8b722aa2043ae4980977915c6aa8c68b4bc26e46", size = 1796658, upload-time = "2026-05-20T21:17:35.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/96/548ca77aed5cb9a44e855a6c23ebceeb3554a0ea9ca0c01c311878899a3e/gevent-26.5.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:7da34aef7e87c43dd3662e5785e79ed505c01399a7cb42876d2d8925969fd75f", size = 1891473, upload-time = "2026-05-20T21:16:05.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/4f/f48bd47d5287afb0fbcc56165f3ed47583f1803bad401653fe27e71ade2d/gevent-26.5.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:1c6293a7046bcc6f3d8972a74b19cd7a4cfd02d3881edf0fcf827aa514bd247b", size = 1841429, upload-time = "2026-05-20T21:30:59.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/72/1925215fc720d2561fa3ec8d4af5f098f8d0cbfa76a45fafed6e5ade7718/gevent-26.5.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:d3bde0f140a275b2fa88e4b6516bda85551930e10bc2fd95e18c1b7d11cb780c", size = 2123895, upload-time = "2026-05-20T20:35:34.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/59/0f584f6b1170c9a6abd9b70ccf5e9cc5ead34eabafabc0e21876ef0fe6f7/gevent-26.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e29fb4b17d9958ec8cb7f6339a111b29bc23f2c2efbef86189d1248bb4862d17", size = 1809047, upload-time = "2026-05-20T21:16:45.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/88/61e854bfd98ac22eac78a97fc6db10de0f9ace46514072b435c217168729/gevent-26.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b2239df2f7570efa03736678f3f053bb1bdd22a8a16cd28a2feb7d32ea5f533f", size = 2150764, upload-time = "2026-05-20T20:43:33.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/f5/af048b97433d7f9a7df7f5510b2c46918b7d073dcfb3bf6d0ef0e5a83dcc/gevent-26.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:aae214952fd38d27a42dc416bb70193962ec932384b63445d29bbb5817a1c042", size = 1722600, upload-time = "2026-05-20T20:19:56.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/95/fb74a2299c6a2d78d9de12deaaac640ab5d2ef96a8e0f97a3ff84b9ca84b/gevent-26.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:f7067564f139e33bf26a31ee3b13d168d76eb99a44b85ced626652b158baa80c", size = 1574406, upload-time = "2026-05-20T20:17:12.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/bb/ab60d496cbdc0293ebbd6c2070b34da0632bd7a2ca20163c17e18d2d2dc9/gevent-26.7.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0e0e3bf7ae0f82dbc5c6be26b4781e86c97f1e28d516b7a9746ac8b04bcc6948", size = 2992503, upload-time = "2026-07-22T16:24:36.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/35/75f27c06a82a5b22600aaccbd9567d89bb4091be43e96c02981f10aff23d/gevent-26.7.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:740050b53048207b080a1e183a377c47809ad0b7b7b0cd7eab0dea1045f7e480", size = 1809173, upload-time = "2026-07-22T18:11:30.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/5f/a6b32b4db3fa76bd8a070f0f46f5306123bf6336e7a0ca0cd2f9b99473df/gevent-26.7.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:67983607eb6c7bafa362c5c43b69a27145b936c34a3d6441ed42413d62fae0a6", size = 1906630, upload-time = "2026-07-22T18:10:45.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/87/832495d8fcc05ff7432f038b7c4decbd5632425a2cd5da2ce73cb2d800c4/gevent-26.7.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:475848518d708e07d1987c3d94cb8ff53e2b3a69df32e39feda2779cafe400b0", size = 1855278, upload-time = "2026-07-22T18:29:11.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/8b/2f36c0fa389fa2b7ceb5a8972b0e7da7bc770f9135315cf4246c607ca5fc/gevent-26.7.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0f8ed457dd616bfe6682569f92730f9ab45aafb1aeca5e80eb2f6b9a2ce26d11", size = 2136155, upload-time = "2026-07-22T16:48:33.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/98/09f2cfaa23dbce48e3271e95b0d003f93acece6b5cfd40f4cebe3850d79b/gevent-26.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15373c68cf1fa14114bec2f09b16e2c65374bd5309e897e0a28740b09ce329e0", size = 1822108, upload-time = "2026-07-22T18:07:15.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d8/05a294165c17569f04284ad3c889684c8780544885b4cdf77b1432947d0c/gevent-26.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73f3d53f2f390369e290c933b75bd87f1f2261f2f2f2175aa667c43ee3049bad", size = 2162814, upload-time = "2026-07-22T17:02:18.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/89/58a545c4eda33e106d6887a0387adc2249abc14c779e3eb88bbfdf3768d6/gevent-26.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:f11b558d544ad2249029ba023cd6519ec3a0eee54a3d027e6515c1eaa322422a", size = 1706971, upload-time = "2026-07-22T16:27:02.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/cd/413f293e54961e5c89c54235370e3603ec0f561e7ace8357980410efbf78/gevent-26.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:3871f4ca59ec2328c3ef638a0fe01a28a825443a133368dc78eb5ceadcad7609", size = 1585078, upload-time = "2026-07-22T16:30:48.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/3a/47f29f632aaa38aa12410f57f1732fc50bfd4d4006d2e7e022ce731cabc9/gevent-26.7.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3e3d6e20a94239ad353b776e72b8ce18c35dbe4e98c279aef3932651553d8404", size = 2996208, upload-time = "2026-07-22T16:23:23.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/b3/4620f1ce81ecec9890229806c73f07dd022e40f76a552bd430391e7316c4/gevent-26.7.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:ddbd3cc76b9bc69df651a216c2a62fc6415ad463b3ac9c6cbbbb8b7b8224af17", size = 1811545, upload-time = "2026-07-22T18:11:32.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/fd/d285212ffd5585d511299e13e61d76262def8e826e9f20c92cb85df406f2/gevent-26.7.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:01ceab7e608dc1b9859d9511a0a29d7ce2e7d909ab19fddc860e70a2ed5b10ce", size = 1910418, upload-time = "2026-07-22T18:10:47.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/e0/c5d666e6065652918cfb6e6a3cf8f721d0e152c57cec217ad81792a1323b/gevent-26.7.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:2e6c917b2b8baeb6080797a6b25e35e1fd784319a05bb92b87c53546e5578eb2", size = 1857891, upload-time = "2026-07-22T18:29:13.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/67/e945ed458fa98b34572876bfd0d35fe4fa3f1159f43660b71d982b7cb63e/gevent-26.7.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:df75a1748b26030f2f7f10042cc45640b22954d9d0dc6b4b6f0dbe0b6751a2d4", size = 2138121, upload-time = "2026-07-22T16:48:35.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/55/622468fa1a3c4cf51f20e14813f5cc1592fe6e44a42ccca9195a0b18c769/gevent-26.7.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ee1b389587e5d5c1eb19d0455b5b4d7a0fb5c5287af4e226ec66d9dfd2548107", size = 1825114, upload-time = "2026-07-22T18:07:16.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/7a/151a2afcacf487ca25faf8b1bdd6c5b4ace2f7c1e6b4eaffe0a5e6e1df61/gevent-26.7.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c2918641ba756f46aa01ab9dd82d6dfceec403c77c2787298746b411dcf0288e", size = 2165990, upload-time = "2026-07-22T17:02:19.817Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -739,11 +731,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.10.0"
|
||||
version = "4.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -757,26 +749,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prek"
|
||||
version = "0.4.8"
|
||||
version = "0.4.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/46/e436a6eb9fdb4d3fd08d0ab7fdba19fe03a9e994ec810de57869b853bd8e/prek-0.4.8.tar.gz", hash = "sha256:d15d8bef72ab7b02c7dc01458ac9e05b3131534492b5ce9bb11c4f6f636fa868", size = 494570, upload-time = "2026-07-04T12:05:10.941Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/1a/73b6dae5ce7e997cb35a69bfe1d25a798e85fa3d2eabf95324563f461b30/prek-0.4.11.tar.gz", hash = "sha256:4a14cb9bbae850605ae3904fbdbb12f0e00c12455efaa2266da8fb8e5c0350d7", size = 516254, upload-time = "2026-07-24T17:05:35.107Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/78/b4149c8913ced2e42debb49e261c4788a1ce431e84226921c2e1a7ea8545/prek-0.4.8-py3-none-linux_armv6l.whl", hash = "sha256:1f8f8cdc65836b571824c965daebb81b449f7e4a43894c58621f5708d5a185ed", size = 5668955, upload-time = "2026-07-04T12:04:41.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/5f/7f54a0087b6b2f1751aeb41266d9c15e66fd0055492814798ab818cd0414/prek-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bce1798e96d9e3a6e6abf435da7107e81452f69edb3ca7c6f90a457355ea46e2", size = 6030947, upload-time = "2026-07-04T12:04:43.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/d6/f2829fc3902920c36b764a386fa303e71a8219dac25cb3827c575e84199a/prek-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab3a52db17254d701c3cebb7eea58c8230aa7c1959aacfd5b5f25de18edb15d1", size = 5572593, upload-time = "2026-07-04T12:04:45.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/8c/c5589955bcd5e3e33b67d8bc3110818cecac82a38fd6bc8b5dfdc5de421c/prek-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:b3fcfd620523bbc3f51a21d7cd63449f659b9e2cf3582de12dd5949e23227b8f", size = 5847150, upload-time = "2026-07-04T12:04:47.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/9d/1f2dc91bdb79d2c4714b27eac9477a51490fba5b4731330dbbebc76bd345/prek-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42e65bc8425e9d7f1691a13ca1da2e07807d1ba76c35740833354b945131689e", size = 5573738, upload-time = "2026-07-04T12:04:49.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/29/69a7b58e16ecbc5f3989bf4b028018d11a82dcdd320b93d6588d72f32aa7/prek-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f578492a8e0c9bc6b4bf6dfbba8716f647d4cd0769bf10ad6cf336e3096fd392", size = 5981054, upload-time = "2026-07-04T12:04:50.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/cc/9b9850a60c22ed18c7755ebd2d72c6eefb37fac58149d09f6adc4691c2cf/prek-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4335f9d5beb123a3884a7fe34f57c9f0828f4fbb7666beab4298833459b104f", size = 6751350, upload-time = "2026-07-04T12:04:52.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/e5/c425aa7272b430630119e6757def3a2007555ba8cbeb2630e0448e7a8b7f/prek-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18a8747df9c602e052881d3efb14dd7f7d62a59bd7277ae5171c9e7661d59d84", size = 6243881, upload-time = "2026-07-04T12:04:54.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/da/accd3ad07fd2891d3c2777eb42435439fdf11982c51d60f087c0b6b6e102/prek-0.4.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4db639db481d5f854eff9b3d2108889e613b8c15868bcf6bdd777c7cee577436", size = 5848846, upload-time = "2026-07-04T12:04:56.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/00/3477704635249f21f5f98ce444cd7690c2aa9dc8d146a045db88ef2cd8c5/prek-0.4.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c3890a6f92316d2cf44eb50584e8d2b23a596dd70487022e61186a71a2ac0900", size = 5713942, upload-time = "2026-07-04T12:04:58.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e6/3ca4fabaebeadc976d9a92d1d9130674265355ea3b728418bad61583b097/prek-0.4.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:fc7e15c24c591a37c6ffce5b25a021b16c299ac2649f183d812b67d665cd6551", size = 5554725, upload-time = "2026-07-04T12:04:59.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/46/2ab6aaaeff0cedb8955b2e4032071c8712382bdd423bb849718c3720180d/prek-0.4.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:36fe721704ff0c7624c1167639e23a5fe658bfd38c314f487219c9afd1eeb733", size = 5838595, upload-time = "2026-07-04T12:05:01.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8b/91398f2b6cd1629d5d8ca8c85b08eca500814a374313b0193f4aaf6ab6c4/prek-0.4.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:162e544abc394a8124f3a4ad68efee116bad09440e679dbd1675177335c2a432", size = 6357222, upload-time = "2026-07-04T12:05:03.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/2a/ce5cbfaad36866134a21754640a05ecdba641fcd7ad15aa74cf3443f34f6/prek-0.4.8-py3-none-win32.whl", hash = "sha256:2602e46c8c5da7dfa69f60fcf88c2b57132ac623f49fb08bfb3094298c5f07e3", size = 5354388, upload-time = "2026-07-04T12:05:05.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/03/3bc908bc5f7e430315553e47dfa055f19923a3888f9afe4da19f244b5cbf/prek-0.4.8-py3-none-win_amd64.whl", hash = "sha256:7cb22da60bee41b89c4978c0bea7126a3c0ccc003dae6748cf29b53947815edc", size = 5748221, upload-time = "2026-07-04T12:05:07.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a7/4295e6d5f5028171dfeb115ad38ab76bf3fe0c8df91b70d73c79aa760a94/prek-0.4.8-py3-none-win_arm64.whl", hash = "sha256:da70057f577b15d4bd121bf9dd29ee205fd4b4d75a0cafba062e84d7e8b4378b", size = 5574425, upload-time = "2026-07-04T12:05:09.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/d7/a00b2de492a80e99b1698e72c1d196ac3ed544dc7ee0ada261ac066e78e0/prek-0.4.11-py3-none-linux_armv6l.whl", hash = "sha256:3830cb7cc47e837888b8b464ecb21355a69235cfacef0fd89101e17f09345d63", size = 5770511, upload-time = "2026-07-24T17:05:12.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/1e/f97c74defcd5d5645888cb99fcdd9b8b48cc7247cf31e64414d106d56d66/prek-0.4.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:45facadf9c2332b28e6ab2744312ae0275a93ab5a437da8bcc53a8c7260cb4b0", size = 6118049, upload-time = "2026-07-24T17:05:14.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/92/8367d26421ee6fe6019a63928fb0ed31179cd0d6199879c524f89ef4c95c/prek-0.4.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2f8a194b1d00d24dff8baff691c99e2668339da2da3482b4ee99c1a0f2409378", size = 5601478, upload-time = "2026-07-24T17:05:15.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/6f/7617c9b87afaadede4167720aae7ef12d7e51167db903bc8fbac0cadef7b/prek-0.4.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:21d93e6d76bf3d7a9bb70c6ac86ef372adaee50068c45749e6b9e1c2ac4ec939", size = 5932071, upload-time = "2026-07-24T17:05:17.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/41/6796a4011b04212333259064aa885a71d03d9581ba9bff52db3ce58d1f06/prek-0.4.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fb07cde2d2156efa6980122b3f13dc88f20c84b933c6205675d0f1e7be2cde8", size = 5677617, upload-time = "2026-07-24T17:05:18.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/5f/3e339901f8460b6073313619b4fd9bf4135e48430695c53640b83ace88ea/prek-0.4.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b7f5e446d2aca739bd18b380578e9c78bb4c086299abc3e167d51c47840c56f", size = 6106370, upload-time = "2026-07-24T17:05:20.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/4e/94e24b5c1910ec15692ecaf33d0d8ef0d02a02a8b5e40d3c976396880cba/prek-0.4.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7059d640e595d098600e2d97af961f46292b4112670edbe632de84f6828389e", size = 6884342, upload-time = "2026-07-24T17:05:21.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/7c/fc0daa033dcafe74990c00af2da1e16922790b9c1b8da7e8eebf1123838b/prek-0.4.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a4df33998fcac878bce3b2c624e1caeb9609f3d562c640702c1234ed815daf", size = 6331365, upload-time = "2026-07-24T17:05:22.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/36/49f152b8f539930e9685cff0509695ce4054abcecc22f4c16c4e1e5c23d0/prek-0.4.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:866991f387527c5f880ce2cc3ddea9b33cc5b986881f8c7f92524cf0969c1350", size = 5939075, upload-time = "2026-07-24T17:05:24.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/fe/7b097af9161edae7ddedcc9f5cda4a5f31346a492ce3f92583d96c46f628/prek-0.4.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:247e5d8740e137ebdf24fa96f01a95b2d2f1892e956a1ab00c7b1474a59ebcc0", size = 5799029, upload-time = "2026-07-24T17:05:25.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/63/dc955ff99e1002d3cd375b21467c87046bc41deddfce86d898240757b151/prek-0.4.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:603ba9f2fd9d666dddb3ae190a25a5c55091b843cde90ed52e0a1116f50d4062", size = 5651211, upload-time = "2026-07-24T17:05:27.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b8/bf2139ec25eefb5afef43afac7620c5181f2c2661f220598beacb1771207/prek-0.4.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f2682ece3c5fc7201106c4fdd84b0587ccea4b8a2ecefa3e94d3074d2841f1df", size = 5954784, upload-time = "2026-07-24T17:05:28.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/29/3fe5990aee1bd7c4d50a03358ad867c5e912d9510aafb83a359c7347e5fa/prek-0.4.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:22721d30394192931fecc80d6fc6dd47e8ddf8db8b9693805aba8ec0f50087ec", size = 6448916, upload-time = "2026-07-24T17:05:29.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/fb/abddacf43738302242ecd237236c7c80cd7c1d27545e16e803770aee76e9/prek-0.4.11-py3-none-win32.whl", hash = "sha256:8b093e7624522146049e994d5cf283d01d71632b638620b79ae0f96afeaa2624", size = 5483539, upload-time = "2026-07-24T17:05:31.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/1e/c293f7a15cb93963c4be36a02e144b93f43906bc02644a3f04e5708e7453/prek-0.4.11-py3-none-win_amd64.whl", hash = "sha256:5a3d7c80b970b456e5f1bcec8382008ee1ae6a3f324a6b9bb4ff7e666ab0f3c4", size = 5861119, upload-time = "2026-07-24T17:05:32.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/0c/05fe6eb9d6a54d0e02dfa8cc5ad6f86869bf953419ec15909a32466a28ee/prek-0.4.11-py3-none-win_arm64.whl", hash = "sha256:e7b0df37ce05e45a14a9da39ab104691474d72f139bf4f6c860f754763a322cb", size = 5626386, upload-time = "2026-07-24T17:05:33.813Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1127,25 +1119,25 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "qbittorrent-api"
|
||||
version = "2026.6.1"
|
||||
version = "2026.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/a8/ea2ab39fdb5ef4408ac71a3329af9646c8a47d54c35a9138fa4d355f22dc/qbittorrent_api-2026.6.1.tar.gz", hash = "sha256:57d74ff2e710286973673e1268878826e9c7e5f471e632d7a8f059ef20f83fd4", size = 1428879, upload-time = "2026-06-27T00:03:19.996Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/49/73123503771d0610a27bf2e5c73995ddce15e8d8cac6babef8eef51097bb/qbittorrent_api-2026.7.0.tar.gz", hash = "sha256:9f0a6e33ee4dba290d155de3ad59c191de02e4d9639d424bf02ec4a00ecf3205", size = 1427366, upload-time = "2026-07-08T00:54:39.78Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/2f/d330c335e2ef3974ac940ee38e6325af4bea7515688496ae8d2d5b6aa49a/qbittorrent_api-2026.6.1-py3-none-any.whl", hash = "sha256:9e9e4b64383c463e716825e7b447b759c2c047d23e1399ed7a66d37f4aa9b994", size = 69432, upload-time = "2026-06-27T00:03:18.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/79/e1ee523dd8b14a4e8b8ddd474a93803855b8170a10240a65997c21d69fe2/qbittorrent_api-2026.7.0-py3-none-any.whl", hash = "sha256:189ff96c2c08f999d82b3b4970c4d3e0da1c32ae9c36e9da2fdec7847e5f37a5", size = 69450, upload-time = "2026-07-08T00:54:38.377Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rarfile"
|
||||
version = "4.2"
|
||||
version = "4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/3f/3118a797444e7e30e784921c4bfafb6500fb288a0c84cb8c32ed15853c16/rarfile-4.2.tar.gz", hash = "sha256:8e1c8e72d0845ad2b32a47ab11a719bc2e41165ec101fd4d3fe9e92aa3f469ef", size = 153476, upload-time = "2024-04-03T17:10:53.798Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/84/a00a5be12adbe5abb0094513ad6d3aefc80fe5dfa4a5604e22561d4b7d3b/rarfile-4.4.tar.gz", hash = "sha256:b7d29d671223cdc80e8b992df212d152ad88e6c961d4426c3fb2fefe8dd86fdf", size = 155437, upload-time = "2026-07-22T16:19:15.533Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/fc/ab37559419ca36dd8dd317c3a98395ed4dcee2beeb28bf6059b972906727/rarfile-4.2-py3-none-any.whl", hash = "sha256:8757e1e3757e32962e229cab2432efc1f15f210823cc96ccba0f6a39d17370c9", size = 29052, upload-time = "2024-04-03T17:10:52.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/06/7135da7d7d662767aee9aeebaa360f4ad29f08d9198cc1b1632428114acd/rarfile-4.4-py3-none-any.whl", hash = "sha256:befb813c283fbcdd46dda2bad090f8272c8bb1de246d04cd570e9d890294d119", size = 29932, upload-time = "2026-07-22T16:19:14.559Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1205,27 +1197,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.20"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1239,7 +1231,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "selenium"
|
||||
version = "4.45.0"
|
||||
version = "4.46.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -1249,14 +1241,14 @@ dependencies = [
|
||||
{ name = "urllib3", extra = ["socks"] },
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/48/486aa67320f27452e9f551b8608f1a59ce7091c8fe7ebc9f4eba274775d4/selenium-4.45.0.tar.gz", hash = "sha256:563f0c4102f112df1cda30d46ce6d177b2e4a7a3d4b0756902d5dc84d3a8a365", size = 1005503, upload-time = "2026-06-16T04:43:57.915Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a0/53/053df98ef0c38535a74ea732217eb2dbfddc7ff5442a47a4b315ba577f85/selenium-4.46.0.tar.gz", hash = "sha256:54f7e1a4df5f7508ac8c38ce2ea584db1b27083dd79962b22524219219df5cbe", size = 1006000, upload-time = "2026-07-11T00:38:41.11Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/8a/6ff6beb9c7c6cc642f628df9328a8b6637f86602eff8d28e70b5d4e8bca7/selenium-4.45.0-py3-none-any.whl", hash = "sha256:1fd9d0dc08192b2f8100e264ed720f83b05d2dd3a7feff673df04e0c7580df4b", size = 9536616, upload-time = "2026-06-16T04:43:55.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/f4/365c723a37c7e9ba6a6a19bf0c9e407137703f56a72c6201d0e4cb1432cb/selenium-4.46.0-py3-none-any.whl", hash = "sha256:f03e864770a21ab32009961c9d015cb2c6275eba45c926c272e02d90af423aad", size = 9428587, upload-time = "2026-07-11T00:38:38.84Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "seleniumbase"
|
||||
version = "4.50.5"
|
||||
version = "4.51.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
@@ -1320,18 +1312,18 @@ dependencies = [
|
||||
{ name = "wheel" },
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/84/fadfc7c543e3bed55acd7350ffc2e0a9f24bae37eb792732611eb8dc1cc2/seleniumbase-4.50.5.tar.gz", hash = "sha256:6a510cfd319c879745a5314eb9b253433b414beeaeb1b7f2e9fc0d05a93f9cd1", size = 667241, upload-time = "2026-07-03T14:34:24.662Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/92/dac65663cbab903d7d2be831f94587df7fa907ca905c0215ae3a8ab96792/seleniumbase-4.51.8.tar.gz", hash = "sha256:92d71030b9ebca772924b5caed7c44c865a5e149a539b2d733664a4e5bfd05db", size = 670520, upload-time = "2026-07-26T04:51:09.923Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/df/ccf0aabfccbbd59141ddbe1f446056524c4b96cf5a7fa511e0859b4da580/seleniumbase-4.50.5-py3-none-any.whl", hash = "sha256:a78887c52e458402eaee07d3abc97a557f79c78c550fec4f864b8901458cc2c6", size = 672979, upload-time = "2026-07-03T14:34:21.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/7c/6af2efd2f1edda88a0c4d83138586b9014bd155870bad90c4836200eb048/seleniumbase-4.51.8-py3-none-any.whl", hash = "sha256:1c240f6d6fe173c0e51828187a048f5907b1e3ff8c23a4d487f5c65e3730cb41", size = 676369, upload-time = "2026-07-26T04:51:07.017Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "82.0.1"
|
||||
version = "83.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1398,10 +1390,10 @@ requires-dist = [
|
||||
{ name = "python-socketio" },
|
||||
{ name = "python-xlib", marker = "extra == 'browser'" },
|
||||
{ name = "pyvirtualdisplay", marker = "extra == 'browser'" },
|
||||
{ name = "qbittorrent-api" },
|
||||
{ name = "qbittorrent-api", specifier = ">=2026.5.3" },
|
||||
{ name = "rarfile" },
|
||||
{ name = "requests", extras = ["socks"] },
|
||||
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.50.5" },
|
||||
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.51.8" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "transmission-rpc" },
|
||||
]
|
||||
@@ -1414,7 +1406,7 @@ dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.8.0" },
|
||||
{ name = "ruff", specifier = "==0.15.20" },
|
||||
{ name = "ruff", specifier = "==0.16.0" },
|
||||
{ name = "vulture", specifier = ">=2.14" },
|
||||
]
|
||||
|
||||
@@ -1459,11 +1451,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.4"
|
||||
version = "2.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1480,14 +1472,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.68.3"
|
||||
version = "4.70.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1586,29 +1578,45 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
version = "16.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user