mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e8608c427 | ||
|
|
7f770f54fa | ||
|
|
0eb8b78dc7 | ||
|
|
eb98b6a816 | ||
|
|
29ce83e274 | ||
|
|
e320b7623d | ||
|
|
bb848f05bc | ||
|
|
cc1a95f965 | ||
|
|
dfcd7c9b00 | ||
|
|
bd9a22bd6c | ||
|
|
ba4f7fb5e6 | ||
|
|
f1de248ed5 | ||
|
|
78d5d0632a | ||
|
|
3c51b7cfaa | ||
|
|
3a9cff9816 | ||
|
|
453d1f2b56 | ||
|
|
db11da5bda | ||
|
|
0b22618d96 | ||
|
|
42f308bc89 | ||
|
|
e93fbd2a9b | ||
|
|
cdd156e142 | ||
|
|
21f2b6b95c | ||
|
|
10554444a9 | ||
|
|
ce026e2eb8 | ||
|
|
ff770940ca | ||
|
|
340853477f | ||
|
|
816a735cde | ||
|
|
d1cdaaeb5e | ||
|
|
a1367f431d | ||
|
|
a4086f5e06 | ||
|
|
0d02c6db47 | ||
|
|
7c457ff83e | ||
|
|
00f3fa565c | ||
|
|
7fdaf3f67d | ||
|
|
e9f3b9a3ad | ||
|
|
8eb9bd5c21 | ||
|
|
a4d587037d |
+19
-3
@@ -46,9 +46,7 @@ updates:
|
||||
# pre-release filter is bypassed for *grouped* updates
|
||||
# (dependabot-core#9496), so a grouped python update proposes pre-release
|
||||
# tags like python:3.15.0b2 as if they were a normal stable minor bump.
|
||||
# Updated individually, python is filtered correctly: alpha/beta/rc tags
|
||||
# are skipped and only stable releases (e.g. 3.15.0 once final) are
|
||||
# proposed. node + uv stay grouped into a single digest PR.
|
||||
# node + uv stay grouped into a single digest PR.
|
||||
patterns: ["*"]
|
||||
exclude-patterns: ["python"]
|
||||
ignore:
|
||||
@@ -58,6 +56,24 @@ updates:
|
||||
- dependency-name: "node"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# Python: block minor/major bumps. Ungrouping python (above) is NOT enough
|
||||
# to keep pre-releases out — dependabot-core#13815 rewrote the Docker
|
||||
# pre-release heuristic to catch PEP 440 tags like 3.15.0a2 / 3.5.0b3, but
|
||||
# the suffixed real tag still slipped through as PR #1169
|
||||
# (python:3.14.6-slim -> python:3.15.0b3-slim). CPython spells
|
||||
# pre-releases without a separator, so tag parsing reads 3.15.0b3 as an
|
||||
# ordinary version that sorts above 3.14.6.
|
||||
#
|
||||
# A minor-version ignore blocks it regardless of spelling. Patch bumps
|
||||
# (3.14.6 -> 3.14.7) and same-tag digest refreshes still land automatically.
|
||||
# Moving the runtime to a new Python minor is a manual, deliberate change:
|
||||
# bump the tag here and confirm C-extension wheels (greenlet/gevent) exist
|
||||
# for it — a source build against a pre-release ABI boots an app that binds
|
||||
# its port but never serves, which wedges e2e for the full 6h job limit.
|
||||
- dependency-name: "python"
|
||||
update-types:
|
||||
["version-update:semver-major", "version-update:semver-minor"]
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
|
||||
- name: Generate artifact attestation for ${{ matrix.target }} image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
LEGACY_NAME: calibre-web-automated-book-downloader
|
||||
steps:
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.14"
|
||||
|
||||
@@ -25,14 +25,14 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
|
||||
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # 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.2
|
||||
- uses: dorny/paths-filter@v4.0.3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -60,6 +60,9 @@ jobs:
|
||||
e2e:
|
||||
needs: select-profiles
|
||||
runs-on: ubuntu-latest
|
||||
# A wedged app under test must not burn GitHub's 6h max job limit. A healthy
|
||||
# profile run finishes in ~3-5 min; anything past 25 is hung, not slow.
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -69,7 +72,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
@@ -87,12 +90,14 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.relevant == 'true' || github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
# Real Chrome + qBittorrent is the slowest profile; still nowhere near 40 min.
|
||||
timeout-minutes: 40
|
||||
name: e2e (full — real Chrome + qBittorrent)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
|
||||
+7
-2
@@ -4,7 +4,7 @@ ARG BUILDPLATFORM
|
||||
ARG BUILDARCH
|
||||
|
||||
# Frontend build stage.
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS frontend-builder
|
||||
|
||||
# Helpful debug output to see what platforms BuildKit thinks it's using
|
||||
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
|
||||
@@ -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:d3400aa122fa42cf0af0dbe8ec3091b047eac5c8f7e3539f7135e86d855dc015 AS base
|
||||
FROM python:3.14.7-slim@sha256:83c1cebb322d099ac9e3a3a532ba74b0146d702838b25e4c75c02fa81ffeb910 AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 /uv /uvx /bin/
|
||||
|
||||
@@ -59,6 +59,11 @@ ENV FLASK_PORT=8084
|
||||
# Configure locale, timezone, and perform initial cleanup in a single layer
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
# For building C-extensions (cffi, gevent, etc.)
|
||||
gcc \
|
||||
g++ \
|
||||
libffi-dev \
|
||||
python3-dev \
|
||||
# For locale
|
||||
locales tzdata \
|
||||
# For healthcheck
|
||||
|
||||
+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"] }
|
||||
]
|
||||
|
||||
+141
-18
@@ -23,6 +23,7 @@ This document lists all configuration options that can be set via environment va
|
||||
- [Hardcover](#metadata-providers-hardcover)
|
||||
- [Open Library](#metadata-providers-open-library)
|
||||
- [Google Books](#metadata-providers-google-books)
|
||||
- [Moly.hu](#metadata-providers-moly.hu)
|
||||
- [Direct Download](#direct-download)
|
||||
- [Download Sources](#direct-download-download-sources)
|
||||
- [Cloudflare Bypass](#direct-download-cloudflare-bypass)
|
||||
@@ -432,8 +433,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 +455,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 +500,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 +509,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 +710,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 +719,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}`
|
||||
@@ -1046,6 +1047,7 @@ Comma-separated hosts to bypass proxy (e.g., localhost,127.0.0.1,10.*,*.local)
|
||||
|----------|-------------|------|---------|
|
||||
| `URL_BASE` | Optional URL path prefix. Use a path like /shelfmark (no hostname). Leave blank for root. | string | _none_ |
|
||||
| `DEBUG` | Enable verbose logging to console and file. Not recommended for normal use. | boolean | `false` |
|
||||
| `LOG_LEVEL` | Lowest severity written to the console and log file. Ignored while Debug Mode is on, which forces Debug. | string (choice) | `INFO` |
|
||||
| `MAIN_LOOP_SLEEP_TIME` | How often the download queue is checked for new items. | number | `5` |
|
||||
| `DOWNLOAD_PROGRESS_UPDATE_INTERVAL` | How often download progress is broadcast to the UI. | number | `1` |
|
||||
| `CUSTOM_SCRIPT` | Path to a script to run after each successful download. Must be executable. | string | _none_ |
|
||||
@@ -1082,6 +1084,17 @@ Enable verbose logging to console and file. Not recommended for normal use.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `LOG_LEVEL`
|
||||
|
||||
**Log Level**
|
||||
|
||||
Lowest severity written to the console and log file. Ignored while Debug Mode is on, which forces Debug.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `INFO`
|
||||
- **Requires restart:** Yes
|
||||
- **Options:** `DEBUG` (Debug), `INFO` (Info), `WARNING` (Warning), `ERROR` (Error), `CRITICAL` (Critical)
|
||||
|
||||
#### `MAIN_LOOP_SLEEP_TIME`
|
||||
|
||||
**Queue Check Interval (seconds)**
|
||||
@@ -1211,6 +1224,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 +1277,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 +1418,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 +1460,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 +1480,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**
|
||||
@@ -1478,9 +1521,12 @@ How long to keep cached search results before they expire.
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `PROWLARR_TORRENT_CLIENT` | Choose which torrent client to use | string (choice) | _empty string_ |
|
||||
| `ALLDEBRID_API_KEY` | AllDebrid API Key (apiv4) from your AllDebrid account settings | string (secret) | _none_ |
|
||||
| `REALDEBRID_API_KEY` | Real-Debrid API Key (Secret Token) from your Real-Debrid account settings | string (secret) | _none_ |
|
||||
| `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 +1546,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` |
|
||||
@@ -1526,7 +1574,25 @@ Choose which torrent client to use
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** _empty string_
|
||||
- **Options:** `""` (None), `qbittorrent` (qBittorrent), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent)
|
||||
- **Options:** `""` (None), `alldebrid` (AllDebrid), `qbittorrent` (qBittorrent), `realdebrid` (Real-Debrid), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent)
|
||||
|
||||
#### `ALLDEBRID_API_KEY`
|
||||
|
||||
**API Key**
|
||||
|
||||
AllDebrid API Key (apiv4) from your AllDebrid account settings
|
||||
|
||||
- **Type:** string (secret)
|
||||
- **Default:** _none_
|
||||
|
||||
#### `REALDEBRID_API_KEY`
|
||||
|
||||
**API Key**
|
||||
|
||||
Real-Debrid API Key (Secret Token) from your Real-Debrid account settings
|
||||
|
||||
- **Type:** string (secret)
|
||||
- **Default:** _none_
|
||||
|
||||
#### `QBITTORRENT_URL`
|
||||
|
||||
@@ -1555,6 +1621,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 +1805,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 +1832,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`
|
||||
|
||||
@@ -2004,6 +2097,26 @@ Default sort order for Google Books search results.
|
||||
|
||||
</details>
|
||||
|
||||
### Metadata Providers: Moly.hu
|
||||
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `MOLY_ENABLED` | Enable Moly.hu as a metadata provider for book searches | boolean | `false` |
|
||||
|
||||
<details>
|
||||
<summary>Detailed descriptions</summary>
|
||||
|
||||
#### `MOLY_ENABLED`
|
||||
|
||||
**Enable Moly.hu**
|
||||
|
||||
Enable Moly.hu as a metadata provider for book searches
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
</details>
|
||||
|
||||
## Direct Download
|
||||
|
||||
### Direct Download: Download Sources
|
||||
@@ -2011,6 +2124,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 +2152,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**
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ With a subpath (`URL_BASE=/shelfmark/`):
|
||||
https://<your-shelfmark-domain>/shelfmark/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
The callback URL is constructed from the incoming request, so your reverse proxy must forward `X-Forwarded-Proto` and `X-Forwarded-Host` correctly. PKCE (S256) is used automatically.
|
||||
The callback URL is constructed from the incoming request, so your reverse proxy must forward `X-Forwarded-Proto` and `X-Forwarded-Host` correctly, including the external port when it is not the protocol default. PKCE (S256) is used automatically.
|
||||
|
||||
## Settings
|
||||
|
||||
|
||||
@@ -23,10 +23,11 @@ server {
|
||||
location / {
|
||||
proxy_pass http://shelfmark:8084;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
@@ -56,11 +57,11 @@ All Shelfmark paths (UI, API, assets, Socket.IO) are served under the base path.
|
||||
location /shelfmark/ {
|
||||
proxy_pass http://shelfmark:8084/shelfmark/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
@@ -136,11 +137,11 @@ location /shelfmark/ {
|
||||
|
||||
proxy_pass http://shelfmark:8084/shelfmark/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
@@ -158,6 +159,7 @@ If login, settings saves, or downloads appear to fail in the browser but the act
|
||||
- Do not force `Connection: upgrade` on every request. That can break normal `POST` and `PUT` responses while the backend still processes them.
|
||||
- If your proxy UI does not support conditional websocket headers, remove the forced websocket headers entirely and let Shelfmark fall back to polling.
|
||||
- Keep the standard forwarded headers: `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` when using a subpath or OIDC.
|
||||
- Preserve the original port in `Host` and `X-Forwarded-Host` by using `$http_host` rather than `$host` when Shelfmark is exposed on a custom port.
|
||||
|
||||
This is especially relevant for Nginx Proxy Manager or custom advanced config snippets that add websocket headers globally.
|
||||
|
||||
|
||||
+18
-2
@@ -261,7 +261,12 @@ test_write() {
|
||||
fi
|
||||
|
||||
FILE_CONTENT=$(cat "$test_file" 2>/dev/null || echo "")
|
||||
rm -f "$test_file"
|
||||
# A folder can be writable but not deletable (e.g. a Synology share without
|
||||
# "Delete subfolders and files"). That is not a boot failure - the app writes
|
||||
# files in place on such shares - so don't let a failed cleanup print an
|
||||
# alarming error or fail the probe.
|
||||
run_as_target_user rm -f "$test_file" 2>/dev/null || \
|
||||
echo "Note: could not remove test file in $folder (folder is writable but not deletable)"
|
||||
[ "$FILE_CONTENT" = "0123456789_TEST" ]
|
||||
result=$?
|
||||
if [ $result -eq 0 ]; then
|
||||
@@ -479,7 +484,18 @@ fi
|
||||
# Always run Gunicorn (even when DEBUG=true) to ensure Socket.IO WebSocket
|
||||
# upgrades work reliably on customer machines.
|
||||
# Map app LOG_LEVEL (often DEBUG/INFO/...) to gunicorn's --log-level (lowercase).
|
||||
gunicorn_loglevel=$([ "$DEBUG" = "true" ] && echo debug || echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]')
|
||||
# Gunicorn rejects anything outside its own list, so normalize and fall back to
|
||||
# info rather than letting a typo stop the container from booting.
|
||||
if [ "$DEBUG" = "true" ]; then
|
||||
gunicorn_loglevel=debug
|
||||
else
|
||||
gunicorn_loglevel=$(echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]')
|
||||
[ "$gunicorn_loglevel" = "warn" ] && gunicorn_loglevel=warning
|
||||
case "$gunicorn_loglevel" in
|
||||
debug|info|warning|error|critical) ;;
|
||||
*) gunicorn_loglevel=info ;;
|
||||
esac
|
||||
fi
|
||||
command="${GUNICORN_BIN} --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} shelfmark.main:app"
|
||||
|
||||
# If DEBUG and not using an external bypass
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@ dependencies = [
|
||||
"psutil",
|
||||
"emoji",
|
||||
"rarfile",
|
||||
"qbittorrent-api",
|
||||
"qbittorrent-api>=2026.8.0",
|
||||
"transmission-rpc",
|
||||
"authlib>=1.7.2,<1.8",
|
||||
"apprise>=1.12.0",
|
||||
@@ -29,7 +29,7 @@ dependencies = [
|
||||
browser = [
|
||||
"pyvirtualdisplay",
|
||||
"pyautogui",
|
||||
"seleniumbase==4.51.2",
|
||||
"seleniumbase==4.51.11",
|
||||
"python-xlib",
|
||||
]
|
||||
|
||||
@@ -40,7 +40,7 @@ dev = [
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"ruff==0.15.22",
|
||||
"ruff==0.16.2",
|
||||
"vulture>=2.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ Works great alongside the following library tools, with support for automatic im
|
||||
### Prerequisites
|
||||
|
||||
- Docker & Docker Compose
|
||||
- At least 2 GB of RAM available to the container when using the standard image — see [Memory Requirements](#memory-requirements)
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -138,6 +139,17 @@ docker compose up -d
|
||||
|
||||
The full-featured image with all network capabilities included.
|
||||
|
||||
#### Memory Requirements
|
||||
|
||||
The standard image ships a real Chromium browser, which it launches to solve Cloudflare challenges for Direct Download. Chromium needs room to run:
|
||||
|
||||
- **2 GB of RAM available to the container** is a safe minimum; 1 GB or less is where problems usually start
|
||||
- Only relevant if you use Direct Download. Prowlarr, IRC and audiobook sources don't start the browser
|
||||
|
||||
When the container is starved of memory, Chromium fails to start and every Direct Download fails with unrelated-looking errors — repeated `403 detected; switching to bypasser` followed by `No download URL found`, and downloads that never complete. If you're seeing that, check the container's memory limit and the host's free memory before suspecting your ISP or DNS.
|
||||
|
||||
If you can't spare the memory, use the [Lite](#lite) image with an external resolver (e.g. FlareSolverr) running elsewhere.
|
||||
|
||||
#### Tor Routing
|
||||
Optional Tor support for network privacy:
|
||||
```bash
|
||||
@@ -175,6 +187,7 @@ A lighter image without the built-in browser automation. Ideal for:
|
||||
- **External services** - Already running FlareSolverr or similar for other applications
|
||||
- **Alternative sources** - Using Prowlarr, IRC, or other configured sources
|
||||
- **Audiobooks** - Using Shelfmark primarily for audiobooks
|
||||
- **Constrained hosts** - No bundled browser, so it runs comfortably below the standard image's [memory requirements](#memory-requirements)
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.lite.yml
|
||||
@@ -247,7 +260,10 @@ Logs are available via:
|
||||
- `docker logs <container-name>`
|
||||
- `/var/log/shelfmark/` inside the container (when `ENABLE_LOGGING=true`)
|
||||
|
||||
Log level is configurable via Settings or `LOG_LEVEL` environment variable.
|
||||
Log level is configurable under Settings → Advanced or via the `LOG_LEVEL` environment
|
||||
variable (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`; case-insensitive, defaults to
|
||||
`INFO`). The environment variable wins over the setting, and `DEBUG=true` forces `DEBUG`
|
||||
regardless of either. Changes take effect on restart.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -47,6 +47,22 @@ def _coerce_timeout_ms(value: object, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def max_duration_seconds() -> float:
|
||||
"""Upper bound on how long get_bypassed_page() can take for one URL.
|
||||
|
||||
MAX_RETRY attempts at the configured read timeout, plus the exponential backoff waited
|
||||
between them (jitter is < 1s per gap, counted as a full second to stay conservative).
|
||||
Callers use this to declare a stall-detection grace; see shelfmark.download.activity.
|
||||
"""
|
||||
bypasser_timeout = _coerce_timeout_ms(config.get("EXT_BYPASSER_TIMEOUT", 60000), 60000)
|
||||
read_timeout = min((bypasser_timeout / 1000) + READ_TIMEOUT_BUFFER, MAX_READ_TIMEOUT)
|
||||
backoff_total = sum(
|
||||
min(BACKOFF_CAP, BACKOFF_BASE * (2 ** (attempt - 1))) + 1.0
|
||||
for attempt in range(1, MAX_RETRY)
|
||||
)
|
||||
return MAX_RETRY * read_timeout + backoff_total
|
||||
|
||||
|
||||
def _fetch_via_bypasser(target_url: str) -> str | None:
|
||||
"""Make a single request to the external bypasser service. Returns HTML or None."""
|
||||
raw_bypasser_url = _coerce_config_str(
|
||||
|
||||
@@ -50,6 +50,9 @@ _LOADING_BODY_LENGTH_MAX = 50
|
||||
_PAGE_BODY_PREVIEW_CHARS = 500
|
||||
_BROWSER_START_TIMEOUT_SECONDS = 45.0
|
||||
_BYPASS_SUBPROCESS_TIMEOUT_SECONDS = 420.0
|
||||
# Same budget as the Docker helper process, applied to the in-process CDP path so both
|
||||
# branches of get() are bounded the same way.
|
||||
_IN_PROCESS_BYPASS_TIMEOUT_SECONDS = _BYPASS_SUBPROCESS_TIMEOUT_SECONDS
|
||||
_BYPASS_CHILD_ENV = "SHELFMARK_INTERNAL_BYPASSER_CHILD"
|
||||
|
||||
# Challenge detection indicators
|
||||
@@ -217,7 +220,13 @@ class _CdpWorker:
|
||||
msg = "CDP worker loop not available"
|
||||
raise RuntimeError(msg)
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
return future.result(timeout=timeout)
|
||||
try:
|
||||
return future.result(timeout=timeout)
|
||||
except TimeoutError:
|
||||
# Otherwise the coroutine keeps running in the worker loop after we stop
|
||||
# waiting, holding the browser and racing the next bypass.
|
||||
future.cancel()
|
||||
raise
|
||||
|
||||
|
||||
_CDP_WORKER = _CdpWorker()
|
||||
@@ -896,7 +905,11 @@ def _run_bypass_in_current_process(url: str, retry: int, cancel_flag: Event | No
|
||||
|
||||
if os.environ.get(_BYPASS_CHILD_ENV) == "1":
|
||||
return asyncio.run(_run_bypass())
|
||||
return _CDP_WORKER.run(_run_bypass())
|
||||
# Bound the wait: this path runs in-process (non-Docker installs), holds the module-wide
|
||||
# LOCKED for its whole duration, and neither page.get() nor page.wait() has a timeout of
|
||||
# its own. Without a deadline here a single wedged CDP session blocks every subsequent
|
||||
# bypass in the process forever.
|
||||
return _CDP_WORKER.run(_run_bypass(), timeout=_IN_PROCESS_BYPASS_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
def _store_child_bypass_state(payload: dict[str, Any]) -> None:
|
||||
@@ -1256,6 +1269,16 @@ def _try_with_cached_cookies(url: str, hostname: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def max_duration_seconds() -> float:
|
||||
"""Upper bound on how long get_bypassed_page() can take for one URL.
|
||||
|
||||
Both branches of get() are capped at _BYPASS_SUBPROCESS_TIMEOUT_SECONDS, and
|
||||
get_bypassed_page() may call it twice (once, then again after a mirror/DNS rotation).
|
||||
Callers use this to declare a stall-detection grace; see shelfmark.download.activity.
|
||||
"""
|
||||
return 2 * _BYPASS_SUBPROCESS_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def get_bypassed_page(
|
||||
url: str, selector: network.AAMirrorSelector | None = None, cancel_flag: Event | None = None
|
||||
) -> str | None:
|
||||
|
||||
+57
-13
@@ -6,34 +6,78 @@ import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
||||
|
||||
|
||||
def string_to_bool(s: str) -> bool:
|
||||
"""Convert string to boolean."""
|
||||
return s.lower() in ["true", "yes", "1", "y"]
|
||||
|
||||
|
||||
def _read_advanced_config(key: str) -> object | None:
|
||||
"""Read a key from the advanced settings file (import-time safe)."""
|
||||
config_dir = Path(os.getenv("CONFIG_DIR", "/config"))
|
||||
config_file = config_dir / "plugins" / "advanced.json"
|
||||
|
||||
if config_file.exists():
|
||||
try:
|
||||
with config_file.open() as f:
|
||||
config = json.load(f)
|
||||
if key in config:
|
||||
return config[key]
|
||||
except json.JSONDecodeError, OSError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_debug_from_config() -> bool:
|
||||
"""Read DEBUG from env var or config file (import-time safe)."""
|
||||
env_debug = os.environ.get("DEBUG")
|
||||
if env_debug is not None:
|
||||
return string_to_bool(env_debug)
|
||||
|
||||
# Try to read from config file
|
||||
config_dir = Path(os.getenv("CONFIG_DIR", "/config"))
|
||||
config_file = config_dir / "plugins" / "advanced.json"
|
||||
|
||||
if config_file.exists():
|
||||
try:
|
||||
with config_file.open() as f:
|
||||
config = json.load(f)
|
||||
if "DEBUG" in config:
|
||||
return bool(config["DEBUG"])
|
||||
except json.JSONDecodeError, OSError:
|
||||
pass
|
||||
value = _read_advanced_config("DEBUG")
|
||||
if value is not None:
|
||||
return bool(value)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def normalize_log_level(raw: str | None) -> str:
|
||||
"""Normalize a log level name, falling back to INFO when unrecognized."""
|
||||
if raw is None:
|
||||
return "INFO"
|
||||
|
||||
normalized = raw.strip().upper()
|
||||
# "WARN" is a logging alias, but gunicorn only accepts "warning".
|
||||
if normalized == "WARN":
|
||||
normalized = "WARNING"
|
||||
|
||||
if normalized not in LOG_LEVELS:
|
||||
return "INFO"
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _read_log_level_from_config(debug: bool) -> str:
|
||||
"""Resolve the app log level from DEBUG, env var, or config file.
|
||||
|
||||
DEBUG wins when enabled, mirroring how entrypoint.sh picks gunicorn's level.
|
||||
Otherwise LOG_LEVEL is read from the env var, then the settings file, and
|
||||
falls back to INFO when unset or unrecognized.
|
||||
"""
|
||||
if debug:
|
||||
return "DEBUG"
|
||||
|
||||
raw = os.environ.get("LOG_LEVEL")
|
||||
if raw is None:
|
||||
value = _read_advanced_config("LOG_LEVEL")
|
||||
raw = value if isinstance(value, str) else None
|
||||
|
||||
return normalize_log_level(raw)
|
||||
|
||||
|
||||
def _is_sqlite_file(path: Path) -> bool:
|
||||
"""Check if a file is a valid SQLite database by reading magic bytes."""
|
||||
try:
|
||||
@@ -101,7 +145,7 @@ INGEST_DIR = Path(os.getenv("INGEST_DIR", "/books"))
|
||||
# =============================================================================
|
||||
|
||||
DEBUG = _read_debug_from_config()
|
||||
LOG_LEVEL = "DEBUG" if DEBUG else "INFO"
|
||||
LOG_LEVEL = _read_log_level_from_config(DEBUG)
|
||||
ENABLE_LOGGING = string_to_bool(os.getenv("ENABLE_LOGGING", "true"))
|
||||
|
||||
|
||||
|
||||
@@ -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."
|
||||
@@ -1764,6 +1761,23 @@ def advanced_settings() -> list[SettingsField]:
|
||||
default=False,
|
||||
requires_restart=True,
|
||||
),
|
||||
SelectField(
|
||||
key="LOG_LEVEL",
|
||||
label="Log Level",
|
||||
description=(
|
||||
"Lowest severity written to the console and log file. "
|
||||
"Ignored while Debug Mode is on, which forces Debug."
|
||||
),
|
||||
options=[
|
||||
{"value": "DEBUG", "label": "Debug", "description": "Everything, very noisy."},
|
||||
{"value": "INFO", "label": "Info", "description": "Normal activity (default)."},
|
||||
{"value": "WARNING", "label": "Warning", "description": "Warnings and problems."},
|
||||
{"value": "ERROR", "label": "Error", "description": "Failures only."},
|
||||
{"value": "CRITICAL", "label": "Critical", "description": "Fatal errors only."},
|
||||
],
|
||||
default="INFO",
|
||||
requires_restart=True,
|
||||
),
|
||||
NumberField(
|
||||
key="MAIN_LOOP_SLEEP_TIME",
|
||||
label="Queue Check Interval (seconds)",
|
||||
|
||||
@@ -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)
|
||||
@@ -119,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()
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shelfmark.config.env import normalize_log_level
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import coerce_bool, normalize_optional_text
|
||||
|
||||
@@ -898,6 +899,9 @@ def _get_env_value_for_field(field: FieldBase) -> tuple[bool, object | None]:
|
||||
"WELIB_MIRROR_URLS",
|
||||
} and isinstance(parsed, list):
|
||||
parsed = _normalize_mirror_env_urls(parsed)
|
||||
if field.key == "LOG_LEVEL" and isinstance(parsed, str):
|
||||
# LOG_LEVEL is commonly set lowercase; the field options are uppercase.
|
||||
parsed = normalize_log_level(parsed)
|
||||
return True, parsed
|
||||
|
||||
if field.key == "AA_MIRROR_URLS":
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Stall-detection grace signalling for long single-shot download operations.
|
||||
|
||||
The orchestrator cancels a download after `STALL_TIMEOUT` seconds without activity, where
|
||||
"activity" means a *changed* status event or a *changed* progress value. That de-duplication
|
||||
is deliberate - a keep-alive that repeats the same payload on a timer proves nothing about
|
||||
whether the operation is still making progress, so letting it refresh the stall clock would
|
||||
make a genuinely wedged download immortal.
|
||||
|
||||
Operations that legitimately take longer than `STALL_TIMEOUT` but cannot report incremental
|
||||
progress therefore declare an explicit upper bound up front instead:
|
||||
|
||||
request_activity_grace(status_callback, my_worst_case_seconds)
|
||||
try:
|
||||
...one long blocking call...
|
||||
finally:
|
||||
release_activity_grace(status_callback)
|
||||
|
||||
The grace is a single absolute deadline. It is never extended, so the operation still dies
|
||||
if it overruns its own declared budget - just at *its* bound rather than at a global 300s.
|
||||
|
||||
The signal rides on the existing `status_callback` channel using a sentinel status, which
|
||||
avoids threading a new parameter through every handler, post-processor and output module.
|
||||
`shelfmark.download.orchestrator`'s per-task `status_callback` closure intercepts the
|
||||
sentinel and never forwards it to `update_download_status`.
|
||||
|
||||
Adopters should be operations that yield to the gevent hub while blocking (`requests`,
|
||||
patched `subprocess`). An operation that blocks the hub outright - `shutil.copy2`, sqlite -
|
||||
will still be killed by the gunicorn worker timeout regardless of any grace, and must go
|
||||
through `shelfmark.download.fs.run_blocking_io` first.
|
||||
|
||||
Current adopters: `shelfmark.download.http.html_get_page` (protection bypass).
|
||||
Candidates: `download.clients.base_handler._wait_for_completed_path`, archive extraction in
|
||||
`download.postprocess.scan`, large-file copies in `download.outputs.folder`, email/BookLore
|
||||
uploads, and the Anna's Archive countdown in `release_sources.direct_download` (which today
|
||||
refreshes the stall clock on every tick of a loop that proves nothing about the remote).
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
# Not a QueueStatus value, so `update_download_status` would reject it anyway; the
|
||||
# orchestrator's status_callback intercepts it before that point.
|
||||
ACTIVITY_GRACE_STATUS = "__activity_grace__"
|
||||
|
||||
StatusCallback = Callable[[str, str | None], None]
|
||||
|
||||
# A status_callback is caller-supplied and may raise; a failed liveness hint must never
|
||||
# break the operation it was protecting. Mirrors http._STATUS_CALLBACK_ERRORS.
|
||||
_CALLBACK_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError)
|
||||
|
||||
|
||||
def request_activity_grace(status_callback: StatusCallback | None, seconds: float) -> None:
|
||||
"""Ask the orchestrator to suppress stall detection for up to `seconds` from now."""
|
||||
_emit(status_callback, seconds)
|
||||
|
||||
|
||||
def release_activity_grace(status_callback: StatusCallback | None) -> None:
|
||||
"""Drop any outstanding grace and count now as activity."""
|
||||
_emit(status_callback, 0)
|
||||
|
||||
|
||||
def parse_activity_grace(status: str, message: str | None) -> float | None:
|
||||
"""Return the requested grace in seconds, or None if this is not a grace event.
|
||||
|
||||
Never raises: a malformed sentinel is treated as "not a grace event" so a bad emitter
|
||||
cannot take down the status pipeline.
|
||||
"""
|
||||
if status != ACTIVITY_GRACE_STATUS:
|
||||
return None
|
||||
try:
|
||||
return max(float(message or 0), 0.0)
|
||||
except TypeError, ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _emit(status_callback: StatusCallback | None, seconds: float) -> None:
|
||||
if status_callback is None:
|
||||
return
|
||||
try:
|
||||
status_callback(ACTIVITY_GRACE_STATUS, str(float(seconds)))
|
||||
except _CALLBACK_ERRORS:
|
||||
return
|
||||
@@ -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
|
||||
@@ -284,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,
|
||||
|
||||
@@ -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:
|
||||
@@ -422,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:
|
||||
|
||||
@@ -44,6 +44,7 @@ _HASH_LENGTH_40 = 40
|
||||
_HASH_LENGTH_ED2K = 32
|
||||
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
|
||||
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
_METADATA_DOWNLOAD_STATES = {"forcedMetaDL", "metaDL"}
|
||||
_ONE_WEEK_IN_SECONDS = 604800
|
||||
|
||||
|
||||
@@ -94,6 +95,25 @@ def _hashes_match(hash1: str, hash2: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _torrent_matches_download_id(torrent: object, download_id: str) -> bool:
|
||||
"""Match an ID against every identity qBittorrent exposes.
|
||||
|
||||
For hybrid torrents, qBittorrent's primary `hash` can change from the v1
|
||||
hash to the truncated v2 hash after metadata resolution. The full
|
||||
`infohash_v1` and `infohash_v2` fields preserve the torrent's identities.
|
||||
"""
|
||||
identifiers = (
|
||||
getattr(torrent, "hash", None),
|
||||
getattr(torrent, "infohash_v1", None),
|
||||
getattr(torrent, "infohash_v2", None),
|
||||
)
|
||||
|
||||
return any(
|
||||
isinstance(identifier, str) and identifier and _hashes_match(identifier, download_id)
|
||||
for identifier in identifiers
|
||||
)
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
@@ -166,63 +186,6 @@ def _build_qbittorrent_child_path(base_path: object, child_path: object) -> str
|
||||
class QBittorrentClient(DownloadClient):
|
||||
"""qBittorrent download client."""
|
||||
|
||||
def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, str | None]:
|
||||
"""Check whether qBittorrent has registered a torrent yet.
|
||||
|
||||
Uses `/api/v2/torrents/properties?hash=<hash>`.
|
||||
|
||||
Returns:
|
||||
(loaded, error_message)
|
||||
|
||||
Notes:
|
||||
A false result with no error means "not loaded yet".
|
||||
|
||||
"""
|
||||
url = f"{self._base_url}/api/v2/torrents/properties"
|
||||
params = {"hash": torrent_hash}
|
||||
|
||||
try:
|
||||
self._client.auth_log_in()
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
# Re-authenticate and retry once on 403
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
logger.debug(
|
||||
"qBittorrent returned 403 for properties; re-authenticating and retrying"
|
||||
)
|
||||
self._client.auth_log_in()
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
return False, "qBittorrent authentication failed (HTTP 403)"
|
||||
|
||||
# qBittorrent returns 404/409-ish responses depending on version when missing.
|
||||
if response.status_code == _HTTP_STATUS_NOT_FOUND:
|
||||
return False, None
|
||||
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
if status == _HTTP_STATUS_NOT_FOUND:
|
||||
return False, None
|
||||
if status:
|
||||
return False, f"qBittorrent API request failed (HTTP {status})"
|
||||
return False, "qBittorrent API request failed"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, f"Cannot connect to qBittorrent at {self._base_url}"
|
||||
except requests.exceptions.Timeout:
|
||||
return False, f"qBittorrent request timed out at {self._base_url}"
|
||||
except requests.exceptions.InvalidSchema:
|
||||
return (
|
||||
False,
|
||||
"qBittorrent URL is invalid (missing http:// or https://). "
|
||||
f"Configured: {self._base_url}",
|
||||
)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
return False, f"qBittorrent API error: {type(e).__name__}: {e}"
|
||||
else:
|
||||
return True, None
|
||||
|
||||
protocol = "torrent"
|
||||
name = "qbittorrent"
|
||||
|
||||
@@ -244,6 +207,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,44 +215,40 @@ 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", []))
|
||||
|
||||
def _get_torrents_info(
|
||||
self, torrent_hash: str | None = None, category: str | None = None
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
"""Get torrent info using GET.
|
||||
@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
|
||||
|
||||
Behaviors:
|
||||
- Retry once on HTTP 403 by re-authenticating.
|
||||
- Keep "API/auth/connect" errors distinct from "torrent missing".
|
||||
- If a hash-specific query returns empty, fall back to listing by category
|
||||
and matching locally.
|
||||
- Without a hash, `category` narrows the listing to that category.
|
||||
|
||||
Returns:
|
||||
(torrents, error_message)
|
||||
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 _request_torrent_info_records(
|
||||
self, params: dict[str, str]
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
"""Request torrent info records from qBittorrent."""
|
||||
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()
|
||||
return self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
def parse_response(
|
||||
response: requests.Response,
|
||||
*,
|
||||
request_params: dict[str, str],
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
try:
|
||||
self._ensure_authenticated()
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
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()
|
||||
response = self._client._session.get(url, params=request_params, timeout=10)
|
||||
self._ensure_authenticated()
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
logger.warning("qBittorrent authentication failed (HTTP 403)")
|
||||
@@ -297,43 +257,6 @@ class QBittorrentClient(DownloadClient):
|
||||
response.raise_for_status()
|
||||
torrents = response.json()
|
||||
return [SimpleNamespace(**t) for t in torrents], None
|
||||
|
||||
try:
|
||||
primary_params: dict[str, str] = {}
|
||||
if torrent_hash:
|
||||
primary_params["hashes"] = torrent_hash
|
||||
elif category:
|
||||
primary_params["category"] = category
|
||||
|
||||
response = do_request(primary_params)
|
||||
torrents, error = parse_response(response, request_params=primary_params)
|
||||
if error:
|
||||
return [], error
|
||||
|
||||
if torrent_hash and not torrents:
|
||||
# Fallback 1: list by configured category
|
||||
category_params: dict[str, str] = {}
|
||||
if self._category:
|
||||
category_params["category"] = self._category
|
||||
|
||||
category_response = do_request(category_params)
|
||||
category_torrents, category_error = parse_response(
|
||||
category_response, request_params=category_params
|
||||
)
|
||||
if category_error:
|
||||
return [], category_error
|
||||
|
||||
if category_torrents:
|
||||
return category_torrents, None
|
||||
|
||||
# Fallback 2: list everything (handles per-task categories like audiobooks)
|
||||
all_response = do_request({})
|
||||
all_torrents, all_error = parse_response(all_response, request_params={})
|
||||
if all_error:
|
||||
return [], all_error
|
||||
|
||||
return all_torrents, None
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
if status:
|
||||
@@ -358,12 +281,66 @@ class QBittorrentClient(DownloadClient):
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
logger.debug("Failed to get torrents info: %s", e)
|
||||
return [], f"qBittorrent API error: {type(e).__name__}: {e}"
|
||||
else:
|
||||
return torrents, None
|
||||
|
||||
def _get_torrent_info(self, download_id: str) -> tuple[SimpleNamespace | None, str | None]:
|
||||
"""Get one torrent by its current qBittorrent hash."""
|
||||
torrents, error = self._request_torrent_info_records({"hashes": download_id})
|
||||
if error or not torrents:
|
||||
return None, error
|
||||
return (
|
||||
next(
|
||||
(
|
||||
torrent
|
||||
for torrent in torrents
|
||||
if isinstance(getattr(torrent, "hash", None), str)
|
||||
and _hashes_match(torrent.hash, download_id)
|
||||
),
|
||||
None,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _list_torrents_by_category(
|
||||
self, category: str | None
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
"""List torrent records in a category, or all records when unset."""
|
||||
params = {"category": category} if category else {}
|
||||
return self._request_torrent_info_records(params)
|
||||
|
||||
def _resolve_torrent(
|
||||
self, download_id: str, category: str | None = None
|
||||
) -> tuple[SimpleNamespace | None, str | None]:
|
||||
"""Resolve any known torrent identity to its current qBittorrent record."""
|
||||
torrent, error = self._get_torrent_info(download_id)
|
||||
if error or torrent:
|
||||
return torrent, error
|
||||
|
||||
categories = [candidate for candidate in (category, self._category) if candidate]
|
||||
for candidate in dict.fromkeys(categories):
|
||||
torrents, error = self._list_torrents_by_category(candidate)
|
||||
if error:
|
||||
return None, error
|
||||
torrent = next(
|
||||
(item for item in torrents if _torrent_matches_download_id(item, download_id)),
|
||||
None,
|
||||
)
|
||||
if torrent:
|
||||
return torrent, None
|
||||
|
||||
torrents, error = self._list_torrents_by_category(None)
|
||||
if error:
|
||||
return None, error
|
||||
return (
|
||||
next(
|
||||
(item for item in torrents if _torrent_matches_download_id(item, download_id)),
|
||||
None,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _list_category_hashes(self, category: str | None) -> set[str] | None:
|
||||
"""Snapshot the hashes qBittorrent currently reports for a category."""
|
||||
torrents, error = self._get_torrents_info(category=category)
|
||||
torrents, error = self._list_torrents_by_category(category)
|
||||
if error:
|
||||
logger.debug("Could not snapshot qBittorrent torrents: %s", error)
|
||||
return None
|
||||
@@ -381,7 +358,7 @@ class QBittorrentClient(DownloadClient):
|
||||
torrent matching the requested rename can identify the new arrival.
|
||||
"""
|
||||
for _ in range(20):
|
||||
torrents, error = self._get_torrents_info(category=category)
|
||||
torrents, error = self._list_torrents_by_category(category)
|
||||
if error:
|
||||
logger.debug("qBittorrent hash discovery: %s", error)
|
||||
else:
|
||||
@@ -409,7 +386,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}"
|
||||
@@ -518,21 +495,22 @@ class QBittorrentClient(DownloadClient):
|
||||
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
|
||||
# the torrent becomes visible over trusting the response body alone.
|
||||
for _ in range(10):
|
||||
loaded, error = self._is_torrent_loaded(expected_hash)
|
||||
# Wait until qBittorrent has resolved magnet metadata so the returned
|
||||
# hash is its stable primary torrent ID, which may differ from the v1 hash.
|
||||
for _ in range(20):
|
||||
torrent, error = self._resolve_torrent(expected_hash, category)
|
||||
if error:
|
||||
logger.debug("qBittorrent add_download: %s", error)
|
||||
if loaded:
|
||||
logger.info("Added torrent: %s", expected_hash)
|
||||
return expected_hash.lower()
|
||||
elif torrent and getattr(torrent, "state", None) not in _METADATA_DOWNLOAD_STATES:
|
||||
torrent_hash = getattr(torrent, "hash", None)
|
||||
if isinstance(torrent_hash, str) and torrent_hash:
|
||||
logger.info("Added torrent: %s", torrent_hash)
|
||||
return torrent_hash.lower()
|
||||
time.sleep(0.5)
|
||||
|
||||
logger.warning(
|
||||
"Torrent add was not confirmed within the visibility grace period (response=%s), returning expected hash",
|
||||
result_text,
|
||||
_raise_runtime_error(
|
||||
"Torrent metadata resolution was not confirmed within the visibility grace period "
|
||||
f"(response={result_text})"
|
||||
)
|
||||
except _QBITTORRENT_CLIENT_ERRORS:
|
||||
logger.exception("qBittorrent add failed")
|
||||
@@ -551,19 +529,9 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
"""
|
||||
try:
|
||||
torrents, error = self._get_torrents_info(download_id)
|
||||
torrent, error = self._get_torrent_info(download_id)
|
||||
if error:
|
||||
return DownloadStatus.error(error)
|
||||
|
||||
torrent = next(
|
||||
(
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(t.hash, download_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not torrent:
|
||||
return DownloadStatus.error("Torrent not found in qBittorrent")
|
||||
|
||||
@@ -657,6 +625,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.
|
||||
|
||||
@@ -669,20 +657,10 @@ class QBittorrentClient(DownloadClient):
|
||||
- join `save_path` with the torrent's top-level directory
|
||||
"""
|
||||
try:
|
||||
torrents, error = self._get_torrents_info(download_id)
|
||||
torrent, error = self._get_torrent_info(download_id)
|
||||
if error:
|
||||
logger.debug("qBittorrent get_download_path: %s", error)
|
||||
return None
|
||||
|
||||
torrent = next(
|
||||
(
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(t.hash, download_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not torrent:
|
||||
return None
|
||||
|
||||
@@ -728,11 +706,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
|
||||
|
||||
@@ -789,23 +767,19 @@ class QBittorrentClient(DownloadClient):
|
||||
if not torrent_info.info_hash:
|
||||
return None
|
||||
|
||||
torrents, error = self._get_torrents_info(torrent_info.info_hash)
|
||||
if error:
|
||||
logger.debug("qBittorrent find_existing: %s", error)
|
||||
return None
|
||||
|
||||
torrent = next(
|
||||
(
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(t.hash, torrent_info.info_hash)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if torrent and isinstance(getattr(torrent, "hash", None), str):
|
||||
torrent_hash = torrent.hash
|
||||
return (torrent_hash.lower(), self.get_status(torrent_hash.lower()))
|
||||
for _ in range(20):
|
||||
torrent, error = self._resolve_torrent(torrent_info.info_hash, category)
|
||||
if error:
|
||||
logger.debug("qBittorrent find_existing: %s", error)
|
||||
return None
|
||||
if not torrent:
|
||||
return None
|
||||
if getattr(torrent, "state", None) not in _METADATA_DOWNLOAD_STATES:
|
||||
torrent_hash = getattr(torrent, "hash", None)
|
||||
if isinstance(torrent_hash, str) and torrent_hash:
|
||||
torrent_hash = torrent_hash.lower()
|
||||
return (torrent_hash, self.get_status(torrent_hash))
|
||||
time.sleep(0.5)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
logger.debug("Error checking for existing torrent: %s", e)
|
||||
return None
|
||||
|
||||
@@ -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: ...
|
||||
@@ -332,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",
|
||||
@@ -351,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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+153
-6
@@ -4,6 +4,7 @@ These utilities handle file collisions atomically, avoiding TOCTOU race conditio
|
||||
when multiple workers may try to write to the same path simultaneously.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import errno
|
||||
import os
|
||||
import shutil
|
||||
@@ -104,6 +105,57 @@ _PUBLISH_VERIFY_RETRY_SECONDS = 0.25
|
||||
_TEMPFILE_PREFIX = ".shelfmark."
|
||||
_TEMPFILE_SUFFIX = ".tmp"
|
||||
|
||||
# Destinations that accept writes but reject unlink/rename, e.g. a Synology share
|
||||
# with "Delete subfolders and files" unticked. Publishing a temp file into place
|
||||
# removes a directory entry, so those paths must be written in place instead.
|
||||
_DELETE_DENIED_DIRS: set[str] = set()
|
||||
|
||||
|
||||
class _PublishDeniedError(Exception):
|
||||
"""A fully-written temp file could not be renamed onto its final path."""
|
||||
|
||||
|
||||
def _is_delete_denied_error(error: Exception) -> bool:
|
||||
return isinstance(error, OSError) and error.errno in {errno.EACCES, errno.EPERM}
|
||||
|
||||
|
||||
def mark_delete_denied(directory: Path) -> None:
|
||||
"""Record that `directory` rejects deletes so later writes skip the temp file."""
|
||||
key = str(directory)
|
||||
if key in _DELETE_DENIED_DIRS:
|
||||
return
|
||||
_DELETE_DENIED_DIRS.add(key)
|
||||
logger.warning(
|
||||
"Destination %s rejects delete/rename; writing files in place instead of "
|
||||
"publishing atomically. Grant delete permission to restore atomic writes.",
|
||||
directory,
|
||||
)
|
||||
|
||||
|
||||
def clear_delete_denied(directory: Path) -> None:
|
||||
"""Forget recorded denials for `directory` and anything beneath it.
|
||||
|
||||
Subdirectories get marked independently (an `organize` layout publishes into
|
||||
per-author folders), so clearing only the exact key would leave a fixed
|
||||
destination writing in place until restart.
|
||||
"""
|
||||
if not _DELETE_DENIED_DIRS:
|
||||
return
|
||||
key = str(directory)
|
||||
prefix = f"{key}{os.sep}"
|
||||
_DELETE_DENIED_DIRS.difference_update(
|
||||
{marked for marked in _DELETE_DENIED_DIRS if marked == key or marked.startswith(prefix)}
|
||||
)
|
||||
|
||||
|
||||
def is_delete_denied(directory: Path) -> bool:
|
||||
"""True if `directory` or one of its ancestors is known to reject deletes."""
|
||||
if not _DELETE_DENIED_DIRS:
|
||||
return False
|
||||
if str(directory) in _DELETE_DENIED_DIRS:
|
||||
return True
|
||||
return any(str(parent) in _DELETE_DENIED_DIRS for parent in directory.parents)
|
||||
|
||||
|
||||
def _verify_transfer_size(
|
||||
dest: Path,
|
||||
@@ -361,10 +413,40 @@ def _create_temp_path(dest_path: Path) -> Path:
|
||||
return Path(temp_path)
|
||||
|
||||
|
||||
def _discard_path(path: Path) -> None:
|
||||
"""Best-effort unlink that tolerates destinations which reject deletes."""
|
||||
try:
|
||||
run_blocking_io(path.unlink, missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not remove %s: %s", path, exc)
|
||||
|
||||
|
||||
def _copy_into_claimed(source_path: Path, dest_path: Path, expected_size: int) -> None:
|
||||
"""Copy content straight into an already-claimed destination path.
|
||||
|
||||
Used when the destination rejects rename/unlink: there is no temp file to
|
||||
publish, so the final name is written in place. This is not atomic - a
|
||||
watcher can observe a partial file - but it is the only way to deliver on
|
||||
such a share. `copyfile` (not `copy2`) because metadata copying needs chmod,
|
||||
which those shares also tend to refuse.
|
||||
"""
|
||||
try:
|
||||
run_blocking_io(shutil.copyfile, str(source_path), str(dest_path))
|
||||
_verify_transfer_size(dest_path, expected_size, "copy")
|
||||
except Exception:
|
||||
with contextlib.suppress(OSError):
|
||||
run_blocking_io(dest_path.unlink, missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
|
||||
"""Publish a temp file to its final path without overwriting existing files.
|
||||
|
||||
Returns True on success, False if the destination already exists.
|
||||
|
||||
Raises `_PublishDeniedError` when the rename is refused for lack of delete
|
||||
permission. The claimed destination is left in place so the caller can write
|
||||
into it directly instead.
|
||||
"""
|
||||
claimed = _claim_destination(dest_path)
|
||||
if not claimed:
|
||||
@@ -374,7 +456,19 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
|
||||
# Publish by renaming the fully-written temp file into place. This gives
|
||||
# watchers an IN_MOVED_TO-style event on the final path instead of relying
|
||||
# on hardlink support in the destination filesystem.
|
||||
run_blocking_io(os.replace, str(temp_path), str(dest_path))
|
||||
try:
|
||||
run_blocking_io(os.replace, str(temp_path), str(dest_path))
|
||||
except OSError as e:
|
||||
if _is_delete_denied_error(e):
|
||||
log_transfer_permission_context(
|
||||
"publish_replace",
|
||||
source=temp_path,
|
||||
dest=dest_path,
|
||||
error=e,
|
||||
)
|
||||
mark_delete_denied(dest_path.parent)
|
||||
raise _PublishDeniedError(str(e)) from e
|
||||
raise
|
||||
|
||||
# Best-effort nudge for watchers that only react to close-write on the
|
||||
# final filename rather than rename/move events.
|
||||
@@ -383,6 +477,8 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
|
||||
run_blocking_io(os.close, fd)
|
||||
except OSError:
|
||||
pass
|
||||
except _PublishDeniedError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if _is_permission_error(e):
|
||||
log_transfer_permission_context(
|
||||
@@ -391,12 +487,23 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
|
||||
dest=dest_path,
|
||||
error=e,
|
||||
)
|
||||
run_blocking_io(dest_path.unlink, missing_ok=True)
|
||||
_discard_path(dest_path)
|
||||
raise
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def _move_via_copy(source_path: Path, dest_path: Path, max_attempts: int) -> Path:
|
||||
"""Deliver a move as copy + source unlink.
|
||||
|
||||
For destinations that reject rename. The source lives in TMP_DIR (which we
|
||||
own and can delete), so only the destination-side semantics change.
|
||||
"""
|
||||
final_path = atomic_copy(source_path, dest_path, max_attempts=max_attempts)
|
||||
_discard_path(source_path)
|
||||
return final_path
|
||||
|
||||
|
||||
def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path:
|
||||
"""Move a file with collision detection.
|
||||
|
||||
@@ -423,6 +530,11 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
ext = dest_path.suffix
|
||||
parent = dest_path.parent
|
||||
|
||||
# rename() removes a directory entry, so a destination that refuses deletes
|
||||
# cannot be moved into. Deliver it as copy + source unlink instead.
|
||||
if is_delete_denied(parent):
|
||||
return _move_via_copy(source_path, dest_path, max_attempts)
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
|
||||
|
||||
@@ -449,6 +561,13 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
continue
|
||||
except OSError as e:
|
||||
if _is_delete_denied_error(e):
|
||||
# Destination refuses the rename; fall back to copy + unlink source.
|
||||
mark_delete_denied(parent)
|
||||
if claimed:
|
||||
_discard_path(try_path)
|
||||
return _move_via_copy(source_path, dest_path, max_attempts)
|
||||
|
||||
# Cross-filesystem - copy to temp and publish atomically.
|
||||
if e.errno != errno.EXDEV:
|
||||
if claimed:
|
||||
@@ -497,7 +616,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
try:
|
||||
_verify_published_file(try_path, expected_size, "move")
|
||||
except Exception:
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
_discard_path(try_path)
|
||||
raise
|
||||
|
||||
run_blocking_io(source_path.unlink)
|
||||
@@ -508,9 +627,18 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
if temp_path:
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
continue
|
||||
except _PublishDeniedError:
|
||||
# Destination is claimed but unrenameable; write into it directly.
|
||||
_copy_into_claimed(source_path, try_path, expected_size)
|
||||
if temp_path:
|
||||
_discard_path(temp_path)
|
||||
_discard_path(source_path)
|
||||
if attempt > 0:
|
||||
logger.info("File collision resolved: %s", try_path.name)
|
||||
return try_path
|
||||
except Exception:
|
||||
if temp_path:
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
_discard_path(temp_path)
|
||||
raise
|
||||
else:
|
||||
return try_path
|
||||
@@ -629,6 +757,17 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
|
||||
if run_blocking_io(try_path.exists):
|
||||
continue
|
||||
|
||||
# Known-undeletable destination: skip the temp file entirely, otherwise
|
||||
# every transfer would strand a `.shelfmark.*.tmp` we cannot clean up.
|
||||
if is_delete_denied(parent):
|
||||
if not _claim_destination(try_path):
|
||||
continue
|
||||
_copy_into_claimed(source_path, try_path, expected_size)
|
||||
if attempt > 0:
|
||||
logger.info("File collision resolved: %s", try_path.name)
|
||||
return try_path
|
||||
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
temp_path = _create_temp_path(try_path)
|
||||
@@ -680,14 +819,22 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
try:
|
||||
_verify_published_file(try_path, expected_size, "copy")
|
||||
except Exception:
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
_discard_path(try_path)
|
||||
raise
|
||||
|
||||
if attempt > 0:
|
||||
logger.info("File collision resolved: %s", try_path.name)
|
||||
except _PublishDeniedError:
|
||||
# The destination is claimed but unrenameable; write into it directly.
|
||||
_copy_into_claimed(source_path, try_path, expected_size)
|
||||
if temp_path:
|
||||
_discard_path(temp_path)
|
||||
if attempt > 0:
|
||||
logger.info("File collision resolved: %s", try_path.name)
|
||||
return try_path
|
||||
except Exception:
|
||||
if temp_path:
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
_discard_path(temp_path)
|
||||
raise
|
||||
else:
|
||||
return try_path
|
||||
|
||||
+74
-23
@@ -4,7 +4,6 @@ import random
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from threading import Event, Thread
|
||||
from typing import TYPE_CHECKING, NoReturn
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
@@ -16,24 +15,34 @@ from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import coerce_bool, normalize_positive_int
|
||||
from shelfmark.download import network
|
||||
from shelfmark.download.activity import release_activity_grace, request_activity_grace
|
||||
from shelfmark.download.network import get_proxies, get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from threading import Event
|
||||
from types import ModuleType
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
_RNG = random.SystemRandom()
|
||||
|
||||
_MAX_REDIRECTS = 5
|
||||
# Z-Library answers the first hit with a 503 whose only real payload is a Set-Cookie; echoing
|
||||
# that cookie back returns the 302 to the real page. Two attempts cover the handshake without
|
||||
# letting a server that keeps re-issuing cookies hold us in the loop.
|
||||
_MAX_COOKIE_HANDSHAKE_RETRIES = 2
|
||||
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
|
||||
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
_HTTP_STATUS_RATE_LIMITED = HTTPStatus.TOO_MANY_REQUESTS
|
||||
_HTTP_STATUS_SERVICE_UNAVAILABLE = HTTPStatus.SERVICE_UNAVAILABLE
|
||||
_HTTP_STATUS_OK = HTTPStatus.OK
|
||||
_HTTP_STATUS_RANGE_NOT_SATISFIABLE = HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE
|
||||
_HTTP_STATUS_PARTIAL_CONTENT = HTTPStatus.PARTIAL_CONTENT
|
||||
_HTTP_STATUS_NON_RETRYABLE = (_HTTP_STATUS_FORBIDDEN, _HTTP_STATUS_NOT_FOUND)
|
||||
_STATUS_CALLBACK_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError)
|
||||
# Added on top of the active bypasser's own budget so it reports its real failure before
|
||||
# stall detection cancels the download.
|
||||
_BYPASS_GRACE_SLACK_SECONDS = 30.0
|
||||
_BYPASSER_ERRORS = (
|
||||
AttributeError,
|
||||
BypassCancelledError,
|
||||
@@ -54,6 +63,18 @@ def _raise_too_many_redirects(message: str) -> NoReturn:
|
||||
raise requests.exceptions.TooManyRedirects(message)
|
||||
|
||||
|
||||
def _new_cookies(response: requests.Response, already_sent: dict[str, str]) -> dict[str, str]:
|
||||
"""Cookies a response set that we were not already echoing back.
|
||||
|
||||
Returning only the *new* ones is what makes the retry terminate: a server that keeps
|
||||
re-issuing the same cookie yields nothing here, so we stop instead of spinning.
|
||||
"""
|
||||
jar = getattr(response, "cookies", None)
|
||||
if not jar:
|
||||
return {}
|
||||
return {name: value for name, value in jar.items() if already_sent.get(name) != value}
|
||||
|
||||
|
||||
def _get_internal_bypasser() -> ModuleType:
|
||||
"""Lazy import of internal bypasser module."""
|
||||
global _internal_bypasser
|
||||
@@ -99,6 +120,19 @@ def _is_cf_bypass_enabled() -> bool:
|
||||
return coerce_bool(app_config.get("USE_CF_BYPASS", True))
|
||||
|
||||
|
||||
def _bypass_grace_seconds() -> float:
|
||||
"""How long a bypass may block before stall detection should give up on it.
|
||||
|
||||
Each bypasser knows its own retry/timeout budget, so ask the active one rather than
|
||||
duplicating the arithmetic here. The slack keeps the bypasser's own deadline expiring
|
||||
first, so the user sees its real error instead of a generic "Download stalled".
|
||||
"""
|
||||
bypasser = (
|
||||
_get_external_bypasser() if _is_using_external_bypasser() else _get_internal_bypasser()
|
||||
)
|
||||
return bypasser.max_duration_seconds() + _BYPASS_GRACE_SLACK_SECONDS
|
||||
|
||||
|
||||
def get_bypassed_page(
|
||||
url: str,
|
||||
selector: network.AAMirrorSelector | None = None,
|
||||
@@ -261,6 +295,9 @@ def html_get_page(
|
||||
original_url = url
|
||||
current_url = selector.rewrite(original_url)
|
||||
use_bypasser_now = use_bypasser
|
||||
# Survives across attempts so a cookie won once is still presented on later retries.
|
||||
handshake_cookies: dict[str, str] = {}
|
||||
handshake_retries = 0
|
||||
|
||||
for attempt in range(1, retry_limit + 1):
|
||||
# Check for cancellation before each attempt
|
||||
@@ -273,34 +310,27 @@ def html_get_page(
|
||||
if use_bypasser_now and _is_cf_bypass_enabled():
|
||||
if status_callback:
|
||||
status_callback("resolving", "Bypassing protection...")
|
||||
heartbeat_stop = Event()
|
||||
heartbeat_thread: Thread | None = None
|
||||
if status_callback:
|
||||
|
||||
def _heartbeat() -> None:
|
||||
# Keep the download "alive" during long bypass operations so the orchestrator
|
||||
# doesn't flag it as stalled.
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
return
|
||||
try:
|
||||
status_callback("resolving", "Bypassing protection...")
|
||||
except _STATUS_CALLBACK_ERRORS:
|
||||
return
|
||||
|
||||
heartbeat_thread = Thread(
|
||||
target=_heartbeat, daemon=True, name="BypassHeartbeat"
|
||||
)
|
||||
heartbeat_thread.start()
|
||||
try:
|
||||
# A bypass is one long blocking call with no incremental progress, so
|
||||
# tell the orchestrator up front how long it may legitimately take
|
||||
# instead of trying to fake activity while it runs. Inside the try so a
|
||||
# bypasser that fails to load is still reported as a bypasser error.
|
||||
request_activity_grace(status_callback, _bypass_grace_seconds())
|
||||
result = get_bypassed_page(current_url, selector, cancel_flag)
|
||||
return _result(result or "", current_url)
|
||||
except _BYPASSER_ERRORS as e:
|
||||
logger.warning("Bypasser error: %s: %s", type(e).__name__, e)
|
||||
# Surface the real reason. Without this the caller only sees an empty
|
||||
# page and the download dies with a generic failure, hiding e.g. a
|
||||
# FlareSolverr 500 behind a silent wait.
|
||||
if status_callback and not isinstance(e, BypassCancelledError):
|
||||
try:
|
||||
status_callback("error", f"Bypass failed: {type(e).__name__}: {e}")
|
||||
except _STATUS_CALLBACK_ERRORS:
|
||||
logger.debug("Bypass error status callback failed", exc_info=True)
|
||||
return _result("", current_url)
|
||||
finally:
|
||||
heartbeat_stop.set()
|
||||
if heartbeat_thread:
|
||||
heartbeat_thread.join(timeout=1)
|
||||
release_activity_grace(status_callback)
|
||||
|
||||
logger.debug("GET: %s", current_url)
|
||||
|
||||
@@ -322,12 +352,31 @@ def html_get_page(
|
||||
current_url,
|
||||
proxies=get_proxies(current_url),
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
cookies=cookies,
|
||||
# Bypasser-derived cookies win: they came from a real solved challenge.
|
||||
cookies={**handshake_cookies, **cookies},
|
||||
headers=headers,
|
||||
allow_redirects=allow_redirects,
|
||||
verify=get_ssl_verify(current_url),
|
||||
)
|
||||
|
||||
# Z-Library gates the first hit with a 503 that carries nothing but a
|
||||
# Set-Cookie; echoing it back yields the 302 to the real page. Without this
|
||||
# the cookie is dropped and every retry re-runs the same rejected request.
|
||||
if (
|
||||
response.status_code == _HTTP_STATUS_SERVICE_UNAVAILABLE
|
||||
and handshake_retries < _MAX_COOKIE_HANDSHAKE_RETRIES
|
||||
):
|
||||
issued = _new_cookies(response, handshake_cookies)
|
||||
if issued:
|
||||
handshake_cookies.update(issued)
|
||||
handshake_retries += 1
|
||||
logger.debug(
|
||||
"503 set %s cookie(s); retrying with them: %s",
|
||||
len(issued),
|
||||
current_url,
|
||||
)
|
||||
continue
|
||||
|
||||
if is_aa_url and response.is_redirect:
|
||||
location = response.headers.get("Location", "")
|
||||
if not location:
|
||||
@@ -356,6 +405,7 @@ def html_get_page(
|
||||
current_url = new_url
|
||||
# Reset per-request state for the new host.
|
||||
headers = {"User-Agent": DOWNLOAD_HEADERS["User-Agent"]}
|
||||
handshake_cookies.clear()
|
||||
is_aa_url = network.should_rotate_dns_for_url(current_url)
|
||||
allow_redirects = not is_aa_url
|
||||
redirects_followed = 0
|
||||
@@ -425,6 +475,7 @@ def html_get_page(
|
||||
new_url = _try_rotation(original_url, current_url, selector)
|
||||
if new_url:
|
||||
current_url = new_url
|
||||
handshake_cookies.clear()
|
||||
continue
|
||||
|
||||
# Retry with backoff
|
||||
|
||||
@@ -12,7 +12,7 @@ from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from email.utils import parseaddr
|
||||
from pathlib import Path
|
||||
from threading import Event, Lock
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
@@ -24,6 +24,7 @@ from shelfmark.core.request_helpers import (
|
||||
)
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.core.utils import transform_cover_url
|
||||
from shelfmark.download.activity import parse_activity_grace
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path
|
||||
from shelfmark.download.postprocess.router import post_process_download
|
||||
@@ -33,6 +34,9 @@ from shelfmark.release_sources import (
|
||||
get_source_display_name,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
_RNG = random.SystemRandom()
|
||||
|
||||
@@ -65,7 +69,16 @@ _last_progress_value: dict[str, float] = {}
|
||||
# De-duplicate status updates (keep-alive updates shouldn't spam clients)
|
||||
_last_status_event: dict[str, tuple[str, str | None]] = {}
|
||||
STALL_TIMEOUT = 300 # 5 minutes without progress/status update = stalled
|
||||
# Absolute deadlines (time.time()) until which stall detection is suppressed for a task.
|
||||
# Long single-shot operations (protection bypass, etc.) declare their own upper bound via
|
||||
# `shelfmark.download.activity` instead of faking progress. See set_activity_grace().
|
||||
_activity_grace: dict[str, float] = {}
|
||||
# A caller cannot buy immortality: the largest grace any operation may request. Must stay
|
||||
# above the largest budget any caller can declare (see http._bypass_grace_seconds).
|
||||
_MAX_ACTIVITY_GRACE_SECONDS = 960.0
|
||||
COORDINATOR_LOOP_ERROR_RETRY_DELAY = 1.0
|
||||
# Ceiling for the exponential backoff applied to repeated coordinator loop failures.
|
||||
_COORDINATOR_LOOP_ERROR_MAX_DELAY = 30.0
|
||||
_PROGRESS_BROADCAST_START_PERCENT = 1
|
||||
_PROGRESS_BROADCAST_COMPLETE_PERCENT = 99
|
||||
_PROGRESS_BROADCAST_MIN_DELTA = 10
|
||||
@@ -251,6 +264,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")
|
||||
@@ -285,6 +299,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,
|
||||
@@ -420,6 +435,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 {},
|
||||
@@ -477,6 +493,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 {},
|
||||
@@ -645,6 +662,17 @@ def _download_task(task_id: str, cancel_flag: Event) -> str | None:
|
||||
update_download_progress(task_id, progress)
|
||||
|
||||
def status_callback(status: str, message: str | None = None) -> None:
|
||||
# Liveness hint from a long single-shot operation, not a user-visible status.
|
||||
# Handled here so it never reaches update_download_status (which dedupes status
|
||||
# transitions on purpose). See shelfmark.download.activity.
|
||||
grace = parse_activity_grace(status, message)
|
||||
if grace is not None:
|
||||
if grace > 0:
|
||||
set_activity_grace(task_id, grace)
|
||||
else:
|
||||
clear_activity_grace(task_id)
|
||||
return
|
||||
|
||||
status_key = status.lower()
|
||||
if status_key == "error":
|
||||
_capture_task_error(
|
||||
@@ -882,6 +910,7 @@ def _cleanup_progress_tracking(task_id: str) -> None:
|
||||
_last_activity.pop(task_id, None)
|
||||
_last_progress_value.pop(task_id, None)
|
||||
_last_status_event.pop(task_id, None)
|
||||
_activity_grace.pop(task_id, None)
|
||||
|
||||
|
||||
def _finalize_download_failure(task_id: str) -> None:
|
||||
@@ -929,6 +958,66 @@ def _process_single_download(task_id: str, cancel_flag: Event) -> None:
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
|
||||
def set_activity_grace(book_id: str, seconds: float) -> None:
|
||||
"""Suppress stall detection for `book_id` for up to `seconds` from now.
|
||||
|
||||
For long single-shot operations that cannot report incremental progress (protection
|
||||
bypass being the motivating case). The grace is a single absolute deadline computed
|
||||
once, so it cannot be extended into immortality by a keep-alive that carries no real
|
||||
liveness information - an operation that hangs forever is still cancelled once its
|
||||
declared budget expires.
|
||||
|
||||
Deliberately touches neither the queue nor the WebSocket: this is a liveness
|
||||
assertion, not a user-visible status transition.
|
||||
"""
|
||||
grace = _config_float(seconds, 0.0)
|
||||
grace = min(max(grace, 0.0), _MAX_ACTIVITY_GRACE_SECONDS)
|
||||
with _progress_lock:
|
||||
_activity_grace[book_id] = time.time() + grace
|
||||
|
||||
|
||||
def clear_activity_grace(book_id: str) -> None:
|
||||
"""Drop any activity grace for `book_id` and count this moment as activity.
|
||||
|
||||
Resetting `_last_activity` means a nested or abandoned grace degrades to a fresh
|
||||
full STALL_TIMEOUT window rather than an immediate stall.
|
||||
"""
|
||||
with _progress_lock:
|
||||
_activity_grace.pop(book_id, None)
|
||||
_last_activity[book_id] = time.time()
|
||||
|
||||
|
||||
def _find_stalled_tasks(task_ids: Iterable[str], now: float) -> list[str]:
|
||||
"""Return the task ids with no activity inside STALL_TIMEOUT and no active grace.
|
||||
|
||||
Holds `_progress_lock` for dict reads only - never call into `book_queue` from here,
|
||||
see _cancel_stalled_task().
|
||||
"""
|
||||
stalled: list[str] = []
|
||||
with _progress_lock:
|
||||
for task_id in task_ids:
|
||||
last_active = _last_activity.get(task_id, now)
|
||||
deadline = max(last_active + STALL_TIMEOUT, _activity_grace.get(task_id, 0.0))
|
||||
if now > deadline:
|
||||
stalled.append(task_id)
|
||||
return stalled
|
||||
|
||||
|
||||
def _cancel_stalled_task(task_id: str) -> None:
|
||||
"""Cancel a stalled download.
|
||||
|
||||
Must be called WITHOUT `_progress_lock` held. `book_queue.cancel_download` runs the
|
||||
terminal-status hooks, which reach a sqlite write that gevent does not patch; holding
|
||||
the progress lock across that blocks the hub and every other download worker.
|
||||
"""
|
||||
logger.warning("Download stalled for %s, cancelling", task_id)
|
||||
book_queue.cancel_download(task_id)
|
||||
book_queue.update_status_message(
|
||||
task_id,
|
||||
f"Download stalled (no activity for {STALL_TIMEOUT}s)",
|
||||
)
|
||||
|
||||
|
||||
def concurrent_download_loop() -> None:
|
||||
"""Run the main concurrent download coordinator."""
|
||||
max_workers = normalize_positive_int(config.MAX_CONCURRENT_DOWNLOADS) or 1
|
||||
@@ -938,6 +1027,7 @@ def concurrent_download_loop() -> None:
|
||||
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="Download") as executor:
|
||||
active_futures: dict[Future, tuple[str, Event]] = {} # Track active download futures
|
||||
stalled_tasks: set[str] = set() # Track tasks already cancelled due to stall
|
||||
consecutive_errors = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -988,19 +1078,14 @@ def concurrent_download_loop() -> None:
|
||||
|
||||
# Check for stalled downloads (no activity in STALL_TIMEOUT seconds)
|
||||
current_time = time.time()
|
||||
with _progress_lock:
|
||||
for _future, (task_id, _cancel_flag) in list(active_futures.items()):
|
||||
if task_id in stalled_tasks:
|
||||
continue
|
||||
last_active = _last_activity.get(task_id, current_time)
|
||||
if current_time - last_active > STALL_TIMEOUT:
|
||||
logger.warning("Download stalled for %s, cancelling", task_id)
|
||||
book_queue.cancel_download(task_id)
|
||||
book_queue.update_status_message(
|
||||
task_id,
|
||||
f"Download stalled (no activity for {STALL_TIMEOUT}s)",
|
||||
)
|
||||
stalled_tasks.add(task_id)
|
||||
candidates = [
|
||||
task_id
|
||||
for _future, (task_id, _cancel_flag) in list(active_futures.items())
|
||||
if task_id not in stalled_tasks
|
||||
]
|
||||
for task_id in _find_stalled_tasks(candidates, current_time):
|
||||
_cancel_stalled_task(task_id)
|
||||
stalled_tasks.add(task_id)
|
||||
|
||||
# Start new downloads if we have capacity
|
||||
while len(active_futures) < max_workers:
|
||||
@@ -1023,9 +1108,24 @@ def concurrent_download_loop() -> None:
|
||||
|
||||
# Brief sleep to prevent busy waiting
|
||||
time.sleep(main_loop_sleep_time)
|
||||
except (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError) as e:
|
||||
consecutive_errors = 0
|
||||
# This loop is the only thing driving the download queue; if it exits, nothing
|
||||
# is ever picked up again and the app looks healthy while doing nothing (#823,
|
||||
# #1166). A narrow exception list let gevent's LoopExit and friends through, so
|
||||
# catch everything short of BaseException - GreenletExit and gevent.Timeout must
|
||||
# still propagate, and the tests' loop-stopping sentinels derive from
|
||||
# BaseException for exactly this reason.
|
||||
except Exception as e: # noqa: BLE001 - coordinator loop must never die
|
||||
consecutive_errors += 1
|
||||
logger.error_trace("Download coordinator loop error: %s", e)
|
||||
time.sleep(COORDINATOR_LOOP_ERROR_RETRY_DELAY)
|
||||
# Back off when the failure is persistent so we don't spin at 1Hz forever,
|
||||
# but keep the first delay unchanged for a normal transient blip.
|
||||
time.sleep(
|
||||
min(
|
||||
COORDINATOR_LOOP_ERROR_RETRY_DELAY * 2 ** min(consecutive_errors - 1, 5),
|
||||
_COORDINATOR_LOOP_ERROR_MAX_DELAY,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Download coordinator thread (started explicitly via start())
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
@@ -13,7 +12,11 @@ from shelfmark.core.utils import (
|
||||
from shelfmark.core.utils import (
|
||||
is_audiobook as check_audiobook,
|
||||
)
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
from shelfmark.download.fs import (
|
||||
clear_delete_denied,
|
||||
mark_delete_denied,
|
||||
run_blocking_io,
|
||||
)
|
||||
from shelfmark.download.permissions_debug import log_path_permission_context
|
||||
from shelfmark.release_sources import get_source
|
||||
|
||||
@@ -25,6 +28,8 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = setup_logger("shelfmark.download.postprocess.pipeline")
|
||||
|
||||
_WRITE_PROBE_NAME = ".shelfmark_write_test.tmp"
|
||||
|
||||
|
||||
def validate_destination(
|
||||
destination: Path, status_callback: Callable[[str, str | None], None]
|
||||
@@ -52,7 +57,9 @@ def validate_destination(
|
||||
status_callback("error", f"Cannot create destination: {destination} ({exc})")
|
||||
return False
|
||||
|
||||
test_path = destination / f".shelfmark_write_test_{uuid.uuid4().hex}.tmp"
|
||||
# Stable name: on shares that refuse deletes the probe file cannot be cleaned
|
||||
# up, so reusing one name bounds the leftovers at a single hidden file.
|
||||
test_path = destination / _WRITE_PROBE_NAME
|
||||
|
||||
try:
|
||||
test_content = (
|
||||
@@ -60,7 +67,6 @@ def validate_destination(
|
||||
"It should've been automatically deleted. Feel free to delete it.\n"
|
||||
)
|
||||
run_blocking_io(test_path.write_text, test_content)
|
||||
run_blocking_io(test_path.unlink, missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.debug("Destination write probe path: %s", test_path)
|
||||
log_path_permission_context("destination_write_probe", destination)
|
||||
@@ -71,6 +77,23 @@ def validate_destination(
|
||||
run_blocking_io(destination.rmdir)
|
||||
return False
|
||||
|
||||
try:
|
||||
run_blocking_io(test_path.unlink, missing_ok=True)
|
||||
except OSError as exc:
|
||||
# Writable but not deletable, e.g. a Synology share with "Delete
|
||||
# subfolders and files" unticked. Not fatal: record it so transfers write
|
||||
# files in place instead of publishing a temp file via rename.
|
||||
mark_delete_denied(destination)
|
||||
logger.warning(
|
||||
"Destination %s is writable but refuses deletes (%s); leaving probe file %s "
|
||||
"behind and writing files in place",
|
||||
destination,
|
||||
exc,
|
||||
test_path.name,
|
||||
)
|
||||
else:
|
||||
clear_delete_denied(destination)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ BASE_PATH = normalize_base_path(normalize_optional_text(app_config.get("URL_BASE
|
||||
app = Flask(__name__)
|
||||
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0 # Disable caching
|
||||
app.config["APPLICATION_ROOT"] = BASE_PATH or "/"
|
||||
wsgi_app = cast(Any, ProxyFix(app.wsgi_app))
|
||||
wsgi_app = cast(Any, ProxyFix(app.wsgi_app, x_host=1, x_port=1))
|
||||
if BASE_PATH:
|
||||
wsgi_app = cast(Any, PrefixMiddleware(wsgi_app, BASE_PATH, bypass_paths={"/api/health"}))
|
||||
app.wsgi_app = wsgi_app
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -709,3 +709,6 @@ with suppress(ImportError):
|
||||
|
||||
with suppress(ImportError):
|
||||
from shelfmark.metadata_providers import googlebooks as googlebooks
|
||||
|
||||
with suppress(ImportError):
|
||||
from shelfmark.metadata_providers import moly as moly
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Hardcover.app metadata provider. Requires API key."""
|
||||
|
||||
import re
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
@@ -553,6 +554,31 @@ TITLE_SUGGESTION_FIELDS = "title,alternative_titles"
|
||||
TITLE_SUGGESTION_WEIGHTS = "5,2"
|
||||
TITLE_SUGGESTION_SORT = "_text_match:desc,users_count:desc"
|
||||
|
||||
# Hardcover forwards `sort` to Typesense's `sort_by` and rejects the whole search
|
||||
# if it does not like the value -- an unknown field, a bare field name with no
|
||||
# direction, more than three keys. A rejected search comes back as HTTP 200 with
|
||||
# no GraphQL errors and a null `results` body, which is otherwise indistinguishable
|
||||
# from "nothing matched". An empty sort is always accepted, so fall back to it and
|
||||
# keep the fallback sticky for a while rather than paying for a doomed request on
|
||||
# every search.
|
||||
SORT_FALLBACK = ""
|
||||
SORT_FALLBACK_TTL = 900.0
|
||||
_sort_fallback_until = 0.0
|
||||
|
||||
|
||||
def _search_payload_rejected(result: dict[str, Any] | None) -> bool:
|
||||
"""Report whether Hardcover answered a search with a null results body.
|
||||
|
||||
A search that genuinely matched nothing still returns a results object with
|
||||
``found: 0``; only a rejected search nulls it out entirely.
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
return False
|
||||
root = result.get("search", result)
|
||||
if not isinstance(root, dict) or "results" not in root:
|
||||
return False
|
||||
return root["results"] is None
|
||||
|
||||
|
||||
def _combine_headline_description(headline: str | None, description: str | None) -> str | None:
|
||||
"""Combine headline (tagline) and description into a single description."""
|
||||
@@ -1200,7 +1226,7 @@ class HardcoverProvider(MetadataProvider):
|
||||
if not self.api_key or len(normalized_query) < HARDCOVER_MIN_TYPEAHEAD_QUERY_LENGTH:
|
||||
return []
|
||||
|
||||
result = self._execute_query(
|
||||
result = self._execute_search_query(
|
||||
SEARCH_FIELD_OPTIONS_QUERY,
|
||||
{
|
||||
"query": normalized_query,
|
||||
@@ -1431,7 +1457,7 @@ class HardcoverProvider(MetadataProvider):
|
||||
logger.debug("Invalid Hardcover series id field value: %s", normalized_value)
|
||||
return None
|
||||
|
||||
result = self._execute_query(
|
||||
result = self._execute_search_query(
|
||||
SEARCH_FIELD_OPTIONS_QUERY,
|
||||
{
|
||||
"query": normalized_value,
|
||||
@@ -2386,7 +2412,7 @@ class HardcoverProvider(MetadataProvider):
|
||||
variables["weights"] = search_weights
|
||||
|
||||
try:
|
||||
result = self._execute_query(graphql_query, variables)
|
||||
result = self._execute_search_query(graphql_query, variables)
|
||||
if not result:
|
||||
logger.debug("Hardcover search: No result from API")
|
||||
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
|
||||
@@ -2654,6 +2680,45 @@ class HardcoverProvider(MetadataProvider):
|
||||
raise RuntimeError(msg) from e
|
||||
return None
|
||||
|
||||
def _execute_search_query(self, query: str, variables: dict[str, Any]) -> dict | None:
|
||||
"""Execute a search query, retrying without ``sort`` if Hardcover rejects it.
|
||||
|
||||
Returns None when the search was rejected, so callers report an empty
|
||||
result rather than silently treating a failure as "nothing matched".
|
||||
"""
|
||||
global _sort_fallback_until
|
||||
|
||||
sort = variables.get("sort")
|
||||
if sort and time.monotonic() < _sort_fallback_until:
|
||||
variables = {**variables, "sort": SORT_FALLBACK}
|
||||
sort = None
|
||||
|
||||
result = self._execute_query(query, variables)
|
||||
if not _search_payload_rejected(result):
|
||||
return result
|
||||
|
||||
if not sort:
|
||||
logger.error(
|
||||
"Hardcover rejected this search (query_type=%s, fields=%s) and returned "
|
||||
"no result body",
|
||||
variables.get("queryType", "Book"),
|
||||
variables.get("fields"),
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Hardcover rejected sort '%s'; retrying searches without a sort order for %ss",
|
||||
sort,
|
||||
int(SORT_FALLBACK_TTL),
|
||||
)
|
||||
_sort_fallback_until = time.monotonic() + SORT_FALLBACK_TTL
|
||||
|
||||
retry = self._execute_query(query, {**variables, "sort": SORT_FALLBACK})
|
||||
if _search_payload_rejected(retry):
|
||||
logger.error("Hardcover rejected this search even without a sort order")
|
||||
return None
|
||||
return retry
|
||||
|
||||
def _parse_search_result(self, item: dict) -> BookMetadata | None:
|
||||
"""Parse a search result item into BookMetadata."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
"""Moly.hu metadata provider. Hungarian book catalog, no API key required.
|
||||
|
||||
Scraping approach (search URL, book-page structure, language mapping) adapted
|
||||
from the Calibre Moly_hu plugin by Hoffer Csaba, Kloon, otapi, Dezso, Hokutya,
|
||||
seeder and contributors (GPL v3, mobileread.com).
|
||||
"""
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
from collections import deque
|
||||
from typing import Any, ClassVar
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from shelfmark.core.cache import cacheable
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.settings_registry import (
|
||||
ActionButton,
|
||||
CheckboxField,
|
||||
HeadingField,
|
||||
SettingsField,
|
||||
register_settings,
|
||||
)
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
DisplayField,
|
||||
MetadataProvider,
|
||||
MetadataSearchOptions,
|
||||
SearchField,
|
||||
SearchType,
|
||||
SortOrder,
|
||||
TextSearchField,
|
||||
register_provider,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
MOLY_BASE_URL = "https://moly.hu"
|
||||
MOLY_BOOK_URL = f"{MOLY_BASE_URL}/konyvek/"
|
||||
MOLY_SEARCH_URL = f"{MOLY_BASE_URL}/kereses?query="
|
||||
|
||||
# Be polite: moly.hu is a small community site
|
||||
RATE_LIMIT_REQUESTS = 30
|
||||
RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
|
||||
REQUEST_HEADERS = {
|
||||
"User-Agent": ("Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"),
|
||||
"Accept-Language": "hu,en;q=0.7",
|
||||
}
|
||||
|
||||
ISBN_13_LENGTH = 13
|
||||
|
||||
# Moly tags its foreign-language editions; everything else is Hungarian.
|
||||
# Mapping from the Calibre Moly_hu plugin.
|
||||
_LANGUAGE_TAG_MAP = {
|
||||
"angol nyelvű": "en",
|
||||
"n\xe9met nyelvű": "de",
|
||||
"francia nyelvű": "fr",
|
||||
"olasz nyelvű": "it",
|
||||
"spanyol nyelvű": "es",
|
||||
"orosz nyelvű": "ru",
|
||||
"t\xf6r\xf6k nyelvű": "tr",
|
||||
"g\xf6r\xf6g nyelvű": "el",
|
||||
"k\xednai nyelvű": "zh",
|
||||
"jap\xe1n nyelvű": "ja",
|
||||
}
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Simple sliding window rate limiter."""
|
||||
|
||||
def __init__(self, max_requests: int, window_seconds: int) -> None:
|
||||
"""Initialize rate limiter with max requests per time window."""
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self.timestamps: deque[float] = deque()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def wait_if_needed(self) -> None:
|
||||
"""Block until a request is allowed (thread-safe)."""
|
||||
wait_time = 0.0
|
||||
|
||||
with self.lock:
|
||||
now = time.time()
|
||||
cutoff = now - self.window_seconds
|
||||
while self.timestamps and self.timestamps[0] < cutoff:
|
||||
self.timestamps.popleft()
|
||||
if len(self.timestamps) >= self.max_requests:
|
||||
wait_time = self.timestamps[0] + self.window_seconds - now
|
||||
|
||||
if wait_time > 0:
|
||||
logger.debug("Rate limited, waiting %0.2fs", wait_time)
|
||||
time.sleep(wait_time)
|
||||
|
||||
with self.lock:
|
||||
now = time.time()
|
||||
cutoff = now - self.window_seconds
|
||||
while self.timestamps and self.timestamps[0] < cutoff:
|
||||
self.timestamps.popleft()
|
||||
self.timestamps.append(time.time())
|
||||
|
||||
|
||||
_rate_limiter = RateLimiter(RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS)
|
||||
|
||||
|
||||
def _clean_text(value: str | None) -> str | None:
|
||||
"""Strip zero-width characters and collapse whitespace."""
|
||||
if value is None:
|
||||
return None
|
||||
value = value.replace("", "").replace("", "")
|
||||
return " ".join(value.split())
|
||||
|
||||
|
||||
def _normalize_for_match(value: str | None) -> str:
|
||||
"""Accent-insensitive, punctuation-insensitive comparison form."""
|
||||
if not value:
|
||||
return ""
|
||||
value = unicodedata.normalize("NFKD", value)
|
||||
value = "".join(char for char in value if not unicodedata.combining(char))
|
||||
value = "".join(char if char.isalnum() else " " for char in value)
|
||||
return " ".join(value.lower().split())
|
||||
|
||||
|
||||
def _absolute_url(url: str | None) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
if url.startswith(("http://", "https://")):
|
||||
return url
|
||||
return MOLY_BASE_URL + url
|
||||
|
||||
|
||||
def _valid_isbn(candidate: str) -> str | None:
|
||||
"""Return a normalized ISBN-10/13 (digits, with optional X check digit), else None."""
|
||||
digits = candidate.replace("-", "").strip()
|
||||
if len(digits) == ISBN_13_LENGTH and digits.isdigit():
|
||||
return digits
|
||||
if len(digits) == 10 and re.fullmatch(r"\d{9}[\dXx]", digits):
|
||||
return digits.upper()
|
||||
return None
|
||||
|
||||
|
||||
@register_provider("moly")
|
||||
class MolyProvider(MetadataProvider):
|
||||
"""Moly.hu metadata provider (HTML scraping, Hungarian catalog)."""
|
||||
|
||||
name = "moly"
|
||||
display_name = "Moly.hu"
|
||||
requires_auth = False
|
||||
supported_sorts: ClassVar[tuple[SortOrder, ...]] = (SortOrder.RELEVANCE,)
|
||||
search_fields: ClassVar[tuple[SearchField, ...]] = (
|
||||
TextSearchField(
|
||||
key="author",
|
||||
label="Author",
|
||||
description="Search by author name",
|
||||
),
|
||||
TextSearchField(
|
||||
key="title",
|
||||
label="Title",
|
||||
description="Search by book title",
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize provider."""
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(REQUEST_HEADERS)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Moly.hu needs no authentication."""
|
||||
return True
|
||||
|
||||
def _fetch(self, url: str, timeout: int = 15) -> str | None:
|
||||
_rate_limiter.wait_if_needed()
|
||||
try:
|
||||
response = self.session.get(url, timeout=timeout, verify=get_ssl_verify(MOLY_BASE_URL))
|
||||
response.raise_for_status()
|
||||
except requests.Timeout:
|
||||
logger.warning("Moly.hu request timed out: %s", url)
|
||||
return None
|
||||
except requests.RequestException:
|
||||
logger.exception("Moly.hu request failed: %s", url)
|
||||
return None
|
||||
return response.text
|
||||
|
||||
def search(self, options: MetadataSearchOptions) -> list[BookMetadata]:
|
||||
"""Search moly.hu's site search."""
|
||||
if options.search_type == SearchType.ISBN:
|
||||
result = self.search_by_isbn(options.query)
|
||||
return [result] if result else []
|
||||
|
||||
# Moly's search is a single ranked page; no server-side pagination.
|
||||
if options.page > 1:
|
||||
return []
|
||||
|
||||
author_value = (options.fields.get("author") or "").strip()
|
||||
title_value = (options.fields.get("title") or "").strip()
|
||||
terms = " ".join(t for t in (author_value, title_value) if t)
|
||||
query = terms or options.query.strip()
|
||||
if not query:
|
||||
return []
|
||||
|
||||
fields_key = ":".join(f"{k}={v}" for k, v in sorted(options.fields.items()))
|
||||
cache_key = f"{query}:{options.search_type.value}:{options.limit}:{fields_key}"
|
||||
return self._search_cached(cache_key, query, options.limit) or []
|
||||
|
||||
@cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="moly:search")
|
||||
def _search_cached(self, cache_key: str, query: str, limit: int) -> list[BookMetadata] | None:
|
||||
# Return None (not []) on fetch failure so the failure is not cached.
|
||||
html = self._fetch(MOLY_SEARCH_URL + quote(query.encode("utf-8")))
|
||||
if html is None:
|
||||
return None
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
books: list[BookMetadata] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for anchor in soup.select("#content div.search_area a.book_selector"):
|
||||
href = anchor.get("href") or ""
|
||||
match = re.search(r"/konyvek/([^/?#]+)", str(href))
|
||||
if not match:
|
||||
continue
|
||||
slug = match.group(1)
|
||||
if slug in seen:
|
||||
continue
|
||||
|
||||
# No separator: moly wraps matched search terms in <strong> even
|
||||
# mid-word ("Lis<strong>a</strong> Jewell"), so inserting one
|
||||
# would split words at highlight boundaries.
|
||||
text = _clean_text(anchor.get_text()) or ""
|
||||
author, _, title = text.partition(":")
|
||||
if not title:
|
||||
# Result rows are "Author: Title"; skip anything else.
|
||||
continue
|
||||
author = author.strip()
|
||||
title = title.strip()
|
||||
|
||||
seen.add(slug)
|
||||
books.append(
|
||||
BookMetadata(
|
||||
provider=self.name,
|
||||
provider_id=slug,
|
||||
provider_display_name=self.display_name,
|
||||
title=title,
|
||||
authors=[author] if author else [],
|
||||
cover_url=self._cover_for_result(soup, text),
|
||||
source_url=MOLY_BOOK_URL + slug,
|
||||
language="hu",
|
||||
search_title=title,
|
||||
search_author=author or None,
|
||||
display_fields=self._result_display_fields(anchor),
|
||||
)
|
||||
)
|
||||
if len(books) >= limit:
|
||||
break
|
||||
|
||||
logger.info("Moly.hu search '%s' returned %s results", query, len(books))
|
||||
return books
|
||||
|
||||
def _cover_for_result(self, soup: BeautifulSoup, result_text: str) -> str | None:
|
||||
"""Find the search-result thumbnail whose alt matches 'Author: Title'."""
|
||||
target = _normalize_for_match(result_text)
|
||||
if not target:
|
||||
return None
|
||||
for img in soup.select("#content img.tooltip[alt]"):
|
||||
if _normalize_for_match(str(img.get("alt") or "")) == target:
|
||||
return _absolute_url(str(img.get("src") or "")) or None
|
||||
return None
|
||||
|
||||
def _result_display_fields(self, anchor: Tag) -> list[DisplayField]:
|
||||
fields: list[DisplayField] = []
|
||||
parent = anchor.parent
|
||||
if parent is None:
|
||||
return fields
|
||||
like = parent.select_one("span.like_count")
|
||||
if like:
|
||||
fields.append(
|
||||
DisplayField(label="Rating", value=like.get_text(strip=True), icon="star")
|
||||
)
|
||||
series = parent.select_one('a[href*="/sorozatok/"]')
|
||||
if series:
|
||||
fields.append(
|
||||
DisplayField(
|
||||
label="Series",
|
||||
value=series.get_text(strip=True).strip("()"),
|
||||
icon="editions",
|
||||
)
|
||||
)
|
||||
return fields
|
||||
|
||||
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="moly:book")
|
||||
def get_book(self, book_id: str) -> BookMetadata | None:
|
||||
"""Get book details by moly.hu slug (e.g. 'mocsidzuki-mai-a-telihold-kavezo')."""
|
||||
html = self._fetch(MOLY_BOOK_URL + quote(book_id))
|
||||
if html is None:
|
||||
return None
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
title = self._parse_title(soup)
|
||||
authors = [_clean_text(a.get_text()) or "" for a in soup.select("#content div.authors a")]
|
||||
authors = [a for a in authors if a]
|
||||
if not title or not authors:
|
||||
logger.warning("Moly.hu book page missing title/authors: %s", book_id)
|
||||
return None
|
||||
|
||||
isbn_13, isbn_10 = self._parse_isbns(soup)
|
||||
series = self._parse_series(soup)
|
||||
tags = [_clean_text(t.get_text()) or "" for t in soup.select("#book_tags a.tag")]
|
||||
tags = [t for t in tags if t]
|
||||
|
||||
display_fields: list[DisplayField] = []
|
||||
rating = soup.select_one("#content .rating .like_count")
|
||||
if rating:
|
||||
display_fields.append(
|
||||
DisplayField(label="Rating", value=rating.get_text(strip=True), icon="star")
|
||||
)
|
||||
if series:
|
||||
display_fields.append(DisplayField(label="Series", value=series, icon="editions"))
|
||||
|
||||
return BookMetadata(
|
||||
provider=self.name,
|
||||
provider_id=book_id,
|
||||
provider_display_name=self.display_name,
|
||||
title=title,
|
||||
authors=authors,
|
||||
isbn_13=isbn_13,
|
||||
isbn_10=isbn_10,
|
||||
cover_url=self._parse_cover(soup),
|
||||
description=self._parse_description(soup),
|
||||
publisher=self._parse_publisher(soup),
|
||||
publish_year=self._parse_publish_year(soup),
|
||||
language=self._parse_language(tags),
|
||||
genres=tags,
|
||||
source_url=MOLY_BOOK_URL + book_id,
|
||||
search_title=title,
|
||||
search_author=authors[0],
|
||||
display_fields=display_fields,
|
||||
)
|
||||
|
||||
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="moly:isbn")
|
||||
def search_by_isbn(self, isbn: str) -> BookMetadata | None:
|
||||
"""Moly's site search resolves ISBN queries directly."""
|
||||
isbn = isbn.replace("-", "").strip()
|
||||
if not isbn:
|
||||
return None
|
||||
html = self._fetch(MOLY_SEARCH_URL + quote(isbn))
|
||||
if html is None:
|
||||
return None
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
anchor = soup.select_one("#content div.search_area a.book_selector[href]")
|
||||
if not anchor:
|
||||
return None
|
||||
match = re.search(r"/konyvek/([^/?#]+)", str(anchor.get("href")))
|
||||
if not match:
|
||||
return None
|
||||
return self.get_book(match.group(1))
|
||||
|
||||
def _parse_title(self, soup: BeautifulSoup) -> str | None:
|
||||
node = soup.select_one("#content .head_title h1 span.item")
|
||||
if node:
|
||||
# The series link is nested inside this span; only direct text
|
||||
# belongs to the book title.
|
||||
direct = "".join(node.find_all(string=True, recursive=False))
|
||||
title = _clean_text(direct)
|
||||
if title:
|
||||
return title
|
||||
node = soup.select_one("#content .book > span")
|
||||
if node:
|
||||
return _clean_text(node.get_text())
|
||||
return None
|
||||
|
||||
def _parse_series(self, soup: BeautifulSoup) -> str | None:
|
||||
node = soup.select_one('#content h1 a[href*="/sorozatok/"]')
|
||||
if not node:
|
||||
return None
|
||||
return (_clean_text(node.get_text()) or "").strip("()") or None
|
||||
|
||||
def _parse_isbns(self, soup: BeautifulSoup) -> tuple[str | None, str | None]:
|
||||
isbn_13 = isbn_10 = None
|
||||
editions = soup.select("#content .items .edition") or soup.select("#content .items > div")
|
||||
for edition in editions:
|
||||
text = edition.get_text(" ")
|
||||
for candidate in re.findall(r"(?<!\d)[\d-]{10,17}(?!\d)", text):
|
||||
isbn = _valid_isbn(candidate)
|
||||
if not isbn:
|
||||
continue
|
||||
if len(isbn) == ISBN_13_LENGTH and not isbn_13:
|
||||
isbn_13 = isbn
|
||||
elif len(isbn) != ISBN_13_LENGTH and not isbn_10:
|
||||
isbn_10 = isbn
|
||||
if isbn_13:
|
||||
break
|
||||
return isbn_13, isbn_10
|
||||
|
||||
def _parse_cover(self, soup: BeautifulSoup) -> str | None:
|
||||
node = soup.select_one("#content .coverbox a.zoom[href]")
|
||||
if node:
|
||||
return _absolute_url(str(node.get("href")))
|
||||
img = soup.select_one("#content .coverbox img[src]")
|
||||
if img:
|
||||
return _absolute_url(str(img.get("src")))
|
||||
return None
|
||||
|
||||
def _parse_description(self, soup: BeautifulSoup) -> str | None:
|
||||
node = soup.select_one("#content #full_description")
|
||||
if node is None:
|
||||
node = soup.select_one("#content div.text")
|
||||
if node is None:
|
||||
return None
|
||||
spoiler_warning = "Vigyázat! Cselekményleírást tartalmaz."
|
||||
parts = []
|
||||
for text in node.stripped_strings:
|
||||
cleaned = _clean_text(text) or ""
|
||||
if cleaned.startswith(spoiler_warning):
|
||||
cleaned = cleaned[len(spoiler_warning) :].strip()
|
||||
if cleaned:
|
||||
parts.append(cleaned)
|
||||
return "\n".join(parts) or None
|
||||
|
||||
def _parse_publisher(self, soup: BeautifulSoup) -> str | None:
|
||||
node = soup.select_one('#content .items .edition a[href*="/kiadok/"]')
|
||||
if node:
|
||||
return _clean_text(node.get_text())
|
||||
return None
|
||||
|
||||
def _parse_publish_year(self, soup: BeautifulSoup) -> int | None:
|
||||
editions = soup.select("#content .items .edition") or soup.select("#content .items > div")
|
||||
for edition in editions:
|
||||
match = re.search(r"\b(\d{4})\b", edition.get_text(" "))
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
def _parse_language(self, tags: list[str]) -> str:
|
||||
for tag in tags:
|
||||
code = _LANGUAGE_TAG_MAP.get(tag.lower().strip())
|
||||
if code:
|
||||
return code
|
||||
return "hu"
|
||||
|
||||
|
||||
def _test_moly_connection() -> dict[str, Any]:
|
||||
"""Test connectivity to moly.hu."""
|
||||
try:
|
||||
provider = MolyProvider()
|
||||
response = provider.session.get(
|
||||
MOLY_SEARCH_URL + quote("teszt"),
|
||||
timeout=10,
|
||||
verify=get_ssl_verify(MOLY_BASE_URL),
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.Timeout:
|
||||
return {"success": False, "message": "Connection timed out"}
|
||||
except requests.RequestException as e:
|
||||
return {"success": False, "message": f"Connection failed: {e}"}
|
||||
if "moly" in response.text.lower():
|
||||
return {"success": True, "message": "Successfully connected to moly.hu"}
|
||||
return {"success": False, "message": "Unexpected response from moly.hu"}
|
||||
|
||||
|
||||
@register_settings("moly", "Moly.hu", icon="library", order=54, group="metadata_providers")
|
||||
def moly_settings() -> list[SettingsField]:
|
||||
"""Moly.hu metadata provider settings."""
|
||||
return [
|
||||
HeadingField(
|
||||
key="moly_heading",
|
||||
title="Moly.hu",
|
||||
description=(
|
||||
"Hungarian community book catalog with excellent coverage of "
|
||||
"Hungarian editions and translations. No API key required."
|
||||
),
|
||||
link_url="https://moly.hu",
|
||||
link_text="moly.hu",
|
||||
),
|
||||
CheckboxField(
|
||||
key="MOLY_ENABLED",
|
||||
label="Enable Moly.hu",
|
||||
description="Enable Moly.hu as a metadata provider for book searches",
|
||||
default=False,
|
||||
),
|
||||
ActionButton(
|
||||
key="test_connection",
|
||||
label="Test Connection",
|
||||
description="Verify moly.hu is accessible",
|
||||
style="primary",
|
||||
callback=_test_moly_connection,
|
||||
),
|
||||
]
|
||||
@@ -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>").
|
||||
|
||||
@@ -189,16 +189,24 @@ class ProwlarrClient:
|
||||
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
|
||||
|
||||
@@ -34,6 +34,7 @@ from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, Prowlarr
|
||||
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,
|
||||
@@ -318,11 +319,25 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
|
||||
@staticmethod
|
||||
def _raw_release_matches_task(raw_release: dict[str, Any], task_id: str) -> bool:
|
||||
identities = (
|
||||
normalize_optional_text(raw_release.get("guid")),
|
||||
normalize_optional_text(raw_release.get("infoUrl")),
|
||||
)
|
||||
return any(identity == task_id for identity in identities)
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,6 +12,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.core.request_helpers import normalize_optional_text
|
||||
from shelfmark.core.search_plan import ReleaseSearchVariant
|
||||
@@ -33,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,
|
||||
@@ -44,6 +46,9 @@ _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.
|
||||
@@ -69,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:
|
||||
@@ -110,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
|
||||
@@ -201,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
|
||||
|
||||
@@ -387,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)
|
||||
@@ -612,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(
|
||||
@@ -817,8 +895,12 @@ 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 = (
|
||||
_fetch_indexer_seed_settings(client, indexer_ids)
|
||||
@@ -859,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):
|
||||
@@ -887,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()
|
||||
|
||||
@@ -917,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
+1230
-596
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -17,24 +17,24 @@
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"knip": "^6.26.0",
|
||||
"oxfmt": "^0.58.0",
|
||||
"oxlint": "^1.73.0",
|
||||
"oxlint-tsgolint": "^0.24.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"knip": "^6.32.0",
|
||||
"oxfmt": "^0.62.0",
|
||||
"oxlint": "^1.77.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.4",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.1",
|
||||
"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]);
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -101,3 +101,36 @@ def test_get_bypassed_page_retries_and_rotates_selector_between_attempts(monkeyp
|
||||
]
|
||||
assert selector.rotate_calls == 1
|
||||
assert sleeps == [1.0]
|
||||
|
||||
|
||||
def _stub_ext_bypasser_timeout(monkeypatch, external_bypasser, value: int) -> None:
|
||||
"""Override only EXT_BYPASSER_TIMEOUT on the shared config singleton."""
|
||||
real_get = external_bypasser.config.get
|
||||
monkeypatch.setattr(
|
||||
external_bypasser.config,
|
||||
"get",
|
||||
lambda key, default="": value if key == "EXT_BYPASSER_TIMEOUT" else real_get(key, default),
|
||||
)
|
||||
|
||||
|
||||
def test_max_duration_seconds_covers_every_attempt_and_backoff(monkeypatch):
|
||||
"""The declared budget must not undercut what get_bypassed_page() can actually take."""
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
_stub_ext_bypasser_timeout(monkeypatch, external_bypasser, 60000)
|
||||
|
||||
budget = external_bypasser.max_duration_seconds()
|
||||
|
||||
# 5 attempts at min(60 + 15, 120) = 75s, plus the 1+2+4+8 backoff and its jitter.
|
||||
assert budget == 5 * 75.0 + (1 + 1) + (2 + 1) + (4 + 1) + (8 + 1)
|
||||
|
||||
|
||||
def test_max_duration_seconds_respects_the_read_timeout_ceiling(monkeypatch):
|
||||
import shelfmark.bypass.external_bypasser as external_bypasser
|
||||
|
||||
_stub_ext_bypasser_timeout(monkeypatch, external_bypasser, 300000)
|
||||
|
||||
budget = external_bypasser.max_duration_seconds()
|
||||
|
||||
# 300s + 15s buffer is clamped to MAX_READ_TIMEOUT, not used raw.
|
||||
assert budget == 5 * external_bypasser.MAX_READ_TIMEOUT + 19.0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -430,3 +431,54 @@ def test_get_bypassed_page_retries_next_mirror_after_runtime_error(monkeypatch):
|
||||
"https://mirror-one.example/book",
|
||||
"https://mirror-two.example/book",
|
||||
]
|
||||
|
||||
|
||||
def test_max_duration_seconds_allows_for_a_mirror_rotation_retry():
|
||||
"""get_bypassed_page() may call get() twice, so the budget must cover both."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
assert internal_bypasser.max_duration_seconds() == (
|
||||
2 * internal_bypasser._BYPASS_SUBPROCESS_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
|
||||
def test_cdp_worker_run_times_out_and_cancels_the_orphaned_coroutine(monkeypatch):
|
||||
"""A wedged in-process bypass must not block forever holding LOCKED.
|
||||
|
||||
Regression guard: _CDP_WORKER.run() used to wait with timeout=None, so one hung CDP
|
||||
session blocked every subsequent bypass in the process indefinitely.
|
||||
"""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
started = threading.Event()
|
||||
|
||||
async def _never_finish():
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
coro = _never_finish()
|
||||
with pytest.raises(TimeoutError):
|
||||
internal_bypasser._CDP_WORKER.run(coro, timeout=0.05)
|
||||
|
||||
assert started.is_set(), "coroutine should have been scheduled before timing out"
|
||||
|
||||
|
||||
def test_run_bypass_in_current_process_bounds_its_wait(monkeypatch):
|
||||
"""The in-process path passes a deadline rather than waiting forever."""
|
||||
import shelfmark.bypass.internal_bypasser as internal_bypasser
|
||||
|
||||
observed: dict[str, float | None] = {}
|
||||
|
||||
class _FakeWorker:
|
||||
def run(self, coro, timeout=None):
|
||||
observed["timeout"] = timeout
|
||||
coro.close()
|
||||
return "html"
|
||||
|
||||
monkeypatch.setattr(internal_bypasser, "_CDP_WORKER", _FakeWorker())
|
||||
monkeypatch.delenv("SHELFMARK_INTERNAL_BYPASSER_CHILD", raising=False)
|
||||
|
||||
result = internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
|
||||
|
||||
assert result == "html"
|
||||
assert observed["timeout"] == internal_bypasser._IN_PROCESS_BYPASS_TIMEOUT_SECONDS
|
||||
|
||||
@@ -8,6 +8,7 @@ Run with: uv run pytest tests/config/test_environment.py -v
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -15,6 +16,15 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _restore_env(monkeypatch, name: str, value: str | None) -> None:
|
||||
"""Restore an env var to its pre-test value."""
|
||||
if value is None:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
else:
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Directory Setup Tests
|
||||
# =============================================================================
|
||||
@@ -436,6 +446,7 @@ class TestDebugConfiguration:
|
||||
original_debug = os.environ.get("DEBUG")
|
||||
|
||||
try:
|
||||
monkeypatch.delenv("LOG_LEVEL", raising=False)
|
||||
monkeypatch.setenv("DEBUG", "true")
|
||||
importlib.reload(env_module)
|
||||
assert env_module.DEBUG is True
|
||||
@@ -452,6 +463,76 @@ class TestDebugConfiguration:
|
||||
monkeypatch.setenv("DEBUG", original_debug)
|
||||
importlib.reload(env_module)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("error", "ERROR"),
|
||||
("ERROR", "ERROR"),
|
||||
(" Warning ", "WARNING"),
|
||||
("warn", "WARNING"),
|
||||
("critical", "CRITICAL"),
|
||||
("nonsense", "INFO"),
|
||||
("", "INFO"),
|
||||
(None, "INFO"),
|
||||
],
|
||||
)
|
||||
def test_normalize_log_level(self, raw, expected):
|
||||
"""Log level names are case-insensitive and fall back to INFO."""
|
||||
from shelfmark.config.env import normalize_log_level
|
||||
|
||||
assert normalize_log_level(raw) == expected
|
||||
|
||||
def test_log_level_from_env_var(self, monkeypatch):
|
||||
"""LOG_LEVEL env var should set the app log level when DEBUG is off."""
|
||||
import shelfmark.config.env as env_module
|
||||
|
||||
original_debug = os.environ.get("DEBUG")
|
||||
original_level = os.environ.get("LOG_LEVEL")
|
||||
|
||||
try:
|
||||
monkeypatch.setenv("DEBUG", "false")
|
||||
monkeypatch.setenv("LOG_LEVEL", "error")
|
||||
importlib.reload(env_module)
|
||||
assert env_module.LOG_LEVEL == "ERROR"
|
||||
|
||||
# DEBUG wins over LOG_LEVEL, matching entrypoint.sh.
|
||||
monkeypatch.setenv("DEBUG", "true")
|
||||
importlib.reload(env_module)
|
||||
assert env_module.LOG_LEVEL == "DEBUG"
|
||||
finally:
|
||||
_restore_env(monkeypatch, "DEBUG", original_debug)
|
||||
_restore_env(monkeypatch, "LOG_LEVEL", original_level)
|
||||
importlib.reload(env_module)
|
||||
|
||||
def test_log_level_from_config_file(self, monkeypatch, tmp_path):
|
||||
"""LOG_LEVEL should fall back to the advanced settings file."""
|
||||
import shelfmark.config.env as env_module
|
||||
|
||||
original_debug = os.environ.get("DEBUG")
|
||||
original_level = os.environ.get("LOG_LEVEL")
|
||||
original_config_dir = os.environ.get("CONFIG_DIR")
|
||||
|
||||
advanced = tmp_path / "plugins" / "advanced.json"
|
||||
advanced.parent.mkdir(parents=True)
|
||||
advanced.write_text(json.dumps({"LOG_LEVEL": "WARNING"}))
|
||||
|
||||
try:
|
||||
monkeypatch.setenv("DEBUG", "false")
|
||||
monkeypatch.delenv("LOG_LEVEL", raising=False)
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
importlib.reload(env_module)
|
||||
assert env_module.LOG_LEVEL == "WARNING"
|
||||
|
||||
# Env var takes precedence over the stored setting.
|
||||
monkeypatch.setenv("LOG_LEVEL", "critical")
|
||||
importlib.reload(env_module)
|
||||
assert env_module.LOG_LEVEL == "CRITICAL"
|
||||
finally:
|
||||
_restore_env(monkeypatch, "DEBUG", original_debug)
|
||||
_restore_env(monkeypatch, "LOG_LEVEL", original_level)
|
||||
_restore_env(monkeypatch, "CONFIG_DIR", original_config_dir)
|
||||
importlib.reload(env_module)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Proxy and Network Configuration Tests
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ _BOOTSTRAP_ENV_ACCESS_ALLOWLIST = {
|
||||
}
|
||||
_BOOTSTRAP_ENV_ACCESS_KEY_ALLOWLIST = {
|
||||
(Path("shelfmark/config/settings.py"), "USING_TOR"),
|
||||
# Loggers are configured while settings_registry itself is still importing,
|
||||
# so the level has to come from the bootstrap env module.
|
||||
(Path("shelfmark/core/logger.py"), "LOG_LEVEL"),
|
||||
}
|
||||
_RAW_CONFIG_READ_ALLOWLIST = {
|
||||
Path("shelfmark/config/notifications_settings.py"),
|
||||
@@ -205,7 +208,10 @@ class ConfigAccessVisitor(ast.NodeVisitor):
|
||||
return
|
||||
for alias in node.names:
|
||||
imported_name = alias.name
|
||||
if imported_name in self.registered_keys:
|
||||
if (
|
||||
imported_name in self.registered_keys
|
||||
and imported_name not in self._bootstrap_env_key_allowlist
|
||||
):
|
||||
self._record_violation(
|
||||
node,
|
||||
"direct env-module import",
|
||||
|
||||
@@ -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
|
||||
@@ -2,16 +2,27 @@ import errno
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.models import DownloadTask, SearchMode
|
||||
from shelfmark.download import fs
|
||||
from shelfmark.download.postprocess.pipeline import collect_directory_files, validate_destination
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_delete_denied():
|
||||
fs._DELETE_DENIED_DIRS.clear()
|
||||
yield
|
||||
fs._DELETE_DENIED_DIRS.clear()
|
||||
|
||||
|
||||
def test_validate_destination_success_cleans_up_probe(tmp_path):
|
||||
destination = tmp_path / "dest"
|
||||
status_cb = MagicMock()
|
||||
|
||||
assert validate_destination(destination, status_cb) is True
|
||||
assert list(destination.glob(".shelfmark_write_test_*")) == []
|
||||
assert list(destination.glob(".shelfmark_write_test*")) == []
|
||||
assert fs.is_delete_denied(destination) is False
|
||||
|
||||
|
||||
def test_validate_destination_write_probe_permission_error(tmp_path):
|
||||
@@ -22,7 +33,7 @@ def test_validate_destination_write_probe_permission_error(tmp_path):
|
||||
real_write_text = Path.write_text
|
||||
|
||||
def fake_write_text(self, data, *args, **kwargs):
|
||||
if ".shelfmark_write_test_" in self.name:
|
||||
if ".shelfmark_write_test" in self.name:
|
||||
raise PermissionError(errno.EACCES, "Permission denied", str(self))
|
||||
return real_write_text(self, data, *args, **kwargs)
|
||||
|
||||
@@ -34,6 +45,37 @@ def test_validate_destination_write_probe_permission_error(tmp_path):
|
||||
assert "Destination not writable" in status_cb.call_args[0][1]
|
||||
|
||||
|
||||
def test_validate_destination_accepts_writable_but_undeletable_destination(tmp_path):
|
||||
"""Synology-style share: creating files is allowed, deleting them is not."""
|
||||
destination = tmp_path / "dest"
|
||||
destination.mkdir()
|
||||
status_cb = MagicMock()
|
||||
|
||||
real_unlink = Path.unlink
|
||||
|
||||
def fake_unlink(self, *args, **kwargs):
|
||||
if ".shelfmark_write_test" in self.name:
|
||||
raise PermissionError(errno.EACCES, "Permission denied", str(self))
|
||||
return real_unlink(self, *args, **kwargs)
|
||||
|
||||
with patch("pathlib.Path.unlink", new=fake_unlink):
|
||||
assert validate_destination(destination, status_cb) is True
|
||||
|
||||
# Not fatal: no error is surfaced, and the destination is flagged so
|
||||
# transfers write in place instead of publishing via rename.
|
||||
assert not any(call[0][0] == "error" for call in status_cb.call_args_list)
|
||||
assert fs.is_delete_denied(destination) is True
|
||||
|
||||
|
||||
def test_validate_destination_clears_stale_delete_denial(tmp_path):
|
||||
destination = tmp_path / "dest"
|
||||
destination.mkdir()
|
||||
fs.mark_delete_denied(destination)
|
||||
|
||||
assert validate_destination(destination, MagicMock()) is True
|
||||
assert fs.is_delete_denied(destination) is False
|
||||
|
||||
|
||||
def test_collect_directory_files_ignores_permission_errors(tmp_path):
|
||||
directory = tmp_path / "download"
|
||||
directory.mkdir()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import importlib
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_module():
|
||||
with patch("shelfmark.download.orchestrator.start"):
|
||||
import shelfmark.main as main
|
||||
|
||||
importlib.reload(main)
|
||||
return main
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers",
|
||||
[
|
||||
{
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": "library.example.com:12345",
|
||||
},
|
||||
{
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": "library.example.com",
|
||||
"X-Forwarded-Port": "12345",
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_oidc_redirect_uses_forwarded_external_port(main_module, headers):
|
||||
oidc_client = Mock()
|
||||
oidc_client.authorize_redirect.return_value = ("", 302)
|
||||
|
||||
with patch(
|
||||
"shelfmark.core.oidc_routes._get_oidc_client",
|
||||
return_value=(oidc_client, {}),
|
||||
):
|
||||
response = main_module.app.test_client().get(
|
||||
"/api/auth/oidc/login",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
oidc_client.authorize_redirect.assert_called_once_with(
|
||||
"https://library.example.com:12345/api/auth/oidc/callback"
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests for the stall-detection activity grace signalling."""
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.models import QueueStatus
|
||||
from shelfmark.download.activity import (
|
||||
ACTIVITY_GRACE_STATUS,
|
||||
parse_activity_grace,
|
||||
release_activity_grace,
|
||||
request_activity_grace,
|
||||
)
|
||||
|
||||
|
||||
def test_request_and_parse_round_trip():
|
||||
calls: list[tuple[str, str | None]] = []
|
||||
request_activity_grace(lambda status, message: calls.append((status, message)), 330)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert parse_activity_grace(*calls[0]) == 330.0
|
||||
|
||||
|
||||
def test_release_emits_zero_grace():
|
||||
calls: list[tuple[str, str | None]] = []
|
||||
release_activity_grace(lambda status, message: calls.append((status, message)))
|
||||
|
||||
assert parse_activity_grace(*calls[0]) == 0.0
|
||||
|
||||
|
||||
def test_parse_returns_none_for_real_status_events():
|
||||
assert parse_activity_grace("resolving", "Bypassing protection...") is None
|
||||
assert parse_activity_grace("downloading", None) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [s.value for s in QueueStatus])
|
||||
def test_sentinel_cannot_collide_with_a_queue_status(status):
|
||||
"""The sentinel must never be mistaken for a real status, or vice versa."""
|
||||
assert status != ACTIVITY_GRACE_STATUS
|
||||
assert parse_activity_grace(status, "anything") is None
|
||||
|
||||
|
||||
def test_parse_tolerates_a_malformed_sentinel():
|
||||
"""A bad emitter must not take down the status pipeline."""
|
||||
assert parse_activity_grace(ACTIVITY_GRACE_STATUS, "not-a-number") == 0.0
|
||||
assert parse_activity_grace(ACTIVITY_GRACE_STATUS, None) == 0.0
|
||||
|
||||
|
||||
def test_parse_clamps_negative_grace():
|
||||
assert parse_activity_grace(ACTIVITY_GRACE_STATUS, "-5") == 0.0
|
||||
|
||||
|
||||
def test_emitters_are_noops_without_a_callback():
|
||||
request_activity_grace(None, 30)
|
||||
release_activity_grace(None)
|
||||
|
||||
|
||||
def test_emitters_swallow_a_raising_callback():
|
||||
def boom(_status: str, _message: str | None) -> None:
|
||||
raise RuntimeError("callback failed")
|
||||
|
||||
request_activity_grace(boom, 30)
|
||||
release_activity_grace(boom)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Transfers into destinations that accept writes but refuse deletes.
|
||||
|
||||
Reproduces the Synology case from issue #1174: a share where "Delete subfolders
|
||||
and files" is unticked. Creating files is allowed, but `os.replace()` cannot
|
||||
publish a temp file into place because renaming removes a directory entry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.download import fs
|
||||
from shelfmark.download.fs import atomic_copy, atomic_move
|
||||
|
||||
CONTENT = b"a book" * 1024
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_delete_denied():
|
||||
fs._DELETE_DENIED_DIRS.clear()
|
||||
yield
|
||||
fs._DELETE_DENIED_DIRS.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source(tmp_path: Path) -> Path:
|
||||
src = tmp_path / "tmp_dir" / "staged.epub"
|
||||
src.parent.mkdir()
|
||||
src.write_bytes(CONTENT)
|
||||
return src
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library(tmp_path: Path) -> Path:
|
||||
lib = tmp_path / "library"
|
||||
lib.mkdir()
|
||||
return lib
|
||||
|
||||
|
||||
def _leftover_temps(directory: Path) -> list[Path]:
|
||||
return list(directory.glob(".shelfmark.*"))
|
||||
|
||||
|
||||
def _deny_replace(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Make os.replace fail the way a no-delete share does."""
|
||||
|
||||
def fake_replace(src, dst, *args, **kwargs):
|
||||
raise PermissionError(errno.EACCES, "Permission denied", str(dst))
|
||||
|
||||
monkeypatch.setattr(fs.os, "replace", fake_replace)
|
||||
|
||||
|
||||
def test_copy_writes_in_place_when_destination_is_known_undeletable(
|
||||
source: Path, library: Path
|
||||
) -> None:
|
||||
fs.mark_delete_denied(library)
|
||||
|
||||
final_path = atomic_copy(source, library / "book.epub")
|
||||
|
||||
assert final_path == library / "book.epub"
|
||||
assert final_path.read_bytes() == CONTENT
|
||||
# The temp file is skipped entirely, so nothing is stranded in the library.
|
||||
assert _leftover_temps(library) == []
|
||||
assert source.exists()
|
||||
|
||||
|
||||
def test_copy_falls_back_when_rename_is_refused(
|
||||
source: Path, library: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Cold cache: the denial is discovered at publish time and recovered from."""
|
||||
_deny_replace(monkeypatch)
|
||||
|
||||
final_path = atomic_copy(source, library / "book.epub")
|
||||
|
||||
assert final_path.read_bytes() == CONTENT
|
||||
assert _leftover_temps(library) == []
|
||||
# The destination is remembered so later transfers skip the temp file.
|
||||
assert fs.is_delete_denied(library) is True
|
||||
|
||||
|
||||
def test_move_falls_back_to_copy_and_removes_source(source: Path, library: Path) -> None:
|
||||
fs.mark_delete_denied(library)
|
||||
|
||||
final_path = atomic_move(source, library / "book.epub")
|
||||
|
||||
assert final_path.read_bytes() == CONTENT
|
||||
assert not source.exists()
|
||||
assert _leftover_temps(library) == []
|
||||
|
||||
|
||||
def test_move_falls_back_when_rename_is_refused(
|
||||
source: Path, library: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
real_rename = fs.os.rename
|
||||
|
||||
def fake_rename(src, dst, *args, **kwargs):
|
||||
if str(dst).startswith(str(library)):
|
||||
raise PermissionError(errno.EACCES, "Permission denied", str(dst))
|
||||
return real_rename(src, dst, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(fs.os, "rename", fake_rename)
|
||||
_deny_replace(monkeypatch)
|
||||
|
||||
final_path = atomic_move(source, library / "book.epub")
|
||||
|
||||
assert final_path.read_bytes() == CONTENT
|
||||
assert not source.exists()
|
||||
assert fs.is_delete_denied(library) is True
|
||||
|
||||
|
||||
def test_in_place_copy_still_resolves_collisions(source: Path, library: Path) -> None:
|
||||
fs.mark_delete_denied(library)
|
||||
(library / "book.epub").write_bytes(b"existing")
|
||||
|
||||
final_path = atomic_copy(source, library / "book.epub")
|
||||
|
||||
assert final_path == library / "book_1.epub"
|
||||
assert final_path.read_bytes() == CONTENT
|
||||
# The pre-existing file is never overwritten.
|
||||
assert (library / "book.epub").read_bytes() == b"existing"
|
||||
|
||||
|
||||
def test_denial_applies_to_subdirectories(source: Path, library: Path) -> None:
|
||||
"""`organize` mode writes into per-author subfolders under the library root."""
|
||||
fs.mark_delete_denied(library)
|
||||
nested = library / "Frank Herbert" / "Dune"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
assert fs.is_delete_denied(nested) is True
|
||||
|
||||
final_path = atomic_copy(source, nested / "book.epub")
|
||||
|
||||
assert final_path.read_bytes() == CONTENT
|
||||
assert _leftover_temps(nested) == []
|
||||
|
||||
|
||||
def test_clearing_a_denial_also_clears_subdirectories(library: Path) -> None:
|
||||
nested = library / "Frank Herbert"
|
||||
fs.mark_delete_denied(library)
|
||||
fs.mark_delete_denied(nested)
|
||||
|
||||
fs.clear_delete_denied(library)
|
||||
|
||||
assert fs.is_delete_denied(library) is False
|
||||
assert fs.is_delete_denied(nested) is False
|
||||
|
||||
|
||||
def test_normal_destination_still_publishes_atomically(
|
||||
source: Path, library: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Regression guard: unaffected shares keep the temp-file + rename path."""
|
||||
replaced: list[tuple[str, str]] = []
|
||||
real_replace = os.replace
|
||||
|
||||
def spy_replace(src, dst, *args, **kwargs):
|
||||
replaced.append((str(src), str(dst)))
|
||||
return real_replace(src, dst, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(fs.os, "replace", spy_replace)
|
||||
|
||||
final_path = atomic_copy(source, library / "book.epub")
|
||||
|
||||
assert final_path.read_bytes() == CONTENT
|
||||
assert len(replaced) == 1
|
||||
assert Path(replaced[0][0]).name.startswith(".shelfmark.")
|
||||
assert fs.is_delete_denied(library) is False
|
||||
@@ -11,22 +11,13 @@ class _FakeResponse:
|
||||
self.url = url
|
||||
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._target = kwargs["target"]
|
||||
|
||||
def start(self) -> None:
|
||||
self._target()
|
||||
|
||||
def join(self, timeout: float | None = None) -> None:
|
||||
del timeout
|
||||
|
||||
|
||||
def test_html_get_page_ignores_heartbeat_callback_failure(monkeypatch):
|
||||
def test_html_get_page_ignores_status_callback_failure(monkeypatch):
|
||||
"""A raising status_callback must not break the bypass it was reporting on."""
|
||||
import shelfmark.download.http as http
|
||||
from shelfmark.download.activity import ACTIVITY_GRACE_STATUS
|
||||
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
monkeypatch.setattr(http, "Thread", _ImmediateThread)
|
||||
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 330.0)
|
||||
monkeypatch.setattr(http, "get_bypassed_page", lambda *_args, **_kwargs: "OK")
|
||||
|
||||
calls: list[tuple[str, str | None]] = []
|
||||
@@ -46,10 +37,92 @@ def test_html_get_page_ignores_heartbeat_callback_failure(monkeypatch):
|
||||
assert html == "OK"
|
||||
assert calls == [
|
||||
("resolving", "Bypassing protection..."),
|
||||
("resolving", "Bypassing protection..."),
|
||||
(ACTIVITY_GRACE_STATUS, "330.0"),
|
||||
(ACTIVITY_GRACE_STATUS, "0.0"),
|
||||
]
|
||||
|
||||
|
||||
def test_html_get_page_requests_and_releases_activity_grace(monkeypatch):
|
||||
"""The bypass declares its budget before blocking and releases it afterwards."""
|
||||
import shelfmark.download.http as http
|
||||
from shelfmark.download.activity import ACTIVITY_GRACE_STATUS
|
||||
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 424.0)
|
||||
|
||||
calls: list[tuple[str, str | None]] = []
|
||||
|
||||
def fake_bypass(*_args, **_kwargs):
|
||||
# The grace must already be in place before the long blocking call starts.
|
||||
assert calls[-1] == (ACTIVITY_GRACE_STATUS, "424.0")
|
||||
return "OK"
|
||||
|
||||
monkeypatch.setattr(http, "get_bypassed_page", fake_bypass)
|
||||
|
||||
html = http.html_get_page(
|
||||
"https://example.com",
|
||||
retry=1,
|
||||
use_bypasser=True,
|
||||
status_callback=lambda status, message: calls.append((status, message)),
|
||||
)
|
||||
|
||||
assert html == "OK"
|
||||
assert calls[-1] == (ACTIVITY_GRACE_STATUS, "0.0")
|
||||
|
||||
|
||||
def test_html_get_page_releases_grace_and_reports_error_when_bypasser_fails(monkeypatch):
|
||||
"""A failing bypasser surfaces its real error instead of a silent empty result."""
|
||||
import shelfmark.download.http as http
|
||||
from shelfmark.download.activity import ACTIVITY_GRACE_STATUS
|
||||
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 100.0)
|
||||
|
||||
def failing_bypasser(*_args, **_kwargs):
|
||||
raise requests.exceptions.RequestException("500 Server Error")
|
||||
|
||||
monkeypatch.setattr(http, "get_bypassed_page", failing_bypasser)
|
||||
|
||||
calls: list[tuple[str, str | None]] = []
|
||||
html = http.html_get_page(
|
||||
"https://example.com",
|
||||
retry=1,
|
||||
use_bypasser=True,
|
||||
status_callback=lambda status, message: calls.append((status, message)),
|
||||
)
|
||||
|
||||
assert html == ""
|
||||
errors = [message for status, message in calls if status == "error"]
|
||||
assert len(errors) == 1
|
||||
assert "500 Server Error" in (errors[0] or "")
|
||||
# The grace is always released, even on the failure path.
|
||||
assert calls[-1] == (ACTIVITY_GRACE_STATUS, "0.0")
|
||||
|
||||
|
||||
def test_html_get_page_does_not_report_error_when_bypass_is_cancelled(monkeypatch):
|
||||
"""Cancellation is a user action, not a failure worth surfacing as an error."""
|
||||
import shelfmark.download.http as http
|
||||
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 100.0)
|
||||
|
||||
def cancelled_bypasser(*_args, **_kwargs):
|
||||
raise BypassCancelledError("Bypass cancelled")
|
||||
|
||||
monkeypatch.setattr(http, "get_bypassed_page", cancelled_bypasser)
|
||||
|
||||
calls: list[tuple[str, str | None]] = []
|
||||
html = http.html_get_page(
|
||||
"https://example.com",
|
||||
retry=1,
|
||||
use_bypasser=True,
|
||||
status_callback=lambda status, message: calls.append((status, message)),
|
||||
)
|
||||
|
||||
assert html == ""
|
||||
assert [status for status, _message in calls if status == "error"] == []
|
||||
|
||||
|
||||
def test_html_get_page_returns_empty_on_bypass_cancellation(monkeypatch):
|
||||
import shelfmark.download.http as http
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests for the Z-Library 503 cookie handshake."""
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Minimal stand-in for requests.Response covering what html_get_page touches."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
*,
|
||||
url: str = "https://z-lib.fm/md5/abc",
|
||||
text: str = "",
|
||||
cookies: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self.url = url
|
||||
self.text = text
|
||||
self.cookies = cookies or {}
|
||||
self.headers = {"Content-Type": "text/html;charset=utf-8"}
|
||||
self.is_redirect = False
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
error = requests.exceptions.HTTPError(f"{self.status_code} Error")
|
||||
error.response = self
|
||||
raise error
|
||||
|
||||
|
||||
def _neutralize_network(monkeypatch, http):
|
||||
monkeypatch.setattr(http, "_apply_cf_bypass", lambda _url, _headers: {})
|
||||
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
|
||||
monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True)
|
||||
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: False)
|
||||
monkeypatch.setattr(http.time, "sleep", lambda _seconds: None)
|
||||
|
||||
|
||||
def test_html_get_page_echoes_503_cookie(monkeypatch):
|
||||
"""A 503 that only sets a cookie is cleared by sending that cookie back."""
|
||||
import shelfmark.download.http as http
|
||||
|
||||
_neutralize_network(monkeypatch, http)
|
||||
|
||||
sent_cookies: list[dict[str, str]] = []
|
||||
|
||||
def fake_get(_url: str, **kwargs):
|
||||
sent_cookies.append(dict(kwargs["cookies"]))
|
||||
if len(sent_cookies) == 1:
|
||||
return _FakeResponse(503, cookies={"zlib_sid": "s3cr3t"})
|
||||
return _FakeResponse(200, text="<html>real page</html>")
|
||||
|
||||
monkeypatch.setattr(http.requests, "get", fake_get)
|
||||
|
||||
html = http.html_get_page("https://z-lib.fm/md5/abc", retry=3, success_delay=0)
|
||||
|
||||
assert html == "<html>real page</html>"
|
||||
# The first hit carries nothing; the retry echoes back exactly what the 503 issued.
|
||||
assert sent_cookies == [{}, {"zlib_sid": "s3cr3t"}]
|
||||
|
||||
|
||||
def test_html_get_page_stops_echoing_when_cookie_is_reissued(monkeypatch):
|
||||
"""A server repeating the same cookie must not spin the request loop forever."""
|
||||
import shelfmark.download.http as http
|
||||
|
||||
_neutralize_network(monkeypatch, http)
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: False)
|
||||
|
||||
sent_cookies: list[dict[str, str]] = []
|
||||
|
||||
def fake_get(_url: str, **kwargs):
|
||||
sent_cookies.append(dict(kwargs["cookies"]))
|
||||
return _FakeResponse(503, cookies={"zlib_sid": "same"})
|
||||
|
||||
monkeypatch.setattr(http.requests, "get", fake_get)
|
||||
|
||||
html = http.html_get_page("https://z-lib.fm/md5/abc", retry=1, success_delay=0)
|
||||
|
||||
assert html == ""
|
||||
# One initial hit plus one echo; the reissued identical cookie yields no third request.
|
||||
assert sent_cookies == [{}, {"zlib_sid": "same"}]
|
||||
|
||||
|
||||
def test_html_get_page_leaves_cookieless_503_on_the_retry_path(monkeypatch):
|
||||
"""A plain overloaded-server 503 keeps its existing retry behaviour."""
|
||||
import shelfmark.download.http as http
|
||||
|
||||
_neutralize_network(monkeypatch, http)
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: False)
|
||||
|
||||
attempts: list[dict[str, str]] = []
|
||||
|
||||
def fake_get(_url: str, **kwargs):
|
||||
attempts.append(dict(kwargs["cookies"]))
|
||||
return _FakeResponse(503)
|
||||
|
||||
monkeypatch.setattr(http.requests, "get", fake_get)
|
||||
|
||||
html = http.html_get_page("https://z-lib.fm/md5/abc", retry=2, success_delay=0)
|
||||
|
||||
assert html == ""
|
||||
# Two ordinary attempts, no extra in-place retry and no cookies invented.
|
||||
assert attempts == [{}, {}]
|
||||
@@ -9,6 +9,10 @@ class _StopLoop(BaseException):
|
||||
"""Sentinel used to stop the infinite coordinator loop during tests."""
|
||||
|
||||
|
||||
class _UnexpectedCall(BaseException):
|
||||
"""Guard-rail failure that the coordinator's `except Exception` must not swallow."""
|
||||
|
||||
|
||||
class _FakeExecutor:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.args = args
|
||||
@@ -21,7 +25,7 @@ class _FakeExecutor:
|
||||
return False
|
||||
|
||||
def submit(self, *args, **kwargs): # pragma: no cover - not expected in these tests
|
||||
raise AssertionError("submit() should not be called in this test")
|
||||
raise _UnexpectedCall("submit() should not be called in this test")
|
||||
|
||||
|
||||
class _StopCoordinator(BaseException):
|
||||
@@ -68,6 +72,69 @@ def test_concurrent_download_loop_logs_and_recovers_after_loop_error(monkeypatch
|
||||
]
|
||||
|
||||
|
||||
def test_concurrent_download_loop_survives_exceptions_outside_the_legacy_list(monkeypatch):
|
||||
"""Regression for #823/#1166: the coordinator must not die on an unlisted exception.
|
||||
|
||||
`gevent.exceptions.LoopExit` and `StopIteration` are Exceptions that the old narrow
|
||||
except tuple let through, silently killing the only thread that drives the queue.
|
||||
"""
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
raised: list[type[BaseException]] = []
|
||||
escapes = [StopIteration, ZeroDivisionError, KeyboardInterrupt]
|
||||
|
||||
def fake_get_next():
|
||||
if escapes:
|
||||
exc = escapes.pop(0)
|
||||
if exc is KeyboardInterrupt:
|
||||
# BaseException: must still propagate and stop the loop.
|
||||
raise KeyboardInterrupt
|
||||
raised.append(exc)
|
||||
raise exc("boom")
|
||||
return None
|
||||
|
||||
mock_queue = MagicMock()
|
||||
mock_queue.get_next.side_effect = fake_get_next
|
||||
|
||||
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
|
||||
monkeypatch.setattr(orchestrator, "ThreadPoolExecutor", _FakeExecutor)
|
||||
monkeypatch.setattr(orchestrator.time, "sleep", lambda _delay: None)
|
||||
monkeypatch.setattr(orchestrator.logger, "error_trace", MagicMock())
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
orchestrator.concurrent_download_loop()
|
||||
|
||||
assert raised == [StopIteration, ZeroDivisionError]
|
||||
|
||||
|
||||
def test_concurrent_download_loop_backs_off_on_repeated_errors(monkeypatch):
|
||||
"""A persistent failure must not spin the loop at 1Hz forever."""
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
sleep_delays: list[float] = []
|
||||
|
||||
def fake_sleep(delay: float) -> None:
|
||||
sleep_delays.append(delay)
|
||||
if len(sleep_delays) >= 4:
|
||||
raise _StopLoop()
|
||||
|
||||
mock_queue = MagicMock()
|
||||
mock_queue.get_next.side_effect = RuntimeError("persistent boom")
|
||||
|
||||
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
|
||||
monkeypatch.setattr(orchestrator, "ThreadPoolExecutor", _FakeExecutor)
|
||||
monkeypatch.setattr(orchestrator.time, "sleep", fake_sleep)
|
||||
monkeypatch.setattr(orchestrator.logger, "error_trace", MagicMock())
|
||||
|
||||
with pytest.raises(_StopLoop):
|
||||
orchestrator.concurrent_download_loop()
|
||||
|
||||
base = orchestrator.COORDINATOR_LOOP_ERROR_RETRY_DELAY
|
||||
# First delay stays unchanged for a normal transient blip, then doubles.
|
||||
assert sleep_delays == [base, base * 2, base * 4, base * 8]
|
||||
assert max(sleep_delays) <= orchestrator._COORDINATOR_LOOP_ERROR_MAX_DELAY
|
||||
|
||||
|
||||
def test_concurrent_download_loop_recovers_and_processes_task_after_transient_loop_error(
|
||||
monkeypatch,
|
||||
):
|
||||
@@ -91,13 +158,16 @@ def test_concurrent_download_loop_recovers_and_processes_task_after_transient_lo
|
||||
raise _StopCoordinator()
|
||||
return None
|
||||
|
||||
# These raise _UnexpectedCall rather than AssertionError because the coordinator
|
||||
# loop catches Exception: an AssertionError here would be swallowed and logged,
|
||||
# letting the test pass vacuously instead of reporting the unexpected call.
|
||||
def cancel_download(self, task_id: str) -> None: # pragma: no cover - unused
|
||||
raise AssertionError(f"cancel_download unexpectedly called for {task_id}")
|
||||
raise _UnexpectedCall(f"cancel_download unexpectedly called for {task_id}")
|
||||
|
||||
def update_status_message(
|
||||
self, task_id: str, message: str
|
||||
) -> None: # pragma: no cover - unused
|
||||
raise AssertionError(
|
||||
raise _UnexpectedCall(
|
||||
f"update_status_message unexpectedly called for {task_id}: {message}"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Tests for stall detection and the activity-grace window.
|
||||
|
||||
Covers the regression behind issue #1001: a protection bypass is a single long blocking
|
||||
call that emits no changing status or progress, so it used to be cancelled at exactly
|
||||
STALL_TIMEOUT even though the bypasser itself was still working.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def _stub_ext_bypasser_timeout(monkeypatch, external_bypasser, value: int) -> None:
|
||||
"""Override only EXT_BYPASSER_TIMEOUT.
|
||||
|
||||
`external_bypasser.config` is the shared config singleton, so a blanket `get` stub
|
||||
also answers unrelated lookups (DNS setup, etc.) with the wrong value.
|
||||
"""
|
||||
real_get = external_bypasser.config.get
|
||||
monkeypatch.setattr(
|
||||
external_bypasser.config,
|
||||
"get",
|
||||
lambda key, default="": value if key == "EXT_BYPASSER_TIMEOUT" else real_get(key, default),
|
||||
)
|
||||
|
||||
|
||||
def _reset(orchestrator) -> None:
|
||||
orchestrator._last_activity.clear()
|
||||
orchestrator._last_progress_value.clear()
|
||||
orchestrator._last_status_event.clear()
|
||||
orchestrator._activity_grace.clear()
|
||||
|
||||
|
||||
def test_find_stalled_tasks_flags_task_past_stall_timeout():
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
_reset(orchestrator)
|
||||
orchestrator._last_activity["book"] = 1000.0
|
||||
now = 1000.0 + orchestrator.STALL_TIMEOUT + 1
|
||||
|
||||
assert orchestrator._find_stalled_tasks(["book"], now) == ["book"]
|
||||
|
||||
|
||||
def test_find_stalled_tasks_ignores_task_within_stall_timeout():
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
_reset(orchestrator)
|
||||
orchestrator._last_activity["book"] = 1000.0
|
||||
now = 1000.0 + orchestrator.STALL_TIMEOUT - 1
|
||||
|
||||
assert orchestrator._find_stalled_tasks(["book"], now) == []
|
||||
|
||||
|
||||
def test_find_stalled_tasks_ignores_unknown_task():
|
||||
"""A task with no recorded activity yet is not considered stalled."""
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
_reset(orchestrator)
|
||||
|
||||
assert orchestrator._find_stalled_tasks(["never-seen"], 99999.0) == []
|
||||
|
||||
|
||||
def test_activity_grace_suppresses_stall_until_its_deadline(monkeypatch):
|
||||
"""A bypass that legitimately outlives STALL_TIMEOUT is not cancelled."""
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
_reset(orchestrator)
|
||||
monkeypatch.setattr(orchestrator.time, "time", lambda: 1000.0)
|
||||
orchestrator._last_activity["book"] = 1000.0
|
||||
orchestrator.set_activity_grace("book", 400.0)
|
||||
|
||||
# Well past STALL_TIMEOUT, but inside the declared budget.
|
||||
assert orchestrator._find_stalled_tasks(["book"], 1390.0) == []
|
||||
|
||||
|
||||
def test_activity_grace_expires_and_task_is_flagged(monkeypatch):
|
||||
"""A genuinely wedged operation still dies - the grace never extends itself."""
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
_reset(orchestrator)
|
||||
monkeypatch.setattr(orchestrator.time, "time", lambda: 1000.0)
|
||||
orchestrator._last_activity["book"] = 1000.0
|
||||
orchestrator.set_activity_grace("book", 400.0)
|
||||
|
||||
assert orchestrator._find_stalled_tasks(["book"], 1401.0) == ["book"]
|
||||
|
||||
|
||||
def test_set_activity_grace_is_clamped(monkeypatch):
|
||||
"""A caller cannot buy immortality by asking for an absurd grace."""
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
_reset(orchestrator)
|
||||
monkeypatch.setattr(orchestrator.time, "time", lambda: 1000.0)
|
||||
orchestrator.set_activity_grace("book", 10_000_000.0)
|
||||
|
||||
assert orchestrator._activity_grace["book"] == 1000.0 + orchestrator._MAX_ACTIVITY_GRACE_SECONDS
|
||||
|
||||
|
||||
def test_max_activity_grace_covers_the_largest_bypass_budget():
|
||||
"""The clamp must not silently bite the one caller that exists."""
|
||||
import shelfmark.download.http as http
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
from shelfmark.bypass import external_bypasser, internal_bypasser
|
||||
|
||||
largest = (
|
||||
max(
|
||||
external_bypasser.max_duration_seconds(),
|
||||
internal_bypasser.max_duration_seconds(),
|
||||
)
|
||||
+ http._BYPASS_GRACE_SLACK_SECONDS
|
||||
)
|
||||
|
||||
assert largest <= orchestrator._MAX_ACTIVITY_GRACE_SECONDS
|
||||
|
||||
|
||||
def test_set_activity_grace_touches_neither_queue_nor_websocket(monkeypatch):
|
||||
"""A liveness hint is not a user-visible status transition."""
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
_reset(orchestrator)
|
||||
mock_queue = MagicMock()
|
||||
mock_ws = MagicMock()
|
||||
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
|
||||
monkeypatch.setattr(orchestrator, "ws_manager", mock_ws)
|
||||
|
||||
orchestrator.set_activity_grace("book", 100.0)
|
||||
orchestrator.clear_activity_grace("book")
|
||||
|
||||
assert mock_queue.mock_calls == []
|
||||
assert mock_ws.mock_calls == []
|
||||
|
||||
|
||||
def test_clear_activity_grace_refreshes_last_activity(monkeypatch):
|
||||
"""Releasing a grace restarts the normal window rather than stalling instantly."""
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
_reset(orchestrator)
|
||||
orchestrator._last_activity["book"] = 1.0
|
||||
monkeypatch.setattr(orchestrator.time, "time", lambda: 5000.0)
|
||||
|
||||
orchestrator.set_activity_grace("book", 100.0)
|
||||
orchestrator.clear_activity_grace("book")
|
||||
|
||||
assert "book" not in orchestrator._activity_grace
|
||||
assert orchestrator._last_activity["book"] == 5000.0
|
||||
assert orchestrator._find_stalled_tasks(["book"], 5000.0) == []
|
||||
|
||||
|
||||
def test_cleanup_progress_tracking_drops_activity_grace(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
_reset(orchestrator)
|
||||
monkeypatch.setattr(orchestrator, "ws_manager", None)
|
||||
orchestrator.set_activity_grace("book", 100.0)
|
||||
|
||||
orchestrator._cleanup_progress_tracking("book")
|
||||
|
||||
assert "book" not in orchestrator._activity_grace
|
||||
|
||||
|
||||
def test_update_download_status_rejects_the_grace_sentinel(monkeypatch):
|
||||
"""Defence in depth: the sentinel is intercepted upstream, but must be inert here too."""
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
from shelfmark.download.activity import ACTIVITY_GRACE_STATUS
|
||||
|
||||
_reset(orchestrator)
|
||||
mock_queue = MagicMock()
|
||||
mock_ws = MagicMock()
|
||||
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
|
||||
monkeypatch.setattr(orchestrator, "ws_manager", mock_ws)
|
||||
monkeypatch.setattr(orchestrator, "queue_status", lambda: {})
|
||||
|
||||
orchestrator.update_download_status("book", ACTIVITY_GRACE_STATUS, "330.0")
|
||||
|
||||
assert mock_queue.mock_calls == []
|
||||
assert mock_ws.mock_calls == []
|
||||
assert "book" not in orchestrator._last_activity
|
||||
|
||||
|
||||
def test_issue_1001_slow_bypass_is_not_cancelled_at_stall_timeout(monkeypatch):
|
||||
"""End-to-end replay of the issue #1001 timeline.
|
||||
|
||||
From a reporter's debug log: a 403 switched to the external bypasser at 07:04:33, five
|
||||
FlareSolverr attempts each took ~64s and returned HTTP 500, and the watchdog cancelled
|
||||
the download at 07:09:33.987 - exactly STALL_TIMEOUT later and 41s before the bypasser
|
||||
would have finished and reported the real error.
|
||||
|
||||
Now the bypass declares its own budget, so the watchdog holds off and the user sees the
|
||||
actual failure instead of a five-minute silent hang.
|
||||
"""
|
||||
import shelfmark.download.http as http
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
from shelfmark.bypass import external_bypasser
|
||||
from shelfmark.download.activity import parse_activity_grace
|
||||
|
||||
_reset(orchestrator)
|
||||
clock = [1000.0]
|
||||
monkeypatch.setattr(orchestrator.time, "time", lambda: clock[0])
|
||||
|
||||
# Mirror the orchestrator's per-task status_callback closure.
|
||||
events: list[tuple[str, str | None]] = []
|
||||
|
||||
def status_callback(status: str, message: str | None = None) -> None:
|
||||
grace = parse_activity_grace(status, message)
|
||||
if grace is not None:
|
||||
if grace > 0:
|
||||
orchestrator.set_activity_grace("book", grace)
|
||||
else:
|
||||
orchestrator.clear_activity_grace("book")
|
||||
return
|
||||
events.append((status, message))
|
||||
orchestrator._last_activity["book"] = clock[0]
|
||||
|
||||
# The reporter was on external FlareSolverr with the default 60s timeout.
|
||||
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
|
||||
monkeypatch.setattr(http, "_is_using_external_bypasser", lambda: True)
|
||||
monkeypatch.setattr(http, "_get_external_bypasser", lambda: external_bypasser)
|
||||
_stub_ext_bypasser_timeout(monkeypatch, external_bypasser, 60000)
|
||||
|
||||
stall_checks: list[list[str]] = []
|
||||
|
||||
def slow_failing_bypasser(*_args, **_kwargs):
|
||||
# Five attempts at ~64s, then give up - as in the log.
|
||||
for _attempt in range(5):
|
||||
clock[0] += 64.0
|
||||
stall_checks.append(orchestrator._find_stalled_tasks(["book"], clock[0]))
|
||||
raise requests.exceptions.RequestException("500 Server Error")
|
||||
|
||||
monkeypatch.setattr(http, "get_bypassed_page", slow_failing_bypasser)
|
||||
|
||||
html = http.html_get_page(
|
||||
"https://annas-archive.gl/slow_download/abc/0/6",
|
||||
retry=1,
|
||||
use_bypasser=True,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
|
||||
# The bypass ran 320s - past STALL_TIMEOUT - and was never flagged as stalled.
|
||||
assert 320.0 > orchestrator.STALL_TIMEOUT
|
||||
assert stall_checks[-1] == []
|
||||
assert all(check == [] for check in stall_checks)
|
||||
|
||||
# And the real reason reached the user instead of a generic stall message.
|
||||
assert html == ""
|
||||
assert [status for status, _m in events if status == "error"]
|
||||
assert "500 Server Error" in str(events[-1][1])
|
||||
|
||||
|
||||
def test_issue_1001_bypass_overrunning_its_own_budget_is_still_cancelled(monkeypatch):
|
||||
"""The other half: declaring a budget must not make a wedged bypass immortal."""
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
_reset(orchestrator)
|
||||
monkeypatch.setattr(orchestrator.time, "time", lambda: 1000.0)
|
||||
orchestrator._last_activity["book"] = 1000.0
|
||||
|
||||
# External bypasser default budget (394s) plus http's 30s slack.
|
||||
orchestrator.set_activity_grace("book", 424.0)
|
||||
|
||||
assert orchestrator._find_stalled_tasks(["book"], 1000.0 + 424.0) == []
|
||||
assert orchestrator._find_stalled_tasks(["book"], 1000.0 + 425.0) == ["book"]
|
||||
|
||||
|
||||
def test_cancel_stalled_task_does_not_hold_the_progress_lock(monkeypatch):
|
||||
"""Regression guard: cancelling reaches a sqlite write that must not block the hub.
|
||||
|
||||
`book_queue.cancel_download` runs the terminal-status hooks, which are not
|
||||
gevent-patched. Holding `_progress_lock` across that stalls every download worker.
|
||||
"""
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
_reset(orchestrator)
|
||||
observed: list[bool] = []
|
||||
|
||||
def fake_cancel(_task_id: str) -> bool:
|
||||
acquired = orchestrator._progress_lock.acquire(blocking=False)
|
||||
observed.append(acquired)
|
||||
if acquired:
|
||||
orchestrator._progress_lock.release()
|
||||
return True
|
||||
|
||||
mock_queue = MagicMock()
|
||||
mock_queue.cancel_download.side_effect = fake_cancel
|
||||
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
|
||||
|
||||
orchestrator._cancel_stalled_task("book")
|
||||
|
||||
assert observed == [True], "_progress_lock was held while cancelling a stalled task"
|
||||
mock_queue.update_status_message.assert_called_once_with(
|
||||
"book", f"Download stalled (no activity for {orchestrator.STALL_TIMEOUT}s)"
|
||||
)
|
||||
@@ -387,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
|
||||
|
||||
@@ -59,8 +59,17 @@ fi
|
||||
|
||||
echo "==> [$PROFILE] waiting for shelfmark health"
|
||||
HEALTHY=0
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:8084/api/health >/dev/null 2>&1; then HEALTHY=1; break; fi
|
||||
# The curl timeouts are load-bearing, not belt-and-braces. A container that
|
||||
# binds 8084 but never answers (e.g. a broken C-extension wheel wedging the
|
||||
# gunicorn worker) blocks a bare `curl` forever on read, so an iteration-counted
|
||||
# loop never reaches iteration 2 and the wait becomes unbounded — that hung CI
|
||||
# for the full 6h job limit on PR #1169. Bound each probe AND the whole wait.
|
||||
HEALTH_DEADLINE=$((SECONDS + 120))
|
||||
while ((SECONDS < HEALTH_DEADLINE)); do
|
||||
if curl -fsS --connect-timeout 3 --max-time 5 http://localhost:8084/api/health >/dev/null 2>&1; then
|
||||
HEALTHY=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.metadata_providers import MetadataSearchOptions
|
||||
from shelfmark.metadata_providers.hardcover import HardcoverProvider
|
||||
|
||||
# Hardcover answers a rejected search with HTTP 200, no GraphQL errors, and a
|
||||
# null results body. A search that genuinely matched nothing still returns a
|
||||
# results object with found: 0.
|
||||
REJECTED = {"search": {"results": None}}
|
||||
EMPTY = {"search": {"results": {"hits": [], "found": 0}}}
|
||||
ONE_HIT = {"search": {"results": {"hits": [{"document": {"id": 7, "title": "Dune"}}], "found": 1}}}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_sort_fallback(monkeypatch):
|
||||
"""Keep the process-wide sort fallback from leaking between tests."""
|
||||
monkeypatch.setattr("shelfmark.metadata_providers.hardcover._sort_fallback_until", 0.0)
|
||||
|
||||
|
||||
def _reject_sorted(calls: list[dict[str, Any]], *, success=ONE_HIT):
|
||||
"""Build an _execute_query stand-in that rejects any request carrying a sort."""
|
||||
|
||||
def fake_execute(query: str, variables):
|
||||
calls.append(dict(variables))
|
||||
return REJECTED if variables.get("sort") else success
|
||||
|
||||
return fake_execute
|
||||
|
||||
|
||||
class TestHardcoverSortFallback:
|
||||
def test_retries_without_sort_when_hardcover_rejects_the_sort(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(provider, "_execute_query", _reject_sorted(calls))
|
||||
|
||||
result = provider._execute_search_query("query", {"query": "dune", "sort": "relevance"})
|
||||
|
||||
assert result == ONE_HIT
|
||||
assert [call["sort"] for call in calls] == ["relevance", ""]
|
||||
|
||||
def test_treats_an_empty_result_set_as_success(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(
|
||||
provider, "_execute_query", lambda query, variables: calls.append(variables) or EMPTY
|
||||
)
|
||||
|
||||
result = provider._execute_search_query("query", {"query": "dune", "sort": "rating:desc"})
|
||||
|
||||
assert result == EMPTY
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_reports_failure_when_the_retry_is_also_rejected(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(
|
||||
provider, "_execute_query", lambda query, variables: calls.append(variables) or REJECTED
|
||||
)
|
||||
|
||||
result = provider._execute_search_query("query", {"query": "dune", "sort": "rating:desc"})
|
||||
|
||||
assert result is None
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_reports_failure_for_an_unsorted_rejection(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(
|
||||
provider, "_execute_query", lambda query, variables: calls.append(variables) or REJECTED
|
||||
)
|
||||
|
||||
result = provider._execute_search_query("query", {"query": "dune", "sort": ""})
|
||||
|
||||
assert result is None
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_skips_the_doomed_request_on_later_searches(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(provider, "_execute_query", _reject_sorted(calls))
|
||||
|
||||
provider._execute_search_query("query", {"query": "dune", "sort": "relevance"})
|
||||
provider._execute_search_query("query", {"query": "hyperion", "sort": "relevance"})
|
||||
|
||||
assert [call["sort"] for call in calls] == ["relevance", "", ""]
|
||||
|
||||
def test_search_returns_results_despite_a_rejected_sort(self, monkeypatch):
|
||||
provider = HardcoverProvider(api_key="test-token")
|
||||
calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(provider, "_execute_query", _reject_sorted(calls))
|
||||
|
||||
result = provider.search_paginated(
|
||||
MetadataSearchOptions(query="dune sort fallback", page=1, limit=25)
|
||||
)
|
||||
|
||||
assert result.total_found == 1
|
||||
assert [book.title for book in result.books] == ["Dune"]
|
||||
assert [call["sort"] for call in calls] == ["_text_match:desc,users_count:desc", ""]
|
||||
|
||||
|
||||
class TestSearchPayloadRejection:
|
||||
def test_distinguishes_a_null_body_from_an_empty_result_set(self):
|
||||
from shelfmark.metadata_providers.hardcover import _search_payload_rejected
|
||||
|
||||
assert _search_payload_rejected(REJECTED) is True
|
||||
assert _search_payload_rejected(EMPTY) is False
|
||||
assert _search_payload_rejected(ONE_HIT) is False
|
||||
assert _search_payload_rejected(None) is False
|
||||
assert _search_payload_rejected({}) is False
|
||||
# Non-search payloads (list lookups, book fetches) must pass through.
|
||||
assert _search_payload_rejected({"series": [{"id": 1}]}) is False
|
||||
@@ -0,0 +1,202 @@
|
||||
import requests
|
||||
|
||||
from shelfmark.core.cache import get_metadata_cache
|
||||
from shelfmark.metadata_providers import MetadataSearchOptions, SearchType
|
||||
from shelfmark.metadata_providers.moly import MolyProvider, _valid_isbn
|
||||
|
||||
SEARCH_HTML = """
|
||||
<html><body>
|
||||
<div id="content">
|
||||
<div class="search_area">
|
||||
<div class="book_with_shop">
|
||||
<a href="/konyvek/mocsidzuki-mai-a-telihold-kavezo">
|
||||
<img alt="Mocsidzuki Mai: A Telihold kávézó" class="tooltip"
|
||||
src="https://assets.moly.hu/system/covers/normal/covers_864003.jpg" />
|
||||
</a>
|
||||
</div>
|
||||
<p>
|
||||
<a class="book_selector" data-id="1"
|
||||
href="/konyvek/mocsidzuki-mai-a-telihold-kavezo">Mocsidzuki Mai:
|
||||
<strong class="highlight">A Telihold</strong> kávézó</a>
|
||||
<span class="like_count">82%</span>
|
||||
<a rel="modal" class="action" href="/sorozatok/a-telihold-kavezo">(A Telihold kávézó 1.)</a>
|
||||
</p>
|
||||
<p>
|
||||
<a class="book_selector" data-id="2"
|
||||
href="/konyvek/mocsidzuki-mai-a-telihold-kavezo">Mocsidzuki Mai: A Telihold kávézó</a>
|
||||
</p>
|
||||
<p>
|
||||
<a class="book_selector" data-id="3"
|
||||
href="/konyvek/mocsidzuki-mai-az-igazi-kivansag">Mocsidzuki Mai: Az igazi kívánság</a>
|
||||
</p>
|
||||
<p>
|
||||
<a class="book_selector" data-id="4"
|
||||
href="/konyvek/lisa-jewell-a-fold-nyelte-el">Lis<strong class="highlight">a</strong> Jew<strong class="highlight">el</strong>l: <strong class="highlight">A föld nyelte el</strong></a>
|
||||
</p>
|
||||
<p><a class="book_selector" data-id="5" href="/konyvek/broken">Not a book row</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
BOOK_HTML = """
|
||||
<html><body>
|
||||
<div id="content">
|
||||
<div class="head_title">
|
||||
<h1><span class="item">A Telihold kávézó
|
||||
<a href="/sorozatok/a-telihold-kavezo">(A Telihold kávézó 1.)</a></span></h1>
|
||||
<div class="rating"><span class="like_count">82%</span></div>
|
||||
</div>
|
||||
<div class="authors"><a href="/alkotok/mocsidzuki-mai">Mocsidzuki Mai</a></div>
|
||||
<div class="coverbox">
|
||||
<a rel="light" class="zoom" href="/system/covers/big/covers_864003.jpg?1712583436"></a>
|
||||
</div>
|
||||
<div id="full_description">
|
||||
<p>Vigyázat! Cselekményleírást tartalmaz.</p>
|
||||
<p>A japánok úgy tartják, hogy ha gondoskodsz egy macskáról,
|
||||
az egy napon meghálálja.</p>
|
||||
</div>
|
||||
<div class="items">
|
||||
<div class="edition">
|
||||
<div><a href="/kiadok/athenaeum">Athenaeum</a>, Budapest, <abbr>2024</abbr></div>
|
||||
<div>180 oldal · ISBN: 9789635434435 · Fordította: Nagy Anita</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="book_tags">
|
||||
<a class="tag" href="/cimkek/asztrologia">asztrológia</a>
|
||||
<a class="tag" href="/cimkek/japan">japán</a>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
class _MolyResponse:
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
|
||||
class _MolySession:
|
||||
"""Serves canned HTML per URL substring."""
|
||||
|
||||
def __init__(self, pages):
|
||||
self.pages = pages
|
||||
self.calls = 0
|
||||
self.headers = {}
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
self.calls += 1
|
||||
for fragment, html in self.pages.items():
|
||||
if fragment in url:
|
||||
return _MolyResponse(html)
|
||||
raise requests.HTTPError(f"unexpected URL: {url}")
|
||||
|
||||
|
||||
class _FlakyMolySession(_MolySession):
|
||||
def get(self, url, **kwargs):
|
||||
if self.calls == 0:
|
||||
self.calls += 1
|
||||
raise requests.Timeout
|
||||
return super().get(url, **kwargs)
|
||||
|
||||
|
||||
def _provider(session):
|
||||
provider = MolyProvider()
|
||||
provider.session = session
|
||||
return provider
|
||||
|
||||
|
||||
class TestMolySearch:
|
||||
def test_search_parses_results(self):
|
||||
get_metadata_cache().clear()
|
||||
provider = _provider(_MolySession({"/kereses": SEARCH_HTML}))
|
||||
|
||||
books = provider.search(MetadataSearchOptions(query="telihold kávézó"))
|
||||
|
||||
assert len(books) == 3 # duplicate slug deduped, colon-less row skipped
|
||||
|
||||
# Mid-word <strong class="highlight"> wrapping must not split words
|
||||
highlighted = books[2]
|
||||
assert highlighted.title == "A föld nyelte el"
|
||||
assert highlighted.authors == ["Lisa Jewell"]
|
||||
book = books[0]
|
||||
assert book.provider == "moly"
|
||||
assert book.provider_id == "mocsidzuki-mai-a-telihold-kavezo"
|
||||
assert book.title == "A Telihold kávézó"
|
||||
assert book.authors == ["Mocsidzuki Mai"]
|
||||
assert book.cover_url == "https://assets.moly.hu/system/covers/normal/covers_864003.jpg"
|
||||
assert book.source_url == "https://moly.hu/konyvek/mocsidzuki-mai-a-telihold-kavezo"
|
||||
labels = {f.label: f.value for f in book.display_fields}
|
||||
assert labels["Rating"] == "82%"
|
||||
assert labels["Series"] == "A Telihold kávézó 1."
|
||||
|
||||
def test_search_second_page_is_empty(self):
|
||||
provider = _provider(_MolySession({"/kereses": SEARCH_HTML}))
|
||||
|
||||
assert provider.search(MetadataSearchOptions(query="telihold", page=2)) == []
|
||||
|
||||
def test_search_does_not_cache_request_failures(self):
|
||||
get_metadata_cache().clear()
|
||||
session = _FlakyMolySession({"/kereses": SEARCH_HTML})
|
||||
provider = _provider(session)
|
||||
options = MetadataSearchOptions(query="telihold kávézó")
|
||||
|
||||
assert provider.search(options) == []
|
||||
|
||||
books = provider.search(options)
|
||||
|
||||
assert session.calls == 2
|
||||
assert [book.provider_id for book in books] == [
|
||||
"mocsidzuki-mai-a-telihold-kavezo",
|
||||
"mocsidzuki-mai-az-igazi-kivansag",
|
||||
"lisa-jewell-a-fold-nyelte-el",
|
||||
]
|
||||
|
||||
|
||||
class TestMolyGetBook:
|
||||
def test_get_book_parses_details(self):
|
||||
get_metadata_cache().clear()
|
||||
provider = _provider(_MolySession({"/konyvek/": BOOK_HTML}))
|
||||
|
||||
book = provider.get_book("mocsidzuki-mai-a-telihold-kavezo")
|
||||
|
||||
assert book is not None
|
||||
# Zero-width space stripped, nested series link excluded from title
|
||||
assert book.title == "A Telihold kávézó"
|
||||
assert book.authors == ["Mocsidzuki Mai"]
|
||||
assert book.isbn_13 == "9789635434435"
|
||||
assert book.publisher == "Athenaeum"
|
||||
assert book.publish_year == 2024
|
||||
assert book.language == "hu"
|
||||
assert book.cover_url == "https://moly.hu/system/covers/big/covers_864003.jpg?1712583436"
|
||||
assert "asztrológia" in book.genres
|
||||
# Spoiler warning stripped from the description
|
||||
assert book.description is not None
|
||||
assert not book.description.startswith("Vigyázat!")
|
||||
assert "japánok" in book.description
|
||||
|
||||
def test_isbn_search_resolves_first_result(self):
|
||||
get_metadata_cache().clear()
|
||||
provider = _provider(_MolySession({"/kereses": SEARCH_HTML, "/konyvek/": BOOK_HTML}))
|
||||
|
||||
book = provider.search(
|
||||
MetadataSearchOptions(query="9789635434435", search_type=SearchType.ISBN)
|
||||
)[0]
|
||||
|
||||
assert book.provider_id == "mocsidzuki-mai-a-telihold-kavezo"
|
||||
assert book.isbn_13 == "9789635434435"
|
||||
|
||||
|
||||
class TestValidIsbn:
|
||||
def test_isbn_13(self):
|
||||
assert _valid_isbn("978-963-543-443-5") == "9789635434435"
|
||||
|
||||
def test_isbn_10_with_check_x(self):
|
||||
assert _valid_isbn("963-543-443-X") == "963543443X"
|
||||
|
||||
def test_rejects_page_counts_and_years(self):
|
||||
assert _valid_isbn("2024") is None
|
||||
assert _valid_isbn("1234567") is None
|
||||
@@ -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()."""
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from shelfmark.download.clients import (
|
||||
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:
|
||||
@@ -1456,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")
|
||||
@@ -1584,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)
|
||||
|
||||
@@ -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
|
||||
@@ -25,6 +26,8 @@ class MockTorrent:
|
||||
dlspeed=1024000,
|
||||
eta=3600,
|
||||
content_path="/downloads/test.txt",
|
||||
infohash_v1=None,
|
||||
infohash_v2=None,
|
||||
):
|
||||
self.hash = hash_val
|
||||
self.name = name
|
||||
@@ -33,10 +36,12 @@ class MockTorrent:
|
||||
self.dlspeed = dlspeed
|
||||
self.eta = eta
|
||||
self.content_path = content_path
|
||||
self.infohash_v1 = infohash_v1
|
||||
self.infohash_v2 = infohash_v2
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dict for JSON response mocking."""
|
||||
return {
|
||||
result = {
|
||||
"hash": self.hash,
|
||||
"name": self.name,
|
||||
"progress": self.progress,
|
||||
@@ -45,6 +50,11 @@ class MockTorrent:
|
||||
"eta": self.eta,
|
||||
"content_path": self.content_path,
|
||||
}
|
||||
if self.infohash_v1 is not None:
|
||||
result["infohash_v1"] = self.infohash_v1
|
||||
if self.infohash_v2 is not None:
|
||||
result["infohash_v2"] = self.infohash_v2
|
||||
return result
|
||||
|
||||
|
||||
def create_mock_session_response(torrents, status_code=200):
|
||||
@@ -58,6 +68,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 +203,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()."""
|
||||
|
||||
@@ -572,12 +666,9 @@ class TestQBittorrentClientGetStatus:
|
||||
)
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
# hashes query empty -> category list empty -> full list empty
|
||||
mock_client_instance._session.get.side_effect = [
|
||||
create_mock_session_response([], status_code=200),
|
||||
create_mock_session_response([], status_code=200),
|
||||
create_mock_session_response([], status_code=200),
|
||||
]
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
[], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
|
||||
@@ -594,6 +685,39 @@ class TestQBittorrentClientGetStatus:
|
||||
assert status.message is not None
|
||||
assert "not found" in status.message.lower()
|
||||
|
||||
def test_get_status_rejects_unrelated_exact_result(self, monkeypatch):
|
||||
"""Reject an unrelated record when the client ignores the hash filter."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
[MockTorrent(hash_val="different")]
|
||||
)
|
||||
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)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
status = client.get_status("requested")
|
||||
|
||||
assert status.state_value == "error"
|
||||
assert status.message is not None
|
||||
assert "not found" in status.message.lower()
|
||||
|
||||
def test_get_status_stalled(self, monkeypatch):
|
||||
"""Test status for stalled torrent."""
|
||||
config_values = {
|
||||
@@ -716,9 +840,8 @@ class TestQBittorrentClientAddDownload:
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance.torrents_info.return_value = [mock_torrent]
|
||||
# Used by the properties check
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
{}, status_code=200
|
||||
[mock_torrent], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -736,6 +859,107 @@ class TestQBittorrentClientAddDownload:
|
||||
assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
||||
assert mock_client_instance._session.get.call_count >= 1
|
||||
|
||||
@pytest.mark.parametrize("metadata_state", ["metaDL", "forcedMetaDL"])
|
||||
def test_add_waits_for_metadata_and_returns_current_hash(self, monkeypatch, metadata_state):
|
||||
"""Return qBittorrent's current hash after hybrid metadata resolves."""
|
||||
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
|
||||
)
|
||||
|
||||
v1_hash = "edf46c7f938a3c678081734d7bff8b9c652ba5e5"
|
||||
v2_hash = "0bed5f40753b342cb143e83c2b21924cc8474731"
|
||||
full_v2_hash = "0bed5f40753b342cb143e83c2b21924cc847473134e44d1bd300bdc58c13010f"
|
||||
metadata_torrent = MockTorrent(
|
||||
hash_val=v1_hash,
|
||||
state=metadata_state,
|
||||
infohash_v1=v1_hash,
|
||||
)
|
||||
resolved_torrent = MockTorrent(
|
||||
hash_val=v2_hash,
|
||||
state="downloading",
|
||||
infohash_v1=v1_hash,
|
||||
infohash_v2=full_v2_hash,
|
||||
)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance._session.get.side_effect = [
|
||||
create_mock_session_response([metadata_torrent]),
|
||||
create_mock_session_response([]),
|
||||
create_mock_session_response([resolved_torrent]),
|
||||
]
|
||||
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)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
magnet = f"magnet:?xt=urn:btih:{v1_hash}&dn=test"
|
||||
result = client.add_download(magnet, "Test Download", category="audiobooks")
|
||||
|
||||
assert result == v2_hash
|
||||
assert [
|
||||
call.kwargs["params"] for call in mock_client_instance._session.get.call_args_list
|
||||
] == [
|
||||
{"hashes": v1_hash},
|
||||
{"hashes": v1_hash},
|
||||
{"category": "audiobooks"},
|
||||
]
|
||||
|
||||
def test_add_fails_when_metadata_never_resolves(self, monkeypatch):
|
||||
"""Fail rather than return a transitional hash after the metadata timeout."""
|
||||
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
|
||||
)
|
||||
|
||||
v1_hash = "edf46c7f938a3c678081734d7bff8b9c652ba5e5"
|
||||
metadata_torrent = MockTorrent(
|
||||
hash_val=v1_hash,
|
||||
state="metaDL",
|
||||
infohash_v1=v1_hash,
|
||||
)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
[metadata_torrent]
|
||||
)
|
||||
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)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
magnet = f"magnet:?xt=urn:btih:{v1_hash}&dn=test"
|
||||
with pytest.raises(RuntimeError, match="metadata resolution was not confirmed"):
|
||||
client.add_download(magnet, "Test Download")
|
||||
|
||||
def test_add_download_uses_expected_hash_without_fetch(self, monkeypatch):
|
||||
"""Skip proxy fetch when expected hash is provided for URL torrents."""
|
||||
config_values = {
|
||||
@@ -755,7 +979,7 @@ class TestQBittorrentClientAddDownload:
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance.torrents_info.return_value = [mock_torrent]
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
{}, status_code=200
|
||||
[mock_torrent], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -819,11 +1043,10 @@ class TestQBittorrentClientAddDownload:
|
||||
|
||||
torrents_before_add = create_mock_session_response([existing])
|
||||
torrents_after_add = create_mock_session_response([existing, added])
|
||||
properties_ok = create_mock_session_response({}, status_code=200)
|
||||
|
||||
def session_get(request_url, params=None, timeout=None):
|
||||
if request_url.endswith("/torrents/properties"):
|
||||
return properties_ok
|
||||
if params == {"hashes": discovered_hash}:
|
||||
return create_mock_session_response([added])
|
||||
if mock_client_instance.torrents_add.called:
|
||||
return torrents_after_add
|
||||
return torrents_before_add
|
||||
@@ -977,9 +1200,8 @@ class TestQBittorrentClientAddDownload:
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance.torrents_info.return_value = [mock_torrent]
|
||||
# Used by the properties check
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
{}, status_code=200
|
||||
[mock_torrent], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -1010,10 +1232,11 @@ class TestQBittorrentClientAddDownload:
|
||||
)
|
||||
|
||||
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
mock_torrent = MockTorrent(hash_val=valid_hash)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = ""
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
{}, status_code=200
|
||||
[mock_torrent], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -1076,10 +1299,11 @@ class TestQBittorrentClientAddDownload:
|
||||
)
|
||||
|
||||
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
mock_torrent = MockTorrent(hash_val=valid_hash)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
{}, status_code=200
|
||||
[mock_torrent], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -1113,10 +1337,11 @@ class TestQBittorrentClientAddDownload:
|
||||
)
|
||||
|
||||
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
mock_torrent = MockTorrent(hash_val=valid_hash)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
{}, status_code=200
|
||||
[mock_torrent], status_code=200
|
||||
)
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
@@ -1451,6 +1676,74 @@ class TestQBittorrentClientFindExisting:
|
||||
assert download_id == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
||||
assert isinstance(status, DownloadStatus)
|
||||
|
||||
def test_find_existing_waits_for_metadata(self, monkeypatch):
|
||||
"""Wait for an existing hybrid torrent to expose its current primary hash."""
|
||||
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
|
||||
)
|
||||
|
||||
v1_hash = "edf46c7f938a3c678081734d7bff8b9c652ba5e5"
|
||||
v2_hash = "0bed5f40753b342cb143e83c2b21924cc8474731"
|
||||
full_v2_hash = "0bed5f40753b342cb143e83c2b21924cc847473134e44d1bd300bdc58c13010f"
|
||||
metadata_torrent = MockTorrent(
|
||||
hash_val=v1_hash,
|
||||
state="metaDL",
|
||||
infohash_v1=v1_hash,
|
||||
)
|
||||
unrelated_torrent = MockTorrent(hash_val="a" * 40)
|
||||
resolved_torrent = MockTorrent(
|
||||
hash_val=v2_hash,
|
||||
state="downloading",
|
||||
infohash_v1=v1_hash,
|
||||
infohash_v2=full_v2_hash,
|
||||
)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance._session.get.side_effect = [
|
||||
create_mock_session_response([metadata_torrent]),
|
||||
create_mock_session_response([]),
|
||||
create_mock_session_response([unrelated_torrent]),
|
||||
create_mock_session_response([unrelated_torrent]),
|
||||
create_mock_session_response([resolved_torrent]),
|
||||
create_mock_session_response([resolved_torrent]),
|
||||
]
|
||||
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)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
magnet = f"magnet:?xt=urn:btih:{v1_hash}&dn=test"
|
||||
result = client.find_existing(magnet, category="audiobooks")
|
||||
|
||||
assert result is not None
|
||||
download_id, status = result
|
||||
assert download_id == v2_hash
|
||||
assert status.state_value == "downloading"
|
||||
assert [
|
||||
call.kwargs["params"] for call in mock_client_instance._session.get.call_args_list
|
||||
] == [
|
||||
{"hashes": v1_hash},
|
||||
{"hashes": v1_hash},
|
||||
{"category": "audiobooks"},
|
||||
{"category": "books"},
|
||||
{},
|
||||
{"hashes": v2_hash},
|
||||
]
|
||||
|
||||
def test_find_existing_not_found(self, monkeypatch):
|
||||
"""Test finding non-existent torrent."""
|
||||
config_values = {
|
||||
@@ -1556,3 +1849,28 @@ class TestHashesMatch:
|
||||
|
||||
assert _hashes_match("a" * 40, "b" * 30) is False
|
||||
assert _hashes_match("a" * 38, "b" * 32) is False
|
||||
|
||||
|
||||
class TestTorrentMatchesId:
|
||||
"""Tests for matching qBittorrent's available torrent identities."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("torrent", "download_id"),
|
||||
[
|
||||
(MockTorrent(hash_val="current"), "current"),
|
||||
(MockTorrent(hash_val="other", infohash_v1="v1"), "v1"),
|
||||
(MockTorrent(hash_val="other", infohash_v2="v2"), "v2"),
|
||||
],
|
||||
)
|
||||
def test_matches_each_identity(self, torrent, download_id):
|
||||
"""Match IDs exposed through hash, infohash_v1, or infohash_v2."""
|
||||
from shelfmark.download.clients.qbittorrent import _torrent_matches_download_id
|
||||
|
||||
assert _torrent_matches_download_id(torrent, download_id) is True
|
||||
|
||||
def test_rejects_unrelated_or_missing_identities(self):
|
||||
"""Reject unrelated IDs and records without identity fields."""
|
||||
from shelfmark.download.clients.qbittorrent import _torrent_matches_download_id
|
||||
|
||||
assert _torrent_matches_download_id(MockTorrent(hash_val="different"), "requested") is False
|
||||
assert _torrent_matches_download_id(object(), "requested") is False
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -11,13 +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:
|
||||
@@ -265,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):
|
||||
@@ -719,3 +728,497 @@ class TestFetchIndexerSeedSettingsFallback:
|
||||
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"
|
||||
|
||||
@@ -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]]
|
||||
@@ -291,11 +291,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cssselect"
|
||||
version = "1.4.0"
|
||||
version = "1.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/2e/cdfd8b01c37cbf4f9482eefd455853a3cf9c995029a46acd31dfaa9c1dd6/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92", size = 40589, upload-time = "2026-01-29T07:00:26.701Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/5a/6d6fcf922709391fac986f0a03ad4546f4f45b94d10aeb6c1ee041599993/cssselect-1.5.0.tar.gz", hash = "sha256:3cbe82dd7acbee9ba9e5723b5f9e4749826912f1fb31cd7f92aabed5fde15b15", size = 47598, upload-time = "2026-07-27T09:17:34.189Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/0c/7bb51e3acfafd16c48875bf3db03607674df16f5b6ef8d056586af7e2b8b/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8", size = 18540, upload-time = "2026-01-29T07:00:24.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/e9/6734502f67533a752ea8b1c8f7f227c94eecf300252ba8bf23e3e59d8a36/cssselect-1.5.0-py3-none-any.whl", hash = "sha256:1d1aded98e82bdde447ded990a191fd6916177c4f0c914fb62eccd58e2ffcdcc", size = 20797, upload-time = "2026-07-27T09:17:33.04Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -354,11 +354,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.31.2"
|
||||
version = "3.32.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/2c/c603f8aedff281295f7afce455d5c05f33459c7bf684abb46228f844a1f0/filelock-3.31.2.tar.gz", hash = "sha256:e6d35965c709527915a184837a8421826d18bc3f9d7e9a5a0c8114a782475d66", size = 200476, upload-time = "2026-07-21T04:04:46.471Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/4c/c0ec6b645dcfefdd158313edb68c676d36c3f93ce8d2d9f0725d6341e506/filelock-3.31.2-py3-none-any.whl", hash = "sha256:18a4179901809ad1905c6631d907f1c3a41806c0d3506f7f9862565576a81a16", size = 97486, upload-time = "2026-07-21T04:04:44.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -410,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'" },
|
||||
@@ -418,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]]
|
||||
@@ -454,29 +452,49 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.4.0"
|
||||
version = "3.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -670,11 +688,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -724,20 +742,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pip"
|
||||
version = "26.1.2"
|
||||
version = "26.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
@@ -751,26 +769,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prek"
|
||||
version = "0.4.10"
|
||||
version = "0.4.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/54/edc21e275f9fa3540d4d98cf349c2de11621d6729cc401bb7aedf563609e/prek-0.4.10.tar.gz", hash = "sha256:db3122f4e780eb4587635e6a83df881caf2dbb1eb7799d1cca51158216d6f33b", size = 502565, upload-time = "2026-07-16T10:13:00.788Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/5c/cb6e63f7e5a58a5313ddb70409174f4dc004e4b0910b8a8d3f59b2225a95/prek-0.4.12.tar.gz", hash = "sha256:04beeba7f40437cd2f36804b84101bd7f3c9fb40b52da46a25604642ab2bfb09", size = 519080, upload-time = "2026-08-03T11:28:33.147Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e7/5a63528ba7b95b64f38db3e253aed49ee8e5e8ba16589889d2b7f809edb7/prek-0.4.10-py3-none-linux_armv6l.whl", hash = "sha256:023f302741d79301346c3088ba43a9592aff0ecdbe5ddc3019fa9b1183319c5e", size = 5694609, upload-time = "2026-07-16T10:12:26.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ef/ee9e6bf9a5ce242e9e4e66ac4e2e9042a0f6fd9f367cee18ad404456e93d/prek-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:72adc707e16f97564bbae08d22b222ac3bb2491f8fbfb5a0754f80d472c28a71", size = 6044037, upload-time = "2026-07-16T10:12:28.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/7e/da08cc39e5348ccb9234e63a21ee56861f72e8497d6a78f0db1ccae6515d/prek-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:04c9321957e1b32e1fc7cf60bb4f90bba3761f8659d5551ed04f96e25596de49", size = 5535983, upload-time = "2026-07-16T10:12:30.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c6/0486a35bb687a9beac7a5810bd1104c6da56d469b30b1eeaeefd03c99da2/prek-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:e66ccf6c5e4ebadd05cd98cb338d7f553e4d27aa243cf91279c5a569b3cdccc7", size = 5862085, upload-time = "2026-07-16T10:12:33.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/39/277fe17ae1f121e532e3942456f5a6d01ddacfbc550e481dcb359be7a1b0/prek-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:63f9061d75a50ef0ca92c4b596ad352937a845df80758244950e513b27e9e18f", size = 5605697, upload-time = "2026-07-16T10:12:35.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/a1/08354af3e000f2656fad086690d834eab6c04631ff41313a219ea6232199/prek-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c2ff7110e4bfaafbbab13c2893a337081aca61ed797f14b6b224d2ea9741eef", size = 6034111, upload-time = "2026-07-16T10:12:37.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/74/4702396c8d486132e5ce009ab56a0b37f50cb6866830d371f2617b7bdfdc/prek-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b696a05542e79aa27bcce68d1792e77f4fe6f9c6b012b34d74d62f964f3c72d", size = 6787203, upload-time = "2026-07-16T10:12:40.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/29/b5d5d6fb87ebd64b37471e3e79761de9983f85e14d69c522efe7af6620ce/prek-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:431b44d6054e72815b4b05e1173596dfd02a7f7461211d40a2e3117e414642ad", size = 6261333, upload-time = "2026-07-16T10:12:42.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/d6/54ba696d19f7efdc184093353cce713a850aef9c3556e23faeecafa22e94/prek-0.4.10-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ccbd2b4fd1df790087ba18b4506f680471922a5f13714f19801568434a040dee", size = 5867761, upload-time = "2026-07-16T10:12:44.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/7d/3975098aa2baaabfc10f99f9fcf78045c4f10851beed8e9812b6a2688eab/prek-0.4.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:479e7480b447191aa5c6ed67e80f081d0f5ee4e878b140f4d2cee44165395f1c", size = 5714412, upload-time = "2026-07-16T10:12:46.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c0/3e0aac190fe95fdef98526343559b61d4d9fd54444c8c9137ba02412afe1/prek-0.4.10-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0bb7451025cbd2b68e480a13cf665d7a5c87c8b87bf18549a78985c17df817ed", size = 5578145, upload-time = "2026-07-16T10:12:48.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/44/7b26035534204b8b8a9d5e625479201e616413d287262f557cb32e1f8d77/prek-0.4.10-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4fb047e5776676805794574b2d7b178cb3ab536793aadf172419fcda56b34a57", size = 5889245, upload-time = "2026-07-16T10:12:50.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/6c/178a9d768876b4211a1bf63907fe308ae02d173639bcf41cea3c5eed35c1/prek-0.4.10-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:08318818d19caf79643babb89f872c92fda134a622b4731df1d6ed61e29d2d26", size = 6372849, upload-time = "2026-07-16T10:12:52.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/84/d5f5ac8193602883f9dd1d675d9d4084e34fbe3ed2ef50a0c336d8a53d8f/prek-0.4.10-py3-none-win32.whl", hash = "sha256:092872714dcde480a662bbdd98b980b248c2d3e10543d4d53a3a58cc9e5b35b0", size = 5413005, upload-time = "2026-07-16T10:12:55.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/63/9e648fda10bc02c9b6ba305f93b6a6e4fd37d23d13a269a9d2d6bb44eaa1/prek-0.4.10-py3-none-win_amd64.whl", hash = "sha256:3d323a18d0f8c50e474a8fa29fb93bd2db680116d8afb19b76e72ad4667f58e6", size = 5799075, upload-time = "2026-07-16T10:12:56.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/74/b34d8c80cec8dccc7b922c75b9dca62b18b603b5ed2eea93c9d7c2928d2d/prek-0.4.10-py3-none-win_arm64.whl", hash = "sha256:5e93865ef96756c4a26f37ece04ad514abbc19ae6a23ed1a507b6314e6a0d2fb", size = 5563955, upload-time = "2026-07-16T10:12:59.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/23/5811a3161e072e5f93e4da01af611ee30c32922507b8ab4d9873df6affd3/prek-0.4.12-py3-none-linux_armv6l.whl", hash = "sha256:cd92000b051e433f26340821cf1cc8e6e3960f1275f3d516ca01f05905abba64", size = 5793226, upload-time = "2026-08-03T11:28:09.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/88/8607845d94eb1482e1bd335dadf098618f077a15775f7e98de99669052b4/prek-0.4.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5904fe6c6ab26e7d8792a3c7f1e3fc8d94fcfb63ad33b247c35f004b62cb6275", size = 6132269, upload-time = "2026-08-03T11:28:11.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/28/571d79ba457fbd9ecf40ae879c91952e12f5fa475306218c91139b86db7a/prek-0.4.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:df3eff1db9c24dc293010a07bc7a0ae0c541d55af828f5586405dedc28c4920d", size = 5614964, upload-time = "2026-08-03T11:28:12.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/a9/3f5cb79a73c764a8ac38d5bcd51e0df57239856eca7949b09bdac4338bf3/prek-0.4.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c7733b44ca772ea32ec6a8bee669d0358bdf45873e79767afed196065084f31c", size = 5941047, upload-time = "2026-08-03T11:28:14.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/00/1dfed0ef8af10c5c32aa903486dccd33d2df171f3d945a037c5692f10760/prek-0.4.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87f170cf1ffd6e3a196f947b83dff1f6c2cd68635f8d49740278bebe7b682262", size = 5707994, upload-time = "2026-08-03T11:28:15.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/bd/5f388f6cbdc0445b850e7c1a160d0be67fcef8bf221e3c8141a1feccef17/prek-0.4.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57dad513831f060cf73808df8edec29d46ec311435aa69f21c80edebf23dc5e1", size = 6133784, upload-time = "2026-08-03T11:28:17.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/47/342091a987bf68a74acec6d226a40ce7d51faf0019aa4126cc7bc952f8a7/prek-0.4.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b204844abc7ded983471f576ae8dc13b99e9b8d022e4d4b46176c6654769c9d8", size = 6901589, upload-time = "2026-08-03T11:28:18.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/8a/3ef7bdc3c3441649ebc040b9e164a13163e1e5fabae23e7bbb901992f3de/prek-0.4.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43b0a5a9d3f2f77871fdcb7893bfc5c8fe7e44f4e603ce6e4712bfec96b2d6f2", size = 6342189, upload-time = "2026-08-03T11:28:20Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/da/6277908442301b1b92a2879f6b04aaa03accb900f80e42776fc28b8197ef/prek-0.4.12-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0d188e572c306cc44b96e1bae5647e25b7bd311113f3f3f4a67320c257ee64a3", size = 5951250, upload-time = "2026-08-03T11:28:21.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/68/bff51a7332837edb1ecbe017325adb7fafd69b9c7828ddc81a1334b884af/prek-0.4.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:986f52d104b7066190f0f32aebe3467710356de265e9bfd892101ba99371db4d", size = 5804147, upload-time = "2026-08-03T11:28:22.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/de/b7f544971072ed7814125145dfeb1f7c15cce6b78ccea65a96298ff37838/prek-0.4.12-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:13e34d9e09bafcbf1f25a01cf86985e2c5e486591d3f45b2786ba3de82e5153a", size = 5680104, upload-time = "2026-08-03T11:28:24.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/94/95942bcc20a6a91ec2989aa30fdeb00ad095be736ec48b4bbcf0376166b1/prek-0.4.12-py3-none-musllinux_1_1_i686.whl", hash = "sha256:3d0208370da73e8b5bc97f2492dc3975f8dd2c22f4bf6e1f2cf3342503764b52", size = 5975030, upload-time = "2026-08-03T11:28:25.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/6d/26e6497198d81cf9aa82495400aef46adea8df3e4a4efc5f00e3b6ab3292/prek-0.4.12-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b1005f42920111bec1403c25e8f2f12ec7af0be06686cc3b8dcf85429af908a8", size = 6458532, upload-time = "2026-08-03T11:28:27.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/02/ee140c2eb4701bd194db429d84630733492be94897d5f72b61d6f11e6619/prek-0.4.12-py3-none-win32.whl", hash = "sha256:afee229488dcceaea282288e4d7096a93da5a8b85649d9ef506dbdbcd78f38a7", size = 5502213, upload-time = "2026-08-03T11:28:28.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7b/744cff84def48c1ce38c0b4f643a3553c66976c5bb7869ab7317044870e4/prek-0.4.12-py3-none-win_amd64.whl", hash = "sha256:fdd27bad8adafea8fe77606950ca09200d59296a47ab131cfb88718d460949d7", size = 5868065, upload-time = "2026-08-03T11:28:30.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/1d/e2c0fc222904ef73df1739b11a83edc29e38bc4bc61259f2ca6d2f15abb0/prek-0.4.12-py3-none-win_arm64.whl", hash = "sha256:45e34a24fba4a4e4568682477158591698efc2375b8d1d418ae424691c4bd01b", size = 5632819, upload-time = "2026-08-03T11:28:31.743Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1049,15 +1067,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-socketio"
|
||||
version = "5.16.3"
|
||||
version = "5.16.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bidict" },
|
||||
{ name = "python-engineio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/2d/ffce71017c106b75099fea569df6518c63fee5d6202ce0cfe7b01e6f22c3/python_socketio-5.16.3.tar.gz", hash = "sha256:89b136f677ae65607a84cecda9b4d6c5377b40a97582c504c25df89af16d520e", size = 128095, upload-time = "2026-06-15T22:07:04.003Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/5e/87d6b547c87c6d64f4a05f5bfaf6f42e9b786561216434290fdaa83f8667/python_socketio-5.16.4.tar.gz", hash = "sha256:f7fa4a43cc8e687930b5c6e44d6e2efc2071eca4bef49b8bb3dc0827f7f92235", size = 128140, upload-time = "2026-08-06T23:11:21.346Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl", hash = "sha256:0eb9c7687e7fbf59e60d714fd62afba77dfaf8ef8a06a0bff05a86c351accc2f", size = 82098, upload-time = "2026-08-06T23:11:19.851Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1121,25 +1139,25 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "qbittorrent-api"
|
||||
version = "2026.7.0"
|
||||
version = "2026.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/d2/4a6d6ae6aaca1ba83039d27f28e5bf30bacef27ff4eb635c729d2d582363/qbittorrent_api-2026.8.0.tar.gz", hash = "sha256:4127a9c1d5c7ad9cd4e8de16f83daeea2e8b2ed962f99f284d238f927479391a", size = 1430678, upload-time = "2026-08-01T21:05:55.88Z" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/81/b456688c20e75cae5964700b58712630930b9dfaf32ce7c6a8881658d0d0/qbittorrent_api-2026.8.0-py3-none-any.whl", hash = "sha256:03b3db598d351194fd3c65f1d3e53843f65f85bef1d5077791efaa2e26869d2b", size = 68196, upload-time = "2026-08-01T21:05:54.376Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rarfile"
|
||||
version = "4.3"
|
||||
version = "4.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/80/442df102d81a10d31ec2cf73d6f411f5575e3bb7adc0aaa45cbe693a37b6/rarfile-4.3.tar.gz", hash = "sha256:0d8b5a7ffb9f8e9a7b002f2398905e4420c0cb373e799b48e48f418db9c8816a", size = 154855, upload-time = "2026-07-08T19:21:54.072Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/eb/33ea5625de4e84144069ea021a282ea3c44ff7bd341b740bcadff028bea7/rarfile-4.5.tar.gz", hash = "sha256:7425d0afa180f0092db903abb1526a130b36858980aad90b3694f48e41420155", size = 155541, upload-time = "2026-08-02T20:45:53.221Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2d/4a161de993dab0baac89b8c5b9765f420e8cc5239c5c82e0f51cb41e5a74/rarfile-4.3-py3-none-any.whl", hash = "sha256:f9dd2f8bd811b2e30fb074aa176f972f51633aa41cf909db3f2921ed040e1128", size = 29583, upload-time = "2026-07-08T19:21:52.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/36/ffd87452bf72310dfd1185ade33e5b8be374ab370d19573a8197ca25c95b/rarfile-4.5-py3-none-any.whl", hash = "sha256:c74341f4b9a3a3ebb35ef396d59daf059eb028f34995a7162950a41d97b84de9", size = 30035, upload-time = "2026-08-02T20:45:52.212Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1199,27 +1217,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.22"
|
||||
version = "0.16.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1250,7 +1268,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "seleniumbase"
|
||||
version = "4.51.2"
|
||||
version = "4.51.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
@@ -1314,9 +1332,9 @@ dependencies = [
|
||||
{ name = "wheel" },
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/74/3124c6d7506b797bd4ea63b0676b8c3792803a25a6181f05b49084220562/seleniumbase-4.51.2.tar.gz", hash = "sha256:27209495bcbaa5cd699a1ace8a9881fa635c0c3591ee50ff674ae73437aa6e71", size = 667905, upload-time = "2026-07-17T21:31:09.475Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/d9/0e54caf802c04f2f61dc034ae65d96defde37dc6d1462f6a8554ba3c3aab/seleniumbase-4.51.11.tar.gz", hash = "sha256:42eef7baa910df5771d7196a17562466073fee92a8ce52c5d5c3e53d55516793", size = 671742, upload-time = "2026-08-07T04:55:05.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/67/efb27dfab7807f38c5bb954fff5e8db76d16ac9b24032bb463a455e6627f/seleniumbase-4.51.2-py3-none-any.whl", hash = "sha256:f56f5ae40e2a04b75363ea253c8068a8dc7d0c69e49934b939b5fd97eadfaaa1", size = 673696, upload-time = "2026-07-17T21:31:06.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/4f/70bb412ddfdc63efae4c30c30acab8f543675199e0514666eecb3b6c4523/seleniumbase-4.51.11-py3-none-any.whl", hash = "sha256:9286930104e255b2a4b28f0136210c120178f0e534434ea28743dfbef825b076", size = 677503, upload-time = "2026-08-07T04:55:02.653Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1392,10 +1410,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.8.0" },
|
||||
{ name = "rarfile" },
|
||||
{ name = "requests", extras = ["socks"] },
|
||||
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.51.2" },
|
||||
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.51.11" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "transmission-rpc" },
|
||||
]
|
||||
@@ -1408,7 +1426,7 @@ dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.8.0" },
|
||||
{ name = "ruff", specifier = "==0.15.22" },
|
||||
{ name = "ruff", specifier = "==0.16.2" },
|
||||
{ name = "vulture", specifier = ">=2.14" },
|
||||
]
|
||||
|
||||
@@ -1453,11 +1471,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.4"
|
||||
version = "2.9.2"
|
||||
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/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" }
|
||||
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/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1474,27 +1492,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.69.0"
|
||||
version = "4.70.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/69/40407dfc835517f058b603dbf37a6df094d8582b015a51eddc988febbcb7/tqdm-4.69.0.tar.gz", hash = "sha256:700c5e85dcd5f009dd6222588a29180a193a748247a5d855b4d67db93d79a53b", size = 792569, upload-time = "2026-07-17T18:09:06.2Z" }
|
||||
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/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl", hash = "sha256:9979978912be667a6ef21fd5d8abf54e324e63d82f7f43c360792ebc2bc4e622", size = 676680, upload-time = "2026-07-17T18:09:04.172Z" },
|
||||
{ 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]]
|
||||
name = "transmission-rpc"
|
||||
version = "7.0.11"
|
||||
version = "7.0.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/b8/dc4debf525c3bb8a676f4fd0ab8534845e3b067c78a81ad05ac39014d849/transmission_rpc-7.0.11.tar.gz", hash = "sha256:5872322e60b42e368bc9c4724773aea4593113cb19bd2da589f0ffcdabe57963", size = 113744, upload-time = "2024-08-20T22:41:07.485Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/9d/c4e24d5bd0a1166935ca146dfb99b7041e54a38a6eed600e6c9798e98f58/transmission_rpc-7.0.12.tar.gz", hash = "sha256:b65f195a26bf3db792f88ee93211929144321fbc3a4f018bf7029996304fcfa5", size = 26897, upload-time = "2026-08-09T14:37:41.519Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/4c/6319bcb1026e3f78c9cbcc9c24de77a76f09954e67ffc5ebfc29f7ce4b90/transmission_rpc-7.0.11-py3-none-any.whl", hash = "sha256:94fd008b54640dd9fff14d7ae26848f901e9d130a70950b8930f9b395988914f", size = 28231, upload-time = "2024-08-20T22:41:05.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/9c/edc8ee0d9ab7a05f0cb334fc506ce3749ff869a8d41683108313096ce5ab/transmission_rpc-7.0.12-py3-none-any.whl", hash = "sha256:85e26b7ca13e1e102a695fe19f19f4236bd42bb4920b0edb9e35f6bb46bbe86e", size = 29103, upload-time = "2026-08-09T14:37:40.272Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1580,45 +1598,51 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.1"
|
||||
version = "17.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user