mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-25 06:50:20 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ab503d82d |
+3
-25
@@ -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,34 +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.
|
||||
# Updated individually, python is filtered correctly: alpha/beta/rc tags
|
||||
# are skipped and only stable releases (e.g. 3.15.0 once final) are
|
||||
# proposed. node + uv stay grouped into a single digest PR.
|
||||
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).
|
||||
@@ -63,8 +43,6 @@ updates:
|
||||
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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.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@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Create legacy aliases
|
||||
run: |
|
||||
|
||||
+15
-15
@@ -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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.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"
|
||||
|
||||
@@ -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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
|
||||
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -1,130 +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.2
|
||||
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
|
||||
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
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
|
||||
name: e2e (full — real Chrome + qBittorrent)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
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)."
|
||||
@@ -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
|
||||
|
||||
+12
-65
@@ -4,7 +4,7 @@ ARG BUILDPLATFORM
|
||||
ARG BUILDARCH
|
||||
|
||||
# Frontend build stage.
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-builder
|
||||
|
||||
# Helpful debug output to see what platforms BuildKit thinks it's using
|
||||
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
|
||||
@@ -25,9 +25,9 @@ COPY src/frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Use python-slim as the base image
|
||||
FROM python:3.14.6-slim@sha256:d3400aa122fa42cf0af0dbe8ec3091b047eac5c8f7e3539f7135e86d855dc015 AS base
|
||||
FROM python:3.14-slim AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.3 /uv /uvx /bin/
|
||||
|
||||
# Add build argument for version
|
||||
ARG BUILD_VERSION
|
||||
@@ -72,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 && \
|
||||
@@ -108,15 +103,6 @@ COPY pyproject.toml uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/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 . .
|
||||
|
||||
@@ -136,7 +122,7 @@ 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}
|
||||
@@ -152,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
|
||||
@@ -196,24 +164,6 @@ RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian-
|
||||
RUN --mount=type=cache,target=/root/.cache/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 \
|
||||
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__)"
|
||||
|
||||
# uv is only needed while building the image.
|
||||
RUN rm -f /usr/bin/uv /usr/bin/uvx
|
||||
|
||||
# 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}" && \
|
||||
@@ -230,7 +180,4 @@ FROM base AS shelfmark-lite
|
||||
|
||||
ENV USING_EXTERNAL_BYPASSER=true
|
||||
|
||||
# uv is only needed while building the image.
|
||||
RUN rm -f /usr/bin/uv /usr/bin/uvx
|
||||
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
+16
-227
@@ -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)
|
||||
@@ -32,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 |
|
||||
|----------|-------------|------|---------|
|
||||
@@ -44,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` |
|
||||
|
||||
@@ -109,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.
|
||||
@@ -146,103 +120,10 @@ 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` |
|
||||
@@ -252,15 +133,6 @@ Seconds since the last WireGuard handshake before the healthcheck bounces the tu
|
||||
<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**
|
||||
@@ -316,7 +188,6 @@ Default language filter for searches.
|
||||
| `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_ |
|
||||
@@ -364,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**
|
||||
@@ -432,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}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
|
||||
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
|
||||
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {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_ |
|
||||
@@ -449,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}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {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_ |
|
||||
@@ -499,7 +361,7 @@ Choose how downloaded book files are named and organized.
|
||||
|
||||
**Naming Template**
|
||||
|
||||
Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
Variables: {Author}, {Title}, {Year}, {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})`
|
||||
@@ -508,7 +370,7 @@ Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename wi
|
||||
|
||||
**Path Template**
|
||||
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {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})`
|
||||
@@ -662,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}`
|
||||
@@ -709,7 +571,7 @@ Choose how downloaded audiobook files are named and organized.
|
||||
|
||||
**Naming Template**
|
||||
|
||||
Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
Variables: {Author}, {Title}, {Year}, {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}`
|
||||
@@ -718,10 +580,10 @@ Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename wi
|
||||
|
||||
**Path Template**
|
||||
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {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`
|
||||
|
||||
@@ -777,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` |
|
||||
@@ -799,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`
|
||||
@@ -1051,7 +913,6 @@ Comma-separated hosts to bypass proxy (e.g., localhost,127.0.0.1,10.*,*.local)
|
||||
| `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` |
|
||||
@@ -1132,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**
|
||||
@@ -1211,7 +1062,6 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480
|
||||
| `PROWLARR_API_KEY` | Found in Prowlarr: Settings > General > API Key | string (secret) | _none_ |
|
||||
| `PROWLARR_INDEXERS` | Select which indexers to search. 📚 = has book categories. Leave empty to search all. | string (comma-separated) | _empty list_ |
|
||||
| `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
|
||||
| `PROWLARR_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>
|
||||
@@ -1263,66 +1113,6 @@ Automatically retry search without category filtering if no results are found
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `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_URL` | Base URL of your Newznab indexer or aggregator | string | _none_ |
|
||||
| `NEWZNAB_API_KEY` | Your Newznab API key (leave blank if not required) | string (secret) | _none_ |
|
||||
| `NEWZNAB_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_URL`
|
||||
|
||||
**Newznab URL**
|
||||
|
||||
Base URL of your Newznab indexer or aggregator
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
- **Required:** Yes
|
||||
|
||||
#### `NEWZNAB_API_KEY`
|
||||
|
||||
**API Key**
|
||||
|
||||
Your Newznab API key (leave blank if not required)
|
||||
|
||||
- **Type:** string (secret)
|
||||
- **Default:** _none_
|
||||
|
||||
#### `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
|
||||
@@ -1397,7 +1187,7 @@ Delay between requests in seconds to avoid rate limiting (0-10).
|
||||
| `IRC_USE_TLS` | Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't support TLS. | boolean | `true` |
|
||||
| `IRC_CHANNEL` | Channel name without the # prefix | string | _none_ |
|
||||
| `IRC_NICK` | Your IRC nickname (required). Must be unique on the IRC network. | string | _none_ |
|
||||
| `IRC_SEARCH_BOT` | The search bot to address queries to (required). | string | _none_ |
|
||||
| `IRC_SEARCH_BOT` | The search bot to query for results | string | _none_ |
|
||||
| `IRC_CACHE_TTL` | How long to keep cached search results before they expire. | string (choice) | `2592000` |
|
||||
|
||||
<details>
|
||||
@@ -1455,11 +1245,10 @@ Your IRC nickname (required). Must be unique on the IRC network.
|
||||
|
||||
**Search bot**
|
||||
|
||||
The search bot to address queries to (required). Searches are sent as "@<bot> <query>". Without it, queries would be posted unaddressed to the channel.
|
||||
The search bot to query for results
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
- **Required:** Yes
|
||||
|
||||
#### `IRC_CACHE_TTL`
|
||||
|
||||
|
||||
+3
-18
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+28
-70
@@ -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,11 +223,7 @@ 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
|
||||
@@ -342,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
|
||||
|
||||
@@ -458,29 +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_loglevel=$([ "$DEBUG" = "true" ] && echo debug || echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]')
|
||||
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"
|
||||
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
|
||||
@@ -549,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"
|
||||
|
||||
+6
-5
@@ -21,26 +21,27 @@ dependencies = [
|
||||
"rarfile",
|
||||
"qbittorrent-api",
|
||||
"transmission-rpc",
|
||||
"authlib>=1.7.2,<1.8",
|
||||
"apprise>=1.12.0",
|
||||
"authlib>=1.7.0,<1.8",
|
||||
"apprise>=1.9.0",
|
||||
"Pillow>=11.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
browser = [
|
||||
"pyvirtualdisplay",
|
||||
"pyautogui",
|
||||
"seleniumbase==4.51.2",
|
||||
"seleniumbase==4.48.2",
|
||||
"python-xlib",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"basedpyright>=1.39.9",
|
||||
"basedpyright>=1.39.3",
|
||||
"prek",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"ruff==0.15.22",
|
||||
"ruff==0.15.11",
|
||||
"vulture>=2.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
<img src="src/frontend/public/logo.png" alt="Shelfmark" width="200">
|
||||
|
||||
> [!NOTE]
|
||||
> This project is in a stable state as of May 2026 but is not under active maintenance.
|
||||
|
||||
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:
|
||||
@@ -106,16 +103,6 @@ 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.
|
||||
|
||||
@@ -151,24 +138,6 @@ 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:
|
||||
|
||||
|
||||
+13
-134
@@ -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._")
|
||||
|
||||
@@ -939,17 +939,7 @@ def _get_via_subprocess(url: str, retry: int, cancel_flag: Event | None = None)
|
||||
result_path = (
|
||||
Path(tempfile.gettempdir()) / f"shelfmark-bypass-{os.getpid()}-{time.time_ns()}.json"
|
||||
)
|
||||
# DNS provider state lives only in the parent's memory (no disk persistence), so the
|
||||
# freshly spawned helper would otherwise pre-resolve AA hostnames against the system
|
||||
# resolver - which may be blocked or hijacked by the user's ISP. Pass the parent's
|
||||
# active DNS config so the helper mirrors it (e.g. DoH) when building Chrome's host
|
||||
# resolver rules.
|
||||
payload = {
|
||||
"url": url,
|
||||
"retry": retry,
|
||||
"result_path": str(result_path),
|
||||
"dns_config": network.get_dns_config(),
|
||||
}
|
||||
payload = {"url": url, "retry": retry, "result_path": str(result_path)}
|
||||
env_vars = os.environ.copy()
|
||||
env_vars[_BYPASS_CHILD_ENV] = "1"
|
||||
env_vars = _prepare_child_browser_env(env_vars)
|
||||
@@ -1288,30 +1278,6 @@ def get_bypassed_page(
|
||||
return response_html
|
||||
|
||||
|
||||
def _apply_parent_dns_config(dns_config: dict[str, Any]) -> None:
|
||||
"""Mirror the parent process's active DNS provider in this helper subprocess.
|
||||
|
||||
DNS state is in-memory only, so a fresh helper defaults to system DNS and would
|
||||
pre-resolve AA hostnames (for Chrome's --host-resolver-rules) against a resolver
|
||||
that may be blocked/hijacked. Re-applying the parent's provider keeps the helper on
|
||||
the same DoH/custom resolver the parent already validated.
|
||||
"""
|
||||
provider = str(dns_config.get("provider") or "").strip().lower()
|
||||
# "auto" means the parent has not rotated off system DNS yet, so the helper's own
|
||||
# default initialization already matches it - nothing to override.
|
||||
if not provider or provider == "auto":
|
||||
return
|
||||
manual_servers = dns_config.get("servers") if provider == "manual" else None
|
||||
try:
|
||||
network.set_dns_provider(
|
||||
provider,
|
||||
manual_servers,
|
||||
use_doh=bool(dns_config.get("doh_enabled")),
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
logger.warning("Could not apply parent DNS config (%s): %s", provider, exc)
|
||||
|
||||
|
||||
def _run_child_process() -> int:
|
||||
"""CLI entrypoint used by the Docker helper subprocess."""
|
||||
request = json.loads(sys.stdin.read() or "{}")
|
||||
@@ -1321,10 +1287,6 @@ def _run_child_process() -> int:
|
||||
request.get("retry"), _coerce_positive_int(app_config.MAX_RETRY, 10)
|
||||
)
|
||||
|
||||
dns_config = request.get("dns_config")
|
||||
if isinstance(dns_config, dict):
|
||||
_apply_parent_dns_config(dns_config)
|
||||
|
||||
try:
|
||||
html = get(url, retry=retry)
|
||||
payload = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -121,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"))
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
*(
|
||||
[]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -36,9 +36,6 @@ from shelfmark.core.settings_registry import (
|
||||
register_settings,
|
||||
)
|
||||
|
||||
_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]:
|
||||
"""Validate advanced settings before persisting."""
|
||||
@@ -46,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}
|
||||
@@ -490,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",
|
||||
@@ -983,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",
|
||||
@@ -1011,14 +966,7 @@ def download_settings() -> list[SettingsField]:
|
||||
_naming_template_field(
|
||||
key="TEMPLATE_RENAME",
|
||||
label="Naming Template",
|
||||
description=(
|
||||
"Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} "
|
||||
"(source filename without extension). Universal adds: {Series}, "
|
||||
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
|
||||
"Rename templates are filename-only (no '/' or '\\'); use Organize for folders. "
|
||||
"Applies to single-file downloads."
|
||||
),
|
||||
description="Filename template for single-file book downloads.",
|
||||
default="{Author} - {Title} ({Year})",
|
||||
placeholder="{Author} - {Title} ({Year})",
|
||||
show_when=[
|
||||
@@ -1030,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}, {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=[
|
||||
@@ -1293,14 +1236,7 @@ def download_settings() -> list[SettingsField]:
|
||||
_naming_template_field(
|
||||
key="TEMPLATE_AUDIOBOOK_RENAME",
|
||||
label="Naming Template",
|
||||
description=(
|
||||
"Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} "
|
||||
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
|
||||
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
|
||||
"Rename templates are filename-only (no '/' or '\\'); use Organize for folders. "
|
||||
"Applies to single-file downloads."
|
||||
),
|
||||
description="Filename template for single-file audiobook downloads.",
|
||||
default="{Author} - {Title}",
|
||||
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
|
||||
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
|
||||
@@ -1310,13 +1246,8 @@ def download_settings() -> list[SettingsField]:
|
||||
_naming_template_field(
|
||||
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
|
||||
label="Path Template",
|
||||
description=(
|
||||
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, "
|
||||
"{OriginalName} (source filename without extension), {Series}, {SeriesPosition}, "
|
||||
"{Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
|
||||
),
|
||||
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,
|
||||
@@ -1485,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",
|
||||
@@ -1817,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",
|
||||
|
||||
@@ -82,7 +82,6 @@ _SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
|
||||
"DEFAULT_RELEASE_SOURCE",
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||
"SHOW_COMBINED_SELECTOR",
|
||||
"FORCE_COMBINED_SEARCH",
|
||||
*_SEARCH_PREFERENCE_PROVIDER_KEYS,
|
||||
}
|
||||
|
||||
@@ -224,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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
+182
-70
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -91,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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -333,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):
|
||||
@@ -360,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
|
||||
|
||||
@@ -494,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
|
||||
@@ -536,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
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
|
||||
@@ -1,21 +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 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
|
||||
@@ -211,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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -340,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(
|
||||
@@ -451,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:
|
||||
@@ -481,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 = (
|
||||
@@ -496,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,
|
||||
@@ -525,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:
|
||||
@@ -559,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. "
|
||||
@@ -578,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)
|
||||
|
||||
@@ -605,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:
|
||||
@@ -638,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,
|
||||
@@ -655,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...")
|
||||
@@ -802,38 +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,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -277,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,13 +46,6 @@ _HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
_ONE_WEEK_IN_SECONDS = 604800
|
||||
|
||||
|
||||
class _UnsafeQBittorrentPath:
|
||||
pass
|
||||
|
||||
|
||||
_UNSAFE_QBITTORRENT_PATH = _UnsafeQBittorrentPath()
|
||||
|
||||
|
||||
class _QBittorrentAddKwargs(TypedDict, total=False):
|
||||
rename: str
|
||||
category: str
|
||||
@@ -144,24 +136,6 @@ 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."""
|
||||
@@ -258,7 +232,7 @@ class QBittorrentClient(DownloadClient):
|
||||
self._tags = _normalize_tags(config.get("QBITTORRENT_TAG", []))
|
||||
|
||||
def _get_torrents_info(
|
||||
self, torrent_hash: str | None = None, category: str | None = None
|
||||
self, torrent_hash: str | None = None
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
"""Get torrent info using GET.
|
||||
|
||||
@@ -267,7 +241,6 @@ class QBittorrentClient(DownloadClient):
|
||||
- Keep "API/auth/connect" errors distinct from "torrent missing".
|
||||
- If a hash-specific query returns empty, fall back to listing by category
|
||||
and matching locally.
|
||||
- Without a hash, `category` narrows the listing to that category.
|
||||
|
||||
Returns:
|
||||
(torrents, error_message)
|
||||
@@ -302,8 +275,6 @@ class QBittorrentClient(DownloadClient):
|
||||
primary_params: dict[str, str] = {}
|
||||
if torrent_hash:
|
||||
primary_params["hashes"] = torrent_hash
|
||||
elif category:
|
||||
primary_params["category"] = category
|
||||
|
||||
response = do_request(primary_params)
|
||||
torrents, error = parse_response(response, request_params=primary_params)
|
||||
@@ -361,44 +332,6 @@ class QBittorrentClient(DownloadClient):
|
||||
else:
|
||||
return torrents, None
|
||||
|
||||
def _list_category_hashes(self, category: str | None) -> set[str] | None:
|
||||
"""Snapshot the hashes qBittorrent currently reports for a category."""
|
||||
torrents, error = self._get_torrents_info(category=category)
|
||||
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._get_torrents_info(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
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Check if qBittorrent is configured and selected as the torrent client."""
|
||||
@@ -466,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)
|
||||
@@ -504,20 +433,12 @@ 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)
|
||||
|
||||
# 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.
|
||||
@@ -708,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
|
||||
@@ -766,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",
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -116,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:
|
||||
@@ -161,17 +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 = []
|
||||
|
||||
is_audiobook = kwargs.get("content_type") == "audiobook"
|
||||
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}")
|
||||
@@ -201,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)
|
||||
|
||||
@@ -400,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.
|
||||
|
||||
|
||||
@@ -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,18 +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
|
||||
|
||||
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()
|
||||
@@ -356,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(
|
||||
|
||||
@@ -748,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",
|
||||
|
||||
@@ -5,23 +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
|
||||
|
||||
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
|
||||
@@ -36,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
|
||||
|
||||
@@ -66,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:
|
||||
@@ -77,7 +60,6 @@ class TorrentInfo:
|
||||
torrent_data=self.torrent_data,
|
||||
is_magnet=self.is_magnet,
|
||||
magnet_url=self.magnet_url,
|
||||
fetch_error=self.fetch_error,
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -112,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()
|
||||
@@ -178,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
|
||||
@@ -229,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]:
|
||||
|
||||
@@ -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"
|
||||
|
||||
+12
-16
@@ -229,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)
|
||||
@@ -467,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,
|
||||
@@ -587,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,
|
||||
@@ -635,16 +631,16 @@ def atomic_copy(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 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,
|
||||
|
||||
@@ -852,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
|
||||
|
||||
@@ -29,7 +29,6 @@ from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_clea
|
||||
from shelfmark.download.postprocess.router import post_process_download
|
||||
from shelfmark.release_sources import (
|
||||
get_handler,
|
||||
get_source,
|
||||
get_source_display_name,
|
||||
)
|
||||
|
||||
@@ -109,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)
|
||||
@@ -146,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
|
||||
@@ -167,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(
|
||||
@@ -205,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,
|
||||
}
|
||||
|
||||
@@ -219,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
|
||||
@@ -404,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),
|
||||
@@ -433,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)
|
||||
),
|
||||
@@ -461,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,
|
||||
@@ -492,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)),
|
||||
)
|
||||
|
||||
@@ -623,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",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -41,11 +40,9 @@ 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)
|
||||
@@ -66,9 +63,6 @@ def validate_destination(
|
||||
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
|
||||
|
||||
return True
|
||||
|
||||
@@ -13,6 +13,7 @@ from shelfmark.core.naming import (
|
||||
build_library_path,
|
||||
derive_primary_title,
|
||||
parse_naming_template,
|
||||
same_filesystem,
|
||||
sanitize_filename,
|
||||
)
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
@@ -38,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
|
||||
|
||||
@@ -92,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,
|
||||
|
||||
+65
-130
@@ -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,
|
||||
@@ -1163,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(
|
||||
@@ -1481,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)
|
||||
|
||||
@@ -1627,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.
|
||||
|
||||
@@ -1639,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.
|
||||
@@ -1648,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
|
||||
@@ -1839,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:
|
||||
@@ -1893,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:
|
||||
@@ -1931,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}")
|
||||
@@ -1962,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}")
|
||||
@@ -2058,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
|
||||
|
||||
@@ -2290,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", "")
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -390,10 +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 {}
|
||||
|
||||
@abstractmethod
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
"""Cancel an in-progress download."""
|
||||
|
||||
@@ -24,8 +24,6 @@ if TYPE_CHECKING:
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
DEFAULT_ABB_HOSTNAME = "audiobookbay.lu"
|
||||
ALLOWED_DETAIL_URL_SCHEMES = {"https"}
|
||||
|
||||
|
||||
def _resolve_configured_hostname() -> str:
|
||||
@@ -34,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."""
|
||||
@@ -88,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)
|
||||
|
||||
@@ -417,18 +417,6 @@ def extract_magnet_link(details_url: str, hostname: str = "audiobookbay.lu") ->
|
||||
# Clean up info hash (remove whitespace, ensure uppercase)
|
||||
info_hash = re.sub(r"\s+", "", info_hash).upper()
|
||||
|
||||
# Validate: SHA1 = 40 hex chars, SHA256 = 64 hex chars
|
||||
if not re.match(r"^[0-9A-F]{40}$|^[0-9A-F]{64}$", info_hash):
|
||||
logger.warning("Info Hash invalid (got %r), trying magnet fallback.", info_hash)
|
||||
# Fallback: search entire page for a complete magnet link (e.g. posted in comments)
|
||||
magnet_match = re.search(r"magnet:\?xt=urn:btih:([0-9a-fA-F]{40,64})", detail_html)
|
||||
if magnet_match:
|
||||
info_hash = magnet_match.group(1).upper()
|
||||
logger.info("Found hash via magnet fallback: %s", info_hash)
|
||||
else:
|
||||
logger.warning("No valid magnet link found on page, giving up.")
|
||||
return None
|
||||
|
||||
# 2. Extract Trackers
|
||||
# Find all <td> containing udp:// or http://
|
||||
trackers = []
|
||||
|
||||
@@ -238,8 +238,8 @@ class AudiobookBaySource(ReleaseSource):
|
||||
exact_phrase=exact_phrase,
|
||||
)
|
||||
|
||||
# Fallback to broad matching if exact phrase returns nothing (manual or auto query).
|
||||
if exact_phrase and not results:
|
||||
# For auto-generated queries, fallback to broad matching if exact phrase returns nothing.
|
||||
if exact_phrase and not results and not plan.manual_query:
|
||||
logger.info(
|
||||
"No exact phrase results, retrying AudiobookBay search without quotes"
|
||||
)
|
||||
@@ -288,7 +288,7 @@ class AudiobookBaySource(ReleaseSource):
|
||||
size_str = result.get("size")
|
||||
size_bytes = parse_size(size_str) if size_str else None
|
||||
language_raw = result.get("language")
|
||||
language_code = _map_language(language_raw) if language_raw else "en"
|
||||
language_code = _map_language(language_raw) if language_raw else None
|
||||
bitrate = result.get("bitrate")
|
||||
bitrate_kbps = _parse_bitrate_to_kbps(bitrate)
|
||||
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
from dataclasses import replace
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, ClassVar, NoReturn, TypedDict
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
@@ -200,49 +197,6 @@ _SOURCE_FAILURE_THRESHOLD = 4
|
||||
_MIN_VALID_FILE_SIZE = 10 * 1024
|
||||
_AA_COUNTDOWN_MAX_SECONDS = 300
|
||||
|
||||
# --- Distant-path language detection ---
|
||||
|
||||
_DISTANT_PATH_EXTENSIONS = (
|
||||
"epub",
|
||||
"mobi",
|
||||
"azw3",
|
||||
"fb2",
|
||||
"djvu",
|
||||
"cbz",
|
||||
"cbr",
|
||||
"pdf",
|
||||
"zip",
|
||||
"rar",
|
||||
"m4b",
|
||||
"mp3",
|
||||
)
|
||||
_DISTANT_PATH_EXTENSION_PATTERN = "|".join(re.escape(e) for e in _DISTANT_PATH_EXTENSIONS)
|
||||
_DISTANT_PATH_PATTERN = re.compile(
|
||||
rf"(?:[A-Za-z0-9._-]+/)?[A-Za-z]:(?:\\|/)[^\n\r<>\"]+?\.(?:{_DISTANT_PATH_EXTENSION_PATTERN})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DISTANT_PATH_FALLBACK_PATTERN = re.compile(
|
||||
r"(?:[A-Za-z0-9._-]+/)?[A-Za-z]:(?:\\|/)[^\n\r<>\"]+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRACKETED_LANGUAGE_CODE_PATTERN = re.compile(
|
||||
r"\[(?:bd[\s._-]*)?([A-Za-z]{2,3})\]",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_KEYED_LANGUAGE_CODE_PATTERN = re.compile(
|
||||
r"\b(?:bd|lang(?:uage)?)\s*[:._-]?\s*([A-Za-z]{2,3})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LANGUAGE_CODE_TOKEN_PATTERN = re.compile(
|
||||
r"(?:^|[\s_./\\\-\[(])([A-Za-z]{2,3})(?=$|[\s_./\\\-)\]])"
|
||||
)
|
||||
_LANGUAGE_NAME_TOKEN_PATTERN = re.compile(r"[a-z]{4,}(?:-[a-z0-9]+)?")
|
||||
_LANGUAGE_ALIAS_TO_CODE: dict[str, str] | None = None
|
||||
_LANGUAGE_ALIAS_LOCK = threading.Lock()
|
||||
_LANGUAGE_PLACEHOLDERS = frozenset({"", "-", "--", "unknown", "unk", "n/a", "na"})
|
||||
# Short codes that appear in common words — require bracket/key context to accept
|
||||
_AMBIGUOUS_SHORT_LANGUAGE_CODES = frozenset({"de", "en", "it", "la", "no", "or", "is", "in"})
|
||||
|
||||
# Sources that require Cloudflare bypass
|
||||
_CF_BYPASS_REQUIRED = frozenset({"aa-slow-nowait", "aa-slow-wait", "zlib", "welib"})
|
||||
|
||||
@@ -250,189 +204,6 @@ _CF_BYPASS_REQUIRED = frozenset({"aa-slow-nowait", "aa-slow-wait", "zlib", "weli
|
||||
_AA_PAGE_SOURCES = frozenset({"aa-slow-nowait", "aa-slow-wait"})
|
||||
|
||||
|
||||
def _is_language_from_path_enabled() -> bool:
|
||||
return bool(config.get("DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH", False))
|
||||
|
||||
|
||||
def _normalize_language_token(value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
for dash in ("‑", "–", "—", "−"):
|
||||
normalized = normalized.replace(dash, "-")
|
||||
return normalized
|
||||
|
||||
|
||||
def _fold_text(value: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", value)
|
||||
return "".join(c for c in normalized if not unicodedata.combining(c)).lower()
|
||||
|
||||
|
||||
def _language_alias_to_code() -> dict[str, str]:
|
||||
"""Build alias→code map from bundled language metadata (lazy, cached)."""
|
||||
global _LANGUAGE_ALIAS_TO_CODE
|
||||
cached = _LANGUAGE_ALIAS_TO_CODE
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
with _LANGUAGE_ALIAS_LOCK:
|
||||
cached = _LANGUAGE_ALIAS_TO_CODE
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
mapping: dict[str, str] = {}
|
||||
data_path = Path(__file__).resolve().parents[2] / "data" / "book-languages.json"
|
||||
|
||||
try:
|
||||
raw = json.loads(data_path.read_text(encoding="utf-8"))
|
||||
except OSError, ValueError, TypeError:
|
||||
_LANGUAGE_ALIAS_TO_CODE = {}
|
||||
return _LANGUAGE_ALIAS_TO_CODE
|
||||
|
||||
if not isinstance(raw, list):
|
||||
_LANGUAGE_ALIAS_TO_CODE = {}
|
||||
return _LANGUAGE_ALIAS_TO_CODE
|
||||
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
code = _normalize_language_token(str(item.get("code", "")))
|
||||
name = _normalize_language_token(str(item.get("language", "")))
|
||||
if not code:
|
||||
continue
|
||||
mapping.setdefault(code, code)
|
||||
mapping.setdefault(code.replace("-", "_"), code)
|
||||
mapping.setdefault(code.split("-")[0], code)
|
||||
mapping.setdefault(_fold_text(code), code)
|
||||
if name:
|
||||
mapping.setdefault(name, code)
|
||||
mapping.setdefault(_fold_text(name), code)
|
||||
|
||||
_LANGUAGE_ALIAS_TO_CODE = mapping
|
||||
return _LANGUAGE_ALIAS_TO_CODE
|
||||
|
||||
|
||||
def _extract_distant_path(row: Tag, *, enabled: bool) -> str | None:
|
||||
"""Extract the Windows-style file path from an AA search result row."""
|
||||
if not enabled:
|
||||
return None
|
||||
|
||||
def _normalize_candidate(text: str) -> str:
|
||||
normalized = re.sub(r"\s*([\\/])\s*", r"\1", text)
|
||||
normalized = re.sub(r":\s*([\\/])", r":\1", normalized)
|
||||
return re.sub(
|
||||
r"\s+\.(epub|mobi|azw3|fb2|djvu|cbz|cbr|pdf|zip|rar|m4b|mp3)\b",
|
||||
r".\1",
|
||||
normalized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
candidates = [row.get_text(" ", strip=True)]
|
||||
for cell in row.find_all("td"):
|
||||
cell_text = cell.get_text(" ", strip=True)
|
||||
if cell_text:
|
||||
candidates.append(cell_text)
|
||||
|
||||
best: str | None = None
|
||||
for text in candidates:
|
||||
for match in _DISTANT_PATH_PATTERN.findall(_normalize_candidate(text)):
|
||||
candidate = match.strip().rstrip(".,;")
|
||||
if best is None or len(candidate) > len(best):
|
||||
best = candidate
|
||||
|
||||
if best is not None:
|
||||
return best
|
||||
|
||||
for text in candidates:
|
||||
for match in _DISTANT_PATH_FALLBACK_PATTERN.findall(_normalize_candidate(text)):
|
||||
candidate = match.strip().rstrip(".,;")
|
||||
if best is None or len(candidate) > len(best):
|
||||
best = candidate
|
||||
|
||||
return best
|
||||
|
||||
|
||||
def _detect_language_from_distant_path(path: str | None) -> str | None:
|
||||
"""Infer a language code from distant-path tags such as [BD FR] or [Fr]."""
|
||||
if not path:
|
||||
return None
|
||||
|
||||
aliases = _language_alias_to_code()
|
||||
if not aliases:
|
||||
return None
|
||||
|
||||
folded_path = _fold_text(path)
|
||||
strong_candidates: list[str] = []
|
||||
|
||||
for code in _BRACKETED_LANGUAGE_CODE_PATTERN.findall(path):
|
||||
normalized = _normalize_language_token(code)
|
||||
if normalized in aliases:
|
||||
strong_candidates.append(aliases[normalized])
|
||||
|
||||
for code in _KEYED_LANGUAGE_CODE_PATTERN.findall(path):
|
||||
normalized = _normalize_language_token(code)
|
||||
if normalized in aliases:
|
||||
strong_candidates.append(aliases[normalized])
|
||||
|
||||
non_ambiguous = [c for c in strong_candidates if c not in _AMBIGUOUS_SHORT_LANGUAGE_CODES]
|
||||
if non_ambiguous:
|
||||
return non_ambiguous[0]
|
||||
|
||||
for token in _LANGUAGE_NAME_TOKEN_PATTERN.findall(folded_path):
|
||||
normalized = _normalize_language_token(token)
|
||||
if normalized in aliases:
|
||||
candidate = aliases[normalized]
|
||||
if candidate not in _AMBIGUOUS_SHORT_LANGUAGE_CODES:
|
||||
return candidate
|
||||
|
||||
if strong_candidates:
|
||||
return strong_candidates[0]
|
||||
|
||||
for code in _LANGUAGE_CODE_TOKEN_PATTERN.findall(path):
|
||||
normalized = _normalize_language_token(code)
|
||||
if normalized in _AMBIGUOUS_SHORT_LANGUAGE_CODES:
|
||||
continue
|
||||
if normalized in aliases:
|
||||
return aliases[normalized]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_missing_or_placeholder_language(language: str | None) -> bool:
|
||||
if language is None:
|
||||
return True
|
||||
return _normalize_language_token(language) in _LANGUAGE_PLACEHOLDERS
|
||||
|
||||
|
||||
def _normalize_requested_languages(languages: list[str] | None) -> set[str]:
|
||||
if not languages:
|
||||
return set()
|
||||
aliases = _language_alias_to_code()
|
||||
normalized: set[str] = set()
|
||||
for value in languages:
|
||||
token = _normalize_language_token(str(value))
|
||||
if not token or token == "all": # noqa: S105 - "all" is a language sentinel
|
||||
continue
|
||||
normalized.add(aliases.get(token, token))
|
||||
return normalized
|
||||
|
||||
|
||||
def _book_matches_requested_languages(book_language: str | None, requested: set[str]) -> bool:
|
||||
"""Return True when a book's language matches the requested filter.
|
||||
|
||||
Books with unknown/missing language always pass — the server-side &lang= filter
|
||||
already narrowed the result set, so dropping unlabelled rows hides valid results.
|
||||
"""
|
||||
if not requested:
|
||||
return True
|
||||
if not book_language:
|
||||
return True
|
||||
aliases = _language_alias_to_code()
|
||||
normalized_book = aliases.get(
|
||||
_normalize_language_token(book_language),
|
||||
_normalize_language_token(book_language),
|
||||
)
|
||||
return normalized_book in requested
|
||||
|
||||
|
||||
def _is_configured_zlib_link(url: str) -> bool:
|
||||
"""Return True when a URL belongs to a configured Z-Library mirror."""
|
||||
from shelfmark.core.mirrors import get_zlib_cookie_domains
|
||||
@@ -589,17 +360,9 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
|
||||
|
||||
filters_query = ""
|
||||
|
||||
path_language_enabled = _is_language_from_path_enabled()
|
||||
requested_langs = _normalize_requested_languages(filters.lang)
|
||||
|
||||
# When path-language inference is on and a language is requested, skip the
|
||||
# server-side &lang= filter: lgli files often have no AA language metadata
|
||||
# and would be excluded before we can infer language from the distant path.
|
||||
# Local filtering below handles the narrowing instead.
|
||||
if not (path_language_enabled and requested_langs):
|
||||
for value in filters.lang or []:
|
||||
if value and value != "all":
|
||||
filters_query += f"&lang={quote(value)}"
|
||||
for value in filters.lang or []:
|
||||
if value and value != "all":
|
||||
filters_query += f"&lang={quote(value)}"
|
||||
|
||||
if filters.sort and filters.sort != "relevance":
|
||||
filters_query += f"&sort={quote(filters.sort)}"
|
||||
@@ -634,13 +397,14 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
|
||||
msg = "Unable to reach download source. Network restricted or mirrors are blocked."
|
||||
raise SearchUnavailableError(msg)
|
||||
|
||||
if "No files found." in html:
|
||||
logger.info("No books found for query: %s", query)
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(_html_response_text(html), "html.parser")
|
||||
tbody = soup.find("table")
|
||||
|
||||
if tbody is None:
|
||||
if "No files found." in html:
|
||||
logger.info("No books found for query: %s", query)
|
||||
return []
|
||||
logger.warning("No results table found for query: %s", query)
|
||||
msg = "No books found. Please try another query."
|
||||
raise RuntimeError(msg)
|
||||
@@ -654,9 +418,6 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
|
||||
if book:
|
||||
books.append(book)
|
||||
|
||||
if path_language_enabled and requested_langs:
|
||||
books = [b for b in books if _book_matches_requested_languages(b.language, requested_langs)]
|
||||
|
||||
supported_formats = _get_supported_formats()
|
||||
|
||||
books.sort(
|
||||
@@ -710,23 +471,10 @@ def _parse_search_result_row(row: Tag) -> BrowseRecord | None:
|
||||
if not record_id:
|
||||
return None
|
||||
|
||||
path_language_enabled = _is_language_from_path_enabled()
|
||||
distant_path = _extract_distant_path(row, enabled=path_language_enabled)
|
||||
|
||||
preview_img = cells[0].find("img")
|
||||
preview = _get_attr(preview_img, "src") if isinstance(preview_img, Tag) else None
|
||||
|
||||
title_span = cells[1].find("span")
|
||||
if isinstance(title_span, Tag):
|
||||
# AA nests related-edition spans inside the main title span — take only direct text.
|
||||
direct = " ".join(
|
||||
str(c).strip()
|
||||
for c in title_span.children
|
||||
if isinstance(c, NavigableString) and str(c).strip()
|
||||
).strip()
|
||||
title = direct or _first_stripped_text(title_span)
|
||||
else:
|
||||
title = None
|
||||
title = _first_stripped_text(cells[1].find("span"))
|
||||
author = _first_stripped_text(cells[2].find("span"))
|
||||
publisher = _first_stripped_text(cells[3].find("span"))
|
||||
year = _first_stripped_text(cells[4].find("span"))
|
||||
@@ -735,19 +483,18 @@ def _parse_search_result_row(row: Tag) -> BrowseRecord | None:
|
||||
file_format = _first_stripped_text(cells[9].find("span"))
|
||||
size = _first_stripped_text(cells[10].find("span"))
|
||||
|
||||
# Only title and format are truly required — lgli rows often have sparse metadata
|
||||
if title is None or file_format is None:
|
||||
if (
|
||||
title is None
|
||||
or author is None
|
||||
or publisher is None
|
||||
or year is None
|
||||
or language is None
|
||||
or content is None
|
||||
or file_format is None
|
||||
or size is None
|
||||
):
|
||||
return None
|
||||
|
||||
# Skip entries where the title is a catalog format descriptor, not a real title
|
||||
# e.g. "Book/Online Audio", "Print book" — lgli metadata pollution
|
||||
if title and "/" in title and len(title) < 40 and not any(c.isdigit() for c in title):
|
||||
return None
|
||||
|
||||
if path_language_enabled and _is_missing_or_placeholder_language(language):
|
||||
detected = _detect_language_from_distant_path(distant_path)
|
||||
language = detected or "unknown"
|
||||
|
||||
return BrowseRecord(
|
||||
id=record_id,
|
||||
title=title,
|
||||
@@ -760,7 +507,6 @@ def _parse_search_result_row(row: Tag) -> BrowseRecord | None:
|
||||
content=content.lower() if content else None,
|
||||
format=file_format.lower() if file_format else None,
|
||||
size=size,
|
||||
download_path=distant_path,
|
||||
)
|
||||
except (AttributeError, IndexError, KeyError, TypeError) as e:
|
||||
logger.error_trace(f"Error parsing search result row: {e}")
|
||||
@@ -1483,9 +1229,6 @@ def _get_download_url(
|
||||
return downloader.get_absolute_url(link, url)
|
||||
|
||||
|
||||
_AA_COUNTDOWN_MAX_RETRIES = 3
|
||||
|
||||
|
||||
def _extract_slow_download_url(
|
||||
soup: BeautifulSoup,
|
||||
link: str,
|
||||
@@ -1494,7 +1237,6 @@ def _extract_slow_download_url(
|
||||
status_callback: Callable[[str, str | None], None] | None,
|
||||
selector: network.AAMirrorSelector,
|
||||
source_context: str | None = None,
|
||||
_countdown_attempts: int = 0,
|
||||
) -> str:
|
||||
"""Extract download URL from AA slow download pages."""
|
||||
html_str = str(soup)
|
||||
@@ -1559,14 +1301,6 @@ def _extract_slow_download_url(
|
||||
|
||||
countdown_seconds = _extract_countdown_seconds(soup, html_str)
|
||||
if countdown_seconds > 0:
|
||||
if _countdown_attempts >= _AA_COUNTDOWN_MAX_RETRIES:
|
||||
logger.warning(
|
||||
"Countdown retry limit (%s) reached for %s, giving up",
|
||||
_AA_COUNTDOWN_MAX_RETRIES,
|
||||
title,
|
||||
)
|
||||
return ""
|
||||
|
||||
max_countdown_seconds = 600
|
||||
sleep_time = min(countdown_seconds, max_countdown_seconds)
|
||||
if countdown_seconds > max_countdown_seconds:
|
||||
@@ -1575,13 +1309,7 @@ def _extract_slow_download_url(
|
||||
countdown_seconds,
|
||||
max_countdown_seconds,
|
||||
)
|
||||
logger.info(
|
||||
"AA waitlist: %ss for %s (attempt %s/%s)",
|
||||
sleep_time,
|
||||
title,
|
||||
_countdown_attempts + 1,
|
||||
_AA_COUNTDOWN_MAX_RETRIES,
|
||||
)
|
||||
logger.info("AA waitlist: %ss for %s", sleep_time, title)
|
||||
|
||||
# Live countdown with status updates
|
||||
for remaining in range(sleep_time, 0, -1):
|
||||
@@ -1602,31 +1330,12 @@ def _extract_slow_download_url(
|
||||
if status_callback and source_context:
|
||||
status_callback("resolving", f"{source_context} - Fetching")
|
||||
|
||||
html = downloader.html_get_page(
|
||||
link, selector=selector, cancel_flag=cancel_flag, status_callback=status_callback
|
||||
)
|
||||
if not html:
|
||||
return ""
|
||||
new_soup = BeautifulSoup(_html_response_text(html), "html.parser")
|
||||
return _extract_slow_download_url(
|
||||
new_soup,
|
||||
link,
|
||||
title,
|
||||
cancel_flag,
|
||||
status_callback,
|
||||
selector,
|
||||
source_context,
|
||||
_countdown_attempts + 1,
|
||||
return _get_download_url(
|
||||
link, title, cancel_flag, status_callback, selector, source_context
|
||||
)
|
||||
|
||||
link_texts = [a.get_text(strip=True)[:50] for a in soup.find_all("a", href=True)[:10]]
|
||||
logger.warning("No download URL found. First 10 links: %s", link_texts)
|
||||
# A bypassed page with no AA download links often means the network served a wrong
|
||||
# page (e.g. an ISP block page) instead of Anna's Archive. Probe for DNS interference
|
||||
# so we can give the user an actionable hint instead of a generic failure.
|
||||
host = urlparse(link).hostname or ""
|
||||
if host:
|
||||
network.note_possible_dns_interference(host)
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1937,6 +1646,7 @@ class DirectDownloadSource(ReleaseSource):
|
||||
except Exception:
|
||||
logger.exception("Search error")
|
||||
|
||||
logger.info("Found %s releases via title+author", len(all_results))
|
||||
return [_browse_record_to_release(record) for record in all_results]
|
||||
|
||||
def is_available(self) -> bool:
|
||||
@@ -2059,14 +1769,7 @@ class DirectDownloadHandler(DownloadHandler):
|
||||
return None
|
||||
|
||||
if not success_url:
|
||||
if network.dns_interference_detected():
|
||||
status_callback(
|
||||
"error",
|
||||
"All sources failed - your network/ISP appears to be blocking "
|
||||
"Anna's Archive. Enable DNS-over-HTTPS in settings.",
|
||||
)
|
||||
else:
|
||||
status_callback("error", "All download sources failed")
|
||||
status_callback("error", "All download sources failed")
|
||||
return None
|
||||
|
||||
# Return temp path - orchestrator handles post-processing (archive extraction, ingest)
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
|
||||
from shelfmark.config import env
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.release_sources import Release, ReleaseProtocol
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -55,6 +56,12 @@ def _coerce_timestamp(value: object) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _generate_cache_key(provider: str, provider_id: str, content_type: str | None = None) -> str:
|
||||
"""Generate a cache key from provider, provider_id, and content type."""
|
||||
normalized_content_type = "audiobook" if check_audiobook(content_type) else "ebook"
|
||||
return f"{provider}:{provider_id}:{normalized_content_type}"
|
||||
|
||||
|
||||
def _load_cache() -> dict[str, Any]:
|
||||
"""Load cache from disk."""
|
||||
try:
|
||||
@@ -96,17 +103,17 @@ def _dict_to_release(data: dict[str, Any]) -> Release:
|
||||
|
||||
|
||||
def get_cached_results(
|
||||
cache_key: str,
|
||||
provider: str,
|
||||
provider_id: str,
|
||||
content_type: str | None = None,
|
||||
ttl_seconds: int | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get the cached IRC answer for a query identity (server:channel:query).
|
||||
|
||||
The cache stores the whole answer (releases for all content types) under the query
|
||||
identity, so it is not isolated by book or content type. Callers filter by content
|
||||
type after reading.
|
||||
"""Get cached search results for a book.
|
||||
|
||||
Args:
|
||||
cache_key: Query identity (e.g. "server:channel:query")
|
||||
provider: Metadata provider name (e.g., "hardcover", "openlibrary")
|
||||
provider_id: Book ID in the provider's system
|
||||
content_type: Search content type for cache isolation
|
||||
ttl_seconds: Cache TTL in seconds (from settings)
|
||||
|
||||
Returns:
|
||||
@@ -120,6 +127,8 @@ def get_cached_results(
|
||||
ttl_value = config.get("IRC_CACHE_TTL", DEFAULT_CACHE_TTL)
|
||||
ttl_seconds = _coerce_cache_ttl(ttl_value, DEFAULT_CACHE_TTL)
|
||||
|
||||
cache_key = _generate_cache_key(provider, provider_id, content_type)
|
||||
|
||||
with _cache_lock:
|
||||
cache = _load_cache()
|
||||
entry = cache.get("entries", {}).get(cache_key)
|
||||
@@ -132,9 +141,10 @@ def get_cached_results(
|
||||
age = time.time() - cached_at
|
||||
|
||||
if ttl_seconds != 0 and age > ttl_seconds:
|
||||
title = entry.get("title", cache_key)
|
||||
logger.debug(
|
||||
"IRC cache expired for '%s' (age: %.0fs > TTL: %ss)",
|
||||
entry.get("title", cache_key),
|
||||
title,
|
||||
age,
|
||||
ttl_seconds,
|
||||
)
|
||||
@@ -143,36 +153,44 @@ def get_cached_results(
|
||||
|
||||
# Convert dicts back to Release objects
|
||||
releases = [_dict_to_release(r) for r in entry.get("releases", [])]
|
||||
online_servers = entry.get("online_servers", [])
|
||||
title = entry.get("title", "")
|
||||
|
||||
logger.info(
|
||||
"IRC cache hit for '%s' (%s releases, age: %.0fs)",
|
||||
entry.get("title", ""),
|
||||
title,
|
||||
len(releases),
|
||||
age,
|
||||
)
|
||||
|
||||
return {
|
||||
"releases": releases,
|
||||
"online_servers": entry.get("online_servers", []),
|
||||
"online_servers": online_servers,
|
||||
"cached_at": cached_at,
|
||||
}
|
||||
|
||||
|
||||
def cache_results(
|
||||
cache_key: str,
|
||||
provider: str,
|
||||
provider_id: str,
|
||||
title: str,
|
||||
releases: list[Release],
|
||||
content_type: str | None = None,
|
||||
online_servers: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Cache the whole IRC answer for a query identity.
|
||||
"""Cache search results for a book.
|
||||
|
||||
Args:
|
||||
cache_key: Query identity (e.g. "server:channel:query")
|
||||
title: Query text (for logging/display)
|
||||
releases: All Release objects from the search (every content type)
|
||||
provider: Metadata provider name
|
||||
provider_id: Book ID in the provider's system
|
||||
title: Book title (for logging/display)
|
||||
releases: List of Release objects from search
|
||||
content_type: Search content type for cache isolation
|
||||
online_servers: List of online server nicks (optional)
|
||||
|
||||
"""
|
||||
cache_key = _generate_cache_key(provider, provider_id, content_type)
|
||||
|
||||
with _cache_lock:
|
||||
cache = _load_cache()
|
||||
|
||||
@@ -180,6 +198,9 @@ def cache_results(
|
||||
cache["entries"] = {}
|
||||
|
||||
cache["entries"][cache_key] = {
|
||||
"provider": provider,
|
||||
"provider_id": provider_id,
|
||||
"content_type": "audiobook" if check_audiobook(content_type) else "ebook",
|
||||
"title": title,
|
||||
"releases": [_release_to_dict(r) for r in releases],
|
||||
"online_servers": list(online_servers) if online_servers else [],
|
||||
@@ -190,23 +211,27 @@ def cache_results(
|
||||
logger.info("Cached %s IRC releases for '%s'", len(releases), title)
|
||||
|
||||
|
||||
def invalidate_cache(cache_key: str) -> bool:
|
||||
def invalidate_cache(provider: str, provider_id: str, content_type: str | None = None) -> bool:
|
||||
"""Remove a specific entry from the cache.
|
||||
|
||||
Args:
|
||||
cache_key: Query identity to remove
|
||||
provider: Metadata provider name
|
||||
provider_id: Book ID in the provider's system
|
||||
content_type: Search content type for cache isolation
|
||||
|
||||
Returns:
|
||||
True if entry was found and removed
|
||||
|
||||
"""
|
||||
cache_key = _generate_cache_key(provider, provider_id, content_type)
|
||||
|
||||
with _cache_lock:
|
||||
cache = _load_cache()
|
||||
entries = cache.get("entries", {})
|
||||
entry = cache.get("entries", {}).get(cache_key)
|
||||
title = entry.get("title", cache_key) if entry else cache_key
|
||||
|
||||
if cache_key in entries:
|
||||
title = entries[cache_key].get("title", cache_key)
|
||||
del entries[cache_key]
|
||||
if cache_key in cache.get("entries", {}):
|
||||
del cache["entries"][cache_key]
|
||||
_save_cache(cache)
|
||||
logger.info("Invalidated IRC cache for '%s'", title)
|
||||
return True
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Self
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
from .dcc import DCCError, DCCOffer, parse_dcc_send, validate_dcc_endpoint
|
||||
from .dcc import DCCOffer, parse_dcc_send
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@@ -400,33 +400,6 @@ class IRCClient:
|
||||
self.send_notice(sender, f"\x01VERSION {self.version}\x01")
|
||||
logger.debug("Sent VERSION to %s", sender)
|
||||
|
||||
@staticmethod
|
||||
def _sender_nick(msg: IRCMessage) -> str | None:
|
||||
"""Extract the nick from a message prefix."""
|
||||
if not msg.prefix:
|
||||
return None
|
||||
return msg.prefix.split("!", maxsplit=1)[0]
|
||||
|
||||
def _is_allowed_dcc_sender(
|
||||
self,
|
||||
msg: IRCMessage,
|
||||
expected_senders: set[str] | None,
|
||||
) -> bool:
|
||||
allowed_senders = expected_senders or self.online_servers
|
||||
if not allowed_senders:
|
||||
return True
|
||||
|
||||
sender = self._sender_nick(msg)
|
||||
if sender is None:
|
||||
logger.warning("Ignoring DCC offer without sender prefix")
|
||||
return False
|
||||
|
||||
normalized_allowed = {nick.casefold() for nick in allowed_senders}
|
||||
if sender.casefold() not in normalized_allowed:
|
||||
logger.warning("Ignoring DCC offer from unexpected sender: %s", sender)
|
||||
return False
|
||||
return True
|
||||
|
||||
def read_messages(self, *, auto_handle: bool = True) -> Iterator[IRCMessage]:
|
||||
"""Read and yield IRC messages, optionally auto-handling PING/VERSION."""
|
||||
for line in self._recv_lines():
|
||||
@@ -449,7 +422,6 @@ class IRCClient:
|
||||
timeout: float = 60.0,
|
||||
*,
|
||||
result_type: bool = False,
|
||||
expected_senders: set[str] | None = None,
|
||||
) -> DCCOffer | None:
|
||||
"""Wait for a DCC SEND offer. Returns None on timeout or no results."""
|
||||
target_event = IRCEvent.SEARCH_RESULT if result_type else IRCEvent.BOOK_RESULT
|
||||
@@ -461,15 +433,12 @@ class IRCClient:
|
||||
return None
|
||||
|
||||
if msg.event == target_event:
|
||||
if not self._is_allowed_dcc_sender(msg, expected_senders):
|
||||
continue
|
||||
try:
|
||||
offer = parse_dcc_send(msg.raw)
|
||||
validate_dcc_endpoint(offer)
|
||||
logger.info("Received DCC offer: %s", offer.filename)
|
||||
except DCCError:
|
||||
logger.exception("Rejected DCC offer")
|
||||
continue
|
||||
except Exception:
|
||||
logger.exception("Failed to parse DCC")
|
||||
return None
|
||||
else:
|
||||
return offer
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import re
|
||||
import socket
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from ipaddress import ip_address
|
||||
from pathlib import PureWindowsPath
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
@@ -61,10 +59,6 @@ class DCCConnectionError(DCCError):
|
||||
"""Failed to connect to DCC sender."""
|
||||
|
||||
|
||||
class DCCSecurityError(DCCError):
|
||||
"""Rejected unsafe DCC offer metadata."""
|
||||
|
||||
|
||||
def int_to_ip(ip_int: int) -> str:
|
||||
"""Convert 32-bit integer (DCC format) to dotted IP notation."""
|
||||
packed = struct.pack(">I", ip_int)
|
||||
@@ -82,53 +76,15 @@ def parse_dcc_send(text: str) -> DCCOffer:
|
||||
ip_int = int(match.group(2))
|
||||
port = int(match.group(3))
|
||||
size = int(match.group(4))
|
||||
try:
|
||||
ip = int_to_ip(ip_int)
|
||||
except struct.error as e:
|
||||
msg = f"Invalid DCC IP integer: {ip_int}"
|
||||
raise DCCParseError(msg) from e
|
||||
|
||||
return DCCOffer(
|
||||
filename=safe_dcc_filename(filename),
|
||||
ip=ip,
|
||||
filename=filename,
|
||||
ip=int_to_ip(ip_int),
|
||||
port=port,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
def safe_dcc_filename(filename: str) -> str:
|
||||
"""Return a DCC filename that cannot escape its destination directory."""
|
||||
safe_name = filename.strip()
|
||||
windows_path = PureWindowsPath(safe_name)
|
||||
if (
|
||||
not safe_name
|
||||
or safe_name in {".", ".."}
|
||||
or "/" in safe_name
|
||||
or "\\" in safe_name
|
||||
or windows_path.drive
|
||||
):
|
||||
msg = f"Rejected unsafe DCC filename: {filename!r}"
|
||||
raise DCCSecurityError(msg)
|
||||
return safe_name
|
||||
|
||||
|
||||
def validate_dcc_endpoint(offer: DCCOffer) -> None:
|
||||
"""Reject DCC endpoints that can target local/internal network services."""
|
||||
if not 1 <= offer.port <= 65535:
|
||||
msg = f"Rejected invalid DCC port: {offer.port}"
|
||||
raise DCCSecurityError(msg)
|
||||
|
||||
try:
|
||||
address = ip_address(offer.ip)
|
||||
except ValueError as e:
|
||||
msg = f"Rejected invalid DCC IP address: {offer.ip}"
|
||||
raise DCCSecurityError(msg) from e
|
||||
|
||||
if not address.is_global:
|
||||
msg = f"Rejected non-public DCC endpoint: {offer.ip}"
|
||||
raise DCCSecurityError(msg)
|
||||
|
||||
|
||||
def download_dcc(
|
||||
offer: DCCOffer,
|
||||
dest_path: Path,
|
||||
@@ -137,7 +93,6 @@ def download_dcc(
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
"""Download file via DCC protocol to dest_path. Raises DCCError on failure."""
|
||||
validate_dcc_endpoint(offer)
|
||||
logger.info("DCC connecting to %s:%s for %s", offer.ip, offer.port, offer.filename)
|
||||
|
||||
try:
|
||||
|
||||
@@ -12,7 +12,7 @@ from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.release_sources import DownloadHandler, register_handler
|
||||
|
||||
from .connection_manager import connection_manager
|
||||
from .dcc import DCCError, download_dcc, safe_dcc_filename
|
||||
from .dcc import DCCError, download_dcc
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -23,15 +23,6 @@ if TYPE_CHECKING:
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _server_from_download_request(download_request: str) -> str | None:
|
||||
"""Extract the expected IRC bot nick from a release request line."""
|
||||
stripped = download_request.strip()
|
||||
if not stripped.startswith("!"):
|
||||
return None
|
||||
server = stripped[1:].split(maxsplit=1)[0]
|
||||
return server or None
|
||||
|
||||
|
||||
def _config_text(key: str) -> str:
|
||||
"""Read a string config value with whitespace trimmed."""
|
||||
value = config.get(key, "")
|
||||
@@ -81,7 +72,6 @@ class IRCDownloadHandler(DownloadHandler):
|
||||
"""Download a release via IRC DCC. task.task_id contains the IRC request string."""
|
||||
download_request = task.task_id
|
||||
logger.info("IRC download: %s...", download_request[:60])
|
||||
expected_server = _server_from_download_request(download_request)
|
||||
|
||||
# Get IRC settings
|
||||
server = _config_text("IRC_SERVER")
|
||||
@@ -133,8 +123,7 @@ class IRCDownloadHandler(DownloadHandler):
|
||||
# Phase 3: Wait for DCC offer
|
||||
status_callback("resolving", "Waiting for bot response")
|
||||
|
||||
wait_kwargs = {"expected_senders": {expected_server}} if expected_server else {}
|
||||
offer = client.wait_for_dcc(timeout=120.0, result_type=False, **wait_kwargs)
|
||||
offer = client.wait_for_dcc(timeout=120.0, result_type=False)
|
||||
|
||||
if not offer:
|
||||
status_callback("error", "No response from bot")
|
||||
@@ -148,9 +137,7 @@ class IRCDownloadHandler(DownloadHandler):
|
||||
status_callback("downloading", "")
|
||||
|
||||
# Get file extension from offer filename
|
||||
ext = (
|
||||
Path(safe_dcc_filename(offer.filename)).suffix.lstrip(".") or task.format or "epub"
|
||||
)
|
||||
ext = Path(offer.filename).suffix.lstrip(".") or task.format or "epub"
|
||||
|
||||
# Stage to temp directory (lazy import to avoid circular import)
|
||||
from shelfmark.download.staging import get_staging_path
|
||||
|
||||
@@ -88,11 +88,7 @@ def irc_settings() -> list[SettingsField]:
|
||||
key="IRC_SEARCH_BOT",
|
||||
label="Search bot",
|
||||
placeholder="e.g. search",
|
||||
description=(
|
||||
"The search bot to address queries to (required). Searches are sent as "
|
||||
'"@<bot> <query>".'
|
||||
),
|
||||
required=True,
|
||||
description="The search bot to query for results",
|
||||
env_supported=True,
|
||||
),
|
||||
HeadingField(
|
||||
|
||||
@@ -15,7 +15,6 @@ if TYPE_CHECKING:
|
||||
from shelfmark.api.websocket import ws_manager
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import is_audiobook
|
||||
from shelfmark.release_sources import (
|
||||
ColumnColorHint,
|
||||
ColumnRenderType,
|
||||
@@ -31,7 +30,7 @@ from shelfmark.release_sources import (
|
||||
)
|
||||
|
||||
from .connection_manager import connection_manager
|
||||
from .dcc import DCCError, download_dcc, safe_dcc_filename
|
||||
from .dcc import DCCError, download_dcc
|
||||
from .parser import SearchResult, extract_results_from_zip, parse_results_file
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -89,17 +88,6 @@ def _emit_status(message: str, phase: str = "searching") -> None:
|
||||
MIN_SEARCH_INTERVAL = 15.0
|
||||
_last_search_time: float = 0
|
||||
|
||||
# Anti-spam budget: the exact same message may only be posted to the channel a limited
|
||||
# number of times within a rolling window. This stops a retry/refresh loop from flooding
|
||||
# the channel with the same line over and over, while still allowing a few genuine retries
|
||||
# (a search that came back empty can be tried again, and Refresh works until the budget runs
|
||||
# out). Normal use never hits this: successful searches are served from the result cache
|
||||
# without re-posting at all.
|
||||
MAX_IDENTICAL_SENDS = 3
|
||||
IDENTICAL_SEND_WINDOW_SECONDS = 24 * 60 * 60 # 24 hours
|
||||
# message-send-key -> timestamps of recent posts of that exact message
|
||||
_recent_message_sends: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def _enforce_rate_limit() -> None:
|
||||
"""Ensure minimum time between searches."""
|
||||
@@ -114,36 +102,6 @@ def _enforce_rate_limit() -> None:
|
||||
_last_search_time = time.time()
|
||||
|
||||
|
||||
def _query_identity(server: str, channel: str, query: str) -> str:
|
||||
"""Stable identity for a query on a given IRC server-channel.
|
||||
|
||||
Used as BOTH the result-cache key and the per-query send-counter key, so the same
|
||||
query shares one cached answer and one send budget regardless of which book or
|
||||
content type triggered it.
|
||||
"""
|
||||
return f"{server.casefold()}:{channel.casefold()}:{query.strip().casefold()}"
|
||||
|
||||
|
||||
def _recent_send_count(key: str) -> int:
|
||||
"""Number of times this exact message was posted within the rolling window."""
|
||||
cutoff = time.time() - IDENTICAL_SEND_WINDOW_SECONDS
|
||||
timestamps = [ts for ts in _recent_message_sends.get(key, []) if ts > cutoff]
|
||||
if timestamps:
|
||||
_recent_message_sends[key] = timestamps
|
||||
else:
|
||||
_recent_message_sends.pop(key, None)
|
||||
return len(timestamps)
|
||||
|
||||
|
||||
def _record_message_sent(key: str) -> None:
|
||||
"""Record that an exact message was just posted to the channel."""
|
||||
now = time.time()
|
||||
cutoff = now - IDENTICAL_SEND_WINDOW_SECONDS
|
||||
timestamps = [ts for ts in _recent_message_sends.get(key, []) if ts > cutoff]
|
||||
timestamps.append(now)
|
||||
_recent_message_sends[key] = timestamps
|
||||
|
||||
|
||||
@register_source("irc")
|
||||
class IRCReleaseSource(ReleaseSource):
|
||||
"""Search IRC channels for ebook and audiobook releases."""
|
||||
@@ -159,16 +117,11 @@ class IRCReleaseSource(ReleaseSource):
|
||||
self._online_servers: set[str] | None = None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if IRC is configured (server, channel, nick, and search bot are set).
|
||||
|
||||
The search bot is required: without it we would post bare queries straight
|
||||
to the channel, which reads as spam and gets the nick banned.
|
||||
"""
|
||||
"""Check if IRC is configured (server, channel, and nick are set)."""
|
||||
server = _config_text("IRC_SERVER")
|
||||
channel = _config_text("IRC_CHANNEL")
|
||||
nick = _config_text("IRC_NICK")
|
||||
search_bot = _config_text("IRC_SEARCH_BOT")
|
||||
return bool(server and channel and nick and search_bot)
|
||||
return bool(server and channel and nick)
|
||||
|
||||
def get_column_config(self) -> ReleaseColumnConfig:
|
||||
"""Configure UI columns for IRC results."""
|
||||
@@ -226,12 +179,25 @@ class IRCReleaseSource(ReleaseSource):
|
||||
logger.debug("IRC source is disabled, skipping search")
|
||||
return []
|
||||
|
||||
# Check cache first (unless expand_search/refresh is requested)
|
||||
if not expand_search:
|
||||
cached = get_cached_results(book.provider, book.provider_id, content_type=content_type)
|
||||
if cached:
|
||||
_emit_status("Using cached results", phase="complete")
|
||||
self._online_servers = set(cached.get("online_servers", []))
|
||||
return cached["releases"]
|
||||
|
||||
# Build search query
|
||||
query = plan.primary_query or self._build_query(book)
|
||||
if not query:
|
||||
logger.warning("No search query could be built")
|
||||
return []
|
||||
|
||||
logger.info("IRC search: %s", query)
|
||||
|
||||
# Enforce rate limit
|
||||
_enforce_rate_limit()
|
||||
|
||||
# Get IRC settings
|
||||
server = _config_text("IRC_SERVER")
|
||||
port = _config_port("IRC_PORT", 6697)
|
||||
@@ -240,54 +206,6 @@ class IRCReleaseSource(ReleaseSource):
|
||||
nick = _config_text("IRC_NICK")
|
||||
search_bot = _config_text("IRC_SEARCH_BOT")
|
||||
|
||||
# Never post an unaddressed query to the channel. A bare book title looks like
|
||||
# spam to everyone else in the channel and gets the nick banned. Searches must
|
||||
# be addressed to a search bot ("@<bot> <query>").
|
||||
if not search_bot:
|
||||
logger.warning(
|
||||
"IRC search bot not configured; refusing to post unaddressed query to channel"
|
||||
)
|
||||
_emit_status("IRC search bot not configured", phase="error")
|
||||
return []
|
||||
|
||||
# One identity per query on this server-channel. The result cache and the send
|
||||
# counter are both keyed on it: the SAME query shares one cached answer and one
|
||||
# send budget regardless of which book/content type triggered it, while different
|
||||
# queries are independent (searching 100 different books posts 100 messages).
|
||||
requested = "audiobook" if is_audiobook(content_type) else "ebook"
|
||||
query_key = _query_identity(server, channel, query)
|
||||
|
||||
# Serve the cached whole answer for an identical query (unless this is a refresh).
|
||||
if not expand_search:
|
||||
cached = get_cached_results(query_key)
|
||||
if cached:
|
||||
_emit_status("Using cached results", phase="complete")
|
||||
self._online_servers = set(cached.get("online_servers", []))
|
||||
return self._filter_by_content_type(cached["releases"], requested)
|
||||
|
||||
# Anti-spam cap: the exact same query may only be POSTED a limited number of times
|
||||
# per window, even via refresh. Beyond that, serve whatever is cached rather than
|
||||
# re-posting the identical message to the channel.
|
||||
if _recent_send_count(query_key) >= MAX_IDENTICAL_SENDS:
|
||||
logger.info(
|
||||
"IRC query hit %s-send limit in window, not re-posting: %s",
|
||||
MAX_IDENTICAL_SENDS,
|
||||
query,
|
||||
)
|
||||
_emit_status(
|
||||
"Search limit reached for this query — showing latest results", phase="complete"
|
||||
)
|
||||
cached = get_cached_results(query_key)
|
||||
if cached:
|
||||
self._online_servers = set(cached.get("online_servers", []))
|
||||
return self._filter_by_content_type(cached["releases"], requested)
|
||||
return []
|
||||
|
||||
logger.info("IRC search: %s", query)
|
||||
|
||||
# Enforce rate limit
|
||||
_enforce_rate_limit()
|
||||
|
||||
client = None
|
||||
try:
|
||||
# Get or reuse IRC connection
|
||||
@@ -303,33 +221,33 @@ class IRCReleaseSource(ReleaseSource):
|
||||
# Capture online servers (elevated users in channel)
|
||||
self._online_servers = client.online_servers
|
||||
|
||||
# Send search request (always addressed to the search bot, never bare)
|
||||
client.send_message(f"#{channel}", f"@{search_bot} {query}")
|
||||
_record_message_sent(query_key)
|
||||
# Send search request
|
||||
search_msg = f"@{search_bot} {query}" if search_bot else query
|
||||
client.send_message(f"#{channel}", search_msg)
|
||||
|
||||
# Wait for results DCC - this is the long wait.
|
||||
# Don't restrict the sender to the trigger bot's nick: many channels answer an
|
||||
# "@search" from a differently-named results bot. The DCC endpoint/filename are
|
||||
# still validated, and wait_for_dcc falls back to the channel's server list.
|
||||
# Wait for results DCC - this is the long wait
|
||||
_emit_status(f"Connected to #{channel} - Waiting for results...", phase="searching")
|
||||
offer = client.wait_for_dcc(timeout=60.0, result_type=True)
|
||||
|
||||
online_servers = list(self._online_servers) if self._online_servers else None
|
||||
|
||||
if not offer:
|
||||
logger.info("No search results received")
|
||||
_emit_status("No results found", phase="complete")
|
||||
# Release connection for reuse (don't close it)
|
||||
connection_manager.release_connection(client)
|
||||
# Cache the (empty) answer under the query identity so an identical query
|
||||
# is served from cache instead of re-posting.
|
||||
cache_results(query_key, query, [], online_servers=online_servers)
|
||||
# Cache empty result to avoid repeated failed searches
|
||||
cache_results(
|
||||
book.provider,
|
||||
book.provider_id,
|
||||
book.title,
|
||||
[],
|
||||
content_type=content_type,
|
||||
online_servers=list(self._online_servers) if self._online_servers else None,
|
||||
)
|
||||
return []
|
||||
|
||||
# Download results file
|
||||
_emit_status(f"Connected to #{channel} - Downloading results...", phase="downloading")
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result_path = Path(tmpdir) / safe_dcc_filename(offer.filename)
|
||||
result_path = Path(tmpdir) / offer.filename
|
||||
download_dcc(offer, result_path, timeout=30.0)
|
||||
|
||||
# Parse results
|
||||
@@ -341,22 +259,19 @@ class IRCReleaseSource(ReleaseSource):
|
||||
# Release connection for reuse (don't close it)
|
||||
connection_manager.release_connection(client)
|
||||
|
||||
# A single "@search" returns one file containing every format. Parse the whole
|
||||
# answer (both ebooks and audiobooks) and cache it under the query identity, so
|
||||
# requesting the other content type is served from cache without re-posting.
|
||||
ebook_releases = self._convert_to_releases(
|
||||
parse_results_file(content, content_type="ebook"), content_type="ebook"
|
||||
)
|
||||
audiobook_releases = self._convert_to_releases(
|
||||
parse_results_file(content, content_type="audiobook"), content_type="audiobook"
|
||||
)
|
||||
# Convert to Release objects
|
||||
results = parse_results_file(content, content_type=content_type)
|
||||
releases = self._convert_to_releases(results, content_type=content_type)
|
||||
|
||||
# Cache results
|
||||
cache_results(
|
||||
query_key,
|
||||
query,
|
||||
ebook_releases + audiobook_releases,
|
||||
online_servers=online_servers,
|
||||
book.provider,
|
||||
book.provider_id,
|
||||
book.title,
|
||||
releases,
|
||||
content_type=content_type,
|
||||
online_servers=list(self._online_servers) if self._online_servers else None,
|
||||
)
|
||||
releases = audiobook_releases if requested == "audiobook" else ebook_releases
|
||||
|
||||
except DCCError as e:
|
||||
logger.exception("DCC error during search")
|
||||
@@ -474,15 +389,6 @@ class IRCReleaseSource(ReleaseSource):
|
||||
|
||||
return releases
|
||||
|
||||
@staticmethod
|
||||
def _filter_by_content_type(releases: list[Release], requested: str) -> list[Release]:
|
||||
"""Pick the requested content type out of a cached whole answer.
|
||||
|
||||
The cache stores releases for every content type under one query identity; each
|
||||
release is tagged with its content type (defaulting to ebook when missing).
|
||||
"""
|
||||
return [release for release in releases if (release.content_type or "ebook") == requested]
|
||||
|
||||
@staticmethod
|
||||
def _parse_size(size_str: str) -> int | None:
|
||||
"""Parse human-readable size (e.g., '1.2MB', '500K') to bytes."""
|
||||
|
||||
@@ -8,7 +8,6 @@ if TYPE_CHECKING:
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import normalize_optional_text
|
||||
from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients
|
||||
from shelfmark.download.clients.base_handler import (
|
||||
COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS,
|
||||
@@ -84,44 +83,6 @@ class NewznabHandler(ExternalClientHandler):
|
||||
def _completed_path_max_attempts(self) -> int:
|
||||
return COMPLETED_PATH_MAX_ATTEMPTS
|
||||
|
||||
def build_retry_resolution_fields(self, release_data: dict) -> dict:
|
||||
source_id = normalize_optional_text(release_data.get("source_id"))
|
||||
if source_id is None:
|
||||
return {}
|
||||
|
||||
result = get_release(source_id)
|
||||
if result is None:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"retry_download_url": normalize_optional_text(_get_download_url(result)),
|
||||
"retry_download_protocol": normalize_optional_text(_get_protocol(result)),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _restore_download_request_from_task(cls, task: DownloadTask) -> DownloadRequest | None:
|
||||
retry_download_url = normalize_optional_text(getattr(task, "retry_download_url", None))
|
||||
retry_download_protocol = normalize_optional_text(
|
||||
getattr(task, "retry_download_protocol", None)
|
||||
)
|
||||
if retry_download_url is None or retry_download_protocol is None:
|
||||
return None
|
||||
|
||||
protocol = retry_download_protocol.lower()
|
||||
if protocol not in {"torrent", "usenet"}:
|
||||
return None
|
||||
|
||||
return DownloadRequest(
|
||||
url=retry_download_url,
|
||||
protocol=protocol,
|
||||
release_name=(
|
||||
normalize_optional_text(getattr(task, "retry_release_name", None))
|
||||
or task.title
|
||||
or "Unknown"
|
||||
),
|
||||
expected_hash=normalize_optional_text(getattr(task, "retry_expected_hash", None)),
|
||||
)
|
||||
|
||||
def _resolve_download(
|
||||
self,
|
||||
task: DownloadTask,
|
||||
@@ -129,10 +90,6 @@ class NewznabHandler(ExternalClientHandler):
|
||||
) -> DownloadRequest | None:
|
||||
result = get_release(task.task_id)
|
||||
if not result:
|
||||
restored_request = self._restore_download_request_from_task(task)
|
||||
if restored_request is not None:
|
||||
logger.info("Restored Newznab download request for retry: %s", task.task_id)
|
||||
return restored_request
|
||||
logger.warning("Newznab release cache miss: %s", task.task_id)
|
||||
status_callback("error", "Release not found in cache (may have expired)")
|
||||
return None
|
||||
|
||||
@@ -109,6 +109,7 @@ def _newznab_result_to_release(result: dict, content_type: str = "ebook") -> Rel
|
||||
if is_freeleech:
|
||||
add_flag("FreeLeech")
|
||||
|
||||
download_url = str(result.get("downloadUrl") or "").strip()
|
||||
info_url = result.get("infoUrl") or result.get("guid")
|
||||
|
||||
return Release(
|
||||
@@ -119,7 +120,7 @@ def _newznab_result_to_release(result: dict, content_type: str = "ebook") -> Rel
|
||||
language=None,
|
||||
size=_parse_size(size_bytes),
|
||||
size_bytes=size_bytes,
|
||||
download_url=None,
|
||||
download_url=download_url or None,
|
||||
info_url=info_url,
|
||||
protocol=protocol,
|
||||
indexer=indexer,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from collections.abc import Mapping
|
||||
from contextlib import suppress
|
||||
from http import HTTPStatus
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
@@ -11,7 +11,7 @@ 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.release_sources.prowlarr.torznab import parse_torznab_xml
|
||||
from shelfmark.release_sources.prowlarr.utils import coerce_float_like, coerce_int_like
|
||||
from shelfmark.release_sources.prowlarr.utils import coerce_int_like
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -27,15 +27,6 @@ _PROWLARR_CLIENT_ERRORS = (
|
||||
)
|
||||
|
||||
|
||||
class IndexerSeedSettings(TypedDict, total=False):
|
||||
ratio_limit: float
|
||||
seeding_time_limit_minutes: int
|
||||
|
||||
|
||||
_INDEXER_FIELD_SEED_RATIO = "torrentBaseSettings.seedRatio"
|
||||
_INDEXER_FIELD_SEED_TIME_MINUTES = "torrentBaseSettings.seedTime"
|
||||
|
||||
|
||||
def _normalize_json_object(payload: object, *, context: str) -> dict[str, Any]:
|
||||
"""Return a JSON object payload with string keys or raise on unexpected shapes."""
|
||||
if not isinstance(payload, Mapping):
|
||||
@@ -61,19 +52,6 @@ def _normalize_json_object_list(payload: object, *, context: str) -> list[dict[s
|
||||
return [_normalize_json_object(item, context=context) for item in payload]
|
||||
|
||||
|
||||
def _get_field_value(fields: object, name: str) -> object | None:
|
||||
if not isinstance(fields, list):
|
||||
return None
|
||||
|
||||
for field in fields:
|
||||
if not isinstance(field, Mapping):
|
||||
continue
|
||||
if field.get("name") == name:
|
||||
return field.get("value")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ProwlarrClient:
|
||||
"""Client for interacting with the Prowlarr API."""
|
||||
|
||||
@@ -124,12 +102,10 @@ class ProwlarrClient:
|
||||
msg = f"Invalid JSON response: {e}"
|
||||
raise ValueError(msg) from e
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status_code = e.response.status_code if e.response is not None else "unknown"
|
||||
reason = e.response.reason if e.response is not None else "unknown"
|
||||
logger.exception(
|
||||
"Prowlarr API HTTP error: %s %s",
|
||||
status_code,
|
||||
reason,
|
||||
e.response.status_code,
|
||||
e.response.reason,
|
||||
)
|
||||
raise
|
||||
except requests.exceptions.RequestException:
|
||||
@@ -158,35 +134,24 @@ class ProwlarrClient:
|
||||
logger.info("Prowlarr connection successful: version %s", version)
|
||||
return True, f"Connected to Prowlarr {version}"
|
||||
|
||||
def get_indexers(self, *, raise_on_error: bool = False) -> list[dict[str, Any]]:
|
||||
"""Get all configured indexers.
|
||||
|
||||
Args:
|
||||
raise_on_error: When True, propagate API failures instead of
|
||||
returning an empty list. Callers that must distinguish
|
||||
"no indexers" from "the request failed" should set this.
|
||||
|
||||
"""
|
||||
def get_indexers(self) -> list[dict[str, Any]]:
|
||||
"""Get all configured indexers."""
|
||||
try:
|
||||
return _normalize_json_object_list(
|
||||
self._request("GET", "/api/v1/indexer"),
|
||||
context="Prowlarr indexer list",
|
||||
)
|
||||
except _PROWLARR_CLIENT_ERRORS:
|
||||
if raise_on_error:
|
||||
raise
|
||||
logger.exception("Failed to get indexers")
|
||||
return []
|
||||
|
||||
def get_enabled_indexers_detailed(
|
||||
self, *, raise_on_error: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
def get_enabled_indexers_detailed(self) -> list[dict[str, Any]]:
|
||||
"""Get enabled indexers, including implementation metadata.
|
||||
|
||||
Note: Prowlarr indexer "name" is user-configurable; prefer
|
||||
"implementation"/"implementationName" for stable identification.
|
||||
"""
|
||||
indexers = self.get_indexers(raise_on_error=raise_on_error)
|
||||
indexers = self.get_indexers()
|
||||
return [idx for idx in indexers if idx.get("enable", False)]
|
||||
|
||||
def get_enriched_indexer_ids(self, *, restrict_to: list[int] | None = None) -> list[int]:
|
||||
@@ -218,49 +183,6 @@ class ProwlarrClient:
|
||||
|
||||
return enriched_ids
|
||||
|
||||
def get_indexer_seed_settings(
|
||||
self, *, restrict_to: list[int] | None = None
|
||||
) -> dict[int, IndexerSeedSettings]:
|
||||
"""Return configured per-indexer torrent share limits.
|
||||
|
||||
Prowlarr exposes seedTime in minutes, which is also the unit expected by
|
||||
torrent clients.
|
||||
|
||||
Raises:
|
||||
requests.exceptions.RequestException (and other client errors) when
|
||||
the indexer list cannot be fetched. An empty dict strictly means
|
||||
"no share limits are configured", never "the request failed" -
|
||||
callers rely on this to avoid silently dropping seed limits.
|
||||
|
||||
"""
|
||||
settings_by_indexer: dict[int, IndexerSeedSettings] = {}
|
||||
|
||||
for idx in self.get_enabled_indexers_detailed(raise_on_error=True):
|
||||
idx_id_int = coerce_int_like(idx.get("id"))
|
||||
if idx_id_int is None:
|
||||
continue
|
||||
if restrict_to is not None and idx_id_int not in restrict_to:
|
||||
continue
|
||||
if str(idx.get("protocol") or "").lower() != "torrent":
|
||||
continue
|
||||
|
||||
fields = idx.get("fields")
|
||||
ratio_limit = coerce_float_like(_get_field_value(fields, _INDEXER_FIELD_SEED_RATIO))
|
||||
seeding_time_limit = coerce_int_like(
|
||||
_get_field_value(fields, _INDEXER_FIELD_SEED_TIME_MINUTES)
|
||||
)
|
||||
|
||||
settings: IndexerSeedSettings = {}
|
||||
if ratio_limit is not None and ratio_limit > 0:
|
||||
settings["ratio_limit"] = ratio_limit
|
||||
if seeding_time_limit is not None and seeding_time_limit > 0:
|
||||
settings["seeding_time_limit_minutes"] = seeding_time_limit
|
||||
|
||||
if settings:
|
||||
settings_by_indexer[idx_id_int] = settings
|
||||
|
||||
return settings_by_indexer
|
||||
|
||||
def get_enabled_indexers(self) -> list[dict[str, Any]]:
|
||||
"""Get enabled indexers with book capability info."""
|
||||
indexers = self.get_indexers()
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
"""Prowlarr download handler - resolves releases and delegates lifecycle to shared clients."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import normalize_optional_text
|
||||
from shelfmark.core.search_plan import build_release_search_plan
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
get_client,
|
||||
@@ -28,11 +23,8 @@ from shelfmark.download.clients.base_handler import (
|
||||
DownloadRequest,
|
||||
ExternalClientHandler,
|
||||
)
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
from shelfmark.release_sources import register_handler
|
||||
from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient
|
||||
from shelfmark.release_sources.prowlarr.cache import cache_release, get_release, remove_release
|
||||
from shelfmark.release_sources.prowlarr.source import ProwlarrSource
|
||||
from shelfmark.release_sources.prowlarr.cache import get_release, remove_release
|
||||
from shelfmark.release_sources.prowlarr.utils import (
|
||||
coerce_int_like,
|
||||
get_preferred_download_url,
|
||||
@@ -45,16 +37,6 @@ if TYPE_CHECKING:
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Errors that ProwlarrClient can raise when fetching indexer settings.
|
||||
_SEED_SETTINGS_FALLBACK_ERRORS = (
|
||||
requests.exceptions.RequestException,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ProwlarrHandler",
|
||||
"POLL_INTERVAL",
|
||||
@@ -67,65 +49,30 @@ __all__ = [
|
||||
POLL_INTERVAL = _DEFAULT_POLL_INTERVAL
|
||||
COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL
|
||||
COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS
|
||||
EXPIRED_LINK_REFRESH_ERROR = (
|
||||
"The indexer download link expired and the release could not be refreshed. "
|
||||
"Search again for a fresh result."
|
||||
)
|
||||
HASH_DETECTION_ERROR = "Could not determine torrent hash from URL"
|
||||
|
||||
|
||||
def _coerce_positive_minutes(raw_minutes: object) -> int | None:
|
||||
minutes = coerce_int_like(raw_minutes)
|
||||
if minutes is None:
|
||||
def _coerce_seed_time_minutes(raw_seed_time: object) -> int | None:
|
||||
"""Convert Prowlarr's minimum seed time from seconds to whole minutes."""
|
||||
if raw_seed_time is None:
|
||||
return None
|
||||
return minutes if minutes > 0 else None
|
||||
|
||||
seed_time_seconds = coerce_int_like(raw_seed_time)
|
||||
if seed_time_seconds is None:
|
||||
logger.warning("Invalid Prowlarr minimumSeedTime value: %r", raw_seed_time)
|
||||
return None
|
||||
|
||||
if seed_time_seconds < 0:
|
||||
logger.warning("Ignoring negative Prowlarr minimumSeedTime value: %s", seed_time_seconds)
|
||||
return None
|
||||
|
||||
# Round up so we never under-seed when a tracker uses a non-minute boundary.
|
||||
return (seed_time_seconds + 59) // 60
|
||||
|
||||
|
||||
@register_handler("prowlarr")
|
||||
class ProwlarrHandler(ExternalClientHandler):
|
||||
"""Handler for Prowlarr downloads via configured torrent or usenet client."""
|
||||
|
||||
@staticmethod
|
||||
def _build_prowlarr_client() -> ProwlarrClient | None:
|
||||
"""Build a ProwlarrClient from config, or None if not configured."""
|
||||
raw_url = config.get("PROWLARR_URL", "")
|
||||
raw_api_key = config.get("PROWLARR_API_KEY", "")
|
||||
url = normalize_optional_text(raw_url) if isinstance(raw_url, str) else None
|
||||
api_key = normalize_optional_text(raw_api_key) if isinstance(raw_api_key, str) else None
|
||||
if not url or not api_key:
|
||||
return None
|
||||
normalized_url = normalize_http_url(url)
|
||||
if not normalized_url:
|
||||
return None
|
||||
return ProwlarrClient(normalized_url, api_key)
|
||||
|
||||
def _fetch_seed_settings_fallback(self, raw_indexer_id: object) -> IndexerSeedSettings | None:
|
||||
"""Fetch share limits for one indexer directly from Prowlarr.
|
||||
|
||||
Used when the cached release is missing its search-time seed-limit
|
||||
enrichment so that transient failures during search cannot cause a
|
||||
torrent to be added without its configured share limits.
|
||||
"""
|
||||
indexer_id = coerce_int_like(raw_indexer_id)
|
||||
if indexer_id is None:
|
||||
return None
|
||||
|
||||
client = self._build_prowlarr_client()
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
settings = client.get_indexer_seed_settings(restrict_to=[indexer_id])
|
||||
except _SEED_SETTINGS_FALLBACK_ERRORS:
|
||||
logger.warning(
|
||||
"Grab-time seed settings fallback failed for indexerId=%s",
|
||||
indexer_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
return settings.get(indexer_id)
|
||||
|
||||
def _get_client(self, protocol: str) -> DownloadClient | None:
|
||||
"""Compatibility shim so module-level patching still works in tests."""
|
||||
return get_client(protocol)
|
||||
@@ -143,34 +90,6 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
def _completed_path_max_attempts(self) -> int:
|
||||
return COMPLETED_PATH_MAX_ATTEMPTS
|
||||
|
||||
def build_retry_resolution_fields(self, release_data: dict[str, Any]) -> dict[str, Any]:
|
||||
source_id = normalize_optional_text(release_data.get("source_id"))
|
||||
extra = release_data.get("extra")
|
||||
if not isinstance(extra, dict):
|
||||
extra = {}
|
||||
|
||||
retry_source_context: dict[str, Any] = {}
|
||||
indexer_id = release_data.get("indexer_id") or extra.get("indexer_id")
|
||||
if indexer_id is not None:
|
||||
retry_source_context["indexer_id"] = indexer_id
|
||||
|
||||
indexer = normalize_optional_text(release_data.get("indexer") or extra.get("indexer"))
|
||||
if indexer is not None and indexer.lower() != "unknown":
|
||||
retry_source_context["indexer"] = indexer
|
||||
|
||||
info_url = normalize_optional_text(release_data.get("info_url") or extra.get("info_url"))
|
||||
if info_url is not None:
|
||||
retry_source_context["info_url"] = info_url
|
||||
|
||||
if source_id is not None:
|
||||
retry_source_context["source_id"] = source_id
|
||||
|
||||
return {
|
||||
"retry_download_url": None,
|
||||
"retry_download_protocol": None,
|
||||
"retry_source_context": retry_source_context,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _restore_download_request_from_task(cls, task: DownloadTask) -> DownloadRequest | None:
|
||||
"""Rebuild a DownloadRequest when the in-memory Prowlarr cache is gone."""
|
||||
@@ -215,12 +134,13 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
# Look up the cached release
|
||||
prowlarr_result = get_release(task.task_id)
|
||||
if not prowlarr_result:
|
||||
logger.info("Prowlarr release cache miss, refreshing: %s", task.task_id)
|
||||
prowlarr_result = self._refresh_release(task)
|
||||
if prowlarr_result is None:
|
||||
logger.warning("Prowlarr release refresh failed: %s", task.task_id)
|
||||
status_callback("error", EXPIRED_LINK_REFRESH_ERROR)
|
||||
restored_request = self._restore_download_request_from_task(task)
|
||||
if restored_request is None:
|
||||
logger.warning("Release cache miss: %s", task.task_id)
|
||||
status_callback("error", "Release not found in cache (may have expired)")
|
||||
return None
|
||||
logger.info("Restored Prowlarr download request for retry: %s", task.task_id)
|
||||
return restored_request
|
||||
|
||||
# Extract download URL
|
||||
download_url = get_preferred_download_url(prowlarr_result)
|
||||
@@ -237,36 +157,12 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
release_name = prowlarr_result.get("title") or task.title or "Unknown"
|
||||
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
|
||||
|
||||
seeding_time_limit = None
|
||||
ratio_limit = None
|
||||
if config.get("PROWLARR_USE_SEED_PREFERENCES", False):
|
||||
raw_configured_seed_time = prowlarr_result.get("configuredSeedTimeMinutes")
|
||||
raw_configured_ratio = prowlarr_result.get("configuredRatioLimit")
|
||||
# Seed criteria from the indexer (Torznab attributes)
|
||||
raw_seed_time = prowlarr_result.get("minimumSeedTime")
|
||||
raw_ratio = prowlarr_result.get("minimumRatio")
|
||||
|
||||
seeding_time_limit = _coerce_positive_minutes(raw_configured_seed_time)
|
||||
ratio_limit = float(raw_configured_ratio) if raw_configured_ratio is not None else None
|
||||
|
||||
# Fallback: search-time enrichment can be missing when the indexer
|
||||
# settings fetch transiently failed during the search (#795).
|
||||
# Re-resolve the limits from Prowlarr at grab time so torrents are
|
||||
# never sent to the client without their configured share limits.
|
||||
if seeding_time_limit is None and ratio_limit is None and protocol == "torrent":
|
||||
fallback = self._fetch_seed_settings_fallback(prowlarr_result.get("indexerId"))
|
||||
if fallback:
|
||||
seeding_time_limit = _coerce_positive_minutes(
|
||||
fallback.get("seeding_time_limit_minutes")
|
||||
)
|
||||
raw_ratio = fallback.get("ratio_limit")
|
||||
ratio_limit = float(raw_ratio) if raw_ratio is not None else None
|
||||
|
||||
if seeding_time_limit is None and ratio_limit is None and protocol == "torrent":
|
||||
logger.warning(
|
||||
"Prowlarr seed preferences are enabled but no share limits "
|
||||
"could be resolved for release '%s' (indexerId=%s); the "
|
||||
"torrent will use the client's global limits",
|
||||
release_name,
|
||||
prowlarr_result.get("indexerId"),
|
||||
)
|
||||
seeding_time_limit = _coerce_seed_time_minutes(raw_seed_time)
|
||||
ratio_limit = float(raw_ratio) if raw_ratio is not None else None
|
||||
|
||||
return DownloadRequest(
|
||||
url=download_url,
|
||||
@@ -277,78 +173,6 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
ratio_limit=ratio_limit,
|
||||
)
|
||||
|
||||
def _refresh_release(self, task: DownloadTask) -> dict[str, Any] | None:
|
||||
"""Re-query Prowlarr and cache the exact original release if it still exists."""
|
||||
title = normalize_optional_text(task.title)
|
||||
if title is None:
|
||||
return None
|
||||
|
||||
context = getattr(task, "retry_source_context", None)
|
||||
if not isinstance(context, dict):
|
||||
context = {}
|
||||
|
||||
indexer = normalize_optional_text(context.get("indexer"))
|
||||
book = BookMetadata(
|
||||
provider="shelfmark",
|
||||
provider_id=task.task_id,
|
||||
title=title,
|
||||
authors=[task.author] if task.author else [],
|
||||
search_title=title,
|
||||
search_author=task.author,
|
||||
)
|
||||
plan = build_release_search_plan(
|
||||
book,
|
||||
indexers=[indexer] if indexer is not None else None,
|
||||
)
|
||||
|
||||
source = ProwlarrSource()
|
||||
results = source.search(book, plan, content_type=task.content_type or "ebook")
|
||||
for release in results:
|
||||
raw_release = get_release(release.source_id)
|
||||
if raw_release is None:
|
||||
continue
|
||||
if not self._raw_release_matches_task(raw_release, task.task_id):
|
||||
continue
|
||||
|
||||
cache_release(task.task_id, raw_release)
|
||||
logger.info("Refreshed Prowlarr release: %s", task.task_id)
|
||||
return raw_release
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _raw_release_matches_task(raw_release: dict[str, Any], task_id: str) -> bool:
|
||||
identities = (
|
||||
normalize_optional_text(raw_release.get("guid")),
|
||||
normalize_optional_text(raw_release.get("infoUrl")),
|
||||
)
|
||||
return any(identity == task_id for identity in identities)
|
||||
|
||||
def _refresh_download_request_after_add_failure(
|
||||
self,
|
||||
*,
|
||||
task: DownloadTask,
|
||||
request: DownloadRequest,
|
||||
error: Exception,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> DownloadRequest | None:
|
||||
"""Refresh once when a cached Prowlarr torrent proxy URL has expired."""
|
||||
if request.protocol != "torrent":
|
||||
return None
|
||||
if HASH_DETECTION_ERROR not in str(error):
|
||||
return None
|
||||
|
||||
parsed = urlparse(request.url)
|
||||
if parsed.scheme.lower() not in {"http", "https"}:
|
||||
return None
|
||||
|
||||
logger.info("Refreshing stale Prowlarr torrent URL for %s", task.task_id)
|
||||
remove_release(task.task_id)
|
||||
refreshed_request = self._resolve_download(task, status_callback)
|
||||
if refreshed_request is None:
|
||||
raise RuntimeError(EXPIRED_LINK_REFRESH_ERROR) from error
|
||||
return refreshed_request
|
||||
|
||||
def _on_download_complete(self, task: DownloadTask) -> None:
|
||||
"""Remove completed release from the Prowlarr cache."""
|
||||
remove_release(task.task_id)
|
||||
|
||||
@@ -190,11 +190,4 @@ def prowlarr_config_settings() -> list[SettingsField]:
|
||||
description="Automatically retry search without category filtering if no results are found",
|
||||
show_when={"field": "PROWLARR_ENABLED", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="PROWLARR_USE_SEED_PREFERENCES",
|
||||
label="Use Prowlarr seed preferences",
|
||||
default=False,
|
||||
description="Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client",
|
||||
show_when={"field": "PROWLARR_ENABLED", "value": True},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
import re
|
||||
import time
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, ClassVar, NoReturn
|
||||
|
||||
import requests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
@@ -30,11 +27,12 @@ from shelfmark.release_sources import (
|
||||
SortOption,
|
||||
register_source,
|
||||
)
|
||||
from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient
|
||||
from shelfmark.release_sources.prowlarr.api import ProwlarrClient
|
||||
from shelfmark.release_sources.prowlarr.cache import cache_release
|
||||
from shelfmark.release_sources.prowlarr.utils import (
|
||||
coerce_float_like,
|
||||
coerce_int_like,
|
||||
get_preferred_download_url,
|
||||
get_protocol,
|
||||
)
|
||||
|
||||
@@ -44,11 +42,6 @@ _SIZE_UNIT_BASE = 1024
|
||||
_TWO_FORMATS = 2
|
||||
_PROWLARR_SOURCE_ERRORS = (AttributeError, OSError, RuntimeError, TypeError, ValueError)
|
||||
|
||||
# Errors that can surface from ProwlarrClient.get_indexer_seed_settings(). The
|
||||
# client raises requests exceptions (subclasses of OSError via IOError lineage
|
||||
# is not guaranteed), so include RequestException explicitly.
|
||||
_PROWLARR_SEED_SETTINGS_ERRORS = (*_PROWLARR_SOURCE_ERRORS, requests.exceptions.RequestException)
|
||||
|
||||
|
||||
def _raise_timeout_error(message: str) -> NoReturn:
|
||||
raise TimeoutError(message)
|
||||
@@ -414,7 +407,7 @@ def _prowlarr_result_to_release(
|
||||
language=language_detected,
|
||||
size=_parse_size(size_bytes),
|
||||
size_bytes=size_bytes,
|
||||
download_url=None,
|
||||
download_url=get_preferred_download_url(result),
|
||||
info_url=result.get("infoUrl") or result.get("guid"),
|
||||
protocol=(
|
||||
ReleaseProtocol.TORRENT
|
||||
@@ -440,8 +433,8 @@ def _prowlarr_result_to_release(
|
||||
"freeleech": is_freeleech,
|
||||
"download_volume_factor": result.get("downloadVolumeFactor"),
|
||||
"upload_volume_factor": result.get("uploadVolumeFactor"),
|
||||
"configured_ratio_limit": result.get("configuredRatioLimit"),
|
||||
"configured_seed_time_minutes": result.get("configuredSeedTimeMinutes"),
|
||||
"minimum_ratio": result.get("minimumRatio"),
|
||||
"minimum_seed_time": result.get("minimumSeedTime"),
|
||||
"info_hash": result.get("infoHash"),
|
||||
"formats": formats or None,
|
||||
"formats_display": formats_display,
|
||||
@@ -451,57 +444,6 @@ def _prowlarr_result_to_release(
|
||||
)
|
||||
|
||||
|
||||
# Last successfully fetched per-indexer share limits. Used as a fallback when
|
||||
# a transient Prowlarr API failure prevents fetching fresh settings during a
|
||||
# search, so results are never silently cached without seed limits (#795).
|
||||
_seed_settings_lock = Lock()
|
||||
_last_known_seed_settings: dict[int, IndexerSeedSettings] = {}
|
||||
|
||||
|
||||
def _fetch_indexer_seed_settings(
|
||||
client: ProwlarrClient,
|
||||
indexer_ids: list[int] | None,
|
||||
) -> dict[int, IndexerSeedSettings]:
|
||||
"""Fetch per-indexer share limits, falling back to last-known-good on failure."""
|
||||
try:
|
||||
fetched = client.get_indexer_seed_settings(restrict_to=indexer_ids)
|
||||
except _PROWLARR_SEED_SETTINGS_ERRORS:
|
||||
with _seed_settings_lock:
|
||||
fallback = dict(_last_known_seed_settings)
|
||||
logger.warning(
|
||||
"Failed to fetch Prowlarr indexer seed settings; "
|
||||
"falling back to last known settings for %s indexer(s)",
|
||||
len(fallback),
|
||||
exc_info=True,
|
||||
)
|
||||
return fallback
|
||||
|
||||
with _seed_settings_lock:
|
||||
_last_known_seed_settings.update(fetched)
|
||||
return fetched
|
||||
|
||||
|
||||
def _apply_indexer_seed_settings(
|
||||
result: dict,
|
||||
indexer_seed_settings: dict[int, IndexerSeedSettings],
|
||||
) -> dict:
|
||||
indexer_id = _coerce_indexer_id(result.get("indexerId"))
|
||||
if indexer_id is None:
|
||||
return result
|
||||
|
||||
seed_settings = indexer_seed_settings.get(indexer_id)
|
||||
if not seed_settings:
|
||||
return result
|
||||
|
||||
enriched_result = dict(result)
|
||||
if "ratio_limit" in seed_settings:
|
||||
enriched_result["configuredRatioLimit"] = seed_settings["ratio_limit"]
|
||||
if "seeding_time_limit_minutes" in seed_settings:
|
||||
enriched_result["configuredSeedTimeMinutes"] = seed_settings["seeding_time_limit_minutes"]
|
||||
|
||||
return enriched_result
|
||||
|
||||
|
||||
@register_source("prowlarr")
|
||||
class ProwlarrSource(ReleaseSource):
|
||||
"""Prowlarr release source for ebooks and audiobooks."""
|
||||
@@ -820,11 +762,6 @@ class ProwlarrSource(ReleaseSource):
|
||||
# Some indexers benefit from title+author queries and extra format detection.
|
||||
enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids)
|
||||
enriched_indexer_ids_set = set(enriched_indexer_ids)
|
||||
indexer_seed_settings = (
|
||||
_fetch_indexer_seed_settings(client, indexer_ids)
|
||||
if config.get("PROWLARR_USE_SEED_PREFERENCES", False)
|
||||
else {}
|
||||
)
|
||||
|
||||
def _check_timeout() -> None:
|
||||
if time.monotonic() > deadline:
|
||||
@@ -902,18 +839,15 @@ class ProwlarrSource(ReleaseSource):
|
||||
results: list[Release] = []
|
||||
enriched_source_ids: set[str] = set()
|
||||
|
||||
for raw_result in all_results:
|
||||
result_with_seed_settings = _apply_indexer_seed_settings(
|
||||
raw_result, indexer_seed_settings
|
||||
)
|
||||
idx_id = result_with_seed_settings.get("indexerId")
|
||||
for r in all_results:
|
||||
idx_id = r.get("indexerId")
|
||||
idx_id_int = _coerce_indexer_id(idx_id)
|
||||
|
||||
is_enriched = bool(
|
||||
idx_id_int is not None and idx_id_int in enriched_indexer_ids_set
|
||||
)
|
||||
release = _prowlarr_result_to_release(
|
||||
result_with_seed_settings,
|
||||
r,
|
||||
content_type,
|
||||
enable_format_detection=is_enriched,
|
||||
)
|
||||
|
||||
@@ -140,6 +140,9 @@ def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]:
|
||||
|
||||
download_volume_factor = _coerce_float(attrs.get("downloadvolumefactor"))
|
||||
upload_volume_factor = _coerce_float(attrs.get("uploadvolumefactor"))
|
||||
minimum_ratio = _coerce_float(attrs.get("minimumratio"))
|
||||
minimum_seed_time = _coerce_int(attrs.get("minimumseedtime"))
|
||||
|
||||
cleaned_title = _strip_author_from_title(title, author)
|
||||
|
||||
results.append(
|
||||
@@ -165,6 +168,8 @@ def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]:
|
||||
"bookTitle": book_title,
|
||||
"downloadVolumeFactor": download_volume_factor,
|
||||
"uploadVolumeFactor": upload_volume_factor,
|
||||
"minimumRatio": minimum_ratio,
|
||||
"minimumSeedTime": minimum_seed_time,
|
||||
# Pass through all torznab attributes for tooltip display
|
||||
"torznabAttrs": attrs,
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
"react/jsx-no-useless-fragment": "error",
|
||||
"react/self-closing-comp": "error",
|
||||
"typescript/switch-exhaustiveness-check": "error",
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
|
||||
// This script is intentionally loaded from index.html as a classic script,
|
||||
// so we need to declare it as an entry point manually.
|
||||
"entry": ["public/theme-init.js"],
|
||||
"entry": ["public/theme-init.js"]
|
||||
}
|
||||
|
||||
Generated
+707
-832
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -17,24 +17,24 @@
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"knip": "^6.26.0",
|
||||
"oxfmt": "^0.58.0",
|
||||
"oxlint": "^1.73.0",
|
||||
"oxlint-tsgolint": "^0.24.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"knip": "^6.6.2",
|
||||
"oxfmt": "^0.46.0",
|
||||
"oxlint": "^1.61.0",
|
||||
"oxlint-tsgolint": "^0.21.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "^4.1.10"
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -766,8 +766,7 @@ function App() {
|
||||
(cfg.show_combined_selector ?? true) &&
|
||||
getDefaultMode('ebook') !== 'blocked' &&
|
||||
getDefaultMode('audiobook') !== 'blocked';
|
||||
const nextEffectiveCombinedMode =
|
||||
nextCombinedModeAllowed && (combinedMode || cfg.force_combined_search);
|
||||
const nextEffectiveCombinedMode = combinedMode && nextCombinedModeAllowed;
|
||||
const activeConfiguredProvider =
|
||||
nextEffectiveCombinedMode && metadataProviderState.configured_provider_combined
|
||||
? metadataProviderState.configured_provider_combined
|
||||
@@ -865,8 +864,7 @@ function App() {
|
||||
const audiobookMode = getDefaultMode('audiobook');
|
||||
return ebookMode !== 'blocked' && audiobookMode !== 'blocked';
|
||||
}, [effectiveSearchMode, config?.show_combined_selector, getDefaultMode]);
|
||||
const combinedModeLocked = combinedModeAllowed && config?.force_combined_search === true;
|
||||
const effectiveCombinedMode = combinedModeAllowed && (combinedMode || combinedModeLocked);
|
||||
const effectiveCombinedMode = combinedMode && combinedModeAllowed;
|
||||
const effectiveCombinedState = effectiveCombinedMode ? combinedState : null;
|
||||
|
||||
const defaultMetadataProviderForContentType =
|
||||
@@ -1491,7 +1489,7 @@ function App() {
|
||||
const handleCancel = async (id: string) => {
|
||||
try {
|
||||
await cancelDownload(id);
|
||||
await Promise.all([fetchStatus(), refreshActivitySnapshot()]);
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Cancel failed:', error);
|
||||
showToast('Failed to cancel/clear download', 'error');
|
||||
@@ -2420,7 +2418,6 @@ function App() {
|
||||
onContentTypeChange={setContentType}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
combinedMode={effectiveCombinedMode}
|
||||
combinedModeLocked={combinedModeLocked}
|
||||
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={effectiveActiveQueryTarget}
|
||||
@@ -2502,7 +2499,6 @@ function App() {
|
||||
onContentTypeChange={setContentType}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
combinedMode={effectiveCombinedMode}
|
||||
combinedModeLocked={combinedModeLocked}
|
||||
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
|
||||
activeQueryField={activeQueryField}
|
||||
searchMode={effectiveSearchMode}
|
||||
@@ -2778,13 +2774,10 @@ function App() {
|
||||
parsedParams={parsedParams}
|
||||
config={config}
|
||||
contentType={contentType}
|
||||
combinedMode={combinedMode}
|
||||
combinedModeAllowed={combinedModeAllowed}
|
||||
advancedFilters={advancedFilters}
|
||||
resolvedMetadataDefaultSort={resolvedMetadataDefaultSort}
|
||||
resolvedMetadataSortOptions={resolvedMetadataSortOptions}
|
||||
setContentType={setContentType}
|
||||
setCombinedMode={setCombinedMode}
|
||||
setSearchInput={setSearchInput}
|
||||
setAdvancedFilters={setAdvancedFilters}
|
||||
setShowAdvanced={setShowAdvanced}
|
||||
|
||||
@@ -58,8 +58,6 @@ const SEARCH_MODE_OPTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
const EMPTY_PROVIDERS: MetadataProviderSummary[] = [];
|
||||
|
||||
export const AdvancedFilters = ({
|
||||
visible,
|
||||
bookLanguages,
|
||||
@@ -70,7 +68,7 @@ export const AdvancedFilters = ({
|
||||
renderWrapper,
|
||||
searchMode,
|
||||
onSearchModeChange,
|
||||
metadataProviders = EMPTY_PROVIDERS,
|
||||
metadataProviders = [],
|
||||
activeMetadataProvider,
|
||||
onMetadataProviderChange,
|
||||
contentType = 'ebook',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useMountEffect } from '../hooks/useMountEffect';
|
||||
import type { Book, ButtonStateInfo } from '../types';
|
||||
import { isMetadataBook } from '../types';
|
||||
import { bookSupportsTargets } from '../utils/bookTargetLoader';
|
||||
import { getSizedCoverUrl } from '../utils/covers';
|
||||
import { isUserCancelledError } from '../utils/errors';
|
||||
import { BookTargetDropdown } from './BookTargetDropdown';
|
||||
|
||||
@@ -136,6 +137,10 @@ export const DetailsModal = ({
|
||||
const artworkMaxWidth = isSquareCover
|
||||
? 'min(45vw, 400px, calc(90vh - 220px))'
|
||||
: 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
|
||||
const optimizedPreview = getSizedCoverUrl(book.preview, {
|
||||
width: isSquareCover ? 640 : 480,
|
||||
height: isSquareCover ? 640 : 720,
|
||||
});
|
||||
const additionalInfo =
|
||||
book.info && Object.keys(book.info).length > 0
|
||||
? Object.entries(book.info).filter(([key]) => {
|
||||
@@ -201,14 +206,17 @@ export const DetailsModal = ({
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-6">
|
||||
<div className="flex flex-col gap-6 lg:min-h-0 lg:flex-row lg:items-stretch lg:gap-8">
|
||||
<div className="flex w-full justify-center lg:w-auto lg:flex-none lg:justify-start lg:self-stretch lg:pr-4">
|
||||
{book.preview ? (
|
||||
{optimizedPreview ? (
|
||||
<div
|
||||
className="flex w-full items-center justify-center lg:h-full lg:max-w-none"
|
||||
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
|
||||
>
|
||||
<img
|
||||
src={book.preview}
|
||||
src={optimizedPreview}
|
||||
alt="Book cover"
|
||||
width={isSquareCover ? 640 : 480}
|
||||
height={isSquareCover ? 640 : 720}
|
||||
decoding="async"
|
||||
className="h-auto max-h-full w-auto max-w-full rounded-xl object-contain shadow-lg"
|
||||
style={{ maxHeight: '100%', maxWidth: '100%' }}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useId, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useDismiss } from '../hooks/useDismiss';
|
||||
@@ -55,7 +55,6 @@ export const Dropdown = ({
|
||||
onOpenChange,
|
||||
}: DropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownId = useId();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -241,8 +240,8 @@ export const Dropdown = ({
|
||||
<div className={widthClassName} ref={containerRef}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={dropdownId}
|
||||
className="mb-1.5 block cursor-pointer text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
className="mb-1.5 block text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
onClick={toggleOpen}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
@@ -252,7 +251,6 @@ export const Dropdown = ({
|
||||
renderTrigger({ isOpen, toggle: toggleOpen })
|
||||
) : (
|
||||
<button
|
||||
id={dropdownId}
|
||||
type="button"
|
||||
onClick={toggleOpen}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -147,7 +147,6 @@ export const DropdownList = ({
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(option.value)}
|
||||
readOnly
|
||||
aria-label={option.label}
|
||||
className="pointer-events-none h-4 w-4 rounded-sm border-gray-300 text-sky-600 focus:ring-sky-500"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -62,7 +62,6 @@ interface HeaderProps {
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
combinedMode?: boolean;
|
||||
combinedModeLocked?: boolean;
|
||||
onCombinedModeChange?: (enabled: boolean) => void;
|
||||
queryTargets?: QueryTargetOption[];
|
||||
activeQueryTarget?: string;
|
||||
@@ -80,15 +79,6 @@ const applyTheme = (preference: string): void => {
|
||||
document.documentElement.style.colorScheme = effective;
|
||||
};
|
||||
|
||||
const DEFAULT_STATUS_COUNTS: ActivityStatusCounts = {
|
||||
ongoing: 0,
|
||||
completed: 0,
|
||||
errored: 0,
|
||||
pendingRequests: 0,
|
||||
};
|
||||
const EMPTY_ADMIN_USERS: ActingAsUserSelection[] = [];
|
||||
const EMPTY_QUERY_TARGETS: QueryTargetOption[] = [];
|
||||
|
||||
export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
||||
(
|
||||
{
|
||||
@@ -108,7 +98,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
||||
onSettingsClick,
|
||||
isAdmin = false,
|
||||
canAccessSettings,
|
||||
statusCounts = DEFAULT_STATUS_COUNTS,
|
||||
statusCounts = { ongoing: 0, completed: 0, errored: 0, pendingRequests: 0 },
|
||||
onLogoClick,
|
||||
authRequired = false,
|
||||
isAuthenticated = false,
|
||||
@@ -116,7 +106,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
||||
displayName,
|
||||
actingAsUser = null,
|
||||
onActingAsUserChange,
|
||||
adminUsers = EMPTY_ADMIN_USERS,
|
||||
adminUsers = [],
|
||||
isAdminUsersLoading = false,
|
||||
adminUsersError = null,
|
||||
hasLoadedAdminUsers = false,
|
||||
@@ -128,9 +118,8 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
combinedMode,
|
||||
combinedModeLocked,
|
||||
onCombinedModeChange,
|
||||
queryTargets = EMPTY_QUERY_TARGETS,
|
||||
queryTargets = [],
|
||||
activeQueryTarget = 'general',
|
||||
onQueryTargetChange,
|
||||
activeQueryField = null,
|
||||
@@ -316,8 +305,8 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
||||
// Determine if we should show icons only (both URLs configured)
|
||||
const showIconsOnly = Boolean(calibreWebUrl && audiobookLibraryUrl);
|
||||
|
||||
// Icon buttons - reused for both states
|
||||
const iconButtonsNode = (
|
||||
// Icon buttons component - reused for both states
|
||||
const IconButtons = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Book Library Button */}
|
||||
{calibreWebUrl && (
|
||||
@@ -590,7 +579,6 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
||||
onClick={handleLogout}
|
||||
className="hover-action shrink-0 rounded-full p-2 text-red-600 transition-colors dark:text-red-400"
|
||||
title="Sign Out"
|
||||
aria-label="Sign Out"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
@@ -686,7 +674,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
||||
/>
|
||||
))}
|
||||
|
||||
{iconButtonsNode}
|
||||
<IconButtons />
|
||||
</div>
|
||||
|
||||
{/* Search bar - appear second on mobile (below logo+icons), first on desktop (left side) */}
|
||||
@@ -723,7 +711,6 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
combinedMode={combinedMode}
|
||||
combinedModeLocked={combinedModeLocked}
|
||||
onCombinedModeChange={onCombinedModeChange}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={activeQueryTarget}
|
||||
@@ -736,7 +723,9 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
||||
|
||||
{/* When search is NOT active: show icon buttons only on the right */}
|
||||
{!showSearch && (
|
||||
<div className="flex min-h-[48px] items-center justify-end">{iconButtonsNode}</div>
|
||||
<div className="flex min-h-[48px] items-center justify-end">
|
||||
<IconButtons />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -101,7 +101,6 @@ const PasswordLoginForm = ({
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
aria-label="Username"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
@@ -132,7 +131,6 @@ const PasswordLoginForm = ({
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
aria-label="Password"
|
||||
autoComplete="current-password"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
@@ -170,7 +168,6 @@ const PasswordLoginForm = ({
|
||||
checked={rememberMe}
|
||||
onChange={(event) => setRememberMe(event.target.checked)}
|
||||
disabled={isLoading}
|
||||
aria-label="Remember me for 7 days"
|
||||
className="h-4 w-4 rounded-sm accent-sky-900 focus:ring-2 focus:ring-sky-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-color)' }}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import { isMetadataBook } from '../types';
|
||||
import { bookSupportsTargets } from '../utils/bookTargetLoader';
|
||||
import { getColorStyleFromHint } from '../utils/colorMaps';
|
||||
import { getSizedCoverUrl } from '../utils/covers';
|
||||
import {
|
||||
LANGUAGE_OPTION_DEFAULT,
|
||||
getLanguageFilterValues,
|
||||
@@ -210,8 +211,9 @@ function StarRating({ rating, maxRating = 5 }: { rating: number; maxRating?: num
|
||||
const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const optimizedPreview = getSizedCoverUrl(preview, { width: 32, height: 48 });
|
||||
|
||||
if (!preview || imageError) {
|
||||
if (!optimizedPreview || imageError) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-10 w-7 shrink-0 items-center justify-center rounded-sm bg-zinc-200 text-[7px] font-medium text-zinc-500 sm:h-12 sm:w-8 sm:text-[8px] dark:bg-zinc-700 dark:text-zinc-400"
|
||||
@@ -228,10 +230,13 @@ const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string
|
||||
<div className="absolute inset-0 animate-pulse bg-linear-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700" />
|
||||
)}
|
||||
<img
|
||||
src={preview}
|
||||
src={optimizedPreview}
|
||||
alt={title || 'Book cover'}
|
||||
className="h-full w-full object-cover object-top"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={32}
|
||||
height={48}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => setImageError(true)}
|
||||
style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }}
|
||||
@@ -729,8 +734,6 @@ function ErrorState({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_SUPPORTED_AUDIOBOOK_FORMATS: string[] = [];
|
||||
|
||||
const ReleaseModalSession = ({
|
||||
book,
|
||||
onClose,
|
||||
@@ -739,7 +742,7 @@ const ReleaseModalSession = ({
|
||||
onRequestBook,
|
||||
getPolicyModeForSource,
|
||||
supportedFormats,
|
||||
supportedAudiobookFormats = EMPTY_SUPPORTED_AUDIOBOOK_FORMATS,
|
||||
supportedAudiobookFormats = [],
|
||||
contentType,
|
||||
defaultLanguages,
|
||||
bookLanguages,
|
||||
@@ -1239,6 +1242,10 @@ const ReleaseModalSession = ({
|
||||
} else if (book.series_name) {
|
||||
coverSizeClassName = 'h-[144px] w-24';
|
||||
}
|
||||
const modalPreview = getSizedCoverUrl(book.preview, {
|
||||
width: book.cover_aspect === 'square' ? 144 : 96,
|
||||
height: 144,
|
||||
});
|
||||
|
||||
let combinedFooterEbookMode = combinedEbookMode;
|
||||
if (combinedPhase === 'ebook') {
|
||||
@@ -1327,13 +1334,14 @@ const ReleaseModalSession = ({
|
||||
{/* Mobile: static thumbnail always visible */}
|
||||
{!isRequestMode && (
|
||||
<div className="shrink-0 sm:hidden">
|
||||
{book.preview ? (
|
||||
{modalPreview ? (
|
||||
<img
|
||||
src={book.preview}
|
||||
src={modalPreview}
|
||||
alt=""
|
||||
width={book.cover_aspect === 'square' ? 68 : 46}
|
||||
height={68}
|
||||
className={`rounded-sm object-cover shadow-md ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`}
|
||||
decoding="async"
|
||||
style={{
|
||||
width: book.cover_aspect === 'square' ? 68 : 46,
|
||||
height: 68,
|
||||
@@ -1367,13 +1375,14 @@ const ReleaseModalSession = ({
|
||||
className="transition-opacity duration-300 ease-out"
|
||||
style={{ opacity: showHeaderThumb ? 1 : 0 }}
|
||||
>
|
||||
{book.preview ? (
|
||||
{modalPreview ? (
|
||||
<img
|
||||
src={book.preview}
|
||||
src={modalPreview}
|
||||
alt=""
|
||||
width={book.cover_aspect === 'square' ? 68 : 46}
|
||||
height={68}
|
||||
className={`rounded-sm object-cover shadow-md ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`}
|
||||
decoding="async"
|
||||
style={{
|
||||
width: book.cover_aspect === 'square' ? 68 : 46,
|
||||
height: 68,
|
||||
@@ -1436,10 +1445,13 @@ const ReleaseModalSession = ({
|
||||
ref={bookSummaryRef}
|
||||
className="flex gap-4 border-b border-(--border-muted) px-5 py-4"
|
||||
>
|
||||
{book.preview ? (
|
||||
{modalPreview ? (
|
||||
<img
|
||||
src={book.preview}
|
||||
src={modalPreview}
|
||||
alt="Book cover"
|
||||
width={book.cover_aspect === 'square' ? (book.series_name ? 144 : 120) : 96}
|
||||
height={book.series_name ? 144 : 120}
|
||||
decoding="async"
|
||||
className={`hidden shrink-0 rounded-lg object-cover shadow-md sm:block ${coverAspectClassName} ${coverSizeClassName}`}
|
||||
/>
|
||||
) : (
|
||||
@@ -2096,7 +2108,6 @@ const ReleaseModalSession = ({
|
||||
value={manualQuery}
|
||||
onChange={(e) => setManualQuery(e.target.value)}
|
||||
placeholder="Type a custom search query (overrides all sources)"
|
||||
aria-label="Custom search query"
|
||||
className="w-full rounded-lg border border-(--border-muted) bg-(--bg) px-3 py-2 text-sm text-(--text)"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEscapeKey } from '../hooks/useEscapeKey';
|
||||
import { useMountEffect } from '../hooks/useMountEffect';
|
||||
import { getMetadataBookInfo } from '../services/api';
|
||||
import type { CreateRequestPayload } from '../types';
|
||||
import { getSizedCoverUrl } from '../utils/covers';
|
||||
import type { RequestConfirmationPreview } from '../utils/requestConfirmation';
|
||||
import {
|
||||
applyRequestNoteToPayload,
|
||||
@@ -64,11 +65,9 @@ const getRequestConfirmationSessionKey = (payload: CreateRequestPayload): string
|
||||
].join('|');
|
||||
};
|
||||
|
||||
const EMPTY_PAYLOADS: CreateRequestPayload[] = [];
|
||||
|
||||
export function RequestConfirmationModal({
|
||||
payload,
|
||||
extraPayloads = EMPTY_PAYLOADS,
|
||||
extraPayloads = [],
|
||||
allowNotes,
|
||||
onConfirm,
|
||||
onClose,
|
||||
@@ -91,7 +90,7 @@ export function RequestConfirmationModal({
|
||||
|
||||
function RequestConfirmationModalSession({
|
||||
payload,
|
||||
extraPayloads = EMPTY_PAYLOADS,
|
||||
extraPayloads = [],
|
||||
allowNotes,
|
||||
onConfirm,
|
||||
onClose,
|
||||
@@ -177,6 +176,7 @@ function RequestConfirmationModalSession({
|
||||
|
||||
const titleId = 'request-confirmation-modal-title';
|
||||
const confirmDisabled = isSubmitting || (allowNotes && note.length > MAX_REQUEST_NOTE_LENGTH);
|
||||
const previewImage = getSizedCoverUrl(preview.preview, { width: 64, height: 96 });
|
||||
|
||||
const submit = async () => {
|
||||
if (confirmDisabled) {
|
||||
@@ -242,11 +242,15 @@ function RequestConfirmationModalSession({
|
||||
<div className="rounded-xl border border-(--border-muted) bg-(--bg-soft) px-4 py-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-lg border border-(--border-muted) bg-(--bg)">
|
||||
{preview.preview ? (
|
||||
{previewImage ? (
|
||||
<img
|
||||
src={preview.preview}
|
||||
src={previewImage}
|
||||
alt={`${preview.title} cover`}
|
||||
className="h-full w-full object-cover object-top"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={64}
|
||||
height={96}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[10px] opacity-60">
|
||||
@@ -307,7 +311,6 @@ function RequestConfirmationModalSession({
|
||||
</label>
|
||||
<textarea
|
||||
id="request-note"
|
||||
aria-label="Note (optional)"
|
||||
value={note}
|
||||
onChange={(event) => setNote(truncateRequestNote(event.target.value))}
|
||||
maxLength={MAX_REQUEST_NOTE_LENGTH}
|
||||
|
||||
@@ -34,7 +34,6 @@ interface SearchBarProps {
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
combinedMode?: boolean;
|
||||
combinedModeLocked?: boolean;
|
||||
onCombinedModeChange?: (enabled: boolean) => void;
|
||||
queryTargets?: QueryTargetOption[];
|
||||
activeQueryTarget?: string;
|
||||
@@ -49,7 +48,6 @@ export interface SearchBarHandle {
|
||||
|
||||
const EMPTY_SORT_OPTIONS: SortOption[] = [];
|
||||
const EMPTY_AUTOCOMPLETE_OPTIONS: DynamicFieldOption[] = [];
|
||||
const EMPTY_QUERY_TARGETS: QueryTargetOption[] = [];
|
||||
|
||||
const BookIcon = () => (
|
||||
<svg
|
||||
@@ -182,9 +180,8 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
combinedMode = false,
|
||||
combinedModeLocked = false,
|
||||
onCombinedModeChange,
|
||||
queryTargets = EMPTY_QUERY_TARGETS,
|
||||
queryTargets = [],
|
||||
activeQueryTarget = 'general',
|
||||
onQueryTargetChange,
|
||||
activeQueryField,
|
||||
@@ -521,7 +518,6 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
aria-label={activeQueryField.label}
|
||||
className="h-4 w-4 rounded-sm border-(--border-muted) text-emerald-500 focus:ring-emerald-500/50"
|
||||
/>
|
||||
<span className="truncate text-sm" style={{ color: 'var(--text)' }}>
|
||||
@@ -800,19 +796,11 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{combinedModeLocked ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,6 @@ interface SearchSectionProps {
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
combinedMode?: boolean;
|
||||
combinedModeLocked?: boolean;
|
||||
onCombinedModeChange?: (enabled: boolean) => void;
|
||||
activeQueryField?: MetadataSearchField | null;
|
||||
searchMode: SearchMode;
|
||||
@@ -65,7 +64,6 @@ export const SearchSection = ({
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
combinedMode,
|
||||
combinedModeLocked,
|
||||
onCombinedModeChange,
|
||||
activeQueryField,
|
||||
searchMode,
|
||||
@@ -107,7 +105,6 @@ export const SearchSection = ({
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
combinedMode={combinedMode}
|
||||
combinedModeLocked={combinedModeLocked}
|
||||
onCombinedModeChange={onCombinedModeChange}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={activeQueryTarget}
|
||||
|
||||
@@ -12,13 +12,10 @@ interface UrlSearchBootstrapMountProps {
|
||||
parsedParams: ParsedUrlSearch;
|
||||
config: AppConfig;
|
||||
contentType: ContentType;
|
||||
combinedMode: boolean;
|
||||
combinedModeAllowed: boolean;
|
||||
advancedFilters: AdvancedFilterState;
|
||||
resolvedMetadataDefaultSort: string;
|
||||
resolvedMetadataSortOptions: SortOption[];
|
||||
setContentType: (value: ContentType) => void;
|
||||
setCombinedMode: (value: boolean) => void;
|
||||
setSearchInput: (value: string) => void;
|
||||
setAdvancedFilters: Dispatch<SetStateAction<AdvancedFilterState>>;
|
||||
setShowAdvanced: (value: boolean) => void;
|
||||
@@ -35,13 +32,10 @@ export const UrlSearchBootstrapMount = ({
|
||||
parsedParams,
|
||||
config,
|
||||
contentType,
|
||||
combinedMode,
|
||||
combinedModeAllowed,
|
||||
advancedFilters,
|
||||
resolvedMetadataDefaultSort,
|
||||
resolvedMetadataSortOptions,
|
||||
setContentType,
|
||||
setCombinedMode,
|
||||
setSearchInput,
|
||||
setAdvancedFilters,
|
||||
setShowAdvanced,
|
||||
@@ -55,19 +49,11 @@ export const UrlSearchBootstrapMount = ({
|
||||
const parsedSearchMode = config.search_mode || 'universal';
|
||||
const urlContentTypeOverride =
|
||||
parsedSearchMode === 'universal' ? parsedParams.contentType : undefined;
|
||||
const urlForcesCombined =
|
||||
parsedSearchMode === 'universal' && parsedParams.combinedMode === true && combinedModeAllowed;
|
||||
|
||||
if (urlContentTypeOverride && urlContentTypeOverride !== contentType) {
|
||||
setContentType(urlContentTypeOverride);
|
||||
}
|
||||
|
||||
if (urlForcesCombined && !combinedMode) {
|
||||
setCombinedMode(true);
|
||||
} else if (urlContentTypeOverride && combinedMode) {
|
||||
setCombinedMode(false);
|
||||
}
|
||||
|
||||
if (!parsedParams.hasSearchParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { RequestRecord } from '../../types';
|
||||
import { withBasePath } from '../../utils/basePath';
|
||||
import { getSizedCoverUrl } from '../../utils/covers';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import type { ActivityCardAction } from './activityCardModel';
|
||||
import { buildActivityCardModel } from './activityCardModel';
|
||||
@@ -436,7 +437,6 @@ const RejectInlinePanel = ({
|
||||
Reject request for <span className="opacity-80">{itemTitle || 'Untitled request'}</span>
|
||||
</p>
|
||||
<textarea
|
||||
aria-label="Optional note shown to the user"
|
||||
value={rejectNote}
|
||||
onChange={(event) => setRejectNote(event.target.value.slice(0, MAX_ADMIN_NOTE_LENGTH))}
|
||||
rows={3}
|
||||
@@ -500,6 +500,7 @@ export const ActivityCard = ({
|
||||
const titleLineRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const [badgeOverflow, setBadgeOverflow] = useState<Record<string, boolean>>({});
|
||||
const [titleOverflow, setTitleOverflow] = useState(false);
|
||||
const previewImage = getSizedCoverUrl(item.preview, { width: 48, height: 72 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const measureBadgeOverflow = () => {
|
||||
@@ -710,11 +711,15 @@ export const ActivityCard = ({
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Artwork */}
|
||||
<div className="h-18 w-12 shrink-0 overflow-hidden rounded-sm bg-gray-200 dark:bg-gray-700">
|
||||
{item.preview ? (
|
||||
{previewImage ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
src={previewImage}
|
||||
alt={`${item.title} cover`}
|
||||
className="h-full w-full object-cover object-top"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={48}
|
||||
height={72}
|
||||
/>
|
||||
) : (
|
||||
<BookFallback />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user