mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9668371f7 | ||
|
|
d55e42fbc6 | ||
|
|
ade4878cfd | ||
|
|
aba1a68dda | ||
|
|
6231678c28 | ||
|
|
b5acbc1209 | ||
|
|
b0df608a53 | ||
|
|
68e4e6120e | ||
|
|
c64c2d374a | ||
|
|
75c0b0c33a | ||
|
|
d695b636f0 | ||
|
|
bce5ea0641 | ||
|
|
0633cfde87 | ||
|
|
b2131fa940 | ||
|
|
7e8a0248a0 | ||
|
|
b914f77748 | ||
|
|
5f3a94d3b2 | ||
|
|
5d5b8d4bef | ||
|
|
8c86cdc1dd | ||
|
|
2e25d2d8db | ||
|
|
709d3828d9 | ||
|
|
d808c362ea | ||
|
|
ad5686a23a | ||
|
|
3d18320096 | ||
|
|
3ac8d9f681 | ||
|
|
2f9b42f428 | ||
|
|
5a53d5910d | ||
|
|
b28ad55d46 | ||
|
|
9c3af5793b | ||
|
|
d1fd93f180 | ||
|
|
b67df083e6 | ||
|
|
b038867d8d | ||
|
|
472aae608b | ||
|
|
0e120abfaf | ||
|
|
f6357ead41 | ||
|
|
d67eeace3c | ||
|
|
e615797e69 | ||
|
|
2aee1d587e | ||
|
|
d1ab58411b | ||
|
|
f4daf05d03 | ||
|
|
5f9f47cc41 | ||
|
|
81b448bc9f | ||
|
|
eee8ba0e83 | ||
|
|
cecbae738e | ||
|
|
f5fafd2265 | ||
|
|
fbbff8f715 | ||
|
|
9b8402c9a7 | ||
|
|
3305ec9e46 | ||
|
|
f03be02de0 | ||
|
|
7d589abc35 | ||
|
|
84c8142b24 | ||
|
|
9bd7eae2b5 | ||
|
|
d6590be551 | ||
|
|
4c782ca92d | ||
|
|
b10a5a35ca | ||
|
|
196578fb18 | ||
|
|
ba62771a53 | ||
|
|
7a2de1ccdd | ||
|
|
4881adc19f | ||
|
|
ee54033d23 | ||
|
|
3554d01c81 | ||
|
|
9dd445f2af | ||
|
|
4e41b1a8ec |
+25
-3
@@ -1,10 +1,17 @@
|
|||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
# Python dependencies
|
# 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"
|
- package-ecosystem: "uv"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "daily"
|
||||||
|
time: "05:00"
|
||||||
|
timezone: "Europe/London"
|
||||||
|
cooldown:
|
||||||
|
default-days: 3
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
groups:
|
groups:
|
||||||
python-deps:
|
python-deps:
|
||||||
@@ -16,21 +23,34 @@ updates:
|
|||||||
directory: "/src/frontend"
|
directory: "/src/frontend"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
cooldown:
|
||||||
|
default-days: 3
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
groups:
|
groups:
|
||||||
npm-deps:
|
npm-deps:
|
||||||
patterns: ["*"]
|
patterns: ["*"]
|
||||||
update-types: ["minor", "patch"]
|
update-types: ["minor", "patch"]
|
||||||
|
|
||||||
# Dockerfile base images
|
# Dockerfile base image digests. When a tag stays the same, Dependabot titles
|
||||||
|
# can only show digest prefixes, so keep the group name explicit.
|
||||||
- package-ecosystem: "docker"
|
- package-ecosystem: "docker"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
cooldown:
|
||||||
|
default-days: 3
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
groups:
|
groups:
|
||||||
docker-images:
|
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.
|
||||||
patterns: ["*"]
|
patterns: ["*"]
|
||||||
|
exclude-patterns: ["python"]
|
||||||
ignore:
|
ignore:
|
||||||
# Node.js: block major-version bumps so dependabot never proposes
|
# 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).
|
# moving from one LTS line to a non-LTS "Current" release (e.g. 24 -> 25).
|
||||||
@@ -43,6 +63,8 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
cooldown:
|
||||||
|
default-days: 3
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
groups:
|
groups:
|
||||||
gh-actions:
|
gh-actions:
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ jobs:
|
|||||||
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract metadata for ${{ matrix.target }} image
|
- name: Extract metadata for ${{ matrix.target }} image
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -90,11 +90,11 @@ jobs:
|
|||||||
type=ref,event=tag
|
type=ref,event=tag
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|
||||||
- name: Build and push ${{ matrix.target }} Docker image
|
- name: Build and push ${{ matrix.target }} Docker image
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
context: .
|
context: .
|
||||||
@@ -127,14 +127,14 @@ jobs:
|
|||||||
LEGACY_NAME: calibre-web-automated-book-downloader
|
LEGACY_NAME: calibre-web-automated-book-downloader
|
||||||
steps:
|
steps:
|
||||||
- name: Log in to registry
|
- name: Log in to registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|
||||||
- name: Create legacy aliases
|
- name: Create legacy aliases
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+12
-12
@@ -13,10 +13,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Install uv and Python
|
- name: Install uv and Python
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||||
with:
|
with:
|
||||||
version: "0.11.3"
|
version: "0.11.3"
|
||||||
python-version: "3.14"
|
python-version: "3.14"
|
||||||
@@ -39,10 +39,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Install uv and Python
|
- name: Install uv and Python
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||||
with:
|
with:
|
||||||
version: "0.11.3"
|
version: "0.11.3"
|
||||||
python-version: "3.14"
|
python-version: "3.14"
|
||||||
@@ -59,10 +59,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Install uv and Python
|
- name: Install uv and Python
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||||
with:
|
with:
|
||||||
version: "0.11.3"
|
version: "0.11.3"
|
||||||
python-version: "3.14"
|
python-version: "3.14"
|
||||||
@@ -78,13 +78,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|
||||||
- name: Build shelfmark-lite image
|
- name: Build shelfmark-lite image
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: shelfmark-lite
|
target: shelfmark-lite
|
||||||
@@ -99,7 +99,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
@@ -122,7 +122,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
@@ -142,7 +142,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
|||||||
@@ -22,17 +22,17 @@ jobs:
|
|||||||
language: [python, javascript-typescript]
|
language: [python, javascript-typescript]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
|
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3
|
||||||
with:
|
with:
|
||||||
category: "/language:${{ matrix.language }}"
|
category: "/language:${{ matrix.language }}"
|
||||||
|
|||||||
+18
-3
@@ -4,7 +4,7 @@ ARG BUILDPLATFORM
|
|||||||
ARG BUILDARCH
|
ARG BUILDARCH
|
||||||
|
|
||||||
# Frontend build stage.
|
# Frontend build stage.
|
||||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-builder
|
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:fb71d01345f11b708a3553c66e7c74074f2d506400ea81973343d915cb64eef0 AS frontend-builder
|
||||||
|
|
||||||
# Helpful debug output to see what platforms BuildKit thinks it's using
|
# Helpful debug output to see what platforms BuildKit thinks it's using
|
||||||
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
|
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
|
||||||
@@ -25,9 +25,9 @@ COPY src/frontend/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Use python-slim as the base image
|
# Use python-slim as the base image
|
||||||
FROM python:3.14-slim AS base
|
FROM python:3.14.5-slim@sha256:c845af9399020c7e562969a13689e929074a10fd057acd1b1fad06a2fb068e97 AS base
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.11.3 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 /uv /uvx /bin/
|
||||||
|
|
||||||
# Add build argument for version
|
# Add build argument for version
|
||||||
ARG BUILD_VERSION
|
ARG BUILD_VERSION
|
||||||
@@ -103,6 +103,15 @@ COPY pyproject.toml uv.lock ./
|
|||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --locked --no-default-groups
|
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 application code *after* dependencies are installed
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
@@ -164,6 +173,9 @@ RUN apt-get update && \
|
|||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --locked --no-default-groups --extra browser
|
uv sync --locked --no-default-groups --extra browser
|
||||||
|
|
||||||
|
# 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.
|
# 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')") && \
|
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}" && \
|
chown -R 1000:1000 "${SELENIUMBASE_DRIVERS_DIR}" && \
|
||||||
@@ -180,4 +192,7 @@ FROM base AS shelfmark-lite
|
|||||||
|
|
||||||
ENV USING_EXTERNAL_BYPASSER=true
|
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"]
|
CMD ["/app/entrypoint.sh"]
|
||||||
|
|||||||
+123
-16
@@ -14,6 +14,7 @@ This document lists all configuration options that can be set via environment va
|
|||||||
- [Network](#network)
|
- [Network](#network)
|
||||||
- [Advanced](#advanced)
|
- [Advanced](#advanced)
|
||||||
- [Prowlarr](#prowlarr)
|
- [Prowlarr](#prowlarr)
|
||||||
|
- [Newznab](#newznab)
|
||||||
- [AudiobookBay](#audiobookbay)
|
- [AudiobookBay](#audiobookbay)
|
||||||
- [IRC](#irc)
|
- [IRC](#irc)
|
||||||
- [Download Clients](#download-clients)
|
- [Download Clients](#download-clients)
|
||||||
@@ -30,7 +31,7 @@ This document lists all configuration options that can be set via environment va
|
|||||||
|
|
||||||
## Bootstrap Configuration
|
## Bootstrap Configuration
|
||||||
|
|
||||||
These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.
|
These environment variables are used at startup before the settings system loads. They typically configure paths, server settings, and authentication startup behavior.
|
||||||
|
|
||||||
| Variable | Description | Type | Default |
|
| Variable | Description | Type | Default |
|
||||||
|----------|-------------|------|---------|
|
|----------|-------------|------|---------|
|
||||||
@@ -42,6 +43,9 @@ These environment variables are used at startup before the settings system loads
|
|||||||
| `FLASK_PORT` | Port number for the Flask web server. | number | `8084` |
|
| `FLASK_PORT` | Port number for the Flask web server. | number | `8084` |
|
||||||
| `SESSION_COOKIE_SECURE` | Enable secure cookies (requires HTTPS). | boolean | `false` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `ONBOARDING` | Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage). | boolean | `true` |
|
||||||
|
|
||||||
@@ -104,6 +108,27 @@ Path to the Calibre-Web database for authentication integration.
|
|||||||
- **Type:** string (path)
|
- **Type:** string (path)
|
||||||
- **Default:** `/auth/app.db`
|
- **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`
|
#### `DOCKERMODE`
|
||||||
|
|
||||||
Indicates the application is running inside a Docker container.
|
Indicates the application is running inside a Docker container.
|
||||||
@@ -124,6 +149,7 @@ Show the onboarding wizard on first run. Set to false to skip (useful for epheme
|
|||||||
|
|
||||||
| Variable | Description | Type | Default |
|
| 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_ |
|
| `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_ |
|
| `AUDIOBOOK_LIBRARY_URL` | Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text. | string | _none_ |
|
||||||
| `SUPPORTED_FORMATS` | Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found. | string (comma-separated) | `epub,mobi,azw3,fb2,djvu,cbz,cbr` |
|
| `SUPPORTED_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` |
|
||||||
@@ -133,6 +159,15 @@ Show the onboarding wizard on first run. Set to false to skip (useful for epheme
|
|||||||
<details>
|
<details>
|
||||||
<summary>Detailed descriptions</summary>
|
<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`
|
#### `CALIBRE_WEB_URL`
|
||||||
|
|
||||||
**Library URL**
|
**Library URL**
|
||||||
@@ -188,6 +223,7 @@ Default language filter for searches.
|
|||||||
| `AA_DEFAULT_SORT` | Default sort order for search results. | string (choice) | `relevance` |
|
| `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_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` |
|
| `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` | 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_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_ |
|
| `METADATA_PROVIDER_COMBINED` | Metadata provider for combined mode searches. Uses the book provider if not set. | string (choice) | _empty string_ |
|
||||||
@@ -235,6 +271,15 @@ Show the option to search for and download both a book and audiobook together.
|
|||||||
- **Type:** boolean
|
- **Type:** boolean
|
||||||
- **Default:** `true`
|
- **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`
|
#### `METADATA_PROVIDER`
|
||||||
|
|
||||||
**Book Metadata Provider**
|
**Book Metadata Provider**
|
||||||
@@ -294,8 +339,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` |
|
| `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` |
|
| `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` |
|
| `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}. 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_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}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | 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})` |
|
||||||
| `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` |
|
| `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_HOST` | Base URL of your Grimmory instance | string | _none_ |
|
||||||
| `BOOKLORE_USERNAME` | Grimmory account username | string | _none_ |
|
| `BOOKLORE_USERNAME` | Grimmory account username | string | _none_ |
|
||||||
@@ -311,13 +356,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_USERNAME` | SMTP username (leave empty for no authentication). | string | _none_ |
|
||||||
| `EMAIL_SMTP_PASSWORD` | SMTP password (required if Username is set). | string (secret) | _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_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}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}. | string | `{Title}` |
|
| `EMAIL_SUBJECT_TEMPLATE` | Email subject. Variables: {Author}, {Title}, {PrimaryTitle}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}. | string | `{Title}` |
|
||||||
| `EMAIL_SMTP_TIMEOUT_SECONDS` | How long to wait for SMTP operations before failing. | number | `60` |
|
| `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` |
|
| `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_ |
|
| `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` |
|
| `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}, {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_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}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | 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}` |
|
||||||
| `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` |
|
| `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` |
|
| `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_ |
|
| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ |
|
||||||
@@ -361,7 +406,7 @@ Choose how downloaded book files are named and organized.
|
|||||||
|
|
||||||
**Naming Template**
|
**Naming Template**
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
- **Type:** string
|
- **Type:** string
|
||||||
- **Default:** `{Author} - {Title} ({Year})`
|
- **Default:** `{Author} - {Title} ({Year})`
|
||||||
@@ -370,7 +415,7 @@ Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename wi
|
|||||||
|
|
||||||
**Path Template**
|
**Path Template**
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
- **Type:** string
|
- **Type:** string
|
||||||
- **Default:** `{Author}/{Title} ({Year})`
|
- **Default:** `{Author}/{Title} ({Year})`
|
||||||
@@ -524,7 +569,7 @@ From address used for the email. You can include a display name (e.g., Shelfmark
|
|||||||
|
|
||||||
**Subject Template**
|
**Subject Template**
|
||||||
|
|
||||||
Email subject. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}.
|
Email subject. Variables: {Author}, {Title}, {PrimaryTitle}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}.
|
||||||
|
|
||||||
- **Type:** string
|
- **Type:** string
|
||||||
- **Default:** `{Title}`
|
- **Default:** `{Title}`
|
||||||
@@ -571,7 +616,7 @@ Choose how downloaded audiobook files are named and organized.
|
|||||||
|
|
||||||
**Naming Template**
|
**Naming Template**
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
- **Type:** string
|
- **Type:** string
|
||||||
- **Default:** `{Author} - {Title}`
|
- **Default:** `{Author} - {Title}`
|
||||||
@@ -580,10 +625,10 @@ Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename wi
|
|||||||
|
|
||||||
**Path Template**
|
**Path Template**
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
- **Type:** string
|
- **Type:** string
|
||||||
- **Default:** `{Author}/{Title}`
|
- **Default:** `{Author}/{Title}/{Title}`
|
||||||
|
|
||||||
#### `HARDLINK_TORRENTS_AUDIOBOOK`
|
#### `HARDLINK_TORRENTS_AUDIOBOOK`
|
||||||
|
|
||||||
@@ -639,7 +684,7 @@ How long to keep completed/failed downloads in the queue display.
|
|||||||
|
|
||||||
| Variable | Description | Type | Default |
|
| Variable | Description | Type | Default |
|
||||||
|----------|-------------|------|---------|
|
|----------|-------------|------|---------|
|
||||||
| `AUTH_METHOD` | Select the authentication method for accessing Shelfmark. | string (choice) | `none` |
|
| `AUTH_METHOD` | Select the authentication method for accessing Shelfmark. Restart container after changing Calibre-Web passwords. | string (choice) | `none` |
|
||||||
| `PROXY_AUTH_USER_HEADER` | The HTTP header your proxy uses to pass the authenticated username. | string | `X-Auth-User` |
|
| `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_LOGOUT_URL` | The URL to redirect users to for logging out. Leave empty to disable logout functionality. | string | _empty string_ |
|
||||||
| `PROXY_AUTH_ADMIN_GROUP_HEADER` | Optional: header your proxy uses to pass user groups/roles. | string | `X-Auth-Groups` |
|
| `PROXY_AUTH_ADMIN_GROUP_HEADER` | Optional: header your proxy uses to pass user groups/roles. | string | `X-Auth-Groups` |
|
||||||
@@ -661,7 +706,7 @@ How long to keep completed/failed downloads in the queue display.
|
|||||||
|
|
||||||
**Authentication Method**
|
**Authentication Method**
|
||||||
|
|
||||||
Select the authentication method for accessing Shelfmark.
|
Select the authentication method for accessing Shelfmark. Restart container after changing Calibre-Web passwords.
|
||||||
|
|
||||||
- **Type:** string (choice)
|
- **Type:** string (choice)
|
||||||
- **Default:** `none`
|
- **Default:** `none`
|
||||||
@@ -1062,6 +1107,7 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480
|
|||||||
| `PROWLARR_API_KEY` | Found in Prowlarr: Settings > General > API Key | string (secret) | _none_ |
|
| `PROWLARR_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_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_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>
|
<details>
|
||||||
<summary>Detailed descriptions</summary>
|
<summary>Detailed descriptions</summary>
|
||||||
@@ -1113,6 +1159,66 @@ Automatically retry search without category filtering if no results are found
|
|||||||
- **Type:** boolean
|
- **Type:** boolean
|
||||||
- **Default:** `false`
|
- **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>
|
</details>
|
||||||
|
|
||||||
## AudiobookBay
|
## AudiobookBay
|
||||||
@@ -1187,7 +1293,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_USE_TLS` | Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't support TLS. | boolean | `true` |
|
||||||
| `IRC_CHANNEL` | Channel name without the # prefix | string | _none_ |
|
| `IRC_CHANNEL` | Channel name without the # prefix | string | _none_ |
|
||||||
| `IRC_NICK` | Your IRC nickname (required). Must be unique on the IRC network. | string | _none_ |
|
| `IRC_NICK` | Your IRC nickname (required). Must be unique on the IRC network. | string | _none_ |
|
||||||
| `IRC_SEARCH_BOT` | The search bot to query for results | string | _none_ |
|
| `IRC_SEARCH_BOT` | The search bot to address queries to (required). | string | _none_ |
|
||||||
| `IRC_CACHE_TTL` | How long to keep cached search results before they expire. | string (choice) | `2592000` |
|
| `IRC_CACHE_TTL` | How long to keep cached search results before they expire. | string (choice) | `2592000` |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -1245,10 +1351,11 @@ Your IRC nickname (required). Must be unique on the IRC network.
|
|||||||
|
|
||||||
**Search bot**
|
**Search bot**
|
||||||
|
|
||||||
The search bot to query for results
|
The search bot to address queries to (required). Searches are sent as "@<bot> <query>". Without it, queries would be posted unaddressed to the channel.
|
||||||
|
|
||||||
- **Type:** string
|
- **Type:** string
|
||||||
- **Default:** _none_
|
- **Default:** _none_
|
||||||
|
- **Required:** Yes
|
||||||
|
|
||||||
#### `IRC_CACHE_TTL`
|
#### `IRC_CACHE_TTL`
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -39,9 +39,10 @@ These optional environment variables control login page behavior when OIDC is en
|
|||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `HIDE_LOCAL_AUTH` | Hide the username/password login option, so only the OIDC button is shown | `false` |
|
| `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` |
|
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page | `false` |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ http://your-server:8084/?q=harry+potter
|
|||||||
| `lang` | Filter by language (ISO 639-1 code) | `/?lang=en` |
|
| `lang` | Filter by language (ISO 639-1 code) | `/?lang=en` |
|
||||||
| `format` | Filter by file format | `/?format=epub` |
|
| `format` | Filter by file format | `/?format=epub` |
|
||||||
| `content` | Filter by content type | `/?content=fiction` |
|
| `content` | Filter by content type | `/?content=fiction` |
|
||||||
| `content_type` | Select media type (`ebook` or `audiobook`) in Universal mode only | `/?q=dune&content_type=audiobook` |
|
| `content_type` | Select media type (`ebook`, `audiobook`, or `combined`) in Universal mode only | `/?q=dune&content_type=audiobook` |
|
||||||
| `sort` | Sort order for results | `/?sort=newest` |
|
| `sort` | Sort order for results | `/?sort=newest` |
|
||||||
|
|
||||||
## Multiple Values
|
## Multiple Values
|
||||||
@@ -63,6 +63,11 @@ Some parameters support multiple values by repeating the parameter:
|
|||||||
/?q=dune&content_type=audiobook
|
/?q=dune&content_type=audiobook
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Universal search forcing combined (ebook + audiobook):**
|
||||||
|
```
|
||||||
|
/?q=dune&content_type=combined
|
||||||
|
```
|
||||||
|
|
||||||
## Search Mode Behavior
|
## Search Mode Behavior
|
||||||
|
|
||||||
### Direct Mode
|
### Direct Mode
|
||||||
@@ -74,6 +79,8 @@ 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.
|
`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
|
## Notes
|
||||||
|
|
||||||
- URL parameters are read once on page load
|
- URL parameters are read once on page load
|
||||||
|
|||||||
+43
-27
@@ -106,6 +106,18 @@ if [ ! -x "$PYTHON_BIN" ]; then
|
|||||||
PYTHON_BIN="python3"
|
PYTHON_BIN="python3"
|
||||||
fi
|
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
|
# Print build version
|
||||||
echo "Build version: $BUILD_VERSION"
|
echo "Build version: $BUILD_VERSION"
|
||||||
echo "Release version: $RELEASE_VERSION"
|
echo "Release version: $RELEASE_VERSION"
|
||||||
@@ -310,6 +322,27 @@ require_writable_dir() {
|
|||||||
fi
|
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() {
|
resolve_runtime_home() {
|
||||||
local runtime_home
|
local runtime_home
|
||||||
|
|
||||||
@@ -405,37 +438,15 @@ else
|
|||||||
# Config is Shelfmark-owned state, so it keeps the thorough repair path.
|
# Config is Shelfmark-owned state, so it keeps the thorough repair path.
|
||||||
make_writable "${CONFIG_DIR:-/config}" tree
|
make_writable "${CONFIG_DIR:-/config}" tree
|
||||||
|
|
||||||
# Fallback to root if config dir is still not writable (common on NAS/Unraid after upgrade from v0.4.0)
|
# Refuse to continue if the config directory is still not writable after repair.
|
||||||
CONFIG_PATH=${CONFIG_DIR:-/config}
|
CONFIG_PATH=${CONFIG_DIR:-/config}
|
||||||
set +e
|
set +e
|
||||||
test_write "$CONFIG_PATH" >/dev/null 2>&1
|
test_write "$CONFIG_PATH" >/dev/null 2>&1
|
||||||
config_ok=$?
|
config_ok=$?
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if [ $config_ok -ne 0 ] && [ "$RUN_UID" != "0" ]; then
|
if [ $config_ok -ne 0 ]; then
|
||||||
config_owner=$(stat -c '%u' "$CONFIG_PATH" 2>/dev/null || echo "unknown")
|
fail_unwritable_config_dir "$CONFIG_PATH"
|
||||||
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
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -443,7 +454,7 @@ fi
|
|||||||
# upgrades work reliably on customer machines.
|
# upgrades work reliably on customer machines.
|
||||||
# Map app LOG_LEVEL (often DEBUG/INFO/...) to gunicorn's --log-level (lowercase).
|
# Map app LOG_LEVEL (often DEBUG/INFO/...) to gunicorn's --log-level (lowercase).
|
||||||
gunicorn_loglevel=$([ "$DEBUG" = "true" ] && echo debug || echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]')
|
gunicorn_loglevel=$([ "$DEBUG" = "true" ] && echo debug || echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]')
|
||||||
command="gunicorn --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} shelfmark.main:app"
|
command="${GUNICORN_BIN} --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} shelfmark.main:app"
|
||||||
|
|
||||||
# If DEBUG and not using an external bypass
|
# If DEBUG and not using an external bypass
|
||||||
if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
|
if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
|
||||||
@@ -512,7 +523,12 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
RUNTIME_HOME=$(resolve_runtime_home)
|
RUNTIME_HOME=$(resolve_runtime_home)
|
||||||
require_writable_dir "$RUNTIME_HOME" "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
|
||||||
|
|
||||||
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
|
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
|
||||||
echo "Startup mode: non-root"
|
echo "Startup mode: non-root"
|
||||||
|
|||||||
+5
-6
@@ -21,27 +21,26 @@ dependencies = [
|
|||||||
"rarfile",
|
"rarfile",
|
||||||
"qbittorrent-api",
|
"qbittorrent-api",
|
||||||
"transmission-rpc",
|
"transmission-rpc",
|
||||||
"authlib>=1.7.0,<1.8",
|
"authlib>=1.7.2,<1.8",
|
||||||
"apprise>=1.9.0",
|
"apprise>=1.11.0",
|
||||||
"Pillow>=11.0.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
browser = [
|
browser = [
|
||||||
"pyvirtualdisplay",
|
"pyvirtualdisplay",
|
||||||
"pyautogui",
|
"pyautogui",
|
||||||
"seleniumbase==4.48.2",
|
"seleniumbase==4.49.10",
|
||||||
"python-xlib",
|
"python-xlib",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"basedpyright>=1.39.3",
|
"basedpyright>=1.39.7",
|
||||||
"prek",
|
"prek",
|
||||||
"pytest",
|
"pytest",
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
"pytest-xdist>=3.8.0",
|
"pytest-xdist>=3.8.0",
|
||||||
"ruff==0.15.11",
|
"ruff==0.15.17",
|
||||||
"vulture>=2.14",
|
"vulture>=2.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
<img src="src/frontend/public/logo.png" alt="Shelfmark" width="200">
|
<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.
|
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:
|
Works great alongside the following library tools, with support for automatic imports:
|
||||||
|
|||||||
@@ -172,6 +172,24 @@ def _generate_bootstrap_env_docs() -> list[str]:
|
|||||||
"type": "string (path)",
|
"type": "string (path)",
|
||||||
"default": "/auth/app.db",
|
"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",
|
"name": "DOCKERMODE",
|
||||||
"description": "Indicates the application is running inside a Docker container.",
|
"description": "Indicates the application is running inside a Docker container.",
|
||||||
@@ -189,7 +207,7 @@ def _generate_bootstrap_env_docs() -> list[str]:
|
|||||||
lines = [
|
lines = [
|
||||||
"## Bootstrap Configuration",
|
"## Bootstrap Configuration",
|
||||||
"",
|
"",
|
||||||
"These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.",
|
"These environment variables are used at startup before the settings system loads. They typically configure paths, server settings, and authentication startup behavior.",
|
||||||
"",
|
"",
|
||||||
"| Variable | Description | Type | Default |",
|
"| Variable | Description | Type | Default |",
|
||||||
"|----------|-------------|------|---------|",
|
"|----------|-------------|------|---------|",
|
||||||
@@ -310,7 +328,7 @@ def generate_env_docs() -> str:
|
|||||||
|
|
||||||
def _generate_tab_docs(tab: Any, group_prefix: str | None = None) -> list[str]:
|
def _generate_tab_docs(tab: Any, group_prefix: str | None = None) -> list[str]:
|
||||||
"""Generate documentation for a single settings tab."""
|
"""Generate documentation for a single settings tab."""
|
||||||
from shelfmark.core.settings_registry import ActionButton, CustomComponentField, HeadingField
|
from shelfmark.core.settings_registry import iter_value_fields
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
@@ -323,17 +341,9 @@ def _generate_tab_docs(tab: Any, group_prefix: str | None = None) -> list[str]:
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Collect env-supported fields
|
# Collect env-supported fields
|
||||||
env_fields = []
|
env_fields = [
|
||||||
for field in tab.fields:
|
field for field in iter_value_fields(tab) if getattr(field, "env_supported", True)
|
||||||
# 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:
|
if not env_fields:
|
||||||
lines.append("_No environment variables for this section._")
|
lines.append("_No environment variables for this section._")
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
|
|||||||
SESSION_COOKIE_NAME = "shelfmark_session"
|
SESSION_COOKIE_NAME = "shelfmark_session"
|
||||||
CWA_DB_PATH = _resolve_cwa_db_path()
|
CWA_DB_PATH = _resolve_cwa_db_path()
|
||||||
HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false"))
|
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"))
|
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)
|
@register_settings("security", "Security", icon="shield", order=5)
|
||||||
def security_settings() -> list[SettingsField]:
|
def security_settings() -> list[SettingsField]:
|
||||||
"""Security and authentication settings."""
|
"""Security and authentication settings."""
|
||||||
from shelfmark.config.env import CWA_DB_PATH
|
from shelfmark.config.env import CWA_DB_PATH, DISABLE_LOCAL_AUTH
|
||||||
|
|
||||||
cwa_db_available = CWA_DB_PATH is not None and CWA_DB_PATH.exists()
|
cwa_db_available = CWA_DB_PATH is not None and CWA_DB_PATH.exists()
|
||||||
|
|
||||||
@@ -108,11 +108,17 @@ def security_settings() -> list[SettingsField]:
|
|||||||
),
|
),
|
||||||
show_when=_auth_condition("builtin"),
|
show_when=_auth_condition("builtin"),
|
||||||
),
|
),
|
||||||
CustomComponentField(
|
*(
|
||||||
key="oidc_admin_requirement",
|
[]
|
||||||
component="oidc_admin_hint",
|
if DISABLE_LOCAL_AUTH
|
||||||
label="A local admin account is required before OIDC can be enabled.",
|
else [
|
||||||
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,6 +4,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from shelfmark.config.env import DISABLE_LOCAL_AUTH
|
||||||
from shelfmark.core.user_db import UserDB
|
from shelfmark.core.user_db import UserDB
|
||||||
from shelfmark.core.utils import normalize_http_url
|
from shelfmark.core.utils import normalize_http_url
|
||||||
from shelfmark.download.network import get_ssl_verify
|
from shelfmark.download.network import get_ssl_verify
|
||||||
@@ -78,7 +79,7 @@ def on_save_security(
|
|||||||
auth_method = str(effective_values.get("AUTH_METHOD", "") or "").strip().lower()
|
auth_method = str(effective_values.get("AUTH_METHOD", "") or "").strip().lower()
|
||||||
|
|
||||||
if auth_method == "oidc":
|
if auth_method == "oidc":
|
||||||
if not _has_local_password_admin():
|
if not DISABLE_LOCAL_AUTH and not _has_local_password_admin():
|
||||||
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": normalized_values}
|
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": normalized_values}
|
||||||
|
|
||||||
missing_fields = _get_missing_oidc_required_fields(effective_values)
|
missing_fields = _get_missing_oidc_required_fields(effective_values)
|
||||||
|
|||||||
@@ -453,6 +453,14 @@ def search_mode_settings() -> list[SettingsField]:
|
|||||||
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
||||||
user_overridable=True,
|
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(
|
HeadingField(
|
||||||
key="universal_mode_heading",
|
key="universal_mode_heading",
|
||||||
title="Universal Mode Settings",
|
title="Universal Mode Settings",
|
||||||
@@ -938,7 +946,7 @@ def download_settings() -> list[SettingsField]:
|
|||||||
SelectField(
|
SelectField(
|
||||||
key="FILE_ORGANIZATION",
|
key="FILE_ORGANIZATION",
|
||||||
label="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=[
|
options=[
|
||||||
{
|
{
|
||||||
"value": "none",
|
"value": "none",
|
||||||
@@ -966,7 +974,14 @@ def download_settings() -> list[SettingsField]:
|
|||||||
_naming_template_field(
|
_naming_template_field(
|
||||||
key="TEMPLATE_RENAME",
|
key="TEMPLATE_RENAME",
|
||||||
label="Naming Template",
|
label="Naming Template",
|
||||||
description="Filename template for single-file book downloads.",
|
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."
|
||||||
|
),
|
||||||
default="{Author} - {Title} ({Year})",
|
default="{Author} - {Title} ({Year})",
|
||||||
placeholder="{Author} - {Title} ({Year})",
|
placeholder="{Author} - {Title} ({Year})",
|
||||||
show_when=[
|
show_when=[
|
||||||
@@ -978,7 +993,12 @@ def download_settings() -> list[SettingsField]:
|
|||||||
_naming_template_field(
|
_naming_template_field(
|
||||||
key="TEMPLATE_ORGANIZE",
|
key="TEMPLATE_ORGANIZE",
|
||||||
label="Path Template",
|
label="Path Template",
|
||||||
description="Folder and filename template for book downloads.",
|
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."
|
||||||
|
),
|
||||||
default="{Author}/{Title} ({Year})",
|
default="{Author}/{Title} ({Year})",
|
||||||
placeholder="{Author}/{Series/}{Title} ({Year})",
|
placeholder="{Author}/{Series/}{Title} ({Year})",
|
||||||
show_when=[
|
show_when=[
|
||||||
@@ -1236,7 +1256,14 @@ def download_settings() -> list[SettingsField]:
|
|||||||
_naming_template_field(
|
_naming_template_field(
|
||||||
key="TEMPLATE_AUDIOBOOK_RENAME",
|
key="TEMPLATE_AUDIOBOOK_RENAME",
|
||||||
label="Naming Template",
|
label="Naming Template",
|
||||||
description="Filename template for single-file audiobook downloads.",
|
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."
|
||||||
|
),
|
||||||
default="{Author} - {Title}",
|
default="{Author} - {Title}",
|
||||||
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
|
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
|
||||||
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
|
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
|
||||||
@@ -1246,8 +1273,13 @@ def download_settings() -> list[SettingsField]:
|
|||||||
_naming_template_field(
|
_naming_template_field(
|
||||||
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
|
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
|
||||||
label="Path Template",
|
label="Path Template",
|
||||||
description="Folder and filename template for audiobook downloads.",
|
description=(
|
||||||
default="{Author}/{Title}",
|
"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}",
|
||||||
placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}",
|
placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}",
|
||||||
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"},
|
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"},
|
||||||
universal_only=True,
|
universal_only=True,
|
||||||
@@ -1416,6 +1448,17 @@ def download_source_settings() -> list[SettingsField]:
|
|||||||
),
|
),
|
||||||
default=False,
|
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(
|
PasswordField(
|
||||||
key="AA_DONATOR_KEY",
|
key="AA_DONATOR_KEY",
|
||||||
label="Account Donator Key",
|
label="Account Donator Key",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ _SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
|
|||||||
"DEFAULT_RELEASE_SOURCE",
|
"DEFAULT_RELEASE_SOURCE",
|
||||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||||
"SHOW_COMBINED_SELECTOR",
|
"SHOW_COMBINED_SELECTOR",
|
||||||
|
"FORCE_COMBINED_SEARCH",
|
||||||
*_SEARCH_PREFERENCE_PROVIDER_KEYS,
|
*_SEARCH_PREFERENCE_PROVIDER_KEYS,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +224,11 @@ def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | N
|
|||||||
return value, None
|
return value, None
|
||||||
return bool(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
|
return value, None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,14 +71,16 @@ def determine_auth_mode(
|
|||||||
cwa_db_path: object | None,
|
cwa_db_path: object | None,
|
||||||
*,
|
*,
|
||||||
has_local_admin: bool = True,
|
has_local_admin: bool = True,
|
||||||
|
disable_local_auth: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Determine active auth mode from security config and runtime prerequisites."""
|
"""Determine active auth mode from security config and runtime prerequisites."""
|
||||||
auth_mode = security_config.get("AUTH_METHOD", "none")
|
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:
|
if auth_mode == AUTH_SOURCE_CWA and cwa_db_path:
|
||||||
return AUTH_SOURCE_CWA
|
return AUTH_SOURCE_CWA
|
||||||
|
|
||||||
if auth_mode == AUTH_SOURCE_BUILTIN and has_local_admin:
|
if auth_mode == AUTH_SOURCE_BUILTIN and local_admin_available:
|
||||||
return AUTH_SOURCE_BUILTIN
|
return AUTH_SOURCE_BUILTIN
|
||||||
|
|
||||||
if auth_mode == AUTH_SOURCE_PROXY and security_config.get("PROXY_AUTH_USER_HEADER"):
|
if auth_mode == AUTH_SOURCE_PROXY and security_config.get("PROXY_AUTH_USER_HEADER"):
|
||||||
@@ -86,7 +88,7 @@ def determine_auth_mode(
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
auth_mode == AUTH_SOURCE_OIDC
|
auth_mode == AUTH_SOURCE_OIDC
|
||||||
and has_local_admin
|
and local_admin_available
|
||||||
and security_config.get("OIDC_DISCOVERY_URL")
|
and security_config.get("OIDC_DISCOVERY_URL")
|
||||||
and security_config.get("OIDC_CLIENT_ID")
|
and security_config.get("OIDC_CLIENT_ID")
|
||||||
):
|
):
|
||||||
@@ -102,6 +104,7 @@ def load_active_auth_mode(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Resolve active auth mode using current security config and runtime prerequisites."""
|
"""Resolve active auth mode using current security config and runtime prerequisites."""
|
||||||
try:
|
try:
|
||||||
|
from shelfmark.config.env import DISABLE_LOCAL_AUTH
|
||||||
from shelfmark.core.config import config as app_config
|
from shelfmark.core.config import config as app_config
|
||||||
|
|
||||||
security_config = {
|
security_config = {
|
||||||
@@ -114,6 +117,7 @@ def load_active_auth_mode(
|
|||||||
security_config,
|
security_config,
|
||||||
cwa_db_path,
|
cwa_db_path,
|
||||||
has_local_admin=has_local_password_admin(user_db),
|
has_local_admin=has_local_password_admin(user_db),
|
||||||
|
disable_local_auth=DISABLE_LOCAL_AUTH,
|
||||||
)
|
)
|
||||||
except ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error:
|
except ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error:
|
||||||
return "none"
|
return "none"
|
||||||
|
|||||||
+70
-182
@@ -8,7 +8,7 @@ import time
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ FETCH_HEADERS = {
|
|||||||
|
|
||||||
# Maximum image size to fetch (5 MB)
|
# Maximum image size to fetch (5 MB)
|
||||||
MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
||||||
|
MAX_REDIRECTS = 5
|
||||||
|
|
||||||
# Negative cache TTL (for failed fetches) - 1 hour
|
# Negative cache TTL (for failed fetches) - 1 hour
|
||||||
NEGATIVE_CACHE_TTL = 3600
|
NEGATIVE_CACHE_TTL = 3600
|
||||||
@@ -49,9 +50,6 @@ TRANSIENT_CACHE_TTL = 60
|
|||||||
|
|
||||||
_MIN_WEBP_HEADER_LENGTH = 12
|
_MIN_WEBP_HEADER_LENGTH = 12
|
||||||
HTTP_NOT_FOUND = HTTPStatus.NOT_FOUND
|
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:
|
def _detect_image_type(data: bytes) -> tuple[str, str] | None:
|
||||||
@@ -75,164 +73,6 @@ def _detect_image_type(data: bytes) -> tuple[str, str] | None:
|
|||||||
return 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:
|
class ImageCacheService:
|
||||||
"""Persistent image cache with LRU eviction and TTL support."""
|
"""Persistent image cache with LRU eviction and TTL support."""
|
||||||
|
|
||||||
@@ -643,29 +483,85 @@ class ImageCacheService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_safe_url(url: str) -> bool:
|
def _prepare_safe_url(url: str) -> str | None:
|
||||||
"""Check that a URL is safe to fetch (no SSRF to internal resources)."""
|
"""Prepare and validate a URL before fetching it."""
|
||||||
|
if "\\" in url or any(ord(char) < 32 for char in url):
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = urlparse(url)
|
prepared = requests.Request("GET", url).prepare()
|
||||||
|
prepared_url = prepared.url
|
||||||
|
if not isinstance(prepared_url, str):
|
||||||
|
return None
|
||||||
|
parsed = urlparse(prepared_url)
|
||||||
hostname = parsed.hostname
|
hostname = parsed.hostname
|
||||||
except ValueError:
|
except requests.exceptions.RequestException, ValueError:
|
||||||
return False
|
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
|
||||||
|
|
||||||
if parsed.scheme not in ("http", "https"):
|
if parsed.scheme not in ("http", "https"):
|
||||||
return False
|
return None
|
||||||
if not hostname:
|
if not hostname:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = socket.getaddrinfo(hostname, None)
|
resolved = socket.getaddrinfo(hostname, None)
|
||||||
for _, _, _, _, sockaddr in resolved:
|
for _, _, _, _, sockaddr in resolved:
|
||||||
ip = ipaddress.ip_address(sockaddr[0])
|
ip = ipaddress.ip_address(sockaddr[0])
|
||||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||||
return False
|
return None
|
||||||
except socket.gaierror, ValueError:
|
except socket.gaierror, ValueError:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
return True
|
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
|
||||||
|
|
||||||
def fetch_and_cache(self, cache_id: str, url: str) -> tuple[bytes, str] | None:
|
def fetch_and_cache(self, cache_id: str, url: str) -> tuple[bytes, str] | None:
|
||||||
"""Fetch an image from URL and cache it.
|
"""Fetch an image from URL and cache it.
|
||||||
@@ -680,17 +576,9 @@ class ImageCacheService:
|
|||||||
"""
|
"""
|
||||||
cached_data: tuple[bytes, str] | None = None
|
cached_data: tuple[bytes, str] | None = None
|
||||||
try:
|
try:
|
||||||
if not self._is_safe_url(url):
|
response = self._fetch_safe_response(url)
|
||||||
logger.warning("Blocked request to disallowed URL: %s", url)
|
if response is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
response = requests.get(
|
|
||||||
url,
|
|
||||||
timeout=(5, 10),
|
|
||||||
headers=FETCH_HEADERS,
|
|
||||||
stream=True,
|
|
||||||
verify=get_ssl_verify(url),
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
# Validate content type
|
# Validate content type
|
||||||
|
|||||||
@@ -393,6 +393,41 @@ def _plugin_label(plugin: object, fallback_scheme: str) -> str:
|
|||||||
return " ".join(parts)
|
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(
|
def _dispatch_to_apprise(
|
||||||
urls: Iterable[str],
|
urls: Iterable[str],
|
||||||
*,
|
*,
|
||||||
@@ -400,6 +435,8 @@ def _dispatch_to_apprise(
|
|||||||
body: str,
|
body: str,
|
||||||
notify_type: object,
|
notify_type: object,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
import os
|
||||||
|
|
||||||
normalized_urls = _normalize_urls(list(urls))
|
normalized_urls = _normalize_urls(list(urls))
|
||||||
url_schemes = _extract_url_schemes(normalized_urls)
|
url_schemes = _extract_url_schemes(normalized_urls)
|
||||||
if not normalized_urls:
|
if not normalized_urls:
|
||||||
@@ -408,6 +445,11 @@ def _dispatch_to_apprise(
|
|||||||
if apprise is None:
|
if apprise is None:
|
||||||
return {"success": False, "message": "Apprise is not installed"}
|
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
|
valid_urls = 0
|
||||||
invalid_urls = 0
|
invalid_urls = 0
|
||||||
delivered_urls = 0
|
delivered_urls = 0
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ def _has_username_or_email(claims: dict[str, Any]) -> bool:
|
|||||||
return False
|
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:
|
def _login_error_url(message: str) -> str:
|
||||||
"""Build a login URL (with script_root) that includes an OIDC error message."""
|
"""Build a login URL (with script_root) that includes an OIDC error message."""
|
||||||
script_root = request.script_root.rstrip("/")
|
script_root = request.script_root.rstrip("/")
|
||||||
@@ -295,7 +305,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
|||||||
if admin_group and use_admin_group:
|
if admin_group and use_admin_group:
|
||||||
is_admin = admin_group in groups
|
is_admin = admin_group in groups
|
||||||
|
|
||||||
allow_email_link = bool(user_info.get("email"))
|
allow_email_link = bool(user_info.get("email")) and _is_email_verified(claims)
|
||||||
user = provision_oidc_user(
|
user = provision_oidc_user(
|
||||||
user_db,
|
user_db,
|
||||||
user_info,
|
user_info,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ A mapping rewrites a remote path prefix into a local path prefix.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path, PureWindowsPath
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -50,6 +50,42 @@ def _normalize_host(host: str) -> str:
|
|||||||
return str(host or "").strip().lower()
|
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]:
|
def parse_remote_path_mappings(value: object) -> list[RemotePathMapping]:
|
||||||
"""Parse configured remote-path mapping rows into normalized mappings."""
|
"""Parse configured remote-path mapping rows into normalized mappings."""
|
||||||
if not value or not isinstance(value, list):
|
if not value or not isinstance(value, list):
|
||||||
@@ -81,8 +117,12 @@ def remap_remote_to_local_with_match(
|
|||||||
mappings: Iterable[RemotePathMapping],
|
mappings: Iterable[RemotePathMapping],
|
||||||
host: str,
|
host: str,
|
||||||
remote_path: str | Path,
|
remote_path: str | Path,
|
||||||
) -> tuple[Path, bool]:
|
) -> tuple[Path | None, bool]:
|
||||||
"""Remap a remote path and report whether a configured mapping matched."""
|
"""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.
|
||||||
|
"""
|
||||||
host_normalized = _normalize_host(host)
|
host_normalized = _normalize_host(host)
|
||||||
remote_normalized = _normalize_prefix(str(remote_path))
|
remote_normalized = _normalize_prefix(str(remote_path))
|
||||||
|
|
||||||
@@ -119,7 +159,10 @@ def remap_remote_to_local_with_match(
|
|||||||
|
|
||||||
remainder = remainder.removeprefix("/")
|
remainder = remainder.removeprefix("/")
|
||||||
|
|
||||||
remapped = Path(local_prefix) / remainder if remainder else Path(local_prefix)
|
remapped = _join_contained_path(local_prefix, remainder)
|
||||||
|
if remapped is None:
|
||||||
|
return None, True
|
||||||
|
|
||||||
return remapped, True
|
return remapped, True
|
||||||
|
|
||||||
return Path(remote_normalized), False
|
return Path(remote_normalized), False
|
||||||
@@ -134,6 +177,8 @@ def remap_remote_to_local(
|
|||||||
host=host,
|
host=host,
|
||||||
remote_path=remote_path,
|
remote_path=remote_path,
|
||||||
)
|
)
|
||||||
|
if remapped is None:
|
||||||
|
return Path(str(remote_path))
|
||||||
return remapped
|
return remapped
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,26 @@ def _normalize_release_result_request_payload(
|
|||||||
return "release", normalized_release_data
|
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:
|
def _resolve_request_title(request_row: dict[str, Any]) -> str:
|
||||||
return _resolve_title_from_book_data(request_row.get("book_data"))
|
return _resolve_title_from_book_data(request_row.get("book_data"))
|
||||||
|
|
||||||
@@ -317,6 +337,10 @@ def _prepare_request_create_arguments(
|
|||||||
content_type = normalize_content_type(
|
content_type = normalize_content_type(
|
||||||
context.get("content_type") or data.get("content_type") or book_data.get("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(
|
request_level, release_data = _normalize_release_result_request_payload(
|
||||||
source=source,
|
source=source,
|
||||||
request_level=request_level,
|
request_level=request_level,
|
||||||
@@ -324,6 +348,10 @@ def _prepare_request_create_arguments(
|
|||||||
release_data=release_data,
|
release_data=release_data,
|
||||||
content_type=content_type,
|
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(
|
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
|
||||||
user_db,
|
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))
|
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."""
|
"""Yield value-bearing fields for a tab."""
|
||||||
for settings_field in tab.fields:
|
for settings_field in tab.fields:
|
||||||
if isinstance(settings_field, CustomComponentField):
|
if isinstance(settings_field, CustomComponentField):
|
||||||
@@ -360,7 +360,7 @@ def get_settings_field_map(
|
|||||||
|
|
||||||
field_map: dict[str, tuple[FieldBase, str]] = {}
|
field_map: dict[str, tuple[FieldBase, str]] = {}
|
||||||
for tab in tabs:
|
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)
|
field_map[settings_field.key] = (settings_field, tab.name)
|
||||||
return field_map
|
return field_map
|
||||||
|
|
||||||
@@ -494,7 +494,7 @@ def initialize_default_configs() -> bool:
|
|||||||
|
|
||||||
# Collect default values for all fields
|
# Collect default values for all fields
|
||||||
defaults = {}
|
defaults = {}
|
||||||
for field in _iter_value_fields(tab):
|
for field in iter_value_fields(tab):
|
||||||
# Only include fields that have a non-None default
|
# Only include fields that have a non-None default
|
||||||
if field.default is not None:
|
if field.default is not None:
|
||||||
defaults[field.key] = field.default
|
defaults[field.key] = field.default
|
||||||
@@ -536,7 +536,7 @@ def sync_env_to_config() -> None:
|
|||||||
for tab in get_all_settings_tabs():
|
for tab in get_all_settings_tabs():
|
||||||
values_to_sync = {}
|
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
|
# Skip fields that don't support ENV vars
|
||||||
if not getattr(settings_field, "env_supported", True):
|
if not getattr(settings_field, "env_supported", True):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ def normalize_http_url(
|
|||||||
if scheme:
|
if scheme:
|
||||||
normalized = f"{scheme}://{normalized}"
|
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:
|
if strip_trailing_slash:
|
||||||
normalized = normalized.rstrip("/")
|
normalized = normalized.rstrip("/")
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
"""Archive extraction utilities for downloaded book archives."""
|
"""Archive extraction utilities for downloaded book archives."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from shelfmark.core.logger import setup_logger
|
from shelfmark.core.logger import setup_logger
|
||||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||||
from shelfmark.download.fs import atomic_write
|
from shelfmark.download.fs import atomic_move
|
||||||
from shelfmark.download.postprocess.policy import (
|
from shelfmark.download.postprocess.policy import (
|
||||||
get_supported_audiobook_formats,
|
get_supported_audiobook_formats,
|
||||||
get_supported_formats,
|
get_supported_formats,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = setup_logger(__name__)
|
logger = setup_logger(__name__)
|
||||||
|
_ARCHIVE_COPY_CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import rarfile
|
import rarfile
|
||||||
@@ -208,9 +211,25 @@ def _extract_files_from_archive(archive: ArchiveType, output_dir: Path) -> list[
|
|||||||
logger.warning("Path traversal attempt blocked: %r", info.filename)
|
logger.warning("Path traversal attempt blocked: %r", info.filename)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
with archive.open(info) as src:
|
temp_path: Path | None = None
|
||||||
data = src.read()
|
try:
|
||||||
final_path = atomic_write(target_path, data)
|
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
|
||||||
extracted_files.append(final_path)
|
extracted_files.append(final_path)
|
||||||
logger.debug("Extracted: %s", filename)
|
logger.debug("Extracted: %s", filename)
|
||||||
|
|
||||||
|
|||||||
@@ -276,7 +276,18 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
|||||||
remote_path=source_path_obj,
|
remote_path=source_path_obj,
|
||||||
)
|
)
|
||||||
|
|
||||||
delete_path = remapped if matched_mapping else 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
|
||||||
|
|
||||||
if str(delete_path) in ("", "/"):
|
if str(delete_path) in ("", "/"):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -435,6 +446,19 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if matched_mapping:
|
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 None, message
|
||||||
|
|
||||||
remapped_exists, remapped_error = _probe_completed_path(remapped)
|
remapped_exists, remapped_error = _probe_completed_path(remapped)
|
||||||
|
|
||||||
if log_details:
|
if log_details:
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import NoReturn, TypedDict
|
from typing import NoReturn, TypedDict
|
||||||
|
|
||||||
@@ -46,6 +47,13 @@ _HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
|||||||
_ONE_WEEK_IN_SECONDS = 604800
|
_ONE_WEEK_IN_SECONDS = 604800
|
||||||
|
|
||||||
|
|
||||||
|
class _UnsafeQBittorrentPath:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_UNSAFE_QBITTORRENT_PATH = _UnsafeQBittorrentPath()
|
||||||
|
|
||||||
|
|
||||||
class _QBittorrentAddKwargs(TypedDict, total=False):
|
class _QBittorrentAddKwargs(TypedDict, total=False):
|
||||||
rename: str
|
rename: str
|
||||||
category: str
|
category: str
|
||||||
@@ -136,6 +144,24 @@ def _is_explicit_add_failure(raw_result: object) -> bool:
|
|||||||
return normalized in {"fail", "fails", "error", "errors"}
|
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")
|
@register_client("torrent")
|
||||||
class QBittorrentClient(DownloadClient):
|
class QBittorrentClient(DownloadClient):
|
||||||
"""qBittorrent download client."""
|
"""qBittorrent download client."""
|
||||||
@@ -629,16 +655,18 @@ class QBittorrentClient(DownloadClient):
|
|||||||
download_id = getattr(torrent, "hash", "")
|
download_id = getattr(torrent, "hash", "")
|
||||||
if isinstance(download_id, str) and download_id:
|
if isinstance(download_id, str) and download_id:
|
||||||
derived = self._derive_download_path_from_files(download_id)
|
derived = self._derive_download_path_from_files(download_id)
|
||||||
if derived:
|
if derived and not isinstance(derived, _UnsafeQBittorrentPath):
|
||||||
return derived
|
return derived
|
||||||
|
|
||||||
# Legacy fallback: save_path + name (for older clients/emulators)
|
# Legacy fallback: save_path + name (for older clients/emulators)
|
||||||
return self._build_path(
|
return _build_qbittorrent_child_path(
|
||||||
getattr(torrent, "save_path", ""),
|
getattr(torrent, "save_path", ""),
|
||||||
getattr(torrent, "name", ""),
|
getattr(torrent, "name", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _derive_download_path_from_files(self, download_id: str) -> str | None:
|
def _derive_download_path_from_files(
|
||||||
|
self, download_id: str
|
||||||
|
) -> str | _UnsafeQBittorrentPath | None:
|
||||||
"""Derive completed download path using `/torrents/properties` + `/torrents/files`.
|
"""Derive completed download path using `/torrents/properties` + `/torrents/files`.
|
||||||
|
|
||||||
This mirrors how common automation apps derive the path when
|
This mirrors how common automation apps derive the path when
|
||||||
@@ -685,9 +713,12 @@ class QBittorrentClient(DownloadClient):
|
|||||||
first_name_norm = first_name.replace("\\", "/")
|
first_name_norm = first_name.replace("\\", "/")
|
||||||
top_level = first_name_norm.split("/", 1)[0]
|
top_level = first_name_norm.split("/", 1)[0]
|
||||||
if not top_level:
|
if not top_level:
|
||||||
return None
|
return _UNSAFE_QBITTORRENT_PATH
|
||||||
|
|
||||||
return os.path.normpath(str(Path(save_path) / top_level))
|
derived = _build_qbittorrent_child_path(save_path, top_level)
|
||||||
|
if derived is None:
|
||||||
|
return _UNSAFE_QBITTORRENT_PATH
|
||||||
|
return os.path.normpath(derived)
|
||||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"qBittorrent could not derive path from files: %s: %s",
|
"qBittorrent could not derive path from files: %s: %s",
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ class RTorrentClient(DownloadClient):
|
|||||||
self._rpc = _create_rtorrent_server_proxy(self._base_url)
|
self._rpc = _create_rtorrent_server_proxy(self._base_url)
|
||||||
self._download_dir = config_text(config.get("RTORRENT_DOWNLOAD_DIR", ""))
|
self._download_dir = config_text(config.get("RTORRENT_DOWNLOAD_DIR", ""))
|
||||||
self._label = config_text(config.get("RTORRENT_LABEL", ""))
|
self._label = config_text(config.get("RTORRENT_LABEL", ""))
|
||||||
|
self._audiobook_label = config_text(config.get("RTORRENT_AUDIOBOOK_LABEL", ""))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_configured() -> bool:
|
def is_configured() -> bool:
|
||||||
@@ -161,7 +162,11 @@ class RTorrentClient(DownloadClient):
|
|||||||
|
|
||||||
commands = []
|
commands = []
|
||||||
|
|
||||||
label = category or self._label
|
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
|
||||||
if label:
|
if label:
|
||||||
logger.debug("Setting rTorrent label: %s", label)
|
logger.debug("Setting rTorrent label: %s", label)
|
||||||
commands.append(f"d.custom1.set={label}")
|
commands.append(f"d.custom1.set={label}")
|
||||||
|
|||||||
@@ -33,6 +33,24 @@ _SABNZBD_CLIENT_ERRORS = (
|
|||||||
_SabnzbdRequestParam = str | int | float | bool
|
_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:
|
def _parse_eta(eta_str: str) -> int | None:
|
||||||
"""Parse SABnzbd ETA string (format: 'H:MM:SS') to seconds."""
|
"""Parse SABnzbd ETA string (format: 'H:MM:SS') to seconds."""
|
||||||
if not eta_str or eta_str == "0:00:00":
|
if not eta_str or eta_str == "0:00:00":
|
||||||
@@ -220,6 +238,18 @@ class SABnzbdClient(DownloadClient):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.content
|
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:
|
def _get_prowlarr_headers(self, url: str) -> dict:
|
||||||
# TODO(shelfmark): Move this source-specific Prowlarr auth handling into a source hook.
|
# TODO(shelfmark): Move this source-specific Prowlarr auth handling into a source hook.
|
||||||
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
|
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
|
||||||
@@ -326,15 +356,20 @@ class SABnzbdClient(DownloadClient):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug("Adding NZB to SABnzbd: %s", name)
|
logger.debug("Adding NZB to SABnzbd: %s", name)
|
||||||
nzb_filename = self._build_nzb_filename(name, url)
|
if self._can_prefetch_nzb_url(url):
|
||||||
nzb_content = self._fetch_nzb_content(url)
|
nzb_filename = self._build_nzb_filename(name, url)
|
||||||
result = self._api_post_file(nzb_content, nzb_filename, name, resolved_category)
|
nzb_content = self._fetch_nzb_content(url)
|
||||||
nzo_id = self._extract_nzo_id(result)
|
result = self._api_post_file(nzb_content, nzb_filename, name, resolved_category)
|
||||||
logger.info("Added NZB to SABnzbd: %s", nzo_id)
|
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 = ""
|
||||||
except _SABNZBD_CLIENT_ERRORS as e:
|
except _SABNZBD_CLIENT_ERRORS as e:
|
||||||
logger.warning("SABnzbd addfile failed, falling back to addurl: %s", e)
|
logger.warning("SABnzbd addfile failed, falling back to addurl: %s", e)
|
||||||
else:
|
else:
|
||||||
return nzo_id
|
if nzo_id:
|
||||||
|
return nzo_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self._api_call(
|
result = self._api_call(
|
||||||
|
|||||||
@@ -748,11 +748,18 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
|||||||
TextField(
|
TextField(
|
||||||
key="RTORRENT_LABEL",
|
key="RTORRENT_LABEL",
|
||||||
label="Book Label",
|
label="Book Label",
|
||||||
description="Label to assign to book downloads in rTorrent",
|
description="Label to assign to ebook downloads in rTorrent",
|
||||||
placeholder="cwabd",
|
placeholder="cwabd",
|
||||||
default="cwabd",
|
default="cwabd",
|
||||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
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(
|
TextField(
|
||||||
key="RTORRENT_DOWNLOAD_DIR",
|
key="RTORRENT_DOWNLOAD_DIR",
|
||||||
label="Download Directory",
|
label="Download Directory",
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import hashlib
|
|||||||
import re
|
import re
|
||||||
from binascii import Error as BinasciiError
|
from binascii import Error as BinasciiError
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from urllib.parse import parse_qs, urljoin, urlparse
|
from urllib.parse import ParseResult, parse_qs, urljoin, urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from shelfmark.core.config import config
|
from shelfmark.core.config import config
|
||||||
from shelfmark.core.logger import setup_logger
|
from shelfmark.core.logger import setup_logger
|
||||||
|
from shelfmark.core.utils import normalize_http_url
|
||||||
from shelfmark.download.network import get_ssl_verify
|
from shelfmark.download.network import get_ssl_verify
|
||||||
|
|
||||||
logger = setup_logger(__name__)
|
logger = setup_logger(__name__)
|
||||||
@@ -32,6 +33,7 @@ _TORRENT_FETCH_ERRORS = (
|
|||||||
ValueError,
|
ValueError,
|
||||||
)
|
)
|
||||||
_TORRENT_PARSE_ERRORS = (IndexError, KeyError, TypeError, ValueError)
|
_TORRENT_PARSE_ERRORS = (IndexError, KeyError, TypeError, ValueError)
|
||||||
|
_TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS = ("PROWLARR_URL", "NEWZNAB_URL")
|
||||||
|
|
||||||
type BencodeValue = dict[str | bytes, BencodeValue] | list[BencodeValue] | int | bytes | str
|
type BencodeValue = dict[str | bytes, BencodeValue] | list[BencodeValue] | int | bytes | str
|
||||||
|
|
||||||
@@ -94,11 +96,21 @@ def extract_torrent_info(
|
|||||||
if not fetch_torrent:
|
if not fetch_torrent:
|
||||||
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||||
|
|
||||||
|
# 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). 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. The Prowlarr API
|
||||||
|
# key, however, is only ever sent to a trusted origin so it can never leak
|
||||||
|
# to an arbitrary indexer/tracker host.
|
||||||
|
trusted_origin = _is_trusted_torrent_fetch_url(url)
|
||||||
|
|
||||||
headers: dict[str, str] = {"Accept": "application/x-bittorrent"}
|
headers: dict[str, str] = {"Accept": "application/x-bittorrent"}
|
||||||
# TODO(shelfmark): Move this source-specific Prowlarr auth handling into a source hook.
|
if trusted_origin:
|
||||||
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
|
# TODO(shelfmark): Move this source-specific Prowlarr auth handling into a source hook.
|
||||||
if api_key:
|
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
|
||||||
headers["X-Api-Key"] = api_key
|
if api_key:
|
||||||
|
headers["X-Api-Key"] = api_key
|
||||||
|
|
||||||
def resolve_url(current: str, location: str) -> str:
|
def resolve_url(current: str, location: str) -> str:
|
||||||
if not location:
|
if not location:
|
||||||
@@ -133,6 +145,12 @@ def extract_torrent_info(
|
|||||||
is_magnet=True,
|
is_magnet=True,
|
||||||
magnet_url=redirect_url,
|
magnet_url=redirect_url,
|
||||||
)
|
)
|
||||||
|
if not _is_trusted_torrent_fetch_url(redirect_url):
|
||||||
|
logger.debug(
|
||||||
|
"Skipping torrent prefetch redirect to untrusted URL: %s...",
|
||||||
|
redirect_url[:80],
|
||||||
|
)
|
||||||
|
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||||
# Not a magnet redirect, follow it manually
|
# Not a magnet redirect, follow it manually
|
||||||
logger.debug("Following redirect to: %s...", redirect_url[:80])
|
logger.debug("Following redirect to: %s...", redirect_url[:80])
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
@@ -172,6 +190,36 @@ def extract_torrent_info(
|
|||||||
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
def parse_transmission_url(url: str) -> tuple[str, str, int, str]:
|
def parse_transmission_url(url: str) -> tuple[str, str, int, str]:
|
||||||
"""Parse Transmission URL into (protocol, host, port, path)."""
|
"""Parse Transmission URL into (protocol, host, port, path)."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
|
|||||||
@@ -316,8 +316,12 @@ class TransmissionClient(DownloadClient):
|
|||||||
|
|
||||||
state, message = status_map.get(status_value, ("downloading", "Downloading"))
|
state, message = status_map.get(status_value, ("downloading", "Downloading"))
|
||||||
progress = torrent.percent_done * 100
|
progress = torrent.percent_done * 100
|
||||||
# Only mark complete when seeding - seed pending means files still being moved
|
# Only mark complete when seeding or stopped (e.g. if seed limit/ratio is 0)
|
||||||
complete = progress >= _SEEDING_PROGRESS_PERCENT and status_value == "seeding"
|
# and progress is complete. seed pending means files still being moved
|
||||||
|
complete = progress >= _SEEDING_PROGRESS_PERCENT and status_value in (
|
||||||
|
"seeding",
|
||||||
|
"stopped",
|
||||||
|
)
|
||||||
|
|
||||||
if complete:
|
if complete:
|
||||||
message = "Complete"
|
message = "Complete"
|
||||||
|
|||||||
+16
-12
@@ -229,6 +229,10 @@ def _is_permission_error(e: Exception) -> bool:
|
|||||||
return isinstance(e, PermissionError) or (isinstance(e, OSError) and e.errno == errno.EPERM)
|
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:
|
def _system_op(op: str, source: Path, dest: Path) -> None:
|
||||||
"""Execute system command (mv or cp) as final fallback."""
|
"""Execute system command (mv or cp) as final fallback."""
|
||||||
logger.warning("Attempting system %s as final fallback: %s -> %s", op, source, dest)
|
logger.warning("Attempting system %s as final fallback: %s -> %s", op, source, dest)
|
||||||
@@ -463,9 +467,9 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
|||||||
try:
|
try:
|
||||||
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
|
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
|
||||||
except (PermissionError, OSError) as copy_error:
|
except (PermissionError, OSError) as copy_error:
|
||||||
if _is_permission_error(copy_error):
|
if _should_fallback_to_content_copy(copy_error):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Permission error during move-copy, falling back to copyfile (%s -> %s): %s",
|
"copy2 failed during move-copy, falling back to copyfile (%s -> %s): %s",
|
||||||
source_path,
|
source_path,
|
||||||
temp_path,
|
temp_path,
|
||||||
copy_error,
|
copy_error,
|
||||||
@@ -583,7 +587,7 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100)
|
|||||||
error=e,
|
error=e,
|
||||||
)
|
)
|
||||||
if permission_error or _hardlink_not_supported(e):
|
if permission_error or _hardlink_not_supported(e):
|
||||||
logger.debug(
|
logger.warning(
|
||||||
"Hardlink failed (%s), falling back to copy: %s -> %s",
|
"Hardlink failed (%s), falling back to copy: %s -> %s",
|
||||||
e,
|
e,
|
||||||
source_path,
|
source_path,
|
||||||
@@ -631,16 +635,16 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
|||||||
try:
|
try:
|
||||||
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
|
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
|
||||||
except (PermissionError, OSError) as e:
|
except (PermissionError, OSError) as e:
|
||||||
# Handle NFS permission errors immediately here
|
if _should_fallback_to_content_copy(e):
|
||||||
if _is_permission_error(e):
|
if _is_permission_error(e):
|
||||||
log_transfer_permission_context(
|
log_transfer_permission_context(
|
||||||
"atomic_copy",
|
"atomic_copy",
|
||||||
source=source_path,
|
source=source_path,
|
||||||
dest=temp_path,
|
dest=temp_path,
|
||||||
error=e,
|
error=e,
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Permission error during copy, falling back to copyfile (%s -> %s): %s",
|
"copy2 failed during copy, falling back to copyfile (%s -> %s): %s",
|
||||||
source_path,
|
source_path,
|
||||||
temp_path,
|
temp_path,
|
||||||
e,
|
e,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_clea
|
|||||||
from shelfmark.download.postprocess.router import post_process_download
|
from shelfmark.download.postprocess.router import post_process_download
|
||||||
from shelfmark.release_sources import (
|
from shelfmark.release_sources import (
|
||||||
get_handler,
|
get_handler,
|
||||||
|
get_source,
|
||||||
get_source_display_name,
|
get_source_display_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,6 +109,13 @@ def _parse_release_search_mode(value: object) -> SearchMode:
|
|||||||
raise ValueError(msg)
|
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:
|
def _optional_number(value: object) -> float | None:
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return float(value)
|
return float(value)
|
||||||
@@ -138,13 +146,6 @@ def _optional_positive_int(value: object) -> int | None:
|
|||||||
return parsed if parsed > 0 else 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:
|
def _config_float(value: object, default: float) -> float:
|
||||||
if isinstance(value, bool) or value is None:
|
if isinstance(value, bool) or value is None:
|
||||||
return default
|
return default
|
||||||
@@ -166,19 +167,34 @@ def _build_retry_resolution_fields(
|
|||||||
if not isinstance(extra, dict):
|
if not isinstance(extra, dict):
|
||||||
extra = {}
|
extra = {}
|
||||||
|
|
||||||
|
retry_download_url = normalize_optional_text(release_data.get("download_url"))
|
||||||
protocol = normalize_optional_text(release_data.get("protocol"))
|
protocol = normalize_optional_text(release_data.get("protocol"))
|
||||||
|
source = normalize_optional_text(release_data.get("source"))
|
||||||
|
if source is not None:
|
||||||
|
handler = get_handler(source)
|
||||||
|
source_retry_fields = handler.build_retry_resolution_fields(release_data)
|
||||||
|
retry_download_url = (
|
||||||
|
normalize_optional_text(source_retry_fields.get("retry_download_url"))
|
||||||
|
or retry_download_url
|
||||||
|
)
|
||||||
|
protocol = (
|
||||||
|
normalize_optional_text(source_retry_fields.get("retry_download_protocol")) or protocol
|
||||||
|
)
|
||||||
|
|
||||||
ratio_limit = _optional_number(release_data.get("ratio_limit"))
|
ratio_limit = _optional_number(release_data.get("ratio_limit"))
|
||||||
if ratio_limit is None:
|
if ratio_limit is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False):
|
||||||
ratio_limit = _optional_number(extra.get("minimum_ratio"))
|
ratio_limit = _optional_number(extra.get("configured_ratio_limit"))
|
||||||
|
|
||||||
seeding_time_limit_minutes = _optional_positive_int(
|
seeding_time_limit_minutes = _optional_positive_int(
|
||||||
release_data.get("seeding_time_limit_minutes")
|
release_data.get("seeding_time_limit_minutes")
|
||||||
)
|
)
|
||||||
if seeding_time_limit_minutes is None:
|
if seeding_time_limit_minutes is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False):
|
||||||
seeding_time_limit_minutes = _seed_time_seconds_to_minutes(extra.get("minimum_seed_time"))
|
seeding_time_limit_minutes = _optional_positive_int(
|
||||||
|
extra.get("configured_seed_time_minutes")
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"retry_download_url": normalize_optional_text(release_data.get("download_url")),
|
"retry_download_url": retry_download_url,
|
||||||
"retry_download_protocol": protocol.lower() if protocol is not None else None,
|
"retry_download_protocol": protocol.lower() if protocol is not None else None,
|
||||||
"retry_release_name": normalize_optional_text(release_data.get("title")),
|
"retry_release_name": normalize_optional_text(release_data.get("title")),
|
||||||
"retry_expected_hash": normalize_optional_text(
|
"retry_expected_hash": normalize_optional_text(
|
||||||
@@ -199,6 +215,11 @@ def queue_release(
|
|||||||
"""Add a release to the download queue. Returns (success, error_message)."""
|
"""Add a release to the download queue. Returns (success, error_message)."""
|
||||||
try:
|
try:
|
||||||
source = release_data["source"]
|
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", {})
|
extra = release_data.get("extra", {})
|
||||||
raw_request_id = release_data.get("_request_id")
|
raw_request_id = release_data.get("_request_id")
|
||||||
request_id: int | None = None
|
request_id: int | None = None
|
||||||
@@ -590,6 +611,16 @@ def _download_task(task_id: str, cancel_flag: Event) -> str | None:
|
|||||||
logger.error("Task not found in queue: %s", task_id)
|
logger.error("Task not found in queue: %s", task_id)
|
||||||
return None
|
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"
|
title_label = task.title or "Unknown title"
|
||||||
logger.info(
|
logger.info(
|
||||||
"Task %s: starting download (%s) - %s",
|
"Task %s: starting download (%s) - %s",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -40,9 +41,11 @@ def validate_destination(
|
|||||||
status_callback("error", f"Destination is not a directory: {destination}")
|
status_callback("error", f"Destination is not a directory: {destination}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
created_by_us = False
|
||||||
if not destination_exists:
|
if not destination_exists:
|
||||||
try:
|
try:
|
||||||
run_blocking_io(destination.mkdir, parents=True, exist_ok=True)
|
run_blocking_io(destination.mkdir, parents=True, exist_ok=True)
|
||||||
|
created_by_us = True
|
||||||
except (OSError, PermissionError) as exc:
|
except (OSError, PermissionError) as exc:
|
||||||
log_path_permission_context("destination_create", destination)
|
log_path_permission_context("destination_create", destination)
|
||||||
logger.warning("Cannot create destination: %s (%s)", destination, exc)
|
logger.warning("Cannot create destination: %s (%s)", destination, exc)
|
||||||
@@ -63,6 +66,9 @@ def validate_destination(
|
|||||||
log_path_permission_context("destination_write_probe", destination)
|
log_path_permission_context("destination_write_probe", destination)
|
||||||
logger.warning("Destination not writable: %s (%s)", destination, exc)
|
logger.warning("Destination not writable: %s (%s)", destination, exc)
|
||||||
status_callback("error", f"Destination not writable: {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 False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from shelfmark.core.naming import (
|
|||||||
build_library_path,
|
build_library_path,
|
||||||
derive_primary_title,
|
derive_primary_title,
|
||||||
parse_naming_template,
|
parse_naming_template,
|
||||||
same_filesystem,
|
|
||||||
sanitize_filename,
|
sanitize_filename,
|
||||||
)
|
)
|
||||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||||
@@ -39,10 +38,7 @@ _TRANSFER_PROCESS_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, Typ
|
|||||||
|
|
||||||
|
|
||||||
def should_hardlink(task: DownloadTask) -> bool:
|
def should_hardlink(task: DownloadTask) -> bool:
|
||||||
"""Check if hardlinking is enabled for this task (Prowlarr torrents only)."""
|
"""Check if hardlinking is enabled for this torrent-backed task."""
|
||||||
if task.source != "prowlarr":
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not task.original_download_path:
|
if not task.original_download_path:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -96,21 +92,21 @@ def resolve_hardlink_source(
|
|||||||
if hardlink_enabled and task.original_download_path:
|
if hardlink_enabled and task.original_download_path:
|
||||||
hardlink_source = Path(task.original_download_path)
|
hardlink_source = Path(task.original_download_path)
|
||||||
hardlink_source_exists = run_blocking_io(hardlink_source.exists)
|
hardlink_source_exists = run_blocking_io(hardlink_source.exists)
|
||||||
if (
|
if hardlink_source_exists:
|
||||||
destination
|
|
||||||
and hardlink_source_exists
|
|
||||||
and run_blocking_io(same_filesystem, hardlink_source, destination)
|
|
||||||
):
|
|
||||||
use_hardlink = True
|
use_hardlink = True
|
||||||
source_path = hardlink_source
|
source_path = hardlink_source
|
||||||
elif hardlink_source_exists:
|
logger.info(
|
||||||
logger.warning(
|
"Hardlink enabled for task %s; attempting link from %s to %s",
|
||||||
"Cannot hardlink: %s and %s are on different filesystems. Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination.",
|
task.task_id,
|
||||||
hardlink_source,
|
hardlink_source,
|
||||||
destination,
|
destination,
|
||||||
)
|
)
|
||||||
if status_callback:
|
else:
|
||||||
status_callback("resolving", "Cannot hardlink (different filesystems), using copy")
|
logger.warning(
|
||||||
|
"Hardlink enabled for task %s, but source path does not exist: %s",
|
||||||
|
task.task_id,
|
||||||
|
hardlink_source,
|
||||||
|
)
|
||||||
|
|
||||||
return TransferPlan(
|
return TransferPlan(
|
||||||
source_path=source_path,
|
source_path=source_path,
|
||||||
|
|||||||
+130
-65
@@ -26,6 +26,7 @@ from shelfmark.config.env import (
|
|||||||
BUILD_VERSION,
|
BUILD_VERSION,
|
||||||
CONFIG_DIR,
|
CONFIG_DIR,
|
||||||
CWA_DB_PATH,
|
CWA_DB_PATH,
|
||||||
|
DISABLE_LOCAL_AUTH,
|
||||||
FLASK_HOST,
|
FLASK_HOST,
|
||||||
FLASK_PORT,
|
FLASK_PORT,
|
||||||
HIDE_LOCAL_AUTH,
|
HIDE_LOCAL_AUTH,
|
||||||
@@ -1162,6 +1163,9 @@ def api_config() -> Response | tuple[Response, int]:
|
|||||||
"show_combined_selector": app_config.get(
|
"show_combined_selector": app_config.get(
|
||||||
"SHOW_COMBINED_SELECTOR", True, user_id=db_user_id
|
"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"),
|
"books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"),
|
||||||
"auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True),
|
"auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True),
|
||||||
"hardcover_auto_remove_on_download": app_config.get(
|
"hardcover_auto_remove_on_download": app_config.get(
|
||||||
@@ -1477,6 +1481,43 @@ def _download_row_owned_by_actor(
|
|||||||
return False
|
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_queue_hook(_record_download_queued)
|
||||||
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
|
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
|
||||||
|
|
||||||
@@ -1586,6 +1627,7 @@ def api_local_download() -> Response | tuple[Response, int]:
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/covers/<cover_id>", methods=["GET"])
|
@app.route("/api/covers/<cover_id>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
def api_cover(cover_id: str) -> Response | tuple[Response, int]:
|
def api_cover(cover_id: str) -> Response | tuple[Response, int]:
|
||||||
"""Serve a cached book cover image.
|
"""Serve a cached book cover image.
|
||||||
|
|
||||||
@@ -1597,9 +1639,6 @@ def api_cover(cover_id: str) -> Response | tuple[Response, int]:
|
|||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
url (str): Base64-encoded original image URL (required on first request)
|
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:
|
Returns:
|
||||||
flask.Response: Binary image data with appropriate Content-Type, or 404.
|
flask.Response: Binary image data with appropriate Content-Type, or 404.
|
||||||
@@ -1609,84 +1648,43 @@ def api_cover(cover_id: str) -> Response | tuple[Response, int]:
|
|||||||
import base64
|
import base64
|
||||||
|
|
||||||
from shelfmark.config.env import is_covers_cache_enabled
|
from shelfmark.config.env import is_covers_cache_enabled
|
||||||
from shelfmark.core.image_cache import (
|
from shelfmark.core.image_cache import get_image_cache
|
||||||
build_variant_cache_id,
|
|
||||||
create_image_variant,
|
|
||||||
get_image_cache,
|
|
||||||
normalize_variant_dimension,
|
|
||||||
normalize_variant_format,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if caching is enabled
|
# Check if caching is enabled
|
||||||
if not is_covers_cache_enabled():
|
if not is_covers_cache_enabled():
|
||||||
return jsonify({"error": "Cover caching is disabled"}), 404
|
return jsonify({"error": "Cover caching is disabled"}), 404
|
||||||
|
|
||||||
cache = get_image_cache()
|
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
|
|
||||||
)
|
|
||||||
|
|
||||||
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"] = cache_status
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Try to get from cache first
|
# Try to get from cache first
|
||||||
cache_lookup_id = variant_cache_id or cover_id
|
cached = cache.get(cover_id)
|
||||||
cached = cache.get(cache_lookup_id)
|
|
||||||
if cached:
|
if cached:
|
||||||
image_data, content_type = cached
|
image_data, content_type = cached
|
||||||
return make_cover_response(image_data, content_type, cache_status="HIT")
|
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"
|
||||||
|
return response
|
||||||
|
|
||||||
# Cache miss - get URL from query parameter
|
# Cache miss - get URL from query parameter
|
||||||
encoded_url = request.args.get("url")
|
encoded_url = request.args.get("url")
|
||||||
original: tuple[bytes, str] | None = cache.get(cover_id) if variant_cache_id else None
|
if not encoded_url:
|
||||||
|
return jsonify({"error": "Cover URL not provided"}), 404
|
||||||
|
|
||||||
if original is None:
|
try:
|
||||||
if not encoded_url:
|
original_url = base64.urlsafe_b64decode(encoded_url).decode()
|
||||||
return jsonify({"error": "Cover URL not provided"}), 404
|
except (binascii.Error, UnicodeDecodeError) as e:
|
||||||
|
logger.warning("Failed to decode cover URL: %s", e)
|
||||||
|
return jsonify({"error": "Invalid cover URL encoding"}), 400
|
||||||
|
|
||||||
try:
|
# Fetch and cache the image
|
||||||
original_url = base64.urlsafe_b64decode(encoded_url).decode()
|
result = cache.fetch_and_cache(cover_id, original_url)
|
||||||
except (binascii.Error, UnicodeDecodeError) as e:
|
if not result:
|
||||||
logger.warning("Failed to decode cover URL: %s", e)
|
return jsonify({"error": "Failed to fetch cover image"}), 404
|
||||||
return jsonify({"error": "Invalid cover URL encoding"}), 400
|
|
||||||
|
|
||||||
# Fetch and cache the original image
|
image_data, content_type = result
|
||||||
original = cache.fetch_and_cache(cover_id, original_url)
|
response = app.response_class(response=image_data, status=200, mimetype=content_type)
|
||||||
if not original:
|
response.headers["Cache-Control"] = "public, max-age=86400"
|
||||||
return jsonify({"error": "Failed to fetch cover image"}), 404
|
response.headers["X-Cache"] = "MISS"
|
||||||
|
|
||||||
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:
|
except _IMPORT_OPERATIONAL_ERRORS as e:
|
||||||
logger.error_trace(f"Cover fetch error: {e}")
|
logger.error_trace(f"Cover fetch error: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
@@ -1841,6 +1839,22 @@ def api_set_priority(book_id: str) -> Response | tuple[Response, int]:
|
|||||||
return jsonify({"error": "Priority not provided"}), 400
|
return jsonify({"error": "Priority not provided"}), 400
|
||||||
|
|
||||||
priority = int(data["priority"])
|
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)
|
success = backend.set_book_priority(book_id, priority)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -1879,6 +1893,23 @@ def api_reorder_queue() -> Response | tuple[Response, int]:
|
|||||||
if not isinstance(priority, int):
|
if not isinstance(priority, int):
|
||||||
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
|
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)
|
success = backend.reorder_queue(book_priorities)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -1900,6 +1931,20 @@ def api_queue_order() -> Response | tuple[Response, int]:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
queue_order = backend.get_queue_order()
|
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})
|
return jsonify({"queue": queue_order})
|
||||||
except _OPERATIONAL_ERRORS as e:
|
except _OPERATIONAL_ERRORS as e:
|
||||||
logger.error_trace(f"Queue order error: {e}")
|
logger.error_trace(f"Queue order error: {e}")
|
||||||
@@ -1917,6 +1962,20 @@ def api_active_downloads() -> Response | tuple[Response, int]:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
active_downloads = backend.get_active_downloads()
|
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})
|
return jsonify({"active_downloads": active_downloads})
|
||||||
except _OPERATIONAL_ERRORS as e:
|
except _OPERATIONAL_ERRORS as e:
|
||||||
logger.error_trace(f"Active downloads error: {e}")
|
logger.error_trace(f"Active downloads error: {e}")
|
||||||
@@ -1999,6 +2058,9 @@ def api_login() -> Response | tuple[Response, int]:
|
|||||||
if auth_mode == "proxy":
|
if auth_mode == "proxy":
|
||||||
return jsonify({"error": "Proxy authentication is enabled"}), 401
|
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:
|
if auth_mode == "oidc" and HIDE_LOCAL_AUTH:
|
||||||
return jsonify({"error": "Local authentication is disabled"}), 403
|
return jsonify({"error": "Local authentication is disabled"}), 403
|
||||||
|
|
||||||
@@ -2228,6 +2290,9 @@ def api_auth_check() -> Response | tuple[Response, int]:
|
|||||||
if logout_url:
|
if logout_url:
|
||||||
response_data["logout_url"] = 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
|
# Add custom OIDC button label and SSO enforcement flags if configured
|
||||||
if auth_mode == "oidc":
|
if auth_mode == "oidc":
|
||||||
oidc_button_label = app_config.get("OIDC_BUTTON_LABEL", "")
|
oidc_button_label = app_config.get("OIDC_BUTTON_LABEL", "")
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ _HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
|||||||
|
|
||||||
GOOGLE_BOOKS_BASE_URL = "https://www.googleapis.com/books/v1"
|
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 - Google only supports "relevance" and "newest"
|
||||||
SORT_MAPPING: dict[SortOrder, str | None] = {
|
SORT_MAPPING: dict[SortOrder, str | None] = {
|
||||||
SortOrder.RELEVANCE: None, # Default, no param needed
|
SortOrder.RELEVANCE: None, # Default, no param needed
|
||||||
@@ -117,7 +122,10 @@ class GoogleBooksProvider(MetadataProvider):
|
|||||||
f"{options.query}:{options.search_type.value}:{options.sort.value}:"
|
f"{options.query}:{options.search_type.value}:{options.sort.value}:"
|
||||||
f"{options.language}:{options.limit}:{options.page}:{fields_key}"
|
f"{options.language}:{options.limit}:{options.page}:{fields_key}"
|
||||||
)
|
)
|
||||||
return self._search_cached(cache_key, options)
|
try:
|
||||||
|
return self._search_cached(cache_key, options)
|
||||||
|
except _GoogleBooksRequestError:
|
||||||
|
return []
|
||||||
|
|
||||||
@cacheable(
|
@cacheable(
|
||||||
ttl_key="METADATA_CACHE_SEARCH_TTL",
|
ttl_key="METADATA_CACHE_SEARCH_TTL",
|
||||||
@@ -166,18 +174,19 @@ class GoogleBooksProvider(MetadataProvider):
|
|||||||
if options.language:
|
if options.language:
|
||||||
params["langRestrict"] = options.language
|
params["langRestrict"] = options.language
|
||||||
|
|
||||||
|
result = self._make_request("/volumes", params)
|
||||||
|
if result is None:
|
||||||
|
raise _GoogleBooksRequestError
|
||||||
|
|
||||||
books: list[BookMetadata] = []
|
books: list[BookMetadata] = []
|
||||||
try:
|
try:
|
||||||
result = self._make_request("/volumes", params)
|
items = result.get("items", [])
|
||||||
if result:
|
for item in items:
|
||||||
items = result.get("items", [])
|
book = self._parse_volume(item)
|
||||||
|
if book:
|
||||||
|
books.append(book)
|
||||||
|
|
||||||
for item in items:
|
logger.info("Google Books search '%s' returned %s results", query, len(books))
|
||||||
book = self._parse_volume(item)
|
|
||||||
if book:
|
|
||||||
books.append(book)
|
|
||||||
|
|
||||||
logger.info("Google Books search '%s' returned %s results", query, len(books))
|
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Google Books search error")
|
logger.exception("Google Books search error")
|
||||||
|
|||||||
@@ -395,6 +395,76 @@ query GetSeriesBooks($seriesId: Int!) {
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
HARDCOVER_STATUS_PREFIX = "status:"
|
HARDCOVER_STATUS_PREFIX = "status:"
|
||||||
HARDCOVER_STATUSES: list[dict] = [
|
HARDCOVER_STATUSES: list[dict] = [
|
||||||
{"id": 1, "label": "Want to Read", "slug": "want-to-read", "query_key": "want_to_read_count"},
|
{"id": 1, "label": "Want to Read", "slug": "want-to-read", "query_key": "want_to_read_count"},
|
||||||
@@ -869,6 +939,7 @@ class HardcoverProvider(MetadataProvider):
|
|||||||
label="Author",
|
label="Author",
|
||||||
placeholder="Search author...",
|
placeholder="Search author...",
|
||||||
description="Search by author name",
|
description="Search by author name",
|
||||||
|
suggestions_endpoint="/api/metadata/field-options?provider=hardcover&field=author",
|
||||||
),
|
),
|
||||||
TextSearchField(
|
TextSearchField(
|
||||||
key="title",
|
key="title",
|
||||||
@@ -917,7 +988,7 @@ class HardcoverProvider(MetadataProvider):
|
|||||||
Returns (query, fields, weights) tuple. Fields/weights are None for general search.
|
Returns (query, fields, weights) tuple. Fields/weights are None for general search.
|
||||||
"""
|
"""
|
||||||
if author and not title and not series:
|
if author and not title and not series:
|
||||||
return author, "author_names", "1"
|
return author, None, None
|
||||||
if title and not author and not series:
|
if title and not author and not series:
|
||||||
return title, "title,alternative_titles", "5,1"
|
return title, "title,alternative_titles", "5,1"
|
||||||
if author and title and not series:
|
if author and title and not series:
|
||||||
@@ -1210,13 +1281,14 @@ class HardcoverProvider(MetadataProvider):
|
|||||||
if item is None:
|
if item is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
author_id = coerce_int(item.get("id"), 0)
|
||||||
label = str(item.get("name") or "").strip()
|
label = str(item.get("name") or "").strip()
|
||||||
normalized_label = label.casefold()
|
normalized_label = label.casefold()
|
||||||
if not label or normalized_label in seen_labels:
|
if author_id < 1 or not label or normalized_label in seen_labels:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
seen_labels.add(normalized_label)
|
seen_labels.add(normalized_label)
|
||||||
options.append({"value": label, "label": label})
|
options.append({"value": f"id:{author_id}", "label": label})
|
||||||
|
|
||||||
return options
|
return options
|
||||||
|
|
||||||
@@ -1527,6 +1599,72 @@ class HardcoverProvider(MetadataProvider):
|
|||||||
has_more = offset + len(page_rows) < total_found
|
has_more = offset + len(page_rows) < total_found
|
||||||
return SearchResult(books=books, page=page, total_found=total_found, has_more=has_more)
|
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)
|
||||||
|
|
||||||
@cacheable(ttl=120, key_prefix="hardcover:user_lists")
|
@cacheable(ttl=120, key_prefix="hardcover:user_lists")
|
||||||
def _get_user_lists_cached(self, _cache_user_id: str) -> list[dict[str, str]]:
|
def _get_user_lists_cached(self, _cache_user_id: str) -> list[dict[str, str]]:
|
||||||
"""Return cached user lists keyed by Hardcover user id."""
|
"""Return cached user lists keyed by Hardcover user id."""
|
||||||
@@ -2160,6 +2298,29 @@ class HardcoverProvider(MetadataProvider):
|
|||||||
exclude_unreleased=exclude_unreleased,
|
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
|
# Handle ISBN search separately
|
||||||
if options.search_type == SearchType.ISBN:
|
if options.search_type == SearchType.ISBN:
|
||||||
result = self.search_by_isbn(options.query)
|
result = self.search_by_isbn(options.query)
|
||||||
@@ -2467,7 +2628,7 @@ class HardcoverProvider(MetadataProvider):
|
|||||||
raise RuntimeError(msg) from e
|
raise RuntimeError(msg) from e
|
||||||
return None
|
return None
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
if e.response.status_code == HTTPStatus.UNAUTHORIZED:
|
if e.response is not None and e.response.status_code == HTTPStatus.UNAUTHORIZED:
|
||||||
logger.exception("Hardcover API key is invalid")
|
logger.exception("Hardcover API key is invalid")
|
||||||
if raise_on_error:
|
if raise_on_error:
|
||||||
msg = "Hardcover API key is invalid"
|
msg = "Hardcover API key is invalid"
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ class OpenLibraryProvider(MetadataProvider):
|
|||||||
logger.warning("Open Library search timed out")
|
logger.warning("Open Library search timed out")
|
||||||
return []
|
return []
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
if e.response.status_code == HTTPStatus.SERVICE_UNAVAILABLE:
|
if e.response is not None and e.response.status_code == HTTPStatus.SERVICE_UNAVAILABLE:
|
||||||
logger.warning("Open Library service unavailable (503)")
|
logger.warning("Open Library service unavailable (503)")
|
||||||
else:
|
else:
|
||||||
logger.exception("Open Library HTTP error")
|
logger.exception("Open Library HTTP error")
|
||||||
@@ -253,7 +253,7 @@ class OpenLibraryProvider(MetadataProvider):
|
|||||||
logger.warning("Open Library get_book timed out")
|
logger.warning("Open Library get_book timed out")
|
||||||
return None
|
return None
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
if e.response.status_code == HTTPStatus.NOT_FOUND:
|
if e.response is not None and e.response.status_code == HTTPStatus.NOT_FOUND:
|
||||||
logger.debug("Open Library work not found: %s", book_id)
|
logger.debug("Open Library work not found: %s", book_id)
|
||||||
else:
|
else:
|
||||||
logger.exception("Open Library HTTP error")
|
logger.exception("Open Library HTTP error")
|
||||||
@@ -314,7 +314,7 @@ class OpenLibraryProvider(MetadataProvider):
|
|||||||
return self._parse_edition(edition, clean_isbn)
|
return self._parse_edition(edition, clean_isbn)
|
||||||
|
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
if e.response.status_code == HTTPStatus.NOT_FOUND:
|
if e.response is not None and e.response.status_code == HTTPStatus.NOT_FOUND:
|
||||||
logger.debug("Open Library ISBN not found: %s", isbn)
|
logger.debug("Open Library ISBN not found: %s", isbn)
|
||||||
else:
|
else:
|
||||||
logger.exception("Open Library ISBN search HTTP error")
|
logger.exception("Open Library ISBN search HTTP error")
|
||||||
|
|||||||
@@ -390,6 +390,10 @@ class DownloadHandler(ABC):
|
|||||||
"""
|
"""
|
||||||
return
|
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
|
@abstractmethod
|
||||||
def cancel(self, task_id: str) -> bool:
|
def cancel(self, task_id: str) -> bool:
|
||||||
"""Cancel an in-progress download."""
|
"""Cancel an in-progress download."""
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ if TYPE_CHECKING:
|
|||||||
from shelfmark.core.models import DownloadTask
|
from shelfmark.core.models import DownloadTask
|
||||||
|
|
||||||
logger = setup_logger(__name__)
|
logger = setup_logger(__name__)
|
||||||
|
DEFAULT_ABB_HOSTNAME = "audiobookbay.lu"
|
||||||
|
ALLOWED_DETAIL_URL_SCHEMES = {"https"}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_configured_hostname() -> str:
|
def _resolve_configured_hostname() -> str:
|
||||||
@@ -32,6 +34,23 @@ def _resolve_configured_hostname() -> str:
|
|||||||
return normalize_hostname(configured_hostname if isinstance(configured_hostname, str) else "")
|
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")
|
@register_handler("audiobookbay")
|
||||||
class AudiobookBayHandler(ExternalClientHandler):
|
class AudiobookBayHandler(ExternalClientHandler):
|
||||||
"""Handler for AudiobookBay downloads via configured torrent client."""
|
"""Handler for AudiobookBay downloads via configured torrent client."""
|
||||||
@@ -69,9 +88,14 @@ class AudiobookBayHandler(ExternalClientHandler):
|
|||||||
logger.warning("Missing details URL for AudiobookBay task: %s", task.task_id)
|
logger.warning("Missing details URL for AudiobookBay task: %s", task.task_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
hostname = _resolve_configured_hostname()
|
hostname = _resolve_allowed_detail_hostname()
|
||||||
if not hostname:
|
if not _detail_url_matches_host(detail_url, hostname):
|
||||||
hostname = normalize_hostname(urlparse(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
|
||||||
|
|
||||||
status_callback("resolving", "Extracting magnet link")
|
status_callback("resolving", "Extracting magnet link")
|
||||||
magnet_link = scraper.extract_magnet_link(detail_url, hostname)
|
magnet_link = scraper.extract_magnet_link(detail_url, hostname)
|
||||||
|
|||||||
@@ -417,6 +417,18 @@ def extract_magnet_link(details_url: str, hostname: str = "audiobookbay.lu") ->
|
|||||||
# Clean up info hash (remove whitespace, ensure uppercase)
|
# Clean up info hash (remove whitespace, ensure uppercase)
|
||||||
info_hash = re.sub(r"\s+", "", info_hash).upper()
|
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
|
# 2. Extract Trackers
|
||||||
# Find all <td> containing udp:// or http://
|
# Find all <td> containing udp:// or http://
|
||||||
trackers = []
|
trackers = []
|
||||||
|
|||||||
@@ -238,8 +238,8 @@ class AudiobookBaySource(ReleaseSource):
|
|||||||
exact_phrase=exact_phrase,
|
exact_phrase=exact_phrase,
|
||||||
)
|
)
|
||||||
|
|
||||||
# For auto-generated queries, fallback to broad matching if exact phrase returns nothing.
|
# Fallback to broad matching if exact phrase returns nothing (manual or auto query).
|
||||||
if exact_phrase and not results and not plan.manual_query:
|
if exact_phrase and not results:
|
||||||
logger.info(
|
logger.info(
|
||||||
"No exact phrase results, retrying AudiobookBay search without quotes"
|
"No exact phrase results, retrying AudiobookBay search without quotes"
|
||||||
)
|
)
|
||||||
@@ -288,7 +288,7 @@ class AudiobookBaySource(ReleaseSource):
|
|||||||
size_str = result.get("size")
|
size_str = result.get("size")
|
||||||
size_bytes = parse_size(size_str) if size_str else None
|
size_bytes = parse_size(size_str) if size_str else None
|
||||||
language_raw = result.get("language")
|
language_raw = result.get("language")
|
||||||
language_code = _map_language(language_raw) if language_raw else None
|
language_code = _map_language(language_raw) if language_raw else "en"
|
||||||
bitrate = result.get("bitrate")
|
bitrate = result.get("bitrate")
|
||||||
bitrate_kbps = _parse_bitrate_to_kbps(bitrate)
|
bitrate_kbps = _parse_bitrate_to_kbps(bitrate)
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
import itertools
|
import itertools
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import unicodedata
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, ClassVar, NoReturn, TypedDict
|
from typing import TYPE_CHECKING, ClassVar, NoReturn, TypedDict
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
@@ -197,6 +200,49 @@ _SOURCE_FAILURE_THRESHOLD = 4
|
|||||||
_MIN_VALID_FILE_SIZE = 10 * 1024
|
_MIN_VALID_FILE_SIZE = 10 * 1024
|
||||||
_AA_COUNTDOWN_MAX_SECONDS = 300
|
_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
|
# Sources that require Cloudflare bypass
|
||||||
_CF_BYPASS_REQUIRED = frozenset({"aa-slow-nowait", "aa-slow-wait", "zlib", "welib"})
|
_CF_BYPASS_REQUIRED = frozenset({"aa-slow-nowait", "aa-slow-wait", "zlib", "welib"})
|
||||||
|
|
||||||
@@ -204,6 +250,189 @@ _CF_BYPASS_REQUIRED = frozenset({"aa-slow-nowait", "aa-slow-wait", "zlib", "weli
|
|||||||
_AA_PAGE_SOURCES = frozenset({"aa-slow-nowait", "aa-slow-wait"})
|
_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:
|
def _is_configured_zlib_link(url: str) -> bool:
|
||||||
"""Return True when a URL belongs to a configured Z-Library mirror."""
|
"""Return True when a URL belongs to a configured Z-Library mirror."""
|
||||||
from shelfmark.core.mirrors import get_zlib_cookie_domains
|
from shelfmark.core.mirrors import get_zlib_cookie_domains
|
||||||
@@ -360,9 +589,17 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
|
|||||||
|
|
||||||
filters_query = ""
|
filters_query = ""
|
||||||
|
|
||||||
for value in filters.lang or []:
|
path_language_enabled = _is_language_from_path_enabled()
|
||||||
if value and value != "all":
|
requested_langs = _normalize_requested_languages(filters.lang)
|
||||||
filters_query += f"&lang={quote(value)}"
|
|
||||||
|
# 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)}"
|
||||||
|
|
||||||
if filters.sort and filters.sort != "relevance":
|
if filters.sort and filters.sort != "relevance":
|
||||||
filters_query += f"&sort={quote(filters.sort)}"
|
filters_query += f"&sort={quote(filters.sort)}"
|
||||||
@@ -397,14 +634,13 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
|
|||||||
msg = "Unable to reach download source. Network restricted or mirrors are blocked."
|
msg = "Unable to reach download source. Network restricted or mirrors are blocked."
|
||||||
raise SearchUnavailableError(msg)
|
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")
|
soup = BeautifulSoup(_html_response_text(html), "html.parser")
|
||||||
tbody = soup.find("table")
|
tbody = soup.find("table")
|
||||||
|
|
||||||
if tbody is None:
|
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)
|
logger.warning("No results table found for query: %s", query)
|
||||||
msg = "No books found. Please try another query."
|
msg = "No books found. Please try another query."
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
@@ -418,6 +654,9 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
|
|||||||
if book:
|
if book:
|
||||||
books.append(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()
|
supported_formats = _get_supported_formats()
|
||||||
|
|
||||||
books.sort(
|
books.sort(
|
||||||
@@ -471,10 +710,23 @@ def _parse_search_result_row(row: Tag) -> BrowseRecord | None:
|
|||||||
if not record_id:
|
if not record_id:
|
||||||
return None
|
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_img = cells[0].find("img")
|
||||||
preview = _get_attr(preview_img, "src") if isinstance(preview_img, Tag) else None
|
preview = _get_attr(preview_img, "src") if isinstance(preview_img, Tag) else None
|
||||||
|
|
||||||
title = _first_stripped_text(cells[1].find("span"))
|
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
|
||||||
author = _first_stripped_text(cells[2].find("span"))
|
author = _first_stripped_text(cells[2].find("span"))
|
||||||
publisher = _first_stripped_text(cells[3].find("span"))
|
publisher = _first_stripped_text(cells[3].find("span"))
|
||||||
year = _first_stripped_text(cells[4].find("span"))
|
year = _first_stripped_text(cells[4].find("span"))
|
||||||
@@ -483,18 +735,19 @@ def _parse_search_result_row(row: Tag) -> BrowseRecord | None:
|
|||||||
file_format = _first_stripped_text(cells[9].find("span"))
|
file_format = _first_stripped_text(cells[9].find("span"))
|
||||||
size = _first_stripped_text(cells[10].find("span"))
|
size = _first_stripped_text(cells[10].find("span"))
|
||||||
|
|
||||||
if (
|
# Only title and format are truly required — lgli rows often have sparse metadata
|
||||||
title is None
|
if title is None or file_format 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
|
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(
|
return BrowseRecord(
|
||||||
id=record_id,
|
id=record_id,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -507,6 +760,7 @@ def _parse_search_result_row(row: Tag) -> BrowseRecord | None:
|
|||||||
content=content.lower() if content else None,
|
content=content.lower() if content else None,
|
||||||
format=file_format.lower() if file_format else None,
|
format=file_format.lower() if file_format else None,
|
||||||
size=size,
|
size=size,
|
||||||
|
download_path=distant_path,
|
||||||
)
|
)
|
||||||
except (AttributeError, IndexError, KeyError, TypeError) as e:
|
except (AttributeError, IndexError, KeyError, TypeError) as e:
|
||||||
logger.error_trace(f"Error parsing search result row: {e}")
|
logger.error_trace(f"Error parsing search result row: {e}")
|
||||||
@@ -1229,6 +1483,9 @@ def _get_download_url(
|
|||||||
return downloader.get_absolute_url(link, url)
|
return downloader.get_absolute_url(link, url)
|
||||||
|
|
||||||
|
|
||||||
|
_AA_COUNTDOWN_MAX_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
def _extract_slow_download_url(
|
def _extract_slow_download_url(
|
||||||
soup: BeautifulSoup,
|
soup: BeautifulSoup,
|
||||||
link: str,
|
link: str,
|
||||||
@@ -1237,6 +1494,7 @@ def _extract_slow_download_url(
|
|||||||
status_callback: Callable[[str, str | None], None] | None,
|
status_callback: Callable[[str, str | None], None] | None,
|
||||||
selector: network.AAMirrorSelector,
|
selector: network.AAMirrorSelector,
|
||||||
source_context: str | None = None,
|
source_context: str | None = None,
|
||||||
|
_countdown_attempts: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Extract download URL from AA slow download pages."""
|
"""Extract download URL from AA slow download pages."""
|
||||||
html_str = str(soup)
|
html_str = str(soup)
|
||||||
@@ -1301,6 +1559,14 @@ def _extract_slow_download_url(
|
|||||||
|
|
||||||
countdown_seconds = _extract_countdown_seconds(soup, html_str)
|
countdown_seconds = _extract_countdown_seconds(soup, html_str)
|
||||||
if countdown_seconds > 0:
|
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
|
max_countdown_seconds = 600
|
||||||
sleep_time = min(countdown_seconds, max_countdown_seconds)
|
sleep_time = min(countdown_seconds, max_countdown_seconds)
|
||||||
if countdown_seconds > max_countdown_seconds:
|
if countdown_seconds > max_countdown_seconds:
|
||||||
@@ -1309,7 +1575,13 @@ def _extract_slow_download_url(
|
|||||||
countdown_seconds,
|
countdown_seconds,
|
||||||
max_countdown_seconds,
|
max_countdown_seconds,
|
||||||
)
|
)
|
||||||
logger.info("AA waitlist: %ss for %s", sleep_time, title)
|
logger.info(
|
||||||
|
"AA waitlist: %ss for %s (attempt %s/%s)",
|
||||||
|
sleep_time,
|
||||||
|
title,
|
||||||
|
_countdown_attempts + 1,
|
||||||
|
_AA_COUNTDOWN_MAX_RETRIES,
|
||||||
|
)
|
||||||
|
|
||||||
# Live countdown with status updates
|
# Live countdown with status updates
|
||||||
for remaining in range(sleep_time, 0, -1):
|
for remaining in range(sleep_time, 0, -1):
|
||||||
@@ -1330,8 +1602,21 @@ def _extract_slow_download_url(
|
|||||||
if status_callback and source_context:
|
if status_callback and source_context:
|
||||||
status_callback("resolving", f"{source_context} - Fetching")
|
status_callback("resolving", f"{source_context} - Fetching")
|
||||||
|
|
||||||
return _get_download_url(
|
html = downloader.html_get_page(
|
||||||
link, title, cancel_flag, status_callback, selector, source_context
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
link_texts = [a.get_text(strip=True)[:50] for a in soup.find_all("a", href=True)[:10]]
|
link_texts = [a.get_text(strip=True)[:50] for a in soup.find_all("a", href=True)[:10]]
|
||||||
@@ -1646,7 +1931,6 @@ class DirectDownloadSource(ReleaseSource):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Search error")
|
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]
|
return [_browse_record_to_release(record) for record in all_results]
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
def is_available(self) -> bool:
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from typing import Any
|
|||||||
|
|
||||||
from shelfmark.config import env
|
from shelfmark.config import env
|
||||||
from shelfmark.core.logger import setup_logger
|
from shelfmark.core.logger import setup_logger
|
||||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
|
||||||
from shelfmark.release_sources import Release, ReleaseProtocol
|
from shelfmark.release_sources import Release, ReleaseProtocol
|
||||||
|
|
||||||
logger = setup_logger(__name__)
|
logger = setup_logger(__name__)
|
||||||
@@ -56,12 +55,6 @@ def _coerce_timestamp(value: object) -> float:
|
|||||||
return 0.0
|
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]:
|
def _load_cache() -> dict[str, Any]:
|
||||||
"""Load cache from disk."""
|
"""Load cache from disk."""
|
||||||
try:
|
try:
|
||||||
@@ -103,17 +96,17 @@ def _dict_to_release(data: dict[str, Any]) -> Release:
|
|||||||
|
|
||||||
|
|
||||||
def get_cached_results(
|
def get_cached_results(
|
||||||
provider: str,
|
cache_key: str,
|
||||||
provider_id: str,
|
|
||||||
content_type: str | None = None,
|
|
||||||
ttl_seconds: int | None = None,
|
ttl_seconds: int | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Get cached search results for a book.
|
"""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.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider: Metadata provider name (e.g., "hardcover", "openlibrary")
|
cache_key: Query identity (e.g. "server:channel:query")
|
||||||
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)
|
ttl_seconds: Cache TTL in seconds (from settings)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -127,8 +120,6 @@ def get_cached_results(
|
|||||||
ttl_value = config.get("IRC_CACHE_TTL", DEFAULT_CACHE_TTL)
|
ttl_value = config.get("IRC_CACHE_TTL", DEFAULT_CACHE_TTL)
|
||||||
ttl_seconds = _coerce_cache_ttl(ttl_value, 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:
|
with _cache_lock:
|
||||||
cache = _load_cache()
|
cache = _load_cache()
|
||||||
entry = cache.get("entries", {}).get(cache_key)
|
entry = cache.get("entries", {}).get(cache_key)
|
||||||
@@ -141,10 +132,9 @@ def get_cached_results(
|
|||||||
age = time.time() - cached_at
|
age = time.time() - cached_at
|
||||||
|
|
||||||
if ttl_seconds != 0 and age > ttl_seconds:
|
if ttl_seconds != 0 and age > ttl_seconds:
|
||||||
title = entry.get("title", cache_key)
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"IRC cache expired for '%s' (age: %.0fs > TTL: %ss)",
|
"IRC cache expired for '%s' (age: %.0fs > TTL: %ss)",
|
||||||
title,
|
entry.get("title", cache_key),
|
||||||
age,
|
age,
|
||||||
ttl_seconds,
|
ttl_seconds,
|
||||||
)
|
)
|
||||||
@@ -153,44 +143,36 @@ def get_cached_results(
|
|||||||
|
|
||||||
# Convert dicts back to Release objects
|
# Convert dicts back to Release objects
|
||||||
releases = [_dict_to_release(r) for r in entry.get("releases", [])]
|
releases = [_dict_to_release(r) for r in entry.get("releases", [])]
|
||||||
online_servers = entry.get("online_servers", [])
|
|
||||||
title = entry.get("title", "")
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"IRC cache hit for '%s' (%s releases, age: %.0fs)",
|
"IRC cache hit for '%s' (%s releases, age: %.0fs)",
|
||||||
title,
|
entry.get("title", ""),
|
||||||
len(releases),
|
len(releases),
|
||||||
age,
|
age,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"releases": releases,
|
"releases": releases,
|
||||||
"online_servers": online_servers,
|
"online_servers": entry.get("online_servers", []),
|
||||||
"cached_at": cached_at,
|
"cached_at": cached_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def cache_results(
|
def cache_results(
|
||||||
provider: str,
|
cache_key: str,
|
||||||
provider_id: str,
|
|
||||||
title: str,
|
title: str,
|
||||||
releases: list[Release],
|
releases: list[Release],
|
||||||
content_type: str | None = None,
|
|
||||||
online_servers: list[str] | None = None,
|
online_servers: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Cache search results for a book.
|
"""Cache the whole IRC answer for a query identity.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider: Metadata provider name
|
cache_key: Query identity (e.g. "server:channel:query")
|
||||||
provider_id: Book ID in the provider's system
|
title: Query text (for logging/display)
|
||||||
title: Book title (for logging/display)
|
releases: All Release objects from the search (every content type)
|
||||||
releases: List of Release objects from search
|
|
||||||
content_type: Search content type for cache isolation
|
|
||||||
online_servers: List of online server nicks (optional)
|
online_servers: List of online server nicks (optional)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
cache_key = _generate_cache_key(provider, provider_id, content_type)
|
|
||||||
|
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
cache = _load_cache()
|
cache = _load_cache()
|
||||||
|
|
||||||
@@ -198,9 +180,6 @@ def cache_results(
|
|||||||
cache["entries"] = {}
|
cache["entries"] = {}
|
||||||
|
|
||||||
cache["entries"][cache_key] = {
|
cache["entries"][cache_key] = {
|
||||||
"provider": provider,
|
|
||||||
"provider_id": provider_id,
|
|
||||||
"content_type": "audiobook" if check_audiobook(content_type) else "ebook",
|
|
||||||
"title": title,
|
"title": title,
|
||||||
"releases": [_release_to_dict(r) for r in releases],
|
"releases": [_release_to_dict(r) for r in releases],
|
||||||
"online_servers": list(online_servers) if online_servers else [],
|
"online_servers": list(online_servers) if online_servers else [],
|
||||||
@@ -211,27 +190,23 @@ def cache_results(
|
|||||||
logger.info("Cached %s IRC releases for '%s'", len(releases), title)
|
logger.info("Cached %s IRC releases for '%s'", len(releases), title)
|
||||||
|
|
||||||
|
|
||||||
def invalidate_cache(provider: str, provider_id: str, content_type: str | None = None) -> bool:
|
def invalidate_cache(cache_key: str) -> bool:
|
||||||
"""Remove a specific entry from the cache.
|
"""Remove a specific entry from the cache.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider: Metadata provider name
|
cache_key: Query identity to remove
|
||||||
provider_id: Book ID in the provider's system
|
|
||||||
content_type: Search content type for cache isolation
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if entry was found and removed
|
True if entry was found and removed
|
||||||
|
|
||||||
"""
|
"""
|
||||||
cache_key = _generate_cache_key(provider, provider_id, content_type)
|
|
||||||
|
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
cache = _load_cache()
|
cache = _load_cache()
|
||||||
entry = cache.get("entries", {}).get(cache_key)
|
entries = cache.get("entries", {})
|
||||||
title = entry.get("title", cache_key) if entry else cache_key
|
|
||||||
|
|
||||||
if cache_key in cache.get("entries", {}):
|
if cache_key in entries:
|
||||||
del cache["entries"][cache_key]
|
title = entries[cache_key].get("title", cache_key)
|
||||||
|
del entries[cache_key]
|
||||||
_save_cache(cache)
|
_save_cache(cache)
|
||||||
logger.info("Invalidated IRC cache for '%s'", title)
|
logger.info("Invalidated IRC cache for '%s'", title)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Self
|
|||||||
|
|
||||||
from shelfmark.core.logger import setup_logger
|
from shelfmark.core.logger import setup_logger
|
||||||
|
|
||||||
from .dcc import DCCOffer, parse_dcc_send
|
from .dcc import DCCError, DCCOffer, parse_dcc_send, validate_dcc_endpoint
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
@@ -400,6 +400,33 @@ class IRCClient:
|
|||||||
self.send_notice(sender, f"\x01VERSION {self.version}\x01")
|
self.send_notice(sender, f"\x01VERSION {self.version}\x01")
|
||||||
logger.debug("Sent VERSION to %s", sender)
|
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]:
|
def read_messages(self, *, auto_handle: bool = True) -> Iterator[IRCMessage]:
|
||||||
"""Read and yield IRC messages, optionally auto-handling PING/VERSION."""
|
"""Read and yield IRC messages, optionally auto-handling PING/VERSION."""
|
||||||
for line in self._recv_lines():
|
for line in self._recv_lines():
|
||||||
@@ -422,6 +449,7 @@ class IRCClient:
|
|||||||
timeout: float = 60.0,
|
timeout: float = 60.0,
|
||||||
*,
|
*,
|
||||||
result_type: bool = False,
|
result_type: bool = False,
|
||||||
|
expected_senders: set[str] | None = None,
|
||||||
) -> DCCOffer | None:
|
) -> DCCOffer | None:
|
||||||
"""Wait for a DCC SEND offer. Returns None on timeout or no results."""
|
"""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
|
target_event = IRCEvent.SEARCH_RESULT if result_type else IRCEvent.BOOK_RESULT
|
||||||
@@ -433,12 +461,15 @@ class IRCClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if msg.event == target_event:
|
if msg.event == target_event:
|
||||||
|
if not self._is_allowed_dcc_sender(msg, expected_senders):
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
offer = parse_dcc_send(msg.raw)
|
offer = parse_dcc_send(msg.raw)
|
||||||
|
validate_dcc_endpoint(offer)
|
||||||
logger.info("Received DCC offer: %s", offer.filename)
|
logger.info("Received DCC offer: %s", offer.filename)
|
||||||
except Exception:
|
except DCCError:
|
||||||
logger.exception("Failed to parse DCC")
|
logger.exception("Rejected DCC offer")
|
||||||
return None
|
continue
|
||||||
else:
|
else:
|
||||||
return offer
|
return offer
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import re
|
|||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from ipaddress import ip_address
|
||||||
|
from pathlib import PureWindowsPath
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from shelfmark.core.logger import setup_logger
|
from shelfmark.core.logger import setup_logger
|
||||||
@@ -59,6 +61,10 @@ class DCCConnectionError(DCCError):
|
|||||||
"""Failed to connect to DCC sender."""
|
"""Failed to connect to DCC sender."""
|
||||||
|
|
||||||
|
|
||||||
|
class DCCSecurityError(DCCError):
|
||||||
|
"""Rejected unsafe DCC offer metadata."""
|
||||||
|
|
||||||
|
|
||||||
def int_to_ip(ip_int: int) -> str:
|
def int_to_ip(ip_int: int) -> str:
|
||||||
"""Convert 32-bit integer (DCC format) to dotted IP notation."""
|
"""Convert 32-bit integer (DCC format) to dotted IP notation."""
|
||||||
packed = struct.pack(">I", ip_int)
|
packed = struct.pack(">I", ip_int)
|
||||||
@@ -76,15 +82,53 @@ def parse_dcc_send(text: str) -> DCCOffer:
|
|||||||
ip_int = int(match.group(2))
|
ip_int = int(match.group(2))
|
||||||
port = int(match.group(3))
|
port = int(match.group(3))
|
||||||
size = int(match.group(4))
|
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(
|
return DCCOffer(
|
||||||
filename=filename,
|
filename=safe_dcc_filename(filename),
|
||||||
ip=int_to_ip(ip_int),
|
ip=ip,
|
||||||
port=port,
|
port=port,
|
||||||
size=size,
|
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(
|
def download_dcc(
|
||||||
offer: DCCOffer,
|
offer: DCCOffer,
|
||||||
dest_path: Path,
|
dest_path: Path,
|
||||||
@@ -93,6 +137,7 @@ def download_dcc(
|
|||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Download file via DCC protocol to dest_path. Raises DCCError on failure."""
|
"""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)
|
logger.info("DCC connecting to %s:%s for %s", offer.ip, offer.port, offer.filename)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from shelfmark.core.logger import setup_logger
|
|||||||
from shelfmark.release_sources import DownloadHandler, register_handler
|
from shelfmark.release_sources import DownloadHandler, register_handler
|
||||||
|
|
||||||
from .connection_manager import connection_manager
|
from .connection_manager import connection_manager
|
||||||
from .dcc import DCCError, download_dcc
|
from .dcc import DCCError, download_dcc, safe_dcc_filename
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -23,6 +23,15 @@ if TYPE_CHECKING:
|
|||||||
logger = setup_logger(__name__)
|
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:
|
def _config_text(key: str) -> str:
|
||||||
"""Read a string config value with whitespace trimmed."""
|
"""Read a string config value with whitespace trimmed."""
|
||||||
value = config.get(key, "")
|
value = config.get(key, "")
|
||||||
@@ -72,6 +81,7 @@ class IRCDownloadHandler(DownloadHandler):
|
|||||||
"""Download a release via IRC DCC. task.task_id contains the IRC request string."""
|
"""Download a release via IRC DCC. task.task_id contains the IRC request string."""
|
||||||
download_request = task.task_id
|
download_request = task.task_id
|
||||||
logger.info("IRC download: %s...", download_request[:60])
|
logger.info("IRC download: %s...", download_request[:60])
|
||||||
|
expected_server = _server_from_download_request(download_request)
|
||||||
|
|
||||||
# Get IRC settings
|
# Get IRC settings
|
||||||
server = _config_text("IRC_SERVER")
|
server = _config_text("IRC_SERVER")
|
||||||
@@ -123,7 +133,8 @@ class IRCDownloadHandler(DownloadHandler):
|
|||||||
# Phase 3: Wait for DCC offer
|
# Phase 3: Wait for DCC offer
|
||||||
status_callback("resolving", "Waiting for bot response")
|
status_callback("resolving", "Waiting for bot response")
|
||||||
|
|
||||||
offer = client.wait_for_dcc(timeout=120.0, result_type=False)
|
wait_kwargs = {"expected_senders": {expected_server}} if expected_server else {}
|
||||||
|
offer = client.wait_for_dcc(timeout=120.0, result_type=False, **wait_kwargs)
|
||||||
|
|
||||||
if not offer:
|
if not offer:
|
||||||
status_callback("error", "No response from bot")
|
status_callback("error", "No response from bot")
|
||||||
@@ -137,7 +148,9 @@ class IRCDownloadHandler(DownloadHandler):
|
|||||||
status_callback("downloading", "")
|
status_callback("downloading", "")
|
||||||
|
|
||||||
# Get file extension from offer filename
|
# Get file extension from offer filename
|
||||||
ext = Path(offer.filename).suffix.lstrip(".") or task.format or "epub"
|
ext = (
|
||||||
|
Path(safe_dcc_filename(offer.filename)).suffix.lstrip(".") or task.format or "epub"
|
||||||
|
)
|
||||||
|
|
||||||
# Stage to temp directory (lazy import to avoid circular import)
|
# Stage to temp directory (lazy import to avoid circular import)
|
||||||
from shelfmark.download.staging import get_staging_path
|
from shelfmark.download.staging import get_staging_path
|
||||||
|
|||||||
@@ -88,7 +88,11 @@ def irc_settings() -> list[SettingsField]:
|
|||||||
key="IRC_SEARCH_BOT",
|
key="IRC_SEARCH_BOT",
|
||||||
label="Search bot",
|
label="Search bot",
|
||||||
placeholder="e.g. search",
|
placeholder="e.g. search",
|
||||||
description="The search bot to query for results",
|
description=(
|
||||||
|
"The search bot to address queries to (required). Searches are sent as "
|
||||||
|
'"@<bot> <query>".'
|
||||||
|
),
|
||||||
|
required=True,
|
||||||
env_supported=True,
|
env_supported=True,
|
||||||
),
|
),
|
||||||
HeadingField(
|
HeadingField(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
|||||||
from shelfmark.api.websocket import ws_manager
|
from shelfmark.api.websocket import ws_manager
|
||||||
from shelfmark.core.config import config
|
from shelfmark.core.config import config
|
||||||
from shelfmark.core.logger import setup_logger
|
from shelfmark.core.logger import setup_logger
|
||||||
|
from shelfmark.core.utils import is_audiobook
|
||||||
from shelfmark.release_sources import (
|
from shelfmark.release_sources import (
|
||||||
ColumnColorHint,
|
ColumnColorHint,
|
||||||
ColumnRenderType,
|
ColumnRenderType,
|
||||||
@@ -30,7 +31,7 @@ from shelfmark.release_sources import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .connection_manager import connection_manager
|
from .connection_manager import connection_manager
|
||||||
from .dcc import DCCError, download_dcc
|
from .dcc import DCCError, download_dcc, safe_dcc_filename
|
||||||
from .parser import SearchResult, extract_results_from_zip, parse_results_file
|
from .parser import SearchResult, extract_results_from_zip, parse_results_file
|
||||||
|
|
||||||
logger = setup_logger(__name__)
|
logger = setup_logger(__name__)
|
||||||
@@ -88,6 +89,17 @@ def _emit_status(message: str, phase: str = "searching") -> None:
|
|||||||
MIN_SEARCH_INTERVAL = 15.0
|
MIN_SEARCH_INTERVAL = 15.0
|
||||||
_last_search_time: float = 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:
|
def _enforce_rate_limit() -> None:
|
||||||
"""Ensure minimum time between searches."""
|
"""Ensure minimum time between searches."""
|
||||||
@@ -102,6 +114,36 @@ def _enforce_rate_limit() -> None:
|
|||||||
_last_search_time = time.time()
|
_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")
|
@register_source("irc")
|
||||||
class IRCReleaseSource(ReleaseSource):
|
class IRCReleaseSource(ReleaseSource):
|
||||||
"""Search IRC channels for ebook and audiobook releases."""
|
"""Search IRC channels for ebook and audiobook releases."""
|
||||||
@@ -117,11 +159,16 @@ class IRCReleaseSource(ReleaseSource):
|
|||||||
self._online_servers: set[str] | None = None
|
self._online_servers: set[str] | None = None
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
def is_available(self) -> bool:
|
||||||
"""Check if IRC is configured (server, channel, and nick are set)."""
|
"""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.
|
||||||
|
"""
|
||||||
server = _config_text("IRC_SERVER")
|
server = _config_text("IRC_SERVER")
|
||||||
channel = _config_text("IRC_CHANNEL")
|
channel = _config_text("IRC_CHANNEL")
|
||||||
nick = _config_text("IRC_NICK")
|
nick = _config_text("IRC_NICK")
|
||||||
return bool(server and channel and nick)
|
search_bot = _config_text("IRC_SEARCH_BOT")
|
||||||
|
return bool(server and channel and nick and search_bot)
|
||||||
|
|
||||||
def get_column_config(self) -> ReleaseColumnConfig:
|
def get_column_config(self) -> ReleaseColumnConfig:
|
||||||
"""Configure UI columns for IRC results."""
|
"""Configure UI columns for IRC results."""
|
||||||
@@ -179,25 +226,12 @@ class IRCReleaseSource(ReleaseSource):
|
|||||||
logger.debug("IRC source is disabled, skipping search")
|
logger.debug("IRC source is disabled, skipping search")
|
||||||
return []
|
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
|
# Build search query
|
||||||
query = plan.primary_query or self._build_query(book)
|
query = plan.primary_query or self._build_query(book)
|
||||||
if not query:
|
if not query:
|
||||||
logger.warning("No search query could be built")
|
logger.warning("No search query could be built")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
logger.info("IRC search: %s", query)
|
|
||||||
|
|
||||||
# Enforce rate limit
|
|
||||||
_enforce_rate_limit()
|
|
||||||
|
|
||||||
# Get IRC settings
|
# Get IRC settings
|
||||||
server = _config_text("IRC_SERVER")
|
server = _config_text("IRC_SERVER")
|
||||||
port = _config_port("IRC_PORT", 6697)
|
port = _config_port("IRC_PORT", 6697)
|
||||||
@@ -206,6 +240,54 @@ class IRCReleaseSource(ReleaseSource):
|
|||||||
nick = _config_text("IRC_NICK")
|
nick = _config_text("IRC_NICK")
|
||||||
search_bot = _config_text("IRC_SEARCH_BOT")
|
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
|
client = None
|
||||||
try:
|
try:
|
||||||
# Get or reuse IRC connection
|
# Get or reuse IRC connection
|
||||||
@@ -221,33 +303,33 @@ class IRCReleaseSource(ReleaseSource):
|
|||||||
# Capture online servers (elevated users in channel)
|
# Capture online servers (elevated users in channel)
|
||||||
self._online_servers = client.online_servers
|
self._online_servers = client.online_servers
|
||||||
|
|
||||||
# Send search request
|
# Send search request (always addressed to the search bot, never bare)
|
||||||
search_msg = f"@{search_bot} {query}" if search_bot else query
|
client.send_message(f"#{channel}", f"@{search_bot} {query}")
|
||||||
client.send_message(f"#{channel}", search_msg)
|
_record_message_sent(query_key)
|
||||||
|
|
||||||
# Wait for results DCC - this is the long wait
|
# 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.
|
||||||
_emit_status(f"Connected to #{channel} - Waiting for results...", phase="searching")
|
_emit_status(f"Connected to #{channel} - Waiting for results...", phase="searching")
|
||||||
offer = client.wait_for_dcc(timeout=60.0, result_type=True)
|
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:
|
if not offer:
|
||||||
logger.info("No search results received")
|
logger.info("No search results received")
|
||||||
_emit_status("No results found", phase="complete")
|
_emit_status("No results found", phase="complete")
|
||||||
# Release connection for reuse (don't close it)
|
# Release connection for reuse (don't close it)
|
||||||
connection_manager.release_connection(client)
|
connection_manager.release_connection(client)
|
||||||
# Cache empty result to avoid repeated failed searches
|
# Cache the (empty) answer under the query identity so an identical query
|
||||||
cache_results(
|
# is served from cache instead of re-posting.
|
||||||
book.provider,
|
cache_results(query_key, query, [], online_servers=online_servers)
|
||||||
book.provider_id,
|
|
||||||
book.title,
|
|
||||||
[],
|
|
||||||
content_type=content_type,
|
|
||||||
online_servers=list(self._online_servers) if self._online_servers else None,
|
|
||||||
)
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Download results file
|
# Download results file
|
||||||
_emit_status(f"Connected to #{channel} - Downloading results...", phase="downloading")
|
_emit_status(f"Connected to #{channel} - Downloading results...", phase="downloading")
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
result_path = Path(tmpdir) / offer.filename
|
result_path = Path(tmpdir) / safe_dcc_filename(offer.filename)
|
||||||
download_dcc(offer, result_path, timeout=30.0)
|
download_dcc(offer, result_path, timeout=30.0)
|
||||||
|
|
||||||
# Parse results
|
# Parse results
|
||||||
@@ -259,19 +341,22 @@ class IRCReleaseSource(ReleaseSource):
|
|||||||
# Release connection for reuse (don't close it)
|
# Release connection for reuse (don't close it)
|
||||||
connection_manager.release_connection(client)
|
connection_manager.release_connection(client)
|
||||||
|
|
||||||
# Convert to Release objects
|
# A single "@search" returns one file containing every format. Parse the whole
|
||||||
results = parse_results_file(content, content_type=content_type)
|
# answer (both ebooks and audiobooks) and cache it under the query identity, so
|
||||||
releases = self._convert_to_releases(results, content_type=content_type)
|
# requesting the other content type is served from cache without re-posting.
|
||||||
|
ebook_releases = self._convert_to_releases(
|
||||||
# Cache results
|
parse_results_file(content, content_type="ebook"), content_type="ebook"
|
||||||
cache_results(
|
|
||||||
book.provider,
|
|
||||||
book.provider_id,
|
|
||||||
book.title,
|
|
||||||
releases,
|
|
||||||
content_type=content_type,
|
|
||||||
online_servers=list(self._online_servers) if self._online_servers else None,
|
|
||||||
)
|
)
|
||||||
|
audiobook_releases = self._convert_to_releases(
|
||||||
|
parse_results_file(content, content_type="audiobook"), content_type="audiobook"
|
||||||
|
)
|
||||||
|
cache_results(
|
||||||
|
query_key,
|
||||||
|
query,
|
||||||
|
ebook_releases + audiobook_releases,
|
||||||
|
online_servers=online_servers,
|
||||||
|
)
|
||||||
|
releases = audiobook_releases if requested == "audiobook" else ebook_releases
|
||||||
|
|
||||||
except DCCError as e:
|
except DCCError as e:
|
||||||
logger.exception("DCC error during search")
|
logger.exception("DCC error during search")
|
||||||
@@ -389,6 +474,15 @@ class IRCReleaseSource(ReleaseSource):
|
|||||||
|
|
||||||
return releases
|
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
|
@staticmethod
|
||||||
def _parse_size(size_str: str) -> int | None:
|
def _parse_size(size_str: str) -> int | None:
|
||||||
"""Parse human-readable size (e.g., '1.2MB', '500K') to bytes."""
|
"""Parse human-readable size (e.g., '1.2MB', '500K') to bytes."""
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ if TYPE_CHECKING:
|
|||||||
from shelfmark.core.models import DownloadTask
|
from shelfmark.core.models import DownloadTask
|
||||||
|
|
||||||
from shelfmark.core.logger import setup_logger
|
from shelfmark.core.logger import setup_logger
|
||||||
|
from shelfmark.core.request_helpers import normalize_optional_text
|
||||||
from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients
|
from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients
|
||||||
from shelfmark.download.clients.base_handler import (
|
from shelfmark.download.clients.base_handler import (
|
||||||
COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS,
|
COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS,
|
||||||
@@ -83,6 +84,44 @@ class NewznabHandler(ExternalClientHandler):
|
|||||||
def _completed_path_max_attempts(self) -> int:
|
def _completed_path_max_attempts(self) -> int:
|
||||||
return COMPLETED_PATH_MAX_ATTEMPTS
|
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(
|
def _resolve_download(
|
||||||
self,
|
self,
|
||||||
task: DownloadTask,
|
task: DownloadTask,
|
||||||
@@ -90,6 +129,10 @@ class NewznabHandler(ExternalClientHandler):
|
|||||||
) -> DownloadRequest | None:
|
) -> DownloadRequest | None:
|
||||||
result = get_release(task.task_id)
|
result = get_release(task.task_id)
|
||||||
if not result:
|
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)
|
logger.warning("Newznab release cache miss: %s", task.task_id)
|
||||||
status_callback("error", "Release not found in cache (may have expired)")
|
status_callback("error", "Release not found in cache (may have expired)")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ def _newznab_result_to_release(result: dict, content_type: str = "ebook") -> Rel
|
|||||||
if is_freeleech:
|
if is_freeleech:
|
||||||
add_flag("FreeLeech")
|
add_flag("FreeLeech")
|
||||||
|
|
||||||
download_url = str(result.get("downloadUrl") or "").strip()
|
|
||||||
info_url = result.get("infoUrl") or result.get("guid")
|
info_url = result.get("infoUrl") or result.get("guid")
|
||||||
|
|
||||||
return Release(
|
return Release(
|
||||||
@@ -120,7 +119,7 @@ def _newznab_result_to_release(result: dict, content_type: str = "ebook") -> Rel
|
|||||||
language=None,
|
language=None,
|
||||||
size=_parse_size(size_bytes),
|
size=_parse_size(size_bytes),
|
||||||
size_bytes=size_bytes,
|
size_bytes=size_bytes,
|
||||||
download_url=download_url or None,
|
download_url=None,
|
||||||
info_url=info_url,
|
info_url=info_url,
|
||||||
protocol=protocol,
|
protocol=protocol,
|
||||||
indexer=indexer,
|
indexer=indexer,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Any
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ from shelfmark.core.logger import setup_logger
|
|||||||
from shelfmark.core.utils import normalize_http_url
|
from shelfmark.core.utils import normalize_http_url
|
||||||
from shelfmark.download.network import get_ssl_verify
|
from shelfmark.download.network import get_ssl_verify
|
||||||
from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml
|
from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml
|
||||||
from shelfmark.release_sources.prowlarr.utils import coerce_int_like
|
from shelfmark.release_sources.prowlarr.utils import coerce_float_like, coerce_int_like
|
||||||
|
|
||||||
logger = setup_logger(__name__)
|
logger = setup_logger(__name__)
|
||||||
|
|
||||||
@@ -27,6 +27,15 @@ _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]:
|
def _normalize_json_object(payload: object, *, context: str) -> dict[str, Any]:
|
||||||
"""Return a JSON object payload with string keys or raise on unexpected shapes."""
|
"""Return a JSON object payload with string keys or raise on unexpected shapes."""
|
||||||
if not isinstance(payload, Mapping):
|
if not isinstance(payload, Mapping):
|
||||||
@@ -52,6 +61,19 @@ def _normalize_json_object_list(payload: object, *, context: str) -> list[dict[s
|
|||||||
return [_normalize_json_object(item, context=context) for item in payload]
|
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:
|
class ProwlarrClient:
|
||||||
"""Client for interacting with the Prowlarr API."""
|
"""Client for interacting with the Prowlarr API."""
|
||||||
|
|
||||||
@@ -102,10 +124,12 @@ class ProwlarrClient:
|
|||||||
msg = f"Invalid JSON response: {e}"
|
msg = f"Invalid JSON response: {e}"
|
||||||
raise ValueError(msg) from e
|
raise ValueError(msg) from e
|
||||||
except requests.exceptions.HTTPError as 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(
|
logger.exception(
|
||||||
"Prowlarr API HTTP error: %s %s",
|
"Prowlarr API HTTP error: %s %s",
|
||||||
e.response.status_code,
|
status_code,
|
||||||
e.response.reason,
|
reason,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
except requests.exceptions.RequestException:
|
except requests.exceptions.RequestException:
|
||||||
@@ -183,6 +207,42 @@ class ProwlarrClient:
|
|||||||
|
|
||||||
return enriched_ids
|
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.
|
||||||
|
"""
|
||||||
|
settings_by_indexer: dict[int, IndexerSeedSettings] = {}
|
||||||
|
|
||||||
|
for idx in self.get_enabled_indexers_detailed():
|
||||||
|
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]]:
|
def get_enabled_indexers(self) -> list[dict[str, Any]]:
|
||||||
"""Get enabled indexers with book capability info."""
|
"""Get enabled indexers with book capability info."""
|
||||||
indexers = self.get_indexers()
|
indexers = self.get_indexers()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Prowlarr download handler - resolves releases and delegates lifecycle to shared clients."""
|
"""Prowlarr download handler - resolves releases and delegates lifecycle to shared clients."""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from shelfmark.core.config import config
|
from shelfmark.core.config import config
|
||||||
from shelfmark.core.logger import setup_logger
|
from shelfmark.core.logger import setup_logger
|
||||||
@@ -51,22 +51,11 @@ COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL
|
|||||||
COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS
|
COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS
|
||||||
|
|
||||||
|
|
||||||
def _coerce_seed_time_minutes(raw_seed_time: object) -> int | None:
|
def _coerce_positive_minutes(raw_minutes: object) -> int | None:
|
||||||
"""Convert Prowlarr's minimum seed time from seconds to whole minutes."""
|
minutes = coerce_int_like(raw_minutes)
|
||||||
if raw_seed_time is None:
|
if minutes is None:
|
||||||
return 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")
|
@register_handler("prowlarr")
|
||||||
@@ -90,6 +79,22 @@ class ProwlarrHandler(ExternalClientHandler):
|
|||||||
def _completed_path_max_attempts(self) -> int:
|
def _completed_path_max_attempts(self) -> int:
|
||||||
return COMPLETED_PATH_MAX_ATTEMPTS
|
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"))
|
||||||
|
if source_id is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
prowlarr_result = get_release(source_id)
|
||||||
|
if prowlarr_result is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"retry_download_url": normalize_optional_text(
|
||||||
|
get_preferred_download_url(prowlarr_result)
|
||||||
|
),
|
||||||
|
"retry_download_protocol": normalize_optional_text(get_protocol(prowlarr_result)),
|
||||||
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _restore_download_request_from_task(cls, task: DownloadTask) -> DownloadRequest | None:
|
def _restore_download_request_from_task(cls, task: DownloadTask) -> DownloadRequest | None:
|
||||||
"""Rebuild a DownloadRequest when the in-memory Prowlarr cache is gone."""
|
"""Rebuild a DownloadRequest when the in-memory Prowlarr cache is gone."""
|
||||||
@@ -157,12 +162,14 @@ class ProwlarrHandler(ExternalClientHandler):
|
|||||||
release_name = prowlarr_result.get("title") or task.title or "Unknown"
|
release_name = prowlarr_result.get("title") or task.title or "Unknown"
|
||||||
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
|
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
|
||||||
|
|
||||||
# Seed criteria from the indexer (Torznab attributes)
|
seeding_time_limit = None
|
||||||
raw_seed_time = prowlarr_result.get("minimumSeedTime")
|
ratio_limit = None
|
||||||
raw_ratio = prowlarr_result.get("minimumRatio")
|
if config.get("PROWLARR_USE_SEED_PREFERENCES", False):
|
||||||
|
raw_configured_seed_time = prowlarr_result.get("configuredSeedTimeMinutes")
|
||||||
|
raw_configured_ratio = prowlarr_result.get("configuredRatioLimit")
|
||||||
|
|
||||||
seeding_time_limit = _coerce_seed_time_minutes(raw_seed_time)
|
seeding_time_limit = _coerce_positive_minutes(raw_configured_seed_time)
|
||||||
ratio_limit = float(raw_ratio) if raw_ratio is not None else None
|
ratio_limit = float(raw_configured_ratio) if raw_configured_ratio is not None else None
|
||||||
|
|
||||||
return DownloadRequest(
|
return DownloadRequest(
|
||||||
url=download_url,
|
url=download_url,
|
||||||
|
|||||||
@@ -190,4 +190,11 @@ def prowlarr_config_settings() -> list[SettingsField]:
|
|||||||
description="Automatically retry search without category filtering if no results are found",
|
description="Automatically retry search without category filtering if no results are found",
|
||||||
show_when={"field": "PROWLARR_ENABLED", "value": True},
|
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},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -27,12 +27,11 @@ from shelfmark.release_sources import (
|
|||||||
SortOption,
|
SortOption,
|
||||||
register_source,
|
register_source,
|
||||||
)
|
)
|
||||||
from shelfmark.release_sources.prowlarr.api import ProwlarrClient
|
from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient
|
||||||
from shelfmark.release_sources.prowlarr.cache import cache_release
|
from shelfmark.release_sources.prowlarr.cache import cache_release
|
||||||
from shelfmark.release_sources.prowlarr.utils import (
|
from shelfmark.release_sources.prowlarr.utils import (
|
||||||
coerce_float_like,
|
coerce_float_like,
|
||||||
coerce_int_like,
|
coerce_int_like,
|
||||||
get_preferred_download_url,
|
|
||||||
get_protocol,
|
get_protocol,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -407,7 +406,7 @@ def _prowlarr_result_to_release(
|
|||||||
language=language_detected,
|
language=language_detected,
|
||||||
size=_parse_size(size_bytes),
|
size=_parse_size(size_bytes),
|
||||||
size_bytes=size_bytes,
|
size_bytes=size_bytes,
|
||||||
download_url=get_preferred_download_url(result),
|
download_url=None,
|
||||||
info_url=result.get("infoUrl") or result.get("guid"),
|
info_url=result.get("infoUrl") or result.get("guid"),
|
||||||
protocol=(
|
protocol=(
|
||||||
ReleaseProtocol.TORRENT
|
ReleaseProtocol.TORRENT
|
||||||
@@ -433,8 +432,8 @@ def _prowlarr_result_to_release(
|
|||||||
"freeleech": is_freeleech,
|
"freeleech": is_freeleech,
|
||||||
"download_volume_factor": result.get("downloadVolumeFactor"),
|
"download_volume_factor": result.get("downloadVolumeFactor"),
|
||||||
"upload_volume_factor": result.get("uploadVolumeFactor"),
|
"upload_volume_factor": result.get("uploadVolumeFactor"),
|
||||||
"minimum_ratio": result.get("minimumRatio"),
|
"configured_ratio_limit": result.get("configuredRatioLimit"),
|
||||||
"minimum_seed_time": result.get("minimumSeedTime"),
|
"configured_seed_time_minutes": result.get("configuredSeedTimeMinutes"),
|
||||||
"info_hash": result.get("infoHash"),
|
"info_hash": result.get("infoHash"),
|
||||||
"formats": formats or None,
|
"formats": formats or None,
|
||||||
"formats_display": formats_display,
|
"formats_display": formats_display,
|
||||||
@@ -444,6 +443,27 @@ def _prowlarr_result_to_release(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
@register_source("prowlarr")
|
||||||
class ProwlarrSource(ReleaseSource):
|
class ProwlarrSource(ReleaseSource):
|
||||||
"""Prowlarr release source for ebooks and audiobooks."""
|
"""Prowlarr release source for ebooks and audiobooks."""
|
||||||
@@ -762,6 +782,11 @@ class ProwlarrSource(ReleaseSource):
|
|||||||
# Some indexers benefit from title+author queries and extra format detection.
|
# Some indexers benefit from title+author queries and extra format detection.
|
||||||
enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids)
|
enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids)
|
||||||
enriched_indexer_ids_set = set(enriched_indexer_ids)
|
enriched_indexer_ids_set = set(enriched_indexer_ids)
|
||||||
|
indexer_seed_settings = (
|
||||||
|
client.get_indexer_seed_settings(restrict_to=indexer_ids)
|
||||||
|
if config.get("PROWLARR_USE_SEED_PREFERENCES", False)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
|
||||||
def _check_timeout() -> None:
|
def _check_timeout() -> None:
|
||||||
if time.monotonic() > deadline:
|
if time.monotonic() > deadline:
|
||||||
@@ -839,15 +864,18 @@ class ProwlarrSource(ReleaseSource):
|
|||||||
results: list[Release] = []
|
results: list[Release] = []
|
||||||
enriched_source_ids: set[str] = set()
|
enriched_source_ids: set[str] = set()
|
||||||
|
|
||||||
for r in all_results:
|
for raw_result in all_results:
|
||||||
idx_id = r.get("indexerId")
|
result_with_seed_settings = _apply_indexer_seed_settings(
|
||||||
|
raw_result, indexer_seed_settings
|
||||||
|
)
|
||||||
|
idx_id = result_with_seed_settings.get("indexerId")
|
||||||
idx_id_int = _coerce_indexer_id(idx_id)
|
idx_id_int = _coerce_indexer_id(idx_id)
|
||||||
|
|
||||||
is_enriched = bool(
|
is_enriched = bool(
|
||||||
idx_id_int is not None and idx_id_int in enriched_indexer_ids_set
|
idx_id_int is not None and idx_id_int in enriched_indexer_ids_set
|
||||||
)
|
)
|
||||||
release = _prowlarr_result_to_release(
|
release = _prowlarr_result_to_release(
|
||||||
r,
|
result_with_seed_settings,
|
||||||
content_type,
|
content_type,
|
||||||
enable_format_detection=is_enriched,
|
enable_format_detection=is_enriched,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -140,9 +140,6 @@ def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
download_volume_factor = _coerce_float(attrs.get("downloadvolumefactor"))
|
download_volume_factor = _coerce_float(attrs.get("downloadvolumefactor"))
|
||||||
upload_volume_factor = _coerce_float(attrs.get("uploadvolumefactor"))
|
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)
|
cleaned_title = _strip_author_from_title(title, author)
|
||||||
|
|
||||||
results.append(
|
results.append(
|
||||||
@@ -168,8 +165,6 @@ def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]:
|
|||||||
"bookTitle": book_title,
|
"bookTitle": book_title,
|
||||||
"downloadVolumeFactor": download_volume_factor,
|
"downloadVolumeFactor": download_volume_factor,
|
||||||
"uploadVolumeFactor": upload_volume_factor,
|
"uploadVolumeFactor": upload_volume_factor,
|
||||||
"minimumRatio": minimum_ratio,
|
|
||||||
"minimumSeedTime": minimum_seed_time,
|
|
||||||
# Pass through all torznab attributes for tooltip display
|
# Pass through all torznab attributes for tooltip display
|
||||||
"torznabAttrs": attrs,
|
"torznabAttrs": attrs,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"react/jsx-no-useless-fragment": "error",
|
"react/jsx-no-useless-fragment": "error",
|
||||||
"react/self-closing-comp": "error",
|
"react/self-closing-comp": "error",
|
||||||
"typescript/switch-exhaustiveness-check": "error",
|
"typescript/switch-exhaustiveness-check": "error",
|
||||||
|
"jsx-a11y/prefer-tag-over-role": "off",
|
||||||
"no-restricted-imports": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
|
|
||||||
// This script is intentionally loaded from index.html as a classic script,
|
// This script is intentionally loaded from index.html as a classic script,
|
||||||
// so we need to declare it as an entry point manually.
|
// so we need to declare it as an entry point manually.
|
||||||
"entry": ["public/theme-init.js"]
|
"entry": ["public/theme-init.js"],
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+646
-680
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -17,24 +17,24 @@
|
|||||||
"test:unit": "vitest run"
|
"test:unit": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.7",
|
||||||
"react-router-dom": "^7.14.2",
|
"react-router-dom": "^7.17.0",
|
||||||
"socket.io-client": "^4.7.5"
|
"socket.io-client": "^4.7.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.9.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"knip": "^6.6.2",
|
"knip": "^6.16.1",
|
||||||
"oxfmt": "^0.46.0",
|
"oxfmt": "^0.54.0",
|
||||||
"oxlint": "^1.61.0",
|
"oxlint": "^1.69.0",
|
||||||
"oxlint-tsgolint": "^0.21.1",
|
"oxlint-tsgolint": "^0.23.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.10",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -766,7 +766,8 @@ function App() {
|
|||||||
(cfg.show_combined_selector ?? true) &&
|
(cfg.show_combined_selector ?? true) &&
|
||||||
getDefaultMode('ebook') !== 'blocked' &&
|
getDefaultMode('ebook') !== 'blocked' &&
|
||||||
getDefaultMode('audiobook') !== 'blocked';
|
getDefaultMode('audiobook') !== 'blocked';
|
||||||
const nextEffectiveCombinedMode = combinedMode && nextCombinedModeAllowed;
|
const nextEffectiveCombinedMode =
|
||||||
|
nextCombinedModeAllowed && (combinedMode || cfg.force_combined_search);
|
||||||
const activeConfiguredProvider =
|
const activeConfiguredProvider =
|
||||||
nextEffectiveCombinedMode && metadataProviderState.configured_provider_combined
|
nextEffectiveCombinedMode && metadataProviderState.configured_provider_combined
|
||||||
? metadataProviderState.configured_provider_combined
|
? metadataProviderState.configured_provider_combined
|
||||||
@@ -864,7 +865,8 @@ function App() {
|
|||||||
const audiobookMode = getDefaultMode('audiobook');
|
const audiobookMode = getDefaultMode('audiobook');
|
||||||
return ebookMode !== 'blocked' && audiobookMode !== 'blocked';
|
return ebookMode !== 'blocked' && audiobookMode !== 'blocked';
|
||||||
}, [effectiveSearchMode, config?.show_combined_selector, getDefaultMode]);
|
}, [effectiveSearchMode, config?.show_combined_selector, getDefaultMode]);
|
||||||
const effectiveCombinedMode = combinedMode && combinedModeAllowed;
|
const combinedModeLocked = combinedModeAllowed && config?.force_combined_search === true;
|
||||||
|
const effectiveCombinedMode = combinedModeAllowed && (combinedMode || combinedModeLocked);
|
||||||
const effectiveCombinedState = effectiveCombinedMode ? combinedState : null;
|
const effectiveCombinedState = effectiveCombinedMode ? combinedState : null;
|
||||||
|
|
||||||
const defaultMetadataProviderForContentType =
|
const defaultMetadataProviderForContentType =
|
||||||
@@ -1489,7 +1491,7 @@ function App() {
|
|||||||
const handleCancel = async (id: string) => {
|
const handleCancel = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await cancelDownload(id);
|
await cancelDownload(id);
|
||||||
await fetchStatus();
|
await Promise.all([fetchStatus(), refreshActivitySnapshot()]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Cancel failed:', error);
|
console.error('Cancel failed:', error);
|
||||||
showToast('Failed to cancel/clear download', 'error');
|
showToast('Failed to cancel/clear download', 'error');
|
||||||
@@ -2418,6 +2420,7 @@ function App() {
|
|||||||
onContentTypeChange={setContentType}
|
onContentTypeChange={setContentType}
|
||||||
allowedContentTypes={allowedContentTypes}
|
allowedContentTypes={allowedContentTypes}
|
||||||
combinedMode={effectiveCombinedMode}
|
combinedMode={effectiveCombinedMode}
|
||||||
|
combinedModeLocked={combinedModeLocked}
|
||||||
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
|
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
|
||||||
queryTargets={queryTargets}
|
queryTargets={queryTargets}
|
||||||
activeQueryTarget={effectiveActiveQueryTarget}
|
activeQueryTarget={effectiveActiveQueryTarget}
|
||||||
@@ -2499,6 +2502,7 @@ function App() {
|
|||||||
onContentTypeChange={setContentType}
|
onContentTypeChange={setContentType}
|
||||||
allowedContentTypes={allowedContentTypes}
|
allowedContentTypes={allowedContentTypes}
|
||||||
combinedMode={effectiveCombinedMode}
|
combinedMode={effectiveCombinedMode}
|
||||||
|
combinedModeLocked={combinedModeLocked}
|
||||||
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
|
onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined}
|
||||||
activeQueryField={activeQueryField}
|
activeQueryField={activeQueryField}
|
||||||
searchMode={effectiveSearchMode}
|
searchMode={effectiveSearchMode}
|
||||||
@@ -2774,10 +2778,13 @@ function App() {
|
|||||||
parsedParams={parsedParams}
|
parsedParams={parsedParams}
|
||||||
config={config}
|
config={config}
|
||||||
contentType={contentType}
|
contentType={contentType}
|
||||||
|
combinedMode={combinedMode}
|
||||||
|
combinedModeAllowed={combinedModeAllowed}
|
||||||
advancedFilters={advancedFilters}
|
advancedFilters={advancedFilters}
|
||||||
resolvedMetadataDefaultSort={resolvedMetadataDefaultSort}
|
resolvedMetadataDefaultSort={resolvedMetadataDefaultSort}
|
||||||
resolvedMetadataSortOptions={resolvedMetadataSortOptions}
|
resolvedMetadataSortOptions={resolvedMetadataSortOptions}
|
||||||
setContentType={setContentType}
|
setContentType={setContentType}
|
||||||
|
setCombinedMode={setCombinedMode}
|
||||||
setSearchInput={setSearchInput}
|
setSearchInput={setSearchInput}
|
||||||
setAdvancedFilters={setAdvancedFilters}
|
setAdvancedFilters={setAdvancedFilters}
|
||||||
setShowAdvanced={setShowAdvanced}
|
setShowAdvanced={setShowAdvanced}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ const SEARCH_MODE_OPTIONS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const EMPTY_PROVIDERS: MetadataProviderSummary[] = [];
|
||||||
|
|
||||||
export const AdvancedFilters = ({
|
export const AdvancedFilters = ({
|
||||||
visible,
|
visible,
|
||||||
bookLanguages,
|
bookLanguages,
|
||||||
@@ -68,7 +70,7 @@ export const AdvancedFilters = ({
|
|||||||
renderWrapper,
|
renderWrapper,
|
||||||
searchMode,
|
searchMode,
|
||||||
onSearchModeChange,
|
onSearchModeChange,
|
||||||
metadataProviders = [],
|
metadataProviders = EMPTY_PROVIDERS,
|
||||||
activeMetadataProvider,
|
activeMetadataProvider,
|
||||||
onMetadataProviderChange,
|
onMetadataProviderChange,
|
||||||
contentType = 'ebook',
|
contentType = 'ebook',
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useMountEffect } from '../hooks/useMountEffect';
|
|||||||
import type { Book, ButtonStateInfo } from '../types';
|
import type { Book, ButtonStateInfo } from '../types';
|
||||||
import { isMetadataBook } from '../types';
|
import { isMetadataBook } from '../types';
|
||||||
import { bookSupportsTargets } from '../utils/bookTargetLoader';
|
import { bookSupportsTargets } from '../utils/bookTargetLoader';
|
||||||
import { getSizedCoverUrl } from '../utils/covers';
|
|
||||||
import { isUserCancelledError } from '../utils/errors';
|
import { isUserCancelledError } from '../utils/errors';
|
||||||
import { BookTargetDropdown } from './BookTargetDropdown';
|
import { BookTargetDropdown } from './BookTargetDropdown';
|
||||||
|
|
||||||
@@ -137,10 +136,6 @@ export const DetailsModal = ({
|
|||||||
const artworkMaxWidth = isSquareCover
|
const artworkMaxWidth = isSquareCover
|
||||||
? 'min(45vw, 400px, calc(90vh - 220px))'
|
? 'min(45vw, 400px, calc(90vh - 220px))'
|
||||||
: 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
|
: 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
|
||||||
const optimizedPreview = getSizedCoverUrl(book.preview, {
|
|
||||||
width: isSquareCover ? 640 : 480,
|
|
||||||
height: isSquareCover ? 640 : 720,
|
|
||||||
});
|
|
||||||
const additionalInfo =
|
const additionalInfo =
|
||||||
book.info && Object.keys(book.info).length > 0
|
book.info && Object.keys(book.info).length > 0
|
||||||
? Object.entries(book.info).filter(([key]) => {
|
? Object.entries(book.info).filter(([key]) => {
|
||||||
@@ -206,17 +201,14 @@ export const DetailsModal = ({
|
|||||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-6">
|
<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 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">
|
<div className="flex w-full justify-center lg:w-auto lg:flex-none lg:justify-start lg:self-stretch lg:pr-4">
|
||||||
{optimizedPreview ? (
|
{book.preview ? (
|
||||||
<div
|
<div
|
||||||
className="flex w-full items-center justify-center lg:h-full lg:max-w-none"
|
className="flex w-full items-center justify-center lg:h-full lg:max-w-none"
|
||||||
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
|
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={optimizedPreview}
|
src={book.preview}
|
||||||
alt="Book cover"
|
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"
|
className="h-auto max-h-full w-auto max-w-full rounded-xl object-contain shadow-lg"
|
||||||
style={{ maxHeight: '100%', maxWidth: '100%' }}
|
style={{ maxHeight: '100%', maxWidth: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
import { useCallback, useId, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { useDismiss } from '../hooks/useDismiss';
|
import { useDismiss } from '../hooks/useDismiss';
|
||||||
@@ -55,6 +55,7 @@ export const Dropdown = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DropdownProps) => {
|
}: DropdownProps) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownId = useId();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const triggerRef = useRef<HTMLDivElement>(null);
|
const triggerRef = useRef<HTMLDivElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -240,8 +241,8 @@ export const Dropdown = ({
|
|||||||
<div className={widthClassName} ref={containerRef}>
|
<div className={widthClassName} ref={containerRef}>
|
||||||
{label && (
|
{label && (
|
||||||
<label
|
<label
|
||||||
className="mb-1.5 block text-xs font-medium text-gray-500 dark:text-gray-400"
|
htmlFor={dropdownId}
|
||||||
onClick={toggleOpen}
|
className="mb-1.5 block cursor-pointer text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
@@ -251,6 +252,7 @@ export const Dropdown = ({
|
|||||||
renderTrigger({ isOpen, toggle: toggleOpen })
|
renderTrigger({ isOpen, toggle: toggleOpen })
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
id={dropdownId}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleOpen}
|
onClick={toggleOpen}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export const DropdownList = ({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedValues.includes(option.value)}
|
checked={selectedValues.includes(option.value)}
|
||||||
readOnly
|
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"
|
className="pointer-events-none h-4 w-4 rounded-sm border-gray-300 text-sky-600 focus:ring-sky-500"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ interface HeaderProps {
|
|||||||
onContentTypeChange?: (type: ContentType) => void;
|
onContentTypeChange?: (type: ContentType) => void;
|
||||||
allowedContentTypes?: ContentType[];
|
allowedContentTypes?: ContentType[];
|
||||||
combinedMode?: boolean;
|
combinedMode?: boolean;
|
||||||
|
combinedModeLocked?: boolean;
|
||||||
onCombinedModeChange?: (enabled: boolean) => void;
|
onCombinedModeChange?: (enabled: boolean) => void;
|
||||||
queryTargets?: QueryTargetOption[];
|
queryTargets?: QueryTargetOption[];
|
||||||
activeQueryTarget?: string;
|
activeQueryTarget?: string;
|
||||||
@@ -79,6 +80,15 @@ const applyTheme = (preference: string): void => {
|
|||||||
document.documentElement.style.colorScheme = effective;
|
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>(
|
export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@@ -98,7 +108,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
|||||||
onSettingsClick,
|
onSettingsClick,
|
||||||
isAdmin = false,
|
isAdmin = false,
|
||||||
canAccessSettings,
|
canAccessSettings,
|
||||||
statusCounts = { ongoing: 0, completed: 0, errored: 0, pendingRequests: 0 },
|
statusCounts = DEFAULT_STATUS_COUNTS,
|
||||||
onLogoClick,
|
onLogoClick,
|
||||||
authRequired = false,
|
authRequired = false,
|
||||||
isAuthenticated = false,
|
isAuthenticated = false,
|
||||||
@@ -106,7 +116,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
|||||||
displayName,
|
displayName,
|
||||||
actingAsUser = null,
|
actingAsUser = null,
|
||||||
onActingAsUserChange,
|
onActingAsUserChange,
|
||||||
adminUsers = [],
|
adminUsers = EMPTY_ADMIN_USERS,
|
||||||
isAdminUsersLoading = false,
|
isAdminUsersLoading = false,
|
||||||
adminUsersError = null,
|
adminUsersError = null,
|
||||||
hasLoadedAdminUsers = false,
|
hasLoadedAdminUsers = false,
|
||||||
@@ -118,8 +128,9 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
|||||||
onContentTypeChange,
|
onContentTypeChange,
|
||||||
allowedContentTypes,
|
allowedContentTypes,
|
||||||
combinedMode,
|
combinedMode,
|
||||||
|
combinedModeLocked,
|
||||||
onCombinedModeChange,
|
onCombinedModeChange,
|
||||||
queryTargets = [],
|
queryTargets = EMPTY_QUERY_TARGETS,
|
||||||
activeQueryTarget = 'general',
|
activeQueryTarget = 'general',
|
||||||
onQueryTargetChange,
|
onQueryTargetChange,
|
||||||
activeQueryField = null,
|
activeQueryField = null,
|
||||||
@@ -305,8 +316,8 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
|||||||
// Determine if we should show icons only (both URLs configured)
|
// Determine if we should show icons only (both URLs configured)
|
||||||
const showIconsOnly = Boolean(calibreWebUrl && audiobookLibraryUrl);
|
const showIconsOnly = Boolean(calibreWebUrl && audiobookLibraryUrl);
|
||||||
|
|
||||||
// Icon buttons component - reused for both states
|
// Icon buttons - reused for both states
|
||||||
const IconButtons = () => (
|
const iconButtonsNode = (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Book Library Button */}
|
{/* Book Library Button */}
|
||||||
{calibreWebUrl && (
|
{calibreWebUrl && (
|
||||||
@@ -579,6 +590,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
|||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="hover-action shrink-0 rounded-full p-2 text-red-600 transition-colors dark:text-red-400"
|
className="hover-action shrink-0 rounded-full p-2 text-red-600 transition-colors dark:text-red-400"
|
||||||
title="Sign Out"
|
title="Sign Out"
|
||||||
|
aria-label="Sign Out"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
@@ -674,7 +686,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<IconButtons />
|
{iconButtonsNode}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search bar - appear second on mobile (below logo+icons), first on desktop (left side) */}
|
{/* Search bar - appear second on mobile (below logo+icons), first on desktop (left side) */}
|
||||||
@@ -711,6 +723,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
|||||||
onContentTypeChange={onContentTypeChange}
|
onContentTypeChange={onContentTypeChange}
|
||||||
allowedContentTypes={allowedContentTypes}
|
allowedContentTypes={allowedContentTypes}
|
||||||
combinedMode={combinedMode}
|
combinedMode={combinedMode}
|
||||||
|
combinedModeLocked={combinedModeLocked}
|
||||||
onCombinedModeChange={onCombinedModeChange}
|
onCombinedModeChange={onCombinedModeChange}
|
||||||
queryTargets={queryTargets}
|
queryTargets={queryTargets}
|
||||||
activeQueryTarget={activeQueryTarget}
|
activeQueryTarget={activeQueryTarget}
|
||||||
@@ -723,9 +736,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
|
|||||||
|
|
||||||
{/* When search is NOT active: show icon buttons only on the right */}
|
{/* When search is NOT active: show icon buttons only on the right */}
|
||||||
{!showSearch && (
|
{!showSearch && (
|
||||||
<div className="flex min-h-[48px] items-center justify-end">
|
<div className="flex min-h-[48px] items-center justify-end">{iconButtonsNode}</div>
|
||||||
<IconButtons />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ const PasswordLoginForm = ({
|
|||||||
type="text"
|
type="text"
|
||||||
id="username"
|
id="username"
|
||||||
name="username"
|
name="username"
|
||||||
|
aria-label="Username"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
@@ -131,6 +132,7 @@ const PasswordLoginForm = ({
|
|||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
|
aria-label="Password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
@@ -168,6 +170,7 @@ const PasswordLoginForm = ({
|
|||||||
checked={rememberMe}
|
checked={rememberMe}
|
||||||
onChange={(event) => setRememberMe(event.target.checked)}
|
onChange={(event) => setRememberMe(event.target.checked)}
|
||||||
disabled={isLoading}
|
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"
|
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)' }}
|
style={{ borderColor: 'var(--border-color)' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import type {
|
|||||||
import { isMetadataBook } from '../types';
|
import { isMetadataBook } from '../types';
|
||||||
import { bookSupportsTargets } from '../utils/bookTargetLoader';
|
import { bookSupportsTargets } from '../utils/bookTargetLoader';
|
||||||
import { getColorStyleFromHint } from '../utils/colorMaps';
|
import { getColorStyleFromHint } from '../utils/colorMaps';
|
||||||
import { getSizedCoverUrl } from '../utils/covers';
|
|
||||||
import {
|
import {
|
||||||
LANGUAGE_OPTION_DEFAULT,
|
LANGUAGE_OPTION_DEFAULT,
|
||||||
getLanguageFilterValues,
|
getLanguageFilterValues,
|
||||||
@@ -211,9 +210,8 @@ function StarRating({ rating, maxRating = 5 }: { rating: number; maxRating?: num
|
|||||||
const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
|
const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const optimizedPreview = getSizedCoverUrl(preview, { width: 32, height: 48 });
|
|
||||||
|
|
||||||
if (!optimizedPreview || imageError) {
|
if (!preview || imageError) {
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
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"
|
||||||
@@ -230,13 +228,10 @@ 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" />
|
<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
|
<img
|
||||||
src={optimizedPreview}
|
src={preview}
|
||||||
alt={title || 'Book cover'}
|
alt={title || 'Book cover'}
|
||||||
className="h-full w-full object-cover object-top"
|
className="h-full w-full object-cover object-top"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
|
||||||
width={32}
|
|
||||||
height={48}
|
|
||||||
onLoad={() => setImageLoaded(true)}
|
onLoad={() => setImageLoaded(true)}
|
||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }}
|
style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }}
|
||||||
@@ -734,6 +729,8 @@ function ErrorState({ message }: { message: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_SUPPORTED_AUDIOBOOK_FORMATS: string[] = [];
|
||||||
|
|
||||||
const ReleaseModalSession = ({
|
const ReleaseModalSession = ({
|
||||||
book,
|
book,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -742,7 +739,7 @@ const ReleaseModalSession = ({
|
|||||||
onRequestBook,
|
onRequestBook,
|
||||||
getPolicyModeForSource,
|
getPolicyModeForSource,
|
||||||
supportedFormats,
|
supportedFormats,
|
||||||
supportedAudiobookFormats = [],
|
supportedAudiobookFormats = EMPTY_SUPPORTED_AUDIOBOOK_FORMATS,
|
||||||
contentType,
|
contentType,
|
||||||
defaultLanguages,
|
defaultLanguages,
|
||||||
bookLanguages,
|
bookLanguages,
|
||||||
@@ -1242,10 +1239,6 @@ const ReleaseModalSession = ({
|
|||||||
} else if (book.series_name) {
|
} else if (book.series_name) {
|
||||||
coverSizeClassName = 'h-[144px] w-24';
|
coverSizeClassName = 'h-[144px] w-24';
|
||||||
}
|
}
|
||||||
const modalPreview = getSizedCoverUrl(book.preview, {
|
|
||||||
width: book.cover_aspect === 'square' ? 144 : 96,
|
|
||||||
height: 144,
|
|
||||||
});
|
|
||||||
|
|
||||||
let combinedFooterEbookMode = combinedEbookMode;
|
let combinedFooterEbookMode = combinedEbookMode;
|
||||||
if (combinedPhase === 'ebook') {
|
if (combinedPhase === 'ebook') {
|
||||||
@@ -1334,14 +1327,13 @@ const ReleaseModalSession = ({
|
|||||||
{/* Mobile: static thumbnail always visible */}
|
{/* Mobile: static thumbnail always visible */}
|
||||||
{!isRequestMode && (
|
{!isRequestMode && (
|
||||||
<div className="shrink-0 sm:hidden">
|
<div className="shrink-0 sm:hidden">
|
||||||
{modalPreview ? (
|
{book.preview ? (
|
||||||
<img
|
<img
|
||||||
src={modalPreview}
|
src={book.preview}
|
||||||
alt=""
|
alt=""
|
||||||
width={book.cover_aspect === 'square' ? 68 : 46}
|
width={book.cover_aspect === 'square' ? 68 : 46}
|
||||||
height={68}
|
height={68}
|
||||||
className={`rounded-sm object-cover shadow-md ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`}
|
className={`rounded-sm object-cover shadow-md ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`}
|
||||||
decoding="async"
|
|
||||||
style={{
|
style={{
|
||||||
width: book.cover_aspect === 'square' ? 68 : 46,
|
width: book.cover_aspect === 'square' ? 68 : 46,
|
||||||
height: 68,
|
height: 68,
|
||||||
@@ -1375,14 +1367,13 @@ const ReleaseModalSession = ({
|
|||||||
className="transition-opacity duration-300 ease-out"
|
className="transition-opacity duration-300 ease-out"
|
||||||
style={{ opacity: showHeaderThumb ? 1 : 0 }}
|
style={{ opacity: showHeaderThumb ? 1 : 0 }}
|
||||||
>
|
>
|
||||||
{modalPreview ? (
|
{book.preview ? (
|
||||||
<img
|
<img
|
||||||
src={modalPreview}
|
src={book.preview}
|
||||||
alt=""
|
alt=""
|
||||||
width={book.cover_aspect === 'square' ? 68 : 46}
|
width={book.cover_aspect === 'square' ? 68 : 46}
|
||||||
height={68}
|
height={68}
|
||||||
className={`rounded-sm object-cover shadow-md ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`}
|
className={`rounded-sm object-cover shadow-md ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`}
|
||||||
decoding="async"
|
|
||||||
style={{
|
style={{
|
||||||
width: book.cover_aspect === 'square' ? 68 : 46,
|
width: book.cover_aspect === 'square' ? 68 : 46,
|
||||||
height: 68,
|
height: 68,
|
||||||
@@ -1445,13 +1436,10 @@ const ReleaseModalSession = ({
|
|||||||
ref={bookSummaryRef}
|
ref={bookSummaryRef}
|
||||||
className="flex gap-4 border-b border-(--border-muted) px-5 py-4"
|
className="flex gap-4 border-b border-(--border-muted) px-5 py-4"
|
||||||
>
|
>
|
||||||
{modalPreview ? (
|
{book.preview ? (
|
||||||
<img
|
<img
|
||||||
src={modalPreview}
|
src={book.preview}
|
||||||
alt="Book cover"
|
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}`}
|
className={`hidden shrink-0 rounded-lg object-cover shadow-md sm:block ${coverAspectClassName} ${coverSizeClassName}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -2108,6 +2096,7 @@ const ReleaseModalSession = ({
|
|||||||
value={manualQuery}
|
value={manualQuery}
|
||||||
onChange={(e) => setManualQuery(e.target.value)}
|
onChange={(e) => setManualQuery(e.target.value)}
|
||||||
placeholder="Type a custom search query (overrides all sources)"
|
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)"
|
className="w-full rounded-lg border border-(--border-muted) bg-(--bg) px-3 py-2 text-sm text-(--text)"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useEscapeKey } from '../hooks/useEscapeKey';
|
|||||||
import { useMountEffect } from '../hooks/useMountEffect';
|
import { useMountEffect } from '../hooks/useMountEffect';
|
||||||
import { getMetadataBookInfo } from '../services/api';
|
import { getMetadataBookInfo } from '../services/api';
|
||||||
import type { CreateRequestPayload } from '../types';
|
import type { CreateRequestPayload } from '../types';
|
||||||
import { getSizedCoverUrl } from '../utils/covers';
|
|
||||||
import type { RequestConfirmationPreview } from '../utils/requestConfirmation';
|
import type { RequestConfirmationPreview } from '../utils/requestConfirmation';
|
||||||
import {
|
import {
|
||||||
applyRequestNoteToPayload,
|
applyRequestNoteToPayload,
|
||||||
@@ -65,9 +64,11 @@ const getRequestConfirmationSessionKey = (payload: CreateRequestPayload): string
|
|||||||
].join('|');
|
].join('|');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_PAYLOADS: CreateRequestPayload[] = [];
|
||||||
|
|
||||||
export function RequestConfirmationModal({
|
export function RequestConfirmationModal({
|
||||||
payload,
|
payload,
|
||||||
extraPayloads = [],
|
extraPayloads = EMPTY_PAYLOADS,
|
||||||
allowNotes,
|
allowNotes,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -90,7 +91,7 @@ export function RequestConfirmationModal({
|
|||||||
|
|
||||||
function RequestConfirmationModalSession({
|
function RequestConfirmationModalSession({
|
||||||
payload,
|
payload,
|
||||||
extraPayloads = [],
|
extraPayloads = EMPTY_PAYLOADS,
|
||||||
allowNotes,
|
allowNotes,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -176,7 +177,6 @@ function RequestConfirmationModalSession({
|
|||||||
|
|
||||||
const titleId = 'request-confirmation-modal-title';
|
const titleId = 'request-confirmation-modal-title';
|
||||||
const confirmDisabled = isSubmitting || (allowNotes && note.length > MAX_REQUEST_NOTE_LENGTH);
|
const confirmDisabled = isSubmitting || (allowNotes && note.length > MAX_REQUEST_NOTE_LENGTH);
|
||||||
const previewImage = getSizedCoverUrl(preview.preview, { width: 64, height: 96 });
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (confirmDisabled) {
|
if (confirmDisabled) {
|
||||||
@@ -242,15 +242,11 @@ function RequestConfirmationModalSession({
|
|||||||
<div className="rounded-xl border border-(--border-muted) bg-(--bg-soft) px-4 py-4">
|
<div className="rounded-xl border border-(--border-muted) bg-(--bg-soft) px-4 py-4">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-lg border border-(--border-muted) bg-(--bg)">
|
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-lg border border-(--border-muted) bg-(--bg)">
|
||||||
{previewImage ? (
|
{preview.preview ? (
|
||||||
<img
|
<img
|
||||||
src={previewImage}
|
src={preview.preview}
|
||||||
alt={`${preview.title} cover`}
|
alt={`${preview.title} cover`}
|
||||||
className="h-full w-full object-cover object-top"
|
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">
|
<div className="flex h-full w-full items-center justify-center text-[10px] opacity-60">
|
||||||
@@ -311,6 +307,7 @@ function RequestConfirmationModalSession({
|
|||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="request-note"
|
id="request-note"
|
||||||
|
aria-label="Note (optional)"
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(event) => setNote(truncateRequestNote(event.target.value))}
|
onChange={(event) => setNote(truncateRequestNote(event.target.value))}
|
||||||
maxLength={MAX_REQUEST_NOTE_LENGTH}
|
maxLength={MAX_REQUEST_NOTE_LENGTH}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface SearchBarProps {
|
|||||||
onContentTypeChange?: (type: ContentType) => void;
|
onContentTypeChange?: (type: ContentType) => void;
|
||||||
allowedContentTypes?: ContentType[];
|
allowedContentTypes?: ContentType[];
|
||||||
combinedMode?: boolean;
|
combinedMode?: boolean;
|
||||||
|
combinedModeLocked?: boolean;
|
||||||
onCombinedModeChange?: (enabled: boolean) => void;
|
onCombinedModeChange?: (enabled: boolean) => void;
|
||||||
queryTargets?: QueryTargetOption[];
|
queryTargets?: QueryTargetOption[];
|
||||||
activeQueryTarget?: string;
|
activeQueryTarget?: string;
|
||||||
@@ -48,6 +49,7 @@ export interface SearchBarHandle {
|
|||||||
|
|
||||||
const EMPTY_SORT_OPTIONS: SortOption[] = [];
|
const EMPTY_SORT_OPTIONS: SortOption[] = [];
|
||||||
const EMPTY_AUTOCOMPLETE_OPTIONS: DynamicFieldOption[] = [];
|
const EMPTY_AUTOCOMPLETE_OPTIONS: DynamicFieldOption[] = [];
|
||||||
|
const EMPTY_QUERY_TARGETS: QueryTargetOption[] = [];
|
||||||
|
|
||||||
const BookIcon = () => (
|
const BookIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
@@ -180,8 +182,9 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
|||||||
onContentTypeChange,
|
onContentTypeChange,
|
||||||
allowedContentTypes,
|
allowedContentTypes,
|
||||||
combinedMode = false,
|
combinedMode = false,
|
||||||
|
combinedModeLocked = false,
|
||||||
onCombinedModeChange,
|
onCombinedModeChange,
|
||||||
queryTargets = [],
|
queryTargets = EMPTY_QUERY_TARGETS,
|
||||||
activeQueryTarget = 'general',
|
activeQueryTarget = 'general',
|
||||||
onQueryTargetChange,
|
onQueryTargetChange,
|
||||||
activeQueryField,
|
activeQueryField,
|
||||||
@@ -518,6 +521,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={Boolean(value)}
|
checked={Boolean(value)}
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
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"
|
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)' }}>
|
<span className="truncate text-sm" style={{ color: 'var(--text)' }}>
|
||||||
@@ -796,11 +800,19 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
{combinedModeLocked ? (
|
||||||
strokeLinecap="round"
|
<path
|
||||||
strokeLinejoin="round"
|
strokeLinecap="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"
|
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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ interface SearchSectionProps {
|
|||||||
onContentTypeChange?: (type: ContentType) => void;
|
onContentTypeChange?: (type: ContentType) => void;
|
||||||
allowedContentTypes?: ContentType[];
|
allowedContentTypes?: ContentType[];
|
||||||
combinedMode?: boolean;
|
combinedMode?: boolean;
|
||||||
|
combinedModeLocked?: boolean;
|
||||||
onCombinedModeChange?: (enabled: boolean) => void;
|
onCombinedModeChange?: (enabled: boolean) => void;
|
||||||
activeQueryField?: MetadataSearchField | null;
|
activeQueryField?: MetadataSearchField | null;
|
||||||
searchMode: SearchMode;
|
searchMode: SearchMode;
|
||||||
@@ -64,6 +65,7 @@ export const SearchSection = ({
|
|||||||
onContentTypeChange,
|
onContentTypeChange,
|
||||||
allowedContentTypes,
|
allowedContentTypes,
|
||||||
combinedMode,
|
combinedMode,
|
||||||
|
combinedModeLocked,
|
||||||
onCombinedModeChange,
|
onCombinedModeChange,
|
||||||
activeQueryField,
|
activeQueryField,
|
||||||
searchMode,
|
searchMode,
|
||||||
@@ -105,6 +107,7 @@ export const SearchSection = ({
|
|||||||
onContentTypeChange={onContentTypeChange}
|
onContentTypeChange={onContentTypeChange}
|
||||||
allowedContentTypes={allowedContentTypes}
|
allowedContentTypes={allowedContentTypes}
|
||||||
combinedMode={combinedMode}
|
combinedMode={combinedMode}
|
||||||
|
combinedModeLocked={combinedModeLocked}
|
||||||
onCombinedModeChange={onCombinedModeChange}
|
onCombinedModeChange={onCombinedModeChange}
|
||||||
queryTargets={queryTargets}
|
queryTargets={queryTargets}
|
||||||
activeQueryTarget={activeQueryTarget}
|
activeQueryTarget={activeQueryTarget}
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ interface UrlSearchBootstrapMountProps {
|
|||||||
parsedParams: ParsedUrlSearch;
|
parsedParams: ParsedUrlSearch;
|
||||||
config: AppConfig;
|
config: AppConfig;
|
||||||
contentType: ContentType;
|
contentType: ContentType;
|
||||||
|
combinedMode: boolean;
|
||||||
|
combinedModeAllowed: boolean;
|
||||||
advancedFilters: AdvancedFilterState;
|
advancedFilters: AdvancedFilterState;
|
||||||
resolvedMetadataDefaultSort: string;
|
resolvedMetadataDefaultSort: string;
|
||||||
resolvedMetadataSortOptions: SortOption[];
|
resolvedMetadataSortOptions: SortOption[];
|
||||||
setContentType: (value: ContentType) => void;
|
setContentType: (value: ContentType) => void;
|
||||||
|
setCombinedMode: (value: boolean) => void;
|
||||||
setSearchInput: (value: string) => void;
|
setSearchInput: (value: string) => void;
|
||||||
setAdvancedFilters: Dispatch<SetStateAction<AdvancedFilterState>>;
|
setAdvancedFilters: Dispatch<SetStateAction<AdvancedFilterState>>;
|
||||||
setShowAdvanced: (value: boolean) => void;
|
setShowAdvanced: (value: boolean) => void;
|
||||||
@@ -32,10 +35,13 @@ export const UrlSearchBootstrapMount = ({
|
|||||||
parsedParams,
|
parsedParams,
|
||||||
config,
|
config,
|
||||||
contentType,
|
contentType,
|
||||||
|
combinedMode,
|
||||||
|
combinedModeAllowed,
|
||||||
advancedFilters,
|
advancedFilters,
|
||||||
resolvedMetadataDefaultSort,
|
resolvedMetadataDefaultSort,
|
||||||
resolvedMetadataSortOptions,
|
resolvedMetadataSortOptions,
|
||||||
setContentType,
|
setContentType,
|
||||||
|
setCombinedMode,
|
||||||
setSearchInput,
|
setSearchInput,
|
||||||
setAdvancedFilters,
|
setAdvancedFilters,
|
||||||
setShowAdvanced,
|
setShowAdvanced,
|
||||||
@@ -49,11 +55,19 @@ export const UrlSearchBootstrapMount = ({
|
|||||||
const parsedSearchMode = config.search_mode || 'universal';
|
const parsedSearchMode = config.search_mode || 'universal';
|
||||||
const urlContentTypeOverride =
|
const urlContentTypeOverride =
|
||||||
parsedSearchMode === 'universal' ? parsedParams.contentType : undefined;
|
parsedSearchMode === 'universal' ? parsedParams.contentType : undefined;
|
||||||
|
const urlForcesCombined =
|
||||||
|
parsedSearchMode === 'universal' && parsedParams.combinedMode === true && combinedModeAllowed;
|
||||||
|
|
||||||
if (urlContentTypeOverride && urlContentTypeOverride !== contentType) {
|
if (urlContentTypeOverride && urlContentTypeOverride !== contentType) {
|
||||||
setContentType(urlContentTypeOverride);
|
setContentType(urlContentTypeOverride);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (urlForcesCombined && !combinedMode) {
|
||||||
|
setCombinedMode(true);
|
||||||
|
} else if (urlContentTypeOverride && combinedMode) {
|
||||||
|
setCombinedMode(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (!parsedParams.hasSearchParams) {
|
if (!parsedParams.hasSearchParams) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import type { RequestRecord } from '../../types';
|
import type { RequestRecord } from '../../types';
|
||||||
import { withBasePath } from '../../utils/basePath';
|
import { withBasePath } from '../../utils/basePath';
|
||||||
import { getSizedCoverUrl } from '../../utils/covers';
|
|
||||||
import { Tooltip } from '../shared/Tooltip';
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
import type { ActivityCardAction } from './activityCardModel';
|
import type { ActivityCardAction } from './activityCardModel';
|
||||||
import { buildActivityCardModel } from './activityCardModel';
|
import { buildActivityCardModel } from './activityCardModel';
|
||||||
@@ -437,6 +436,7 @@ const RejectInlinePanel = ({
|
|||||||
Reject request for <span className="opacity-80">{itemTitle || 'Untitled request'}</span>
|
Reject request for <span className="opacity-80">{itemTitle || 'Untitled request'}</span>
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
|
aria-label="Optional note shown to the user"
|
||||||
value={rejectNote}
|
value={rejectNote}
|
||||||
onChange={(event) => setRejectNote(event.target.value.slice(0, MAX_ADMIN_NOTE_LENGTH))}
|
onChange={(event) => setRejectNote(event.target.value.slice(0, MAX_ADMIN_NOTE_LENGTH))}
|
||||||
rows={3}
|
rows={3}
|
||||||
@@ -500,7 +500,6 @@ export const ActivityCard = ({
|
|||||||
const titleLineRef = useRef<HTMLParagraphElement | null>(null);
|
const titleLineRef = useRef<HTMLParagraphElement | null>(null);
|
||||||
const [badgeOverflow, setBadgeOverflow] = useState<Record<string, boolean>>({});
|
const [badgeOverflow, setBadgeOverflow] = useState<Record<string, boolean>>({});
|
||||||
const [titleOverflow, setTitleOverflow] = useState(false);
|
const [titleOverflow, setTitleOverflow] = useState(false);
|
||||||
const previewImage = getSizedCoverUrl(item.preview, { width: 48, height: 72 });
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const measureBadgeOverflow = () => {
|
const measureBadgeOverflow = () => {
|
||||||
@@ -711,15 +710,11 @@ export const ActivityCard = ({
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* Artwork */}
|
{/* Artwork */}
|
||||||
<div className="h-18 w-12 shrink-0 overflow-hidden rounded-sm bg-gray-200 dark:bg-gray-700">
|
<div className="h-18 w-12 shrink-0 overflow-hidden rounded-sm bg-gray-200 dark:bg-gray-700">
|
||||||
{previewImage ? (
|
{item.preview ? (
|
||||||
<img
|
<img
|
||||||
src={previewImage}
|
src={item.preview}
|
||||||
alt={`${item.title} cover`}
|
alt={`${item.title} cover`}
|
||||||
className="h-full w-full object-cover object-top"
|
className="h-full w-full object-cover object-top"
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
width={48}
|
|
||||||
height={72}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BookFallback />
|
<BookFallback />
|
||||||
|
|||||||
@@ -225,6 +225,9 @@ const getInitialPinnedPreference = (): boolean => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_KEYS: string[] = [];
|
||||||
|
const EMPTY_ITEMS: ActivityItem[] = [];
|
||||||
|
|
||||||
export const ActivitySidebar = ({
|
export const ActivitySidebar = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -235,8 +238,8 @@ export const ActivitySidebar = ({
|
|||||||
onRetry,
|
onRetry,
|
||||||
onDownloadDismiss,
|
onDownloadDismiss,
|
||||||
requestItems,
|
requestItems,
|
||||||
dismissedItemKeys = [],
|
dismissedItemKeys = EMPTY_KEYS,
|
||||||
historyItems = [],
|
historyItems = EMPTY_ITEMS,
|
||||||
historyLoaded = false,
|
historyLoaded = false,
|
||||||
historyHasMore = false,
|
historyHasMore = false,
|
||||||
historyLoading = false,
|
historyLoading = false,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useState } from 'react';
|
|||||||
import { useSearchMode } from '../../contexts/SearchModeContext';
|
import { useSearchMode } from '../../contexts/SearchModeContext';
|
||||||
import type { Book, ButtonStateInfo } from '../../types';
|
import type { Book, ButtonStateInfo } from '../../types';
|
||||||
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
|
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
|
||||||
import { getSizedCoverUrl } from '../../utils/covers';
|
|
||||||
import { BookActionButton } from '../BookActionButton';
|
import { BookActionButton } from '../BookActionButton';
|
||||||
import { BookTargetDropdown } from '../BookTargetDropdown';
|
import { BookTargetDropdown } from '../BookTargetDropdown';
|
||||||
import { DisplayFieldBadges } from '../shared';
|
import { DisplayFieldBadges } from '../shared';
|
||||||
@@ -42,11 +41,6 @@ export const CardView = ({
|
|||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const targetProvider = book.provider;
|
const targetProvider = book.provider;
|
||||||
const targetBookId = book.provider_id;
|
const targetBookId = book.provider_id;
|
||||||
const isSquareCover = book.cover_aspect === 'square';
|
|
||||||
const optimizedPreview = getSizedCoverUrl(book.preview, {
|
|
||||||
width: 292,
|
|
||||||
height: isSquareCover ? 292 : 438,
|
|
||||||
});
|
|
||||||
let zIndex: number | undefined;
|
let zIndex: number | undefined;
|
||||||
if (dropdownOpen) {
|
if (dropdownOpen) {
|
||||||
zIndex = 20;
|
zIndex = 20;
|
||||||
@@ -103,7 +97,7 @@ export const CardView = ({
|
|||||||
#{book.series_position}
|
#{book.series_position}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{optimizedPreview && !imageError ? (
|
{book.preview && !imageError ? (
|
||||||
<>
|
<>
|
||||||
{!imageLoaded && (
|
{!imageLoaded && (
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
@@ -111,13 +105,10 @@ export const CardView = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<img
|
<img
|
||||||
src={optimizedPreview}
|
src={book.preview}
|
||||||
alt={book.title || 'Book cover'}
|
alt={book.title || 'Book cover'}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
|
||||||
width={292}
|
|
||||||
height={isSquareCover ? 292 : 438}
|
|
||||||
style={{
|
style={{
|
||||||
opacity: imageLoaded ? 1 : 0,
|
opacity: imageLoaded ? 1 : 0,
|
||||||
transition: 'opacity 0.3s ease-in-out',
|
transition: 'opacity 0.3s ease-in-out',
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useState } from 'react';
|
|||||||
import { useSearchMode } from '../../contexts/SearchModeContext';
|
import { useSearchMode } from '../../contexts/SearchModeContext';
|
||||||
import type { Book, ButtonStateInfo } from '../../types';
|
import type { Book, ButtonStateInfo } from '../../types';
|
||||||
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
|
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
|
||||||
import { getSizedCoverUrl } from '../../utils/covers';
|
|
||||||
import { BookActionButton } from '../BookActionButton';
|
import { BookActionButton } from '../BookActionButton';
|
||||||
import { BookTargetDropdown } from '../BookTargetDropdown';
|
import { BookTargetDropdown } from '../BookTargetDropdown';
|
||||||
import { DisplayFieldBadges, DisplayFieldIcon } from '../shared';
|
import { DisplayFieldBadges, DisplayFieldIcon } from '../shared';
|
||||||
@@ -45,11 +44,6 @@ export const CompactView = ({
|
|||||||
const targetProvider = book.provider;
|
const targetProvider = book.provider;
|
||||||
const targetBookId = book.provider_id;
|
const targetBookId = book.provider_id;
|
||||||
const microphoneField = book.display_fields?.find((field) => field.icon === 'microphone');
|
const microphoneField = book.display_fields?.find((field) => field.icon === 'microphone');
|
||||||
const isSquareCover = book.cover_aspect === 'square';
|
|
||||||
const optimizedPreview = getSizedCoverUrl(book.preview, {
|
|
||||||
width: 120,
|
|
||||||
height: isSquareCover ? 120 : 180,
|
|
||||||
});
|
|
||||||
let zIndex: number | undefined;
|
let zIndex: number | undefined;
|
||||||
if (dropdownOpen) {
|
if (dropdownOpen) {
|
||||||
zIndex = 20;
|
zIndex = 20;
|
||||||
@@ -103,7 +97,7 @@ export const CompactView = ({
|
|||||||
#{book.series_position}
|
#{book.series_position}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{optimizedPreview && !imageError ? (
|
{book.preview && !imageError ? (
|
||||||
<>
|
<>
|
||||||
{!imageLoaded && (
|
{!imageLoaded && (
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
@@ -111,13 +105,10 @@ export const CompactView = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<img
|
<img
|
||||||
src={optimizedPreview}
|
src={book.preview}
|
||||||
alt={book.title || 'Book cover'}
|
alt={book.title || 'Book cover'}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
|
||||||
width={120}
|
|
||||||
height={isSquareCover ? 120 : 180}
|
|
||||||
style={{
|
style={{
|
||||||
opacity: imageLoaded ? 1 : 0,
|
opacity: imageLoaded ? 1 : 0,
|
||||||
transition: 'opacity 0.3s ease-in-out',
|
transition: 'opacity 0.3s ease-in-out',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useSearchMode } from '../../contexts/SearchModeContext';
|
|||||||
import type { Book, ButtonStateInfo, DisplayField } from '../../types';
|
import type { Book, ButtonStateInfo, DisplayField } from '../../types';
|
||||||
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
|
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
|
||||||
import { getFormatColor, getLanguageColor } from '../../utils/colorMaps';
|
import { getFormatColor, getLanguageColor } from '../../utils/colorMaps';
|
||||||
import { getSizedCoverUrl } from '../../utils/covers';
|
|
||||||
import { BookActionButton } from '../BookActionButton';
|
import { BookActionButton } from '../BookActionButton';
|
||||||
import { BookTargetDropdown } from '../BookTargetDropdown';
|
import { BookTargetDropdown } from '../BookTargetDropdown';
|
||||||
import { DisplayFieldIcon, DisplayFieldBadge } from '../shared';
|
import { DisplayFieldIcon, DisplayFieldBadge } from '../shared';
|
||||||
@@ -48,12 +47,8 @@ const ListViewThumbnail = ({
|
|||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const isSquare = coverAspect === 'square';
|
const isSquare = coverAspect === 'square';
|
||||||
const sizeClass = isSquare ? 'w-10 h-10 sm:w-14 sm:h-14' : 'w-7 h-10 sm:w-10 sm:h-14';
|
const sizeClass = isSquare ? 'w-10 h-10 sm:w-14 sm:h-14' : 'w-7 h-10 sm:w-10 sm:h-14';
|
||||||
const optimizedPreview = getSizedCoverUrl(preview, {
|
|
||||||
width: isSquare ? 56 : 40,
|
|
||||||
height: isSquare ? 56 : 56,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!optimizedPreview || imageError) {
|
if (!preview || imageError) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${sizeClass} flex items-center justify-center rounded-sm bg-gray-200 text-[8px] font-medium text-gray-500 sm:text-[9px] dark:bg-gray-700 dark:text-gray-300`}
|
className={`${sizeClass} flex items-center justify-center rounded-sm bg-gray-200 text-[8px] font-medium text-gray-500 sm:text-[9px] dark:bg-gray-700 dark:text-gray-300`}
|
||||||
@@ -72,13 +67,10 @@ const ListViewThumbnail = ({
|
|||||||
<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" />
|
<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
|
<img
|
||||||
src={optimizedPreview}
|
src={preview}
|
||||||
alt={title || 'Book cover'}
|
alt={title || 'Book cover'}
|
||||||
className={`h-full w-full object-cover ${isSquare ? 'object-center' : 'object-top'}`}
|
className={`h-full w-full object-cover ${isSquare ? 'object-center' : 'object-top'}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
|
||||||
width={isSquare ? 56 : 40}
|
|
||||||
height={isSquare ? 56 : 56}
|
|
||||||
onLoad={() => setImageLoaded(true)}
|
onLoad={() => setImageLoaded(true)}
|
||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }}
|
style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }}
|
||||||
|
|||||||
@@ -102,11 +102,11 @@ export const NamingTemplateField = ({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(boundField.key, event.target.value)}
|
onChange={(event) => onChange(boundField.key, event.target.value)}
|
||||||
placeholder={boundField.placeholder}
|
placeholder={boundField.placeholder}
|
||||||
|
aria-label={boundField.placeholder || 'Naming Template'}
|
||||||
maxLength={boundField.maxLength}
|
maxLength={boundField.maxLength}
|
||||||
disabled={fieldDisabled}
|
disabled={fieldDisabled}
|
||||||
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
|
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
{boundField.description && <p className="text-xs opacity-60">{boundField.description}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(hasPathSeparatorInFilename || preview.unknownTokens.length > 0) && (
|
{(hasPathSeparatorInFilename || preview.unknownTokens.length > 0) && (
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => {
|
|||||||
{' '}
|
{' '}
|
||||||
<span className="opacity-40"># Hide the local login form</span>
|
<span className="opacity-40"># Hide the local login form</span>
|
||||||
{'\n'}
|
{'\n'}
|
||||||
|
{' '}- <span className="text-blue-400">DISABLE_LOCAL_AUTH</span>=
|
||||||
|
<span className="text-green-400">true</span>{' '}
|
||||||
|
<span className="opacity-40"># Disable username/password login</span>
|
||||||
|
{'\n'}
|
||||||
{' '}- <span className="text-blue-400">OIDC_AUTO_REDIRECT</span>=
|
{' '}- <span className="text-blue-400">OIDC_AUTO_REDIRECT</span>=
|
||||||
<span className="text-green-400">true</span>
|
<span className="text-green-400">true</span>
|
||||||
{' '}
|
{' '}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const NumberField = ({ field, value, onChange, disabled }: NumberFieldPro
|
|||||||
min={field.min}
|
min={field.min}
|
||||||
max={field.max}
|
max={field.max}
|
||||||
step={field.step ?? 1}
|
step={field.step ?? 1}
|
||||||
|
aria-label={field.label || 'Number field'}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
|
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const PasswordField = ({ field, value, onChange, disabled }: PasswordFiel
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
|
aria-label={field.label || field.placeholder || 'Password field'}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 pr-10 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
|
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 pr-10 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
|||||||
checked={Boolean(cellValue)}
|
checked={Boolean(cellValue)}
|
||||||
onChange={(e) => updateCell(rowIndex, col.key, e.target.checked)}
|
onChange={(e) => updateCell(rowIndex, col.key, e.target.checked)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
aria-label={`${col.label || col.key} row ${rowIndex + 1}`}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500 disabled:cursor-not-allowed disabled:opacity-60"
|
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,6 +362,7 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
|
|||||||
value={toPrimitiveString(cellValue)}
|
value={toPrimitiveString(cellValue)}
|
||||||
onChange={(e) => updateCell(rowIndex, col.key, e.target.value)}
|
onChange={(e) => updateCell(rowIndex, col.key, e.target.value)}
|
||||||
placeholder={col.placeholder}
|
placeholder={col.placeholder}
|
||||||
|
aria-label={`${col.label || col.key} row ${rowIndex + 1}`}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
|
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export const TagListField = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
aria-label={field.label || field.placeholder || 'Add item'}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const TextField = ({ field, value, onChange, disabled }: TextFieldProps)
|
|||||||
value={value ?? ''}
|
value={value ?? ''}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
|
aria-label={field.label || field.placeholder || 'Text field'}
|
||||||
maxLength={field.maxLength}
|
maxLength={field.maxLength}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
|
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
|||||||
@@ -139,9 +139,11 @@ function formatUserOverrideValue(value: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_DETAILS: Array<{ userId: number; username: string; value: unknown }> = [];
|
||||||
|
|
||||||
const UserOverriddenBadge = ({
|
const UserOverriddenBadge = ({
|
||||||
count,
|
count,
|
||||||
details = [],
|
details = EMPTY_DETAILS,
|
||||||
}: {
|
}: {
|
||||||
count: number;
|
count: number;
|
||||||
details?: Array<{ userId: number; username: string; value: unknown }>;
|
details?: Array<{ userId: number; username: string; value: unknown }>;
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const modeDescriptions: Record<RequestPolicyMode, string> = {
|
|||||||
blocked: 'Downloads and requests are blocked.',
|
blocked: 'Downloads and requests are blocked.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_BASE_RULES: RequestPolicyRuleRow[] = [];
|
||||||
|
|
||||||
export const RequestPolicyGrid = ({
|
export const RequestPolicyGrid = ({
|
||||||
defaultModes,
|
defaultModes,
|
||||||
onDefaultModeChange,
|
onDefaultModeChange,
|
||||||
@@ -67,7 +69,7 @@ export const RequestPolicyGrid = ({
|
|||||||
defaultModeOverrides,
|
defaultModeOverrides,
|
||||||
defaultModeDisabled,
|
defaultModeDisabled,
|
||||||
explicitRules,
|
explicitRules,
|
||||||
baseRules = [],
|
baseRules = EMPTY_BASE_RULES,
|
||||||
onExplicitRulesChange,
|
onExplicitRulesChange,
|
||||||
sourceCapabilities,
|
sourceCapabilities,
|
||||||
rulesDisabled = false,
|
rulesDisabled = false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ interface ToggleSwitchProps {
|
|||||||
onChange: (checked: boolean) => void;
|
onChange: (checked: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
color?: 'sky' | 'emerald';
|
color?: 'sky' | 'emerald';
|
||||||
|
ariaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorClasses = {
|
const colorClasses = {
|
||||||
@@ -15,6 +16,7 @@ export const ToggleSwitch = ({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
color = 'sky',
|
color = 'sky',
|
||||||
|
ariaLabel = 'Toggle switch',
|
||||||
}: ToggleSwitchProps) => {
|
}: ToggleSwitchProps) => {
|
||||||
const { active, ring } = colorClasses[color];
|
const { active, ring } = colorClasses[color];
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ export const ToggleSwitch = ({
|
|||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
|
aria-label={ariaLabel}
|
||||||
onClick={() => !disabled && onChange(!checked)}
|
onClick={() => !disabled && onChange(!checked)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:ring-2 focus:outline-hidden ${ring} disabled:cursor-not-allowed disabled:opacity-60 ${checked ? active : 'bg-gray-300 dark:bg-gray-600'}`}
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:ring-2 focus:outline-hidden ${ring} disabled:cursor-not-allowed disabled:opacity-60 ${checked ? active : 'bg-gray-300 dark:bg-gray-600'}`}
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ const hydrateSettingsResponse = (response: SettingsResponse): HydratedSettingsSt
|
|||||||
});
|
});
|
||||||
|
|
||||||
const values = extractSettingsValues(tabs);
|
const values = extractSettingsValues(tabs);
|
||||||
if (values.general && Object.prototype.hasOwnProperty.call(values.general, '_THEME')) {
|
if (values.general && Object.prototype.hasOwnProperty.call(values.general, THEME_FIELD.key)) {
|
||||||
values.general._THEME = getStoredThemePreference();
|
values.general[THEME_FIELD.key] = getStoredThemePreference();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -208,7 +208,7 @@ export function useSettings(): UseSettingsReturn {
|
|||||||
|
|
||||||
const updateValue = useCallback(
|
const updateValue = useCallback(
|
||||||
(tabName: string, key: string, value: unknown) => {
|
(tabName: string, key: string, value: unknown) => {
|
||||||
if (key === '_THEME' && typeof value === 'string') {
|
if (key === THEME_FIELD.key && typeof value === 'string') {
|
||||||
setThemePreference(value);
|
setThemePreference(value);
|
||||||
setOriginalValues((prev) => ({
|
setOriginalValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -277,7 +277,7 @@ export function useSettings(): UseSettingsReturn {
|
|||||||
if (tab) {
|
if (tab) {
|
||||||
for (const field of getValueBearingFields(tab.fields)) {
|
for (const field of getValueBearingFields(tab.fields)) {
|
||||||
if (field.fromEnv) continue; // Skip env-locked fields
|
if (field.fromEnv) continue; // Skip env-locked fields
|
||||||
if (field.key === '_THEME') continue; // Skip client-side only theme field
|
if (field.key === THEME_FIELD.key) continue; // Skip client-side only theme field
|
||||||
|
|
||||||
const value = tabValues[field.key];
|
const value = tabValues[field.key];
|
||||||
const originalValue = originalTabValues[field.key];
|
const originalValue = originalTabValues[field.key];
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function useUrlSearch({ enabled }: UseUrlSearchOptions): UseUrlSearchRetu
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseUrlSearchParams(searchParams);
|
const parsed = parseUrlSearchParams(searchParams);
|
||||||
return parsed.hasSearchParams || parsed.contentType ? parsed : null;
|
return parsed.hasSearchParams || parsed.contentType || parsed.combinedMode ? parsed : null;
|
||||||
}, [enabled, searchParams]);
|
}, [enabled, searchParams]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { getSizedCoverUrl } from '../utils/covers';
|
|
||||||
|
|
||||||
describe('getSizedCoverUrl', () => {
|
|
||||||
it('adds size and format params to local cover proxy URLs', () => {
|
|
||||||
expect(
|
|
||||||
getSizedCoverUrl('/api/covers/book-1?url=abc', {
|
|
||||||
width: 120,
|
|
||||||
height: 180,
|
|
||||||
}),
|
|
||||||
).toBe('/api/covers/book-1?url=abc&w=120&h=180&format=webp');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves external preview URLs alone', () => {
|
|
||||||
expect(
|
|
||||||
getSizedCoverUrl('https://covers.example.com/book.jpg', {
|
|
||||||
width: 120,
|
|
||||||
height: 180,
|
|
||||||
}),
|
|
||||||
).toBe('https://covers.example.com/book.jpg');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves absolute proxy URLs', () => {
|
|
||||||
expect(
|
|
||||||
getSizedCoverUrl('https://bookrequest.example.com/api/covers/book-1?url=abc', {
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
format: 'png',
|
|
||||||
}),
|
|
||||||
).toBe('https://bookrequest.example.com/api/covers/book-1?url=abc&w=56&h=56&format=png');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,7 +4,9 @@ import type { Language } from '../types/index';
|
|||||||
import {
|
import {
|
||||||
LANGUAGE_OPTION_ALL,
|
LANGUAGE_OPTION_ALL,
|
||||||
LANGUAGE_OPTION_DEFAULT,
|
LANGUAGE_OPTION_DEFAULT,
|
||||||
|
buildLanguageNormalizer,
|
||||||
getReleaseSearchLanguageParams,
|
getReleaseSearchLanguageParams,
|
||||||
|
releaseLanguageMatchesFilter,
|
||||||
} from '../utils/languageFilters';
|
} from '../utils/languageFilters';
|
||||||
|
|
||||||
const supportedLanguages: Language[] = [
|
const supportedLanguages: Language[] = [
|
||||||
@@ -35,4 +37,33 @@ describe('languageFilters release search params', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(['de', 'hu']);
|
expect(result).toEqual(['de', 'hu']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes legacy default language names when combined with explicit filters', () => {
|
||||||
|
const result = getReleaseSearchLanguageParams(
|
||||||
|
[LANGUAGE_OPTION_DEFAULT, 'de'],
|
||||||
|
supportedLanguages,
|
||||||
|
['english'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(['en', 'de']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('releaseLanguageMatchesFilter', () => {
|
||||||
|
it('matches release language names against legacy default language names', () => {
|
||||||
|
const normalizer = buildLanguageNormalizer(supportedLanguages);
|
||||||
|
|
||||||
|
expect(releaseLanguageMatchesFilter('English', ['english'], normalizer)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps English-only issue 948 fallback results with a legacy English default', () => {
|
||||||
|
const normalizer = buildLanguageNormalizer(supportedLanguages);
|
||||||
|
const issue948Languages = [...Array<string>(48).fill('en'), 'de, en', 'en, es'];
|
||||||
|
|
||||||
|
const visibleLanguages = issue948Languages.filter((language) =>
|
||||||
|
releaseLanguageMatchesFilter(language, ['english'], normalizer),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(visibleLanguages).toHaveLength(48);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildNamingTemplatePreview,
|
buildNamingTemplatePreview,
|
||||||
|
NAMING_TEMPLATE_TOKENS,
|
||||||
renderNamingTemplate,
|
renderNamingTemplate,
|
||||||
SAMPLE_NAMING_METADATA,
|
SAMPLE_NAMING_METADATA,
|
||||||
} from '../utils/namingTemplatePreview';
|
} from '../utils/namingTemplatePreview';
|
||||||
|
|
||||||
describe('namingTemplatePreview', () => {
|
describe('namingTemplatePreview', () => {
|
||||||
|
it('groups primary title with universal variables', () => {
|
||||||
|
expect(NAMING_TEMPLATE_TOKENS.find((token) => token.token === 'PrimaryTitle')?.group).toBe(
|
||||||
|
'Universal',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders primary title in path previews', () => {
|
it('renders primary title in path previews', () => {
|
||||||
const preview = buildNamingTemplatePreview(
|
const preview = buildNamingTemplatePreview(
|
||||||
'{Author}/{Series/}{SeriesPosition - }{PrimaryTitle} ({Year})',
|
'{Author}/{Series/}{SeriesPosition - }{PrimaryTitle} ({Year})',
|
||||||
|
|||||||
@@ -42,4 +42,22 @@ describe('parseUrlSearchParams', () => {
|
|||||||
expect(parsed.hasSearchParams).toBe(false);
|
expect(parsed.hasSearchParams).toBe(false);
|
||||||
expect(parsed.contentType).toBe('ebook');
|
expect(parsed.contentType).toBe('ebook');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses content_type=combined as a combined-mode override', () => {
|
||||||
|
const parsed = parseUrlSearchParams(new URLSearchParams('q=dune&content_type=combined'));
|
||||||
|
|
||||||
|
expect(parsed.searchInput).toBe('dune');
|
||||||
|
expect(parsed.hasSearchParams).toBe(true);
|
||||||
|
expect(parsed.contentType).toBe(undefined);
|
||||||
|
expect(parsed.combinedMode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps combined-only links from auto-triggering a blank search', () => {
|
||||||
|
const parsed = parseUrlSearchParams(new URLSearchParams('content_type=combined'));
|
||||||
|
|
||||||
|
expect(parsed.searchInput).toBe('');
|
||||||
|
expect(parsed.hasSearchParams).toBe(false);
|
||||||
|
expect(parsed.contentType).toBe(undefined);
|
||||||
|
expect(parsed.combinedMode).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ export interface AppConfig {
|
|||||||
default_release_source_audiobook?: string; // Default tab in ReleaseModal for audiobooks
|
default_release_source_audiobook?: string; // Default tab in ReleaseModal for audiobooks
|
||||||
show_release_source_links: boolean;
|
show_release_source_links: boolean;
|
||||||
show_combined_selector: boolean;
|
show_combined_selector: boolean;
|
||||||
|
force_combined_search: boolean;
|
||||||
books_output_mode: BooksOutputMode;
|
books_output_mode: BooksOutputMode;
|
||||||
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
|
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
|
||||||
hardcover_auto_remove_on_download: boolean; // Auto-remove from active Hardcover list on download
|
hardcover_auto_remove_on_download: boolean; // Auto-remove from active Hardcover list on download
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ const resolveBasePath = (): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Lazy initialization to ensure DOM is ready when base path is resolved
|
// Lazy initialization to ensure DOM is ready when base path is resolved
|
||||||
let _basePath: string | null = null;
|
let cachedBasePath: string | null = null;
|
||||||
|
|
||||||
export const getBasePath = (): string => {
|
export const getBasePath = (): string => {
|
||||||
if (_basePath === null) {
|
if (cachedBasePath === null) {
|
||||||
_basePath = normalizeBasePath(resolveBasePath());
|
cachedBasePath = normalizeBasePath(resolveBasePath());
|
||||||
}
|
}
|
||||||
return _basePath;
|
return cachedBasePath;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const withBasePath = (path: string): string => {
|
export const withBasePath = (path: string): string => {
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
const COVER_PROXY_PATH = '/api/covers/';
|
|
||||||
const LOCAL_URL_BASE = 'http://shelfmark.local';
|
|
||||||
const DEFAULT_COVER_FORMAT = 'webp';
|
|
||||||
const MAX_COVER_DIMENSION = 1024;
|
|
||||||
|
|
||||||
type CoverFormat = 'jpeg' | 'png' | 'webp';
|
|
||||||
|
|
||||||
interface SizedCoverUrlOptions {
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
format?: CoverFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeDimension = (value?: number) => {
|
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rounded = Math.round(value);
|
|
||||||
if (rounded <= 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.min(rounded, MAX_COVER_DIMENSION);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSizedCoverUrl = (
|
|
||||||
preview?: string,
|
|
||||||
{ width, height, format = DEFAULT_COVER_FORMAT }: SizedCoverUrlOptions = {},
|
|
||||||
) => {
|
|
||||||
if (!preview) {
|
|
||||||
return preview;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRelativeUrl = preview.startsWith('/');
|
|
||||||
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(preview, LOCAL_URL_BASE);
|
|
||||||
} catch {
|
|
||||||
return preview;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!url.pathname.includes(COVER_PROXY_PATH)) {
|
|
||||||
return preview;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedWidth = normalizeDimension(width);
|
|
||||||
const normalizedHeight = normalizeDimension(height);
|
|
||||||
|
|
||||||
if (normalizedWidth !== undefined) {
|
|
||||||
url.searchParams.set('w', String(normalizedWidth));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedHeight !== undefined) {
|
|
||||||
url.searchParams.set('h', String(normalizedHeight));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (format) {
|
|
||||||
url.searchParams.set('format', format);
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = url.searchParams.toString();
|
|
||||||
const relativeUrl = `${url.pathname}${search ? `?${search}` : ''}${url.hash}`;
|
|
||||||
return isRelativeUrl ? relativeUrl : url.toString();
|
|
||||||
};
|
|
||||||
@@ -48,8 +48,11 @@ export const getLanguageFilterValues = (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportedCodes = new Set(supportedLanguages.map((lang) => lang.code));
|
const languageNormalizer = buildLanguageNormalizer(supportedLanguages);
|
||||||
const defaultCodes = defaultLanguageCodes.filter((code) => supportedCodes.has(code));
|
const supportedCodes = new Set(supportedLanguages.map((lang) => lang.code.toLowerCase()));
|
||||||
|
const defaultCodes = defaultLanguageCodes
|
||||||
|
.map((code) => languageNormalizer.get(code.toLowerCase()) ?? code.toLowerCase())
|
||||||
|
.filter((code) => supportedCodes.has(code));
|
||||||
const resolved = new Set<string>();
|
const resolved = new Set<string>();
|
||||||
|
|
||||||
uniqueSelection.forEach((code) => {
|
uniqueSelection.forEach((code) => {
|
||||||
@@ -58,8 +61,9 @@ export const getLanguageFilterValues = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supportedCodes.has(code)) {
|
const normalizedCode = languageNormalizer.get(code.toLowerCase()) ?? code.toLowerCase();
|
||||||
resolved.add(code);
|
if (supportedCodes.has(normalizedCode)) {
|
||||||
|
resolved.add(normalizedCode);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,6 +134,11 @@ export const releaseLanguageMatchesFilter = (
|
|||||||
return part;
|
return part;
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedSet = new Set(selectedCodes.map((c) => c.toLowerCase()));
|
const selectedSet = new Set(
|
||||||
|
selectedCodes.map((code) => {
|
||||||
|
const normalizedCode = code.toLowerCase();
|
||||||
|
return languageNormalizer?.get(normalizedCode) ?? normalizedCode;
|
||||||
|
}),
|
||||||
|
);
|
||||||
return releaseCodes.every((code) => selectedSet.has(code));
|
return releaseCodes.every((code) => selectedSet.has(code));
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user