mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c6d6a02cd | ||
|
|
4a0675e0d3 | ||
|
|
e1c3f057ab | ||
|
|
d978896142 | ||
|
|
3b280009ae | ||
|
|
1a5b37d9d3 | ||
|
|
7c8e89c567 | ||
|
|
bc03ad062e | ||
|
|
ab3aa9a8b0 | ||
|
|
d4619be69a | ||
|
|
c1315a2b23 | ||
|
|
7934924678 | ||
|
|
a6204a318e | ||
|
|
545480c557 | ||
|
|
127dd82615 | ||
|
|
acd59f7cbb | ||
|
|
c42edac363 | ||
|
|
d09ec9de25 | ||
|
|
83f86242b4 | ||
|
|
44f4e13cce | ||
|
|
b7002a6eca | ||
|
|
aafce2be1d | ||
|
|
cdd001bdd9 | ||
|
|
e5dd34ae0e | ||
|
|
38a429acc8 | ||
|
|
c53545d9fe | ||
|
|
8f608f2e64 | ||
|
|
2bb84a17a2 | ||
|
|
2ed2e9a5d4 | ||
|
|
c6b70a6844 | ||
|
|
35b89b0d78 | ||
|
|
a5cd9f0bfb | ||
|
|
af21d1da1f | ||
|
|
1b17fe179a | ||
|
|
35037b35fd | ||
|
|
8c902d7f7a | ||
|
|
68de5de241 | ||
|
|
9eb47989ec | ||
|
|
99e0cfde3d | ||
|
|
c45d342931 | ||
|
|
1e3fd48b8b | ||
|
|
c576003319 | ||
|
|
265da07d7f | ||
|
|
96d1b7c33a | ||
|
|
6addae9d7c | ||
|
|
46d21cafbc | ||
|
|
22aa59e567 | ||
|
|
b317dd1110 | ||
|
|
223d8a2256 | ||
|
|
7b5853f35d | ||
|
|
c18da92569 | ||
|
|
97d1bb0df4 | ||
|
|
9f11e83e1f | ||
|
|
b98c2cb83e | ||
|
|
9452ebc70d | ||
|
|
d3f4ccd79a | ||
|
|
cb690b45b8 | ||
|
|
3d7ea40088 | ||
|
|
633004ecf0 | ||
|
|
c06b8ce8ef | ||
|
|
69ff0d6a78 | ||
|
|
3937ae119b | ||
|
|
d7fe28595c | ||
|
|
68c0e83330 | ||
|
|
faaa119884 | ||
|
|
be41a92436 | ||
|
|
7de9319c7a | ||
|
|
97e289ae13 | ||
|
|
c95ee72ad5 | ||
|
|
b25acdb2ad | ||
|
|
7569aaecc5 | ||
|
|
f441b85da2 | ||
|
|
02b7e9d958 | ||
|
|
ff06a1a581 | ||
|
|
463ef49ac3 | ||
|
|
a5595cf9f1 | ||
|
|
9bcf595111 | ||
|
|
65e2e3be20 | ||
|
|
ddc26f01b6 | ||
|
|
89104ae80f | ||
|
|
1e45add4d5 | ||
|
|
cb3f6fee82 | ||
|
|
0dc13c1ca4 | ||
|
|
d1f8527089 | ||
|
|
95e34670f7 | ||
|
|
7d56624ab6 | ||
|
|
f4421ff189 | ||
|
|
e7007865a4 | ||
|
|
5b3df2a463 | ||
|
|
5247ec6124 | ||
|
|
bd21ec1257 | ||
|
|
7b9c416df8 | ||
|
|
646b531669 | ||
|
|
eafb965662 | ||
|
|
7193036626 | ||
|
|
12d554a92f |
@@ -3,10 +3,8 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
schedule:
|
||||
# Nightly at 03:17 UTC — only builds if there are new commits on main
|
||||
# since the last successful run (see check-changes job).
|
||||
- cron: '17 3 * * *'
|
||||
# Also dispatched on main by dev-image-debounce.yml, once main has been
|
||||
# quiet for an hour, to publish the dev image.
|
||||
workflow_dispatch:
|
||||
permissions: read-all
|
||||
|
||||
@@ -14,41 +12,7 @@ env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/shelfmark
|
||||
jobs:
|
||||
check-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
steps:
|
||||
- name: Check for new commits since last successful build
|
||||
id: check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
CURRENT_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# Always build on tag pushes and manual dispatch.
|
||||
if [[ "$EVENT_NAME" != "schedule" ]]; then
|
||||
echo "Event is $EVENT_NAME — building unconditionally."
|
||||
echo "should_build=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Scheduled run: only build if HEAD differs from the last successful build on main.
|
||||
LAST_SHA=$(gh api "/repos/${REPO}/actions/workflows/build-and-publish-docker-image.yml/runs?branch=main&status=success&per_page=1" --jq '.workflow_runs[0].head_sha' 2>/dev/null || true)
|
||||
echo "Last successful build SHA: ${LAST_SHA:-<none>}"
|
||||
echo "Current HEAD SHA: ${CURRENT_SHA}"
|
||||
if [[ -z "$LAST_SHA" || "$LAST_SHA" != "$CURRENT_SHA" ]]; then
|
||||
echo "New commits detected — building."
|
||||
echo "should_build=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No new commits since last successful build — skipping."
|
||||
echo "should_build=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build-and-push-images:
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -96,11 +60,11 @@ jobs:
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
|
||||
- name: Build and push ${{ matrix.target }} Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
@@ -141,7 +105,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
|
||||
- name: Create legacy aliases
|
||||
run: |
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.14"
|
||||
@@ -81,10 +81,10 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
|
||||
- name: Build shelfmark-lite image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
target: shelfmark-lite
|
||||
|
||||
@@ -25,14 +25,14 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v3
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v3
|
||||
uses: github/codeql-action/autobuild@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v3
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Debounce dev image
|
||||
# A burst of merges to main should publish one dev image, not one per commit.
|
||||
# Each push to main waits out the 60-minute wait timer on the
|
||||
# dev-image-debounce environment (Settings → Environments; waiting holds no
|
||||
# runner), then dispatches the Docker workflow only if main still points at
|
||||
# its commit. So only the last push of a burst builds, and the Docker
|
||||
# workflow's history holds real builds only.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
debounce:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: dev-image-debounce
|
||||
deployment: false
|
||||
permissions:
|
||||
actions: write # dispatch the build, delete finished debounce runs
|
||||
contents: read
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Dispatch the dev build if main is still at this commit
|
||||
env:
|
||||
CURRENT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
HEAD_SHA=$(gh api "repos/${GH_REPO}/git/ref/heads/main" --jq '.object.sha')
|
||||
echo "This run's commit: ${CURRENT_SHA}"
|
||||
echo "main HEAD now: ${HEAD_SHA}"
|
||||
if [[ "$HEAD_SHA" == "$CURRENT_SHA" ]]; then
|
||||
echo "No newer commits on main — dispatching the dev build."
|
||||
gh workflow run build-and-publish-docker-image.yml --ref main
|
||||
else
|
||||
echo "main has moved on — the newer push's run will build it."
|
||||
fi
|
||||
|
||||
# A finished debounce run is noise: it either did nothing or its build
|
||||
# run is the record. Best effort, since runs from the same burst race to
|
||||
# delete the same runs.
|
||||
- name: Delete finished debounce runs
|
||||
run: |
|
||||
# On an HTTP error gh prints the error body to stdout, so bail out
|
||||
# rather than loop over it.
|
||||
ids=$(gh api "repos/${GH_REPO}/actions/workflows/dev-image-debounce.yml/runs?status=success&per_page=100" --jq '.workflow_runs[].id') || exit 0
|
||||
for id in $ids; do
|
||||
gh api -X DELETE "repos/${GH_REPO}/actions/runs/${id}" || true
|
||||
done
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
|
||||
@@ -236,6 +236,7 @@ pyrightconfig.json
|
||||
*.local.*
|
||||
AGENTS.md
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.nvmrc
|
||||
.playwright-mcp/
|
||||
frontend-dist/
|
||||
|
||||
+11
-6
@@ -4,7 +4,7 @@ ARG BUILDPLATFORM
|
||||
ARG BUILDARCH
|
||||
|
||||
# Frontend build stage.
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 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"
|
||||
@@ -28,10 +28,10 @@ RUN npm run build
|
||||
# than copied into the image. A COPY here would land ~24 MB in a `base` layer that
|
||||
# every published image inherits, and a later `rm` cannot take it back out again --
|
||||
# a RUN adds a layer, it does not rewrite the one underneath.
|
||||
FROM ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.12.16@sha256:adc68cd785ca65ea25c0611043b0a00b4ea3a22e1b54102fc084406d888082ee AS uv
|
||||
|
||||
# Use python-slim as the base image
|
||||
FROM python:3.14.7-slim@sha256:ce40764625a4ff50df3548277632e7f96c4e77fe75fa848aae9885476e7df5a4 AS base
|
||||
FROM python:3.14.7-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6 AS base
|
||||
|
||||
# Add build argument for version
|
||||
ARG BUILD_VERSION
|
||||
@@ -152,9 +152,14 @@ RUN mkdir -p \
|
||||
EXPOSE ${FLASK_PORT}
|
||||
|
||||
# Add healthcheck for container status
|
||||
# Uses /api/health which doesn't require authentication
|
||||
HEALTHCHECK --interval=60s --timeout=60s --start-period=60s --retries=3 \
|
||||
CMD curl -s http://localhost:${FLASK_PORT}/api/health > /dev/null || exit 1
|
||||
# Uses /api/health which doesn't require authentication.
|
||||
# curl needs -f so an HTTP error status fails the probe instead of passing it:
|
||||
# plain `curl -s` exits 0 on a 500, which reported a broken app as healthy.
|
||||
# timeout stays well under interval so a hung probe cannot occupy a whole cycle.
|
||||
# --start-interval matches the daemon default (5s), made explicit so startup
|
||||
# probing does not depend on that default staying put.
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --start-interval=5s --retries=3 \
|
||||
CMD curl -fsS http://localhost:${FLASK_PORT}/api/health > /dev/null || exit 1
|
||||
|
||||
# Use dumb-init as the entrypoint to handle signals properly
|
||||
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# API access with an API key
|
||||
|
||||
Shelfmark's web interface is driven entirely by a JSON API under `/api/`. Set
|
||||
the `SHELFMARK_API_KEY` environment variable and scripts, dashboards and assistants can
|
||||
call the same API without a browser session. Browser logins keep working
|
||||
exactly as before: it is cookie **or** key.
|
||||
|
||||
## Set the key
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
SHELFMARK_API_KEY: "a-long-random-secret"
|
||||
```
|
||||
|
||||
Generate something long and random (for example `openssl rand -base64 32`).
|
||||
A request carrying the key acts as an **admin**: the first admin user in
|
||||
Shelfmark's user database. Create an admin before relying on the key in any
|
||||
install that has none yet (for example an OIDC-only install). Without an
|
||||
admin user, the key still authenticates as an admin identity with no user
|
||||
row, and routes that need one (requests, activity) answer 403. To rotate,
|
||||
change the variable and restart. Unset it and the feature is off. When the
|
||||
instance runs with no authentication configured (`AUTH_METHOD=none`), the
|
||||
key is simply unnecessary.
|
||||
|
||||
## Send the key
|
||||
|
||||
Either header works, and both are checked, so the key can be sent in
|
||||
`X-Api-Key` behind a reverse proxy that sets its own `Authorization` header.
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" https://shelfmark.example.com/api/downloads/active
|
||||
curl -s -H "X-Api-Key: $SHELFMARK_API_KEY" https://shelfmark.example.com/api/downloads/active
|
||||
```
|
||||
|
||||
A request that carries the key is authenticated by the key alone. Session
|
||||
cookies are ignored and none are set. A bearer value that is not the configured
|
||||
key is ignored and the request continues with normal session authentication,
|
||||
so reverse proxies that forward their own tokens are unaffected; without a valid
|
||||
session such a request gets the usual `401 {"error": "Unauthorized"}`. A
|
||||
database error while resolving the admin returns
|
||||
`500 {"error": "Authentication error"}` — never anonymous access.
|
||||
`/api/auth/check` reflects the browser session only and ignores the key, so
|
||||
use `/api/status` to verify a key.
|
||||
|
||||
## Examples
|
||||
|
||||
Search, then look up releases, then queue one (the same calls the web UI makes):
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" \
|
||||
"https://shelfmark.example.com/api/metadata/search?query=dune%20frank%20herbert"
|
||||
# -> {"books":[{"provider":"hardcover","provider_id":"427363", ...}]}
|
||||
|
||||
curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" \
|
||||
"https://shelfmark.example.com/api/releases?provider=hardcover&book_id=427363&content_type=ebook"
|
||||
# -> {"releases":[{"source":"direct_download","source_id":"...", ...}], ...}
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $SHELFMARK_API_KEY" -H "Content-Type: application/json" \
|
||||
-d @release.json https://shelfmark.example.com/api/releases/download
|
||||
# release.json = one object from "releases" (source and source_id are required)
|
||||
|
||||
curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" https://shelfmark.example.com/api/status
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- The key is compared in constant time and is never logged.
|
||||
- Keyed requests never set cookies and ignore any cookie sent with them.
|
||||
- WebSocket (live activity) connections do not accept the key; poll `/api/status` instead.
|
||||
- The key is a root-equivalent credential: an admin can configure a custom
|
||||
post-download script that the server executes, so treat it like a root
|
||||
password and send it only over HTTPS.
|
||||
@@ -276,6 +276,27 @@ class DownloadHandler(ABC):
|
||||
pass
|
||||
```
|
||||
|
||||
### Optional: Listing Files Before Download
|
||||
|
||||
Some releases bundle several books (a whole-series torrent). Shelfmark inspects a
|
||||
release before queueing it so the user can review how it will be split into books.
|
||||
Override `list_files` when your source can enumerate a release's files without
|
||||
downloading it; the default returns `None`, which the UI reports as "can't inspect":
|
||||
|
||||
```python
|
||||
from shelfmark.download.postprocess.packs import PackFile
|
||||
|
||||
def list_files(self, release_data: dict[str, Any]) -> list[PackFile] | None:
|
||||
"""Return the release's files (release-relative paths + sizes), or None."""
|
||||
torrent_bytes = ... # e.g. fetch the .torrent, or scrape the indexer's detail page
|
||||
return extract_file_list_from_torrent(torrent_bytes) # from download.clients.torrent_utils
|
||||
```
|
||||
|
||||
`release_data` is the same payload the frontend sends to `/api/releases/download`
|
||||
(`source_id`, `download_url`, `content_type`, `series_name`, ...). Built-in examples:
|
||||
Prowlarr parses the `.torrent` it already fetches (magnet-only releases return
|
||||
`None`), and AudiobookBay reads the file table off its detail page.
|
||||
|
||||
### Download Method Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|
||||
+132
-30
@@ -47,6 +47,7 @@ These environment variables are used at startup before the settings system loads
|
||||
| `CWA_DB_PATH` | Path to the Calibre-Web database for authentication integration. | string (path) | `/auth/app.db` |
|
||||
| `HIDE_LOCAL_AUTH` | Hide the username/password login form when OIDC is active. | boolean | `false` |
|
||||
| `DISABLE_LOCAL_AUTH` | Disable username/password login and remove the local-admin prerequisite for OIDC. Implies HIDE_LOCAL_AUTH; with AUTH_METHOD=builtin, everyone is locked out until auth env vars are changed. | boolean | `false` |
|
||||
| `SHELFMARK_API_KEY` | Optional static API key. When set, requests carrying it as 'Authorization: Bearer <key>' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off. | string | `unset` |
|
||||
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page. | boolean | `false` |
|
||||
| `DOCKERMODE` | Indicates the application is running inside a Docker container. | boolean | `false` |
|
||||
| `ONBOARDING` | Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage). | boolean | `true` |
|
||||
@@ -124,6 +125,13 @@ Disable username/password login and remove the local-admin prerequisite for OIDC
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `SHELFMARK_API_KEY`
|
||||
|
||||
Optional static API key. When set, requests carrying it as 'Authorization: Bearer <key>' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `unset`
|
||||
|
||||
#### `OIDC_AUTO_REDIRECT`
|
||||
|
||||
Automatically redirect to the OIDC provider instead of showing the login page.
|
||||
@@ -247,7 +255,7 @@ Seconds since the last WireGuard handshake before the healthcheck bounces the tu
|
||||
| `CALIBRE_WEB_URL` | Adds a navigation button to your book library (Calibre-Web Automated, Grimmory, etc). | string | _none_ |
|
||||
| `AUDIOBOOK_LIBRARY_URL` | Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text. | string | _none_ |
|
||||
| `SUPPORTED_FORMATS` | Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found. | string (comma-separated) | `epub,mobi,azw3,fb2,djvu,cbz,cbr` |
|
||||
| `SUPPORTED_AUDIOBOOK_FORMATS` | Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found. | string (comma-separated) | `m4b,mp3,m4a,flac,ogg,wma,aac,wav,opus,zip,rar` |
|
||||
| `SUPPORTED_AUDIOBOOK_FORMATS` | Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found. | string (comma-separated) | `m4b,mp3,m4a,mp4,flac,ogg,wma,aac,wav,opus,zip,rar` |
|
||||
| `BOOK_LANGUAGE` | Default language filter for searches. | string (comma-separated) | `en` |
|
||||
|
||||
<details>
|
||||
@@ -296,16 +304,7 @@ Book formats to include in search results. ZIP/RAR archives are extracted automa
|
||||
Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found.
|
||||
|
||||
- **Type:** string (comma-separated)
|
||||
- **Default:** `m4b,mp3,m4a,flac,ogg,wma,aac,wav,opus,zip,rar`
|
||||
|
||||
#### `BOOK_LANGUAGE`
|
||||
|
||||
**Default Book Languages**
|
||||
|
||||
Default language filter for searches.
|
||||
|
||||
- **Type:** string (comma-separated)
|
||||
- **Default:** `en`
|
||||
- **Default:** `m4b,mp3,m4a,mp4,flac,ogg,wma,aac,wav,opus,zip,rar`
|
||||
|
||||
</details>
|
||||
|
||||
@@ -314,6 +313,7 @@ Default language filter for searches.
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `SEARCH_MODE` | How you want to search for and download books. | string (choice) | `universal` |
|
||||
| `BOOK_LANGUAGE` | Default language filter for searches. Users can override this for their own account. | string (comma-separated) | `en` |
|
||||
| `AA_DEFAULT_SORT` | Default sort order for search results. | string (choice) | `relevance` |
|
||||
| `SHOW_RELEASE_SOURCE_LINKS` | Show clickable release-source links in release and details modals. Metadata provider links stay enabled. | boolean | `true` |
|
||||
| `SHOW_COMBINED_SELECTOR` | Show the option to search for and download both a book and audiobook together. | boolean | `true` |
|
||||
@@ -337,6 +337,15 @@ How you want to search for and download books.
|
||||
- **Default:** `universal`
|
||||
- **Options:** `direct` (Direct), `universal` (Universal)
|
||||
|
||||
#### `BOOK_LANGUAGE`
|
||||
|
||||
**Default Book Languages**
|
||||
|
||||
Default language filter for searches. Users can override this for their own account.
|
||||
|
||||
- **Type:** string (comma-separated)
|
||||
- **Default:** `en`
|
||||
|
||||
#### `AA_DEFAULT_SORT`
|
||||
|
||||
**Default Sort Order**
|
||||
@@ -433,8 +442,9 @@ The release source tab to open by default in the release modal for audiobooks. U
|
||||
| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` |
|
||||
| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` |
|
||||
| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` |
|
||||
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
|
||||
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
|
||||
| `NAMING_WORD_SEPARATOR` | Replaces spaces inside naming template values (e.g. 'Conan Doyle' -> 'Conan.Doyle' with '.'). Applies to books and audiobooks, rename and organize templates alike. Literal characters typed into a template (like the '-' in '{Author} - {Title}') are left as-is. Leave empty to keep spaces as-is. | string | _empty string_ |
|
||||
| `TEMPLATE_RENAME` | Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
|
||||
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
|
||||
| `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` |
|
||||
| `BOOKLORE_HOST` | Base URL of your Grimmory instance | string | _none_ |
|
||||
| `BOOKLORE_USERNAME` | Grimmory account username | string | _none_ |
|
||||
@@ -455,8 +465,8 @@ The release source tab to open by default in the release modal for audiobooks. U
|
||||
| `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` |
|
||||
| `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ |
|
||||
| `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` |
|
||||
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` |
|
||||
| `HARDLINK_TORRENTS_AUDIOBOOK` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `true` |
|
||||
| `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` |
|
||||
| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ |
|
||||
@@ -496,11 +506,20 @@ Choose how downloaded book files are named and organized.
|
||||
- **Default:** `rename`
|
||||
- **Options:** `none` (None), `rename` (Rename Only), `organize` (Rename and Organize)
|
||||
|
||||
#### `NAMING_WORD_SEPARATOR`
|
||||
|
||||
**Word Separator**
|
||||
|
||||
Replaces spaces inside naming template values (e.g. 'Conan Doyle' -> 'Conan.Doyle' with '.'). Applies to books and audiobooks, rename and organize templates alike. Literal characters typed into a template (like the '-' in '{Author} - {Title}') are left as-is. Leave empty to keep spaces as-is.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `TEMPLATE_RENAME`
|
||||
|
||||
**Naming Template**
|
||||
|
||||
Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author} - {Title} ({Year})`
|
||||
@@ -509,7 +528,7 @@ Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source
|
||||
|
||||
**Path Template**
|
||||
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author}/{Title} ({Year})`
|
||||
@@ -704,13 +723,13 @@ Choose how downloaded audiobook files are named and organized.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `rename`
|
||||
- **Options:** `none` (None), `rename` (Rename Only), `organize` (Rename and Organize)
|
||||
- **Options:** `none` (None), `rename` (Rename Only), `organize` (Rename and Organize), `rename_and_group` (Rename and Group)
|
||||
|
||||
#### `TEMPLATE_AUDIOBOOK_RENAME`
|
||||
|
||||
**Naming Template**
|
||||
|
||||
Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author} - {Title}`
|
||||
@@ -719,7 +738,7 @@ Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source
|
||||
|
||||
**Path Template**
|
||||
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author}/{Title}/{Title}`
|
||||
@@ -749,6 +768,7 @@ Automatically open the downloads sidebar when a new download is queued.
|
||||
Automatically download completed files to your browser for the selected content types.
|
||||
|
||||
- **Type:** string (comma-separated)
|
||||
|
||||
- **Default:** _empty list_
|
||||
|
||||
#### `MAX_CONCURRENT_DOWNLOADS`
|
||||
@@ -782,6 +802,7 @@ How long to keep completed/failed downloads in the queue display.
|
||||
| `PROXY_AUTH_USER_HEADER` | The HTTP header your proxy uses to pass the authenticated username. | string | `X-Auth-User` |
|
||||
| `PROXY_AUTH_LOGOUT_URL` | The URL to redirect users to for logging out. Leave empty to disable logout functionality. | string | _empty string_ |
|
||||
| `PROXY_AUTH_ADMIN_GROUP_HEADER` | Optional: header your proxy uses to pass user groups/roles. | string | `X-Auth-Groups` |
|
||||
| `PROXY_AUTH_DEFAULT_ROLE` | Role for users the proxy authenticates for the first time when no admin group is configured. The first account is always an admin so the instance is never left without one. | string (choice) | `user` |
|
||||
| `PROXY_AUTH_ADMIN_GROUP_NAME` | Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection. | string | _empty string_ |
|
||||
| `OIDC_DISCOVERY_URL` | OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration. | string | _none_ |
|
||||
| `OIDC_CLIENT_ID` | OAuth2 client ID from your identity provider. | string | _none_ |
|
||||
@@ -841,6 +862,16 @@ Optional: users in this group are treated as admins. Leave blank to skip group-b
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
#### `PROXY_AUTH_DEFAULT_ROLE`
|
||||
|
||||
**Proxy Auth Default Role**
|
||||
|
||||
Role for users the proxy authenticates for the first time when no admin group is configured. The first account is always an admin so the instance is never left without one.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `user`
|
||||
- **Options:** `user` (User), `admin` (Admin)
|
||||
|
||||
|
||||
#### `OIDC_DISCOVERY_URL`
|
||||
|
||||
@@ -1223,6 +1254,7 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480
|
||||
| `PROWLARR_URL` | Base URL of your Prowlarr instance | string | _none_ |
|
||||
| `PROWLARR_API_KEY` | Found in Prowlarr: Settings > General > API Key | string (secret) | _none_ |
|
||||
| `PROWLARR_INDEXERS` | Select which indexers to search. 📚 = has book categories. Leave empty to search all. | string (comma-separated) | _empty list_ |
|
||||
| `PROWLARR_INDEXER_TIMEOUT` | How long to wait for a single indexer to answer a search. Indexers behind FlareSolverr can need 90 seconds or more while a cold Cloudflare challenge is solved; raise this if searches come back empty and the Prowlarr log shows the search still running. | number | `90` |
|
||||
| `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
|
||||
| `PROWLARR_COLLAPSE_DUPLICATES` | Collapse a release that several indexer entries returned down to a single row, keeping the entry with the best Prowlarr priority. Turn this off to see every entry that carried it, which is what makes results from filter-specific entries (freeleech and the like) visible. | boolean | `true` |
|
||||
| `PROWLARR_USE_SEED_PREFERENCES` | Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client | boolean | `false` |
|
||||
@@ -1268,6 +1300,16 @@ Select which indexers to search. 📚 = has book categories. Leave empty to sear
|
||||
- **Type:** string (comma-separated)
|
||||
- **Default:** _empty list_
|
||||
|
||||
#### `PROWLARR_INDEXER_TIMEOUT`
|
||||
|
||||
**Indexer Search Timeout (seconds)**
|
||||
|
||||
How long to wait for a single indexer to answer a search. Indexers behind FlareSolverr can need 90 seconds or more while a cold Cloudflare challenge is solved; raise this if searches come back empty and the Prowlarr log shows the search still running.
|
||||
|
||||
- **Type:** number
|
||||
- **Default:** `90`
|
||||
- **Constraints:** min: 5, max: 300
|
||||
|
||||
#### `PROWLARR_AUTO_EXPAND`
|
||||
|
||||
**Auto-expand search on no results**
|
||||
@@ -1302,8 +1344,9 @@ Apply per-indexer seed time and ratio preferences from Prowlarr when sending tor
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `NEWZNAB_ENABLED` | Enable searching for books via a Newznab-compatible indexer | boolean | `false` |
|
||||
| `NEWZNAB_URL` | Base URL of your Newznab indexer or aggregator | string | _none_ |
|
||||
| `NEWZNAB_API_KEY` | Your Newznab API key (leave blank if not required) | string (secret) | _none_ |
|
||||
| `NEWZNAB_INDEXERS` | Named Newznab connections. Each row accepts `name`, `url`, and `api_key`. | JSON array | `[]` |
|
||||
| `NEWZNAB_URL` | Legacy single-indexer URL, used when `NEWZNAB_INDEXERS` is empty | string | _none_ |
|
||||
| `NEWZNAB_API_KEY` | Legacy single-indexer API key | string (secret) | _none_ |
|
||||
| `NEWZNAB_EBOOK_CATEGORIES` | Newznab category IDs searched for ebooks. Most indexers use the standard 7000, but some use custom IDs. Leave empty to use 7000. | string (comma-separated) | `7000` |
|
||||
| `NEWZNAB_AUDIOBOOK_CATEGORIES` | Newznab category IDs searched for audiobooks. Most indexers use the standard 3030, but some use custom IDs. Leave empty to use 3030. | string (comma-separated) | `3030` |
|
||||
| `NEWZNAB_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
|
||||
@@ -1320,21 +1363,36 @@ Enable searching for books via a Newznab-compatible indexer
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `NEWZNAB_INDEXERS`
|
||||
|
||||
**Named Indexers**
|
||||
|
||||
Configure multiple named Newznab-compatible indexers. The name is shown beside each search result. For environment-based configuration, provide a JSON array:
|
||||
|
||||
```json
|
||||
[
|
||||
{"name":"NZBGeek","url":"https://api.nzbgeek.info","api_key":"..."},
|
||||
{"name":"DrunkenSlug","url":"https://drunkenslug.com","api_key":"..."}
|
||||
]
|
||||
```
|
||||
|
||||
- **Type:** JSON array
|
||||
- **Default:** `[]`
|
||||
|
||||
#### `NEWZNAB_URL`
|
||||
|
||||
**Newznab URL**
|
||||
**Legacy Newznab URL**
|
||||
|
||||
Base URL of your Newznab indexer or aggregator
|
||||
Single-indexer fallback used only when `NEWZNAB_INDEXERS` is empty.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
- **Required:** Yes
|
||||
|
||||
#### `NEWZNAB_API_KEY`
|
||||
|
||||
**API Key**
|
||||
**Legacy API Key**
|
||||
|
||||
Your Newznab API key (leave blank if not required)
|
||||
API key for the legacy Newznab URL.
|
||||
|
||||
- **Type:** string (secret)
|
||||
- **Default:** _none_
|
||||
@@ -1543,6 +1601,7 @@ How long to keep cached search results before they expire.
|
||||
| `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_ |
|
||||
| `TORBOX_API_KEY` | TorBox API Key from your TorBox 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_ |
|
||||
@@ -1594,7 +1653,7 @@ Choose which torrent client to use
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** _empty string_
|
||||
- **Options:** `""` (None), `alldebrid` (AllDebrid), `qbittorrent` (qBittorrent), `realdebrid` (Real-Debrid), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent)
|
||||
- **Options:** `""` (None), `alldebrid` (AllDebrid), `qbittorrent` (qBittorrent), `realdebrid` (Real-Debrid), `torbox` (TorBox), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent)
|
||||
|
||||
#### `ALLDEBRID_API_KEY`
|
||||
|
||||
@@ -1614,6 +1673,15 @@ Real-Debrid API Key (Secret Token) from your Real-Debrid account settings
|
||||
- **Type:** string (secret)
|
||||
- **Default:** _none_
|
||||
|
||||
#### `TORBOX_API_KEY`
|
||||
|
||||
**API Key**
|
||||
|
||||
TorBox API Key from your TorBox account settings
|
||||
|
||||
- **Type:** string (secret)
|
||||
- **Default:** _none_
|
||||
|
||||
#### `QBITTORRENT_URL`
|
||||
|
||||
**qBittorrent URL**
|
||||
@@ -1977,7 +2045,7 @@ Move deletes the job from your usenet client after import; Copy keeps it in the
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `HARDCOVER_ENABLED` | Enable Hardcover as a metadata provider for book searches | boolean | `false` |
|
||||
| `HARDCOVER_API_KEY` | Get your API key from hardcover.app/account/api | string (secret) | _none_ |
|
||||
| `HARDCOVER_API_KEY` | Get your API key from hardcover.app/account/api (starts with hc_pat_) | string (secret) | _none_ |
|
||||
| `HARDCOVER_DEFAULT_SORT` | Default sort order for Hardcover search results. | string (choice) | `relevance` |
|
||||
| `HARDCOVER_EXCLUDE_COMPILATIONS` | Filter out compilations, anthologies, and omnibus editions from search results | boolean | `false` |
|
||||
| `HARDCOVER_EXCLUDE_UNRELEASED` | Filter out books with a release year in the future | boolean | `false` |
|
||||
@@ -1999,7 +2067,7 @@ Enable Hardcover as a metadata provider for book searches
|
||||
|
||||
**API Key**
|
||||
|
||||
Get your API key from hardcover.app/account/api
|
||||
Get your API key from hardcover.app/account/api (starts with hc_pat_)
|
||||
|
||||
- **Type:** string (secret)
|
||||
- **Default:** _none_
|
||||
@@ -2150,6 +2218,7 @@ Enable Moly.hu as a metadata provider for book searches
|
||||
| `SOURCE_PRIORITY` | Fallback sources, may have waiting. Requires bypasser. Drag to reorder. | JSON array | _see UI for defaults_ |
|
||||
| `MAX_RETRY` | Maximum retry attempts for failed downloads. | number | `10` |
|
||||
| `DEFAULT_SLEEP` | Wait time between download retry attempts. | number | `5` |
|
||||
| `RELEASE_SEARCH_TIMEOUT` | How long one release search may run before it gives up and reports why. A first search on a cold start pays for a browser solve, so leave room for one. If you use a reverse proxy, its read timeout should be at least this high or it will cut the search off with a 504 first. | number | `300` |
|
||||
| `AA_CONTENT_TYPE_ROUTING` | Override destination based on content type metadata. | boolean | `false` |
|
||||
| `AA_CONTENT_TYPE_DIR_FICTION` | Fiction Books | string | _none_ |
|
||||
| `AA_CONTENT_TYPE_DIR_NON_FICTION` | Non-Fiction Books | string | _none_ |
|
||||
@@ -2228,6 +2297,16 @@ Wait time between download retry attempts.
|
||||
- **Default:** `5`
|
||||
- **Constraints:** min: 1, max: 60
|
||||
|
||||
#### `RELEASE_SEARCH_TIMEOUT`
|
||||
|
||||
**Release Search Timeout (seconds)**
|
||||
|
||||
How long one release search may run before it gives up and reports why. A first search on a cold start pays for a browser solve, so leave room for one. If you use a reverse proxy, its read timeout should be at least this high or it will cut the search off with a 504 first.
|
||||
|
||||
- **Type:** number
|
||||
- **Default:** `300`
|
||||
- **Constraints:** min: 30, max: 1800
|
||||
|
||||
#### `AA_CONTENT_TYPE_ROUTING`
|
||||
|
||||
**Enable Content-Type Routing**
|
||||
@@ -2304,6 +2383,8 @@ Override destination based on content type metadata.
|
||||
| `EXT_BYPASSER_URL` | URL of the external bypasser service (e.g., FlareSolverr). | string | `http://flaresolverr:8191` |
|
||||
| `EXT_BYPASSER_PATH` | API path for the external bypasser. | string | `/v1` |
|
||||
| `EXT_BYPASSER_TIMEOUT` | Timeout for external bypasser requests in milliseconds. | number | `60000` |
|
||||
| `BYPASS_PAGE_SOURCE_TIMEOUT` | How long to wait for a solved page to produce its content before the bypass is retried. Raise it if solves succeed but searches still fail. | number | `20` |
|
||||
| `BYPASS_BROWSER_IDLE_TIMEOUT` | How long the bypass helper process may sit unused before it is shut down. Higher keeps more searches fast, lower frees memory sooner. | number | `180` |
|
||||
|
||||
<details>
|
||||
<summary>Detailed descriptions</summary>
|
||||
@@ -2359,6 +2440,27 @@ Timeout for external bypasser requests in milliseconds.
|
||||
- **Requires restart:** Yes
|
||||
- **Constraints:** min: 10000, max: 300000
|
||||
|
||||
#### `BYPASS_PAGE_SOURCE_TIMEOUT`
|
||||
|
||||
**Page Read Timeout (seconds)**
|
||||
|
||||
How long to wait for a solved page to produce its content before the bypass is retried. Raise it if solves succeed but searches still fail.
|
||||
|
||||
- **Type:** number
|
||||
- **Default:** `20`
|
||||
- **Constraints:** min: 1, max: 120
|
||||
|
||||
#### `BYPASS_BROWSER_IDLE_TIMEOUT`
|
||||
|
||||
**Bypasser Idle Timeout (seconds)**
|
||||
|
||||
How long the bypass helper process may sit unused before it is shut down. Higher keeps more searches fast, lower frees memory sooner.
|
||||
|
||||
- **Type:** number
|
||||
- **Default:** `180`
|
||||
- **Requires restart:** Yes
|
||||
- **Constraints:** min: 30, max: 3600
|
||||
|
||||
</details>
|
||||
|
||||
### Direct Download: Mirrors
|
||||
|
||||
@@ -15,6 +15,7 @@ Use the guides below to set up the app, connect your library tools, and understa
|
||||
- [Users & Requests](users-and-requests.md)
|
||||
- [Reverse Proxy](reverse-proxy.md)
|
||||
- [OIDC](oidc.md)
|
||||
- [API Access](api-access.md)
|
||||
- [URL Search Parameters](url-search-parameters.md)
|
||||
- [Custom Scripts](custom-scripts.md)
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ Configure in Settings → Security:
|
||||
| Proxy Auth Logout URL | `https://auth.example.com/logout` |
|
||||
| Proxy Auth Admin Group Header | `Remote-Groups` |
|
||||
| Proxy Auth Admin Group Name | `admins` (or your admin group) |
|
||||
| Proxy Auth Default Role | `User` — first-time users are regular users; the very first account is still made admin. Only consulted when no admin group is set |
|
||||
|
||||
#### Nginx Configuration with Authelia
|
||||
|
||||
|
||||
@@ -1,71 +1,79 @@
|
||||
# URL Search Parameters
|
||||
|
||||
You can trigger searches directly via URL by adding query parameters. This enables bookmarking searches and sharing links.
|
||||
You can trigger searches directly via URL. This enables bookmarking searches and sharing links.
|
||||
|
||||
Parameters live in the URL **hash** (`#…`), so they stay in the browser and are never sent to
|
||||
the server. Shelfmark also keeps the hash in sync as you search, so the address bar always
|
||||
holds a shareable link to what you're looking at.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```
|
||||
http://your-server:8084/?q=harry+potter
|
||||
http://your-server:8084/#q=harry+potter
|
||||
```
|
||||
|
||||
Older query-string links (`/?q=harry+potter`) still work: they're read once on load and
|
||||
rewritten to the hash form.
|
||||
|
||||
## Supported Parameters
|
||||
|
||||
| Parameter | Description | Example |
|
||||
|-----------|-------------|---------|
|
||||
| `q` or `query` | Main search query | `/?q=dune` |
|
||||
| `author` | Filter by author name | `/?author=frank+herbert` |
|
||||
| `title` | Filter by book title | `/?title=foundation` |
|
||||
| `isbn` | Filter by ISBN | `/?isbn=978-0747532699` |
|
||||
| `lang` | Filter by language (ISO 639-1 code) | `/?lang=en` |
|
||||
| `format` | Filter by file format | `/?format=epub` |
|
||||
| `content` | Filter by content type | `/?content=fiction` |
|
||||
| `content_type` | Select media type (`ebook`, `audiobook`, or `combined`) in Universal mode only | `/?q=dune&content_type=audiobook` |
|
||||
| `sort` | Sort order for results | `/?sort=newest` |
|
||||
| `q` or `query` | Main search query | `/#q=dune` |
|
||||
| `author` | Filter by author name | `/#author=frank+herbert` |
|
||||
| `title` | Filter by book title | `/#title=foundation` |
|
||||
| `isbn` | Filter by ISBN | `/#isbn=978-0747532699` |
|
||||
| `lang` | Filter by language (ISO 639-1 code) | `/#lang=en` |
|
||||
| `format` | Filter by file format | `/#format=epub` |
|
||||
| `content` | Filter by content type | `/#content=fiction` |
|
||||
| `content_type` | Select media type (`ebook`, `audiobook`, or `combined`) in Universal mode only | `/#q=dune&content_type=audiobook` |
|
||||
| `sort` | Sort order for results | `/#sort=newest` |
|
||||
| `search_by` | "Search By" target the query applies to (`general`, `author`, `title`, `isbn`, a metadata provider field like `series`, or `manual`) | `/#search_by=author&q=frank+herbert` |
|
||||
|
||||
## Multiple Values
|
||||
|
||||
Some parameters support multiple values by repeating the parameter:
|
||||
|
||||
```
|
||||
/?lang=en&lang=de&lang=fr
|
||||
/?format=epub&format=mobi&format=azw3
|
||||
/#lang=en&lang=de&lang=fr
|
||||
/#format=epub&format=mobi&format=azw3
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
**Simple search:**
|
||||
```
|
||||
/?q=lord+of+the+rings
|
||||
/#q=lord+of+the+rings
|
||||
```
|
||||
|
||||
**Search with author filter:**
|
||||
```
|
||||
/?q=dune&author=frank+herbert
|
||||
/#q=dune&author=frank+herbert
|
||||
```
|
||||
|
||||
**Search with format and language:**
|
||||
```
|
||||
/?q=harry+potter&format=epub&lang=en
|
||||
/#q=harry+potter&format=epub&lang=en
|
||||
```
|
||||
|
||||
**Author search with multiple formats:**
|
||||
```
|
||||
/?author=stephen+king&format=epub&format=mobi
|
||||
/#author=stephen+king&format=epub&format=mobi
|
||||
```
|
||||
|
||||
**Search with sort order:**
|
||||
```
|
||||
/?q=science+fiction&sort=newest
|
||||
/#q=science+fiction&sort=newest
|
||||
```
|
||||
|
||||
**Universal search as audiobook:**
|
||||
```
|
||||
/?q=dune&content_type=audiobook
|
||||
/#q=dune&content_type=audiobook
|
||||
```
|
||||
|
||||
**Universal search forcing combined (ebook + audiobook):**
|
||||
```
|
||||
/?q=dune&content_type=combined
|
||||
/#q=dune&content_type=combined
|
||||
```
|
||||
|
||||
## Search Mode Behavior
|
||||
@@ -77,13 +85,28 @@ When Search Mode is set to Direct, all parameters are used to filter results fro
|
||||
|
||||
### Universal Mode
|
||||
|
||||
`q`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities.
|
||||
`q`, `search_by`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities — except when `search_by` names one of the provider's own search fields, in which case `q` is sent as that field's value.
|
||||
|
||||
`content_type=combined` forces combined mode (search ebook and audiobook providers together), overriding the last-used preference. It is silently ignored if combined mode is unavailable (e.g. the combined selector is disabled in settings, or either content type is blocked by request policy).
|
||||
|
||||
## Search By
|
||||
|
||||
`search_by` picks which target the `q` value is applied to, matching the selector next to the
|
||||
search box. It can be deep-linked on its own (`/#search_by=manual`) to open the app in that
|
||||
mode with an empty query.
|
||||
|
||||
`search_by=manual` fills the search box but does not auto-run: manual search opens the release
|
||||
browser from an explicit submit.
|
||||
|
||||
A `search_by` naming a target that isn't available (wrong search mode, or a metadata provider
|
||||
that doesn't offer that field) is ignored, and the query falls back to a general search.
|
||||
|
||||
## Notes
|
||||
|
||||
- URL parameters are read once on page load
|
||||
- The URL is not updated when you perform searches manually
|
||||
- URL parameters are read once on page load, and again if the hash is replaced in an open tab
|
||||
(e.g. pasting a shared link into the address bar)
|
||||
- The hash is kept in sync with the search box, Search By target and filters as you search
|
||||
- Spaces should be encoded as `+` or `%20`
|
||||
- Invalid or unknown parameters are silently ignored
|
||||
- Your last-used Search By target is remembered in browser storage and used when a link
|
||||
doesn't specify one
|
||||
|
||||
@@ -30,7 +30,7 @@ Requires mounting your Calibre-Web `app.db` to `/auth/app.db`.
|
||||
|
||||
Admins can configure per-user settings by editing a user in the user management panel. Non-admin users can also edit their own settings through **My Account** (accessible from the user menu). Admins control which sections are visible in My Account via the **Visible Self-Settings Sections** option.
|
||||
|
||||
There are three categories of per-user settings:
|
||||
There are four categories of per-user settings:
|
||||
|
||||
### Delivery Preferences
|
||||
|
||||
@@ -42,6 +42,15 @@ Override where a user's downloads are sent. Options depend on the global output
|
||||
- **BookLore library/path** — Per-user BookLore target (when using BookLore output mode)
|
||||
- **Email recipient** — Per-user email address (when using Email output mode)
|
||||
|
||||
### Search Preferences
|
||||
|
||||
Override how a user searches, on top of the global search defaults:
|
||||
|
||||
- **Search mode** — Direct or Universal for this user
|
||||
- **Default book languages** — The languages a user's searches fall back to when they don't pick one themselves. Useful for a shared instance where readers want different languages.
|
||||
- **Metadata providers** — Book, audiobook, and combined-mode provider for this user
|
||||
- **Default release sources** — The release tab opened first for books and audiobooks
|
||||
|
||||
### Notifications
|
||||
|
||||
Users can configure personal notification routes, separate from the global notification settings. Each route targets a URL (e.g. an Apprise-compatible endpoint) and can be scoped to specific event types or all events.
|
||||
|
||||
+6
-6
@@ -19,10 +19,10 @@ dependencies = [
|
||||
"psutil",
|
||||
"emoji",
|
||||
"rarfile",
|
||||
"qbittorrent-api>=2026.8.0",
|
||||
"qbittorrent-api>=2026.8.1",
|
||||
"transmission-rpc",
|
||||
"authlib>=1.7.2,<1.8",
|
||||
"apprise>=1.12.0",
|
||||
"authlib>=1.8.0,<1.9",
|
||||
"apprise>=1.13.1",
|
||||
# HTTP/2 client for RFC 8484 DoH: quad9 rejects HTTP/1.1 outright (505), which
|
||||
# requests cannot speak. See shelfmark/download/doh_wireformat.py.
|
||||
"httpx[http2]>=0.28.1",
|
||||
@@ -32,18 +32,18 @@ dependencies = [
|
||||
browser = [
|
||||
"pyvirtualdisplay",
|
||||
"pyautogui",
|
||||
"seleniumbase==4.51.12",
|
||||
"seleniumbase==4.54.9",
|
||||
"python-xlib",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"basedpyright>=1.39.10",
|
||||
"basedpyright>=1.40.1",
|
||||
"prek",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"ruff==0.16.3",
|
||||
"ruff==0.16.8",
|
||||
"vulture>=2.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -95,6 +95,30 @@ volumes:
|
||||
- Aggregates releases from multiple configured sources
|
||||
- Full audiobook support
|
||||
|
||||
### Hardcover API Key
|
||||
|
||||
Hardcover powers metadata search in Universal mode. Create a token at
|
||||
[hardcover.app/account/api](https://hardcover.app/account/api) — current keys start with `hc_pat_`
|
||||
and are far shorter than the JWTs Hardcover issued before August 2026.
|
||||
|
||||
Tick these seven scopes on the token screen:
|
||||
|
||||
| Scope | Used for |
|
||||
|-------|----------|
|
||||
| `read:catalog` | Metadata search, plus book, edition, author and series lookups |
|
||||
| `read:library` | Your reading status and shelf counts |
|
||||
| `read:lists` | Your lists and the books on them |
|
||||
| `read:me:content` | Test Connection and the "Connected as" label |
|
||||
| `read:users` | Usernames shown alongside lists |
|
||||
| `write:library` | Setting a book's reading status from Shelfmark |
|
||||
| `write:lists` | Adding and removing books from lists, including auto-remove on download |
|
||||
|
||||
The two `write:` scopes matter only if you set reading status from Shelfmark or leave
|
||||
**Auto-Remove from List on Download** enabled (it is on by default) — without them those actions
|
||||
fail silently. Everything else Hardcover offers (journal, goals, reviews, prompts, notifications,
|
||||
account) can stay unticked. The `all` scope works too, but it grants full account access including
|
||||
deletion, so prefer the list above.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Environment variables work for initial setup and Docker deployments. They serve as defaults that can be overridden in the web interface.
|
||||
@@ -123,6 +147,7 @@ See the full [Environment Variables Reference](docs/environment-variables.md) fo
|
||||
Some of the additional options available in Settings:
|
||||
- **Prowlarr** - Configure indexers and download clients to download books and audiobooks
|
||||
- **Additional audiobook sources** - Configure additional sources for audiobook discovery
|
||||
- **Direct Download mirrors** - Supply your own Anna's Archive mirror URLs; Auto mode tries them in the order listed. The `annas-archive.is` domain does not currently work as a source — use `annas-archive.gl` instead (checked August 2026; mirror availability changes)
|
||||
- **IRC** - Add details for IRC book sources and download directly from the UI. Most networks serve audiobooks from the same channel as ebooks (on `irc.irchighway.net` that's `#ebooks`, while `#bookz` is effectively inactive), so leave the separate audiobook channel blank unless your network actually indexes one. IRC audiobooks usually arrive as ZIP/RAR archives — keep those enabled under Supported Audiobook Formats or the releases are filtered out of results
|
||||
- **Library Link** - Add a link to your Calibre-Web or Grimmory instance in the UI header
|
||||
- **File processing** - Customiseable download paths, file renaming and directory creation with template-based renaming
|
||||
@@ -223,6 +248,8 @@ volumes:
|
||||
|
||||
With any authentication method enabled, Shelfmark supports multi-user management with admin/user roles. Users can have per-user settings for download destinations, email recipients, and notification preferences. Non-admin users only see their own downloads and can submit book requests for admin review. Admins can configure request policies per source to control whether users can download directly, must submit a request, or are blocked entirely.
|
||||
|
||||
See [API Access](docs/api-access.md) to call the API with a static key from scripts and integrations.
|
||||
|
||||
## Project Scope
|
||||
|
||||
Shelfmark is a manual search and download tool, the entry point to your book library, not a library manager. It finds books, downloads them, and sends them to a configured destination. That's the full scope.
|
||||
|
||||
@@ -184,6 +184,12 @@ def _generate_bootstrap_env_docs() -> list[str]:
|
||||
"type": "boolean",
|
||||
"default": "false",
|
||||
},
|
||||
{
|
||||
"name": "SHELFMARK_API_KEY",
|
||||
"description": "Optional static API key. When set, requests carrying it as 'Authorization: Bearer <key>' (or X-Api-Key) are authenticated as an admin; browser sessions keep working. Unset = off.",
|
||||
"type": "string",
|
||||
"default": "unset",
|
||||
},
|
||||
{
|
||||
"name": "OIDC_AUTO_REDIRECT",
|
||||
"description": "Automatically redirect to the OIDC provider instead of showing the login page.",
|
||||
|
||||
@@ -3,3 +3,14 @@
|
||||
|
||||
class BypassCancelledError(Exception):
|
||||
"""Raised when a bypass operation is cancelled."""
|
||||
|
||||
|
||||
class ChallengeNotSolvedError(Exception):
|
||||
"""Raised when a bypasser ran but the site still answered with a challenge.
|
||||
|
||||
Distinct from a bypasser that is broken or unreachable, which is what every
|
||||
"the bypass failed" message used to say. A solver can do its job perfectly and
|
||||
still be handed something it cannot clear - DDoS-Guard's manual CAPTCHA page is
|
||||
the case from #1292 - and telling the user to go check that FlareSolverr is
|
||||
reachable sends them to fix a service that is working.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Challenge-page detection shared by the bypassers and the HTTP retry path.
|
||||
|
||||
Kept out of `internal_bypasser` so the HTTP layer can recognise an interstitial
|
||||
without importing SeleniumBase: that module is imported lazily precisely because its
|
||||
browser dependencies are optional, and external-bypasser setups run without them.
|
||||
"""
|
||||
|
||||
# Matched against lowercased text, so every entry must be lowercase.
|
||||
CLOUDFLARE_INDICATORS = [
|
||||
"just a moment",
|
||||
"verify you are human",
|
||||
"verifying you are human",
|
||||
"cloudflare.com/products/turnstile",
|
||||
]
|
||||
|
||||
DDOS_GUARD_INDICATORS = [
|
||||
"ddos-guard",
|
||||
"ddos guard",
|
||||
"checking your browser before accessing",
|
||||
"complete the manual check to continue",
|
||||
"could not verify your browser automatically",
|
||||
]
|
||||
|
||||
# Markers that exist only in raw markup: the bypassers scan rendered innerText, where
|
||||
# a script src or a <title> never appears. The title match is scoped to the tag on
|
||||
# purpose - hosts word the rest of that sentence differently, and matching "checking
|
||||
# your browser" as free text would trip on any page that merely discusses a challenge.
|
||||
_RAW_HTML_MARKERS = (
|
||||
"<title>checking your browser",
|
||||
"/cdn-cgi/challenge-platform",
|
||||
"/.well-known/ddos-guard/",
|
||||
)
|
||||
|
||||
# An interstitial is a few KB of markup. Past that it is a real page that happens to
|
||||
# mention a marker - a protected site links its own DDoS-Guard endpoints on every page.
|
||||
MAX_CHALLENGE_HTML_CHARS = 64 * 1024
|
||||
|
||||
|
||||
def challenge_marker(html: str) -> str | None:
|
||||
"""Return the marker proving `html` is an unsolved challenge page, or None.
|
||||
|
||||
Only meaningful for a response that already carries a challenge status: the
|
||||
markers appear on protected sites' real pages too, so the status is what
|
||||
separates "blocked" from "served".
|
||||
"""
|
||||
if not html or len(html) > MAX_CHALLENGE_HTML_CHARS:
|
||||
return None
|
||||
lowered = html.lower()
|
||||
for marker in (*_RAW_HTML_MARKERS, *DDOS_GUARD_INDICATORS, *CLOUDFLARE_INDICATORS):
|
||||
if marker in lowered:
|
||||
return marker
|
||||
return None
|
||||
@@ -38,6 +38,10 @@ DDG_COOKIE_NAMES = {
|
||||
"ddg_last_challenge",
|
||||
}
|
||||
|
||||
# Anna's Archive's own pass for its ?check=1 hop. Without it the hop 302s back
|
||||
# forever, however good the __ddg* clearance is.
|
||||
AA_COOKIE_NAMES = {"aa_ddg_check"}
|
||||
|
||||
# DDoS-Guard cookies that describe *one* check rather than granting clearance, and so
|
||||
# must never be replayed on a later request. Observed live on Anna's Archive:
|
||||
#
|
||||
@@ -71,17 +75,25 @@ def _get_full_cookie_domains() -> set[str]:
|
||||
return {_get_base_domain(domain) for domain in get_zlib_cookie_domains()}
|
||||
|
||||
|
||||
def _replay_per_check_cookies() -> bool:
|
||||
"""Whether the per-check trio is kept rather than dropped (see env.py)."""
|
||||
from shelfmark.config import env
|
||||
|
||||
return env.DDG_REPLAY_PER_CHECK_COOKIES
|
||||
|
||||
|
||||
def _should_extract_cookie(name: str, *, extract_all: bool) -> bool:
|
||||
"""Determine if a cookie should be extracted based on its name."""
|
||||
# Checked before extract_all: a per-check token is wrong to replay for every
|
||||
# domain, including the full-session ones.
|
||||
if name in DDG_EPHEMERAL_COOKIE_NAMES:
|
||||
if name in DDG_EPHEMERAL_COOKIE_NAMES and not _replay_per_check_cookies():
|
||||
return False
|
||||
if extract_all:
|
||||
return True
|
||||
is_cf = name in CF_COOKIE_NAMES or name.startswith("cf_")
|
||||
is_ddg = name in DDG_COOKIE_NAMES or name.startswith("__ddg")
|
||||
return is_cf or is_ddg
|
||||
is_aa = name in AA_COOKIE_NAMES
|
||||
return is_cf or is_ddg or is_aa
|
||||
|
||||
|
||||
def _cookie_field(cookie: Any, name: str) -> Any:
|
||||
@@ -138,9 +150,11 @@ def store_extracted_cookies(
|
||||
extract_all = base_domain in _get_full_cookie_domains()
|
||||
|
||||
cookies_found: dict[str, dict[str, Any]] = {}
|
||||
dropped: list[str] = []
|
||||
for cookie in cookies:
|
||||
name = _cookie_field(cookie, "name") or ""
|
||||
if not _should_extract_cookie(name, extract_all=extract_all):
|
||||
dropped.append(name)
|
||||
continue
|
||||
secure = _cookie_field(cookie, "secure")
|
||||
cookies_found[name] = {
|
||||
@@ -152,6 +166,18 @@ def store_extracted_cookies(
|
||||
"httpOnly": True,
|
||||
}
|
||||
|
||||
# Names only, never values. Which cookies a solve won, and which of them were held
|
||||
# back, is the evidence needed to settle what DDoS-Guard actually treats as clearance
|
||||
# (issue #1276) - and without it a debug log shows a solve succeeding and the next
|
||||
# request being challenged with nothing in between to explain why.
|
||||
logger.debug(
|
||||
"Solve on %s won %s; keeping %s; dropping %s",
|
||||
base_domain,
|
||||
sorted({_cookie_field(c, "name") or "" for c in cookies}),
|
||||
sorted(cookies_found),
|
||||
sorted(set(dropped)) or "nothing",
|
||||
)
|
||||
|
||||
if not cookies_found:
|
||||
return
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.bypass import BypassCancelledError
|
||||
from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError
|
||||
from shelfmark.bypass.challenge import challenge_marker
|
||||
from shelfmark.bypass.cookie_store import store_extracted_cookies
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
@@ -91,7 +92,13 @@ def _store_solution_clearance(target_url: str, solution: Mapping[str, Any]) -> N
|
||||
|
||||
|
||||
def _fetch_via_bypasser(target_url: str) -> str | None:
|
||||
"""Make a single request to the external bypasser service. Returns HTML or None."""
|
||||
"""Make a single request to the external bypasser service. Returns HTML or None.
|
||||
|
||||
Raises:
|
||||
ChallengeNotSolvedError: the service answered with a page that is still a
|
||||
challenge, whatever verdict it reported on itself.
|
||||
|
||||
"""
|
||||
raw_bypasser_url = _coerce_config_str(
|
||||
config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191"),
|
||||
"http://flaresolverr:8191",
|
||||
@@ -143,6 +150,32 @@ def _fetch_via_bypasser(target_url: str) -> str | None:
|
||||
logger.warning("External bypasser returned empty response for '%s'", target_url)
|
||||
return None
|
||||
|
||||
# "Challenge solved!" is the solver's verdict on its own work, and #1289 showed
|
||||
# it can be reported alongside a page the caller then rejects. Say what actually
|
||||
# came back, so a later report does not have to infer it from downstream errors.
|
||||
marker = challenge_marker(html)
|
||||
logger.debug(
|
||||
"External bypasser page for '%s': %d bytes, challenge_marker=%r",
|
||||
target_url,
|
||||
len(html),
|
||||
marker,
|
||||
)
|
||||
if marker:
|
||||
# The solver's verdict is not evidence; the page is. Returning this one as a
|
||||
# success is what made #1292 unrecoverable: the retry-and-rotate loop that
|
||||
# could still have saved the search - the next mirror is a different
|
||||
# DDoS-Guard host, in its own state - was never entered, and the challenge
|
||||
# page's own __ddg cookies were filed as this host's clearance and replayed
|
||||
# on every later request.
|
||||
logger.warning(
|
||||
"External bypasser reported success but returned a challenge page for "
|
||||
"'%s' (%d bytes, marker=%r) - the solve did not clear the protection",
|
||||
target_url,
|
||||
len(html),
|
||||
marker,
|
||||
)
|
||||
raise ChallengeNotSolvedError(marker)
|
||||
|
||||
try:
|
||||
_store_solution_clearance(target_url, solution)
|
||||
except AttributeError, KeyError, TypeError, ValueError:
|
||||
@@ -192,16 +225,33 @@ def get_bypassed_page(
|
||||
selector: network.AAMirrorSelector | None = None,
|
||||
cancel_flag: Event | None = None,
|
||||
) -> str | None:
|
||||
"""Fetch HTML via external bypasser with retries and mirror rotation."""
|
||||
"""Fetch HTML via external bypasser with retries and mirror rotation.
|
||||
|
||||
Raises:
|
||||
ChallengeNotSolvedError: every attempt came back still carrying a challenge.
|
||||
Reported apart from returning None because the two ask the user for
|
||||
opposite things: None means go and check the bypasser, this means the
|
||||
bypasser is fine and the host is the one refusing.
|
||||
BypassCancelledError: the caller's cancel flag was set.
|
||||
|
||||
"""
|
||||
from shelfmark.download import network as network_module
|
||||
|
||||
sel = selector or network_module.AAMirrorSelector()
|
||||
unsolved_marker: str | None = None
|
||||
|
||||
for attempt in range(1, MAX_RETRY + 1):
|
||||
_check_cancelled(cancel_flag, "by user")
|
||||
|
||||
attempt_url = sel.rewrite(url)
|
||||
result = _fetch_via_bypasser(attempt_url)
|
||||
try:
|
||||
result = _fetch_via_bypasser(attempt_url)
|
||||
except ChallengeNotSolvedError as e:
|
||||
# Worth the remaining attempts rather than an immediate give-up: the retry
|
||||
# rotates onto the next mirror, and that is a different DDoS-Guard host with
|
||||
# its own idea of whether this caller needs a CAPTCHA.
|
||||
unsolved_marker = str(e) or unsolved_marker
|
||||
result = None
|
||||
if result:
|
||||
return result
|
||||
|
||||
@@ -222,4 +272,11 @@ def get_bypassed_page(
|
||||
if action in ("mirror", "dns") and new_base:
|
||||
logger.info("Rotated %s for retry", action)
|
||||
|
||||
if unsolved_marker:
|
||||
msg = (
|
||||
"The bypasser ran, but the site kept answering with a protection challenge "
|
||||
f"(marker={unsolved_marker!r}). That is usually a manual CAPTCHA, which no "
|
||||
"bypasser can answer - the bypasser itself is working. Try again shortly."
|
||||
)
|
||||
raise ChallengeNotSolvedError(msg)
|
||||
return None
|
||||
|
||||
@@ -27,6 +27,7 @@ from seleniumbase import cdp_driver
|
||||
from seleniumbase.undetected.cdp_driver.connection import ProtocolException
|
||||
|
||||
from shelfmark.bypass import BypassCancelledError
|
||||
from shelfmark.bypass.challenge import CLOUDFLARE_INDICATORS, DDOS_GUARD_INDICATORS
|
||||
from shelfmark.bypass.cookie_store import (
|
||||
clear_cf_cookies,
|
||||
export_store,
|
||||
@@ -36,6 +37,7 @@ from shelfmark.bypass.cookie_store import (
|
||||
store_extracted_cookies,
|
||||
)
|
||||
from shelfmark.bypass.fingerprint import get_screen_size
|
||||
from shelfmark.bypass.waiting_room import WaitingRoomTimeoutError, is_aa_waiting_room
|
||||
from shelfmark.config import env
|
||||
from shelfmark.config.env import LOG_DIR
|
||||
from shelfmark.config.settings import RECORDING_DIR
|
||||
@@ -57,32 +59,53 @@ _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
|
||||
# How long a cancelled bypass may take to close its browser before the calling thread
|
||||
# stops waiting for it. Counted on top of the bypass deadline, so every budget below is
|
||||
# set to leave room for it.
|
||||
_CDP_UNWIND_GRACE_SECONDS = 15.0
|
||||
# Same wall-clock budget as the Docker helper process, applied to the in-process CDP path
|
||||
# so both branches of get() are bounded the same way: the deadline plus the unwind grace
|
||||
# comes to _BYPASS_SUBPROCESS_TIMEOUT_SECONDS either way.
|
||||
_IN_PROCESS_BYPASS_TIMEOUT_SECONDS = _BYPASS_SUBPROCESS_TIMEOUT_SECONDS - _CDP_UNWIND_GRACE_SECONDS
|
||||
_BYPASS_CHILD_ENV = "SHELFMARK_INTERNAL_BYPASSER_CHILD"
|
||||
# The helper bounds each bypass below the parent's deadline, so it is the side that gives
|
||||
# up first: it still gets to report the timeout and close its browser, and stays available
|
||||
# for the next request. A parent that hit its deadline first could only kill the helper,
|
||||
# throwing away a process the next request would have to start again. The 30s covers the
|
||||
# unwind grace as well, so a helper that times out and closes its browser as slowly as it
|
||||
# is allowed to still answers with 15s to spare.
|
||||
_CHILD_BYPASS_TIMEOUT_SECONDS = _BYPASS_SUBPROCESS_TIMEOUT_SECONDS - 30.0
|
||||
# The helper publishes its answer by writing the result file the request named, so the
|
||||
# parent waits by watching for that file rather than by reading a stream it would have to
|
||||
# demultiplex from the helper's log output.
|
||||
_HELPER_RESULT_POLL_SECONDS = 0.05
|
||||
# Closing the helper's stdin asks it to shut down; this is how long it may take to finish
|
||||
# what it is doing and exit before its session is killed instead.
|
||||
_HELPER_SHUTDOWN_GRACE_SECONDS = 15.0
|
||||
_HELPER_IDLE_TIMEOUT_DEFAULT = 180.0
|
||||
# How long to wait for a solved page to produce its document before the attempt is
|
||||
# abandoned. SeleniumBase's own get_page_source() allows one second; see _read_page_source.
|
||||
_PAGE_SOURCE_TIMEOUT_DEFAULT = 20.0
|
||||
# Leave time inside the existing browser watchdog for challenge solving and cleanup.
|
||||
_AA_WAITING_ROOM_TIMEOUT_SECONDS = 300.0
|
||||
_AA_WAITING_ROOM_POLL_SECONDS = 1.0
|
||||
_PARENT_WATCHDOG_INTERVAL_SECONDS = 5.0
|
||||
# How much of ffmpeg's stderr to quote when reporting that it died.
|
||||
_FFMPEG_ERROR_TAIL_CHARS = 500
|
||||
|
||||
# Challenge detection indicators
|
||||
CLOUDFLARE_INDICATORS = [
|
||||
"just a moment",
|
||||
"verify you are human",
|
||||
"verifying you are human",
|
||||
"cloudflare.com/products/turnstile",
|
||||
]
|
||||
|
||||
DDOS_GUARD_INDICATORS = [
|
||||
"ddos-guard",
|
||||
"ddos guard",
|
||||
"checking your browser before accessing",
|
||||
"complete the manual check to continue",
|
||||
"could not verify your browser automatically",
|
||||
]
|
||||
class _WaitingRoomSnapshot(TypedDict):
|
||||
html: str
|
||||
title: str
|
||||
body: str
|
||||
url: str
|
||||
waiting: bool
|
||||
|
||||
|
||||
class _DisplayState(TypedDict):
|
||||
ffmpeg: subprocess.Popen[bytes] | None
|
||||
ffmpeg_output: Path | None
|
||||
ffmpeg_error_log: Path | None
|
||||
|
||||
|
||||
class _PageWithWindowRect(Protocol):
|
||||
@@ -96,6 +119,7 @@ class _BrowserWithWindowRectPage(Protocol):
|
||||
DISPLAY: _DisplayState = {
|
||||
"ffmpeg": None,
|
||||
"ffmpeg_output": None,
|
||||
"ffmpeg_error_log": None,
|
||||
}
|
||||
LOCKED = threading.Lock()
|
||||
_PROC_ROOT = Path("/proc")
|
||||
@@ -222,14 +246,33 @@ class _CdpWorker:
|
||||
msg = "CDP worker loop failed to start"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
@staticmethod
|
||||
async def _bounded(coro: Any, timeout: float | None) -> Any:
|
||||
"""Run the coroutine under its deadline, on the loop that owns it.
|
||||
|
||||
The deadline has to be enforced from inside the loop rather than by the calling
|
||||
thread: asyncio.wait_for() cancels the bypass and then *waits for it to unwind*,
|
||||
so `finally: await _close_cdp_driver(driver)` has finished by the time this
|
||||
raises. Cancelling from outside returns the moment the cancellation is scheduled,
|
||||
which in a helper serving many requests let the abandoned bypass close its browser
|
||||
while the next one was already opening its own - on the same loop, sharing the
|
||||
DISPLAY globals and one process group.
|
||||
"""
|
||||
if timeout is None:
|
||||
return await coro
|
||||
return await asyncio.wait_for(coro, timeout)
|
||||
|
||||
def run(self, coro: Any, timeout: float | None = None) -> Any:
|
||||
self.start()
|
||||
if not self._loop or self._loop.is_closed():
|
||||
msg = "CDP worker loop not available"
|
||||
raise RuntimeError(msg)
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
future = asyncio.run_coroutine_threadsafe(self._bounded(coro, timeout), self._loop)
|
||||
# Backstop for an unwind that wedges too: _close_cdp_driver awaits websockets that
|
||||
# a dead browser may never answer, and _bounded cannot outlive its own cleanup.
|
||||
wait_for = None if timeout is None else timeout + _CDP_UNWIND_GRACE_SECONDS
|
||||
try:
|
||||
return future.result(timeout=timeout)
|
||||
return future.result(timeout=wait_for)
|
||||
except TimeoutError:
|
||||
# Otherwise the coroutine keeps running in the worker loop after we stop
|
||||
# waiting, holding the browser and racing the next bypass.
|
||||
@@ -423,6 +466,14 @@ async def _detect_challenge_type(page: Any) -> str:
|
||||
async def _is_bypassed(page: Any, *, escape_emojis: bool = True) -> bool:
|
||||
"""Check if the protection has been bypassed."""
|
||||
title, body, current_url = await _get_page_info(page)
|
||||
return _is_bypassed_content(title, body, current_url, escape_emojis=escape_emojis)
|
||||
|
||||
|
||||
def _is_bypassed_content(
|
||||
title: str, body: str, current_url: str, *, escape_emojis: bool = True
|
||||
) -> bool:
|
||||
"""Apply the same protection checks to one consistent page snapshot."""
|
||||
title, body = title.lower(), body.lower()
|
||||
body_len = len(body.strip())
|
||||
|
||||
# Long page content = probably bypassed
|
||||
@@ -494,18 +545,6 @@ async def _bypass_method_humanlike(page: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def _bypass_method_cdp_solve(page: Any) -> bool:
|
||||
"""CDP Mode with solve_captcha() - auto-detects challenge type."""
|
||||
try:
|
||||
logger.debug("Attempting bypass: CDP solve_captcha")
|
||||
await page.solve_captcha()
|
||||
await asyncio.sleep(_RNG.uniform(3, 5))
|
||||
return await _is_bypassed(page)
|
||||
except _CDP_OPERATION_ERRORS as e:
|
||||
logger.debug("CDP solve_captcha failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
CDP_CLICK_SELECTORS = [
|
||||
"#turnstile-widget div", # Cloudflare Turnstile
|
||||
"#cf-turnstile div", # Alternative CF Turnstile
|
||||
@@ -585,8 +624,13 @@ async def _bypass_method_cdp_gui_click(page: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# Ordered cheapest-first, and deliberately without a bare `solve_captcha()` entry:
|
||||
# _bypass_method_cdp_gui_click opens by doing exactly that and returns the moment it
|
||||
# works, so a separate method ahead of it could only ever repeat the half that had
|
||||
# already failed - one wasted round trip plus the backoff before the next attempt, on
|
||||
# every solve that gets this far. Measured at ~5.5s of the ~26s each solve cost, and
|
||||
# 0/19 successes for the standalone method against DDoS-Guard. See issue #1285.
|
||||
BYPASS_METHODS = [
|
||||
_bypass_method_cdp_solve,
|
||||
_bypass_method_cdp_gui_click,
|
||||
_bypass_method_cdp_click,
|
||||
_bypass_method_humanlike,
|
||||
@@ -594,6 +638,25 @@ BYPASS_METHODS = [
|
||||
|
||||
MAX_CONSECUTIVE_SAME_CHALLENGE = 3
|
||||
|
||||
# How many method attempts one _bypass() pass may make. Deliberately *not* MAX_RETRY:
|
||||
# that value is already the outer page-load retry in _run_bypass_in_current_process, and
|
||||
# reading it here too squared the budget - the default 10 meant 10 page loads x 4 methods
|
||||
# = 40 solve attempts on one browser, which overruns the worker deadline and reports
|
||||
# `TimeoutError` instead of a plain "bypass failed". One full pass through the methods
|
||||
# plus a spare is all this loop can use anyway: the stuck-challenge guard below aborts at
|
||||
# len(BYPASS_METHODS) + 1, so a larger number here only ever showed up in the logs.
|
||||
_BYPASS_METHOD_ATTEMPTS = len(BYPASS_METHODS) + 1
|
||||
|
||||
# The undisturbed window a passive challenge gets before any method runs. Sized off the
|
||||
# real thing: a desktop browser clears Anna's Archive's DDoS-Guard JS check in under 10s.
|
||||
_PASSIVE_SOLVE_SECONDS = 15.0
|
||||
_PASSIVE_SOLVE_POLL_SECONDS = 1.0
|
||||
|
||||
# Head-room the retry loop leaves itself so it can return a real failure rather than be
|
||||
# cancelled at the worker deadline. Enough for the pass in flight to unwind and the
|
||||
# browser to close.
|
||||
_RESERVE_FOR_CLEAN_FAILURE_SECONDS = 60.0
|
||||
|
||||
|
||||
def _check_cancellation(cancel_flag: Event | None, message: str) -> None:
|
||||
"""Check if cancellation was requested and raise if so."""
|
||||
@@ -603,13 +666,26 @@ def _check_cancellation(cancel_flag: Event | None, message: str) -> None:
|
||||
raise BypassCancelledError(msg)
|
||||
|
||||
|
||||
async def _wait_for_passive_solve(page: Any, cancel_flag: Event | None = None) -> bool:
|
||||
"""Poll for a challenge that clears itself, without touching the page.
|
||||
|
||||
Returns True as soon as the page looks bypassed, False once the window is spent.
|
||||
"""
|
||||
logger.info("Waiting up to %.0fs for the challenge to clear itself...", _PASSIVE_SOLVE_SECONDS)
|
||||
deadline = time.monotonic() + _PASSIVE_SOLVE_SECONDS
|
||||
while time.monotonic() < deadline:
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled while waiting for a passive solve")
|
||||
await asyncio.sleep(_PASSIVE_SOLVE_POLL_SECONDS)
|
||||
if await _is_bypassed(page):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _bypass(
|
||||
page: Any, max_retries: int | None = None, cancel_flag: Event | None = None
|
||||
) -> bool:
|
||||
"""Attempt to bypass Cloudflare/DDOS-Guard protection using multiple methods."""
|
||||
max_retries = (
|
||||
max_retries if max_retries is not None else _coerce_positive_int(app_config.MAX_RETRY, 10)
|
||||
)
|
||||
max_retries = max_retries if max_retries is not None else _BYPASS_METHOD_ATTEMPTS
|
||||
|
||||
last_challenge_type = None
|
||||
consecutive_same_challenge = 0
|
||||
@@ -627,6 +703,20 @@ async def _bypass(
|
||||
challenge_type = await _detect_challenge_type(page)
|
||||
logger.debug("Challenge detected: %s", challenge_type)
|
||||
|
||||
# Give a passive check the undisturbed window it needs before touching the page.
|
||||
# DDoS-Guard's JS check on Anna's Archive has no click target: it runs, then
|
||||
# navigates on its own - a desktop browser clears it in well under 15s. Every
|
||||
# method below either clicks a selector that is not there or reloads, and a reload
|
||||
# restarts an in-flight check (which DDoS-Guard also throttles), so going straight
|
||||
# to them meant the one thing that actually solves this challenge was the one
|
||||
# thing never tried. Costs one 15s window per solve against a minutes-long budget,
|
||||
# and a challenge that needs interaction simply falls through to the methods.
|
||||
if try_count == 0 and challenge_type != "none":
|
||||
if await _wait_for_passive_solve(page, cancel_flag):
|
||||
logger.info("Bypass successful: %s challenge cleared itself", challenge_type)
|
||||
return True
|
||||
logger.debug("Challenge did not clear on its own; trying bypass methods")
|
||||
|
||||
# No challenge detected but page doesn't look bypassed - wait and retry
|
||||
if challenge_type == "none":
|
||||
logger.info("No challenge detected, waiting for page to settle...")
|
||||
@@ -745,6 +835,91 @@ def _build_host_resolver_rules() -> list[str]:
|
||||
DRIVER_RESET_ERRORS = {"ProtocolException", "RuntimeError", "TimeoutError"}
|
||||
|
||||
|
||||
async def _read_page_source(page: Any) -> str:
|
||||
"""Read a solved page's HTML, waiting for the document to arrive.
|
||||
|
||||
`get_page_source()` waits one second for the `html` element. A page released from a
|
||||
challenge is often still navigating to the real content, so the read times out even
|
||||
though the solve succeeded: the whole attempt is retried, and the repeated requests
|
||||
are what earn a 429 from a host that was about to serve us.
|
||||
"""
|
||||
timeout = _coerce_non_negative_float(
|
||||
app_config.get("BYPASS_PAGE_SOURCE_TIMEOUT", _PAGE_SOURCE_TIMEOUT_DEFAULT),
|
||||
_PAGE_SOURCE_TIMEOUT_DEFAULT,
|
||||
)
|
||||
element = await page.find("html", timeout=timeout)
|
||||
return await element.get_html_async()
|
||||
|
||||
|
||||
async def _read_waiting_room_snapshot(
|
||||
page: Any, cancel_flag: Event | None
|
||||
) -> _WaitingRoomSnapshot | None:
|
||||
"""Read one DOM snapshot while still checking cancellation during a stalled read."""
|
||||
task = asyncio.create_task(
|
||||
page.evaluate("""({
|
||||
html: document.documentElement?.outerHTML || '',
|
||||
title: document.title,
|
||||
body: document.body?.innerText || '',
|
||||
url: location.href,
|
||||
waiting: !!document.querySelector('.js-partner-countdown')
|
||||
})""")
|
||||
)
|
||||
try:
|
||||
while not task.done():
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled in Anna's waiting room")
|
||||
# Keep a slow CDP request alive. Cancelling and reissuing it on every
|
||||
# poll can break the listener when a late response targets a cancelled
|
||||
# SeleniumBase transaction. Cancel only when this browser is unwinding.
|
||||
await asyncio.wait({task}, timeout=_AA_WAITING_ROOM_POLL_SECONDS)
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled in Anna's waiting room")
|
||||
return task.result()
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
|
||||
async def _wait_for_aa_download_page(
|
||||
page: Any, url: str, html: str, cancel_flag: Event | None = None
|
||||
) -> str:
|
||||
"""Let the waiting room's own JavaScript countdown and navigation finish.
|
||||
|
||||
Returning the timer HTML closes this incognito browser. Sleeping in the HTTP
|
||||
downloader and fetching again then starts a different session, losing queue state.
|
||||
Keep the original tab alive, including through zero and automatic page reloads.
|
||||
"""
|
||||
if not is_aa_waiting_room(url, html):
|
||||
return html
|
||||
|
||||
logger.info("Waiting for Anna's Archive queue in the same browser session...")
|
||||
started = time.monotonic()
|
||||
try:
|
||||
async with asyncio.timeout(_AA_WAITING_ROOM_TIMEOUT_SECONDS):
|
||||
while True:
|
||||
try:
|
||||
# Read readiness and HTML atomically: navigation between separate
|
||||
# CDP reads could validate a new page but return an old interstitial.
|
||||
snapshot = await _read_waiting_room_snapshot(page, cancel_flag)
|
||||
except _CDP_OPERATION_ERRORS:
|
||||
# A frame/context can disappear during automatic navigation.
|
||||
snapshot = None
|
||||
if (
|
||||
snapshot
|
||||
and not snapshot["waiting"]
|
||||
and _is_bypassed_content(snapshot["title"], snapshot["body"], snapshot["url"])
|
||||
):
|
||||
logger.info(
|
||||
"Anna's Archive waiting room finished after %.0fs",
|
||||
time.monotonic() - started,
|
||||
)
|
||||
return snapshot["html"]
|
||||
# A zero timer, empty document, or protection page is not completion.
|
||||
await asyncio.sleep(_AA_WAITING_ROOM_POLL_SECONDS)
|
||||
except TimeoutError as exc:
|
||||
raise WaitingRoomTimeoutError(
|
||||
f"Anna's Archive waiting room did not finish within {_AA_WAITING_ROOM_TIMEOUT_SECONDS:g}s"
|
||||
) from exc
|
||||
|
||||
|
||||
async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str:
|
||||
"""Fetch URL with Cloudflare bypass using a CDP browser."""
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled before starting")
|
||||
@@ -767,8 +942,10 @@ async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str:
|
||||
|
||||
logger.debug("Starting bypass process...")
|
||||
if await _bypass(page, cancel_flag=cancel_flag):
|
||||
html = await _read_page_source(page)
|
||||
html = await _wait_for_aa_download_page(page, url, html, cancel_flag)
|
||||
await _extract_cookies_from_cdp(driver, page, url)
|
||||
return await page.get_page_source()
|
||||
return html
|
||||
|
||||
logger.warning("Bypass completed but page still shows protection")
|
||||
try:
|
||||
@@ -786,20 +963,40 @@ async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str:
|
||||
|
||||
def _run_bypass_in_current_process(url: str, retry: int, cancel_flag: Event | None = None) -> str:
|
||||
"""Run the CDP bypass in the current process."""
|
||||
timeout = (
|
||||
_CHILD_BYPASS_TIMEOUT_SECONDS
|
||||
if os.environ.get(_BYPASS_CHILD_ENV) == "1"
|
||||
else _IN_PROCESS_BYPASS_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
async def _run_bypass() -> str:
|
||||
driver = None
|
||||
# Stop retrying while there is still time to say so. A challenge nothing can solve
|
||||
# would otherwise spend every one of `retry` passes and be cut off mid-pass by the
|
||||
# worker deadline, which surfaces to the caller as `RuntimeError: TimeoutError` -
|
||||
# a message that says nothing about protection and sent users looking at their
|
||||
# reverse proxy. Giving up a pass early returns the real "bypass failed" instead.
|
||||
deadline = time.monotonic() + timeout - _RESERVE_FOR_CLEAN_FAILURE_SECONDS
|
||||
try:
|
||||
driver = await _create_cdp_browser(url)
|
||||
|
||||
for attempt in range(retry):
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled before attempt")
|
||||
if attempt > 0 and time.monotonic() >= deadline:
|
||||
logger.warning(
|
||||
"Bypass budget spent after %s/%s attempts; giving up on %s",
|
||||
attempt,
|
||||
retry,
|
||||
url,
|
||||
)
|
||||
break
|
||||
|
||||
try:
|
||||
result = await _get(url, driver, cancel_flag)
|
||||
if result:
|
||||
return result
|
||||
except BypassCancelledError:
|
||||
except BypassCancelledError, WaitingRoomTimeoutError:
|
||||
# Retrying would restart the same queue in another browser.
|
||||
raise
|
||||
except _CDP_OPERATION_ERRORS as e:
|
||||
error_details = f"{type(e).__name__}: {e}"
|
||||
@@ -814,19 +1011,24 @@ def _run_bypass_in_current_process(url: str, retry: int, cancel_flag: Event | No
|
||||
await _close_cdp_driver(driver)
|
||||
driver = await _create_cdp_browser(url)
|
||||
|
||||
logger.error("Bypass failed after %s attempts", retry)
|
||||
logger.error("Bypass failed for %s", url)
|
||||
return ""
|
||||
finally:
|
||||
if driver:
|
||||
await _close_cdp_driver(driver)
|
||||
|
||||
if os.environ.get(_BYPASS_CHILD_ENV) == "1":
|
||||
return asyncio.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)
|
||||
# Bound the wait: this 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.
|
||||
#
|
||||
# The helper goes through the worker too, rather than asyncio.run: that owns a loop for
|
||||
# one call and closes it on the way out, so a helper serving many requests would build
|
||||
# and tear down a loop per bypass and would carry no deadline of its own. The worker's
|
||||
# loop lives in a thread, outlives any single bypass, and cancels the coroutine when the
|
||||
# deadline passes. `_run_bypass` aims to finish inside this same budget of its own
|
||||
# accord, so reaching this deadline now means a wedged session rather than a stubborn
|
||||
# challenge - which is the only case worth reporting as a timeout.
|
||||
return _CDP_WORKER.run(_run_bypass(), timeout=timeout)
|
||||
|
||||
|
||||
def _store_child_bypass_state(payload: dict[str, Any]) -> None:
|
||||
@@ -868,6 +1070,213 @@ def _terminate_helper_session(proc: subprocess.Popen[str]) -> None:
|
||||
proc.wait(timeout=5)
|
||||
|
||||
|
||||
def _part_path(result_path: Path) -> Path:
|
||||
"""Where the helper stages a result before renaming it into place."""
|
||||
return result_path.with_name(result_path.name + ".part")
|
||||
|
||||
|
||||
class _BypassHelper:
|
||||
"""The helper subprocess that runs the bypasses, kept alive across them.
|
||||
|
||||
Spawning it costs about 4.5 seconds of interpreter start and imports before any work
|
||||
begins, paid on every protected request - and a single search issues several. What it
|
||||
keeps is the process, not the browser: each bypass still starts and closes its own
|
||||
Chrome, so nothing accumulates between requests.
|
||||
|
||||
Protocol: one JSON request per line on stdin, answered by writing the result file that
|
||||
request named. stdout and stderr stay attached to the parent's, so helper logs keep
|
||||
showing up in `docker logs` as before.
|
||||
|
||||
Only one request is ever in flight - get() serializes every bypass behind LOCKED. The
|
||||
lock here is for the idle reaper, which runs on a timer thread.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._proc: subprocess.Popen[str] | None = None
|
||||
self._last_used = 0.0
|
||||
self._idle_timer: threading.Timer | None = None
|
||||
|
||||
def _idle_timeout(self) -> float:
|
||||
return _coerce_non_negative_float(
|
||||
app_config.get("BYPASS_BROWSER_IDLE_TIMEOUT", _HELPER_IDLE_TIMEOUT_DEFAULT),
|
||||
_HELPER_IDLE_TIMEOUT_DEFAULT,
|
||||
)
|
||||
|
||||
def _spawn(self) -> subprocess.Popen[str]:
|
||||
env_vars = os.environ.copy()
|
||||
env_vars[_BYPASS_CHILD_ENV] = "1"
|
||||
env_vars = _prepare_child_browser_env(env_vars)
|
||||
return subprocess.Popen(
|
||||
[sys.executable, "-m", "shelfmark.bypass.internal_bypasser"],
|
||||
stdin=subprocess.PIPE,
|
||||
text=True,
|
||||
env=env_vars,
|
||||
# Give the helper its own session: Chrome, Xvfb and ffmpeg inherit its process
|
||||
# group, which is what lets the cleanup sweep tell this helper's browsers apart
|
||||
# from a concurrent worker's (#1231) and lets us kill the whole tree below.
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
def _running(self) -> subprocess.Popen[str] | None:
|
||||
proc = self._proc
|
||||
if proc is None:
|
||||
return None
|
||||
if proc.poll() is not None or proc.stdin is None or proc.stdin.closed:
|
||||
return None
|
||||
return proc
|
||||
|
||||
def _ensure_running(self) -> subprocess.Popen[str]:
|
||||
proc = self._running()
|
||||
if proc is not None:
|
||||
return proc
|
||||
if self._proc is not None:
|
||||
logger.info("Bypass helper exited (code %s), starting a new one", self._proc.returncode)
|
||||
self._discard()
|
||||
self._proc = self._spawn()
|
||||
return self._proc
|
||||
|
||||
def _discard(self, *, wait_for_exit: bool = True) -> None:
|
||||
"""Stop the helper and forget it.
|
||||
|
||||
`wait_for_exit` belongs to a helper that could still act on the closed pipe: an
|
||||
idle one is sitting in its stdin read, notices EOF and exits on its own. A helper
|
||||
dropped mid-bypass is blocked inside the solve and will not return to that read,
|
||||
so the grace cannot end in anything but the kill below - and the caller waiting it
|
||||
out is a user cancelling a download, holding LOCKED while every other bypass in
|
||||
the worker queues behind them.
|
||||
"""
|
||||
proc = self._proc
|
||||
self._proc = None
|
||||
if proc is None:
|
||||
return
|
||||
|
||||
# Closing stdin ends the helper's request loop, so an idle helper gets to exit on
|
||||
# its own. One mid-bypass cannot answer, and is killed below.
|
||||
with suppress(OSError):
|
||||
if proc.stdin is not None and not proc.stdin.closed:
|
||||
proc.stdin.close()
|
||||
if wait_for_exit:
|
||||
try:
|
||||
proc.wait(timeout=_HELPER_SHUTDOWN_GRACE_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Bypass helper did not exit on request, killing its session")
|
||||
|
||||
# Tear the session down either way: a helper killed mid-bypass leaves its Chrome
|
||||
# and Xvfb running, and those leftovers are what made the next worker's browser
|
||||
# fail to start. Harmless once it has already exited.
|
||||
_terminate_helper_session(proc)
|
||||
|
||||
def _cancel_idle_timer(self) -> None:
|
||||
if self._idle_timer is not None:
|
||||
self._idle_timer.cancel()
|
||||
self._idle_timer = None
|
||||
|
||||
def _arm_idle_timer(self) -> None:
|
||||
self._cancel_idle_timer()
|
||||
timeout = self._idle_timeout()
|
||||
if self._proc is None or timeout <= 0:
|
||||
return
|
||||
timer = threading.Timer(timeout, self._reap_if_idle)
|
||||
timer.daemon = True
|
||||
self._idle_timer = timer
|
||||
timer.start()
|
||||
|
||||
def _reap_if_idle(self) -> None:
|
||||
with self._lock:
|
||||
if self._proc is None:
|
||||
return
|
||||
idle_for = time.monotonic() - self._last_used
|
||||
timeout = self._idle_timeout()
|
||||
if idle_for < timeout:
|
||||
# A bypass started while this timer was waiting for the lock.
|
||||
self._arm_idle_timer()
|
||||
return
|
||||
logger.info("Closing idle bypass helper after %.0fs without work", idle_for)
|
||||
self._discard()
|
||||
|
||||
def run(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
timeout: float,
|
||||
cancel_flag: Event | None,
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._cancel_idle_timer()
|
||||
try:
|
||||
return self._exchange(payload, timeout, cancel_flag)
|
||||
finally:
|
||||
self._last_used = time.monotonic()
|
||||
self._arm_idle_timer()
|
||||
|
||||
def _exchange(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
timeout: float,
|
||||
cancel_flag: Event | None,
|
||||
) -> dict[str, Any]:
|
||||
request_line = json.dumps(payload) + "\n"
|
||||
proc = self._ensure_running()
|
||||
try:
|
||||
self._write(proc, request_line)
|
||||
except OSError as exc:
|
||||
# A live helper can die between the liveness check and the write, so one retry
|
||||
# on a fresh process. A fresh one failing here is a real failure.
|
||||
logger.info("Bypass helper closed its pipe (%s), retrying on a new one", exc)
|
||||
# Nothing to ask of a helper we cannot write to: its read end is already gone.
|
||||
self._discard(wait_for_exit=False)
|
||||
proc = self._ensure_running()
|
||||
self._write(proc, request_line)
|
||||
|
||||
return self._await_result(proc, Path(str(payload["result_path"])), timeout, cancel_flag)
|
||||
|
||||
def _write(self, proc: subprocess.Popen[str], request_line: str) -> None:
|
||||
if proc.stdin is None:
|
||||
msg = "Bypass helper has no stdin pipe"
|
||||
raise OSError(msg)
|
||||
proc.stdin.write(request_line)
|
||||
proc.stdin.flush()
|
||||
|
||||
def _await_result(
|
||||
self,
|
||||
proc: subprocess.Popen[str],
|
||||
result_path: Path,
|
||||
timeout: float,
|
||||
cancel_flag: Event | None,
|
||||
) -> dict[str, Any]:
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
while not result_path.exists():
|
||||
if proc.poll() is not None:
|
||||
returncode = proc.returncode
|
||||
self._discard(wait_for_exit=False)
|
||||
msg = f"Internal bypasser helper exited without a result (code {returncode})"
|
||||
raise RuntimeError(msg)
|
||||
if cancel_flag is not None and cancel_flag.is_set():
|
||||
# The helper is mid-bypass and cannot be told to stop, so it goes.
|
||||
self._discard(wait_for_exit=False)
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled while waiting for helper")
|
||||
if time.monotonic() >= deadline:
|
||||
self._discard(wait_for_exit=False)
|
||||
msg = "Internal bypasser helper process timed out"
|
||||
raise TimeoutError(msg)
|
||||
time.sleep(_HELPER_RESULT_POLL_SECONDS)
|
||||
|
||||
return json.loads(result_path.read_text(encoding="utf-8"))
|
||||
finally:
|
||||
# Every way out of here is final for this request: either the answer has been
|
||||
# read, or the helper that would have written it has just been killed. Nothing
|
||||
# will write these paths afterwards and nothing will come looking for them, so
|
||||
# they are cleaned on the failure paths too - otherwise every cancelled
|
||||
# download and every wedged solve leaves one behind for the container's life.
|
||||
for path in (result_path, _part_path(result_path)):
|
||||
with suppress(OSError):
|
||||
path.unlink()
|
||||
|
||||
|
||||
_BYPASS_HELPER = _BypassHelper()
|
||||
|
||||
|
||||
def _get_via_subprocess(url: str, retry: int, cancel_flag: Event | None = None) -> str:
|
||||
"""Run the browser bypass in a helper process isolated from gunicorn/gevent."""
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled before helper process")
|
||||
@@ -878,50 +1287,15 @@ def _get_via_subprocess(url: str, retry: int, cancel_flag: Event | None = None)
|
||||
# freshly spawned helper would otherwise pre-resolve AA hostnames against the system
|
||||
# resolver - which may be blocked or hijacked by the user's ISP. Pass the parent's
|
||||
# active DNS config so the helper mirrors it (e.g. DoH) when building Chrome's host
|
||||
# resolver rules.
|
||||
# resolver rules. Sent with every request, not just at spawn, because a helper outlives
|
||||
# changes the parent makes to its DNS provider.
|
||||
payload = {
|
||||
"url": url,
|
||||
"retry": retry,
|
||||
"result_path": str(result_path),
|
||||
"dns_config": network.get_dns_config(),
|
||||
}
|
||||
env_vars = os.environ.copy()
|
||||
env_vars[_BYPASS_CHILD_ENV] = "1"
|
||||
env_vars = _prepare_child_browser_env(env_vars)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "shelfmark.bypass.internal_bypasser"],
|
||||
stdin=subprocess.PIPE,
|
||||
text=True,
|
||||
env=env_vars,
|
||||
# Give the helper its own session: Chrome, Xvfb and ffmpeg inherit its process
|
||||
# group, which is what lets the cleanup sweep tell this bypass's browsers apart
|
||||
# from a concurrent worker's (#1231) and lets us kill the whole tree below.
|
||||
start_new_session=True,
|
||||
)
|
||||
timed_out = False
|
||||
try:
|
||||
proc.communicate(json.dumps(payload), timeout=_BYPASS_SUBPROCESS_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
finally:
|
||||
# Always tear the session down, not just on timeout: killing the helper alone
|
||||
# leaves its Chrome and Xvfb running, and those leftovers are what made the next
|
||||
# worker's browser fail to start in the first place.
|
||||
_terminate_helper_session(proc)
|
||||
|
||||
if timed_out:
|
||||
msg = "Internal bypasser helper process timed out"
|
||||
raise TimeoutError(msg)
|
||||
|
||||
try:
|
||||
result = json.loads(result_path.read_text())
|
||||
except FileNotFoundError as exc:
|
||||
msg = f"Internal bypasser helper exited without a result (code {proc.returncode})"
|
||||
raise RuntimeError(msg) from exc
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
result_path.unlink()
|
||||
result = _BYPASS_HELPER.run(payload, _BYPASS_SUBPROCESS_TIMEOUT_SECONDS, cancel_flag)
|
||||
|
||||
if not isinstance(result, dict):
|
||||
msg = "Internal bypasser helper returned an invalid result"
|
||||
@@ -933,6 +1307,8 @@ def _get_via_subprocess(url: str, retry: int, cancel_flag: Event | None = None)
|
||||
trace = result.get("traceback")
|
||||
if trace:
|
||||
logger.debug("Internal bypasser helper traceback: %s", trace)
|
||||
if error_type == WaitingRoomTimeoutError.__name__:
|
||||
raise WaitingRoomTimeoutError(error)
|
||||
msg = f"{error_type}: {error}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
@@ -951,6 +1327,20 @@ def get(url: str, retry: int | None = None, cancel_flag: Event | None = None) ->
|
||||
if cached_result:
|
||||
return cached_result
|
||||
|
||||
# Re-checked after the cached attempt, not just in get_bypassed_page: that check
|
||||
# ran before the queue, and this call may have spent minutes holding for LOCKED
|
||||
# while another request collected a 429 (or collected one itself, just above).
|
||||
# A solve cannot clear a throttle - the challenge renders, the solve "succeeds",
|
||||
# and the cleared request is refused again while the backoff is renewed.
|
||||
remaining = network.host_cooldown_remaining(url)
|
||||
if remaining > 0:
|
||||
hostname = urlparse(url).hostname or url
|
||||
msg = (
|
||||
f"{hostname} is rate-limited (429); skipping bypass for ~{remaining:.0f}s "
|
||||
"until the cooldown clears."
|
||||
)
|
||||
raise network.RateLimitedError(msg)
|
||||
|
||||
if env.DOCKERMODE and os.environ.get(_BYPASS_CHILD_ENV) != "1":
|
||||
return _get_via_subprocess(url, retry, cancel_flag)
|
||||
return _run_bypass_in_current_process(url, retry, cancel_flag)
|
||||
@@ -1101,17 +1491,16 @@ def _start_ffmpeg_recording(display: str) -> None:
|
||||
timestamp = datetime.now(UTC).strftime("%y%m%d-%H%M%S")
|
||||
output_file = RECORDING_DIR / f"screen_recording_{timestamp}.mp4"
|
||||
|
||||
screen_width, screen_height = get_screen_size()
|
||||
display_width = screen_width + 100
|
||||
display_height = screen_height + 150
|
||||
|
||||
# No -video_size: x11grab then captures the whole screen, whatever size it is. The
|
||||
# size we ask SeleniumBase for (xvfb_metrics) is not the size we get. It builds that
|
||||
# display with use_xauth=True, the image ships no xauth binary, so it falls back to a
|
||||
# fixed 1440x1880 Xvfb. Asking ffmpeg for the fingerprint size plus margin then asked
|
||||
# for an area larger than the screen, and every recording died at startup (#1364).
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"x11grab",
|
||||
"-video_size",
|
||||
f"{display_width}x{display_height}",
|
||||
"-i",
|
||||
display,
|
||||
"-c:v",
|
||||
@@ -1135,13 +1524,48 @@ def _start_ffmpeg_recording(display: str) -> None:
|
||||
"-an",
|
||||
output_file.as_posix(),
|
||||
"-nostats",
|
||||
# Was "0", which discards everything including the reason it could not start.
|
||||
# Recordings have been arriving empty with no explanation anywhere: on issue
|
||||
# #1276 all three of a session's recordings were gone and the log said only
|
||||
# "FFmpeg already stopped", because ffmpeg exits before creating the file when
|
||||
# it cannot open the X display. Errors only - this is a debug-mode recorder, not
|
||||
# something to make chatty.
|
||||
"-loglevel",
|
||||
"0",
|
||||
"error",
|
||||
]
|
||||
logger.debug("Starting FFmpeg recording to %s", output_file)
|
||||
logger.debug_trace(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
|
||||
DISPLAY["ffmpeg"] = subprocess.Popen(ffmpeg_cmd)
|
||||
# Kept beside the recording so it travels in the debug bundle, which is the only
|
||||
# place anyone will look for it. A file rather than a pipe: nothing here would drain
|
||||
# a pipe, and a full one would wedge ffmpeg partway through a capture.
|
||||
error_log = output_file.with_suffix(".ffmpeg.log")
|
||||
try:
|
||||
stderr_handle = error_log.open("wb")
|
||||
except OSError as exc:
|
||||
logger.debug("Could not open FFmpeg error log %s: %s", error_log, exc)
|
||||
stderr_handle = None
|
||||
DISPLAY["ffmpeg"] = subprocess.Popen(
|
||||
ffmpeg_cmd, stderr=stderr_handle, stdout=subprocess.DEVNULL
|
||||
)
|
||||
if stderr_handle is not None:
|
||||
# The child holds its own descriptor; this one has done its job.
|
||||
stderr_handle.close()
|
||||
DISPLAY["ffmpeg_output"] = output_file
|
||||
DISPLAY["ffmpeg_error_log"] = error_log
|
||||
|
||||
|
||||
def _ffmpeg_error_summary() -> str:
|
||||
"""What ffmpeg wrote to stderr, for the log line that reports it died."""
|
||||
error_log = DISPLAY.get("ffmpeg_error_log")
|
||||
if not error_log:
|
||||
return "No FFmpeg error log was captured."
|
||||
try:
|
||||
text = Path(error_log).read_text(encoding="utf-8", errors="replace").strip()
|
||||
except OSError as exc:
|
||||
return f"FFmpeg error log unreadable ({exc})."
|
||||
if not text:
|
||||
return f"FFmpeg logged nothing to {error_log}."
|
||||
return f"FFmpeg said: {text[-_FFMPEG_ERROR_TAIL_CHARS:]}"
|
||||
|
||||
|
||||
def _stop_ffmpeg_recording() -> None:
|
||||
@@ -1153,9 +1577,17 @@ def _stop_ffmpeg_recording() -> None:
|
||||
if not proc:
|
||||
return
|
||||
if proc.poll() is not None:
|
||||
logger.debug("FFmpeg already stopped")
|
||||
# Not "already stopped" - ffmpeg was asked to record until now and is gone, so
|
||||
# the recording for this bypass does not exist. Say so, with the reason, rather
|
||||
# than leaving an empty recording/ directory to be discovered later.
|
||||
logger.warning(
|
||||
"FFmpeg exited early (code %s); no recording for this bypass. %s",
|
||||
proc.returncode,
|
||||
_ffmpeg_error_summary(),
|
||||
)
|
||||
DISPLAY["ffmpeg"] = None
|
||||
DISPLAY["ffmpeg_output"] = None
|
||||
DISPLAY["ffmpeg_error_log"] = None
|
||||
return
|
||||
try:
|
||||
proc.send_signal(signal.SIGINT)
|
||||
@@ -1170,6 +1602,7 @@ def _stop_ffmpeg_recording() -> None:
|
||||
proc.kill()
|
||||
DISPLAY["ffmpeg"] = None
|
||||
DISPLAY["ffmpeg_output"] = None
|
||||
DISPLAY["ffmpeg_error_log"] = None
|
||||
|
||||
|
||||
def _try_with_cached_cookies(url: str, hostname: str) -> str | None:
|
||||
@@ -1194,8 +1627,26 @@ def _try_with_cached_cookies(url: str, hostname: str) -> str | None:
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
if is_aa_waiting_room(url, response.text):
|
||||
# Clearance is valid, but HTTP cannot run the queue's JavaScript.
|
||||
# Enforce this here for both cache checks, including the locked one.
|
||||
return None
|
||||
logger.debug("Cached cookies worked, skipped Chrome bypass")
|
||||
return response.text
|
||||
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
# Throttled, not challenged. The clearance is still good - the origin is
|
||||
# rate-limiting this IP and would answer 429 to a browser holding the very
|
||||
# same cookies. Discarding it here (as every other rejection does) meant a
|
||||
# solve won seconds earlier was thrown away and the next query bought its
|
||||
# own 20-60s browser solve, which is itself more traffic at a host that has
|
||||
# just asked for less. Keep it, arm the backoff, and let the caller wait.
|
||||
wait = network.note_rate_limited(url)
|
||||
logger.debug(
|
||||
"Cached cookies hit a 429 for %s; keeping them and backing off ~%.0fs",
|
||||
url,
|
||||
wait,
|
||||
)
|
||||
return None
|
||||
logger.debug(
|
||||
"Cached cookies rejected (%s) for %s; discarding them",
|
||||
response.status_code,
|
||||
@@ -1234,13 +1685,25 @@ def get_bypassed_page(
|
||||
attempt_url = sel.rewrite(url)
|
||||
hostname = urlparse(attempt_url).hostname or ""
|
||||
|
||||
# A 429 means the origin is throttling this IP; the challenge still renders, so a
|
||||
# solve "succeeds" but the cleared request is rejected again and the throttle is only
|
||||
# renewed. Never spend a minutes-long Chrome solve on a cooling-down host - fail fast
|
||||
# so the caller waits the backoff out instead of looping the solve.
|
||||
remaining = network.host_cooldown_remaining(attempt_url)
|
||||
if remaining > 0:
|
||||
msg = (
|
||||
f"{hostname} is rate-limited (429); skipping bypass for ~{remaining:.0f}s "
|
||||
"until the cooldown clears."
|
||||
)
|
||||
raise network.RateLimitedError(msg)
|
||||
|
||||
cached_result = _try_with_cached_cookies(attempt_url, hostname)
|
||||
if cached_result:
|
||||
return cached_result
|
||||
|
||||
try:
|
||||
response_html = get(attempt_url, cancel_flag=cancel_flag)
|
||||
except BypassCancelledError:
|
||||
except BypassCancelledError, WaitingRoomTimeoutError:
|
||||
raise
|
||||
except _CDP_OPERATION_ERRORS + _REQUEST_OPERATION_ERRORS:
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled")
|
||||
@@ -1258,26 +1721,38 @@ def get_bypassed_page(
|
||||
return response_html
|
||||
|
||||
|
||||
def _dns_fingerprint(dns_config: dict[str, Any]) -> tuple[str, tuple[str, ...], bool]:
|
||||
"""Reduce a DNS config to what has to match for two of them to be the same one."""
|
||||
provider = str(dns_config.get("provider") or "").strip().lower()
|
||||
servers = dns_config.get("servers") if provider == "manual" else None
|
||||
server_list = tuple(str(server) for server in servers) if isinstance(servers, list) else ()
|
||||
return (provider, server_list, bool(dns_config.get("doh_enabled")))
|
||||
|
||||
|
||||
def _apply_parent_dns_config(dns_config: dict[str, Any]) -> None:
|
||||
"""Mirror the parent process's active DNS provider in this helper subprocess.
|
||||
|
||||
DNS state is in-memory only, so a fresh helper defaults to system DNS and would
|
||||
pre-resolve AA hostnames (for Chrome's --host-resolver-rules) against a resolver
|
||||
that may be blocked/hijacked. Re-applying the parent's provider keeps the helper on
|
||||
the same DoH/custom resolver the parent already validated.
|
||||
DNS state is in-memory only, so a helper left to itself would pre-resolve AA hostnames
|
||||
(for Chrome's --host-resolver-rules) against a resolver that may be blocked or
|
||||
hijacked. Re-applying the parent's provider keeps the helper on the same DoH/custom
|
||||
resolver the parent already validated.
|
||||
|
||||
Compared against what this process is *actually* resolving through, rather than
|
||||
against the last config it happened to be handed. The helper now outlives the request,
|
||||
so it has to be able to travel back to auto as well as away from it - which a user
|
||||
flipping CUSTOM_DNS in settings does live, without a restart - and asking the network
|
||||
module what it is doing beats keeping a second, drifting copy of that answer here.
|
||||
"""
|
||||
provider = str(dns_config.get("provider") or "").strip().lower()
|
||||
# "auto" means the parent has not rotated off system DNS yet, so the helper's own
|
||||
# default initialization already matches it - nothing to override.
|
||||
if not provider or provider == "auto":
|
||||
wanted = _dns_fingerprint(dns_config)
|
||||
provider, servers, use_doh = wanted
|
||||
if not provider:
|
||||
return
|
||||
manual_servers = dns_config.get("servers") if provider == "manual" else None
|
||||
# set_dns_provider() rebuilds the resolvers, so it is worth doing only on a real change.
|
||||
if wanted == _dns_fingerprint(network.get_dns_config()):
|
||||
return
|
||||
|
||||
try:
|
||||
network.set_dns_provider(
|
||||
provider,
|
||||
manual_servers,
|
||||
use_doh=bool(dns_config.get("doh_enabled")),
|
||||
)
|
||||
network.set_dns_provider(provider, list(servers) or None, use_doh=use_doh)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
logger.warning("Could not apply parent DNS config (%s): %s", provider, exc)
|
||||
|
||||
@@ -1314,9 +1789,20 @@ def _start_parent_watchdog() -> None:
|
||||
).start()
|
||||
|
||||
|
||||
def _run_child_process() -> int:
|
||||
"""CLI entrypoint used by the Docker helper subprocess."""
|
||||
request = json.loads(sys.stdin.read() or "{}")
|
||||
def _publish_result(result_path: Path, payload: dict[str, Any]) -> None:
|
||||
"""Write the result file atomically.
|
||||
|
||||
The parent decides the request is answered the moment this path exists, so it must
|
||||
never observe a half-written file. Rename within the same directory is atomic.
|
||||
"""
|
||||
tmp_path = _part_path(result_path)
|
||||
tmp_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
tmp_path.replace(result_path)
|
||||
|
||||
|
||||
def _handle_child_request(request_line: str) -> int:
|
||||
"""Answer one request from the parent."""
|
||||
request = json.loads(request_line or "{}")
|
||||
result_path = Path(str(request["result_path"]))
|
||||
url = str(request["url"])
|
||||
retry = _coerce_positive_int(
|
||||
@@ -1327,6 +1813,15 @@ def _run_child_process() -> int:
|
||||
if isinstance(dns_config, dict):
|
||||
_apply_parent_dns_config(dns_config)
|
||||
|
||||
# The parent owns the cookie store; this process only solves. Starting each request
|
||||
# from an empty store is what a helper spawned per request gave for free, and losing
|
||||
# it is what let clearance the parent had deliberately purged for some *other* host
|
||||
# survive here and get merged back over the parent's copy by the export below - the
|
||||
# dead-cookie resurrection that http.py's _redirect_loop_handoff purges to avoid.
|
||||
# Nothing is lost by dropping it: get() below re-checks cached cookies, and the
|
||||
# parent already ran that same check against a store that is a superset of this one.
|
||||
clear_cf_cookies()
|
||||
|
||||
try:
|
||||
html = get(url, retry=retry)
|
||||
cookies, user_agents = export_store()
|
||||
@@ -1336,7 +1831,7 @@ def _run_child_process() -> int:
|
||||
"cookies": cookies,
|
||||
"user_agents": user_agents,
|
||||
}
|
||||
result_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
_publish_result(result_path, payload)
|
||||
except Exception as exc: # noqa: BLE001 - helper boundary must serialize failures.
|
||||
payload = {
|
||||
"ok": False,
|
||||
@@ -1344,11 +1839,28 @@ def _run_child_process() -> int:
|
||||
"error": str(exc),
|
||||
"traceback": traceback.format_exc(),
|
||||
}
|
||||
result_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
_publish_result(result_path, payload)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _run_child_process() -> int:
|
||||
"""CLI entrypoint used by the Docker helper subprocess.
|
||||
|
||||
Serves one request per line of stdin until the parent closes the pipe, so a burst of
|
||||
protected requests - a single search is several - pays the interpreter start and imports
|
||||
once instead of per request. Each bypass still gets its own browser, closed before the
|
||||
answer is published.
|
||||
"""
|
||||
exit_code = 0
|
||||
for line in sys.stdin:
|
||||
request_line = line.strip()
|
||||
if not request_line:
|
||||
continue
|
||||
exit_code = _handle_child_request(request_line)
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Started here rather than in _run_child_process() so it only ever watches a real
|
||||
# spawned helper, never a test or an embedded call.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Recognize Anna's Archive pages that require a live JavaScript timer."""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
class WaitingRoomTimeoutError(TimeoutError):
|
||||
"""The source waiting room did not finish within the browser session budget."""
|
||||
|
||||
|
||||
def is_aa_waiting_room(url: str, html: str) -> bool:
|
||||
"""Match the download route and actual timer element, not a script reference."""
|
||||
return urlparse(url).path.startswith("/slow_download/") and bool(
|
||||
BeautifulSoup(html, "html.parser").select_one(".js-partner-countdown")
|
||||
)
|
||||
@@ -166,6 +166,9 @@ SESSION_COOKIE_NAME = "shelfmark_session"
|
||||
CWA_DB_PATH = _resolve_cwa_db_path()
|
||||
HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false"))
|
||||
DISABLE_LOCAL_AUTH = string_to_bool(os.getenv("DISABLE_LOCAL_AUTH", "false"))
|
||||
# Optional static API key. When set, requests carrying it as a Bearer token
|
||||
# (or X-Api-Key) are authenticated as an admin for that request only.
|
||||
SHELFMARK_API_KEY = os.getenv("SHELFMARK_API_KEY", "").strip()
|
||||
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
|
||||
|
||||
|
||||
@@ -203,6 +206,21 @@ ONBOARDING = string_to_bool(os.getenv("ONBOARDING", "true"))
|
||||
_DEBUG_SKIP_SOURCES_RAW = os.getenv("DEBUG_SKIP_SOURCES", "").strip().lower()
|
||||
DEBUG_SKIP_SOURCES = {s.strip() for s in _DEBUG_SKIP_SOURCES_RAW.split(",") if s.strip()}
|
||||
|
||||
# Debug: keep DDoS-Guard's __ddg8_/__ddg9_/__ddg10_ in the clearance store instead of
|
||||
# dropping them after a solve.
|
||||
#
|
||||
# Which of DDoS-Guard's cookies actually *are* clearance is not settled. The store treats
|
||||
# the trio as describing one check (client IP, timestamp, token) and drops them, on the
|
||||
# reasoning that replaying a stale IP/timestamp is what re-arms the ?check=1 loop - see
|
||||
# shelfmark.bypass.cookie_store. Field reports on issue #1276 point the other way: every
|
||||
# request after a successful solve was challenged again, which is only consistent with
|
||||
# what the store keeps not being sufficient clearance on its own.
|
||||
#
|
||||
# Deliberately env-only and off by default: this is a knob for reproducing the question
|
||||
# against a live host, not a setting to offer users. Set it to true, solve once, and watch
|
||||
# whether the next search still logs "Redirect loop detected".
|
||||
DDG_REPLAY_PER_CHECK_COOKIES = string_to_bool(os.getenv("DDG_REPLAY_PER_CHECK_COOKIES", "false"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Legacy migration support - will be removed in future version
|
||||
|
||||
@@ -179,6 +179,18 @@ def security_settings() -> list[SettingsField]:
|
||||
placeholder="e.g. admins",
|
||||
default="",
|
||||
),
|
||||
_auth_field(
|
||||
SelectField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_DEFAULT_ROLE",
|
||||
label="Proxy Auth Default Role",
|
||||
description="Role for users the proxy authenticates for the first time when no admin group is configured. The first account is always an admin so the instance is never left without one.",
|
||||
options=[
|
||||
{"value": "user", "label": "User"},
|
||||
{"value": "admin", "label": "Admin"},
|
||||
],
|
||||
default="user",
|
||||
),
|
||||
]
|
||||
|
||||
fields.append(
|
||||
|
||||
@@ -204,6 +204,7 @@ register_group(
|
||||
|
||||
# Direct mode sort options
|
||||
_AA_SORT_OPTIONS = [
|
||||
{"value": "", "label": "Most downloads"},
|
||||
{"value": "relevance", "label": "Most relevant"},
|
||||
{"value": "newest", "label": "Newest (publication year)"},
|
||||
{"value": "oldest", "label": "Oldest (publication year)"},
|
||||
@@ -430,13 +431,6 @@ def general_settings() -> list[SettingsField]:
|
||||
options=_AUDIOBOOK_FORMAT_OPTIONS,
|
||||
default=[*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS],
|
||||
),
|
||||
MultiSelectField(
|
||||
key="BOOK_LANGUAGE",
|
||||
label="Default Book Languages",
|
||||
description="Default language filter for searches.",
|
||||
options=_LANGUAGE_OPTIONS,
|
||||
default=["en"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -474,12 +468,23 @@ def search_mode_settings() -> list[SettingsField]:
|
||||
default="universal",
|
||||
user_overridable=True,
|
||||
),
|
||||
MultiSelectField(
|
||||
key="BOOK_LANGUAGE",
|
||||
label="Default Book Languages",
|
||||
description=(
|
||||
"Default language filter for searches. Users can override this for their "
|
||||
"own account."
|
||||
),
|
||||
options=_LANGUAGE_OPTIONS,
|
||||
default=["en"],
|
||||
user_overridable=True,
|
||||
),
|
||||
SelectField(
|
||||
key="AA_DEFAULT_SORT",
|
||||
label="Default Sort Order",
|
||||
description="Default sort order for search results.",
|
||||
options=_AA_SORT_OPTIONS,
|
||||
default="relevance",
|
||||
default="",
|
||||
show_when={"field": "SEARCH_MODE", "value": "direct"},
|
||||
),
|
||||
CheckboxField(
|
||||
@@ -766,7 +771,10 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
# Audiobooks are always folder output.
|
||||
if effective.get("FILE_ORGANIZATION_AUDIOBOOK", "rename") == "rename":
|
||||
if effective.get("FILE_ORGANIZATION_AUDIOBOOK", "rename") in {
|
||||
"rename",
|
||||
"rename_and_group",
|
||||
}:
|
||||
template = effective.get("TEMPLATE_AUDIOBOOK_RENAME", "")
|
||||
if _contains_path_separators(template):
|
||||
return {
|
||||
@@ -1016,12 +1024,30 @@ def download_settings() -> list[SettingsField]:
|
||||
"value": "folder",
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
key="NAMING_WORD_SEPARATOR",
|
||||
label="Word Separator",
|
||||
description=(
|
||||
"Replaces spaces inside naming template values (e.g. 'Conan Doyle' -> "
|
||||
"'Conan.Doyle' with '.'). Applies to books and audiobooks, rename and "
|
||||
"organize templates alike. Literal characters typed into a template "
|
||||
"(like the '-' in '{Author} - {Title}') are left as-is. Leave empty to "
|
||||
"keep spaces as-is."
|
||||
),
|
||||
default="",
|
||||
placeholder=".",
|
||||
max_length=5,
|
||||
show_when={
|
||||
"field": "BOOKS_OUTPUT_MODE",
|
||||
"value": "folder",
|
||||
},
|
||||
),
|
||||
# Rename mode template - filename only
|
||||
_naming_template_field(
|
||||
key="TEMPLATE_RENAME",
|
||||
label="Naming Template",
|
||||
description=(
|
||||
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
||||
"Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
||||
"(source filename without extension). Universal adds: {Series}, "
|
||||
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
|
||||
@@ -1040,7 +1066,7 @@ def download_settings() -> list[SettingsField]:
|
||||
key="TEMPLATE_ORGANIZE",
|
||||
label="Path Template",
|
||||
description=(
|
||||
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, "
|
||||
"Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, "
|
||||
"{OriginalName} (source filename without extension). Universal adds: {Series}, "
|
||||
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
|
||||
@@ -1294,6 +1320,11 @@ def download_settings() -> list[SettingsField]:
|
||||
"label": "Rename and Organize",
|
||||
"description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders.",
|
||||
},
|
||||
{
|
||||
"value": "rename_and_group",
|
||||
"label": "Rename and Group",
|
||||
"description": "Rename single-file downloads; keep multi-file downloads grouped in their source folder. Do not use with ingest folders.",
|
||||
},
|
||||
],
|
||||
default="rename",
|
||||
universal_only=True,
|
||||
@@ -1303,7 +1334,7 @@ def download_settings() -> list[SettingsField]:
|
||||
key="TEMPLATE_AUDIOBOOK_RENAME",
|
||||
label="Naming Template",
|
||||
description=(
|
||||
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
||||
"Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
||||
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
|
||||
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
|
||||
@@ -1312,7 +1343,10 @@ def download_settings() -> list[SettingsField]:
|
||||
),
|
||||
default="{Author} - {Title}",
|
||||
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
|
||||
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
|
||||
show_when={
|
||||
"field": "FILE_ORGANIZATION_AUDIOBOOK",
|
||||
"value": ["rename", "rename_and_group"],
|
||||
},
|
||||
universal_only=True,
|
||||
),
|
||||
# Organize mode template - folders allowed
|
||||
@@ -1320,7 +1354,7 @@ def download_settings() -> list[SettingsField]:
|
||||
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
|
||||
label="Path Template",
|
||||
description=(
|
||||
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, "
|
||||
"Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, "
|
||||
"{OriginalName} (source filename without extension), {Series}, {SeriesPosition}, "
|
||||
"{Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
|
||||
@@ -1545,6 +1579,19 @@ def download_source_settings() -> list[SettingsField]:
|
||||
min_value=1,
|
||||
max_value=60,
|
||||
),
|
||||
NumberField(
|
||||
key="RELEASE_SEARCH_TIMEOUT",
|
||||
label="Release Search Timeout (seconds)",
|
||||
description=(
|
||||
"How long one release search may run before it gives up and reports why. "
|
||||
"A first search on a cold start pays for a browser solve, so leave room "
|
||||
"for one. If you use a reverse proxy, its read timeout should be at least "
|
||||
"this high or it will cut the search off with a 504 first."
|
||||
),
|
||||
default=300,
|
||||
min_value=30,
|
||||
max_value=1800,
|
||||
),
|
||||
HeadingField(
|
||||
key="content_type_routing_heading",
|
||||
title="Content-Type Routing",
|
||||
@@ -1655,6 +1702,31 @@ def cloudflare_bypass_settings() -> list[SettingsField]:
|
||||
requires_restart=True,
|
||||
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": True},
|
||||
),
|
||||
NumberField(
|
||||
key="BYPASS_PAGE_SOURCE_TIMEOUT",
|
||||
label="Page Read Timeout (seconds)",
|
||||
description=(
|
||||
"How long to wait for a solved page to produce its content before the "
|
||||
"bypass is retried. Raise it if solves succeed but searches still fail."
|
||||
),
|
||||
default=20,
|
||||
min_value=1,
|
||||
max_value=120,
|
||||
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": False},
|
||||
),
|
||||
NumberField(
|
||||
key="BYPASS_BROWSER_IDLE_TIMEOUT",
|
||||
label="Bypasser Idle Timeout (seconds)",
|
||||
description=(
|
||||
"How long the bypass helper process may sit unused before it is shut down. "
|
||||
"Higher keeps more searches fast, lower frees memory sooner."
|
||||
),
|
||||
default=180,
|
||||
min_value=30,
|
||||
max_value=3600,
|
||||
requires_restart=True,
|
||||
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": False},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ that talks to /api/admin/users endpoints.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.languages import normalize_language
|
||||
from shelfmark.core.request_policy import (
|
||||
get_source_content_type_capabilities,
|
||||
parse_policy_mode,
|
||||
@@ -61,7 +62,7 @@ _SELF_SETTINGS_SECTION_OPTIONS = [
|
||||
{
|
||||
"value": "search",
|
||||
"label": "Search Preferences",
|
||||
"description": "Show personal search mode and provider settings.",
|
||||
"description": "Show personal search mode, language, and provider settings.",
|
||||
},
|
||||
{
|
||||
"value": "notifications",
|
||||
@@ -77,8 +78,9 @@ _SEARCH_PREFERENCE_PROVIDER_KEYS = {
|
||||
"METADATA_PROVIDER_AUDIOBOOK",
|
||||
"METADATA_PROVIDER_COMBINED",
|
||||
}
|
||||
_SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
|
||||
SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
|
||||
"SEARCH_MODE",
|
||||
"BOOK_LANGUAGE",
|
||||
"DEFAULT_RELEASE_SOURCE",
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||
"SHOW_COMBINED_SELECTOR",
|
||||
@@ -178,14 +180,43 @@ def _get_request_policy_rule_columns() -> list[dict[str, object]]:
|
||||
]
|
||||
|
||||
|
||||
def _validate_book_languages(value: Any) -> tuple[Any, str | None]:
|
||||
"""Validate a per-user default language list against the known languages.
|
||||
|
||||
Accepts the list the settings UI sends as well as a comma-separated string, so an
|
||||
API client can spell the value the way the env var does. Blank entries are skipped
|
||||
rather than rejected, which makes "" and "en," mean the same as [] and ["en"]. An
|
||||
empty result is a deliberate override meaning "no default language filter", so it
|
||||
is kept as-is; ``None`` clears the override further up the chain.
|
||||
"""
|
||||
entries = value.split(",") if isinstance(value, str) else value
|
||||
if not isinstance(entries, (list, tuple)):
|
||||
return value, "BOOK_LANGUAGE must be a list of language codes"
|
||||
|
||||
normalized: list[str] = []
|
||||
for entry in entries:
|
||||
if entry is None or (isinstance(entry, str) and not entry.strip()):
|
||||
continue
|
||||
code = normalize_language(entry)
|
||||
if code is None:
|
||||
return value, f"BOOK_LANGUAGE contains an unsupported language: {entry}"
|
||||
if code not in normalized:
|
||||
normalized.append(code)
|
||||
|
||||
return normalized, None
|
||||
|
||||
|
||||
def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | None]:
|
||||
"""Validate and normalize a search preference value for user overrides."""
|
||||
if key not in _SEARCH_PREFERENCE_VALIDATABLE_KEYS:
|
||||
if key not in SEARCH_PREFERENCE_VALIDATABLE_KEYS:
|
||||
return value, None
|
||||
|
||||
if value is None:
|
||||
return None, None
|
||||
|
||||
if key == "BOOK_LANGUAGE":
|
||||
return _validate_book_languages(value)
|
||||
|
||||
normalized_value = str(value).strip()
|
||||
|
||||
if key == "SEARCH_MODE":
|
||||
@@ -298,7 +329,7 @@ def _on_save_users(values: dict[str, object]) -> dict[str, object]:
|
||||
}
|
||||
values["REQUEST_POLICY_RULES"] = normalized_rules
|
||||
|
||||
for key in _SEARCH_PREFERENCE_VALIDATABLE_KEYS:
|
||||
for key in SEARCH_PREFERENCE_VALIDATABLE_KEYS:
|
||||
if key not in values:
|
||||
continue
|
||||
normalized_value, validation_error = validate_search_preference_value(key, values[key])
|
||||
|
||||
@@ -280,6 +280,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
|
||||
# Handle optional password update
|
||||
password = data.get("password", "")
|
||||
password_hash: str | None = None
|
||||
if password:
|
||||
if not capabilities["canSetPassword"]:
|
||||
return jsonify(
|
||||
@@ -292,7 +293,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
return jsonify(
|
||||
{"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}
|
||||
), 400
|
||||
user_db.update_user(user_id, password_hash=generate_password_hash(password))
|
||||
password_hash = generate_password_hash(password)
|
||||
|
||||
# Update user fields
|
||||
user_fields = {}
|
||||
@@ -358,10 +359,8 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
if field in user_fields and user_fields[field] == user.get(field):
|
||||
user_fields.pop(field)
|
||||
|
||||
if user_fields:
|
||||
user_db.update_user(user_id, **user_fields)
|
||||
|
||||
# Update per-user settings
|
||||
# Validate per-user settings
|
||||
validated_settings: dict[str, Any] | None = None
|
||||
if "settings" in data:
|
||||
if not isinstance(data["settings"], dict):
|
||||
return jsonify({"error": "Settings must be an object"}), 400
|
||||
@@ -375,6 +374,14 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
}
|
||||
), 400
|
||||
|
||||
# Apply the writes only once the whole payload has been accepted.
|
||||
if password_hash is not None:
|
||||
user_fields["password_hash"] = password_hash
|
||||
|
||||
if user_fields:
|
||||
user_db.update_user(user_id, **user_fields)
|
||||
|
||||
if validated_settings is not None:
|
||||
user_db.set_user_settings(user_id, validated_settings)
|
||||
# Ensure runtime reads see updated per-user overrides immediately.
|
||||
try:
|
||||
|
||||
@@ -11,7 +11,10 @@ from shelfmark.config.notifications_settings import (
|
||||
is_valid_notification_url,
|
||||
normalize_notification_routes,
|
||||
)
|
||||
from shelfmark.config.users_settings import validate_search_preference_value
|
||||
from shelfmark.config.users_settings import (
|
||||
SEARCH_PREFERENCE_VALIDATABLE_KEYS,
|
||||
validate_search_preference_value,
|
||||
)
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.request_policy import parse_policy_mode, validate_policy_rules
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
@@ -91,13 +94,9 @@ def validate_user_settings(
|
||||
if search_validation_error:
|
||||
errors.append(search_validation_error)
|
||||
continue
|
||||
if key in {
|
||||
"SEARCH_MODE",
|
||||
"METADATA_PROVIDER",
|
||||
"METADATA_PROVIDER_AUDIOBOOK",
|
||||
"DEFAULT_RELEASE_SOURCE",
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||
}:
|
||||
# Every key the search validator recognises keeps its normalized value;
|
||||
# a hand-maintained subset here silently dropped normalization for the rest.
|
||||
if key in SEARCH_PREFERENCE_VALIDATABLE_KEYS:
|
||||
valid[key] = normalized_search_value
|
||||
continue
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Static API-key authentication backed by the SHELFMARK_API_KEY environment variable.
|
||||
|
||||
When ``SHELFMARK_API_KEY`` is set, a request carrying that value as a Bearer token or in
|
||||
``X-Api-Key`` is treated as an admin for that request only. Both headers are
|
||||
checked, since a reverse proxy in front of Shelfmark may set its own
|
||||
``Authorization`` header, which would otherwise shadow an operator-supplied
|
||||
``X-Api-Key``. A candidate that matches neither is ignored so that bearer
|
||||
tokens forwarded by reverse proxies keep working. The key is never logged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
|
||||
from shelfmark.config.env import SHELFMARK_API_KEY
|
||||
|
||||
|
||||
def extract_api_key_candidates(
|
||||
authorization_header: str | None, api_key_header: str | None
|
||||
) -> list[str]:
|
||||
"""Return the non-empty credentials a client presented, Bearer token first."""
|
||||
candidates: list[str] = []
|
||||
if authorization_header:
|
||||
scheme, _, token = authorization_header.strip().partition(" ")
|
||||
token = token.strip()
|
||||
if scheme.lower() == "bearer" and token:
|
||||
candidates.append(token)
|
||||
if api_key_header:
|
||||
token = api_key_header.strip()
|
||||
if token:
|
||||
candidates.append(token)
|
||||
return candidates
|
||||
|
||||
|
||||
def matches_api_key(candidate: str) -> bool:
|
||||
"""Constant-time comparison against the configured key. False when unset."""
|
||||
if not SHELFMARK_API_KEY or not candidate:
|
||||
return False
|
||||
return hmac.compare_digest(candidate.encode("utf-8"), SHELFMARK_API_KEY.encode("utf-8"))
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Comparing and trimming author names for release search.
|
||||
|
||||
Lives in core because more than one release source needs it: Prowlarr ranks
|
||||
results on author agreement (#1293), and IRC both trims the name it searches
|
||||
for and ranks what comes back.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_AUTHOR_TOKEN_PATTERN = re.compile(r"\w+", re.UNICODE)
|
||||
_AUTHOR_NOISE_TOKENS = frozenset(
|
||||
{"jr", "sr", "ii", "iii", "iv", "phd", "md", "dr", "mr", "mrs", "ms", "et", "al", "and", "the"}
|
||||
)
|
||||
|
||||
# Ordering tiers for author agreement between the requested book and what an
|
||||
# indexer reported. Lower sorts first.
|
||||
AUTHOR_MATCH = 0
|
||||
AUTHOR_PARTIAL = 1
|
||||
AUTHOR_UNKNOWN = 2
|
||||
AUTHOR_MISMATCH = 3
|
||||
|
||||
# A mononym ("Homer") can only ever agree on one token; a longer name needs a
|
||||
# given name and a surname to agree before it counts as the same person.
|
||||
_AUTHOR_TOKENS_REQUIRED = 2
|
||||
|
||||
|
||||
def _author_tokens(value: object) -> list[str]:
|
||||
"""Split an author string into comparable lowercase name tokens."""
|
||||
if not isinstance(value, str):
|
||||
return []
|
||||
tokens = [token.lower() for token in _AUTHOR_TOKEN_PATTERN.findall(value)]
|
||||
return [token for token in tokens if token not in _AUTHOR_NOISE_TOKENS]
|
||||
|
||||
|
||||
def _author_tokens_compatible(wanted: str, offered: str) -> bool:
|
||||
"""Treat an abbreviated given name as the name it abbreviates."""
|
||||
return wanted == offered or wanted.startswith(offered) or offered.startswith(wanted)
|
||||
|
||||
|
||||
def author_affinity(wanted: object, offered: object) -> int:
|
||||
"""Rank how far an indexer's author field is from the requested author.
|
||||
|
||||
Shelfmark ranks on this rather than filtering on it, so a wrong verdict only
|
||||
costs a release its position in the list, never its visibility. That is what
|
||||
makes the loose token comparison safe: "Tim"/"Timothy" and "T."/"Timothy"
|
||||
agree, while a transliteration ("Dostoevsky"/"Dostoyevsky") is merely sorted
|
||||
last instead of being hidden.
|
||||
|
||||
Graded, not binary, because the ways of falling short are not equally bad.
|
||||
An indexer that reports no author at all must not sort below one that reports
|
||||
a wrong author, so "no metadata" ranks between agreement and disagreement. And
|
||||
a name that merely says *less* than the one asked for is not evidence of a
|
||||
different person: "Petrie" contradicts nothing about "David Petrie", while
|
||||
"Gordon Petrie" does. That gap matters most where a source is searched by
|
||||
surname alone (#1331) - the filenames such a search is meant to reach are
|
||||
exactly the ones filed under a bare surname, and ranking them as wrong put
|
||||
them below every result that named someone else entirely.
|
||||
"""
|
||||
wanted_tokens = _author_tokens(wanted)
|
||||
offered_tokens = _author_tokens(offered)
|
||||
if not wanted_tokens or not offered_tokens:
|
||||
return AUTHOR_UNKNOWN
|
||||
|
||||
matched = sum(
|
||||
1
|
||||
for wanted_token in wanted_tokens
|
||||
if any(
|
||||
_author_tokens_compatible(wanted_token, offered_token)
|
||||
for offered_token in offered_tokens
|
||||
)
|
||||
)
|
||||
required = min(_AUTHOR_TOKENS_REQUIRED, len(wanted_tokens))
|
||||
if matched >= required:
|
||||
return AUTHOR_MATCH
|
||||
|
||||
# Too little agreement to call it the same person, so the question is whether
|
||||
# what was offered *disagrees*. A name every token of which fits the wanted
|
||||
# name is an abbreviation of it; one carrying a token that fits nothing is a
|
||||
# different name that happens to share a surname.
|
||||
if all(
|
||||
any(
|
||||
_author_tokens_compatible(wanted_token, offered_token) for wanted_token in wanted_tokens
|
||||
)
|
||||
for offered_token in offered_tokens
|
||||
):
|
||||
return AUTHOR_PARTIAL
|
||||
|
||||
return AUTHOR_MISMATCH
|
||||
|
||||
|
||||
def search_surname(author: object) -> str:
|
||||
"""The one name token worth sending to a source that matches conjunctively.
|
||||
|
||||
Given names are where catalogues disagree - "David Petrie" is filed as
|
||||
"D. Petrie", "Timothy" as "Tim" - so a query carrying one matches nothing on
|
||||
a source that requires every term to appear. The surname is the token both
|
||||
spellings share.
|
||||
|
||||
Keeps the author's own capitalisation, because the result is posted to a
|
||||
public channel, and returns "" when no usable token is left so the caller
|
||||
searches by title alone rather than by noise.
|
||||
"""
|
||||
if not isinstance(author, str):
|
||||
return ""
|
||||
tokens = [
|
||||
token
|
||||
for token in _AUTHOR_TOKEN_PATTERN.findall(author)
|
||||
if token.lower() not in _AUTHOR_NOISE_TOKENS
|
||||
]
|
||||
if not tokens:
|
||||
return ""
|
||||
return tokens[-1]
|
||||
@@ -207,6 +207,7 @@ class DownloadHistoryService:
|
||||
"content_type": row.get("content_type"),
|
||||
"source": row.get("source"),
|
||||
"source_display_name": row.get("source_display_name"),
|
||||
"downloads": row.get("downloads"),
|
||||
"status_message": row.get("status_message"),
|
||||
"download_path": DownloadHistoryService._resolve_existing_download_path(
|
||||
row.get("download_path")
|
||||
@@ -272,6 +273,7 @@ class DownloadHistoryService:
|
||||
size: str | None,
|
||||
preview: str | None,
|
||||
content_type: str | None,
|
||||
downloads: int | None,
|
||||
origin: str,
|
||||
retry_payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
@@ -307,15 +309,16 @@ class DownloadHistoryService:
|
||||
title, author, format, size, preview, content_type,
|
||||
origin, final_status,
|
||||
status_message, download_path, retry_payload,
|
||||
queued_at, terminal_at
|
||||
queued_at, terminal_at, downloads
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, ?, ?, ?)
|
||||
ON CONFLICT(task_id) DO UPDATE SET
|
||||
final_status = 'active',
|
||||
status_message = NULL,
|
||||
download_path = NULL,
|
||||
retry_payload = excluded.retry_payload,
|
||||
terminal_at = ?
|
||||
terminal_at = ?,
|
||||
downloads = excluded.downloads
|
||||
""",
|
||||
(
|
||||
normalized_task_id,
|
||||
@@ -334,6 +337,7 @@ class DownloadHistoryService:
|
||||
normalized_retry_payload,
|
||||
recorded_at,
|
||||
recorded_at,
|
||||
downloads,
|
||||
recorded_at,
|
||||
),
|
||||
)
|
||||
|
||||
+22
-10
@@ -4,6 +4,7 @@ import logging
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.config.env import ENABLE_LOGGING, LOG_FILE, LOG_LEVEL
|
||||
@@ -12,6 +13,10 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_file_handlers: dict[Path, RotatingFileHandler] = {}
|
||||
_file_handlers_lock = Lock()
|
||||
|
||||
|
||||
class CustomLogger(logging.Logger):
|
||||
"""Custom logger class with additional error_trace method."""
|
||||
|
||||
@@ -122,6 +127,22 @@ def _normalize_log_extra(value: object) -> Mapping[str, object] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_file_handler(log_file: Path, formatter: logging.Formatter) -> RotatingFileHandler:
|
||||
"""Return the process-wide rotating handler for a log file."""
|
||||
with _file_handlers_lock:
|
||||
handler = _file_handlers.get(log_file)
|
||||
if handler is None:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10485760, # 10MB
|
||||
backupCount=5,
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
_file_handlers[log_file] = handler
|
||||
return handler
|
||||
|
||||
|
||||
def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
"""Set up and configure a logger instance.
|
||||
|
||||
@@ -163,16 +184,7 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
# File handler if log file is specified
|
||||
try:
|
||||
if ENABLE_LOGGING:
|
||||
# Create log directory if it doesn't exist
|
||||
log_dir = log_file.parent
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10485760, # 10MB
|
||||
backupCount=5,
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
logger.addHandler(_get_file_handler(log_file, formatter))
|
||||
except (OSError, TypeError, ValueError) as e:
|
||||
logger.error_trace(f"Failed to create log file: {e}", exc_info=True)
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ class DownloadTask:
|
||||
year: str | None = None
|
||||
format: str | None = None
|
||||
size: str | None = None
|
||||
downloads: int | None = None # Download count from source
|
||||
preview: str | None = None
|
||||
content_type: str | None = None # "book (fiction)", "audiobook", "magazine", etc.
|
||||
source_url: str | None = None # Original release URL used by source-specific handlers
|
||||
@@ -136,6 +137,12 @@ class DownloadTask:
|
||||
default_factory=dict
|
||||
) # Per-output parameters (e.g. email recipient)
|
||||
|
||||
# Multi-book packs: one release holding several books. `book_plan` is the split the
|
||||
# user approved before download (list of {title, series_position, year, files});
|
||||
# `multi_book` asks post-processing to split heuristically when no plan exists.
|
||||
multi_book: bool = False
|
||||
book_plan: list[dict[str, Any]] | None = None
|
||||
|
||||
# User association (multi-user support)
|
||||
user_id: int | None = None # DB user ID who queued this download
|
||||
username: str | None = None # Username for {User} template variable
|
||||
|
||||
@@ -14,11 +14,12 @@ logger = setup_logger(__name__)
|
||||
|
||||
|
||||
# Known variable tokens, sorted longest-first to avoid partial matches
|
||||
# e.g., "SeriesPosition" must match before "Series"
|
||||
# e.g., "SeriesPosition" must match before "Series", "FirstAuthor" before "Author"
|
||||
KNOWN_TOKENS = [
|
||||
"seriesposition",
|
||||
"primarytitle",
|
||||
"originalname",
|
||||
"firstauthor",
|
||||
"partnumber",
|
||||
"language",
|
||||
"subtitle",
|
||||
@@ -29,12 +30,22 @@ KNOWN_TOKENS = [
|
||||
"user",
|
||||
]
|
||||
|
||||
# Authors reach naming already joined as "First Author, Second Author, ...".
|
||||
# {FirstAuthor} keeps only the first entry. A single name written "Last, First"
|
||||
# is split on the comma too and renders as "Last" -- the source metadata does
|
||||
# not mark which form it is (see #930).
|
||||
AUTHOR_LIST_SEPARATOR = re.compile(r"\s*[,;]\s*")
|
||||
|
||||
# Match any {...} block for template parsing
|
||||
BRACE_PATTERN = re.compile(r"\{([^}]+)\}")
|
||||
|
||||
# Characters that are invalid in filenames on various filesystems
|
||||
INVALID_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||
|
||||
# Runs of whitespace inside a single placeholder's rendered value, e.g. "Conan Doyle"
|
||||
# -- collapsed to the configured word separator (see `parse_naming_template`).
|
||||
WHITESPACE_RUN = re.compile(r"\s+")
|
||||
|
||||
|
||||
def _sanitize(name: str | None, max_length: int = 245) -> str:
|
||||
"""Sanitize a string for filesystem use."""
|
||||
@@ -56,6 +67,14 @@ def sanitize_filename(name: str | None, max_length: int = 245) -> str:
|
||||
sanitize_path_component = sanitize_filename
|
||||
|
||||
|
||||
def first_author(value: object) -> str:
|
||||
"""Return the first entry from an author string joined with ',' or ';'."""
|
||||
text = " ".join(str(value or "").split())
|
||||
if not text:
|
||||
return ""
|
||||
return AUTHOR_LIST_SEPARATOR.split(text, maxsplit=1)[0].strip()
|
||||
|
||||
|
||||
def format_series_position(position: str | float | None) -> str:
|
||||
"""Format a series position for naming templates."""
|
||||
if position is None:
|
||||
@@ -143,8 +162,16 @@ def parse_naming_template(
|
||||
metadata: Mapping[str, str | int | float | None],
|
||||
*,
|
||||
allow_path_separators: bool = True,
|
||||
word_separator: str = " ",
|
||||
) -> str:
|
||||
"""Render a naming template with Shelfmark metadata placeholders."""
|
||||
"""Render a naming template with Shelfmark metadata placeholders.
|
||||
|
||||
`word_separator` replaces whitespace *inside* each placeholder's rendered
|
||||
value (e.g. "Conan Doyle" -> "Conan.Doyle" for a "." separator). It never
|
||||
touches literal characters typed into the template itself, so a template
|
||||
like "{Author}.-.{Title}" keeps its own dots regardless of this setting.
|
||||
The default (" ") leaves values untouched, matching prior behavior.
|
||||
"""
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
@@ -163,6 +190,8 @@ def parse_naming_template(
|
||||
value = normalized.get(placeholder_name)
|
||||
if placeholder_name == "seriesposition":
|
||||
value = format_series_position(value)
|
||||
elif placeholder_name == "firstauthor" and not value:
|
||||
value = first_author(normalized.get("author"))
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
@@ -178,6 +207,8 @@ def parse_naming_template(
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
if word_separator != " ":
|
||||
value = WHITESPACE_RUN.sub(word_separator, value)
|
||||
if not allow_path_separators:
|
||||
value = value.replace("/", "_")
|
||||
value = sanitize_filename(value)
|
||||
@@ -243,9 +274,13 @@ def build_library_path(
|
||||
template: str,
|
||||
metadata: Mapping[str, str | int | float | None],
|
||||
extension: str | None = None,
|
||||
*,
|
||||
word_separator: str = " ",
|
||||
) -> Path:
|
||||
"""Build a final library path from a template and metadata."""
|
||||
relative = parse_naming_template(template, metadata, allow_path_separators=True)
|
||||
relative = parse_naming_template(
|
||||
template, metadata, allow_path_separators=True, word_separator=word_separator
|
||||
)
|
||||
|
||||
if not relative:
|
||||
# Fallback to title if template produces empty result
|
||||
|
||||
@@ -109,7 +109,7 @@ def _normalize_return_to(raw_return_to: object) -> str | None:
|
||||
return None
|
||||
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
if parsed.scheme or parsed.netloc or "\\" in parsed.path:
|
||||
return None
|
||||
|
||||
script_root = request.script_root.rstrip("/")
|
||||
|
||||
+32
-20
@@ -145,28 +145,36 @@ class BookQueue:
|
||||
with self._lock:
|
||||
self._queue_hook = hook
|
||||
|
||||
def update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||
"""Update status of a book in the queue."""
|
||||
def _apply_status_locked(
|
||||
self, book_id: str, status: QueueStatus
|
||||
) -> tuple[Callable[[str, QueueStatus, DownloadTask], None] | None, DownloadTask | None]:
|
||||
"""Apply a status change; returns the terminal hook to run after releasing the lock."""
|
||||
hook: Callable[[str, QueueStatus, DownloadTask], None] | None = None
|
||||
hook_task: DownloadTask | None = None
|
||||
previous_status = self._status.get(book_id)
|
||||
self._update_status(book_id, status)
|
||||
|
||||
if (
|
||||
status in TERMINAL_QUEUE_STATUSES
|
||||
and previous_status != status
|
||||
and self._terminal_status_hook is not None
|
||||
):
|
||||
current_task = self._task_data.get(book_id)
|
||||
if current_task is not None:
|
||||
hook = self._terminal_status_hook
|
||||
hook_task = current_task
|
||||
|
||||
# Clean up active download tracking when finished
|
||||
if status in TERMINAL_QUEUE_STATUSES:
|
||||
self._active_downloads.pop(book_id, None)
|
||||
self._cancel_flags.pop(book_id, None)
|
||||
|
||||
return hook, hook_task
|
||||
|
||||
def update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||
"""Update status of a book in the queue."""
|
||||
with self._lock:
|
||||
previous_status = self._status.get(book_id)
|
||||
self._update_status(book_id, status)
|
||||
|
||||
if (
|
||||
status in TERMINAL_QUEUE_STATUSES
|
||||
and previous_status != status
|
||||
and self._terminal_status_hook is not None
|
||||
):
|
||||
current_task = self._task_data.get(book_id)
|
||||
if current_task is not None:
|
||||
hook = self._terminal_status_hook
|
||||
hook_task = current_task
|
||||
|
||||
# Clean up active download tracking when finished
|
||||
if status in TERMINAL_QUEUE_STATUSES:
|
||||
self._active_downloads.pop(book_id, None)
|
||||
self._cancel_flags.pop(book_id, None)
|
||||
hook, hook_task = self._apply_status_locked(book_id, status)
|
||||
|
||||
if hook is not None and hook_task is not None:
|
||||
hook(book_id, status, hook_task)
|
||||
@@ -257,7 +265,11 @@ class BookQueue:
|
||||
# Not in a cancellable state
|
||||
return False
|
||||
|
||||
self.update_status(task_id, QueueStatus.CANCELLED)
|
||||
# Write under the same lock so a download that finishes first is not overwritten
|
||||
hook, hook_task = self._apply_status_locked(task_id, QueueStatus.CANCELLED)
|
||||
|
||||
if hook is not None and hook_task is not None:
|
||||
hook(task_id, QueueStatus.CANCELLED, hook_task)
|
||||
return True
|
||||
|
||||
def set_priority(self, task_id: str, new_priority: int) -> bool:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Pre-download release inspection: list a release's files and plan a multi-book split."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import is_audiobook
|
||||
from shelfmark.download.postprocess.packs import PackFile, PackPlan, plan_pack
|
||||
from shelfmark.download.postprocess.policy import (
|
||||
get_supported_audiobook_formats,
|
||||
get_supported_formats,
|
||||
)
|
||||
from shelfmark.release_sources import get_handler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from flask import Flask, Response
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_INSPECT_ERRORS = (OSError, RuntimeError, ValueError, TypeError, KeyError, AttributeError)
|
||||
NOT_INSPECTABLE_REASON = "This source cannot list the release's files before downloading"
|
||||
|
||||
|
||||
def _serialize_plan(plan: PackPlan) -> dict[str, Any]:
|
||||
return {
|
||||
"is_pack": plan.is_pack,
|
||||
"ignored": plan.ignored,
|
||||
"books": [
|
||||
{
|
||||
"title": book.title,
|
||||
"series_position": book.series_position,
|
||||
"year": book.year,
|
||||
"files": book.files,
|
||||
}
|
||||
for book in plan.books
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def inspect_release(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the inspect response for a release payload (same shape as a download)."""
|
||||
source = str(data["source"])
|
||||
handler = get_handler(source)
|
||||
try:
|
||||
files: list[PackFile] | None = handler.list_files(data)
|
||||
except _INSPECT_ERRORS as exc:
|
||||
logger.warning(
|
||||
"Could not list files for %s release %s: %s", source, data.get("source_id"), exc
|
||||
)
|
||||
return {"inspected": False, "reason": str(exc), "files": [], "plan": None}
|
||||
|
||||
if files is None:
|
||||
return {"inspected": False, "reason": NOT_INSPECTABLE_REASON, "files": [], "plan": None}
|
||||
|
||||
content_type = data.get("content_type")
|
||||
supported = (
|
||||
get_supported_audiobook_formats()
|
||||
if is_audiobook(content_type if isinstance(content_type, str) else None)
|
||||
else get_supported_formats()
|
||||
)
|
||||
series_name = data.get("series_name")
|
||||
author_name = data.get("author")
|
||||
plan = plan_pack(
|
||||
files,
|
||||
supported_extensions=set(supported),
|
||||
series_name=series_name if isinstance(series_name, str) else None,
|
||||
author_name=author_name if isinstance(author_name, str) else None,
|
||||
)
|
||||
return {
|
||||
"inspected": True,
|
||||
"reason": None,
|
||||
"files": [{"path": f.path, "size": f.size} for f in files],
|
||||
"plan": _serialize_plan(plan),
|
||||
}
|
||||
|
||||
|
||||
def register_release_inspect_routes(
|
||||
app: Flask,
|
||||
login_required: Callable[..., Any],
|
||||
) -> None:
|
||||
"""Register POST /api/releases/inspect."""
|
||||
|
||||
@app.route("/api/releases/inspect", methods=["POST"])
|
||||
@login_required
|
||||
def api_inspect_release() -> Response | tuple[Response, int]:
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
if not data.get("source_id"):
|
||||
return jsonify({"error": "source_id is required"}), 400
|
||||
if not data.get("source"):
|
||||
return jsonify({"error": "source is required"}), 400
|
||||
try:
|
||||
get_handler(str(data["source"]))
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(inspect_release(data))
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Shared release-search helpers.
|
||||
|
||||
Extracted from the ``/api/releases`` route so the same per-source search logic can
|
||||
be reused outside the HTTP route (for example by background automation) without
|
||||
going through Flask. Behaviour for the HTTP route is preserved: the route delegates
|
||||
its inner per-source search to :func:`search_source_releases`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.search_plan import build_release_search_plan
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.models import SearchFilters
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
from shelfmark.release_sources import Release, ReleaseSource
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Mirror of main._OPERATIONAL_ERRORS so a misbehaving source can't crash a caller.
|
||||
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
|
||||
|
||||
|
||||
def search_source_releases(
|
||||
source_name: str,
|
||||
search_book: BookMetadata,
|
||||
*,
|
||||
languages: list[str] | None = None,
|
||||
manual_query: str | None = None,
|
||||
indexers: list[str] | None = None,
|
||||
expand_search: bool = False,
|
||||
content_type: str = "ebook",
|
||||
source_filters: SearchFilters | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> tuple[ReleaseSource | None, list[Release], str | None]:
|
||||
"""Search a single release source, returning any error instead of raising.
|
||||
|
||||
Returns ``(source, releases, error_message)``. On failure ``source`` is ``None``
|
||||
and ``error_message`` describes the problem. ``user_id`` lets the search plan
|
||||
pick up that user's default languages when no explicit filter is given.
|
||||
"""
|
||||
from shelfmark.release_sources import SourceUnavailableError, get_source
|
||||
|
||||
try:
|
||||
source = get_source(source_name)
|
||||
|
||||
plan = build_release_search_plan(
|
||||
search_book,
|
||||
languages=languages,
|
||||
manual_query=manual_query,
|
||||
indexers=indexers,
|
||||
source_filters=source_filters,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
if plan.source_filters is not None:
|
||||
planned_query = plan.manual_query or plan.primary_query
|
||||
planned_query_type = "query"
|
||||
elif plan.manual_query:
|
||||
planned_query = plan.manual_query
|
||||
planned_query_type = "manual"
|
||||
elif not expand_search and plan.isbn_candidates:
|
||||
planned_query = plan.isbn_candidates[0]
|
||||
planned_query_type = "isbn"
|
||||
else:
|
||||
planned_query = plan.primary_query
|
||||
planned_query_type = "title_author"
|
||||
|
||||
logger.debug(
|
||||
"Searching %s: %s='%s' (title='%s', authors=%s, expand=%s, content_type=%s)",
|
||||
source_name,
|
||||
planned_query_type,
|
||||
planned_query,
|
||||
search_book.title,
|
||||
search_book.authors,
|
||||
expand_search,
|
||||
content_type,
|
||||
)
|
||||
|
||||
releases = source.search(
|
||||
search_book, plan, expand_search=expand_search, content_type=content_type
|
||||
)
|
||||
except ValueError:
|
||||
return None, [], f"Unknown source: {source_name}"
|
||||
except (SourceUnavailableError, *_OPERATIONAL_ERRORS) as exc:
|
||||
logger.warning("Release search failed for source %s: %s", source_name, exc)
|
||||
return None, [], f"{source_name}: {exc!s}"
|
||||
else:
|
||||
return source, releases, None
|
||||
@@ -715,6 +715,8 @@ def register_request_routes(
|
||||
raw_requests = data.get("requests")
|
||||
if not isinstance(raw_requests, list) or len(raw_requests) == 0:
|
||||
return jsonify({"error": "requests must contain at least one request"}), 400
|
||||
if not all(isinstance(raw_request, dict) for raw_request in raw_requests):
|
||||
return jsonify({"error": "requests must contain objects"}), 400
|
||||
|
||||
try:
|
||||
prepared_requests = [
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""A wall-clock budget for one release search, enforced through the existing cancel flag.
|
||||
|
||||
`/api/releases` is synchronous: the browser waits on it while the search runs. Nothing
|
||||
bounded that wait, and the bypasser's own worst case is minutes long
|
||||
(`internal_bypasser.max_duration_seconds()`), so a search that ran into an unsolvable
|
||||
protection challenge outlived every reverse proxy in front of it. The user then saw
|
||||
"Server unavailable (504)" - a gateway timeout that says nothing about what went wrong
|
||||
and points the blame at their proxy config. See issue #1276.
|
||||
|
||||
The budget is expressed as the cancel flag the download path already understands: an
|
||||
Event armed by a timer. `html_get_page`, the bypassers and the helper subprocess all poll
|
||||
it, so an expired budget stops a solve already in flight rather than only refusing the
|
||||
next one. When it trips, the search fails with a message that names the real cause.
|
||||
|
||||
Scoped to a context variable so it applies to the request that set it and to nothing else
|
||||
- a queued download must keep its own, much longer, budget.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# What one search may spend. A first search on a cold start legitimately pays for a
|
||||
# browser solve - jfmlima measured 60-120s for a successful one on Anna's Archive - so
|
||||
# this cannot be as tight as a proxy's default read timeout without breaking working
|
||||
# setups. It is instead well below the ~840s the bypass path could previously reach,
|
||||
# which is what turned a failing challenge into a gateway timeout.
|
||||
DEFAULT_SEARCH_BUDGET_SECONDS = 300.0
|
||||
|
||||
_MIN_SEARCH_BUDGET_SECONDS = 30.0
|
||||
_MAX_SEARCH_BUDGET_SECONDS = 1800.0
|
||||
|
||||
# Raised to the caller when the budget runs out, so the API can say so plainly.
|
||||
SEARCH_DEADLINE_MESSAGE = (
|
||||
"The release search ran out of time (%.0fs). Anna's Archive is behind a protection "
|
||||
"challenge the bypasser could not solve in that window. Raise the release search "
|
||||
"timeout if your setup is simply slow."
|
||||
)
|
||||
|
||||
|
||||
class SearchDeadline:
|
||||
"""A budget with an Event that trips when it expires."""
|
||||
|
||||
def __init__(self, budget_seconds: float) -> None:
|
||||
self.budget_seconds = budget_seconds
|
||||
self.expires_at = time.monotonic() + budget_seconds
|
||||
# A plain threading.Event on purpose: this is handed on as a cancel flag, and
|
||||
# that is the type the download path, the CDP worker thread and the bypass helper
|
||||
# already poll.
|
||||
self.event = threading.Event()
|
||||
self._timer = threading.Timer(budget_seconds, self.event.set)
|
||||
self._timer.daemon = True
|
||||
|
||||
def start(self) -> None:
|
||||
self._timer.start()
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._timer.cancel()
|
||||
|
||||
@property
|
||||
def remaining(self) -> float:
|
||||
return max(0.0, self.expires_at - time.monotonic())
|
||||
|
||||
@property
|
||||
def expired(self) -> bool:
|
||||
return self.event.is_set() or self.remaining <= 0
|
||||
|
||||
|
||||
_current: ContextVar[SearchDeadline | None] = ContextVar("search_deadline", default=None)
|
||||
|
||||
|
||||
def budget_seconds() -> float:
|
||||
"""The configured budget for one release search."""
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
raw = app_config.get("RELEASE_SEARCH_TIMEOUT", DEFAULT_SEARCH_BUDGET_SECONDS)
|
||||
if isinstance(raw, bool) or not isinstance(raw, int | float | str):
|
||||
return DEFAULT_SEARCH_BUDGET_SECONDS
|
||||
try:
|
||||
value = float(raw)
|
||||
except TypeError, ValueError:
|
||||
return DEFAULT_SEARCH_BUDGET_SECONDS
|
||||
if value <= 0:
|
||||
return DEFAULT_SEARCH_BUDGET_SECONDS
|
||||
return min(max(value, _MIN_SEARCH_BUDGET_SECONDS), _MAX_SEARCH_BUDGET_SECONDS)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def search_deadline(budget: float | None = None) -> Iterator[SearchDeadline]:
|
||||
"""Apply a budget to everything the calling context does."""
|
||||
deadline = SearchDeadline(budget if budget is not None else budget_seconds())
|
||||
token = _current.set(deadline)
|
||||
deadline.start()
|
||||
logger.debug("Release search budget: %.0fs", deadline.budget_seconds)
|
||||
try:
|
||||
yield deadline
|
||||
finally:
|
||||
deadline.cancel()
|
||||
_current.reset(token)
|
||||
|
||||
|
||||
def current() -> SearchDeadline | None:
|
||||
"""The budget in force, or None outside a search."""
|
||||
return _current.get()
|
||||
|
||||
|
||||
def expired() -> bool:
|
||||
"""Whether the budget in force has run out. False when there is no budget."""
|
||||
deadline = _current.get()
|
||||
return deadline is not None and deadline.expired
|
||||
|
||||
|
||||
def cancel_event() -> threading.Event | None:
|
||||
"""The Event that trips when the budget runs out, for use as a cancel flag."""
|
||||
deadline = _current.get()
|
||||
return deadline.event if deadline is not None else None
|
||||
|
||||
|
||||
def deadline_message() -> str:
|
||||
"""The failure to report when the budget has run out."""
|
||||
deadline = _current.get()
|
||||
budget = deadline.budget_seconds if deadline else DEFAULT_SEARCH_BUDGET_SECONDS
|
||||
return SEARCH_DEADLINE_MESSAGE % budget
|
||||
+103
-28
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
build_localized_search_titles,
|
||||
@@ -16,6 +17,8 @@ from shelfmark.metadata_providers import (
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.models import SearchFilters
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
MANUAL_QUERY_MAX_LEN = 256
|
||||
|
||||
|
||||
@@ -52,44 +55,110 @@ class ReleaseSearchPlan:
|
||||
return self.title_variants[0].query if self.title_variants else ""
|
||||
|
||||
|
||||
def _normalize_languages(languages: list[str] | None) -> list[str] | None:
|
||||
def _to_language_codes(values: Iterable[object], *, source: str) -> list[str] | None:
|
||||
"""Resolve any spelling of a language to the ISO code the sources expect.
|
||||
|
||||
Anna's Archive matches `lang=` against ISO codes: `lang=english` is not a loose
|
||||
spelling of `lang=en`, it is a facet value AA does not have, and it filters every
|
||||
search down to nothing. Only the *per-user* override was normalised
|
||||
(config.users_settings.validate), so a global BOOK_LANGUAGE=english - the spelling
|
||||
the old docs used - reached the query verbatim and silently emptied every search
|
||||
with no error anywhere. See issue #1276.
|
||||
|
||||
An entry that resolves to nothing is dropped with a warning rather than passed
|
||||
through: searching unfiltered and saying so beats reporting "no results" for a book
|
||||
the source is full of.
|
||||
"""
|
||||
from shelfmark.core.languages import normalize_language
|
||||
|
||||
codes: list[str] = []
|
||||
unresolved: list[str] = []
|
||||
for value in values:
|
||||
text = str(value).strip() if value is not None else ""
|
||||
if not text:
|
||||
continue
|
||||
if text.lower() == "all":
|
||||
# An explicit "search every language", not a language.
|
||||
return None
|
||||
code = normalize_language(text)
|
||||
if code is None:
|
||||
unresolved.append(text)
|
||||
continue
|
||||
if code not in codes:
|
||||
codes.append(code)
|
||||
|
||||
if unresolved:
|
||||
logger.warning(
|
||||
"Ignoring unrecognised language(s) in %s: %s. Use an ISO code such as 'en', "
|
||||
"a three-letter code, or an English name like 'English'.",
|
||||
source,
|
||||
", ".join(unresolved),
|
||||
)
|
||||
|
||||
return codes or None
|
||||
|
||||
|
||||
def _normalize_languages(languages: list[str] | None, user_id: int | None) -> list[str] | None:
|
||||
if not languages:
|
||||
default = getattr(config, "BOOK_LANGUAGE", None)
|
||||
default = config.get("BOOK_LANGUAGE", None, user_id=user_id)
|
||||
if isinstance(default, str):
|
||||
default_values: list[object] = [default]
|
||||
elif isinstance(default, Iterable) and not isinstance(default, (bytes, bytearray, dict)):
|
||||
default_values = list(default)
|
||||
else:
|
||||
return None
|
||||
return [str(lang).strip() for lang in default_values if str(lang).strip()]
|
||||
return _to_language_codes(default_values, source="BOOK_LANGUAGE")
|
||||
|
||||
normalized: list[str] = []
|
||||
for lang in languages:
|
||||
if not lang:
|
||||
continue
|
||||
s = str(lang).strip()
|
||||
if not s:
|
||||
continue
|
||||
normalized.append(s)
|
||||
|
||||
if any(lang.lower() == "all" for lang in normalized):
|
||||
return None
|
||||
|
||||
return normalized or None
|
||||
return _to_language_codes(languages, source="the search request")
|
||||
|
||||
|
||||
def _pick_search_author(book: BookMetadata) -> str:
|
||||
def first_author(value: str) -> str:
|
||||
"""The first name in a possibly comma-joined author string.
|
||||
|
||||
Both ends of the app hand us every contributor in one string. The frontend joins
|
||||
`authors` with ", " for display (`bookTransformers.ts`) and that display string comes
|
||||
straight back as the `author` request parameter, while several providers set
|
||||
`search_author` from the same joined text. Searching a release source for
|
||||
"Blindness Jose Saramago, Giovanni Pontiero, ..." - the author plus two translators -
|
||||
matches nothing, and the user is told the book has no releases at all.
|
||||
|
||||
A "Last, First" author collapses to the surname, which is still a usable search term
|
||||
and is what the authors[] fallback has always done with the same input. See #1252.
|
||||
"""
|
||||
first, _, _ = value.partition(",")
|
||||
return first.strip()
|
||||
|
||||
|
||||
def pick_search_author(book: BookMetadata) -> str:
|
||||
"""The one author a release query should carry, from whichever field holds one.
|
||||
|
||||
Every release source that builds its own query wants exactly this, so it lives here
|
||||
rather than being re-derived per source - the two branches below drifted apart once
|
||||
already (#1252) and the IRC source carried a third copy of the same preference.
|
||||
|
||||
#1290 fixed the same report by merging the two branches and trimming whichever one
|
||||
won; this keeps that outcome ("Blindness Jose Saramago" from either field, measured
|
||||
there at 0 releases before and 49 after) and adds the empty-narrowing fallback, so a
|
||||
credit list that merely starts with a blank entry does not fall out to title-only.
|
||||
"""
|
||||
# Narrowing can come back empty - the joined string starts with a comma because the
|
||||
# first contributor was blank, and `authors.join(', ')` does not drop the empty entry.
|
||||
# Falling through to authors[] then still finds a usable name; returning "" would
|
||||
# search by title alone and lose the author we were holding all along.
|
||||
if book.search_author:
|
||||
return book.search_author
|
||||
narrowed = first_author(book.search_author)
|
||||
if narrowed:
|
||||
return narrowed
|
||||
|
||||
if not book.authors:
|
||||
return ""
|
||||
# A bare string here would otherwise be iterated one character at a time; the IRC
|
||||
# source guarded against exactly that before it shared this helper.
|
||||
authors = book.authors if isinstance(book.authors, list) else [book.authors or ""]
|
||||
for author in authors:
|
||||
narrowed = first_author(author or "")
|
||||
if narrowed:
|
||||
return narrowed
|
||||
|
||||
first = book.authors[0]
|
||||
if "," in first:
|
||||
first = first.split(",")[0].strip()
|
||||
|
||||
return first
|
||||
return ""
|
||||
|
||||
|
||||
def _pick_search_title(book: BookMetadata) -> str:
|
||||
@@ -102,15 +171,21 @@ def build_release_search_plan(
|
||||
manual_query: str | None = None,
|
||||
indexers: list[str] | None = None,
|
||||
source_filters: SearchFilters | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> ReleaseSearchPlan:
|
||||
"""Build normalized search variants shared across release sources."""
|
||||
resolved_languages = _normalize_languages(languages)
|
||||
"""Build normalized search variants shared across release sources.
|
||||
|
||||
``user_id`` picks up that user's default languages when the caller does not
|
||||
filter explicitly, so a search started without a language filter uses the
|
||||
reader's own default rather than the instance-wide one.
|
||||
"""
|
||||
resolved_languages = _normalize_languages(languages, user_id)
|
||||
|
||||
resolved_manual_query = None
|
||||
if manual_query:
|
||||
resolved_manual_query = manual_query.strip()[:MANUAL_QUERY_MAX_LEN] or None
|
||||
|
||||
author = _pick_search_author(book)
|
||||
author = pick_search_author(book)
|
||||
base_title = _pick_search_title(book)
|
||||
|
||||
if resolved_manual_query:
|
||||
|
||||
@@ -302,6 +302,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
auth_source = capabilities["authSource"]
|
||||
|
||||
password = data.get("password", "")
|
||||
password_hash: str | None = None
|
||||
if password:
|
||||
if not capabilities["canSetPassword"]:
|
||||
return jsonify(
|
||||
@@ -314,7 +315,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
return jsonify(
|
||||
{"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}
|
||||
), 400
|
||||
user_db.update_user(user_id, password_hash=generate_password_hash(password))
|
||||
password_hash = generate_password_hash(password)
|
||||
|
||||
user_fields: dict[str, Any] = {}
|
||||
if "email" in data:
|
||||
@@ -363,9 +364,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
if field in user_fields and user_fields[field] == user.get(field):
|
||||
user_fields.pop(field)
|
||||
|
||||
if user_fields:
|
||||
user_db.update_user(user_id, **user_fields)
|
||||
|
||||
validated_settings: dict[str, Any] | None = None
|
||||
if "settings" in data:
|
||||
settings_payload = data["settings"]
|
||||
if not isinstance(settings_payload, dict):
|
||||
@@ -397,6 +396,14 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
}
|
||||
), 400
|
||||
|
||||
# Apply the writes only once the whole payload has been accepted.
|
||||
if password_hash is not None:
|
||||
user_fields["password_hash"] = password_hash
|
||||
|
||||
if user_fields:
|
||||
user_db.update_user(user_id, **user_fields)
|
||||
|
||||
if validated_settings is not None:
|
||||
user_db.set_user_settings(user_id, validated_settings)
|
||||
try:
|
||||
app_config.refresh(force=True)
|
||||
|
||||
@@ -87,7 +87,8 @@ CREATE TABLE IF NOT EXISTS download_history (
|
||||
download_path TEXT,
|
||||
retry_payload TEXT,
|
||||
queued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
terminal_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
terminal_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
downloads INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_history_user_status
|
||||
@@ -203,6 +204,7 @@ class UserDB:
|
||||
self._migrate_request_delivery_columns(conn)
|
||||
self._migrate_download_history_queued_at(conn)
|
||||
self._migrate_download_history_retry_payload(conn)
|
||||
self._migrate_download_history_downloads(conn)
|
||||
conn.commit()
|
||||
# WAL mode must be changed outside an open transaction.
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
@@ -270,6 +272,13 @@ class UserDB:
|
||||
if "retry_payload" not in column_names:
|
||||
conn.execute("ALTER TABLE download_history ADD COLUMN retry_payload TEXT")
|
||||
|
||||
def _migrate_download_history_downloads(self, conn: sqlite3.Connection) -> None:
|
||||
"""Ensure download_history.downloads exists for download count persistence."""
|
||||
columns = conn.execute("PRAGMA table_info(download_history)").fetchall()
|
||||
column_names = {str(col["name"]) for col in columns}
|
||||
if "downloads" not in column_names:
|
||||
conn.execute("ALTER TABLE download_history ADD COLUMN downloads INTEGER")
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
@@ -424,6 +433,26 @@ class UserDB:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_first_admin(self) -> dict[str, Any] | None:
|
||||
"""Return the lowest-id admin user, or None. Used as the identity for SHELFMARK_API_KEY requests."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def has_admin(self) -> bool:
|
||||
"""Return True when at least one admin user exists."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def has_admin_with_password(self) -> bool:
|
||||
"""Return True when at least one admin user with a password hash exists."""
|
||||
conn = self._connect()
|
||||
|
||||
@@ -122,7 +122,12 @@ def is_audiobook(content_type: str | None) -> bool:
|
||||
# had drifted apart: the settings UI only offered m4b/mp3/m4a, which meant a FLAC
|
||||
# audiobook could never be enabled, was silently dropped from every search result, and
|
||||
# was rejected after download as "format not supported".
|
||||
AUDIOBOOK_FORMATS = ("m4b", "mp3", "m4a", "flac", "ogg", "wma", "aac", "wav", "opus")
|
||||
#
|
||||
# "mp4" is here because some trackers (MyAnonamouse in particular) ship AAC audiobooks
|
||||
# as per-chapter .mp4 files - the same ISO-BMFF container as .m4a/.m4b, just with the
|
||||
# generic extension. Without it those releases downloaded fine and then failed
|
||||
# post-processing with "No book files found in download".
|
||||
AUDIOBOOK_FORMATS = ("m4b", "mp3", "m4a", "mp4", "flac", "ogg", "wma", "aac", "wav", "opus")
|
||||
|
||||
# Multi-file audiobooks are almost always distributed as an archive. These are containers
|
||||
# rather than formats: they are what a *release* looks like, and the formats above are
|
||||
|
||||
@@ -190,6 +190,8 @@ class DownloadClient(ABC):
|
||||
# Class attributes that subclasses must define
|
||||
protocol: str
|
||||
name: str
|
||||
handoff_only = False
|
||||
prefers_torrent_file = False
|
||||
|
||||
def _log_error(self, method: str, e: Exception, level: str = "error") -> str:
|
||||
"""Log a client error with consistent formatting.
|
||||
@@ -375,12 +377,14 @@ _CLIENTS: dict[str, list[type[DownloadClient]]] = {}
|
||||
ClientType = TypeVar("ClientType", bound=DownloadClient)
|
||||
_BUILTIN_CLIENT_MODULES = (
|
||||
"shelfmark.download.clients.alldebrid",
|
||||
"shelfmark.download.clients.blackhole",
|
||||
"shelfmark.download.clients.deluge",
|
||||
"shelfmark.download.clients.nzbget",
|
||||
"shelfmark.download.clients.qbittorrent",
|
||||
"shelfmark.download.clients.realdebrid",
|
||||
"shelfmark.download.clients.rtorrent",
|
||||
"shelfmark.download.clients.sabnzbd",
|
||||
"shelfmark.download.clients.torbox",
|
||||
"shelfmark.download.clients.transmission",
|
||||
)
|
||||
_builtin_client_state = {"loaded": False}
|
||||
@@ -467,6 +471,15 @@ def list_configured_clients() -> list[str]:
|
||||
return result
|
||||
|
||||
|
||||
def client_prefers_torrent_file(protocol: str) -> bool:
|
||||
"""Whether the active client needs a fetched .torrent file instead of a magnet."""
|
||||
_ensure_builtin_clients_registered()
|
||||
return any(
|
||||
client_cls.prefers_torrent_file and client_cls.is_configured()
|
||||
for client_cls in _CLIENTS.get(protocol, [])
|
||||
)
|
||||
|
||||
|
||||
def get_all_clients() -> dict[str, list[type[DownloadClient]]]:
|
||||
"""Get all registered client classes.
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ from shelfmark.download.clients import (
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.download.clients._coercion import config_text
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
DebridMagnet,
|
||||
DebridUpload,
|
||||
resolve_debrid_upload,
|
||||
)
|
||||
from shelfmark.download.http import download_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
@@ -71,6 +76,7 @@ _BOOK_EXTENSIONS = (
|
||||
".m4b",
|
||||
".mobi",
|
||||
".mp3",
|
||||
".mp4",
|
||||
".ogg",
|
||||
".opus",
|
||||
".pdf",
|
||||
@@ -202,41 +208,19 @@ class AllDebridClient(DownloadClient):
|
||||
expected_hash: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""Upload a magnet link to AllDebrid and return the magnet ID."""
|
||||
"""Send a torrent to AllDebrid and return the magnet ID.
|
||||
|
||||
Accepts a magnet link, a .torrent URL, or an indexer proxy URL; anything
|
||||
that is not already a magnet is resolved first, since an HTTP URL posted
|
||||
as a magnet is rejected rather than downloaded (#1250).
|
||||
"""
|
||||
if not self._api_key:
|
||||
msg = "AllDebrid API key is not configured"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
magnet_link = url
|
||||
if not magnet_link.startswith("magnet:") and expected_hash:
|
||||
magnet_link = f"magnet:?xt=urn:btih:{expected_hash}"
|
||||
|
||||
api_url = f"{_API_BASE}/magnet/upload"
|
||||
try:
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
headers=self._auth_headers(),
|
||||
data={"magnets[]": magnet_link},
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(api_url),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status") != "success":
|
||||
code = data.get("error", {}).get("code", "UNKNOWN")
|
||||
msg = f"AllDebrid upload failed: {code}"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
magnets = data.get("data", {}).get("magnets", [])
|
||||
if not magnets:
|
||||
msg = "No magnet returned from AllDebrid"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
info = magnets[0]
|
||||
if info.get("error"):
|
||||
code = info["error"].get("code", "UNKNOWN")
|
||||
msg = f"AllDebrid magnet error: {code}"
|
||||
_raise_runtime_error(msg)
|
||||
upload = resolve_debrid_upload(url, expected_hash=expected_hash)
|
||||
info = self._send_torrent(upload)
|
||||
|
||||
magnet_id = str(info.get("id", ""))
|
||||
if not magnet_id:
|
||||
@@ -262,12 +246,65 @@ class AllDebridClient(DownloadClient):
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to upload magnet to AllDebrid")
|
||||
logger.exception("Failed to add torrent to AllDebrid")
|
||||
raise
|
||||
|
||||
else:
|
||||
return magnet_id
|
||||
|
||||
def _send_torrent(self, upload: DebridUpload) -> dict[str, Any]:
|
||||
"""Hand the torrent to AllDebrid, as a magnet or as a file upload.
|
||||
|
||||
Both endpoints answer with the same envelope and the same per-entry
|
||||
error shape, differing only in which key holds the entries.
|
||||
"""
|
||||
if isinstance(upload, DebridMagnet):
|
||||
api_url = f"{_API_BASE}/magnet/upload"
|
||||
entries_key = "magnets"
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
headers=self._auth_headers(),
|
||||
data={"magnets[]": upload.magnet_url},
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(api_url),
|
||||
)
|
||||
else:
|
||||
api_url = f"{_API_BASE}/magnet/upload/file"
|
||||
entries_key = "files"
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
headers=self._auth_headers(),
|
||||
files={
|
||||
"files[]": (
|
||||
"release.torrent",
|
||||
upload.torrent_data,
|
||||
"application/x-bittorrent",
|
||||
)
|
||||
},
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(api_url),
|
||||
)
|
||||
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("status") != "success":
|
||||
code = data.get("error", {}).get("code", "UNKNOWN")
|
||||
msg = f"AllDebrid upload failed: {code}"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
entries = data.get("data", {}).get(entries_key, [])
|
||||
if not entries:
|
||||
msg = "AllDebrid accepted the upload but returned no torrent"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
info = entries[0]
|
||||
if info.get("error"):
|
||||
code = info["error"].get("code", "UNKNOWN")
|
||||
msg = f"AllDebrid rejected the torrent: {code}"
|
||||
_raise_runtime_error(msg)
|
||||
|
||||
return info
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""Poll AllDebrid for magnet status and drive the download."""
|
||||
state = self._ensure_state(download_id)
|
||||
|
||||
@@ -24,7 +24,7 @@ from shelfmark.download.clients import (
|
||||
)
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
from shelfmark.download.permissions_debug import log_path_permission_context
|
||||
from shelfmark.release_sources import DownloadHandler
|
||||
from shelfmark.release_sources import DownloadHandler, HandoffResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -762,8 +762,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Execute download via configured torrent/usenet client. Returns file path or None."""
|
||||
) -> str | HandoffResult | None:
|
||||
"""Execute download via configured torrent/usenet client."""
|
||||
try:
|
||||
if cancel_flag.is_set():
|
||||
status_callback("cancelled", "Cancelled")
|
||||
@@ -843,6 +843,9 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
expected_hash=request.expected_hash,
|
||||
seeding_time_limit=request.seeding_time_limit,
|
||||
ratio_limit=request.ratio_limit,
|
||||
# rTorrent has no category concept, so its audiobook label
|
||||
# can only be chosen from the content type (#1235).
|
||||
content_type=task.content_type,
|
||||
)
|
||||
except Exception as e:
|
||||
if not refresh_attempted:
|
||||
@@ -868,6 +871,33 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
logger.info(
|
||||
"Added to %s: %s for '%s'", client.name, download_id, request.release_name
|
||||
)
|
||||
if getattr(client, "handoff_only", False) is True:
|
||||
if cancel_flag.is_set():
|
||||
# The file is published and unpublishing it would race a watcher
|
||||
# that may already have consumed it, so the handoff stands even
|
||||
# though the task is cancelled. Say so rather than leaving a bare
|
||||
# "Cancelled" the user cannot act on.
|
||||
logger.info(
|
||||
"Cancelled after handoff to %s; leaving publication in place: %s",
|
||||
client.name,
|
||||
download_id,
|
||||
)
|
||||
status_callback(
|
||||
"cancelled",
|
||||
f"Cancelled, but the torrent was already handed off to {client.name}",
|
||||
)
|
||||
return None
|
||||
# A watcher can consume the publication as soon as add_download returns,
|
||||
# so the handoff completes here rather than in the poll loop. The
|
||||
# orchestrator deliberately does not check that this path still exists:
|
||||
# a consumed publication is indistinguishable from a bogus one, and
|
||||
# treating it as an error is the failure this avoids (#1345).
|
||||
progress_callback(100)
|
||||
self._on_download_complete(task)
|
||||
return HandoffResult(
|
||||
path=download_id,
|
||||
message=f"Torrent file saved to {download_id}",
|
||||
)
|
||||
|
||||
# Poll for progress
|
||||
return self._poll_and_complete(
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Blackhole download client that saves torrent files for an external watcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.naming import sanitize_filename
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadState,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.download.clients._coercion import config_text
|
||||
from shelfmark.download.clients.torrent_utils import extract_torrent_info
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class BlackholeClient(DownloadClient):
|
||||
"""Write fetched torrent files to a directory watched by another downloader."""
|
||||
|
||||
protocol = "torrent"
|
||||
name = "blackhole"
|
||||
handoff_only = True
|
||||
prefers_torrent_file = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
directory = config_text(config.get("BLACKHOLE_DIRECTORY", ""))
|
||||
if not directory:
|
||||
msg = "BLACKHOLE_DIRECTORY is required"
|
||||
raise ValueError(msg)
|
||||
self._directory = Path(directory)
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
return config_text(config.get("PROWLARR_TORRENT_CLIENT", "")) == "blackhole" and bool(
|
||||
config_text(config.get("BLACKHOLE_DIRECTORY", ""))
|
||||
)
|
||||
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
try:
|
||||
self._directory.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as error:
|
||||
return False, f"Could not create Blackhole directory: {error}"
|
||||
return True, f"Blackhole directory is ready: {self._directory}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
if not torrent_info.torrent_data:
|
||||
msg = "Blackhole requires a .torrent file; this release only provides a magnet link"
|
||||
raise ValueError(msg)
|
||||
|
||||
self._directory.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{sanitize_filename(name) or 'torrent'}.torrent"
|
||||
destination = self._next_destination(filename)
|
||||
file_descriptor, temporary_path = tempfile.mkstemp(
|
||||
dir=self._directory,
|
||||
prefix=".blackhole-",
|
||||
suffix=".tmp",
|
||||
)
|
||||
try:
|
||||
with os.fdopen(file_descriptor, "wb") as temporary_file:
|
||||
temporary_file.write(torrent_info.torrent_data)
|
||||
temporary_file.flush()
|
||||
os.fsync(temporary_file.fileno())
|
||||
Path(temporary_path).replace(destination)
|
||||
except Exception:
|
||||
Path(temporary_path).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
return str(destination)
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
file_path = Path(download_id)
|
||||
if file_path.is_file():
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
state=DownloadState.COMPLETE,
|
||||
message="Torrent file saved",
|
||||
complete=True,
|
||||
file_path=str(file_path),
|
||||
)
|
||||
return DownloadStatus.error("Blackhole torrent file was not created")
|
||||
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
return download_id if Path(download_id).is_file() else None
|
||||
|
||||
def _next_destination(self, filename: str) -> Path:
|
||||
candidate = self._directory / filename
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
|
||||
stem = Path(filename).stem
|
||||
suffix = Path(filename).suffix
|
||||
index = 1
|
||||
while True:
|
||||
candidate = self._directory / f"{stem}_{index}{suffix}"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
index += 1
|
||||
@@ -292,11 +292,14 @@ class DelugeClient(DownloadClient):
|
||||
# Per-torrent seeding limits from indexer
|
||||
seeding_time_limit = coerce_optional_int(kwargs.get("seeding_time_limit"))
|
||||
if seeding_time_limit is not None:
|
||||
options["seed_time_limit"] = seeding_time_limit
|
||||
logger.debug(
|
||||
"Deluge has no per-torrent seeding time limit, ignoring %s minutes",
|
||||
seeding_time_limit,
|
||||
)
|
||||
ratio_limit = coerce_optional_float(kwargs.get("ratio_limit"))
|
||||
if ratio_limit is not None:
|
||||
options["stop_at_ratio"] = ratio_limit
|
||||
options["stop_at_ratio_enabled"] = True
|
||||
options["stop_ratio"] = ratio_limit
|
||||
options["stop_at_ratio"] = True
|
||||
|
||||
if torrent_info.is_magnet:
|
||||
magnet_url = torrent_info.magnet_url or url
|
||||
|
||||
@@ -45,6 +45,10 @@ _HASH_LENGTH_ED2K = 32
|
||||
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
|
||||
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
_METADATA_DOWNLOAD_STATES = {"forcedMetaDL", "metaDL"}
|
||||
# How long add_download waits for magnet metadata before falling back to the info
|
||||
# hash it already knows, rather than holding the download queue on a thin swarm.
|
||||
_METADATA_WAIT_POLLS = 20
|
||||
_METADATA_WAIT_INTERVAL_SECONDS = 0.5
|
||||
_ONE_WEEK_IN_SECONDS = 604800
|
||||
|
||||
|
||||
@@ -221,6 +225,9 @@ class QBittorrentClient(DownloadClient):
|
||||
self._category = config_text(config.get("QBITTORRENT_CATEGORY", "books"))
|
||||
self._download_dir = config_text(config.get("QBITTORRENT_DOWNLOAD_DIR", ""))
|
||||
self._tags = _normalize_tags(config.get("QBITTORRENT_TAG", []))
|
||||
# download_id -> qBittorrent's current primary hash, for identities that no
|
||||
# longer match it directly. See _resolve_torrent().
|
||||
self._primary_hashes: dict[str, str] = {}
|
||||
|
||||
@property
|
||||
def _can_reauthenticate(self) -> bool:
|
||||
@@ -307,13 +314,31 @@ class QBittorrentClient(DownloadClient):
|
||||
params = {"category": category} if category else {}
|
||||
return self._request_torrent_info_records(params)
|
||||
|
||||
def _remember_primary_hash(self, download_id: str, torrent: SimpleNamespace) -> None:
|
||||
"""Note the primary hash a listing scan found, so later lookups skip the scan."""
|
||||
torrent_hash = getattr(torrent, "hash", None)
|
||||
if isinstance(torrent_hash, str) and torrent_hash:
|
||||
self._primary_hashes[download_id.lower()] = torrent_hash.lower()
|
||||
|
||||
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
|
||||
"""Resolve any known torrent identity to its current qBittorrent record.
|
||||
|
||||
A hybrid torrent's primary hash switches from the v1 hash to the truncated v2
|
||||
hash once metadata resolves, so a download tracked by its v1 hash misses the
|
||||
`hashes=` lookup and falls through to a full listing. Since `get_status()`
|
||||
polls every couple of seconds for the life of the download, remember the
|
||||
primary hash a scan finds and try it first.
|
||||
"""
|
||||
cached = self._primary_hashes.get(download_id.lower())
|
||||
for candidate in (item for item in dict.fromkeys((cached, download_id)) if item):
|
||||
torrent, error = self._get_torrent_info(candidate)
|
||||
if error:
|
||||
return None, error
|
||||
if torrent:
|
||||
self._remember_primary_hash(download_id, torrent)
|
||||
return torrent, None
|
||||
|
||||
categories = [candidate for candidate in (category, self._category) if candidate]
|
||||
for candidate in dict.fromkeys(categories):
|
||||
@@ -325,18 +350,41 @@ class QBittorrentClient(DownloadClient):
|
||||
None,
|
||||
)
|
||||
if torrent:
|
||||
self._remember_primary_hash(download_id, 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,
|
||||
),
|
||||
torrent = next(
|
||||
(item for item in torrents if _torrent_matches_download_id(item, download_id)),
|
||||
None,
|
||||
)
|
||||
if torrent:
|
||||
self._remember_primary_hash(download_id, torrent)
|
||||
else:
|
||||
# The torrent is gone; drop the note so a re-add is not looked up by a
|
||||
# hash that no longer exists.
|
||||
self._primary_hashes.pop(download_id.lower(), None)
|
||||
return torrent, None
|
||||
|
||||
def _current_hash(self, download_id: str) -> str:
|
||||
"""qBittorrent's current primary hash for any identity we know the torrent by.
|
||||
|
||||
Falls back to the given ID when the torrent cannot be found, so callers
|
||||
still address the hash they were handed and surface the client's error.
|
||||
"""
|
||||
try:
|
||||
torrent, error = self._resolve_torrent(download_id)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
logger.debug("Could not resolve current hash for %s: %s", download_id, e)
|
||||
return download_id
|
||||
if error or not torrent:
|
||||
return download_id
|
||||
torrent_hash = getattr(torrent, "hash", None)
|
||||
if isinstance(torrent_hash, str) and torrent_hash:
|
||||
return torrent_hash
|
||||
return download_id
|
||||
|
||||
def _list_category_hashes(self, category: str | None) -> set[str] | None:
|
||||
"""Snapshot the hashes qBittorrent currently reports for a category."""
|
||||
@@ -495,9 +543,13 @@ class QBittorrentClient(DownloadClient):
|
||||
message = f"{message} (torrent file fetch failed: {torrent_info.fetch_error})"
|
||||
_raise_runtime_error(message)
|
||||
|
||||
# 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):
|
||||
# Prefer qBittorrent's primary torrent ID, which for hybrid torrents
|
||||
# switches from the v1 hash to the truncated v2 hash once metadata
|
||||
# resolves. A magnet with few peers can take minutes to fetch metadata,
|
||||
# and the torrent is worth keeping in the meantime: every lookup goes
|
||||
# through `_resolve_torrent`, which still matches the v1 hash against
|
||||
# `infohash_v1` after the primary ID has changed.
|
||||
for _ in range(_METADATA_WAIT_POLLS):
|
||||
torrent, error = self._resolve_torrent(expected_hash, category)
|
||||
if error:
|
||||
logger.debug("qBittorrent add_download: %s", error)
|
||||
@@ -506,17 +558,18 @@ class QBittorrentClient(DownloadClient):
|
||||
if isinstance(torrent_hash, str) and torrent_hash:
|
||||
logger.info("Added torrent: %s", torrent_hash)
|
||||
return torrent_hash.lower()
|
||||
time.sleep(0.5)
|
||||
time.sleep(_METADATA_WAIT_INTERVAL_SECONDS)
|
||||
|
||||
_raise_runtime_error(
|
||||
"Torrent metadata resolution was not confirmed within the visibility grace period "
|
||||
f"(response={result_text})"
|
||||
logger.info(
|
||||
"Added torrent %s; metadata still pending after %.0fs, tracking it by info hash",
|
||||
expected_hash,
|
||||
_METADATA_WAIT_POLLS * _METADATA_WAIT_INTERVAL_SECONDS,
|
||||
)
|
||||
except _QBITTORRENT_CLIENT_ERRORS:
|
||||
logger.exception("qBittorrent add failed")
|
||||
raise
|
||||
else:
|
||||
return expected_hash
|
||||
return expected_hash.lower()
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""Get torrent status by hash.
|
||||
@@ -529,7 +582,7 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
"""
|
||||
try:
|
||||
torrent, error = self._get_torrent_info(download_id)
|
||||
torrent, error = self._resolve_torrent(download_id)
|
||||
if error:
|
||||
return DownloadStatus.error(error)
|
||||
if not torrent:
|
||||
@@ -613,7 +666,9 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
"""
|
||||
try:
|
||||
self._client.torrents_delete(torrent_hashes=download_id, delete_files=delete_files)
|
||||
torrent_hash = self._current_hash(download_id)
|
||||
self._client.torrents_delete(torrent_hashes=torrent_hash, delete_files=delete_files)
|
||||
self._primary_hashes.pop(download_id.lower(), None)
|
||||
logger.info(
|
||||
"Removed torrent from qBittorrent: %s%s",
|
||||
download_id,
|
||||
@@ -635,7 +690,7 @@ class QBittorrentClient(DownloadClient):
|
||||
logger.debug("Could not create category '%s': %s", category, e)
|
||||
|
||||
self._client.torrents_set_category(
|
||||
torrent_hashes=download_id,
|
||||
torrent_hashes=self._current_hash(download_id),
|
||||
category=category,
|
||||
)
|
||||
logger.info("Set qBittorrent category for %s to '%s'", download_id, category)
|
||||
@@ -657,7 +712,7 @@ class QBittorrentClient(DownloadClient):
|
||||
- join `save_path` with the torrent's top-level directory
|
||||
"""
|
||||
try:
|
||||
torrent, error = self._get_torrent_info(download_id)
|
||||
torrent, error = self._resolve_torrent(download_id)
|
||||
if error:
|
||||
logger.debug("qBittorrent get_download_path: %s", error)
|
||||
return None
|
||||
@@ -758,6 +813,33 @@ class QBittorrentClient(DownloadClient):
|
||||
)
|
||||
return None
|
||||
|
||||
def _await_existing_torrent(
|
||||
self, info_hash: str, category: str | None
|
||||
) -> tuple[str, DownloadStatus] | None:
|
||||
"""Report a torrent already in qBittorrent, waiting out magnet metadata first."""
|
||||
for _ in range(_METADATA_WAIT_POLLS):
|
||||
torrent, error = self._resolve_torrent(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(_METADATA_WAIT_INTERVAL_SECONDS)
|
||||
|
||||
# Metadata is still pending, but the torrent is here and `add_download` keeps
|
||||
# one in this state rather than giving up. Report it by info hash so the
|
||||
# caller joins the download in progress instead of adding a duplicate.
|
||||
logger.info(
|
||||
"Existing torrent %s is still fetching metadata; joining it by info hash",
|
||||
info_hash,
|
||||
)
|
||||
return (info_hash.lower(), self.get_status(info_hash))
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: str | None = None
|
||||
) -> tuple[str, DownloadStatus] | None:
|
||||
@@ -767,21 +849,9 @@ class QBittorrentClient(DownloadClient):
|
||||
if not torrent_info.info_hash:
|
||||
return None
|
||||
|
||||
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)
|
||||
existing = self._await_existing_torrent(torrent_info.info_hash, category)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
logger.debug("Error checking for existing torrent: %s", e)
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
return existing
|
||||
|
||||
@@ -24,6 +24,11 @@ from shelfmark.download.clients import (
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.download.clients._coercion import config_text
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
DebridMagnet,
|
||||
DebridUpload,
|
||||
resolve_debrid_upload,
|
||||
)
|
||||
from shelfmark.download.http import download_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
@@ -45,6 +50,7 @@ _STATUS_DOWNLOADING = frozenset(
|
||||
{
|
||||
"magnet_conversion",
|
||||
"waiting_files_selection",
|
||||
"queued",
|
||||
"downloading",
|
||||
"compressing",
|
||||
"uploading",
|
||||
@@ -75,6 +81,7 @@ _BOOK_EXTENSIONS = (
|
||||
".m4b",
|
||||
".mobi",
|
||||
".mp3",
|
||||
".mp4",
|
||||
".ogg",
|
||||
".opus",
|
||||
".pdf",
|
||||
@@ -173,26 +180,19 @@ class RealDebridClient(DownloadClient):
|
||||
expected_hash: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""Upload a magnet link to Real-Debrid and select all files."""
|
||||
"""Send a torrent to Real-Debrid and select all files.
|
||||
|
||||
Accepts a magnet link, a .torrent URL, or an indexer proxy URL; anything
|
||||
that is not already a magnet is resolved first, because Real-Debrid
|
||||
answers a non-magnet body on addMagnet with a bare 404 (#1250).
|
||||
"""
|
||||
if not self._api_key:
|
||||
msg = "Real-Debrid API key is not configured"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
magnet_link = url
|
||||
if not magnet_link.startswith("magnet:") and expected_hash:
|
||||
magnet_link = f"magnet:?xt=urn:btih:{expected_hash}"
|
||||
|
||||
add_url = f"{_API_BASE}/torrents/addMagnet"
|
||||
try:
|
||||
resp = requests.post(
|
||||
add_url,
|
||||
headers=self._auth_headers(),
|
||||
data={"magnet": magnet_link},
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(add_url),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
upload = resolve_debrid_upload(url, expected_hash=expected_hash)
|
||||
data = self._send_torrent(upload)
|
||||
|
||||
torrent_id = str(data.get("id", ""))
|
||||
if not torrent_id:
|
||||
@@ -229,12 +229,41 @@ class RealDebridClient(DownloadClient):
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to upload magnet to Real-Debrid")
|
||||
logger.exception("Failed to add torrent to Real-Debrid")
|
||||
raise
|
||||
|
||||
else:
|
||||
return torrent_id
|
||||
|
||||
def _send_torrent(self, upload: DebridUpload) -> dict[str, Any]:
|
||||
"""Hand the torrent to Real-Debrid, as a magnet or as a file upload."""
|
||||
if isinstance(upload, DebridMagnet):
|
||||
add_url = f"{_API_BASE}/torrents/addMagnet"
|
||||
resp = requests.post(
|
||||
add_url,
|
||||
headers=self._auth_headers(),
|
||||
data={"magnet": upload.magnet_url},
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(add_url),
|
||||
)
|
||||
else:
|
||||
# addTorrent is a PUT that takes the raw file as the request body,
|
||||
# not a form field: https://api.real-debrid.com/
|
||||
add_url = f"{_API_BASE}/torrents/addTorrent"
|
||||
resp = requests.put(
|
||||
add_url,
|
||||
headers={
|
||||
**self._auth_headers(),
|
||||
"Content-Type": "application/x-bittorrent",
|
||||
},
|
||||
data=upload.torrent_data,
|
||||
timeout=_API_TIMEOUT,
|
||||
verify=get_ssl_verify(add_url),
|
||||
)
|
||||
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""Poll Real-Debrid for torrent status and drive the download."""
|
||||
state = self._ensure_state(download_id)
|
||||
|
||||
@@ -11,7 +11,12 @@ from urllib.parse import urlparse
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import get_hardened_xmlrpc_client
|
||||
from shelfmark.core.utils import (
|
||||
get_hardened_xmlrpc_client,
|
||||
)
|
||||
from shelfmark.core.utils import (
|
||||
is_audiobook as check_audiobook,
|
||||
)
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
@@ -173,7 +178,8 @@ class RTorrentClient(DownloadClient):
|
||||
|
||||
commands = []
|
||||
|
||||
is_audiobook = kwargs.get("content_type") == "audiobook"
|
||||
content_type = kwargs.get("content_type")
|
||||
is_audiobook = check_audiobook(content_type if isinstance(content_type, str) else None)
|
||||
default_label = (
|
||||
self._audiobook_label if is_audiobook and self._audiobook_label else self._label
|
||||
)
|
||||
|
||||
@@ -248,6 +248,15 @@ class SABnzbdClient(DownloadClient):
|
||||
if trusted_url and _url_origin(trusted_url) == target_origin:
|
||||
return True
|
||||
|
||||
named_indexers = config.get("NEWZNAB_INDEXERS", [])
|
||||
if isinstance(named_indexers, list):
|
||||
for row in named_indexers:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
trusted_url = normalize_http_config_url(row.get("url"))
|
||||
if trusted_url and _url_origin(trusted_url) == target_origin:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_prowlarr_headers(self, url: str) -> dict:
|
||||
|
||||
@@ -565,6 +565,23 @@ def _test_realdebrid_connection(current_values: dict[str, Any] | None = None) ->
|
||||
return {"success": success, "message": message}
|
||||
|
||||
|
||||
def _test_torbox_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Test the TorBox API connection using current form values."""
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.download.clients.torbox import TorBoxClient
|
||||
|
||||
current_values = current_values or {}
|
||||
api_key = _resolve_string_setting(current_values, config.get, "TORBOX_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
return {"success": False, "message": "TorBox API Key is required"}
|
||||
|
||||
client = TorBoxClient()
|
||||
client._api_key = api_key
|
||||
success, message = client.test_connection()
|
||||
return {"success": success, "message": message}
|
||||
|
||||
|
||||
# ==================== Download Clients Tab ====================
|
||||
|
||||
|
||||
@@ -590,14 +607,23 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
options=[
|
||||
{"value": "", "label": "None"},
|
||||
{"value": "alldebrid", "label": "AllDebrid"},
|
||||
{"value": "blackhole", "label": "Blackhole"},
|
||||
{"value": "qbittorrent", "label": "qBittorrent"},
|
||||
{"value": "realdebrid", "label": "Real-Debrid"},
|
||||
{"value": "torbox", "label": "TorBox"},
|
||||
{"value": "transmission", "label": "Transmission"},
|
||||
{"value": "deluge", "label": "Deluge"},
|
||||
{"value": "rtorrent", "label": "rTorrent"},
|
||||
],
|
||||
default="",
|
||||
),
|
||||
TextField(
|
||||
key="BLACKHOLE_DIRECTORY",
|
||||
label="Blackhole Directory",
|
||||
description="Directory where Shelfmark saves .torrent files for another downloader",
|
||||
placeholder="/blackhole",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "blackhole"},
|
||||
),
|
||||
# --- AllDebrid Settings ---
|
||||
PasswordField(
|
||||
key="ALLDEBRID_API_KEY",
|
||||
@@ -628,6 +654,21 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
callback=_test_realdebrid_connection,
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "realdebrid"},
|
||||
),
|
||||
# --- TorBox Settings ---
|
||||
PasswordField(
|
||||
key="TORBOX_API_KEY",
|
||||
label="API Key",
|
||||
description="TorBox API Key from your TorBox account settings",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "torbox"},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_torbox",
|
||||
label="Test Connection",
|
||||
description="Verify your TorBox configuration",
|
||||
style="primary",
|
||||
callback=_test_torbox_connection,
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "torbox"},
|
||||
),
|
||||
# --- qBittorrent Settings ---
|
||||
TextField(
|
||||
key="QBITTORRENT_URL",
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
"""TorBox debrid service client for Shelfmark."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import shutil
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any, ClassVar, NoReturn
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.config.env import TMP_DIR
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadState,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.download.clients._coercion import config_text
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
DebridMagnet,
|
||||
DebridUpload,
|
||||
resolve_debrid_upload,
|
||||
)
|
||||
from shelfmark.download.http import download_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_API_BASE = "https://api.torbox.app/v1/api"
|
||||
_API_TIMEOUT = 30
|
||||
_STATUS_TIMEOUT = 15
|
||||
_WORKER_JOIN_TIMEOUT = 5.0
|
||||
|
||||
_BOOK_EXTENSIONS = (
|
||||
".aac",
|
||||
".azw",
|
||||
".azw3",
|
||||
".cbr",
|
||||
".cbz",
|
||||
".djvu",
|
||||
".doc",
|
||||
".docx",
|
||||
".epub",
|
||||
".fb2",
|
||||
".flac",
|
||||
".lit",
|
||||
".m4a",
|
||||
".m4b",
|
||||
".mobi",
|
||||
".mp3",
|
||||
".mp4",
|
||||
".ogg",
|
||||
".opus",
|
||||
".pdf",
|
||||
".rtf",
|
||||
".txt",
|
||||
".wma",
|
||||
)
|
||||
|
||||
_TERMINAL_STATES = frozenset({"error", "failed", "missingfiles", "dead"})
|
||||
_PLAN_NAMES = {0: "Free", 1: "Essential", 2: "Pro", 3: "Standard"}
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DownloadState:
|
||||
"""Internal mutable state for an in-progress TorBox download."""
|
||||
|
||||
torrent_id: str
|
||||
name: str
|
||||
target_dir: Path
|
||||
phase: str = "waiting_torbox"
|
||||
error_message: str | None = None
|
||||
progress: float = 0.0
|
||||
download_thread: threading.Thread | None = None
|
||||
cancel_event: threading.Event = field(default_factory=threading.Event)
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class TorBoxClient(DownloadClient):
|
||||
"""Download torrent content through TorBox and its CDN."""
|
||||
|
||||
protocol = "torrent"
|
||||
name = "torbox"
|
||||
prefers_torrent_file = True
|
||||
|
||||
_downloads: ClassVar[dict[str, _DownloadState]] = {}
|
||||
_downloads_lock = threading.Lock()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._api_key = config_text(config.get("TORBOX_API_KEY", ""))
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
"""Return the authorization headers used by TorBox API calls."""
|
||||
return {"Authorization": f"Bearer {self._api_key}"}
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Return True when TorBox is selected and an API key exists."""
|
||||
client = config_text(config.get("PROWLARR_TORRENT_CLIENT", ""))
|
||||
api_key = config_text(config.get("TORBOX_API_KEY", ""))
|
||||
return client == "torbox" and bool(api_key)
|
||||
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Validate the API key and report the connected TorBox plan."""
|
||||
if not self._api_key:
|
||||
return False, "TorBox API Key is required"
|
||||
|
||||
try:
|
||||
user = self._request_data(
|
||||
"GET",
|
||||
"/user/me",
|
||||
operation="account lookup",
|
||||
params={"settings": "false"},
|
||||
timeout=_STATUS_TIMEOUT,
|
||||
)
|
||||
if not isinstance(user, dict):
|
||||
_raise_runtime_error("TorBox account lookup returned invalid user data")
|
||||
except (
|
||||
OSError,
|
||||
requests.exceptions.RequestException,
|
||||
RuntimeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
) as e:
|
||||
return False, f"Connection failed: {e}"
|
||||
|
||||
plan_value = user.get("plan")
|
||||
plan = _PLAN_NAMES.get(plan_value, "Unknown") if isinstance(plan_value, int) else "Unknown"
|
||||
email = user.get("email")
|
||||
account = f" as '{email}'" if isinstance(email, str) and email else ""
|
||||
return True, f"Connected to TorBox{account} ({plan} plan)"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Send a magnet or torrent file to TorBox and return its torrent ID."""
|
||||
if not self._api_key:
|
||||
_raise_runtime_error("TorBox API key is not configured")
|
||||
|
||||
try:
|
||||
upload = resolve_debrid_upload(url, expected_hash=expected_hash)
|
||||
data = self._send_torrent(upload, name)
|
||||
torrent_id = self._normalize_torrent_id(data.get("torrent_id"))
|
||||
|
||||
target_dir = TMP_DIR / f"torbox_{torrent_id}"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
state = _DownloadState(torrent_id=torrent_id, name=name, target_dir=target_dir)
|
||||
with self._downloads_lock:
|
||||
self._downloads[torrent_id] = state
|
||||
|
||||
logger.info(
|
||||
"Added torrent to TorBox: ID %s", torrent_id, extra={"torrent_id": torrent_id}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to add torrent to TorBox")
|
||||
raise
|
||||
else:
|
||||
return torrent_id
|
||||
|
||||
def _send_torrent(self, upload: DebridUpload, name: str) -> dict[str, Any]:
|
||||
"""Create a TorBox torrent from a magnet link or torrent file."""
|
||||
endpoint = "/torrents/createtorrent"
|
||||
data: dict[str, str] = {"name": name}
|
||||
files: dict[str, tuple[str, bytes, str]] | None = None
|
||||
if isinstance(upload, DebridMagnet):
|
||||
data["magnet"] = upload.magnet_url
|
||||
else:
|
||||
files = {
|
||||
"file": (
|
||||
"release.torrent",
|
||||
upload.torrent_data,
|
||||
"application/x-bittorrent",
|
||||
)
|
||||
}
|
||||
|
||||
result = self._request_data(
|
||||
"POST",
|
||||
endpoint,
|
||||
operation="torrent creation",
|
||||
data=data,
|
||||
files=files,
|
||||
timeout=_API_TIMEOUT,
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
_raise_runtime_error("TorBox torrent creation returned invalid data")
|
||||
return result
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""Poll TorBox for torrent status and drive local file retrieval."""
|
||||
download_id = self._normalize_torrent_id(download_id)
|
||||
state = self._ensure_state(download_id)
|
||||
with state.lock:
|
||||
if state.phase == "error":
|
||||
return DownloadStatus.error(state.error_message or "TorBox download failed")
|
||||
if state.phase == "complete":
|
||||
return DownloadStatus(
|
||||
progress=100.0,
|
||||
state=DownloadState.COMPLETE,
|
||||
message="Complete",
|
||||
complete=True,
|
||||
file_path=str(state.target_dir),
|
||||
)
|
||||
if state.phase == "downloading_http":
|
||||
return DownloadStatus(
|
||||
progress=state.progress,
|
||||
state=DownloadState.DOWNLOADING,
|
||||
message="Downloading files via TorBox...",
|
||||
complete=False,
|
||||
file_path=None,
|
||||
)
|
||||
|
||||
try:
|
||||
data = self._request_data(
|
||||
"GET",
|
||||
"/torrents/mylist",
|
||||
operation="torrent status lookup",
|
||||
params={"id": download_id, "bypass_cache": "true"},
|
||||
timeout=_STATUS_TIMEOUT,
|
||||
)
|
||||
torrent = self._extract_torrent(data, download_id)
|
||||
return self._handle_torrent_status(torrent, state)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Failed to check TorBox torrent status",
|
||||
extra={"torrent_id": download_id},
|
||||
)
|
||||
return DownloadStatus.error(f"TorBox status check failed: {e}")
|
||||
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
"""Delete the remote torrent and clean up its local temporary directory."""
|
||||
download_id = self._normalize_torrent_id(download_id)
|
||||
remote_removed = True
|
||||
try:
|
||||
self._request_data(
|
||||
"POST",
|
||||
"/torrents/controltorrent",
|
||||
operation="torrent deletion",
|
||||
json={"torrent_id": int(download_id), "operation": "delete"},
|
||||
timeout=_STATUS_TIMEOUT,
|
||||
require_data=False,
|
||||
)
|
||||
except OSError, requests.exceptions.RequestException, RuntimeError, TypeError, ValueError:
|
||||
remote_removed = False
|
||||
logger.warning("Failed to delete TorBox torrent", extra={"torrent_id": download_id})
|
||||
|
||||
with self._downloads_lock:
|
||||
state = self._downloads.get(download_id)
|
||||
if state:
|
||||
with state.lock:
|
||||
state.cancel_event.set()
|
||||
if state.download_thread and state.download_thread is not threading.current_thread():
|
||||
state.download_thread.join(_WORKER_JOIN_TIMEOUT)
|
||||
if state.download_thread.is_alive():
|
||||
logger.warning(
|
||||
"TorBox retrieval thread did not stop; deferring cleanup",
|
||||
extra={"torrent_id": download_id},
|
||||
)
|
||||
return False
|
||||
with self._downloads_lock:
|
||||
state = self._downloads.pop(download_id, None)
|
||||
target_dir = state.target_dir if state else TMP_DIR / f"torbox_{download_id}"
|
||||
|
||||
local_removed = True
|
||||
if target_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(target_dir)
|
||||
except OSError:
|
||||
local_removed = False
|
||||
logger.warning(
|
||||
"Failed to remove TorBox temporary files",
|
||||
extra={"torrent_id": download_id},
|
||||
)
|
||||
return remote_removed and local_removed
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Return the local directory once TorBox files have been retrieved."""
|
||||
download_id = self._normalize_torrent_id(download_id)
|
||||
with self._downloads_lock:
|
||||
state = self._downloads.get(download_id)
|
||||
if state and state.phase == "complete":
|
||||
return str(state.target_dir)
|
||||
return None
|
||||
|
||||
def _request_data(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
operation: str,
|
||||
require_data: bool = True,
|
||||
**kwargs: object,
|
||||
) -> Any:
|
||||
"""Send a TorBox request and validate its JSON response envelope."""
|
||||
url = f"{_API_BASE}{endpoint}"
|
||||
request_kwargs: Any = {
|
||||
"headers": self._auth_headers(),
|
||||
"verify": get_ssl_verify(url),
|
||||
**kwargs,
|
||||
}
|
||||
request: Any = requests.get if method == "GET" else requests.post
|
||||
try:
|
||||
response = request(url, **request_kwargs)
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise RuntimeError(f"TorBox {operation} failed: {type(e).__name__}") from None
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except (AttributeError, TypeError, ValueError) as e:
|
||||
_raise_runtime_error(f"TorBox {operation} returned invalid JSON: {e}")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
_raise_runtime_error(f"TorBox {operation} returned an invalid response")
|
||||
|
||||
error = payload.get("error")
|
||||
detail = payload.get("detail")
|
||||
status_code = getattr(response, "status_code", 200)
|
||||
if not isinstance(status_code, int) or not 200 <= status_code < 300:
|
||||
message = detail if isinstance(detail, str) and detail else f"HTTP {status_code}"
|
||||
code = f" [{error}]" if isinstance(error, str) and error else ""
|
||||
_raise_runtime_error(f"TorBox {operation} failed{code}: {message}")
|
||||
if payload.get("success") is not True or error:
|
||||
message = detail if isinstance(detail, str) and detail else "Unknown TorBox error"
|
||||
code = f" [{error}]" if isinstance(error, str) and error else ""
|
||||
_raise_runtime_error(f"TorBox {operation} failed{code}: {message}")
|
||||
|
||||
data = payload.get("data")
|
||||
if require_data and data is None:
|
||||
_raise_runtime_error(f"TorBox {operation} returned no data")
|
||||
return data
|
||||
|
||||
def _ensure_state(self, download_id: str) -> _DownloadState:
|
||||
"""Get or create download state for a TorBox torrent ID."""
|
||||
download_id = self._normalize_torrent_id(download_id)
|
||||
with self._downloads_lock:
|
||||
state = self._downloads.get(download_id)
|
||||
if state is None:
|
||||
state = _DownloadState(
|
||||
torrent_id=download_id,
|
||||
name=f"Download {download_id}",
|
||||
target_dir=TMP_DIR / f"torbox_{download_id}",
|
||||
)
|
||||
self._downloads[download_id] = state
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def _normalize_torrent_id(value: object) -> str:
|
||||
"""Return a canonical positive decimal TorBox torrent ID."""
|
||||
torrent_id = str(value) if value is not None else ""
|
||||
if not torrent_id.isascii() or not torrent_id.isdecimal():
|
||||
_raise_runtime_error("TorBox returned an invalid torrent ID")
|
||||
|
||||
normalized = str(int(torrent_id))
|
||||
if normalized == "0":
|
||||
_raise_runtime_error("TorBox returned an invalid torrent ID")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _extract_torrent(data: Any, download_id: str) -> dict[str, Any]:
|
||||
"""Extract the requested torrent from TorBox's object or list response."""
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
if isinstance(data, list):
|
||||
for torrent in data:
|
||||
if isinstance(torrent, dict) and str(torrent.get("id", "")) == download_id:
|
||||
return torrent
|
||||
_raise_runtime_error(f"TorBox torrent {download_id} was not found")
|
||||
|
||||
def _handle_torrent_status(
|
||||
self,
|
||||
torrent: dict[str, Any],
|
||||
state: _DownloadState,
|
||||
) -> DownloadStatus:
|
||||
"""Map a TorBox torrent object into Shelfmark download status."""
|
||||
remote_state = str(torrent.get("download_state", "unknown"))
|
||||
normalized_state = remote_state.lower()
|
||||
if normalized_state in _TERMINAL_STATES:
|
||||
message = torrent.get("tracker_message") or f"TorBox status error: {remote_state}"
|
||||
self._set_error(state, str(message))
|
||||
return DownloadStatus.error(str(message))
|
||||
|
||||
finished = torrent.get("download_finished") is True
|
||||
present = torrent.get("download_present") is True
|
||||
if finished and not present:
|
||||
message = "TorBox finished processing but the download is unavailable"
|
||||
self._set_error(state, message)
|
||||
return DownloadStatus.error(message)
|
||||
if finished and present:
|
||||
files = torrent.get("files")
|
||||
if not isinstance(files, list):
|
||||
message = "TorBox returned no file list for a completed torrent"
|
||||
self._set_error(state, message)
|
||||
return DownloadStatus.error(message)
|
||||
self._maybe_start_download_thread(state, files)
|
||||
return DownloadStatus(
|
||||
progress=50.0,
|
||||
state=DownloadState.DOWNLOADING,
|
||||
message="TorBox ready, retrieving files...",
|
||||
complete=False,
|
||||
file_path=None,
|
||||
)
|
||||
|
||||
progress = self._normalize_remote_progress(torrent.get("progress")) * 0.5
|
||||
speed = self._integer_value(torrent.get("download_speed"))
|
||||
eta = self._integer_value(torrent.get("eta"))
|
||||
name = torrent.get("name") or state.name
|
||||
return DownloadStatus(
|
||||
progress=progress,
|
||||
state=DownloadState.DOWNLOADING,
|
||||
message=f"TorBox processing torrent ({name}: {remote_state})",
|
||||
complete=False,
|
||||
file_path=None,
|
||||
download_speed=speed,
|
||||
eta=eta,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_remote_progress(value: object) -> float:
|
||||
"""Normalize fractional or percentage TorBox progress to 0 through 100."""
|
||||
if not isinstance(value, int | float | str):
|
||||
return 0.0
|
||||
try:
|
||||
progress = float(value)
|
||||
except TypeError, ValueError:
|
||||
return 0.0
|
||||
if not math.isfinite(progress):
|
||||
return 0.0
|
||||
if 0.0 <= progress <= 1.0:
|
||||
progress *= 100.0
|
||||
return max(0.0, min(100.0, progress))
|
||||
|
||||
@staticmethod
|
||||
def _integer_value(value: object) -> int | None:
|
||||
"""Return an integer metric when TorBox provided a numeric value."""
|
||||
if not isinstance(value, int | float | str):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
def _maybe_start_download_thread(
|
||||
self,
|
||||
state: _DownloadState,
|
||||
files: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Start exactly one background worker to retrieve TorBox files."""
|
||||
with state.lock:
|
||||
already_running = state.phase in {"downloading_http", "complete"}
|
||||
thread_alive = state.download_thread is not None and state.download_thread.is_alive()
|
||||
if already_running or thread_alive:
|
||||
return
|
||||
state.phase = "downloading_http"
|
||||
state.progress = 50.0
|
||||
state.download_thread = threading.Thread(
|
||||
target=self._process_and_download,
|
||||
args=(state, files),
|
||||
daemon=True,
|
||||
)
|
||||
state.download_thread.start()
|
||||
|
||||
def _process_and_download(self, state: _DownloadState, files: list[dict[str, Any]]) -> None:
|
||||
"""Request direct file links from TorBox and download supported content."""
|
||||
try:
|
||||
if state.cancel_event.is_set():
|
||||
return
|
||||
relevant = [
|
||||
file_info
|
||||
for file_info in files
|
||||
if self._file_name(file_info).lower().endswith(_BOOK_EXTENSIONS)
|
||||
]
|
||||
if not relevant:
|
||||
_raise_runtime_error("TorBox torrent contains no supported book or audiobook files")
|
||||
|
||||
with state.lock:
|
||||
if state.cancel_event.is_set():
|
||||
return
|
||||
state.target_dir.mkdir(parents=True, exist_ok=True)
|
||||
for index, file_info in enumerate(relevant, start=1):
|
||||
if state.cancel_event.is_set():
|
||||
return
|
||||
file_id = self._file_id(file_info)
|
||||
relative_path = self._safe_relative_path(file_info, state.target_dir)
|
||||
direct_url = self._request_download_link(state.torrent_id, file_id)
|
||||
buffer = download_url(
|
||||
direct_url,
|
||||
referer="https://torbox.app/",
|
||||
cancel_flag=state.cancel_event,
|
||||
)
|
||||
if state.cancel_event.is_set():
|
||||
return
|
||||
if not buffer:
|
||||
_raise_runtime_error(
|
||||
f"TorBox file download failed for torrent {state.torrent_id}, file {file_id}"
|
||||
)
|
||||
|
||||
destination = state.target_dir / relative_path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with destination.open("wb") as output:
|
||||
buffer.seek(0)
|
||||
shutil.copyfileobj(buffer, output)
|
||||
with state.lock:
|
||||
state.progress = 50.0 + index / len(relevant) * 50.0
|
||||
|
||||
with state.lock:
|
||||
if state.cancel_event.is_set():
|
||||
return
|
||||
state.phase = "complete"
|
||||
state.progress = 100.0
|
||||
logger.info(
|
||||
"TorBox download complete: ID %s",
|
||||
state.torrent_id,
|
||||
extra={"torrent_id": state.torrent_id},
|
||||
)
|
||||
except Exception as e:
|
||||
if state.cancel_event.is_set():
|
||||
logger.info(
|
||||
"TorBox file retrieval cancelled",
|
||||
extra={"torrent_id": state.torrent_id},
|
||||
)
|
||||
return
|
||||
logger.exception(
|
||||
"TorBox file retrieval failed",
|
||||
extra={"torrent_id": state.torrent_id},
|
||||
)
|
||||
self._set_error(state, str(e) or "TorBox file retrieval failed")
|
||||
|
||||
def _request_download_link(self, torrent_id: str, file_id: int) -> str:
|
||||
"""Request a temporary direct link without exposing the token in messages."""
|
||||
data = self._request_data(
|
||||
"GET",
|
||||
"/torrents/requestdl",
|
||||
operation="file-link request",
|
||||
params={
|
||||
"token": self._api_key,
|
||||
"torrent_id": torrent_id,
|
||||
"file_id": file_id,
|
||||
"redirect": "false",
|
||||
"append_name": "true",
|
||||
},
|
||||
timeout=_API_TIMEOUT,
|
||||
)
|
||||
if not isinstance(data, str):
|
||||
_raise_runtime_error(
|
||||
f"TorBox returned an invalid download link for torrent {torrent_id}, file {file_id}"
|
||||
)
|
||||
parsed = urlparse(data)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
_raise_runtime_error(
|
||||
f"TorBox returned an invalid download link for torrent {torrent_id}, file {file_id}"
|
||||
)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _file_name(file_info: dict[str, Any]) -> str:
|
||||
"""Return the provider path, falling back to its shortened name."""
|
||||
name = file_info.get("name")
|
||||
if isinstance(name, str) and name.strip():
|
||||
return name
|
||||
short_name = file_info.get("short_name")
|
||||
return short_name.strip() if isinstance(short_name, str) else ""
|
||||
|
||||
@staticmethod
|
||||
def _file_id(file_info: dict[str, Any]) -> int:
|
||||
"""Return a validated TorBox file ID."""
|
||||
try:
|
||||
return int(file_info["id"])
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
_raise_runtime_error(f"TorBox returned an invalid file ID: {e}")
|
||||
|
||||
@classmethod
|
||||
def _safe_relative_path(cls, file_info: dict[str, Any], target_dir: Path) -> Path:
|
||||
"""Validate external file metadata before writing below ``target_dir``."""
|
||||
name = cls._file_name(file_info)
|
||||
if not name:
|
||||
_raise_runtime_error("TorBox returned a file without a name")
|
||||
|
||||
normalized = name.replace("\\", "/")
|
||||
relative_path = PurePosixPath(normalized)
|
||||
windows_path = PureWindowsPath(name)
|
||||
if (
|
||||
relative_path.is_absolute()
|
||||
or windows_path.is_absolute()
|
||||
or windows_path.drive
|
||||
or ".." in relative_path.parts
|
||||
):
|
||||
_raise_runtime_error(f"TorBox returned an unsafe file path: {name}")
|
||||
if relative_path == PurePosixPath("."):
|
||||
_raise_runtime_error("TorBox returned a file without a usable name")
|
||||
|
||||
destination = (target_dir / Path(*relative_path.parts)).resolve()
|
||||
try:
|
||||
destination.relative_to(target_dir.resolve())
|
||||
except ValueError:
|
||||
_raise_runtime_error(f"TorBox returned an unsafe file path: {name}")
|
||||
return Path(*relative_path.parts)
|
||||
|
||||
@staticmethod
|
||||
def _set_error(state: _DownloadState, message: str) -> None:
|
||||
"""Record a terminal local error for later polling calls."""
|
||||
with state.lock:
|
||||
state.phase = "error"
|
||||
state.error_message = message
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
from binascii import Error as BinasciiError
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
from urllib.parse import ParseResult, parse_qs, urljoin, urlparse
|
||||
from urllib.parse import ParseResult, parse_qs, urljoin, urlparse, urlunparse
|
||||
|
||||
import requests
|
||||
|
||||
@@ -17,6 +17,7 @@ from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.download.postprocess.packs import PackFile
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -50,6 +51,28 @@ _torrent_fetch_cache: dict[str, tuple[float, TorrentInfo]] = {}
|
||||
type BencodeValue = dict[str | bytes, BencodeValue] | list[BencodeValue] | int | bytes | str
|
||||
|
||||
|
||||
def _safe_url(url: str, *, limit: int = 120) -> str:
|
||||
"""Return a log-safe URL: scheme/host/path kept, query and fragment dropped.
|
||||
|
||||
Torrent download URLs commonly carry credentials in their query string
|
||||
(Prowlarr's ``apikey=...`` proxy links among them), so raw URLs must never
|
||||
reach logs or exception messages.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return f"<unparsed url: {type(url).__name__}>"
|
||||
safe = urlunparse(parsed._replace(query="", fragment=""))
|
||||
return safe[:limit]
|
||||
|
||||
|
||||
_URL_IN_TEXT_PATTERN = re.compile(r"https?://\S+")
|
||||
|
||||
|
||||
def _redact_urls_in_text(text: str) -> str:
|
||||
"""Scrub credential-bearing URLs out of free-form text such as exception messages."""
|
||||
return _URL_IN_TEXT_PATTERN.sub(lambda match: _safe_url(match.group(0)), text)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TorrentInfo:
|
||||
"""Parsed information from a torrent URL."""
|
||||
@@ -82,6 +105,60 @@ class TorrentInfo:
|
||||
return self
|
||||
|
||||
|
||||
@dataclass
|
||||
class DebridMagnet:
|
||||
"""A magnet link, ready to hand to a debrid service as-is."""
|
||||
|
||||
magnet_url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DebridTorrentFile:
|
||||
"""Raw .torrent bytes, for a debrid service's file-upload endpoint."""
|
||||
|
||||
torrent_data: bytes
|
||||
|
||||
|
||||
# A debrid service takes one or the other, never an indexer page or a proxy URL.
|
||||
type DebridUpload = DebridMagnet | DebridTorrentFile
|
||||
|
||||
|
||||
def resolve_debrid_upload(url: str, *, expected_hash: str | None = None) -> DebridUpload:
|
||||
"""Resolve a release download URL into a magnet link or .torrent bytes.
|
||||
|
||||
Prowlarr hands out a proxy URL, with no magnetUrl and no infoHash, for any
|
||||
indexer that only publishes torrent files - 1337x among them. Posting that
|
||||
URL to a debrid service as if it were a magnet is what produced a bare 404
|
||||
from the service instead of a download (#1250).
|
||||
|
||||
The torrent file is preferred over a synthesized `urn:btih:` magnet because
|
||||
it carries the tracker list, which is how the service finds a swarm that is
|
||||
not already cached. Fetches are shared with the rest of the add path through
|
||||
the torrent fetch cache, so resolving here costs at most one request.
|
||||
|
||||
Raises:
|
||||
ValueError: The URL resolved to neither form, so there is nothing to send.
|
||||
|
||||
"""
|
||||
if url.startswith("magnet:"):
|
||||
return DebridMagnet(magnet_url=url)
|
||||
|
||||
info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
|
||||
if info.is_magnet and info.magnet_url:
|
||||
# The download URL redirected to, or returned, a magnet link.
|
||||
return DebridMagnet(magnet_url=info.magnet_url)
|
||||
if info.torrent_data:
|
||||
return DebridTorrentFile(torrent_data=info.torrent_data)
|
||||
if info.info_hash:
|
||||
# No file to upload, but the hash alone still identifies the torrent.
|
||||
return DebridMagnet(magnet_url=f"magnet:?xt=urn:btih:{info.info_hash}")
|
||||
|
||||
reason = info.fetch_error or "no magnet link, info hash, or torrent file was available"
|
||||
msg = f"Could not resolve a torrent to send from {_safe_url(url)} ({reason})"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def extract_torrent_info(
|
||||
url: str,
|
||||
*,
|
||||
@@ -130,7 +207,7 @@ def _get_cached_torrent_fetch(url: str) -> TorrentInfo | None:
|
||||
if time.monotonic() - fetched_at > _TORRENT_FETCH_CACHE_TTL_SECONDS:
|
||||
del _torrent_fetch_cache[url]
|
||||
return None
|
||||
logger.debug("Reusing recently fetched torrent data for: %s...", url[:80])
|
||||
logger.debug("Reusing recently fetched torrent data for: %s...", _safe_url(url))
|
||||
return info
|
||||
|
||||
|
||||
@@ -176,7 +253,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
|
||||
return urljoin(current, location)
|
||||
|
||||
try:
|
||||
logger.debug("Fetching torrent file from: %s...", url[:80])
|
||||
logger.debug("Fetching torrent file from: %s...", _safe_url(url))
|
||||
|
||||
# Redirects are followed manually: some indexers redirect download URLs
|
||||
# to magnet links, and each hop must decide anew whether it may see the
|
||||
@@ -209,7 +286,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
|
||||
magnet_url=redirect_url,
|
||||
)
|
||||
if redirects_remaining <= 0:
|
||||
logger.warning("Too many redirects fetching torrent file: %s...", url[:80])
|
||||
logger.warning("Too many redirects fetching torrent file: %s...", _safe_url(url))
|
||||
return TorrentInfo(
|
||||
info_hash=None,
|
||||
torrent_data=None,
|
||||
@@ -217,7 +294,7 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
|
||||
fetch_error="too many redirects",
|
||||
)
|
||||
redirects_remaining -= 1
|
||||
logger.debug("Following redirect to: %s...", redirect_url[:80])
|
||||
logger.debug("Following redirect to: %s...", _safe_url(redirect_url))
|
||||
current_url = redirect_url
|
||||
|
||||
resp.raise_for_status()
|
||||
@@ -243,8 +320,11 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
|
||||
logger.warning("Could not extract hash from torrent file")
|
||||
return TorrentInfo(info_hash=info_hash, torrent_data=torrent_data, is_magnet=False)
|
||||
except _TORRENT_FETCH_ERRORS as e:
|
||||
logger.warning("Could not fetch torrent file: %s", e)
|
||||
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False, fetch_error=str(e))
|
||||
# Exception messages can repeat the source or redirect URL, including its
|
||||
# credentials; scrub them before logging or storing the reason.
|
||||
message = _redact_urls_in_text(str(e))
|
||||
logger.warning("Could not fetch torrent file: %s: %s", type(e).__name__, message)
|
||||
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False, fetch_error=message)
|
||||
|
||||
|
||||
def _is_trusted_torrent_fetch_url(url: str) -> bool:
|
||||
@@ -379,6 +459,56 @@ def extract_info_hash_from_torrent(torrent_data: bytes) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _decode_torrent_text(value: object) -> str | None:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def extract_file_list_from_torrent(torrent_data: bytes) -> list[PackFile] | None:
|
||||
"""List the files a .torrent describes, release-relative, without downloading it.
|
||||
|
||||
Multi-file torrents nest every path under the torrent name (which becomes the
|
||||
client's save folder); single-file torrents are just the named file.
|
||||
"""
|
||||
try:
|
||||
decoded, _ = bencode_decode(torrent_data)
|
||||
except _TORRENT_PARSE_ERRORS as e:
|
||||
logger.debug("Failed to parse torrent file list: %s", e)
|
||||
return None
|
||||
if not isinstance(decoded, dict):
|
||||
return None
|
||||
info = decoded.get(b"info")
|
||||
if not isinstance(info, dict):
|
||||
return None
|
||||
|
||||
name = _decode_torrent_text(info.get(b"name")) or ""
|
||||
raw_files = info.get(b"files")
|
||||
if not isinstance(raw_files, list):
|
||||
length = info.get(b"length")
|
||||
if not name:
|
||||
return None
|
||||
return [PackFile(name, length if isinstance(length, int) else None)]
|
||||
|
||||
files: list[PackFile] = []
|
||||
for entry in raw_files:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
raw_path = entry.get(b"path")
|
||||
if not isinstance(raw_path, list):
|
||||
continue
|
||||
segments = [seg for seg in (_decode_torrent_text(part) for part in raw_path) if seg]
|
||||
if not segments:
|
||||
continue
|
||||
if name:
|
||||
segments.insert(0, name)
|
||||
length = entry.get(b"length")
|
||||
files.append(PackFile("/".join(segments), length if isinstance(length, int) else None))
|
||||
return files
|
||||
|
||||
|
||||
def extract_hash_from_magnet(magnet_url: str) -> str | None:
|
||||
"""Extract info_hash from a magnet URL."""
|
||||
if not magnet_url.startswith("magnet:"):
|
||||
|
||||
+244
-32
@@ -5,12 +5,15 @@ import time
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING, NoReturn
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
|
||||
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
from shelfmark.bypass import BypassCancelledError, cookie_store
|
||||
from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError, cookie_store
|
||||
from shelfmark.bypass.challenge import challenge_marker
|
||||
from shelfmark.bypass.waiting_room import WaitingRoomTimeoutError, is_aa_waiting_room
|
||||
from shelfmark.core import search_deadline
|
||||
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
|
||||
@@ -27,6 +30,10 @@ logger = setup_logger(__name__)
|
||||
_RNG = random.SystemRandom()
|
||||
|
||||
_MAX_REDIRECTS = 5
|
||||
# DDoS-Guard's re-check probe. Its 302 to `?check=1` is one hop of a handshake rather
|
||||
# than a page: the parameter asserts the caller already holds the cookies that hop
|
||||
# issued.
|
||||
_DDG_CHECK_PARAM = "check"
|
||||
# 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.
|
||||
@@ -46,11 +53,13 @@ _BYPASS_GRACE_SLACK_SECONDS = 30.0
|
||||
_BYPASSER_ERRORS = (
|
||||
AttributeError,
|
||||
BypassCancelledError,
|
||||
ChallengeNotSolvedError,
|
||||
KeyError,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
network.RateLimitedError,
|
||||
requests.exceptions.RequestException,
|
||||
)
|
||||
|
||||
@@ -233,6 +242,49 @@ def _is_retryable_error(e: Exception) -> bool:
|
||||
_DEAD_MIRROR_CODES = (410, 451)
|
||||
|
||||
|
||||
def _response_challenge_marker(response: requests.Response) -> str | None:
|
||||
"""The challenge marker in a response body, or None if it carries no challenge.
|
||||
|
||||
Content type is checked first so a JSON or octet-stream error body is never
|
||||
decoded just to be scanned; a missing header is scanned anyway, since an
|
||||
interstitial served without one is still an interstitial.
|
||||
"""
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if content_type and "html" not in content_type.lower():
|
||||
return None
|
||||
try:
|
||||
return challenge_marker(response.text)
|
||||
except UnicodeDecodeError, ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _solvable_url(url: str) -> str:
|
||||
"""The URL a solver should open, given one we may be mid-handshake on.
|
||||
|
||||
The manual AA redirect follower in `html_get_page` walks DDoS-Guard's handshake by
|
||||
reassigning `current_url`, so by the time a 403, a 503 challenge or a redirect loop
|
||||
hands that URL to a bypasser it is often the `?check=1` probe rather than the page
|
||||
we actually wanted. A solver opens it in a fresh browser holding none of the cookies
|
||||
the probe exists to collect, so DDoS-Guard cannot verify it automatically and answers
|
||||
with the manual CAPTCHA page that nothing can solve - the failure in #1292, where
|
||||
FlareSolverr reported "Challenge solved!" over a 4.7 KB DDOS-GUARD interstitial.
|
||||
|
||||
Handing over the pre-probe URL instead lets the solver's browser run the whole
|
||||
handshake itself, which is what a real browser does and what the solver is for.
|
||||
|
||||
Scoped to the hosts whose redirects we follow manually: everywhere else `check` is
|
||||
an ordinary query parameter and none of our business.
|
||||
"""
|
||||
if not network.should_rotate_dns_for_url(url):
|
||||
return url
|
||||
parsed = urlparse(url)
|
||||
params = parse_qsl(parsed.query, keep_blank_values=True)
|
||||
kept = [(key, value) for key, value in params if key != _DDG_CHECK_PARAM]
|
||||
if len(kept) == len(params):
|
||||
return url
|
||||
return urlunparse(parsed._replace(query=urlencode(kept)))
|
||||
|
||||
|
||||
def _fatal_mirror_reason(e: Exception) -> str | None:
|
||||
"""Return why ``e`` proves the mirror is unusable, or None if it may recover.
|
||||
|
||||
@@ -274,10 +326,10 @@ def _try_rotation(
|
||||
)
|
||||
if action in ("mirror", "dns") and new_base:
|
||||
new_url = selector.rewrite(original_url)
|
||||
logger.info("[%s] switching to: %s", action, new_url)
|
||||
logger.info("[%s] switching mirror", action)
|
||||
return new_url
|
||||
elif network.should_rotate_dns_for_url(current_url) and network.rotate_dns_provider():
|
||||
logger.info("[dns-rotate] retrying: %s", original_url)
|
||||
logger.info("[dns-rotate] retrying download")
|
||||
return original_url
|
||||
return None
|
||||
|
||||
@@ -316,11 +368,34 @@ def html_get_page(
|
||||
|
||||
"""
|
||||
|
||||
# Normalise before the closures below capture it: they touch selector.last_failure,
|
||||
# so it must be a concrete selector, not the Optional parameter.
|
||||
selector = selector or network.AAMirrorSelector()
|
||||
|
||||
# A release search runs under a wall-clock budget (see shelfmark.core.search_deadline).
|
||||
# Adopting it as the cancel flag is what makes the budget bite on a solve already in
|
||||
# flight: the bypassers and the helper subprocess poll this flag but know nothing about
|
||||
# deadlines. Only when the caller has no flag of its own - a queued download brings one
|
||||
# and must keep it, and runs outside any search context anyway.
|
||||
if cancel_flag is None:
|
||||
cancel_flag = search_deadline.cancel_event()
|
||||
|
||||
def _result(html: str, response_url: str) -> str | tuple[str, str]:
|
||||
if include_response_url:
|
||||
return html, response_url
|
||||
return html
|
||||
|
||||
def _fail(reason: str, response_url: str) -> str | tuple[str, str]:
|
||||
"""Record why the fetch is giving up, then return the empty result.
|
||||
|
||||
Every give-up path returns an empty page, which is all the caller used to
|
||||
see. Stashing the concrete reason on the shared selector lets the caller
|
||||
surface it (see release_sources.direct_download) rather than reporting the
|
||||
same generic "network restricted or mirrors blocked" for every cause.
|
||||
"""
|
||||
selector.last_failure = reason
|
||||
return _result("", response_url)
|
||||
|
||||
def _run_bypasser(bypass_url: str) -> str | tuple[str, str]:
|
||||
"""Run the active bypasser for one URL and return its result.
|
||||
|
||||
@@ -329,6 +404,16 @@ def html_get_page(
|
||||
retry-loop branch above with `continue`, and with MAX_RETRY=1 there is no
|
||||
later attempt for that branch to run on either.
|
||||
"""
|
||||
# Every handoff reaches the solver through here, so this is the one place the
|
||||
# mid-handshake `?check=1` URL has to be unwound. See _solvable_url.
|
||||
bypass_url = _solvable_url(bypass_url)
|
||||
# Never start a minutes-long browser solve on a budget that has already run out:
|
||||
# nothing downstream would get to report the real reason before the caller's
|
||||
# deadline (or its reverse proxy) cut the request off.
|
||||
if search_deadline.expired():
|
||||
logger.info("Release search budget spent; not starting a bypass for %s", bypass_url)
|
||||
return _fail(search_deadline.deadline_message(), bypass_url)
|
||||
|
||||
if status_callback:
|
||||
status_callback("resolving", "Bypassing protection...")
|
||||
try:
|
||||
@@ -338,7 +423,39 @@ def html_get_page(
|
||||
# bypasser that fails to load is still reported as a bypasser error.
|
||||
request_activity_grace(status_callback, _bypass_grace_seconds())
|
||||
result = get_bypassed_page(bypass_url, selector, cancel_flag)
|
||||
return _result(result or "", bypass_url)
|
||||
if result:
|
||||
return _result(result, bypass_url)
|
||||
return _fail(
|
||||
"The protection bypasser returned an empty page — the challenge was "
|
||||
"not solved. Check that FlareSolverr/the CF bypasser is reachable.",
|
||||
bypass_url,
|
||||
)
|
||||
except network.RateLimitedError as e:
|
||||
# Not a bypasser malfunction: the host is throttling this IP and a solve
|
||||
# cannot help. Surface the wait as a plain failure so the search ends cleanly
|
||||
# instead of looping another minutes-long solve against a 429.
|
||||
logger.info("Skipping bypass (rate-limited): %s", e)
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("resolving", "Rate limited, try again shortly")
|
||||
except _STATUS_CALLBACK_ERRORS:
|
||||
logger.debug("Rate-limit status callback failed", exc_info=True)
|
||||
return _fail(str(e), bypass_url)
|
||||
except WaitingRoomTimeoutError as e:
|
||||
logger.info("Waiting room timed out: %s", e)
|
||||
return _fail(str(e), bypass_url)
|
||||
except ChallengeNotSolvedError as e:
|
||||
# Not a bypasser malfunction: it ran, and the host answered with something it
|
||||
# cannot clear - DDoS-Guard's manual CAPTCHA, typically. Must precede the
|
||||
# generic handler below, whose "the protection bypasser failed" is what sent
|
||||
# #1292 off to fix a FlareSolverr that was working perfectly.
|
||||
logger.info("Bypass ran but did not clear the protection: %s", e)
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("error", str(e))
|
||||
except _STATUS_CALLBACK_ERRORS:
|
||||
logger.debug("Unsolved-challenge status callback failed", exc_info=True)
|
||||
return _fail(str(e), bypass_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
|
||||
@@ -349,7 +466,13 @@ def html_get_page(
|
||||
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("", bypass_url)
|
||||
if isinstance(e, BypassCancelledError):
|
||||
# The budget trips the same cancel flag a user's cancel does, so tell them
|
||||
# apart here - "cancelled" is a confusing thing to read when nobody did.
|
||||
if search_deadline.expired():
|
||||
return _fail(search_deadline.deadline_message(), bypass_url)
|
||||
return _fail("The protection bypass was cancelled.", bypass_url)
|
||||
return _fail(f"The protection bypasser failed: {type(e).__name__}: {e}", bypass_url)
|
||||
finally:
|
||||
release_activity_grace(status_callback)
|
||||
|
||||
@@ -391,19 +514,24 @@ def html_get_page(
|
||||
retry_limit = (
|
||||
retry if retry is not None else (configured_retry if configured_retry is not None else 1)
|
||||
)
|
||||
selector = selector or network.AAMirrorSelector()
|
||||
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
|
||||
# Last transport error seen, so the exhausted-retries path can name the real
|
||||
# cause (timeout, connection refused, DNS, ...) instead of a generic message.
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(1, retry_limit + 1):
|
||||
# Check for cancellation before each attempt
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
if search_deadline.expired():
|
||||
logger.info("Release search budget spent before attempt %s", attempt)
|
||||
return _fail(search_deadline.deadline_message(), current_url)
|
||||
logger.info("html_get_page cancelled before attempt %s", attempt)
|
||||
return _result("", current_url)
|
||||
return _fail("The request was cancelled.", current_url)
|
||||
|
||||
cookies: dict[str, str] = {}
|
||||
try:
|
||||
@@ -430,8 +558,15 @@ def html_get_page(
|
||||
current_url,
|
||||
proxies=get_proxies(current_url),
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
# Bypasser-derived cookies win: they came from a real solved challenge.
|
||||
cookies={**handshake_cookies, **cookies},
|
||||
# Handshake cookies win. They were issued by *this* exchange, so by
|
||||
# definition they are fresher than anything the store holds, and the
|
||||
# server is waiting to see them echoed back on the very next hop.
|
||||
# Letting the store overwrite them meant a stored cookie of the same
|
||||
# name (DDoS-Guard reuses __ddg1_/__ddg2_ for both) was replayed on
|
||||
# every hop and the freshly issued value never left this process - the
|
||||
# ?check=1 probe could then never terminate, so every request ended in
|
||||
# the redirect-loop handoff and paid for a full browser solve.
|
||||
cookies={**cookies, **handshake_cookies},
|
||||
headers=headers,
|
||||
allow_redirects=allow_redirects,
|
||||
verify=get_ssl_verify(current_url),
|
||||
@@ -455,6 +590,35 @@ def html_get_page(
|
||||
)
|
||||
continue
|
||||
|
||||
# A 503 still serving a challenge is protection, not a busy origin. The
|
||||
# handshake above has nothing left to echo back, and 503 is in
|
||||
# RETRYABLE_CODES, so without this the request spends every attempt on
|
||||
# the same wall: the bypasser is only ever reached from the 403 branch
|
||||
# and the AA redirect rescues. Gate on the body, not the status, so a
|
||||
# genuine overloaded-origin 503 keeps its retry path.
|
||||
if response.status_code == _HTTP_STATUS_SERVICE_UNAVAILABLE:
|
||||
marker = _response_challenge_marker(response)
|
||||
if marker and _bypass_handoff_allowed():
|
||||
if cookies:
|
||||
# Challenged while presenting clearance means those cookies
|
||||
# are dead; same reasoning as the 403 branch below.
|
||||
logger.debug(
|
||||
"503 challenge with cookies presented; purging: %s", current_url
|
||||
)
|
||||
_purge_clearance(current_url)
|
||||
logger.info(
|
||||
"503 challenge detected (%s); switching to bypasser: %s",
|
||||
marker,
|
||||
current_url,
|
||||
)
|
||||
return _run_bypasser(current_url)
|
||||
if marker:
|
||||
logger.debug(
|
||||
"503 challenge (%s) but no bypasser handoff available: %s",
|
||||
marker,
|
||||
current_url,
|
||||
)
|
||||
|
||||
if is_aa_url and response.is_redirect:
|
||||
location = response.headers.get("Location", "")
|
||||
if not location:
|
||||
@@ -476,7 +640,12 @@ def html_get_page(
|
||||
redirect_host,
|
||||
current_url,
|
||||
)
|
||||
return _result("", current_url)
|
||||
return _fail(
|
||||
f"The configured mirror {current_host} redirected to "
|
||||
f"{redirect_host}; it may be down or seized. Point MIRROR at "
|
||||
"a working host or switch to auto mode.",
|
||||
current_url,
|
||||
)
|
||||
|
||||
new_url = _try_rotation(original_url, current_url, selector)
|
||||
if new_url:
|
||||
@@ -495,7 +664,11 @@ def html_get_page(
|
||||
redirect_host,
|
||||
current_url,
|
||||
)
|
||||
return _result("", current_url)
|
||||
return _fail(
|
||||
"Every Anna's Archive mirror redirected away to a dead host — "
|
||||
"all configured mirrors are unreachable.",
|
||||
current_url,
|
||||
)
|
||||
|
||||
# Same-host redirect (relative or absolute) - follow manually.
|
||||
# DDoS-Guard gates AA /search behind a cookie probe: the 302 to
|
||||
@@ -526,16 +699,30 @@ def html_get_page(
|
||||
logger.warning(
|
||||
"Redirect loop and no bypasser available, giving up: %s", current_url
|
||||
)
|
||||
return _result("", current_url)
|
||||
return _fail(
|
||||
"Anna's Archive is behind a protection challenge (endless "
|
||||
"redirect loop) and no bypasser is enabled to solve it. Enable "
|
||||
"FlareSolverr/the CF bypasser.",
|
||||
current_url,
|
||||
)
|
||||
current_url = redirect_url
|
||||
continue
|
||||
|
||||
response.raise_for_status()
|
||||
if (
|
||||
_bypass_handoff_allowed()
|
||||
and not _is_using_external_bypasser()
|
||||
and is_aa_waiting_room(current_url, response.text)
|
||||
):
|
||||
# A successful HTTP response can still need a live browser: the
|
||||
# queue's JavaScript must finish in the session that entered it.
|
||||
return _run_bypasser(current_url)
|
||||
if success_delay > 0:
|
||||
time.sleep(success_delay)
|
||||
return _result(response.text, response.url)
|
||||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
status = _get_status_code(e)
|
||||
|
||||
# The same DDoS-Guard rescue, for the loops the manual AA follower above hands
|
||||
@@ -562,7 +749,10 @@ def html_get_page(
|
||||
current_url = new_url
|
||||
continue
|
||||
logger.warning("403 error, mirrors exhausted: %s", current_url)
|
||||
return _result("", current_url)
|
||||
return _fail(
|
||||
"Anna's Archive returned 403 (blocked) and all mirrors are exhausted.",
|
||||
current_url,
|
||||
)
|
||||
|
||||
if _is_cf_bypass_enabled() and not use_bypasser_now:
|
||||
# Before switching to bypasser, check if cookies have become available
|
||||
@@ -596,12 +786,24 @@ def html_get_page(
|
||||
# Same reasoning as the redirect-loop handoffs.
|
||||
return _run_bypasser(current_url)
|
||||
logger.warning("403 error, giving up: %s", current_url)
|
||||
return _result("", current_url)
|
||||
return _fail(
|
||||
"Anna's Archive returned 403 (blocked) and no bypasser is enabled "
|
||||
"to solve the protection challenge.",
|
||||
current_url,
|
||||
)
|
||||
|
||||
# 404 = Not found
|
||||
if status == _HTTP_STATUS_NOT_FOUND:
|
||||
logger.warning("404 error: %s", current_url)
|
||||
return _result("", current_url)
|
||||
return _fail(
|
||||
f"Anna's Archive returned 404 Not Found for {current_url}.", current_url
|
||||
)
|
||||
|
||||
# 429 = origin throttling this IP. Arm the per-host backoff so selection and
|
||||
# the bypasser stop hammering it, then fall through to normal rotation onto a
|
||||
# mirror that is not (yet) rate-limited.
|
||||
if status == _HTTP_STATUS_RATE_LIMITED:
|
||||
network.note_rate_limited(current_url)
|
||||
|
||||
# Try mirror/DNS rotation on retryable errors. A failure that proves the
|
||||
# mirror is unusable also drops it from this process's rotation, so the
|
||||
@@ -630,7 +832,16 @@ def html_get_page(
|
||||
else:
|
||||
logger.exception("Giving up after %s attempts: %s", retry_limit, current_url)
|
||||
|
||||
return _result("", current_url)
|
||||
if last_error is not None:
|
||||
return _fail(
|
||||
f"Could not reach Anna's Archive after {retry_limit} attempt(s): "
|
||||
f"{type(last_error).__name__}: {last_error}",
|
||||
current_url,
|
||||
)
|
||||
return _fail(
|
||||
"Could not reach Anna's Archive — all mirrors were exhausted without a usable response.",
|
||||
current_url,
|
||||
)
|
||||
|
||||
|
||||
def download_url(
|
||||
@@ -669,12 +880,7 @@ def download_url(
|
||||
f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Downloading: %s (attempt %s/%s)",
|
||||
current_url,
|
||||
attempt + 1,
|
||||
MAX_DOWNLOAD_RETRIES,
|
||||
)
|
||||
logger.info("Downloading (attempt %s/%s)", attempt + 1, MAX_DOWNLOAD_RETRIES)
|
||||
# Try with CF cookies/UA if available
|
||||
cookies = _apply_cf_bypass(current_url, headers)
|
||||
response = requests.get(
|
||||
@@ -712,7 +918,7 @@ def download_url(
|
||||
and bytes_downloaded < total_size * 0.9
|
||||
and response.headers.get("content-type", "").startswith("text/html")
|
||||
):
|
||||
logger.warning("Received HTML instead of file: %s", current_url)
|
||||
logger.warning("Received HTML instead of file")
|
||||
return None
|
||||
|
||||
logger.debug("Download completed: %s bytes", bytes_downloaded)
|
||||
@@ -730,23 +936,27 @@ def download_url(
|
||||
parsed = urlparse(current_url)
|
||||
if _is_configured_zlib_host(parsed.hostname) and referer:
|
||||
zlib_cookie_refresh_attempted = True
|
||||
logger.info("Z-Library 403 - refreshing cookies via referer: %s", referer)
|
||||
logger.info("Z-Library 403 - refreshing cookies via referer")
|
||||
try:
|
||||
get_bypassed_page(referer, selector, cancel_flag)
|
||||
time.sleep(0.5)
|
||||
# Retry with fresh cookies (don't increment attempt)
|
||||
continue
|
||||
except _BYPASSER_ERRORS as cookie_err:
|
||||
logger.warning("Z-Library cookie refresh failed: %s", cookie_err)
|
||||
logger.warning(
|
||||
"Z-Library cookie refresh failed: %s",
|
||||
type(cookie_err).__name__,
|
||||
)
|
||||
|
||||
# Non-retryable errors
|
||||
if status in _HTTP_STATUS_NON_RETRYABLE:
|
||||
logger.warning("Download failed (%s): %s", status, current_url)
|
||||
logger.warning("Download failed (%s)", status)
|
||||
return None
|
||||
|
||||
# Rate limited - skip to next source immediately
|
||||
# (waiting doesn't help with concurrent downloads hitting the same server)
|
||||
if status == _HTTP_STATUS_RATE_LIMITED:
|
||||
network.note_rate_limited(current_url)
|
||||
logger.info("Rate limited (429) - trying next source")
|
||||
if status_callback:
|
||||
status_callback("resolving", "Server busy, trying next")
|
||||
@@ -754,7 +964,7 @@ def download_url(
|
||||
|
||||
# Timeout - don't retry, server likely overloaded
|
||||
if isinstance(e, requests.exceptions.Timeout):
|
||||
logger.warning("Timeout: %s - skipping to next source", current_url)
|
||||
logger.warning("Timeout - skipping to next source")
|
||||
if status_callback:
|
||||
status_callback("resolving", "Server timed out, trying next")
|
||||
return None
|
||||
@@ -781,14 +991,14 @@ def download_url(
|
||||
attempt += 1
|
||||
continue
|
||||
|
||||
logger.warning("Download error: %s: %s", type(e).__name__, e)
|
||||
logger.warning("Download error: %s", type(e).__name__)
|
||||
if attempt < MAX_DOWNLOAD_RETRIES - 1:
|
||||
time.sleep(_backoff_delay(attempt + 1))
|
||||
attempt += 1
|
||||
else:
|
||||
return buffer
|
||||
|
||||
logger.error("Download failed after %s attempts: %s", MAX_DOWNLOAD_RETRIES, link)
|
||||
logger.error("Download failed after %s attempts", MAX_DOWNLOAD_RETRIES)
|
||||
return None
|
||||
|
||||
|
||||
@@ -878,7 +1088,7 @@ def _try_resume(
|
||||
logger.info("Resume completed: %s bytes", start_byte)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.debug("Resume attempt %s failed: %s", attempt + 1, e)
|
||||
logger.debug("Resume attempt %s failed: %s", attempt + 1, type(e).__name__)
|
||||
else:
|
||||
return buffer
|
||||
|
||||
@@ -894,5 +1104,7 @@ def get_absolute_url(base_url: str, url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
base = urlparse(base_url)
|
||||
if not parsed.netloc or not parsed.scheme:
|
||||
parsed = parsed._replace(netloc=base.netloc, scheme=base.scheme)
|
||||
parsed = parsed._replace(
|
||||
netloc=parsed.netloc or base.netloc, scheme=parsed.scheme or base.scheme
|
||||
)
|
||||
return parsed.geturl()
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import fnmatch
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from socket import AddressFamily, SocketKind
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple, cast
|
||||
|
||||
import dns.resolver
|
||||
import httpx
|
||||
@@ -287,6 +288,108 @@ _dead_aa_urls: set[str] = set()
|
||||
_dead_aa_urls_lock = _RLock()
|
||||
|
||||
|
||||
# Per-host rate-limit backoff. A 429 is the origin throttling *this IP*, not a challenge:
|
||||
# a DDoS-Guard/Cloudflare solve still renders, so the bypass "succeeds" yet the cleared
|
||||
# request is rejected again and the throttle is only renewed. The single answer is to
|
||||
# wait, so a 429 sidelines the host for a growing window - mirror selection and the
|
||||
# bypasser both skip a cooling-down host until its deadline passes. The wait escalates
|
||||
# 2 -> 5 -> 10 -> 15 -> 30 minutes each time the host throttles us again *after* we
|
||||
# already waited a full window out; a host left clear for longer than the top step
|
||||
# starts the ladder over. Keyed by host so every mirror and source shares one view;
|
||||
# in-memory only, so a restart starts clean.
|
||||
_RATE_LIMIT_COOLDOWN_LADDER_SECONDS: tuple[float, ...] = (120.0, 300.0, 600.0, 900.0, 1800.0)
|
||||
# A host that has been clear this long is treated as a fresh episode: the next 429
|
||||
# restarts the ladder at 2 minutes rather than resuming the escalation.
|
||||
_RATE_LIMIT_RESET_AFTER_SECONDS = 1800.0
|
||||
|
||||
|
||||
class _Cooldown(NamedTuple):
|
||||
"""One host's active rate-limit window and how far up the ladder it has climbed."""
|
||||
|
||||
deadline: float # time.monotonic() value at which the wait expires
|
||||
level: int # index into _RATE_LIMIT_COOLDOWN_LADDER_SECONDS
|
||||
|
||||
|
||||
_host_cooldowns: dict[str, _Cooldown] = {}
|
||||
_host_cooldowns_lock = _RLock()
|
||||
|
||||
|
||||
class RateLimitedError(Exception):
|
||||
"""Raised to abandon a request whose host is in a 429 cooldown.
|
||||
|
||||
Not a transport failure - nothing is wrong with the network, the origin is
|
||||
throttling this IP and only time clears it. Callers surface it as a plain failure
|
||||
rather than retrying or handing the URL to the bypasser.
|
||||
"""
|
||||
|
||||
|
||||
def _cooldown_key(url: str) -> str:
|
||||
"""Host a cooldown is keyed by; '' when the URL carries none."""
|
||||
return (urllib.parse.urlparse(url).hostname or "").lower()
|
||||
|
||||
|
||||
def note_rate_limited(url: str) -> float:
|
||||
"""Escalate a host's 429 backoff and (re)arm its cooldown; return the wait applied.
|
||||
|
||||
The step advances only when a fresh 429 arrives *after* the previous window already
|
||||
elapsed - i.e. we waited it out and the host throttled us again. A 429 that lands
|
||||
while the host is still cooling is the same episode: it neither escalates the level
|
||||
nor shortens the wait. See the ladder note above.
|
||||
"""
|
||||
host = _cooldown_key(url)
|
||||
if not host:
|
||||
return 0.0
|
||||
now = time.monotonic()
|
||||
ladder = _RATE_LIMIT_COOLDOWN_LADDER_SECONDS
|
||||
with _host_cooldowns_lock:
|
||||
prev = _host_cooldowns.get(host)
|
||||
if prev is not None and now < prev.deadline:
|
||||
# Still inside the current window - same throttling episode, leave it be.
|
||||
return prev.deadline - now
|
||||
if prev is None or now - prev.deadline > _RATE_LIMIT_RESET_AFTER_SECONDS:
|
||||
level = 0
|
||||
else:
|
||||
level = min(prev.level + 1, len(ladder) - 1)
|
||||
wait = ladder[level]
|
||||
_host_cooldowns[host] = _Cooldown(deadline=now + wait, level=level)
|
||||
logger.info(
|
||||
"Rate limited (429): backing off %s for %.0fs (step %d/%d)",
|
||||
host,
|
||||
wait,
|
||||
level + 1,
|
||||
len(ladder),
|
||||
)
|
||||
return wait
|
||||
|
||||
|
||||
def host_cooldown_remaining(url: str) -> float:
|
||||
"""Seconds left on a host's 429 cooldown; 0.0 when clear or expired.
|
||||
|
||||
Leaves an expired record in place: the ladder level it carries is what a later 429
|
||||
escalates from (or resets, once the clear gap is long enough).
|
||||
"""
|
||||
host = _cooldown_key(url)
|
||||
if not host:
|
||||
return 0.0
|
||||
now = time.monotonic()
|
||||
with _host_cooldowns_lock:
|
||||
rec = _host_cooldowns.get(host)
|
||||
if rec is None or rec.deadline <= now:
|
||||
return 0.0
|
||||
return rec.deadline - now
|
||||
|
||||
|
||||
def is_host_cooling_down(url: str) -> bool:
|
||||
"""True while ``url``'s host is inside its 429 cooldown window."""
|
||||
return host_cooldown_remaining(url) > 0.0
|
||||
|
||||
|
||||
def clear_host_cooldowns() -> None:
|
||||
"""Forget all rate-limit cooldowns (manual reset / tests)."""
|
||||
with _host_cooldowns_lock:
|
||||
_host_cooldowns.clear()
|
||||
|
||||
|
||||
def _ensure_initialized() -> None:
|
||||
"""Lazy guard so runtime setup happens once and late calls still work."""
|
||||
global _initialized
|
||||
@@ -1418,8 +1521,13 @@ def get_available_aa_urls() -> list[str]:
|
||||
if not alive and _aa_urls:
|
||||
logger.warning("All AA mirrors quarantined; retrying the full list")
|
||||
_dead_aa_urls.clear()
|
||||
return _aa_urls.copy()
|
||||
return alive
|
||||
alive = _aa_urls.copy()
|
||||
# Prefer mirrors that are not serving a 429 cooldown so rotation stops hammering a
|
||||
# throttled host. When every live mirror is cooling, keep the full live list rather
|
||||
# than returning nothing: selection must never be left with nowhere to point, and
|
||||
# the bypasser's fail-fast reports the "all rate-limited" case with a clear error.
|
||||
breathing = [url for url in alive if not is_host_cooling_down(url)]
|
||||
return breathing or alive
|
||||
|
||||
|
||||
def _aa_base_for_url(url: str) -> str:
|
||||
@@ -1486,6 +1594,11 @@ class AAMirrorSelector:
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize mirror state from the current AA configuration."""
|
||||
# Set by html_get_page at each give-up path so a caller that only sees the
|
||||
# returned empty page can still report *why* the fetch produced nothing
|
||||
# (403, 404, redirect loop, bypasser error, mirrors exhausted, ...) instead
|
||||
# of a blanket "network restricted" guess. None means "no failure recorded".
|
||||
self.last_failure: str | None = None
|
||||
self._ensure_fresh_state(reset_attempts=True)
|
||||
|
||||
def _ensure_fresh_state(self, *, reset_attempts: bool = False) -> None:
|
||||
|
||||
@@ -29,6 +29,7 @@ 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
|
||||
from shelfmark.release_sources import (
|
||||
HandoffResult,
|
||||
get_handler,
|
||||
get_source,
|
||||
get_source_display_name,
|
||||
@@ -265,6 +266,8 @@ def queue_release(
|
||||
series_position = release_data.get("series_position") or extra.get("series_position")
|
||||
subtitle = release_data.get("subtitle") or extra.get("subtitle")
|
||||
language = release_data.get("language") or extra.get("language")
|
||||
multi_book = bool(release_data.get("multi_book") or extra.get("multi_book"))
|
||||
book_plan = _normalize_book_plan(release_data.get("book_plan") or extra.get("book_plan"))
|
||||
|
||||
books_output_mode = (
|
||||
str(config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder")
|
||||
@@ -293,6 +296,7 @@ def queue_release(
|
||||
year=year,
|
||||
format=release_data.get("format"),
|
||||
size=release_data.get("size"),
|
||||
downloads=release_data.get("downloads") or extra.get("downloads"),
|
||||
preview=preview,
|
||||
content_type=content_type,
|
||||
source_url=source_url,
|
||||
@@ -300,6 +304,8 @@ def queue_release(
|
||||
series_position=series_position,
|
||||
subtitle=subtitle,
|
||||
language=language,
|
||||
multi_book=multi_book or book_plan is not None,
|
||||
book_plan=book_plan,
|
||||
search_mode=search_mode,
|
||||
output_mode=output_mode,
|
||||
output_args=output_args,
|
||||
@@ -314,7 +320,14 @@ def queue_release(
|
||||
logger.info("Release already in queue: %s", task.title)
|
||||
return False, "Release is already in the download queue"
|
||||
|
||||
logger.info("Release queued with priority %s: %s", priority, task.title)
|
||||
logger.info(
|
||||
"Release queued with priority %s: %s (downloads=%s, release_data.downloads=%s, extra=%s)",
|
||||
priority,
|
||||
task.title,
|
||||
task.downloads,
|
||||
release_data.get("downloads"),
|
||||
extra,
|
||||
)
|
||||
|
||||
# Broadcast status update via WebSocket
|
||||
if ws_manager:
|
||||
@@ -408,6 +421,33 @@ def can_retry_download_task(
|
||||
return _has_staged_retry_source(task)
|
||||
|
||||
|
||||
def _normalize_book_plan(value: object) -> list[dict[str, Any]] | None:
|
||||
"""Keep only well-formed pack books: a title plus a non-empty list of file paths."""
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
books: list[dict[str, Any]] = []
|
||||
for entry in value:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
title = normalize_optional_text(entry.get("title"))
|
||||
raw_files = entry.get("files")
|
||||
if title is None or not isinstance(raw_files, list):
|
||||
continue
|
||||
files = [f for f in raw_files if isinstance(f, str) and f.strip()]
|
||||
if not files:
|
||||
continue
|
||||
year = entry.get("year")
|
||||
books.append(
|
||||
{
|
||||
"title": title,
|
||||
"series_position": _optional_number(entry.get("series_position")),
|
||||
"year": year if isinstance(year, int) and not isinstance(year, bool) else None,
|
||||
"files": files,
|
||||
}
|
||||
)
|
||||
return books or None
|
||||
|
||||
|
||||
def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
|
||||
"""Serialize the task state needed for restart-safe retries."""
|
||||
raw_search_mode = getattr(task, "search_mode", None)
|
||||
@@ -437,6 +477,8 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
|
||||
"subtitle": getattr(task, "subtitle", None),
|
||||
"language": getattr(task, "language", None),
|
||||
"search_mode": search_mode,
|
||||
"multi_book": bool(getattr(task, "multi_book", False)),
|
||||
"book_plan": _normalize_book_plan(getattr(task, "book_plan", None)),
|
||||
"output_mode": getattr(task, "output_mode", None),
|
||||
"output_args": dict(raw_output_args) if isinstance(raw_output_args, dict) else {},
|
||||
"user_id": getattr(task, "user_id", None),
|
||||
@@ -487,6 +529,7 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
|
||||
year=normalize_optional_text(payload.get("year")),
|
||||
format=normalize_optional_text(payload.get("format")),
|
||||
size=normalize_optional_text(payload.get("size")),
|
||||
downloads=int(payload["downloads"]) if payload.get("downloads") is not None else None,
|
||||
preview=normalize_optional_text(payload.get("preview")),
|
||||
content_type=normalize_optional_text(payload.get("content_type")),
|
||||
source_url=normalize_optional_text(payload.get("source_url")),
|
||||
@@ -495,6 +538,8 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
|
||||
subtitle=normalize_optional_text(payload.get("subtitle")),
|
||||
language=normalize_optional_text(payload.get("language")),
|
||||
search_mode=search_mode,
|
||||
multi_book=bool(payload.get("multi_book", False)),
|
||||
book_plan=_normalize_book_plan(payload.get("book_plan")),
|
||||
output_mode=normalize_optional_text(payload.get("output_mode")),
|
||||
output_args=dict(output_args) if isinstance(output_args, dict) else {},
|
||||
user_id=normalize_positive_int(payload.get("user_id")),
|
||||
@@ -581,6 +626,7 @@ def _task_to_dict(
|
||||
"author": task.author,
|
||||
"format": task.format,
|
||||
"size": task.size,
|
||||
"downloads": task.downloads,
|
||||
"preview": preview,
|
||||
"content_type": task.content_type,
|
||||
"source": task.source,
|
||||
@@ -715,6 +761,13 @@ def _download_task(task_id: str, cancel_flag: Event) -> str | None:
|
||||
if not temp_path:
|
||||
return None
|
||||
|
||||
if isinstance(temp_path, HandoffResult):
|
||||
handoff_path = Path(temp_path.path)
|
||||
status_callback("complete", temp_path.message)
|
||||
handler.post_process_cleanup(task, success=True)
|
||||
_clear_task_error_state(task)
|
||||
return str(handoff_path)
|
||||
|
||||
temp_file = Path(temp_path)
|
||||
if not run_blocking_io(temp_file.exists):
|
||||
logger.error("Handler returned non-existent path: %s", temp_path)
|
||||
|
||||
@@ -105,6 +105,7 @@ def process_folder_output(
|
||||
maybe_run_custom_script,
|
||||
prepare_output_files,
|
||||
record_step,
|
||||
resolve_book_groups,
|
||||
transfer_book_files,
|
||||
)
|
||||
|
||||
@@ -205,6 +206,7 @@ def process_folder_output(
|
||||
is_torrent=is_torrent,
|
||||
preserve_source=preserve_source,
|
||||
organization_mode=plan.organization_mode,
|
||||
source_root=source_path,
|
||||
)
|
||||
|
||||
if error:
|
||||
@@ -259,7 +261,15 @@ def process_folder_output(
|
||||
prepared.cleanup_paths,
|
||||
)
|
||||
|
||||
message = "Complete" if len(final_paths) == 1 else f"Complete ({len(final_paths)} files)"
|
||||
pack_groups = resolve_book_groups(
|
||||
task, prepared.files, organization_mode=plan.organization_mode
|
||||
)
|
||||
if pack_groups is not None:
|
||||
message = f"Complete ({len(pack_groups)} books, {len(final_paths)} files)"
|
||||
elif len(final_paths) == 1:
|
||||
message = "Complete"
|
||||
else:
|
||||
message = f"Complete ({len(final_paths)} files)"
|
||||
status_callback("complete", message)
|
||||
|
||||
return str(final_paths[0])
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
"""Multi-book ("pack") release planning.
|
||||
|
||||
A pack is one release that contains several books: a whole-series torrent with one
|
||||
subfolder per book, or a flat folder of `Series 1.0 - Title.m4b` files. The same
|
||||
planning rules serve pre-download inspection (the file list comes from the release
|
||||
source) and post-processing (the file list comes from disk), so what the user
|
||||
approved in the modal is what gets filed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from shelfmark.core.utils import AUDIOBOOK_FORMATS
|
||||
|
||||
# m4b/m4a hold a whole audiobook in one file; every other audio format (mp3, flac, ...) is
|
||||
# chaptered - many files make up one book. Ebook formats are always one file per book, so
|
||||
# only chaptered *audio* matters here. A flat folder is split one-book-per-file only when
|
||||
# none of its files are chaptered audio: a bare list of `01 - Chapter.mp3` tracks is a
|
||||
# single chaptered audiobook, not a pack of books.
|
||||
_SINGLE_FILE_AUDIO_CONTAINERS = frozenset({"m4b", "m4a"})
|
||||
_CHAPTERED_AUDIO_EXTENSIONS = frozenset(AUDIOBOOK_FORMATS) - _SINGLE_FILE_AUDIO_CONTAINERS
|
||||
|
||||
_YEAR_SUFFIX_RE = re.compile(r"\s*\(\s*(?P<year>\d{4})\s*\)\s*$")
|
||||
_SERIES_MARKER_RE = re.compile(
|
||||
r"""
|
||||
^\s*
|
||||
(?:
|
||||
\[\s*\#?(?P<bracket>\d+(?:\.\d+)?)\s*\] # [03] / [#3]
|
||||
| \#(?P<hash>\d+(?:\.\d+)?) # #3
|
||||
| book\.?\s*(?P<book>\d+(?:\.\d+)?) # Book 3 / Book. 03
|
||||
| (?P<plain>\d+(?:\.\d+)?)(?=[\s\-:.]) # 03 - / 1.0 - / 3.
|
||||
)
|
||||
\s*(?:[-:.]\s*)?
|
||||
""",
|
||||
re.IGNORECASE | re.VERBOSE,
|
||||
)
|
||||
_SEPARATOR_CHARS = " \t-_:."
|
||||
# "Gods of Risk 2.5 - Gods of Risk": the title repeated on both sides of the position.
|
||||
_REPEATED_TITLE_RE = re.compile(
|
||||
r"^(?P<left>.+?)\s+(?P<position>\d+(?:\.\d+)?)\s*[-:\u2013]\s*(?P<right>.+)$"
|
||||
)
|
||||
_SERIES_LABEL_WORDS = r"(?:novella|novellas|short\s+story|short|story|novel)"
|
||||
# "Uncrowned Cradle, Book 7" / "Reaper Cradle, Volume 10" / "Wintersteel (Cradle, Book 8)":
|
||||
# an explicit word marks the position at the END of the name. A bare trailing number
|
||||
# is deliberately not matched — "Title - 02" is a chapter, not a series position.
|
||||
_TRAILING_MARKER_RE = re.compile(
|
||||
r"""
|
||||
[\s,\-:\u2013(]*
|
||||
(?:book|volume|vol\.?)\s*\#?(?P<position>\d+(?:\.\d+)?)
|
||||
\s*\)?\s*$
|
||||
""",
|
||||
re.IGNORECASE | re.VERBOSE,
|
||||
)
|
||||
# AudiobookBay renders a file inside a folder as "<folder> <file>" with no separator,
|
||||
# so a pack row reads "Author - Title Series, Book 1 Title Series, Book 1".
|
||||
_GLUED_FOLDER_RE = re.compile(
|
||||
r"^(?P<prefix>.+?\s[-\u2013]\s)?(?P<core>.+?)\s+(?P=core)$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackFile:
|
||||
"""One file inside a release, path relative to the release root."""
|
||||
|
||||
path: str
|
||||
size: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackBook:
|
||||
"""One book split out of a pack, files as release-relative paths."""
|
||||
|
||||
title: str
|
||||
series_position: float | None
|
||||
year: int | None
|
||||
files: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackPlan:
|
||||
books: list[PackBook]
|
||||
ignored: list[str]
|
||||
|
||||
@property
|
||||
def is_pack(self) -> bool:
|
||||
return len(self.books) > 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BookGroup:
|
||||
"""One book's on-disk files, ready for transfer."""
|
||||
|
||||
title: str
|
||||
series_position: float | None
|
||||
year: int | None
|
||||
files: list[Path]
|
||||
|
||||
|
||||
def _strip_series_name(name: str, series_name: str | None) -> str:
|
||||
if not series_name:
|
||||
return name
|
||||
prefix = series_name.strip()
|
||||
if not prefix or not name.lower().startswith(prefix.lower()):
|
||||
return name
|
||||
remainder = name[len(prefix) :]
|
||||
if remainder and remainder[0].isalnum():
|
||||
return name
|
||||
return remainder.lstrip(_SEPARATOR_CHARS)
|
||||
|
||||
|
||||
def _strip_series_label(work: str, series_name: str | None) -> str:
|
||||
"""Drop a leading "An <Series> Novella - " style label that some packs prepend."""
|
||||
if not series_name:
|
||||
return work
|
||||
# "The Expanse" is labelled "An Expanse Novella", so match without the article.
|
||||
core = re.sub(r"^(?:the|an?)\s+", "", series_name.strip(), flags=re.IGNORECASE)
|
||||
if not core:
|
||||
return work
|
||||
pattern = re.compile(
|
||||
rf"^(?:an?\s+|the\s+)?{re.escape(core)}\s+{_SERIES_LABEL_WORDS}\s*[-:\u2013]\s*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
return pattern.sub("", work, count=1)
|
||||
|
||||
|
||||
def _collapse_glued_folder(name: str) -> str:
|
||||
match = _GLUED_FOLDER_RE.match(name)
|
||||
if not match:
|
||||
return name
|
||||
prefix = match.group("prefix") or ""
|
||||
core = match.group("core")
|
||||
# "Author - X X" → "Author - X" (the folder carried the author, the file did not).
|
||||
return (prefix + core).strip()
|
||||
|
||||
|
||||
def _strip_author_name(name: str, author_name: str | None) -> str:
|
||||
"""Drop a leading "Author - " (packs are often filed as `Author - Title`)."""
|
||||
if not author_name:
|
||||
return name
|
||||
prefix = author_name.strip()
|
||||
if not prefix or not name.lower().startswith(prefix.lower()):
|
||||
return name
|
||||
remainder = name[len(prefix) :]
|
||||
stripped = remainder.lstrip(_SEPARATOR_CHARS + "\u2013")
|
||||
if stripped == remainder: # no separator after the author: part of the title
|
||||
return name
|
||||
return stripped
|
||||
|
||||
|
||||
def _strip_trailing_series_name(work: str, series_name: str | None) -> str:
|
||||
"""Drop a trailing series name left behind by a trailing position marker."""
|
||||
if not series_name:
|
||||
return work
|
||||
suffix = series_name.strip()
|
||||
if not suffix or not work.lower().endswith(suffix.lower()):
|
||||
return work
|
||||
remainder = work[: -len(suffix)]
|
||||
stripped = remainder.rstrip(_SEPARATOR_CHARS + ",(\u2013")
|
||||
if not stripped or stripped == remainder:
|
||||
return work
|
||||
return stripped
|
||||
|
||||
|
||||
def parse_pack_book_name(
|
||||
name: str, *, series_name: str | None, author_name: str | None = None
|
||||
) -> tuple[str, float | None, int | None]:
|
||||
"""Split a book folder/file-stem name into (title, series position, year).
|
||||
|
||||
Strips a leading series name, a leading position marker (`Book 3 - `, `03 - `,
|
||||
`1.0 - `, `3. `, `[03] `, `#3 `) and a trailing `(YYYY)`. Also understands a
|
||||
trailing marker (`Title Series, Book 3`, `Title (Series, Volume 3)`), a leading
|
||||
`Author - `, and AudiobookBay's glued `<folder> <file>` names. Returns the name
|
||||
unchanged with no position/year when nothing would be left of the title.
|
||||
"""
|
||||
work = _collapse_glued_folder(name.strip())
|
||||
work = _strip_author_name(work, author_name)
|
||||
work = _strip_series_name(work, series_name)
|
||||
|
||||
year: int | None = None
|
||||
year_match = _YEAR_SUFFIX_RE.search(work)
|
||||
if year_match:
|
||||
year = int(year_match.group("year"))
|
||||
work = work[: year_match.start()]
|
||||
|
||||
position: float | None = None
|
||||
repeated = _REPEATED_TITLE_RE.match(work.strip())
|
||||
if (
|
||||
repeated
|
||||
and repeated.group("left").strip().lower() == repeated.group("right").strip().lower()
|
||||
):
|
||||
return repeated.group("right").strip(), float(repeated.group("position")), year
|
||||
|
||||
marker = _SERIES_MARKER_RE.match(work)
|
||||
if marker:
|
||||
raw = (
|
||||
marker.group("bracket")
|
||||
or marker.group("hash")
|
||||
or marker.group("book")
|
||||
or marker.group("plain")
|
||||
)
|
||||
position = float(raw)
|
||||
work = work[marker.end() :]
|
||||
else:
|
||||
trailing = _TRAILING_MARKER_RE.search(work)
|
||||
if trailing and trailing.start() > 0:
|
||||
position = float(trailing.group("position"))
|
||||
work = _strip_trailing_series_name(work[: trailing.start()], series_name)
|
||||
|
||||
work = _strip_series_label(work, series_name)
|
||||
title = work.strip().strip(_SEPARATOR_CHARS).strip()
|
||||
if not title:
|
||||
return name, None, None
|
||||
return title, position, year
|
||||
|
||||
|
||||
def _book_from_name(
|
||||
name: str, files: list[str], series_name: str | None, author_name: str | None = None
|
||||
) -> PackBook:
|
||||
title, position, year = parse_pack_book_name(
|
||||
name, series_name=series_name, author_name=author_name
|
||||
)
|
||||
return PackBook(title=title, series_position=position, year=year, files=files)
|
||||
|
||||
|
||||
def _common_root_parts(paths: list[PurePosixPath]) -> tuple[str, ...]:
|
||||
parents = [p.parent.parts for p in paths]
|
||||
common: list[str] = []
|
||||
for parts in zip(*parents, strict=False):
|
||||
if len(set(parts)) != 1:
|
||||
break
|
||||
common.append(parts[0])
|
||||
return tuple(common)
|
||||
|
||||
|
||||
def plan_pack(
|
||||
files: list[PackFile],
|
||||
*,
|
||||
supported_extensions: set[str],
|
||||
series_name: str | None,
|
||||
author_name: str | None = None,
|
||||
root_depth: int | None = None,
|
||||
) -> PackPlan:
|
||||
"""Group a release's file list into books.
|
||||
|
||||
Files in a subfolder (relative to the common root) group by that subfolder. Files
|
||||
directly in the root split one-book-per-file only when at least two of them carry
|
||||
a series position in their names; otherwise they are one book (a chaptered
|
||||
audiobook, e.g. `01.mp3`, `02.mp3`). `root_depth` fixes how many leading path
|
||||
components form the root instead of deriving it from the files' common parent.
|
||||
"""
|
||||
supported = {ext.lower().lstrip(".") for ext in supported_extensions}
|
||||
book_files: list[PurePosixPath] = []
|
||||
ignored: list[str] = []
|
||||
for pack_file in files:
|
||||
rel = PurePosixPath(pack_file.path.replace("\\", "/").lstrip("./"))
|
||||
if rel.suffix.lower().lstrip(".") in supported:
|
||||
book_files.append(rel)
|
||||
else:
|
||||
ignored.append(pack_file.path)
|
||||
|
||||
if not book_files:
|
||||
return PackPlan(books=[], ignored=ignored)
|
||||
|
||||
root_parts = (
|
||||
_common_root_parts(book_files) if root_depth is None else book_files[0].parts[:root_depth]
|
||||
)
|
||||
depth = len(root_parts)
|
||||
|
||||
root_files: list[PurePosixPath] = []
|
||||
folders: dict[str, list[str]] = {}
|
||||
for rel in book_files:
|
||||
remainder = rel.parts[depth:]
|
||||
if len(remainder) > 1:
|
||||
folders.setdefault(remainder[0], []).append(str(rel))
|
||||
else:
|
||||
root_files.append(rel)
|
||||
|
||||
books: list[PackBook] = []
|
||||
if root_files:
|
||||
parsed = [
|
||||
parse_pack_book_name(f.stem, series_name=series_name, author_name=author_name)
|
||||
for f in root_files
|
||||
]
|
||||
positions = {p[1] for p in parsed if p[1] is not None}
|
||||
titles = {p[0].strip().lower() for p in parsed if p[0]}
|
||||
one_book_per_file = all(
|
||||
rel.suffix.lower().lstrip(".") not in _CHAPTERED_AUDIO_EXTENSIONS for rel in root_files
|
||||
)
|
||||
# Split a flat folder into a book per file only with real evidence of distinct
|
||||
# books: two or more series positions, more than one title, and no chaptered audio
|
||||
# (a bare list of `01 - Chapter.mp3` tracks is one book, not a pack).
|
||||
if len(positions) >= 2 and len(titles) >= 2 and one_book_per_file:
|
||||
books.extend(
|
||||
PackBook(title=title, series_position=position, year=year, files=[str(f)])
|
||||
for f, (title, position, year) in zip(root_files, parsed, strict=True)
|
||||
)
|
||||
elif len(root_files) == 1:
|
||||
books.append(
|
||||
_book_from_name(root_files[0].stem, [str(root_files[0])], series_name, author_name)
|
||||
)
|
||||
else:
|
||||
group_name = root_parts[-1] if root_parts else ""
|
||||
books.append(
|
||||
_book_from_name(group_name, [str(f) for f in root_files], series_name, author_name)
|
||||
)
|
||||
|
||||
books.extend(
|
||||
_book_from_name(folder, paths, series_name, author_name)
|
||||
for folder, paths in folders.items()
|
||||
)
|
||||
return PackPlan(books=books, ignored=ignored)
|
||||
|
||||
|
||||
def _relative_paths(
|
||||
book_files: list[Path], root: Path | None = None
|
||||
) -> tuple[Path, dict[Path, str]]:
|
||||
if root is None:
|
||||
root = Path(os.path.commonpath([str(f.parent) for f in book_files]))
|
||||
return root, {f: f.relative_to(root).as_posix() for f in book_files}
|
||||
|
||||
|
||||
def group_files_into_books(
|
||||
book_files: list[Path],
|
||||
*,
|
||||
series_name: str | None,
|
||||
author_name: str | None = None,
|
||||
root: Path | None = None,
|
||||
) -> list[BookGroup]:
|
||||
"""Heuristically split on-disk files into books (see `plan_pack`).
|
||||
|
||||
`root` pins the release root when grouping a subset of a larger file set.
|
||||
"""
|
||||
if not book_files:
|
||||
return []
|
||||
_root, rel_by_path = _relative_paths(book_files, root)
|
||||
path_by_rel = {rel: path for path, rel in rel_by_path.items()}
|
||||
extensions = {f.suffix.lower().lstrip(".") for f in book_files}
|
||||
plan = plan_pack(
|
||||
[PackFile(rel) for rel in rel_by_path.values()],
|
||||
supported_extensions=extensions,
|
||||
series_name=series_name,
|
||||
author_name=author_name,
|
||||
root_depth=None if root is None else 0,
|
||||
)
|
||||
return [
|
||||
BookGroup(
|
||||
title=book.title,
|
||||
series_position=book.series_position,
|
||||
year=book.year,
|
||||
files=[path_by_rel[rel] for rel in book.files],
|
||||
)
|
||||
for book in plan.books
|
||||
]
|
||||
|
||||
|
||||
def match_plan_to_files(
|
||||
plan: list[PackBook],
|
||||
book_files: list[Path],
|
||||
*,
|
||||
series_name: str | None = None,
|
||||
author_name: str | None = None,
|
||||
) -> list[BookGroup]:
|
||||
"""Apply an approved plan to on-disk files.
|
||||
|
||||
Files match by release-relative path first, then by basename (archive extraction
|
||||
and client save paths can shift the root), then by the on-disk basename being a
|
||||
suffix of the planned name (sources that glue folder and file names together).
|
||||
Book files the plan does not mention fall back to heuristic grouping so nothing
|
||||
is silently dropped.
|
||||
"""
|
||||
if not book_files:
|
||||
return []
|
||||
root, rel_by_path = _relative_paths(book_files)
|
||||
by_rel = {rel: path for path, rel in rel_by_path.items()}
|
||||
by_name: dict[str, list[Path]] = {}
|
||||
for path in book_files:
|
||||
by_name.setdefault(path.name, []).append(path)
|
||||
|
||||
claimed: set[Path] = set()
|
||||
groups: list[BookGroup] = []
|
||||
for book in plan:
|
||||
matched: list[Path] = []
|
||||
for wanted in book.files:
|
||||
wanted_rel = wanted.replace("\\", "/").lstrip("./")
|
||||
candidate = by_rel.get(wanted_rel)
|
||||
if candidate is None:
|
||||
candidates = [
|
||||
p for p in by_name.get(PurePosixPath(wanted_rel).name, []) if p not in claimed
|
||||
]
|
||||
candidate = candidates[0] if candidates else None
|
||||
if candidate is None:
|
||||
wanted_name = PurePosixPath(wanted_rel).name.lower()
|
||||
candidates = [
|
||||
p
|
||||
for p in book_files
|
||||
if p not in claimed and wanted_name.endswith(p.name.lower())
|
||||
]
|
||||
candidate = candidates[0] if len(candidates) == 1 else None
|
||||
if candidate is not None and candidate not in claimed:
|
||||
claimed.add(candidate)
|
||||
matched.append(candidate)
|
||||
if matched:
|
||||
groups.append(
|
||||
BookGroup(
|
||||
title=book.title,
|
||||
series_position=book.series_position,
|
||||
year=book.year,
|
||||
files=matched,
|
||||
)
|
||||
)
|
||||
|
||||
unmatched = [p for p in book_files if p not in claimed]
|
||||
still_unmatched: list[Path] = []
|
||||
for p in unmatched:
|
||||
p_ext = p.suffix.lower().lstrip(".")
|
||||
is_chaptered = p_ext in _CHAPTERED_AUDIO_EXTENSIONS
|
||||
|
||||
matching_groups = [g for g in groups if any(f.parent == p.parent for f in g.files)]
|
||||
|
||||
target_group: BookGroup | None = None
|
||||
if len(matching_groups) == 1 and is_chaptered:
|
||||
target_group = matching_groups[0]
|
||||
elif len(plan) == 1 and len(groups) == 1 and is_chaptered:
|
||||
target_group = groups[0]
|
||||
|
||||
if target_group is not None:
|
||||
target_group.files.append(p)
|
||||
claimed.add(p)
|
||||
else:
|
||||
still_unmatched.append(p)
|
||||
|
||||
if still_unmatched:
|
||||
groups.extend(
|
||||
group_files_into_books(
|
||||
still_unmatched, series_name=series_name, author_name=author_name, root=root
|
||||
)
|
||||
)
|
||||
return groups
|
||||
@@ -40,6 +40,7 @@ from .transfer import (
|
||||
build_metadata_dict,
|
||||
is_torrent_source,
|
||||
process_directory,
|
||||
resolve_book_groups,
|
||||
resolve_hardlink_source,
|
||||
should_hardlink,
|
||||
transfer_book_files,
|
||||
@@ -80,6 +81,7 @@ __all__ = [
|
||||
"process_directory",
|
||||
"record_step",
|
||||
"resolve_custom_script_target",
|
||||
"resolve_book_groups",
|
||||
"resolve_hardlink_source",
|
||||
"run_custom_script",
|
||||
"safe_cleanup_path",
|
||||
|
||||
@@ -52,7 +52,18 @@ def get_file_organization(*, is_audiobook: bool) -> str:
|
||||
"""Get the file organization mode for the content type."""
|
||||
key = "FILE_ORGANIZATION_AUDIOBOOK" if is_audiobook else "FILE_ORGANIZATION"
|
||||
mode = _config_text(core_config.config.get(key, "rename")).strip().lower()
|
||||
return mode if mode in ("none", "rename", "organize") else "rename"
|
||||
return mode if mode in ("none", "rename", "rename_and_group", "organize") else "rename"
|
||||
|
||||
|
||||
def get_word_separator() -> str:
|
||||
"""Get the configured word separator for naming template values.
|
||||
|
||||
Replaces whitespace inside each placeholder's rendered value (e.g. "Conan
|
||||
Doyle" -> "Conan.Doyle"). The setting holds the separator character
|
||||
directly (e.g. "." or "_"); empty means a plain space, which leaves
|
||||
values unchanged.
|
||||
"""
|
||||
return _config_text(core_config.config.get("NAMING_WORD_SEPARATOR", "")) or " "
|
||||
|
||||
|
||||
def get_template(*, is_audiobook: bool, organization_mode: str) -> str:
|
||||
|
||||
@@ -187,7 +187,9 @@ def scan_directory_tree(
|
||||
file_path = Path(root) / filename
|
||||
suffix = file_path.suffix.lower()
|
||||
|
||||
if suffix in supported_exts:
|
||||
# zip/rar can also be enabled as supported formats. Keep archives
|
||||
# out of book_files so they are extracted rather than imported whole.
|
||||
if suffix in supported_exts and not is_archive(file_path):
|
||||
book_files.append(file_path)
|
||||
elif suffix in trackable_exts:
|
||||
rejected_files.append(file_path)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -17,14 +18,20 @@ from shelfmark.core.naming import (
|
||||
sanitize_filename,
|
||||
)
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.download.archive import is_archive
|
||||
from shelfmark.download.fs import (
|
||||
atomic_copy,
|
||||
atomic_hardlink,
|
||||
atomic_move,
|
||||
run_blocking_io,
|
||||
)
|
||||
from shelfmark.download.postprocess.policy import get_file_organization, get_template
|
||||
from shelfmark.download.postprocess.policy import (
|
||||
get_file_organization,
|
||||
get_template,
|
||||
get_word_separator,
|
||||
)
|
||||
|
||||
from .packs import BookGroup, PackBook, group_files_into_books, match_plan_to_files
|
||||
from .scan import collect_directory_files, scan_directory_tree
|
||||
from .types import TransferPlan
|
||||
from .workspace import safe_cleanup_path
|
||||
@@ -160,6 +167,24 @@ def _transfer_single_file(
|
||||
return atomic_move(source_path, dest_path, max_attempts=max_attempts), "move"
|
||||
|
||||
|
||||
def _group_folder_name(source_root: Path | None) -> str:
|
||||
"""Name the folder a grouped multi-file audiobook is transferred into.
|
||||
|
||||
A directory names the group directly. A file cannot hold several book files
|
||||
on its own, so a non-directory source that produced more than one means
|
||||
`collect_staged_files` extracted an archive: the stem is the release name and
|
||||
the suffix is packaging, which is why `Book.zip` groups into `Book/` rather
|
||||
than `Book.zip/` or, worse, not at all.
|
||||
"""
|
||||
if source_root is None:
|
||||
return ""
|
||||
if run_blocking_io(source_root.is_dir):
|
||||
return sanitize_filename(source_root.name)
|
||||
if is_archive(source_root):
|
||||
return sanitize_filename(source_root.stem)
|
||||
return ""
|
||||
|
||||
|
||||
def transfer_book_files(
|
||||
book_files: list[Path],
|
||||
destination: Path,
|
||||
@@ -169,6 +194,7 @@ def transfer_book_files(
|
||||
is_torrent: bool,
|
||||
preserve_source: bool = False,
|
||||
organization_mode: str | None = None,
|
||||
source_root: Path | None = None,
|
||||
) -> tuple[list[Path], str | None, dict[str, int]]:
|
||||
"""Transfer discovered book files into their final destination layout."""
|
||||
if not book_files:
|
||||
@@ -176,6 +202,20 @@ def transfer_book_files(
|
||||
|
||||
is_audiobook = check_audiobook(task.content_type)
|
||||
organization_mode = organization_mode or get_file_organization(is_audiobook=is_audiobook)
|
||||
word_separator = get_word_separator()
|
||||
|
||||
groups = resolve_book_groups(task, book_files, organization_mode=organization_mode)
|
||||
if groups is not None:
|
||||
return _transfer_book_groups(
|
||||
groups,
|
||||
destination,
|
||||
task,
|
||||
use_hardlink=use_hardlink,
|
||||
is_torrent=is_torrent,
|
||||
preserve_source=preserve_source,
|
||||
organization_mode=organization_mode,
|
||||
)
|
||||
|
||||
max_attempts = _max_attempts_for_batch(len(book_files))
|
||||
|
||||
final_paths: list[Path] = []
|
||||
@@ -194,6 +234,7 @@ def transfer_book_files(
|
||||
template,
|
||||
file_metadata,
|
||||
extension=ext or None,
|
||||
word_separator=word_separator,
|
||||
)
|
||||
run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
@@ -221,6 +262,7 @@ def transfer_book_files(
|
||||
template,
|
||||
file_metadata,
|
||||
extension=ext or None,
|
||||
word_separator=word_separator,
|
||||
)
|
||||
run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
@@ -238,6 +280,13 @@ def transfer_book_files(
|
||||
|
||||
return final_paths, None, op_counts
|
||||
|
||||
transfer_destination = destination
|
||||
if is_audiobook and len(book_files) > 1 and organization_mode == "rename_and_group":
|
||||
source_folder = _group_folder_name(source_root)
|
||||
if source_folder:
|
||||
transfer_destination = destination / source_folder
|
||||
run_blocking_io(transfer_destination.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
for book_file in book_files:
|
||||
if len(book_files) == 1 and organization_mode != "none":
|
||||
if not task.format:
|
||||
@@ -247,7 +296,9 @@ def transfer_book_files(
|
||||
metadata = build_file_metadata(task, book_file)
|
||||
extension = book_file.suffix.lstrip(".") or task.format or ""
|
||||
|
||||
filename = parse_naming_template(template, metadata, allow_path_separators=False)
|
||||
filename = parse_naming_template(
|
||||
template, metadata, allow_path_separators=False, word_separator=word_separator
|
||||
)
|
||||
filename = Path(filename).name if filename else ""
|
||||
if filename and extension:
|
||||
filename = f"{sanitize_filename(filename)}.{extension}"
|
||||
@@ -256,7 +307,7 @@ def transfer_book_files(
|
||||
else:
|
||||
filename = book_file.name
|
||||
|
||||
dest_path = destination / filename
|
||||
dest_path = transfer_destination / filename
|
||||
final_path, op = _transfer_single_file(
|
||||
book_file,
|
||||
dest_path,
|
||||
@@ -272,6 +323,101 @@ def transfer_book_files(
|
||||
return final_paths, None, op_counts
|
||||
|
||||
|
||||
def resolve_book_groups(
|
||||
task: DownloadTask,
|
||||
book_files: list[Path],
|
||||
*,
|
||||
organization_mode: str,
|
||||
) -> list[BookGroup] | None:
|
||||
"""Split a multi-book pack into per-book groups, or None to file as one book.
|
||||
|
||||
An approved `book_plan` wins; a bare `multi_book` flag falls back to heuristic
|
||||
grouping. Organization `none` keeps files as-is, and a split that yields a single
|
||||
group is not a pack at all.
|
||||
"""
|
||||
if organization_mode == "none" or not (task.book_plan or task.multi_book):
|
||||
return None
|
||||
if task.book_plan:
|
||||
plan = [
|
||||
PackBook(
|
||||
title=str(entry.get("title") or ""),
|
||||
series_position=entry.get("series_position"),
|
||||
year=entry.get("year"),
|
||||
files=list(entry.get("files") or []),
|
||||
)
|
||||
for entry in task.book_plan
|
||||
if isinstance(entry, dict)
|
||||
]
|
||||
groups = match_plan_to_files(
|
||||
plan, book_files, series_name=task.series_name, author_name=task.author
|
||||
)
|
||||
else:
|
||||
groups = group_files_into_books(
|
||||
book_files, series_name=task.series_name, author_name=task.author
|
||||
)
|
||||
return groups if len(groups) > 1 else None
|
||||
|
||||
|
||||
def _transfer_book_groups(
|
||||
groups: list[BookGroup],
|
||||
destination: Path,
|
||||
task: DownloadTask,
|
||||
*,
|
||||
use_hardlink: bool,
|
||||
is_torrent: bool,
|
||||
preserve_source: bool,
|
||||
organization_mode: str,
|
||||
) -> tuple[list[Path], str | None, dict[str, int]]:
|
||||
"""Transfer each book of a pack through the normal single-book path.
|
||||
|
||||
Each book gets an isolated task copy (the single-file path mutates `task.format`)
|
||||
carrying its own title, position and year; the searched book's position must not
|
||||
leak onto its siblings, while author and series name apply to all of them.
|
||||
"""
|
||||
all_paths: list[Path] = []
|
||||
totals: dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0}
|
||||
errors: list[str] = []
|
||||
|
||||
for group in groups:
|
||||
book_task = dataclasses.replace(
|
||||
task,
|
||||
title=group.title or task.title,
|
||||
year=str(group.year) if group.year is not None else None,
|
||||
subtitle=None,
|
||||
series_position=group.series_position,
|
||||
multi_book=False,
|
||||
book_plan=None,
|
||||
)
|
||||
paths, error, op_counts = transfer_book_files(
|
||||
group.files,
|
||||
destination,
|
||||
book_task,
|
||||
use_hardlink=use_hardlink,
|
||||
is_torrent=is_torrent,
|
||||
preserve_source=preserve_source,
|
||||
organization_mode=organization_mode,
|
||||
source_root=group.files[0].parent,
|
||||
)
|
||||
for op, count in op_counts.items():
|
||||
totals[op] = totals.get(op, 0) + count
|
||||
if error:
|
||||
errors.append(f"{group.title}: {error}")
|
||||
logger.warning("Task %s: pack book %r failed: %s", task.task_id, group.title, error)
|
||||
continue
|
||||
all_paths.extend(paths)
|
||||
|
||||
if not all_paths:
|
||||
return [], "; ".join(errors) or "No book files found", totals
|
||||
if errors:
|
||||
logger.warning(
|
||||
"Task %s: pack filed with %d failed book(s): %s",
|
||||
task.task_id,
|
||||
len(errors),
|
||||
"; ".join(errors),
|
||||
)
|
||||
return all_paths, None, totals
|
||||
|
||||
|
||||
def process_directory(
|
||||
directory: Path,
|
||||
ingest_dir: Path,
|
||||
@@ -346,7 +492,12 @@ def transfer_file_to_library(
|
||||
template_metadata = dict(metadata)
|
||||
template_metadata.setdefault("OriginalName", source_path.stem)
|
||||
dest_path = run_blocking_io(
|
||||
build_library_path, library_base, template, template_metadata, extension
|
||||
build_library_path,
|
||||
library_base,
|
||||
template,
|
||||
template_metadata,
|
||||
extension,
|
||||
word_separator=get_word_separator(),
|
||||
)
|
||||
run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
@@ -401,12 +552,14 @@ def transfer_directory_to_library(
|
||||
safe_cleanup_path(temp_file, task)
|
||||
return None
|
||||
|
||||
word_separator = get_word_separator()
|
||||
base_library_path = run_blocking_io(
|
||||
build_library_path,
|
||||
library_base,
|
||||
template,
|
||||
metadata,
|
||||
extension=None,
|
||||
word_separator=word_separator,
|
||||
)
|
||||
run_blocking_io(base_library_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
@@ -437,7 +590,12 @@ def transfer_directory_to_library(
|
||||
ext = source_file.suffix.lstrip(".")
|
||||
file_metadata = {**metadata, "PartNumber": part_number}
|
||||
file_path = run_blocking_io(
|
||||
build_library_path, library_base, template, file_metadata, extension=ext
|
||||
build_library_path,
|
||||
library_base,
|
||||
template,
|
||||
file_metadata,
|
||||
extension=ext,
|
||||
word_separator=word_separator,
|
||||
)
|
||||
run_blocking_io(file_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -32,6 +32,19 @@ _DEFAULT_QUERY = "The Great Gatsby"
|
||||
_warmup_thread: threading.Thread | None = None
|
||||
_warmup_lock = threading.Lock()
|
||||
|
||||
# Set as soon as a real release search starts. The warm-up exists to pay the cold path
|
||||
# *before* the user does; once they have beaten it to the box there is nothing left to
|
||||
# pre-solve, and running anyway is actively harmful - the bypasser serializes on one
|
||||
# browser, so the warm-up's solve goes in front of the search the user is watching. In
|
||||
# the bundle on issue #1276 that cost a full minute of a 2m27s wait, on a container 16
|
||||
# seconds old, for a throwaway "The Great Gatsby" query nobody asked for.
|
||||
_user_search_seen = threading.Event()
|
||||
|
||||
|
||||
def note_user_search() -> None:
|
||||
"""Record that a real search has run, so a pending warm-up stands down."""
|
||||
_user_search_seen.set()
|
||||
|
||||
|
||||
def _as_bool(value: object, *, default: bool) -> bool:
|
||||
"""Coerce a config value that may arrive as a string, bool or None."""
|
||||
@@ -85,6 +98,12 @@ def run_warmup() -> bool:
|
||||
"""
|
||||
from shelfmark.core.mirrors import has_aa_mirror_configuration
|
||||
|
||||
# Checked here rather than only at schedule time: the delay is what this races with,
|
||||
# so the user's first search usually lands *during* the wait, not before it.
|
||||
if _user_search_seen.is_set():
|
||||
logger.info("Search warm-up skipped: a real search got there first")
|
||||
return False
|
||||
|
||||
if not has_aa_mirror_configuration():
|
||||
logger.debug("Search warm-up skipped: no Anna's Archive mirrors configured")
|
||||
return False
|
||||
@@ -95,7 +114,7 @@ def run_warmup() -> bool:
|
||||
from shelfmark.core.models import SearchFilters
|
||||
from shelfmark.release_sources.direct_download import search_books
|
||||
|
||||
results = search_books(query, SearchFilters())
|
||||
results, _ = search_books(query, SearchFilters())
|
||||
except Exception:
|
||||
# Broad by design: a warm-up must never take the app down, and the source
|
||||
# raises everything from network errors to parse failures.
|
||||
|
||||
+175
-66
@@ -14,7 +14,7 @@ from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast
|
||||
|
||||
from flask import Flask, jsonify, request, send_file, send_from_directory, session
|
||||
from flask import Flask, g, jsonify, request, send_file, send_from_directory, session
|
||||
from flask_cors import CORS
|
||||
from flask_socketio import SocketIO, emit
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
@@ -42,6 +42,8 @@ from shelfmark.config.settings import (
|
||||
_SUPPORTED_BOOK_LANGUAGE,
|
||||
migrate_audiobook_format_settings,
|
||||
)
|
||||
from shelfmark.core import api_key as api_key_module # module access lets tests monkeypatch the key
|
||||
from shelfmark.core import search_deadline
|
||||
from shelfmark.core.activity_view_state_service import ActivityViewStateService
|
||||
from shelfmark.core.auth_modes import (
|
||||
get_auth_check_admin_status,
|
||||
@@ -62,6 +64,7 @@ from shelfmark.core.notifications import (
|
||||
notify_user,
|
||||
)
|
||||
from shelfmark.core.prefix_middleware import PrefixMiddleware
|
||||
from shelfmark.core.release_inspect_routes import register_release_inspect_routes
|
||||
from shelfmark.core.request_helpers import (
|
||||
coerce_bool,
|
||||
emit_ws_event,
|
||||
@@ -396,7 +399,7 @@ def _resolve_policy_mode_for_current_user(*, source: Any, content_type: Any) ->
|
||||
auth_mode = get_auth_mode()
|
||||
if auth_mode == "none":
|
||||
return None
|
||||
if session.get("is_admin", True):
|
||||
if session.get("is_admin", False):
|
||||
return None
|
||||
if user_db is None:
|
||||
return None
|
||||
@@ -548,7 +551,7 @@ if _is_debug_enabled():
|
||||
r"/*": {
|
||||
"origins": ["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
"supports_credentials": True,
|
||||
"allow_headers": ["Content-Type", "Authorization"],
|
||||
"allow_headers": ["Content-Type", "Authorization", "X-Api-Key"],
|
||||
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
}
|
||||
},
|
||||
@@ -656,6 +659,73 @@ logger.info(
|
||||
logger.info("Session cookie name: %s", SESSION_COOKIE_NAME)
|
||||
|
||||
|
||||
def _proxy_default_is_admin(db: UserDB) -> bool:
|
||||
"""Role for a proxy user seen for the first time when no admin group is configured.
|
||||
|
||||
The first account ever provisioned is an admin so the instance is never left without
|
||||
one; later accounts follow PROXY_AUTH_DEFAULT_ROLE (default: user).
|
||||
"""
|
||||
if not db.has_admin():
|
||||
return True
|
||||
role = str(app_config.get("PROXY_AUTH_DEFAULT_ROLE", "user") or "user").strip().lower()
|
||||
return role == "admin"
|
||||
|
||||
|
||||
_API_KEY_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||
_API_KEY_EXEMPT_PATHS = frozenset({"/api/health"})
|
||||
|
||||
|
||||
@app.before_request
|
||||
def api_key_auth_middleware() -> Response | tuple[Response, int] | None:
|
||||
"""Authenticate requests that present the configured SHELFMARK_API_KEY.
|
||||
|
||||
Both Authorization: Bearer and X-Api-Key are checked, and either
|
||||
matching authenticates the request as an admin for this request only:
|
||||
any session cookie is ignored and none is written back. Checking both
|
||||
means a reverse proxy's own Authorization header never shadows an
|
||||
operator-supplied X-Api-Key. No matching candidate is ignored so bearer
|
||||
tokens forwarded by reverse proxies keep working; the request then
|
||||
continues on the normal session path.
|
||||
"""
|
||||
if not request.path.startswith("/api/"):
|
||||
return None
|
||||
if request.path in _API_KEY_EXEMPT_PATHS or request.path.startswith(_API_KEY_EXEMPT_PREFIXES):
|
||||
return None
|
||||
if not api_key_module.SHELFMARK_API_KEY:
|
||||
return None
|
||||
|
||||
candidates = api_key_module.extract_api_key_candidates(
|
||||
request.headers.get("Authorization"), request.headers.get("X-Api-Key")
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
if not any(api_key_module.matches_api_key(candidate) for candidate in candidates):
|
||||
return None
|
||||
if get_auth_mode() == "none":
|
||||
return None
|
||||
|
||||
# Mark the request as keyed before the lookup so the after-request cookie
|
||||
# reset also covers the error path below.
|
||||
g.api_key_auth = True
|
||||
|
||||
try:
|
||||
admin = user_db.get_first_admin() if user_db is not None else None
|
||||
except _OPERATIONAL_ERRORS:
|
||||
logger.exception("API key auth middleware error")
|
||||
return jsonify({"error": "Authentication error"}), 500
|
||||
|
||||
session.clear()
|
||||
session["user_id"] = admin["username"] if admin else "api"
|
||||
session["is_admin"] = True
|
||||
if admin:
|
||||
session["db_user_id"] = admin["id"]
|
||||
session.permanent = False
|
||||
# Identity is per request; never persist it as a cookie.
|
||||
session.modified = False
|
||||
return None
|
||||
|
||||
|
||||
@app.before_request
|
||||
def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
|
||||
"""Middleware to handle proxy authentication.
|
||||
@@ -669,6 +739,10 @@ def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
|
||||
if auth_mode != "proxy":
|
||||
return None
|
||||
|
||||
# A request already authenticated by SHELFMARK_API_KEY needs no proxy headers.
|
||||
if g.get("api_key_auth"):
|
||||
return None
|
||||
|
||||
# Skip for public endpoints that don't need auth
|
||||
if request.path == "/api/health":
|
||||
return None
|
||||
@@ -708,8 +782,9 @@ def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
|
||||
|
||||
# Resolve admin role for proxy sessions.
|
||||
# If an admin group is configured, derive from groups header.
|
||||
# Otherwise preserve existing DB role for known users and default
|
||||
# first-time users to admin (to avoid lockouts).
|
||||
# Otherwise preserve the existing DB role for known users; a first-time user
|
||||
# is an admin only while the instance has none (so nobody is locked out),
|
||||
# after that PROXY_AUTH_DEFAULT_ROLE decides (default: user).
|
||||
admin_group_header = (
|
||||
normalize_optional_text(
|
||||
app_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups")
|
||||
@@ -732,6 +807,8 @@ def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
|
||||
existing_db_user = user_db.get_user(username=username)
|
||||
if existing_db_user:
|
||||
is_admin = existing_db_user.get("role") == "admin"
|
||||
else:
|
||||
is_admin = _proxy_default_is_admin(user_db)
|
||||
|
||||
# Create or update session
|
||||
previous_username = session.get("user_id")
|
||||
@@ -798,6 +875,17 @@ def set_security_headers(response: Response) -> Response:
|
||||
return response
|
||||
|
||||
|
||||
@app.after_request
|
||||
def strip_cookie_for_api_key_requests(response: Response) -> Response:
|
||||
"""Keyed requests never mint or refresh a session cookie, even if a handler dirties the session."""
|
||||
if g.get("api_key_auth"):
|
||||
# Setting `permanent` mutates the session dict (re-marking it
|
||||
# modified), so it must be reset before `modified`, not after.
|
||||
session.permanent = False
|
||||
session.modified = False
|
||||
return response
|
||||
|
||||
|
||||
def login_required(
|
||||
f: Callable[..., Response | tuple[Response, int]],
|
||||
) -> Callable[..., Response | tuple[Response, int]]:
|
||||
@@ -1024,6 +1112,9 @@ def _serialize_release(release: Release) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
register_release_inspect_routes(app, login_required)
|
||||
|
||||
|
||||
@app.route("/api/releases/download", methods=["POST"])
|
||||
@login_required
|
||||
def api_download_release() -> Response | tuple[Response, int]:
|
||||
@@ -1068,6 +1159,13 @@ def api_download_release() -> Response | tuple[Response, int]:
|
||||
release_payload = dict(data)
|
||||
release_payload["content_type"] = resolved_content_type
|
||||
|
||||
logger.info(
|
||||
"Download request received. keys=%s downloads=%s extra.downloads=%s",
|
||||
list(data.keys()),
|
||||
data.get("downloads"),
|
||||
data.get("extra", {}).get("downloads") if isinstance(data.get("extra"), dict) else None,
|
||||
)
|
||||
|
||||
priority = data.get("priority", 0)
|
||||
# Per-user download overrides
|
||||
db_user_id = session.get("db_user_id")
|
||||
@@ -1150,7 +1248,7 @@ def api_config() -> Response | tuple[Response, int]:
|
||||
"build_version": BUILD_VERSION,
|
||||
"release_version": RELEASE_VERSION,
|
||||
"book_languages": _SUPPORTED_BOOK_LANGUAGE,
|
||||
"default_language": app_config.BOOK_LANGUAGE,
|
||||
"default_language": app_config.get("BOOK_LANGUAGE", ["en"], user_id=db_user_id),
|
||||
"supported_formats": app_config.SUPPORTED_FORMATS,
|
||||
"supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS,
|
||||
"search_mode": search_mode,
|
||||
@@ -1175,12 +1273,18 @@ def api_config() -> Response | tuple[Response, int]:
|
||||
[],
|
||||
user_id=db_user_id,
|
||||
),
|
||||
# The client must not give up before this budget does. `/api/releases`
|
||||
# answers a spent budget with a message naming the real cause (a protection
|
||||
# challenge nobody could solve); a browser that aborted first replaces it
|
||||
# with a generic network/proxy error and RELEASE_SEARCH_TIMEOUT becomes a
|
||||
# setting the user can raise with no visible effect. See issue #1285.
|
||||
"release_search_timeout": search_deadline.budget_seconds(),
|
||||
"settings_enabled": _is_config_dir_writable(),
|
||||
"onboarding_complete": _get_onboarding_complete(),
|
||||
# Default sort orders
|
||||
"default_sort": app_config.get(
|
||||
"AA_DEFAULT_SORT", "relevance"
|
||||
), # For direct mode (Anna's Archive)
|
||||
"AA_DEFAULT_SORT", ""
|
||||
), # For direct mode (Anna's Archive) — empty means use local downloads sort
|
||||
"metadata_default_sort": get_provider_default_sort(
|
||||
metadata_ui_provider
|
||||
), # For universal mode
|
||||
@@ -1343,6 +1447,7 @@ def _record_download_queued(task_id: str, task: Any) -> None:
|
||||
size=normalize_optional_text(getattr(task, "size", None)),
|
||||
preview=normalize_optional_text(getattr(task, "preview", None)),
|
||||
content_type=normalize_optional_text(getattr(task, "content_type", None)),
|
||||
downloads=getattr(task, "downloads", None),
|
||||
origin=origin,
|
||||
retry_payload=backend.serialize_task_for_retry(task),
|
||||
)
|
||||
@@ -1615,6 +1720,17 @@ def api_local_download() -> Response | tuple[Response, int]:
|
||||
|
||||
# Book data not found or not available
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
is_admin, db_user_id, can_access_status = _resolve_status_scope()
|
||||
if not is_admin:
|
||||
actor_username = session.get("user_id")
|
||||
if not can_access_status or not _task_owned_by_actor(
|
||||
book_info,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username if isinstance(actor_username, str) else None,
|
||||
):
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
file_name = book_info.get_filename() if book_info is not None else Path(book_id).name
|
||||
# Prepare the file for sending to the client
|
||||
data = io.BytesIO(file_data)
|
||||
@@ -2810,7 +2926,7 @@ def api_releases() -> Response | tuple[Response, int]:
|
||||
try:
|
||||
from dataclasses import asdict
|
||||
|
||||
from shelfmark.core.search_plan import build_release_search_plan
|
||||
from shelfmark.core.release_search import search_source_releases
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
get_provider,
|
||||
@@ -2829,53 +2945,17 @@ def api_releases() -> Response | tuple[Response, int]:
|
||||
source_name: str, search_book: BookMetadata
|
||||
) -> tuple[Any | None, list[Any], str | None]:
|
||||
"""Search one source and return any error message instead of raising."""
|
||||
try:
|
||||
source = get_source(source_name)
|
||||
|
||||
plan = build_release_search_plan(
|
||||
search_book,
|
||||
languages=browse_filters.lang
|
||||
if source_query_filters is not None
|
||||
else languages,
|
||||
manual_query=query_text if source_query_filters is not None else manual_query,
|
||||
indexers=indexers,
|
||||
source_filters=source_query_filters,
|
||||
)
|
||||
|
||||
if plan.source_filters is not None:
|
||||
planned_query = plan.manual_query or plan.primary_query
|
||||
planned_query_type = "query"
|
||||
elif plan.manual_query:
|
||||
planned_query = plan.manual_query
|
||||
planned_query_type = "manual"
|
||||
elif not expand_search and plan.isbn_candidates:
|
||||
planned_query = plan.isbn_candidates[0]
|
||||
planned_query_type = "isbn"
|
||||
else:
|
||||
planned_query = plan.primary_query
|
||||
planned_query_type = "title_author"
|
||||
|
||||
logger.debug(
|
||||
"Searching %s: %s='%s' (title='%s', authors=%s, expand=%s, content_type=%s)",
|
||||
source_name,
|
||||
planned_query_type,
|
||||
planned_query,
|
||||
search_book.title,
|
||||
search_book.authors,
|
||||
expand_search,
|
||||
content_type,
|
||||
)
|
||||
|
||||
releases = source.search(
|
||||
search_book, plan, expand_search=expand_search, content_type=content_type
|
||||
)
|
||||
except ValueError:
|
||||
return None, [], f"Unknown source: {source_name}"
|
||||
except (SourceUnavailableError, *_OPERATIONAL_ERRORS) as e:
|
||||
logger.warning("Release search failed for source %s: %s", source_name, e)
|
||||
return None, [], f"{source_name}: {e!s}"
|
||||
else:
|
||||
return source, releases, None
|
||||
return search_source_releases(
|
||||
source_name,
|
||||
search_book,
|
||||
languages=(browse_filters.lang if source_query_filters is not None else languages),
|
||||
manual_query=(query_text if source_query_filters is not None else manual_query),
|
||||
indexers=indexers,
|
||||
expand_search=expand_search,
|
||||
content_type=content_type,
|
||||
source_filters=source_query_filters,
|
||||
user_id=db_user_id,
|
||||
)
|
||||
|
||||
provider = request.args.get("provider", "").strip()
|
||||
book_id = request.args.get("book_id", "").strip()
|
||||
@@ -2892,6 +2972,8 @@ def api_releases() -> Response | tuple[Response, int]:
|
||||
if languages_param
|
||||
else None
|
||||
)
|
||||
# Without an explicit filter the plan falls back to this user's default languages.
|
||||
db_user_id = get_session_db_user_id(session)
|
||||
# Content type for audiobook vs ebook search
|
||||
content_type = request.args.get("content_type", "ebook").strip()
|
||||
|
||||
@@ -2939,6 +3021,10 @@ def api_releases() -> Response | tuple[Response, int]:
|
||||
elif provider == "manual":
|
||||
resolved_title = title_param or manual_query or "Manual Search"
|
||||
resolved_author = author_param or ""
|
||||
# The release modal sends `authors.join(', ')` as `author`, so the commas here
|
||||
# are joins between contributors, not part of one name. This split is the only
|
||||
# place that knows that, so `search_author` comes from it rather than from the
|
||||
# joined text - see issue #1252.
|
||||
authors = [a.strip() for a in resolved_author.split(",") if a.strip()]
|
||||
|
||||
book = BookMetadata(
|
||||
@@ -2947,7 +3033,7 @@ def api_releases() -> Response | tuple[Response, int]:
|
||||
provider_display_name="Manual Search",
|
||||
title=resolved_title,
|
||||
search_title=resolved_title,
|
||||
search_author=resolved_author or None,
|
||||
search_author=authors[0] if authors else None,
|
||||
authors=authors,
|
||||
)
|
||||
else:
|
||||
@@ -2980,18 +3066,36 @@ def api_releases() -> Response | tuple[Response, int]:
|
||||
# Search only enabled sources
|
||||
sources_to_search = [src["name"] for src in list_available_sources() if src["enabled"]]
|
||||
|
||||
# Search each source for releases
|
||||
# Search each source for releases.
|
||||
#
|
||||
# Under a wall-clock budget: this endpoint is synchronous, and the bypass path it
|
||||
# can reach used to be allowed minutes per URL with nothing bounding the request
|
||||
# as a whole. A search that ran into an unsolvable protection challenge therefore
|
||||
# outlived every reverse proxy in front of it and surfaced to the user as
|
||||
# "Server unavailable (504)" - a gateway timeout that blames their proxy for a
|
||||
# challenge failure. The budget is shared across sources, so a stuck first source
|
||||
# cannot spend the whole request on its own. See issue #1276.
|
||||
all_releases = []
|
||||
errors = []
|
||||
source_instances = {} # Keep source instances for column config
|
||||
|
||||
for source_name in sources_to_search:
|
||||
source, releases, error = _search_source_releases(source_name, book)
|
||||
if source is not None:
|
||||
source_instances[source_name] = source
|
||||
all_releases.extend(releases)
|
||||
if error is not None:
|
||||
errors.append(error)
|
||||
# A real search is under way, so a warm-up still sitting on its start-up delay
|
||||
# should stand down rather than queue its throwaway solve in front of this one.
|
||||
warmup.note_user_search()
|
||||
|
||||
with search_deadline.search_deadline():
|
||||
for source_name in sources_to_search:
|
||||
if search_deadline.expired():
|
||||
logger.warning("Release search budget spent; %s not searched", source_name)
|
||||
errors.append(f"{source_name}: {search_deadline.deadline_message()}")
|
||||
continue
|
||||
|
||||
source, releases, error = _search_source_releases(source_name, book)
|
||||
if source is not None:
|
||||
source_instances[source_name] = source
|
||||
all_releases.extend(releases)
|
||||
if error is not None:
|
||||
errors.append(error)
|
||||
|
||||
# Convert Release objects to dicts
|
||||
releases_data = [_serialize_release(release) for release in all_releases]
|
||||
@@ -3016,8 +3120,13 @@ def api_releases() -> Response | tuple[Response, int]:
|
||||
|
||||
search_info = {}
|
||||
for source_name, source_instance in source_instances.items():
|
||||
info: dict[str, str | int | None] = {}
|
||||
if hasattr(source_instance, "last_search_type") and source_instance.last_search_type:
|
||||
search_info[source_name] = {"search_type": source_instance.last_search_type}
|
||||
info["search_type"] = source_instance.last_search_type
|
||||
if hasattr(source_instance, "total_results"):
|
||||
info["total_results"] = source_instance.total_results
|
||||
if info:
|
||||
search_info[source_name] = info
|
||||
|
||||
response = {
|
||||
"releases": releases_data,
|
||||
|
||||
@@ -158,10 +158,11 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
query = "+".join(query_parts)
|
||||
|
||||
# Build request params
|
||||
page_size = min(options.limit, 40) # Google max is 40
|
||||
params: dict[str, Any] = {
|
||||
"q": query,
|
||||
"maxResults": min(options.limit, 40), # Google max is 40
|
||||
"startIndex": (options.page - 1) * options.limit,
|
||||
"maxResults": page_size,
|
||||
"startIndex": (options.page - 1) * page_size,
|
||||
"printType": "books", # Exclude magazines
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ HARDCOVER_PAGE_SIZE = 25 # Hardcover API returns max 25 results per page
|
||||
HARDCOVER_MIN_AUTHOR_PARTS = 2
|
||||
HARDCOVER_MIN_TYPEAHEAD_QUERY_LENGTH = 2
|
||||
HARDCOVER_MAX_SERIES_OPTIONS = 7
|
||||
# Hardcover hands out short opaque tokens now ("hc_pat_...") instead of the ~500 char
|
||||
# JWTs it used to, so the length floor only applies to keys without that prefix.
|
||||
HARDCOVER_API_KEY_PREFIX = "hc_pat_"
|
||||
HARDCOVER_BEARER_PREFIX_PATTERN = re.compile(r"^bearer\s+", re.IGNORECASE)
|
||||
HARDCOVER_API_KEY_MIN_LENGTH = 100
|
||||
HARDCOVER_LIST_URL_PATTERN = re.compile(
|
||||
r"^/(?:@([\w.-]+)/)?lists?/([\w-]+)/?$",
|
||||
@@ -670,7 +674,7 @@ def _normalize_series_position(value: Any) -> float | None:
|
||||
def _normalize_hardcover_api_key(value: object) -> str:
|
||||
"""Normalize Hardcover API keys, stripping copied auth-header prefixes."""
|
||||
normalized_value = normalize_optional_text(value) or ""
|
||||
return normalized_value.removeprefix("Bearer ").strip()
|
||||
return HARDCOVER_BEARER_PREFIX_PATTERN.sub("", normalized_value.strip()).strip()
|
||||
|
||||
|
||||
def _normalize_search_text(value: str) -> str:
|
||||
@@ -3019,12 +3023,13 @@ def _test_hardcover_connection(current_values: dict[str, Any] | None = None) ->
|
||||
_save_connected_user(None, None)
|
||||
return {"success": False, "message": "API key is required"}
|
||||
|
||||
if key_len < HARDCOVER_API_KEY_MIN_LENGTH:
|
||||
is_prefixed_key = api_key.startswith(HARDCOVER_API_KEY_PREFIX)
|
||||
if not is_prefixed_key and key_len < HARDCOVER_API_KEY_MIN_LENGTH:
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
f"API key seems too short ({key_len} chars). "
|
||||
f"Expected {HARDCOVER_API_KEY_MIN_LENGTH}+ chars."
|
||||
f"API key seems too short ({key_len} chars). Expected a key starting "
|
||||
f"with {HARDCOVER_API_KEY_PREFIX} or {HARDCOVER_API_KEY_MIN_LENGTH}+ chars."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -3131,7 +3136,7 @@ def hardcover_settings() -> list[SettingsField]:
|
||||
PasswordField(
|
||||
key="HARDCOVER_API_KEY",
|
||||
label="API Key",
|
||||
description="Get your API key from hardcover.app/account/api",
|
||||
description="Get your API key from hardcover.app/account/api (starts with hc_pat_)",
|
||||
required=True,
|
||||
),
|
||||
ActionButton(
|
||||
|
||||
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||
from shelfmark.download.postprocess.packs import PackFile
|
||||
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
@@ -45,6 +46,7 @@ class BrowseRecord:
|
||||
content: str | None = None
|
||||
format: str | None = None
|
||||
size: str | None = None
|
||||
downloads: int | None = None
|
||||
info: dict[str, list[str]] | None = None
|
||||
description: str | None = None
|
||||
download_urls: list[str] = field(default_factory=list)
|
||||
@@ -367,12 +369,21 @@ class ReleaseSource(ABC):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HandoffResult:
|
||||
"""An external handoff that completed without a Shelfmark book payload."""
|
||||
|
||||
path: str
|
||||
message: str
|
||||
|
||||
|
||||
class DownloadHandler(ABC):
|
||||
"""Interface for executing downloads.
|
||||
|
||||
A handler may either:
|
||||
- download directly into ``TMP_DIR`` (managed by Shelfmark), or
|
||||
- return a path owned by an external client (e.g. torrent/usenet).
|
||||
- finish an external handoff without producing a book payload.
|
||||
|
||||
The orchestrator is responsible for post-processing (archive extraction, output mode
|
||||
handling) and transferring files into their final destination.
|
||||
@@ -385,8 +396,8 @@ class DownloadHandler(ABC):
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Execute download and return a path to the downloaded payload."""
|
||||
) -> str | HandoffResult | None:
|
||||
"""Execute download and return a payload path or completed external handoff."""
|
||||
|
||||
def post_process_cleanup(self, task: DownloadTask, *, success: bool) -> None:
|
||||
"""Run optional cleanup after orchestrator post-processing.
|
||||
@@ -400,6 +411,14 @@ class DownloadHandler(ABC):
|
||||
"""Return private queue-time fields needed for restart-safe retry."""
|
||||
return {}
|
||||
|
||||
def list_files(self, release_data: dict[str, Any]) -> list[PackFile] | None:
|
||||
"""Return the release's file list without downloading it.
|
||||
|
||||
Lets the UI review a multi-book pack before queueing. Return None when the
|
||||
source cannot know the files ahead of time (magnet links, usenet, ...).
|
||||
"""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
"""Cancel an in-progress download."""
|
||||
@@ -413,6 +432,7 @@ _BUILTIN_SOURCE_MODULES = (
|
||||
"shelfmark.release_sources.audiobookbay",
|
||||
"shelfmark.release_sources.direct_download",
|
||||
"shelfmark.release_sources.irc",
|
||||
"shelfmark.release_sources.libgen",
|
||||
"shelfmark.release_sources.newznab",
|
||||
"shelfmark.release_sources.prowlarr",
|
||||
)
|
||||
@@ -510,6 +530,10 @@ def browse_record_to_book_metadata(
|
||||
"""Convert a source-native browse record into generic book metadata."""
|
||||
resolved_title = title_override or str(record.title or "").strip() or "Unknown title"
|
||||
resolved_author = author_override or str(record.author or "").strip()
|
||||
# `author_override` is the frontend's display string, `authors.join(', ')` - every
|
||||
# contributor, translators included. The split below is the only place that knows the
|
||||
# commas were joins rather than part of a name, so `search_author` is taken from it
|
||||
# rather than from the joined text. See issue #1252.
|
||||
authors = [part.strip() for part in resolved_author.split(",") if part.strip()]
|
||||
publish_year = None
|
||||
|
||||
@@ -526,7 +550,7 @@ def browse_record_to_book_metadata(
|
||||
provider_display_name=get_source_display_name(record.source),
|
||||
title=resolved_title,
|
||||
search_title=resolved_title,
|
||||
search_author=resolved_author or None,
|
||||
search_author=authors[0] if authors else None,
|
||||
authors=authors,
|
||||
cover_url=record.preview,
|
||||
description=record.description,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""AudiobookBay download handler - resolves magnet links and uses shared client lifecycle."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from shelfmark.core.config import config
|
||||
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.download.postprocess.packs import PackFile
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
DEFAULT_ABB_HOSTNAME = "audiobookbay.lu"
|
||||
@@ -68,6 +69,19 @@ class AudiobookBayHandler(ExternalClientHandler):
|
||||
return task_id
|
||||
return None
|
||||
|
||||
def list_files(self, release_data: dict[str, Any]) -> list[PackFile] | None:
|
||||
"""Read the torrent's file list off the detail page, without downloading."""
|
||||
raw_url = release_data.get("download_url") or release_data.get("source_url")
|
||||
detail_url = raw_url.strip() if isinstance(raw_url, str) else ""
|
||||
hostname = _resolve_allowed_detail_hostname()
|
||||
if not detail_url or not _detail_url_matches_host(detail_url, hostname):
|
||||
logger.debug("Cannot list files for AudiobookBay release without a valid detail URL")
|
||||
return None
|
||||
detail_html = scraper.fetch_detail_html(detail_url, hostname)
|
||||
if not detail_html:
|
||||
return None
|
||||
return scraper.extract_file_list(detail_html)
|
||||
|
||||
def _get_client(self, protocol: str) -> DownloadClient | None:
|
||||
"""Compatibility shim so module-level patching still works in tests."""
|
||||
return get_client(protocol)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import re
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
from threading import Lock
|
||||
from urllib.parse import quote, quote_plus
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -10,6 +11,8 @@ from bs4 import BeautifulSoup
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download import http as downloader
|
||||
from shelfmark.download.postprocess.packs import PackFile
|
||||
from shelfmark.release_sources.audiobookbay.utils import normalize_search_punctuation
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -31,6 +34,13 @@ FIRST_PAGE_SESSION_REFRESH_ATTEMPTS = 2
|
||||
# Legacy search parameter used by older ABB flows
|
||||
LEGACY_CATEGORY_QUERY = "undefined%2Cundefined"
|
||||
|
||||
# Detail pages are fetched once and shared by inspection (file list) and download
|
||||
# (magnet link) so a "review then download" round trip costs ABB a single request.
|
||||
DETAIL_PAGE_CACHE_TTL_SECONDS = 120.0
|
||||
DETAIL_PAGE_CACHE_MAX_ENTRIES = 8
|
||||
_detail_page_cache: dict[str, tuple[float, str]] = {}
|
||||
_detail_page_cache_lock = Lock()
|
||||
|
||||
# Precompiled patterns used while parsing result cards
|
||||
LANGUAGE_PATTERN = re.compile(r"Language:\s*([A-Za-z]+)")
|
||||
POSTED_PATTERN = re.compile(r"Posted:\s*(\d+\s+[A-Za-z]+\s+\d{4})")
|
||||
@@ -38,6 +48,11 @@ FORMAT_PATTERN = re.compile(r"Format:\s*([A-Za-z0-9]+)")
|
||||
BITRATE_PATTERN = re.compile(r"Bitrate:\s*([\d]+\s*[A-Za-z/]+)")
|
||||
SIZE_PATTERN = re.compile(r"File Size:\s*([\d.]+)\s*([A-Za-z]+)")
|
||||
INFO_HASH_LABEL_PATTERN = re.compile(r"Info Hash", re.IGNORECASE)
|
||||
FILE_ROW_SIZE_PATTERN = re.compile(
|
||||
r"^(?P<name>.+?)\s+(?P<size>\d+(?:\.\d+)?)\s*(?P<unit>Bytes?|KBs?|MBs?|GBs?|TBs?)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FILE_SIZE_MULTIPLIERS = {"b": 1, "k": 1024, "m": 1024**2, "g": 1024**3, "t": 1024**4}
|
||||
|
||||
|
||||
def _coerce_non_negative_float(value: object, default: float) -> float:
|
||||
@@ -98,8 +113,10 @@ def _encode_search_query(query: str, *, exact_phrase: bool) -> str:
|
||||
and not (search_query.startswith('"') and search_query.endswith('"'))
|
||||
):
|
||||
search_query = f'"{search_query}"'
|
||||
# Keep ABB-friendly encoding style (spaces as '+') while percent-encoding quotes.
|
||||
return search_query.replace('"', "%22").replace(" ", "+")
|
||||
# Keep ABB's space-as-'+' style, but percent-encode everything else: a bare
|
||||
# '&' would otherwise start a new query parameter, '%' would open an invalid
|
||||
# escape, and a literal '+' would arrive as a space.
|
||||
return quote_plus(search_query)
|
||||
|
||||
|
||||
def _normalize_result_url(url: str, hostname: str) -> str:
|
||||
@@ -153,6 +170,9 @@ def search_audiobookbay(
|
||||
|
||||
"""
|
||||
results = []
|
||||
# ABB matches the stored, untexturized title, so a curly apostrophe reaching
|
||||
# the search returns nothing at all rather than merely ranking worse.
|
||||
query = normalize_search_punctuation(query)
|
||||
rate_limit_delay = _coerce_non_negative_float(config.get("ABB_RATE_LIMIT_DELAY", 1.0), 1.0)
|
||||
session = requests.Session()
|
||||
|
||||
@@ -342,6 +362,98 @@ def search_audiobookbay(
|
||||
return results
|
||||
|
||||
|
||||
def _get_cached_detail_page(details_url: str) -> str | None:
|
||||
with _detail_page_cache_lock:
|
||||
entry = _detail_page_cache.get(details_url)
|
||||
if entry is None:
|
||||
return None
|
||||
fetched_at, html = entry
|
||||
if time.monotonic() - fetched_at > DETAIL_PAGE_CACHE_TTL_SECONDS:
|
||||
del _detail_page_cache[details_url]
|
||||
return None
|
||||
return html
|
||||
|
||||
|
||||
def _store_cached_detail_page(details_url: str, html: str) -> None:
|
||||
with _detail_page_cache_lock:
|
||||
_detail_page_cache[details_url] = (time.monotonic(), html)
|
||||
while len(_detail_page_cache) > DETAIL_PAGE_CACHE_MAX_ENTRIES:
|
||||
oldest = min(_detail_page_cache, key=lambda key: _detail_page_cache[key][0])
|
||||
del _detail_page_cache[oldest]
|
||||
|
||||
|
||||
def clear_detail_page_cache() -> None:
|
||||
"""Drop cached detail pages (used by tests)."""
|
||||
with _detail_page_cache_lock:
|
||||
_detail_page_cache.clear()
|
||||
|
||||
|
||||
def _fetch_detail_page_once(details_url: str, hostname: str) -> str:
|
||||
session = requests.Session()
|
||||
_bootstrap_abb_session(hostname, session, DETAIL_PAGE_RETRY_ATTEMPTS)
|
||||
return _coerce_markup_to_html(
|
||||
downloader.html_get_page(
|
||||
details_url,
|
||||
retry=DETAIL_PAGE_RETRY_ATTEMPTS,
|
||||
use_bypasser=False,
|
||||
allow_bypasser_fallback=False,
|
||||
success_delay=0,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def fetch_detail_html(details_url: str, hostname: str = "audiobookbay.lu") -> str:
|
||||
"""Fetch a detail page (one retry with a fresh session), cached briefly per URL."""
|
||||
cached = _get_cached_detail_page(details_url)
|
||||
if cached is not None:
|
||||
logger.debug("Reusing recently fetched detail page: %s", details_url)
|
||||
return cached
|
||||
detail_html = _fetch_detail_page_once(details_url, hostname)
|
||||
if not detail_html:
|
||||
detail_html = _fetch_detail_page_once(details_url, hostname)
|
||||
if detail_html:
|
||||
_store_cached_detail_page(details_url, detail_html)
|
||||
return detail_html
|
||||
|
||||
|
||||
def _parse_file_row(text: str) -> PackFile | None:
|
||||
match = FILE_ROW_SIZE_PATTERN.match(text.strip())
|
||||
if not match:
|
||||
return None
|
||||
multiplier = _FILE_SIZE_MULTIPLIERS[match.group("unit")[0].lower()]
|
||||
return PackFile(match.group("name"), int(float(match.group("size")) * multiplier))
|
||||
|
||||
|
||||
def extract_file_list(detail_html: str) -> list[PackFile] | None:
|
||||
"""Read the torrent file rows off a detail page.
|
||||
|
||||
ABB renders the torrent's file table as single-cell rows between the
|
||||
"This is a Multifile Torrent" marker (absent for single-file torrents) and the
|
||||
"Combined File Size" row. Returns None when the page has no such table.
|
||||
"""
|
||||
soup = BeautifulSoup(detail_html, "html.parser")
|
||||
rows: list[PackFile] = []
|
||||
for row in soup.find_all("tr"):
|
||||
cells = row.find_all("td")
|
||||
if not cells:
|
||||
continue
|
||||
label = cells[0].get_text(" ", strip=True)
|
||||
if label.lower().startswith("combined file size"):
|
||||
return rows or None
|
||||
if len(cells) != 1:
|
||||
rows = [] # a two-column metadata row means we're not in the file table yet
|
||||
continue
|
||||
text = cells[0].get_text(" ", strip=True)
|
||||
if "multifile torrent" in text.lower():
|
||||
rows = []
|
||||
continue
|
||||
parsed = _parse_file_row(text)
|
||||
if parsed is not None:
|
||||
rows.append(parsed)
|
||||
return None
|
||||
|
||||
|
||||
def extract_magnet_link(details_url: str, hostname: str = "audiobookbay.lu") -> str | None:
|
||||
"""Extract info hash and trackers from book detail page, then construct magnet link.
|
||||
|
||||
@@ -354,35 +466,7 @@ def extract_magnet_link(details_url: str, hostname: str = "audiobookbay.lu") ->
|
||||
|
||||
"""
|
||||
try:
|
||||
session = requests.Session()
|
||||
_bootstrap_abb_session(hostname, session, DETAIL_PAGE_RETRY_ATTEMPTS)
|
||||
|
||||
# Fetch detail page
|
||||
detail_html = _coerce_markup_to_html(
|
||||
downloader.html_get_page(
|
||||
details_url,
|
||||
retry=DETAIL_PAGE_RETRY_ATTEMPTS,
|
||||
use_bypasser=False,
|
||||
allow_bypasser_fallback=False,
|
||||
success_delay=0,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
|
||||
if not detail_html:
|
||||
session = requests.Session()
|
||||
_bootstrap_abb_session(hostname, session, DETAIL_PAGE_RETRY_ATTEMPTS)
|
||||
detail_html = _coerce_markup_to_html(
|
||||
downloader.html_get_page(
|
||||
details_url,
|
||||
retry=DETAIL_PAGE_RETRY_ATTEMPTS,
|
||||
use_bypasser=False,
|
||||
allow_bypasser_fallback=False,
|
||||
success_delay=0,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
|
||||
detail_html = fetch_detail_html(details_url, hostname)
|
||||
if not detail_html:
|
||||
logger.warning("Failed to fetch details page")
|
||||
return None
|
||||
|
||||
@@ -23,7 +23,11 @@ from shelfmark.release_sources import (
|
||||
register_source,
|
||||
)
|
||||
from shelfmark.release_sources.audiobookbay import scraper
|
||||
from shelfmark.release_sources.audiobookbay.utils import normalize_hostname, parse_size
|
||||
from shelfmark.release_sources.audiobookbay.utils import (
|
||||
normalize_hostname,
|
||||
normalize_search_punctuation,
|
||||
parse_size,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
MIN_RELEVANCE_QUERY_WORD_LENGTH = 2
|
||||
@@ -227,10 +231,12 @@ class AudiobookBaySource(ReleaseSource):
|
||||
deduped_queries[index + 1].lower(),
|
||||
)
|
||||
|
||||
# Extract query words for relevance checking
|
||||
# Extract query words for relevance checking. Both sides of the
|
||||
# comparison are punctuation-normalized: scraped titles carry the
|
||||
# typographic forms WordPress renders, queries carry the ASCII ones.
|
||||
query_words = {
|
||||
word.lower()
|
||||
for word in query_lower.split()
|
||||
for word in normalize_search_punctuation(query_lower).split()
|
||||
if len(word) > MIN_RELEVANCE_QUERY_WORD_LENGTH
|
||||
}
|
||||
|
||||
@@ -239,7 +245,7 @@ class AudiobookBaySource(ReleaseSource):
|
||||
try:
|
||||
raw_title = result["title"]
|
||||
title, author = _split_title_and_author(raw_title)
|
||||
title_for_filter = raw_title.lower()
|
||||
title_for_filter = normalize_search_punctuation(raw_title).lower()
|
||||
|
||||
# Basic relevance check: ensure title contains at least one query word
|
||||
# This filters out homepage "Latest" feed items that may leak through
|
||||
|
||||
@@ -2,6 +2,63 @@
|
||||
|
||||
import re
|
||||
|
||||
# WordPress texturizes punctuation on output only: a post stored as "The
|
||||
# Stranger's Wife" is rendered as "The Stranger’s Wife". ABB's search matches the
|
||||
# stored value, so a query carrying the typographic form matches nothing -- and
|
||||
# because ABB ANDs its search terms, one such term empties the entire result set.
|
||||
# Book metadata and phone keyboards both hand us the typographic forms, so map
|
||||
# them back before they reach a search or a title comparison.
|
||||
_ASCII_PUNCTUATION = str.maketrans(
|
||||
{
|
||||
# Single quotes
|
||||
"‘": "'", # left single quotation mark
|
||||
"’": "'", # right single quotation mark
|
||||
"‚": "'", # single low-9 quotation mark
|
||||
"‛": "'", # single high-reversed-9 quotation mark
|
||||
"′": "'", # prime
|
||||
"´": "'", # acute accent
|
||||
"`": "'", # grave accent
|
||||
# Double quotes
|
||||
"“": '"', # left double quotation mark
|
||||
"”": '"', # right double quotation mark
|
||||
"„": '"', # double low-9 quotation mark
|
||||
"‟": '"', # double high-reversed-9 quotation mark
|
||||
"″": '"', # double prime
|
||||
# Dashes
|
||||
"‐": "-", # hyphen
|
||||
"‑": "-", # non-breaking hyphen
|
||||
"‒": "-", # figure dash
|
||||
"–": "-", # en dash
|
||||
"—": "-", # em dash
|
||||
"―": "-", # horizontal bar
|
||||
"−": "-", # minus sign
|
||||
"﹘": "-", # small em dash
|
||||
"﹣": "-", # small hyphen-minus
|
||||
"-": "-", # fullwidth hyphen-minus
|
||||
# Ellipsis
|
||||
"…": "...", # horizontal ellipsis
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normalize_search_punctuation(text: str) -> str:
|
||||
"""Replace typographic punctuation with the ASCII forms ABB stores.
|
||||
|
||||
Each character is mapped individually rather than collapsing runs, so an
|
||||
ASCII "--" is left alone: only characters ABB cannot have stored are
|
||||
rewritten.
|
||||
|
||||
Args:
|
||||
text: A search query, or a scraped title being compared against one.
|
||||
|
||||
Returns:
|
||||
The text with curly quotes, dashes and ellipses mapped to ASCII.
|
||||
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
return text.translate(_ASCII_PUNCTUATION)
|
||||
|
||||
|
||||
def normalize_hostname(raw: str | None) -> str:
|
||||
"""Normalize a user-supplied hostname for URL construction.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Direct Download release source and public entry points.
|
||||
|
||||
Importing the source and handler classes registers them with Shelfmark.
|
||||
"""
|
||||
|
||||
from shelfmark.release_sources.direct_download.annas_archive import search_books
|
||||
from shelfmark.release_sources.direct_download.common import DirectDownloadUnavailableError
|
||||
from shelfmark.release_sources.direct_download.handler import DirectDownloadHandler
|
||||
from shelfmark.release_sources.direct_download.source import DirectDownloadSource
|
||||
|
||||
__all__ = [
|
||||
"DirectDownloadUnavailableError",
|
||||
"DirectDownloadHandler",
|
||||
"DirectDownloadSource",
|
||||
"SearchUnavailableError",
|
||||
"search_books",
|
||||
]
|
||||
|
||||
# Compatibility alias for integrations that imported the old module-level name.
|
||||
SearchUnavailableError = DirectDownloadUnavailableError
|
||||
+456
-531
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,272 @@
|
||||
"""Shared contracts and result normalization for Direct Download websites."""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.languages import language_alias_map
|
||||
from shelfmark.release_sources import BrowseRecord, SourceUnavailableError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
|
||||
from shelfmark.core.models import SearchFilters
|
||||
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
_LANGUAGE_ALIAS_TO_CODE: dict[str, str] | None = None
|
||||
_LANGUAGE_ALIAS_LOCK = threading.Lock()
|
||||
_SIZE_UNIT_PATTERN = re.compile(r"(kb|mb|gb|tb)", re.IGNORECASE)
|
||||
MIN_VALID_FILE_SIZE = 10 * 1024
|
||||
|
||||
|
||||
class DirectDownloadUnavailableError(SourceUnavailableError):
|
||||
"""Raised when the composite Direct Download source cannot be reached."""
|
||||
|
||||
|
||||
def coerce_str_list(value: object) -> list[str]:
|
||||
"""Return only string items from a config value."""
|
||||
if not isinstance(value, list | tuple):
|
||||
return []
|
||||
return [item for item in value if isinstance(item, str)]
|
||||
|
||||
|
||||
def get_supported_formats() -> list[str]:
|
||||
"""Return configured supported formats as a clean string list."""
|
||||
return coerce_str_list(config.SUPPORTED_FORMATS)
|
||||
|
||||
|
||||
def html_response_text(response: str | tuple[str, str]) -> str:
|
||||
"""Extract the HTML body from downloader responses."""
|
||||
if isinstance(response, tuple):
|
||||
return response[0]
|
||||
return response
|
||||
|
||||
|
||||
def attr_to_str(value: object) -> str | None:
|
||||
"""Convert a BeautifulSoup attribute value to a plain string."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, str):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def get_attr(tag: Tag, attr: str) -> str | None:
|
||||
"""Safely fetch a tag attribute as a string."""
|
||||
return attr_to_str(tag.get(attr))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedSearchResult:
|
||||
"""Provider-neutral fields extracted from one search-result element."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
formats: tuple[str, ...]
|
||||
record_id: str | None = None
|
||||
author: str | None = None
|
||||
publisher: str | None = None
|
||||
year: str | None = None
|
||||
language: str | None = None
|
||||
content: str | None = None
|
||||
size: str | None = None
|
||||
preview: str | None = None
|
||||
source_url: str | None = None
|
||||
download_path: str | None = None
|
||||
|
||||
|
||||
class DirectDownloadProvider(Protocol):
|
||||
"""Provider lifecycle used by the composite Direct Download source."""
|
||||
|
||||
id: str
|
||||
display_name: str
|
||||
|
||||
def is_enabled(self) -> bool: ...
|
||||
|
||||
def handles(self, url: str) -> bool: ...
|
||||
|
||||
def search(
|
||||
self,
|
||||
book: BookMetadata,
|
||||
plan: ReleaseSearchPlan,
|
||||
*,
|
||||
expand_search: bool = False,
|
||||
content_type: str = "ebook",
|
||||
) -> list[BrowseRecord]: ...
|
||||
|
||||
def download(
|
||||
self,
|
||||
book_info: BrowseRecord,
|
||||
book_path: Path,
|
||||
progress_callback: Callable[[float], None] | None,
|
||||
cancel_flag: Event | None,
|
||||
status_callback: Callable[[str, str | None], None] | None,
|
||||
) -> str | None: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RecordLookupProvider(Protocol):
|
||||
"""Optional capability for providers that can reopen source-native records."""
|
||||
|
||||
def get_record(
|
||||
self, record_id: str, *, fetch_download_count: bool = True
|
||||
) -> BrowseRecord | None: ...
|
||||
|
||||
|
||||
def normalize_language_token(value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
for dash in ("‑", "–", "—", "−"):
|
||||
normalized = normalized.replace(dash, "-")
|
||||
return normalized
|
||||
|
||||
|
||||
def language_alias_to_code() -> dict[str, str]:
|
||||
"""Alias to code map, delegating to the shared language data."""
|
||||
global _LANGUAGE_ALIAS_TO_CODE
|
||||
cached = _LANGUAGE_ALIAS_TO_CODE
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
with _LANGUAGE_ALIAS_LOCK:
|
||||
cached = _LANGUAGE_ALIAS_TO_CODE
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
_LANGUAGE_ALIAS_TO_CODE = language_alias_map()
|
||||
return _LANGUAGE_ALIAS_TO_CODE
|
||||
|
||||
|
||||
def normalize_requested_languages(languages: list[str] | None) -> set[str]:
|
||||
if not languages:
|
||||
return set()
|
||||
aliases = language_alias_to_code()
|
||||
normalized: set[str] = set()
|
||||
for value in languages:
|
||||
token = normalize_language_token(str(value))
|
||||
if not token or token == "all": # noqa: S105 - "all" is a language sentinel
|
||||
continue
|
||||
normalized.add(aliases.get(token, token))
|
||||
return normalized
|
||||
|
||||
|
||||
def book_matches_requested_languages(book_language: str | None, requested: set[str]) -> bool:
|
||||
"""Return True when a book's language matches the requested filter.
|
||||
|
||||
Books with unknown/missing language always pass — the server-side &lang= filter
|
||||
already narrowed the result set, so dropping unlabelled rows hides valid results.
|
||||
"""
|
||||
if not requested:
|
||||
return True
|
||||
if not book_language:
|
||||
return True
|
||||
aliases = language_alias_to_code()
|
||||
normalized_book = aliases.get(
|
||||
normalize_language_token(book_language),
|
||||
normalize_language_token(book_language),
|
||||
)
|
||||
return normalized_book in requested
|
||||
|
||||
|
||||
def normalize_size(size_str: str) -> str:
|
||||
"""Normalize size string by uppercasing units (e.g., '5.2 mb' -> '5.2 MB')."""
|
||||
return _SIZE_UNIT_PATTERN.sub(lambda m: m.group(1).upper(), size_str.strip())
|
||||
|
||||
|
||||
def parse_search_items(
|
||||
items: Iterable[Tag],
|
||||
filters: SearchFilters | None,
|
||||
*,
|
||||
provider_id: str,
|
||||
extract_item: Callable[[Tag], ParsedSearchResult | None],
|
||||
filter_languages: bool = True,
|
||||
) -> list[BrowseRecord]:
|
||||
"""Normalize provider-specific HTML elements into Direct Download records.
|
||||
|
||||
Providers only describe how fields are extracted from their DOM. Language and
|
||||
format filtering, stable IDs, and BrowseRecord construction stay shared.
|
||||
|
||||
Pass ``filter_languages=False`` when the site already filtered by language: its
|
||||
language cells are free text, and re-matching them locally drops rows it matched.
|
||||
"""
|
||||
requested_languages = (
|
||||
normalize_requested_languages(filters.lang) if filters and filter_languages else set()
|
||||
)
|
||||
requested_formats = (
|
||||
{value.casefold() for value in (filters.format or get_supported_formats())}
|
||||
if filters
|
||||
else set()
|
||||
)
|
||||
records: list[BrowseRecord] = []
|
||||
|
||||
for item in items:
|
||||
parsed = extract_item(item)
|
||||
if parsed is None:
|
||||
continue
|
||||
|
||||
normalized_language = normalize_language_token(parsed.language) if parsed.language else ""
|
||||
language = language_alias_to_code().get(normalized_language, normalized_language) or None
|
||||
if not book_matches_requested_languages(language, requested_languages):
|
||||
continue
|
||||
|
||||
formats = parsed.formats or ("",)
|
||||
for book_format in formats:
|
||||
normalized_format = book_format.casefold()
|
||||
if (
|
||||
normalized_format
|
||||
and requested_formats
|
||||
and normalized_format not in requested_formats
|
||||
):
|
||||
continue
|
||||
record_id = parsed.record_id
|
||||
if not record_id or len(formats) > 1:
|
||||
source_key = f"{parsed.key}#{normalized_format}"
|
||||
digest = hashlib.blake2b(source_key.encode(), digest_size=16).hexdigest()
|
||||
record_id = f"{provider_id}:{digest}"
|
||||
records.append(
|
||||
BrowseRecord(
|
||||
id=record_id,
|
||||
title=parsed.title,
|
||||
source="direct_download",
|
||||
author=parsed.author,
|
||||
publisher=parsed.publisher,
|
||||
year=parsed.year,
|
||||
language=language,
|
||||
format=normalized_format or None,
|
||||
size=parsed.size,
|
||||
preview=parsed.preview,
|
||||
content=parsed.content,
|
||||
source_url=parsed.source_url,
|
||||
download_path=parsed.download_path,
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def parse_search_page(
|
||||
page: str | BeautifulSoup | Tag,
|
||||
filters: SearchFilters | None,
|
||||
*,
|
||||
provider_id: str,
|
||||
item_selector: str,
|
||||
extract_item: Callable[[Tag], ParsedSearchResult | None],
|
||||
filter_languages: bool = True,
|
||||
) -> list[BrowseRecord]:
|
||||
"""Parse a result page using provider-specific selectors and extraction."""
|
||||
root = BeautifulSoup(page, "html.parser") if isinstance(page, str) else page
|
||||
return parse_search_items(
|
||||
(item for item in root.select(item_selector) if isinstance(item, Tag)),
|
||||
filters,
|
||||
provider_id=provider_id,
|
||||
extract_item=extract_item,
|
||||
filter_languages=filter_languages,
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Direct Download routing, staging, and cancellation."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.config.env import TMP_DIR
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask, build_filename
|
||||
from shelfmark.download import network
|
||||
from shelfmark.release_sources import (
|
||||
BrowseRecord,
|
||||
DownloadHandler,
|
||||
register_handler,
|
||||
)
|
||||
from shelfmark.release_sources.direct_download import registry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _download_book(
|
||||
book_info: BrowseRecord,
|
||||
book_path: Path,
|
||||
progress_callback: Callable[[float], None] | None = None,
|
||||
cancel_flag: Event | None = None,
|
||||
status_callback: Callable[[str, str | None], None] | None = None,
|
||||
) -> str | None:
|
||||
"""Route a website record or an Anna's Archive MD5 to its download flow."""
|
||||
provider = registry.provider_for_record(book_info)
|
||||
if provider is None:
|
||||
msg = f"No Direct Download provider owns record {book_info.id!r}"
|
||||
raise RuntimeError(msg)
|
||||
return provider.download(book_info, book_path, progress_callback, cancel_flag, status_callback)
|
||||
|
||||
|
||||
@register_handler("direct_download")
|
||||
class DirectDownloadHandler(DownloadHandler):
|
||||
"""Route and stage downloads from registered Direct Download providers."""
|
||||
|
||||
def download(
|
||||
self,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Execute a provider-owned direct HTTP download.
|
||||
|
||||
Args:
|
||||
task: Download task with a provider-owned source ID
|
||||
cancel_flag: Event to check for cancellation
|
||||
progress_callback: Called with progress percentage (0-100)
|
||||
status_callback: Called with (status, message) for status updates
|
||||
|
||||
Returns:
|
||||
Path to downloaded file if successful, None otherwise
|
||||
|
||||
"""
|
||||
try:
|
||||
# Check for cancellation before starting
|
||||
if cancel_flag.is_set():
|
||||
logger.info("Download cancelled before starting: %s", task.task_id)
|
||||
status_callback("cancelled", "Cancelled")
|
||||
return None
|
||||
|
||||
# Reconstruct the provider-owned record without resolving it again.
|
||||
book_info = BrowseRecord(
|
||||
id=task.task_id,
|
||||
title=task.title,
|
||||
source="direct_download",
|
||||
author=task.author,
|
||||
year=task.year,
|
||||
format=task.format,
|
||||
size=task.size,
|
||||
preview=task.preview,
|
||||
source_url=task.source_url,
|
||||
)
|
||||
|
||||
return self._execute_download(
|
||||
book_info, cancel_flag, progress_callback, status_callback
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if cancel_flag.is_set():
|
||||
logger.info("Download cancelled during error handling: %s", task.task_id)
|
||||
status_callback("cancelled", "Cancelled")
|
||||
else:
|
||||
logger.exception("Error downloading book")
|
||||
status_callback("error", str(e))
|
||||
return None
|
||||
|
||||
def _execute_download(
|
||||
self,
|
||||
book_info: BrowseRecord,
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Execute the direct-download flow with a fetched browse record.
|
||||
|
||||
This contains the core download logic: cascade through sources,
|
||||
handle bypass, move to final location.
|
||||
"""
|
||||
try:
|
||||
logger.debug("Starting download: %s", book_info.title)
|
||||
|
||||
# Prepare paths - use descriptive staging filename, orchestrator will rename
|
||||
# based on FILE_ORGANIZATION setting
|
||||
file_org = config.get("FILE_ORGANIZATION", "rename")
|
||||
if file_org == "none":
|
||||
book_name = f"{book_info.id}.{book_info.format or 'bin'}"
|
||||
else:
|
||||
book_name = build_filename(
|
||||
book_info.title,
|
||||
book_info.author,
|
||||
book_info.year,
|
||||
book_info.format,
|
||||
)
|
||||
book_path = TMP_DIR / book_name
|
||||
|
||||
# Check cancellation before download
|
||||
if cancel_flag.is_set():
|
||||
logger.info("Download cancelled before download call: %s", book_info.id)
|
||||
status_callback("cancelled", "Cancelled")
|
||||
return None
|
||||
|
||||
# Execute download via _download_book (handles cascade and bypass)
|
||||
status_callback("resolving", "Finding download source")
|
||||
success_url = _download_book(
|
||||
book_info, book_path, progress_callback, cancel_flag, status_callback
|
||||
)
|
||||
|
||||
# Check for cancellation after download
|
||||
if cancel_flag.is_set():
|
||||
logger.info("Download cancelled during download: %s", book_info.id)
|
||||
if book_path.exists():
|
||||
book_path.unlink()
|
||||
status_callback("cancelled", "Cancelled")
|
||||
return None
|
||||
|
||||
if not success_url:
|
||||
if network.dns_interference_detected():
|
||||
status_callback(
|
||||
"error",
|
||||
"All sources failed - your network/ISP appears to be blocking "
|
||||
"Anna's Archive. Enable DNS-over-HTTPS in settings.",
|
||||
)
|
||||
else:
|
||||
status_callback("error", "All download sources failed")
|
||||
return None
|
||||
|
||||
# Return temp path - orchestrator handles post-processing (archive extraction, ingest)
|
||||
return str(book_path)
|
||||
|
||||
except Exception:
|
||||
if cancel_flag.is_set():
|
||||
logger.info("Download cancelled during error handling: %s", book_info.id)
|
||||
status_callback("cancelled", "Cancelled")
|
||||
else:
|
||||
logger.exception("Error downloading book")
|
||||
return None
|
||||
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
"""Cancel an in-progress download.
|
||||
|
||||
Cancellation is handled via the cancel_flag passed to download().
|
||||
This method exists for the interface but actual cancellation
|
||||
happens through the Event flag mechanism.
|
||||
"""
|
||||
# Cancellation is handled by the orchestrator via cancel_flag
|
||||
return False
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Provider composition and dispatch for the Direct Download release source."""
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.release_sources.direct_download.annas_archive import AnnasArchiveProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from shelfmark.release_sources import BrowseRecord
|
||||
from shelfmark.release_sources.direct_download.common import DirectDownloadProvider
|
||||
|
||||
|
||||
PROVIDER_TYPES = (AnnasArchiveProvider,)
|
||||
_AA_MD5_PATTERN = re.compile(r"^[0-9a-f]{32}$", re.IGNORECASE)
|
||||
|
||||
|
||||
def create_providers() -> tuple[DirectDownloadProvider, ...]:
|
||||
"""Create request-local providers so mutable search state is not shared."""
|
||||
return tuple(provider_type() for provider_type in PROVIDER_TYPES)
|
||||
|
||||
|
||||
def enabled_providers(
|
||||
providers: Sequence[DirectDownloadProvider] | None = None,
|
||||
) -> tuple[DirectDownloadProvider, ...]:
|
||||
if not config.get("DIRECT_DOWNLOAD_ENABLED", False):
|
||||
return ()
|
||||
candidates = providers if providers is not None else create_providers()
|
||||
return tuple(provider for provider in candidates if provider.is_enabled())
|
||||
|
||||
|
||||
def get_unavailable_reason(
|
||||
providers: Sequence[DirectDownloadProvider] | None = None,
|
||||
) -> str | None:
|
||||
if not config.get("DIRECT_DOWNLOAD_ENABLED", False):
|
||||
return "Direct Download is disabled. Enable the source in Settings."
|
||||
if not enabled_providers(providers):
|
||||
return (
|
||||
"Direct Download is not configured. Enable and configure at least one "
|
||||
"download provider in Settings."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def provider_by_id(
|
||||
provider_id: str | None,
|
||||
providers: Sequence[DirectDownloadProvider] | None = None,
|
||||
) -> DirectDownloadProvider | None:
|
||||
if not provider_id:
|
||||
return None
|
||||
candidates = providers if providers is not None else create_providers()
|
||||
return next((provider for provider in candidates if provider.id == provider_id), None)
|
||||
|
||||
|
||||
def provider_for_record(
|
||||
record: BrowseRecord,
|
||||
providers: Sequence[DirectDownloadProvider] | None = None,
|
||||
) -> DirectDownloadProvider | None:
|
||||
"""Resolve a record explicitly, retaining safe compatibility with legacy tasks."""
|
||||
candidates = providers if providers is not None else create_providers()
|
||||
prefix, separator, _remainder = record.id.partition(":")
|
||||
if separator:
|
||||
provider = provider_by_id(prefix, candidates)
|
||||
if provider is not None:
|
||||
return provider
|
||||
|
||||
if record.source_url:
|
||||
provider = next(
|
||||
(provider for provider in candidates if provider.handles(record.source_url)),
|
||||
None,
|
||||
)
|
||||
if provider is not None:
|
||||
return provider
|
||||
|
||||
# Anna's Archive records historically carried only their raw MD5. Preserve those
|
||||
# persisted tasks without treating arbitrary unknown URLs as Anna's Archive.
|
||||
if _AA_MD5_PATTERN.fullmatch(record.id):
|
||||
return provider_by_id("annas_archive", candidates)
|
||||
return None
|
||||
|
||||
|
||||
def provider_for_record_id(
|
||||
record_id: str,
|
||||
providers: Sequence[DirectDownloadProvider] | None = None,
|
||||
) -> DirectDownloadProvider | None:
|
||||
candidates = providers if providers is not None else create_providers()
|
||||
prefix, separator, _remainder = record_id.partition(":")
|
||||
if separator:
|
||||
return provider_by_id(prefix, candidates)
|
||||
# Record lookup predates provider-qualified IDs, so unqualified IDs are AA IDs.
|
||||
return provider_by_id("annas_archive", candidates)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Direct Download search and release-source integration."""
|
||||
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import get_aa_content_type_dir
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.release_sources import (
|
||||
BrowseRecord,
|
||||
ColumnAlign,
|
||||
ColumnColorHint,
|
||||
ColumnRenderType,
|
||||
ColumnSchema,
|
||||
Release,
|
||||
ReleaseColumnConfig,
|
||||
ReleaseProtocol,
|
||||
ReleaseSource,
|
||||
SourceUnavailableError,
|
||||
register_source,
|
||||
)
|
||||
from shelfmark.release_sources.direct_download import registry
|
||||
from shelfmark.release_sources.direct_download.common import (
|
||||
DirectDownloadUnavailableError,
|
||||
RecordLookupProvider,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _extract_downloads(record: BrowseRecord) -> int | None:
|
||||
"""Extract download count from record info for Release.extra.downloads."""
|
||||
downloads = None
|
||||
if record.info and "Downloads" in record.info:
|
||||
downloads_value = record.info["Downloads"]
|
||||
if isinstance(downloads_value, list) and len(downloads_value) > 0:
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
downloads = int(downloads_value[0])
|
||||
elif isinstance(downloads_value, (int, float)):
|
||||
downloads = int(downloads_value)
|
||||
return downloads
|
||||
|
||||
|
||||
def _browse_record_to_release(record: BrowseRecord) -> Release:
|
||||
"""Convert a browse record to a Release object.
|
||||
|
||||
This bridges the direct source's browse data to the generic release model.
|
||||
"""
|
||||
provider = registry.provider_for_record(record)
|
||||
provider_id = provider.id if provider is not None else None
|
||||
return Release(
|
||||
source=record.source,
|
||||
source_id=record.id,
|
||||
title=record.title,
|
||||
format=record.format,
|
||||
language=record.language, # Top-level language for filtering
|
||||
size=record.size,
|
||||
download_url=record.source_url
|
||||
or (record.download_urls[0] if record.download_urls else None),
|
||||
info_url=record.source_url,
|
||||
protocol=ReleaseProtocol.HTTP,
|
||||
indexer="Direct Download",
|
||||
content_type=record.content, # Preserve content type from source
|
||||
extra={
|
||||
"author": record.author,
|
||||
"publisher": record.publisher,
|
||||
"year": record.year,
|
||||
"language": record.language,
|
||||
"preview": record.preview,
|
||||
"description": record.description,
|
||||
"download_urls": record.download_urls,
|
||||
"info": record.info,
|
||||
"direct_download_provider": provider_id,
|
||||
"downloads": _extract_downloads(record),
|
||||
# Kept for older frontends and persisted request payloads.
|
||||
"web_provider": provider_id if provider_id != "annas_archive" else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@register_source("direct_download")
|
||||
class DirectDownloadSource(ReleaseSource):
|
||||
"""Direct download source - searches web sources for books.
|
||||
|
||||
This wraps the search_books() functionality to provide releases
|
||||
via the plugin interface.
|
||||
"""
|
||||
|
||||
name = "direct_download"
|
||||
display_name = "Direct Download"
|
||||
supported_content_types: ClassVar[list[str]] = ["ebook"] # Direct downloads only support ebooks
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize per-instance search state for direct downloads."""
|
||||
self._providers = registry.create_providers()
|
||||
|
||||
@property
|
||||
def last_search_type(self) -> str:
|
||||
"""Returns the search type used in the last search() call."""
|
||||
provider = registry.provider_by_id("annas_archive", self._providers)
|
||||
return str(getattr(provider, "last_search_type", "title_author"))
|
||||
|
||||
@property
|
||||
def total_results(self) -> int | None:
|
||||
"""Returns the total result count from the last search."""
|
||||
provider = registry.provider_by_id("annas_archive", self._providers)
|
||||
return getattr(provider, "total_results", None)
|
||||
|
||||
def get_column_config(self) -> ReleaseColumnConfig:
|
||||
"""Column configuration for Direct Download source.
|
||||
|
||||
Shows language, format, size, and downloads for each release.
|
||||
Language, format, size, and downloads are all shown on mobile.
|
||||
"""
|
||||
return ReleaseColumnConfig(
|
||||
columns=[
|
||||
ColumnSchema(
|
||||
key="extra.language",
|
||||
label="Language",
|
||||
render_type=ColumnRenderType.BADGE,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="60px",
|
||||
hide_mobile=False, # Language shown on mobile
|
||||
color_hint=ColumnColorHint(type="map", value="language"),
|
||||
uppercase=True,
|
||||
),
|
||||
ColumnSchema(
|
||||
key="format",
|
||||
label="Format",
|
||||
render_type=ColumnRenderType.BADGE,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="80px",
|
||||
hide_mobile=False, # Format shown on mobile
|
||||
color_hint=ColumnColorHint(type="map", value="format"),
|
||||
uppercase=True,
|
||||
),
|
||||
ColumnSchema(
|
||||
key="size",
|
||||
label="Size",
|
||||
render_type=ColumnRenderType.SIZE,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="80px",
|
||||
hide_mobile=False, # Size shown on mobile
|
||||
),
|
||||
ColumnSchema(
|
||||
key="extra.downloads",
|
||||
label="Downloads",
|
||||
render_type=ColumnRenderType.NUMBER,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="80px",
|
||||
hide_mobile=False, # Downloads shown on mobile
|
||||
),
|
||||
],
|
||||
grid_template="minmax(0,2fr) 60px 80px 80px 80px",
|
||||
supported_filters=["format", "language"], # AA has reliable language metadata
|
||||
)
|
||||
|
||||
def get_record(
|
||||
self,
|
||||
record_id: str,
|
||||
*,
|
||||
fetch_download_count: bool = True,
|
||||
) -> BrowseRecord | None:
|
||||
"""Resolve a direct-download record for direct-mode info/download flows."""
|
||||
provider = registry.provider_for_record_id(record_id, self._providers)
|
||||
if provider is None or not isinstance(provider, RecordLookupProvider):
|
||||
return None
|
||||
native_id = record_id.partition(":")[2] or record_id
|
||||
return provider.get_record(native_id, fetch_download_count=fetch_download_count)
|
||||
|
||||
def search_results_are_releases(self) -> bool:
|
||||
"""Direct search results already represent concrete downloadable releases."""
|
||||
return True
|
||||
|
||||
def get_destination_override(self, task: DownloadTask) -> Path | None:
|
||||
"""Apply Anna's Archive content-type routing when configured."""
|
||||
if check_audiobook(task.content_type):
|
||||
return None
|
||||
return get_aa_content_type_dir(task.content_type)
|
||||
|
||||
def search(
|
||||
self,
|
||||
book: BookMetadata,
|
||||
plan: ReleaseSearchPlan,
|
||||
*,
|
||||
expand_search: bool = False,
|
||||
content_type: str = "ebook",
|
||||
) -> list[Release]:
|
||||
"""Search every enabled provider through the shared provider lifecycle."""
|
||||
unavailable_reason = registry.get_unavailable_reason(self._providers)
|
||||
if unavailable_reason:
|
||||
raise DirectDownloadUnavailableError(unavailable_reason)
|
||||
|
||||
releases: list[Release] = []
|
||||
failures: list[Exception] = []
|
||||
for provider in registry.enabled_providers(self._providers):
|
||||
try:
|
||||
records = provider.search(
|
||||
book,
|
||||
plan,
|
||||
expand_search=expand_search,
|
||||
content_type=content_type,
|
||||
)
|
||||
except SourceUnavailableError as exc:
|
||||
failures.append(exc)
|
||||
continue
|
||||
except (
|
||||
RuntimeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
requests.exceptions.RequestException,
|
||||
) as exc:
|
||||
logger.warning("%s search failed: %s", provider.display_name, exc)
|
||||
failures.append(exc)
|
||||
continue
|
||||
releases.extend(_browse_record_to_release(record) for record in records)
|
||||
|
||||
# A provider failure is only quiet when another provider answered. Otherwise the
|
||||
# caller has to see it, or a failed search reads as a search with no hits.
|
||||
if failures and not releases:
|
||||
raise failures[0]
|
||||
return releases
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if Direct Download has been explicitly enabled and configured."""
|
||||
return registry.get_unavailable_reason(self._providers) is None
|
||||
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
from shelfmark.api.websocket import ws_manager
|
||||
from shelfmark.core.author_match import author_affinity, search_surname
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import is_audiobook
|
||||
@@ -85,6 +86,18 @@ def _emit_status(message: str, phase: str = "searching") -> None:
|
||||
)
|
||||
|
||||
|
||||
def _reported_author(release: Release) -> str:
|
||||
"""The author a result actually claims, with the parser's sentinel read as none.
|
||||
|
||||
A filename with no " - " separator has no author to report and parser.py:168
|
||||
fills in "Unknown". Ranked literally that sorts as a wrong author, below every
|
||||
result that named someone else; as absent it sorts between agreement and
|
||||
disagreement, which is what the tier was built for.
|
||||
"""
|
||||
author = release.extra.get("author", "")
|
||||
return "" if author == "Unknown" else author
|
||||
|
||||
|
||||
# Rate limiting to avoid server throttling
|
||||
MIN_SEARCH_INTERVAL = 15.0
|
||||
_last_search_time: float = 0
|
||||
@@ -226,12 +239,15 @@ class IRCReleaseSource(ReleaseSource):
|
||||
logger.debug("IRC source is disabled, skipping search")
|
||||
return []
|
||||
|
||||
# Build search query
|
||||
query = plan.primary_query or self._build_query(book)
|
||||
query = self._build_query(book, plan)
|
||||
if not query:
|
||||
logger.warning("No search query could be built")
|
||||
return []
|
||||
|
||||
# A manual query is the user's own words; ranking it against the metadata
|
||||
# author would second-guess what they typed.
|
||||
wanted_author = "" if plan.manual_query else plan.author
|
||||
|
||||
# Get IRC settings
|
||||
server = _config_text("IRC_SERVER")
|
||||
port = _config_port("IRC_PORT", 6697)
|
||||
@@ -276,7 +292,9 @@ class IRCReleaseSource(ReleaseSource):
|
||||
if cached:
|
||||
_emit_status("Using cached results", phase="complete")
|
||||
self._online_servers = set(cached.get("online_servers", []))
|
||||
return self._filter_by_content_type(cached["releases"], requested)
|
||||
return self._rank_by_author(
|
||||
self._filter_by_content_type(cached["releases"], requested), wanted_author
|
||||
)
|
||||
|
||||
# Anti-spam cap: the exact same query may only be POSTED a limited number of times
|
||||
# per window, even via refresh. Beyond that, serve whatever is cached rather than
|
||||
@@ -293,7 +311,9 @@ class IRCReleaseSource(ReleaseSource):
|
||||
cached = get_cached_results(query_key)
|
||||
if cached:
|
||||
self._online_servers = set(cached.get("online_servers", []))
|
||||
return self._filter_by_content_type(cached["releases"], requested)
|
||||
return self._rank_by_author(
|
||||
self._filter_by_content_type(cached["releases"], requested), wanted_author
|
||||
)
|
||||
return []
|
||||
|
||||
logger.info("IRC search: %s", query)
|
||||
@@ -369,7 +389,12 @@ class IRCReleaseSource(ReleaseSource):
|
||||
ebook_releases + audiobook_releases,
|
||||
online_servers=online_servers,
|
||||
)
|
||||
releases = audiobook_releases if requested == "audiobook" else ebook_releases
|
||||
# Ranked on the way out, never before the cache: one query identity is
|
||||
# shared by every book that produced the same query, so the order has to
|
||||
# follow the author asked for now, not the one that filled the cache.
|
||||
releases = self._rank_by_author(
|
||||
audiobook_releases if requested == "audiobook" else ebook_releases, wanted_author
|
||||
)
|
||||
|
||||
except DCCError as e:
|
||||
logger.exception("DCC error during search")
|
||||
@@ -387,22 +412,48 @@ class IRCReleaseSource(ReleaseSource):
|
||||
else:
|
||||
return releases
|
||||
|
||||
def _build_query(self, book: BookMetadata) -> str:
|
||||
"""Build search query from book metadata."""
|
||||
parts = []
|
||||
def _build_query(self, book: BookMetadata, plan: ReleaseSearchPlan) -> str:
|
||||
"""Build the line posted to the channel: a title, plus a surname.
|
||||
|
||||
if book.search_title or book.title:
|
||||
parts.append(book.search_title or book.title)
|
||||
|
||||
if book.search_author:
|
||||
parts.append(book.search_author)
|
||||
elif book.authors:
|
||||
# Use first author
|
||||
author = book.authors[0] if isinstance(book.authors, list) else book.authors
|
||||
parts.append(author)
|
||||
Both come off the variant rather than the plan: an ISBN fallback variant
|
||||
carries `author=""` on purpose (search_plan.py:246), and so does a manual
|
||||
query, so reading `plan.author` here would append a surname to searches
|
||||
that deliberately have none.
|
||||
|
||||
Returns "" without a title, so the caller reports "no query" rather than
|
||||
posting one. A surname on its own is not a search: `@search Petrie` asks
|
||||
the bot for every Petrie on the channel, and a bare over-broad line is the
|
||||
kind of post `is_available` refuses queries to avoid being banned for.
|
||||
"""
|
||||
variant = plan.title_variants[0] if plan.title_variants else None
|
||||
title = variant.title if variant else (book.search_title or book.title)
|
||||
if not title:
|
||||
return ""
|
||||
author = variant.author if variant else plan.author
|
||||
parts = [part for part in (title, search_surname(author)) if part]
|
||||
return " ".join(parts)
|
||||
|
||||
def _rank_by_author(self, releases: list[Release], wanted_author: str) -> list[Release]:
|
||||
"""Order releases by author agreement, under the server's availability.
|
||||
|
||||
A surname is a weak filter - it also matches a different author who shares
|
||||
it - so the full name decides the order while the search bot decides the
|
||||
set. Availability stays the outer key: downloading asks one named bot and
|
||||
waits 120s for it (handler.py:133-139), so a release from a bot that is not
|
||||
in the channel must not outrank one that can actually answer. Sorting is
|
||||
stable, so format and server order survive inside each tier.
|
||||
"""
|
||||
if not wanted_author:
|
||||
return releases
|
||||
online = self._online_servers or set()
|
||||
return sorted(
|
||||
releases,
|
||||
key=lambda release: (
|
||||
0 if release.extra.get("server", "") in online else 1,
|
||||
author_affinity(wanted_author, _reported_author(release)),
|
||||
),
|
||||
)
|
||||
|
||||
# Format priority for sorting (lower = higher priority)
|
||||
EBOOK_FORMAT_PRIORITY: ClassVar[dict[str, int]] = {
|
||||
"epub": 0,
|
||||
@@ -428,14 +479,15 @@ class IRCReleaseSource(ReleaseSource):
|
||||
"m4b": 0,
|
||||
"mp3": 1,
|
||||
"m4a": 2,
|
||||
"flac": 3,
|
||||
"opus": 4,
|
||||
"ogg": 5,
|
||||
"aac": 6,
|
||||
"wav": 7,
|
||||
"wma": 8,
|
||||
"rar": 9,
|
||||
"zip": 10,
|
||||
"mp4": 3,
|
||||
"flac": 4,
|
||||
"opus": 5,
|
||||
"ogg": 6,
|
||||
"aac": 7,
|
||||
"wav": 8,
|
||||
"wma": 9,
|
||||
"rar": 10,
|
||||
"zip": 11,
|
||||
}
|
||||
|
||||
def _convert_to_releases(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Libgen release source - direct catalogue search over the libgen.li family."""
|
||||
|
||||
# Import to trigger registration
|
||||
from shelfmark.release_sources.libgen import handler as handler
|
||||
from shelfmark.release_sources.libgen import settings as settings
|
||||
from shelfmark.release_sources.libgen import source as source
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Libgen download handler - resolves an md5 to a file via the ads.php cascade.
|
||||
|
||||
Selected by ``get_handler(task.source)`` for ``source == "libgen"``. It mirrors
|
||||
DirectDownloadHandler's shape (stage into TMP_DIR, let the orchestrator post-process) but
|
||||
only knows the libgen ``ads.php?md5= -> get.php`` path, keyed on the md5 the search source
|
||||
put in ``source_id``.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.config.env import TMP_DIR
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import build_filename
|
||||
from shelfmark.download import http as downloader
|
||||
from shelfmark.release_sources import DownloadHandler, register_handler
|
||||
from shelfmark.release_sources.libgen import scraper
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from threading import Event
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Files under this size are almost certainly an error/challenge page, not a book. Same
|
||||
# threshold direct_download uses; duplicated to keep the package self-contained.
|
||||
_MIN_VALID_FILE_SIZE = 10 * 1024
|
||||
|
||||
|
||||
@register_handler("libgen")
|
||||
class LibgenHandler(DownloadHandler):
|
||||
"""Download handler for Libgen search releases."""
|
||||
|
||||
def download(
|
||||
self,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Resolve the md5 through each configured mirror and download the file.
|
||||
|
||||
Returns the staged temp path on success (orchestrator handles post-processing) or
|
||||
None if every mirror fails.
|
||||
"""
|
||||
from shelfmark.core import mirrors
|
||||
|
||||
try:
|
||||
if cancel_flag.is_set():
|
||||
status_callback("cancelled", "Cancelled")
|
||||
return None
|
||||
|
||||
# source_id was namespaced "libgen:<md5>" to avoid a queue-key collision with
|
||||
# direct_download; strip it back to the bare (lowercase) md5 the download page expects.
|
||||
md5 = task.task_id.split(":", 1)[-1].lower()
|
||||
|
||||
if config.get("FILE_ORGANIZATION", "rename") == "none":
|
||||
book_name = f"{md5}.{task.format or 'bin'}"
|
||||
else:
|
||||
book_name = build_filename(task.title, task.author, task.year, task.format)
|
||||
book_path = TMP_DIR / book_name
|
||||
|
||||
for base in mirrors.get_libgen_mirrors():
|
||||
if cancel_flag.is_set():
|
||||
status_callback("cancelled", "Cancelled")
|
||||
return None
|
||||
|
||||
ads_url = f"{base.rstrip('/')}/ads.php?md5={md5}"
|
||||
status_callback("resolving", "Resolving Libgen")
|
||||
ads_html = scraper.fetch_page(ads_url, (5, 10))
|
||||
if not ads_html:
|
||||
continue
|
||||
get_url = scraper.resolve_download_url(ads_html, base)
|
||||
if not get_url:
|
||||
continue
|
||||
|
||||
# _selector=None: download_url builds its own AAMirrorSelector (a no-op for
|
||||
# non-AA URLs), so we avoid initialising dead AA-mirror state here.
|
||||
data = downloader.download_url(
|
||||
get_url,
|
||||
task.size or "",
|
||||
progress_callback,
|
||||
cancel_flag,
|
||||
None,
|
||||
status_callback,
|
||||
referer=ads_url,
|
||||
)
|
||||
if not data:
|
||||
continue
|
||||
if data.tell() < _MIN_VALID_FILE_SIZE:
|
||||
logger.warning("Libgen file too small from %s, treating as failure", base)
|
||||
continue
|
||||
|
||||
data.seek(0)
|
||||
with book_path.open("wb") as file:
|
||||
file.write(data.getbuffer())
|
||||
return str(book_path)
|
||||
except Exception as exc:
|
||||
if cancel_flag.is_set():
|
||||
status_callback("cancelled", "Cancelled")
|
||||
else:
|
||||
logger.exception("Error downloading from Libgen")
|
||||
status_callback("error", str(exc))
|
||||
return None
|
||||
else:
|
||||
# Loop exhausted without returning: every mirror failed to resolve/download.
|
||||
status_callback("error", "All Libgen mirrors failed")
|
||||
return None
|
||||
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
"""Cancellation is handled by the orchestrator via the cancel_flag."""
|
||||
return False
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Libgen catalogue scraping: search results and download-link resolution.
|
||||
|
||||
This is the pure fetch+parse core of the Libgen source. It talks to the libgen.li
|
||||
family of mirrors (``index.php?req=`` search, ``ads.php?md5=`` download pages) using
|
||||
plain HTTP -- these mirrors are not behind DDoS-Guard, so no browser/bypasser is
|
||||
needed. All shelfmark-stateful behaviour lives in source.py/handler.py.
|
||||
"""
|
||||
|
||||
import re
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from shelfmark.core.languages import normalize_language
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download import http as downloader
|
||||
from shelfmark.download import network
|
||||
from shelfmark.release_sources import BrowseRecord
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# The libgen.li results table. Both full (9-cell) and compact (5-cell) rows live in it.
|
||||
_RESULTS_TABLE_ID = "tablelibgen"
|
||||
|
||||
# md5 appears in the row's Mirrors cell as get.php?md5=<hash> and an AA /md5/<hash> link.
|
||||
_MD5_RE = re.compile(r"md5=([0-9a-f]{32})", re.IGNORECASE)
|
||||
|
||||
# Patterns for the keyed GET link on an ads.php page. Kept in sync with the resolution
|
||||
# libgen download has always used (direct_download._LIBGEN_GET_PATTERNS); duplicated here
|
||||
# on purpose so the Libgen source stays self-contained and does not import that module's
|
||||
# internals (which an in-flight upstream refactor is relocating).
|
||||
_GET_KEY_PATTERNS = [
|
||||
re.compile(
|
||||
r'<a\s+href=["\']([^"\']*get\.php\?md5=[^"\']+&key=[^"\']+)["\'][^>]*>\s*'
|
||||
r"<h2[^>]*>GET</h2>\s*</a>",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(
|
||||
r'<a[^>]+href=["\']([^"\']*get\.php\?md5=[^"\']+&(?:amp;)?key=[^"\']+)["\']',
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(
|
||||
r'<a\s+href=["\']([^"\']*get\.php[^"\']*)["\'][^>]*>[\s\S]*?<h2[^>]*>GET</h2>',
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(
|
||||
r'href=["\']([^"\']*get\.php\?[^"\']*md5=[^"\']*&[^"\']*key=[^"\']+)["\']',
|
||||
re.IGNORECASE,
|
||||
),
|
||||
]
|
||||
|
||||
# Labels that terminate a metadata value on an ads.php page, so e.g. "Year: 2003 ISBN: ..."
|
||||
# stops Year at "ISBN:" rather than swallowing it. We only emit a subset (see _parse_ads_metadata).
|
||||
_METADATA_STOP_LABELS = [
|
||||
"Title",
|
||||
"Series",
|
||||
"Author(s)",
|
||||
"Publisher",
|
||||
"Year",
|
||||
"Language",
|
||||
"Pages",
|
||||
"ISBN",
|
||||
"Edition",
|
||||
"Extension",
|
||||
"Size",
|
||||
"Time added",
|
||||
"ID",
|
||||
"Filename",
|
||||
"Description",
|
||||
]
|
||||
|
||||
|
||||
def fetch_page(url: str, timeout: tuple[int, int] = (5, 15)) -> str | None:
|
||||
"""GET a libgen page, returning its text on HTTP 200 or None on any failure.
|
||||
|
||||
Public (not underscore-prefixed) because handler.py fetches ads.php pages through it
|
||||
and tests patch it. Uses the app's proxy/SSL/DNS configuration so egress stays on
|
||||
whatever network the container is bound to (the VPN namespace, in the deployed stack).
|
||||
"""
|
||||
# libgen.li's ads.php returns an empty 200 body to requests without a Referer (an
|
||||
# anti-hotlinking check the mirrors added). A same-origin Referer is enough and is
|
||||
# harmless for the search page, so send one for every fetch.
|
||||
parts = urlsplit(url)
|
||||
headers = {**downloader.DOWNLOAD_HEADERS, "Referer": f"{parts.scheme}://{parts.netloc}/"}
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
allow_redirects=True,
|
||||
proxies=network.get_proxies(url),
|
||||
verify=network.get_ssl_verify(url),
|
||||
)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logger.debug("Libgen fetch failed for %s: %s", url, exc)
|
||||
return None
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
logger.debug("Libgen fetch %s returned %s", url, response.status_code)
|
||||
return None
|
||||
return response.text
|
||||
|
||||
|
||||
def search_libgen(
|
||||
query: str,
|
||||
mirrors: list[str],
|
||||
*,
|
||||
max_results: int,
|
||||
timeout: tuple[int, int] = (5, 15),
|
||||
) -> list[BrowseRecord]:
|
||||
"""Search each mirror's catalogue until one answers with a results table.
|
||||
|
||||
The first mirror that returns a parseable ``#tablelibgen`` wins -- including when that
|
||||
table is empty ([] is returned as final). Mirrors can lag independently, but falling
|
||||
through on every empty result would multiply latency under the shared search deadline,
|
||||
so an empty-but-well-formed answer is trusted rather than re-queried elsewhere.
|
||||
"""
|
||||
for base in mirrors:
|
||||
url = f"{base.rstrip('/')}/index.php?req={quote(query)}&res={max_results}"
|
||||
html = fetch_page(url, timeout)
|
||||
if html is None:
|
||||
continue
|
||||
records = _parse_results(html, base)
|
||||
if records is not None:
|
||||
return records
|
||||
return []
|
||||
|
||||
|
||||
def fetch_record_by_md5(
|
||||
md5: str,
|
||||
mirrors: list[str],
|
||||
*,
|
||||
timeout: tuple[int, int] = (5, 10),
|
||||
) -> BrowseRecord | None:
|
||||
"""Resolve a single record from its md5 by parsing an ads.php page's metadata.
|
||||
|
||||
libgen's ``index.php?req=<md5>`` does not match on md5 (req= indexes title/author/
|
||||
description), so md5 -> record must go through the ads.php page instead.
|
||||
"""
|
||||
for base in mirrors:
|
||||
html = fetch_page(f"{base.rstrip('/')}/ads.php?md5={md5}", timeout)
|
||||
if html is None:
|
||||
continue
|
||||
record = _parse_ads_metadata(html, md5, base)
|
||||
if record is not None:
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def resolve_download_url(ads_html: str, base_url: str) -> str | None:
|
||||
"""Extract the keyed get.php download URL from an ads.php page, or None."""
|
||||
if "get.php" not in ads_html:
|
||||
return None
|
||||
for pattern in _GET_KEY_PATTERNS:
|
||||
match = pattern.search(ads_html)
|
||||
if not match:
|
||||
continue
|
||||
url = match.group(1).replace("&", "&").replace(">", ">").replace("<", "<")
|
||||
if not url.startswith("http"):
|
||||
url = f"{base_url.rstrip('/')}/{url.lstrip('/')}"
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
def _cell_text(cell: Tag) -> str:
|
||||
"""Cell text with runs of whitespace (incl. / \\xa0) collapsed to single spaces."""
|
||||
return re.sub(r"\s+", " ", cell.get_text(" ", strip=True)).strip()
|
||||
|
||||
|
||||
def _parse_results(html: str, base_url: str) -> list[BrowseRecord] | None:
|
||||
"""Parse a libgen search page.
|
||||
|
||||
Returns None when the page has no results table (a challenge/error page -> the caller
|
||||
tries the next mirror), or a list (possibly empty) when the table is present.
|
||||
|
||||
Row shapes vary and carry NO rowspans: full rows have 9 cells
|
||||
``[Title, Author, Publisher, Year, Language, Pages, Size, Ext, Mirrors]`` and compact
|
||||
rows (extra files under one edition) have 5 ``[Title, Pages, Size, Ext, Mirrors]``. The
|
||||
file-level columns are stable from the right, so index from the end: Mirrors[-1] (md5),
|
||||
Ext[-2], Size[-3]. Title is always [0]. Author/Language exist only on full rows.
|
||||
|
||||
The two shapes are the only ones libgen.li is known to emit; an unexpected width just
|
||||
fails safe (author/language read as None) rather than mis-columning.
|
||||
"""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
table = soup.find("table", id=_RESULTS_TABLE_ID)
|
||||
if not isinstance(table, Tag):
|
||||
return None
|
||||
|
||||
records: list[BrowseRecord] = []
|
||||
for row in table.find_all("tr")[1:]: # skip the header row
|
||||
cells = row.find_all("td")
|
||||
if len(cells) < 5:
|
||||
continue
|
||||
# Scope the md5 to the Mirrors cell (last column): scanning the whole row could match
|
||||
# an md5-shaped string elsewhere (e.g. a cover-image URL) and misattribute it.
|
||||
md5_match = _MD5_RE.search(str(cells[-1]))
|
||||
if not md5_match:
|
||||
continue # spacer/section rows carry no md5
|
||||
md5 = md5_match.group(1).lower()
|
||||
|
||||
title = _cell_text(cells[0])
|
||||
fmt = _cell_text(cells[-2]).lower() or None
|
||||
size = _cell_text(cells[-3]) or None
|
||||
|
||||
author = None
|
||||
language = None
|
||||
if len(cells) >= 9: # full row: middle metadata columns are present
|
||||
author = _cell_text(cells[1]) or None
|
||||
language = normalize_language(_cell_text(cells[4]))
|
||||
|
||||
records.append(
|
||||
BrowseRecord(
|
||||
id=md5,
|
||||
title=title,
|
||||
source="libgen",
|
||||
author=author,
|
||||
language=language,
|
||||
size=size,
|
||||
format=fmt,
|
||||
source_url=f"{base_url.rstrip('/')}/ads.php?md5={md5}",
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def _parse_ads_metadata(html: str, md5: str, base_url: str) -> BrowseRecord | None:
|
||||
"""Build a BrowseRecord from an ads.php page's labelled metadata.
|
||||
|
||||
The page's metadata lives in a deeply nested table, so read it from the visible text by
|
||||
label rather than by cell position -- the labels (Title:, Series:, Author(s): ...) are
|
||||
stable even though the surrounding markup is not. Returns None if the page has no title.
|
||||
"""
|
||||
text = re.sub(r"\s+", " ", BeautifulSoup(html, "html.parser").get_text(" ", strip=True))
|
||||
|
||||
def field(name: str) -> str | None:
|
||||
others = "|".join(
|
||||
re.escape(other) + r":" for other in _METADATA_STOP_LABELS if other != name
|
||||
)
|
||||
match = re.search(re.escape(name) + r":\s*(.*?)\s*(?:" + others + r"|$)", text)
|
||||
value = match.group(1).strip() if match else ""
|
||||
return value or None
|
||||
|
||||
title = field("Title")
|
||||
if not title:
|
||||
return None
|
||||
return BrowseRecord(
|
||||
id=md5,
|
||||
title=title,
|
||||
source="libgen",
|
||||
author=field("Author(s)"),
|
||||
publisher=field("Publisher"),
|
||||
year=field("Year"),
|
||||
language=normalize_language(field("Language") or ""),
|
||||
source_url=f"{base_url.rstrip('/')}/ads.php?md5={md5}",
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Libgen search settings registration."""
|
||||
|
||||
from shelfmark.core.settings_registry import (
|
||||
CheckboxField,
|
||||
NumberField,
|
||||
SettingsField,
|
||||
register_settings,
|
||||
)
|
||||
|
||||
|
||||
@register_settings("libgen_config", "Libgen Search", icon="download", order=46)
|
||||
def libgen_config_settings() -> list[SettingsField]:
|
||||
"""Libgen search configuration settings."""
|
||||
return [
|
||||
CheckboxField(
|
||||
key="LIBGEN_SEARCH_ENABLED",
|
||||
label="Enable Libgen Search",
|
||||
description=(
|
||||
"Search the Libgen catalogue directly, including CBZ/CBR comics and manga "
|
||||
"that Anna's Archive does not index. Uses the Libgen mirrors configured "
|
||||
"under Mirrors for both search and download."
|
||||
),
|
||||
default=False,
|
||||
),
|
||||
NumberField(
|
||||
key="LIBGEN_SEARCH_MAX_RESULTS",
|
||||
label="Max Results",
|
||||
description="Maximum number of results to request per search (1-100).",
|
||||
default=25,
|
||||
min_value=1,
|
||||
max_value=100,
|
||||
show_when={"field": "LIBGEN_SEARCH_ENABLED", "value": True},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Libgen release source - searches the libgen catalogue directly.
|
||||
|
||||
Anna's Archive is shelfmark's only other web search source, and libgen appears there
|
||||
purely as a download mirror keyed by an AA md5. This source searches libgen's own
|
||||
catalogue, which surfaces content AA does not index -- most visibly CBZ/CBR comics and
|
||||
manga volumes. Downloads reuse the existing ``ads.php?md5=`` resolution (see handler.py).
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from shelfmark.core import mirrors
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.release_sources import (
|
||||
BrowseRecord,
|
||||
ColumnAlign,
|
||||
ColumnColorHint,
|
||||
ColumnRenderType,
|
||||
ColumnSchema,
|
||||
Release,
|
||||
ReleaseColumnConfig,
|
||||
ReleaseProtocol,
|
||||
ReleaseSource,
|
||||
register_source,
|
||||
)
|
||||
from shelfmark.release_sources.libgen import scraper
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.models import DownloadTask # noqa: F401
|
||||
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_DEFAULT_MAX_RESULTS = 25
|
||||
|
||||
|
||||
def _coerce_positive_int(value: object, default: int) -> int:
|
||||
"""Return a positive integer config value or the provided default."""
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, int) and value > 0:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _build_query_candidates(plan: ReleaseSearchPlan, book: BookMetadata) -> list[str]:
|
||||
"""Build ordered, de-duplicated search queries from the plan (mirrors AudiobookBay)."""
|
||||
candidates: list[str] = []
|
||||
if plan.manual_query:
|
||||
candidates.append(plan.manual_query.strip())
|
||||
elif plan.title_variants:
|
||||
variant = plan.title_variants[0]
|
||||
combined = f"{variant.title} {variant.author}".strip()
|
||||
title_only = (variant.title or "").strip()
|
||||
if combined:
|
||||
candidates.append(combined)
|
||||
if title_only and title_only.lower() != combined.lower():
|
||||
candidates.append(title_only)
|
||||
elif book.title:
|
||||
candidates.append(book.title.strip())
|
||||
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
normalized = candidate.strip()
|
||||
if not normalized or normalized.lower() in seen:
|
||||
continue
|
||||
seen.add(normalized.lower())
|
||||
deduped.append(normalized)
|
||||
return deduped
|
||||
|
||||
|
||||
@register_source("libgen")
|
||||
class LibgenSource(ReleaseSource):
|
||||
"""Release source that searches the libgen catalogue for downloadable files."""
|
||||
|
||||
name = "libgen"
|
||||
display_name = "Libgen"
|
||||
supported_content_types: ClassVar[list[str]] = ["ebook"] # incl. comics/manga (cbz/cbr)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available only when explicitly enabled and libgen mirrors are configured.
|
||||
|
||||
``is True`` rather than ``bool(...)`` matches the AudiobookBay idiom and avoids a
|
||||
truthy string ever enabling network egress to an unmoderated site.
|
||||
"""
|
||||
return (
|
||||
config.get("LIBGEN_SEARCH_ENABLED", False) is True
|
||||
and mirrors.has_libgen_mirror_configuration()
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
book: BookMetadata,
|
||||
plan: ReleaseSearchPlan,
|
||||
*,
|
||||
expand_search: bool = False,
|
||||
content_type: str = "ebook",
|
||||
) -> list[Release]:
|
||||
"""Search libgen for releases of a book."""
|
||||
if content_type != "ebook":
|
||||
return []
|
||||
if not self.is_available():
|
||||
return []
|
||||
|
||||
queries = _build_query_candidates(plan, book)
|
||||
if not queries:
|
||||
return []
|
||||
max_results = _coerce_positive_int(
|
||||
config.get("LIBGEN_SEARCH_MAX_RESULTS", _DEFAULT_MAX_RESULTS), _DEFAULT_MAX_RESULTS
|
||||
)
|
||||
mirror_list = mirrors.get_libgen_mirrors()
|
||||
|
||||
# One search_libgen call per candidate; it already retries every mirror internally.
|
||||
# Worst case (all mirrors dead) stays within the shared search deadline.
|
||||
for query in queries:
|
||||
logger.info("Searching Libgen for: %s", query)
|
||||
records = scraper.search_libgen(query, mirror_list, max_results=max_results)
|
||||
if records:
|
||||
return [self._record_to_release(record) for record in records]
|
||||
return []
|
||||
|
||||
def _record_to_release(self, record: BrowseRecord) -> Release:
|
||||
"""Normalize a libgen catalogue record into a Release.
|
||||
|
||||
``source_id`` is namespaced ``libgen:<md5>`` so the download queue key never
|
||||
collides with a direct_download release for the same md5 (Anna's Archive heavily
|
||||
indexes libgen, so the same md5 routinely appears from both sources). The handler
|
||||
strips the prefix back to the bare md5.
|
||||
"""
|
||||
return Release(
|
||||
source="libgen",
|
||||
source_id=f"libgen:{record.id}",
|
||||
title=record.title,
|
||||
format=record.format,
|
||||
language=record.language,
|
||||
size=record.size,
|
||||
download_url=None, # handler builds ads.php?md5= from the md5
|
||||
info_url=record.source_url,
|
||||
protocol=ReleaseProtocol.HTTP,
|
||||
indexer="Libgen",
|
||||
content_type="ebook",
|
||||
extra={
|
||||
"author": record.author,
|
||||
"year": record.year,
|
||||
"md5": record.id,
|
||||
"language": record.language,
|
||||
},
|
||||
)
|
||||
|
||||
def search_results_are_releases(self) -> bool:
|
||||
"""Libgen search rows are concrete, directly downloadable releases."""
|
||||
return True
|
||||
|
||||
def get_record(
|
||||
self,
|
||||
record_id: str,
|
||||
*,
|
||||
fetch_download_count: bool = True,
|
||||
) -> BrowseRecord | None:
|
||||
"""Resolve a libgen record by (possibly prefixed) md5, or None if not found."""
|
||||
md5 = record_id.split(":", 1)[-1].lower()
|
||||
return scraper.fetch_record_by_md5(md5, mirrors.get_libgen_mirrors())
|
||||
|
||||
def get_column_config(self) -> ReleaseColumnConfig:
|
||||
"""Language, format and size badges -- same layout as Direct Download."""
|
||||
return ReleaseColumnConfig(
|
||||
columns=[
|
||||
ColumnSchema(
|
||||
key="extra.language",
|
||||
label="Language",
|
||||
render_type=ColumnRenderType.BADGE,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="60px",
|
||||
color_hint=ColumnColorHint(type="map", value="language"),
|
||||
uppercase=True,
|
||||
),
|
||||
ColumnSchema(
|
||||
key="format",
|
||||
label="Format",
|
||||
render_type=ColumnRenderType.BADGE,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="80px",
|
||||
color_hint=ColumnColorHint(type="map", value="format"),
|
||||
uppercase=True,
|
||||
),
|
||||
ColumnSchema(
|
||||
key="size",
|
||||
label="Size",
|
||||
render_type=ColumnRenderType.SIZE,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="80px",
|
||||
),
|
||||
],
|
||||
grid_template="minmax(0,2fr) 60px 80px 80px",
|
||||
supported_filters=["format", "language"],
|
||||
)
|
||||
@@ -9,7 +9,12 @@ if TYPE_CHECKING:
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import normalize_optional_text
|
||||
from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
client_prefers_torrent_file,
|
||||
get_client,
|
||||
list_configured_clients,
|
||||
)
|
||||
from shelfmark.download.clients.base_handler import (
|
||||
COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS,
|
||||
)
|
||||
@@ -54,13 +59,15 @@ def _get_protocol(result: dict) -> str:
|
||||
return "usenet"
|
||||
|
||||
|
||||
def _get_download_url(result: dict) -> str:
|
||||
def _get_download_url(result: dict, *, prefer_torrent_file: bool = False) -> str:
|
||||
"""Pick the best URL to hand to a download client."""
|
||||
protocol = _get_protocol(result)
|
||||
magnet_url = str(result.get("magnetUrl") or "").strip()
|
||||
download_url = str(result.get("downloadUrl") or "").strip()
|
||||
|
||||
if protocol == "torrent":
|
||||
if prefer_torrent_file:
|
||||
return download_url or magnet_url
|
||||
return magnet_url or download_url
|
||||
return download_url or magnet_url
|
||||
|
||||
@@ -93,9 +100,15 @@ class NewznabHandler(ExternalClientHandler):
|
||||
if result is None:
|
||||
return {}
|
||||
|
||||
protocol = _get_protocol(result)
|
||||
return {
|
||||
"retry_download_url": normalize_optional_text(_get_download_url(result)),
|
||||
"retry_download_protocol": normalize_optional_text(_get_protocol(result)),
|
||||
"retry_download_url": normalize_optional_text(
|
||||
_get_download_url(
|
||||
result,
|
||||
prefer_torrent_file=client_prefers_torrent_file(protocol),
|
||||
)
|
||||
),
|
||||
"retry_download_protocol": normalize_optional_text(protocol),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -137,16 +150,19 @@ class NewznabHandler(ExternalClientHandler):
|
||||
status_callback("error", "Release not found in cache (may have expired)")
|
||||
return None
|
||||
|
||||
download_url = _get_download_url(result)
|
||||
if not download_url:
|
||||
status_callback("error", "No download URL available")
|
||||
return None
|
||||
|
||||
protocol = _get_protocol(result)
|
||||
if protocol not in ("torrent", "usenet"):
|
||||
status_callback("error", "Could not determine download protocol")
|
||||
return None
|
||||
|
||||
download_url = _get_download_url(
|
||||
result,
|
||||
prefer_torrent_file=client_prefers_torrent_file(protocol),
|
||||
)
|
||||
if not download_url:
|
||||
status_callback("error", "No download URL available")
|
||||
return None
|
||||
|
||||
release_name = result.get("title") or task.title or "Unknown"
|
||||
expected_hash = str(result.get("infoHash") or "").strip() or None
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from shelfmark.core.settings_registry import (
|
||||
HeadingField,
|
||||
PasswordField,
|
||||
SettingsField,
|
||||
TableField,
|
||||
TagListField,
|
||||
TextField,
|
||||
register_settings,
|
||||
@@ -16,12 +17,36 @@ from shelfmark.core.utils import normalize_http_url
|
||||
|
||||
|
||||
def _test_newznab_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Test the Newznab connection using current form values."""
|
||||
"""Test all named Newznab connections, or the legacy connection as fallback."""
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.release_sources.newznab.api import NewznabClient
|
||||
from shelfmark.release_sources.newznab.source import _parse_indexer_rows
|
||||
|
||||
current_values = current_values or {}
|
||||
|
||||
raw_indexers = current_values.get("NEWZNAB_INDEXERS")
|
||||
if raw_indexers is None:
|
||||
raw_indexers = config.get("NEWZNAB_INDEXERS", [])
|
||||
indexers = _parse_indexer_rows(raw_indexers)
|
||||
|
||||
if indexers:
|
||||
details: list[str] = []
|
||||
all_successful = True
|
||||
for name, url, api_key in indexers:
|
||||
try:
|
||||
success, message = NewznabClient(url, api_key).test_connection()
|
||||
except Exception as e: # noqa: BLE001 — surface unexpected errors to the UI
|
||||
success, message = False, f"Connection failed: {e!s}"
|
||||
all_successful = all_successful and success
|
||||
details.append(f"{name}: {message}")
|
||||
|
||||
summary = (
|
||||
f"Connected to all {len(indexers)} indexers"
|
||||
if all_successful
|
||||
else "One or more Newznab indexers failed"
|
||||
)
|
||||
return {"success": all_successful, "message": summary, "details": details}
|
||||
|
||||
raw_url = str(current_values.get("NEWZNAB_URL") or config.get("NEWZNAB_URL", "") or "")
|
||||
api_key = str(current_values.get("NEWZNAB_API_KEY") or config.get("NEWZNAB_API_KEY", "") or "")
|
||||
|
||||
@@ -64,25 +89,60 @@ def newznab_config_settings() -> list[SettingsField]:
|
||||
default=False,
|
||||
description="Enable searching for books via a Newznab-compatible indexer",
|
||||
),
|
||||
TableField(
|
||||
key="NEWZNAB_INDEXERS",
|
||||
label="Named Indexers",
|
||||
description=(
|
||||
"Add each Newznab-compatible indexer separately. The configured name is shown "
|
||||
"beside every result from that indexer."
|
||||
),
|
||||
columns=[
|
||||
{
|
||||
"key": "name",
|
||||
"label": "Name",
|
||||
"type": "text",
|
||||
"placeholder": "NZBGeek",
|
||||
},
|
||||
{
|
||||
"key": "url",
|
||||
"label": "URL",
|
||||
"type": "text",
|
||||
"placeholder": "https://api.nzbgeek.info",
|
||||
},
|
||||
{
|
||||
"key": "api_key",
|
||||
"label": "API Key",
|
||||
"type": "password",
|
||||
"placeholder": "Optional",
|
||||
},
|
||||
],
|
||||
default=[],
|
||||
add_label="Add Indexer",
|
||||
empty_message=(
|
||||
"No named indexers configured. The legacy single-indexer fields below are used "
|
||||
"as a fallback."
|
||||
),
|
||||
show_when={"field": "NEWZNAB_ENABLED", "value": True},
|
||||
),
|
||||
TextField(
|
||||
key="NEWZNAB_URL",
|
||||
label="Newznab URL",
|
||||
description="Base URL of your Newznab indexer or aggregator",
|
||||
label="Legacy Newznab URL",
|
||||
description="Used only when the named indexer list is empty",
|
||||
placeholder="http://nzbhydra:5076",
|
||||
required=True,
|
||||
required=False,
|
||||
show_when={"field": "NEWZNAB_ENABLED", "value": True},
|
||||
),
|
||||
PasswordField(
|
||||
key="NEWZNAB_API_KEY",
|
||||
label="API Key",
|
||||
description="Your Newznab API key (leave blank if not required)",
|
||||
label="Legacy API Key",
|
||||
description="Used only with the legacy Newznab URL",
|
||||
required=False,
|
||||
show_when={"field": "NEWZNAB_ENABLED", "value": True},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_newznab",
|
||||
label="Test Connection",
|
||||
description="Verify your Newznab configuration",
|
||||
label="Test Connections",
|
||||
description="Verify every named indexer, or the legacy connection when the list is empty",
|
||||
style="primary",
|
||||
callback=_test_newznab_connection,
|
||||
show_when={"field": "NEWZNAB_ENABLED", "value": True},
|
||||
|
||||
@@ -4,7 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||
@@ -48,6 +51,50 @@ _DEFAULT_BOOK_CATS = [7000]
|
||||
NEWZNAB_SEARCH_TIMEOUT_SECONDS = _SEARCH_TIMEOUT
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _NamedClient:
|
||||
"""A configured Newznab connection and its stable cache namespace."""
|
||||
|
||||
name: str
|
||||
connection_id: str
|
||||
client: NewznabClient
|
||||
|
||||
|
||||
def _parse_indexer_rows(raw: object) -> list[tuple[str, str, str]]:
|
||||
"""Normalize structured Newznab indexer settings.
|
||||
|
||||
Invalid/incomplete rows are ignored so one partially edited row cannot disable
|
||||
the other configured indexers.
|
||||
"""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
|
||||
indexers: list[tuple[str, str, str]] = []
|
||||
seen_connections: set[tuple[str, str]] = set()
|
||||
for row in raw:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
raw_url = str(row.get("url") or "").strip()
|
||||
url = normalize_http_url(raw_url)
|
||||
if not url:
|
||||
if raw_url:
|
||||
logger.warning("Newznab: ignoring indexer row with invalid URL '%s'", raw_url)
|
||||
continue
|
||||
|
||||
api_key = str(row.get("api_key") or "").strip()
|
||||
connection_key = (url, api_key)
|
||||
if connection_key in seen_connections:
|
||||
continue
|
||||
seen_connections.add(connection_key)
|
||||
|
||||
configured_name = str(row.get("name") or "").strip()
|
||||
hostname = urlparse(url).hostname or ""
|
||||
name = configured_name or hostname or "Newznab"
|
||||
indexers.append((name, url, api_key))
|
||||
|
||||
return indexers
|
||||
|
||||
|
||||
def _parse_category_ids(raw: object) -> list[int]:
|
||||
"""Parse a configured category setting into Newznab category IDs.
|
||||
|
||||
@@ -146,8 +193,11 @@ def _newznab_result_to_release(
|
||||
else None
|
||||
)
|
||||
|
||||
# Build source_id from GUID
|
||||
source_id = result.get("guid") or f"newznab:{hash(raw_title)}"
|
||||
# Namespace IDs from named connections so identical GUIDs returned by two
|
||||
# indexers cannot overwrite one another in the private release cache.
|
||||
raw_source_id = result.get("guid") or f"newznab:{hash(raw_title)}"
|
||||
connection_id = str(result.get("_newznab_connection_id") or "").strip()
|
||||
source_id = f"newznab:{connection_id}:{raw_source_id}" if connection_id else raw_source_id
|
||||
|
||||
# Cache the raw result for the handler
|
||||
cache_release(source_id, result)
|
||||
@@ -272,6 +322,7 @@ class NewznabSource(ReleaseSource):
|
||||
)
|
||||
|
||||
def _get_client(self) -> NewznabClient | None:
|
||||
"""Build the legacy single-indexer client."""
|
||||
raw_url = str(config.get("NEWZNAB_URL", "") or "")
|
||||
api_key = str(config.get("NEWZNAB_API_KEY", "") or "")
|
||||
|
||||
@@ -284,6 +335,28 @@ class NewznabSource(ReleaseSource):
|
||||
|
||||
return NewznabClient(url, api_key or "")
|
||||
|
||||
def _get_clients(self) -> list[_NamedClient]:
|
||||
"""Build named clients, falling back to the legacy single connection."""
|
||||
configured = _parse_indexer_rows(config.get("NEWZNAB_INDEXERS", []))
|
||||
if configured:
|
||||
clients: list[_NamedClient] = []
|
||||
for name, url, api_key in configured:
|
||||
digest = sha256(f"{name}\0{url}\0{api_key}".encode()).hexdigest()[:16]
|
||||
clients.append(
|
||||
_NamedClient(
|
||||
name=name,
|
||||
connection_id=digest,
|
||||
client=NewznabClient(url, api_key),
|
||||
)
|
||||
)
|
||||
return clients
|
||||
|
||||
legacy_client = self._get_client()
|
||||
if legacy_client is None:
|
||||
return []
|
||||
legacy_name = str(config.get("NEWZNAB_NAME", "") or "").strip() or "Newznab"
|
||||
return [_NamedClient(name=legacy_name, connection_id="legacy", client=legacy_client)]
|
||||
|
||||
def search(
|
||||
self,
|
||||
book: BookMetadata,
|
||||
@@ -293,8 +366,8 @@ class NewznabSource(ReleaseSource):
|
||||
content_type: str = "ebook",
|
||||
) -> list[Release]:
|
||||
"""Search the Newznab indexer for releases matching the book."""
|
||||
client = self._get_client()
|
||||
if not client:
|
||||
clients = self._get_clients()
|
||||
if not clients:
|
||||
logger.warning("Newznab not configured - skipping search")
|
||||
return []
|
||||
|
||||
@@ -324,40 +397,60 @@ class NewznabSource(ReleaseSource):
|
||||
all_results: list[dict] = []
|
||||
|
||||
try:
|
||||
for idx, query in enumerate(queries, start=1):
|
||||
_check_timeout()
|
||||
if len(queries) > 1:
|
||||
logger.debug("Newznab query %d/%d: '%s'", idx, len(queries), query)
|
||||
for connection in clients:
|
||||
try:
|
||||
for idx, query in enumerate(queries, start=1):
|
||||
_check_timeout()
|
||||
if len(queries) > 1:
|
||||
logger.debug(
|
||||
"Newznab [%s] query %d/%d: '%s'",
|
||||
connection.name,
|
||||
idx,
|
||||
len(queries),
|
||||
query,
|
||||
)
|
||||
|
||||
raw = client.search(query=query, categories=categories)
|
||||
raw = connection.client.search(query=query, categories=categories)
|
||||
|
||||
# Auto-expand: retry without category filter if no results
|
||||
if not raw and categories and auto_expand:
|
||||
_check_timeout()
|
||||
logger.info(
|
||||
"Newznab: no results for '%s' with category filter, auto-expanding",
|
||||
query,
|
||||
)
|
||||
raw = client.search(query=query, categories=None)
|
||||
# Auto-expand: retry without category filter if no results
|
||||
if not raw and categories and auto_expand:
|
||||
_check_timeout()
|
||||
logger.info(
|
||||
"Newznab [%s]: no results for '%s' with category filter, "
|
||||
"auto-expanding",
|
||||
connection.name,
|
||||
query,
|
||||
)
|
||||
raw = connection.client.search(query=query, categories=None)
|
||||
|
||||
for r in raw:
|
||||
key = (
|
||||
r.get("guid")
|
||||
or r.get("downloadUrl")
|
||||
or f"{r.get('indexer')}:{r.get('title')}"
|
||||
)
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
all_results.append(r)
|
||||
for raw_result in raw:
|
||||
r = dict(raw_result)
|
||||
# Aggregators can identify the underlying indexer. Plain feeds
|
||||
# generally cannot, so use the user-configured connection name.
|
||||
r["indexer"] = r.get("indexer") or connection.name
|
||||
r["_newznab_connection_id"] = connection.connection_id
|
||||
key = (
|
||||
connection.connection_id,
|
||||
r.get("guid")
|
||||
or r.get("downloadUrl")
|
||||
or f"{r.get('indexer')}:{r.get('title')}",
|
||||
)
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
all_results.append(r)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Newznab search failed for %s", connection.name)
|
||||
|
||||
except TimeoutError as e:
|
||||
logger.warning("Newznab search timed out: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Newznab search failed")
|
||||
return []
|
||||
|
||||
results = [_newznab_result_to_release(r, content_type, categories) for r in all_results]
|
||||
if plan.indexers:
|
||||
selected_indexers = set(plan.indexers)
|
||||
results = [r for r in results if r.indexer in selected_indexers]
|
||||
|
||||
if results:
|
||||
nzb_count = sum(1 for r in results if r.protocol == ReleaseProtocol.NZB)
|
||||
@@ -379,5 +472,7 @@ class NewznabSource(ReleaseSource):
|
||||
def is_available(self) -> bool:
|
||||
if not config.get("NEWZNAB_ENABLED", False):
|
||||
return False
|
||||
if _parse_indexer_rows(config.get("NEWZNAB_INDEXERS", [])):
|
||||
return True
|
||||
url = normalize_http_url(str(config.get("NEWZNAB_URL", "") or ""))
|
||||
return bool(url)
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
from collections.abc import Mapping
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from http import HTTPStatus
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
@@ -18,6 +20,19 @@ logger = setup_logger(__name__)
|
||||
_HTTP_STATUS_UNAUTHORIZED = HTTPStatus.UNAUTHORIZED
|
||||
_BOOK_CATEGORY_RANGE_START = 7000
|
||||
_BOOK_CATEGORY_RANGE_END = 8000
|
||||
|
||||
# Prowlarr's own JSON endpoints (status, indexer list) read local state and answer
|
||||
# in milliseconds, so they keep a short timeout. A Torznab search is different: it
|
||||
# is Prowlarr proxying a live request to the tracker, which for a Cloudflare-fronted
|
||||
# indexer means waiting on FlareSolverr to solve a challenge. A cold challenge
|
||||
# routinely runs past a minute, so indexer searches get their own, longer budget.
|
||||
DEFAULT_INDEXER_TIMEOUT_SECONDS = 90
|
||||
MIN_INDEXER_TIMEOUT_SECONDS = 5
|
||||
MAX_INDEXER_TIMEOUT_SECONDS = 300
|
||||
|
||||
# Connecting to Prowlarr itself is a LAN hop; only the read is allowed to be slow.
|
||||
_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
_PROWLARR_CLIENT_ERRORS = (
|
||||
requests.exceptions.RequestException,
|
||||
OSError,
|
||||
@@ -27,6 +42,37 @@ _PROWLARR_CLIENT_ERRORS = (
|
||||
)
|
||||
|
||||
|
||||
class ProwlarrSearchError(RuntimeError):
|
||||
"""A Torznab search could not be completed.
|
||||
|
||||
Deliberately distinct from an empty result list. Reporting a failed search as
|
||||
"this indexer has nothing" is what turns a slow FlareSolverr challenge into
|
||||
"No releases found for this book" in the UI (#1249), and it also makes the
|
||||
auto-expand retry fire a second request on top of the one still running.
|
||||
"""
|
||||
|
||||
|
||||
def resolve_indexer_timeout(timeout: object = None) -> int:
|
||||
"""Resolve the per-indexer search timeout, falling back to config.
|
||||
|
||||
Out-of-range and unparsable values are clamped rather than rejected: this
|
||||
feeds an HTTP timeout, and a bad setting should not take searching down.
|
||||
"""
|
||||
if timeout is None:
|
||||
timeout = config.get("PROWLARR_INDEXER_TIMEOUT", DEFAULT_INDEXER_TIMEOUT_SECONDS)
|
||||
|
||||
resolved = coerce_int_like(timeout)
|
||||
if resolved is None:
|
||||
logger.warning(
|
||||
"Invalid PROWLARR_INDEXER_TIMEOUT %r - using %ss",
|
||||
timeout,
|
||||
DEFAULT_INDEXER_TIMEOUT_SECONDS,
|
||||
)
|
||||
return DEFAULT_INDEXER_TIMEOUT_SECONDS
|
||||
|
||||
return max(MIN_INDEXER_TIMEOUT_SECONDS, min(MAX_INDEXER_TIMEOUT_SECONDS, resolved))
|
||||
|
||||
|
||||
class IndexerSeedSettings(TypedDict, total=False):
|
||||
ratio_limit: float
|
||||
seeding_time_limit_minutes: int
|
||||
@@ -77,11 +123,23 @@ def _get_field_value(fields: object, name: str) -> object | None:
|
||||
class ProwlarrClient:
|
||||
"""Client for interacting with the Prowlarr API."""
|
||||
|
||||
def __init__(self, url: str, api_key: str, timeout: int = 30) -> None:
|
||||
"""Initialize the API client with base URL, key, and timeout."""
|
||||
def __init__(
|
||||
self, url: str, api_key: str, timeout: int = 30, indexer_timeout: int | None = None
|
||||
) -> None:
|
||||
"""Initialize the API client with base URL, key, and timeouts.
|
||||
|
||||
Args:
|
||||
url: Prowlarr base URL.
|
||||
api_key: Prowlarr API key.
|
||||
timeout: Timeout for Prowlarr's own JSON endpoints.
|
||||
indexer_timeout: Timeout for Torznab searches, which Prowlarr proxies
|
||||
out to the tracker. Defaults to PROWLARR_INDEXER_TIMEOUT.
|
||||
|
||||
"""
|
||||
self.base_url = normalize_http_url(url)
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.indexer_timeout = resolve_indexer_timeout(indexer_timeout)
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(
|
||||
{
|
||||
@@ -178,6 +236,34 @@ class ProwlarrClient:
|
||||
logger.exception("Failed to get indexers")
|
||||
return []
|
||||
|
||||
def get_disabled_indexers(self, *, now: datetime | None = None) -> dict[int, str]:
|
||||
"""Get indexers in failure back-off, keyed by ID with their disabledTill value."""
|
||||
try:
|
||||
entries = _normalize_json_object_list(
|
||||
self._request("GET", "/api/v1/indexerstatus"),
|
||||
context="Prowlarr indexer status",
|
||||
)
|
||||
except _PROWLARR_CLIENT_ERRORS:
|
||||
logger.exception("Failed to get indexer status")
|
||||
return {}
|
||||
|
||||
current = now or datetime.now(UTC)
|
||||
disabled: dict[int, str] = {}
|
||||
for entry in entries:
|
||||
indexer_id = coerce_int_like(entry.get("indexerId"))
|
||||
disabled_till_raw = entry.get("disabledTill")
|
||||
if indexer_id is None or not disabled_till_raw:
|
||||
continue
|
||||
try:
|
||||
disabled_till = datetime.fromisoformat(str(disabled_till_raw))
|
||||
except ValueError:
|
||||
continue
|
||||
if disabled_till.tzinfo is None:
|
||||
disabled_till = disabled_till.replace(tzinfo=UTC)
|
||||
if disabled_till > current:
|
||||
disabled[indexer_id] = str(disabled_till_raw)
|
||||
return disabled
|
||||
|
||||
def get_enabled_indexers_detailed(
|
||||
self, *, raise_on_error: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -307,6 +393,12 @@ class ProwlarrClient:
|
||||
|
||||
This returns richer fields (e.g., author/booktitle, torznab tags like
|
||||
FreeLeech) than the JSON /api/v1/search endpoint.
|
||||
|
||||
Raises:
|
||||
ProwlarrSearchError: The search could not be completed. An empty list
|
||||
strictly means the indexer answered with no matches, never that
|
||||
the request timed out or errored.
|
||||
|
||||
"""
|
||||
if not query:
|
||||
return []
|
||||
@@ -329,7 +421,7 @@ class ProwlarrClient:
|
||||
response = self._session.get(
|
||||
url=url,
|
||||
params=params,
|
||||
timeout=self.timeout,
|
||||
timeout=(_CONNECT_TIMEOUT_SECONDS, self.indexer_timeout),
|
||||
headers={
|
||||
# Override the session default JSON accept header.
|
||||
"Accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8"
|
||||
@@ -347,9 +439,20 @@ class ProwlarrClient:
|
||||
for r in results:
|
||||
if r.get("indexerId") is None:
|
||||
r["indexerId"] = int(indexer_id)
|
||||
except Exception:
|
||||
except requests.exceptions.Timeout as e:
|
||||
logger.warning(
|
||||
"Prowlarr Torznab search for indexer %s timed out after %ss. An indexer "
|
||||
"behind FlareSolverr can need far longer than that on a cold Cloudflare "
|
||||
"challenge - raise PROWLARR_INDEXER_TIMEOUT if this keeps happening.",
|
||||
indexer_id,
|
||||
self.indexer_timeout,
|
||||
)
|
||||
msg = f"indexer {indexer_id} did not respond within {self.indexer_timeout}s"
|
||||
raise ProwlarrSearchError(msg) from e
|
||||
except Exception as e:
|
||||
logger.exception("Prowlarr Torznab search failed for indexer %s", indexer_id)
|
||||
return []
|
||||
msg = f"indexer {indexer_id} search failed: {e}"
|
||||
raise ProwlarrSearchError(msg) from e
|
||||
else:
|
||||
return results
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from shelfmark.core.search_plan import build_release_search_plan
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
client_prefers_torrent_file,
|
||||
get_client,
|
||||
list_configured_clients,
|
||||
)
|
||||
@@ -28,6 +29,10 @@ from shelfmark.download.clients.base_handler import (
|
||||
DownloadRequest,
|
||||
ExternalClientHandler,
|
||||
)
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_file_list_from_torrent,
|
||||
extract_torrent_info,
|
||||
)
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
from shelfmark.release_sources import register_handler
|
||||
from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient
|
||||
@@ -38,12 +43,14 @@ from shelfmark.release_sources.prowlarr.utils import (
|
||||
coerce_int_like,
|
||||
get_preferred_download_url,
|
||||
get_protocol,
|
||||
sanitize_download_url,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.download.postprocess.packs import PackFile
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -127,6 +134,24 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
|
||||
return settings.get(indexer_id)
|
||||
|
||||
def list_files(self, release_data: dict[str, Any]) -> list[PackFile] | None:
|
||||
"""List a cached torrent release's files from its .torrent, without downloading.
|
||||
|
||||
Magnet-only and usenet releases cannot be listed ahead of time.
|
||||
"""
|
||||
source_id = str(release_data.get("source_id") or "")
|
||||
prowlarr_result = get_release(source_id) if source_id else None
|
||||
if not prowlarr_result or get_protocol(prowlarr_result) != "torrent":
|
||||
return None
|
||||
download_url = sanitize_download_url(str(prowlarr_result.get("downloadUrl") or "").strip())
|
||||
if not download_url or download_url.startswith("magnet:"):
|
||||
return None
|
||||
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
|
||||
info = extract_torrent_info(download_url, expected_hash=expected_hash)
|
||||
if not info.torrent_data:
|
||||
return None
|
||||
return extract_file_list_from_torrent(info.torrent_data)
|
||||
|
||||
def _get_client(self, protocol: str) -> DownloadClient | None:
|
||||
"""Compatibility shim so module-level patching still works in tests."""
|
||||
return get_client(protocol)
|
||||
@@ -223,18 +248,20 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
status_callback("error", EXPIRED_LINK_REFRESH_ERROR)
|
||||
return None
|
||||
|
||||
# Extract download URL
|
||||
download_url = get_preferred_download_url(prowlarr_result)
|
||||
if not download_url:
|
||||
status_callback("error", "No download URL available")
|
||||
return None
|
||||
|
||||
# Determine protocol
|
||||
protocol = get_protocol(prowlarr_result)
|
||||
if protocol == "unknown":
|
||||
status_callback("error", "Could not determine download protocol")
|
||||
return None
|
||||
|
||||
download_url = get_preferred_download_url(
|
||||
prowlarr_result,
|
||||
prefer_torrent_file=client_prefers_torrent_file(protocol),
|
||||
)
|
||||
if not download_url:
|
||||
status_callback("error", "No download URL available")
|
||||
return None
|
||||
|
||||
release_name = prowlarr_result.get("title") or task.title or "Unknown"
|
||||
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
|
||||
|
||||
@@ -297,6 +324,8 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
search_title=title,
|
||||
search_author=task.author,
|
||||
)
|
||||
# No language default here on purpose: this re-finds one exact release by its
|
||||
# guid, and Prowlarr does not filter on plan.languages anyway.
|
||||
plan = build_release_search_plan(
|
||||
book,
|
||||
indexers=[indexer] if indexer is not None else None,
|
||||
|
||||
@@ -10,12 +10,18 @@ from shelfmark.core.settings_registry import (
|
||||
CheckboxField,
|
||||
HeadingField,
|
||||
MultiSelectField,
|
||||
NumberField,
|
||||
PasswordField,
|
||||
SettingsField,
|
||||
TextField,
|
||||
register_settings,
|
||||
)
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.release_sources.prowlarr.api import (
|
||||
DEFAULT_INDEXER_TIMEOUT_SECONDS,
|
||||
MAX_INDEXER_TIMEOUT_SECONDS,
|
||||
MIN_INDEXER_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
# ==================== Dynamic Options Loaders ====================
|
||||
|
||||
@@ -183,6 +189,20 @@ def prowlarr_config_settings() -> list[SettingsField]:
|
||||
default=[],
|
||||
show_when={"field": "PROWLARR_ENABLED", "value": True},
|
||||
),
|
||||
NumberField(
|
||||
key="PROWLARR_INDEXER_TIMEOUT",
|
||||
label="Indexer Search Timeout (seconds)",
|
||||
description=(
|
||||
"How long to wait for a single indexer to answer a search. Indexers behind "
|
||||
"FlareSolverr can need 90 seconds or more while a cold Cloudflare challenge "
|
||||
"is solved; raise this if searches come back empty and the Prowlarr log "
|
||||
"shows the search still running."
|
||||
),
|
||||
default=DEFAULT_INDEXER_TIMEOUT_SECONDS,
|
||||
min_value=MIN_INDEXER_TIMEOUT_SECONDS,
|
||||
max_value=MAX_INDEXER_TIMEOUT_SECONDS,
|
||||
show_when={"field": "PROWLARR_ENABLED", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="PROWLARR_AUTO_EXPAND",
|
||||
label="Auto-expand search on no results",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, ClassVar, NoReturn
|
||||
|
||||
@@ -11,6 +12,7 @@ if TYPE_CHECKING:
|
||||
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
from shelfmark.core.author_match import AUTHOR_UNKNOWN, author_affinity
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.languages import normalize_language
|
||||
from shelfmark.core.logger import setup_logger
|
||||
@@ -30,9 +32,14 @@ from shelfmark.release_sources import (
|
||||
ReleaseProtocol,
|
||||
ReleaseSource,
|
||||
SortOption,
|
||||
SourceUnavailableError,
|
||||
register_source,
|
||||
)
|
||||
from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient
|
||||
from shelfmark.release_sources.prowlarr.api import (
|
||||
IndexerSeedSettings,
|
||||
ProwlarrClient,
|
||||
ProwlarrSearchError,
|
||||
)
|
||||
from shelfmark.release_sources.prowlarr.cache import cache_release
|
||||
from shelfmark.release_sources.prowlarr.utils import (
|
||||
build_source_id,
|
||||
@@ -50,10 +57,10 @@ _PROWLARR_SOURCE_ERRORS = (AttributeError, OSError, RuntimeError, TypeError, Val
|
||||
# Prowlarr indexer priority is 1-50 and lower is preferred; unknown sorts last.
|
||||
_UNRANKED_INDEXER_RANK = 51
|
||||
|
||||
# Errors that can surface from ProwlarrClient.get_indexer_seed_settings(). The
|
||||
# Errors that can surface from a ProwlarrClient call that talks to Prowlarr. The
|
||||
# client raises requests exceptions (subclasses of OSError via IOError lineage
|
||||
# is not guaranteed), so include RequestException explicitly.
|
||||
_PROWLARR_SEED_SETTINGS_ERRORS = (*_PROWLARR_SOURCE_ERRORS, requests.exceptions.RequestException)
|
||||
_PROWLARR_REQUEST_ERRORS = (*_PROWLARR_SOURCE_ERRORS, requests.exceptions.RequestException)
|
||||
|
||||
|
||||
def _raise_timeout_error(message: str) -> NoReturn:
|
||||
@@ -139,6 +146,36 @@ def _build_indexer_priority(indexers: list[dict]) -> dict[int, int]:
|
||||
return priority
|
||||
|
||||
|
||||
def _drop_unknown_indexer_ids(
|
||||
selected_ids: list[int] | None, indexers: list[dict]
|
||||
) -> list[int] | None:
|
||||
"""Keep only selected indexer ids Prowlarr still serves.
|
||||
|
||||
An indexer removed or disabled in Prowlarr stays in the saved selection,
|
||||
where settings can no longer show it - so it cannot be unselected, and every
|
||||
search keeps querying an indexer that is gone (#1283). Dropping it here
|
||||
keeps the saved selection intact for an indexer that comes back.
|
||||
"""
|
||||
if selected_ids is None:
|
||||
return None
|
||||
|
||||
live_ids = {
|
||||
indexer_id
|
||||
for indexer in indexers
|
||||
if (indexer_id := _coerce_indexer_id(indexer.get("id"))) is not None
|
||||
}
|
||||
kept = [indexer_id for indexer_id in selected_ids if indexer_id in live_ids]
|
||||
|
||||
stale = [indexer_id for indexer_id in selected_ids if indexer_id not in live_ids]
|
||||
if stale:
|
||||
logger.warning(
|
||||
"Skipping selected Prowlarr indexers that are no longer enabled in Prowlarr: %s",
|
||||
stale,
|
||||
)
|
||||
|
||||
return kept
|
||||
|
||||
|
||||
def _rank_for_indexer_id(indexer_id: object, priority: dict[int, int]) -> int:
|
||||
"""Preference rank for an indexer id. Lower wins, unknown ranks last."""
|
||||
coerced = _coerce_indexer_id(indexer_id)
|
||||
@@ -232,6 +269,36 @@ ALL_BOOK_FORMATS = AUDIOBOOK_FORMATS + EBOOK_FORMATS
|
||||
# Backend safeguard: cap total Prowlarr search time per request.
|
||||
PROWLARR_SEARCH_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
# The overall budget has to leave room for at least a couple of indexers to spend
|
||||
# their full per-indexer timeout, otherwise raising PROWLARR_INDEXER_TIMEOUT for a
|
||||
# Cloudflare-fronted tracker just moves the cutoff here. Capped short of the
|
||||
# gunicorn worker timeout (300s) so the worker is never the thing that gives up.
|
||||
_MAX_SEARCH_BUDGET_SECONDS = 240.0
|
||||
|
||||
|
||||
def _search_budget_seconds(indexer_timeout: int) -> float:
|
||||
"""Total time one Prowlarr search may spend, scaled to the per-indexer timeout."""
|
||||
return min(
|
||||
_MAX_SEARCH_BUDGET_SECONDS,
|
||||
max(PROWLARR_SEARCH_TIMEOUT_SECONDS, indexer_timeout * 2.0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _IndexerSearchOutcome:
|
||||
"""What one pass over the target indexers produced.
|
||||
|
||||
Separates "every indexer answered, none had this book" from "the indexers
|
||||
never answered", which the caller has to tell apart before it decides to
|
||||
auto-expand or to report the search as failed.
|
||||
"""
|
||||
|
||||
results: list[dict]
|
||||
attempted: int = 0
|
||||
failed: int = 0
|
||||
skipped: int = 0
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
def _extract_format(title: str) -> str | None:
|
||||
"""Extract ebook/audiobook format from release title (extension, bracketed, or standalone)."""
|
||||
@@ -282,19 +349,25 @@ def _extract_mam_language(raw_title: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_mam_formats(raw_title: str) -> list[str]:
|
||||
"""Extract a list of formats from MyAnonamouse titles.
|
||||
def _split_mam_formats(raw_title: str) -> tuple[list[str], list[str]]:
|
||||
"""Split the format tokens of a MyAnonamouse title into (recognized, unrecognized).
|
||||
|
||||
Prowlarr's MAM parser appends a structured bracket segment like:
|
||||
[ENG / EPUB MOBI PDF]
|
||||
|
||||
We only trust this structured segment (and do not attempt generic title
|
||||
heuristics for other indexers).
|
||||
|
||||
Tokens after the "/" that Shelfmark does not know as a book or audiobook format
|
||||
(e.g. ``[ENG / AVI]``) are returned separately so the UI can warn that the release
|
||||
will download but cannot be processed, instead of showing a bare content-type icon
|
||||
that looks like an ordinary result.
|
||||
"""
|
||||
if not raw_title:
|
||||
return []
|
||||
return [], []
|
||||
|
||||
format_set = set(ALL_BOOK_FORMATS)
|
||||
first_unrecognized: list[str] | None = None
|
||||
for bracket in re.findall(r"\[([^\]]+)\]", raw_title):
|
||||
if "/" not in bracket:
|
||||
continue
|
||||
@@ -303,15 +376,26 @@ def _extract_mam_formats(raw_title: str) -> list[str]:
|
||||
tokens = re.findall(r"[A-Za-z0-9]+", after_slash)
|
||||
|
||||
formats: list[str] = []
|
||||
unrecognized: list[str] = []
|
||||
for token in tokens:
|
||||
fmt = token.lower()
|
||||
if fmt in format_set and fmt not in formats:
|
||||
formats.append(fmt)
|
||||
if fmt in format_set:
|
||||
if fmt not in formats:
|
||||
formats.append(fmt)
|
||||
elif fmt not in unrecognized:
|
||||
unrecognized.append(fmt)
|
||||
|
||||
if formats:
|
||||
return formats
|
||||
return formats, unrecognized
|
||||
if unrecognized and first_unrecognized is None:
|
||||
first_unrecognized = unrecognized
|
||||
|
||||
return []
|
||||
return [], first_unrecognized or []
|
||||
|
||||
|
||||
def _extract_mam_formats(raw_title: str) -> list[str]:
|
||||
"""Extract the recognized formats from a MyAnonamouse title (see _split_mam_formats)."""
|
||||
return _split_mam_formats(raw_title)[0]
|
||||
|
||||
|
||||
def _formats_display(formats: list[str]) -> str | None:
|
||||
@@ -450,6 +534,7 @@ def _prowlarr_result_to_release(
|
||||
|
||||
format_detected: str | None = None
|
||||
formats: list[str] = []
|
||||
unrecognized_formats: list[str] = []
|
||||
formats_display: str | None = None
|
||||
language_detected: str | None = None
|
||||
if enable_format_detection:
|
||||
@@ -457,7 +542,7 @@ def _prowlarr_result_to_release(
|
||||
if book_title:
|
||||
title = book_title
|
||||
|
||||
formats = _extract_mam_formats(str(raw_title or ""))
|
||||
formats, unrecognized_formats = _split_mam_formats(str(raw_title or ""))
|
||||
format_detected = formats[0] if formats else None
|
||||
formats_display = _formats_display(formats)
|
||||
language_detected = _extract_mam_language(str(raw_title or ""))
|
||||
@@ -519,6 +604,9 @@ def _prowlarr_result_to_release(
|
||||
"info_hash": result.get("infoHash"),
|
||||
"formats": formats or None,
|
||||
"formats_display": formats_display,
|
||||
# Format tokens the indexer declared but Shelfmark can't process (e.g. a MAM
|
||||
# "[ENG / AVI]"). Lets the UI warn instead of showing a bare content icon.
|
||||
"unrecognized_formats": unrecognized_formats or None,
|
||||
# Raw torznab attributes for rich tooltips (enriched indexers)
|
||||
"torznab_attrs": result.get("torznabAttrs"),
|
||||
},
|
||||
@@ -539,7 +627,7 @@ def _fetch_indexer_seed_settings(
|
||||
"""Fetch per-indexer share limits, falling back to last-known-good on failure."""
|
||||
try:
|
||||
fetched = client.get_indexer_seed_settings(restrict_to=indexer_ids)
|
||||
except _PROWLARR_SEED_SETTINGS_ERRORS:
|
||||
except _PROWLARR_REQUEST_ERRORS:
|
||||
with _seed_settings_lock:
|
||||
fallback = dict(_last_known_seed_settings)
|
||||
logger.warning(
|
||||
@@ -895,8 +983,17 @@ class ProwlarrSource(ReleaseSource):
|
||||
|
||||
try:
|
||||
auto_expand_enabled = config.get("PROWLARR_AUTO_EXPAND", False)
|
||||
deadline = time.monotonic() + PROWLARR_SEARCH_TIMEOUT_SECONDS
|
||||
enabled_indexers = client.get_enabled_indexers_detailed()
|
||||
search_budget = _search_budget_seconds(client.indexer_timeout)
|
||||
deadline = time.monotonic() + search_budget
|
||||
try:
|
||||
enabled_indexers = client.get_enabled_indexers_detailed(raise_on_error=True)
|
||||
except _PROWLARR_REQUEST_ERRORS as e:
|
||||
# Prowlarr itself is unreachable. Swallowing this leaves the search
|
||||
# with no indexers to query, which the UI renders as "No releases
|
||||
# found for this book" - the same lie as a swallowed timeout (#1249).
|
||||
msg = f"could not reach Prowlarr: {e}"
|
||||
raise SourceUnavailableError(msg) from e
|
||||
indexer_ids = _drop_unknown_indexer_ids(indexer_ids, enabled_indexers)
|
||||
indexer_priority = _build_indexer_priority(enabled_indexers)
|
||||
# Some indexers benefit from title+author queries and extra format detection.
|
||||
enriched_indexer_ids = client.get_enriched_indexer_ids(
|
||||
@@ -911,65 +1008,111 @@ class ProwlarrSource(ReleaseSource):
|
||||
|
||||
def _check_timeout() -> None:
|
||||
if time.monotonic() > deadline:
|
||||
_raise_timeout_error(
|
||||
f"Prowlarr search timed out after {int(PROWLARR_SEARCH_TIMEOUT_SECONDS)}s"
|
||||
)
|
||||
_raise_timeout_error(f"Prowlarr search timed out after {int(search_budget)}s")
|
||||
|
||||
def search_indexers(
|
||||
query: str, cats: list[int] | None, *, enriched_query: str | None = None
|
||||
) -> list[dict]:
|
||||
"""Search indexers with given categories via Torznab/Newznab."""
|
||||
results: list[dict] = []
|
||||
# Prowlarr's own search skips an indexer in failure back-off; the
|
||||
# per-indexer Torznab endpoint answers 429 instead.
|
||||
try:
|
||||
disabled_indexers = client.get_disabled_indexers()
|
||||
except _PROWLARR_SOURCE_ERRORS as e:
|
||||
logger.warning("Failed to load Prowlarr indexer status: %s", e)
|
||||
disabled_indexers = {}
|
||||
if disabled_indexers:
|
||||
logger.info(
|
||||
"Prowlarr: skipping indexer(s) in failure back-off: %s",
|
||||
", ".join(
|
||||
f"{indexer_id} (till {till})"
|
||||
for indexer_id, till in sorted(disabled_indexers.items())
|
||||
),
|
||||
)
|
||||
|
||||
def search_indexers(query: str, cats: list[int] | None) -> _IndexerSearchOutcome:
|
||||
"""Search indexers with given categories via Torznab/Newznab.
|
||||
|
||||
Every indexer gets the same title-only query. Enriched indexers used
|
||||
to be sent "{title} {author}", but an indexer that ANDs its search
|
||||
terms (MyAnonamouse) returns nothing whenever the metadata provider
|
||||
spells the author differently to the tracker - "Timothy Ferriss" vs
|
||||
"Tim Ferriss" - and the UI reports the book as missing (#1293). The
|
||||
author still decides ordering below, where a spelling difference
|
||||
costs a release its position rather than its existence.
|
||||
"""
|
||||
outcome = _IndexerSearchOutcome(results=[])
|
||||
target_indexer_ids = self._get_search_indexer_ids(client, indexer_ids, cats)
|
||||
if not target_indexer_ids:
|
||||
return results
|
||||
return outcome
|
||||
|
||||
for indexer_id in target_indexer_ids:
|
||||
if indexer_id in disabled_indexers:
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
_check_timeout()
|
||||
indexer_query = (
|
||||
enriched_query
|
||||
if indexer_id in enriched_indexer_ids_set and enriched_query
|
||||
else query
|
||||
)
|
||||
raw = client.torznab_search(
|
||||
indexer_id=indexer_id,
|
||||
query=indexer_query,
|
||||
categories=cats,
|
||||
search_type="book",
|
||||
)
|
||||
outcome.attempted += 1
|
||||
try:
|
||||
raw = client.torznab_search(
|
||||
indexer_id=indexer_id,
|
||||
query=query,
|
||||
categories=cats,
|
||||
search_type="book",
|
||||
)
|
||||
except ProwlarrSearchError as e:
|
||||
# One unreachable indexer must not sink the others, but it
|
||||
# is not "no results" either - record it so the caller can
|
||||
# report a failed search instead of an empty one.
|
||||
outcome.failed += 1
|
||||
outcome.last_error = str(e)
|
||||
continue
|
||||
if raw:
|
||||
results.extend(raw)
|
||||
outcome.results.extend(raw)
|
||||
|
||||
return results
|
||||
return outcome
|
||||
|
||||
seen_keys: set[tuple[int | None, str]] = set()
|
||||
all_results: list[dict] = []
|
||||
attempted_searches = 0
|
||||
failed_searches = 0
|
||||
skipped_searches = 0
|
||||
last_search_error: str | None = None
|
||||
|
||||
for idx, variant in enumerate(variants, start=1):
|
||||
_check_timeout()
|
||||
query = variant.title
|
||||
enriched_query = variant.query # title + author
|
||||
|
||||
if len(variants) > 1:
|
||||
logger.debug("Prowlarr query %s/%s: '%s'", idx, len(variants), query)
|
||||
|
||||
raw_results = search_indexers(
|
||||
query=query, cats=categories, enriched_query=enriched_query
|
||||
)
|
||||
outcome = search_indexers(query=query, cats=categories)
|
||||
|
||||
# Auto-expand: if no results with categories and auto-expand enabled, retry without
|
||||
if not raw_results and categories and auto_expand_enabled:
|
||||
# Auto-expand: if no results with categories and auto-expand enabled, retry without.
|
||||
# Only when every indexer actually answered: a failed search says nothing about
|
||||
# whether the category filter is what hid the book, and retrying it stacks a second
|
||||
# request on an indexer that is still busy solving a Cloudflare challenge (#1249).
|
||||
if (
|
||||
not outcome.results
|
||||
and not outcome.failed
|
||||
and outcome.attempted
|
||||
and categories
|
||||
and auto_expand_enabled
|
||||
):
|
||||
_check_timeout()
|
||||
logger.info(
|
||||
"Prowlarr: no results for query '%s' with category filter, auto-expanding search",
|
||||
query,
|
||||
)
|
||||
raw_results = search_indexers(
|
||||
query=query, cats=None, enriched_query=enriched_query
|
||||
)
|
||||
expanded = search_indexers(query=query, cats=None)
|
||||
outcome.results = expanded.results
|
||||
outcome.attempted += expanded.attempted
|
||||
outcome.failed += expanded.failed
|
||||
outcome.skipped += expanded.skipped
|
||||
outcome.last_error = expanded.last_error or outcome.last_error
|
||||
self.last_search_type = "expanded"
|
||||
|
||||
for r in raw_results:
|
||||
attempted_searches += outcome.attempted
|
||||
failed_searches += outcome.failed
|
||||
skipped_searches += outcome.skipped
|
||||
last_search_error = outcome.last_error or last_search_error
|
||||
|
||||
for r in outcome.results:
|
||||
key = _result_dedup_key(r)
|
||||
if key is not None:
|
||||
if key in seen_keys:
|
||||
@@ -977,6 +1120,14 @@ class ProwlarrSource(ReleaseSource):
|
||||
seen_keys.add(key)
|
||||
all_results.append(r)
|
||||
|
||||
if failed_searches:
|
||||
logger.warning(
|
||||
"Prowlarr: %s of %s indexer searches failed (%s)",
|
||||
failed_searches,
|
||||
attempted_searches,
|
||||
last_search_error,
|
||||
)
|
||||
|
||||
if config.get("PROWLARR_COLLAPSE_DUPLICATES", True):
|
||||
before_collapse = len(all_results)
|
||||
all_results = _collapse_duplicate_indexer_results(all_results, indexer_priority)
|
||||
@@ -988,6 +1139,10 @@ class ProwlarrSource(ReleaseSource):
|
||||
|
||||
results: list[Release] = []
|
||||
enriched_source_ids: set[str] = set()
|
||||
affinity_by_source_id: dict[str, int] = {}
|
||||
# A manual query is the user's own words; ranking it against the
|
||||
# metadata author would second-guess what they typed.
|
||||
wanted_author = "" if plan.manual_query else plan.author
|
||||
|
||||
for raw_result in all_results:
|
||||
result_with_seed_settings = _apply_indexer_seed_settings(
|
||||
@@ -1007,13 +1162,20 @@ class ProwlarrSource(ReleaseSource):
|
||||
if idx_id_int is not None and idx_id_int in indexer_priority:
|
||||
release.extra["indexer_priority"] = indexer_priority[idx_id_int]
|
||||
results.append(release)
|
||||
affinity_by_source_id[release.source_id] = author_affinity(
|
||||
wanted_author, release.extra.get("author")
|
||||
)
|
||||
|
||||
if is_enriched:
|
||||
enriched_source_ids.add(release.source_id)
|
||||
|
||||
# Indexer priority first: it is an explicit user preference. Author
|
||||
# agreement then orders what one indexer returned, so the editions that
|
||||
# match the requested author lead and the rest stay reachable below.
|
||||
results.sort(
|
||||
key=lambda r: (
|
||||
_release_indexer_rank(r, indexer_priority),
|
||||
affinity_by_source_id.get(r.source_id, AUTHOR_UNKNOWN),
|
||||
0 if r.source_id in enriched_source_ids else 1,
|
||||
)
|
||||
)
|
||||
@@ -1033,6 +1195,10 @@ class ProwlarrSource(ReleaseSource):
|
||||
else:
|
||||
logger.debug("Prowlarr: no results found")
|
||||
|
||||
except SourceUnavailableError:
|
||||
# Already carries its own message for the caller to surface; the blanket
|
||||
# handler below would turn it back into a silent empty result.
|
||||
raise
|
||||
except TimeoutError as e:
|
||||
logger.warning("Prowlarr search timed out: %s", e)
|
||||
raise
|
||||
@@ -1040,6 +1206,19 @@ class ProwlarrSource(ReleaseSource):
|
||||
logger.exception("Prowlarr search failed")
|
||||
return []
|
||||
else:
|
||||
# An empty list is the UI's "No releases found for this book", so it has
|
||||
# to mean the indexers answered and had nothing. When they failed instead,
|
||||
# say so rather than blaming the book (#1249).
|
||||
if not results and failed_searches:
|
||||
msg = (
|
||||
f"{failed_searches} of {attempted_searches} indexer searches failed "
|
||||
f"({last_search_error})"
|
||||
)
|
||||
raise SourceUnavailableError(msg)
|
||||
if not results and not attempted_searches and skipped_searches:
|
||||
until = max(disabled_indexers.values(), default="later")
|
||||
msg = f"every indexer is disabled by Prowlarr after recent failures (until {until})"
|
||||
raise SourceUnavailableError(msg)
|
||||
return results
|
||||
|
||||
def is_available(self) -> bool:
|
||||
|
||||
@@ -92,17 +92,19 @@ def get_protocol(result: dict) -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_preferred_download_url(result: dict) -> str:
|
||||
def get_preferred_download_url(result: dict, *, prefer_torrent_file: bool = False) -> str:
|
||||
"""Pick the best URL to hand to a download client.
|
||||
|
||||
For torrent results, prefer magnetUrl when available (downloadUrl may be a
|
||||
Prowlarr proxy URL that needs auth/headers).
|
||||
For torrent results, prefer magnetUrl when available unless the configured
|
||||
client needs the fetched .torrent bytes.
|
||||
"""
|
||||
protocol = str(result.get("protocol", "")).lower()
|
||||
magnet_url = str(result.get("magnetUrl") or "").strip()
|
||||
download_url = sanitize_download_url(str(result.get("downloadUrl") or "").strip())
|
||||
|
||||
if protocol == "torrent":
|
||||
if prefer_torrent_file:
|
||||
return download_url or magnet_url
|
||||
return magnet_url or download_url
|
||||
if protocol == "usenet":
|
||||
return download_url or magnet_url
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: shelfmark
|
||||
description: Tool for downloading books.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
# Shelfmark Book Download Skill
|
||||
|
||||
Use this skill to search for and download books from a local Shelfmark instance using Playwright.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Shelfmark must be running at `http://localhost:8084/`
|
||||
- Use `playwright-cli` skill for browser automation capabilities
|
||||
- Python 3.10+ with `playwright` package installed
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Show help information for download script
|
||||
python3 /home/username/.agents/skills/shelfmark/download_books.py -h
|
||||
|
||||
# Download a single book
|
||||
python3 /home/username/.agents/skills/shelfmark/download_books.py '[{"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"}]'
|
||||
|
||||
# Download multiple books
|
||||
python3 /home/username/.agents/skills/shelfmark/download_books.py '[{"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"}, {"title": "Oliver Twist", "author": "Charles Dickens"}, {"title": "Frankenstein", "author": "Marry Shelley"}]'
|
||||
|
||||
# Check calibre database before downloading (skip if already present)
|
||||
python3 /home/username/.agents/skills/shelfmark/download_books.py --check-calibre '[{"title": "Frankenstein", "author": "Marry Shelley"}]'
|
||||
|
||||
# Load books from a JSON file
|
||||
python3 /home/username/.agents/skills/shelfmark/download_books.py --file books.json
|
||||
```
|
||||
|
||||
The JSON file (`books.json`) should contain an array of book objects:
|
||||
|
||||
```json
|
||||
[
|
||||
{"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"},
|
||||
{"title": "Frankenstein", "author": "Mary Shelley"},
|
||||
{"title": "Oliver Twist", "author": "Charles Dickens"}
|
||||
]
|
||||
```
|
||||
|
||||
Array format is also supported: `[["The Great Gatsby", "F. Scott Fitzgerald"], ["Oliver Twist", "Charles Dickens"], ["Frankenstein", "Marry Shelley"]]`
|
||||
|
||||
Title-only (no author) is supported: `["Frankenstein"]`
|
||||
|
||||
## Key Characteristics
|
||||
|
||||
- **Shelfmark is a React SPA** — raw HTML is a shell; JavaScript dynamically populates the DOM
|
||||
- **Desktop viewport required** (`1280x900`) — download buttons use `hidden sm:flex` and won't render on mobile
|
||||
- **Download count pattern** in HTML: `<span>•</span> <span>NUMBER</span> </div>` (the last number before the download button)
|
||||
- **Download button selector**: `<button ... data-action="download" ...>Download</button>`
|
||||
- **Search input**: `<input type="search" placeholder="Search Books">`
|
||||
- **Results indicator**: `<span class="text-sm font-medium whitespace-nowrap">Most relevant</span>`
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Search for a Book
|
||||
|
||||
The script navigates to the main page, enters the search query, and waits for "Most relevant" to appear:
|
||||
|
||||
```python
|
||||
# Navigate to main page first to reset SPA state
|
||||
page.goto('http://localhost:8084/')
|
||||
page.wait_for_load_state('networkidle')
|
||||
|
||||
# Enter search query and submit
|
||||
page.fill('input[type="search"]', f'{title} {author}')
|
||||
page.press('input[type="search"]', 'Enter')
|
||||
|
||||
# Wait for "Most relevant" to appear (indicates search results are fully rendered)
|
||||
page.wait_for_selector('span.text-sm.font-medium:has-text("Most relevant")', timeout=60000)
|
||||
```
|
||||
|
||||
- Always navigate to the main page before each search to reset React SPA state
|
||||
- Include both title and author in the search query
|
||||
- Wait for `span.text-sm.font-medium:has-text("Most relevant")` to appear — this indicates search results are fully loaded
|
||||
- **Do not use timers** to wait for results — always wait for a specific page element
|
||||
|
||||
### 2. Find and Parse Download Buttons
|
||||
|
||||
```python
|
||||
def parse_books(page):
|
||||
btns = page.query_selector_all('button[data-action="download"]')
|
||||
books = []
|
||||
for i, btn in enumerate(btns):
|
||||
content = btn.evaluate_handle('el => el.parentElement.parentElement').inner_html()
|
||||
|
||||
title_match = re.search(r'<h3[^>]*>(.*?)</h3>', content, re.IGNORECASE | re.DOTALL)
|
||||
title = title_match.group(1).strip() if title_match else 'Unknown'
|
||||
|
||||
author_match = re.search(r'class="min-w-0 truncate text-xs[^"]*"[^>]*>(.*?)<', content, re.IGNORECASE | re.DOTALL)
|
||||
author = author_match.group(1).strip() if author_match else 'Unknown'
|
||||
|
||||
dl_match = re.search(r'<span>•</span>\s*<span>([\d,]+)</span>', content)
|
||||
downloads = int(dl_match.group(1).replace(',', '')) if dl_match else 0
|
||||
|
||||
books.append({'index': i, 'title': title, 'author': author, 'downloads': downloads})
|
||||
return books
|
||||
```
|
||||
|
||||
### 3. Select and Download the Book with Most Downloads
|
||||
|
||||
```python
|
||||
# Filter books matching the search criteria
|
||||
matching_books = [b for b in books if matches_search(b['title'], b['author'], title, author)]
|
||||
|
||||
if matching_books:
|
||||
best = max(matching_books, key=lambda x: x['downloads'])
|
||||
|
||||
# CRITICAL: Click on the article h3 to open detail view
|
||||
h3s = page.query_selector_all('article h3')
|
||||
if best['index'] < len(h3s):
|
||||
h3s[best['index']].click()
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
# Then click the download button
|
||||
btn = page.query_selector_all('button[data-action="download"]')[best['index']]
|
||||
btn.click()
|
||||
page.wait_for_timeout(500)
|
||||
```
|
||||
|
||||
**Important**: The React SPA requires clicking on the article's `<h3>` element first to open the detail view. Simply clicking the download button directly often fails silently.
|
||||
|
||||
### 4. Wait for Download to Complete
|
||||
|
||||
```python
|
||||
for i in range(60):
|
||||
page.wait_for_timeout(5000)
|
||||
activity_text = page.inner_text('aside')
|
||||
|
||||
if 'IN PROGRESS' in activity_text:
|
||||
print("Download started!")
|
||||
elif 'Complete' in activity_text or 'Saved' in activity_text:
|
||||
print("Download complete!")
|
||||
break
|
||||
elif 'No activity' in activity_text:
|
||||
print("Download not started")
|
||||
break
|
||||
```
|
||||
|
||||
### 5. Clear Completed Downloads
|
||||
|
||||
```python
|
||||
# Click "Clear Completed" using JavaScript
|
||||
page.evaluate('''
|
||||
() => {
|
||||
for (const b of document.querySelectorAll('button')) {
|
||||
if (b.textContent.includes('Clear Completed')) {
|
||||
b.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
''')
|
||||
|
||||
page.wait_for_timeout(2000)
|
||||
```
|
||||
|
||||
### Checking Calibre Database
|
||||
|
||||
Use `--check-calibre` (or `-c`) to check if books are already in your calibre database before downloading:
|
||||
|
||||
```bash
|
||||
python3 download_books.py --check-calibre '[{"title": "Frankenstein", "author": "Mary Shelley"}]'
|
||||
```
|
||||
|
||||
Books found in calibre are skipped with a message. If all books are already present, the script exits early without launching the browser.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Always click the article element first** before clicking the download button — the React SPA requires this to properly initialize the download workflow
|
||||
- **Wait for "Most relevant" text** to appear after search — this indicates results are fully loaded (don't use timers)
|
||||
- **Navigate to main page** (`http://localhost:8084/`) before each new search to reset React SPA state
|
||||
- **Some books may have different authors listed** than what's in your source file — the script falls back to title-only search if author search fails
|
||||
- **The download count** is the last number in the format `• NUMBER` before the download button
|
||||
- **Book titles may include series info** in brackets, e.g., `(The Locked Tomb Trilogy)`
|
||||
- **Use `page.evaluate_handle`** to get parent element HTML for parsing — the button's `parentElement.parentElement` contains the card content
|
||||
- **Title matching** prefers exact matches over partial matches (e.g., "Yesteryear" matches "Yesteryear: A Novel" but not "The Piers of Yesteryear")
|
||||
- **Books are passed as JSON** — use `--check-calibre` to optionally skip books already in your calibre database
|
||||
|
||||
## Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| No download buttons found | Use desktop viewport (1280x900), wait for `span.text-sm.font-medium:has-text("Most relevant")` |
|
||||
| Download button click does nothing | Click the article's `<h3>` element first, then click the download button |
|
||||
| Search returns no results | The script falls back to title-only search automatically |
|
||||
| Download count shows 0 | The parsing regex may need adjustment — check the HTML structure |
|
||||
| Sidebar shows "No activity" after click | Ensure you clicked the `<h3>` element first, and wait at least 2 seconds before checking |
|
||||
| Search results don't update between books | Navigate to `http://localhost:8084/` before each new search to reset SPA state |
|
||||
| Book downloaded is wrong title | The script prefers exact title matches — if the title is ambiguous, the author search will help narrow it down |
|
||||
| Books passed incorrectly | Books must be valid JSON — use `{"title": "...", "author": "..."}` format, not `Title: Author` |
|
||||
Executable
+353
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shelfmark Book Downloader - Downloads books from a local Shelfmark instance."""
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
import re, time, sys, argparse, json
|
||||
from urllib.parse import quote
|
||||
|
||||
SHELFMARK_URL = 'http://localhost:8084/'
|
||||
|
||||
def parse_books(page):
|
||||
btns = page.query_selector_all('button[data-action="download"]')
|
||||
books = []
|
||||
for i, btn in enumerate(btns):
|
||||
content = btn.evaluate_handle('el => el.parentElement.parentElement').inner_html()
|
||||
|
||||
title_match = re.search(r'<h3[^>]*>(.*?)</h3>', content, re.IGNORECASE | re.DOTALL)
|
||||
title = title_match.group(1).strip() if title_match else 'Unknown'
|
||||
|
||||
author_match = re.search(r'class="min-w-0 truncate text-xs[^"]*"[^>]*>(.*?)<', content, re.IGNORECASE | re.DOTALL)
|
||||
author = author_match.group(1).strip() if author_match else 'Unknown'
|
||||
|
||||
dl_match = re.search(r'<span>•</span>\s*<span>([\d,]+)</span>', content)
|
||||
downloads = int(dl_match.group(1).replace(',', '')) if dl_match else 0
|
||||
|
||||
books.append({'index': i, 'title': title, 'author': author, 'downloads': downloads})
|
||||
return books
|
||||
|
||||
def do_search(page, title, author, search_type="author"):
|
||||
"""Search with title+author, fall back to title only"""
|
||||
# Navigate to main page first to reset SPA state
|
||||
page.goto(SHELFMARK_URL)
|
||||
page.wait_for_load_state('networkidle')
|
||||
|
||||
# Enter search query and submit
|
||||
search_query = f'{title} {author}' if search_type == "author" else title
|
||||
page.fill('input[type="search"]', search_query)
|
||||
page.press('input[type="search"]', 'Enter')
|
||||
|
||||
# Wait for "Most relevant" to appear (indicates search results are fully rendered)
|
||||
try:
|
||||
page.wait_for_selector('span.text-sm.font-medium:has-text("Most relevant")', timeout=60000)
|
||||
except:
|
||||
if search_type == "author":
|
||||
print(" -> Trying title only...")
|
||||
sys.stdout.flush()
|
||||
return do_search(page, title, author, search_type="title")
|
||||
else:
|
||||
print(" >> Timeout waiting for search results")
|
||||
sys.stdout.flush()
|
||||
return None
|
||||
|
||||
books = parse_books(page)
|
||||
|
||||
def clean_title_for_match(book_title, search_title):
|
||||
"""Clean book title to check if it matches the search title"""
|
||||
# Remove common subtitle patterns
|
||||
clean = re.sub(r'\s*[:–—]\s*(A Novel|Reese\'s Book Club.*?|The Hilarious.*?|A GMA Book Club Pick.*?|Movie Tie-In.*?|eBook.*?|\[.*?\].*?)$', '', book_title, flags=re.IGNORECASE)
|
||||
clean = clean.strip()
|
||||
# Remove trailing punctuation
|
||||
clean = clean.rstrip(':,;.')
|
||||
return clean.lower().strip() == search_title.lower().strip()
|
||||
|
||||
def matches_search(book_title, book_author, search_title, search_author):
|
||||
"""Check if book matches search criteria more strictly"""
|
||||
title_match = search_title.lower() in book_title.lower()
|
||||
author_match = search_author.lower() in book_author.lower()
|
||||
|
||||
# For title+author search, ensure title starts with search title (not just contains it)
|
||||
if title_match and author_match:
|
||||
return clean_title_for_match(book_title, search_title) or book_title.lower().startswith(search_title.lower())
|
||||
|
||||
# Also check if author name appears in reverse order (e.g., "Grann, David" matches "David Grann")
|
||||
if title_match:
|
||||
author_parts = search_author.lower().split()
|
||||
if len(author_parts) >= 2:
|
||||
reversed_author = f"{author_parts[-1]} {author_parts[0]}"
|
||||
if reversed_author in book_author.lower() or book_author.lower().startswith(reversed_author):
|
||||
return clean_title_for_match(book_title, search_title) or book_title.lower().startswith(search_title.lower())
|
||||
|
||||
return False
|
||||
|
||||
if search_type == "author":
|
||||
matching = [b for b in books if matches_search(b['title'], b['author'], title, author)]
|
||||
print(f" With author: {len(books)} total, {len(matching)} matching")
|
||||
for b in books[:3]:
|
||||
print(f" [{b['index']}] '{b['title']}' by {b['author']} ({b['downloads']})")
|
||||
sys.stdout.flush()
|
||||
|
||||
if matching:
|
||||
# Check if any matching book is already in the download queue
|
||||
try:
|
||||
sidebar_text = page.inner_text('aside')
|
||||
if 'IN PROGRESS' in sidebar_text:
|
||||
# Get the title of the book currently downloading
|
||||
current_download = sidebar_text.split('IN PROGRESS')[1].split('—')[0].strip().split('\n')[0].strip()
|
||||
# Check if the current download matches our search
|
||||
if title.lower() in current_download.lower():
|
||||
print(f" >> '{current_download}' already downloading, skipping")
|
||||
sys.stdout.flush()
|
||||
return None
|
||||
except:
|
||||
pass
|
||||
|
||||
if matching:
|
||||
return matching, books
|
||||
|
||||
# Fall back to title only
|
||||
print(" -> Trying title only...")
|
||||
sys.stdout.flush()
|
||||
return do_search(page, title, author, search_type="title")
|
||||
|
||||
# Title-only search - prefer exact matches first
|
||||
matching = [b for b in books if title.lower() in b['title'].lower()]
|
||||
exact_matches = [b for b in matching if clean_title_for_match(b['title'], title)]
|
||||
|
||||
if exact_matches:
|
||||
matching = exact_matches
|
||||
|
||||
print(f" Title-only: {len(books)} total, {len(matching)} matching ({len(exact_matches)} exact)")
|
||||
for b in books[:5]:
|
||||
print(f" [{b['index']}] '{b['title']}' by {b['author']} ({b['downloads']})")
|
||||
sys.stdout.flush()
|
||||
|
||||
if not matching:
|
||||
return None
|
||||
|
||||
return (matching, books)
|
||||
|
||||
def download_book(page, title, author):
|
||||
result = do_search(page, title, author)
|
||||
if not result:
|
||||
print("\n >>> NOT FOUND")
|
||||
sys.stdout.flush()
|
||||
return
|
||||
|
||||
matching, all_books = result
|
||||
if not matching:
|
||||
print("\n >>> NOT FOUND")
|
||||
sys.stdout.flush()
|
||||
return
|
||||
|
||||
# Find the best book that is not disabled
|
||||
btns = page.query_selector_all('button[data-action="download"]')
|
||||
best = None
|
||||
for b in sorted(matching, key=lambda x: x['downloads'], reverse=True):
|
||||
if b['index'] < len(btns) and not btns[b['index']].is_disabled():
|
||||
best = b
|
||||
break
|
||||
|
||||
if not best:
|
||||
print("\n >>> All matching books are already in download queue")
|
||||
sys.stdout.flush()
|
||||
return
|
||||
|
||||
print(f"\n >>> DOWNLOADING: '{best['title']}' by {best['author']} ({best['downloads']} dl) [idx={best['index']}]")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Click on the article h3 to open detail view (required for download to work)
|
||||
h3s = page.query_selector_all('article h3')
|
||||
if best['index'] < len(h3s):
|
||||
h3s[best['index']].click()
|
||||
page.wait_for_timeout(300)
|
||||
else:
|
||||
print(f" >> Warning: h3 index {best['index']} out of range ({len(h3s)} h3s)")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Click the download button
|
||||
btn = btns[best['index']]
|
||||
btn.click()
|
||||
|
||||
# Wait a moment for the click to register
|
||||
page.wait_for_timeout(500)
|
||||
print(f" >> Click sent")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Check sidebar immediately
|
||||
time.sleep(2)
|
||||
try:
|
||||
txt = page.inner_text('aside')
|
||||
if 'IN PROGRESS' in txt:
|
||||
print(f" >> Download started!")
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
print(f" >> Sidebar after 2s: {txt[:150]}")
|
||||
sys.stdout.flush()
|
||||
except:
|
||||
print(f" >> Sidebar not visible after 2s")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Wait for download to complete - check sidebar every 2 seconds, max 5 minutes
|
||||
last_state = None
|
||||
for attempt in range(150):
|
||||
time.sleep(2)
|
||||
try:
|
||||
txt = page.inner_text('aside')
|
||||
if 'IN PROGRESS' in txt:
|
||||
state = 'downloading'
|
||||
elif 'Complete' in txt or 'Saved' in txt or 'No activity' in txt:
|
||||
state = 'done'
|
||||
else:
|
||||
state = 'other'
|
||||
|
||||
if state != last_state:
|
||||
if state == 'downloading':
|
||||
print(f" >> Download started!")
|
||||
elif state == 'done':
|
||||
if 'No activity' in txt:
|
||||
print(f" >> Download not started (no activity)")
|
||||
else:
|
||||
print(f" >> Download complete!")
|
||||
last_state = state
|
||||
|
||||
if state == 'done':
|
||||
break
|
||||
except:
|
||||
if last_state is None:
|
||||
print(f" >> Sidebar not visible yet")
|
||||
last_state = 'no_sidebar'
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
if last_state != 'done':
|
||||
print(" >> Timeout waiting for download")
|
||||
sys.stdout.flush()
|
||||
|
||||
def clear_completed_downloads(page):
|
||||
"""Clear completed downloads from previous sessions"""
|
||||
page.evaluate('''
|
||||
() => {
|
||||
for (const b of document.querySelectorAll('button')) {
|
||||
if (b.textContent.includes('Clear Completed')) { b.click(); return; }
|
||||
}
|
||||
}
|
||||
''')
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
def check_calibre(title):
|
||||
"""Check if a book is already in the calibre database."""
|
||||
import subprocess
|
||||
cmd = f'calibredb list --search title:="{title}"'
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
output = result.stdout.strip()
|
||||
# calibredb output has header line "id title authors", count non-header lines
|
||||
lines = [l.strip() for l in output.split('\n') if l.strip()]
|
||||
# Remove header if present (first line starts with 'id')
|
||||
if lines and lines[0].startswith('id'):
|
||||
lines = lines[1:]
|
||||
return len(lines) > 0
|
||||
|
||||
def load_books_from_json(json_str, source):
|
||||
"""Load books from a JSON string or file path."""
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON in {source}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if not isinstance(data, list):
|
||||
print(f"Error: JSON in {source} must be an array of book objects")
|
||||
sys.exit(1)
|
||||
|
||||
books = []
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
if 'title' not in item or 'author' not in item:
|
||||
print(f"Error: Each book object must have 'title' and 'author' keys: {item}")
|
||||
sys.exit(1)
|
||||
books.append((item['title'].strip(), item['author'].strip()))
|
||||
elif isinstance(item, list) and len(item) >= 2:
|
||||
books.append((str(item[0]).strip(), str(item[1]).strip()))
|
||||
elif isinstance(item, str):
|
||||
# Support plain strings as title-only (author will be searched separately)
|
||||
books.append((item.strip(), ""))
|
||||
else:
|
||||
print(f"Warning: Skipping unrecognized book entry: {item}")
|
||||
return books
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Download books from Shelfmark')
|
||||
parser.add_argument('books', nargs='*', help='Books as JSON array of [title, author] pairs or {"title": ..., "author": ...} objects')
|
||||
parser.add_argument('--file', '-f', help='Path to JSON file containing an array of books')
|
||||
parser.add_argument('-c', '--check-calibre', action='store_true', help='Check calibre database before downloading, skip if already present')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load books from --file, positional JSON arg, or both
|
||||
BOOKS = []
|
||||
|
||||
if args.file:
|
||||
try:
|
||||
with open(args.file, 'r') as f:
|
||||
BOOKS = load_books_from_json(f.read(), f"file '{args.file}'")
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {args.file}")
|
||||
sys.exit(1)
|
||||
except IOError as e:
|
||||
print(f"Error reading file: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
for book_arg in args.books:
|
||||
BOOKS.extend(load_books_from_json(book_arg, 'command line argument'))
|
||||
|
||||
if not BOOKS:
|
||||
print("Error: No books specified. Use positional JSON args or --file <path>")
|
||||
sys.exit(1)
|
||||
|
||||
# Check calibre database if requested
|
||||
if args.check_calibre:
|
||||
print("\nChecking calibre database...")
|
||||
sys.stdout.flush()
|
||||
remaining = []
|
||||
for title, author in BOOKS:
|
||||
if check_calibre(title):
|
||||
print(f" >> '{title}' already in calibre, skipping")
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
remaining.append((title, author))
|
||||
BOOKS = remaining
|
||||
if not BOOKS:
|
||||
print("All books already in calibre, nothing to download.")
|
||||
sys.stdout.flush()
|
||||
return
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Downloading {len(BOOKS)} book(s) from Shelfmark")
|
||||
print('='*60)
|
||||
sys.stdout.flush()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page(viewport={'width': 1280, 'height': 900})
|
||||
page.goto(SHELFMARK_URL)
|
||||
page.wait_for_load_state('networkidle')
|
||||
|
||||
# Clear completed downloads at start of batch
|
||||
print(f"\n{'='*60}")
|
||||
print("Clearing completed downloads from previous sessions...")
|
||||
print('='*60)
|
||||
sys.stdout.flush()
|
||||
clear_completed_downloads(page)
|
||||
|
||||
for i, (title, author) in enumerate(BOOKS, 1):
|
||||
print(f"\n{'='*60}")
|
||||
print(f"[{i}/{len(BOOKS)}] '{title}' by {author}")
|
||||
print('='*60)
|
||||
sys.stdout.flush()
|
||||
|
||||
download_book(page, title, author)
|
||||
|
||||
browser.close()
|
||||
print("\nDone!")
|
||||
sys.stdout.flush()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -35,6 +35,15 @@
|
||||
"typescript/no-misused-promises": "error",
|
||||
"typescript/no-non-null-assertion": "error",
|
||||
"typescript/only-throw-error": "error",
|
||||
// React Compiler advisories, enforced everywhere with no per-file exemptions.
|
||||
// The violations inherited from the oxlint 1.70 -> 1.80 bump are all resolved:
|
||||
// three by widening a dependency to the object the compiler infers, and seven
|
||||
// by an `oxlint-disable-next-line` that says, at the callsite, why the flagged
|
||||
// dependency is load-bearing - five are re-run triggers that are never read,
|
||||
// and two are values the callback genuinely uses.
|
||||
"react/preserve-manual-memoization": "error",
|
||||
"react/exhaustive-effect-dependencies": "error",
|
||||
"react/memo-dependencies": "error",
|
||||
"react/no-danger": "error",
|
||||
"react/no-clone-element": "error",
|
||||
"react/no-react-children": "error",
|
||||
|
||||
Generated
+505
-606
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.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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.1",
|
||||
"oxfmt": "^0.63.0",
|
||||
"oxlint": "^1.78.0",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"knip": "^6.35.1",
|
||||
"oxfmt": "^0.68.0",
|
||||
"oxlint": "^1.83.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.10"
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user