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 |
+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:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6 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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)**
|
||||
@@ -1508,6 +1521,8 @@ 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_ |
|
||||
@@ -1559,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`
|
||||
|
||||
@@ -2064,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
|
||||
|
||||
+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>=2026.5.3",
|
||||
"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.8",
|
||||
"seleniumbase==4.51.11",
|
||||
"python-xlib",
|
||||
]
|
||||
|
||||
@@ -40,7 +40,7 @@ dev = [
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"ruff==0.16.0",
|
||||
"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"))
|
||||
|
||||
|
||||
|
||||
@@ -1761,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)",
|
||||
|
||||
@@ -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
|
||||
@@ -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._ensure_authenticated()
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
# Re-authenticate and retry once on 403
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
|
||||
logger.debug(
|
||||
"qBittorrent returned 403 for properties; re-authenticating and retrying"
|
||||
)
|
||||
self._ensure_authenticated()
|
||||
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"
|
||||
|
||||
@@ -274,37 +237,18 @@ class QBittorrentClient(DownloadClient):
|
||||
return
|
||||
self._client.auth_log_in()
|
||||
|
||||
def _get_torrents_info(
|
||||
self, torrent_hash: str | None = None, category: str | None = None
|
||||
def _request_torrent_info_records(
|
||||
self, params: dict[str, str]
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
"""Get torrent info using GET.
|
||||
|
||||
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)
|
||||
|
||||
"""
|
||||
"""Request torrent info records from qBittorrent."""
|
||||
url = f"{self._base_url}/api/v2/torrents/info"
|
||||
|
||||
def do_request(params: dict[str, str]) -> requests.Response:
|
||||
try:
|
||||
self._ensure_authenticated()
|
||||
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]:
|
||||
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._ensure_authenticated()
|
||||
response = self._client._session.get(url, params=request_params, timeout=10)
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
logger.warning("qBittorrent authentication failed (HTTP 403)")
|
||||
@@ -313,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:
|
||||
@@ -374,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
|
||||
@@ -397,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:
|
||||
@@ -534,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")
|
||||
@@ -567,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")
|
||||
|
||||
@@ -705,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
|
||||
|
||||
@@ -825,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
|
||||
|
||||
+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
|
||||
@@ -649,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(
|
||||
@@ -886,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:
|
||||
@@ -933,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
|
||||
@@ -942,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:
|
||||
@@ -992,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:
|
||||
@@ -1027,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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
Generated
+793
-520
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -18,23 +18,23 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"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.27.0",
|
||||
"oxfmt": "^0.59.0",
|
||||
"oxlint": "^1.74.0",
|
||||
"oxlint-tsgolint": "^0.25.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": "^7.0.2",
|
||||
"vite": "^8.1.5",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -26,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
|
||||
@@ -34,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,
|
||||
@@ -46,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):
|
||||
@@ -657,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)}):
|
||||
@@ -679,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 = {
|
||||
@@ -801,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)
|
||||
|
||||
@@ -821,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 = {
|
||||
@@ -840,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)
|
||||
|
||||
@@ -904,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
|
||||
@@ -1062,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)
|
||||
|
||||
@@ -1095,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)
|
||||
|
||||
@@ -1161,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)
|
||||
|
||||
@@ -1198,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)
|
||||
|
||||
@@ -1536,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 = {
|
||||
@@ -1641,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
|
||||
|
||||
@@ -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.32.0"
|
||||
version = "3.32.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" }
|
||||
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/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" },
|
||||
{ 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]]
|
||||
@@ -452,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]]
|
||||
@@ -668,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]]
|
||||
@@ -722,11 +742,11 @@ 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]]
|
||||
@@ -749,26 +769,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prek"
|
||||
version = "0.4.11"
|
||||
version = "0.4.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/1a/73b6dae5ce7e997cb35a69bfe1d25a798e85fa3d2eabf95324563f461b30/prek-0.4.11.tar.gz", hash = "sha256:4a14cb9bbae850605ae3904fbdbb12f0e00c12455efaa2266da8fb8e5c0350d7", size = 516254, upload-time = "2026-07-24T17:05:35.107Z" }
|
||||
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/8e/d7/a00b2de492a80e99b1698e72c1d196ac3ed544dc7ee0ada261ac066e78e0/prek-0.4.11-py3-none-linux_armv6l.whl", hash = "sha256:3830cb7cc47e837888b8b464ecb21355a69235cfacef0fd89101e17f09345d63", size = 5770511, upload-time = "2026-07-24T17:05:12.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/1e/f97c74defcd5d5645888cb99fcdd9b8b48cc7247cf31e64414d106d56d66/prek-0.4.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:45facadf9c2332b28e6ab2744312ae0275a93ab5a437da8bcc53a8c7260cb4b0", size = 6118049, upload-time = "2026-07-24T17:05:14.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/92/8367d26421ee6fe6019a63928fb0ed31179cd0d6199879c524f89ef4c95c/prek-0.4.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2f8a194b1d00d24dff8baff691c99e2668339da2da3482b4ee99c1a0f2409378", size = 5601478, upload-time = "2026-07-24T17:05:15.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/6f/7617c9b87afaadede4167720aae7ef12d7e51167db903bc8fbac0cadef7b/prek-0.4.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:21d93e6d76bf3d7a9bb70c6ac86ef372adaee50068c45749e6b9e1c2ac4ec939", size = 5932071, upload-time = "2026-07-24T17:05:17.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/41/6796a4011b04212333259064aa885a71d03d9581ba9bff52db3ce58d1f06/prek-0.4.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fb07cde2d2156efa6980122b3f13dc88f20c84b933c6205675d0f1e7be2cde8", size = 5677617, upload-time = "2026-07-24T17:05:18.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/5f/3e339901f8460b6073313619b4fd9bf4135e48430695c53640b83ace88ea/prek-0.4.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b7f5e446d2aca739bd18b380578e9c78bb4c086299abc3e167d51c47840c56f", size = 6106370, upload-time = "2026-07-24T17:05:20.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/4e/94e24b5c1910ec15692ecaf33d0d8ef0d02a02a8b5e40d3c976396880cba/prek-0.4.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7059d640e595d098600e2d97af961f46292b4112670edbe632de84f6828389e", size = 6884342, upload-time = "2026-07-24T17:05:21.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/7c/fc0daa033dcafe74990c00af2da1e16922790b9c1b8da7e8eebf1123838b/prek-0.4.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a4df33998fcac878bce3b2c624e1caeb9609f3d562c640702c1234ed815daf", size = 6331365, upload-time = "2026-07-24T17:05:22.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/36/49f152b8f539930e9685cff0509695ce4054abcecc22f4c16c4e1e5c23d0/prek-0.4.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:866991f387527c5f880ce2cc3ddea9b33cc5b986881f8c7f92524cf0969c1350", size = 5939075, upload-time = "2026-07-24T17:05:24.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/fe/7b097af9161edae7ddedcc9f5cda4a5f31346a492ce3f92583d96c46f628/prek-0.4.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:247e5d8740e137ebdf24fa96f01a95b2d2f1892e956a1ab00c7b1474a59ebcc0", size = 5799029, upload-time = "2026-07-24T17:05:25.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/63/dc955ff99e1002d3cd375b21467c87046bc41deddfce86d898240757b151/prek-0.4.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:603ba9f2fd9d666dddb3ae190a25a5c55091b843cde90ed52e0a1116f50d4062", size = 5651211, upload-time = "2026-07-24T17:05:27.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b8/bf2139ec25eefb5afef43afac7620c5181f2c2661f220598beacb1771207/prek-0.4.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f2682ece3c5fc7201106c4fdd84b0587ccea4b8a2ecefa3e94d3074d2841f1df", size = 5954784, upload-time = "2026-07-24T17:05:28.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/29/3fe5990aee1bd7c4d50a03358ad867c5e912d9510aafb83a359c7347e5fa/prek-0.4.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:22721d30394192931fecc80d6fc6dd47e8ddf8db8b9693805aba8ec0f50087ec", size = 6448916, upload-time = "2026-07-24T17:05:29.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/fb/abddacf43738302242ecd237236c7c80cd7c1d27545e16e803770aee76e9/prek-0.4.11-py3-none-win32.whl", hash = "sha256:8b093e7624522146049e994d5cf283d01d71632b638620b79ae0f96afeaa2624", size = 5483539, upload-time = "2026-07-24T17:05:31.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/1e/c293f7a15cb93963c4be36a02e144b93f43906bc02644a3f04e5708e7453/prek-0.4.11-py3-none-win_amd64.whl", hash = "sha256:5a3d7c80b970b456e5f1bcec8382008ee1ae6a3f324a6b9bb4ff7e666ab0f3c4", size = 5861119, upload-time = "2026-07-24T17:05:32.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/0c/05fe6eb9d6a54d0e02dfa8cc5ad6f86869bf953419ec15909a32466a28ee/prek-0.4.11-py3-none-win_arm64.whl", hash = "sha256:e7b0df37ce05e45a14a9da39ab104691474d72f139bf4f6c860f754763a322cb", size = 5626386, upload-time = "2026-07-24T17:05:33.813Z" },
|
||||
{ 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]]
|
||||
@@ -1047,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]]
|
||||
@@ -1119,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.4"
|
||||
version = "4.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/84/a00a5be12adbe5abb0094513ad6d3aefc80fe5dfa4a5604e22561d4b7d3b/rarfile-4.4.tar.gz", hash = "sha256:b7d29d671223cdc80e8b992df212d152ad88e6c961d4426c3fb2fefe8dd86fdf", size = 155437, upload-time = "2026-07-22T16:19:15.533Z" }
|
||||
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/e2/06/7135da7d7d662767aee9aeebaa360f4ad29f08d9198cc1b1632428114acd/rarfile-4.4-py3-none-any.whl", hash = "sha256:befb813c283fbcdd46dda2bad090f8272c8bb1de246d04cd570e9d890294d119", size = 29932, upload-time = "2026-07-22T16:19:14.559Z" },
|
||||
{ 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]]
|
||||
@@ -1197,27 +1217,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
|
||||
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/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
|
||||
{ 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]]
|
||||
@@ -1248,7 +1268,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "seleniumbase"
|
||||
version = "4.51.8"
|
||||
version = "4.51.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
@@ -1312,9 +1332,9 @@ dependencies = [
|
||||
{ name = "wheel" },
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/92/dac65663cbab903d7d2be831f94587df7fa907ca905c0215ae3a8ab96792/seleniumbase-4.51.8.tar.gz", hash = "sha256:92d71030b9ebca772924b5caed7c44c865a5e149a539b2d733664a4e5bfd05db", size = 670520, upload-time = "2026-07-26T04:51:09.923Z" }
|
||||
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/f1/7c/6af2efd2f1edda88a0c4d83138586b9014bd155870bad90c4836200eb048/seleniumbase-4.51.8-py3-none-any.whl", hash = "sha256:1c240f6d6fe173c0e51828187a048f5907b1e3ff8c23a4d487f5c65e3730cb41", size = 676369, upload-time = "2026-07-26T04:51:07.017Z" },
|
||||
{ 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]]
|
||||
@@ -1390,10 +1410,10 @@ requires-dist = [
|
||||
{ name = "python-socketio" },
|
||||
{ name = "python-xlib", marker = "extra == 'browser'" },
|
||||
{ name = "pyvirtualdisplay", marker = "extra == 'browser'" },
|
||||
{ name = "qbittorrent-api", specifier = ">=2026.5.3" },
|
||||
{ name = "qbittorrent-api", specifier = ">=2026.8.0" },
|
||||
{ name = "rarfile" },
|
||||
{ name = "requests", extras = ["socks"] },
|
||||
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.51.8" },
|
||||
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.51.11" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "transmission-rpc" },
|
||||
]
|
||||
@@ -1406,7 +1426,7 @@ dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.8.0" },
|
||||
{ name = "ruff", specifier = "==0.16.0" },
|
||||
{ name = "ruff", specifier = "==0.16.2" },
|
||||
{ name = "vulture", specifier = ">=2.14" },
|
||||
]
|
||||
|
||||
@@ -1451,11 +1471,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" }
|
||||
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/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" },
|
||||
{ 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]]
|
||||
@@ -1484,15 +1504,15 @@ wheels = [
|
||||
|
||||
[[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]]
|
||||
@@ -1578,45 +1598,51 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.1.1"
|
||||
version = "17.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" }
|
||||
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/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" },
|
||||
{ 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