Compare commits

..
Author SHA1 Message Date
Alex 3ab503d82d Split hardcover 2026-04-29 19:37:27 +01:00
374 changed files with 7545 additions and 40383 deletions
+3 -41
View File
@@ -1,17 +1,10 @@
version: 2
updates:
# Python dependencies
# Dependabot supports uv version updates, but GitHub currently lists uv
# security updates as "Not applicable"; daily checks keep uv.lock moving
# while repo-level Dependabot alerts/security updates cover supported ecosystems.
- package-ecosystem: "uv"
directory: "/"
schedule:
interval: "daily"
time: "05:00"
timezone: "Europe/London"
cooldown:
default-days: 3
interval: "weekly"
open-pull-requests-limit: 10
groups:
python-deps:
@@ -23,32 +16,21 @@ updates:
directory: "/src/frontend"
schedule:
interval: "weekly"
cooldown:
default-days: 3
open-pull-requests-limit: 10
groups:
npm-deps:
patterns: ["*"]
update-types: ["minor", "patch"]
# Dockerfile base image digests. When a tag stays the same, Dependabot titles
# can only show digest prefixes, so keep the group name explicit.
# Dockerfile base images
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
docker-base-image-digests:
# Exclude python from the group on purpose. Dependabot's Docker
# pre-release filter is bypassed for *grouped* updates
# (dependabot-core#9496), so a grouped python update proposes pre-release
# tags like python:3.15.0b2 as if they were a normal stable minor bump.
# node + uv stay grouped into a single digest PR.
docker-images:
patterns: ["*"]
exclude-patterns: ["python"]
ignore:
# Node.js: block major-version bumps so dependabot never proposes
# moving from one LTS line to a non-LTS "Current" release (e.g. 24 -> 25).
@@ -56,31 +38,11 @@ updates:
- dependency-name: "node"
update-types: ["version-update:semver-major"]
# Python: block minor/major bumps. Ungrouping python (above) is NOT enough
# to keep pre-releases out — dependabot-core#13815 rewrote the Docker
# pre-release heuristic to catch PEP 440 tags like 3.15.0a2 / 3.5.0b3, but
# the suffixed real tag still slipped through as PR #1169
# (python:3.14.6-slim -> python:3.15.0b3-slim). CPython spells
# pre-releases without a separator, so tag parsing reads 3.15.0b3 as an
# ordinary version that sorts above 3.14.6.
#
# A minor-version ignore blocks it regardless of spelling. Patch bumps
# (3.14.6 -> 3.14.7) and same-tag digest refreshes still land automatically.
# Moving the runtime to a new Python minor is a manual, deliberate change:
# bump the tag here and confirm C-extension wheels (greenlet/gevent) exist
# for it — a source build against a pre-release ABI boots an app that binds
# its port but never serves, which wedges e2e for the full 6h job limit.
- dependency-name: "python"
update-types:
["version-update:semver-major", "version-update:semver-minor"]
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 3
open-pull-requests-limit: 5
groups:
gh-actions:
@@ -67,10 +67,10 @@ jobs:
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -78,13 +78,7 @@ jobs:
- name: Extract metadata for ${{ matrix.target }} image
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
env:
# Annotate both the per-platform manifests and the multi-arch image
# index. The index level is what manifest-list consumers (Renovate's
# minimumReleaseAge soak check, provenance/SBOM tooling) read for the
# standard org.opencontainers.image.* annotations, including `created`.
DOCKER_METADATA_ANNOTATIONS_LEVELS: index,manifest
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
tags: |
@@ -96,11 +90,11 @@ jobs:
type=ref,event=tag
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
platforms: linux/amd64,linux/arm64
context: .
@@ -111,11 +105,10 @@ jobs:
RELEASE_VERSION=${{ github.ref_name }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
- name: Generate artifact attestation for ${{ matrix.target }} image
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
subject-digest: ${{ steps.push.outputs.digest }}
@@ -134,14 +127,14 @@ jobs:
LEGACY_NAME: calibre-web-automated-book-downloader
steps:
- name: Log in to registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Create legacy aliases
run: |
+15 -15
View File
@@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.11.3"
python-version: "3.14"
@@ -39,10 +39,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.11.3"
python-version: "3.14"
@@ -59,10 +59,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.11.3"
python-version: "3.14"
@@ -78,13 +78,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build shelfmark-lite image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
target: shelfmark-lite
@@ -99,10 +99,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: "npm"
@@ -122,10 +122,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: "npm"
@@ -142,10 +142,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: "npm"
+4 -4
View File
@@ -22,17 +22,17 @@ jobs:
language: [python, javascript-typescript]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
with:
category: "/language:${{ matrix.language }}"
-135
View File
@@ -1,135 +0,0 @@
name: E2E Platform
# Hermetic end-to-end matrix: boots the app under test against mock
# Anna's Archive / Cloudflare / bypasser / DNS / proxy / Tor / real torrent
# clients and runs the cluster suite under each config profile.
#
# On a PR that touches relevant code, this runs a fast core subset *and* the heavy
# `full` profile (real Chrome solving Cloudflare + DoH + real qBittorrent). The
# `e2e-required` job aggregates them into ONE status check — make that check a
# required status check in branch protection to block merges on any e2e failure
# (see tests/e2e/platform/README.md "Gating PRs").
on:
pull_request:
schedule:
- cron: "0 4 * * *" # nightly full matrix
workflow_dispatch:
concurrency:
group: e2e-platform-${{ github.ref }}
cancel-in-progress: true
jobs:
# Detect whether anything that affects the e2e platform changed. This lets the
# required check always report (never stuck "pending") while only spending CI on
# PRs that can actually break the e2e stack.
changes:
runs-on: ubuntu-latest
outputs:
relevant: ${{ steps.filter.outputs.relevant }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4.0.3
id: filter
with:
filters: |
relevant:
- 'shelfmark/**'
- 'entrypoint.sh'
- 'tor.sh'
- 'Dockerfile'
- 'tests/e2e/platform/**'
- '.github/workflows/e2e-platform.yml'
select-profiles:
needs: changes
if: needs.changes.outputs.relevant == 'true' || github.event_name != 'pull_request'
runs-on: ubuntu-latest
outputs:
profiles: ${{ steps.pick.outputs.profiles }}
steps:
- id: pick
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo 'profiles=["baseline","bypasser-external","dns-blocked"]' >> "$GITHUB_OUTPUT"
else
echo 'profiles=["baseline","bypasser-external","bypasser-disabled","dns-manual","dns-blocked","dns-doh","proxy-http","proxy-socks","tor","client-transmission","client-deluge","client-qbittorrent-delayed"]' >> "$GITHUB_OUTPUT"
fi
e2e:
needs: select-profiles
runs-on: ubuntu-latest
# A wedged app under test must not burn GitHub's 6h max job limit. A healthy
# profile run finishes in ~3-5 min; anything past 25 is hung, not slow.
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
profile: ${{ fromJSON(needs.select-profiles.outputs.profiles) }}
name: e2e (${{ matrix.profile }})
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install uv and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: "3.14"
enable-cache: true
- name: Sync dependencies
run: make install-python-dev
- name: Run e2e platform (${{ matrix.profile }})
run: tests/e2e/platform/run-e2e.sh env/${{ matrix.profile }}.env
- name: Dump shelfmark logs on failure
if: failure()
run: cat tests/e2e/platform/.state/shelfmark.${{ matrix.profile }}.log || true
# Heavy "everything real" job: real Chrome internal bypasser solving Cloudflare +
# DoH + real qBittorrent webseed download. Runs on relevant PRs and nightly.
e2e-full:
needs: changes
if: needs.changes.outputs.relevant == 'true' || github.event_name != 'pull_request'
runs-on: ubuntu-latest
# Real Chrome + qBittorrent is the slowest profile; still nowhere near 40 min.
timeout-minutes: 40
name: e2e (full — real Chrome + qBittorrent)
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install uv and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: "3.14"
enable-cache: true
- name: Sync dependencies
run: make install-python-dev
- name: Run full pipeline
run: tests/e2e/platform/run-e2e.sh env/full.env
- name: Dump logs on failure
if: failure()
run: |
cat tests/e2e/platform/.state/shelfmark.full.log || true
docker logs e2e-qbittorrent || true
# Single aggregated gate. ALWAYS runs (so a required check never hangs "pending"
# on unrelated PRs) and FAILS if any e2e job failed/was cancelled. Make THIS the
# required status check in branch protection.
e2e-required:
name: e2e required
needs: [e2e, e2e-full]
if: always()
runs-on: ubuntu-latest
steps:
- name: Aggregate e2e results
run: |
matrix='${{ needs.e2e.result }}'
full='${{ needs.e2e-full.result }}'
echo "e2e matrix=$matrix, e2e-full=$full"
# success or skipped (unrelated PR) is OK; failure/cancelled blocks.
for r in "$matrix" "$full"; do
if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then
echo "::error::An e2e platform job did not pass — blocking."
exit 1
fi
done
echo "All e2e platform jobs passed (or were skipped as not relevant)."
-5
View File
@@ -166,10 +166,6 @@ ENV/
env.bak/
venv.bak/
# ...but the e2e platform test profiles live in an env/ dir and must be tracked
!tests/e2e/platform/env/
!tests/e2e/platform/env/*.env
# Spyder project settings
.spyderproject
.spyproject
@@ -236,7 +232,6 @@ pyrightconfig.json
*.local.*
AGENTS.md
.claude/
CLAUDE.md
.nvmrc
.playwright-mcp/
frontend-dist/
+16 -80
View File
@@ -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 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"
@@ -24,14 +24,10 @@ COPY src/frontend/ ./
# Build the frontend
RUN npm run build
# uv is a build-time tool only, so it is mounted into the RUNs that need it rather
# 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.12.5@sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1 AS uv
# Use python-slim as the base image
FROM python:3.14.7-slim@sha256:cae66f2ef0ec51a9891263eeee7f987dacf0a9879e8aa9353d5606e0530619a5 AS base
FROM python:3.14-slim AS base
COPY --from=ghcr.io/astral-sh/uv:0.11.3 /uv /uvx /bin/
# Add build argument for version
ARG BUILD_VERSION
@@ -63,11 +59,6 @@ ENV FLASK_PORT=8084
# Configure locale, timezone, and perform initial cleanup in a single layer
RUN apt-get update && \
apt-get install -y --no-install-recommends \
# For building C-extensions (cffi, gevent, etc.)
gcc \
g++ \
libffi-dev \
python3-dev \
# For locale
locales tzdata \
# For healthcheck
@@ -81,12 +72,7 @@ RUN apt-get update && \
# --- Tor support (activated via USING_TOR=true) ---
tor \
supervisor \
iptables \
# --- WireGuard support (activated via USING_WIREGUARD=true) ---
wireguard-tools \
iproute2 \
procps \
ca-certificates && \
iptables && \
# Configure iptables alternatives for tor.sh compatibility
update-alternatives --set iptables /usr/sbin/iptables-legacy && \
update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy && \
@@ -115,18 +101,8 @@ WORKDIR /app
# Install core Python dependencies first for better layer caching
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=from=uv,source=/uv,target=/usr/local/bin/uv \
uv sync --locked --no-default-groups
# Runtime dependencies are installed into /app/.venv during the build. Remove the
# base image's system pip so stale installer CVEs do not ship in the final image.
RUN rm -rf \
/usr/local/bin/pip \
/usr/local/bin/pip3 \
/usr/local/bin/pip3.* \
/usr/local/lib/python*/site-packages/pip \
/usr/local/lib/python*/site-packages/pip-*.dist-info
# Copy application code *after* dependencies are installed
COPY . .
@@ -146,20 +122,15 @@ RUN mkdir -p \
ln -s /tmp/shelfmark/seleniumbase/archived_files /app/archived_files && \
chown -R 1000:1000 /config /books /home/shelfmark /tmp/shelfmark /var/log/shelfmark && \
chmod -R a+rX /app && \
chmod +x /app/entrypoint.sh /app/tor.sh /app/wireguard.sh /app/genDebug.sh
chmod +x /app/entrypoint.sh /app/tor.sh /app/genDebug.sh
# Expose the application port
EXPOSE ${FLASK_PORT}
# Add healthcheck for container status
# 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
# 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
# Use dumb-init as the entrypoint to handle signals properly
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
@@ -167,39 +138,21 @@ ENTRYPOINT ["/usr/bin/dumb-init", "--"]
FROM base AS shelfmark
# --- Chromium (PINNED to 149.0.7827.196) ---
# Debian's chromium 150.0.7871.46-1~deb13u1 security update (trixie-security,
# 2026-07-05) no longer opens the DevTools remote-debugging TCP port at all
# (no listener, no DevToolsActivePort file, even with a custom --user-data-dir;
# the RemoteDebuggingAllowed policy does not restore it). The SeleniumBase
# Pure-CDP driver connects through that port (/json/version), so with 150 every
# internal bypass dies with "Pure CDP browser startup failed" and all
# CF-gated downloads fail. Install the last working version from
# snapshot.debian.org until the bypasser can talk to Chromium >= 150 (e.g.
# pipe-based DevTools / UC mode) or seleniumbase ships a fix.
# Chrome 144+ requires --enable-unsafe-swiftshader for WebGL in Docker.
# This flag is set in internal_bypasser.py _get_browser_args()
ARG CHROMIUM_VERSION=149.0.7827.196-1~deb13u1
ARG CHROMIUM_SNAPSHOT=20260704T000000Z
RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian-security/${CHROMIUM_SNAPSHOT}/ trixie-security main" \
> /etc/apt/sources.list.d/chromium-pin-snapshot.list && \
apt-get update -o Acquire::Retries=5 && \
apt-get install -y --no-install-recommends -o Acquire::Retries=5 \
RUN apt-get update && \
apt-get install -y --no-install-recommends \
# For dumb display
xvfb \
# For screen recording
ffmpeg \
chromium=${CHROMIUM_VERSION} \
chromium-common=${CHROMIUM_VERSION} \
# --- Chromium (unpinned - uses latest from Debian repos) ---
# Chrome 144+ requires --enable-unsafe-swiftshader for WebGL in Docker.
# This flag is set in internal_bypasser.py _get_browser_args()
chromium \
chromium-common \
# For tkinter (pyautogui)
python3-tk \
# For RAR extraction
unrar-free && \
# Keep apt from "upgrading" chromium past the pin inside derived images
printf 'Package: chromium chromium-common\nPin: version %s\nPin-Priority: 1001\n' "${CHROMIUM_VERSION}" \
> /etc/apt/preferences.d/chromium-pin && \
rm /etc/apt/sources.list.d/chromium-pin-snapshot.list && \
# Create symlink so rarfile library can find unrar
ln -sf /usr/bin/unrar-free /usr/bin/unrar && \
# Cleanup APT cache
@@ -209,25 +162,8 @@ RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian-
# Install the browser automation stack used by the full image
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=from=uv,source=/uv,target=/usr/local/bin/uv \
uv sync --locked --no-default-groups --extra browser
# Deterministically resolve the Xlib namespace collision.
# pyautogui/mouseinfo pull the stale `python3-xlib` (0.15, 2014), while the
# `--extra browser` set pulls `python-xlib` (0.33). Both packages install into
# the same top-level `Xlib/` namespace, so whichever lands last wins. When the
# 2014 build wins, `Xlib.X` is missing `FamilyServerInterpreted`, which the
# SeleniumBase Pure-CDP driver requires at browser startup -> every bypass fails
# with "module 'Xlib.X' has no attribute 'FamilyServerInterpreted'" and no
# Cloudflare/DDoS-Guard protected download can complete. Drop the stale package
# and force python-xlib 0.33 to own the namespace. pyautogui runs fine against
# 0.33 (superset API).
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=from=uv,source=/uv,target=/usr/local/bin/uv \
uv pip uninstall --python /app/.venv/bin/python python3-xlib && \
uv pip install --python /app/.venv/bin/python --reinstall python-xlib==0.33 && \
/app/.venv/bin/python -c "import Xlib.X; assert hasattr(Xlib.X, 'FamilyServerInterpreted'), 'Xlib.X.FamilyServerInterpreted missing after fix'; print('Xlib namespace OK:', Xlib.__version__)"
# Keep SeleniumBase's bundled driver cache writable for the fixed non-root user.
RUN SELENIUMBASE_DRIVERS_DIR=$(/app/.venv/bin/python -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')") && \
chown -R 1000:1000 "${SELENIUMBASE_DRIVERS_DIR}" && \
+1 -29
View File
@@ -1,4 +1,4 @@
.PHONY: help install install-ci install-python-dev dev build preview frontend-typecheck frontend-lint frontend-format frontend-format-fix frontend-checks frontend-test clean up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-fix python-typecheck python-dead-code python-checks python-test python-test-cov e2e-platform e2e-platform-profile e2e-platform-matrix e2e-platform-full e2e-platform-build checks fix
.PHONY: help install install-ci install-python-dev dev build preview frontend-typecheck frontend-lint frontend-format frontend-format-fix frontend-checks frontend-test clean up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-fix python-typecheck python-dead-code python-checks python-test python-test-cov checks fix
# Frontend directory
FRONTEND_DIR := src/frontend
@@ -38,10 +38,6 @@ help:
@echo " python-checks - Run all Python static analysis checks"
@echo " python-test - Run unit tests"
@echo " python-test-cov - Run unit tests with coverage report"
@echo " e2e-platform - Run e2e docker platform (baseline profile)"
@echo " e2e-platform-profile PROFILE=<name> - Run e2e platform for one profile"
@echo " e2e-platform-matrix - Run e2e platform across all config profiles"
@echo " e2e-platform-full - Run heavy 'full' profile (real Chrome bypasser + DoH + real qBittorrent)"
@echo " clean - Remove node_modules and build artifacts"
@echo ""
@echo "Backend (Docker):"
@@ -131,30 +127,6 @@ python-test-cov:
@echo "Running tests with coverage..."
uv run pytest tests/ -x --tb=short -m "not integration and not e2e" --cov --cov-report=term-missing
# E2E docker platform: hermetic stack (mock AA/Cloudflare/bypasser/DNS/proxy/Tor)
# exercised across config profiles. See tests/e2e/platform/README.md.
E2E_PLATFORM_DIR := tests/e2e/platform
e2e-platform:
@echo "Running e2e platform (baseline profile)..."
cd $(E2E_PLATFORM_DIR) && ./run-e2e.sh env/baseline.env
e2e-platform-profile:
@echo "Running e2e platform (profile=$(PROFILE))..."
cd $(E2E_PLATFORM_DIR) && ./run-e2e.sh env/$(PROFILE).env
e2e-platform-matrix:
@echo "Running e2e platform matrix (all profiles)..."
cd $(E2E_PLATFORM_DIR) && ./run-matrix.sh
e2e-platform-build:
@echo "Pre-building e2e platform images once (reused across profiles)..."
cd $(E2E_PLATFORM_DIR) && ./build-images.sh
e2e-platform-full:
@echo "Running e2e platform FULL profile (real Chrome bypasser + DoH + real qBittorrent)..."
cd $(E2E_PLATFORM_DIR) && ./run-e2e.sh env/full.env
# Frontend linting
frontend-lint:
@echo "Running Oxlint..."
-43
View File
@@ -1,43 +0,0 @@
# Routes all traffic through a WireGuard tunnel - requires root startup.
#
# Mount your wg-quick config at /config/wg0.conf (read-only is fine). All
# non-LAN egress is forced through the tunnel by an iptables kill-switch, so if
# the tunnel drops, external traffic fails closed. LAN ranges (WebUI + internal
# download clients like Prowlarr / qBittorrent) stay reachable off-tunnel.
services:
shelfmark-wireguard:
image: ghcr.io/calibrain/shelfmark:latest
environment:
FLASK_PORT: 8084
# Quoted so it is passed as the literal string "true": entrypoint.sh compares
# $USING_WIREGUARD against "true", and some Compose implementations stringify
# a bare YAML boolean as "True", which would silently NOT enable WireGuard.
USING_WIREGUARD: "true"
# Path to the mounted wg-quick config (default shown).
WIREGUARD_CONFIG: /config/wg0.conf
# CIDRs kept OFF the tunnel so the WebUI and internal clients stay reachable.
LAN_NETWORK: 127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
PUID: 1000
PGID: 1000
cap_add:
- NET_ADMIN
- NET_RAW
# WireGuard needs the module/kernel routing; NET_ADMIN covers wg-quick.
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
# Disable IPv6 in the container so the kill-switch can guarantee no IPv6
# leak path on kernels/containers without a usable ip6tables. wireguard.sh
# fails closed if IPv6 is neither kill-switched nor disabled. If your host
# DOES have a working ip6tables you may omit these (an ip6tables kill-switch
# is installed instead); or set WIREGUARD_ALLOW_IPV6_LEAK=true only if the
# container genuinely has no IPv6 connectivity.
- net.ipv6.conf.all.disable_ipv6=1
- net.ipv6.conf.default.disable_ipv6=1
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration (put wg0.conf here)
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
+53 -60
View File
@@ -1,79 +1,72 @@
[
{ "language": "English", "code": "en", "aliases": ["eng"] },
{ "language": "Chinese", "code": "zh", "aliases": ["chi", "zho"] },
{ "language": "Russian", "code": "ru", "aliases": ["rus"] },
{ "language": "Spanish", "code": "es", "aliases": ["spa"] },
{ "language": "French", "code": "fr", "aliases": ["fra", "fre"] },
{ "language": "German", "code": "de", "aliases": ["deu", "ger"] },
{ "language": "Italian", "code": "it", "aliases": ["ita"] },
{ "language": "Portuguese", "code": "pt", "aliases": ["por"] },
{ "language": "Polish", "code": "pl", "aliases": ["pol"] },
{ "language": "Bulgarian", "code": "bg", "aliases": ["bul"] },
{ "language": "Dutch", "code": "nl", "aliases": ["dut", "nld"] },
{ "language": "Japanese", "code": "ja", "aliases": ["jap", "jpn"] },
{ "language": "Arabic", "code": "ar", "aliases": ["ara"] },
{ "language": "Hebrew", "code": "he", "aliases": ["heb"] },
{ "language": "Turkish", "code": "tr", "aliases": ["tur"] },
{ "language": "Hungarian", "code": "hu", "aliases": ["hun"] },
{ "language": "Latin", "code": "la", "aliases": ["lat"] },
{ "language": "Czech", "code": "cs", "aliases": ["ces", "cze"] },
{ "language": "Korean", "code": "ko", "aliases": ["kor"] },
{ "language": "Ukrainian", "code": "uk", "aliases": ["ukr"] },
{ "language": "Indonesian", "code": "id", "aliases": ["ind"] },
{ "language": "Romanian", "code": "ro", "aliases": ["rom", "ron"] },
{ "language": "Swedish", "code": "sv", "aliases": ["swe"] },
{ "language": "Greek", "code": "el", "aliases": ["ell", "gre"] },
{ "language": "Lithuanian", "code": "lt", "aliases": ["lit"] },
{ "language": "Bangla", "code": "bn", "aliases": ["ben", "bengali"] },
{ "language": "Traditional Chinese", "code": "zh-Hant", "aliases": ["zh‑Hant"] },
{ "language": "Afrikaans", "code": "af", "aliases": ["afr"] },
{ "language": "Catalan", "code": "ca", "aliases": ["cat"] },
{ "language": "Danish", "code": "da", "aliases": ["dan"] },
{ "language": "Thai", "code": "th", "aliases": ["tha"] },
{ "language": "Hindi", "code": "hi", "aliases": ["hin"] },
{ "language": "Irish", "code": "ga", "aliases": ["gle"] },
{ "language": "Latvian", "code": "lv", "aliases": ["lav"] },
{ "language": "English", "code": "en" },
{ "language": "Chinese", "code": "zh" },
{ "language": "Russian", "code": "ru" },
{ "language": "Spanish", "code": "es" },
{ "language": "French", "code": "fr" },
{ "language": "German", "code": "de" },
{ "language": "Italian", "code": "it" },
{ "language": "Portuguese", "code": "pt" },
{ "language": "Polish", "code": "pl" },
{ "language": "Bulgarian", "code": "bg" },
{ "language": "Dutch", "code": "nl" },
{ "language": "Japanese", "code": "ja" },
{ "language": "Arabic", "code": "ar" },
{ "language": "Hebrew", "code": "he" },
{ "language": "Turkish", "code": "tr" },
{ "language": "Hungarian", "code": "hu" },
{ "language": "Latin", "code": "la" },
{ "language": "Czech", "code": "cs" },
{ "language": "Korean", "code": "ko" },
{ "language": "Ukrainian", "code": "uk" },
{ "language": "Indonesian", "code": "id" },
{ "language": "Romanian", "code": "ro" },
{ "language": "Swedish", "code": "sv" },
{ "language": "Greek", "code": "el" },
{ "language": "Lithuanian", "code": "lt" },
{ "language": "Bangla", "code": "bn" },
{ "language": "Traditional Chinese", "code": "zh‑Hant" },
{ "language": "Afrikaans", "code": "af" },
{ "language": "Catalan", "code": "ca" },
{ "language": "Danish", "code": "da" },
{ "language": "Thai", "code": "th" },
{ "language": "Hindi", "code": "hi" },
{ "language": "Irish", "code": "ga" },
{ "language": "Latvian", "code": "lv" },
{ "language": "Tibetan", "code": "bo" },
{ "language": "Kannada", "code": "kn", "aliases": ["kan"] },
{ "language": "Serbian", "code": "sr", "aliases": ["srp"] },
{ "language": "Persian", "code": "fa", "aliases": ["farsi", "fas", "per"] },
{ "language": "Croatian", "code": "hr", "aliases": ["hrv"] },
{ "language": "Kannada", "code": "kn" },
{ "language": "Serbian", "code": "sr" },
{ "language": "Persian", "code": "fa" },
{ "language": "Croatian", "code": "hr" },
{ "language": "Slovak", "code": "sk" },
{ "language": "Javanese", "code": "jv", "aliases": ["jav"] },
{ "language": "Vietnamese", "code": "vi", "aliases": ["vie"] },
{ "language": "Urdu", "code": "ur", "aliases": ["urd"] },
{ "language": "Finnish", "code": "fi", "aliases": ["fin"] },
{ "language": "Norwegian", "code": "no", "aliases": ["nor"] },
{ "language": "Javanese", "code": "jv" },
{ "language": "Vietnamese", "code": "vi" },
{ "language": "Urdu", "code": "ur" },
{ "language": "Finnish", "code": "fi" },
{ "language": "Norwegian", "code": "no" },
{ "language": "Kinyarwanda", "code": "rw" },
{ "language": "Tamil", "code": "ta", "aliases": ["tam"] },
{ "language": "Tamil", "code": "ta" },
{ "language": "Belarusian", "code": "be" },
{ "language": "Kazakh", "code": "kk" },
{ "language": "Mongolian", "code": "mn" },
{ "language": "Georgian", "code": "ka" },
{ "language": "Slovenian", "code": "sl", "aliases": ["slv"] },
{ "language": "Slovenian", "code": "sl" },
{ "language": "Esperanto", "code": "eo" },
{ "language": "Galician", "code": "gl" },
{ "language": "Marathi", "code": "mr", "aliases": ["mar"] },
{ "language": "Filipino", "code": "fil", "aliases": ["tagalog", "tgl"] },
{ "language": "Gujarati", "code": "gu", "aliases": ["guj"] },
{ "language": "Malayalam", "code": "ml", "aliases": ["mal"] },
{ "language": "Marathi", "code": "mr" },
{ "language": "Filipino", "code": "fil" },
{ "language": "Gujarati", "code": "gu" },
{ "language": "Malayalam", "code": "ml" },
{ "language": "Kyrgyz", "code": "ky" },
{ "language": "Azerbaijani", "code": "az" },
{ "language": "Quechua", "code": "qu" },
{ "language": "Swahili", "code": "sw" },
{ "language": "Bashkir", "code": "ba" },
{ "language": "Punjabi", "code": "pa", "aliases": ["pan"] },
{ "language": "Malay", "code": "ms", "aliases": ["may", "msa"] },
{ "language": "Telugu", "code": "te", "aliases": ["tel"] },
{ "language": "Punjabi", "code": "pa" },
{ "language": "Malay", "code": "ms" },
{ "language": "Telugu", "code": "te" },
{ "language": "Albanian", "code": "sq" },
{ "language": "Uyghur", "code": "ug" },
{ "language": "Armenian", "code": "hy" },
{ "language": "Shan", "code": "shn" },
{ "language": "Bosnian", "code": "bs", "aliases": ["bos"] },
{ "language": "Burmese", "code": "my", "aliases": ["bur", "mya"] },
{ "language": "Estonian", "code": "et", "aliases": ["est"] },
{ "language": "Icelandic", "code": "is", "aliases": ["ice", "isl"] },
{ "language": "Manx", "code": "gv", "aliases": ["glv"] },
{ "language": "Scottish Gaelic", "code": "gd", "aliases": ["gla"] },
{ "language": "Sanskrit", "code": "sa", "aliases": ["san"] }
{ "language": "Shan", "code": "shn" }
]
-25
View File
@@ -1,25 +0,0 @@
# Local development - WireGuard variant
services:
shelfmark-wireguard-dev:
extends:
file: ./compose/docker-compose.wireguard.yml
service: shelfmark-wireguard
build:
context: .
dockerfile: Dockerfile
target: shelfmark
environment:
# Quoted so they are passed as the literal string "true" (entrypoint.sh and
# the app compare against "true"); a bare YAML boolean can be stringified as
# "True" by some Compose variants, silently disabling the feature.
DEBUG: "true"
USING_WIREGUARD: "true"
WIREGUARD_CONFIG: /config/wg0.conf
volumes:
- ./.local/config:/config
- ./.local/books:/books
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
# Place your wg-quick config at ./.local/config/wg0.conf
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
-4
View File
@@ -91,10 +91,6 @@ Example:
- Shelfmark can see the same files at `/downloads/books/...`
- Add a mapping from Remote Path `/data/torrents` to Local Path `/downloads`
If the files are copied or synced into Shelfmark on a delay, increase **Completed Path Wait (seconds)**
in Settings -> Advanced. The default is 60 seconds; seedbox or remote-sync setups may need a value
longer than the sync interval.
## File Processing Options
### Transfer Method (Torrent / Usenet Only)
-21
View File
@@ -276,27 +276,6 @@ 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 |
+38 -455
View File
@@ -7,7 +7,6 @@ This document lists all configuration options that can be set via environment va
## Table of Contents
- [Bootstrap Configuration](#bootstrap-configuration)
- [Egress / VPN Routing](#egress--vpn-routing)
- [General](#general)
- [Search Mode](#search-mode)
- [Downloads](#downloads)
@@ -15,7 +14,6 @@ This document lists all configuration options that can be set via environment va
- [Network](#network)
- [Advanced](#advanced)
- [Prowlarr](#prowlarr)
- [Newznab](#newznab)
- [AudiobookBay](#audiobookbay)
- [IRC](#irc)
- [Download Clients](#download-clients)
@@ -23,7 +21,6 @@ This document lists all configuration options that can be set via environment va
- [Hardcover](#metadata-providers-hardcover)
- [Open Library](#metadata-providers-open-library)
- [Google Books](#metadata-providers-google-books)
- [Moly.hu](#metadata-providers-moly.hu)
- [Direct Download](#direct-download)
- [Download Sources](#direct-download-download-sources)
- [Cloudflare Bypass](#direct-download-cloudflare-bypass)
@@ -33,7 +30,7 @@ This document lists all configuration options that can be set via environment va
## Bootstrap Configuration
These environment variables are used at startup before the settings system loads. They typically configure paths, server settings, and authentication startup behavior.
These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
@@ -45,9 +42,6 @@ These environment variables are used at startup before the settings system loads
| `FLASK_PORT` | Port number for the Flask web server. | number | `8084` |
| `SESSION_COOKIE_SECURE` | Enable secure cookies (requires HTTPS). | boolean | `false` |
| `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` |
| `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` |
@@ -110,27 +104,6 @@ Path to the Calibre-Web database for authentication integration.
- **Type:** string (path)
- **Default:** `/auth/app.db`
#### `HIDE_LOCAL_AUTH`
Hide the username/password login form when OIDC is active.
- **Type:** boolean
- **Default:** `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.
- **Type:** boolean
- **Default:** `false`
#### `OIDC_AUTO_REDIRECT`
Automatically redirect to the OIDC provider instead of showing the login page.
- **Type:** boolean
- **Default:** `false`
#### `DOCKERMODE`
Indicates the application is running inside a Docker container.
@@ -147,121 +120,19 @@ Show the onboarding wizard on first run. Set to false to skip (useful for epheme
</details>
## Egress / VPN Routing
These startup-only variables are consumed by `entrypoint.sh` / `wireguard.sh` to select and configure the WireGuard transparent-egress kill-switch. `USING_WIREGUARD` and [`USING_TOR`](#using_tor) (documented under Network) are mutually exclusive; both require root startup.
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `USING_WIREGUARD` | Route all traffic through a WireGuard VPN tunnel with a fail-closed iptables kill-switch (non-tunnel egress is dropped). Requires root startup and NET_ADMIN (plus NET_RAW). Mutually exclusive with USING_TOR. | boolean | `false` |
| `WIREGUARD_CONFIG` | Path to the mounted wg-quick configuration file. | string (path) | `/config/wg0.conf` |
| `WIREGUARD_INTERFACE` | WireGuard interface name brought up by wg-quick. | string | `wg0` |
| `LAN_NETWORK` | Comma-separated CIDRs kept off the tunnel so the WebUI and internal download clients (Prowlarr, qBittorrent) stay reachable. | string (comma-separated) | `127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` |
| `WIREGUARD_ENFORCE_DNS` | Pin the container's resolver so DNS cannot silently fall back to an off-tunnel path. The resolver used is WIREGUARD_DNS if set, else the tunnel config's DNS = line. This does NOT force queries through the tunnel: it is designed for a trusted LAN resolver kept reachable off-tunnel via LAN_NETWORK (the query leaves over the LAN; the resolver encrypts upstream while the download still egresses via the tunnel). Special case: when Docker's embedded resolver (nameserver 127.0.0.11) is present, it is PRESERVED so container-name resolution (e.g. prowlarr, qbittorrent) keeps working, and the embedded resolver's upstream must be pinned via the container's compose dns: list. Fails closed (refuses to start) only when no embedded resolver is present AND no resolver is defined, or /etc/resolv.conf is not writable. | boolean | `true` |
| `WIREGUARD_DNS` | Explicit resolver(s) (comma/space separated) to pin when WIREGUARD_ENFORCE_DNS is true and Docker's embedded resolver is NOT in use. Use when the VPN's pushed DNS filters domains you need; point it at a resolver reachable via the tunnel or an allowed LAN resolver. NOTE: when the embedded resolver (127.0.0.11) is present it is preserved and this value cannot repoint its upstream from inside the container — set the container's compose dns: list to the trusted resolver instead. | string (comma-separated) | `unset (uses config DNS = line)` |
| `WIREGUARD_DISABLE_IPV6` | Strip IPv6 Address/AllowedIPs/DNS from the tunnel config before wg-quick (many container kernels lack the ip6tables raw table wg-quick needs) and remove IPv6 as a leak surface. | boolean | `true` |
| `WIREGUARD_ALLOW_IPV6_LEAK` | Escape hatch: continue startup even when an IPv6 kill-switch cannot be installed AND IPv6 cannot be disabled. Only set when the container has no IPv6 connectivity, as IPv6 egress may otherwise bypass the tunnel. | boolean | `false` |
| `WIREGUARD_ALLOW_WEBUI_OFFTUNNEL` | When false (default) the kill-switch is strictly fail-closed: the only off-tunnel egress permitted is loopback, the tunnel device and the LAN allowlist. Set true only if a NON-LAN client (e.g. a public reverse proxy on a different segment) must reach the WebUI; it permits app-server REPLY packets (--sport FLASK_PORT, conntrack REPLY) to leave off-tunnel. Server replies only, never client-initiated egress, so it cannot leak outbound browsing/downloads or the real IP for outbound requests, but it is still an off-tunnel path while the tunnel is down, hence opt-in. LAN WebUI clients never need it (covered by LAN_NETWORK). | boolean | `false` |
| `WIREGUARD_STALE_AFTER` | Seconds since the last WireGuard handshake before the healthcheck bounces the tunnel. | number | `180` |
<details>
<summary>Detailed descriptions</summary>
#### `USING_WIREGUARD`
Route all traffic through a WireGuard VPN tunnel with a fail-closed iptables kill-switch (non-tunnel egress is dropped). Requires root startup and NET_ADMIN (plus NET_RAW). Mutually exclusive with USING_TOR.
- **Type:** boolean
- **Default:** `false`
#### `WIREGUARD_CONFIG`
Path to the mounted wg-quick configuration file.
- **Type:** string (path)
- **Default:** `/config/wg0.conf`
#### `WIREGUARD_INTERFACE`
WireGuard interface name brought up by wg-quick.
- **Type:** string
- **Default:** `wg0`
#### `LAN_NETWORK`
Comma-separated CIDRs kept off the tunnel so the WebUI and internal download clients (Prowlarr, qBittorrent) stay reachable.
- **Type:** string (comma-separated)
- **Default:** `127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`
#### `WIREGUARD_ENFORCE_DNS`
Pin the container's resolver so DNS cannot silently fall back to an off-tunnel path. The resolver used is WIREGUARD_DNS if set, else the tunnel config's DNS = line. This does NOT force queries through the tunnel: it is designed for a trusted LAN resolver kept reachable off-tunnel via LAN_NETWORK (the query leaves over the LAN; the resolver encrypts upstream while the download still egresses via the tunnel). Special case: when Docker's embedded resolver (nameserver 127.0.0.11) is present, it is PRESERVED so container-name resolution (e.g. prowlarr, qbittorrent) keeps working, and the embedded resolver's upstream must be pinned via the container's compose dns: list. Fails closed (refuses to start) only when no embedded resolver is present AND no resolver is defined, or /etc/resolv.conf is not writable.
- **Type:** boolean
- **Default:** `true`
#### `WIREGUARD_DNS`
Explicit resolver(s) (comma/space separated) to pin when WIREGUARD_ENFORCE_DNS is true and Docker's embedded resolver is NOT in use. Use when the VPN's pushed DNS filters domains you need; point it at a resolver reachable via the tunnel or an allowed LAN resolver. NOTE: when the embedded resolver (127.0.0.11) is present it is preserved and this value cannot repoint its upstream from inside the container — set the container's compose dns: list to the trusted resolver instead.
- **Type:** string (comma-separated)
- **Default:** `unset (uses config DNS = line)`
#### `WIREGUARD_DISABLE_IPV6`
Strip IPv6 Address/AllowedIPs/DNS from the tunnel config before wg-quick (many container kernels lack the ip6tables raw table wg-quick needs) and remove IPv6 as a leak surface.
- **Type:** boolean
- **Default:** `true`
#### `WIREGUARD_ALLOW_IPV6_LEAK`
Escape hatch: continue startup even when an IPv6 kill-switch cannot be installed AND IPv6 cannot be disabled. Only set when the container has no IPv6 connectivity, as IPv6 egress may otherwise bypass the tunnel.
- **Type:** boolean
- **Default:** `false`
#### `WIREGUARD_ALLOW_WEBUI_OFFTUNNEL`
When false (default) the kill-switch is strictly fail-closed: the only off-tunnel egress permitted is loopback, the tunnel device and the LAN allowlist. Set true only if a NON-LAN client (e.g. a public reverse proxy on a different segment) must reach the WebUI; it permits app-server REPLY packets (--sport FLASK_PORT, conntrack REPLY) to leave off-tunnel. Server replies only, never client-initiated egress, so it cannot leak outbound browsing/downloads or the real IP for outbound requests, but it is still an off-tunnel path while the tunnel is down, hence opt-in. LAN WebUI clients never need it (covered by LAN_NETWORK).
- **Type:** boolean
- **Default:** `false`
#### `WIREGUARD_STALE_AFTER`
Seconds since the last WireGuard handshake before the healthcheck bounces the tunnel.
- **Type:** number
- **Default:** `180`
</details>
## General
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `SEARCH_PAGE_TITLE` | Title shown above the main search box on the homepage. | string | `Shelfmark` |
| `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,mp4,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` |
| `BOOK_LANGUAGE` | Default language filter for searches. | string (comma-separated) | `en` |
<details>
<summary>Detailed descriptions</summary>
#### `SEARCH_PAGE_TITLE`
**Search Page Title**
Title shown above the main search box on the homepage.
- **Type:** string
- **Default:** `Shelfmark`
#### `CALIBRE_WEB_URL`
**Library URL**
@@ -296,7 +167,16 @@ 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,mp4,flac,ogg,wma,aac,wav,opus,zip,rar`
- **Default:** `m4b,mp3`
#### `BOOK_LANGUAGE`
**Default Book Languages**
Default language filter for searches.
- **Type:** string (comma-separated)
- **Default:** `en`
</details>
@@ -305,11 +185,9 @@ Audiobook formats to include in search results. ZIP/RAR archives are extracted a
| 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` |
| `FORCE_COMBINED_SEARCH` | Force combined search whenever it's available. Locks the combined toggle on. | boolean | `false` |
| `METADATA_PROVIDER` | Choose which metadata provider to use for book searches. | string (choice) | `openlibrary` |
| `METADATA_PROVIDER_AUDIOBOOK` | Metadata provider for audiobook searches. Uses the book provider if not set. | string (choice) | _empty string_ |
| `METADATA_PROVIDER_COMBINED` | Metadata provider for combined mode searches. Uses the book provider if not set. | string (choice) | _empty string_ |
@@ -329,15 +207,6 @@ 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**
@@ -366,15 +235,6 @@ Show the option to search for and download both a book and audiobook together.
- **Type:** boolean
- **Default:** `true`
#### `FORCE_COMBINED_SEARCH`
**Always Use Combined Search**
Force combined search whenever it's available. Locks the combined toggle on.
- **Type:** boolean
- **Default:** `false`
#### `METADATA_PROVIDER`
**Book Metadata Provider**
@@ -434,8 +294,8 @@ The release source tab to open by default in the release modal for audiobooks. U
| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` |
| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` |
| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` |
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {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})` |
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. 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_ |
@@ -451,13 +311,13 @@ The release source tab to open by default in the release modal for audiobooks. U
| `EMAIL_SMTP_USERNAME` | SMTP username (leave empty for no authentication). | string | _none_ |
| `EMAIL_SMTP_PASSWORD` | SMTP password (required if Username is set). | string (secret) | _none_ |
| `EMAIL_FROM` | From address used for the email. You can include a display name (e.g., Shelfmark <mail@example.com>). Leave blank to default to the SMTP username (when it is an email address). | string | _none_ |
| `EMAIL_SUBJECT_TEMPLATE` | Email subject. Variables: {Author}, {Title}, {PrimaryTitle}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}. | string | `{Title}` |
| `EMAIL_SUBJECT_TEMPLATE` | Email subject. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}. | string | `{Title}` |
| `EMAIL_SMTP_TIMEOUT_SECONDS` | How long to wait for SMTP operations before failing. | number | `60` |
| `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}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{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_ |
@@ -501,7 +361,7 @@ Choose how downloaded book files are named and organized.
**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}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. 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})`
@@ -510,7 +370,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}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
- **Type:** string
- **Default:** `{Author}/{Title} ({Year})`
@@ -664,7 +524,7 @@ From address used for the email. You can include a display name (e.g., Shelfmark
**Subject Template**
Email subject. Variables: {Author}, {Title}, {PrimaryTitle}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}.
Email subject. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}.
- **Type:** string
- **Default:** `{Title}`
@@ -705,13 +565,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), `rename_and_group` (Rename and Group)
- **Options:** `none` (None), `rename` (Rename Only), `organize` (Rename and Organize)
#### `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}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {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}`
@@ -720,10 +580,10 @@ 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}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
- **Type:** string
- **Default:** `{Author}/{Title}/{Title}`
- **Default:** `{Author}/{Title}`
#### `HARDLINK_TORRENTS_AUDIOBOOK`
@@ -750,7 +610,6 @@ 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`
@@ -780,7 +639,7 @@ How long to keep completed/failed downloads in the queue display.
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `AUTH_METHOD` | Select the authentication method for accessing Shelfmark. Restart container after changing Calibre-Web passwords. | string (choice) | `none` |
| `AUTH_METHOD` | Select the authentication method for accessing Shelfmark. | string (choice) | `none` |
| `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` |
@@ -802,7 +661,7 @@ How long to keep completed/failed downloads in the queue display.
**Authentication Method**
Select the authentication method for accessing Shelfmark. Restart container after changing Calibre-Web passwords.
Select the authentication method for accessing Shelfmark.
- **Type:** string (choice)
- **Default:** `none`
@@ -1049,13 +908,11 @@ Comma-separated hosts to bypass proxy (e.g., localhost,127.0.0.1,10.*,*.local)
|----------|-------------|------|---------|
| `URL_BASE` | Optional URL path prefix. Use a path like /shelfmark (no hostname). Leave blank for root. | string | _none_ |
| `DEBUG` | Enable verbose logging to console and file. Not recommended for normal use. | boolean | `false` |
| `LOG_LEVEL` | Lowest severity written to the console and log file. Ignored while Debug Mode is on, which forces Debug. | string (choice) | `INFO` |
| `MAIN_LOOP_SLEEP_TIME` | How often the download queue is checked for new items. | number | `5` |
| `DOWNLOAD_PROGRESS_UPDATE_INTERVAL` | How often download progress is broadcast to the UI. | number | `1` |
| `CUSTOM_SCRIPT` | Path to a script to run after each successful download. Must be executable. | string | _none_ |
| `CUSTOM_SCRIPT_PATH_MODE` | Pass the path to the custom script as an absolute path or relative to the destination folder. | string (choice) | `absolute` |
| `CUSTOM_SCRIPT_JSON_PAYLOAD` | Send a JSON payload to the script via stdin. Useful for multi-file imports (audiobooks) or richer metadata without relying on path parsing. | boolean | `false` |
| `DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT` | How long to wait after a torrent or usenet client reports completion for the completed file path to become visible to Shelfmark. Increase this for seedbox or remote-sync workflows. | number | `60` |
| `COVERS_CACHE_ENABLED` | Cache book covers on the server for faster loading. | boolean | `true` |
| `COVERS_CACHE_TTL` | How long to keep cached covers. Set to 0 to keep forever (recommended for static artwork). | number | `0` |
| `COVERS_CACHE_MAX_SIZE_MB` | Maximum disk space for cached covers. Oldest images are removed when limit is reached. | number | `500` |
@@ -1086,17 +943,6 @@ Enable verbose logging to console and file. Not recommended for normal use.
- **Default:** `false`
- **Requires restart:** Yes
#### `LOG_LEVEL`
**Log Level**
Lowest severity written to the console and log file. Ignored while Debug Mode is on, which forces Debug.
- **Type:** string (choice)
- **Default:** `INFO`
- **Requires restart:** Yes
- **Options:** `DEBUG` (Debug), `INFO` (Info), `WARNING` (Warning), `ERROR` (Error), `CRITICAL` (Critical)
#### `MAIN_LOOP_SLEEP_TIME`
**Queue Check Interval (seconds)**
@@ -1147,16 +993,6 @@ Send a JSON payload to the script via stdin. Useful for multi-file imports (audi
- **Type:** boolean
- **Default:** `false`
#### `DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT`
**Completed Path Wait (seconds)**
How long to wait after a torrent or usenet client reports completion for the completed file path to become visible to Shelfmark. Increase this for seedbox or remote-sync workflows.
- **Type:** number
- **Default:** `60`
- **Constraints:** min: 0, max: 3600
#### `COVERS_CACHE_ENABLED`
**Enable Cover Cache**
@@ -1225,10 +1061,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` |
<details>
<summary>Detailed descriptions</summary>
@@ -1271,16 +1104,6 @@ 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**
@@ -1290,111 +1113,6 @@ Automatically retry search without category filtering if no results are found
- **Type:** boolean
- **Default:** `false`
#### `PROWLARR_COLLAPSE_DUPLICATES`
**Show one row per release**
Collapse a release that several indexer entries returned down to a single row, keeping the entry with the best Prowlarr priority. Turn this off to see every entry that carried it, which is what makes results from filter-specific entries (freeleech and the like) visible.
- **Type:** boolean
- **Default:** `true`
#### `PROWLARR_USE_SEED_PREFERENCES`
**Use Prowlarr seed preferences**
Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client
- **Type:** boolean
- **Default:** `false`
</details>
## Newznab
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `NEWZNAB_ENABLED` | Enable searching for books via a Newznab-compatible indexer | boolean | `false` |
| `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` |
<details>
<summary>Detailed descriptions</summary>
#### `NEWZNAB_ENABLED`
**Enable Newznab source**
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`
**Legacy Newznab URL**
Single-indexer fallback used only when `NEWZNAB_INDEXERS` is empty.
- **Type:** string
- **Default:** _none_
#### `NEWZNAB_API_KEY`
**Legacy API Key**
API key for the legacy Newznab URL.
- **Type:** string (secret)
- **Default:** _none_
#### `NEWZNAB_EBOOK_CATEGORIES`
**Ebook Categories**
Newznab category IDs searched for ebooks. Most indexers use the standard 7000, but some use custom IDs. Leave empty to use 7000.
- **Type:** string (comma-separated)
- **Default:** `7000`
#### `NEWZNAB_AUDIOBOOK_CATEGORIES`
**Audiobook Categories**
Newznab category IDs searched for audiobooks. Most indexers use the standard 3030, but some use custom IDs. Leave empty to use 3030.
- **Type:** string (comma-separated)
- **Default:** `3030`
#### `NEWZNAB_AUTO_EXPAND`
**Auto-expand search on no results**
Automatically retry search without category filtering if no results are found
- **Type:** boolean
- **Default:** `false`
</details>
## AudiobookBay
@@ -1467,11 +1185,9 @@ Delay between requests in seconds to avoid rate limiting (0-10).
| `IRC_SERVER` | IRC server hostname | string | _none_ |
| `IRC_PORT` | IRC server port (usually 6697 for TLS, 6667 for plain) | number | `6697` |
| `IRC_USE_TLS` | Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't support TLS. | boolean | `true` |
| `IRC_CHANNEL` | Channel name without the # prefix. Used for all searches unless a separate audiobook channel is configured below. | string | _none_ |
| `IRC_CHANNEL` | Channel name without the # prefix | string | _none_ |
| `IRC_NICK` | Your IRC nickname (required). Must be unique on the IRC network. | string | _none_ |
| `IRC_SEARCH_BOT` | The search bot to address queries to (required). Searches are sent as "@<bot> <query>". | string | _none_ |
| `IRC_AUDIOBOOK_CHANNEL` | Optional. Channel name (without the # prefix) for networks that index audiobooks separately, such as Undernet's bookz. Leave blank (the usual setting) to search the main channel above for audiobooks too. | string | _none_ |
| `IRC_AUDIOBOOK_SEARCH_BOT` | Optional. Search bot for the audiobook channel. Leave blank to reuse the main search bot above. Only used when an audiobook channel is set. | string | _none_ |
| `IRC_SEARCH_BOT` | The search bot to query for results | string | _none_ |
| `IRC_CACHE_TTL` | How long to keep cached search results before they expire. | string (choice) | `2592000` |
<details>
@@ -1509,7 +1225,7 @@ Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't
**Channel**
Channel name without the # prefix. Used for all searches unless a separate audiobook channel is configured below.
Channel name without the # prefix
- **Type:** string
- **Default:** _none_
@@ -1529,26 +1245,7 @@ Your IRC nickname (required). Must be unique on the IRC network.
**Search bot**
The search bot to address queries to (required). Searches are sent as "@<bot> <query>".
- **Type:** string
- **Default:** _none_
- **Required:** Yes
#### `IRC_AUDIOBOOK_CHANNEL`
**Audiobook channel**
Optional. Channel name (without the # prefix) for networks that index audiobooks separately, such as Undernet's bookz. Leave blank (the usual setting) to search the main channel above for audiobooks too.
- **Type:** string
- **Default:** _none_
#### `IRC_AUDIOBOOK_SEARCH_BOT`
**Audiobook search bot**
Optional. Search bot for the audiobook channel. Leave blank to reuse the main search bot above. Only used when an audiobook channel is set.
The search bot to query for results
- **Type:** string
- **Default:** _none_
@@ -1570,12 +1267,9 @@ How long to keep cached search results before they expire.
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `PROWLARR_TORRENT_CLIENT` | Choose which torrent client to use | string (choice) | _empty string_ |
| `ALLDEBRID_API_KEY` | AllDebrid API Key (apiv4) from your AllDebrid account settings | string (secret) | _none_ |
| `REALDEBRID_API_KEY` | Real-Debrid API Key (Secret Token) from your Real-Debrid account settings | string (secret) | _none_ |
| `QBITTORRENT_URL` | Web UI URL of your qBittorrent instance | string | _none_ |
| `QBITTORRENT_USERNAME` | qBittorrent Web UI username | string | _none_ |
| `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ |
| `QBITTORRENT_API_KEY` | Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set. | string (secret) | _none_ |
| `QBITTORRENT_CATEGORY` | Category to assign to book downloads in qBittorrent | string | `books` |
| `QBITTORRENT_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ |
| `QBITTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ |
@@ -1595,11 +1289,9 @@ How long to keep cached search results before they expire.
| `RTORRENT_URL` | XML-RPC URL of your rTorrent instance | string | _none_ |
| `RTORRENT_USERNAME` | HTTP Basic auth username (if authentication enabled) | string | _none_ |
| `RTORRENT_PASSWORD` | HTTP Basic auth password | string (secret) | _none_ |
| `RTORRENT_LABEL` | Label to assign to ebook downloads in rTorrent | string | `cwabd` |
| `RTORRENT_AUDIOBOOK_LABEL` | Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set) | string | _none_ |
| `RTORRENT_LABEL` | Label to assign to book downloads in rTorrent | string | `cwabd` |
| `RTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
| `PROWLARR_TORRENT_ACTION` | Choose whether to keep, remove, or move the torrent to another category or label after import | string (choice) | `keep` |
| `PROWLARR_TORRENT_POST_IMPORT_CATEGORY` | Category or label to assign after a successful import | string | _empty string_ |
| `PROWLARR_TORRENT_ACTION` | Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding | string (choice) | `keep` |
| `PROWLARR_USENET_CLIENT` | Choose which usenet client to use | string (choice) | _empty string_ |
| `NZBGET_URL` | URL of your NZBGet instance | string | _none_ |
| `NZBGET_USERNAME` | NZBGet control username | string | `nzbget` |
@@ -1623,25 +1315,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)
#### `ALLDEBRID_API_KEY`
**API Key**
AllDebrid API Key (apiv4) from your AllDebrid account settings
- **Type:** string (secret)
- **Default:** _none_
#### `REALDEBRID_API_KEY`
**API Key**
Real-Debrid API Key (Secret Token) from your Real-Debrid account settings
- **Type:** string (secret)
- **Default:** _none_
- **Options:** `""` (None), `qbittorrent` (qBittorrent), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent)
#### `QBITTORRENT_URL`
@@ -1670,15 +1344,6 @@ qBittorrent Web UI password
- **Type:** string (secret)
- **Default:** _none_
#### `QBITTORRENT_API_KEY`
**API Key**
Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set.
- **Type:** string (secret)
- **Default:** _none_
#### `QBITTORRENT_CATEGORY`
**Book Category**
@@ -1854,20 +1519,11 @@ HTTP Basic auth password
**Book Label**
Label to assign to ebook downloads in rTorrent
Label to assign to book downloads in rTorrent
- **Type:** string
- **Default:** `cwabd`
#### `RTORRENT_AUDIOBOOK_LABEL`
**Audiobook Label**
Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set)
- **Type:** string
- **Default:** _none_
#### `RTORRENT_DOWNLOAD_DIR`
**Download Directory**
@@ -1881,20 +1537,11 @@ Server-side directory where torrents are downloaded (optional, uses rTorrent def
**Torrent Completion Action**
Choose whether to keep, remove, or move the torrent to another category or label after import
Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding
- **Type:** string (choice)
- **Default:** `keep`
- **Options:** `keep` (Keep), `remove` (Remove), `change_category` (Change Category)
#### `PROWLARR_TORRENT_POST_IMPORT_CATEGORY`
**Post-Import Category**
Category or label to assign after a successful import
- **Type:** string
- **Default:** _empty string_
- **Options:** `keep` (Keep), `remove` (Remove)
#### `PROWLARR_USENET_CLIENT`
@@ -2006,7 +1653,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 (starts with hc_pat_) | string (secret) | _none_ |
| `HARDCOVER_API_KEY` | Get your API key from hardcover.app/account/api | 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` |
@@ -2028,7 +1675,7 @@ Enable Hardcover as a metadata provider for book searches
**API Key**
Get your API key from hardcover.app/account/api (starts with hc_pat_)
Get your API key from hardcover.app/account/api
- **Type:** string (secret)
- **Default:** _none_
@@ -2146,26 +1793,6 @@ Default sort order for Google Books search results.
</details>
### Metadata Providers: Moly.hu
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `MOLY_ENABLED` | Enable Moly.hu as a metadata provider for book searches | boolean | `false` |
<details>
<summary>Detailed descriptions</summary>
#### `MOLY_ENABLED`
**Enable Moly.hu**
Enable Moly.hu as a metadata provider for book searches
- **Type:** boolean
- **Default:** `false`
</details>
## Direct Download
### Direct Download: Download Sources
@@ -2173,13 +1800,11 @@ Enable Moly.hu as a metadata provider for book searches
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `DIRECT_DOWNLOAD_ENABLED` | Show Direct Download in release-source lists and allow Direct mode searches. Add your own mirror URLs in the Mirrors tab before using it. | boolean | `false` |
| `DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH` | When language metadata is missing or unknown, parse the distant path (file path shown in search results) for language tags like [BD FR] or [En]. Also enables local language filtering so lgli files without AA language metadata are not excluded before the distant path can be checked. | boolean | `false` |
| `AA_DONATOR_KEY` | Enables fast download access on AA. Get this from your donator account page. | string (secret) | _none_ |
| `FAST_SOURCES_DISPLAY` | Always tried first, no waiting or bypass required. | JSON array | _see UI for defaults_ |
| `SOURCE_PRIORITY` | Fallback sources, may have waiting. Requires bypasser. Drag to reorder. | JSON array | _see UI for defaults_ |
| `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_ |
@@ -2202,15 +1827,6 @@ Show Direct Download in release-source lists and allow Direct mode searches. Add
- **Type:** boolean
- **Default:** `false`
#### `DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH`
**Detect Language From Distant Path**
When language metadata is missing or unknown, parse the distant path (file path shown in search results) for language tags like [BD FR] or [En]. Also enables local language filtering so lgli files without AA language metadata are not excluded before the distant path can be checked.
- **Type:** boolean
- **Default:** `false`
#### `AA_DONATOR_KEY`
**Account Donator Key**
@@ -2258,16 +1874,6 @@ 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**
@@ -2344,8 +1950,6 @@ 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>
@@ -2401,27 +2005,6 @@ 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
+4 -19
View File
@@ -12,7 +12,7 @@ With a subpath (`URL_BASE=/shelfmark/`):
https://<your-shelfmark-domain>/shelfmark/api/auth/oidc/callback
```
The callback URL is constructed from the incoming request, so your reverse proxy must forward `X-Forwarded-Proto` and `X-Forwarded-Host` correctly, including the external port when it is not the protocol default. PKCE (S256) is used automatically.
The callback URL is constructed from the incoming request, so your reverse proxy must forward `X-Forwarded-Proto` and `X-Forwarded-Host` correctly. PKCE (S256) is used automatically.
## Settings
@@ -30,19 +30,7 @@ Configure in **Settings → Security → Authentication Method → OIDC**.
| Auto-Provision Users | Create accounts on first login | `true` |
| Login Button Label | Custom text for the sign-in button | — |
Use **Test Connection** to verify discovery, client configuration, and the provider's token signing keys (JWKS) before attempting login.
> **Authentik users:** make sure your provider has a **Signing Key** selected (e.g. the default self-signed certificate). Without one, Authentik serves an empty JWKS document and every login fails with an OIDC callback error, even though the discovery document looks healthy.
## Account Linking
On login, Shelfmark matches the OIDC identity to a user account in this order:
1. **OIDC subject** — a user who has logged in through this provider before.
2. **Email** — a local account with the same (unique) email address. This only happens when the provider also asserts `email_verified: true` for the address; an unverified email would let anyone claim a local account by registering its address at the IdP.
3. Otherwise, a new account is created when **Auto-Provision Users** is enabled (username conflicts get a numeric suffix), or the login is rejected with "Account not found" when it is disabled.
If the `email_verified` claim is missing or `false`, email linking is silently skipped — a common surprise when the address was never verified at the identity provider (e.g. Keycloak's **Email verified** toggle on the user, or Authentik accounts created without email verification). Make sure the `email` scope is requested and the address is marked verified in your IdP.
Use **Test Connection** to verify discovery and client configuration before attempting login.
## Environment Variables
@@ -51,15 +39,12 @@ These optional environment variables control login page behavior when OIDC is en
| Variable | Description | Default |
|----------|-------------|---------|
| `HIDE_LOCAL_AUTH` | Hide the username/password login option, so only the OIDC button is shown | `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. | `false` |
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page | `false` |
If `DISABLE_LOCAL_AUTH` and `OIDC_AUTO_REDIRECT` are both enabled, users are redirected straight to the OIDC provider. On failure they return to the login page with an error message but no password fallback.
If both are enabled, users are redirected straight to the OIDC provider. On failure they return to the login page with an error message but no password fallback.
## Troubleshooting
- **No token signing keys (empty JWKS)** — The provider's JWKS endpoint returned no keys, so ID tokens can't be verified. In Authentik this happens when the provider has no **Signing Key** selected; pick one (e.g. the default self-signed certificate) and try again.
- **Issuer validation failed** — The issuer in the token doesn't match the discovery document. Check your provider's external URL / issuer configuration.
- **Callback URL mismatch** — Reverse proxy isn't forwarding `X-Forwarded-Proto` or `X-Forwarded-Host`, so the constructed callback URL doesn't match what's registered in the provider.
- **Account not found** — Auto-provision is disabled and the user hasn't been pre-created by an admin. If you pre-created the account with a matching email, see [Account Linking](#account-linking): the provider must send `email_verified: true` for linking to happen.
- **Login created a duplicate account instead of linking to my local one** — Email linking requires a verified email; see [Account Linking](#account-linking). With `DEBUG=true`, the log notes when linking is skipped because the address isn't verified.
- **Account not found** — Auto-provision is disabled and the user hasn't been pre-created by an admin.
+5 -7
View File
@@ -23,11 +23,10 @@ server {
location / {
proxy_pass http://shelfmark:8084;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
@@ -57,11 +56,11 @@ All Shelfmark paths (UI, API, assets, Socket.IO) are served under the base path.
location /shelfmark/ {
proxy_pass http://shelfmark:8084/shelfmark/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
@@ -137,11 +136,11 @@ location /shelfmark/ {
proxy_pass http://shelfmark:8084/shelfmark/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
@@ -159,7 +158,6 @@ If login, settings saves, or downloads appear to fail in the browser but the act
- Do not force `Connection: upgrade` on every request. That can break normal `POST` and `PUT` responses while the backend still processes them.
- If your proxy UI does not support conditional websocket headers, remove the forced websocket headers entirely and let Shelfmark fall back to polling.
- Keep the standard forwarded headers: `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` when using a subpath or OIDC.
- Preserve the original port in `Host` and `X-Forwarded-Host` by using `$http_host` rather than `$host` when Shelfmark is exposed on a custom port.
This is especially relevant for Nginx Proxy Manager or custom advanced config snippets that add websocket headers globally.
+1 -8
View File
@@ -19,7 +19,7 @@ http://your-server:8084/?q=harry+potter
| `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` |
| `content_type` | Select media type (`ebook` or `audiobook`) in Universal mode only | `/?q=dune&content_type=audiobook` |
| `sort` | Sort order for results | `/?sort=newest` |
## Multiple Values
@@ -63,11 +63,6 @@ Some parameters support multiple values by repeating the parameter:
/?q=dune&content_type=audiobook
```
**Universal search forcing combined (ebook + audiobook):**
```
/?q=dune&content_type=combined
```
## Search Mode Behavior
### Direct Mode
@@ -79,8 +74,6 @@ When Search Mode is set to Direct, all parameters are used to filter results fro
`q`, `sort`, and `content_type` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities.
`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).
## Notes
- URL parameters are read once on page load
+1 -10
View File
@@ -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 four categories of per-user settings:
There are three categories of per-user settings:
### Delivery Preferences
@@ -42,15 +42,6 @@ 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.
+30 -88
View File
@@ -81,13 +81,6 @@ if is_truthy "$ENABLE_LOGGING_VALUE"; then
fi
fi
# Egress modes are mutually exclusive. Check this BEFORE starting either one so
# we never run tor.sh and then abort, leaving a half-configured network stack.
if [ "$USING_TOR" = "true" ] && [ "$USING_WIREGUARD" = "true" ]; then
echo "USING_TOR and USING_WIREGUARD are mutually exclusive; enable only one egress mode." >&2
exit 1
fi
if [ "$USING_TOR" = "true" ]; then
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
echo "USING_TOR=true requires the container to start as root." >&2
@@ -97,15 +90,6 @@ if [ "$USING_TOR" = "true" ]; then
./tor.sh
fi
if [ "$USING_WIREGUARD" = "true" ]; then
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
echo "USING_WIREGUARD=true requires the container to start as root." >&2
echo "Non-root mode skips the privileged network setup WireGuard depends on." >&2
exit 1
fi
./wireguard.sh
fi
if [ "$FILE_LOGGING_ENABLED" = "true" ]; then
start_file_logging "$LOG_FILE"
fi
@@ -122,18 +106,6 @@ if [ ! -x "$PYTHON_BIN" ]; then
PYTHON_BIN="python3"
fi
# Defensive: some orchestrators (e.g. Unraid Dockhand templates) inject a default
# PATH that drops the venv bin directory baked in by the Dockerfile. Prepend it
# so subprocesses launched without an absolute path still resolve correctly.
case ":${PATH}:" in
*":/app/.venv/bin:"*) ;;
*) export PATH="/app/.venv/bin:${PATH}" ;;
esac
GUNICORN_BIN="/app/.venv/bin/gunicorn"
if [ ! -x "$GUNICORN_BIN" ]; then
GUNICORN_BIN="gunicorn"
fi
# Print build version
echo "Build version: $BUILD_VERSION"
echo "Release version: $RELEASE_VERSION"
@@ -251,22 +223,13 @@ test_write() {
return 1
fi
# This is a probe: a failure here is expected (e.g. a fresh root-owned bind
# mount) and is recovered by the caller via change_ownership + re-probe. Hide
# the shell's "Permission denied"/"Read-only file system" stderr so a handled
# probe miss doesn't masquerade as a real boot failure in the logs.
if ! run_as_target_user sh -c 'echo 0123456789_TEST 2>/dev/null > "$1"' _ "$test_file"; then
if ! run_as_target_user sh -c 'echo 0123456789_TEST > "$1"' _ "$test_file"; then
echo "Failed to write test file in $folder as $USERNAME"
return 1
fi
FILE_CONTENT=$(cat "$test_file" 2>/dev/null || echo "")
# A folder can be writable but not deletable (e.g. a Synology share without
# "Delete subfolders and files"). That is not a boot failure - the app writes
# files in place on such shares - so don't let a failed cleanup print an
# alarming error or fail the probe.
run_as_target_user rm -f "$test_file" 2>/dev/null || \
echo "Note: could not remove test file in $folder (folder is writable but not deletable)"
rm -f "$test_file"
[ "$FILE_CONTENT" = "0123456789_TEST" ]
result=$?
if [ $result -eq 0 ]; then
@@ -347,27 +310,6 @@ require_writable_dir() {
fi
}
fail_unwritable_config_dir() {
local folder="$1"
local owner
owner=$(stat -c '%u:%g' "$folder" 2>/dev/null || echo "unknown")
echo ""
echo "========================================================"
echo "ERROR: Config directory is not writable!"
echo ""
echo "Config directory: $folder"
echo "Current owner: $owner"
echo "Configured runtime identity: ${RUN_UID}:${RUN_GID}"
echo ""
echo "To fix this permanently, run on your HOST machine:"
echo " chown -R $RUN_UID:$RUN_GID /path/to/config"
echo "========================================================"
echo ""
exit 1
}
resolve_runtime_home() {
local runtime_home
@@ -463,40 +405,45 @@ else
# Config is Shelfmark-owned state, so it keeps the thorough repair path.
make_writable "${CONFIG_DIR:-/config}" tree
# Refuse to continue if the config directory is still not writable after repair.
# Fallback to root if config dir is still not writable (common on NAS/Unraid after upgrade from v0.4.0)
CONFIG_PATH=${CONFIG_DIR:-/config}
set +e
test_write "$CONFIG_PATH" >/dev/null 2>&1
config_ok=$?
set -e
if [ $config_ok -ne 0 ]; then
fail_unwritable_config_dir "$CONFIG_PATH"
if [ $config_ok -ne 0 ] && [ "$RUN_UID" != "0" ]; then
config_owner=$(stat -c '%u' "$CONFIG_PATH" 2>/dev/null || echo "unknown")
if [ "$config_owner" = "0" ]; then
echo ""
echo "========================================================"
echo "WARNING: Permission issue detected!"
echo ""
echo "Config directory is owned by root but PUID=$RUN_UID."
echo "This typically happens after upgrading from v0.4.0 where"
echo "PUID/PGID settings were not respected."
echo ""
echo "Falling back to running as root to prevent data loss."
echo ""
echo "To fix this permanently, run on your HOST machine:"
echo " chown -R $RUN_UID:$RUN_GID /path/to/config"
echo ""
echo "Then restart the container."
echo "========================================================"
echo ""
RUN_UID=0
RUN_GID=0
USERNAME=root
TARGET_USER_SPEC="0:0"
fi
fi
# The ingest/destination library (default /books) is user data and may be a
# bind mount owned by another uid; downloads fail with "Destination not
# writable" if the runtime user can't write there. Fix the top-level dir only
# (root mode) so we don't recursively chown a potentially huge library.
make_writable "${INGEST_DIR:-/books}" root
fi
# Always run Gunicorn (even when DEBUG=true) to ensure Socket.IO WebSocket
# upgrades work reliably on customer machines.
# Map app LOG_LEVEL (often DEBUG/INFO/...) to gunicorn's --log-level (lowercase).
# Gunicorn rejects anything outside its own list, so normalize and fall back to
# info rather than letting a typo stop the container from booting.
if [ "$DEBUG" = "true" ]; then
gunicorn_loglevel=debug
else
gunicorn_loglevel=$(echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]')
[ "$gunicorn_loglevel" = "warn" ] && gunicorn_loglevel=warning
case "$gunicorn_loglevel" in
debug|info|warning|error|critical) ;;
*) gunicorn_loglevel=info ;;
esac
fi
command="${GUNICORN_BIN} --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} shelfmark.main:app"
gunicorn_loglevel=$([ "$DEBUG" = "true" ] && echo debug || echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]')
command="gunicorn --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} shelfmark.main:app"
# If DEBUG and not using an external bypass
if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
@@ -565,12 +512,7 @@ else
fi
RUNTIME_HOME=$(resolve_runtime_home)
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
require_writable_dir "$RUNTIME_HOME" "Home"
else
mkdir -p "$RUNTIME_HOME"
make_writable "$RUNTIME_HOME" tree
fi
require_writable_dir "$RUNTIME_HOME" "Home"
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
echo "Startup mode: non-root"
+7 -9
View File
@@ -19,31 +19,29 @@ dependencies = [
"psutil",
"emoji",
"rarfile",
"qbittorrent-api>=2026.8.1",
"qbittorrent-api",
"transmission-rpc",
"authlib>=1.7.2,<1.8",
"apprise>=1.13.0",
# 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",
"authlib>=1.7.0,<1.8",
"apprise>=1.9.0",
"Pillow>=11.0.0",
]
[project.optional-dependencies]
browser = [
"pyvirtualdisplay",
"pyautogui",
"seleniumbase==4.53.5",
"seleniumbase==4.48.2",
"python-xlib",
]
[dependency-groups]
dev = [
"basedpyright>=1.39.10",
"basedpyright>=1.39.3",
"prek",
"pytest",
"pytest-cov",
"pytest-xdist>=3.8.0",
"ruff==0.16.5",
"ruff==0.15.11",
"vulture>=2.14",
]
+4 -78
View File
@@ -2,9 +2,6 @@
<img src="src/frontend/public/logo.png" alt="Shelfmark" width="200">
> [!NOTE]
> Shelfmark is feature stable and maintained on a best-effort basis. Bug fixes, security updates, and small quality-of-life improvements are still shipped, and pull requests are reviewed — including new features. There is no roadmap for new features for now.
Shelfmark is a self-hosted web interface for searching and requesting books and audiobooks across multiple sources. Bring your own sources, metadata providers, and download clients to build a single hub for your digital library. Supports multiple users with a built-in request system, so you can share your instance with others and let them browse and request books on their own.
Works great alongside the following library tools, with support for automatic imports:
@@ -44,7 +41,6 @@ Works great alongside the following library tools, with support for automatic im
### Prerequisites
- Docker & Docker Compose
- At least 2 GB of RAM available to the container when using the standard image — see [Memory Requirements](#memory-requirements)
### Installation
@@ -95,30 +91,6 @@ 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.
@@ -131,24 +103,13 @@ Environment variables work for initial setup and Docker deployments. They serve
| `PUID` / `PGID` | Runtime user/group for the default root-startup flow (also supports legacy `UID`/`GID`) | `1000` / `1000` |
| `SEARCH_MODE` | `direct` or `universal` | `universal` |
| `USING_TOR` | Enable Tor routing (requires root startup) | `false` |
| `USING_WIREGUARD` | Enable WireGuard VPN egress with kill-switch (requires root startup) | `false` |
| `WIREGUARD_CONFIG` | Path to the mounted wg-quick config | `/config/wg0.conf` |
| `WIREGUARD_INTERFACE` | WireGuard interface name | `wg0` |
| `LAN_NETWORK` | Comma-separated CIDRs kept off the tunnel so the WebUI / internal clients stay reachable | `127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` |
| `WIREGUARD_ENFORCE_DNS` | Pin the resolver (via `WIREGUARD_DNS`, else the config's `DNS =`) so DNS can't silently fall back to an off-tunnel path. Designed for a trusted LAN resolver kept reachable via `LAN_NETWORK` (query leaves over the LAN; download still egresses via the tunnel) — it does **not** force queries through the tunnel. Docker's embedded resolver (`127.0.0.11`) is preserved when present so container-name resolution keeps working; pin its upstream via the container's `dns:` list. Fails closed if no resolver is available or `/etc/resolv.conf` is not writable. | `true` |
| `WIREGUARD_DNS` | Explicit resolver(s) to pin (comma/space separated). Use when the VPN's pushed DNS filters domains you need; point at a resolver reachable via the tunnel or an allowed LAN resolver. | _(unset; uses config `DNS =`)_ |
| `WIREGUARD_DISABLE_IPV6` | Strip IPv6 from the tunnel config (many container kernels lack the ip6tables `raw` table wg-quick needs) and remove IPv6 as a leak surface. | `true` |
| `WIREGUARD_ALLOW_IPV6_LEAK` | Escape hatch: continue even when an IPv6 kill-switch can't be installed AND IPv6 can't be disabled. Only set if the container has no IPv6 connectivity. | `false` |
| `WIREGUARD_ALLOW_WEBUI_OFFTUNNEL` | Opt-in off-tunnel WebUI reachability. Default (`false`) keeps the kill-switch strictly fail-closed: the only off-tunnel egress is loopback, the tunnel device and the LAN allowlist. Set `true` only if a **non-LAN** client (e.g. a public reverse proxy on another segment) must reach the WebUI; it permits app-server **replies** (`--sport FLASK_PORT`, conntrack REPLY) off-tunnel — server replies only, never client-initiated egress. LAN clients never need it (covered by `LAN_NETWORK`). | `false` |
| `WIREGUARD_STALE_AFTER` | Seconds since the last handshake before the healthcheck bounces the tunnel. | `180` |
See the full [Environment Variables Reference](docs/environment-variables.md) for all available options.
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
- **IRC** - Add details for IRC book sources and download directly from the UI
- **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
- **Network Settings** - Custom proxy support (SOCKS5 + HTTP/S) and configurable DNS
@@ -164,17 +125,6 @@ docker compose up -d
The full-featured image with all network capabilities included.
#### Memory Requirements
The standard image ships a real Chromium browser, which it launches to solve Cloudflare challenges for Direct Download. Chromium needs room to run:
- **2 GB of RAM available to the container** is a safe minimum; 1 GB or less is where problems usually start
- Only relevant if you use Direct Download. Prowlarr, IRC and audiobook sources don't start the browser
When the container is starved of memory, Chromium fails to start and every Direct Download fails with unrelated-looking errors — repeated `403 detected; switching to bypasser` followed by `No download URL found`, and downloads that never complete. If you're seeing that, check the container's memory limit and the host's free memory before suspecting your ISP or DNS.
If you can't spare the memory, use the [Lite](#lite) image with an external resolver (e.g. FlareSolverr) running elsewhere.
#### Tor Routing
Optional Tor support for network privacy:
```bash
@@ -188,31 +138,12 @@ docker compose -f docker-compose.tor.yml up -d
- Timezone is auto-detected from Tor exit node
- Custom DNS/proxy settings are ignored when Tor is active
#### WireGuard VPN Routing
Optional WireGuard support to route all external egress through a VPN tunnel with a fail-closed kill-switch:
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.wireguard.yml
# place your wg-quick config where the compose mounts /config, as wg0.conf
docker compose -f docker-compose.wireguard.yml up -d
```
**Notes:**
- Requires root startup
- Requires `NET_ADMIN` and `NET_RAW` capabilities
- Mount a standard wg-quick config at `WIREGUARD_CONFIG` (default `/config/wg0.conf`)
- All non-LAN egress is forced through the tunnel; if the tunnel drops, external traffic **fails closed** while LAN ranges (WebUI, Prowlarr, qBittorrent) stay reachable
- IPv4 and IPv6 both fail closed. On kernels without a usable `ip6tables`, disable IPv6 for the container (`sysctls: net.ipv6.conf.all.disable_ipv6=1`, as in the compose example) or the container refuses to start rather than risk an IPv6 leak
- A supervised healthcheck bounces the tunnel if the handshake goes stale, and refreshes the endpoint allow rules so a roaming/rotated peer endpoint can reconnect
- Mutually exclusive with `USING_TOR`
- **DNS trust:** `WIREGUARD_DNS` must be a resolver you trust on a trusted network segment. When it is a LAN resolver (kept reachable off-tunnel by `LAN_NETWORK`), the query to that resolver leaves as plaintext UDP/53 on the LAN — the resolver is responsible for encrypting upstream. Two resolver paths exist: (1) when Docker's embedded resolver (`127.0.0.11`) is present it is **preserved** so container names (Prowlarr, qBittorrent) resolve — you MUST pin its upstream to a trusted resolver via the container's compose `dns:` list, since `WIREGUARD_DNS` cannot repoint the embedded resolver from inside the container; (2) otherwise `WIREGUARD_DNS`/the config `DNS =` line is written to `/etc/resolv.conf`. Setting `WIREGUARD_ENFORCE_DNS=false` is a **foot-gun**: with no embedded resolver present the container then uses its inherited resolver, which forwards to the Docker daemon's upstream **off-tunnel**, leaking your DNS. Leave enforcement on unless you have pinned the resolver another way.
### Lite
A lighter image without the built-in browser automation. Ideal for:
- **External services** - Already running FlareSolverr or similar for other applications
- **Alternative sources** - Using Prowlarr, IRC, or other configured sources
- **Audiobooks** - Using Shelfmark primarily for audiobooks
- **Constrained hosts** - No bundled browser, so it runs comfortably below the standard image's [memory requirements](#memory-requirements)
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.lite.yml
@@ -263,11 +194,9 @@ These are non-goals, not missing features.
## Contributing
Shelfmark's core feature set is complete.
Shelfmark's core feature set is complete. Development focuses on stability, bug fixes, quality-of-life improvements, and refining the search experience. Contributions in these areas are welcome, please file issues or submit pull requests on GitHub.
Pull requests are welcome and all of them get reviewed, new features included. If you want a feature, the fastest path is to send a PR for it rather than to file a request.
Feature requests that fall outside the project scope (library integration, automation, collection management) will be closed, and PRs implementing them won't be merged. If you're unsure whether something fits, open a discussion first.
Feature requests that fall outside the project scope (library integration, automation, collection management) will be closed. If you're unsure whether something fits, open a discussion first.
## Health Monitoring
@@ -287,10 +216,7 @@ Logs are available via:
- `docker logs <container-name>`
- `/var/log/shelfmark/` inside the container (when `ENABLE_LOGGING=true`)
Log level is configurable under Settings → Advanced or via the `LOG_LEVEL` environment
variable (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`; case-insensitive, defaults to
`INFO`). The environment variable wins over the setting, and `DEBUG=true` forces `DEBUG`
regardless of either. Changes take effect on restart.
Log level is configurable via Settings or `LOG_LEVEL` environment variable.
## Development
+13 -134
View File
@@ -172,24 +172,6 @@ def _generate_bootstrap_env_docs() -> list[str]:
"type": "string (path)",
"default": "/auth/app.db",
},
{
"name": "HIDE_LOCAL_AUTH",
"description": "Hide the username/password login form when OIDC is active.",
"type": "boolean",
"default": "false",
},
{
"name": "DISABLE_LOCAL_AUTH",
"description": "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.",
"type": "boolean",
"default": "false",
},
{
"name": "OIDC_AUTO_REDIRECT",
"description": "Automatically redirect to the OIDC provider instead of showing the login page.",
"type": "boolean",
"default": "false",
},
{
"name": "DOCKERMODE",
"description": "Indicates the application is running inside a Docker container.",
@@ -207,7 +189,7 @@ def _generate_bootstrap_env_docs() -> list[str]:
lines = [
"## Bootstrap Configuration",
"",
"These environment variables are used at startup before the settings system loads. They typically configure paths, server settings, and authentication startup behavior.",
"These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.",
"",
"| Variable | Description | Type | Default |",
"|----------|-------------|------|---------|",
@@ -238,113 +220,6 @@ def _generate_bootstrap_env_docs() -> list[str]:
return lines
def _generate_egress_env_docs() -> list[str]:
"""Generate documentation for VPN/Tor egress environment variables.
These are startup-only variables consumed by entrypoint.sh / wireguard.sh
(before and outside the settings registry) to select and configure the
transparent-egress kill-switch. `USING_TOR` has a registry-backed entry
under Network and is cross-referenced rather than repeated here so the two
mutually exclusive egress modes are discoverable side by side without
emitting a duplicate `#### USING_TOR` anchor.
"""
egress_vars = [
{
"name": "USING_WIREGUARD",
"description": "Route all traffic through a WireGuard VPN tunnel with a fail-closed iptables kill-switch (non-tunnel egress is dropped). Requires root startup and NET_ADMIN (plus NET_RAW). Mutually exclusive with USING_TOR.",
"type": "boolean",
"default": "false",
},
{
"name": "WIREGUARD_CONFIG",
"description": "Path to the mounted wg-quick configuration file.",
"type": "string (path)",
"default": "/config/wg0.conf",
},
{
"name": "WIREGUARD_INTERFACE",
"description": "WireGuard interface name brought up by wg-quick.",
"type": "string",
"default": "wg0",
},
{
"name": "LAN_NETWORK",
"description": "Comma-separated CIDRs kept off the tunnel so the WebUI and internal download clients (Prowlarr, qBittorrent) stay reachable.",
"type": "string (comma-separated)",
"default": "127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
},
{
"name": "WIREGUARD_ENFORCE_DNS",
"description": "Pin the container's resolver so DNS cannot silently fall back to an off-tunnel path. The resolver used is WIREGUARD_DNS if set, else the tunnel config's DNS = line. This does NOT force queries through the tunnel: it is designed for a trusted LAN resolver kept reachable off-tunnel via LAN_NETWORK (the query leaves over the LAN; the resolver encrypts upstream while the download still egresses via the tunnel). Special case: when Docker's embedded resolver (nameserver 127.0.0.11) is present, it is PRESERVED so container-name resolution (e.g. prowlarr, qbittorrent) keeps working, and the embedded resolver's upstream must be pinned via the container's compose dns: list. Fails closed (refuses to start) only when no embedded resolver is present AND no resolver is defined, or /etc/resolv.conf is not writable.",
"type": "boolean",
"default": "true",
},
{
"name": "WIREGUARD_DNS",
"description": "Explicit resolver(s) (comma/space separated) to pin when WIREGUARD_ENFORCE_DNS is true and Docker's embedded resolver is NOT in use. Use when the VPN's pushed DNS filters domains you need; point it at a resolver reachable via the tunnel or an allowed LAN resolver. NOTE: when the embedded resolver (127.0.0.11) is present it is preserved and this value cannot repoint its upstream from inside the container — set the container's compose dns: list to the trusted resolver instead.",
"type": "string (comma-separated)",
"default": "unset (uses config DNS = line)",
},
{
"name": "WIREGUARD_DISABLE_IPV6",
"description": "Strip IPv6 Address/AllowedIPs/DNS from the tunnel config before wg-quick (many container kernels lack the ip6tables raw table wg-quick needs) and remove IPv6 as a leak surface.",
"type": "boolean",
"default": "true",
},
{
"name": "WIREGUARD_ALLOW_IPV6_LEAK",
"description": "Escape hatch: continue startup even when an IPv6 kill-switch cannot be installed AND IPv6 cannot be disabled. Only set when the container has no IPv6 connectivity, as IPv6 egress may otherwise bypass the tunnel.",
"type": "boolean",
"default": "false",
},
{
"name": "WIREGUARD_ALLOW_WEBUI_OFFTUNNEL",
"description": "When false (default) the kill-switch is strictly fail-closed: the only off-tunnel egress permitted is loopback, the tunnel device and the LAN allowlist. Set true only if a NON-LAN client (e.g. a public reverse proxy on a different segment) must reach the WebUI; it permits app-server REPLY packets (--sport FLASK_PORT, conntrack REPLY) to leave off-tunnel. Server replies only, never client-initiated egress, so it cannot leak outbound browsing/downloads or the real IP for outbound requests, but it is still an off-tunnel path while the tunnel is down, hence opt-in. LAN WebUI clients never need it (covered by LAN_NETWORK).",
"type": "boolean",
"default": "false",
},
{
"name": "WIREGUARD_STALE_AFTER",
"description": "Seconds since the last WireGuard handshake before the healthcheck bounces the tunnel.",
"type": "number",
"default": "180",
},
]
lines = [
"## Egress / VPN Routing",
"",
"These startup-only variables are consumed by `entrypoint.sh` / `wireguard.sh` to select and configure the WireGuard transparent-egress kill-switch. `USING_WIREGUARD` and [`USING_TOR`](#using_tor) (documented under Network) are mutually exclusive; both require root startup.",
"",
"| Variable | Description | Type | Default |",
"|----------|-------------|------|---------|",
]
lines.extend(
f"| `{var['name']}` | {var['description']} | {var['type']} | `{var['default']}` |"
for var in egress_vars
)
lines.append("")
lines.append("<details>")
lines.append("<summary>Detailed descriptions</summary>")
lines.append("")
for var in egress_vars:
lines.append(f"#### `{var['name']}`")
lines.append("")
lines.append(var["description"])
lines.append("")
lines.append(f"- **Type:** {var['type']}")
lines.append(f"- **Default:** `{var['default']}`")
lines.append("")
lines.append("</details>")
lines.append("")
return lines
def generate_env_docs() -> str:
"""Generate markdown documentation for all environment variables."""
# Import settings modules to ensure all settings are registered
@@ -389,7 +264,6 @@ def generate_env_docs() -> str:
# Generate TOC
toc_entries = [
"- [Bootstrap Configuration](#bootstrap-configuration)",
"- [Egress / VPN Routing](#egress--vpn-routing)",
]
# Ungrouped tabs first
@@ -415,9 +289,6 @@ def generate_env_docs() -> str:
# Add bootstrap environment variables documentation
lines.extend(_generate_bootstrap_env_docs())
# Add egress / VPN routing (startup-only, shell-driven) documentation
lines.extend(_generate_egress_env_docs())
# Generate documentation for ungrouped tabs
for tab in grouped_tabs.get(None, []):
lines.extend(_generate_tab_docs(tab))
@@ -439,7 +310,7 @@ def generate_env_docs() -> str:
def _generate_tab_docs(tab: Any, group_prefix: str | None = None) -> list[str]:
"""Generate documentation for a single settings tab."""
from shelfmark.core.settings_registry import iter_value_fields
from shelfmark.core.settings_registry import ActionButton, CustomComponentField, HeadingField
lines = []
@@ -452,9 +323,17 @@ def _generate_tab_docs(tab: Any, group_prefix: str | None = None) -> list[str]:
lines.append("")
# Collect env-supported fields
env_fields = [
field for field in iter_value_fields(tab) if getattr(field, "env_supported", True)
]
env_fields = []
for field in tab.fields:
# Skip non-value fields
if isinstance(field, (ActionButton, CustomComponentField, HeadingField)):
continue
# Skip fields that don't support ENV vars
if not getattr(field, "env_supported", True):
continue
env_fields.append(field)
if not env_fields:
lines.append("_No environment variables for this section._")
-11
View File
@@ -3,14 +3,3 @@
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.
"""
-52
View File
@@ -1,52 +0,0 @@
"""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
-281
View File
@@ -1,281 +0,0 @@
"""Clearance cookies won by a bypass, shared by every bypasser implementation.
Kept in its own module rather than inside a bypasser because both of them feed it and
both read from it. The internal bypasser cannot host it: it imports seleniumbase at
module scope, which is exactly the dependency an external-bypasser deployment is
entitled not to have installed.
"""
import threading
import time
from collections.abc import Mapping
from typing import Any
from urllib.parse import urlparse
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
# Cookie storage - shared with requests library for Cloudflare bypass
# Nested mapping of domain to cookie name to cookie metadata.
_cf_cookies: dict[str, dict] = {}
_cf_cookies_lock = threading.Lock()
# User-Agent storage - Cloudflare ties cf_clearance to the UA that solved the challenge
_cf_user_agents: dict[str, str] = {}
# Protection cookie names we care about (Cloudflare and DDoS-Guard)
CF_COOKIE_NAMES = {"cf_clearance", "__cf_bm", "cf_chl_2", "cf_chl_prog"}
DDG_COOKIE_NAMES = {
"__ddg1_",
"__ddg2_",
"__ddg5_",
"__ddg8_",
"__ddg9_",
"__ddg10_",
"__ddgid_",
"__ddgmark_",
"ddg_last_challenge",
}
# 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:
#
# __ddg9_ the client IP address
# __ddg10_ the unix timestamp the check was issued
# __ddg8_ an opaque token issued with them, same ~40 minute expiry
#
# Clearance itself lives in __ddg1_/__ddg2_/__ddgid_ (roughly a year) and __ddg5_.
# Replaying the trio is actively harmful: once the timestamp ages out - or the egress
# IP changes, which happens routinely behind a VPN - the values no longer describe the
# caller, DDoS-Guard re-arms its check and answers every request with a ?check=1
# redirect. That is the redirect loop, and it is self-inflicted. Dropping them simply
# lets DDoS-Guard issue a fresh set, exactly as it does for a browser.
DDG_EPHEMERAL_COOKIE_NAMES = {
"__ddg8_",
"__ddg9_",
"__ddg10_",
"ddg_last_challenge",
}
def _get_base_domain(domain: str) -> str:
"""Extract base domain from hostname (e.g., 'www.example.com' -> 'example.com')."""
return ".".join(domain.split(".")[-2:]) if "." in domain else domain
def _get_full_cookie_domains() -> set[str]:
"""Return mirror domains that need full-session cookie extraction."""
from shelfmark.core.mirrors import get_zlib_cookie_domains
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 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
def _cookie_field(cookie: Any, name: str) -> Any:
"""Read one field from a cookie in either shape we are handed.
The internal bypasser extracts CDP cookie objects; an external bypasser returns
the same fields as JSON objects, so the difference is attribute versus key access.
"""
if isinstance(cookie, Mapping):
return cookie.get(name)
return getattr(cookie, name, None)
def _cookie_expiry(cookie: Any) -> float | None:
"""A cookie's absolute expiry, or None when it is a session cookie.
The two spellings are not interchangeable and both reach this store. CDP and
Playwright cookies carry `expires`; the WebDriver cookie object - what a
Selenium-based solver such as FlareSolverr returns - carries `expiry`. Reading
only one silently turns every cookie from the other into a never-expiring one,
which is exactly how dead clearance ends up replayed forever (see
get_cf_cookies_for_domain).
The value is coerced rather than trusted: it arrives as JSON from a service we
do not control, and a string here used to raise straight out of the store.
"""
for field in ("expires", "expiry"):
raw = _cookie_field(cookie, field)
if raw is None:
continue
try:
expiry = float(raw)
except TypeError, ValueError:
logger.debug("Unreadable cookie expiry %r; treating as a session cookie", raw)
return None
# <= 0 is how both shapes spell "session cookie", not "expired in 1970".
return expiry if expiry > 0 else None
return None
def store_extracted_cookies(
*,
url: str,
cookies: list[Any],
user_agent: str | None = None,
) -> None:
"""Store filtered bypass cookies (and optional UA) for a URL domain."""
parsed = urlparse(url)
domain = parsed.hostname or ""
if not domain:
return
base_domain = _get_base_domain(domain)
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] = {
"value": _cookie_field(cookie, "value") or "",
"domain": _cookie_field(cookie, "domain") or domain,
"path": _cookie_field(cookie, "path") or "/",
"expiry": _cookie_expiry(cookie),
"secure": True if secure is None else bool(secure),
"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
with _cf_cookies_lock:
_cf_cookies[base_domain] = cookies_found
if user_agent:
_cf_user_agents[base_domain] = user_agent
logger.debug("Stored UA for %s: %s...", base_domain, str(user_agent)[:60])
else:
logger.debug("No UA captured for %s", base_domain)
cookie_type = "all" if extract_all else "protection"
logger.debug("Extracted %s %s cookies for %s", len(cookies_found), cookie_type, base_domain)
def _is_cookie_expired(cookie: dict[str, Any]) -> bool:
"""Whether a stored cookie's expiry has passed. Session cookies never expire here."""
expiry = cookie.get("expiry")
if expiry is None:
expiry = cookie.get("expires")
if not expiry or expiry <= 0:
return False
return time.time() > expiry
def get_cf_cookies_for_domain(domain: str) -> dict[str, str]:
"""Get stored cookies for a domain. Returns empty dict if none available."""
if not domain:
return {}
base_domain = _get_base_domain(domain)
with _cf_cookies_lock:
cookies = _cf_cookies.get(base_domain, {})
if not cookies:
return {}
cf_clearance = cookies.get("cf_clearance", {})
if cf_clearance and _is_cookie_expired(cf_clearance):
logger.debug("CF cookies expired for %s", base_domain)
_cf_cookies.pop(base_domain, None)
return {}
# Expiry applies to every cookie, not just Cloudflare's. DDoS-Guard domains
# have no cf_clearance, so the check above never fired for them and dead
# cookies were replayed indefinitely - the server answers those with a
# challenge, which is indistinguishable from having sent nothing at all.
live = {name: c for name, c in cookies.items() if not _is_cookie_expired(c)}
if len(live) != len(cookies):
expired = sorted(set(cookies) - set(live))
logger.debug("Dropping expired cookies for %s: %s", base_domain, expired)
if live:
_cf_cookies[base_domain] = live
else:
_cf_cookies.pop(base_domain, None)
return {name: c["value"] for name, c in live.items()}
def has_valid_cf_cookies(domain: str) -> bool:
"""Check if we have valid Cloudflare cookies for a domain."""
return bool(get_cf_cookies_for_domain(domain))
def get_cf_user_agent_for_domain(domain: str) -> str | None:
"""Get the User-Agent that was used during bypass for a domain."""
if not domain:
return None
with _cf_cookies_lock:
return _cf_user_agents.get(_get_base_domain(domain))
def export_store() -> tuple[dict[str, dict], dict[str, str]]:
"""Snapshot the whole store, for handing to another process.
The internal bypasser's Docker helper solves in a subprocess, so the clearance it
wins has to be serialized back to the parent or the solve is lost with the child.
"""
with _cf_cookies_lock:
return (
{domain: dict(cookies) for domain, cookies in _cf_cookies.items()},
dict(_cf_user_agents),
)
def import_store(cookies: object, user_agents: object) -> None:
"""Merge a snapshot produced by :func:`export_store` into this process's store."""
with _cf_cookies_lock:
if isinstance(cookies, dict):
_cf_cookies.update(cookies)
if isinstance(user_agents, dict):
_cf_user_agents.update(
{str(domain): str(agent) for domain, agent in user_agents.items()}
)
def clear_cf_cookies(domain: str | None = None) -> None:
"""Clear stored Cloudflare cookies and User-Agent. If domain is None, clear all."""
with _cf_cookies_lock:
if domain:
base_domain = _get_base_domain(domain)
_cf_cookies.pop(base_domain, None)
_cf_user_agents.pop(base_domain, None)
else:
_cf_cookies.clear()
_cf_user_agents.clear()
+5 -114
View File
@@ -2,20 +2,17 @@
import random
import time
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
import requests
from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError
from shelfmark.bypass.challenge import challenge_marker
from shelfmark.bypass.cookie_store import store_extracted_cookies
from shelfmark.bypass import BypassCancelledError
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
if TYPE_CHECKING:
from collections.abc import Mapping
from threading import Event
from shelfmark.download import network
@@ -50,55 +47,8 @@ def _coerce_timeout_ms(value: object, default: int) -> int:
return default
def max_duration_seconds() -> float:
"""Upper bound on how long get_bypassed_page() can take for one URL.
MAX_RETRY attempts at the configured read timeout, plus the exponential backoff waited
between them (jitter is < 1s per gap, counted as a full second to stay conservative).
Callers use this to declare a stall-detection grace; see shelfmark.download.activity.
"""
bypasser_timeout = _coerce_timeout_ms(config.get("EXT_BYPASSER_TIMEOUT", 60000), 60000)
read_timeout = min((bypasser_timeout / 1000) + READ_TIMEOUT_BUFFER, MAX_READ_TIMEOUT)
backoff_total = sum(
min(BACKOFF_CAP, BACKOFF_BASE * (2 ** (attempt - 1))) + 1.0
for attempt in range(1, MAX_RETRY)
)
return MAX_RETRY * read_timeout + backoff_total
def _store_solution_clearance(target_url: str, solution: Mapping[str, Any]) -> None:
"""Keep the clearance the solver won, so later requests do not re-solve.
A solve is the expensive part of an external bypass - tens of seconds of real
browser - and FlareSolverr-compatible services hand back the cookies and the
User-Agent that earned it. Dropping them meant every single request paid a 403
plus a full solve, and a file download (which the solver cannot proxy, being
binary) never presented clearance at all.
The UA matters as much as the cookies: Cloudflare ties cf_clearance to the UA
that solved the challenge, so replaying the cookie under our own UA is rejected.
"""
cookies = solution.get("cookies") or []
if not isinstance(cookies, list):
logger.debug("External bypasser returned no usable cookie list for '%s'", target_url)
return
user_agent = solution.get("userAgent")
store_extracted_cookies(
url=target_url,
cookies=cookies,
user_agent=user_agent if isinstance(user_agent, str) else None,
)
def _fetch_via_bypasser(target_url: str) -> str | 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.
"""
"""Make a single request to the external bypasser service. Returns HTML or None."""
raw_bypasser_url = _coerce_config_str(
config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191"),
"http://flaresolverr:8191",
@@ -150,41 +100,6 @@ 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:
# Storing clearance is an optimisation; the page is the product. The
# solution JSON comes from a service we do not control, so a surprise in
# its cookie shape must not discard HTML that already cost a ~30s solve
# and send the caller round for up to MAX_RETRY more of them.
logger.debug("Could not store bypass clearance for '%s'", target_url, exc_info=True)
except requests.exceptions.Timeout:
logger.warning(
"External bypasser timed out for '%s' (connect: %ss, read: %.0fs)",
@@ -225,33 +140,16 @@ 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.
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.
"""
"""Fetch HTML via external bypasser with retries and mirror rotation."""
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)
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
result = _fetch_via_bypasser(attempt_url)
if result:
return result
@@ -272,11 +170,4 @@ 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
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import hashlib
from typing import Any
from shelfmark.core.config import config
@@ -24,7 +23,7 @@ _BOOKLORE_OPTIONS_CACHE: dict[str, Any] = {
def _get_booklore_cache_key(base_url: str, username: str, password: str) -> str:
return f"{base_url}|{username}|{hashlib.sha256(password.encode()).hexdigest()}"
return f"{base_url}|{username}|{hash(password)}"
def _get_booklore_select_options(
+13 -73
View File
@@ -6,78 +6,34 @@ import shutil
import tempfile
from pathlib import Path
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
def string_to_bool(s: str) -> bool:
"""Convert string to boolean."""
return s.lower() in ["true", "yes", "1", "y"]
def _read_advanced_config(key: str) -> object | None:
"""Read a key from the advanced settings file (import-time safe)."""
config_dir = Path(os.getenv("CONFIG_DIR", "/config"))
config_file = config_dir / "plugins" / "advanced.json"
if config_file.exists():
try:
with config_file.open() as f:
config = json.load(f)
if key in config:
return config[key]
except json.JSONDecodeError, OSError:
pass
return None
def _read_debug_from_config() -> bool:
"""Read DEBUG from env var or config file (import-time safe)."""
env_debug = os.environ.get("DEBUG")
if env_debug is not None:
return string_to_bool(env_debug)
value = _read_advanced_config("DEBUG")
if value is not None:
return bool(value)
# Try to read from config file
config_dir = Path(os.getenv("CONFIG_DIR", "/config"))
config_file = config_dir / "plugins" / "advanced.json"
if config_file.exists():
try:
with config_file.open() as f:
config = json.load(f)
if "DEBUG" in config:
return bool(config["DEBUG"])
except json.JSONDecodeError, OSError:
pass
return False
def normalize_log_level(raw: str | None) -> str:
"""Normalize a log level name, falling back to INFO when unrecognized."""
if raw is None:
return "INFO"
normalized = raw.strip().upper()
# "WARN" is a logging alias, but gunicorn only accepts "warning".
if normalized == "WARN":
normalized = "WARNING"
if normalized not in LOG_LEVELS:
return "INFO"
return normalized
def _read_log_level_from_config(debug: bool) -> str:
"""Resolve the app log level from DEBUG, env var, or config file.
DEBUG wins when enabled, mirroring how entrypoint.sh picks gunicorn's level.
Otherwise LOG_LEVEL is read from the env var, then the settings file, and
falls back to INFO when unset or unrecognized.
"""
if debug:
return "DEBUG"
raw = os.environ.get("LOG_LEVEL")
if raw is None:
value = _read_advanced_config("LOG_LEVEL")
raw = value if isinstance(value, str) else None
return normalize_log_level(raw)
def _is_sqlite_file(path: Path) -> bool:
"""Check if a file is a valid SQLite database by reading magic bytes."""
try:
@@ -145,7 +101,7 @@ INGEST_DIR = Path(os.getenv("INGEST_DIR", "/books"))
# =============================================================================
DEBUG = _read_debug_from_config()
LOG_LEVEL = _read_log_level_from_config(DEBUG)
LOG_LEVEL = "DEBUG" if DEBUG else "INFO"
ENABLE_LOGGING = string_to_bool(os.getenv("ENABLE_LOGGING", "true"))
@@ -165,7 +121,6 @@ SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
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"))
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
@@ -203,21 +158,6 @@ 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
+1 -56
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from collections.abc import Callable
from os import PathLike
_DEPRECATED_SETTINGS_RESTRICTION_KEYS = (
@@ -16,13 +16,6 @@ _DEPRECATED_SETTINGS_RESTRICTION_KEYS = (
"RESTRICT_SETTINGS_TO_ADMIN",
)
# The audiobook format list shipped as the default until the format sets were unified.
# It only covered m4b/mp3, so FLAC/OPUS/OGG/M4A releases were dropped from search results
# and rejected after download - and the wider default alone would never reach existing
# installs, because initialize_default_configs() only writes defaults when the config
# file does not exist yet.
_LEGACY_AUDIOBOOK_FORMATS_DEFAULT = ("m4b", "mp3")
class MigrationLogger(Protocol):
"""Logger surface used by config migration helpers."""
@@ -64,54 +57,6 @@ def _pick_legacy_settings_restriction(config: dict[str, Any]) -> bool | None:
return None
def migrate_audiobook_formats(
*,
load_general_config: Callable[[], dict[str, Any]],
# `object` rather than `None`: the result is discarded, and savers that report
# success (settings_registry.save_config_file returns bool) are not assignable to a
# `-> None` callable.
save_general_config: Callable[[dict[str, Any]], object],
widened_formats: Sequence[str],
logger: MigrationLogger,
) -> None:
"""Widen an untouched audiobook format list to the current, fuller default.
Only a list that still matches the old default exactly is rewritten. Any other value
means someone chose it deliberately, and a migration that "helpfully" re-enabled
formats a user had turned off would be worse than leaving them on the narrow list.
"""
try:
config = load_general_config()
if "SUPPORTED_AUDIOBOOK_FORMATS" not in config:
# Nothing persisted, so the field default already applies.
logger.debug("No persisted audiobook formats - the current default applies")
return
current = config.get("SUPPORTED_AUDIOBOOK_FORMATS")
if not isinstance(current, list):
return
normalized = {str(fmt).strip().lower() for fmt in current if str(fmt).strip()}
if normalized != set(_LEGACY_AUDIOBOOK_FORMATS_DEFAULT):
logger.debug(
"Audiobook formats were customized (%s) - left unchanged", sorted(normalized)
)
return
save_general_config({"SUPPORTED_AUDIOBOOK_FORMATS": list(widened_formats)})
logger.info(
"Widened audiobook formats from the legacy default %s to %s",
list(_LEGACY_AUDIOBOOK_FORMATS_DEFAULT),
list(widened_formats),
)
except FileNotFoundError:
logger.debug("No existing general config file found - nothing to migrate")
except Exception:
logger.exception("Failed to migrate audiobook formats")
def migrate_security_settings(
*,
load_security_config: Callable[[], dict[str, Any]],
+6 -12
View File
@@ -76,7 +76,7 @@ def _test_oidc_connection(current_values: dict[str, Any] | None = None) -> dict[
@register_settings("security", "Security", icon="shield", order=5)
def security_settings() -> list[SettingsField]:
"""Security and authentication settings."""
from shelfmark.config.env import CWA_DB_PATH, DISABLE_LOCAL_AUTH
from shelfmark.config.env import CWA_DB_PATH
cwa_db_available = CWA_DB_PATH is not None and CWA_DB_PATH.exists()
@@ -108,17 +108,11 @@ def security_settings() -> list[SettingsField]:
),
show_when=_auth_condition("builtin"),
),
*(
[]
if DISABLE_LOCAL_AUTH
else [
CustomComponentField(
key="oidc_admin_requirement",
component="oidc_admin_hint",
label="A local admin account is required before OIDC can be enabled.",
show_when=_auth_condition("oidc"),
),
]
CustomComponentField(
key="oidc_admin_requirement",
component="oidc_admin_hint",
label="A local admin account is required before OIDC can be enabled.",
show_when=_auth_condition("oidc"),
),
*(
[]
+2 -21
View File
@@ -4,7 +4,6 @@ import os
from pathlib import Path
from typing import TYPE_CHECKING, Any
from shelfmark.config.env import DISABLE_LOCAL_AUTH
from shelfmark.core.user_db import UserDB
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.network import get_ssl_verify
@@ -79,7 +78,7 @@ def on_save_security(
auth_method = str(effective_values.get("AUTH_METHOD", "") or "").strip().lower()
if auth_method == "oidc":
if not DISABLE_LOCAL_AUTH and not _has_local_password_admin():
if not _has_local_password_admin():
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": normalized_values}
missing_fields = _get_missing_oidc_required_fields(effective_values)
@@ -115,7 +114,7 @@ def check_oidc_connection(
response.raise_for_status()
document = response.json()
required_fields = ["issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"]
required_fields = ["issuer", "authorization_endpoint", "token_endpoint"]
missing_fields = [field for field in required_fields if field not in document]
if missing_fields:
return {
@@ -123,24 +122,6 @@ def check_oidc_connection(
"message": f"Discovery document missing fields: {', '.join(missing_fields)}",
}
# Logins verify the ID token against the provider's JWKS, so an empty key
# set (e.g. an Authentik provider with no Signing Key selected) means every
# login will fail even though discovery looks healthy.
jwks_uri = str(document["jwks_uri"])
jwks_response = requests.get(jwks_uri, timeout=10, verify=get_ssl_verify(jwks_uri))
jwks_response.raise_for_status()
jwks_document = jwks_response.json()
jwks_keys = jwks_document.get("keys") if isinstance(jwks_document, dict) else None
if not jwks_keys:
return {
"success": False,
"message": (
"Discovery document is valid, but the provider returned no token "
"signing keys (empty JWKS), so logins will fail. If you use "
"Authentik, select a Signing Key in the provider settings."
),
}
return {"success": True, "message": f"Connected to {document['issuer']}"}
except Exception as exc:
logger.exception("OIDC connection test failed")
+27 -198
View File
@@ -1,5 +1,6 @@
"""Core settings registration and derived configuration values."""
import json
from pathlib import Path
from typing import Any
@@ -14,8 +15,6 @@ from shelfmark.config.download_settings_handlers import (
check_books_destination,
)
from shelfmark.config.email_settings import check_email_connection
from shelfmark.config.migrations import migrate_audiobook_formats
from shelfmark.core.languages import supported_book_languages
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
ActionButton,
@@ -36,10 +35,6 @@ from shelfmark.core.settings_registry import (
register_on_save,
register_settings,
)
from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS
_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_DEFAULT = 60
_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_MAX = 3600
def _on_save_advanced(values: dict[str, Any]) -> dict[str, Any]:
@@ -48,40 +43,6 @@ def _on_save_advanced(values: dict[str, Any]) -> dict[str, Any]:
logger = setup_logger(__name__)
timeout_key = "DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT"
if timeout_key in values:
raw_timeout = values.get(timeout_key)
if isinstance(raw_timeout, bool):
return {
"error": True,
"message": "Completed Path Wait must be a number of seconds",
"values": values,
}
if raw_timeout is None:
return {
"error": True,
"message": "Completed Path Wait must be a number of seconds",
"values": values,
}
try:
timeout_seconds = int(raw_timeout)
except TypeError, ValueError:
return {
"error": True,
"message": "Completed Path Wait must be a number of seconds",
"values": values,
}
if timeout_seconds < 0 or timeout_seconds > _DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_MAX:
return {
"error": True,
"message": (
"Completed Path Wait must be between 0 and "
f"{_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_MAX} seconds"
),
"values": values,
}
values[timeout_key] = timeout_seconds
mappings = values.get("PROWLARR_REMOTE_PATH_MAPPINGS")
if mappings is None:
return {"error": False, "values": values}
@@ -136,20 +97,6 @@ def _on_save_advanced(values: dict[str, Any]) -> dict[str, Any]:
logger = setup_logger(__name__)
def migrate_audiobook_format_settings() -> None:
"""Bring installs created before the audiobook format sets were unified up to date."""
from shelfmark.core.settings_registry import load_config_file, save_config_file
migrate_audiobook_formats(
load_general_config=lambda: load_config_file("general"),
save_general_config=lambda values: save_config_file("general", values),
widened_formats=[*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS],
logger=logger,
)
_SMTP_PORT_MAX = 65535
_EMAIL_ATTACHMENT_LIMIT_MB_MAX = 600
@@ -159,8 +106,11 @@ for key in ["CONFIG_DIR", "LOG_DIR", "TMP_DIR", "INGEST_DIR", "DEBUG", "DOCKERMO
if hasattr(env, key):
logger.debug(" %s: %s", key, getattr(env, key))
# Selectable book languages, without the resolution aliases clients do not need.
_SUPPORTED_BOOK_LANGUAGE = supported_book_languages()
# Load supported book languages from data file
# Path is relative to the package root, not this file
_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
with (_DATA_DIR / "book-languages.json").open() as file:
_SUPPORTED_BOOK_LANGUAGE = json.load(file)
# Directory settings
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -231,7 +181,11 @@ _FORMAT_OPTIONS = [
]
_AUDIOBOOK_FORMAT_OPTIONS = [
{"value": fmt, "label": fmt.upper()} for fmt in (*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS)
{"value": "m4b", "label": "M4B"},
{"value": "mp3", "label": "MP3"},
{"value": "m4a", "label": "M4A"},
{"value": "zip", "label": "ZIP"},
{"value": "rar", "label": "RAR"},
]
_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS = [
@@ -428,7 +382,14 @@ def general_settings() -> list[SettingsField]:
label="Supported Audiobook Formats",
description="Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found.",
options=_AUDIOBOOK_FORMAT_OPTIONS,
default=[*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS],
default=["m4b", "mp3"],
),
MultiSelectField(
key="BOOK_LANGUAGE",
label="Default Book Languages",
description="Default language filter for searches.",
options=_LANGUAGE_OPTIONS,
default=["en"],
),
]
@@ -467,17 +428,6 @@ 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",
@@ -503,14 +453,6 @@ def search_mode_settings() -> list[SettingsField]:
show_when={"field": "SEARCH_MODE", "value": "universal"},
user_overridable=True,
),
CheckboxField(
key="FORCE_COMBINED_SEARCH",
label="Always Use Combined Search",
description="Force combined search whenever it's available. Locks the combined toggle on.",
default=False,
show_when={"field": "SEARCH_MODE", "value": "universal"},
user_overridable=True,
),
HeadingField(
key="universal_mode_heading",
title="Universal Mode Settings",
@@ -770,10 +712,7 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
}
# Audiobooks are always folder output.
if effective.get("FILE_ORGANIZATION_AUDIOBOOK", "rename") in {
"rename",
"rename_and_group",
}:
if effective.get("FILE_ORGANIZATION_AUDIOBOOK", "rename") == "rename":
template = effective.get("TEMPLATE_AUDIOBOOK_RENAME", "")
if _contains_path_separators(template):
return {
@@ -999,7 +938,7 @@ def download_settings() -> list[SettingsField]:
SelectField(
key="FILE_ORGANIZATION",
label="File Organization",
description="Choose how downloaded book files are named and organized.",
description="Choose how downloaded book files are named and organized. ",
options=[
{
"value": "none",
@@ -1027,14 +966,7 @@ def download_settings() -> list[SettingsField]:
_naming_template_field(
key="TEMPLATE_RENAME",
label="Naming Template",
description=(
"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."
),
description="Filename template for single-file book downloads.",
default="{Author} - {Title} ({Year})",
placeholder="{Author} - {Title} ({Year})",
show_when=[
@@ -1046,12 +978,7 @@ def download_settings() -> list[SettingsField]:
_naming_template_field(
key="TEMPLATE_ORGANIZE",
label="Path Template",
description=(
"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."
),
description="Folder and filename template for book downloads.",
default="{Author}/{Title} ({Year})",
placeholder="{Author}/{Series/}{Title} ({Year})",
show_when=[
@@ -1301,11 +1228,6 @@ 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,
@@ -1314,33 +1236,18 @@ def download_settings() -> list[SettingsField]:
_naming_template_field(
key="TEMPLATE_AUDIOBOOK_RENAME",
label="Naming Template",
description=(
"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."
),
description="Filename template for single-file audiobook downloads.",
default="{Author} - {Title}",
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
show_when={
"field": "FILE_ORGANIZATION_AUDIOBOOK",
"value": ["rename", "rename_and_group"],
},
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
universal_only=True,
),
# Organize mode template - folders allowed
_naming_template_field(
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
label="Path Template",
description=(
"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."
),
default="{Author}/{Title}/{Title}",
description="Folder and filename template for audiobook downloads.",
default="{Author}/{Title}",
placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}",
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"},
universal_only=True,
@@ -1509,17 +1416,6 @@ def download_source_settings() -> list[SettingsField]:
),
default=False,
),
CheckboxField(
key="DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH",
label="Detect Language From Distant Path",
description=(
"When language metadata is missing or unknown, parse the distant path "
"(file path shown in search results) for language tags like [BD FR] or [En]. "
"Also enables local language filtering so lgli files without AA language "
"metadata are not excluded before the distant path can be checked."
),
default=False,
),
PasswordField(
key="AA_DONATOR_KEY",
label="Account Donator Key",
@@ -1560,19 +1456,6 @@ 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",
@@ -1683,31 +1566,6 @@ 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},
),
]
@@ -1826,23 +1684,6 @@ def advanced_settings() -> list[SettingsField]:
default=False,
requires_restart=True,
),
SelectField(
key="LOG_LEVEL",
label="Log Level",
description=(
"Lowest severity written to the console and log file. "
"Ignored while Debug Mode is on, which forces Debug."
),
options=[
{"value": "DEBUG", "label": "Debug", "description": "Everything, very noisy."},
{"value": "INFO", "label": "Info", "description": "Normal activity (default)."},
{"value": "WARNING", "label": "Warning", "description": "Warnings and problems."},
{"value": "ERROR", "label": "Error", "description": "Failures only."},
{"value": "CRITICAL", "label": "Critical", "description": "Fatal errors only."},
],
default="INFO",
requires_restart=True,
),
NumberField(
key="MAIN_LOOP_SLEEP_TIME",
label="Queue Check Interval (seconds)",
@@ -1896,18 +1737,6 @@ def advanced_settings() -> list[SettingsField]:
title="Remote Path Mappings",
description="Map download client paths to paths inside Shelfmark. Needed when volume mounts differ between containers.",
),
NumberField(
key="DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT",
label="Completed Path Wait (seconds)",
description=(
"How long to wait after a torrent or usenet client reports completion "
"for the completed file path to become visible to Shelfmark. Increase "
"this for seedbox or remote-sync workflows."
),
default=_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_DEFAULT,
min_value=0,
max_value=_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_MAX,
),
TableField(
key="PROWLARR_REMOTE_PATH_MAPPINGS",
label="Path Mappings",
+4 -41
View File
@@ -7,7 +7,6 @@ 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,
@@ -62,7 +61,7 @@ _SELF_SETTINGS_SECTION_OPTIONS = [
{
"value": "search",
"label": "Search Preferences",
"description": "Show personal search mode, language, and provider settings.",
"description": "Show personal search mode and provider settings.",
},
{
"value": "notifications",
@@ -78,13 +77,11 @@ _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",
"FORCE_COMBINED_SEARCH",
*_SEARCH_PREFERENCE_PROVIDER_KEYS,
}
@@ -180,43 +177,14 @@ 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":
@@ -255,11 +223,6 @@ def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | N
return value, None
return bool(value), None
if key == "FORCE_COMBINED_SEARCH":
if isinstance(value, bool):
return value, None
return bool(value), None
return value, None
@@ -329,7 +292,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])
+8 -7
View File
@@ -11,10 +11,7 @@ from shelfmark.config.notifications_settings import (
is_valid_notification_url,
normalize_notification_routes,
)
from shelfmark.config.users_settings import (
SEARCH_PREFERENCE_VALIDATABLE_KEYS,
validate_search_preference_value,
)
from shelfmark.config.users_settings import 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
@@ -94,9 +91,13 @@ def validate_user_settings(
if search_validation_error:
errors.append(search_validation_error)
continue
# 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:
if key in {
"SEARCH_MODE",
"METADATA_PROVIDER",
"METADATA_PROVIDER_AUDIOBOOK",
"DEFAULT_RELEASE_SOURCE",
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
}:
valid[key] = normalized_search_value
continue
+2 -6
View File
@@ -71,16 +71,14 @@ def determine_auth_mode(
cwa_db_path: object | None,
*,
has_local_admin: bool = True,
disable_local_auth: bool = False,
) -> str:
"""Determine active auth mode from security config and runtime prerequisites."""
auth_mode = security_config.get("AUTH_METHOD", "none")
local_admin_available = has_local_admin or disable_local_auth
if auth_mode == AUTH_SOURCE_CWA and cwa_db_path:
return AUTH_SOURCE_CWA
if auth_mode == AUTH_SOURCE_BUILTIN and local_admin_available:
if auth_mode == AUTH_SOURCE_BUILTIN and has_local_admin:
return AUTH_SOURCE_BUILTIN
if auth_mode == AUTH_SOURCE_PROXY and security_config.get("PROXY_AUTH_USER_HEADER"):
@@ -88,7 +86,7 @@ def determine_auth_mode(
if (
auth_mode == AUTH_SOURCE_OIDC
and local_admin_available
and has_local_admin
and security_config.get("OIDC_DISCOVERY_URL")
and security_config.get("OIDC_CLIENT_ID")
):
@@ -104,7 +102,6 @@ def load_active_auth_mode(
) -> str:
"""Resolve active auth mode using current security config and runtime prerequisites."""
try:
from shelfmark.config.env import DISABLE_LOCAL_AUTH
from shelfmark.core.config import config as app_config
security_config = {
@@ -117,7 +114,6 @@ def load_active_auth_mode(
security_config,
cwa_db_path,
has_local_admin=has_local_password_admin(user_db),
disable_local_auth=DISABLE_LOCAL_AUTH,
)
except ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error:
return "none"
-1
View File
@@ -39,7 +39,6 @@ def upsert_cwa_user(
email=normalized_email,
role=role,
allow_email_link=True,
sync_username=True,
collision_strategy=collision_strategy,
alias_suffix=_CWA_ALIAS_SUFFIX,
context=context,
+4 -62
View File
@@ -108,7 +108,6 @@ def _build_updates(
auth_source: str,
role: str,
sync_role: bool,
username: str | object,
email: str | None | object,
display_name: str | None | object,
subject_field: str | None,
@@ -117,8 +116,6 @@ def _build_updates(
updates: dict[str, Any] = {"auth_source": auth_source}
if sync_role:
updates["role"] = _normalize_role(role)
if username is not UNSET:
updates["username"] = _normalize_username(username)
if email is not UNSET:
updates["email"] = _normalize_email(email)
if display_name is not UNSET:
@@ -128,17 +125,10 @@ def _build_updates(
return updates
def _next_suffix_username(
user_db: UserDB,
base_username: str,
*,
exclude_user_id: int | None = None,
) -> str:
def _next_suffix_username(user_db: UserDB, base_username: str) -> str:
candidate = base_username
suffix = 1
while existing := user_db.get_user(username=candidate):
if exclude_user_id is not None and int(existing.get("id") or 0) == exclude_user_id:
return candidate
while user_db.get_user(username=candidate):
candidate = f"{base_username}_{suffix}"
suffix += 1
return candidate
@@ -159,7 +149,7 @@ def _find_existing_alias_user(
]
if not candidates:
return None
return min(candidates, key=lambda user: int(user.get("id") or 0), default=None)
return sorted(candidates, key=lambda user: int(user.get("id") or 0))[0]
def _resolve_create_username(
@@ -195,38 +185,6 @@ def _resolve_create_username(
return _next_suffix_username(user_db, alias_base), None, "username_collision_alias"
def _resolve_update_username(
user_db: UserDB,
*,
current_user: dict[str, Any],
requested_username: str,
strategy: CollisionStrategy,
alias_suffix: str,
) -> str:
current_user_id = int(current_user["id"])
existing = user_db.get_user(username=requested_username)
if existing is None or int(existing.get("id") or 0) == current_user_id:
return requested_username
if strategy == "suffix":
return _next_suffix_username(
user_db,
requested_username,
exclude_user_id=current_user_id,
)
if strategy == "alias":
return _next_suffix_username(
user_db,
f"{requested_username}{alias_suffix}",
exclude_user_id=current_user_id,
)
# `takeover` can select an existing row during creation, but once an
# identity is already matched it must never replace a different username
# owner. Preserve the matched row's current collision-free name instead.
return str(current_user["username"])
def upsert_external_user(
user_db: UserDB,
*,
@@ -239,7 +197,6 @@ def upsert_external_user(
subject: str | None = None,
allow_email_link: bool = False,
sync_role: bool = True,
sync_username: bool = False,
allow_create: bool = True,
collision_strategy: CollisionStrategy = "takeover",
alias_suffix: str | None = None,
@@ -272,26 +229,10 @@ def upsert_external_user(
subject=subject,
allow_email_link=allow_email_link,
)
resolved_alias_suffix = alias_suffix or f"__{auth_source}"
update_username: str | object = UNSET
if (
matched is not None
and sync_username
and normalize_auth_source(matched.get("auth_source"), matched.get("oidc_subject"))
== auth_source
):
update_username = _resolve_update_username(
user_db,
current_user=matched,
requested_username=normalized_username,
strategy=collision_strategy,
alias_suffix=resolved_alias_suffix,
)
updates = _build_updates(
auth_source=auth_source,
role=normalized_role,
sync_role=sync_role,
username=update_username,
email=normalized_email if email is not UNSET else UNSET,
display_name=normalized_display_name if display_name is not UNSET else UNSET,
subject_field=subject_field,
@@ -320,6 +261,7 @@ def upsert_external_user(
)
return None, "not_found"
resolved_alias_suffix = alias_suffix or f"__{auth_source}"
create_username, takeover_target, create_reason = _resolve_create_username(
user_db,
auth_source=auth_source,
+182 -70
View File
@@ -8,7 +8,7 @@ import time
from http import HTTPStatus
from io import BytesIO
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin, urlparse
from urllib.parse import urlparse
import requests
@@ -39,7 +39,6 @@ FETCH_HEADERS = {
# Maximum image size to fetch (5 MB)
MAX_IMAGE_SIZE = 5 * 1024 * 1024
MAX_REDIRECTS = 5
# Negative cache TTL (for failed fetches) - 1 hour
NEGATIVE_CACHE_TTL = 3600
@@ -50,6 +49,9 @@ TRANSIENT_CACHE_TTL = 60
_MIN_WEBP_HEADER_LENGTH = 12
HTTP_NOT_FOUND = HTTPStatus.NOT_FOUND
MAX_VARIANT_DIMENSION = 1024
WEBP_DEFAULT_QUALITY = 80
JPEG_DEFAULT_QUALITY = 85
def _detect_image_type(data: bytes) -> tuple[str, str] | None:
@@ -73,6 +75,164 @@ def _detect_image_type(data: bytes) -> tuple[str, str] | None:
return None
def normalize_variant_dimension(value: object) -> int | None:
"""Normalize a requested variant dimension, clamping to a safe upper bound."""
dimension = coerce_int(value, 0)
if dimension <= 0:
return None
return min(dimension, MAX_VARIANT_DIMENSION)
def normalize_variant_format(value: object) -> str | None:
"""Normalize a requested output image format."""
if not isinstance(value, str):
return None
normalized = value.strip().lower()
if normalized in {"jpg", "jpeg"}:
return "jpeg"
if normalized in {"png", "webp"}:
return normalized
return None
def build_variant_cache_id(
cache_id: str,
*,
width: int | None,
height: int | None,
image_format: str | None,
) -> str:
"""Build a cache key for a derived cover variant."""
width_token = str(width) if width is not None else "auto"
height_token = str(height) if height is not None else "auto"
format_token = image_format or "original"
return f"{cache_id}__w{width_token}_h{height_token}_f{format_token}"
def _calculate_variant_size(
*,
source_width: int,
source_height: int,
width: int | None,
height: int | None,
) -> tuple[int, int]:
"""Calculate the output size while preserving aspect ratio and avoiding upscaling."""
if width is None and height is None:
return source_width, source_height
width_ratio = (width / source_width) if width is not None else None
height_ratio = (height / source_height) if height is not None else None
if width_ratio is not None and height_ratio is not None:
scale = min(width_ratio, height_ratio, 1.0)
elif width_ratio is not None:
scale = min(width_ratio, 1.0)
elif height_ratio is not None:
scale = min(height_ratio, 1.0)
else:
scale = 1.0
return (
max(1, round(source_width * scale)),
max(1, round(source_height * scale)),
)
def _normalize_source_format(image_data: bytes) -> str | None:
"""Return the normalized detected source image format."""
detected = _detect_image_type(image_data)
if not detected:
return None
content_type, _ext = detected
if content_type == "image/jpeg":
return "jpeg"
if content_type == "image/png":
return "png"
if content_type == "image/webp":
return "webp"
return None
def create_image_variant(
image_data: bytes,
*,
width: int | None = None,
height: int | None = None,
image_format: str | None = None,
) -> tuple[bytes, str] | None:
"""Create a resized and/or transcoded image variant.
Returns None when no variant is needed or the image cannot be safely transformed.
"""
requested_format = normalize_variant_format(image_format)
if width is None and height is None and requested_format is None:
return None
source_format = _normalize_source_format(image_data)
try:
from PIL import Image, ImageOps, UnidentifiedImageError
except ImportError:
logger.warning("Pillow is not installed; serving original cover image")
return None
try:
with Image.open(BytesIO(image_data)) as source_image:
if getattr(source_image, "is_animated", False):
return None
image = ImageOps.exif_transpose(source_image)
source_width, source_height = image.size
output_width, output_height = _calculate_variant_size(
source_width=source_width,
source_height=source_height,
width=width,
height=height,
)
needs_resize = (output_width, output_height) != (source_width, source_height)
output_format = requested_format or source_format
if not needs_resize and output_format == source_format:
return None
if needs_resize:
image = image.resize((output_width, output_height), Image.Resampling.LANCZOS)
if output_format == "jpeg":
if image.mode not in {"RGB", "L"}:
image = image.convert("RGB")
content_type = "image/jpeg"
save_kwargs: dict[str, Any] = {
"format": "JPEG",
"quality": JPEG_DEFAULT_QUALITY,
"optimize": True,
}
elif output_format == "png":
if image.mode not in {"1", "L", "LA", "P", "PA", "RGB", "RGBA"}:
image = image.convert("RGBA")
content_type = "image/png"
save_kwargs = {"format": "PNG", "optimize": True}
else:
if image.mode not in {"RGB", "RGBA"}:
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
content_type = "image/webp"
save_kwargs = {
"format": "WEBP",
"quality": WEBP_DEFAULT_QUALITY,
"method": 6,
}
output = BytesIO()
image.save(output, **save_kwargs)
return output.getvalue(), content_type
except (OSError, UnidentifiedImageError, ValueError) as exc:
logger.warning("Failed to derive image variant: %s", exc)
return None
class ImageCacheService:
"""Persistent image cache with LRU eviction and TTL support."""
@@ -483,85 +643,29 @@ class ImageCacheService:
}
@staticmethod
def _prepare_safe_url(url: str) -> str | None:
"""Prepare and validate a URL before fetching it."""
if "\\" in url or any(ord(char) < 32 for char in url):
return None
def _is_safe_url(url: str) -> bool:
"""Check that a URL is safe to fetch (no SSRF to internal resources)."""
try:
prepared = requests.Request("GET", url).prepare()
prepared_url = prepared.url
if not isinstance(prepared_url, str):
return None
parsed = urlparse(prepared_url)
parsed = urlparse(url)
hostname = parsed.hostname
except requests.exceptions.RequestException, ValueError:
return None
if not prepared_url:
return None
if "\\" in prepared_url or any(ord(char) < 32 for char in prepared_url):
return None
netloc_lower = parsed.netloc.lower()
if "%2f" in netloc_lower or "%5c" in netloc_lower:
return None
except ValueError:
return False
if parsed.scheme not in ("http", "https"):
return None
return False
if not hostname:
return None
return False
try:
resolved = socket.getaddrinfo(hostname, None)
for _, _, _, _, sockaddr in resolved:
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return None
return False
except socket.gaierror, ValueError:
return None
return False
return prepared_url
@staticmethod
def _is_safe_url(url: str) -> bool:
"""Check that a URL is safe to fetch (no SSRF to internal resources)."""
return ImageCacheService._prepare_safe_url(url) is not None
def _fetch_safe_response(self, url: str) -> requests.Response | None:
"""Fetch a URL after validating the initial URL and each redirect."""
current_url = self._prepare_safe_url(url)
if not current_url:
logger.warning("Blocked request to disallowed URL: %s", url)
return None
for _ in range(MAX_REDIRECTS + 1):
response = requests.get(
current_url,
timeout=(5, 10),
headers=FETCH_HEADERS,
stream=True,
verify=get_ssl_verify(current_url),
allow_redirects=False,
)
if not response.is_redirect:
return response
location = response.headers.get("location")
response.close()
if not location:
return None
redirect_url = urljoin(current_url, location)
next_url = self._prepare_safe_url(redirect_url)
if not next_url:
logger.warning("Blocked redirect to disallowed URL: %s", redirect_url)
return None
current_url = next_url
return None
return True
def fetch_and_cache(self, cache_id: str, url: str) -> tuple[bytes, str] | None:
"""Fetch an image from URL and cache it.
@@ -576,9 +680,17 @@ class ImageCacheService:
"""
cached_data: tuple[bytes, str] | None = None
try:
response = self._fetch_safe_response(url)
if response is None:
if not self._is_safe_url(url):
logger.warning("Blocked request to disallowed URL: %s", url)
return None
response = requests.get(
url,
timeout=(5, 10),
headers=FETCH_HEADERS,
stream=True,
verify=get_ssl_verify(url),
)
response.raise_for_status()
# Validate content type
-138
View File
@@ -1,138 +0,0 @@
"""Canonical language resolution shared by every release source.
Release sources report a language in whatever shape their upstream uses: a
two-letter code, an ISO 639-2 three-letter code in either the bibliographic or
terminological form, or an English name. They all need the same ISO 639-1 code
out the other side, so the aliases live in one place (``data/book-languages.json``)
and adding a language means editing one file.
"""
import json
import threading
import unicodedata
from pathlib import Path
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
LANGUAGE_DATA_PATH = Path(__file__).resolve().parents[1].parent / "data" / "book-languages.json"
# Values a source uses to mean "we could not tell".
LANGUAGE_PLACEHOLDERS = frozenset({"", "-", "--", "unknown", "unk", "n/a", "na", "none", "null"})
_ALIAS_TO_CODE: dict[str, str] | None = None
_CODE_TO_NAME: dict[str, str] | None = None
_LOCK = threading.Lock()
# Separators that stand in for the hyphen in a subtag. The dashes turn up in
# codes copied from web pages -- "zh‑Hant" used U+2011, which renders close
# enough to both a hyphen and an underscore to go unnoticed -- and the
# underscore is the spelling Direct Download accepted before this module existed.
_SUBTAG_SEPARATORS = dict.fromkeys(map(ord, "‐‑‒–—―−﹘﹣-_"), "-")
def _fold(value: str) -> str:
"""Casefold, strip accents, and normalize subtag separators, so 'Español'
and 'espanol', or 'zh-Hant', 'zh‑Hant' and 'zh_Hant', all match."""
decomposed = unicodedata.normalize("NFKD", value).translate(_SUBTAG_SEPARATORS)
stripped = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
return " ".join(stripped.split()).casefold()
def _load() -> tuple[dict[str, str], dict[str, str]]:
global _ALIAS_TO_CODE, _CODE_TO_NAME
if _ALIAS_TO_CODE is not None and _CODE_TO_NAME is not None:
return _ALIAS_TO_CODE, _CODE_TO_NAME
with _LOCK:
if _ALIAS_TO_CODE is not None and _CODE_TO_NAME is not None:
return _ALIAS_TO_CODE, _CODE_TO_NAME
alias_to_code: dict[str, str] = {}
code_to_name: dict[str, str] = {}
try:
raw = json.loads(LANGUAGE_DATA_PATH.read_text(encoding="utf-8"))
except OSError, ValueError:
logger.exception("Failed to load language data from %s", LANGUAGE_DATA_PATH)
raw = []
if not isinstance(raw, list):
logger.warning("Language data at %s is not a list", LANGUAGE_DATA_PATH)
raw = []
for item in raw:
if not isinstance(item, dict):
continue
code = str(item.get("code") or "").strip()
name = str(item.get("language") or "").strip()
if not code:
continue
code_to_name.setdefault(code, name or code)
for candidate in (code, name, *(item.get("aliases") or [])):
folded = _fold(str(candidate))
if folded and folded not in LANGUAGE_PLACEHOLDERS:
alias_to_code.setdefault(folded, code)
_ALIAS_TO_CODE = alias_to_code
_CODE_TO_NAME = code_to_name
return alias_to_code, code_to_name
def normalize_language(value: object) -> str | None:
"""Resolve any known spelling of a language to its ISO 639-1 code.
Accepts a two-letter code, an ISO 639-2 three-letter code in either the
bibliographic or terminological form, or an English name. Returns None for
anything unrecognised or for the placeholders a source uses to say it does
not know, so callers can treat "no language" uniformly.
"""
if value is None:
return None
folded = _fold(str(value))
if not folded or folded in LANGUAGE_PLACEHOLDERS:
return None
alias_to_code, _ = _load()
return alias_to_code.get(folded)
def language_name(code: str | None) -> str | None:
"""Return the English name for a language code, or None if unknown."""
if not code:
return None
_, code_to_name = _load()
return code_to_name.get(str(code).strip())
def language_alias_map() -> dict[str, str]:
"""Every known alias mapped to its code, for callers doing their own matching.
Direct Download scans free-text paths and needs the whole alias set up front
to look for, rather than resolving one candidate at a time.
"""
alias_to_code, _ = _load()
return dict(alias_to_code)
def supported_book_languages() -> list[dict[str, str]]:
"""The selectable languages, as ``{"language": ..., "code": ...}``.
Aliases are an implementation detail of resolution, so they are left out of
what the settings dropdown and the API hand to clients.
"""
_, code_to_name = _load()
return [{"language": name, "code": code} for code, name in code_to_name.items()]
def known_language_codes() -> frozenset[str]:
"""Every ISO 639-1 code the bundled language data defines."""
_, code_to_name = _load()
return frozenset(code_to_name)
-10
View File
@@ -108,9 +108,6 @@ class DownloadTask:
retry_expected_hash: str | None = None # Optional torrent hash used to match client downloads
retry_ratio_limit: float | None = None # Optional post-download seeding ratio
retry_seeding_time_limit_minutes: int | None = None # Optional post-download seeding time limit
retry_source_context: dict[str, Any] = field(
default_factory=dict
) # Source-private context for retry/re-resolution
can_retry_without_staged_source: bool = (
True # Whether the source can restart without a preserved staged file
)
@@ -119,7 +116,6 @@ class DownloadTask:
series_name: str | None = None
series_position: float | None = None # Float for novellas (e.g., 1.5)
subtitle: str | None = None # Book subtitle for naming templates
language: str | None = None # Release language code for the {Language} template variable
# Hardlinking support
original_download_path: str | None = None # Path in download client (for hardlinking)
@@ -136,12 +132,6 @@ 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
+2 -30
View File
@@ -4,7 +4,6 @@ import re
from pathlib import Path
from typing import TYPE_CHECKING
from shelfmark.core.languages import LANGUAGE_PLACEHOLDERS, normalize_language
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
@@ -20,7 +19,6 @@ KNOWN_TOKENS = [
"primarytitle",
"originalname",
"partnumber",
"language",
"subtitle",
"author",
"series",
@@ -68,33 +66,6 @@ def format_series_position(position: str | float | None) -> str:
return str(position)
def normalize_language_code(language: str | None) -> str:
"""Resolve a release language to the single spelling used in a path.
Sources report the same language in different shapes: "en", "eng", "English".
All of them have to collapse to one code, or the editions they identify end
up in separate folders, which is the collision this token exists to prevent.
Placeholder values render empty so `{ (Language)}` disappears entirely
rather than labelling a folder "(unknown)".
A language the bundled data does not know is kept, casefolded, rather than
dropped: it still separates editions, and it cannot collide with a resolved
code precisely because nothing resolves it.
"""
if not language:
return ""
resolved = normalize_language(language)
if resolved is not None:
return resolved
normalized = " ".join(str(language).split()).strip().casefold()
if normalized in LANGUAGE_PLACEHOLDERS:
return ""
return normalized
def derive_primary_title(title: str | None, subtitle: str | None) -> str:
"""Return the title without an explicit subtitle suffix when possible."""
title_value = " ".join(str(title or "").split()).strip()
@@ -120,7 +91,8 @@ PAD_NUMBERS_PATTERN = re.compile(r"\d+")
def natural_sort_key(path: str | Path) -> str:
"""Generate a sort key with padded numbers for natural sorting."""
return PAD_NUMBERS_PATTERN.sub(lambda m: m.group().zfill(9), str(path).lower())
filename = Path(path).name.lower()
return PAD_NUMBERS_PATTERN.sub(lambda m: m.group().zfill(9), filename)
def assign_part_numbers(
-42
View File
@@ -393,41 +393,6 @@ def _plugin_label(plugin: object, fallback_scheme: str) -> str:
return " ".join(parts)
def _apprise_proxy_env() -> dict[str, str]:
"""Build proxy env vars from app config so Apprise respects the proxy setting."""
import os
from shelfmark.core.config import config as _cfg
mode = str(_cfg.get("PROXY_MODE", "") or "").lower()
env: dict[str, str] = {}
if mode == "http":
http = str(_cfg.get("HTTP_PROXY", "") or "").strip()
https = str(_cfg.get("HTTPS_PROXY", "") or "").strip() or http
if http:
env["HTTP_PROXY"] = http
env["http_proxy"] = http
if https:
env["HTTPS_PROXY"] = https
env["https_proxy"] = https
elif mode == "socks5":
socks = str(_cfg.get("SOCKS5_PROXY", "") or "").strip()
if socks:
env["HTTP_PROXY"] = socks
env["http_proxy"] = socks
env["HTTPS_PROXY"] = socks
env["https_proxy"] = socks
no_proxy = str(_cfg.get("NO_PROXY", "") or "").strip()
if no_proxy and env:
env["NO_PROXY"] = no_proxy
env["no_proxy"] = no_proxy
# Don't override if the user already set these in the environment directly
return {k: v for k, v in env.items() if not os.environ.get(k)}
def _dispatch_to_apprise(
urls: Iterable[str],
*,
@@ -435,8 +400,6 @@ def _dispatch_to_apprise(
body: str,
notify_type: object,
) -> dict[str, Any]:
import os
normalized_urls = _normalize_urls(list(urls))
url_schemes = _extract_url_schemes(normalized_urls)
if not normalized_urls:
@@ -445,11 +408,6 @@ def _dispatch_to_apprise(
if apprise is None:
return {"success": False, "message": "Apprise is not installed"}
proxy_env = _apprise_proxy_env()
if proxy_env:
logger.debug("Applying proxy env for Apprise dispatch: %s", list(proxy_env.keys()))
os.environ.update(proxy_env)
valid_urls = 0
invalid_urls = 0
delivered_urls = 0
+1 -44
View File
@@ -33,11 +33,6 @@ logger = setup_logger(__name__)
oauth = OAuth()
_RETURN_TO_SESSION_KEY = "oidc_return_to"
_OIDC_CLIENT_ERRORS = (OAuthError, OSError, RuntimeError, TypeError, ValueError)
_EMPTY_JWKS_MESSAGE = (
"Authentication failed: the identity provider returned no token signing keys "
"(empty JWKS). If you use Authentik, select a Signing Key in the provider "
"settings and try again."
)
class _ClaimsMappingLike(Protocol):
@@ -78,16 +73,6 @@ def _has_username_or_email(claims: dict[str, Any]) -> bool:
return False
def _is_email_verified(claims: dict[str, Any]) -> bool:
"""Return True when claims explicitly mark the email address as verified."""
email_verified = claims.get("email_verified")
if isinstance(email_verified, bool):
return email_verified
if isinstance(email_verified, str):
return email_verified.strip().lower() == "true"
return False
def _login_error_url(message: str) -> str:
"""Build a login URL (with script_root) that includes an OIDC error message."""
script_root = request.script_root.rstrip("/")
@@ -126,17 +111,6 @@ def _normalize_return_to(raw_return_to: object) -> str | None:
return urlunsplit(("", "", path, parsed.query, parsed.fragment))
def _idp_jwks_has_no_keys(client: Any) -> bool:
"""Return True when the IdP's JWKS document verifiably contains no signing keys."""
try:
jwk_set = client.fetch_jwk_set(force=True)
except (*_OIDC_CLIENT_ERRORS, KeyError):
return False
if not isinstance(jwk_set, Mapping):
return False
return not jwk_set.get("keys")
def _get_pending_return_to(*, clear: bool = False) -> str | None:
"""Read the pending post-login target from the session."""
raw_return_to = (
@@ -290,17 +264,6 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
return redirect(
_login_error_url(f"OIDC token claim validation failed: {claim_name}")
)
except KeyError, ValueError:
# An IdP serving an empty JWKS document (e.g. an Authentik provider
# with no Signing Key selected) surfaces as KeyError('keys') while
# importing the key set. Test Connection only validates discovery,
# so this is the first place the misconfiguration becomes visible.
if _idp_jwks_has_no_keys(client):
logger.exception(
"OIDC callback failed: the IdP JWKS document contains no signing keys"
)
return redirect(_login_error_url(_EMPTY_JWKS_MESSAGE))
raise
claims = _normalize_claims(token.get("userinfo"))
# If userinfo is missing or claims are too sparse, request it explicitly.
@@ -332,13 +295,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
if admin_group and use_admin_group:
is_admin = admin_group in groups
allow_email_link = bool(user_info.get("email")) and _is_email_verified(claims)
if user_info.get("email") and not allow_email_link:
logger.debug(
"OIDC email %s is not marked verified by the IdP; skipping "
"email-based account linking",
user_info["email"],
)
allow_email_link = bool(user_info.get("email"))
user = provision_oidc_user(
user_db,
user_info,
+4 -49
View File
@@ -10,7 +10,7 @@ A mapping rewrites a remote path prefix into a local path prefix.
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path, PureWindowsPath
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -50,42 +50,6 @@ def _normalize_host(host: str) -> str:
return str(host or "").strip().lower()
def _is_relative_to(path: Path, prefix: Path) -> bool:
try:
path.relative_to(prefix)
except ValueError:
return False
return True
def _join_contained_path(local_prefix: str, remainder: str) -> Path | None:
local_path = Path(local_prefix)
if remainder:
remainder_path = Path(remainder)
windows_remainder_path = PureWindowsPath(remainder)
if (
remainder_path.is_absolute()
or windows_remainder_path.is_absolute()
or ".." in remainder_path.parts
or ".." in windows_remainder_path.parts
):
return None
remapped = local_path / remainder_path
else:
remapped = local_path
resolved_local_path = local_path.resolve(strict=False)
resolved_remapped = remapped.resolve(strict=False)
if not _is_relative_to(resolved_remapped, resolved_local_path):
return None
return remapped
def parse_remote_path_mappings(value: object) -> list[RemotePathMapping]:
"""Parse configured remote-path mapping rows into normalized mappings."""
if not value or not isinstance(value, list):
@@ -117,12 +81,8 @@ def remap_remote_to_local_with_match(
mappings: Iterable[RemotePathMapping],
host: str,
remote_path: str | Path,
) -> tuple[Path | None, bool]:
"""Remap a remote path and report whether a configured mapping matched.
Returns ``(None, True)`` when a mapping prefix matched but the remainder was
unsafe to join under the local prefix.
"""
) -> tuple[Path, bool]:
"""Remap a remote path and report whether a configured mapping matched."""
host_normalized = _normalize_host(host)
remote_normalized = _normalize_prefix(str(remote_path))
@@ -159,10 +119,7 @@ def remap_remote_to_local_with_match(
remainder = remainder.removeprefix("/")
remapped = _join_contained_path(local_prefix, remainder)
if remapped is None:
return None, True
remapped = Path(local_prefix) / remainder if remainder else Path(local_prefix)
return remapped, True
return Path(remote_normalized), False
@@ -177,8 +134,6 @@ def remap_remote_to_local(
host=host,
remote_path=remote_path,
)
if remapped is None:
return Path(str(remote_path))
return remapped
-102
View File
@@ -1,102 +0,0 @@
"""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))
-28
View File
@@ -220,26 +220,6 @@ def _normalize_release_result_request_payload(
return "release", normalized_release_data
def _validate_release_source_matches_policy_context(
*,
source: str,
release_data: object,
) -> None:
if not isinstance(release_data, dict):
return
release_source = normalize_source(release_data.get("source"))
if release_source in {"", "*"} or release_source == source:
return
msg = "Policy context source must match release_data.source"
raise RequestServiceError(
msg,
status_code=400,
code="policy_source_mismatch",
)
def _resolve_request_title(request_row: dict[str, Any]) -> str:
return _resolve_title_from_book_data(request_row.get("book_data"))
@@ -337,10 +317,6 @@ def _prepare_request_create_arguments(
content_type = normalize_content_type(
context.get("content_type") or data.get("content_type") or book_data.get("content_type")
)
_validate_release_source_matches_policy_context(
source=source,
release_data=release_data,
)
request_level, release_data = _normalize_release_result_request_payload(
source=source,
request_level=request_level,
@@ -348,10 +324,6 @@ def _prepare_request_create_arguments(
release_data=release_data,
content_type=content_type,
)
_validate_release_source_matches_policy_context(
source=source,
release_data=release_data,
)
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
user_db,
-134
View File
@@ -1,134 +0,0 @@
"""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
+28 -103
View File
@@ -7,7 +7,6 @@ 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,
@@ -17,8 +16,6 @@ from shelfmark.metadata_providers import (
if TYPE_CHECKING:
from shelfmark.core.models import SearchFilters
logger = setup_logger(__name__)
MANUAL_QUERY_MAX_LEN = 256
@@ -55,110 +52,44 @@ class ReleaseSearchPlan:
return self.title_variants[0].query if self.title_variants else ""
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:
def _normalize_languages(languages: list[str] | None) -> list[str] | None:
if not languages:
default = config.get("BOOK_LANGUAGE", None, user_id=user_id)
default = getattr(config, "BOOK_LANGUAGE", None)
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 _to_language_codes(default_values, source="BOOK_LANGUAGE")
return [str(lang).strip() for lang in default_values if str(lang).strip()]
return _to_language_codes(languages, source="the search request")
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
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.
def _pick_search_author(book: BookMetadata) -> str:
if book.search_author:
narrowed = first_author(book.search_author)
if narrowed:
return narrowed
return book.search_author
# 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
if not book.authors:
return ""
return ""
first = book.authors[0]
if "," in first:
first = first.split(",")[0].strip()
return first
def _pick_search_title(book: BookMetadata) -> str:
@@ -171,21 +102,15 @@ 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.
``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)
"""Build normalized search variants shared across release sources."""
resolved_languages = _normalize_languages(languages)
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:
+4 -8
View File
@@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any
from werkzeug.utils import secure_filename
from shelfmark.config.env import normalize_log_level
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_bool, normalize_optional_text
@@ -334,7 +333,7 @@ def get_all_settings_tabs() -> list[SettingsTab]:
return sorted(_SETTINGS_REGISTRY.values(), key=lambda t: (t.order, t.name))
def iter_value_fields(tab: SettingsTab) -> Iterator[FieldBase]:
def _iter_value_fields(tab: SettingsTab) -> Iterator[FieldBase]:
"""Yield value-bearing fields for a tab."""
for settings_field in tab.fields:
if isinstance(settings_field, CustomComponentField):
@@ -361,7 +360,7 @@ def get_settings_field_map(
field_map: dict[str, tuple[FieldBase, str]] = {}
for tab in tabs:
for settings_field in iter_value_fields(tab):
for settings_field in _iter_value_fields(tab):
field_map[settings_field.key] = (settings_field, tab.name)
return field_map
@@ -495,7 +494,7 @@ def initialize_default_configs() -> bool:
# Collect default values for all fields
defaults = {}
for field in iter_value_fields(tab):
for field in _iter_value_fields(tab):
# Only include fields that have a non-None default
if field.default is not None:
defaults[field.key] = field.default
@@ -537,7 +536,7 @@ def sync_env_to_config() -> None:
for tab in get_all_settings_tabs():
values_to_sync = {}
for settings_field in iter_value_fields(tab):
for settings_field in _iter_value_fields(tab):
# Skip fields that don't support ENV vars
if not getattr(settings_field, "env_supported", True):
continue
@@ -899,9 +898,6 @@ def _get_env_value_for_field(field: FieldBase) -> tuple[bool, object | None]:
"WELIB_MIRROR_URLS",
} and isinstance(parsed, list):
parsed = _normalize_mirror_env_urls(parsed)
if field.key == "LOG_LEVEL" and isinstance(parsed, str):
# LOG_LEVEL is commonly set lowercase; the field options are uppercase.
parsed = normalize_log_level(parsed)
return True, parsed
if field.key == "AA_MIRROR_URLS":
-2
View File
@@ -344,7 +344,6 @@ class UserDB:
_ALLOWED_UPDATE_COLUMNS: ClassVar[frozenset[str]] = frozenset(
{
"username",
"email",
"display_name",
"password_hash",
@@ -354,7 +353,6 @@ class UserDB:
}
)
_USER_UPDATE_STATEMENTS: ClassVar[dict[str, str]] = {
"username": "UPDATE users SET username = ? WHERE id = ?",
"email": "UPDATE users SET email = ? WHERE id = ?",
"display_name": "UPDATE users SET display_name = ? WHERE id = ?",
"password_hash": "UPDATE users SET password_hash = ? WHERE id = ?",
-27
View File
@@ -52,13 +52,6 @@ def normalize_http_url(
if scheme:
normalized = f"{scheme}://{normalized}"
# Strip query string and fragment — mirrors are used as base URLs for
# constructing search requests; params/fragments on the configured URL
# produce malformed URLs when paths are appended (issue #999).
parsed = urlparse(normalized)
if parsed.query or parsed.fragment:
normalized = parsed._replace(query="", fragment="").geturl()
if strip_trailing_slash:
normalized = normalized.rstrip("/")
@@ -115,26 +108,6 @@ def is_audiobook(content_type: str | None) -> bool:
return bool(content_type and "audiobook" in content_type.lower())
# Every audio format an audiobook can legitimately arrive in, and the single source of
# truth for that list. The settings UI, release-source parsing, archive extraction and
# post-download scanning all derive from it, so a format added here becomes selectable,
# searchable AND downloadable at once. These used to be four hand-maintained copies that
# 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".
#
# "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
# what comes out of one after extraction.
ARCHIVE_FORMATS = ("zip", "rar")
CONTENT_TYPES = [
"book (fiction)",
"book (non-fiction)",
-81
View File
@@ -1,81 +0,0 @@
"""Stall-detection grace signalling for long single-shot download operations.
The orchestrator cancels a download after `STALL_TIMEOUT` seconds without activity, where
"activity" means a *changed* status event or a *changed* progress value. That de-duplication
is deliberate - a keep-alive that repeats the same payload on a timer proves nothing about
whether the operation is still making progress, so letting it refresh the stall clock would
make a genuinely wedged download immortal.
Operations that legitimately take longer than `STALL_TIMEOUT` but cannot report incremental
progress therefore declare an explicit upper bound up front instead:
request_activity_grace(status_callback, my_worst_case_seconds)
try:
...one long blocking call...
finally:
release_activity_grace(status_callback)
The grace is a single absolute deadline. It is never extended, so the operation still dies
if it overruns its own declared budget - just at *its* bound rather than at a global 300s.
The signal rides on the existing `status_callback` channel using a sentinel status, which
avoids threading a new parameter through every handler, post-processor and output module.
`shelfmark.download.orchestrator`'s per-task `status_callback` closure intercepts the
sentinel and never forwards it to `update_download_status`.
Adopters should be operations that yield to the gevent hub while blocking (`requests`,
patched `subprocess`). An operation that blocks the hub outright - `shutil.copy2`, sqlite -
will still be killed by the gunicorn worker timeout regardless of any grace, and must go
through `shelfmark.download.fs.run_blocking_io` first.
Current adopters: `shelfmark.download.http.html_get_page` (protection bypass).
Candidates: `download.clients.base_handler._wait_for_completed_path`, archive extraction in
`download.postprocess.scan`, large-file copies in `download.outputs.folder`, email/BookLore
uploads, and the Anna's Archive countdown in `release_sources.direct_download` (which today
refreshes the stall clock on every tick of a loop that proves nothing about the remote).
"""
from collections.abc import Callable
# Not a QueueStatus value, so `update_download_status` would reject it anyway; the
# orchestrator's status_callback intercepts it before that point.
ACTIVITY_GRACE_STATUS = "__activity_grace__"
StatusCallback = Callable[[str, str | None], None]
# A status_callback is caller-supplied and may raise; a failed liveness hint must never
# break the operation it was protecting. Mirrors http._STATUS_CALLBACK_ERRORS.
_CALLBACK_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError)
def request_activity_grace(status_callback: StatusCallback | None, seconds: float) -> None:
"""Ask the orchestrator to suppress stall detection for up to `seconds` from now."""
_emit(status_callback, seconds)
def release_activity_grace(status_callback: StatusCallback | None) -> None:
"""Drop any outstanding grace and count now as activity."""
_emit(status_callback, 0)
def parse_activity_grace(status: str, message: str | None) -> float | None:
"""Return the requested grace in seconds, or None if this is not a grace event.
Never raises: a malformed sentinel is treated as "not a grace event" so a bad emitter
cannot take down the status pipeline.
"""
if status != ACTIVITY_GRACE_STATUS:
return None
try:
return max(float(message or 0), 0.0)
except TypeError, ValueError:
return 0.0
def _emit(status_callback: StatusCallback | None, seconds: float) -> None:
if status_callback is None:
return
try:
status_callback(ACTIVITY_GRACE_STATUS, str(float(seconds)))
except _CALLBACK_ERRORS:
return
+6 -26
View File
@@ -1,22 +1,18 @@
"""Archive extraction utilities for downloaded book archives."""
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import AUDIOBOOK_FORMATS
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.download.fs import atomic_move
from shelfmark.download.fs import atomic_write
from shelfmark.download.postprocess.policy import (
get_supported_audiobook_formats,
get_supported_formats,
)
logger = setup_logger(__name__)
_ARCHIVE_COPY_CHUNK_SIZE = 1024 * 1024
if TYPE_CHECKING:
import rarfile
@@ -99,7 +95,7 @@ ALL_EBOOK_EXTENSIONS = {
}
# All known audio extensions (superset of what user might enable for audiobooks)
ALL_AUDIO_EXTENSIONS = {f".{fmt}" for fmt in AUDIOBOOK_FORMATS}
ALL_AUDIO_EXTENSIONS = {".m4b", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".wma", ".wav", ".opus"}
def _filter_files(
@@ -212,25 +208,9 @@ def _extract_files_from_archive(archive: ArchiveType, output_dir: Path) -> list[
logger.warning("Path traversal attempt blocked: %r", info.filename)
continue
temp_path: Path | None = None
try:
with (
archive.open(info) as src,
tempfile.NamedTemporaryFile(
dir=output_dir,
prefix=".shelfmark-extract-",
suffix=".tmp",
delete=False,
) as temp_file,
):
temp_path = Path(temp_file.name)
shutil.copyfileobj(src, temp_file, length=_ARCHIVE_COPY_CHUNK_SIZE)
final_path = atomic_move(cast("Path", temp_path), target_path)
except Exception:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
raise
with archive.open(info) as src:
data = src.read()
final_path = atomic_write(target_path, data)
extracted_files.append(final_path)
logger.debug("Extracted: %s", filename)
+2 -18
View File
@@ -325,19 +325,6 @@ class DownloadClient(ABC):
"""
def set_category(self, download_id: str, category: str) -> bool:
"""Update a download's category or label when supported by the client.
Args:
download_id: The client-specific download ID.
category: Category or label to assign.
Returns:
True if the category was updated, otherwise False.
"""
return False
@abstractmethod
def get_download_path(self, download_id: str) -> str | None:
"""Get the path where files were downloaded.
@@ -372,13 +359,10 @@ class DownloadClient(ABC):
# Client registry: protocol -> list of client classes
_CLIENTS: dict[str, list[type[DownloadClient]]] = {}
ClientType = TypeVar("ClientType", bound=DownloadClient)
_BUILTIN_CLIENT_MODULES = (
"shelfmark.download.clients.alldebrid",
"shelfmark.download.clients.deluge",
"shelfmark.download.clients.nzbget",
"shelfmark.download.clients.qbittorrent",
"shelfmark.download.clients.realdebrid",
"shelfmark.download.clients.rtorrent",
"shelfmark.download.clients.sabnzbd",
"shelfmark.download.clients.transmission",
@@ -399,7 +383,7 @@ def _ensure_builtin_clients_registered() -> None:
def register_client(
protocol: str,
) -> Callable[[type[ClientType]], type[ClientType]]:
) -> Callable[[type[DownloadClient]], type[DownloadClient]]:
"""Register a download client for a protocol.
Multiple clients can be registered for the same protocol.
@@ -415,7 +399,7 @@ def register_client(
"""
def decorator(cls: type[ClientType]) -> type[ClientType]:
def decorator(cls: type[DownloadClient]) -> type[DownloadClient]:
if protocol not in _CLIENTS:
_CLIENTS[protocol] = []
_CLIENTS[protocol].append(cls)
-718
View File
@@ -1,718 +0,0 @@
"""AllDebrid debrid service client for Shelfmark.
Routes magnet links through the AllDebrid API (v4/v4.1) to download
torrent content via AllDebrid's CDN infrastructure.
"""
from __future__ import annotations
import shutil
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, ClassVar, NoReturn
from urllib.parse import quote
import requests
from shelfmark.config.env import TMP_DIR
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.download.clients import (
DownloadClient,
DownloadState,
DownloadStatus,
register_client,
)
from shelfmark.download.clients._coercion import config_text
from shelfmark.download.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.alldebrid.com/v4"
_AGENT = "shelfmark"
_ALLDEBRID_CLIENT_ERRORS = (
AttributeError,
OSError,
requests.exceptions.RequestException,
RuntimeError,
TypeError,
ValueError,
)
# AllDebrid magnet status codes (from API v4.1 documentation).
_STATUS_DOWNLOADING = frozenset({0, 1, 2, 3})
_STATUS_READY = 4
# Timeouts and retry limits for API calls.
_API_TIMEOUT = 30
_STATUS_TIMEOUT = 15
_DELAYED_POLL_INTERVAL = 5
_DELAYED_POLL_MAX_ATTEMPTS = 12
# File extensions recognised as book or audiobook content.
_BOOK_EXTENSIONS = (
".aac",
".azw",
".azw3",
".cbr",
".cbz",
".djvu",
".doc",
".docx",
".epub",
".fb2",
".flac",
".lit",
".m4a",
".m4b",
".mobi",
".mp3",
".mp4",
".ogg",
".opus",
".pdf",
".rtf",
".txt",
".wma",
)
def _flatten_magnet_files(
entries: list[dict[str, Any]],
prefix: str = "",
) -> list[dict[str, Any]]:
"""Flatten AllDebrid's nested file tree into a list of file dicts.
AllDebrid returns files with ``"n"`` (name), ``"s"`` (size),
``"l"`` (link), and ``"e"`` (children) keys. Directories use
``"e"`` to nest their contents.
Returns:
List of ``{"filename": ..., "size": ..., "link": ...}`` dicts.
"""
flat: list[dict[str, Any]] = []
for entry in entries:
name = entry.get("n", "")
if "e" in entry:
flat.extend(
_flatten_magnet_files(entry["e"], prefix=f"{prefix}{name}/"),
)
elif entry.get("l"):
flat.append(
{
"filename": f"{prefix}{name}",
"size": entry.get("s", 0),
"link": entry["l"],
}
)
return flat
def _raise_runtime_error(message: str) -> NoReturn:
raise RuntimeError(message)
@dataclass
class _DownloadState:
"""Internal mutable state for an in-progress AllDebrid download."""
magnet_id: str
name: str
target_dir: Path
phase: str = "uploading"
error_message: str | None = None
progress: float = 0.0
download_thread: threading.Thread | None = None
lock: threading.Lock = field(default_factory=threading.Lock)
@register_client("torrent")
class AllDebridClient(DownloadClient):
"""AllDebrid debrid service client.
Downloads torrent content by uploading magnet links to AllDebrid,
waiting for the torrent to complete on their servers, then fetching
the resulting files via direct HTTP download from AllDebrid's CDN.
API documentation: https://docs.alldebrid.com/
"""
protocol = "torrent"
name = "alldebrid"
_downloads: ClassVar[dict[str, _DownloadState]] = {}
_downloads_lock = threading.Lock()
def __init__(self) -> None:
self._api_key = config_text(config.get("ALLDEBRID_API_KEY", ""))
def _auth_headers(self) -> dict[str, str]:
"""Return Authorization header dict for API requests."""
return {"Authorization": f"Bearer {self._api_key}"}
# ------------------------------------------------------------------
# DownloadClient interface
# ------------------------------------------------------------------
@staticmethod
def is_configured() -> bool:
"""Return True when AllDebrid is selected and an API key exists."""
client = config_text(config.get("PROWLARR_TORRENT_CLIENT", ""))
api_key = config_text(config.get("ALLDEBRID_API_KEY", ""))
return client == "alldebrid" and bool(api_key)
def test_connection(self) -> tuple[bool, str]:
"""Validate the API key and check Premium subscription status."""
if not self._api_key:
return False, "AllDebrid API Key is required"
try:
url = f"{_API_BASE}/user"
resp = requests.get(
url,
headers=self._auth_headers(),
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(url),
)
resp.raise_for_status()
data = resp.json()
if data.get("status") != "success":
err = data.get("error", {}).get("message", "API error")
return False, f"AllDebrid error: {err}"
user = data.get("data", {}).get("user", {})
username = user.get("username", "Unknown")
if not user.get("isPremium", False):
return (
False,
f"AllDebrid user '{username}' does not have a Premium subscription",
)
except _ALLDEBRID_CLIENT_ERRORS as e:
return False, f"Connection failed: {e}"
else:
return True, f"Connected to AllDebrid as '{username}' (Premium)"
def add_download(
self,
url: str,
name: str,
category: str | None = None,
expected_hash: str | None = None,
**kwargs: object,
) -> str:
"""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)
try:
upload = resolve_debrid_upload(url, expected_hash=expected_hash)
info = self._send_torrent(upload)
magnet_id = str(info.get("id", ""))
if not magnet_id:
msg = "No magnet ID returned from AllDebrid"
_raise_runtime_error(msg)
target_dir = TMP_DIR / f"alldebrid_{magnet_id}"
target_dir.mkdir(parents=True, exist_ok=True)
state = _DownloadState(
magnet_id=magnet_id,
name=name,
target_dir=target_dir,
phase="waiting_ad",
)
with self._downloads_lock:
self._downloads[magnet_id] = state
logger.info(
"Added torrent to AllDebrid: ID %s (%s)",
magnet_id,
name,
)
except Exception:
logger.exception("Failed to 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)
# Return cached terminal / in-flight states immediately.
with state.lock:
if state.phase == "error":
return DownloadStatus.error(
state.error_message or "AllDebrid error",
)
if state.phase == "complete":
return DownloadStatus(
progress=100.0,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=str(state.target_dir),
)
if state.phase == "downloading_http":
return DownloadStatus(
progress=state.progress,
state=DownloadState.DOWNLOADING,
message="Downloading files via HTTP...",
complete=False,
file_path=None,
)
# Ask AllDebrid for the current magnet status.
try:
status_url = f"{_API_BASE.replace('/v4', '/v4.1')}/magnet/status"
resp = requests.post(
status_url,
headers=self._auth_headers(),
data={"id": download_id},
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(status_url),
)
resp.raise_for_status()
data = resp.json()
if data.get("status") != "success":
err = data.get("error", {}).get("message", "Status failed")
return DownloadStatus.error(
f"AllDebrid status error: {err}",
)
mag = self._extract_magnet_info(data)
return self._handle_magnet_status(mag, state)
except Exception as e:
logger.exception(
"Error checking AllDebrid status for %s",
download_id,
)
return DownloadStatus.error(str(e))
def remove(
self,
download_id: str,
*,
delete_files: bool = False,
) -> bool:
"""Delete the magnet from AllDebrid and clean up local files."""
try:
url = f"{_API_BASE}/magnet/delete"
requests.post(
url,
headers=self._auth_headers(),
data={"id": download_id},
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(url),
)
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.warning("Failed to delete magnet from AllDebrid: %s", e)
with self._downloads_lock:
state = self._downloads.pop(download_id, None)
if state and state.target_dir.exists():
shutil.rmtree(state.target_dir, ignore_errors=True)
return True
def get_download_path(self, download_id: str) -> str | None:
"""Return the local directory containing downloaded files."""
with self._downloads_lock:
state = self._downloads.get(download_id)
if state and state.phase == "complete":
return str(state.target_dir)
target_dir = TMP_DIR / f"alldebrid_{download_id}"
if target_dir.exists():
return str(target_dir)
return None
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _ensure_state(self, download_id: str) -> _DownloadState:
"""Get or create download state for the given magnet ID."""
with self._downloads_lock:
state = self._downloads.get(download_id)
if state:
return state
target_dir = TMP_DIR / f"alldebrid_{download_id}"
state = _DownloadState(
magnet_id=download_id,
name=f"Download {download_id}",
target_dir=target_dir,
phase="waiting_ad",
)
with self._downloads_lock:
self._downloads[download_id] = state
return state
@staticmethod
def _extract_magnet_info(data: dict[str, Any]) -> dict[str, Any]:
"""Extract magnet info dict from a status API response."""
mag_data = data.get("data", {}).get("magnets", {})
if isinstance(mag_data, list) and mag_data:
return mag_data[0]
if isinstance(mag_data, dict):
return mag_data
return {}
def _handle_magnet_status(
self,
mag: dict[str, Any],
state: _DownloadState,
) -> DownloadStatus:
"""Map AllDebrid magnet status to a DownloadStatus."""
status_code = mag.get("statusCode")
if status_code in _STATUS_DOWNLOADING:
size = mag.get("size", 0)
downloaded = mag.get("downloaded", 0)
pct = (downloaded / size * 100.0) if size > 0 else 0.0
return DownloadStatus(
progress=pct * 0.5,
state=DownloadState.DOWNLOADING,
message=(f"AllDebrid downloading torrent ({mag.get('filename', state.name)})"),
complete=False,
file_path=None,
download_speed=mag.get("downloadSpeed", 0),
)
if status_code == _STATUS_READY or mag.get("ready", False):
self._maybe_start_download_thread(state)
return DownloadStatus(
progress=50.0,
state=DownloadState.DOWNLOADING,
message="AllDebrid ready, retrieving files...",
complete=False,
file_path=None,
)
# Terminal error from AllDebrid.
error_txt = mag.get("error", {}).get("message") or f"AllDebrid status code {status_code}"
with state.lock:
state.phase = "error"
state.error_message = error_txt
return DownloadStatus.error(error_txt)
def _maybe_start_download_thread(self, state: _DownloadState) -> None:
"""Spawn a background thread to unlock and download files."""
with state.lock:
already_running = state.phase in (
"unlocking",
"downloading_http",
"complete",
)
thread_alive = state.download_thread is not None and state.download_thread.is_alive()
if already_running or thread_alive:
return
state.phase = "unlocking"
t = threading.Thread(
target=self._process_and_download,
args=(state,),
daemon=True,
)
state.download_thread = t
t.start()
# ------------------------------------------------------------------
# Link unlocking
# ------------------------------------------------------------------
def _unlock_file_link(self, link: str) -> str:
"""Resolve an AllDebrid file link to a direct CDN download URL.
AllDebrid's ``/v4/magnet/files`` endpoint returns virtual links
(``alldebrid.com/f/...``) that must be converted to direct CDN
URLs via ``/v4/link/unlock``.
Strategy:
1. If the link is already a CDN URL (``/dl/``), return it.
2. ``POST /v4/link/unlock`` with Bearer auth (primary).
3. ``GET /v4/link/unlock`` with query parameters (fallback).
4. Append ``apikey=`` to ``alldebrid.com/f/`` links
(last-resort fallback for ghost-cached torrents).
"""
# 1. Already a direct CDN link.
if "/dl/" in link:
return link
headers = self._auth_headers()
unlock_url = f"{_API_BASE}/link/unlock"
err_msg = "Unknown unlock error"
# 2. POST unlock (primary method).
try:
resp = requests.post(
unlock_url,
headers=headers,
data={"link": link},
timeout=_API_TIMEOUT,
verify=get_ssl_verify(unlock_url),
)
if resp.status_code == 200:
body = resp.json()
if body.get("status") == "success":
direct = self._resolve_unlock_data(
body.get("data", {}),
headers,
)
if direct:
return direct
err_msg = body.get("error", {}).get(
"message",
"Unlock failed",
)
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.debug("POST unlock exception: %s", e)
# 3. GET unlock fallback with URL-encoded link.
try:
encoded = quote(link, safe="")
get_url = (
f"{_API_BASE}/link/unlock?agent={_AGENT}&apikey={self._api_key}&link={encoded}"
)
resp = requests.get(
get_url,
headers=headers,
timeout=_API_TIMEOUT,
verify=get_ssl_verify(get_url),
)
if resp.status_code == 200:
body = resp.json()
if body.get("status") == "success":
direct = body.get("data", {}).get("link")
if direct:
return direct
err_msg = body.get("error", {}).get("message", err_msg)
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.debug("GET unlock exception: %s", e)
# 4. Last-resort: append apikey to alldebrid.com/f/ links.
if "alldebrid.com/f/" in link:
logger.info(
"Using apikey fallback for AllDebrid file link: %s",
link,
)
if "apikey=" not in link:
sep = "&" if "?" in link else "?"
return f"{link}{sep}apikey={self._api_key}"
return link
logger.error(
"AllDebrid unlock failed for '%s': %s",
link,
err_msg,
)
msg = f"AllDebrid unlock failed: {err_msg}"
raise RuntimeError(msg)
def _resolve_unlock_data(
self,
data: dict[str, Any],
headers: dict[str, str],
) -> str | None:
"""Extract the direct link from unlock response data.
Handles the *delayed link* flow where AllDebrid returns a
``delayed`` ID instead of an immediate download link.
"""
# Delayed link: poll until the CDN file is ready.
if "delayed" in data:
delayed_id = data["delayed"]
logger.info(
"AllDebrid link delayed (ID %s), polling...",
delayed_id,
)
delayed_url = f"{_API_BASE}/link/delayed"
for _ in range(_DELAYED_POLL_MAX_ATTEMPTS):
time.sleep(_DELAYED_POLL_INTERVAL)
try:
resp = requests.post(
delayed_url,
headers=headers,
data={"id": delayed_id},
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(delayed_url),
)
if resp.status_code != 200:
continue
body = resp.json()
d = body.get("data", {})
if body.get("status") == "success" and d.get("status") == 2 and d.get("link"):
return d["link"]
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.debug("Delayed poll exception: %s", e)
return data.get("link")
# ------------------------------------------------------------------
# File download pipeline
# ------------------------------------------------------------------
def _process_and_download(self, state: _DownloadState) -> None:
"""Fetch the file list, unlock links, and download via HTTP.
Runs in a background thread spawned by ``_maybe_start_download_thread``.
"""
try:
files = self._fetch_file_list(state.magnet_id)
relevant = [f for f in files if f["filename"].lower().endswith(_BOOK_EXTENSIONS)]
if not relevant:
relevant = files
with state.lock:
state.phase = "downloading_http"
total = len(relevant)
for idx, file_info in enumerate(relevant):
direct_link = self._unlock_file_link(file_info["link"])
rel_path = Path(file_info["filename"])
dest = state.target_dir / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
logger.info(
"Downloading AllDebrid file %d/%d: %s",
idx + 1,
total,
rel_path,
)
buf = download_url(
direct_link,
referer="https://alldebrid.com/",
)
if not buf:
msg = f"Failed to download from {direct_link}"
_raise_runtime_error(msg)
with dest.open("wb") as fh:
fh.write(buf.getvalue())
with state.lock:
state.progress = 50.0 + (idx + 1) / total * 50.0
with state.lock:
state.phase = "complete"
state.progress = 100.0
logger.info(
"AllDebrid download complete for ID %s at %s",
state.magnet_id,
state.target_dir,
)
except Exception:
logger.exception(
"Error in AllDebrid download for ID %s",
state.magnet_id,
)
with state.lock:
state.phase = "error"
state.error_message = str(
state.error_message or "Download failed",
)
def _fetch_file_list(
self,
magnet_id: str,
) -> list[dict[str, Any]]:
"""Retrieve and flatten the file tree for a magnet."""
url = f"{_API_BASE}/magnet/files"
resp = requests.post(
url,
headers=self._auth_headers(),
data={"id[]": magnet_id},
timeout=_API_TIMEOUT,
verify=get_ssl_verify(url),
)
resp.raise_for_status()
data = resp.json()
if data.get("status") != "success":
msg = f"Failed to list magnet files: {data.get('error')}"
raise RuntimeError(msg)
magnets = data.get("data", {}).get("magnets", [])
if not magnets:
msg = "No magnet files returned"
raise RuntimeError(msg)
files = _flatten_magnet_files(magnets[0].get("files", []))
if not files:
msg = "No files found in torrent"
raise RuntimeError(msg)
return files
+29 -206
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import errno
import math
import shutil
import time
from abc import ABC, abstractmethod
@@ -56,19 +55,6 @@ SECONDS_PER_HOUR = 3600
# How long to wait for completed files to appear (seconds)
COMPLETED_PATH_RETRY_INTERVAL = 5
COMPLETED_PATH_MAX_ATTEMPTS = 12 # 12 attempts * 5s = 60s grace period
COMPLETED_PATH_TIMEOUT_SETTING = "DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT"
COMPLETED_PATH_TIMEOUT_MAX_SECONDS = 3600
_RETRYABLE_COMPLETED_PATH_ERRNOS = frozenset(
code
for code in (
errno.ENOENT,
getattr(errno, "ESTALE", None),
getattr(errno, "EAGAIN", None),
getattr(errno, "EBUSY", None),
getattr(errno, "ETIMEDOUT", None),
)
if code is not None
)
@dataclass(frozen=True)
@@ -83,39 +69,6 @@ class DownloadRequest:
ratio_limit: float | None = None
@dataclass(frozen=True)
class _CompletedPathResolution:
path: Path | None
error: str | None
retryable: bool
def _coerce_completed_path_timeout_seconds(value: object, default: float) -> float:
if isinstance(value, bool) or value is None:
return default
if isinstance(value, (int, float)):
parsed = float(value)
elif isinstance(value, str):
try:
parsed = float(value.strip())
except ValueError:
return default
else:
return default
if not math.isfinite(parsed) or parsed < 0:
return default
return min(parsed, float(COMPLETED_PATH_TIMEOUT_MAX_SECONDS))
def _is_retryable_completed_path_probe(error: OSError | None) -> bool:
return error is not None and error.errno in _RETRYABLE_COMPLETED_PATH_ERRNOS
def _path_needs_mapping(path: str) -> bool:
return (len(path) >= WINDOWS_DRIVE_PREFIX_LENGTH and path[1] == ":") or "\\" in path
def _diagnose_path_issue(path: str) -> str:
"""Analyze a path and return diagnostic hints for common issues.
@@ -214,23 +167,6 @@ class ExternalClientHandler(DownloadHandler, ABC):
"""Maximum attempts when waiting for completed files."""
return COMPLETED_PATH_MAX_ATTEMPTS
def _completed_path_timeout_seconds(self) -> float:
"""Total time to wait for completed files to appear on disk."""
fallback = self._completed_path_retry_interval() * self._completed_path_max_attempts()
configured = config.get(COMPLETED_PATH_TIMEOUT_SETTING, fallback)
return _coerce_completed_path_timeout_seconds(configured, fallback)
def _refresh_download_request_after_add_failure(
self,
*,
task: DownloadTask,
request: DownloadRequest,
error: Exception,
status_callback: Callable[[str, str | None], None],
) -> DownloadRequest | None:
"""Give source handlers one chance to refresh stale resolved download data."""
return None
def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> str | None:
"""Get audiobook category if configured and applicable, else None for default."""
if not is_audiobook(task.content_type):
@@ -284,47 +220,17 @@ class ExternalClientHandler(DownloadHandler, ABC):
)
elif protocol == "torrent":
torrent_action = config.get("PROWLARR_TORRENT_ACTION", "keep")
if torrent_action == "remove":
try:
client.remove(download_id, delete_files=False)
except _CLIENT_CLEANUP_ERRORS as e:
logger.warning(
"Failed to remove torrent %s from %s: %s",
download_id,
getattr(client, "name", "client"),
e,
)
if config.get("PROWLARR_TORRENT_ACTION", "keep") != "remove":
return
if torrent_action != "change_category":
return
post_import_category = normalize_optional_text(
config.get("PROWLARR_TORRENT_POST_IMPORT_CATEGORY", "")
)
if post_import_category is None:
return
try:
category_updated = client.set_category(download_id, post_import_category)
client.remove(download_id, delete_files=False)
except _CLIENT_CLEANUP_ERRORS as e:
logger.warning(
"Failed to set post-import category for torrent %s in %s: %s",
"Failed to remove torrent %s from %s: %s",
download_id,
getattr(client, "name", "client"),
e,
)
return
if not category_updated:
# Clients that cannot label torrents (debrid services) return False here,
# and the ones that can already log the specific failure themselves.
logger.debug(
"Post-import category not applied to torrent %s in %s",
download_id,
getattr(client, "name", "client"),
)
def _remove_usenet_download(
self,
@@ -370,18 +276,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
remote_path=source_path_obj,
)
if matched_mapping:
if remapped is None:
logger.warning(
"Refusing to delete download data for %s %s because remote path mapping rejected unsafe path: %s",
client.name,
download_id,
source_path_obj,
)
return
delete_path = remapped
else:
delete_path = source_path_obj
delete_path = remapped if matched_mapping else source_path_obj
if str(delete_path) in ("", "/"):
logger.warning(
@@ -481,21 +376,6 @@ class ExternalClientHandler(DownloadHandler, ABC):
log_details: bool,
) -> tuple[Path | None, str | None]:
"""Resolve and validate the completed download path once."""
result = self._resolve_download_path_once_detailed(
client,
download_id,
log_details=log_details,
)
return result.path, result.error
def _resolve_download_path_once_detailed(
self,
client: DownloadClient,
download_id: str,
*,
log_details: bool,
) -> _CompletedPathResolution:
"""Resolve and validate a completed path, including retryability."""
try:
raw_path = client.get_download_path(download_id)
except Exception as e:
@@ -511,7 +391,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
logger.debug(
"Failed to resolve download path for %s %s: %s", client.name, download_id, e
)
return _CompletedPathResolution(None, message, retryable=False)
return None, message
if not raw_path:
message = (
@@ -526,7 +406,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
logger.debug(
"Download client returned empty path for %s %s", client.name, download_id
)
return _CompletedPathResolution(None, message, retryable=False)
return None, message
from shelfmark.core.path_mappings import (
get_client_host_identifier,
@@ -555,19 +435,6 @@ class ExternalClientHandler(DownloadHandler, ABC):
)
if matched_mapping:
if remapped is None:
message = (
f"Remote path mapping rejected unsafe path '{source_path_obj}'. "
f"Check Settings > Advanced > Remote Path Mappings."
)
failure_log = "Remote path mapping rejected unsafe path for %s (%s): %s"
failure_args = (client.name, download_id, source_path_obj)
if log_details:
logger.error(failure_log, *failure_args)
else:
logger.debug(failure_log, *failure_args)
return _CompletedPathResolution(None, message, retryable=False)
remapped_exists, remapped_error = _probe_completed_path(remapped)
if log_details:
@@ -589,7 +456,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
source_path_obj,
remapped,
)
return _CompletedPathResolution(remapped, None, retryable=False)
return remapped, None
message = (
f"Remapped path '{remapped}' does not exist. "
@@ -608,11 +475,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
logger.error(failure_log, *failure_args)
else:
logger.debug(failure_log, *failure_args)
return _CompletedPathResolution(
None,
message,
retryable=_is_retryable_completed_path_probe(remapped_error),
)
return None, message
source_exists, source_error = _probe_completed_path(source_path_obj)
@@ -635,7 +498,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
download_id,
source_path_obj,
)
return _CompletedPathResolution(source_path_obj, None, retryable=False)
return source_path_obj, None
hint = _diagnose_path_issue(raw_path)
if mappings:
@@ -668,12 +531,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
logger.error(failure_log, *failure_args)
else:
logger.debug(failure_log, *failure_args)
return _CompletedPathResolution(
None,
message,
retryable=not _path_needs_mapping(raw_path)
and _is_retryable_completed_path_probe(source_error),
)
return None, message
def _wait_for_completed_path(
self,
@@ -685,37 +543,23 @@ class ExternalClientHandler(DownloadHandler, ABC):
) -> tuple[Path | None, str | None]:
"""Wait briefly for completed files to appear on disk."""
last_error: str | None = None
max_attempts = self._completed_path_max_attempts()
retry_interval = self._completed_path_retry_interval()
timeout_seconds = self._completed_path_timeout_seconds()
if retry_interval <= 0 or timeout_seconds <= 0:
max_attempts = 1
else:
max_attempts = int(math.ceil(timeout_seconds / retry_interval)) + 1
for attempt in range(1, max_attempts + 1):
if cancel_flag and cancel_flag.is_set():
return None, last_error
log_details = attempt == max_attempts
result = self._resolve_download_path_once_detailed(
resolved_path, error = self._resolve_download_path_once(
client,
download_id,
log_details=log_details,
)
if result.path:
return result.path, None
if resolved_path:
return resolved_path, None
last_error = result.error
if not result.retryable:
if not log_details:
logger.error(
"Completed path resolution is not retryable for %s (%s): %s",
client.name,
download_id,
last_error,
)
return None, last_error
last_error = error
if attempt < max_attempts:
status_callback("locating", "Waiting for completed files...")
@@ -832,41 +676,20 @@ class ExternalClientHandler(DownloadHandler, ABC):
status_callback("downloading", "Resuming existing download")
else:
# No existing download - add new
refresh_attempted = False
while True:
status_callback("resolving", f"Sending to {client.name}")
try:
download_id = client.add_download(
url=request.url,
name=request.release_name,
category=category,
expected_hash=request.expected_hash,
seeding_time_limit=request.seeding_time_limit,
ratio_limit=request.ratio_limit,
# 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:
refresh_attempted = True
refreshed_request = self._refresh_download_request_after_add_failure(
task=task,
request=request,
error=e,
status_callback=status_callback,
)
if (
refreshed_request is not None
and refreshed_request.protocol == request.protocol
):
request = refreshed_request
continue
logger.exception("Failed to add to %s", client.name)
status_callback("error", f"Failed to add to {client.name}: {e}")
return None
break
status_callback("resolving", f"Sending to {client.name}")
try:
download_id = client.add_download(
url=request.url,
name=request.release_name,
category=category,
expected_hash=request.expected_hash,
seeding_time_limit=request.seeding_time_limit,
ratio_limit=request.ratio_limit,
)
except Exception as e:
logger.exception("Failed to add to %s", client.name)
status_callback("error", f"Failed to add to {client.name}: {e}")
return None
logger.info(
"Added to %s: %s for '%s'", client.name, download_id, request.release_name
+3 -18
View File
@@ -227,10 +227,10 @@ class DelugeClient(DownloadClient):
return self._rpc_call("daemon.info")
def _try_set_label(self, torrent_id: str, label: str) -> bool:
def _try_set_label(self, torrent_id: str, label: str) -> None:
"""Best-effort label assignment (requires Deluge Label plugin)."""
if not label:
return False
return
try:
# label.add will error if the plugin is unavailable or the label exists.
@@ -240,9 +240,6 @@ class DelugeClient(DownloadClient):
self._rpc_call("label.set_torrent", torrent_id, label)
except _DELUGE_CLIENT_ERRORS as e:
logger.debug("Could not set Deluge label '%s' for %s: %s", label, torrent_id, e)
return False
else:
return True
@staticmethod
def is_configured() -> bool:
@@ -280,10 +277,7 @@ class DelugeClient(DownloadClient):
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
if not torrent_info.is_magnet and not torrent_info.torrent_data:
message = "Failed to fetch torrent file"
if torrent_info.fetch_error:
message = f"{message}: {torrent_info.fetch_error}"
_raise_runtime_error(message)
_raise_runtime_error("Failed to fetch torrent file")
options: dict[str, Any] = {}
if self._download_dir:
@@ -425,15 +419,6 @@ class DelugeClient(DownloadClient):
else:
return False
def set_category(self, download_id: str, category: str) -> bool:
"""Assign a label to a torrent using Deluge's Label plugin."""
try:
self._ensure_connected()
return self._try_set_label(download_id, category)
except _DELUGE_CLIENT_ERRORS as e:
self._log_error("set_category", e)
return False
def get_download_path(self, download_id: str) -> str | None:
"""Return the resolved download path for a Deluge torrent."""
try:
+188 -316
View File
@@ -2,10 +2,9 @@
from __future__ import annotations
import os
import time
from http import HTTPStatus
from pathlib import Path, PurePosixPath, PureWindowsPath
from pathlib import Path
from types import SimpleNamespace
from typing import NoReturn, TypedDict
@@ -44,21 +43,9 @@ _HASH_LENGTH_40 = 40
_HASH_LENGTH_ED2K = 32
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
_METADATA_DOWNLOAD_STATES = {"forcedMetaDL", "metaDL"}
# 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
class _UnsafeQBittorrentPath:
pass
_UNSAFE_QBITTORRENT_PATH = _UnsafeQBittorrentPath()
class _QBittorrentAddKwargs(TypedDict, total=False):
rename: str
category: str
@@ -99,25 +86,6 @@ def _hashes_match(hash1: str, hash2: str) -> bool:
return False
def _torrent_matches_download_id(torrent: object, download_id: str) -> bool:
"""Match an ID against every identity qBittorrent exposes.
For hybrid torrents, qBittorrent's primary `hash` can change from the v1
hash to the truncated v2 hash after metadata resolution. The full
`infohash_v1` and `infohash_v2` fields preserve the torrent's identities.
"""
identifiers = (
getattr(torrent, "hash", None),
getattr(torrent, "infohash_v1", None),
getattr(torrent, "infohash_v2", None),
)
return any(
isinstance(identifier, str) and identifier and _hashes_match(identifier, download_id)
for identifier in identifiers
)
def _raise_runtime_error(message: str) -> NoReturn:
raise RuntimeError(message)
@@ -168,28 +136,67 @@ def _is_explicit_add_failure(raw_result: object) -> bool:
return normalized in {"fail", "fails", "error", "errors"}
def _build_qbittorrent_child_path(base_path: object, child_path: object) -> str | None:
"""Build a qBittorrent-reported child path without allowing escape from base."""
if not isinstance(base_path, str) or not base_path:
return None
if not isinstance(child_path, str) or not child_path:
return None
child = child_path.replace("\\", "/")
posix_child = PurePosixPath(child)
windows_child = PureWindowsPath(child_path)
if posix_child.is_absolute() or windows_child.is_absolute() or windows_child.drive:
return None
if any(part == ".." for part in posix_child.parts):
return None
return os.path.normpath(str(Path(base_path) / child))
@register_client("torrent")
class QBittorrentClient(DownloadClient):
"""qBittorrent download client."""
def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, str | None]:
"""Check whether qBittorrent has registered a torrent yet.
Uses `/api/v2/torrents/properties?hash=<hash>`.
Returns:
(loaded, error_message)
Notes:
A false result with no error means "not loaded yet".
"""
url = f"{self._base_url}/api/v2/torrents/properties"
params = {"hash": torrent_hash}
try:
self._client.auth_log_in()
response = self._client._session.get(url, params=params, timeout=10)
# Re-authenticate and retry once on 403
if response.status_code == _HTTP_STATUS_FORBIDDEN:
logger.debug(
"qBittorrent returned 403 for properties; re-authenticating and retrying"
)
self._client.auth_log_in()
response = self._client._session.get(url, params=params, timeout=10)
if response.status_code == _HTTP_STATUS_FORBIDDEN:
return False, "qBittorrent authentication failed (HTTP 403)"
# qBittorrent returns 404/409-ish responses depending on version when missing.
if response.status_code == _HTTP_STATUS_NOT_FOUND:
return False, None
response.raise_for_status()
except requests.exceptions.HTTPError as e:
status = getattr(getattr(e, "response", None), "status_code", None)
if status == _HTTP_STATUS_NOT_FOUND:
return False, None
if status:
return False, f"qBittorrent API request failed (HTTP {status})"
return False, "qBittorrent API request failed"
except requests.exceptions.ConnectionError:
return False, f"Cannot connect to qBittorrent at {self._base_url}"
except requests.exceptions.Timeout:
return False, f"qBittorrent request timed out at {self._base_url}"
except requests.exceptions.InvalidSchema:
return (
False,
"qBittorrent URL is invalid (missing http:// or https://). "
f"Configured: {self._base_url}",
)
except _QBITTORRENT_CLIENT_ERRORS as e:
return False, f"qBittorrent API error: {type(e).__name__}: {e}"
else:
return True, None
protocol = "torrent"
name = "qbittorrent"
@@ -211,7 +218,6 @@ class QBittorrentClient(DownloadClient):
username = config_text(config.get("QBITTORRENT_USERNAME", ""))
password = config_text(config.get("QBITTORRENT_PASSWORD", ""))
self._api_key = config_text(config.get("QBITTORRENT_API_KEY", ""))
# qbittorrent-api accepts either a full URL or host:port; prefer the normalized URL
# for consistency.
@@ -219,43 +225,43 @@ class QBittorrentClient(DownloadClient):
host=self._base_url,
username=username,
password=password,
api_key=self._api_key or None,
VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(self._base_url),
)
self._category = config_text(config.get("QBITTORRENT_CATEGORY", "books"))
self._download_dir = config_text(config.get("QBITTORRENT_DOWNLOAD_DIR", ""))
self._tags = _normalize_tags(config.get("QBITTORRENT_TAG", []))
# 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:
"""Whether a 403 is worth retrying; a bearer token cannot be refreshed like a session."""
return not self._api_key
def _ensure_authenticated(self) -> None:
"""Authenticate the underlying HTTP session before it is used directly.
API keys (qBittorrent 5.2.0+) are sent as a bearer header on every request and
have no login endpoint, so there is no session to establish up front.
"""
if self._api_key:
return
self._client.auth_log_in()
def _request_torrent_info_records(
self, params: dict[str, str]
def _get_torrents_info(
self, torrent_hash: str | None = None
) -> tuple[list[SimpleNamespace], str | None]:
"""Request torrent info records from qBittorrent."""
"""Get torrent info using GET.
Behaviors:
- Retry once on HTTP 403 by re-authenticating.
- Keep "API/auth/connect" errors distinct from "torrent missing".
- If a hash-specific query returns empty, fall back to listing by category
and matching locally.
Returns:
(torrents, error_message)
"""
url = f"{self._base_url}/api/v2/torrents/info"
try:
self._ensure_authenticated()
response = self._client._session.get(url, params=params, timeout=10)
if response.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
def do_request(params: dict[str, str]) -> requests.Response:
# Ensure session is authenticated before using it directly
self._client.auth_log_in()
return self._client._session.get(url, params=params, timeout=10)
def parse_response(
response: requests.Response,
*,
request_params: dict[str, str],
) -> tuple[list[SimpleNamespace], str | None]:
if response.status_code == _HTTP_STATUS_FORBIDDEN:
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
self._ensure_authenticated()
response = self._client._session.get(url, params=params, timeout=10)
self._client.auth_log_in()
response = self._client._session.get(url, params=request_params, timeout=10)
if response.status_code == _HTTP_STATUS_FORBIDDEN:
logger.warning("qBittorrent authentication failed (HTTP 403)")
@@ -264,6 +270,41 @@ class QBittorrentClient(DownloadClient):
response.raise_for_status()
torrents = response.json()
return [SimpleNamespace(**t) for t in torrents], None
try:
primary_params: dict[str, str] = {}
if torrent_hash:
primary_params["hashes"] = torrent_hash
response = do_request(primary_params)
torrents, error = parse_response(response, request_params=primary_params)
if error:
return [], error
if torrent_hash and not torrents:
# Fallback 1: list by configured category
category_params: dict[str, str] = {}
if self._category:
category_params["category"] = self._category
category_response = do_request(category_params)
category_torrents, category_error = parse_response(
category_response, request_params=category_params
)
if category_error:
return [], category_error
if category_torrents:
return category_torrents, None
# Fallback 2: list everything (handles per-task categories like audiobooks)
all_response = do_request({})
all_torrents, all_error = parse_response(all_response, request_params={})
if all_error:
return [], all_error
return all_torrents, None
except requests.exceptions.HTTPError as e:
status = getattr(getattr(e, "response", None), "status_code", None)
if status:
@@ -288,141 +329,8 @@ class QBittorrentClient(DownloadClient):
except _QBITTORRENT_CLIENT_ERRORS as e:
logger.debug("Failed to get torrents info: %s", e)
return [], f"qBittorrent API error: {type(e).__name__}: {e}"
def _get_torrent_info(self, download_id: str) -> tuple[SimpleNamespace | None, str | None]:
"""Get one torrent by its current qBittorrent hash."""
torrents, error = self._request_torrent_info_records({"hashes": download_id})
if error or not torrents:
return None, error
return (
next(
(
torrent
for torrent in torrents
if isinstance(getattr(torrent, "hash", None), str)
and _hashes_match(torrent.hash, download_id)
),
None,
),
None,
)
def _list_torrents_by_category(
self, category: str | None
) -> tuple[list[SimpleNamespace], str | None]:
"""List torrent records in a category, or all records when unset."""
params = {"category": category} if category else {}
return self._request_torrent_info_records(params)
def _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.
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):
torrents, error = self._list_torrents_by_category(candidate)
if error:
return None, error
torrent = next(
(item for item in torrents if _torrent_matches_download_id(item, download_id)),
None,
)
if torrent:
self._remember_primary_hash(download_id, torrent)
return torrent, None
torrents, error = self._list_torrents_by_category(None)
if error:
return None, error
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."""
torrents, error = self._list_torrents_by_category(category)
if error:
logger.debug("Could not snapshot qBittorrent torrents: %s", error)
return None
return {str(torrent.hash).lower() for torrent in torrents if getattr(torrent, "hash", None)}
def _discover_added_torrent_hash(
self,
name: str,
category: str | None,
known_hashes: set[str] | None,
) -> str | None:
"""Recover the hash of a torrent that was added without a known info_hash.
A `known_hashes` of None means the pre-add snapshot failed, so only a
torrent matching the requested rename can identify the new arrival.
"""
for _ in range(20):
torrents, error = self._list_torrents_by_category(category)
if error:
logger.debug("qBittorrent hash discovery: %s", error)
else:
new_torrents = [
torrent
for torrent in torrents
if getattr(torrent, "hash", None)
and (known_hashes is None or str(torrent.hash).lower() not in known_hashes)
]
for torrent in new_torrents:
if getattr(torrent, "name", None) == name:
return str(torrent.hash).lower()
if known_hashes is not None and len(new_torrents) == 1:
return str(new_torrents[0].hash).lower()
time.sleep(0.5)
return None
return torrents, None
@staticmethod
def is_configured() -> bool:
@@ -434,7 +342,7 @@ class QBittorrentClient(DownloadClient):
def test_connection(self) -> tuple[bool, str]:
"""Test connection to qBittorrent."""
try:
self._ensure_authenticated()
self._client.auth_log_in()
api_version = self._client.app.web_api_version
except _QBITTORRENT_CLIENT_ERRORS as e:
return False, f"Connection failed: {e!s}"
@@ -491,10 +399,6 @@ class QBittorrentClient(DownloadClient):
expected_hash = torrent_info.info_hash
torrent_data = torrent_info.torrent_data
known_hashes: set[str] | None = None
if not expected_hash:
known_hashes = self._list_category_hashes(category)
# Per-torrent seeding limits from indexer
seeding_time_limit_value = kwargs.get("seeding_time_limit")
seeding_time_limit = coerce_optional_int(seeding_time_limit_value)
@@ -529,47 +433,33 @@ class QBittorrentClient(DownloadClient):
result_text = _normalize_add_result(result)
logger.debug("qBittorrent add result: %s", result_text)
if not expected_hash:
_raise_runtime_error("Could not determine torrent hash from URL")
if _is_explicit_add_failure(result):
_raise_runtime_error(f"Failed to add torrent: {result_text}")
if not expected_hash:
# qBittorrent fetches .torrent URLs itself, so the add can succeed
# even when no hash could be extracted up front. Recover it by
# watching for the new torrent to appear.
expected_hash = self._discover_added_torrent_hash(name, category, known_hashes)
if not expected_hash:
message = "Could not determine torrent hash from URL"
if torrent_info.fetch_error:
message = f"{message} (torrent file fetch failed: {torrent_info.fetch_error})"
_raise_runtime_error(message)
# 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)
# Some qBittorrent-compatible clients return HTTP 200 with an empty body
# instead of qBittorrent's literal "Ok." response. Prefer verifying that
# the torrent becomes visible over trusting the response body alone.
for _ in range(10):
loaded, error = self._is_torrent_loaded(expected_hash)
if error:
logger.debug("qBittorrent add_download: %s", error)
elif torrent and getattr(torrent, "state", None) not in _METADATA_DOWNLOAD_STATES:
torrent_hash = getattr(torrent, "hash", None)
if isinstance(torrent_hash, str) and torrent_hash:
logger.info("Added torrent: %s", torrent_hash)
return torrent_hash.lower()
time.sleep(_METADATA_WAIT_INTERVAL_SECONDS)
if loaded:
logger.info("Added torrent: %s", expected_hash)
return expected_hash.lower()
time.sleep(0.5)
logger.info(
"Added torrent %s; metadata still pending after %.0fs, tracking it by info hash",
expected_hash,
_METADATA_WAIT_POLLS * _METADATA_WAIT_INTERVAL_SECONDS,
logger.warning(
"Torrent add was not confirmed within the visibility grace period (response=%s), returning expected hash",
result_text,
)
except _QBITTORRENT_CLIENT_ERRORS:
logger.exception("qBittorrent add failed")
raise
else:
return expected_hash.lower()
return expected_hash
def get_status(self, download_id: str) -> DownloadStatus:
"""Get torrent status by hash.
@@ -582,9 +472,19 @@ class QBittorrentClient(DownloadClient):
"""
try:
torrent, error = self._resolve_torrent(download_id)
torrents, error = self._get_torrents_info(download_id)
if error:
return DownloadStatus.error(error)
torrent = next(
(
t
for t in torrents
if isinstance(getattr(t, "hash", None), str)
and _hashes_match(t.hash, download_id)
),
None,
)
if not torrent:
return DownloadStatus.error("Torrent not found in qBittorrent")
@@ -666,9 +566,7 @@ class QBittorrentClient(DownloadClient):
"""
try:
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)
self._client.torrents_delete(torrent_hashes=download_id, delete_files=delete_files)
logger.info(
"Removed torrent from qBittorrent: %s%s",
download_id,
@@ -680,26 +578,6 @@ class QBittorrentClient(DownloadClient):
else:
return True
def set_category(self, download_id: str, category: str) -> bool:
"""Assign a category to a torrent in qBittorrent."""
try:
try:
self._client.torrents_create_category(name=category)
except _QBITTORRENT_CLIENT_ERRORS as e:
if "Conflict" not in type(e).__name__ and "409" not in str(e):
logger.debug("Could not create category '%s': %s", category, e)
self._client.torrents_set_category(
torrent_hashes=self._current_hash(download_id),
category=category,
)
logger.info("Set qBittorrent category for %s to '%s'", download_id, category)
except _QBITTORRENT_CLIENT_ERRORS as e:
self._log_error("set_category", e)
return False
else:
return True
def get_download_path(self, download_id: str) -> str | None:
"""Get the path where torrent files are located.
@@ -712,10 +590,20 @@ class QBittorrentClient(DownloadClient):
- join `save_path` with the torrent's top-level directory
"""
try:
torrent, error = self._resolve_torrent(download_id)
torrents, error = self._get_torrents_info(download_id)
if error:
logger.debug("qBittorrent get_download_path: %s", error)
return None
torrent = next(
(
t
for t in torrents
if isinstance(getattr(t, "hash", None), str)
and _hashes_match(t.hash, download_id)
),
None,
)
if not torrent:
return None
@@ -741,18 +629,16 @@ class QBittorrentClient(DownloadClient):
download_id = getattr(torrent, "hash", "")
if isinstance(download_id, str) and download_id:
derived = self._derive_download_path_from_files(download_id)
if derived and not isinstance(derived, _UnsafeQBittorrentPath):
if derived:
return derived
# Legacy fallback: save_path + name (for older clients/emulators)
return _build_qbittorrent_child_path(
return self._build_path(
getattr(torrent, "save_path", ""),
getattr(torrent, "name", ""),
)
def _derive_download_path_from_files(
self, download_id: str
) -> str | _UnsafeQBittorrentPath | None:
def _derive_download_path_from_files(self, download_id: str) -> str | None:
"""Derive completed download path using `/torrents/properties` + `/torrents/files`.
This mirrors how common automation apps derive the path when
@@ -761,11 +647,11 @@ class QBittorrentClient(DownloadClient):
import os
def get_with_auth(url: str, params: dict[str, str]) -> requests.Response:
self._ensure_authenticated()
self._client.auth_log_in()
resp = self._client._session.get(url, params=params, timeout=10)
if resp.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
if resp.status_code == _HTTP_STATUS_FORBIDDEN:
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
self._ensure_authenticated()
self._client.auth_log_in()
resp = self._client._session.get(url, params=params, timeout=10)
return resp
@@ -799,12 +685,9 @@ class QBittorrentClient(DownloadClient):
first_name_norm = first_name.replace("\\", "/")
top_level = first_name_norm.split("/", 1)[0]
if not top_level:
return _UNSAFE_QBITTORRENT_PATH
return None
derived = _build_qbittorrent_child_path(save_path, top_level)
if derived is None:
return _UNSAFE_QBITTORRENT_PATH
return os.path.normpath(derived)
return os.path.normpath(str(Path(save_path) / top_level))
except _QBITTORRENT_CLIENT_ERRORS as e:
logger.debug(
"qBittorrent could not derive path from files: %s: %s",
@@ -813,33 +696,6 @@ 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:
@@ -849,9 +705,25 @@ class QBittorrentClient(DownloadClient):
if not torrent_info.info_hash:
return None
existing = self._await_existing_torrent(torrent_info.info_hash, category)
torrents, error = self._get_torrents_info(torrent_info.info_hash)
if error:
logger.debug("qBittorrent find_existing: %s", error)
return None
torrent = next(
(
t
for t in torrents
if isinstance(getattr(t, "hash", None), str)
and _hashes_match(t.hash, torrent_info.info_hash)
),
None,
)
if torrent and isinstance(getattr(torrent, "hash", None), str):
torrent_hash = torrent.hash
return (torrent_hash.lower(), self.get_status(torrent_hash.lower()))
except _QBITTORRENT_CLIENT_ERRORS as e:
logger.debug("Error checking for existing torrent: %s", e)
return None
else:
return existing
return None
-545
View File
@@ -1,545 +0,0 @@
"""Real-Debrid debrid service client for Shelfmark.
Routes magnet links through the Real-Debrid REST API (v1.0) to download
torrent content via Real-Debrid's CDN infrastructure.
"""
from __future__ import annotations
import shutil
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, ClassVar, NoReturn
import requests
from shelfmark.config.env import TMP_DIR
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.download.clients import (
DownloadClient,
DownloadState,
DownloadStatus,
register_client,
)
from shelfmark.download.clients._coercion import config_text
from shelfmark.download.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.real-debrid.com/rest/1.0"
_REALDEBRID_CLIENT_ERRORS = (
AttributeError,
OSError,
requests.exceptions.RequestException,
RuntimeError,
TypeError,
ValueError,
)
# Real-Debrid torrent status values.
_STATUS_DOWNLOADING = frozenset(
{
"magnet_conversion",
"waiting_files_selection",
"downloading",
"compressing",
"uploading",
}
)
_STATUS_READY = "downloaded"
_STATUS_ERROR = frozenset({"error", "virus", "dead"})
# Timeouts for API calls.
_API_TIMEOUT = 30
_STATUS_TIMEOUT = 15
# File extensions recognised as book or audiobook content.
_BOOK_EXTENSIONS = (
".aac",
".azw",
".azw3",
".cbr",
".cbz",
".djvu",
".doc",
".docx",
".epub",
".fb2",
".flac",
".lit",
".m4a",
".m4b",
".mobi",
".mp3",
".mp4",
".ogg",
".opus",
".pdf",
".rtf",
".txt",
".wma",
)
def _raise_runtime_error(message: str) -> NoReturn:
raise RuntimeError(message)
@dataclass
class _DownloadState:
"""Internal mutable state for an in-progress Real-Debrid download."""
torrent_id: str
name: str
target_dir: Path
phase: str = "uploading"
error_message: str | None = None
progress: float = 0.0
download_thread: threading.Thread | None = None
lock: threading.Lock = field(default_factory=threading.Lock)
@register_client("torrent")
class RealDebridClient(DownloadClient):
"""Real-Debrid debrid service client.
Downloads torrent content by uploading magnet links to Real-Debrid,
selecting all files for download on their servers, then unrestricting
and fetching the resulting files via direct HTTP download from
Real-Debrid's CDN.
API documentation: https://api.real-debrid.com/
"""
protocol = "torrent"
name = "realdebrid"
_downloads: ClassVar[dict[str, _DownloadState]] = {}
_downloads_lock = threading.Lock()
def __init__(self) -> None:
self._api_key = config_text(config.get("REALDEBRID_API_KEY", ""))
def _auth_headers(self) -> dict[str, str]:
"""Return Authorization header dict for API requests."""
return {"Authorization": f"Bearer {self._api_key}"}
# ------------------------------------------------------------------
# DownloadClient interface
# ------------------------------------------------------------------
@staticmethod
def is_configured() -> bool:
"""Return True when Real-Debrid is selected and an API key exists."""
client = config_text(config.get("PROWLARR_TORRENT_CLIENT", ""))
api_key = config_text(config.get("REALDEBRID_API_KEY", ""))
return client == "realdebrid" and bool(api_key)
def test_connection(self) -> tuple[bool, str]:
"""Validate the API key and check Premium subscription status."""
if not self._api_key:
return False, "Real-Debrid API Key is required"
try:
url = f"{_API_BASE}/user"
resp = requests.get(
url,
headers=self._auth_headers(),
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(url),
)
resp.raise_for_status()
user = resp.json()
username = user.get("username", "Unknown")
account_type = user.get("type", "free")
if account_type != "premium":
return (
False,
f"Real-Debrid user '{username}' does not have "
f"a Premium subscription (type: {account_type})",
)
except _REALDEBRID_CLIENT_ERRORS as e:
return False, f"Connection failed: {e}"
else:
return True, f"Connected to Real-Debrid as '{username}' (Premium)"
def add_download(
self,
url: str,
name: str,
category: str | None = None,
expected_hash: str | None = None,
**kwargs: object,
) -> str:
"""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)
try:
upload = resolve_debrid_upload(url, expected_hash=expected_hash)
data = self._send_torrent(upload)
torrent_id = str(data.get("id", ""))
if not torrent_id:
msg = "No torrent ID returned from Real-Debrid"
_raise_runtime_error(msg)
# Select all files so Real-Debrid starts downloading the torrent
select_url = f"{_API_BASE}/torrents/selectFiles/{torrent_id}"
sel_resp = requests.post(
select_url,
headers=self._auth_headers(),
data={"files": "all"},
timeout=_API_TIMEOUT,
verify=get_ssl_verify(select_url),
)
sel_resp.raise_for_status()
target_dir = TMP_DIR / f"realdebrid_{torrent_id}"
target_dir.mkdir(parents=True, exist_ok=True)
state = _DownloadState(
torrent_id=torrent_id,
name=name,
target_dir=target_dir,
phase="waiting_rd",
)
with self._downloads_lock:
self._downloads[torrent_id] = state
logger.info(
"Added torrent to Real-Debrid: ID %s (%s)",
torrent_id,
name,
)
except Exception:
logger.exception("Failed to 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)
# Return cached terminal / in-flight states immediately.
with state.lock:
if state.phase == "error":
return DownloadStatus.error(
state.error_message or "Real-Debrid error",
)
if state.phase == "complete":
return DownloadStatus(
progress=100.0,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=str(state.target_dir),
)
if state.phase == "downloading_http":
return DownloadStatus(
progress=state.progress,
state=DownloadState.DOWNLOADING,
message="Downloading files via HTTP...",
complete=False,
file_path=None,
)
# Query Real-Debrid for torrent info.
try:
info_url = f"{_API_BASE}/torrents/info/{download_id}"
resp = requests.get(
info_url,
headers=self._auth_headers(),
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(info_url),
)
resp.raise_for_status()
info = resp.json()
return self._handle_torrent_info(info, state)
except Exception as e:
logger.exception(
"Error checking Real-Debrid status for %s",
download_id,
)
return DownloadStatus.error(str(e))
def remove(
self,
download_id: str,
*,
delete_files: bool = False,
) -> bool:
"""Delete the torrent from Real-Debrid and clean up local files."""
try:
url = f"{_API_BASE}/torrents/delete/{download_id}"
requests.delete(
url,
headers=self._auth_headers(),
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(url),
)
except _REALDEBRID_CLIENT_ERRORS as e:
logger.warning("Failed to delete torrent from Real-Debrid: %s", e)
with self._downloads_lock:
state = self._downloads.pop(download_id, None)
if state and state.target_dir.exists():
shutil.rmtree(state.target_dir, ignore_errors=True)
return True
def get_download_path(self, download_id: str) -> str | None:
"""Return the local directory containing downloaded files."""
with self._downloads_lock:
state = self._downloads.get(download_id)
if state and state.phase == "complete":
return str(state.target_dir)
target_dir = TMP_DIR / f"realdebrid_{download_id}"
if target_dir.exists():
return str(target_dir)
return None
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _ensure_state(self, download_id: str) -> _DownloadState:
"""Get or create download state for the given torrent ID."""
with self._downloads_lock:
state = self._downloads.get(download_id)
if state:
return state
target_dir = TMP_DIR / f"realdebrid_{download_id}"
state = _DownloadState(
torrent_id=download_id,
name=f"Download {download_id}",
target_dir=target_dir,
phase="waiting_rd",
)
with self._downloads_lock:
self._downloads[download_id] = state
return state
def _handle_torrent_info(
self,
info: dict[str, Any],
state: _DownloadState,
) -> DownloadStatus:
"""Map Real-Debrid torrent info to a DownloadStatus."""
status = info.get("status", "")
if status in _STATUS_DOWNLOADING:
progress = float(info.get("progress", 0.0))
speed = int(info.get("speed", 0))
filename = info.get("filename", state.name)
return DownloadStatus(
progress=progress * 0.5,
state=DownloadState.DOWNLOADING,
message=f"Real-Debrid downloading torrent ({filename})",
complete=False,
file_path=None,
download_speed=speed,
)
if status == _STATUS_READY:
links = info.get("links", [])
files = info.get("files", [])
self._maybe_start_download_thread(state, links, files)
return DownloadStatus(
progress=50.0,
state=DownloadState.DOWNLOADING,
message="Real-Debrid ready, retrieving files...",
complete=False,
file_path=None,
)
# Terminal error from Real-Debrid.
error_txt = f"Real-Debrid status error: {status}"
with state.lock:
state.phase = "error"
state.error_message = error_txt
return DownloadStatus.error(error_txt)
def _maybe_start_download_thread(
self,
state: _DownloadState,
links: list[str],
files: list[dict[str, Any]],
) -> None:
"""Spawn a background thread to unrestrict and download files."""
with state.lock:
already_running = state.phase in (
"unrestricting",
"downloading_http",
"complete",
)
thread_alive = state.download_thread is not None and state.download_thread.is_alive()
if already_running or thread_alive:
return
state.phase = "unrestricting"
t = threading.Thread(
target=self._process_and_download,
args=(state, links, files),
daemon=True,
)
state.download_thread = t
t.start()
# ------------------------------------------------------------------
# File download pipeline
# ------------------------------------------------------------------
def _process_and_download(
self,
state: _DownloadState,
links: list[str],
files: list[dict[str, Any]],
) -> None:
"""Unrestrict links and download files via HTTP.
Runs in a background thread spawned by ``_maybe_start_download_thread``.
"""
try:
if not links:
msg = "No download links returned by Real-Debrid"
_raise_runtime_error(msg)
# Match selected files with links
selected_files = [f for f in files if f.get("selected") == 1]
# Filter relevant ebook / audiobook files
relevant_indices: list[int] = []
for i, f_info in enumerate(selected_files):
path_str = f_info.get("path", "").lower()
if path_str.endswith(_BOOK_EXTENSIONS):
relevant_indices.append(i)
if not relevant_indices:
relevant_indices = list(range(len(links)))
with state.lock:
state.phase = "downloading_http"
total = len(relevant_indices)
for idx, rel_idx in enumerate(relevant_indices):
if rel_idx >= len(links):
continue
link = links[rel_idx]
# Unrestrict the Real-Debrid link to get direct CDN download URL
unrestrict_url = f"{_API_BASE}/unrestrict/link"
unl_resp = requests.post(
unrestrict_url,
headers=self._auth_headers(),
data={"link": link},
timeout=_API_TIMEOUT,
verify=get_ssl_verify(unrestrict_url),
)
unl_resp.raise_for_status()
unl_data = unl_resp.json()
direct_url = unl_data.get("download")
filename = unl_data.get("filename")
if not direct_url:
msg = f"Failed to unrestrict Real-Debrid link: {link}"
_raise_runtime_error(msg)
# Determine relative file path
if rel_idx < len(selected_files):
rel_path_str = selected_files[rel_idx].get("path", "").lstrip("/")
rel_path = Path(rel_path_str)
else:
rel_path = Path(filename or f"file_{idx + 1}")
dest = state.target_dir / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
logger.info(
"Downloading Real-Debrid file %d/%d: %s",
idx + 1,
total,
rel_path,
)
buf = download_url(
direct_url,
referer="https://real-debrid.com/",
)
if not buf:
msg = f"Failed to download from {direct_url}"
_raise_runtime_error(msg)
with dest.open("wb") as fh:
fh.write(buf.getvalue())
with state.lock:
state.progress = 50.0 + (idx + 1) / total * 50.0
with state.lock:
state.phase = "complete"
state.progress = 100.0
logger.info(
"Real-Debrid download complete for ID %s at %s",
state.torrent_id,
state.target_dir,
)
except Exception:
logger.exception(
"Error in Real-Debrid download for ID %s",
state.torrent_id,
)
with state.lock:
state.phase = "error"
state.error_message = str(
state.error_message or "Download failed",
)
+7 -102
View File
@@ -4,19 +4,13 @@ Uses xmlrpc to communicate with rTorrent's RPC interface.
"""
import ssl
import time
import xmlrpc.client as stdlib_xmlrpc_client
from typing import Any, NoReturn, Protocol, cast
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 (
is_audiobook as check_audiobook,
)
from shelfmark.core.utils import get_hardened_xmlrpc_client
from shelfmark.download.clients import (
DownloadClient,
DownloadStatus,
@@ -52,13 +46,7 @@ class _RTorrentLoadProtocol(Protocol):
def start(self, target: str, url: str, commands: str) -> object: ...
class _RTorrentCustom1Protocol(Protocol):
def set(self, download_id: str, value: str) -> object: ...
class _RTorrentDownloadProtocol(Protocol):
custom1: _RTorrentCustom1Protocol
def multicall2(self, *args: object) -> list[list[Any]]: ...
def delete_tied(self, download_id: str) -> object: ...
@@ -127,7 +115,6 @@ class RTorrentClient(DownloadClient):
self._rpc = _create_rtorrent_server_proxy(self._base_url)
self._download_dir = config_text(config.get("RTORRENT_DOWNLOAD_DIR", ""))
self._label = config_text(config.get("RTORRENT_LABEL", ""))
self._audiobook_label = config_text(config.get("RTORRENT_AUDIOBOOK_LABEL", ""))
@staticmethod
def is_configured() -> bool:
@@ -172,18 +159,9 @@ class RTorrentClient(DownloadClient):
try:
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
known_hashes: set[str] | None = None
if not (torrent_info.info_hash or expected_hash):
known_hashes = self._list_torrent_hashes()
commands = []
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
)
label = category or default_label
label = category or self._label
if label:
logger.debug("Setting rTorrent label: %s", label)
commands.append(f"d.custom1.set={label}")
@@ -213,15 +191,7 @@ class RTorrentClient(DownloadClient):
torrent_hash = torrent_info.info_hash or expected_hash
if not torrent_hash:
# rTorrent fetches .torrent URLs itself, so the add can succeed
# even when no hash could be extracted up front. Recover it by
# watching for the new download to appear.
torrent_hash = self._discover_added_torrent_hash(name, label, known_hashes)
if not torrent_hash:
message = "Could not determine torrent hash from URL"
if torrent_info.fetch_error:
message = f"{message} (torrent file fetch failed: {torrent_info.fetch_error})"
_raise_runtime_error(message)
_raise_runtime_error("Could not determine torrent hash from URL")
logger.debug("Added torrent to rTorrent: %s", torrent_hash)
@@ -344,14 +314,12 @@ class RTorrentClient(DownloadClient):
"""
try:
# rtorrent is somehow case sensitive and requires uppercase hashes for look
torrent_hash = download_id.upper()
if delete_files:
self._rpc.d.delete_tied(torrent_hash)
self._rpc.d.erase(torrent_hash)
self._rpc.d.delete_tied(download_id)
self._rpc.d.erase(download_id)
else:
self._rpc.d.stop(torrent_hash)
self._rpc.d.erase(torrent_hash)
self._rpc.d.stop(download_id)
self._rpc.d.erase(download_id)
logger.info(
"Removed torrent from rTorrent: %s%s",
@@ -365,19 +333,6 @@ class RTorrentClient(DownloadClient):
else:
return True
def set_category(self, download_id: str, category: str) -> bool:
"""Assign a label to a torrent using rTorrent's custom1 field."""
try:
# rtorrent is somehow case sensitive and requires uppercase hashes for look
self._rpc.d.custom1.set(download_id.upper(), category)
logger.info("Set rTorrent label for %s to '%s'", download_id, category)
except _RTORRENT_CLIENT_ERRORS as e:
error_type = type(e).__name__
logger.exception("rTorrent set_category failed (%s)", error_type)
return False
else:
return True
def get_download_path(self, download_id: str) -> str | None:
"""Get the path where torrent files are located.
@@ -427,56 +382,6 @@ class RTorrentClient(DownloadClient):
except _RTORRENT_CLIENT_ERRORS:
return "/downloads"
def _list_torrent_hashes(self) -> set[str] | None:
"""Snapshot the hashes rTorrent currently reports."""
try:
all_torrents = self._rpc.d.multicall2("", "", "d.hash=")
except _RTORRENT_CLIENT_ERRORS as e:
logger.debug("Could not snapshot rTorrent downloads: %s", e)
return None
return {str(row[0]).lower() for row in all_torrents if row and row[0]}
def _discover_added_torrent_hash(
self,
name: str,
label: str,
known_hashes: set[str] | None,
) -> str | None:
"""Recover the hash of a torrent that was added without a known info_hash.
rTorrent fetches .torrent URLs itself, so the add can succeed even when
no hash could be extracted up front. A `known_hashes` of None means the
pre-add snapshot failed, so only an exact name match can identify the
new arrival.
"""
for _ in range(20):
try:
all_torrents = self._rpc.d.multicall2("", "", "d.hash=", "d.name=", "d.custom1=")
except _RTORRENT_CLIENT_ERRORS as e:
logger.debug("rTorrent hash discovery: %s", e)
else:
new_torrents = [
row
for row in all_torrents
if row
and row[0]
and (known_hashes is None or str(row[0]).lower() not in known_hashes)
]
# The label set at add time distinguishes concurrent arrivals,
# but rTorrent may not have applied it yet, so it only ever
# narrows a non-empty candidate list.
if label:
labeled = [row for row in new_torrents if len(row) > 2 and row[2] == label]
if labeled:
new_torrents = labeled
for row in new_torrents:
if len(row) > 1 and row[1] == name:
return str(row[0]).lower()
if known_hashes is not None and len(new_torrents) == 1:
return str(new_torrents[0][0]).lower()
time.sleep(0.5)
return None
def _get_torrent_path(self, download_id: str) -> str | None:
"""Get the file path of a torrent by hash.
+6 -50
View File
@@ -33,24 +33,6 @@ _SABNZBD_CLIENT_ERRORS = (
_SabnzbdRequestParam = str | int | float | bool
def _url_origin(value: str) -> tuple[str, str, int] | None:
try:
parsed = urlparse(value)
port = parsed.port
except ValueError:
return None
scheme = parsed.scheme.lower()
hostname = (parsed.hostname or "").lower()
if scheme not in {"http", "https"} or not hostname:
return None
if port is None:
port = 443 if scheme == "https" else 80
return scheme, hostname, port
def _parse_eta(eta_str: str) -> int | None:
"""Parse SABnzbd ETA string (format: 'H:MM:SS') to seconds."""
if not eta_str or eta_str == "0:00:00":
@@ -238,27 +220,6 @@ class SABnzbdClient(DownloadClient):
response.raise_for_status()
return response.content
def _can_prefetch_nzb_url(self, url: str) -> bool:
target_origin = _url_origin(url)
if target_origin is None:
return False
for key in ("PROWLARR_URL", "NEWZNAB_URL"):
trusted_url = normalize_http_config_url(config.get(key, ""))
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:
# TODO(shelfmark): Move this source-specific Prowlarr auth handling into a source hook.
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
@@ -365,20 +326,15 @@ class SABnzbdClient(DownloadClient):
try:
logger.debug("Adding NZB to SABnzbd: %s", name)
if self._can_prefetch_nzb_url(url):
nzb_filename = self._build_nzb_filename(name, url)
nzb_content = self._fetch_nzb_content(url)
result = self._api_post_file(nzb_content, nzb_filename, name, resolved_category)
nzo_id = self._extract_nzo_id(result)
logger.info("Added NZB to SABnzbd: %s", nzo_id)
else:
logger.info("Skipping SABnzbd addfile prefetch for untrusted NZB URL")
nzo_id = ""
nzb_filename = self._build_nzb_filename(name, url)
nzb_content = self._fetch_nzb_content(url)
result = self._api_post_file(nzb_content, nzb_filename, name, resolved_category)
nzo_id = self._extract_nzo_id(result)
logger.info("Added NZB to SABnzbd: %s", nzo_id)
except _SABNZBD_CLIENT_ERRORS as e:
logger.warning("SABnzbd addfile failed, falling back to addurl: %s", e)
else:
if nzo_id:
return nzo_id
return nzo_id
try:
result = self._api_call(
+3 -102
View File
@@ -159,7 +159,6 @@ def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) -
raw_url = _resolve_string_setting(current_values, config.get, "QBITTORRENT_URL")
username = _resolve_string_setting(current_values, config.get, "QBITTORRENT_USERNAME")
password = _resolve_string_setting(current_values, config.get, "QBITTORRENT_PASSWORD")
api_key = _resolve_string_setting(current_values, config.get, "QBITTORRENT_API_KEY")
if not raw_url:
return {"success": False, "message": "qBittorrent URL is required"}
@@ -175,7 +174,6 @@ def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) -
host=url,
username=username,
password=password,
api_key=api_key or None,
VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(url),
)
client.auth_log_in()
@@ -183,18 +181,9 @@ def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) -
except ImportError:
return {"success": False, "message": "qbittorrent-api package not installed"}
except _QBITTORRENT_SETTINGS_ERRORS as e:
if isinstance(e, _QBittorrentLoginFailed):
# LoginFailed carries no message of its own, so name the rejected credential.
rejected = "API key" if api_key else "username or password"
return {"success": False, "message": f"qBittorrent rejected the {rejected}"}
return {"success": False, "message": f"Connection failed: {e!s}"}
else:
# Both credentials can be set at once, so name the one that actually authenticated.
used = " using the API key" if api_key else ""
return {
"success": True,
"message": f"Connected to qBittorrent (API v{api_version}){used}",
}
return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"}
def _test_transmission_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -531,40 +520,6 @@ def _test_sabnzbd_connection(current_values: dict[str, Any] | None = None) -> di
return {"success": True, "message": f"Connected to SABnzbd {version}"}
def _test_alldebrid_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Test the AllDebrid API connection using current form values."""
from shelfmark.core.config import config
from shelfmark.download.clients.alldebrid import AllDebridClient
current_values = current_values or {}
api_key = _resolve_string_setting(current_values, config.get, "ALLDEBRID_API_KEY")
if not api_key:
return {"success": False, "message": "AllDebrid API Key is required"}
client = AllDebridClient()
client._api_key = api_key
success, message = client.test_connection()
return {"success": success, "message": message}
def _test_realdebrid_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Test the Real-Debrid API connection using current form values."""
from shelfmark.core.config import config
from shelfmark.download.clients.realdebrid import RealDebridClient
current_values = current_values or {}
api_key = _resolve_string_setting(current_values, config.get, "REALDEBRID_API_KEY")
if not api_key:
return {"success": False, "message": "Real-Debrid API Key is required"}
client = RealDebridClient()
client._api_key = api_key
success, message = client.test_connection()
return {"success": success, "message": message}
# ==================== Download Clients Tab ====================
@@ -589,45 +544,13 @@ def prowlarr_clients_settings() -> list[SettingsField]:
description="Choose which torrent client to use",
options=[
{"value": "", "label": "None"},
{"value": "alldebrid", "label": "AllDebrid"},
{"value": "qbittorrent", "label": "qBittorrent"},
{"value": "realdebrid", "label": "Real-Debrid"},
{"value": "transmission", "label": "Transmission"},
{"value": "deluge", "label": "Deluge"},
{"value": "rtorrent", "label": "rTorrent"},
],
default="",
),
# --- AllDebrid Settings ---
PasswordField(
key="ALLDEBRID_API_KEY",
label="API Key",
description="AllDebrid API Key (apiv4) from your AllDebrid account settings",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "alldebrid"},
),
ActionButton(
key="test_alldebrid",
label="Test Connection",
description="Verify your AllDebrid configuration",
style="primary",
callback=_test_alldebrid_connection,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "alldebrid"},
),
# --- Real-Debrid Settings ---
PasswordField(
key="REALDEBRID_API_KEY",
label="API Key",
description="Real-Debrid API Key (Secret Token) from your Real-Debrid account settings",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "realdebrid"},
),
ActionButton(
key="test_realdebrid",
label="Test Connection",
description="Verify your Real-Debrid configuration",
style="primary",
callback=_test_realdebrid_connection,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "realdebrid"},
),
# --- qBittorrent Settings ---
TextField(
key="QBITTORRENT_URL",
@@ -649,12 +572,6 @@ def prowlarr_clients_settings() -> list[SettingsField]:
description="qBittorrent Web UI password",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
PasswordField(
key="QBITTORRENT_API_KEY",
label="API Key",
description="Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set.",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
ActionButton(
key="test_qbittorrent",
label="Test Connection",
@@ -831,18 +748,11 @@ def prowlarr_clients_settings() -> list[SettingsField]:
TextField(
key="RTORRENT_LABEL",
label="Book Label",
description="Label to assign to ebook downloads in rTorrent",
description="Label to assign to book downloads in rTorrent",
placeholder="cwabd",
default="cwabd",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
TextField(
key="RTORRENT_AUDIOBOOK_LABEL",
label="Audiobook Label",
description="Label to assign to audiobook downloads in rTorrent (falls back to Book Label if not set)",
placeholder="audiobooks",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
),
TextField(
key="RTORRENT_DOWNLOAD_DIR",
label="Download Directory",
@@ -854,23 +764,14 @@ def prowlarr_clients_settings() -> list[SettingsField]:
SelectField(
key="PROWLARR_TORRENT_ACTION",
label="Torrent Completion Action",
description="Choose whether to keep, remove, or move the torrent to another category or label after import",
description="Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding",
options=[
{"value": "keep", "label": "Keep"},
{"value": "remove", "label": "Remove"},
{"value": "change_category", "label": "Change Category"},
],
default="keep",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
),
TextField(
key="PROWLARR_TORRENT_POST_IMPORT_CATEGORY",
label="Post-Import Category",
description="Category or label to assign after a successful import",
placeholder="imported",
default="",
show_when={"field": "PROWLARR_TORRENT_ACTION", "value": "change_category"},
),
# --- Usenet Client Selection ---
HeadingField(
key="usenet_heading",
+31 -241
View File
@@ -5,24 +5,19 @@ from __future__ import annotations
import base64
import hashlib
import re
import time
from binascii import Error as BinasciiError
from dataclasses import dataclass
from threading import Lock
from urllib.parse import ParseResult, parse_qs, urljoin, urlparse
from urllib.parse import parse_qs, urljoin, urlparse
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
from shelfmark.download.postprocess.packs import PackFile
logger = setup_logger(__name__)
_MAGNET_RESPONSE_MAX_BYTES = 2000
_TORRENT_FETCH_MAX_REDIRECTS = 5
_BASE32_BTMH_TAG_BYTES = 34
_BTIH_INFO_BYTE_HEX = 0x20
_BTIH_PREFIX_BYTE = 0x12
@@ -37,16 +32,6 @@ _TORRENT_FETCH_ERRORS = (
ValueError,
)
_TORRENT_PARSE_ERRORS = (IndexError, KeyError, TypeError, ValueError)
_TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS = ("PROWLARR_URL", "NEWZNAB_URL")
# Successful torrent fetches are reused for a short window so one add attempt
# hits the download link only once. Tracker download links (e.g. private
# trackers behind Prowlarr's proxy) can be slow, rate-limited, or single-use,
# and both find_existing() and add_download() resolve the same URL (#1111).
_TORRENT_FETCH_CACHE_TTL_SECONDS = 120.0
_TORRENT_FETCH_CACHE_MAX_ENTRIES = 8
_torrent_fetch_cache_lock = Lock()
_torrent_fetch_cache: dict[str, tuple[float, TorrentInfo]] = {}
type BencodeValue = dict[str | bytes, BencodeValue] | list[BencodeValue] | int | bytes | str
@@ -67,9 +52,6 @@ class TorrentInfo:
magnet_url: str | None = None
"""The actual magnet URL, if available."""
fetch_error: str | None = None
"""Why fetching the .torrent URL failed, or None if it succeeded/was skipped."""
def with_info_hash(self, info_hash: str | None) -> TorrentInfo:
"""Return a copy with the info_hash replaced when provided."""
if info_hash:
@@ -78,65 +60,10 @@ class TorrentInfo:
torrent_data=self.torrent_data,
is_magnet=self.is_magnet,
magnet_url=self.magnet_url,
fetch_error=self.fetch_error,
)
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 {url[:120]} ({reason})"
raise ValueError(msg)
def extract_torrent_info(
url: str,
*,
@@ -167,57 +94,6 @@ def extract_torrent_info(
if not fetch_torrent:
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
info = _get_cached_torrent_fetch(url)
if info is None:
info = _fetch_torrent_info(url)
if info.fetch_error is None:
_store_cached_torrent_fetch(url, info)
return info.with_info_hash(info.info_hash or expected_hash)
def _get_cached_torrent_fetch(url: str) -> TorrentInfo | None:
with _torrent_fetch_cache_lock:
entry = _torrent_fetch_cache.get(url)
if entry is None:
return None
fetched_at, info = entry
if time.monotonic() - fetched_at > _TORRENT_FETCH_CACHE_TTL_SECONDS:
del _torrent_fetch_cache[url]
return None
logger.debug("Reusing recently fetched torrent data for: %s...", url[:80])
return info
def _store_cached_torrent_fetch(url: str, info: TorrentInfo) -> None:
with _torrent_fetch_cache_lock:
_torrent_fetch_cache[url] = (time.monotonic(), info)
while len(_torrent_fetch_cache) > _TORRENT_FETCH_CACHE_MAX_ENTRIES:
oldest_url = min(_torrent_fetch_cache, key=lambda key: _torrent_fetch_cache[key][0])
del _torrent_fetch_cache[oldest_url]
def clear_torrent_fetch_cache() -> None:
"""Drop all cached torrent fetches (used by tests)."""
with _torrent_fetch_cache_lock:
_torrent_fetch_cache.clear()
def _fetch_torrent_info(url: str) -> TorrentInfo:
"""Fetch a .torrent URL and parse out the info_hash and raw torrent data.
On failure, the returned TorrentInfo carries the reason in `fetch_error`
so callers can surface it instead of a generic hash error.
"""
# A release source can legitimately hand us a download URL on a different
# origin than the configured Prowlarr/Newznab endpoint (e.g. a direct
# tracker link, or Prowlarr reached through a separate proxy), and a trusted
# Prowlarr download URL commonly redirects to the indexer's own download
# link. We still need to fetch the .torrent to recover the info_hash when
# the source did not provide one, so the prefetch runs regardless of origin
# and follows cross-origin redirects. The Prowlarr API key, however, is
# re-evaluated per hop and only ever sent to a trusted origin so it can
# never leak to an arbitrary indexer/tracker host.
headers: dict[str, str] = {"Accept": "application/x-bittorrent"}
# TODO(shelfmark): Move this source-specific Prowlarr auth handling into a source hook.
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
@@ -233,47 +109,38 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
try:
logger.debug("Fetching torrent file from: %s...", url[:80])
# Redirects are followed manually: some indexers redirect download URLs
# to magnet links, and each hop must decide anew whether it may see the
# API key.
current_url = url
redirects_remaining = _TORRENT_FETCH_MAX_REDIRECTS
while True:
request_headers = dict(headers)
if not _is_trusted_torrent_fetch_url(current_url):
request_headers.pop("X-Api-Key", None)
# Use allow_redirects=False to handle magnet link redirects manually
# Some indexers redirect download URLs to magnet links
resp = requests.get(
url,
timeout=30,
allow_redirects=False,
headers=headers,
verify=get_ssl_verify(url),
)
resp = requests.get(
current_url,
timeout=30,
allow_redirects=False,
headers=request_headers,
verify=get_ssl_verify(current_url),
)
if resp.status_code not in (301, 302, 303, 307, 308):
break
redirect_url = resolve_url(current_url, resp.headers.get("Location", ""))
# Check if this is a redirect to a magnet link
if resp.status_code in (301, 302, 303, 307, 308):
redirect_url = resolve_url(url, resp.headers.get("Location", ""))
if redirect_url.startswith("magnet:"):
logger.debug("Download URL redirected to magnet link")
info_hash = extract_hash_from_magnet(redirect_url)
if not info_hash and expected_hash:
info_hash = expected_hash
return TorrentInfo(
info_hash=extract_hash_from_magnet(redirect_url),
info_hash=info_hash,
torrent_data=None,
is_magnet=True,
magnet_url=redirect_url,
)
if redirects_remaining <= 0:
logger.warning("Too many redirects fetching torrent file: %s...", url[:80])
return TorrentInfo(
info_hash=None,
torrent_data=None,
is_magnet=False,
fetch_error="too many redirects",
)
redirects_remaining -= 1
# Not a magnet redirect, follow it manually
logger.debug("Following redirect to: %s...", redirect_url[:80])
current_url = redirect_url
resp = requests.get(
redirect_url,
timeout=30,
headers=headers,
verify=get_ssl_verify(redirect_url),
)
resp.raise_for_status()
torrent_data = resp.content
@@ -284,52 +151,25 @@ def _fetch_torrent_info(url: str) -> TorrentInfo:
text_content = torrent_data.decode("utf-8", errors="ignore").strip()
if text_content.startswith("magnet:"):
logger.debug("Download URL returned magnet link as response body")
info_hash = extract_hash_from_magnet(text_content)
if not info_hash and expected_hash:
info_hash = expected_hash
return TorrentInfo(
info_hash=extract_hash_from_magnet(text_content),
info_hash=info_hash,
torrent_data=None,
is_magnet=True,
magnet_url=text_content,
)
info_hash = extract_info_hash_from_torrent(torrent_data)
info_hash = extract_info_hash_from_torrent(torrent_data) or expected_hash
if info_hash:
logger.debug("Extracted hash from torrent file: %s", info_hash)
else:
logger.warning("Could not extract hash from torrent file")
return TorrentInfo(info_hash=info_hash, torrent_data=torrent_data, is_magnet=False)
except _TORRENT_FETCH_ERRORS as e:
logger.warning("Could not fetch torrent file: %s", e)
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False, fetch_error=str(e))
def _is_trusted_torrent_fetch_url(url: str) -> bool:
parsed = urlparse(url)
origin = _url_origin(parsed)
if origin is None:
return False
for key in _TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS:
configured_url = str(config.get(key, "") or "").strip()
if not configured_url:
continue
configured_origin = _url_origin(urlparse(normalize_http_url(configured_url)))
if configured_origin == origin:
return True
return False
def _url_origin(parsed_url: ParseResult) -> tuple[str, str, int] | None:
scheme = parsed_url.scheme.lower()
if scheme not in {"http", "https"}:
return None
hostname = parsed_url.hostname
if not hostname:
return None
default_port = 443 if scheme == "https" else 80
return (scheme, hostname.lower(), parsed_url.port or default_port)
logger.debug("Could not fetch torrent file: %s", e)
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
def parse_transmission_url(url: str) -> tuple[str, str, int, str]:
@@ -434,56 +274,6 @@ 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:"):
+2 -30
View File
@@ -316,12 +316,8 @@ class TransmissionClient(DownloadClient):
state, message = status_map.get(status_value, ("downloading", "Downloading"))
progress = torrent.percent_done * 100
# Only mark complete when seeding or stopped (e.g. if seed limit/ratio is 0)
# and progress is complete. seed pending means files still being moved
complete = progress >= _SEEDING_PROGRESS_PERCENT and status_value in (
"seeding",
"stopped",
)
# Only mark complete when seeding - seed pending means files still being moved
complete = progress >= _SEEDING_PROGRESS_PERCENT and status_value == "seeding"
if complete:
message = "Complete"
@@ -390,30 +386,6 @@ class TransmissionClient(DownloadClient):
else:
return True
def _get_torrent_labels(self, download_id: str) -> list[str]:
"""Return a torrent's current labels, preserving their order."""
torrent = self._client.get_torrent(download_id)
raw_labels = getattr(torrent, "labels", None) or []
return [str(label) for label in raw_labels if str(label)]
def set_category(self, download_id: str, category: str) -> bool:
"""Add the post-import label to a torrent, keeping labels set elsewhere."""
try:
existing_labels = self._get_torrent_labels(download_id)
if category in existing_labels:
logger.debug(
"Transmission torrent %s already has label '%s'", download_id, category
)
return True
self._client.change_torrent(ids=download_id, labels=[*existing_labels, category])
logger.info("Added Transmission label '%s' to %s", category, download_id)
except _TRANSMISSION_CLIENT_ERRORS as e:
self._log_error("set_category", e)
return False
else:
return True
def get_download_path(self, download_id: str) -> str | None:
"""Get the path where torrent files are located.
-158
View File
@@ -1,158 +0,0 @@
"""RFC 8484 DNS wireformat encoding/decoding for DoH providers.
Providers split into two incompatible camps and the difference is not cosmetic:
* **JSON** (Cloudflare, Google) - ``?name=<host>&type=A`` returning a JSON body. A
convention, not a standard, and the only one Shelfmark used to speak.
* **Wireformat** (Quad9, OpenDNS) - RFC 8484 proper: a base64url-encoded DNS message
in ``?dns=``, answered with ``application/dns-message``. Quad9 additionally
*requires HTTP/2* per RFC 8484 section 5.2 and answers HTTP/1.1 with 505.
This module carries the codec only; the transport choice lives in the resolver.
Encoding a query is a handful of bytes, and parsing an answer needs message
compression support (RFC 1035 section 4.1.4) because answer names are almost always
pointers back into the question.
"""
from __future__ import annotations
import base64
import secrets
import struct
# Record types we resolve.
TYPE_A = 1
TYPE_AAAA = 28
_CLASS_IN = 1
_HEADER = struct.Struct(">HHHHHH")
_RR_FIXED = struct.Struct(">HHIH") # type, class, ttl, rdlength
_FLAG_RECURSION_DESIRED = 0x0100
_MAX_LABEL_JUMPS = 64 # cap pointer-following so a malicious answer cannot loop
_MAX_NAME_LENGTH = 255
class WireformatError(ValueError):
"""Raised when a DNS wireformat message cannot be parsed."""
def encode_query(hostname: str, record_type: int) -> bytes:
"""Build a DNS query message for ``hostname``.
The ID is zero because RFC 8484 section 4.1 requires it for cacheability, but the
caller may randomise it when not using a cache.
"""
if not hostname:
msg = "hostname must not be empty"
raise WireformatError(msg)
question = bytearray()
for label in hostname.rstrip(".").split("."):
encoded = label.encode("idna") if not label.isascii() else label.encode("ascii")
if not encoded or len(encoded) > 63:
msg = f"invalid DNS label in {hostname!r}"
raise WireformatError(msg)
question.append(len(encoded))
question.extend(encoded)
question.append(0)
question.extend(struct.pack(">HH", record_type, _CLASS_IN))
header = _HEADER.pack(0, _FLAG_RECURSION_DESIRED, 1, 0, 0, 0)
return header + bytes(question)
def encode_query_param(hostname: str, record_type: int) -> str:
"""Return the base64url ``dns=`` parameter value for a query (padding stripped)."""
return base64.urlsafe_b64encode(encode_query(hostname, record_type)).rstrip(b"=").decode()
def _read_name(message: bytes, offset: int) -> int:
"""Skip over a (possibly compressed) name, returning the offset after it."""
jumps = 0
length = 0
while True:
if offset >= len(message):
msg = "truncated DNS name"
raise WireformatError(msg)
label_len = message[offset]
if label_len == 0:
return offset + 1
if label_len & 0xC0 == 0xC0:
# A pointer ends this name; the rest of the record follows the 2 bytes.
if offset + 1 >= len(message):
msg = "truncated DNS name pointer"
raise WireformatError(msg)
return offset + 2
offset += 1 + label_len
length += 1 + label_len
jumps += 1
if jumps > _MAX_LABEL_JUMPS or length > _MAX_NAME_LENGTH:
msg = "malformed DNS name"
raise WireformatError(msg)
def decode_answer(message: bytes, record_type: int) -> list[str]:
"""Extract the IP addresses of ``record_type`` from a DNS response message.
Returns an empty list for a well-formed response that carries no matching record
(NXDOMAIN, or only CNAMEs), and raises WireformatError for a malformed one - the
caller treats those differently.
"""
if len(message) < _HEADER.size:
msg = "DNS response shorter than its header"
raise WireformatError(msg)
_id, _flags, qdcount, ancount, _ns, _ar = _HEADER.unpack_from(message, 0)
offset = _HEADER.size
for _ in range(qdcount):
offset = _read_name(message, offset)
offset += 4 # QTYPE + QCLASS
results: list[str] = []
for _ in range(ancount):
offset = _read_name(message, offset)
if offset + _RR_FIXED.size > len(message):
msg = "truncated resource record"
raise WireformatError(msg)
rtype, rclass, _ttl, rdlength = _RR_FIXED.unpack_from(message, offset)
offset += _RR_FIXED.size
rdata = message[offset : offset + rdlength]
if len(rdata) != rdlength:
msg = "truncated record data"
raise WireformatError(msg)
offset += rdlength
if rclass != _CLASS_IN or rtype != record_type:
continue
if rtype == TYPE_A and rdlength == 4:
results.append(".".join(str(b) for b in rdata))
elif rtype == TYPE_AAAA and rdlength == 16:
groups = struct.unpack(">8H", rdata)
results.append(_compress_ipv6(groups))
return results
def _compress_ipv6(groups: tuple[int, ...]) -> str:
"""Render an IPv6 address with the longest zero run collapsed to '::'."""
best_start = best_len = -1
run_start = -1
for i, group in enumerate([*list(groups), 1]): # sentinel closes a trailing run
if group == 0 and i < len(groups):
if run_start < 0:
run_start = i
elif run_start >= 0:
if i - run_start > best_len:
best_start, best_len = run_start, i - run_start
run_start = -1
parts = [format(g, "x") for g in groups]
if best_len > 1:
return ":".join(parts[:best_start]) + "::" + ":".join(parts[best_start + best_len :])
return ":".join(parts)
def random_query_id() -> int:
"""A random DNS message ID, for callers that do not want the RFC 8484 zero."""
return secrets.randbelow(0x10000)
+18 -169
View File
@@ -4,7 +4,6 @@ These utilities handle file collisions atomically, avoiding TOCTOU race conditio
when multiple workers may try to write to the same path simultaneously.
"""
import contextlib
import errno
import os
import shutil
@@ -105,57 +104,6 @@ _PUBLISH_VERIFY_RETRY_SECONDS = 0.25
_TEMPFILE_PREFIX = ".shelfmark."
_TEMPFILE_SUFFIX = ".tmp"
# Destinations that accept writes but reject unlink/rename, e.g. a Synology share
# with "Delete subfolders and files" unticked. Publishing a temp file into place
# removes a directory entry, so those paths must be written in place instead.
_DELETE_DENIED_DIRS: set[str] = set()
class _PublishDeniedError(Exception):
"""A fully-written temp file could not be renamed onto its final path."""
def _is_delete_denied_error(error: Exception) -> bool:
return isinstance(error, OSError) and error.errno in {errno.EACCES, errno.EPERM}
def mark_delete_denied(directory: Path) -> None:
"""Record that `directory` rejects deletes so later writes skip the temp file."""
key = str(directory)
if key in _DELETE_DENIED_DIRS:
return
_DELETE_DENIED_DIRS.add(key)
logger.warning(
"Destination %s rejects delete/rename; writing files in place instead of "
"publishing atomically. Grant delete permission to restore atomic writes.",
directory,
)
def clear_delete_denied(directory: Path) -> None:
"""Forget recorded denials for `directory` and anything beneath it.
Subdirectories get marked independently (an `organize` layout publishes into
per-author folders), so clearing only the exact key would leave a fixed
destination writing in place until restart.
"""
if not _DELETE_DENIED_DIRS:
return
key = str(directory)
prefix = f"{key}{os.sep}"
_DELETE_DENIED_DIRS.difference_update(
{marked for marked in _DELETE_DENIED_DIRS if marked == key or marked.startswith(prefix)}
)
def is_delete_denied(directory: Path) -> bool:
"""True if `directory` or one of its ancestors is known to reject deletes."""
if not _DELETE_DENIED_DIRS:
return False
if str(directory) in _DELETE_DENIED_DIRS:
return True
return any(str(parent) in _DELETE_DENIED_DIRS for parent in directory.parents)
def _verify_transfer_size(
dest: Path,
@@ -281,10 +229,6 @@ def _is_permission_error(e: Exception) -> bool:
return isinstance(e, PermissionError) or (isinstance(e, OSError) and e.errno == errno.EPERM)
def _should_fallback_to_content_copy(error: Exception) -> bool:
return _is_permission_error(error) or (isinstance(error, OSError) and error.errno == errno.EIO)
def _system_op(op: str, source: Path, dest: Path) -> None:
"""Execute system command (mv or cp) as final fallback."""
logger.warning("Attempting system %s as final fallback: %s -> %s", op, source, dest)
@@ -413,40 +357,10 @@ def _create_temp_path(dest_path: Path) -> Path:
return Path(temp_path)
def _discard_path(path: Path) -> None:
"""Best-effort unlink that tolerates destinations which reject deletes."""
try:
run_blocking_io(path.unlink, missing_ok=True)
except OSError as exc:
logger.warning("Could not remove %s: %s", path, exc)
def _copy_into_claimed(source_path: Path, dest_path: Path, expected_size: int) -> None:
"""Copy content straight into an already-claimed destination path.
Used when the destination rejects rename/unlink: there is no temp file to
publish, so the final name is written in place. This is not atomic - a
watcher can observe a partial file - but it is the only way to deliver on
such a share. `copyfile` (not `copy2`) because metadata copying needs chmod,
which those shares also tend to refuse.
"""
try:
run_blocking_io(shutil.copyfile, str(source_path), str(dest_path))
_verify_transfer_size(dest_path, expected_size, "copy")
except Exception:
with contextlib.suppress(OSError):
run_blocking_io(dest_path.unlink, missing_ok=True)
raise
def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
"""Publish a temp file to its final path without overwriting existing files.
Returns True on success, False if the destination already exists.
Raises `_PublishDeniedError` when the rename is refused for lack of delete
permission. The claimed destination is left in place so the caller can write
into it directly instead.
"""
claimed = _claim_destination(dest_path)
if not claimed:
@@ -456,19 +370,7 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
# Publish by renaming the fully-written temp file into place. This gives
# watchers an IN_MOVED_TO-style event on the final path instead of relying
# on hardlink support in the destination filesystem.
try:
run_blocking_io(os.replace, str(temp_path), str(dest_path))
except OSError as e:
if _is_delete_denied_error(e):
log_transfer_permission_context(
"publish_replace",
source=temp_path,
dest=dest_path,
error=e,
)
mark_delete_denied(dest_path.parent)
raise _PublishDeniedError(str(e)) from e
raise
run_blocking_io(os.replace, str(temp_path), str(dest_path))
# Best-effort nudge for watchers that only react to close-write on the
# final filename rather than rename/move events.
@@ -477,8 +379,6 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
run_blocking_io(os.close, fd)
except OSError:
pass
except _PublishDeniedError:
raise
except Exception as e:
if _is_permission_error(e):
log_transfer_permission_context(
@@ -487,23 +387,12 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
dest=dest_path,
error=e,
)
_discard_path(dest_path)
run_blocking_io(dest_path.unlink, missing_ok=True)
raise
else:
return True
def _move_via_copy(source_path: Path, dest_path: Path, max_attempts: int) -> Path:
"""Deliver a move as copy + source unlink.
For destinations that reject rename. The source lives in TMP_DIR (which we
own and can delete), so only the destination-side semantics change.
"""
final_path = atomic_copy(source_path, dest_path, max_attempts=max_attempts)
_discard_path(source_path)
return final_path
def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path:
"""Move a file with collision detection.
@@ -530,11 +419,6 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
ext = dest_path.suffix
parent = dest_path.parent
# rename() removes a directory entry, so a destination that refuses deletes
# cannot be moved into. Deliver it as copy + source unlink instead.
if is_delete_denied(parent):
return _move_via_copy(source_path, dest_path, max_attempts)
for attempt in range(max_attempts):
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
@@ -561,13 +445,6 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
run_blocking_io(try_path.unlink, missing_ok=True)
continue
except OSError as e:
if _is_delete_denied_error(e):
# Destination refuses the rename; fall back to copy + unlink source.
mark_delete_denied(parent)
if claimed:
_discard_path(try_path)
return _move_via_copy(source_path, dest_path, max_attempts)
# Cross-filesystem - copy to temp and publish atomically.
if e.errno != errno.EXDEV:
if claimed:
@@ -586,9 +463,9 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
try:
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
except (PermissionError, OSError) as copy_error:
if _should_fallback_to_content_copy(copy_error):
if _is_permission_error(copy_error):
logger.debug(
"copy2 failed during move-copy, falling back to copyfile (%s -> %s): %s",
"Permission error during move-copy, falling back to copyfile (%s -> %s): %s",
source_path,
temp_path,
copy_error,
@@ -616,7 +493,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
try:
_verify_published_file(try_path, expected_size, "move")
except Exception:
_discard_path(try_path)
run_blocking_io(try_path.unlink, missing_ok=True)
raise
run_blocking_io(source_path.unlink)
@@ -627,18 +504,9 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
if temp_path:
run_blocking_io(temp_path.unlink, missing_ok=True)
continue
except _PublishDeniedError:
# Destination is claimed but unrenameable; write into it directly.
_copy_into_claimed(source_path, try_path, expected_size)
if temp_path:
_discard_path(temp_path)
_discard_path(source_path)
if attempt > 0:
logger.info("File collision resolved: %s", try_path.name)
return try_path
except Exception:
if temp_path:
_discard_path(temp_path)
run_blocking_io(temp_path.unlink, missing_ok=True)
raise
else:
return try_path
@@ -715,7 +583,7 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100)
error=e,
)
if permission_error or _hardlink_not_supported(e):
logger.warning(
logger.debug(
"Hardlink failed (%s), falling back to copy: %s -> %s",
e,
source_path,
@@ -757,33 +625,22 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
if run_blocking_io(try_path.exists):
continue
# Known-undeletable destination: skip the temp file entirely, otherwise
# every transfer would strand a `.shelfmark.*.tmp` we cannot clean up.
if is_delete_denied(parent):
if not _claim_destination(try_path):
continue
_copy_into_claimed(source_path, try_path, expected_size)
if attempt > 0:
logger.info("File collision resolved: %s", try_path.name)
return try_path
temp_path: Path | None = None
try:
temp_path = _create_temp_path(try_path)
try:
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
except (PermissionError, OSError) as e:
if _should_fallback_to_content_copy(e):
if _is_permission_error(e):
log_transfer_permission_context(
"atomic_copy",
source=source_path,
dest=temp_path,
error=e,
)
# Handle NFS permission errors immediately here
if _is_permission_error(e):
log_transfer_permission_context(
"atomic_copy",
source=source_path,
dest=temp_path,
error=e,
)
logger.debug(
"copy2 failed during copy, falling back to copyfile (%s -> %s): %s",
"Permission error during copy, falling back to copyfile (%s -> %s): %s",
source_path,
temp_path,
e,
@@ -819,22 +676,14 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
try:
_verify_published_file(try_path, expected_size, "copy")
except Exception:
_discard_path(try_path)
run_blocking_io(try_path.unlink, missing_ok=True)
raise
if attempt > 0:
logger.info("File collision resolved: %s", try_path.name)
except _PublishDeniedError:
# The destination is claimed but unrenameable; write into it directly.
_copy_into_claimed(source_path, try_path, expected_size)
if temp_path:
_discard_path(temp_path)
if attempt > 0:
logger.info("File collision resolved: %s", try_path.name)
return try_path
except Exception:
if temp_path:
_discard_path(temp_path)
run_blocking_io(temp_path.unlink, missing_ok=True)
raise
else:
return try_path
+66 -455
View File
@@ -4,61 +4,44 @@ import random
import time
from http import HTTPStatus
from io import BytesIO
from threading import Event, Thread
from typing import TYPE_CHECKING, NoReturn
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
from urllib.parse import urljoin, urlparse
import requests
from tqdm import tqdm
from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError, cookie_store
from shelfmark.bypass.challenge import challenge_marker
from shelfmark.core import search_deadline
from shelfmark.bypass import BypassCancelledError
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_bool, normalize_positive_int
from shelfmark.download import network
from shelfmark.download.activity import release_activity_grace, request_activity_grace
from shelfmark.download.network import get_proxies, get_ssl_verify
if TYPE_CHECKING:
from collections.abc import Callable
from threading import Event
from types import ModuleType
logger = setup_logger(__name__)
_RNG = random.SystemRandom()
_MAX_REDIRECTS = 5
# 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.
_MAX_COOKIE_HANDSHAKE_RETRIES = 2
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
_HTTP_STATUS_RATE_LIMITED = HTTPStatus.TOO_MANY_REQUESTS
_HTTP_STATUS_SERVICE_UNAVAILABLE = HTTPStatus.SERVICE_UNAVAILABLE
_HTTP_STATUS_OK = HTTPStatus.OK
_HTTP_STATUS_RANGE_NOT_SATISFIABLE = HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE
_HTTP_STATUS_PARTIAL_CONTENT = HTTPStatus.PARTIAL_CONTENT
_HTTP_STATUS_NON_RETRYABLE = (_HTTP_STATUS_FORBIDDEN, _HTTP_STATUS_NOT_FOUND)
_STATUS_CALLBACK_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError)
# Added on top of the active bypasser's own budget so it reports its real failure before
# stall detection cancels the download.
_BYPASS_GRACE_SLACK_SECONDS = 30.0
_BYPASSER_ERRORS = (
AttributeError,
BypassCancelledError,
ChallengeNotSolvedError,
KeyError,
OSError,
RuntimeError,
TypeError,
ValueError,
network.RateLimitedError,
requests.exceptions.RequestException,
)
@@ -71,18 +54,6 @@ def _raise_too_many_redirects(message: str) -> NoReturn:
raise requests.exceptions.TooManyRedirects(message)
def _new_cookies(response: requests.Response, already_sent: dict[str, str]) -> dict[str, str]:
"""Cookies a response set that we were not already echoing back.
Returning only the *new* ones is what makes the retry terminate: a server that keeps
re-issuing the same cookie yields nothing here, so we stop instead of spinning.
"""
jar = getattr(response, "cookies", None)
if not jar:
return {}
return {name: value for name, value in jar.items() if already_sent.get(name) != value}
def _get_internal_bypasser() -> ModuleType:
"""Lazy import of internal bypasser module."""
global _internal_bypasser
@@ -128,19 +99,6 @@ def _is_cf_bypass_enabled() -> bool:
return coerce_bool(app_config.get("USE_CF_BYPASS", True))
def _bypass_grace_seconds() -> float:
"""How long a bypass may block before stall detection should give up on it.
Each bypasser knows its own retry/timeout budget, so ask the active one rather than
duplicating the arithmetic here. The slack keeps the bypasser's own deadline expiring
first, so the user sees its real error instead of a generic "Download stalled".
"""
bypasser = (
_get_external_bypasser() if _is_using_external_bypasser() else _get_internal_bypasser()
)
return bypasser.max_duration_seconds() + _BYPASS_GRACE_SLACK_SECONDS
def get_bypassed_page(
url: str,
selector: network.AAMirrorSelector | None = None,
@@ -153,13 +111,19 @@ def get_bypassed_page(
def get_cf_cookies_for_domain(domain: str) -> dict[str, str]:
"""Get the clearance cookies won by whichever bypasser solved this domain."""
return cookie_store.get_cf_cookies_for_domain(domain)
"""Get CF cookies - only available with internal bypasser."""
if _is_using_external_bypasser():
logger.debug("External bypasser in use, CF cookies not available for %s", domain)
return {}
return _get_internal_bypasser().get_cf_cookies_for_domain(domain)
def get_cf_user_agent_for_domain(domain: str) -> str | None:
"""Get the User-Agent that solved this domain's challenge, if one is stored."""
return cookie_store.get_cf_user_agent_for_domain(domain)
"""Get CF user agent - only available with internal bypasser."""
if _is_using_external_bypasser():
logger.debug("External bypasser in use, CF user agent not available for %s", domain)
return None
return _get_internal_bypasser().get_cf_user_agent_for_domain(domain)
def _apply_cf_bypass(url: str, headers: dict) -> dict:
@@ -236,93 +200,13 @@ def _is_retryable_error(e: Exception) -> bool:
return status is not None and status in RETRYABLE_CODES
# Statuses that mean the host is gone rather than busy: 410 Gone and 451 Unavailable
# For Legal Reasons are what a seized domain answers with.
_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.
Hard evidence only - the name does not resolve, nothing is listening, or the host
says it is gone for good. A timeout, a 5xx or a challenge all mean the mirror is
alive, and rotating off it discards the bypass clearance held for that domain.
"""
status = _get_status_code(e)
if status is not None and status in _DEAD_MIRROR_CODES:
return f"HTTP {status}"
# requests wraps the real cause; a read timeout subclasses ConnectionError for
# some adapters, so exclude timeouts explicitly before inspecting the message.
if isinstance(e, requests.exceptions.Timeout):
return None
if not isinstance(e, requests.exceptions.ConnectionError):
return None
text = str(e).lower()
if "nameresolutionerror" in text or "failed to resolve" in text or "name or service" in text:
return "DNS does not resolve"
if "connection refused" in text or "no route to host" in text:
return "connection refused"
return None
def _try_rotation(
original_url: str,
current_url: str,
selector: network.AAMirrorSelector,
*,
fatal_reason: str | None = None,
original_url: str, current_url: str, selector: network.AAMirrorSelector
) -> str | None:
"""Try mirror/DNS rotation. Returns new URL or None."""
aa_base_url = network.get_aa_base_url()
if aa_base_url and current_url.startswith(aa_base_url):
new_base, action = selector.next_mirror_or_rotate_dns(
fatal=fatal_reason is not None, reason=fatal_reason or ""
)
new_base, action = selector.next_mirror_or_rotate_dns()
if action in ("mirror", "dns") and new_base:
new_url = selector.rewrite(original_url)
logger.info("[%s] switching to: %s", action, new_url)
@@ -354,11 +238,8 @@ def html_get_page(
selector: Mirror selector used for AA mirror and DNS rotation.
cancel_flag: Optional event used to abort retries early.
status_callback: Optional callback for UI status updates.
allow_bypasser_fallback: Whether a challenge may be handed to the bypasser.
If False, a 403 triggers mirror rotation instead, and an AA redirect loop
gives up immediately rather than waiting on a browser solve. Use False for
best-effort fetches whose result is optional (e.g. the download count on
the details modal); search and detail pages pass True.
allow_bypasser_fallback: If False, 403 errors will trigger mirror rotation
instead of switching to the bypasser. Use for search operations.
use_bypasser: Whether to start with the bypasser instead of direct HTTP.
include_response_url: If True, return `(html, final_url)` to expose the
resolved response URL after redirects.
@@ -367,172 +248,59 @@ 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.
Factored out so the redirect-loop handoff below can invoke it directly. That
call site sits inside the inner redirect `while`, so it cannot reach the
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:
# A bypass is one long blocking call with no incremental progress, so
# tell the orchestrator up front how long it may legitimately take
# instead of trying to fake activity while it runs. Inside the try so a
# bypasser that fails to load is still reported as a bypasser error.
request_activity_grace(status_callback, _bypass_grace_seconds())
result = get_bypassed_page(bypass_url, selector, cancel_flag)
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 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
# page and the download dies with a generic failure, hiding e.g. a
# FlareSolverr 500 behind a silent wait.
if status_callback and not isinstance(e, BypassCancelledError):
try:
status_callback("error", f"Bypass failed: {type(e).__name__}: {e}")
except _STATUS_CALLBACK_ERRORS:
logger.debug("Bypass error status callback failed", exc_info=True)
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)
def _bypass_handoff_allowed() -> bool:
"""Whether a challenge on the current URL may be handed to the bypasser.
allow_bypasser_fallback is honoured for the same reason the 403 path honours it:
callers such as the /dyn/md5/summary fetch behind the details modal pass False
precisely so a best-effort request fails fast instead of holding the UI open for
a minutes-long browser solve.
"""
return allow_bypasser_fallback and _is_cf_bypass_enabled() and not use_bypasser_now
def _purge_clearance(target_url: str) -> None:
"""Drop the host's stored clearance cookies.
Called whenever the protection answered a request that *carried* cookies:
being challenged while presenting them proves they no longer work, so keeping
them only guarantees the same rejection on every later request. Applies to
either bypasser, since both fill the same store.
"""
hostname = urlparse(target_url).hostname or ""
# An empty domain means "clear every host" to the store, so skip the purge
# rather than wipe clearance for sites that are working fine.
if hostname:
cookie_store.clear_cf_cookies(hostname)
def _redirect_loop_handoff(bypass_url: str) -> str | tuple[str, str]:
"""Drop the host's stale clearance cookies, then bypass `bypass_url`.
A `?check=1` loop is how DDoS-Guard answers a clearance cookie that has gone
stale, so the dead cookie has to go before the solve — otherwise it is merged
back over the fresh one on the next request and the loop simply resumes.
"""
_purge_clearance(bypass_url)
return _run_bypasser(bypass_url)
configured_retry = normalize_positive_int(app_config.MAX_RETRY)
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 _fail("The request was cancelled.", current_url)
return _result("", current_url)
cookies: dict[str, str] = {}
try:
if use_bypasser_now and _is_cf_bypass_enabled():
return _run_bypasser(current_url)
if status_callback:
status_callback("resolving", "Bypassing protection...")
heartbeat_stop = Event()
heartbeat_thread: Thread | None = None
if status_callback:
def _heartbeat() -> None:
# Keep the download "alive" during long bypass operations so the orchestrator
# doesn't flag it as stalled.
if cancel_flag and cancel_flag.is_set():
return
try:
status_callback("resolving", "Bypassing protection...")
except _STATUS_CALLBACK_ERRORS:
return
heartbeat_thread = Thread(
target=_heartbeat, daemon=True, name="BypassHeartbeat"
)
heartbeat_thread.start()
try:
result = get_bypassed_page(current_url, selector, cancel_flag)
return _result(result or "", current_url)
except _BYPASSER_ERRORS as e:
logger.warning("Bypasser error: %s: %s", type(e).__name__, e)
return _result("", current_url)
finally:
heartbeat_stop.set()
if heartbeat_thread:
heartbeat_thread.join(timeout=1)
logger.debug("GET: %s", current_url)
@@ -554,67 +322,12 @@ def html_get_page(
current_url,
proxies=get_proxies(current_url),
timeout=REQUEST_TIMEOUT,
# 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},
cookies=cookies,
headers=headers,
allow_redirects=allow_redirects,
verify=get_ssl_verify(current_url),
)
# Z-Library gates the first hit with a 503 that carries nothing but a
# Set-Cookie; echoing it back yields the 302 to the real page. Without this
# the cookie is dropped and every retry re-runs the same rejected request.
if (
response.status_code == _HTTP_STATUS_SERVICE_UNAVAILABLE
and handshake_retries < _MAX_COOKIE_HANDSHAKE_RETRIES
):
issued = _new_cookies(response, handshake_cookies)
if issued:
handshake_cookies.update(issued)
handshake_retries += 1
logger.debug(
"503 set %s cookie(s); retrying with them: %s",
len(issued),
current_url,
)
continue
# 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:
@@ -636,19 +349,13 @@ def html_get_page(
redirect_host,
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,
)
return _result("", current_url)
new_url = _try_rotation(original_url, current_url, selector)
if new_url:
current_url = new_url
# Reset per-request state for the new host.
headers = {"User-Agent": DOWNLOAD_HEADERS["User-Agent"]}
handshake_cookies.clear()
is_aa_url = network.should_rotate_dns_for_url(current_url)
allow_redirects = not is_aa_url
redirects_followed = 0
@@ -660,47 +367,12 @@ def html_get_page(
redirect_host,
current_url,
)
return _fail(
"Every Anna's Archive mirror redirected away to a dead host — "
"all configured mirrors are unreachable.",
current_url,
)
return _result("", current_url)
# Same-host redirect (relative or absolute) - follow manually.
# DDoS-Guard gates AA /search behind a cookie probe: the 302 to
# ?check=1 carries Set-Cookie (__ddg*) which must be echoed back on
# the next hop, or the server just re-issues the redirect forever.
issued = _new_cookies(response, handshake_cookies)
if issued:
handshake_cookies.update(issued)
redirects_followed += 1
if redirects_followed > _MAX_REDIRECTS:
# A same-host redirect loop on AA is not a network fault — it is
# how DDoS-Guard presents a handshake that is unsolved, or whose
# clearance cookie has gone stale: /search redirects to
# /search&check=1, which redirects back, indefinitely. Hand it
# straight to the bypasser rather than raising, which would send it
# down the retry path to re-run the whole loop on every attempt
# (10 x 6 = ~60 requests to AA) without ever offering the URL to the
# bypasser. `continue` is no use here either — it would target this
# inner redirect loop rather than the retry branch below.
if _bypass_handoff_allowed():
logger.info(
"Redirect loop detected; switching to bypasser: %s", current_url
)
return _redirect_loop_handoff(current_url)
# No bypasser to hand it to. Every AA mirror shares the challenge,
# so rotating only collects another loop — give up now instead of
# raising and burning the same ~60 requests over the retry budget.
logger.warning(
"Redirect loop and no bypasser available, giving up: %s", 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,
)
_raise_too_many_redirects(f"Too many redirects for {current_url}")
current_url = redirect_url
continue
@@ -710,24 +382,8 @@ def html_get_page(
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
# back rather than resolving inline — an AA redirect missing its Location
# header. TooManyRedirects carries no status, so the 403 rescue below never
# fires and every retry would re-send the dead cookies. Scoped to the hosts
# whose redirects we follow manually: elsewhere `requests` follows them itself,
# and a loop there is an ordinary misconfiguration that a cookie purge and a
# minutes-long browser solve would be the wrong answer to.
if (
isinstance(e, requests.exceptions.TooManyRedirects)
and network.should_rotate_dns_for_url(current_url)
and _bypass_handoff_allowed()
):
logger.info("Redirect loop detected; switching to bypasser: %s", current_url)
return _redirect_loop_handoff(current_url)
# 403 = Cloudflare/DDoS-Guard protection
if status == _HTTP_STATUS_FORBIDDEN:
# If bypasser fallback is disabled, try mirrors instead
@@ -737,73 +393,38 @@ def html_get_page(
current_url = new_url
continue
logger.warning("403 error, mirrors exhausted: %s", current_url)
return _fail(
"Anna's Archive returned 403 (blocked) and all mirrors are exhausted.",
current_url,
)
return _result("", current_url)
if _is_cf_bypass_enabled() and not use_bypasser_now:
# Before switching to bypasser, check if cookies have become available
# (another concurrent download may have completed bypass and extracted cookies)
parsed = urlparse(current_url)
fresh_cookies = get_cf_cookies_for_domain(parsed.hostname or "")
if fresh_cookies and not cookies and attempt < retry_limit:
# Cookies are now available - retry with cookies before using bypasser.
# Guarded on there being a next attempt: `continue` on the last one
# ends the retry loop and abandons the request without ever offering
# the URL to the bypasser, and MAX_RETRY=1 is the supported setting.
# Same reasoning as the bypasser invocation below.
if fresh_cookies and not cookies:
# Cookies are now available - retry with cookies before using bypasser
logger.debug(
"403 but cookies now available - retrying with cookies: %s",
current_url,
)
continue
if cookies:
# Challenged *while presenting* clearance: those cookies are
# dead. Without this they survive the solve and get merged back
# over the fresh ones, so every later request re-presents a
# known-rejected cookie and is challenged again - the stale
# retry that never ends.
logger.debug("403 with cookies presented; purging: %s", current_url)
_purge_clearance(current_url)
logger.info("403 detected; switching to bypasser: %s", current_url)
# Invoke it here rather than setting use_bypasser_now and continuing.
# The branch that acts on that flag runs at the top of the *next* retry
# attempt, so under the supported MAX_RETRY=1 there is no next attempt
# and the bypasser was never reached — a 403 simply ended the search.
# Same reasoning as the redirect-loop handoffs.
return _run_bypasser(current_url)
if status_callback:
status_callback("resolving", "Bypassing protection...")
use_bypasser_now = True
continue
logger.warning("403 error, giving up: %s", current_url)
return _fail(
"Anna's Archive returned 403 (blocked) and no bypasser is enabled "
"to solve the protection challenge.",
current_url,
)
return _result("", current_url)
# 404 = Not found
if status == _HTTP_STATUS_NOT_FOUND:
logger.warning("404 error: %s", current_url)
return _fail(
f"Anna's Archive returned 404 Not Found for {current_url}.", current_url
)
return _result("", 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
# next search does not pay for it again.
fatal_reason = _fatal_mirror_reason(e)
if fatal_reason or _is_retryable_error(e):
new_url = _try_rotation(
original_url, current_url, selector, fatal_reason=fatal_reason
)
# Try mirror/DNS rotation on retryable errors
if _is_retryable_error(e):
new_url = _try_rotation(original_url, current_url, selector)
if new_url:
current_url = new_url
handshake_cookies.clear()
continue
# Retry with backoff
@@ -820,16 +441,7 @@ def html_get_page(
else:
logger.exception("Giving up after %s attempts: %s", retry_limit, 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,
)
return _result("", current_url)
def download_url(
@@ -946,7 +558,6 @@ def download_url(
# 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")
+57 -429
View File
@@ -3,16 +3,14 @@
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, NamedTuple, cast
from typing import TYPE_CHECKING, Any, cast
import dns.resolver
import httpx
import requests
from dns.exception import DNSException
@@ -279,116 +277,6 @@ _current_aa_url_index = 0
_aa_urls: list[str] = [] # Initialized lazily in _initialize_aa_state()
_aa_base_url: str = "" # Current active AA URL
# Mirrors quarantined for this process: domains that are not a working AA mirror at
# all (NXDOMAIN, refused, or a 200 that isn't AA - seized/parked/for-sale domains all
# land here). Kept separate from ordinary failures: a 403 challenge or a 5xx means the
# mirror is alive and rotating away from it only discards the DDoS-Guard clearance we
# hold for it. Deliberately in-memory only, so a restart re-probes everything.
_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."""
@@ -410,24 +298,6 @@ DNS_PROVIDERS = [
("opendns", ["208.67.222.222", "208.67.220.220"], "https://doh.opendns.com/dns-query"),
]
# httpx raises its own hierarchy, which shares no base class with requests', so a
# wireformat failure would escape a requests-only except clause.
_DOH_REQUEST_ERRORS = (OSError, ValueError, requests.RequestException, httpx.HTTPError)
def _first_proxy(proxies: dict[str, str] | None) -> str | None:
"""Pick a single proxy URL from a requests-style mapping, for httpx."""
if not proxies:
return None
return proxies.get("https") or proxies.get("http") or None
# DoH providers that speak RFC 8484 wireformat rather than the (non-standard) JSON API
# Cloudflare and Google popularised. Verified against the live services: both reject a
# ?name=&type= query outright - Quad9 with 505 (it also mandates HTTP/2 per RFC 8484
# section 5.2, which requests cannot speak), OpenDNS with 400 "No valid query received".
_DOH_WIREFORMAT_HOSTS = frozenset({"dns.quad9.net", "doh.opendns.com"})
# Domain patterns that should trigger DNS rotation on failure
DNS_ROTATION_DOMAINS = [
"annas-archive",
@@ -592,16 +462,8 @@ class DoHResolver:
# DNS cache: {(hostname, record_type): (ip_list, timestamp)}
self._cache: dict[tuple[str, str], tuple[list[str], datetime]] = {}
# RFC 8484 providers get a separate transport: they need wireformat, and Quad9
# additionally refuses HTTP/1.1, which requests has no way to upgrade from.
self.use_wireformat = urllib.parse.urlparse(self.base_url).hostname in (
_DOH_WIREFORMAT_HOSTS
)
self._http2_client: Any | None = None
if self.use_wireformat:
self.session.headers.update({"Accept": "application/dns-message"})
elif "google" in self.base_url:
# Different headers based on provider
if "google" in self.base_url:
self.session.headers.update(
{
"Accept": "application/json",
@@ -614,35 +476,6 @@ class DoHResolver:
}
)
def _get_http2_client(self) -> Any:
"""Lazily build the HTTP/2 client used for RFC 8484 providers.
Built on first use so a resolver pointed at a JSON provider never opens an
HTTP/2 connection pool it will not use.
"""
if self._http2_client is None:
self._http2_client = httpx.Client(
http2=True,
timeout=10,
verify=get_ssl_verify(self.base_url),
proxy=_first_proxy(get_proxies(self.base_url)),
)
return self._http2_client
def _resolve_wireformat(self, hostname: str, record_type: str) -> list[str]:
"""Resolve via RFC 8484: base64url query in, DNS message out."""
from shelfmark.download import doh_wireformat
qtype = doh_wireformat.TYPE_AAAA if record_type == "AAAA" else doh_wireformat.TYPE_A
param = doh_wireformat.encode_query_param(hostname, qtype)
response = self._get_http2_client().get(
self.base_url,
params={"dns": param},
headers={"Accept": "application/dns-message"},
)
response.raise_for_status()
return doh_wireformat.decode_answer(response.content, qtype)
def _get_cached(self, hostname: str, record_type: str) -> list[str] | None:
"""Get cached DNS result if still valid."""
key = (hostname, record_type)
@@ -692,37 +525,34 @@ class DoHResolver:
return cached
try:
if self.use_wireformat:
answers = self._resolve_wireformat(hostname, record_type)
else:
params = {"name": hostname, "type": "AAAA" if record_type == "AAAA" else "A"}
params = {"name": hostname, "type": "AAAA" if record_type == "AAAA" else "A"}
response = self.session.get(
self.base_url,
params=params,
proxies=get_proxies(self.base_url),
timeout=10, # Increased from 5s to handle slow network conditions
verify=get_ssl_verify(self.base_url),
)
response.raise_for_status()
response = self.session.get(
self.base_url,
params=params,
proxies=get_proxies(self.base_url),
timeout=10, # Increased from 5s to handle slow network conditions
verify=get_ssl_verify(self.base_url),
)
response.raise_for_status()
data = response.json()
if "Answer" not in data:
logger.warning("DoH resolution failed for %s: %s", hostname, data)
return []
data = response.json()
if "Answer" not in data:
logger.warning("DoH resolution failed for %s: %s", hostname, data)
return []
# Extract IP addresses from the response
answers = [
answer["data"]
for answer in data["Answer"]
if answer.get("type") == (28 if record_type == "AAAA" else 1)
]
# Extract IP addresses from the response
answers = [
answer["data"]
for answer in data["Answer"]
if answer.get("type") == (28 if record_type == "AAAA" else 1)
]
# Cache the result
self._set_cached(hostname, record_type, answers)
# Don't log here - the caller (custom_getaddrinfo) will log the final result
except _DOH_REQUEST_ERRORS as e:
except (OSError, ValueError, requests.RequestException) as e:
logger.warning("DoH resolution failed for %s: %s", hostname, e)
return []
else:
@@ -786,6 +616,8 @@ def create_custom_getaddrinfo(
source: str,
provider_label: str,
res: Sequence[tuple[AddressFamily, SocketKind, int, str, tuple[Any, ...]]],
*,
is_bypass: bool = False,
) -> None:
"""Emit a unified resolver log with the IPs returned.
@@ -793,6 +625,7 @@ def create_custom_getaddrinfo(
source: Description of resolver source
provider_label: Label for the DNS provider
res: Resolution results
is_bypass: If True, log at DEBUG level (for local/IP addresses)
"""
# Skip logging entirely for localhost to reduce noise
@@ -808,7 +641,11 @@ def create_custom_getaddrinfo(
ip = sockaddr[0]
if isinstance(ip, str):
ips.append(ip)
logger.debug("Resolved %s via %s [%s]: %s", host_str, source, provider_label, ips)
msg = f"Resolved {host_str} via {source} [{provider_label}]: {ips}"
if is_bypass:
logger.debug(msg)
else:
logger.info(msg)
# Skip custom resolution for IP addresses, local addresses, or if skip check passes
if (
@@ -818,7 +655,7 @@ def create_custom_getaddrinfo(
):
# Quietly bypass custom resolution for IP/local targets
res = original_getaddrinfo(host, port, family, socket_type, proto, flags)
_log_results("system resolver (bypass)", "system", res)
_log_results("system resolver (bypass)", "system", res, is_bypass=True)
return res
results: list[tuple[AddressFamily, SocketKind, int, str, tuple[Any, ...]]] = []
@@ -1015,99 +852,6 @@ def _init_custom_resolver_internal(servers: list[str]) -> dns.resolver.Resolver:
return custom_resolver
# --- ISP / network DNS interference detection ---------------------------------
# Compare what the (tamperable) system resolver returns for a host against a
# tamper-resistant DoH lookup. Divergent answers are a strong signal the network is
# hijacking or NXDOMAIN-blocking the domain (a common reason AA downloads "work" but
# land on an ISP block page). Used to surface an actionable hint to the user.
_dns_interference_warned: set[str] = set()
_dns_interference_active = False
def _build_detection_doh_resolver() -> DoHResolver | None:
"""Build a throwaway DoH resolver for interference checks (no socket patching).
Honours the DoH provider the user selected (``DNS_PROVIDERS[_current_dns_index]``),
falling back to the first configured provider when none is active. The endpoint is
pinned to the provider's own nameserver IP so resolving the DoH host can't be
redirected by the very DNS layer the check is meant to detect.
"""
if 0 <= _current_dns_index < len(DNS_PROVIDERS):
_name, servers, doh_url = DNS_PROVIDERS[_current_dns_index]
elif DNS_PROVIDERS:
_name, servers, doh_url = DNS_PROVIDERS[0]
else:
return None
server_hostname = urllib.parse.urlparse(doh_url).hostname or ""
if not server_hostname or not servers:
return None
return DoHResolver(doh_url, server_hostname, servers[0])
def detect_dns_interference(hostname: str) -> dict[str, list[str]] | None:
"""Detect network DNS interference by comparing system DNS against DoH.
Returns ``{"system_ips": [...], "doh_ips": [...]}`` when the two resolvers disagree
(no overlapping IPs), otherwise None. No-op for IP literals / local hostnames and
when DoH resolution is unavailable, so it never produces a false positive.
"""
host = (hostname or "").strip().lower()
if not host or _is_ip_address(host) or _is_local_address(host):
return None
resolver = _build_detection_doh_resolver()
if resolver is None:
return None
try:
system_ips = {str(info[4][0]) for info in original_getaddrinfo(host, 443, socket.AF_INET)}
except OSError:
return None
if not system_ips:
return None
doh_ips = {ip for ip in resolver.resolve(host, "A") if ip}
if not doh_ips or (system_ips & doh_ips):
return None
return {"system_ips": sorted(system_ips), "doh_ips": sorted(doh_ips)}
def note_possible_dns_interference(hostname: str) -> bool:
"""Check ``hostname`` for DNS interference, logging an actionable warning once.
Returns True when interference has been detected this session. The check runs at
most once per host to avoid repeated DoH lookups and log spam.
"""
global _dns_interference_active
host = (hostname or "").strip().lower()
if not host or host in _dns_interference_warned:
return _dns_interference_active
_dns_interference_warned.add(host)
result = detect_dns_interference(host)
if not result:
return _dns_interference_active
_dns_interference_active = True
routing_via_doh = _current_dns_index >= 0 and bool(DOH_SERVER)
remedy = (
"Shelfmark is routing this domain through DNS-over-HTTPS to work around it."
if routing_via_doh
else "Enable DNS-over-HTTPS (USE_DOH=true) or set a custom DNS provider to bypass it."
)
logger.warning(
"Possible ISP/network DNS interference for %s: system DNS resolves to %s but DoH "
"resolves to %s. The network appears to be blocking or redirecting this domain. %s",
host,
result["system_ips"],
result["doh_ips"],
remedy,
)
return True
def dns_interference_detected() -> bool:
"""Whether network DNS interference has been detected this session."""
return _dns_interference_active
def init_doh_resolver(doh_server: str = "") -> DoHResolver | None:
"""Initialize DNS over HTTPS resolver."""
server = doh_server or DOH_SERVER
@@ -1178,18 +922,14 @@ def rotate_dns_and_reset_aa() -> bool:
configured_url = _get_configured_aa_url()
if configured_url == "auto":
# Auto mode always resets to the first mirror to restart the cascade. Skip any
# quarantined ones: a new DNS provider cannot revive a parked or seized domain.
with _dead_aa_urls_lock:
restart_urls = [url for url in _aa_urls if url not in _dead_aa_urls] or _aa_urls
if restart_urls:
_aa_base_url = restart_urls[0]
_current_aa_url_index = _aa_urls.index(_aa_base_url)
# Auto mode always resets to the first mirror to restart the cascade
_current_aa_url_index = 0
if _aa_urls:
_aa_base_url = _aa_urls[0]
logger.info("After DNS switch, resetting AA URL to: %s", _aa_base_url)
_save_state(aa_url=_aa_base_url)
else:
_aa_base_url = ""
_current_aa_url_index = 0
logger.info("After DNS switch, AA URL remains unconfigured")
else:
# Keep the user's configured primary mirror (if it exists in the list),
@@ -1359,17 +1099,8 @@ def _initialize_aa_state() -> None:
global _aa_base_url, _current_aa_url_index, _aa_urls
# Build URL list from config
previous_urls = _aa_urls
_aa_urls = _build_aa_urls()
# Drop quarantine decisions only when the mirror list itself changed - they were
# made about a list that no longer applies. This runs on every re-init (settings
# sync, DNS rotation, helper subprocess startup), and clearing unconditionally
# would resurrect a parked mirror mid-session.
if previous_urls != _aa_urls:
with _dead_aa_urls_lock:
_dead_aa_urls.clear()
# Get configured base URL from config
configured_url = _get_configured_aa_url()
@@ -1385,34 +1116,26 @@ def _initialize_aa_state() -> None:
return
if configured_url == "auto":
# Never restore or probe a mirror quarantined this session: re-init happens
# often, and re-electing a parked domain costs a wasted request every time
# (its parking page answers 200, so the probe would happily pick it).
with _dead_aa_urls_lock:
candidates = [url for url in _aa_urls if url not in _dead_aa_urls]
restored = state.get("aa_base_url")
if restored and restored in candidates:
_current_aa_url_index = _aa_urls.index(restored)
_aa_base_url = restored
if state.get("aa_base_url") and state["aa_base_url"] in _aa_urls:
_current_aa_url_index = _aa_urls.index(state["aa_base_url"])
_aa_base_url = state["aa_base_url"]
else:
logger.debug("AA_BASE_URL: auto, checking available urls %s", candidates)
for url in candidates:
logger.debug("AA_BASE_URL: auto, checking available urls %s", _aa_urls)
for i, url in enumerate(_aa_urls):
try:
response = requests.get(
url, proxies=get_proxies(url), timeout=3, verify=get_ssl_verify(url)
)
if response.status_code == HTTPStatus.OK:
_current_aa_url_index = _aa_urls.index(url)
_current_aa_url_index = i
_aa_base_url = url
_save_state(aa_url=_aa_base_url)
break
except (OSError, requests.RequestException) as exc:
logger.debug("Could not reach AA mirror candidate %s: %s", url, exc)
# Also covers the case where every probe failed and the previous base is
# itself quarantined - keeping it would aim the next search at a dead host.
if not _aa_base_url or _aa_base_url == "auto" or _aa_base_url not in candidates:
_aa_base_url = (candidates or _aa_urls)[0]
_current_aa_url_index = _aa_urls.index(_aa_base_url)
if not _aa_base_url or _aa_base_url == "auto":
_aa_base_url = _aa_urls[0]
_current_aa_url_index = 0
elif configured_url not in _aa_urls:
logger.info("AA_BASE_URL set to custom value %s; skipping auto-switch", configured_url)
_aa_base_url = configured_url
@@ -1510,80 +1233,22 @@ def is_aa_auto_mode() -> bool:
def get_available_aa_urls() -> list[str]:
"""Get configured AA URLs (copy), minus any quarantined this process.
Falls back to the full list when every mirror has been quarantined: a wrong
classification must not leave the app with nowhere to search.
"""
"""Get list of configured AA URLs (copy)."""
_ensure_initialized()
with _dead_aa_urls_lock:
alive = [url for url in _aa_urls if url not in _dead_aa_urls]
if not alive and _aa_urls:
logger.warning("All AA mirrors quarantined; retrying the full list")
_dead_aa_urls.clear()
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:
"""Return the configured mirror base that ``url`` belongs to, if any."""
for base in _aa_urls:
if base and url.startswith(base):
return base
return ""
def mark_aa_url_dead(url: str, reason: str) -> bool:
"""Quarantine an AA mirror for the rest of this process.
Only for hard evidence that the host is not a working AA mirror. Transient
failures (403 challenge, 429, 5xx, timeouts) must never come through here -
quarantining a live mirror throws away its bypass clearance.
"""
_ensure_initialized()
base = _aa_base_for_url(url) or url
with _dead_aa_urls_lock:
if base not in _aa_urls or base in _dead_aa_urls:
return False
# Keep at least one mirror in play, even if it is the failing one.
if len([u for u in _aa_urls if u not in _dead_aa_urls]) <= 1:
logger.warning("Not quarantining last remaining AA mirror %s (%s)", base, reason)
return False
_dead_aa_urls.add(base)
logger.warning("Quarantined AA mirror %s for this session: %s", base, reason)
return True
def get_dead_aa_urls() -> set[str]:
"""Return the mirrors quarantined this process (copy)."""
with _dead_aa_urls_lock:
return set(_dead_aa_urls)
def set_aa_url(url: str) -> bool:
"""Set the active AA base URL; returns True if applied."""
_ensure_initialized()
global _aa_base_url, _current_aa_url_index
if url not in _aa_urls:
return False
_current_aa_url_index = _aa_urls.index(url)
_aa_base_url = url
logger.info("Set AA URL to: %s", _aa_base_url)
_save_state(aa_url=_aa_base_url)
return True
return _aa_urls.copy()
def set_aa_url_index(new_index: int) -> bool:
"""Set AA base URL by index in the full configured list; True if applied."""
"""Set AA base URL by index in available list; returns True if applied."""
_ensure_initialized()
global _aa_base_url, _current_aa_url_index
if new_index < 0 or new_index >= len(_aa_urls):
return False
return set_aa_url(_aa_urls[new_index])
_current_aa_url_index = new_index
_aa_base_url = _aa_urls[_current_aa_url_index]
logger.info("Set AA URL to: %s", _aa_base_url)
_save_state(aa_url=_aa_base_url)
return True
class AAMirrorSelector:
@@ -1594,20 +1259,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:
_ensure_initialized()
self.aa_urls = get_available_aa_urls()
# Rotation walks the live mirrors, but rewriting has to recognise every
# configured base: a URL built before a mirror was quarantined still points at
# it, and failing to rewrite would send the retry back to the dead host.
self.all_aa_urls = _aa_urls.copy()
self._index = self._safe_index(get_aa_base_url())
self.current_base = self.aa_urls[self._index] if self.aa_urls else ""
if reset_attempts:
@@ -1620,41 +1276,16 @@ class AAMirrorSelector:
def rewrite(self, url: str) -> str:
"""Replace any known AA base in url with current_base."""
for base in self.all_aa_urls:
for base in self.aa_urls:
if url.startswith(base):
return url.replace(base, self.current_base, 1)
return url
def quarantine_current(self, reason: str) -> bool:
"""Quarantine the mirror this selector is on (hard failures only)."""
if not self.current_base:
return False
dropped = mark_aa_url_dead(self.current_base, reason)
if dropped:
# Rebuild from the surviving mirrors so the dead one is out of the cycle.
self._ensure_fresh_state(reset_attempts=False)
return dropped
def next_mirror_or_rotate_dns(
self, *, allow_dns: bool = True, fatal: bool = False, reason: str = ""
) -> tuple[str | None, str]:
def next_mirror_or_rotate_dns(self, *, allow_dns: bool = True) -> tuple[str | None, str]:
"""Advance to the next mirror or rotate DNS if needed.
``fatal`` marks the current mirror as not-an-AA-mirror (NXDOMAIN, refused, a
200 that isn't AA) and drops it from this process's rotation. Leave it False
for anything the mirror can recover from - a challenge or a 5xx means the host
is alive, and quarantining it would discard its bypass clearance.
Returns (new_base, action) where action is 'mirror', 'dns', or 'exhausted'.
"""
if fatal and self.quarantine_current(reason or "unusable mirror"):
# Quarantining rebuilt the state onto a surviving mirror, so that mirror is
# the next one to try - advancing again here would skip straight past it.
self.attempts_this_dns += 1
if self.current_base and is_aa_auto_mode():
set_aa_url(self.current_base)
return self.current_base, "mirror"
self.attempts_this_dns += 1
max_attempts = len(self.aa_urls) if is_aa_auto_mode() else 1
if self.attempts_this_dns >= max_attempts:
@@ -1667,11 +1298,8 @@ class AAMirrorSelector:
# Mirror is explicitly configured; do not fail over to other mirrors.
return None, "exhausted"
if not self.aa_urls:
return None, "exhausted"
next_index = (self._index + 1) % len(self.aa_urls)
set_aa_url(self.aa_urls[next_index])
set_aa_url_index(next_index)
self._ensure_fresh_state(reset_attempts=False)
return self.current_base, "mirror"
+28 -206
View File
@@ -12,7 +12,7 @@ from concurrent.futures import Future, ThreadPoolExecutor
from email.utils import parseaddr
from pathlib import Path
from threading import Event, Lock
from typing import TYPE_CHECKING, Any
from typing import Any
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
@@ -24,19 +24,14 @@ from shelfmark.core.request_helpers import (
)
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.core.utils import transform_cover_url
from shelfmark.download.activity import parse_activity_grace
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path
from shelfmark.download.postprocess.router import post_process_download
from shelfmark.release_sources import (
get_handler,
get_source,
get_source_display_name,
)
if TYPE_CHECKING:
from collections.abc import Iterable
logger = setup_logger(__name__)
_RNG = random.SystemRandom()
@@ -69,16 +64,7 @@ _last_progress_value: dict[str, float] = {}
# De-duplicate status updates (keep-alive updates shouldn't spam clients)
_last_status_event: dict[str, tuple[str, str | None]] = {}
STALL_TIMEOUT = 300 # 5 minutes without progress/status update = stalled
# Absolute deadlines (time.time()) until which stall detection is suppressed for a task.
# Long single-shot operations (protection bypass, etc.) declare their own upper bound via
# `shelfmark.download.activity` instead of faking progress. See set_activity_grace().
_activity_grace: dict[str, float] = {}
# A caller cannot buy immortality: the largest grace any operation may request. Must stay
# above the largest budget any caller can declare (see http._bypass_grace_seconds).
_MAX_ACTIVITY_GRACE_SECONDS = 960.0
COORDINATOR_LOOP_ERROR_RETRY_DELAY = 1.0
# Ceiling for the exponential backoff applied to repeated coordinator loop failures.
_COORDINATOR_LOOP_ERROR_MAX_DELAY = 30.0
_PROGRESS_BROADCAST_START_PERCENT = 1
_PROGRESS_BROADCAST_COMPLETE_PERCENT = 99
_PROGRESS_BROADCAST_MIN_DELTA = 10
@@ -122,13 +108,6 @@ def _parse_release_search_mode(value: object) -> SearchMode:
raise ValueError(msg)
def _source_unavailable_message(source_name: str) -> str | None:
source = get_source(source_name)
if source.is_available():
return None
return f"{source.display_name} is unavailable. Enable and configure the source in Settings."
def _optional_number(value: object) -> float | None:
if isinstance(value, bool):
return float(value)
@@ -159,6 +138,13 @@ def _optional_positive_int(value: object) -> int | None:
return parsed if parsed > 0 else None
def _seed_time_seconds_to_minutes(value: object) -> int | None:
seed_time_seconds = _optional_positive_int(value)
if seed_time_seconds is None:
return None
return (seed_time_seconds + 59) // 60
def _config_float(value: object, default: float) -> float:
if isinstance(value, bool) or value is None:
return default
@@ -180,37 +166,19 @@ def _build_retry_resolution_fields(
if not isinstance(extra, dict):
extra = {}
retry_download_url = normalize_optional_text(release_data.get("download_url"))
protocol = normalize_optional_text(release_data.get("protocol"))
source = normalize_optional_text(release_data.get("source"))
retry_source_context: dict[str, Any] = {}
if source is not None:
handler = get_handler(source)
source_retry_fields = handler.build_retry_resolution_fields(release_data)
if "retry_download_url" in source_retry_fields:
retry_download_url = normalize_optional_text(
source_retry_fields.get("retry_download_url")
)
if "retry_download_protocol" in source_retry_fields:
protocol = normalize_optional_text(source_retry_fields.get("retry_download_protocol"))
raw_retry_source_context = source_retry_fields.get("retry_source_context")
if isinstance(raw_retry_source_context, dict):
retry_source_context = dict(raw_retry_source_context)
ratio_limit = _optional_number(release_data.get("ratio_limit"))
if ratio_limit is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False):
ratio_limit = _optional_number(extra.get("configured_ratio_limit"))
if ratio_limit is None:
ratio_limit = _optional_number(extra.get("minimum_ratio"))
seeding_time_limit_minutes = _optional_positive_int(
release_data.get("seeding_time_limit_minutes")
)
if seeding_time_limit_minutes is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False):
seeding_time_limit_minutes = _optional_positive_int(
extra.get("configured_seed_time_minutes")
)
if seeding_time_limit_minutes is None:
seeding_time_limit_minutes = _seed_time_seconds_to_minutes(extra.get("minimum_seed_time"))
return {
"retry_download_url": retry_download_url,
"retry_download_url": normalize_optional_text(release_data.get("download_url")),
"retry_download_protocol": protocol.lower() if protocol is not None else None,
"retry_release_name": normalize_optional_text(release_data.get("title")),
"retry_expected_hash": normalize_optional_text(
@@ -218,7 +186,6 @@ def _build_retry_resolution_fields(
),
"retry_ratio_limit": ratio_limit,
"retry_seeding_time_limit_minutes": seeding_time_limit_minutes,
"retry_source_context": retry_source_context,
"can_retry_without_staged_source": True,
}
@@ -232,11 +199,6 @@ def queue_release(
"""Add a release to the download queue. Returns (success, error_message)."""
try:
source = release_data["source"]
unavailable_message = _source_unavailable_message(source)
if unavailable_message:
logger.warning("Rejected queue request for unavailable source %s", source)
return False, unavailable_message
extra = release_data.get("extra", {})
raw_request_id = release_data.get("_request_id")
request_id: int | None = None
@@ -264,9 +226,6 @@ def queue_release(
series_name = release_data.get("series_name") or extra.get("series_name")
series_position = release_data.get("series_position") or extra.get("series_position")
subtitle = release_data.get("subtitle") or extra.get("subtitle")
language = release_data.get("language") or extra.get("language")
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")
@@ -301,9 +260,6 @@ def queue_release(
series_name=series_name,
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,
@@ -412,33 +368,6 @@ 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)
@@ -450,7 +379,6 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
search_mode = normalized_search_mode or None
raw_output_args = getattr(task, "output_args", None)
raw_retry_source_context = getattr(task, "retry_source_context", None)
return {
"task_id": getattr(task, "task_id", None),
@@ -466,10 +394,7 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
"series_name": getattr(task, "series_name", None),
"series_position": getattr(task, "series_position", None),
"subtitle": getattr(task, "subtitle", None),
"language": getattr(task, "language", None),
"search_mode": search_mode,
"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),
@@ -482,9 +407,6 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
"retry_expected_hash": getattr(task, "retry_expected_hash", None),
"retry_ratio_limit": getattr(task, "retry_ratio_limit", None),
"retry_seeding_time_limit_minutes": getattr(task, "retry_seeding_time_limit_minutes", None),
"retry_source_context": (
dict(raw_retry_source_context) if isinstance(raw_retry_source_context, dict) else {}
),
"can_retry_without_staged_source": bool(
getattr(task, "can_retry_without_staged_source", True)
),
@@ -510,7 +432,6 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
search_mode = None
output_args = payload.get("output_args")
retry_source_context = payload.get("retry_source_context")
return DownloadTask(
task_id=task_id,
@@ -526,10 +447,7 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
series_name=normalize_optional_text(payload.get("series_name")),
series_position=_optional_number(payload.get("series_position")),
subtitle=normalize_optional_text(payload.get("subtitle")),
language=normalize_optional_text(payload.get("language")),
search_mode=search_mode,
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")),
@@ -544,9 +462,6 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
retry_seeding_time_limit_minutes=_optional_positive_int(
payload.get("retry_seeding_time_limit_minutes")
),
retry_source_context=(
dict(retry_source_context) if isinstance(retry_source_context, dict) else {}
),
can_retry_without_staged_source=bool(payload.get("can_retry_without_staged_source", True)),
)
@@ -675,16 +590,6 @@ def _download_task(task_id: str, cancel_flag: Event) -> str | None:
logger.error("Task not found in queue: %s", task_id)
return None
unavailable_message = _source_unavailable_message(task.source)
if unavailable_message:
logger.warning("Task %s: source unavailable: %s", task_id, unavailable_message)
_capture_task_error(
task,
message=unavailable_message,
exc_type="SourceUnavailable",
)
return None
title_label = task.title or "Unknown title"
logger.info(
"Task %s: starting download (%s) - %s",
@@ -697,17 +602,6 @@ def _download_task(task_id: str, cancel_flag: Event) -> str | None:
update_download_progress(task_id, progress)
def status_callback(status: str, message: str | None = None) -> None:
# Liveness hint from a long single-shot operation, not a user-visible status.
# Handled here so it never reaches update_download_status (which dedupes status
# transitions on purpose). See shelfmark.download.activity.
grace = parse_activity_grace(status, message)
if grace is not None:
if grace > 0:
set_activity_grace(task_id, grace)
else:
clear_activity_grace(task_id)
return
status_key = status.lower()
if status_key == "error":
_capture_task_error(
@@ -945,7 +839,6 @@ def _cleanup_progress_tracking(task_id: str) -> None:
_last_activity.pop(task_id, None)
_last_progress_value.pop(task_id, None)
_last_status_event.pop(task_id, None)
_activity_grace.pop(task_id, None)
def _finalize_download_failure(task_id: str) -> None:
@@ -993,66 +886,6 @@ def _process_single_download(task_id: str, cancel_flag: Event) -> None:
ws_manager.broadcast_status_update(queue_status())
def set_activity_grace(book_id: str, seconds: float) -> None:
"""Suppress stall detection for `book_id` for up to `seconds` from now.
For long single-shot operations that cannot report incremental progress (protection
bypass being the motivating case). The grace is a single absolute deadline computed
once, so it cannot be extended into immortality by a keep-alive that carries no real
liveness information - an operation that hangs forever is still cancelled once its
declared budget expires.
Deliberately touches neither the queue nor the WebSocket: this is a liveness
assertion, not a user-visible status transition.
"""
grace = _config_float(seconds, 0.0)
grace = min(max(grace, 0.0), _MAX_ACTIVITY_GRACE_SECONDS)
with _progress_lock:
_activity_grace[book_id] = time.time() + grace
def clear_activity_grace(book_id: str) -> None:
"""Drop any activity grace for `book_id` and count this moment as activity.
Resetting `_last_activity` means a nested or abandoned grace degrades to a fresh
full STALL_TIMEOUT window rather than an immediate stall.
"""
with _progress_lock:
_activity_grace.pop(book_id, None)
_last_activity[book_id] = time.time()
def _find_stalled_tasks(task_ids: Iterable[str], now: float) -> list[str]:
"""Return the task ids with no activity inside STALL_TIMEOUT and no active grace.
Holds `_progress_lock` for dict reads only - never call into `book_queue` from here,
see _cancel_stalled_task().
"""
stalled: list[str] = []
with _progress_lock:
for task_id in task_ids:
last_active = _last_activity.get(task_id, now)
deadline = max(last_active + STALL_TIMEOUT, _activity_grace.get(task_id, 0.0))
if now > deadline:
stalled.append(task_id)
return stalled
def _cancel_stalled_task(task_id: str) -> None:
"""Cancel a stalled download.
Must be called WITHOUT `_progress_lock` held. `book_queue.cancel_download` runs the
terminal-status hooks, which reach a sqlite write that gevent does not patch; holding
the progress lock across that blocks the hub and every other download worker.
"""
logger.warning("Download stalled for %s, cancelling", task_id)
book_queue.cancel_download(task_id)
book_queue.update_status_message(
task_id,
f"Download stalled (no activity for {STALL_TIMEOUT}s)",
)
def concurrent_download_loop() -> None:
"""Run the main concurrent download coordinator."""
max_workers = normalize_positive_int(config.MAX_CONCURRENT_DOWNLOADS) or 1
@@ -1062,7 +895,6 @@ def concurrent_download_loop() -> None:
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="Download") as executor:
active_futures: dict[Future, tuple[str, Event]] = {} # Track active download futures
stalled_tasks: set[str] = set() # Track tasks already cancelled due to stall
consecutive_errors = 0
while True:
try:
@@ -1113,14 +945,19 @@ def concurrent_download_loop() -> None:
# Check for stalled downloads (no activity in STALL_TIMEOUT seconds)
current_time = time.time()
candidates = [
task_id
for _future, (task_id, _cancel_flag) in list(active_futures.items())
if task_id not in stalled_tasks
]
for task_id in _find_stalled_tasks(candidates, current_time):
_cancel_stalled_task(task_id)
stalled_tasks.add(task_id)
with _progress_lock:
for _future, (task_id, _cancel_flag) in list(active_futures.items()):
if task_id in stalled_tasks:
continue
last_active = _last_activity.get(task_id, current_time)
if current_time - last_active > STALL_TIMEOUT:
logger.warning("Download stalled for %s, cancelling", task_id)
book_queue.cancel_download(task_id)
book_queue.update_status_message(
task_id,
f"Download stalled (no activity for {STALL_TIMEOUT}s)",
)
stalled_tasks.add(task_id)
# Start new downloads if we have capacity
while len(active_futures) < max_workers:
@@ -1143,24 +980,9 @@ def concurrent_download_loop() -> None:
# Brief sleep to prevent busy waiting
time.sleep(main_loop_sleep_time)
consecutive_errors = 0
# This loop is the only thing driving the download queue; if it exits, nothing
# is ever picked up again and the app looks healthy while doing nothing (#823,
# #1166). A narrow exception list let gevent's LoopExit and friends through, so
# catch everything short of BaseException - GreenletExit and gevent.Timeout must
# still propagate, and the tests' loop-stopping sentinels derive from
# BaseException for exactly this reason.
except Exception as e: # noqa: BLE001 - coordinator loop must never die
consecutive_errors += 1
except (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError) as e:
logger.error_trace("Download coordinator loop error: %s", e)
# Back off when the failure is persistent so we don't spin at 1Hz forever,
# but keep the first delay unchanged for a normal transient blip.
time.sleep(
min(
COORDINATOR_LOOP_ERROR_RETRY_DELAY * 2 ** min(consecutive_errors - 1, 5),
_COORDINATOR_LOOP_ERROR_MAX_DELAY,
)
)
time.sleep(COORDINATOR_LOOP_ERROR_RETRY_DELAY)
# Download coordinator thread (started explicitly via start())
+1 -11
View File
@@ -105,7 +105,6 @@ def process_folder_output(
maybe_run_custom_script,
prepare_output_files,
record_step,
resolve_book_groups,
transfer_book_files,
)
@@ -206,7 +205,6 @@ def process_folder_output(
is_torrent=is_torrent,
preserve_source=preserve_source,
organization_mode=plan.organization_mode,
source_root=source_path,
)
if error:
@@ -261,15 +259,7 @@ def process_folder_output(
prepared.cleanup_paths,
)
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)"
message = "Complete" if len(final_paths) == 1 else f"Complete ({len(final_paths)} files)"
status_callback("complete", message)
return str(final_paths[0])
@@ -229,7 +229,6 @@ def _build_custom_script_payload(
"series_name": context.task.series_name,
"series_position": context.task.series_position,
"subtitle": context.task.subtitle,
"language": context.task.language,
"original_download_path": context.task.original_download_path,
},
"output": {
+4 -33
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import contextlib
import uuid
from typing import TYPE_CHECKING
from shelfmark.core.logger import setup_logger
@@ -12,11 +12,7 @@ from shelfmark.core.utils import (
from shelfmark.core.utils import (
is_audiobook as check_audiobook,
)
from shelfmark.download.fs import (
clear_delete_denied,
mark_delete_denied,
run_blocking_io,
)
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.permissions_debug import log_path_permission_context
from shelfmark.release_sources import get_source
@@ -28,8 +24,6 @@ if TYPE_CHECKING:
logger = setup_logger("shelfmark.download.postprocess.pipeline")
_WRITE_PROBE_NAME = ".shelfmark_write_test.tmp"
def validate_destination(
destination: Path, status_callback: Callable[[str, str | None], None]
@@ -46,20 +40,16 @@ def validate_destination(
status_callback("error", f"Destination is not a directory: {destination}")
return False
created_by_us = False
if not destination_exists:
try:
run_blocking_io(destination.mkdir, parents=True, exist_ok=True)
created_by_us = True
except (OSError, PermissionError) as exc:
log_path_permission_context("destination_create", destination)
logger.warning("Cannot create destination: %s (%s)", destination, exc)
status_callback("error", f"Cannot create destination: {destination} ({exc})")
return False
# Stable name: on shares that refuse deletes the probe file cannot be cleaned
# up, so reusing one name bounds the leftovers at a single hidden file.
test_path = destination / _WRITE_PROBE_NAME
test_path = destination / f".shelfmark_write_test_{uuid.uuid4().hex}.tmp"
try:
test_content = (
@@ -67,33 +57,14 @@ def validate_destination(
"It should've been automatically deleted. Feel free to delete it.\n"
)
run_blocking_io(test_path.write_text, test_content)
run_blocking_io(test_path.unlink, missing_ok=True)
except OSError as exc:
logger.debug("Destination write probe path: %s", test_path)
log_path_permission_context("destination_write_probe", destination)
logger.warning("Destination not writable: %s (%s)", destination, exc)
status_callback("error", f"Destination not writable: {destination} ({exc})")
if created_by_us:
with contextlib.suppress(OSError):
run_blocking_io(destination.rmdir)
return False
try:
run_blocking_io(test_path.unlink, missing_ok=True)
except OSError as exc:
# Writable but not deletable, e.g. a Synology share with "Delete
# subfolders and files" unticked. Not fatal: record it so transfers write
# files in place instead of publishing a temp file via rename.
mark_delete_denied(destination)
logger.warning(
"Destination %s is writable but refuses deletes (%s); leaving probe file %s "
"behind and writing files in place",
destination,
exc,
test_path.name,
)
else:
clear_delete_denied(destination)
return True
-423
View File
@@ -1,423 +0,0 @@
"""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]
if unmatched:
groups.extend(
group_files_into_books(
unmatched, series_name=series_name, author_name=author_name, root=root
)
)
return groups
@@ -40,7 +40,6 @@ from .transfer import (
build_metadata_dict,
is_torrent_source,
process_directory,
resolve_book_groups,
resolve_hardlink_source,
should_hardlink,
transfer_book_files,
@@ -81,7 +80,6 @@ __all__ = [
"process_directory",
"record_step",
"resolve_custom_script_target",
"resolve_book_groups",
"resolve_hardlink_source",
"run_custom_script",
"safe_cleanup_path",
+1 -1
View File
@@ -52,7 +52,7 @@ 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", "rename_and_group", "organize") else "rename"
return mode if mode in ("none", "rename", "organize") else "rename"
def get_template(*, is_audiobook: bool, organization_mode: str) -> str:
+2 -3
View File
@@ -7,7 +7,6 @@ from pathlib import Path
from typing import TYPE_CHECKING
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import AUDIOBOOK_FORMATS
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.download.archive import ArchiveExtractionError, extract_archive, is_archive
from shelfmark.download.fs import run_blocking_io
@@ -143,7 +142,7 @@ def scan_directory_tree(
is_audiobook = check_audiobook(content_type)
if is_audiobook:
trackable_exts = {f".{fmt}" for fmt in AUDIOBOOK_FORMATS}
trackable_exts = {".m4b", ".mp3", ".m4a", ".flac", ".ogg", ".wma", ".aac", ".wav"}
else:
trackable_exts = {
".pdf",
@@ -368,7 +367,7 @@ def collect_staged_files(
is_audiobook = check_audiobook(task.content_type)
if is_audiobook:
trackable_exts = {f".{fmt}" for fmt in AUDIOBOOK_FORMATS}
trackable_exts = {".m4b", ".mp3", ".m4a", ".flac", ".ogg", ".wma", ".aac", ".wav"}
else:
trackable_exts = {
".pdf",
+16 -151
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import dataclasses
import os
from pathlib import Path
from typing import TYPE_CHECKING
@@ -13,12 +12,11 @@ from shelfmark.core.naming import (
assign_part_numbers,
build_library_path,
derive_primary_title,
normalize_language_code,
parse_naming_template,
same_filesystem,
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,
@@ -27,7 +25,6 @@ from shelfmark.download.fs import (
)
from shelfmark.download.postprocess.policy import get_file_organization, get_template
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
@@ -42,7 +39,10 @@ _TRANSFER_PROCESS_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, Typ
def should_hardlink(task: DownloadTask) -> bool:
"""Check if hardlinking is enabled for this torrent-backed task."""
"""Check if hardlinking is enabled for this task (Prowlarr torrents only)."""
if task.source != "prowlarr":
return False
if not task.original_download_path:
return False
@@ -67,7 +67,6 @@ def build_metadata_dict(task: DownloadTask) -> dict:
"Year": task.year,
"Series": task.series_name,
"SeriesPosition": task.series_position,
"Language": normalize_language_code(task.language),
"User": task.username,
}
@@ -97,21 +96,21 @@ def resolve_hardlink_source(
if hardlink_enabled and task.original_download_path:
hardlink_source = Path(task.original_download_path)
hardlink_source_exists = run_blocking_io(hardlink_source.exists)
if hardlink_source_exists:
if (
destination
and hardlink_source_exists
and run_blocking_io(same_filesystem, hardlink_source, destination)
):
use_hardlink = True
source_path = hardlink_source
logger.info(
"Hardlink enabled for task %s; attempting link from %s to %s",
task.task_id,
elif hardlink_source_exists:
logger.warning(
"Cannot hardlink: %s and %s are on different filesystems. Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination.",
hardlink_source,
destination,
)
else:
logger.warning(
"Hardlink enabled for task %s, but source path does not exist: %s",
task.task_id,
hardlink_source,
)
if status_callback:
status_callback("resolving", "Cannot hardlink (different filesystems), using copy")
return TransferPlan(
source_path=source_path,
@@ -163,24 +162,6 @@ 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,
@@ -190,7 +171,6 @@ 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:
@@ -198,19 +178,6 @@ def transfer_book_files(
is_audiobook = check_audiobook(task.content_type)
organization_mode = organization_mode or get_file_organization(is_audiobook=is_audiobook)
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] = []
@@ -273,13 +240,6 @@ 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:
@@ -298,7 +258,7 @@ def transfer_book_files(
else:
filename = book_file.name
dest_path = transfer_destination / filename
dest_path = destination / filename
final_path, op = _transfer_single_file(
book_file,
dest_path,
@@ -314,101 +274,6 @@ 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,
-153
View File
@@ -1,153 +0,0 @@
"""Boot-time warm-up of the direct-download source.
The first AA search after a cold start pays for the whole cold path at once: DNS
resolution, electing a live mirror, spinning up headless Chrome and solving the
DDoS-Guard challenge. That is tens of seconds with the user sat at the search box.
Running one throwaway search shortly after boot moves that cost off the user's first
search. It primes the DNS cache, elects (and quarantines) mirrors, and leaves the
clearance cookie in the bypasser's per-domain cache, so the first real search reuses
it instead of solving from scratch.
Runs on a daemon thread and swallows every failure: this is an optimisation, and a
source that is down at boot must not affect startup or health.
"""
from __future__ import annotations
import os
import threading
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
# Delay before the warm-up fires. Long enough that it does not compete with the rest
# of startup (and with a container's own health probe) for the first request.
_DEFAULT_DELAY_SECONDS = 15.0
_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."""
if value is None:
return default
if isinstance(value, str):
from shelfmark.config.env import string_to_bool
return string_to_bool(value)
return bool(value)
def _setting(key: str, default: object) -> object:
"""Read a warm-up setting, preferring the deployment environment.
These keys are not in the settings registry, and ``config.get`` only consults the
environment for keys it knows about - so reading config alone silently ignored
SEARCH_WARMUP_ENABLED and always returned the default. Check os.environ first so
the documented switches actually work.
"""
raw = os.environ.get(key)
if raw is not None and raw.strip():
return raw
return config.get(key, default)
def is_enabled() -> bool:
"""Whether the boot-time warm-up search should run."""
if not _as_bool(_setting("SEARCH_WARMUP_ENABLED", True), default=True):
return False
# Nothing to warm if the source is off, and no challenge to pre-solve without
# the bypasser - a plain search is fast enough not to need this.
if not _as_bool(_setting("DIRECT_DOWNLOAD_ENABLED", True), default=True):
logger.debug("Search warm-up skipped: direct download disabled")
return False
return True
def warmup_query() -> str:
"""The query used to warm the source."""
raw = _setting("SEARCH_WARMUP_QUERY", _DEFAULT_QUERY)
query = str(raw).strip() if raw else ""
return query or _DEFAULT_QUERY
def run_warmup() -> bool:
"""Run one warm-up search. Returns True if it produced results.
Never raises: every failure mode here is one the next real search would hit
anyway, and reporting it is the search path's job, not the warm-up's.
"""
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
query = warmup_query()
logger.info("Warming up direct download search (%r)", query)
try:
from shelfmark.core.models import SearchFilters
from shelfmark.release_sources.direct_download import search_books
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.
logger.warning("Search warm-up did not complete; first user search may be slow")
logger.debug("Search warm-up failure detail", exc_info=True)
return False
if results:
logger.info("Search warm-up complete: %s results, source is ready", len(results))
return True
logger.info("Search warm-up returned no results; source reachable but empty")
return False
def start(delay_seconds: float = _DEFAULT_DELAY_SECONDS) -> bool:
"""Schedule the warm-up on a daemon thread. Safe to call multiple times."""
global _warmup_thread
if not is_enabled():
return False
with _warmup_lock:
if _warmup_thread is not None and _warmup_thread.is_alive():
logger.debug("Search warm-up already scheduled")
return False
def _run() -> None:
run_warmup()
_warmup_thread = threading.Timer(delay_seconds, _run)
_warmup_thread.daemon = True
_warmup_thread.name = "SearchWarmup"
_warmup_thread.start()
logger.debug("Search warm-up scheduled in %ss", delay_seconds)
return True
+91 -191
View File
@@ -26,7 +26,6 @@ from shelfmark.config.env import (
BUILD_VERSION,
CONFIG_DIR,
CWA_DB_PATH,
DISABLE_LOCAL_AUTH,
FLASK_HOST,
FLASK_PORT,
HIDE_LOCAL_AUTH,
@@ -38,11 +37,7 @@ from shelfmark.config.env import (
string_to_bool,
)
from shelfmark.config.security import _migrate_security_settings
from shelfmark.config.settings import (
_SUPPORTED_BOOK_LANGUAGE,
migrate_audiobook_format_settings,
)
from shelfmark.core import search_deadline
from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE
from shelfmark.core.activity_view_state_service import ActivityViewStateService
from shelfmark.core.auth_modes import (
get_auth_check_admin_status,
@@ -63,7 +58,6 @@ 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,
@@ -85,9 +79,8 @@ from shelfmark.core.requests_service import (
sync_delivery_states_from_queue_status,
)
from shelfmark.core.user_db import UserDB
from shelfmark.core.utils import AUDIOBOOK_FORMATS, normalize_base_path
from shelfmark.core.utils import normalize_base_path
from shelfmark.download import orchestrator as backend
from shelfmark.download import warmup
from shelfmark.release_sources import (
BrowseRecord,
Release,
@@ -124,7 +117,7 @@ BASE_PATH = normalize_base_path(normalize_optional_text(app_config.get("URL_BASE
app = Flask(__name__)
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0 # Disable caching
app.config["APPLICATION_ROOT"] = BASE_PATH or "/"
wsgi_app = cast(Any, ProxyFix(app.wsgi_app, x_host=1, x_port=1))
wsgi_app = cast(Any, ProxyFix(app.wsgi_app))
if BASE_PATH:
wsgi_app = cast(Any, PrefixMiddleware(wsgi_app, BASE_PATH, bypass_paths={"/api/health"}))
app.wsgi_app = wsgi_app
@@ -174,9 +167,6 @@ except ImportError as e:
# Migrate legacy security settings if needed
_migrate_security_settings()
# Widen audiobook formats for installs that still carry the old m4b/mp3-only default
migrate_audiobook_format_settings()
# Initialize user database and register multi-user routes
# If CONFIG_DIR doesn't exist or is read-only, multi-user features will be disabled
_user_db_path = str(Path(os.environ.get("CONFIG_DIR", "/config")) / "users.db")
@@ -209,10 +199,6 @@ except (sqlite3.OperationalError, OSError) as e:
# Start download coordinator
backend.start()
# Pre-solve the direct-download source's protection challenge in the background so the
# first user search does not pay for a cold Chrome bypass. Never blocks startup.
warmup.start()
# Rate limiting for login attempts
# Map usernames to their failed-attempt counters and lockout timestamps.
failed_login_attempts: dict[str, dict[str, Any]] = {}
@@ -332,7 +318,19 @@ def get_auth_mode() -> str:
_AUDIOBOOK_CATEGORY_RANGE = (3030, 3049)
_AUDIOBOOK_FORMAT_HINTS = frozenset(AUDIOBOOK_FORMATS)
_AUDIOBOOK_FORMAT_HINTS = frozenset(
{
"m4b",
"mp3",
"m4a",
"flac",
"ogg",
"wma",
"aac",
"wav",
"opus",
}
)
def _contains_audiobook_format_hint(value: Any) -> bool:
@@ -1026,9 +1024,6 @@ 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]:
@@ -1155,7 +1150,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.get("BOOK_LANGUAGE", ["en"], user_id=db_user_id),
"default_language": app_config.BOOK_LANGUAGE,
"supported_formats": app_config.SUPPORTED_FORMATS,
"supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS,
"search_mode": search_mode,
@@ -1167,9 +1162,6 @@ def api_config() -> Response | tuple[Response, int]:
"show_combined_selector": app_config.get(
"SHOW_COMBINED_SELECTOR", True, user_id=db_user_id
),
"force_combined_search": app_config.get(
"FORCE_COMBINED_SEARCH", False, user_id=db_user_id
),
"books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"),
"auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True),
"hardcover_auto_remove_on_download": app_config.get(
@@ -1180,12 +1172,6 @@ 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
@@ -1491,43 +1477,6 @@ def _download_row_owned_by_actor(
return False
def _resolve_queue_actor() -> tuple[bool, int | None, str | None, Response | None]:
is_admin, db_user_id, can_access_status = _resolve_status_scope()
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not is_admin and (not can_access_status or db_user_id is None):
return (
is_admin,
db_user_id,
normalized_actor_username,
jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}),
)
return is_admin, db_user_id, normalized_actor_username, None
def _queue_task_visible_to_actor(
task_id: str,
*,
is_admin: bool,
actor_user_id: int | None,
actor_username: str | None,
) -> bool:
if is_admin:
return True
task = backend.book_queue.get_task(task_id)
if task is None:
return False
return _task_owned_by_actor(
task,
actor_user_id=actor_user_id,
actor_username=actor_username,
)
backend.book_queue.set_queue_hook(_record_download_queued)
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
@@ -1637,7 +1586,6 @@ def api_local_download() -> Response | tuple[Response, int]:
@app.route("/api/covers/<cover_id>", methods=["GET"])
@login_required
def api_cover(cover_id: str) -> Response | tuple[Response, int]:
"""Serve a cached book cover image.
@@ -1649,6 +1597,9 @@ def api_cover(cover_id: str) -> Response | tuple[Response, int]:
Query Parameters:
url (str): Base64-encoded original image URL (required on first request)
w (int): Optional max width for a derived image variant
h (int): Optional max height for a derived image variant
format (str): Optional output format for a derived image variant (webp/png/jpeg)
Returns:
flask.Response: Binary image data with appropriate Content-Type, or 404.
@@ -1658,43 +1609,84 @@ def api_cover(cover_id: str) -> Response | tuple[Response, int]:
import base64
from shelfmark.config.env import is_covers_cache_enabled
from shelfmark.core.image_cache import get_image_cache
from shelfmark.core.image_cache import (
build_variant_cache_id,
create_image_variant,
get_image_cache,
normalize_variant_dimension,
normalize_variant_format,
)
# Check if caching is enabled
if not is_covers_cache_enabled():
return jsonify({"error": "Cover caching is disabled"}), 404
cache = get_image_cache()
width = normalize_variant_dimension(request.args.get("w"))
height = normalize_variant_dimension(request.args.get("h"))
image_format = normalize_variant_format(request.args.get("format"))
variant_cache_id = (
build_variant_cache_id(
cover_id,
width=width,
height=height,
image_format=image_format,
)
if width is not None or height is not None or image_format is not None
else None
)
# Try to get from cache first
cached = cache.get(cover_id)
if cached:
image_data, content_type = cached
def make_cover_response(
image_data: bytes,
content_type: str,
*,
cache_status: str,
) -> Response:
response = app.response_class(response=image_data, status=200, mimetype=content_type)
response.headers["Cache-Control"] = "public, max-age=86400"
response.headers["X-Cache"] = "HIT"
response.headers["X-Cache"] = cache_status
return response
# Try to get from cache first
cache_lookup_id = variant_cache_id or cover_id
cached = cache.get(cache_lookup_id)
if cached:
image_data, content_type = cached
return make_cover_response(image_data, content_type, cache_status="HIT")
# Cache miss - get URL from query parameter
encoded_url = request.args.get("url")
if not encoded_url:
return jsonify({"error": "Cover URL not provided"}), 404
original: tuple[bytes, str] | None = cache.get(cover_id) if variant_cache_id else None
try:
original_url = base64.urlsafe_b64decode(encoded_url).decode()
except (binascii.Error, UnicodeDecodeError) as e:
logger.warning("Failed to decode cover URL: %s", e)
return jsonify({"error": "Invalid cover URL encoding"}), 400
if original is None:
if not encoded_url:
return jsonify({"error": "Cover URL not provided"}), 404
# Fetch and cache the image
result = cache.fetch_and_cache(cover_id, original_url)
if not result:
return jsonify({"error": "Failed to fetch cover image"}), 404
try:
original_url = base64.urlsafe_b64decode(encoded_url).decode()
except (binascii.Error, UnicodeDecodeError) as e:
logger.warning("Failed to decode cover URL: %s", e)
return jsonify({"error": "Invalid cover URL encoding"}), 400
image_data, content_type = result
response = app.response_class(response=image_data, status=200, mimetype=content_type)
response.headers["Cache-Control"] = "public, max-age=86400"
response.headers["X-Cache"] = "MISS"
# Fetch and cache the original image
original = cache.fetch_and_cache(cover_id, original_url)
if not original:
return jsonify({"error": "Failed to fetch cover image"}), 404
image_data, content_type = original
if variant_cache_id:
variant = create_image_variant(
image_data,
width=width,
height=height,
image_format=image_format,
)
if variant:
image_data, content_type = variant
cache.put(variant_cache_id, image_data, content_type)
response = make_cover_response(image_data, content_type, cache_status="MISS")
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Cover fetch error: {e}")
return jsonify({"error": str(e)}), 500
@@ -1849,22 +1841,6 @@ def api_set_priority(book_id: str) -> Response | tuple[Response, int]:
return jsonify({"error": "Priority not provided"}), 400
priority = int(data["priority"])
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
task = backend.book_queue.get_task(book_id)
if task is None:
return jsonify({"error": "Failed to update priority or book not found"}), 404
if not is_admin and not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
success = backend.set_book_priority(book_id, priority)
if success:
@@ -1903,23 +1879,6 @@ def api_reorder_queue() -> Response | tuple[Response, int]:
if not isinstance(priority, int):
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
owned_book_priorities = {}
for book_id in book_priorities:
task = backend.book_queue.get_task(str(book_id))
if task is None:
continue
if not _task_owned_by_actor(
task, actor_user_id=db_user_id, actor_username=actor_username
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
owned_book_priorities[book_id] = book_priorities[book_id]
book_priorities = owned_book_priorities
success = backend.reorder_queue(book_priorities)
if success:
@@ -1941,20 +1900,6 @@ def api_queue_order() -> Response | tuple[Response, int]:
"""
try:
queue_order = backend.get_queue_order()
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
queue_order = [
item
for item in queue_order
if _queue_task_visible_to_actor(
str(item.get("id", "")),
is_admin=False,
actor_user_id=db_user_id,
actor_username=actor_username,
)
]
return jsonify({"queue": queue_order})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Queue order error: {e}")
@@ -1972,20 +1917,6 @@ def api_active_downloads() -> Response | tuple[Response, int]:
"""
try:
active_downloads = backend.get_active_downloads()
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
active_downloads = [
task_id
for task_id in active_downloads
if _queue_task_visible_to_actor(
task_id,
is_admin=False,
actor_user_id=db_user_id,
actor_username=actor_username,
)
]
return jsonify({"active_downloads": active_downloads})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Active downloads error: {e}")
@@ -2068,9 +1999,6 @@ def api_login() -> Response | tuple[Response, int]:
if auth_mode == "proxy":
return jsonify({"error": "Proxy authentication is enabled"}), 401
if auth_mode in ("builtin", "oidc") and DISABLE_LOCAL_AUTH:
return jsonify({"error": "Local authentication is disabled"}), 403
if auth_mode == "oidc" and HIDE_LOCAL_AUTH:
return jsonify({"error": "Local authentication is disabled"}), 403
@@ -2300,9 +2228,6 @@ def api_auth_check() -> Response | tuple[Response, int]:
if logout_url:
response_data["logout_url"] = logout_url
if auth_mode in ("builtin", "oidc") and DISABLE_LOCAL_AUTH:
response_data["hide_local_auth"] = True
# Add custom OIDC button label and SSO enforcement flags if configured
if auth_mode == "oidc":
oidc_button_label = app_config.get("OIDC_BUTTON_LABEL", "")
@@ -2851,7 +2776,6 @@ def api_releases() -> Response | tuple[Response, int]:
manual_query=query_text if source_query_filters is not None else manual_query,
indexers=indexers,
source_filters=source_query_filters,
user_id=db_user_id,
)
if plan.source_filters is not None:
@@ -2904,8 +2828,6 @@ 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()
@@ -2953,10 +2875,6 @@ 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(
@@ -2965,7 +2883,7 @@ def api_releases() -> Response | tuple[Response, int]:
provider_display_name="Manual Search",
title=resolved_title,
search_title=resolved_title,
search_author=authors[0] if authors else None,
search_author=resolved_author or None,
authors=authors,
)
else:
@@ -2998,36 +2916,18 @@ 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.
#
# 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.
# Search each source for releases
all_releases = []
errors = []
source_instances = {} # Keep source instances for column config
# 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)
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)
# Convert Release objects to dicts
releases_data = [_serialize_release(release) for release in all_releases]
+14 -17
View File
@@ -24,12 +24,12 @@ Dataclass representing a book from a metadata provider:
```python
@dataclass
class BookMetadata:
provider: str # Internal provider name (e.g., "hardcover")
provider_id: str # ID in that provider's system
provider: str # Internal provider name (e.g., "hardcover")
provider_id: str # ID in that provider's system
title: str
# Optional fields
provider_display_name: str # Human-readable name (e.g., "Hardcover")
provider_display_name: str # Human-readable name (e.g., "Hardcover")
authors: List[str]
isbn_10: str
isbn_13: str
@@ -39,7 +39,7 @@ class BookMetadata:
publish_year: int
language: str
genres: List[str]
source_url: str # Link to book on provider's site
source_url: str # Link to book on provider's site
display_fields: List[DisplayField] # Provider-specific display data
```
@@ -50,9 +50,9 @@ Provider-specific metadata for UI cards (ratings, page counts, reader counts, et
```python
@dataclass
class DisplayField:
label: str # e.g., "Rating", "Pages", "Readers"
value: str # e.g., "4.5", "496", "8,041"
icon: str # Icon name: "star", "book", "users", "editions"
label: str # e.g., "Rating", "Pages", "Readers"
value: str # e.g., "4.5", "496", "8,041"
icon: str # Icon name: "star", "book", "users", "editions"
```
### MetadataSearchOptions
@@ -64,7 +64,7 @@ Unified search options that work across all providers:
class MetadataSearchOptions:
query: str
search_type: SearchType = SearchType.GENERAL # GENERAL, TITLE, AUTHOR, ISBN
language: str = None # ISO 639-1 code (e.g., "en")
language: str = None # ISO 639-1 code (e.g., "en")
sort: SortOrder = SortOrder.RELEVANCE
limit: int = 40
page: int = 1
@@ -88,10 +88,10 @@ All providers must implement this interface:
```python
class MetadataProvider(ABC):
name: str # Internal identifier
display_name: str # Human-readable name
requires_auth: bool # True if API key required
supported_sorts: List[SortOrder] # Supported sort options
name: str # Internal identifier
display_name: str # Human-readable name
requires_auth: bool # True if API key required
supported_sorts: List[SortOrder] # Supported sort options
@abstractmethod
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
@@ -121,9 +121,9 @@ class MetadataProvider(ABC):
```python
from shelfmark.metadata_providers import register_provider
@register_provider("my_provider")
class MyProvider(MetadataProvider): ...
class MyProvider(MetadataProvider):
...
```
### Getting Providers
@@ -281,13 +281,11 @@ from shelfmark.config.env import (
METADATA_CACHE_BOOK_TTL,
)
@cacheable(ttl=METADATA_CACHE_SEARCH_TTL, key_prefix="myprovider:search")
def _search_cached(self, cache_key: str, options: MetadataSearchOptions):
# Cached search implementation
pass
@cacheable(ttl=METADATA_CACHE_BOOK_TTL, key_prefix="myprovider:book")
def get_book(self, book_id: str):
# Cached book lookup
@@ -304,7 +302,6 @@ from shelfmark.metadata_providers.openlibrary import RateLimiter
# 90 requests per 60 seconds
rate_limiter = RateLimiter(max_requests=90, window_seconds=60)
def make_request(self):
rate_limiter.wait_if_needed() # Blocks if rate limited
# ... make request
-3
View File
@@ -709,6 +709,3 @@ with suppress(ImportError):
with suppress(ImportError):
from shelfmark.metadata_providers import googlebooks as googlebooks
with suppress(ImportError):
from shelfmark.metadata_providers import moly as moly
+10 -19
View File
@@ -47,11 +47,6 @@ _HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
GOOGLE_BOOKS_BASE_URL = "https://www.googleapis.com/books/v1"
class _GoogleBooksRequestError(Exception):
"""Raised when Google Books does not return a usable API response."""
# Sort mapping - Google only supports "relevance" and "newest"
SORT_MAPPING: dict[SortOrder, str | None] = {
SortOrder.RELEVANCE: None, # Default, no param needed
@@ -122,10 +117,7 @@ class GoogleBooksProvider(MetadataProvider):
f"{options.query}:{options.search_type.value}:{options.sort.value}:"
f"{options.language}:{options.limit}:{options.page}:{fields_key}"
)
try:
return self._search_cached(cache_key, options)
except _GoogleBooksRequestError:
return []
return self._search_cached(cache_key, options)
@cacheable(
ttl_key="METADATA_CACHE_SEARCH_TTL",
@@ -174,19 +166,18 @@ class GoogleBooksProvider(MetadataProvider):
if options.language:
params["langRestrict"] = options.language
result = self._make_request("/volumes", params)
if result is None:
raise _GoogleBooksRequestError
books: list[BookMetadata] = []
try:
items = result.get("items", [])
for item in items:
book = self._parse_volume(item)
if book:
books.append(book)
result = self._make_request("/volumes", params)
if result:
items = result.get("items", [])
logger.info("Google Books search '%s' returned %s results", query, len(books))
for item in items:
book = self._parse_volume(item)
if book:
books.append(book)
logger.info("Google Books search '%s' returned %s results", query, len(books))
except Exception:
logger.exception("Google Books search error")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
"""Hardcover metadata provider package."""
from shelfmark.core.cache import get_metadata_cache
from shelfmark.core.config import config as app_config
from .auth import _get_connected_user_id, _get_connected_username, _save_connected_user
from .constants import (
HARDCOVER_LIST_ID_PREFIX,
HARDCOVER_STATUS_GROUP,
HARDCOVER_STATUS_PREFIX,
HARDCOVER_WRITABLE_TARGET_GROUPS,
)
from .models import HardcoverBookTargetState, HardcoverGraphQLError, HardcoverTargetPayloadError
from .parsing import _compute_search_title, _simplify_author_for_search
from .provider import HardcoverProvider
from .settings import hardcover_settings
__all__ = [
"HARDCOVER_LIST_ID_PREFIX",
"HARDCOVER_STATUS_GROUP",
"HARDCOVER_STATUS_PREFIX",
"HARDCOVER_WRITABLE_TARGET_GROUPS",
"HardcoverBookTargetState",
"HardcoverGraphQLError",
"HardcoverProvider",
"HardcoverTargetPayloadError",
"_compute_search_title",
"_get_connected_user_id",
"_get_connected_username",
"_save_connected_user",
"_simplify_author_for_search",
"app_config",
"get_metadata_cache",
"hardcover_settings",
]
@@ -0,0 +1,36 @@
"""Persistence helpers for the connected Hardcover account."""
def _save_connected_user(user_id: str | None, username: str | None) -> None:
"""Save or clear connected user metadata in config."""
from shelfmark.core.settings_registry import load_config_file, save_config_file
config = load_config_file("hardcover")
if user_id:
config["_connected_user_id"] = user_id
else:
config.pop("_connected_user_id", None)
if username:
config["_connected_username"] = username
else:
config.pop("_connected_username", None)
save_config_file("hardcover", config)
def _get_connected_username() -> str | None:
"""Get the stored connected username."""
from shelfmark.core.settings_registry import load_config_file
config = load_config_file("hardcover")
return config.get("_connected_username")
def _get_connected_user_id() -> str | None:
"""Get the stored connected Hardcover user id."""
from shelfmark.core.settings_registry import load_config_file
config = load_config_file("hardcover")
value = config.get("_connected_user_id")
return str(value) if value is not None else None
@@ -0,0 +1,105 @@
"""GraphQL transport helpers for Hardcover."""
from http import HTTPStatus
from typing import Any
import requests
from shelfmark.core.logger import setup_logger
from shelfmark.download.network import get_ssl_verify
from .constants import HARDCOVER_API_URL
from .models import HardcoverGraphQLError
logger = setup_logger(__name__)
def _extract_graphql_error_message(payload: Any) -> str:
"""Extract a readable message from a GraphQL error payload."""
if not isinstance(payload, dict):
return ""
errors = payload.get("errors", [])
if not isinstance(errors, list):
return ""
messages: list[str] = []
for error in errors:
if not isinstance(error, dict):
continue
message = str(error.get("message") or "").strip()
if message:
messages.append(message)
return "; ".join(messages)
class HardcoverClientMixin:
session: requests.Session
def _execute_query(
self,
query: str,
variables: dict[str, Any],
*,
raise_on_error: bool = False,
) -> dict | None:
"""Execute a GraphQL query and return data or None on error."""
def _raise_graphql_error(message: str) -> None:
raise HardcoverGraphQLError(message)
try:
response = self.session.post(
HARDCOVER_API_URL,
json={"query": query, "variables": variables},
timeout=15,
verify=get_ssl_verify(HARDCOVER_API_URL),
)
response.raise_for_status()
data = response.json()
if "errors" in data:
logger.error("GraphQL errors: %s", data["errors"])
if raise_on_error:
message = (
_extract_graphql_error_message(data) or "Hardcover rejected this request"
)
_raise_graphql_error(message)
return None
return data.get("data")
except requests.Timeout as e:
logger.warning("Hardcover API request timed out")
if raise_on_error:
msg = "Hardcover API request timed out"
raise RuntimeError(msg) from e
return None
except requests.HTTPError as e:
if e.response.status_code == HTTPStatus.UNAUTHORIZED:
logger.exception("Hardcover API key is invalid")
if raise_on_error:
msg = "Hardcover API key is invalid"
raise RuntimeError(msg) from e
else:
logger.exception("Hardcover API HTTP error")
if raise_on_error:
msg = f"Hardcover API HTTP error: {e}"
raise RuntimeError(msg) from e
return None
except HardcoverGraphQLError:
raise
except ValueError as e:
logger.exception("Hardcover API returned invalid JSON")
if raise_on_error:
msg = "Hardcover API returned an invalid response"
raise RuntimeError(msg) from e
return None
except (TypeError, requests.RequestException) as e:
logger.exception("Hardcover API request failed")
if raise_on_error:
msg = "Hardcover API request failed"
raise RuntimeError(msg) from e
return None
@@ -0,0 +1,61 @@
"""Constants for the Hardcover metadata provider."""
import re
from shelfmark.metadata_providers import SearchType, SortOrder
HARDCOVER_API_URL = "https://api.hardcover.app/v1/graphql"
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_API_KEY_MIN_LENGTH = 100
HARDCOVER_LIST_URL_PATTERN = re.compile(
r"^/(?:@([\w.-]+)/)?lists?/([\w-]+)/?$",
re.IGNORECASE,
)
HARDCOVER_STATUS_PREFIX = "status:"
HARDCOVER_STATUSES: list[dict] = [
{"id": 1, "label": "Want to Read", "slug": "want-to-read", "query_key": "want_to_read_count"},
{
"id": 2,
"label": "Currently Reading",
"slug": "currently-reading",
"query_key": "currently_reading_count",
},
{"id": 3, "label": "Read", "slug": "read", "query_key": "read_count"},
{
"id": 5,
"label": "Did Not Finish",
"slug": "did-not-finish",
"query_key": "did_not_finish_count",
},
]
HARDCOVER_STATUS_URL_SLUGS: dict[int, str] = {s["id"]: s["slug"] for s in HARDCOVER_STATUSES}
HARDCOVER_STATUS_GROUP = "Reading Status"
HARDCOVER_LIST_ID_PREFIX = "id:"
HARDCOVER_WRITABLE_TARGET_GROUPS = {HARDCOVER_STATUS_GROUP, "My Lists"}
SORT_MAPPING: dict[SortOrder, str] = {
SortOrder.RELEVANCE: "_text_match:desc,users_count:desc",
SortOrder.POPULARITY: "users_count:desc",
SortOrder.RATING: "rating:desc",
SortOrder.NEWEST: "release_year:desc",
SortOrder.OLDEST: "release_year:asc",
}
SEARCH_TYPE_FIELDS: dict[SearchType, str] = {
SearchType.GENERAL: "title,isbns,series_names,author_names,alternative_titles",
SearchType.TITLE: "title,alternative_titles",
SearchType.AUTHOR: "author_names",
# ISBN is handled separately via search_by_isbn()
}
SERIES_SEARCH_FIELDS = "name,books,author_name"
SERIES_SEARCH_WEIGHTS = "2,1,1"
SERIES_SEARCH_SORT = "_text_match:desc,readers_count:desc"
AUTHOR_SUGGESTION_FIELDS = "name,name_personal,alternate_names"
AUTHOR_SUGGESTION_WEIGHTS = "4,3,2"
AUTHOR_SUGGESTION_SORT = "_text_match:desc,books_count:desc"
TITLE_SUGGESTION_FIELDS = "title,alternative_titles"
TITLE_SUGGESTION_WEIGHTS = "5,2"
TITLE_SUGGESTION_SORT = "_text_match:desc,users_count:desc"
@@ -0,0 +1,400 @@
"""Hardcover list and status-shelf workflows."""
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from shelfmark.core.cache import cacheable
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_int
from shelfmark.metadata_providers import BookMetadata, SearchResult
from .auth import _get_connected_user_id, _get_connected_username, _save_connected_user
from .constants import (
HARDCOVER_LIST_URL_PATTERN,
HARDCOVER_STATUS_GROUP,
HARDCOVER_STATUS_PREFIX,
HARDCOVER_STATUS_URL_SLUGS,
HARDCOVER_STATUSES,
)
from .queries import (
LIST_BOOKS_BY_ID_QUERY,
LIST_LOOKUP_QUERY,
USER_BOOKS_BY_STATUS_QUERY,
USER_LISTS_QUERY,
)
logger = setup_logger(__name__)
class HardcoverListsMixin:
if TYPE_CHECKING:
api_key: str
def _execute_query(
self,
query: str,
variables: dict[str, Any],
*,
raise_on_error: bool = False,
) -> dict[str, Any] | None: ...
def _parse_book(self, book: dict[str, Any]) -> BookMetadata: ...
def _detect_list_url(self, query: str) -> tuple[str | None, str] | None:
"""Detect and extract optional owner username + list slug from a URL string."""
candidate = query.strip()
if not candidate:
return None
parsed = urlparse(candidate)
if parsed.scheme not in {"http", "https"}:
return None
hostname = (parsed.hostname or "").lower()
if hostname not in {"hardcover.app", "www.hardcover.app"}:
return None
match = HARDCOVER_LIST_URL_PATTERN.match(parsed.path or "")
if not match:
return None
owner_username = match.group(1).strip() if match.group(1) else None
slug = match.group(2).strip()
if not slug:
return None
return owner_username, slug
@cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:list:id")
def _fetch_list_books_by_id(self, list_id: int, page: int, limit: int) -> SearchResult:
"""Fetch list books by unique Hardcover list ID."""
if not self.api_key:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
offset = (page - 1) * limit
result = self._execute_query(
LIST_BOOKS_BY_ID_QUERY,
{
"id": list_id,
"limit": limit,
"offset": offset,
},
)
if not result:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
lists = result.get("lists", [])
if not lists:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
list_data = lists[0] if isinstance(lists[0], dict) else {}
list_books = list_data.get("list_books", []) if isinstance(list_data, dict) else []
books_count_raw = list_data.get("books_count", 0) if isinstance(list_data, dict) else 0
# Build source URL and title from list metadata
source_url = None
source_title = str(list_data.get("name") or "").strip() or None
list_slug = str(list_data.get("slug") or "").strip()
user_data = list_data.get("user", {})
owner_username = (
str(user_data.get("username") or "").strip() if isinstance(user_data, dict) else ""
)
if list_slug and owner_username:
source_url = f"https://hardcover.app/@{owner_username}/lists/{list_slug}"
try:
books_count = int(books_count_raw)
except TypeError, ValueError:
books_count = 0
books: list[BookMetadata] = []
for item in list_books:
if not isinstance(item, dict):
continue
book_data = item.get("book", {})
if not isinstance(book_data, dict) or not book_data:
continue
try:
parsed_book = self._parse_book(book_data)
if parsed_book:
books.append(parsed_book)
except (AttributeError, IndexError, KeyError, TypeError, ValueError) as exc:
logger.debug("Failed to parse Hardcover list book for list_id=%s: %s", list_id, exc)
has_more = offset + len(list_books) < books_count
return SearchResult(
books=books,
page=page,
total_found=books_count,
has_more=has_more,
source_url=source_url,
source_title=source_title,
)
@cacheable(
ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:list:slug"
)
def _fetch_list_books(
self, slug: str, owner_username: str | None, page: int, limit: int
) -> SearchResult:
"""Fetch list books by slug, optionally disambiguating by owner username."""
if not self.api_key:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
lookup = self._execute_query(LIST_LOOKUP_QUERY, {"slug": slug})
if not lookup:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
lists = lookup.get("lists", [])
if not isinstance(lists, list) or not lists:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
selected: dict[str, Any] | None = None
normalized_owner = owner_username.lower() if owner_username else None
if normalized_owner:
for item in lists:
if not isinstance(item, dict):
continue
owner_data = item.get("user", {})
if not isinstance(owner_data, dict):
continue
candidate_owner = str(owner_data.get("username") or "").strip().lower()
if candidate_owner == normalized_owner:
selected = item
break
if selected is None:
first_item = lists[0]
selected = first_item if isinstance(first_item, dict) else None
if not selected:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
list_id = coerce_int(selected.get("id"), 0)
if list_id < 1:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
return self._fetch_list_books_by_id(list_id, page, limit)
def _resolve_current_user_id(self) -> str | None:
"""Resolve current Hardcover user id from saved settings or API me query."""
connected_user_id = _get_connected_user_id()
if connected_user_id:
return connected_user_id
result = self._execute_query("query { me { id, username } }", {})
if not result:
return None
me_data = result.get("me", {})
if isinstance(me_data, list) and me_data:
me_data = me_data[0]
if not isinstance(me_data, dict):
return None
user_id_raw = me_data.get("id")
if user_id_raw is None:
return None
user_id = str(user_id_raw)
username_raw = me_data.get("username")
username = str(username_raw).strip() if username_raw else _get_connected_username()
_save_connected_user(user_id, username)
return user_id
def get_user_lists(self) -> list[dict[str, str]]:
"""Get authenticated user's own and followed Hardcover lists."""
if not self.api_key:
return []
connected_user_id = self._resolve_current_user_id()
if not connected_user_id:
return self._fetch_user_lists()
return self._get_user_lists_cached(connected_user_id)
@cacheable(ttl=120, key_prefix="hardcover:user_lists")
def _get_user_lists_cached(self, _cache_user_id: str) -> list[dict[str, str]]:
"""Return cached user lists keyed by Hardcover user id."""
return self._fetch_user_lists()
def _fetch_current_user_books_by_status(
self, status_id: int, page: int, limit: int
) -> SearchResult:
"""Fetch the current user's Hardcover books for a specific status shelf."""
if not self.api_key:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
connected_user_id = self._resolve_current_user_id()
if not connected_user_id:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
return self._fetch_user_books_by_status_cached(connected_user_id, status_id, page, limit)
@cacheable(
ttl_key="METADATA_CACHE_SEARCH_TTL",
ttl_default=300,
key_prefix="hardcover:user_books:status",
)
def _fetch_user_books_by_status_cached(
self,
_cache_user_id: str,
status_id: int,
page: int,
limit: int,
) -> SearchResult:
"""Return cached status-shelf books keyed by user id and shelf."""
return self._fetch_user_books_by_status(status_id, page, limit)
def _fetch_user_books_by_status(self, status_id: int, page: int, limit: int) -> SearchResult:
"""Fetch books from the current user's Hardcover status shelf."""
if not self.api_key:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
offset = (page - 1) * limit
result = self._execute_query(
USER_BOOKS_BY_STATUS_QUERY,
{
"statusId": status_id,
"limit": limit,
"offset": offset,
},
)
if not result:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
me_data = result.get("me", {})
if isinstance(me_data, list) and me_data:
me_data = me_data[0]
if not isinstance(me_data, dict):
return SearchResult(books=[], page=page, total_found=0, has_more=False)
status_books = me_data.get("status_books", [])
aggregate_data = me_data.get("status_books_aggregate", {})
aggregate = aggregate_data.get("aggregate", {}) if isinstance(aggregate_data, dict) else {}
count_raw = aggregate.get("count", 0) if isinstance(aggregate, dict) else 0
try:
total_found = int(count_raw)
except TypeError, ValueError:
total_found = 0
books: list[BookMetadata] = []
for item in status_books:
if not isinstance(item, dict):
continue
book_data = item.get("book", {})
if not isinstance(book_data, dict) or not book_data:
continue
try:
parsed_book = self._parse_book(book_data)
if parsed_book:
books.append(parsed_book)
except (AttributeError, KeyError, TypeError, ValueError) as exc:
logger.debug(
"Failed to parse Hardcover status book for status_id=%s: %s", status_id, exc
)
has_more = offset + len(status_books) < total_found
# Build source URL for the status shelf
source_url = None
url_slug = HARDCOVER_STATUS_URL_SLUGS.get(status_id)
username = _get_connected_username()
if url_slug and username:
source_url = f"https://hardcover.app/@{username}/books/{url_slug}"
return SearchResult(
books=books,
page=page,
total_found=total_found,
has_more=has_more,
source_url=source_url,
)
def _fetch_user_lists(self) -> list[dict[str, str]]:
"""Fetch raw list options from Hardcover me query."""
result = self._execute_query(USER_LISTS_QUERY, {})
if not result:
return []
me_data = result.get("me", {})
if isinstance(me_data, list) and me_data:
me_data = me_data[0]
if not isinstance(me_data, dict):
return []
options: list[dict[str, str]] = []
seen_values: set[str] = set()
current_username = str(me_data.get("username") or "").strip()
def _format_label(name: str, books_count: Any) -> str:
try:
return f"{name} ({int(books_count)})"
except TypeError, ValueError:
return name
for status in HARDCOVER_STATUSES:
count_data = me_data.get(status["query_key"], {})
aggregate = count_data.get("aggregate", {}) if isinstance(count_data, dict) else {}
count = aggregate.get("count") if isinstance(aggregate, dict) else None
value = f"{HARDCOVER_STATUS_PREFIX}{status['id']}"
seen_values.add(value)
options.append(
{
"value": value,
"label": _format_label(status["label"], count),
"group": HARDCOVER_STATUS_GROUP,
}
)
for list_item in me_data.get("lists", []):
if not isinstance(list_item, dict):
continue
list_id = list_item.get("id")
slug = str(list_item.get("slug") or "").strip()
name = str(list_item.get("name") or "").strip()
value = f"id:{list_id}" if list_id is not None else slug
if not value or not name or value in seen_values:
continue
seen_values.add(value)
options.append(
{
"value": value,
"label": _format_label(name, list_item.get("books_count")),
"group": "My Lists",
}
)
for followed_item in me_data.get("followed_lists", []):
if not isinstance(followed_item, dict):
continue
list_item = followed_item.get("list", {})
if not isinstance(list_item, dict):
continue
list_id = list_item.get("id")
slug = str(list_item.get("slug") or "").strip()
name = str(list_item.get("name") or "").strip()
value = f"id:{list_id}" if list_id is not None else slug
if not value or not name or value in seen_values:
continue
seen_values.add(value)
option: dict[str, str] = {
"value": value,
"label": _format_label(name, list_item.get("books_count")),
"group": "Followed Lists",
}
owner_data = list_item.get("user", {})
if isinstance(owner_data, dict):
owner_username = str(owner_data.get("username") or "").strip()
if owner_username:
option["description"] = f"by @{owner_username}"
elif current_username:
option["description"] = f"by @{current_username}"
options.append(option)
return options
@@ -0,0 +1,20 @@
"""Small Hardcover-specific models and errors."""
from dataclasses import dataclass
@dataclass(frozen=True)
class HardcoverBookTargetState:
"""Current Hardcover target state for a specific book."""
user_book_id: int | None
status_id: int | None
list_book_ids: dict[int, int]
class HardcoverGraphQLError(ValueError):
"""GraphQL request was rejected by Hardcover."""
class HardcoverTargetPayloadError(RuntimeError):
"""Hardcover returned an invalid payload while loading book targets."""
@@ -0,0 +1,611 @@
"""Parsing and search-normalization helpers for Hardcover payloads."""
import re
from contextlib import suppress
from datetime import datetime
from typing import Any
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.metadata_providers import BookMetadata, DisplayField
from .constants import HARDCOVER_MIN_AUTHOR_PARTS
logger = setup_logger(__name__)
def _combine_headline_description(headline: str | None, description: str | None) -> str | None:
"""Combine headline (tagline) and description into a single description."""
if headline and description:
return f"{headline}\n\n{description}"
return headline or description
def _extract_cover_url(data: dict, *keys: str) -> str | None:
"""Extract cover URL from data dict, trying multiple keys.
Handles both string URLs and dict with 'url' key.
"""
for key in keys:
value = data.get(key)
if value:
if isinstance(value, str):
return value
if isinstance(value, dict):
return value.get("url")
return None
def _extract_publish_year(data: dict) -> int | None:
"""Extract publish year from release_year or release_date fields."""
if data.get("release_year"):
try:
return int(data["release_year"])
except ValueError, TypeError:
pass
if data.get("release_date"):
try:
return int(str(data["release_date"])[:4])
except ValueError, TypeError:
pass
return None
def _parse_release_date(value: Any) -> datetime | None:
"""Parse Hardcover release dates stored as YYYY-MM-DD strings."""
if not value:
return None
normalized_value = str(value).strip()
if not normalized_value:
return None
try:
return datetime.fromisoformat(normalized_value[:10])
except ValueError:
return None
def _normalize_series_position(value: Any) -> float | None:
"""Normalize a series position to a float for sorting and grouping."""
if value is None:
return None
try:
return float(value)
except TypeError, ValueError:
return 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()
def _normalize_search_text(value: str) -> str:
"""Normalize free-text search input for matching and caching."""
return " ".join(value.split()).strip()
def _unwrap_hit_document(hit: Any) -> dict[str, Any] | None:
"""Extract the document dict from a Typesense hit, or return None."""
if not isinstance(hit, dict):
return None
item = hit.get("document", hit)
return item if isinstance(item, dict) else None
def _search_tokens(value: str) -> list[str]:
"""Tokenize search text for lightweight prefix matching."""
return re.findall(r"[a-z0-9']+", value.casefold())
def _query_matches_author_name(query: str, author_name: str) -> bool:
"""Return True when the query looks like an author-name search."""
normalized_query = _normalize_search_text(query)
normalized_author_name = _normalize_search_text(author_name)
if not normalized_query or not normalized_author_name:
return False
query_folded = normalized_query.casefold()
author_folded = normalized_author_name.casefold()
if query_folded in author_folded:
return True
query_tokens = _search_tokens(normalized_query)
author_tokens = _search_tokens(normalized_author_name)
if not query_tokens or not author_tokens:
return False
return all(
any(author_token.startswith(query_token) for author_token in author_tokens)
for query_token in query_tokens
)
def _split_part_base_title(title: str) -> str | None:
"""Extract the base title from segmented part releases like ', Part 2'."""
normalized_title = _normalize_search_text(title)
if not normalized_title:
return None
match = re.match(r"^(?P<base>.+?),\s*Part\s+\d+$", normalized_title, re.IGNORECASE)
if not match:
return None
base_title = str(match.group("base") or "").strip()
return base_title or None
def _series_allows_split_parts(series_name: str) -> bool:
"""Return True for series that intentionally organize split-part releases."""
normalized_name = _normalize_search_text(series_name).casefold()
if not normalized_name:
return False
markers = (
"dramatized adaptation",
"graphicaudio",
"graphic audio",
"(3 parts)",
"(2 parts)",
"(4 parts)",
)
return any(marker in normalized_name for marker in markers)
def _extract_typesense_hits(result: dict[str, Any]) -> tuple[list[dict[str, Any]], int]:
"""Extract hit documents + total count from Hardcover search output."""
root = result.get("search", result) if isinstance(result, dict) else {}
results_obj = root.get("results", {}) if isinstance(root, dict) else {}
if isinstance(results_obj, dict):
hits = results_obj.get("hits", [])
found_count = results_obj.get("found", 0)
else:
hits = results_obj if isinstance(results_obj, list) else []
found_count = 0
return hits, found_count
def _build_source_url(slug: str) -> str | None:
"""Build Hardcover source URL from book slug."""
return f"https://hardcover.app/books/{slug}" if slug else None
def _is_probably_series_position(subtitle: str) -> bool:
normalized = subtitle.strip().lower()
# Common patterns: "Book One", "Book 1", "Part 2", "Volume III", etc.
if re.match(
r"^(book|part|volume|vol\.?|episode)\s+([0-9]+|[ivxlcdm]+|one|two|three|four|five|six|seven|eight|nine|ten)\b",
normalized,
):
return True
# e.g. "A Novel", "An Epic Fantasy", etc. These add noise to indexer queries.
if normalized in {"a novel", "a novella", "a story", "a memoir"}:
return True
# Descriptive subtitles like "A [Name] Novel", "An [Name] Mystery", etc.
genre_words = (
"novel",
"novella",
"story",
"memoir",
"tale",
"thriller",
"mystery",
"romance",
"adventure",
"epic",
"saga",
"chronicle",
"fantasy",
"novel-in-stories",
)
genre_pattern = "|".join(re.escape(w) for w in genre_words)
return bool(re.match(rf"^an?\s+.+\s+({genre_pattern})$", normalized))
def _strip_parenthetical_suffix(title: str) -> str:
# Drop trailing qualifiers like "(Unabridged)", "(Illustrated Edition)", etc.
return re.sub(r"\s*\([^)]*\)\s*$", "", title).strip()
def _simplify_author_for_search(author: str) -> str | None:
"""Return a looser author string for indexer searches.
Primary goal: reduce mismatch between metadata providers and indexers.
Indexers store author names inconsistently ("R.A.", "R. A.", "Salvatore, R.A.")
so initials add noise and hurt recall.
Heuristics:
- Strip all initials (single or compound), keeping only full names
e.g. "R. A. Salvatore" -> "Salvatore", "George R.R. Martin" -> "George Martin"
- Preserve suffixes like "Jr."/"Sr."/"III" as they sometimes matter
"""
if not author:
return None
normalized = " ".join(author.split()).strip()
if not normalized:
return None
# Handle "Last, First ..." -> "First ... Last"
if "," in normalized:
parts = [p.strip() for p in normalized.split(",") if p.strip()]
if len(parts) >= HARDCOVER_MIN_AUTHOR_PARTS:
normalized = " ".join([*parts[1:], parts[0]]).strip()
tokens = normalized.split(" ")
if len(tokens) < HARDCOVER_MIN_AUTHOR_PARTS:
return None
keep_suffixes = {"jr", "jr.", "sr", "sr.", "ii", "iii", "iv", "v"}
simplified: list[str] = []
for idx, token in enumerate(tokens):
t = token.strip()
if not t:
continue
t_lower = t.lower()
is_suffix = (idx == len(tokens) - 1) and (t_lower in keep_suffixes)
if is_suffix:
simplified.append(t)
continue
# Drop all initials: "R.", "R", "R.R.", "J.K.", etc.
if re.match(r"^[A-Za-z]$|^([A-Za-z]\.)+[A-Za-z]?$", t):
continue
simplified.append(t)
if not simplified:
return None
candidate = " ".join(simplified).strip()
if candidate.lower() == normalized.lower():
return None
return candidate
def _compute_search_title(
title: str,
subtitle: str | None,
*,
series_name: str | None = None,
) -> str | None:
"""Compute a provider-specific, *looser* title for indexer searching.
Goal: produce a string that maximizes recall in downstream sources (Prowlarr,
IRC bots, etc.). Being too detailed is counterproductive.
Hardcover often stores titles in a "Series: Book Title" format and places the
standalone book title in `subtitle`. When this appears to be the case, prefer
the subtitle (unless it looks like a series position or other noise).
Additional heuristics:
- If Hardcover prefixes the series in the title, remove it.
- Drop trailing parenthetical qualifiers.
"""
if not title:
return None
original_title = " ".join(title.split()).strip()
normalized_title = _strip_parenthetical_suffix(original_title)
normalized_subtitle = " ".join(subtitle.split()).strip() if subtitle else ""
normalized_subtitle = (
_strip_parenthetical_suffix(normalized_subtitle) if normalized_subtitle else ""
)
if normalized_subtitle and normalized_subtitle.lower() == normalized_title.lower():
normalized_subtitle = ""
# If subtitle is noise, strip it from the title and use just the prefix.
if normalized_subtitle and _is_probably_series_position(normalized_subtitle):
match = re.match(r"^(.+?)\s*:\s*(.+)$", normalized_title)
if match:
suffix = _strip_parenthetical_suffix(match.group(2).strip())
if (
normalized_subtitle.lower() == suffix.lower()
or normalized_subtitle.lower() in suffix.lower()
):
return None
# Prefer subtitle when it looks like the real title.
if normalized_subtitle and not _is_probably_series_position(normalized_subtitle):
match = re.match(r"^(.+?)\s*:\s*(.+)$", normalized_title)
if match:
prefix = match.group(1).strip()
suffix = _strip_parenthetical_suffix(match.group(2).strip())
prefix_words = len(prefix.split()) if prefix else 0
subtitle_words = len(normalized_subtitle.split())
series_normalized = " ".join(series_name.split()).strip() if series_name else ""
if series_normalized and prefix.lower() == series_normalized.lower():
return normalized_subtitle
# If the subtitle is much longer than the prefix, treat it as a descriptive subtitle.
if prefix and subtitle_words >= (prefix_words + 4):
return prefix
# Otherwise assume "Series: Book Title" and prefer the subtitle.
if (
normalized_subtitle.lower() == suffix.lower()
or normalized_subtitle.lower() in suffix.lower()
):
return normalized_subtitle
# Fallback: if title contains the subtitle, this is likely "Series: Subtitle".
if normalized_subtitle.lower() in normalized_title.lower():
return normalized_subtitle
# If we know the series name (from full book fetch), strip it.
if series_name:
series_normalized = " ".join(series_name.split()).strip()
if series_normalized:
# Common Hardcover format: "Series: Book Title".
prefix = f"{series_normalized}:"
if normalized_title.lower().startswith(prefix.lower()):
candidate = normalized_title[len(prefix) :].strip()
candidate = _strip_parenthetical_suffix(candidate)
if candidate and candidate.lower() != normalized_title.lower():
return candidate
# Last resort: return a cleaned version of the title if we removed noise.
if normalized_title and normalized_title.lower() != original_title.lower():
return normalized_title
return None
class HardcoverParsingMixin:
def _parse_search_result(self, item: dict) -> BookMetadata | None:
"""Parse a search result item into BookMetadata."""
try:
book_id = item.get("id") or item.get("document", {}).get("id")
title = item.get("title") or item.get("document", {}).get("title")
if not book_id or not title:
return None
# Extract authors - use contribution_types to filter author_names if available
authors = []
author_names = item.get("author_names", [])
if isinstance(author_names, str):
author_names = [author_names]
contribution_types = item.get("contribution_types", [])
# If we have parallel arrays, filter to only "Author" contributions
if contribution_types and len(contribution_types) == len(author_names):
for name, contrib_type in zip(author_names, contribution_types, strict=True):
if contrib_type == "Author":
authors.append(name)
elif author_names:
# No contribution_types or length mismatch - use all names as fallback
authors = author_names
# Normalize whitespace in author names (some API data has multiple spaces)
authors = [" ".join(name.split()) for name in authors]
search_author = _simplify_author_for_search(authors[0]) if authors else None
cover_url = _extract_cover_url(item, "image")
publish_year = _extract_publish_year(item)
source_url = _build_source_url(item.get("slug", ""))
# Build display fields from Hardcover-specific data
display_fields = []
# Rating (e.g., "4.5 (3,764)")
rating = item.get("rating")
ratings_count = item.get("ratings_count")
if rating is not None:
rating_str = f"{rating:.1f}"
if ratings_count:
rating_str += f" ({ratings_count:,})"
display_fields.append(DisplayField(label="Rating", value=rating_str, icon="star"))
# Readers (users who have this book)
users_count = item.get("users_count")
if users_count:
display_fields.append(
DisplayField(label="Readers", value=f"{users_count:,}", icon="users")
)
# Combine headline and description if both present
headline = item.get("headline")
description = item.get("description")
full_description = _combine_headline_description(headline, description)
# Extract subtitle if available in search results
subtitle = item.get("subtitle")
return BookMetadata(
provider="hardcover",
provider_id=str(book_id),
title=title,
subtitle=subtitle,
search_title=_compute_search_title(title, subtitle),
search_author=search_author,
provider_display_name="Hardcover",
authors=authors,
cover_url=cover_url,
description=full_description,
publish_year=publish_year,
source_url=source_url,
display_fields=display_fields,
)
except (AttributeError, KeyError, TypeError, ValueError) as e:
logger.debug("Failed to parse Hardcover search result: %s", e)
return None
def _parse_book(self, book: dict) -> BookMetadata:
"""Parse a book object into BookMetadata."""
title = str(book.get("title") or "")
subtitle = book.get("subtitle")
# Extract authors - try contributions first (filtered), fall back to cached_contributors
authors = []
contributions = book.get("contributions") or []
cached_contributors = book.get("cached_contributors") or []
# Try contributions first (filtered to "Author" role only - cleaner data)
for contrib in contributions:
author = contrib.get("author", {})
if author and author.get("name"):
authors.append(author["name"])
# Fallback to cached_contributors if no authors found
if not authors:
for contrib in cached_contributors:
if isinstance(contrib, dict):
# Handle nested structure: {"author": {"name": "..."}, "contribution": ...}
if contrib.get("author", {}).get("name"):
authors.append(contrib["author"]["name"])
# Handle flat structure: {"name": "..."}
elif contrib.get("name"):
authors.append(contrib["name"])
elif isinstance(contrib, str):
authors.append(contrib)
# Normalize whitespace in author names (some API data has multiple spaces)
authors = [" ".join(name.split()) for name in authors]
search_author = _simplify_author_for_search(authors[0]) if authors else None
cover_url = _extract_cover_url(book, "cached_image", "image")
publish_year = _extract_publish_year(book)
# Extract genres from cached_tags
genres = []
for tag in book.get("cached_tags", []):
if isinstance(tag, dict) and tag.get("tag"):
genres.append(tag["tag"])
elif isinstance(tag, str):
genres.append(tag)
# Get ISBN from direct fields, default_physical_edition, or editions
isbn_10 = book.get("isbn_10")
isbn_13 = book.get("isbn_13")
if not isbn_10 and not isbn_13:
# Try default_physical_edition first
edition = book.get("default_physical_edition")
if edition:
isbn_10 = edition.get("isbn_10")
isbn_13 = edition.get("isbn_13")
# Fallback to editions array
if not isbn_10 and not isbn_13 and book.get("editions"):
for ed in book["editions"]:
if not isbn_10 and ed.get("isbn_10"):
isbn_10 = ed["isbn_10"]
if not isbn_13 and ed.get("isbn_13"):
isbn_13 = ed["isbn_13"]
if isbn_10 and isbn_13:
break
source_url = _build_source_url(book.get("slug", ""))
# Combine headline and description if both present
headline = book.get("headline")
description = book.get("description")
full_description = _combine_headline_description(headline, description)
# Extract series info from featured_book_series
series_id = None
series_name = None
series_position = None
series_count = None
featured_series = book.get("featured_book_series")
if featured_series:
series_position = featured_series.get("position")
series_data = featured_series.get("series")
if series_data:
if series_data.get("id") is not None:
series_id = str(series_data.get("id"))
series_name = series_data.get("name")
series_count = series_data.get("primary_books_count")
# Extract titles by language from editions
# This allows searching with localized titles when language filter is active
titles_by_language: dict[str, str] = {}
editions = book.get("editions", [])
for edition in editions:
edition_title = edition.get("title")
lang_data = edition.get("language")
if edition_title and lang_data:
# Store by various language identifiers for flexible matching
# Language name (e.g., "German", "English")
lang_name = lang_data.get("language")
# 2-letter code (e.g., "de", "en")
code2 = lang_data.get("code2")
# 3-letter code (e.g., "deu", "eng")
code3 = lang_data.get("code3")
# Store with all available keys (first title wins for each language)
if lang_name and lang_name not in titles_by_language:
titles_by_language[lang_name] = edition_title
if code2 and code2 not in titles_by_language:
titles_by_language[code2] = edition_title
if code3 and code3 not in titles_by_language:
titles_by_language[code3] = edition_title
# Build display fields from Hardcover-specific metrics
display_fields: list[DisplayField] = []
rating = book.get("rating")
ratings_count = book.get("ratings_count")
if rating is not None:
try:
rating_str = f"{float(rating):.1f}"
except TypeError, ValueError:
rating_str = str(rating)
if ratings_count:
with suppress(TypeError, ValueError):
rating_str += f" ({int(ratings_count):,})"
display_fields.append(DisplayField(label="Rating", value=rating_str, icon="star"))
users_count = book.get("users_count")
if users_count:
try:
readers_value = f"{int(users_count):,}"
except TypeError, ValueError:
readers_value = str(users_count)
display_fields.append(DisplayField(label="Readers", value=readers_value, icon="users"))
return BookMetadata(
provider="hardcover",
provider_id=str(book["id"]),
title=title,
subtitle=subtitle,
search_title=_compute_search_title(title, subtitle, series_name=series_name),
search_author=search_author,
provider_display_name="Hardcover",
authors=authors,
isbn_10=isbn_10,
isbn_13=isbn_13,
cover_url=cover_url,
description=full_description,
publish_year=publish_year,
genres=genres,
source_url=source_url,
series_id=series_id,
series_name=series_name,
series_position=series_position,
series_count=series_count,
titles_by_language=titles_by_language,
display_fields=display_fields,
)
@@ -0,0 +1,106 @@
"""Hardcover.app metadata provider. Requires API key."""
from typing import Any, ClassVar
import requests
from shelfmark.core.config import config as app_config
from shelfmark.metadata_providers import (
DynamicSelectSearchField,
MetadataCapability,
MetadataProvider,
SearchField,
SortOrder,
TextSearchField,
register_provider,
register_provider_kwargs,
)
from .client import HardcoverClientMixin
from .lists import HardcoverListsMixin
from .parsing import HardcoverParsingMixin, _normalize_hardcover_api_key
from .search import HardcoverSearchMixin
from .targets import HardcoverTargetsMixin
@register_provider_kwargs("hardcover")
def _hardcover_kwargs() -> dict[str, Any]:
"""Provide Hardcover-specific constructor kwargs."""
return {"api_key": app_config.get("HARDCOVER_API_KEY", "")}
@register_provider("hardcover")
class HardcoverProvider(
HardcoverSearchMixin,
HardcoverListsMixin,
HardcoverTargetsMixin,
HardcoverClientMixin,
HardcoverParsingMixin,
MetadataProvider,
):
"""Hardcover.app metadata provider using GraphQL API."""
name = "hardcover"
display_name = "Hardcover"
requires_auth = True
supported_sorts: ClassVar[tuple[SortOrder, ...]] = (
SortOrder.RELEVANCE,
SortOrder.POPULARITY,
SortOrder.RATING,
SortOrder.NEWEST,
SortOrder.OLDEST,
SortOrder.SERIES_ORDER,
)
capabilities: ClassVar[tuple[MetadataCapability, ...]] = (
MetadataCapability(
key="view_series",
field_key="series",
sort=SortOrder.SERIES_ORDER,
),
)
search_fields: ClassVar[tuple[SearchField, ...]] = (
TextSearchField(
key="author",
label="Author",
placeholder="Search author...",
description="Search by author name",
suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=author",
),
TextSearchField(
key="title",
label="Title",
placeholder="Search title...",
description="Search by book title",
),
TextSearchField(
key="series",
label="Series",
placeholder="Search series...",
description="Search by series name",
suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=series",
),
DynamicSelectSearchField(
key="hardcover_list",
label="List",
options_endpoint="/api/metadata/field-options?provider=hardcover&field=hardcover_list",
placeholder="Browse a list...",
description="Browse books from a Hardcover list",
),
)
def __init__(self, api_key: str | None = None) -> None:
"""Initialize provider with optional API key (falls back to config)."""
raw_key = api_key or app_config.get("HARDCOVER_API_KEY", "")
self.api_key = _normalize_hardcover_api_key(raw_key)
self.session = requests.Session()
if self.api_key:
self.session.headers.update(
{
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
)
def is_available(self) -> bool:
"""Check if provider is configured with an API key."""
return bool(self.api_key)
@@ -0,0 +1,525 @@
"""GraphQL operations used by the Hardcover metadata provider."""
LIST_LOOKUP_QUERY = """
query LookupListsBySlug($slug: String!) {
lists(where: {slug: {_eq: $slug}}, limit: 20) {
id
slug
user {
username
}
}
}
"""
LIST_BOOKS_BY_ID_QUERY = """
query GetListBooksById($id: Int!, $limit: Int!, $offset: Int!) {
lists(where: {id: {_eq: $id}}, limit: 1) {
name
slug
user {
username
}
books_count
list_books(order_by: {position: asc}, limit: $limit, offset: $offset) {
book {
id
title
subtitle
slug
release_date
headline
description
pages
rating
ratings_count
users_count
cached_image
cached_contributors
contributions(where: {contribution: {_eq: "Author"}}) {
author {
name
}
}
featured_book_series {
position
series {
id
name
primary_books_count
}
}
}
}
}
}
"""
USER_LISTS_QUERY = """
query GetUserLists {
me {
id
username
want_to_read_count: user_books_aggregate(where: {status_id: {_eq: 1}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
}
currently_reading_count: user_books_aggregate(where: {status_id: {_eq: 2}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
}
read_count: user_books_aggregate(where: {status_id: {_eq: 3}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
}
did_not_finish_count: user_books_aggregate(where: {status_id: {_eq: 5}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
}
lists(order_by: {name: asc}) {
id
name
slug
books_count
}
followed_lists(order_by: {created_at: desc}) {
list {
id
name
slug
books_count
user {
username
}
}
}
}
}
"""
USER_BOOKS_BY_STATUS_QUERY = """
query GetCurrentUserBooksByStatus($statusId: Int!, $limit: Int!, $offset: Int!) {
me {
status_books: user_books(
where: {status_id: {_eq: $statusId}}
distinct_on: [book_id]
order_by: [{book_id: asc}, {created_at: desc}]
limit: $limit
offset: $offset
) {
book {
id
title
subtitle
slug
release_date
headline
description
pages
rating
ratings_count
users_count
cached_image
cached_contributors
contributions(where: {contribution: {_eq: "Author"}}) {
author {
name
}
}
featured_book_series {
position
series {
id
name
primary_books_count
}
}
}
}
status_books_aggregate: user_books_aggregate(where: {status_id: {_eq: $statusId}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
}
}
}
"""
BOOK_TARGET_MEMBERSHIP_QUERY = """
query GetBookTargetMembership($bookId: Int!) {
me {
user_books(where: {book_id: {_eq: $bookId}}, limit: 1, order_by: [{created_at: desc}]) {
id
status_id
}
lists {
id
list_books(where: {book_id: {_eq: $bookId}}, limit: 1) {
id
}
}
}
}
"""
BOOK_TARGET_MEMBERSHIP_BATCH_QUERY = """
query GetBookTargetMembershipBatch($bookIds: [Int!]!) {
me {
user_books(where: {book_id: {_in: $bookIds}}, order_by: [{created_at: desc}]) {
id
book_id
status_id
}
lists {
id
list_books(where: {book_id: {_in: $bookIds}}) {
id
book_id
}
}
}
}
"""
INSERT_USER_BOOK_MUTATION = """
mutation AddBookToStatus($bookId: Int!, $statusId: Int!) {
insert_user_book(object: {book_id: $bookId, status_id: $statusId}) {
id
error
user_book {
id
book_id
status_id
}
}
}
"""
UPDATE_USER_BOOK_MUTATION = """
mutation UpdateBookStatus($userBookId: Int!, $statusId: Int!) {
update_user_book(id: $userBookId, object: {status_id: $statusId}) {
id
error
user_book {
id
book_id
status_id
}
}
}
"""
DELETE_USER_BOOK_MUTATION = """
mutation RemoveBookStatus($userBookId: Int!) {
delete_user_book(id: $userBookId) {
id
book_id
user_id
}
}
"""
INSERT_LIST_BOOK_MUTATION = """
mutation AddBookToList($bookId: Int!, $listId: Int!) {
insert_list_book(object: {book_id: $bookId, list_id: $listId}) {
id
list_book {
id
book_id
list_id
}
}
}
"""
DELETE_LIST_BOOK_MUTATION = """
mutation RemoveBookFromList($listBookId: Int!) {
delete_list_book(id: $listBookId) {
id
list_id
}
}
"""
SEARCH_FIELD_OPTIONS_QUERY = """
query SearchFieldOptions(
$query: String!,
$queryType: String!,
$limit: Int!,
$page: Int!,
$sort: String,
$fields: String,
$weights: String
) {
search(
query: $query,
query_type: $queryType,
per_page: $limit,
page: $page,
sort: $sort,
fields: $fields,
weights: $weights
) {
results
}
}
"""
SERIES_BY_AUTHOR_IDS_QUERY = """
query SeriesByAuthorIds($authorIds: [Int!], $limit: Int!) {
series(
where: {
author_id: {_in: $authorIds},
canonical_id: {_is_null: true},
state: {_eq: "active"}
},
limit: $limit,
order_by: [{primary_books_count: desc_nulls_last}, {books_count: desc}, {name: asc}]
) {
id
name
primary_books_count
books_count
author {
name
}
}
}
"""
SERIES_BOOKS_BY_ID_QUERY = """
query GetSeriesBooks($seriesId: Int!) {
series(where: {id: {_eq: $seriesId}}, limit: 1) {
id
name
primary_books_count
book_series(
where: {
book: {
canonical_id: {_is_null: true},
state: {_in: ["normalized", "normalizing"]}
}
}
order_by: [{position: asc_nulls_last}, {book_id: asc}]
) {
position
book {
id
title
subtitle
slug
release_date
headline
description
pages
rating
ratings_count
users_count
compilation
editions_count
cached_image
cached_contributors
contributions(where: {contribution: {_eq: "Author"}}) {
author {
name
}
}
featured_book_series {
position
series {
id
name
primary_books_count
}
}
}
}
}
}
"""
AUTHOR_BOOKS_BY_ID_QUERY = """
query GetAuthorBooks($authorId: Int!, $limit: Int!, $offset: Int!) {
authors(where: {id: {_eq: $authorId}}, limit: 1) {
name
contributions(
where: {
contributable_type: {_eq: "Book"},
book: {
canonical_id: {_is_null: true},
state: {_in: ["normalized", "normalizing"]}
}
},
order_by: [
{book: {users_count: desc_nulls_last}},
{book: {ratings_count: desc_nulls_last}},
{book: {release_date: asc_nulls_last}},
{book: {id: asc}}
],
limit: $limit,
offset: $offset
) {
contribution
book {
id
title
subtitle
slug
release_date
headline
description
pages
rating
ratings_count
users_count
compilation
editions_count
cached_image
cached_contributors
contributions(where: {contribution: {_eq: "Author"}}) {
author {
name
}
}
featured_book_series {
position
series {
id
name
primary_books_count
}
}
}
}
contributions_aggregate(
where: {
contributable_type: {_eq: "Book"},
book: {
canonical_id: {_is_null: true},
state: {_in: ["normalized", "normalizing"]}
}
}
) {
aggregate {
count
}
}
}
}
"""
SEARCH_BOOKS_WITH_FIELDS_QUERY = """
query SearchBooks(
$query: String!,
$limit: Int!,
$page: Int!,
$sort: String,
$fields: String,
$weights: String
) {
search(
query: $query,
query_type: "Book",
per_page: $limit,
page: $page,
sort: $sort,
fields: $fields,
weights: $weights
) {
results
}
}
"""
SEARCH_BOOKS_QUERY = """
query SearchBooks($query: String!, $limit: Int!, $page: Int!, $sort: String) {
search(query: $query, query_type: "Book", per_page: $limit, page: $page, sort: $sort) {
results
}
}
"""
GET_BOOK_QUERY = """
query GetBook($id: Int!) {
books(where: {id: {_eq: $id}}, limit: 1) {
id
title
subtitle
slug
release_date
headline
description
pages
cached_image
cached_tags
cached_contributors
contributions(where: {contribution: {_eq: "Author"}}) {
author {
name
}
}
default_physical_edition {
isbn_10
isbn_13
}
featured_book_series {
position
series {
id
name
primary_books_count
}
}
editions(
distinct_on: language_id
order_by: [{language_id: asc}, {users_count: desc}]
limit: 200
) {
title
language {
language
code2
code3
}
}
}
}
"""
SEARCH_BY_ISBN_QUERY = """
query SearchByISBN($isbn: String!) {
editions(
where: {
_or: [
{isbn_10: {_eq: $isbn}},
{isbn_13: {_eq: $isbn}}
]
},
limit: 1
) {
isbn_10
isbn_13
book {
id
title
subtitle
slug
release_date
headline
description
pages
cached_image
cached_tags
contributions(where: {contribution: {_eq: "Author"}}) {
author {
name
}
}
}
}
}
"""
@@ -0,0 +1,844 @@
"""Search, typeahead, series, and book lookup workflows for Hardcover."""
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from shelfmark.core.cache import cacheable
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_bool, coerce_int
from shelfmark.metadata_providers import (
BookMetadata,
MetadataSearchOptions,
SearchResult,
SearchType,
SortOrder,
)
from .constants import (
AUTHOR_SUGGESTION_FIELDS,
AUTHOR_SUGGESTION_SORT,
AUTHOR_SUGGESTION_WEIGHTS,
HARDCOVER_LIST_ID_PREFIX,
HARDCOVER_MAX_SERIES_OPTIONS,
HARDCOVER_MIN_TYPEAHEAD_QUERY_LENGTH,
HARDCOVER_PAGE_SIZE,
HARDCOVER_STATUS_PREFIX,
SERIES_SEARCH_FIELDS,
SERIES_SEARCH_SORT,
SERIES_SEARCH_WEIGHTS,
SORT_MAPPING,
TITLE_SUGGESTION_FIELDS,
TITLE_SUGGESTION_SORT,
TITLE_SUGGESTION_WEIGHTS,
)
from .parsing import (
_extract_typesense_hits,
_normalize_search_text,
_normalize_series_position,
_parse_release_date,
_query_matches_author_name,
_series_allows_split_parts,
_split_part_base_title,
_unwrap_hit_document,
)
from .queries import (
AUTHOR_BOOKS_BY_ID_QUERY,
GET_BOOK_QUERY,
SEARCH_BOOKS_QUERY,
SEARCH_BOOKS_WITH_FIELDS_QUERY,
SEARCH_BY_ISBN_QUERY,
SEARCH_FIELD_OPTIONS_QUERY,
SERIES_BOOKS_BY_ID_QUERY,
SERIES_BY_AUTHOR_IDS_QUERY,
)
logger = setup_logger(__name__)
class HardcoverSearchMixin:
if TYPE_CHECKING:
api_key: str
def _detect_list_url(self, query: str) -> tuple[str | None, str] | None: ...
def _execute_query(
self,
query: str,
variables: dict[str, Any],
*,
raise_on_error: bool = False,
) -> dict[str, Any] | None: ...
def _fetch_current_user_books_by_status(
self, status_id: int, page: int, limit: int
) -> SearchResult: ...
def _fetch_list_books(
self, slug: str, owner_username: str | None, page: int, limit: int
) -> SearchResult: ...
def _fetch_list_books_by_id(self, list_id: int, page: int, limit: int) -> SearchResult: ...
def _parse_book(self, book: dict[str, Any]) -> BookMetadata: ...
@staticmethod
def _parse_prefixed_int(value: str, label: str = "target") -> int: ...
def _parse_search_result(self, item: dict[str, Any]) -> BookMetadata | None: ...
def get_user_lists(self) -> list[dict[str, str]]: ...
def _build_search_params(
self, default_query: str, author: str, title: str, series: str
) -> tuple[str, str | None, str | None]:
"""Build search query, fields, and weights based on provided values.
Returns (query, fields, weights) tuple. Fields/weights are None for general search.
"""
if author and not title and not series:
return author, None, None
if title and not author and not series:
return title, "title,alternative_titles", "5,1"
if author and title and not series:
return f"{title} {author}", "title,alternative_titles,author_names", "5,1,3"
return default_query, None, None
def get_search_field_options(
self,
field_key: str,
query: str | None = None,
) -> list[dict[str, str]]:
"""Provide dynamic options for Hardcover-specific advanced fields."""
if field_key == "author":
return self._search_author_options(query or "")
if field_key == "title":
return self._search_title_options(query or "")
if field_key == "series":
return self._search_series_options(query or "")
if field_key == "hardcover_list":
return self.get_user_lists()
return []
def _search_field_hits(
self,
*,
query: str,
query_type: str,
limit: int,
sort: str | None,
fields: str | None,
weights: str | None,
) -> list[dict[str, Any]]:
"""Run a Hardcover search request for field-level typeahead options."""
normalized_query = _normalize_search_text(query)
if not self.api_key or len(normalized_query) < HARDCOVER_MIN_TYPEAHEAD_QUERY_LENGTH:
return []
result = self._execute_query(
SEARCH_FIELD_OPTIONS_QUERY,
{
"query": normalized_query,
"queryType": query_type,
"limit": limit,
"page": 1,
"sort": sort,
"fields": fields,
"weights": weights,
},
)
if not result:
return []
hits, _found_count = _extract_typesense_hits(result)
return hits
def _search_series_by_matching_author(self, query: str) -> list[dict[str, Any]]:
"""Return direct series rows when the query clearly matches an author."""
author_hits = self._search_field_hits(
query=query,
query_type="Author",
limit=2,
sort=AUTHOR_SUGGESTION_SORT,
fields=AUTHOR_SUGGESTION_FIELDS,
weights=AUTHOR_SUGGESTION_WEIGHTS,
)
author_ids: list[int] = []
for hit in author_hits:
item = _unwrap_hit_document(hit)
if item is None:
continue
author_name = str(item.get("name") or "").strip()
if not _query_matches_author_name(query, author_name):
continue
author_id = coerce_int(item.get("id"), 0)
if author_id < 1:
continue
if author_id not in author_ids:
author_ids.append(author_id)
if not author_ids:
return []
result = self._execute_query(
SERIES_BY_AUTHOR_IDS_QUERY,
{
"authorIds": author_ids,
"limit": 7,
},
)
if not result:
return []
series_rows = result.get("series", [])
return [row for row in series_rows if isinstance(row, dict)]
@cacheable(ttl=120, key_prefix="hardcover:author:options")
def _search_author_options(self, query: str) -> list[dict[str, str]]:
"""Return typeahead options for Hardcover author search."""
hits = self._search_field_hits(
query=query,
query_type="Author",
limit=7,
sort=AUTHOR_SUGGESTION_SORT,
fields=AUTHOR_SUGGESTION_FIELDS,
weights=AUTHOR_SUGGESTION_WEIGHTS,
)
options: list[dict[str, str]] = []
seen_labels: set[str] = set()
for hit in hits:
item = _unwrap_hit_document(hit)
if item is None:
continue
author_id = coerce_int(item.get("id"), 0)
label = str(item.get("name") or "").strip()
normalized_label = label.casefold()
if author_id < 1 or not label or normalized_label in seen_labels:
continue
seen_labels.add(normalized_label)
options.append({"value": f"id:{author_id}", "label": label})
return options
@cacheable(ttl=120, key_prefix="hardcover:title:options")
def _search_title_options(self, query: str) -> list[dict[str, str]]:
"""Return typeahead options for Hardcover title search."""
hits = self._search_field_hits(
query=query,
query_type="Book",
limit=7,
sort=TITLE_SUGGESTION_SORT,
fields=TITLE_SUGGESTION_FIELDS,
weights=TITLE_SUGGESTION_WEIGHTS,
)
exclude_compilations = coerce_bool(
app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False),
default=False,
)
exclude_unreleased = coerce_bool(
app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False),
default=False,
)
current_year = datetime.now(UTC).year
options: list[dict[str, str]] = []
seen_labels: set[str] = set()
for hit in hits:
item = _unwrap_hit_document(hit)
if item is None:
continue
if exclude_compilations and item.get("compilation"):
continue
if exclude_unreleased:
release_year = item.get("release_year")
try:
if release_year is not None and int(release_year) > current_year:
continue
except TypeError, ValueError:
pass
label = str(item.get("title") or "").strip()
normalized_label = label.casefold()
if not label or normalized_label in seen_labels:
continue
seen_labels.add(normalized_label)
options.append({"value": label, "label": label})
return options
def _format_series_option_description(self, item: dict[str, Any]) -> str | None:
"""Build a short description for a series suggestion option."""
author_name = item.get("author_name")
if not author_name:
author_data = item.get("author")
if isinstance(author_data, dict):
author_name = author_data.get("name")
parts: list[str] = []
if author_name:
parts.append(f"by {author_name}")
books_count = item.get("primary_books_count")
if books_count is None:
books_count = item.get("books_count")
try:
if books_count is not None:
books_count_int = int(books_count)
parts.append(f"{books_count_int} book{'s' if books_count_int != 1 else ''}")
except TypeError, ValueError:
pass
return " • ".join(parts) if parts else None
@cacheable(ttl=120, key_prefix="hardcover:series:options")
def _search_series_options(self, query: str) -> list[dict[str, str]]:
"""Return typeahead options for Hardcover series search."""
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=2) as executor:
author_future = executor.submit(self._search_series_by_matching_author, query)
series_future = executor.submit(
self._search_field_hits,
query=query,
query_type="Series",
limit=7,
sort=SERIES_SEARCH_SORT,
fields=SERIES_SEARCH_FIELDS,
weights=SERIES_SEARCH_WEIGHTS,
)
author_series = author_future.result()
hits = series_future.result()
options: list[dict[str, str]] = []
seen_values: set[str] = set()
series_items: list[dict[str, Any]] = []
series_items.extend(author_series)
series_items.extend(doc for hit in hits if (doc := _unwrap_hit_document(hit)) is not None)
for item in series_items:
series_id = item.get("id")
name = str(item.get("name") or "").strip()
if series_id is None or not name:
continue
value = f"id:{series_id}"
if value in seen_values:
continue
seen_values.add(value)
option: dict[str, str] = {
"value": value,
"label": name,
}
description = self._format_series_option_description(item)
if description:
option["description"] = description
options.append(option)
if len(options) >= HARDCOVER_MAX_SERIES_OPTIONS:
break
return options
def _resolve_series_search_value(self, series_value: str) -> dict[str, Any] | None:
"""Resolve a series field value to a canonical Hardcover series."""
normalized_value = _normalize_search_text(series_value)
if not normalized_value:
return None
if normalized_value.startswith(HARDCOVER_LIST_ID_PREFIX):
try:
return {"id": self._parse_prefixed_int(normalized_value, "series id")}
except ValueError:
logger.debug("Invalid Hardcover series id field value: %s", normalized_value)
return None
result = self._execute_query(
SEARCH_FIELD_OPTIONS_QUERY,
{
"query": normalized_value,
"queryType": "Series",
"limit": 10,
"page": 1,
"sort": SERIES_SEARCH_SORT,
"fields": SERIES_SEARCH_FIELDS,
"weights": SERIES_SEARCH_WEIGHTS,
},
)
if not result:
return None
hits, _found_count = _extract_typesense_hits(result)
if not hits:
return None
normalized_lookup = normalized_value.lower()
candidates: list[dict[str, Any]] = []
for hit in hits:
item = _unwrap_hit_document(hit)
if item is None:
continue
series_id = coerce_int(item.get("id"), 0)
if series_id < 1:
continue
name = str(item.get("name") or "").strip()
if not name:
continue
candidates.append({"id": series_id, "name": name})
if not candidates:
return None
exact_match = next(
(
candidate
for candidate in candidates
if candidate["name"].lower() == normalized_lookup
),
None,
)
return exact_match or candidates[0]
@cacheable(
ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:series:rows:v4"
)
def _fetch_series_ordered_rows(
self,
series_id: int,
*,
exclude_compilations: bool,
exclude_unreleased: bool,
) -> dict[str, Any]:
"""Fetch and process all books for a series (cached independently of page)."""
empty: dict[str, Any] = {"rows": [], "series_name": "", "total": 0}
if not self.api_key:
return empty
result = self._execute_query(
SERIES_BOOKS_BY_ID_QUERY,
{"seriesId": series_id},
)
if not result:
return empty
series_items = result.get("series", [])
if not isinstance(series_items, list) or not series_items:
return empty
series_data = series_items[0] if isinstance(series_items[0], dict) else {}
series_name = (
str(series_data.get("name") or "").strip() if isinstance(series_data, dict) else ""
)
allow_split_parts = _series_allows_split_parts(series_name)
today = datetime.now(UTC).date()
book_series_rows = (
series_data.get("book_series", []) if isinstance(series_data, dict) else []
)
rows_by_position: dict[float, dict[str, Any]] = {}
for row in book_series_rows:
if not isinstance(row, dict):
continue
book_data = row.get("book", {})
if not isinstance(book_data, dict) or not book_data:
continue
if exclude_compilations and book_data.get("compilation"):
continue
if not allow_split_parts and _split_part_base_title(str(book_data.get("title") or "")):
continue
position = _normalize_series_position(row.get("position"))
if position is None:
continue
release_date = _parse_release_date(book_data.get("release_date"))
if exclude_unreleased and (release_date is None or release_date.date() > today):
continue
sort_key = (
1 if release_date and release_date.date() <= today else 0,
0 if book_data.get("compilation") else 1,
coerce_int(book_data.get("users_count"), 0),
coerce_int(book_data.get("ratings_count"), 0),
coerce_int(book_data.get("editions_count"), 0),
-coerce_int(book_data.get("id"), 0),
)
existing_row = rows_by_position.get(position)
if existing_row is None:
rows_by_position[position] = {"row": row, "sort_key": sort_key}
continue
if sort_key > existing_row["sort_key"]:
rows_by_position[position] = {"row": row, "sort_key": sort_key}
ordered_rows = [
entry["row"]
for _position, entry in sorted(rows_by_position.items(), key=lambda item: item[0])
]
return {"rows": ordered_rows, "series_name": series_name, "total": len(ordered_rows)}
def _fetch_series_books_by_id(
self,
series_id: int,
page: int,
limit: int,
*,
exclude_compilations: bool,
exclude_unreleased: bool,
) -> SearchResult:
"""Fetch books for a Hardcover series in canonical series order."""
cached = self._fetch_series_ordered_rows(
series_id,
exclude_compilations=exclude_compilations,
exclude_unreleased=exclude_unreleased,
)
ordered_rows = cached["rows"]
series_name = cached["series_name"]
total_found = cached["total"]
offset = (page - 1) * limit
page_rows = ordered_rows[offset : offset + limit]
books: list[BookMetadata] = []
for row in page_rows:
book_data = row.get("book", {})
if not isinstance(book_data, dict) or not book_data:
continue
try:
parsed_book = self._parse_book(book_data)
if not parsed_book:
continue
parsed_book.series_id = str(series_id)
if series_name:
parsed_book.series_name = series_name
parsed_book.series_position = row.get("position")
parsed_book.series_count = total_found
books.append(parsed_book)
except (AttributeError, IndexError, KeyError, TypeError, ValueError) as exc:
logger.debug(
"Failed to parse Hardcover series book for series_id=%s: %s", series_id, exc
)
has_more = offset + len(page_rows) < total_found
return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more)
def _fetch_author_books_by_id(
self,
author_id: int,
page: int,
limit: int,
*,
exclude_compilations: bool,
exclude_unreleased: bool,
) -> SearchResult:
"""Fetch books for a selected Hardcover author."""
if not self.api_key:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
offset = (page - 1) * limit
result = self._execute_query(
AUTHOR_BOOKS_BY_ID_QUERY,
{"authorId": author_id, "limit": limit, "offset": offset},
)
if not result:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
author_items = result.get("authors", [])
if not isinstance(author_items, list) or not author_items:
return SearchResult(books=[], page=page, total_found=0, has_more=False)
author_data = author_items[0] if isinstance(author_items[0], dict) else {}
contributions = (
author_data.get("contributions", []) if isinstance(author_data, dict) else []
)
aggregate = (
author_data.get("contributions_aggregate", {}) if isinstance(author_data, dict) else {}
)
total_found = coerce_int(
aggregate.get("aggregate", {}).get("count") if isinstance(aggregate, dict) else 0,
0,
)
today = datetime.now(UTC).date()
books: list[BookMetadata] = []
for row in contributions:
if not isinstance(row, dict):
continue
contribution = str(row.get("contribution") or "").strip()
if contribution and "author" not in contribution.casefold():
continue
book_data = row.get("book", {})
if not isinstance(book_data, dict) or not book_data:
continue
if exclude_compilations and book_data.get("compilation"):
continue
release_date = _parse_release_date(book_data.get("release_date"))
if exclude_unreleased and (release_date is None or release_date.date() > today):
continue
try:
parsed_book = self._parse_book(book_data)
books.append(parsed_book)
except (AttributeError, IndexError, KeyError, TypeError, ValueError) as exc:
logger.debug(
"Failed to parse Hardcover author book for author_id=%s: %s",
author_id,
exc,
)
has_more = offset + len(contributions) < total_found
return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more)
def search(self, options: MetadataSearchOptions) -> list[BookMetadata]:
"""Search for books using Hardcover's search API."""
return self.search_paginated(options).books
def search_paginated(self, options: MetadataSearchOptions) -> SearchResult:
"""Search for books with pagination info."""
if not self.api_key:
logger.warning("Hardcover API key not configured")
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
# Allow pasting a Hardcover list URL directly in the search input
list_url_parts = self._detect_list_url(options.query)
if list_url_parts:
owner_username, list_slug = list_url_parts
return self._fetch_list_books(list_slug, owner_username, options.page, options.limit)
# Advanced filter list selector (shared fetch path with URL detection)
list_value_from_field = str(options.fields.get("hardcover_list", "")).strip()
if list_value_from_field:
if list_value_from_field.startswith(HARDCOVER_STATUS_PREFIX):
try:
status_id = self._parse_prefixed_int(list_value_from_field, "status")
return self._fetch_current_user_books_by_status(
status_id, options.page, options.limit
)
except ValueError:
logger.debug("Invalid Hardcover status field value: %s", list_value_from_field)
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
if list_value_from_field.startswith(HARDCOVER_LIST_ID_PREFIX):
try:
list_id = self._parse_prefixed_int(list_value_from_field, "list")
return self._fetch_list_books_by_id(list_id, options.page, options.limit)
except ValueError:
logger.debug("Invalid hardcover_list field value: %s", list_value_from_field)
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
return self._fetch_list_books(list_value_from_field, None, options.page, options.limit)
series_value_from_field = str(options.fields.get("series", "")).strip()
if series_value_from_field:
resolved_series = self._resolve_series_search_value(series_value_from_field)
if not resolved_series:
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
exclude_compilations = coerce_bool(
app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False),
default=False,
)
exclude_unreleased = coerce_bool(
app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False),
default=False,
)
return self._fetch_series_books_by_id(
int(resolved_series["id"]),
options.page,
options.limit,
exclude_compilations=exclude_compilations,
exclude_unreleased=exclude_unreleased,
)
author_value_from_field = str(options.fields.get("author", "")).strip()
if author_value_from_field.startswith(HARDCOVER_LIST_ID_PREFIX):
try:
author_id = self._parse_prefixed_int(author_value_from_field, "author id")
except ValueError:
logger.debug("Invalid Hardcover author id field value: %s", author_value_from_field)
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
exclude_compilations = coerce_bool(
app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False),
default=False,
)
exclude_unreleased = coerce_bool(
app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False),
default=False,
)
return self._fetch_author_books_by_id(
author_id,
options.page,
options.limit,
exclude_compilations=exclude_compilations,
exclude_unreleased=exclude_unreleased,
)
# Handle ISBN search separately
if options.search_type == SearchType.ISBN:
result = self.search_by_isbn(options.query)
books = [result] if result else []
return SearchResult(books=books, page=1, total_found=len(books), has_more=False)
# Build cache key from options (include fields and settings for cache differentiation)
fields_key = ":".join(f"{k}={v}" for k, v in sorted(options.fields.items()))
exclude_compilations = coerce_bool(
app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False),
default=False,
)
exclude_unreleased = coerce_bool(
app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False),
default=False,
)
cache_key = f"{options.query}:{options.search_type.value}:{options.sort.value}:{options.limit}:{options.page}:{fields_key}:excl_comp={exclude_compilations}:excl_unrel={exclude_unreleased}"
return self._search_cached(cache_key, options)
@cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:search")
def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> SearchResult:
"""Return cached Hardcover search results."""
# Determine query and fields based on custom search fields
# Note: Hardcover API requires 'weights' when using 'fields' parameter
author_value = options.fields.get("author", "").strip()
title_value = options.fields.get("title", "").strip()
# Build query and field configuration based on which fields are provided
query, search_fields, search_weights = self._build_search_params(
options.query, author_value, title_value, ""
)
graphql_query = SEARCH_BOOKS_WITH_FIELDS_QUERY if search_fields else SEARCH_BOOKS_QUERY
# Map abstract sort order to Hardcover's sort parameter
sort_param = SORT_MAPPING.get(options.sort, SORT_MAPPING[SortOrder.RELEVANCE])
variables = {
"query": query,
"limit": options.limit,
"page": options.page,
"sort": sort_param,
}
if search_fields:
variables["fields"] = search_fields
variables["weights"] = search_weights
try:
result = self._execute_query(graphql_query, variables)
if not result:
logger.debug("Hardcover search: No result from API")
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
# Extract hits from Typesense response
hits, found_count = _extract_typesense_hits(result)
# Parse hits, filtering compilations and unreleased books if enabled
exclude_compilations = coerce_bool(
app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False),
default=False,
)
exclude_unreleased = coerce_bool(
app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False),
default=False,
)
current_year = datetime.now(UTC).year
books = []
for hit in hits:
item = _unwrap_hit_document(hit)
if item is None:
continue
if exclude_compilations and item.get("compilation"):
continue
if exclude_unreleased:
release_year = item.get("release_year")
if release_year is not None and release_year > current_year:
continue
book = self._parse_search_result(item)
if book:
books.append(book)
logger.info(
"Hardcover search '%s' (fields=%s) returned %s results",
query,
search_fields,
len(books),
)
# Calculate if there are more results
results_so_far = (options.page - 1) * HARDCOVER_PAGE_SIZE + len(hits)
has_more = results_so_far < found_count
return SearchResult(
books=books, page=options.page, total_found=found_count, has_more=has_more
)
except AttributeError, KeyError, TypeError, ValueError:
logger.exception("Hardcover search error")
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="hardcover:book")
def get_book(self, book_id: str) -> BookMetadata | None:
"""Get book details by Hardcover ID."""
if not self.api_key:
logger.warning("Hardcover API key not configured")
return None
try:
book_id_int = int(book_id)
result = self._execute_query(GET_BOOK_QUERY, {"id": book_id_int})
if not result:
return None
books = result.get("books", [])
if not books:
return None
return self._parse_book(books[0])
except ValueError:
logger.exception("Invalid book ID: %s", book_id)
return None
except AttributeError, KeyError, TypeError:
logger.exception("Hardcover get_book error")
return None
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="hardcover:isbn")
def search_by_isbn(self, isbn: str) -> BookMetadata | None:
"""Search for a book by ISBN-10 or ISBN-13."""
if not self.api_key:
logger.warning("Hardcover API key not configured")
return None
# Clean ISBN (remove hyphens)
clean_isbn = isbn.replace("-", "").strip()
try:
result = self._execute_query(SEARCH_BY_ISBN_QUERY, {"isbn": clean_isbn})
if not result:
return None
editions = result.get("editions", [])
if not editions:
logger.debug("No Hardcover book found for ISBN: %s", isbn)
return None
edition = editions[0]
book_data = edition.get("book", {})
if not book_data:
return None
# Add ISBN data from edition to book data
book_data["isbn_10"] = edition.get("isbn_10")
book_data["isbn_13"] = edition.get("isbn_13")
return self._parse_book(book_data)
except AttributeError, IndexError, KeyError, TypeError, ValueError:
logger.exception("Hardcover ISBN search error")
return None
@@ -0,0 +1,154 @@
"""Settings registration for the Hardcover metadata provider."""
from typing import Any
import requests
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
ActionButton,
CheckboxField,
HeadingField,
PasswordField,
SelectField,
SettingsField,
register_settings,
)
from .auth import _get_connected_username, _save_connected_user
from .constants import HARDCOVER_API_KEY_MIN_LENGTH
from .parsing import _normalize_hardcover_api_key
from .provider import HardcoverProvider
logger = setup_logger(__name__)
def _test_hardcover_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Test the Hardcover API connection using current form values."""
current_values = current_values or {}
# Use current form values first, fall back to saved config
raw_key = current_values.get("HARDCOVER_API_KEY") or app_config.get("HARDCOVER_API_KEY", "")
api_key = _normalize_hardcover_api_key(raw_key)
key_len = len(api_key) if api_key else 0
logger.debug("Hardcover test: key length=%s", key_len)
if not api_key:
# Clear any stored connection metadata since there's no key
_save_connected_user(None, None)
return {"success": False, "message": "API key is required"}
if 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."
),
}
connection_result = {"success": False, "message": "API request failed - check your API key"}
try:
provider = HardcoverProvider(api_key=api_key)
# Use the 'me' query to test connection (recommended by API docs)
result = provider._execute_query("query { me { id, username } }", {})
if result is not None:
# Handle both single object and array response formats
me_data = result.get("me", {})
if isinstance(me_data, list) and me_data:
me_data = me_data[0]
user_id = (
str(me_data.get("id"))
if isinstance(me_data, dict) and me_data.get("id") is not None
else None
)
username = (
me_data.get("username", "Unknown") if isinstance(me_data, dict) else "Unknown"
)
# Save connected user metadata for persistent display + per-user list caching
_save_connected_user(user_id, username)
connection_result = {"success": True, "message": f"Connected as: {username}"}
else:
_save_connected_user(None, None)
except (AttributeError, KeyError, requests.RequestException, TypeError, ValueError) as e:
logger.exception("Hardcover connection test failed")
_save_connected_user(None, None)
return {"success": False, "message": f"Connection failed: {e!s}"}
return connection_result
_HARDCOVER_SORT_OPTIONS = [
{"value": "relevance", "label": "Most relevant"},
{"value": "popularity", "label": "Most popular"},
{"value": "rating", "label": "Highest rated"},
{"value": "newest", "label": "Newest"},
{"value": "oldest", "label": "Oldest"},
]
@register_settings("hardcover", "Hardcover", icon="book", order=51, group="metadata_providers")
def hardcover_settings() -> list[SettingsField]:
"""Hardcover metadata provider settings."""
# Check for connected username to show status
connected_user = _get_connected_username()
test_button_description = (
f"Connected as: {connected_user}" if connected_user else "Verify your API key works"
)
return [
HeadingField(
key="hardcover_heading",
title="Hardcover",
description="A modern book tracking and discovery platform with a comprehensive API.",
link_url="https://hardcover.app",
link_text="hardcover.app",
),
CheckboxField(
key="HARDCOVER_ENABLED",
label="Enable Hardcover",
description="Enable Hardcover as a metadata provider for book searches",
default=False,
),
PasswordField(
key="HARDCOVER_API_KEY",
label="API Key",
description="Get your API key from hardcover.app/account/api",
required=True,
),
ActionButton(
key="test_connection",
label="Test Connection",
description=test_button_description,
style="primary",
callback=_test_hardcover_connection,
),
SelectField(
key="HARDCOVER_DEFAULT_SORT",
label="Default Sort Order",
description="Default sort order for Hardcover search results.",
options=_HARDCOVER_SORT_OPTIONS,
default="relevance",
),
CheckboxField(
key="HARDCOVER_EXCLUDE_COMPILATIONS",
label="Exclude Compilations",
description="Filter out compilations, anthologies, and omnibus editions from search results",
default=False,
),
CheckboxField(
key="HARDCOVER_EXCLUDE_UNRELEASED",
label="Exclude Unreleased Books",
description="Filter out books with a release year in the future",
default=False,
),
CheckboxField(
key="HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD",
label="Auto-Remove from List on Download",
description="Automatically remove a book from the active Hardcover list when you download it",
default=True,
),
]
@@ -0,0 +1,438 @@
"""Hardcover list/status target read and mutation workflows."""
from typing import TYPE_CHECKING, Any
from shelfmark.core.cache import cache_key
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_int
from .constants import (
HARDCOVER_LIST_ID_PREFIX,
HARDCOVER_STATUS_PREFIX,
HARDCOVER_WRITABLE_TARGET_GROUPS,
)
from .models import HardcoverBookTargetState, HardcoverTargetPayloadError
from .queries import (
BOOK_TARGET_MEMBERSHIP_BATCH_QUERY,
BOOK_TARGET_MEMBERSHIP_QUERY,
DELETE_LIST_BOOK_MUTATION,
DELETE_USER_BOOK_MUTATION,
INSERT_LIST_BOOK_MUTATION,
INSERT_USER_BOOK_MUTATION,
UPDATE_USER_BOOK_MUTATION,
)
logger = setup_logger(__name__)
def _metadata_cache() -> Any:
from shelfmark.metadata_providers import hardcover
return hardcover.get_metadata_cache()
class HardcoverTargetsMixin:
if TYPE_CHECKING:
api_key: str
def _execute_query(
self,
query: str,
variables: dict[str, Any],
*,
raise_on_error: bool = False,
) -> dict[str, Any] | None: ...
def _resolve_current_user_id(self) -> str | None: ...
def get_user_lists(self) -> list[dict[str, str]]: ...
def get_book_targets(self, book_id: str) -> list[dict[str, Any]]:
"""Get writable Hardcover list/status targets for a specific book."""
if not self.api_key:
return []
book_id_int = coerce_int(book_id, 0)
if book_id_int < 1:
msg = "book_id must be a valid Hardcover book id"
raise ValueError(msg)
state = self._fetch_book_target_state(book_id_int)
options: list[dict[str, Any]] = [
dict(option)
for option in self.get_user_lists()
if option.get("group") in HARDCOVER_WRITABLE_TARGET_GROUPS
]
for option in options:
value = str(option.get("value") or "").strip()
option["checked"] = self._is_target_checked(value, state)
option["writable"] = True
return options
def set_book_target_state(
self,
book_id: str,
target: str,
*,
selected: bool,
) -> dict[str, Any]:
"""Set whether a Hardcover book belongs to a status shelf or user list."""
if not self.api_key:
msg = "Hardcover is not configured"
raise ValueError(msg)
book_id_int = coerce_int(book_id, 0)
if book_id_int < 1:
msg = "book_id must be a valid Hardcover book id"
raise ValueError(msg)
selected_target = str(target or "").strip()
if not selected_target:
msg = "target is required"
raise ValueError(msg)
if selected_target not in self._get_writable_targets():
msg = "Unsupported Hardcover target"
raise ValueError(msg)
state = self._fetch_book_target_state(book_id_int)
status_ids_to_invalidate: set[int] = set()
list_ids_to_invalidate: set[int] = set()
deselected_target: str | None = None
if selected_target.startswith(HARDCOVER_STATUS_PREFIX):
status_id = self._parse_prefixed_int(selected_target, "status target")
previous_status_id = state.status_id
changed = self._set_status_target_state(
book_id_int,
status_id,
selected=selected,
state=state,
)
if changed:
if previous_status_id is not None:
status_ids_to_invalidate.add(previous_status_id)
if selected and previous_status_id != status_id:
deselected_target = f"{HARDCOVER_STATUS_PREFIX}{previous_status_id}"
status_ids_to_invalidate.add(status_id)
elif selected_target.startswith(HARDCOVER_LIST_ID_PREFIX):
list_id = self._parse_prefixed_int(selected_target, "list target")
changed = self._set_list_target_state(
book_id_int,
list_id,
selected=selected,
state=state,
)
if changed:
list_ids_to_invalidate.add(list_id)
else:
msg = "Unsupported Hardcover target"
raise ValueError(msg)
if changed:
self._invalidate_book_target_caches(
connected_user_id=self._resolve_current_user_id(),
status_ids=status_ids_to_invalidate,
list_ids=list_ids_to_invalidate,
)
result_data: dict[str, Any] = {"changed": changed}
if deselected_target:
result_data["deselected_target"] = deselected_target
return result_data
@staticmethod
def _unwrap_me_data(result: dict | None) -> dict:
"""Extract and validate the ``me`` payload from a GraphQL result."""
if not isinstance(result, dict):
msg = "Hardcover could not load book targets"
raise HardcoverTargetPayloadError(msg)
me_data = result.get("me", {})
if isinstance(me_data, list) and me_data:
me_data = me_data[0]
if not isinstance(me_data, dict):
msg = "Hardcover returned an invalid target payload"
raise HardcoverTargetPayloadError(msg)
return me_data
def _fetch_book_target_state(self, book_id: int) -> HardcoverBookTargetState:
"""Load current Hardcover membership state for a specific book."""
result = self._execute_query(
BOOK_TARGET_MEMBERSHIP_QUERY,
{"bookId": book_id},
raise_on_error=True,
)
me_data = self._unwrap_me_data(result)
user_book_id: int | None = None
status_id: int | None = None
user_books = me_data.get("user_books", [])
if isinstance(user_books, list) and user_books:
latest_user_book = user_books[0] if isinstance(user_books[0], dict) else {}
user_book_id = coerce_int(latest_user_book.get("id"), 0) or None
status_id = coerce_int(latest_user_book.get("status_id"), 0) or None
list_book_ids: dict[int, int] = {}
for user_list in me_data.get("lists", []):
if not isinstance(user_list, dict):
continue
list_id = coerce_int(user_list.get("id"), 0)
if list_id < 1:
continue
list_books = user_list.get("list_books", [])
if not isinstance(list_books, list) or not list_books:
continue
list_book = list_books[0] if isinstance(list_books[0], dict) else {}
list_book_id = coerce_int(list_book.get("id"), 0)
if list_book_id > 0:
list_book_ids[list_id] = list_book_id
return HardcoverBookTargetState(
user_book_id=user_book_id,
status_id=status_id,
list_book_ids=list_book_ids,
)
def _fetch_book_target_states_batch(
self,
book_ids: list[int],
) -> dict[int, HardcoverBookTargetState]:
"""Load Hardcover membership state for multiple books in one query."""
result = self._execute_query(
BOOK_TARGET_MEMBERSHIP_BATCH_QUERY,
{"bookIds": book_ids},
raise_on_error=True,
)
me_data = self._unwrap_me_data(result)
# Group user_books by book_id (keep only the latest per book)
user_book_by_book: dict[int, dict] = {}
for ub in me_data.get("user_books", []):
if not isinstance(ub, dict):
continue
bid = coerce_int(ub.get("book_id"), 0)
if bid > 0 and bid not in user_book_by_book:
user_book_by_book[bid] = ub
# Group list_book memberships by book_id
list_book_ids_by_book: dict[int, dict[int, int]] = {}
for user_list in me_data.get("lists", []):
if not isinstance(user_list, dict):
continue
list_id = coerce_int(user_list.get("id"), 0)
if list_id < 1:
continue
for lb in user_list.get("list_books", []):
if not isinstance(lb, dict):
continue
bid = coerce_int(lb.get("book_id"), 0)
lb_id = coerce_int(lb.get("id"), 0)
if bid > 0 and lb_id > 0:
list_book_ids_by_book.setdefault(bid, {})[list_id] = lb_id
states: dict[int, HardcoverBookTargetState] = {}
for bid in book_ids:
ub = user_book_by_book.get(bid)
states[bid] = HardcoverBookTargetState(
user_book_id=coerce_int(ub.get("id"), 0) or None if ub else None,
status_id=coerce_int(ub.get("status_id"), 0) or None if ub else None,
list_book_ids=list_book_ids_by_book.get(bid, {}),
)
return states
def get_book_targets_batch(self, book_ids: list[str]) -> dict[str, list[dict[str, Any]]]:
"""Get writable Hardcover list/status targets for multiple books."""
if not self.api_key or not book_ids:
return {bid: [] for bid in book_ids}
int_ids = []
id_map: dict[int, str] = {}
for bid in book_ids:
int_id = coerce_int(bid, 0)
if int_id > 0:
int_ids.append(int_id)
id_map[int_id] = bid
if not int_ids:
return {bid: [] for bid in book_ids}
states = self._fetch_book_target_states_batch(int_ids)
writable_options: list[dict[str, Any]] = [
dict(option)
for option in self.get_user_lists()
if option.get("group") in HARDCOVER_WRITABLE_TARGET_GROUPS
]
results: dict[str, list[dict[str, Any]]] = {}
for int_id, str_id in id_map.items():
state = states.get(
int_id,
HardcoverBookTargetState(
user_book_id=None,
status_id=None,
list_book_ids={},
),
)
options = [dict(opt) for opt in writable_options]
for option in options:
value = str(option.get("value") or "").strip()
option["checked"] = self._is_target_checked(value, state)
option["writable"] = True
results[str_id] = options
# Fill in any book_ids that didn't parse as valid ints
for bid in book_ids:
if bid not in results:
results[bid] = []
return results
def _get_writable_targets(self) -> set[str]:
"""Return the set of writable Hardcover targets for the current user."""
writable_targets: set[str] = set()
for option in self.get_user_lists():
value = str(option.get("value") or "").strip()
if (
option.get("group") in HARDCOVER_WRITABLE_TARGET_GROUPS
and value
and value.startswith((HARDCOVER_STATUS_PREFIX, HARDCOVER_LIST_ID_PREFIX))
):
writable_targets.add(value)
return writable_targets
def _is_target_checked(self, target: str, state: HardcoverBookTargetState) -> bool:
"""Return whether a target is currently selected for the book."""
if target.startswith(HARDCOVER_STATUS_PREFIX):
return state.status_id == self._parse_prefixed_int(target)
if target.startswith(HARDCOVER_LIST_ID_PREFIX):
return self._parse_prefixed_int(target) in state.list_book_ids
return False
def _set_status_target_state(
self,
book_id: int,
status_id: int,
*,
selected: bool,
state: HardcoverBookTargetState,
) -> bool:
"""Set whether the book belongs to a Hardcover status shelf."""
if selected:
if state.user_book_id is None:
result = self._execute_query(
INSERT_USER_BOOK_MUTATION,
{"bookId": book_id, "statusId": status_id},
raise_on_error=True,
)
self._check_mutation_result(result, "insert_user_book")
return True
if state.status_id == status_id:
return False
result = self._execute_query(
UPDATE_USER_BOOK_MUTATION,
{"userBookId": state.user_book_id, "statusId": status_id},
raise_on_error=True,
)
self._check_mutation_result(result, "update_user_book")
return True
if state.user_book_id is None or state.status_id != status_id:
return False
result = self._execute_query(
DELETE_USER_BOOK_MUTATION,
{"userBookId": state.user_book_id},
raise_on_error=True,
)
self._check_mutation_result(result, "delete_user_book", check_error=False)
return True
def _set_list_target_state(
self,
book_id: int,
list_id: int,
*,
selected: bool,
state: HardcoverBookTargetState,
) -> bool:
"""Set whether the book belongs to a Hardcover list."""
list_book_id = state.list_book_ids.get(list_id)
if selected:
if list_book_id is not None:
return False
result = self._execute_query(
INSERT_LIST_BOOK_MUTATION,
{"bookId": book_id, "listId": list_id},
raise_on_error=True,
)
self._check_mutation_result(result, "insert_list_book")
return True
if list_book_id is None:
return False
result = self._execute_query(
DELETE_LIST_BOOK_MUTATION,
{"listBookId": list_book_id},
raise_on_error=True,
)
self._check_mutation_result(result, "delete_list_book", check_error=False)
return True
def _invalidate_book_target_caches(
self,
*,
connected_user_id: str | None,
status_ids: set[int],
list_ids: set[int],
) -> None:
"""Invalidate caches affected by a target membership change."""
metadata_cache = _metadata_cache()
if connected_user_id:
metadata_cache.invalidate(cache_key("hardcover:user_lists", connected_user_id))
for status_id in status_ids:
metadata_cache.invalidate_prefix(
cache_key("hardcover:user_books:status", connected_user_id, status_id)
)
for list_id in list_ids:
metadata_cache.invalidate_prefix(cache_key("hardcover:list:id", list_id))
@staticmethod
def _parse_prefixed_int(value: str, label: str = "target") -> int:
"""Parse an integer from a colon-prefixed value like 'status:1' or 'id:42'."""
try:
return int(value.split(":", 1)[1])
except (IndexError, ValueError) as exc:
msg = f"Invalid Hardcover {label}"
raise ValueError(msg) from exc
@staticmethod
def _check_mutation_result(result: Any, key: str, *, check_error: bool = True) -> None:
"""Raise if a Hardcover mutation failed.
When *check_error* is True (the default) the ``error`` field inside
the payload is inspected and surfaced as a ``ValueError``. Pass
``check_error=False`` for delete mutations that don't return an
error field.
"""
payload = result.get(key, {}) if isinstance(result, dict) else {}
if isinstance(payload, dict):
if check_error:
error_text = str(payload.get("error") or "").strip()
if error_text:
raise ValueError(error_text)
if payload.get("id") is not None:
return
msg = "Hardcover could not complete this action"
raise RuntimeError(msg)
-493
View File
@@ -1,493 +0,0 @@
"""Moly.hu metadata provider. Hungarian book catalog, no API key required.
Scraping approach (search URL, book-page structure, language mapping) adapted
from the Calibre Moly_hu plugin by Hoffer Csaba, Kloon, otapi, Dezso, Hokutya,
seeder and contributors (GPL v3, mobileread.com).
"""
import re
import threading
import time
import unicodedata
from collections import deque
from typing import Any, ClassVar
from urllib.parse import quote
import requests
from bs4 import BeautifulSoup, Tag
from shelfmark.core.cache import cacheable
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
ActionButton,
CheckboxField,
HeadingField,
SettingsField,
register_settings,
)
from shelfmark.download.network import get_ssl_verify
from shelfmark.metadata_providers import (
BookMetadata,
DisplayField,
MetadataProvider,
MetadataSearchOptions,
SearchField,
SearchType,
SortOrder,
TextSearchField,
register_provider,
)
logger = setup_logger(__name__)
MOLY_BASE_URL = "https://moly.hu"
MOLY_BOOK_URL = f"{MOLY_BASE_URL}/konyvek/"
MOLY_SEARCH_URL = f"{MOLY_BASE_URL}/kereses?query="
# Be polite: moly.hu is a small community site
RATE_LIMIT_REQUESTS = 30
RATE_LIMIT_WINDOW_SECONDS = 60
REQUEST_HEADERS = {
"User-Agent": ("Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"),
"Accept-Language": "hu,en;q=0.7",
}
ISBN_13_LENGTH = 13
# Moly tags its foreign-language editions; everything else is Hungarian.
# Mapping from the Calibre Moly_hu plugin.
_LANGUAGE_TAG_MAP = {
"angol nyelvű": "en",
"n\xe9met nyelvű": "de",
"francia nyelvű": "fr",
"olasz nyelvű": "it",
"spanyol nyelvű": "es",
"orosz nyelvű": "ru",
"t\xf6r\xf6k nyelvű": "tr",
"g\xf6r\xf6g nyelvű": "el",
"k\xednai nyelvű": "zh",
"jap\xe1n nyelvű": "ja",
}
class RateLimiter:
"""Simple sliding window rate limiter."""
def __init__(self, max_requests: int, window_seconds: int) -> None:
"""Initialize rate limiter with max requests per time window."""
self.max_requests = max_requests
self.window_seconds = window_seconds
self.timestamps: deque[float] = deque()
self.lock = threading.Lock()
def wait_if_needed(self) -> None:
"""Block until a request is allowed (thread-safe)."""
wait_time = 0.0
with self.lock:
now = time.time()
cutoff = now - self.window_seconds
while self.timestamps and self.timestamps[0] < cutoff:
self.timestamps.popleft()
if len(self.timestamps) >= self.max_requests:
wait_time = self.timestamps[0] + self.window_seconds - now
if wait_time > 0:
logger.debug("Rate limited, waiting %0.2fs", wait_time)
time.sleep(wait_time)
with self.lock:
now = time.time()
cutoff = now - self.window_seconds
while self.timestamps and self.timestamps[0] < cutoff:
self.timestamps.popleft()
self.timestamps.append(time.time())
_rate_limiter = RateLimiter(RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS)
def _clean_text(value: str | None) -> str | None:
"""Strip zero-width characters and collapse whitespace."""
if value is None:
return None
value = value.replace("​", "").replace("", "")
return " ".join(value.split())
def _normalize_for_match(value: str | None) -> str:
"""Accent-insensitive, punctuation-insensitive comparison form."""
if not value:
return ""
value = unicodedata.normalize("NFKD", value)
value = "".join(char for char in value if not unicodedata.combining(char))
value = "".join(char if char.isalnum() else " " for char in value)
return " ".join(value.lower().split())
def _absolute_url(url: str | None) -> str | None:
if not url:
return None
if url.startswith(("http://", "https://")):
return url
return MOLY_BASE_URL + url
def _valid_isbn(candidate: str) -> str | None:
"""Return a normalized ISBN-10/13 (digits, with optional X check digit), else None."""
digits = candidate.replace("-", "").strip()
if len(digits) == ISBN_13_LENGTH and digits.isdigit():
return digits
if len(digits) == 10 and re.fullmatch(r"\d{9}[\dXx]", digits):
return digits.upper()
return None
@register_provider("moly")
class MolyProvider(MetadataProvider):
"""Moly.hu metadata provider (HTML scraping, Hungarian catalog)."""
name = "moly"
display_name = "Moly.hu"
requires_auth = False
supported_sorts: ClassVar[tuple[SortOrder, ...]] = (SortOrder.RELEVANCE,)
search_fields: ClassVar[tuple[SearchField, ...]] = (
TextSearchField(
key="author",
label="Author",
description="Search by author name",
),
TextSearchField(
key="title",
label="Title",
description="Search by book title",
),
)
def __init__(self) -> None:
"""Initialize provider."""
self.session = requests.Session()
self.session.headers.update(REQUEST_HEADERS)
def is_available(self) -> bool:
"""Moly.hu needs no authentication."""
return True
def _fetch(self, url: str, timeout: int = 15) -> str | None:
_rate_limiter.wait_if_needed()
try:
response = self.session.get(url, timeout=timeout, verify=get_ssl_verify(MOLY_BASE_URL))
response.raise_for_status()
except requests.Timeout:
logger.warning("Moly.hu request timed out: %s", url)
return None
except requests.RequestException:
logger.exception("Moly.hu request failed: %s", url)
return None
return response.text
def search(self, options: MetadataSearchOptions) -> list[BookMetadata]:
"""Search moly.hu's site search."""
if options.search_type == SearchType.ISBN:
result = self.search_by_isbn(options.query)
return [result] if result else []
# Moly's search is a single ranked page; no server-side pagination.
if options.page > 1:
return []
author_value = (options.fields.get("author") or "").strip()
title_value = (options.fields.get("title") or "").strip()
terms = " ".join(t for t in (author_value, title_value) if t)
query = terms or options.query.strip()
if not query:
return []
fields_key = ":".join(f"{k}={v}" for k, v in sorted(options.fields.items()))
cache_key = f"{query}:{options.search_type.value}:{options.limit}:{fields_key}"
return self._search_cached(cache_key, query, options.limit) or []
@cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="moly:search")
def _search_cached(self, cache_key: str, query: str, limit: int) -> list[BookMetadata] | None:
# Return None (not []) on fetch failure so the failure is not cached.
html = self._fetch(MOLY_SEARCH_URL + quote(query.encode("utf-8")))
if html is None:
return None
soup = BeautifulSoup(html, "html.parser")
books: list[BookMetadata] = []
seen: set[str] = set()
for anchor in soup.select("#content div.search_area a.book_selector"):
href = anchor.get("href") or ""
match = re.search(r"/konyvek/([^/?#]+)", str(href))
if not match:
continue
slug = match.group(1)
if slug in seen:
continue
# No separator: moly wraps matched search terms in <strong> even
# mid-word ("Lis<strong>a</strong> Jewell"), so inserting one
# would split words at highlight boundaries.
text = _clean_text(anchor.get_text()) or ""
author, _, title = text.partition(":")
if not title:
# Result rows are "Author: Title"; skip anything else.
continue
author = author.strip()
title = title.strip()
seen.add(slug)
books.append(
BookMetadata(
provider=self.name,
provider_id=slug,
provider_display_name=self.display_name,
title=title,
authors=[author] if author else [],
cover_url=self._cover_for_result(soup, text),
source_url=MOLY_BOOK_URL + slug,
language="hu",
search_title=title,
search_author=author or None,
display_fields=self._result_display_fields(anchor),
)
)
if len(books) >= limit:
break
logger.info("Moly.hu search '%s' returned %s results", query, len(books))
return books
def _cover_for_result(self, soup: BeautifulSoup, result_text: str) -> str | None:
"""Find the search-result thumbnail whose alt matches 'Author: Title'."""
target = _normalize_for_match(result_text)
if not target:
return None
for img in soup.select("#content img.tooltip[alt]"):
if _normalize_for_match(str(img.get("alt") or "")) == target:
return _absolute_url(str(img.get("src") or "")) or None
return None
def _result_display_fields(self, anchor: Tag) -> list[DisplayField]:
fields: list[DisplayField] = []
parent = anchor.parent
if parent is None:
return fields
like = parent.select_one("span.like_count")
if like:
fields.append(
DisplayField(label="Rating", value=like.get_text(strip=True), icon="star")
)
series = parent.select_one('a[href*="/sorozatok/"]')
if series:
fields.append(
DisplayField(
label="Series",
value=series.get_text(strip=True).strip("()"),
icon="editions",
)
)
return fields
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="moly:book")
def get_book(self, book_id: str) -> BookMetadata | None:
"""Get book details by moly.hu slug (e.g. 'mocsidzuki-mai-a-telihold-kavezo')."""
html = self._fetch(MOLY_BOOK_URL + quote(book_id))
if html is None:
return None
soup = BeautifulSoup(html, "html.parser")
title = self._parse_title(soup)
authors = [_clean_text(a.get_text()) or "" for a in soup.select("#content div.authors a")]
authors = [a for a in authors if a]
if not title or not authors:
logger.warning("Moly.hu book page missing title/authors: %s", book_id)
return None
isbn_13, isbn_10 = self._parse_isbns(soup)
series = self._parse_series(soup)
tags = [_clean_text(t.get_text()) or "" for t in soup.select("#book_tags a.tag")]
tags = [t for t in tags if t]
display_fields: list[DisplayField] = []
rating = soup.select_one("#content .rating .like_count")
if rating:
display_fields.append(
DisplayField(label="Rating", value=rating.get_text(strip=True), icon="star")
)
if series:
display_fields.append(DisplayField(label="Series", value=series, icon="editions"))
return BookMetadata(
provider=self.name,
provider_id=book_id,
provider_display_name=self.display_name,
title=title,
authors=authors,
isbn_13=isbn_13,
isbn_10=isbn_10,
cover_url=self._parse_cover(soup),
description=self._parse_description(soup),
publisher=self._parse_publisher(soup),
publish_year=self._parse_publish_year(soup),
language=self._parse_language(tags),
genres=tags,
source_url=MOLY_BOOK_URL + book_id,
search_title=title,
search_author=authors[0],
display_fields=display_fields,
)
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="moly:isbn")
def search_by_isbn(self, isbn: str) -> BookMetadata | None:
"""Moly's site search resolves ISBN queries directly."""
isbn = isbn.replace("-", "").strip()
if not isbn:
return None
html = self._fetch(MOLY_SEARCH_URL + quote(isbn))
if html is None:
return None
soup = BeautifulSoup(html, "html.parser")
anchor = soup.select_one("#content div.search_area a.book_selector[href]")
if not anchor:
return None
match = re.search(r"/konyvek/([^/?#]+)", str(anchor.get("href")))
if not match:
return None
return self.get_book(match.group(1))
def _parse_title(self, soup: BeautifulSoup) -> str | None:
node = soup.select_one("#content .head_title h1 span.item")
if node:
# The series link is nested inside this span; only direct text
# belongs to the book title.
direct = "".join(node.find_all(string=True, recursive=False))
title = _clean_text(direct)
if title:
return title
node = soup.select_one("#content .book > span")
if node:
return _clean_text(node.get_text())
return None
def _parse_series(self, soup: BeautifulSoup) -> str | None:
node = soup.select_one('#content h1 a[href*="/sorozatok/"]')
if not node:
return None
return (_clean_text(node.get_text()) or "").strip("()") or None
def _parse_isbns(self, soup: BeautifulSoup) -> tuple[str | None, str | None]:
isbn_13 = isbn_10 = None
editions = soup.select("#content .items .edition") or soup.select("#content .items > div")
for edition in editions:
text = edition.get_text(" ")
for candidate in re.findall(r"(?<!\d)[\d-]{10,17}(?!\d)", text):
isbn = _valid_isbn(candidate)
if not isbn:
continue
if len(isbn) == ISBN_13_LENGTH and not isbn_13:
isbn_13 = isbn
elif len(isbn) != ISBN_13_LENGTH and not isbn_10:
isbn_10 = isbn
if isbn_13:
break
return isbn_13, isbn_10
def _parse_cover(self, soup: BeautifulSoup) -> str | None:
node = soup.select_one("#content .coverbox a.zoom[href]")
if node:
return _absolute_url(str(node.get("href")))
img = soup.select_one("#content .coverbox img[src]")
if img:
return _absolute_url(str(img.get("src")))
return None
def _parse_description(self, soup: BeautifulSoup) -> str | None:
node = soup.select_one("#content #full_description")
if node is None:
node = soup.select_one("#content div.text")
if node is None:
return None
spoiler_warning = "Vigyázat! Cselekményleírást tartalmaz."
parts = []
for text in node.stripped_strings:
cleaned = _clean_text(text) or ""
if cleaned.startswith(spoiler_warning):
cleaned = cleaned[len(spoiler_warning) :].strip()
if cleaned:
parts.append(cleaned)
return "\n".join(parts) or None
def _parse_publisher(self, soup: BeautifulSoup) -> str | None:
node = soup.select_one('#content .items .edition a[href*="/kiadok/"]')
if node:
return _clean_text(node.get_text())
return None
def _parse_publish_year(self, soup: BeautifulSoup) -> int | None:
editions = soup.select("#content .items .edition") or soup.select("#content .items > div")
for edition in editions:
match = re.search(r"\b(\d{4})\b", edition.get_text(" "))
if match:
return int(match.group(1))
return None
def _parse_language(self, tags: list[str]) -> str:
for tag in tags:
code = _LANGUAGE_TAG_MAP.get(tag.lower().strip())
if code:
return code
return "hu"
def _test_moly_connection() -> dict[str, Any]:
"""Test connectivity to moly.hu."""
try:
provider = MolyProvider()
response = provider.session.get(
MOLY_SEARCH_URL + quote("teszt"),
timeout=10,
verify=get_ssl_verify(MOLY_BASE_URL),
)
response.raise_for_status()
except requests.Timeout:
return {"success": False, "message": "Connection timed out"}
except requests.RequestException as e:
return {"success": False, "message": f"Connection failed: {e}"}
if "moly" in response.text.lower():
return {"success": True, "message": "Successfully connected to moly.hu"}
return {"success": False, "message": "Unexpected response from moly.hu"}
@register_settings("moly", "Moly.hu", icon="library", order=54, group="metadata_providers")
def moly_settings() -> list[SettingsField]:
"""Moly.hu metadata provider settings."""
return [
HeadingField(
key="moly_heading",
title="Moly.hu",
description=(
"Hungarian community book catalog with excellent coverage of "
"Hungarian editions and translations. No API key required."
),
link_url="https://moly.hu",
link_text="moly.hu",
),
CheckboxField(
key="MOLY_ENABLED",
label="Enable Moly.hu",
description="Enable Moly.hu as a metadata provider for book searches",
default=False,
),
ActionButton(
key="test_connection",
label="Test Connection",
description="Verify moly.hu is accessible",
style="primary",
callback=_test_moly_connection,
),
]
+3 -3
View File
@@ -214,7 +214,7 @@ class OpenLibraryProvider(MetadataProvider):
logger.warning("Open Library search timed out")
return []
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == HTTPStatus.SERVICE_UNAVAILABLE:
if e.response.status_code == HTTPStatus.SERVICE_UNAVAILABLE:
logger.warning("Open Library service unavailable (503)")
else:
logger.exception("Open Library HTTP error")
@@ -253,7 +253,7 @@ class OpenLibraryProvider(MetadataProvider):
logger.warning("Open Library get_book timed out")
return None
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == HTTPStatus.NOT_FOUND:
if e.response.status_code == HTTPStatus.NOT_FOUND:
logger.debug("Open Library work not found: %s", book_id)
else:
logger.exception("Open Library HTTP error")
@@ -314,7 +314,7 @@ class OpenLibraryProvider(MetadataProvider):
return self._parse_edition(edition, clean_isbn)
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == HTTPStatus.NOT_FOUND:
if e.response.status_code == HTTPStatus.NOT_FOUND:
logger.debug("Open Library ISBN not found: %s", isbn)
else:
logger.exception("Open Library ISBN search HTTP error")
+2 -25
View File
@@ -13,7 +13,6 @@ 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
@@ -164,7 +163,6 @@ class SortOption:
label: str # Display label in the sort dropdown
sort_key: str # Field to sort by on the Release object
default_direction: Literal["asc", "desc"] = "desc" # Which way "best first" runs
@dataclass
@@ -263,12 +261,7 @@ def serialize_column_config(config: ReleaseColumnConfig) -> dict[str, Any]:
# Include extra sort options (sort entries not tied to a column)
if config.extra_sort_options:
result["extra_sort_options"] = [
{
"label": opt.label,
"sort_key": opt.sort_key,
"default_direction": opt.default_direction,
}
for opt in config.extra_sort_options
{"label": opt.label, "sort_key": opt.sort_key} for opt in config.extra_sort_options
]
# Include action button if specified (replaces default expand search)
@@ -397,18 +390,6 @@ class DownloadHandler(ABC):
"""
return
def build_retry_resolution_fields(self, release_data: dict[str, Any]) -> dict[str, Any]:
"""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."""
@@ -519,10 +500,6 @@ 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
@@ -539,7 +516,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=authors[0] if authors else None,
search_author=resolved_author or 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, Any
from typing import TYPE_CHECKING
from urllib.parse import urlparse
from shelfmark.core.config import config
@@ -22,11 +22,8 @@ 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"
ALLOWED_DETAIL_URL_SCHEMES = {"https"}
def _resolve_configured_hostname() -> str:
@@ -35,23 +32,6 @@ def _resolve_configured_hostname() -> str:
return normalize_hostname(configured_hostname if isinstance(configured_hostname, str) else "")
def _resolve_allowed_detail_hostname() -> str:
"""Return the ABB hostname allowed for queued detail URLs."""
return _resolve_configured_hostname() or DEFAULT_ABB_HOSTNAME
def _detail_url_matches_host(detail_url: str, hostname: str) -> bool:
"""Return True when a detail URL uses the allowed ABB scheme and host."""
parsed = urlparse(detail_url)
detail_hostname = normalize_hostname(parsed.hostname)
allowed_hostname = normalize_hostname(hostname).lower().rstrip(".")
return (
parsed.scheme.lower() in ALLOWED_DETAIL_URL_SCHEMES
and bool(detail_hostname)
and detail_hostname.lower().rstrip(".") == allowed_hostname
)
@register_handler("audiobookbay")
class AudiobookBayHandler(ExternalClientHandler):
"""Handler for AudiobookBay downloads via configured torrent client."""
@@ -69,19 +49,6 @@ 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)
@@ -102,14 +69,9 @@ class AudiobookBayHandler(ExternalClientHandler):
logger.warning("Missing details URL for AudiobookBay task: %s", task.task_id)
return None
hostname = _resolve_allowed_detail_hostname()
if not _detail_url_matches_host(detail_url, hostname):
status_callback("error", "Invalid AudiobookBay details URL")
logger.warning(
"Rejected AudiobookBay details URL with invalid scheme or host: %s",
detail_url,
)
return None
hostname = _resolve_configured_hostname()
if not hostname:
hostname = normalize_hostname(urlparse(detail_url).hostname)
status_callback("resolving", "Extracting magnet link")
magnet_link = scraper.extract_magnet_link(detail_url, hostname)

Some files were not shown because too many files have changed in this diff Show More