Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3554d01c81 | ||
|
|
9dd445f2af | ||
|
|
4e41b1a8ec | ||
|
|
c7619488cd | ||
|
|
eba04115ae | ||
|
|
26bba25777 | ||
|
|
795887ae45 | ||
|
|
74a466139d | ||
|
|
d6a7588d22 | ||
|
|
700935ec17 | ||
|
|
f7f920683d | ||
|
|
819d794039 | ||
|
|
e0980a84d9 | ||
|
|
b8fdb2c841 | ||
|
|
8bba0e28d7 | ||
|
|
791ba184ee | ||
|
|
e35b4c47a7 | ||
|
|
18a3f0bf44 | ||
|
|
0f7bcf8fd9 | ||
|
|
98f891916d | ||
|
|
a7694eb8ad | ||
|
|
87d5f127d6 | ||
|
|
d7b9f2e67f | ||
|
|
41c4aa1d72 | ||
|
|
704da62202 | ||
|
|
8d98e122ec | ||
|
|
28eef75de0 | ||
|
|
b3b8f34a13 | ||
|
|
8e78fea947 | ||
|
|
7bc6a9f8c6 | ||
|
|
962e0ec68b | ||
|
|
3d68b5eb2f | ||
|
|
ba4090aee2 | ||
|
|
03ec7d1c06 | ||
|
|
71ee56b7b7 | ||
|
|
e3d5bd91fc | ||
|
|
af38540991 | ||
|
|
3a3a3ce449 | ||
|
|
ff094bed56 | ||
|
|
c1143f808a | ||
|
|
9bfcf828ea | ||
|
|
678c54cba2 | ||
|
|
019d36b27e | ||
|
|
698eb07e71 | ||
|
|
8f949a73d5 | ||
|
|
2c6f46fc88 | ||
|
|
3f90c3805f | ||
|
|
cb093f61c6 | ||
|
|
b464d62672 | ||
|
|
f3f26488b1 | ||
|
|
fec9d31c8a | ||
|
|
3295be82a7 | ||
|
|
fff0fd07a1 | ||
|
|
3f1a14843b | ||
|
|
21a11b06b9 | ||
|
|
0d856a3ef5 | ||
|
|
ebf4312174 | ||
|
|
685c35d552 | ||
|
|
3d72f9e258 | ||
|
|
7f79da11e6 | ||
|
|
c59ea46540 | ||
|
|
a2a5a22324 | ||
|
|
a7db7f04e9 | ||
|
|
9d08bb3ef1 | ||
|
|
80aa289a64 | ||
|
|
edb437e905 | ||
|
|
72464e32b8 | ||
|
|
60893b19c6 | ||
|
|
8bb188c903 | ||
|
|
d6d10a450e | ||
|
|
4b0d1aef13 | ||
|
|
447ed1a924 | ||
|
|
ba92ad90bc | ||
|
|
cce2c10704 | ||
|
|
fbe25725d3 | ||
|
|
bd65bccf52 | ||
|
|
71900e00db | ||
|
|
de18f2b9fe | ||
|
|
6718848cfb | ||
|
|
7d992c3918 | ||
|
|
9593c040b0 | ||
|
|
ea0d06ae08 | ||
|
|
0f3a06bc9c | ||
|
|
d78aad066b | ||
|
|
e7d2845235 | ||
|
|
ac36d539c8 | ||
|
|
91cbd51b67 | ||
|
|
c80c88676c | ||
|
|
0d271f1f69 | ||
|
|
014fc38b48 | ||
|
|
fdd46852f2 | ||
|
|
a57d081caa | ||
|
|
a50b43538a | ||
|
|
8ad7f35136 | ||
|
|
05115f7b41 | ||
|
|
554f5fcbe7 | ||
|
|
8ff2d776ae | ||
|
|
6c351f4bf3 | ||
|
|
7fdf55f5fd | ||
|
|
dd6fd1e199 | ||
|
|
ccb39e674e | ||
|
|
1931eb96a5 | ||
|
|
b7bee132a1 | ||
|
|
68608b6162 | ||
|
|
af9d9ec8db | ||
|
|
a7064939ce | ||
|
|
5bed0b20f4 | ||
|
|
2d2f54729f | ||
|
|
b5923635a6 | ||
|
|
e09f5f7757 | ||
|
|
022e50a0ba | ||
|
|
a560089ce3 | ||
|
|
f84fb082ad | ||
|
|
b10458a48b | ||
|
|
f6dba959c9 | ||
|
|
e5ccabe1ef | ||
|
|
86082c999c | ||
|
|
301b2e5456 | ||
|
|
4fde128fc7 | ||
|
|
d050417e01 | ||
|
|
0a7785a333 |
@@ -1,6 +1,7 @@
|
||||
.git
|
||||
.github
|
||||
.vscode
|
||||
.local
|
||||
.mypy_cache
|
||||
README_images
|
||||
.gitignore
|
||||
|
||||
@@ -11,9 +11,9 @@ assignees: ''
|
||||
|
||||
## Steps To Reproduce
|
||||
<!-- Steps to reproduce the behavior -->
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behavior
|
||||
<!-- A clear and concise description of what you expected to happen -->
|
||||
@@ -34,4 +34,4 @@ When running in debug mode, a DEBUG button will appear in the interface. Please
|
||||
<!-- If applicable, please provide your full docker-compose (redacted from any secrets) -->
|
||||
|
||||
## Additional Context
|
||||
<!-- Add any other context about the problem here -->
|
||||
<!-- Add any other context about the problem here -->
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Python dependencies
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
python-deps:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
# Frontend npm dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/src/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
npm-deps:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
# Dockerfile base images
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
docker-images:
|
||||
patterns: ["*"]
|
||||
ignore:
|
||||
# Node.js: block major-version bumps so dependabot never proposes
|
||||
# moving from one LTS line to a non-LTS "Current" release (e.g. 24 -> 25).
|
||||
# Node LTS is even-numbered only; major bumps should be deliberate.
|
||||
- dependency-name: "node"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
gh-actions:
|
||||
patterns: ["*"]
|
||||
@@ -1,16 +1,54 @@
|
||||
name: Create and publish Docker images
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
tags:
|
||||
- 'v*'
|
||||
schedule:
|
||||
# Nightly at 03:17 UTC — only builds if there are new commits on main
|
||||
# since the last successful run (see check-changes job).
|
||||
- cron: '17 3 * * *'
|
||||
workflow_dispatch:
|
||||
permissions: read-all
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/shelfmark
|
||||
jobs:
|
||||
check-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
steps:
|
||||
- name: Check for new commits since last successful build
|
||||
id: check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
CURRENT_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# Always build on tag pushes and manual dispatch.
|
||||
if [[ "$EVENT_NAME" != "schedule" ]]; then
|
||||
echo "Event is $EVENT_NAME — building unconditionally."
|
||||
echo "should_build=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Scheduled run: only build if HEAD differs from the last successful build on main.
|
||||
LAST_SHA=$(gh api "/repos/${REPO}/actions/workflows/build-and-publish-docker-image.yml/runs?branch=main&status=success&per_page=1" --jq '.workflow_runs[0].head_sha' 2>/dev/null || true)
|
||||
echo "Last successful build SHA: ${LAST_SHA:-<none>}"
|
||||
echo "Current HEAD SHA: ${CURRENT_SHA}"
|
||||
if [[ -z "$LAST_SHA" || "$LAST_SHA" != "$CURRENT_SHA" ]]; then
|
||||
echo "New commits detected — building."
|
||||
echo "should_build=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No new commits since last successful build — skipping."
|
||||
echo "should_build=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build-and-push-images:
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -27,20 +65,20 @@ jobs:
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
- name: Extract metadata for ${{ matrix.target }} image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
|
||||
tags: |
|
||||
@@ -50,13 +88,13 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
type=ref,event=tag
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build and push ${{ matrix.target }} Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
@@ -67,10 +105,10 @@ jobs:
|
||||
RELEASE_VERSION=${{ github.ref_name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
- name: Generate artifact attestation for ${{ matrix.target }} image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/attest-build-provenance@v2
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
@@ -89,14 +127,14 @@ jobs:
|
||||
LEGACY_NAME: calibre-web-automated-book-downloader
|
||||
steps:
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Create legacy aliases
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
python-quality:
|
||||
name: Python Quality
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
|
||||
- name: Sync dependencies
|
||||
run: make install-python-dev
|
||||
|
||||
- name: Lint
|
||||
run: make python-lint
|
||||
|
||||
- name: Check formatting
|
||||
run: make python-format
|
||||
|
||||
- name: Check dead code
|
||||
run: make python-dead-code
|
||||
|
||||
python-typechecks:
|
||||
name: Python Typechecks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
|
||||
- name: Sync dependencies
|
||||
run: make install-python-dev
|
||||
|
||||
- name: Typecheck
|
||||
run: make python-typecheck
|
||||
|
||||
python-tests:
|
||||
name: Python Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: "0.11.3"
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
|
||||
- name: Sync dependencies
|
||||
run: make install-python-dev
|
||||
|
||||
- name: Run tests
|
||||
run: make python-test
|
||||
|
||||
docker-build-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build shelfmark-lite image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: shelfmark-lite
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
build-args: |
|
||||
BUILD_VERSION=pr-${{ github.sha }}
|
||||
RELEASE_VERSION=pr-${{ github.event.pull_request.number }}
|
||||
|
||||
frontend-quality:
|
||||
name: Frontend Quality
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: make install-ci
|
||||
|
||||
- name: Lint
|
||||
run: make frontend-lint
|
||||
|
||||
- name: Check formatting
|
||||
run: make frontend-format
|
||||
|
||||
frontend-typechecks:
|
||||
name: Frontend Typechecks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: make install-ci
|
||||
|
||||
- name: Typecheck
|
||||
run: make frontend-typecheck
|
||||
|
||||
frontend-tests:
|
||||
name: Frontend Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: make install-ci
|
||||
|
||||
- name: Unit tests
|
||||
run: make frontend-test
|
||||
@@ -0,0 +1,38 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly on Monday at 6am UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [python, javascript-typescript]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -74,6 +74,7 @@ pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
src/frontend/coverage/
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
@@ -231,4 +232,7 @@ pyrightconfig.json
|
||||
*.local.*
|
||||
AGENTS.md
|
||||
.claude/
|
||||
.nvmrc
|
||||
.playwright-mcp/
|
||||
frontend-dist/
|
||||
node_modules/
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
repos:
|
||||
- repo: builtin
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-added-large-files
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.10
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: oxfmt
|
||||
name: oxfmt
|
||||
entry: npx --prefix src/frontend oxfmt --config src/frontend/.oxfmtrc.json
|
||||
language: system
|
||||
types_or: [javascript, jsx, ts, tsx, css, json]
|
||||
files: ^src/frontend/
|
||||
exclude: package-lock\.json
|
||||
@@ -61,4 +61,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ ARG BUILDPLATFORM
|
||||
ARG BUILDARCH
|
||||
|
||||
# Frontend build stage.
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-builder
|
||||
|
||||
# Helpful debug output to see what platforms BuildKit thinks it's using
|
||||
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
|
||||
@@ -25,7 +25,9 @@ COPY src/frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Use python-slim as the base image
|
||||
FROM python:3.10-slim AS base
|
||||
FROM python:3.14-slim AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.3 /uv /uvx /bin/
|
||||
|
||||
# Add build argument for version
|
||||
ARG BUILD_VERSION
|
||||
@@ -39,13 +41,12 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
# Consistent environment variables grouped together
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
DOCKERMODE=true \
|
||||
UV_LINK_MODE=copy \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONIOENCODING=UTF-8 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_DEFAULT_TIMEOUT=100 \
|
||||
NAME=Shelfmark \
|
||||
PATH=/app/.venv/bin:$PATH \
|
||||
PYTHONPATH=/app \
|
||||
# PUID/PGID will be handled by entrypoint script, but TZ/Locale are still needed
|
||||
LANG=en_US.UTF-8 \
|
||||
@@ -56,7 +57,6 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
ENV FLASK_PORT=8084
|
||||
|
||||
# Configure locale, timezone, and perform initial cleanup in a single layer
|
||||
# User/group creation is removed
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
# For locale
|
||||
@@ -68,7 +68,7 @@ RUN apt-get update && \
|
||||
# For debug
|
||||
zip iputils-ping \
|
||||
# For user switching
|
||||
sudo \
|
||||
gosu \
|
||||
# --- Tor support (activated via USING_TOR=true) ---
|
||||
tor \
|
||||
supervisor \
|
||||
@@ -88,15 +88,20 @@ RUN apt-get update && \
|
||||
echo "LC_ALL=en_US.UTF-8" >> /etc/environment && \
|
||||
echo "LANG=en_US.UTF-8" > /etc/locale.conf
|
||||
|
||||
# Create a fixed runtime user/group so hardened Docker/Kubernetes deployments
|
||||
# can start the container directly as a non-root user with a passwd entry.
|
||||
RUN groupadd -g 1000 shelfmark && \
|
||||
useradd -u 1000 -g shelfmark -d /home/shelfmark -s /usr/sbin/nologin shelfmark && \
|
||||
mkdir -p /home/shelfmark && \
|
||||
chown 1000:1000 /home/shelfmark
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies using pip
|
||||
# Copying requirements files separately leverages build cache
|
||||
# Cache mount persists pip cache between builds for faster installs
|
||||
COPY requirements-base.txt requirements-shelfmark.txt ./
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install -r requirements-base.txt
|
||||
# Install core Python dependencies first for better layer caching
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-default-groups
|
||||
|
||||
# Copy application code *after* dependencies are installed
|
||||
COPY . .
|
||||
@@ -104,10 +109,19 @@ COPY . .
|
||||
# Copy built frontend from frontend-builder stage
|
||||
COPY --from=frontend-builder /frontend/dist /app/frontend-dist
|
||||
|
||||
# Final setup: permissions and directories in one layer
|
||||
# Only creating directories and setting executable bits.
|
||||
# Ownership will be handled by the entrypoint script.
|
||||
RUN mkdir -p /var/log/shelfmark /books && \
|
||||
# Final setup: create image-owned runtime paths for the fixed non-root user.
|
||||
# Root/PUID mode still re-homes ownership at startup when needed.
|
||||
RUN mkdir -p \
|
||||
/config \
|
||||
/books \
|
||||
/var/log/shelfmark \
|
||||
/tmp/shelfmark/seleniumbase/downloaded_files \
|
||||
/tmp/shelfmark/seleniumbase/archived_files && \
|
||||
rm -rf /app/downloaded_files /app/archived_files && \
|
||||
ln -s /tmp/shelfmark/seleniumbase/downloaded_files /app/downloaded_files && \
|
||||
ln -s /tmp/shelfmark/seleniumbase/archived_files /app/archived_files && \
|
||||
chown -R 1000:1000 /config /books /home/shelfmark /tmp/shelfmark /var/log/shelfmark && \
|
||||
chmod -R a+rX /app && \
|
||||
chmod +x /app/entrypoint.sh /app/tor.sh /app/genDebug.sh
|
||||
|
||||
# Expose the application port
|
||||
@@ -130,11 +144,11 @@ RUN apt-get update && \
|
||||
xvfb \
|
||||
# For screen recording
|
||||
ffmpeg \
|
||||
# --- Chromium ---
|
||||
chromium=143.0.7499.169-1~deb13u1 \
|
||||
chromium-common=143.0.7499.169-1~deb13u1 \
|
||||
# --- ChromeDriver ---
|
||||
chromium-driver=143.0.7499.169-1~deb13u1 \
|
||||
# --- Chromium (unpinned - uses latest from Debian repos) ---
|
||||
# Chrome 144+ requires --enable-unsafe-swiftshader for WebGL in Docker.
|
||||
# This flag is set in internal_bypasser.py _get_browser_args()
|
||||
chromium \
|
||||
chromium-common \
|
||||
# For tkinter (pyautogui)
|
||||
python3-tk \
|
||||
# For RAR extraction
|
||||
@@ -146,14 +160,18 @@ RUN apt-get update && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install additional dependencies (requirements file already copied in base stage)
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install -r requirements-shelfmark.txt
|
||||
# Install the browser automation stack used by the full image
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-default-groups --extra browser
|
||||
|
||||
# Keep SeleniumBase's bundled driver cache writable for the fixed non-root user.
|
||||
RUN SELENIUMBASE_DRIVERS_DIR=$(/app/.venv/bin/python -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')") && \
|
||||
chown -R 1000:1000 "${SELENIUMBASE_DRIVERS_DIR}" && \
|
||||
chmod -R u+rwX,go+rX "${SELENIUMBASE_DRIVERS_DIR}" && \
|
||||
if [ -f "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" ]; then chmod +x "${SELENIUMBASE_DRIVERS_DIR}/uc_driver"; fi
|
||||
|
||||
# Grant read/execute permissions to others
|
||||
RUN chmod -R o+rx /usr/bin/chromium && \
|
||||
chmod -R o+rx /usr/bin/chromedriver && \
|
||||
chmod -R o+rwx /usr/local/lib/python3.10/site-packages/seleniumbase/drivers/
|
||||
RUN chmod -R o+rx /usr/bin/chromium
|
||||
|
||||
# Default command to run the application entrypoint script
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install dev build preview typecheck clean up down docker-build refresh restart
|
||||
.PHONY: help install install-ci install-python-dev dev build preview frontend-typecheck frontend-lint frontend-format frontend-format-fix frontend-checks frontend-test clean up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-fix python-typecheck python-dead-code python-checks python-test python-test-cov checks fix
|
||||
|
||||
# Frontend directory
|
||||
FRONTEND_DIR := src/frontend
|
||||
@@ -10,12 +10,34 @@ COMPOSE_FILE := docker-compose.dev.yml
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo ""
|
||||
@echo "Quality:"
|
||||
@echo " checks - Run ALL static analysis checks (frontend + Python)"
|
||||
@echo " fix - Auto-fix lint + format issues (frontend + Python)"
|
||||
@echo ""
|
||||
@echo "Frontend:"
|
||||
@echo " install - Install frontend dependencies"
|
||||
@echo " dev - Start development server"
|
||||
@echo " build - Build frontend for production"
|
||||
@echo " build-serve - Build and serve via Flask (test prod build without Docker)"
|
||||
@echo " preview - Preview production build"
|
||||
@echo " typecheck - Run TypeScript type checking"
|
||||
@echo " frontend-typecheck - Run TypeScript type checking"
|
||||
@echo " frontend-lint - Run Oxlint against frontend code"
|
||||
@echo " frontend-format - Check frontend formatting with Oxfmt"
|
||||
@echo " frontend-format-fix - Format frontend code with Oxfmt"
|
||||
@echo " frontend-checks - Run all frontend static analysis checks"
|
||||
@echo " frontend-test - Run frontend unit tests"
|
||||
@echo ""
|
||||
@echo "Python:"
|
||||
@echo " install-python-dev - Sync Python runtime + dev tooling with uv"
|
||||
@echo " python-lint - Run Ruff against Python code (backend + tests)"
|
||||
@echo " python-lint-fix - Run Ruff with safe auto-fixes"
|
||||
@echo " python-format - Check Python formatting with Ruff"
|
||||
@echo " python-format-fix - Format Python code with Ruff"
|
||||
@echo " python-typecheck - Run BasedPyright against backend + tests"
|
||||
@echo " python-dead-code - Run Vulture against backend code"
|
||||
@echo " python-checks - Run all Python static analysis checks"
|
||||
@echo " python-test - Run unit tests"
|
||||
@echo " python-test-cov - Run unit tests with coverage report"
|
||||
@echo " clean - Remove node_modules and build artifacts"
|
||||
@echo ""
|
||||
@echo "Backend (Docker):"
|
||||
@@ -30,6 +52,17 @@ install:
|
||||
@echo "Installing frontend dependencies..."
|
||||
cd $(FRONTEND_DIR) && npm install
|
||||
|
||||
install-ci:
|
||||
@echo "Installing frontend dependencies (CI, lockfile-strict)..."
|
||||
cd $(FRONTEND_DIR) && npm ci
|
||||
|
||||
# Install Python development dependencies
|
||||
install-python-dev:
|
||||
@echo "Syncing Python runtime and dev tooling with uv..."
|
||||
uv sync --locked --extra browser
|
||||
@echo "Installing prek git hooks..."
|
||||
uv run prek install
|
||||
|
||||
# Start development server
|
||||
dev:
|
||||
@echo "Starting development server..."
|
||||
@@ -40,16 +73,88 @@ build:
|
||||
@echo "Building frontend for production..."
|
||||
cd $(FRONTEND_DIR) && npm run build
|
||||
|
||||
# Build frontend and sync to frontend-dist for the running container to serve
|
||||
build-serve: build
|
||||
@echo "Syncing build to frontend-dist..."
|
||||
@mkdir -p frontend-dist
|
||||
rsync -a --delete $(FRONTEND_DIR)/dist/ frontend-dist/
|
||||
@echo "Done. Hit the Flask backend (port 8084) to test the production build."
|
||||
|
||||
# Preview production build
|
||||
preview:
|
||||
@echo "Previewing production build..."
|
||||
cd $(FRONTEND_DIR) && npm run preview
|
||||
|
||||
# Type checking
|
||||
typecheck:
|
||||
frontend-typecheck:
|
||||
@echo "Running TypeScript type checking..."
|
||||
cd $(FRONTEND_DIR) && npm run typecheck
|
||||
|
||||
# Python linting (backend + tests)
|
||||
python-lint:
|
||||
@echo "Running Ruff..."
|
||||
uv run ruff check shelfmark tests
|
||||
|
||||
python-lint-fix:
|
||||
@echo "Running Ruff with safe auto-fixes..."
|
||||
uv run ruff check shelfmark tests --fix
|
||||
|
||||
python-format:
|
||||
@echo "Checking Python formatting with Ruff..."
|
||||
uv run ruff format --check shelfmark tests
|
||||
|
||||
python-format-fix:
|
||||
@echo "Formatting Python code with Ruff..."
|
||||
uv run ruff format shelfmark tests
|
||||
|
||||
python-typecheck:
|
||||
@echo "Running BasedPyright..."
|
||||
uv run basedpyright
|
||||
@echo "Running BasedPyright against tests..."
|
||||
uv run basedpyright tests --skipunannotated
|
||||
|
||||
python-dead-code:
|
||||
@echo "Running Vulture..."
|
||||
uv run vulture shelfmark
|
||||
|
||||
python-checks: python-lint python-format python-typecheck python-dead-code
|
||||
|
||||
python-test:
|
||||
@echo "Running tests..."
|
||||
uv run pytest tests/ -x --tb=short -m "not integration and not e2e"
|
||||
|
||||
python-test-cov:
|
||||
@echo "Running tests with coverage..."
|
||||
uv run pytest tests/ -x --tb=short -m "not integration and not e2e" --cov --cov-report=term-missing
|
||||
|
||||
# Frontend linting
|
||||
frontend-lint:
|
||||
@echo "Running Oxlint..."
|
||||
cd $(FRONTEND_DIR) && npm run lint
|
||||
|
||||
# Frontend formatting
|
||||
frontend-format:
|
||||
@echo "Checking frontend formatting with Oxfmt..."
|
||||
cd $(FRONTEND_DIR) && npm run format:check
|
||||
|
||||
frontend-format-fix:
|
||||
@echo "Formatting frontend code with Oxfmt..."
|
||||
cd $(FRONTEND_DIR) && npm run format
|
||||
|
||||
# All frontend static analysis
|
||||
frontend-checks: frontend-lint frontend-format frontend-typecheck
|
||||
|
||||
# Run frontend unit tests
|
||||
frontend-test:
|
||||
@echo "Running frontend unit tests..."
|
||||
cd $(FRONTEND_DIR) && npm run test:unit
|
||||
|
||||
# All static analysis checks (frontend + Python)
|
||||
checks: frontend-checks python-checks
|
||||
|
||||
# Auto-fix lint + format issues (frontend + Python)
|
||||
fix: python-lint-fix python-format-fix frontend-format-fix
|
||||
|
||||
# Clean build artifacts and dependencies
|
||||
clean:
|
||||
@echo "Cleaning build artifacts and dependencies..."
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 854 KiB After Width: | Height: | Size: 848 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.1 MiB |
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
shelfmark-lite:
|
||||
image: ghcr.io/calibrain/shelfmark-lite:latest
|
||||
container_name: shelfmark-lite
|
||||
environment:
|
||||
# EXT_BYPASSER_URL: http://flaresolverr:8191 #If using Flaresolverr
|
||||
PUID: 1000
|
||||
@@ -12,4 +13,4 @@ services:
|
||||
- /path/to/books:/books # Default destination for book downloads
|
||||
- /path/to/config:/config # App configuration
|
||||
# Required for torrent / usenet - path must match your download client's volume exactly
|
||||
# - /path/to/downloads:/path/to/downloads
|
||||
# - /path/to/downloads:/path/to/downloads
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Routes all traffic through Tor - requires NET_ADMIN capability
|
||||
# Routes all traffic through Tor - requires root startup
|
||||
services:
|
||||
shelfmark-tor:
|
||||
image: ghcr.io/calibrain/shelfmark:latest
|
||||
|
||||
@@ -69,4 +69,4 @@
|
||||
{ "language": "Uyghur", "code": "ug" },
|
||||
{ "language": "Armenian", "code": "hy" },
|
||||
{ "language": "Shan", "code": "shn" }
|
||||
]
|
||||
]
|
||||
|
||||
@@ -12,11 +12,14 @@ services:
|
||||
- SYS_PTRACE
|
||||
environment:
|
||||
DEBUG: true
|
||||
# HIDE_LOCAL_AUTH: true
|
||||
OIDC_AUTO_REDIRECT: true
|
||||
volumes:
|
||||
- ./.local/config:/config
|
||||
- ./.local/books:/books
|
||||
- ./.local/log:/var/log/shelfmark
|
||||
- ./.local/tmp:/tmp/shelfmark
|
||||
- ./shelfmark:/app/shelfmark:ro
|
||||
- ./frontend-dist:/app/frontend-dist:ro
|
||||
# Required for torrent / usenet - path must match your download client's volume exactly
|
||||
# - /path/to/downloads:/path/to/downloads
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
# - Transmission: http://localhost:9091 (admin / admin)
|
||||
# - Deluge: http://localhost:8112 (password: deluge)
|
||||
# - NZBGet: http://localhost:6789 (nzbget / tegbzn6789)
|
||||
# - NZBHydra: http://localhost:5076 (no auth by default)
|
||||
# - SABnzbd: http://localhost:8085 (complete setup wizard for API key)
|
||||
# - rTorrent: http://localhost:8000 (admin / admin - if auth enabled)
|
||||
#
|
||||
@@ -35,6 +36,7 @@ services:
|
||||
# - Transmission: http://transmission:9091
|
||||
# - Deluge Web UI: http://deluge:8112
|
||||
# - NZBGet: http://nzbget:6789
|
||||
# - NZBHydra: http://nzbhydra:5076
|
||||
# - SABnzbd: http://sabnzbd:8080
|
||||
# - rTorrent: http://rtorrent:80 (XMLRPC via HTTP) or rtorrent (port 5000 for SCGI)
|
||||
ports:
|
||||
@@ -54,11 +56,13 @@ services:
|
||||
# Mount tests for running pytest in container
|
||||
- ./tests:/app/tests:ro
|
||||
- ./pyproject.toml:/app/pyproject.toml:ro
|
||||
- ./uv.lock:/app/uv.lock:ro
|
||||
# Mount client configs for integration tests to read credentials
|
||||
- ./.local/test-clients/qbittorrent/config:/qbittorrent-config:ro
|
||||
- ./.local/test-clients/sabnzbd/config:/sabnzbd-config:ro
|
||||
depends_on:
|
||||
- nzbget
|
||||
- nzbhydra
|
||||
- sabnzbd
|
||||
- qbittorrent
|
||||
- transmission
|
||||
@@ -179,3 +183,18 @@ services:
|
||||
- "50000:50000" # Incoming connections
|
||||
- "6881:6881/udp"
|
||||
restart: unless-stopped
|
||||
|
||||
nzbhydra:
|
||||
image: lscr.io/linuxserver/nzbhydra2:latest
|
||||
container_name: nzbhydra
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=Europe/London
|
||||
volumes:
|
||||
- ./.local/test-clients/nzbhydra/config:/config
|
||||
- ./.local/test-clients/downloads:/downloads
|
||||
ports:
|
||||
- 5076:5076
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -1,3 +1,131 @@
|
||||
# Configuration
|
||||
# Directory and Volume Setup
|
||||
|
||||
TODO
|
||||
This guide explains how to configure directories and Docker volumes for Shelfmark. It focuses on the difference between the destination folder and your download client paths, and how to make those paths line up inside containers.
|
||||
|
||||
## Conceptual Overview
|
||||
|
||||
```
|
||||
DIRECT DOWNLOADS
|
||||
|
||||
Shelfmark downloads directly -> destination
|
||||
|
||||
TORRENT / USENET
|
||||
|
||||
Prowlarr -> Download client saves to <client path>
|
||||
-> Shelfmark reads from <client path>
|
||||
-> Shelfmark processes to destination
|
||||
```
|
||||
|
||||
Key point: For torrent and usenet downloads, Shelfmark must see the same file path that your download client reports. The container path must match in both containers.
|
||||
|
||||
## Direct Download Volume Setup
|
||||
|
||||
If you plan to use Direct Download, it does not use an external download client. A simple two-folder setup is enough.
|
||||
|
||||
Required volumes:
|
||||
|
||||
| Container path | Purpose | Notes |
|
||||
| --- | --- | --- |
|
||||
| `/config` | Settings, database, cover cache | Configurable via `CONFIG_DIR` |
|
||||
| `/books` | Destination folder for completed files | Configurable via `INGEST_DIR` and Settings -> Downloads -> Destination |
|
||||
|
||||
Example `docker-compose`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
shelfmark:
|
||||
image: ghcr.io/calibrain/shelfmark:latest
|
||||
volumes:
|
||||
- /path/to/config:/config
|
||||
- /path/to/books:/books
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Point `/books` to your library ingest folder (Calibre-Web, Booklore, Audiobookshelf, etc) for automatic import.
|
||||
- If you set Books Output Mode to Booklore (API), books are uploaded via API instead of written to `/books`. Audiobooks still use a destination folder.
|
||||
- Ensure `PUID`/`PGID` (or legacy `UID`/`GID`) match the owner of the host directories.
|
||||
- For non-root mode, start the container as `1000:1000`.
|
||||
- On Kubernetes, set `runAsUser: 1000`, `runAsGroup: 1000`, and `runAsNonRoot: true` together.
|
||||
- `PUID`/`PGID` keep the default root startup flow.
|
||||
- In non-root mode, mounted paths must already be writable by `1000:1000`.
|
||||
- `USING_TOR=true` requires root startup.
|
||||
|
||||
## Torrent / Usenet Setup
|
||||
|
||||
For torrents and usenet, your download client reports a path (for example `/data/torrents/books/MyBook.epub`). Shelfmark must be able to read that exact path inside its own container.
|
||||
|
||||
Required volumes:
|
||||
|
||||
| Container path | Purpose | Notes |
|
||||
| --- | --- | --- |
|
||||
| `/config` | Settings, database, cover cache | Configurable via `CONFIG_DIR` |
|
||||
| `/books` | Destination folder for processed files | Configurable via `INGEST_DIR` |
|
||||
| `<client path>` | Download client path | Must match the download client container path exactly |
|
||||
|
||||
Side-by-side example with qBittorrent:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
shelfmark:
|
||||
volumes:
|
||||
- /path/to/config:/config
|
||||
- /path/to/books:/books
|
||||
- /path/to/downloads:/data/torrents # Must match client
|
||||
|
||||
qbittorrent:
|
||||
volumes:
|
||||
- /path/to/downloads:/data/torrents # Same container path
|
||||
```
|
||||
|
||||
Host paths can be anything. The container path (for example `/data/torrents`) must be identical in both containers.
|
||||
|
||||
### Remote Path Mappings
|
||||
|
||||
If paths cannot match (different machines or a fixed setup), use Remote Path Mappings.
|
||||
|
||||
Where to configure:
|
||||
- Settings -> Advanced -> Remote Path Mappings
|
||||
|
||||
Example:
|
||||
- Client reports `/data/torrents/books/...`
|
||||
- Shelfmark can see the same files at `/downloads/books/...`
|
||||
- Add a mapping from Remote Path `/data/torrents` to Local Path `/downloads`
|
||||
|
||||
## File Processing Options
|
||||
|
||||
### Transfer Method (Torrent / Usenet Only)
|
||||
|
||||
Available methods:
|
||||
- Copy (default). Works everywhere.
|
||||
- Hardlink. Preserves seeding without duplicating files.
|
||||
|
||||
Hardlink requirements and behavior:
|
||||
- Source and destination must be on the same filesystem.
|
||||
- If hardlinking is enabled but not possible, Shelfmark falls back to copying.
|
||||
- Archive extraction is disabled while hardlinking is enabled.
|
||||
- Do not use hardlinking if your destination is a library ingest folder.
|
||||
|
||||
### File Organization
|
||||
|
||||
Shelfmark supports three organization modes for the destination:
|
||||
- None. Keep original filenames from the source.
|
||||
- Rename Only. Rename files using a template.
|
||||
- Rename and Organize. Create folders and rename using templates. Do not use with ingest folders.
|
||||
|
||||
Configure templates in Settings -> Downloads. Template syntax details are documented separately.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- "Download failed - file not found": Path mismatch between Shelfmark and the download client. Ensure container paths match or use Remote Path Mappings.
|
||||
- "Permission denied": `PUID`/`PGID` do not match the host directories. Ensure Shelfmark can read the client path and write to the destination.
|
||||
- "Permission denied" in non-root Docker/Kubernetes mode: ensure the mounted path is writable by UID/GID `1000:1000`, or switch back to root startup with `PUID`/`PGID`.
|
||||
- "Hardlinks not working" or "Files being copied instead": Source and destination are on different filesystems. Move the destination or accept copy fallback.
|
||||
- "Downloads work but library does not see them": Destination does not point to the library ingest folder. Check Settings -> Downloads -> Destination.
|
||||
- CIFS/SMB shares: Use the `nobrl` mount option to avoid database lock errors. Example: `//server/share /mnt/share cifs nobrl,... 0 0`
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- Environment Variables Reference: `docs/environment-variables.md`
|
||||
- Custom Scripts: `docs/custom-scripts.md`
|
||||
- Installation: `docs/installation.md`
|
||||
- Troubleshooting: `docs/troubleshooting.md`
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Custom Scripts
|
||||
|
||||
Shelfmark can run an executable you provide after a download task completes successfully. The script runs after the selected output has finished (for example: transfer to the folder destination, or upload to Booklore).
|
||||
|
||||
|
||||
## Quick Start (Recommended)
|
||||
|
||||
1. Put your script on the machine that runs Shelfmark.
|
||||
1. Make it executable.
|
||||
1. Set it in Shelfmark (Settings -> Advanced -> Custom Script Path).
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
chmod +x /path/to/your/scripts/post_process.sh
|
||||
```
|
||||
|
||||
### Docker Users
|
||||
|
||||
If you run Shelfmark in Docker, the script must exist inside the container. The easiest way is to mount a folder of scripts, then point Shelfmark at the container path in the UI.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
shelfmark:
|
||||
image: ghcr.io/calibrain/shelfmark:latest
|
||||
volumes:
|
||||
- /path/to/your/scripts:/scripts:ro
|
||||
```
|
||||
|
||||
Then set:
|
||||
|
||||
- Settings -> Advanced -> Custom Script Path: `/scripts/post_process.sh`
|
||||
|
||||
<details>
|
||||
<summary>Docker Compose: Configure Via Environment Variables (Optional)</summary>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
shelfmark:
|
||||
environment:
|
||||
- CUSTOM_SCRIPT=/scripts/post_process.sh
|
||||
- CUSTOM_SCRIPT_PATH_MODE=absolute
|
||||
- CUSTOM_SCRIPT_JSON_PAYLOAD=true
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Script Behaviour
|
||||
|
||||
When enabled, Shelfmark runs your script once per successful task:
|
||||
|
||||
```bash
|
||||
<custom_script_path> "<target_path>"
|
||||
```
|
||||
|
||||
- `$1` is always set to the target path.
|
||||
- If **Custom Script JSON Payload** is enabled, Shelfmark writes a JSON document to stdin (UTF-8).
|
||||
- If JSON payload is disabled, stdin is empty (EOF).
|
||||
- Timeout: 300 seconds (5 minutes)
|
||||
- Exit code: `0` = success; anything else = the task is marked as **Error**
|
||||
- Concurrency: downloads can run in parallel, so your script may be invoked concurrently for different tasks.
|
||||
- Runtime: the script runs inside the Shelfmark container (if you use Docker) under the same user as Shelfmark.
|
||||
|
||||
## The Target Path (`$1`)
|
||||
|
||||
Shelfmark chooses a "best single path" for the task:
|
||||
|
||||
- If the output produced exactly one local file: that file path.
|
||||
- If the output produced multiple local files: a directory path (the common parent directory of those files).
|
||||
|
||||
What the target path refers to depends on the output mode:
|
||||
|
||||
- Folder output (`output.mode=folder`, `phase=post_transfer`): the final imported file or folder inside your destination.
|
||||
- Booklore output (`output.mode=booklore`, `phase=post_upload`): the local file or folder that was uploaded (the destination is remote).
|
||||
|
||||
By default, `$1` is an absolute path inside the Shelfmark container (or on your host, if you are not using Docker).
|
||||
|
||||
## JSON Payload (stdin)
|
||||
|
||||
Configure in: Settings -> Advanced -> Custom Script JSON Payload
|
||||
|
||||
When enabled, Shelfmark sends a versioned JSON payload to your script via stdin (and still passes `$1`). This is the recommended way to write robust scripts, especially for multi-file imports (audiobooks) and output-specific context (like Booklore).
|
||||
|
||||
- The JSON payload always includes absolute paths in `paths.*`, even if you set Custom Script Path Mode to `relative` for `$1`.
|
||||
- `output.mode` tells you which output ran.
|
||||
- `output.details` is output-specific. For Booklore output, `output.details.booklore` includes connection details such as `base_url`, `library_id`, and `path_id`.
|
||||
- `phase` indicates when the script is running. Current values: `post_transfer` (folder output), `post_upload` (Booklore output).
|
||||
- `transfer` is only included for outputs that do a local transfer (for example the folder output).
|
||||
|
||||
If JSON payload is disabled, stdin is empty (EOF). Don't `cat` stdin unless you've enabled the payload.
|
||||
|
||||
Example payload shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"phase": "post_transfer",
|
||||
"task": {
|
||||
"task_id": "abc123",
|
||||
"source": "direct",
|
||||
"title": "Foundation",
|
||||
"author": "Isaac Asimov"
|
||||
},
|
||||
"output": {
|
||||
"mode": "folder",
|
||||
"organization_mode": "organize"
|
||||
},
|
||||
"paths": {
|
||||
"destination": "/data/library/books",
|
||||
"target": "/data/library/books/Isaac Asimov/Foundation/Foundation.epub",
|
||||
"final_paths": [
|
||||
"/data/library/books/Isaac Asimov/Foundation/Foundation.epub"
|
||||
]
|
||||
},
|
||||
"transfer": {
|
||||
"op_counts": {"copy": 1, "move": 0, "hardlink": 0},
|
||||
"use_hardlink": false,
|
||||
"is_torrent": false,
|
||||
"preserve_source": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example (bash + python3) (JSON payload must be enabled):
|
||||
|
||||
```bash
|
||||
payload="$(cat)"
|
||||
target="$1"
|
||||
PAYLOAD="$payload" TARGET="$target" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
payload = json.loads(os.environ["PAYLOAD"])
|
||||
|
||||
print(f"target={os.environ['TARGET']}", file=sys.stderr)
|
||||
print(
|
||||
f"mode={payload['output']['mode']} title={payload['task']['title']}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for path in payload["paths"]["final_paths"]:
|
||||
print(path, file=sys.stderr)
|
||||
PY
|
||||
```
|
||||
|
||||
Example (Python) (works whether JSON payload is enabled or not):
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
|
||||
target = sys.argv[1]
|
||||
raw = sys.stdin.read()
|
||||
payload = json.loads(raw) if raw.strip() else None
|
||||
|
||||
print(f"target={target}", file=sys.stderr)
|
||||
if payload:
|
||||
print(f"mode={payload['output']['mode']} phase={payload['phase']}", file=sys.stderr)
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Advanced Options</summary>
|
||||
|
||||
### Absolute vs Relative Target Paths
|
||||
|
||||
Configure in: Settings -> Advanced -> Custom Script Path Mode
|
||||
|
||||
This setting controls what gets passed as `$1`:
|
||||
|
||||
- `absolute` (default): pass an absolute path.
|
||||
- `relative`: pass a path relative to the output's "destination root", and run the script with `$PWD` set to that root.
|
||||
|
||||
For folder output, the destination root is your configured destination folder. For Booklore output, it's the local upload folder.
|
||||
|
||||
Example (folder destination is `/data/library/books`, and the imported file ended up in `Isaac Asimov/Foundation/Foundation.epub`):
|
||||
|
||||
```bash
|
||||
# Absolute mode:
|
||||
$PWD is unchanged
|
||||
$1 = /data/library/books/Isaac Asimov/Foundation/Foundation.epub
|
||||
|
||||
# Relative mode:
|
||||
$PWD = /data/library/books
|
||||
$1 = Isaac Asimov/Foundation/Foundation.epub
|
||||
```
|
||||
|
||||
Note: if the target is the destination folder itself, `relative` mode may pass `.`.
|
||||
|
||||
</details>
|
||||
|
||||
## Notes And Caveats
|
||||
|
||||
- **Hardlinks and torrents:** if you use hardlinking to keep seeding, avoid scripts that modify file contents, since hardlinked files share data with the seeding copy.
|
||||
- **Booklore output mode:** scripts run after upload. `$1` will point at the local uploaded file (or staging folder).
|
||||
@@ -190,6 +190,31 @@ HeadingField(
|
||||
)
|
||||
```
|
||||
|
||||
### CustomComponentField
|
||||
|
||||
Render a frontend-registered custom settings component while still using the
|
||||
decorator-based schema.
|
||||
|
||||
```python
|
||||
from shelfmark.core.settings_registry import CustomComponentField
|
||||
|
||||
CustomComponentField(
|
||||
key="request_policy_editor",
|
||||
component="request_policy_grid", # frontend registry key
|
||||
label="Request Policy Rules",
|
||||
description="Custom editor for policy defaults and matrix rules.",
|
||||
value_fields=[
|
||||
SelectField(key="REQUEST_POLICY_DEFAULT_EBOOK", label="Default Ebook Mode", default="download"),
|
||||
SelectField(key="REQUEST_POLICY_DEFAULT_AUDIOBOOK", label="Default Audiobook Mode", default="download"),
|
||||
TableField(key="REQUEST_POLICY_RULES", label="Rules", columns=_rule_columns, default=[]),
|
||||
],
|
||||
wrap_in_field_wrapper=True, # use standard FieldWrapper label/description layout
|
||||
)
|
||||
```
|
||||
|
||||
When `value_fields` is provided, those backing fields are included in
|
||||
serialization/save/validation automatically and are hidden from the default renderer.
|
||||
|
||||
## Common Field Properties
|
||||
|
||||
All field types support these common properties:
|
||||
@@ -206,6 +231,7 @@ All field types support these common properties:
|
||||
| `requires_restart` | `bool` | `False` | Whether changes require container restart |
|
||||
| `show_when` | `dict` | `None` | Conditional visibility (see below) |
|
||||
| `disabled_when` | `dict` | `None` | Conditional disable (see below) |
|
||||
| `hidden_in_ui` | `bool` | `False` | Hide from default renderer but keep in schema/save path |
|
||||
|
||||
## Conditional Visibility
|
||||
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
# Shelfmark Documentation
|
||||
|
||||
TODO
|
||||
Shelfmark is a self-hosted interface for searching, requesting, and delivering books and audiobooks through the sources and services you choose to configure.
|
||||
|
||||
Use the guides below to set up the app, connect your library tools, and understand the main configuration areas.
|
||||
|
||||
## Getting Started
|
||||
|
||||
- [Installation](installation.md)
|
||||
- [Directory and Volume Setup](configuration.md)
|
||||
- [Environment Variables](environment-variables.md)
|
||||
|
||||
## Core Guides
|
||||
|
||||
- [Users & Requests](users-and-requests.md)
|
||||
- [Reverse Proxy](reverse-proxy.md)
|
||||
- [OIDC](oidc.md)
|
||||
- [URL Search Parameters](url-search-parameters.md)
|
||||
- [Custom Scripts](custom-scripts.md)
|
||||
|
||||
## Help
|
||||
|
||||
- [Troubleshooting](troubleshooting.md)
|
||||
|
||||
@@ -1,3 +1,33 @@
|
||||
# Installation
|
||||
|
||||
TODO
|
||||
Shelfmark is typically deployed with Docker Compose.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Download the compose file from the repository:
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.yml
|
||||
```
|
||||
|
||||
2. Start the service:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. Open `http://localhost:8084`
|
||||
|
||||
4. Configure the sources, metadata providers, and delivery settings you want to use
|
||||
|
||||
## Next Steps
|
||||
|
||||
- For volume and path setup, see [Directory and Volume Setup](configuration.md)
|
||||
- For environment-based setup, see [Environment Variables](environment-variables.md)
|
||||
- For authentication and user management, see [Users & Requests](users-and-requests.md) and [OIDC](oidc.md)
|
||||
|
||||
## Notes
|
||||
|
||||
- Universal search is the default mode for new installs
|
||||
- Direct Download is optional and must be enabled and configured before it can be used
|
||||
- Torrent and usenet setups require matching download paths between Shelfmark and your download client
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# OpenID Connect (OIDC) Authentication
|
||||
|
||||
## Callback URL
|
||||
|
||||
```
|
||||
https://<your-shelfmark-domain>/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
With a subpath (`URL_BASE=/shelfmark/`):
|
||||
|
||||
```
|
||||
https://<your-shelfmark-domain>/shelfmark/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
The callback URL is constructed from the incoming request, so your reverse proxy must forward `X-Forwarded-Proto` and `X-Forwarded-Host` correctly. PKCE (S256) is used automatically.
|
||||
|
||||
## Settings
|
||||
|
||||
Configure in **Settings → Security → Authentication Method → OIDC**.
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Discovery URL | `/.well-known/openid-configuration` endpoint | — |
|
||||
| Client ID | OAuth2 client ID | — |
|
||||
| Client Secret | OAuth2 client secret | — |
|
||||
| Scopes | Scopes to request. The group claim is added automatically when admin group authorization is enabled | `openid email profile` |
|
||||
| Group Claim Name | Claim containing user groups | `groups` |
|
||||
| Admin Group Name | Group granted admin access. Leave empty for database-only roles | — |
|
||||
| Use Admin Group for Authorization | Toggle group-based admin detection | `true` |
|
||||
| Auto-Provision Users | Create accounts on first login | `true` |
|
||||
| Login Button Label | Custom text for the sign-in button | — |
|
||||
|
||||
Use **Test Connection** to verify discovery and client configuration before attempting login.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
These optional environment variables control login page behavior when OIDC is enabled.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HIDE_LOCAL_AUTH` | Hide the username/password login option, so only the OIDC button is shown | `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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Issuer validation failed** — The issuer in the token doesn't match the discovery document. Check your provider's external URL / issuer configuration.
|
||||
- **Callback URL mismatch** — Reverse proxy isn't forwarding `X-Forwarded-Proto` or `X-Forwarded-Host`, so the constructed callback URL doesn't match what's registered in the provider.
|
||||
- **Account not found** — Auto-provision is disabled and the user hasn't been pre-created by an admin.
|
||||
@@ -1,35 +1,168 @@
|
||||
# Reverse Proxy & Subpath Hosting
|
||||
|
||||
Shelfmark can run behind a reverse proxy at the root path (recommended) or
|
||||
under a subpath like `/shelfmark`.
|
||||
Shelfmark can run behind a reverse proxy at the root path (recommended) or under a subpath like `/shelfmark`.
|
||||
|
||||
## Subpath setup
|
||||
## Root path setup (Recommended)
|
||||
|
||||
1) Set the base path in Shelfmark:
|
||||
- UI: Settings → Advanced → Base Path
|
||||
- Env var: `URL_BASE=/shelfmark`
|
||||
If you can serve Shelfmark at the root path (`https://shelfmark.example.com/`), leave `URL_BASE` empty. This is the simplest option and avoids extra subpath configuration.
|
||||
|
||||
2) Configure your reverse proxy to forward the subpath to Shelfmark and
|
||||
**strip the prefix** before sending to the backend. The proxy must also allow
|
||||
WebSocket upgrades for Socket.IO.
|
||||
Define this once in your Nginx `http` block so websocket upgrades are only sent when the client actually requests them:
|
||||
|
||||
Example (Nginx-style):
|
||||
|
||||
```
|
||||
location /shelfmark/ {
|
||||
proxy_pass http://shelfmark:8084/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
```nginx
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Use a trailing slash on the `location` and `proxy_pass` to ensure the
|
||||
`/shelfmark` prefix is removed.
|
||||
- Health checks still work at `/api/health` without the subpath.
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name shelfmark.example.com;
|
||||
|
||||
## Root path setup
|
||||
location / {
|
||||
proxy_pass http://shelfmark:8084;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you can serve Shelfmark at the root path (`https://shelfmark.example.com/`),
|
||||
leave `URL_BASE` empty. This is the simplest option.
|
||||
## Subpath setup
|
||||
|
||||
Running Shelfmark under a subpath like `/shelfmark` is supported without extra rewrite rules.
|
||||
|
||||
### 1. Set the base path in Shelfmark
|
||||
|
||||
- **UI**: Settings → Advanced → Base Path → `/shelfmark/`
|
||||
- **Environment variable**: `URL_BASE=/shelfmark/`
|
||||
|
||||
### 2. Configure your reverse proxy
|
||||
|
||||
All Shelfmark paths (UI, API, assets, Socket.IO) are served under the base path. A single location block is enough.
|
||||
|
||||
---
|
||||
|
||||
### Without Authentication Proxy
|
||||
|
||||
**Complete Nginx configuration for subpath deployment:**
|
||||
|
||||
```nginx
|
||||
location /shelfmark/ {
|
||||
proxy_pass http://shelfmark:8084/shelfmark/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
proxy_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### With Authentication Proxy (Authelia, Authentik, etc.)
|
||||
|
||||
Shelfmark supports Proxy Authentication. When enabled, Shelfmark trusts the authenticated user from headers set by your auth proxy.
|
||||
|
||||
#### Shelfmark Settings
|
||||
|
||||
Configure in Settings → Security:
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Authentication Method | Proxy Authentication |
|
||||
| Proxy Auth User Header | `Remote-User` |
|
||||
| Proxy Auth Logout URL | `https://auth.example.com/logout` |
|
||||
| Proxy Auth Admin Group Header | `Remote-Groups` |
|
||||
| Proxy Auth Admin Group Name | `admins` (or your admin group) |
|
||||
|
||||
#### Nginx Configuration with Authelia
|
||||
|
||||
This example uses Authelia snippets. Adapt for your auth proxy.
|
||||
|
||||
**Authelia auth request snippet** (`/etc/nginx/snippets/authelia-authrequest.conf`):
|
||||
|
||||
```nginx
|
||||
location /authelia {
|
||||
internal;
|
||||
proxy_pass http://authelia:9091/api/authz/auth-request;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
**Authelia location snippet** (`/etc/nginx/snippets/authelia-location.conf`):
|
||||
|
||||
```nginx
|
||||
auth_request /authelia;
|
||||
auth_request_set $target_url $scheme://$http_host$request_uri;
|
||||
auth_request_set $user $upstream_http_remote_user;
|
||||
auth_request_set $groups $upstream_http_remote_groups;
|
||||
auth_request_set $name $upstream_http_remote_name;
|
||||
auth_request_set $email $upstream_http_remote_email;
|
||||
proxy_set_header Remote-User $user;
|
||||
proxy_set_header Remote-Groups $groups;
|
||||
proxy_set_header Remote-Name $name;
|
||||
proxy_set_header Remote-Email $email;
|
||||
error_page 401 =302 https://auth.example.com/?rd=$target_url;
|
||||
```
|
||||
|
||||
**Complete Nginx configuration with Authelia:**
|
||||
|
||||
```nginx
|
||||
# Include Authelia auth endpoint in your server block
|
||||
include /etc/nginx/snippets/authelia-authrequest.conf;
|
||||
|
||||
# Main shelfmark location
|
||||
location /shelfmark/ {
|
||||
include /etc/nginx/snippets/authelia-location.conf;
|
||||
|
||||
proxy_pass http://shelfmark:8084/shelfmark/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
proxy_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting false network errors
|
||||
|
||||
If login, settings saves, or downloads appear to fail in the browser but the action still completes on the server, check your proxy headers first.
|
||||
|
||||
- Do not force `Connection: upgrade` on every request. That can break normal `POST` and `PUT` responses while the backend still processes them.
|
||||
- If your proxy UI does not support conditional websocket headers, remove the forced websocket headers entirely and let Shelfmark fall back to polling.
|
||||
- Keep the standard forwarded headers: `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` when using a subpath or OIDC.
|
||||
|
||||
This is especially relevant for Nginx Proxy Manager or custom advanced config snippets that add websocket headers globally.
|
||||
|
||||
---
|
||||
|
||||
## Health checks
|
||||
|
||||
Health checks work at `/shelfmark/api/health` when using a subpath configuration.
|
||||
|
||||
@@ -19,6 +19,7 @@ http://your-server:8084/?q=harry+potter
|
||||
| `lang` | Filter by language (ISO 639-1 code) | `/?lang=en` |
|
||||
| `format` | Filter by file format | `/?format=epub` |
|
||||
| `content` | Filter by content type | `/?content=fiction` |
|
||||
| `content_type` | Select media type (`ebook` or `audiobook`) in Universal mode only | `/?q=dune&content_type=audiobook` |
|
||||
| `sort` | Sort order for results | `/?sort=newest` |
|
||||
|
||||
## Multiple Values
|
||||
@@ -57,15 +58,21 @@ Some parameters support multiple values by repeating the parameter:
|
||||
/?q=science+fiction&sort=newest
|
||||
```
|
||||
|
||||
**Universal search as audiobook:**
|
||||
```
|
||||
/?q=dune&content_type=audiobook
|
||||
```
|
||||
|
||||
## Search Mode Behavior
|
||||
|
||||
### Direct Download Mode (default)
|
||||
### Direct Mode
|
||||
|
||||
All parameters are used to filter results from the direct download source.
|
||||
When Search Mode is set to Direct, all parameters are used to filter results from the configured direct source.
|
||||
`content_type` is ignored in Direct mode.
|
||||
|
||||
### Universal Mode
|
||||
|
||||
Only `q` and `sort` 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.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Users & Requests
|
||||
|
||||
Configure in **Settings → Users & Requests**.
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
Shelfmark supports four authentication methods, configured in **Settings → Security**.
|
||||
|
||||
### Local
|
||||
|
||||
You create user accounts directly in Shelfmark with a username and password. At least one local admin account must exist before this mode can be enabled.
|
||||
|
||||
### Proxy Authentication
|
||||
|
||||
Your reverse proxy handles authentication and passes the username to Shelfmark via a header (e.g. `Remote-User`). Accounts are created automatically on first sign-in. If a local user with the same username already exists, the proxy identity will be linked to that account rather than creating a duplicate. Admin status can optionally be derived from a groups header.
|
||||
|
||||
### OIDC (OpenID Connect)
|
||||
|
||||
Users sign in through your identity provider. Accounts are created automatically on first login (unless auto-provisioning is disabled, in which case you need to pre-create them). If a local user with a matching verified email already exists, the OIDC identity will be linked to that account on first sign-in. Admin status can optionally be derived from a group claim.
|
||||
|
||||
A local admin account is required as a fallback. See [OIDC](oidc.md) for provider setup.
|
||||
|
||||
### Calibre-Web Database
|
||||
|
||||
User accounts are synced from your Calibre-Web `app.db`. If a local user with a matching email already exists, the CWA identity will be linked to that account. Roles are kept in sync with CWA. Users removed from CWA are cleaned up on the next sync.
|
||||
|
||||
Requires mounting your Calibre-Web `app.db` to `/auth/app.db`.
|
||||
|
||||
## Per-User Settings
|
||||
|
||||
Admins can configure per-user settings by editing a user in the user management panel. Non-admin users can also edit their own settings through **My Account** (accessible from the user menu). Admins control which sections are visible in My Account via the **Visible Self-Settings Sections** option.
|
||||
|
||||
There are three categories of per-user settings:
|
||||
|
||||
### Delivery Preferences
|
||||
|
||||
Override where a user's downloads are sent. Options depend on the global output mode configuration:
|
||||
|
||||
- **Output mode** — Folder, Email (SMTP), or BookLore (API)
|
||||
- **Destination** — A custom folder path for this user's ebook downloads
|
||||
- **Audiobook destination** — A custom folder path for audiobook downloads
|
||||
- **BookLore library/path** — Per-user BookLore target (when using BookLore output mode)
|
||||
- **Email recipient** — Per-user email address (when using Email output mode)
|
||||
|
||||
### Notifications
|
||||
|
||||
Users can configure personal notification routes, separate from the global notification settings. Each route targets a URL (e.g. an Apprise-compatible endpoint) and can be scoped to specific event types or all events.
|
||||
|
||||
### Request Policy (admin-only)
|
||||
|
||||
Admins can override the default ebook/audiobook modes and request rules for individual users. See [Per-User Overrides](#per-user-overrides) below.
|
||||
|
||||
---
|
||||
|
||||
## Requests
|
||||
|
||||
The request system controls whether users can download directly or need admin approval first.
|
||||
|
||||
### Policy Modes
|
||||
|
||||
Each content type (ebook, audiobook) has a default mode that sets the baseline:
|
||||
|
||||
| Mode | Behaviour |
|
||||
|------|-----------|
|
||||
| **Download** | Users download directly, no approval needed |
|
||||
| **Request Release** | Users pick a specific release, then submit it for admin approval |
|
||||
| **Request Book** | Users request the book itself — an admin picks the release and fulfils it |
|
||||
| **Blocked** | No downloads or requests allowed |
|
||||
|
||||
### Settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Enable Requests | Master toggle. When off, everyone downloads directly | Off |
|
||||
| Default Ebook Mode | Baseline mode for all ebook sources | Download |
|
||||
| Default Audiobook Mode | Baseline mode for all audiobook sources | Download |
|
||||
| Request Rules | Per-source overrides (see below) | None |
|
||||
| Max Pending Requests Per User | Open request limit per user | 20 |
|
||||
| Allow Notes on Requests | Let users attach a note when submitting | On |
|
||||
|
||||
### Request Rules
|
||||
|
||||
The rules matrix lets you override the mode for specific source + content type combinations. Rules can only be **equal to or more restrictive** than the content-type default — they cannot grant more access than the baseline.
|
||||
|
||||
For example, if the default ebook mode is "Download", a rule can restrict a specific source to "Request Release" or "Blocked", but not the other way around. If no rule matches, the content-type default applies.
|
||||
|
||||
### Per-User Overrides
|
||||
|
||||
Admins can override the default ebook/audiobook modes and request rules for individual users. Per-user rules are overlaid on the global rules, not replacing them.
|
||||
|
||||
### Request Lifecycle
|
||||
|
||||
1. User submits a request (book or release level, depending on the resolved policy mode)
|
||||
2. Request appears in the admin request queue as **pending**
|
||||
3. Admin either **fulfils** (queues a download) or **rejects** the request
|
||||
4. For fulfilled requests, delivery state is tracked through the download pipeline
|
||||
5. If delivery fails, an admin can reopen the request to try a different release
|
||||
6. Users can cancel their own pending requests
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
is_truthy() {
|
||||
case "${1,,}" in
|
||||
true|yes|1|y) return 0 ;;
|
||||
@@ -11,6 +13,16 @@ ENABLE_LOGGING_VALUE="${ENABLE_LOGGING:-true}"
|
||||
LOG_PIPE_DIR=""
|
||||
LOG_PIPE=""
|
||||
TEE_PID=""
|
||||
FILE_LOGGING_ENABLED="false"
|
||||
CURRENT_UID=$(id -u)
|
||||
CURRENT_GID=$(id -g)
|
||||
RUN_AS_NON_ROOT="false"
|
||||
RUNTIME_TMP_DIR="${TMP_DIR:-/tmp/shelfmark}"
|
||||
DEFAULT_RUNTIME_HOME="${RUNTIME_TMP_DIR}/home"
|
||||
|
||||
if [ "$CURRENT_UID" != "0" ]; then
|
||||
RUN_AS_NON_ROOT="true"
|
||||
fi
|
||||
|
||||
start_file_logging() {
|
||||
local logfile="$1"
|
||||
@@ -43,30 +55,56 @@ stop_file_logging() {
|
||||
|
||||
if is_truthy "$ENABLE_LOGGING_VALUE"; then
|
||||
LOG_DIR=${LOG_ROOT:-/var/log/}/shelfmark
|
||||
mkdir -p "$LOG_DIR"
|
||||
LOG_FILE="${LOG_DIR}/shelfmark_entrypoint.log"
|
||||
if mkdir -p "$LOG_DIR" 2>/dev/null; then
|
||||
LOG_FILE="${LOG_DIR}/shelfmark_entrypoint.log"
|
||||
# Keep the previous entrypoint log instead of deleting all history on boot.
|
||||
rotation_ok="true"
|
||||
if [ -f "${LOG_FILE}.prev" ] && ! rm -f "${LOG_FILE}.prev"; then
|
||||
echo "Warning: could not remove previous entrypoint log ${LOG_FILE}.prev, continuing without file logging" >&2
|
||||
rotation_ok="false"
|
||||
fi
|
||||
if [ "$rotation_ok" = "true" ] && [ -f "$LOG_FILE" ] && ! mv "$LOG_FILE" "${LOG_FILE}.prev"; then
|
||||
echo "Warning: could not rotate entrypoint log $LOG_FILE, continuing without file logging" >&2
|
||||
rotation_ok="false"
|
||||
fi
|
||||
|
||||
# Cleanup any existing files or folders in the log directory
|
||||
rm -rf "$LOG_DIR"/*
|
||||
if [ "$rotation_ok" = "true" ]; then
|
||||
FILE_LOGGING_ENABLED="true"
|
||||
else
|
||||
ENABLE_LOGGING_VALUE="false"
|
||||
export ENABLE_LOGGING="false"
|
||||
fi
|
||||
else
|
||||
echo "Warning: could not create log directory $LOG_DIR, continuing without file logging" >&2
|
||||
ENABLE_LOGGING_VALUE="false"
|
||||
export ENABLE_LOGGING="false"
|
||||
fi
|
||||
fi
|
||||
|
||||
(
|
||||
if [ "$USING_TOR" = "true" ]; then
|
||||
./tor.sh
|
||||
if [ "$USING_TOR" = "true" ]; then
|
||||
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
|
||||
echo "USING_TOR=true requires the container to start as root." >&2
|
||||
echo "Non-root mode skips the privileged filesystem and network setup Tor depends on." >&2
|
||||
exit 1
|
||||
fi
|
||||
)
|
||||
./tor.sh
|
||||
fi
|
||||
|
||||
if is_truthy "$ENABLE_LOGGING_VALUE"; then
|
||||
if [ "$FILE_LOGGING_ENABLED" = "true" ]; then
|
||||
start_file_logging "$LOG_FILE"
|
||||
fi
|
||||
|
||||
echo "Starting entrypoint script"
|
||||
if is_truthy "$ENABLE_LOGGING_VALUE"; then
|
||||
if [ "$FILE_LOGGING_ENABLED" = "true" ]; then
|
||||
echo "Log file: $LOG_FILE"
|
||||
else
|
||||
echo "File logging disabled (ENABLE_LOGGING=$ENABLE_LOGGING_VALUE)"
|
||||
fi
|
||||
set -e
|
||||
|
||||
PYTHON_BIN="/app/.venv/bin/python"
|
||||
if [ ! -x "$PYTHON_BIN" ]; then
|
||||
PYTHON_BIN="python3"
|
||||
fi
|
||||
|
||||
# Print build version
|
||||
echo "Build version: $BUILD_VERSION"
|
||||
@@ -74,67 +112,124 @@ echo "Release version: $RELEASE_VERSION"
|
||||
|
||||
# Configure timezone
|
||||
if [ "$TZ" ]; then
|
||||
echo "Setting timezone to $TZ"
|
||||
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
|
||||
echo "TZ is set to $TZ (non-root mode leaves /etc/localtime unchanged)"
|
||||
else
|
||||
echo "Setting timezone to $TZ"
|
||||
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
fi
|
||||
fi
|
||||
|
||||
# Determine user ID with proper precedence:
|
||||
# 1. PUID (LinuxServer.io standard - recommended)
|
||||
# 2. UID (legacy, for backward compatibility with existing installs)
|
||||
# 3. Default to 1000
|
||||
#
|
||||
# Note: $UID is a bash builtin that's always set. We use `printenv` to detect
|
||||
# if UID was explicitly set as an environment variable (e.g., via docker-compose).
|
||||
if [ -n "$PUID" ]; then
|
||||
RUN_UID="$PUID"
|
||||
echo "Using PUID=$RUN_UID"
|
||||
elif printenv UID >/dev/null 2>&1; then
|
||||
RUN_UID="$(printenv UID)"
|
||||
echo "Using UID=$RUN_UID (legacy - consider migrating to PUID)"
|
||||
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
|
||||
RUN_UID="$CURRENT_UID"
|
||||
RUN_GID="$CURRENT_GID"
|
||||
USERNAME=$(getent passwd "$RUN_UID" 2>/dev/null | cut -d: -f1 || true)
|
||||
if [ -z "$USERNAME" ]; then
|
||||
USERNAME="$RUN_UID"
|
||||
echo "No passwd entry found for UID $RUN_UID; using numeric identity"
|
||||
fi
|
||||
TARGET_USER_SPEC="${RUN_UID}:${RUN_GID}"
|
||||
else
|
||||
RUN_UID=1000
|
||||
echo "Using default UID=$RUN_UID"
|
||||
# Determine user ID with proper precedence:
|
||||
# 1. PUID (LinuxServer.io standard - recommended)
|
||||
# 2. UID (legacy, for backward compatibility with existing installs)
|
||||
# 3. Default to 1000
|
||||
#
|
||||
# Note: $UID is a bash builtin that's always set. We use `printenv` to detect
|
||||
# if UID was explicitly set as an environment variable (e.g., via docker-compose).
|
||||
if [ -n "$PUID" ]; then
|
||||
RUN_UID="$PUID"
|
||||
echo "Using PUID=$RUN_UID"
|
||||
elif printenv UID >/dev/null 2>&1; then
|
||||
RUN_UID="$(printenv UID)"
|
||||
echo "Using UID=$RUN_UID (legacy - consider migrating to PUID)"
|
||||
else
|
||||
RUN_UID=1000
|
||||
echo "Using default UID=$RUN_UID"
|
||||
fi
|
||||
|
||||
# Determine group ID with proper precedence:
|
||||
# 1. PGID (LinuxServer.io standard - recommended)
|
||||
# 2. GID (legacy, for backward compatibility with existing installs)
|
||||
# 3. Default to 1000
|
||||
if [ -n "$PGID" ]; then
|
||||
RUN_GID="$PGID"
|
||||
echo "Using PGID=$RUN_GID"
|
||||
elif [ -n "$GID" ]; then
|
||||
RUN_GID="$GID"
|
||||
echo "Using GID=$RUN_GID (legacy - consider migrating to PGID)"
|
||||
else
|
||||
RUN_GID=1000
|
||||
echo "Using default GID=$RUN_GID"
|
||||
fi
|
||||
|
||||
if ! getent group "$RUN_GID" >/dev/null; then
|
||||
echo "Adding group $RUN_GID with name appuser"
|
||||
groupadd -g "$RUN_GID" appuser
|
||||
fi
|
||||
|
||||
# Create user if it doesn't exist for this UID yet.
|
||||
if ! getent passwd "$RUN_UID" >/dev/null; then
|
||||
echo "Adding user $RUN_UID with name appuser"
|
||||
useradd -u "$RUN_UID" -g "$RUN_GID" -d "$DEFAULT_RUNTIME_HOME" -s /sbin/nologin appuser
|
||||
fi
|
||||
|
||||
# Get username for the UID (whether we just created it or it existed)
|
||||
USERNAME=$(getent passwd "$RUN_UID" | cut -d: -f1)
|
||||
if [ -z "$USERNAME" ]; then
|
||||
USERNAME="$RUN_UID"
|
||||
fi
|
||||
TARGET_USER_SPEC="${RUN_UID}:${RUN_GID}"
|
||||
fi
|
||||
|
||||
# Determine group ID with proper precedence:
|
||||
# 1. PGID (LinuxServer.io standard - recommended)
|
||||
# 2. GID (legacy, for backward compatibility with existing installs)
|
||||
# 3. Default to 1000
|
||||
if [ -n "$PGID" ]; then
|
||||
RUN_GID="$PGID"
|
||||
echo "Using PGID=$RUN_GID"
|
||||
elif [ -n "$GID" ]; then
|
||||
RUN_GID="$GID"
|
||||
echo "Using GID=$RUN_GID (legacy - consider migrating to PGID)"
|
||||
else
|
||||
RUN_GID=1000
|
||||
echo "Using default GID=$RUN_GID"
|
||||
fi
|
||||
# Avoid unnecessary gosu hops when we're already running as the target user.
|
||||
# Some nested LXC setups spin on root-to-root gosu invocations.
|
||||
needs_user_switch() {
|
||||
local current_uid
|
||||
local current_gid
|
||||
|
||||
if ! getent group "$RUN_GID" >/dev/null; then
|
||||
echo "Adding group $RUN_GID with name appuser"
|
||||
groupadd -g "$RUN_GID" appuser
|
||||
fi
|
||||
current_uid=$(id -u)
|
||||
current_gid=$(id -g)
|
||||
|
||||
# Create user if it doesn't exist
|
||||
if ! id -u "$RUN_UID" >/dev/null 2>&1; then
|
||||
echo "Adding user $RUN_UID with name appuser"
|
||||
useradd -u "$RUN_UID" -g "$RUN_GID" -d /app -s /sbin/nologin appuser
|
||||
fi
|
||||
[ "$current_uid" != "$RUN_UID" ] || [ "$current_gid" != "$RUN_GID" ]
|
||||
}
|
||||
|
||||
# Get username for the UID (whether we just created it or it existed)
|
||||
USERNAME=$(getent passwd "$RUN_UID" | cut -d: -f1)
|
||||
echo "Username for UID $RUN_UID is $USERNAME"
|
||||
run_as_target_user() {
|
||||
if needs_user_switch; then
|
||||
gosu "$TARGET_USER_SPEC" "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
"$@"
|
||||
}
|
||||
|
||||
exec_as_target_user() {
|
||||
if needs_user_switch; then
|
||||
exec gosu "$TARGET_USER_SPEC" "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
}
|
||||
|
||||
test_write() {
|
||||
folder=$1
|
||||
test_file=$folder/shelfmark_TEST_WRITE
|
||||
mkdir -p $folder
|
||||
(
|
||||
echo 0123456789_TEST | sudo -E -u "$USERNAME" HOME=/app tee $test_file > /dev/null
|
||||
)
|
||||
FILE_CONTENT=$(cat $test_file || echo "")
|
||||
rm -f $test_file
|
||||
local folder=$1
|
||||
local test_file="$folder/shelfmark_TEST_WRITE"
|
||||
local FILE_CONTENT
|
||||
local result
|
||||
local result_text
|
||||
|
||||
if ! mkdir -p "$folder"; then
|
||||
echo "Failed to create directory for write test: $folder"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! run_as_target_user sh -c 'echo 0123456789_TEST > "$1"' _ "$test_file"; then
|
||||
echo "Failed to write test file in $folder as $USERNAME"
|
||||
return 1
|
||||
fi
|
||||
|
||||
FILE_CONTENT=$(cat "$test_file" 2>/dev/null || echo "")
|
||||
rm -f "$test_file"
|
||||
[ "$FILE_CONTENT" = "0123456789_TEST" ]
|
||||
result=$?
|
||||
if [ $result -eq 0 ]; then
|
||||
@@ -147,108 +242,200 @@ test_write() {
|
||||
}
|
||||
|
||||
make_writable() {
|
||||
folder=$1
|
||||
local folder="$1"
|
||||
local mode="${2:-tree}"
|
||||
local did_full_chown=0
|
||||
local is_writable
|
||||
set +e
|
||||
test_write $folder
|
||||
test_write "$folder"
|
||||
is_writable=$?
|
||||
set -e
|
||||
if [ $is_writable -eq 0 ]; then
|
||||
echo "Folder $folder is writable, no need to change ownership"
|
||||
else
|
||||
echo "Folder $folder is not writable, changing ownership"
|
||||
change_ownership $folder
|
||||
chmod -R g+r,g+w $folder || echo "Failed to change group permissions for ${folder}, continuing..."
|
||||
if [ "$mode" = "root" ]; then
|
||||
echo "Folder $folder is not writable, fixing top-level ownership and permissions"
|
||||
mkdir -p "$folder"
|
||||
chown "${RUN_UID}:${RUN_GID}" "$folder" || echo "Failed to change ownership for ${folder}, continuing..."
|
||||
chmod u+rwx "$folder" || echo "Failed to change owner permissions for ${folder}, continuing..."
|
||||
else
|
||||
echo "Folder $folder is not writable, changing ownership"
|
||||
change_ownership "$folder"
|
||||
chmod -R g+r,g+w "$folder" || echo "Failed to change group permissions for ${folder}, continuing..."
|
||||
fi
|
||||
did_full_chown=1
|
||||
fi
|
||||
# Fix any misowned subdirectories/files (e.g., from previous runs as root)
|
||||
if [ -d "$folder" ]; then
|
||||
misowned_count=$(find "$folder" -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) 2>/dev/null | wc -l)
|
||||
if [ "$misowned_count" -gt 0 ]; then
|
||||
echo "Fixing ownership of $misowned_count files/directories in $folder"
|
||||
find "$folder" -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
|
||||
-exec chown "$RUN_UID:$RUN_GID" {} \; 2>/dev/null || true
|
||||
fi
|
||||
if [ "$mode" = "tree" ] && [ "$did_full_chown" -eq 0 ] && [ -d "$folder" ]; then
|
||||
echo "Checking for misowned files/directories in $folder"
|
||||
# Stay on the same filesystem to avoid traversing mounted subpaths
|
||||
# (for example read-only bind mounts under /app in dev setups).
|
||||
find "$folder" -xdev -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
|
||||
-exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true
|
||||
fi
|
||||
test_write $folder || echo "Failed to test write to ${folder}, continuing..."
|
||||
test_write "$folder" || echo "Failed to test write to ${folder}, continuing..."
|
||||
}
|
||||
|
||||
fix_misowned() {
|
||||
local folder="$1"
|
||||
mkdir -p "$folder"
|
||||
echo "Checking for misowned files/directories in $folder"
|
||||
# Stay on the same filesystem to avoid traversing mounted subpaths
|
||||
# (for example read-only bind mounts under /app in dev setups).
|
||||
find "$folder" -xdev \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
|
||||
-exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Ensure proper ownership of application directories
|
||||
change_ownership() {
|
||||
folder=$1
|
||||
mkdir -p $folder
|
||||
local folder="$1"
|
||||
mkdir -p "$folder"
|
||||
echo "Changing ownership of $folder to $USERNAME:$RUN_GID"
|
||||
chown -R "${RUN_UID}" "${folder}" || echo "Failed to change user ownership for ${folder}, continuing..."
|
||||
chown -R ":${RUN_GID}" "${folder}" || echo "Failed to change group ownership for ${folder}, continuing..."
|
||||
chown -R "${RUN_UID}:${RUN_GID}" "${folder}" || echo "Failed to change ownership for ${folder}, continuing..."
|
||||
}
|
||||
|
||||
change_ownership /app
|
||||
change_ownership /var/log/shelfmark
|
||||
change_ownership /tmp/shelfmark
|
||||
require_writable_dir() {
|
||||
local folder="$1"
|
||||
local label="${2:-Directory}"
|
||||
|
||||
# SeleniumBase (internal bypasser) writes a patched chromedriver binary (uc_driver)
|
||||
# into its own drivers directory. Some NAS/docker setups can apply restrictive ACLs
|
||||
# to extracted image layers that block non-root writes; ensure the runtime UID owns it.
|
||||
if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
|
||||
set +e
|
||||
SELENIUMBASE_DRIVERS_DIR=$(python3 -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')" 2>/dev/null)
|
||||
set -e
|
||||
if ! mkdir -p "$folder"; then
|
||||
echo "Failed to create ${label} directory: $folder"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$SELENIUMBASE_DRIVERS_DIR" ] && [ -d "$SELENIUMBASE_DRIVERS_DIR" ]; then
|
||||
change_ownership "$SELENIUMBASE_DRIVERS_DIR"
|
||||
if ! test_write "$folder"; then
|
||||
echo "${label} directory is not writable in non-root mode: $folder"
|
||||
echo "Prepare ownership outside the container (for example with a pre-owned volume or Kubernetes fsGroup)."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# If the driver already exists, ensure it's executable for the runtime user.
|
||||
if [ -f "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" ]; then
|
||||
chmod +x "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" || echo "Failed to chmod uc_driver, continuing..."
|
||||
resolve_runtime_home() {
|
||||
local runtime_home
|
||||
|
||||
runtime_home=$(getent passwd "$RUN_UID" 2>/dev/null | cut -d: -f6 || true)
|
||||
case "$runtime_home" in
|
||||
""|/|/app|/nonexistent)
|
||||
runtime_home="$DEFAULT_RUNTIME_HOME"
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s\n' "$runtime_home"
|
||||
}
|
||||
|
||||
ensure_tree_writable() {
|
||||
local folder="$1"
|
||||
|
||||
make_writable "$folder"
|
||||
if [ -d "$folder" ]; then
|
||||
chmod -R u+rwX,g+rwX "$folder" || echo "Failed to relax permissions for ${folder}, continuing..."
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_symlinked_dir() {
|
||||
local link_path="$1"
|
||||
local target_path="$2"
|
||||
|
||||
ensure_tree_writable "$target_path"
|
||||
|
||||
if [ -L "$link_path" ]; then
|
||||
local current_target
|
||||
current_target=$(readlink "$link_path" 2>/dev/null || echo "")
|
||||
if [ "$current_target" = "$target_path" ]; then
|
||||
echo "$link_path already points to $target_path"
|
||||
return 0
|
||||
fi
|
||||
echo "Replacing symlink $link_path -> $current_target with $target_path"
|
||||
rm -f "$link_path" || echo "Failed to replace symlink ${link_path}, continuing..."
|
||||
elif [ -d "$link_path" ]; then
|
||||
echo "Moving existing scratch files from $link_path to $target_path"
|
||||
find "$link_path" -xdev -mindepth 1 -maxdepth 1 -exec mv -t "$target_path" {} + 2>/dev/null || true
|
||||
ensure_tree_writable "$target_path"
|
||||
|
||||
if ! rmdir "$link_path" 2>/dev/null; then
|
||||
echo "Could not replace $link_path with symlink, leaving existing directory in place"
|
||||
ensure_tree_writable "$link_path"
|
||||
return 0
|
||||
fi
|
||||
elif [ -e "$link_path" ]; then
|
||||
echo "$link_path exists and is not a directory, leaving it in place"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -e "$link_path" ]; then
|
||||
ln -s "$target_path" "$link_path" || echo "Failed to create symlink ${link_path}, continuing..."
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
|
||||
require_writable_dir /tmp/shelfmark "Temporary"
|
||||
|
||||
if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
|
||||
require_writable_dir /tmp/shelfmark/seleniumbase/downloaded_files "SeleniumBase downloads"
|
||||
require_writable_dir /tmp/shelfmark/seleniumbase/archived_files "SeleniumBase archive"
|
||||
fi
|
||||
|
||||
require_writable_dir "${CONFIG_DIR:-/config}" "Config"
|
||||
else
|
||||
fix_misowned /var/log/shelfmark
|
||||
fix_misowned /tmp/shelfmark
|
||||
|
||||
# Keep SeleniumBase on its default /app-based paths, but redirect the scratch
|
||||
# directories into /tmp so bypasser startup doesn't depend on image-layer writes.
|
||||
if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
|
||||
ensure_symlinked_dir /app/downloaded_files /tmp/shelfmark/seleniumbase/downloaded_files
|
||||
ensure_symlinked_dir /app/archived_files /tmp/shelfmark/seleniumbase/archived_files
|
||||
|
||||
# Keep SeleniumBase's bundled drivers directory writable as well for
|
||||
# compatibility with legacy UC code paths that still probe bundled assets.
|
||||
set +e
|
||||
SELENIUMBASE_DRIVERS_DIR=$("$PYTHON_BIN" -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')" 2>/dev/null)
|
||||
set -e
|
||||
|
||||
if [ -n "$SELENIUMBASE_DRIVERS_DIR" ] && [ -d "$SELENIUMBASE_DRIVERS_DIR" ]; then
|
||||
change_ownership "$SELENIUMBASE_DRIVERS_DIR"
|
||||
|
||||
# If the legacy driver already exists, ensure it's executable for the runtime user.
|
||||
if [ -f "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" ]; then
|
||||
chmod +x "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" || echo "Failed to chmod uc_driver, continuing..."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test write to all folders
|
||||
make_writable ${CONFIG_DIR:-/config}
|
||||
make_writable ${INGEST_DIR:-/books}
|
||||
# Config is Shelfmark-owned state, so it keeps the thorough repair path.
|
||||
make_writable "${CONFIG_DIR:-/config}" tree
|
||||
|
||||
# Fix permissions on directories configured in settings
|
||||
echo "Checking for additional configured directories..."
|
||||
if [ -f /app/scripts/fix_permissions.py ]; then
|
||||
configured_dirs=$(python3 /app/scripts/fix_permissions.py 2>/dev/null || echo "")
|
||||
if [ -n "$configured_dirs" ]; then
|
||||
echo "$configured_dirs" | while read -r dir; do
|
||||
if [ -n "$dir" ] && [ -d "$dir" ]; then
|
||||
echo "Checking configured directory: $dir"
|
||||
make_writable "$dir"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
# Fallback to root if config dir is still not writable (common on NAS/Unraid after upgrade from v0.4.0)
|
||||
CONFIG_PATH=${CONFIG_DIR:-/config}
|
||||
set +e
|
||||
test_write "$CONFIG_PATH" >/dev/null 2>&1
|
||||
config_ok=$?
|
||||
set -e
|
||||
|
||||
# Fallback to root if config dir is still not writable (common on NAS/Unraid after upgrade from v0.4.0)
|
||||
CONFIG_PATH=${CONFIG_DIR:-/config}
|
||||
set +e
|
||||
test_write "$CONFIG_PATH" >/dev/null 2>&1
|
||||
config_ok=$?
|
||||
set -e
|
||||
|
||||
if [ $config_ok -ne 0 ] && [ "$RUN_UID" != "0" ]; then
|
||||
config_owner=$(stat -c '%u' "$CONFIG_PATH" 2>/dev/null || echo "unknown")
|
||||
if [ "$config_owner" = "0" ]; then
|
||||
echo ""
|
||||
echo "========================================================"
|
||||
echo "WARNING: Permission issue detected!"
|
||||
echo ""
|
||||
echo "Config directory is owned by root but PUID=$RUN_UID."
|
||||
echo "This typically happens after upgrading from v0.4.0 where"
|
||||
echo "PUID/PGID settings were not respected."
|
||||
echo ""
|
||||
echo "Falling back to running as root to prevent data loss."
|
||||
echo ""
|
||||
echo "To fix this permanently, run on your HOST machine:"
|
||||
echo " chown -R $RUN_UID:$RUN_GID /path/to/config"
|
||||
echo ""
|
||||
echo "Then restart the container."
|
||||
echo "========================================================"
|
||||
echo ""
|
||||
RUN_UID=0
|
||||
RUN_GID=0
|
||||
USERNAME=root
|
||||
if [ $config_ok -ne 0 ] && [ "$RUN_UID" != "0" ]; then
|
||||
config_owner=$(stat -c '%u' "$CONFIG_PATH" 2>/dev/null || echo "unknown")
|
||||
if [ "$config_owner" = "0" ]; then
|
||||
echo ""
|
||||
echo "========================================================"
|
||||
echo "WARNING: Permission issue detected!"
|
||||
echo ""
|
||||
echo "Config directory is owned by root but PUID=$RUN_UID."
|
||||
echo "This typically happens after upgrading from v0.4.0 where"
|
||||
echo "PUID/PGID settings were not respected."
|
||||
echo ""
|
||||
echo "Falling back to running as root to prevent data loss."
|
||||
echo ""
|
||||
echo "To fix this permanently, run on your HOST machine:"
|
||||
echo " chown -R $RUN_UID:$RUN_GID /path/to/config"
|
||||
echo ""
|
||||
echo "Then restart the container."
|
||||
echo "========================================================"
|
||||
echo ""
|
||||
RUN_UID=0
|
||||
RUN_GID=0
|
||||
USERNAME=root
|
||||
TARGET_USER_SPEC="0:0"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -264,7 +451,7 @@ if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
|
||||
set -x
|
||||
echo "vvvvvvvvvvvv DEBUG MODE vvvvvvvvvvvv"
|
||||
echo "Starting Xvfb for debugging"
|
||||
python3 -c "from pyvirtualdisplay import Display; Display(visible=False, size=(1440,1880)).start()"
|
||||
"$PYTHON_BIN" -c "from pyvirtualdisplay import Display; Display(visible=False, size=(1440,1880)).start()"
|
||||
id
|
||||
free -h
|
||||
uname -a
|
||||
@@ -284,7 +471,7 @@ if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
|
||||
--enable-logging --v=1 --log-level=0 \
|
||||
--log-file=/tmp/chrome_entrypoint_test.log \
|
||||
--crash-dumps-dir=/tmp/chrome_crash_dumps \
|
||||
< /dev/null
|
||||
< /dev/null
|
||||
EXIT_CODE=$?
|
||||
echo "Chrome exit code: $EXIT_CODE"
|
||||
ls -lh /tmp/chrome_entrypoint_test.log
|
||||
@@ -324,7 +511,19 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running command: '$command' as '$USERNAME' (debug=$is_debug)"
|
||||
RUNTIME_HOME=$(resolve_runtime_home)
|
||||
require_writable_dir "$RUNTIME_HOME" "Home"
|
||||
|
||||
if [ "$RUN_AS_NON_ROOT" = "true" ]; then
|
||||
echo "Startup mode: non-root"
|
||||
elif [ "$RUN_UID" = "0" ] && [ "$RUN_GID" = "0" ]; then
|
||||
echo "Startup mode: root"
|
||||
else
|
||||
echo "Startup mode: root bootstrap with privilege drop"
|
||||
fi
|
||||
echo "Runtime identity: $USERNAME (${RUN_UID}:${RUN_GID})"
|
||||
|
||||
echo "Running command: '$command' as '$USERNAME' (debug=${DEBUG:-false})"
|
||||
|
||||
# Set umask for file permissions (default: 0022 = files 644, dirs 755)
|
||||
UMASK_VALUE=${UMASK:-0022}
|
||||
@@ -332,4 +531,4 @@ echo "Setting umask to $UMASK_VALUE"
|
||||
umask $UMASK_VALUE
|
||||
|
||||
stop_file_logging
|
||||
exec sudo -E -u "$USERNAME" HOME=/app $command
|
||||
exec_as_target_user env HOME="$RUNTIME_HOME" $command
|
||||
|
||||
@@ -199,4 +199,3 @@ else
|
||||
echo "Failed to create debug archive"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "shelfmark",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.19"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.19"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,47 @@
|
||||
name = "shelfmark"
|
||||
version = "0.1.0"
|
||||
description = "Shelfmark - Book Downloader"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"flask",
|
||||
"flask-cors",
|
||||
"flask-socketio",
|
||||
"python-socketio",
|
||||
"requests[socks]",
|
||||
"defusedxml",
|
||||
"beautifulsoup4",
|
||||
"tqdm",
|
||||
"dnspython",
|
||||
"gunicorn",
|
||||
"gevent",
|
||||
"gevent-websocket",
|
||||
"psutil",
|
||||
"emoji",
|
||||
"rarfile",
|
||||
"qbittorrent-api",
|
||||
"transmission-rpc",
|
||||
"authlib>=1.7.0,<1.8",
|
||||
"apprise>=1.9.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
browser = [
|
||||
"pyvirtualdisplay",
|
||||
"pyautogui",
|
||||
"seleniumbase==4.48.2",
|
||||
"python-xlib",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"basedpyright>=1.39.3",
|
||||
"prek",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"ruff==0.15.11",
|
||||
"vulture>=2.14",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -12,6 +52,8 @@ python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"-v",
|
||||
"--tb=short",
|
||||
"-n",
|
||||
"auto",
|
||||
]
|
||||
markers = [
|
||||
"integration: marks tests that require running services (deselect with '-m \"not integration\"')",
|
||||
@@ -19,8 +61,112 @@ markers = [
|
||||
"e2e: marks end-to-end tests that require the full application stack",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
ignore_missing_imports = true
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
extend-exclude = [".local"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"F", "I", "UP", "B", "C4", "SIM", "PTH", "RET", "PIE", "FURB", "PERF", "TRY",
|
||||
"A", "DTZ", "N",
|
||||
"BLE001",
|
||||
"ANN001", "ANN002", "ANN003", "ANN201", "ANN202", "ANN204",
|
||||
"E402",
|
||||
"ERA001",
|
||||
"E731",
|
||||
"S101",
|
||||
"S110",
|
||||
"S105", "S108",
|
||||
"S311", "S324",
|
||||
"S607", "S608",
|
||||
"G003", "G004",
|
||||
"PGH003",
|
||||
"PLC0414",
|
||||
"PLR1714",
|
||||
"PLW1510",
|
||||
"PLW2901",
|
||||
"PLW0108",
|
||||
"PT028",
|
||||
"PYI034",
|
||||
"Q000",
|
||||
"RUF005", "RUF012", "RUF013", "RUF059", "RUF100",
|
||||
"TC001", "TC002", "TC003",
|
||||
]
|
||||
ignore = ["D", "EM", "FBT", "PLR2004", "UP035", "TRY003", "E501", "TD002", "S104", "S603"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"scripts/**/*.py" = [
|
||||
"BLE001",
|
||||
"S",
|
||||
"TRY",
|
||||
]
|
||||
"tests/**/*.py" = [
|
||||
"ANN",
|
||||
"BLE001",
|
||||
"B010",
|
||||
"B017",
|
||||
"B028",
|
||||
"DTZ",
|
||||
"E402",
|
||||
"E731",
|
||||
"ERA001",
|
||||
"FURB",
|
||||
"G003",
|
||||
"G004",
|
||||
"PERF",
|
||||
"PIE",
|
||||
"PLC0414",
|
||||
"PLW0108",
|
||||
"PLW1510",
|
||||
"PLW2901",
|
||||
"PTH",
|
||||
"PT028",
|
||||
"PYI034",
|
||||
"Q000",
|
||||
"RET",
|
||||
"RUF012",
|
||||
"S",
|
||||
"SIM",
|
||||
"TC001",
|
||||
"TC002",
|
||||
"TC003",
|
||||
"TRY",
|
||||
"UP028",
|
||||
]
|
||||
|
||||
[tool.basedpyright]
|
||||
include = ["shelfmark"]
|
||||
exclude = [".local", "tests", "**/__pycache__", "**/node_modules"]
|
||||
pythonVersion = "3.14"
|
||||
typeCheckingMode = "standard"
|
||||
|
||||
[tool.vulture]
|
||||
paths = ["shelfmark"]
|
||||
exclude = [".local", "tests"]
|
||||
ignore_decorators = [
|
||||
"@app.route",
|
||||
"@app.before_request",
|
||||
"@app.after_request",
|
||||
"@app.errorhandler",
|
||||
"@socketio.on",
|
||||
"@register_provider",
|
||||
"@register_provider_kwargs",
|
||||
"@register_settings",
|
||||
"@register_source",
|
||||
"@register_handler",
|
||||
"@register_client",
|
||||
"@register_output",
|
||||
]
|
||||
min_confidence = 90
|
||||
sort_by_size = true
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["shelfmark"]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
skip_empty = true
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
# 📚 Shelfmark: Book Downloader
|
||||
|
||||
Formerly *Calibre Web Automated Book Downloader (CWABD)*
|
||||
# 📚 Shelfmark: Book Search & Request Tool
|
||||
|
||||
<img src="src/frontend/public/logo.png" alt="Shelfmark" width="200">
|
||||
|
||||
Shelfmark is a unified web interface for searching and aggregating books and audiobook downloads from multiple sources - all in one place. Works out of the box with popular web sources, no configuration required. Add metadata providers, additional release sources, and download clients to create a single hub for building your digital library.
|
||||
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.
|
||||
|
||||
**Fully standalone** - no external dependencies required. Works great alongside library tools like [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated), [Booklore](https://github.com/booklore-app/booklore) or [Audiobookshelf](https://github.com/advplyr/audiobookshelf) for automatic import.
|
||||
Works great alongside the following library tools, with support for automatic imports:
|
||||
- [Calibre](https://calibre-ebook.com/)
|
||||
- [Calibre-Web](https://github.com/janeczku/calibre-web)
|
||||
- [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated)
|
||||
- [Grimmory](https://github.com/grimmory-tools/grimmory)
|
||||
- [Audiobookshelf](https://github.com/advplyr/audiobookshelf)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **One-Stop Interface** - A clean, modern UI to search, browse, and download from multiple sources in one place
|
||||
- **Multiple sources** - Popular archive websites, Torrent, Usenet and IRC download support
|
||||
- **Audiobook support** - Full audiobook search and download with dedicated processing
|
||||
- **One-Stop Interface** - A clean, modern UI to search, browse, and download from multiple configured sources in one place
|
||||
- **Multiple Sources** - Configurable web, torrent, usenet, and IRC source support
|
||||
- **Audiobook Support** - Full audiobook search and download with dedicated processing
|
||||
- **Flexible Search** - Search metadata providers (Hardcover, Open Library, Google Books) for rich book and audiobook discovery, or query configured sources directly
|
||||
- **Multi-User & Requests** - Share your instance with others, let users browse and request books, and manage approvals with configurable notifications
|
||||
- **Authentication** - Built-in login, OIDC single sign-on, proxy auth, and Calibre-Web database support
|
||||
- **Real-Time Progress** - Unified download queue with live status updates across all sources
|
||||
- **Two Search Modes**:
|
||||
- **Direct** - Search popular web sources
|
||||
- **Universal** - Search metadata providers (Hardcover, Open Library) for richer book and audiobook discovery, with multi-source downloads
|
||||
- **Cloudflare Bypass** - Built-in bypasser for reliable access to protected sources
|
||||
- **Network Flexibility** - Configurable proxy support, DNS settings, and optional Cloudflare handling for protected sources
|
||||
|
||||
## 🖼️ Screenshots
|
||||
|
||||
@@ -53,7 +56,7 @@ Shelfmark is a unified web interface for searching and aggregating books and aud
|
||||
|
||||
3. Open `http://localhost:8084`
|
||||
|
||||
That's it! Configure settings through the web interface as needed.
|
||||
Open the web interface, then configure the sources and settings you want to use.
|
||||
|
||||
### Volume Setup
|
||||
|
||||
@@ -61,27 +64,32 @@ That's it! Configure settings through the web interface as needed.
|
||||
volumes:
|
||||
- /your/config/path:/config # Config, database, and artwork cache directory
|
||||
- /your/download/path:/books # Downloaded books
|
||||
- /client/path:/client/path # Optional: For Torrent/Usenet downloads, match your client directory exactly.
|
||||
- /client/path:/client/path # Optional: For Torrent/Usenet downloads, match your client directory exactly.
|
||||
```
|
||||
|
||||
> **Tip**: Point the download volume to your CWA or Booklore ingest folder for automatic import.
|
||||
> **Tip**: Point the download volume to your CWA or Grimmory ingest folder for automatic import.
|
||||
|
||||
> **Note**: CIFS shares require `nobrl` mount option to avoid database lock errors.
|
||||
|
||||
### Non-root container mode
|
||||
|
||||
- Start the container as `1000:1000` with Docker `user: "1000:1000"` or `docker run --user 1000:1000`.
|
||||
- For Kubernetes, set `runAsUser: 1000`, `runAsGroup: 1000`, and `runAsNonRoot: true` together.
|
||||
- `PUID`/`PGID` keep the default root startup flow.
|
||||
- Mounted paths must already be writable by `1000:1000`.
|
||||
- `USING_TOR=true` requires root startup.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Search Modes
|
||||
|
||||
**Direct** (default)
|
||||
- Works out of the box, no setup required
|
||||
- Searches a huge library of books directly
|
||||
- Returns downloadable releases immediately
|
||||
**Direct**
|
||||
- Queries configured sources directly
|
||||
|
||||
**Universal**
|
||||
- Cleaner search results via metadata providers (Hardcover is recommended)
|
||||
**Universal** (recommended)
|
||||
- Search via metadata providers (Hardcover, Open Library, Google Books) for richer results
|
||||
- Aggregates releases from multiple configured sources
|
||||
- Full Audiobook support
|
||||
- Requires manual setup (API keys, additional sources)
|
||||
- Full audiobook support
|
||||
|
||||
### Environment Variables
|
||||
|
||||
@@ -92,19 +100,19 @@ Environment variables work for initial setup and Docker deployments. They serve
|
||||
| `FLASK_PORT` | Web interface port | `8084` |
|
||||
| `INGEST_DIR` | Book download directory | `/books` |
|
||||
| `TZ` | Container timezone | `UTC` |
|
||||
| `PUID` / `PGID` | Runtime user/group ID (also supports legacy `UID`/`GID`) | `1000` / `1000` |
|
||||
| `SEARCH_MODE` | `direct` or `universal` | `direct` |
|
||||
| `USING_TOR` | Enable Tor routing (requires `NET_ADMIN` capability) | `false` |
|
||||
| `PUID` / `PGID` | Runtime user/group for the default root-startup flow (also supports legacy `UID`/`GID`) | `1000` / `1000` |
|
||||
| `SEARCH_MODE` | `direct` or `universal` | `universal` |
|
||||
| `USING_TOR` | Enable Tor routing (requires root startup) | `false` |
|
||||
|
||||
See the full [Environment Variables Reference](docs/environment-variables.md) for all available options.
|
||||
|
||||
Some of the additional options available in Settings:
|
||||
- **Fast Download Key** - Use your paid account to skip Cloudflare challenges entirely and use faster, direct downloads
|
||||
- **Prowlarr** - Configure indexers and download clients to download books and audiobooks
|
||||
- **Additional audiobook sources** - Configure additional sources for audiobook discovery
|
||||
- **IRC** - Add details for IRC book sources and download directly from the UI
|
||||
- **Library Link** - Add a link to your Calibre-Web or Booklore instance in the UI header
|
||||
- **Library Link** - Add a link to your Calibre-Web or Grimmory instance in the UI header
|
||||
- **File processing** - Customiseable download paths, file renaming and directory creation with template-based renaming
|
||||
- **Network Resilience** - Auto DNS rotation and mirror fallback when sources are unreachable. Custom proxy support (SOCK5 + HTTP/S), Tor routing.
|
||||
- **Network Settings** - Custom proxy support (SOCKS5 + HTTP/S) and configurable DNS
|
||||
- **Format & Language** - Filter downloads by preferred formats, languages and sorting order
|
||||
- **Metadata Providers** - Configure API keys for Hardcover, Open Library, etc.
|
||||
|
||||
@@ -115,38 +123,38 @@ Some of the additional options available in Settings:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full-featured image with built-in Cloudflare bypass.
|
||||
The full-featured image with all network capabilities included.
|
||||
|
||||
#### Enable Tor Routing
|
||||
Routes all traffic through Tor for enhanced privacy:
|
||||
#### Tor Routing
|
||||
Optional Tor support for network privacy:
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.tor.yml
|
||||
docker compose -f docker-compose.tor.yml up -d
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Requires root startup
|
||||
- Requires `NET_ADMIN` and `NET_RAW` capabilities
|
||||
- Timezone is auto-detected from Tor exit node
|
||||
- Custom DNS/proxy settings are ignored when Tor is active
|
||||
|
||||
### Lite
|
||||
A smaller image without the built-in Cloudflare bypasser. Ideal for:
|
||||
A lighter image without the built-in browser automation. Ideal for:
|
||||
|
||||
- **External bypassers** - Already running FlareSolverr or ByParr for other services
|
||||
- **Fast downloads** - Using fast download sources
|
||||
- **Alternative sources only** - Exclusively using Prowlarr, IRC, or other sources
|
||||
- **Audiobooks** - Using Shelfmark exclusively for audiobooks
|
||||
- **External services** - Already running FlareSolverr or similar for other applications
|
||||
- **Alternative sources** - Using Prowlarr, IRC, or other configured sources
|
||||
- **Audiobooks** - Using Shelfmark primarily for audiobooks
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.lite.yml
|
||||
docker compose -f docker-compose.lite.yml up -d
|
||||
```
|
||||
|
||||
If you need Cloudflare bypass with the Lite image, configure an external resolver (FlareSolverr/ByParr) in Settings under the Cloudflare tab.
|
||||
If you need browser-based access with the Lite image, configure an external resolver in Settings.
|
||||
|
||||
## 🔐 Authentication
|
||||
|
||||
Authentication is optional but recommended for shared or exposed instances. Three authentication methods are available in Settings:
|
||||
Authentication is optional but recommended for shared or exposed instances. Multiple authentication methods are available in Settings:
|
||||
|
||||
**1. Single Username/Password**
|
||||
|
||||
@@ -154,7 +162,11 @@ Authentication is optional but recommended for shared or exposed instances. Thre
|
||||
|
||||
Proxy auth trusts headers set by your reverse proxy (e.g. `X-Auth-User`). Ensure Shelfmark is not directly exposed, and configure your proxy to strip/overwrite these headers for all inbound requests.
|
||||
|
||||
**3. Calibre-Web Database**
|
||||
**3. OIDC (OpenID Connect)**
|
||||
|
||||
Integrate with your identity provider (Authelia, Authentik, Keycloak, etc.) for single sign-on. Supports PKCE flow, auto-discovery, group-based admin mapping, and auto-provisioning of new users.
|
||||
|
||||
**4. Calibre-Web Database**
|
||||
|
||||
If you're running Calibre-Web, you can reuse its user database by mounting it:
|
||||
|
||||
@@ -163,6 +175,29 @@ volumes:
|
||||
- /path/to/calibre-web/app.db:/auth/app.db:ro
|
||||
```
|
||||
|
||||
### Multi-User Support
|
||||
|
||||
With any authentication method enabled, Shelfmark supports multi-user management with admin/user roles. Users can have per-user settings for download destinations, email recipients, and notification preferences. Non-admin users only see their own downloads and can submit book requests for admin review. Admins can configure request policies per source to control whether users can download directly, must submit a request, or are blocked entirely.
|
||||
|
||||
## Project Scope
|
||||
|
||||
Shelfmark is a manual search and download tool, the entry point to your book library, not a library manager. It finds books, downloads them, and sends them to a configured destination. That's the full scope.
|
||||
|
||||
Shelfmark intentionally does not:
|
||||
|
||||
- **Track or manage your library** - it doesn't know or care what you already own
|
||||
- **Integrate with library software** - what happens after delivery is up to your library tool
|
||||
- **Monitor authors, series, or new releases** - there is no background automation
|
||||
- **Queue future downloads** - if a book isn't available now, Shelfmark won't watch for it
|
||||
|
||||
These are non-goals, not missing features.
|
||||
|
||||
## Contributing
|
||||
|
||||
Shelfmark's core feature set is complete. Development focuses on stability, bug fixes, quality-of-life improvements, and refining the search experience. Contributions in these areas are welcome, please file issues or submit pull requests on GitHub.
|
||||
|
||||
Feature requests that fall outside the project scope (library integration, automation, collection management) will be closed. If you're unsure whether something fits, open a discussion first.
|
||||
|
||||
## Health Monitoring
|
||||
|
||||
The application exposes a health endpoint at `/api/health` (no authentication required). Add a health check to your compose:
|
||||
@@ -186,11 +221,16 @@ Log level is configurable via Settings or `LOG_LEVEL` environment variable.
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Quality checks
|
||||
make checks # Run ALL static analysis (frontend + Python)
|
||||
make python-checks # Run Ruff, BasedPyright, and Vulture
|
||||
make install-python-dev # Sync Python runtime + dev tools with uv
|
||||
|
||||
# Frontend development
|
||||
make install # Install dependencies
|
||||
make dev # Start Vite dev server (localhost:5173)
|
||||
make build # Production build
|
||||
make typecheck # TypeScript checks
|
||||
make frontend-typecheck # TypeScript checks
|
||||
|
||||
# Backend (Docker)
|
||||
make up # Start backend via docker-compose.dev.yml
|
||||
@@ -201,57 +241,20 @@ make restart # Restart container
|
||||
|
||||
The frontend dev server proxies to the backend on port 8084.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Web Interface │
|
||||
│ (React + TypeScript + Vite) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Flask Backend │
|
||||
│ (REST API + WebSocket) │
|
||||
├───────────────────┬─────────────────────┬───────────────────┤
|
||||
│ Metadata Providers│ Download Queue │ Cloudflare │
|
||||
│ │ & Orchestrator │ Bypass │
|
||||
├───────────────────┼─────────────────────┼───────────────────┤
|
||||
│ • Hardcover │ • Task scheduling │ • Internal │
|
||||
│ • Open Library │ • Progress tracking │ • External │
|
||||
│ │ • Retry logic │ (FlareSolverr) │
|
||||
├───────────────────┴─────────────────────┴───────────────────┤
|
||||
│ Release Sources │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • Direct Download (Web Sources → Mirrors → Fallbacks) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Network Layer │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • Auto DNS rotation • Mirror failover • Resume support │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The backend uses a plugin architecture. Metadata providers and release sources register via decorators and are automatically discovered.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please file issues or submit pull requests on GitHub.
|
||||
|
||||
> **Note**: Additional release sources and download clients are under active development. Want to add support for your favorite source? Check out the plugin architecture above and submit a PR!
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
## ⚠️ Disclaimers
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
### Copyright Notice
|
||||
Shelfmark is a search interface that displays results from external metadata providers and sources. It does not host, store, or distribute any content. The developers are not responsible for how the tool is used or what is accessed through it.
|
||||
|
||||
This tool can access various sources including those that might contain copyrighted material. Users are responsible for:
|
||||
- Ensuring they have the right to download requested materials
|
||||
- Respecting copyright laws and intellectual property rights
|
||||
- Using the tool in compliance with their local regulations
|
||||
Users are solely responsible for:
|
||||
- Ensuring they have the legal right to download any material they access
|
||||
- Complying with copyright laws and intellectual property rights in their jurisdiction
|
||||
- Understanding and accepting the terms of any sources they configure
|
||||
|
||||
### Library Integration
|
||||
|
||||
Downloads are written atomically (via intermediate `.crdownload` files) to prevent partial files from being ingested. However, if your library tool (CWA, Booklore, Calibre) is actively scanning or importing, there's a small chance of race conditions. If you experience database errors or import failures, try pausing your library's auto-import during bulk downloads.
|
||||
Use of this tool is entirely at your own risk.
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
flask
|
||||
flask-cors
|
||||
flask-socketio
|
||||
python-socketio
|
||||
requests[socks]
|
||||
beautifulsoup4
|
||||
tqdm
|
||||
dnspython
|
||||
gunicorn
|
||||
gevent
|
||||
gevent-websocket
|
||||
psutil
|
||||
emoji
|
||||
rarfile
|
||||
qbittorrent-api
|
||||
transmission-rpc
|
||||
@@ -1,5 +0,0 @@
|
||||
pyvirtualdisplay
|
||||
pyautogui
|
||||
selenium==4.39.0
|
||||
seleniumbase==4.45.10
|
||||
python-xlib
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LATEST_IMAGE="${LATEST_IMAGE:-ghcr.io/calibrain/shelfmark:latest}"
|
||||
LEGACY_IMAGE="${LEGACY_IMAGE:-ghcr.io/calibrain/shelfmark:v1.0.2}"
|
||||
WAIT_SECONDS="${WAIT_SECONDS:-5}"
|
||||
STARTUP_TIMEOUT_SECONDS="${STARTUP_TIMEOUT_SECONDS:-120}"
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
echo "Missing required command: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local name="$1"
|
||||
docker rm -f "$name" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
wait_for_startup() {
|
||||
local name="$1"
|
||||
local elapsed=0
|
||||
|
||||
while [ "$elapsed" -lt "$STARTUP_TIMEOUT_SECONDS" ]; do
|
||||
if ! docker inspect "$name" >/dev/null 2>&1; then
|
||||
echo "Container $name no longer exists" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null)" != "running" ]; then
|
||||
echo "Container $name exited before startup completed" >&2
|
||||
docker logs --tail 120 "$name" 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
if docker exec "$name" sh -lc "getent passwd 1000 >/dev/null 2>&1 && ps -eo comm,args | awk '\$1 == \"gunicorn\" && index(\$0, \"shelfmark.main:app\") { found=1 } END { exit(found ? 0 : 1) }'" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
|
||||
echo "Timed out waiting for $name to finish startup" >&2
|
||||
docker logs --tail 120 "$name" 2>&1 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
start_container() {
|
||||
local name="$1"
|
||||
local image="$2"
|
||||
local pre_entrypoint_script="${3:-}"
|
||||
|
||||
cleanup "$name"
|
||||
|
||||
if [ -n "$pre_entrypoint_script" ]; then
|
||||
docker run -d \
|
||||
--name "$name" \
|
||||
--entrypoint sh \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e TZ=UTC \
|
||||
"$image" \
|
||||
-lc "$pre_entrypoint_script
|
||||
exec /app/entrypoint.sh" >/dev/null
|
||||
else
|
||||
docker run -d \
|
||||
--name "$name" \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e TZ=UTC \
|
||||
"$image" >/dev/null
|
||||
sleep "$WAIT_SECONDS"
|
||||
fi
|
||||
|
||||
wait_for_startup "$name"
|
||||
}
|
||||
|
||||
run_probe() {
|
||||
local name="$1"
|
||||
local mode="${2:-default}"
|
||||
docker exec -u 1000:1000 -e PROBE_MODE="$mode" "$name" sh -lc 'python3 - <<'"'"'PY'"'"'
|
||||
import asyncio
|
||||
import os
|
||||
import shelfmark.bypass.internal_bypasser as ib
|
||||
|
||||
|
||||
async def run_probe():
|
||||
driver = None
|
||||
probe_mode = os.environ.get("PROBE_MODE", "default")
|
||||
|
||||
if probe_mode == "proxy_auth" and hasattr(ib, "_get_proxy_string"):
|
||||
ib._get_proxy_string = lambda _url: "user:pass@127.0.0.1:8888"
|
||||
|
||||
if hasattr(ib, "_create_cdp_browser"):
|
||||
try:
|
||||
driver = await ib._create_cdp_browser("https://example.com")
|
||||
profile = getattr(getattr(driver, "config", None), "user_data_dir", "")
|
||||
print(f"PROBE=OK mode={probe_mode} fn=_create_cdp_browser profile={profile}")
|
||||
except Exception as e:
|
||||
print(f"PROBE=ERR mode={probe_mode} fn=_create_cdp_browser type={type(e).__name__} msg={e}")
|
||||
finally:
|
||||
if driver and hasattr(ib, "_close_cdp_driver"):
|
||||
await ib._close_cdp_driver(driver)
|
||||
return
|
||||
|
||||
if hasattr(ib, "_create_driver"):
|
||||
try:
|
||||
driver = await ib._create_driver()
|
||||
print(f"PROBE=OK mode={probe_mode} fn=_create_driver driver_type={type(driver).__name__}")
|
||||
except Exception as e:
|
||||
print(f"PROBE=ERR mode={probe_mode} fn=_create_driver type={type(e).__name__} msg={e}")
|
||||
finally:
|
||||
if driver and hasattr(ib, "_quit_driver"):
|
||||
await ib._quit_driver(driver)
|
||||
return
|
||||
|
||||
print(f"PROBE=ERR mode={probe_mode} fn=unknown type=RuntimeError msg=no supported startup function found")
|
||||
|
||||
|
||||
asyncio.run(run_probe())
|
||||
PY'
|
||||
}
|
||||
|
||||
show_logs() {
|
||||
local name="$1"
|
||||
docker logs --tail 80 "$name" 2>&1 | tail -n 20
|
||||
}
|
||||
|
||||
scenario_latest_baseline() {
|
||||
local name="sb-lab-latest-baseline"
|
||||
echo
|
||||
echo "== latest baseline =="
|
||||
start_container "$name" "$LATEST_IMAGE"
|
||||
run_probe "$name"
|
||||
cleanup "$name"
|
||||
}
|
||||
|
||||
scenario_latest_drivers_readonly() {
|
||||
local name="sb-lab-latest-drivers"
|
||||
echo
|
||||
echo "== latest drivers readonly =="
|
||||
start_container "$name" "$LATEST_IMAGE" '
|
||||
chown -R root:root /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
|
||||
chmod -R a-w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
|
||||
ls -ld /usr/local/lib/python3.10/site-packages/seleniumbase/drivers
|
||||
'
|
||||
run_probe "$name"
|
||||
cleanup "$name"
|
||||
}
|
||||
|
||||
scenario_latest_proxy_auth_baseline() {
|
||||
local name="sb-lab-latest-proxy-baseline"
|
||||
echo
|
||||
echo "== latest proxy auth baseline =="
|
||||
start_container "$name" "$LATEST_IMAGE"
|
||||
run_probe "$name" "proxy_auth"
|
||||
cleanup "$name"
|
||||
}
|
||||
|
||||
scenario_latest_downloads_readonly() {
|
||||
local name="sb-lab-latest-downloads"
|
||||
echo
|
||||
echo "== latest downloaded_files readonly =="
|
||||
start_container "$name" "$LATEST_IMAGE" '
|
||||
mkdir -p /app/downloaded_files &&
|
||||
touch /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
|
||||
chown -R root:root /app/downloaded_files &&
|
||||
chmod -R a-w /app/downloaded_files &&
|
||||
find /app/downloaded_files -maxdepth 2 -printf "%M %u:%g %p\n"
|
||||
'
|
||||
run_probe "$name"
|
||||
show_logs "$name"
|
||||
cleanup "$name"
|
||||
}
|
||||
|
||||
scenario_latest_proxy_auth_downloads_readonly() {
|
||||
local name="sb-lab-latest-proxy-downloads"
|
||||
echo
|
||||
echo "== latest proxy auth with readonly downloaded_files =="
|
||||
start_container "$name" "$LATEST_IMAGE" '
|
||||
mkdir -p /app/downloaded_files &&
|
||||
touch /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
|
||||
chown 1000:1000 /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
|
||||
chmod 0666 /app/downloaded_files/pipfinding.lock /app/downloaded_files/proxy_dir.lock &&
|
||||
chown root:root /app/downloaded_files &&
|
||||
chmod 0555 /app/downloaded_files &&
|
||||
ls -ld /app/downloaded_files &&
|
||||
ls -la /app/downloaded_files
|
||||
'
|
||||
run_probe "$name" "proxy_auth"
|
||||
show_logs "$name"
|
||||
cleanup "$name"
|
||||
}
|
||||
|
||||
scenario_latest_bind_mount_readonly() {
|
||||
local name="sb-lab-latest-bind-ro"
|
||||
local bind_dir
|
||||
bind_dir="$(mktemp -d /tmp/sb-lab-bind.XXXXXX)"
|
||||
echo
|
||||
echo "== latest readonly bind mount for downloaded_files =="
|
||||
chmod 0555 "$bind_dir"
|
||||
cleanup "$name"
|
||||
docker run -d \
|
||||
--name "$name" \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e TZ=UTC \
|
||||
--mount "type=bind,src=${bind_dir},target=/app/downloaded_files,readonly" \
|
||||
"$LATEST_IMAGE" >/dev/null
|
||||
wait_for_startup "$name"
|
||||
run_probe "$name"
|
||||
show_logs "$name"
|
||||
cleanup "$name"
|
||||
rm -rf "$bind_dir"
|
||||
}
|
||||
|
||||
scenario_legacy_drivers_readonly() {
|
||||
local name="sb-lab-legacy-drivers"
|
||||
echo
|
||||
echo "== legacy drivers readonly =="
|
||||
start_container "$name" "$LEGACY_IMAGE" '
|
||||
chown -R root:root /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
|
||||
chmod -R a-w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers &&
|
||||
ls -ld /usr/local/lib/python3.10/site-packages/seleniumbase/drivers
|
||||
'
|
||||
run_probe "$name"
|
||||
show_logs "$name"
|
||||
cleanup "$name"
|
||||
}
|
||||
|
||||
main() {
|
||||
require_cmd docker
|
||||
|
||||
scenario_latest_baseline
|
||||
scenario_latest_drivers_readonly
|
||||
scenario_latest_proxy_auth_baseline
|
||||
scenario_latest_downloads_readonly
|
||||
scenario_latest_proxy_auth_downloads_readonly
|
||||
scenario_latest_bind_mount_readonly
|
||||
scenario_legacy_drivers_readonly
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fix permissions on all configured directories.
|
||||
|
||||
This script is called by the entrypoint to ensure all user-configured
|
||||
directories have correct ownership. It reads directory paths from:
|
||||
- CONFIG_DIR environment variable
|
||||
- Config files in CONFIG_DIR/plugins/
|
||||
|
||||
Outputs directory paths that need permission fixing (one per line).
|
||||
The entrypoint handles the actual chown operations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_directories_from_config() -> set[str]:
|
||||
"""Extract all directory paths from config files."""
|
||||
directories = set()
|
||||
|
||||
config_dir = Path(os.getenv("CONFIG_DIR", "/config"))
|
||||
plugins_dir = config_dir / "plugins"
|
||||
|
||||
if not plugins_dir.exists():
|
||||
return directories
|
||||
|
||||
# Keys that contain directory paths
|
||||
directory_keys = {
|
||||
# Main destinations
|
||||
"DESTINATION",
|
||||
"DESTINATION_AUDIOBOOK",
|
||||
# Content type routing directories
|
||||
"AA_CONTENT_TYPE_DIR_FICTION",
|
||||
"AA_CONTENT_TYPE_DIR_NON_FICTION",
|
||||
"AA_CONTENT_TYPE_DIR_UNKNOWN",
|
||||
"AA_CONTENT_TYPE_DIR_MAGAZINE",
|
||||
"AA_CONTENT_TYPE_DIR_COMIC",
|
||||
"AA_CONTENT_TYPE_DIR_STANDARDS",
|
||||
"AA_CONTENT_TYPE_DIR_MUSICAL_SCORE",
|
||||
"AA_CONTENT_TYPE_DIR_OTHER",
|
||||
# Legacy keys (in case of old configs)
|
||||
"INGEST_DIR",
|
||||
"INGEST_DIR_AUDIOBOOK",
|
||||
"INGEST_DIR_BOOK_FICTION",
|
||||
"INGEST_DIR_BOOK_NON_FICTION",
|
||||
"INGEST_DIR_BOOK_UNKNOWN",
|
||||
"INGEST_DIR_MAGAZINE",
|
||||
"INGEST_DIR_COMIC_BOOK",
|
||||
"INGEST_DIR_STANDARDS_DOCUMENT",
|
||||
"INGEST_DIR_MUSICAL_SCORE",
|
||||
"INGEST_DIR_OTHER",
|
||||
"LIBRARY_PATH",
|
||||
"LIBRARY_PATH_AUDIOBOOK",
|
||||
}
|
||||
|
||||
# Read all JSON config files
|
||||
for config_file in plugins_dir.glob("*.json"):
|
||||
try:
|
||||
with open(config_file, "r") as f:
|
||||
config = json.load(f)
|
||||
|
||||
for key in directory_keys:
|
||||
if key in config:
|
||||
value = config[key]
|
||||
if value and isinstance(value, str) and value.startswith("/"):
|
||||
directories.add(value)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
|
||||
return directories
|
||||
|
||||
|
||||
def main():
|
||||
"""Output all configured directories that exist."""
|
||||
directories = get_directories_from_config()
|
||||
|
||||
# Filter to directories that actually exist
|
||||
existing = []
|
||||
for dir_path in directories:
|
||||
path = Path(dir_path)
|
||||
if path.exists() and path.is_dir():
|
||||
existing.append(dir_path)
|
||||
|
||||
# Output one directory per line
|
||||
for dir_path in sorted(existing):
|
||||
print(dir_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -17,16 +17,15 @@ The generated documentation includes:
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
|
||||
def get_field_type_name(field) -> str:
|
||||
def get_field_type_name(field: Any) -> str:
|
||||
"""Get a human-readable type name for a field."""
|
||||
from shelfmark.core.settings_registry import (
|
||||
CheckboxField,
|
||||
@@ -35,54 +34,55 @@ def get_field_type_name(field) -> str:
|
||||
OrderableListField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
TagListField,
|
||||
TextField,
|
||||
)
|
||||
|
||||
if isinstance(field, CheckboxField):
|
||||
return "boolean"
|
||||
elif isinstance(field, NumberField):
|
||||
if isinstance(field, NumberField):
|
||||
return "number"
|
||||
elif isinstance(field, SelectField):
|
||||
if isinstance(field, SelectField):
|
||||
return "string (choice)"
|
||||
elif isinstance(field, MultiSelectField):
|
||||
if isinstance(field, MultiSelectField):
|
||||
return "string (comma-separated)"
|
||||
elif isinstance(field, OrderableListField):
|
||||
if isinstance(field, TagListField):
|
||||
return "string (comma-separated)"
|
||||
if isinstance(field, OrderableListField):
|
||||
return "JSON array"
|
||||
elif isinstance(field, PasswordField):
|
||||
if isinstance(field, PasswordField):
|
||||
return "string (secret)"
|
||||
elif isinstance(field, TextField):
|
||||
return "string"
|
||||
else:
|
||||
if isinstance(field, TextField):
|
||||
return "string"
|
||||
return "string"
|
||||
|
||||
|
||||
def format_default_value(field) -> str:
|
||||
def format_default_value(field: Any) -> str:
|
||||
"""Format the default value for display."""
|
||||
default = field.default
|
||||
|
||||
if default is None:
|
||||
return "_none_"
|
||||
elif isinstance(default, bool):
|
||||
if isinstance(default, bool):
|
||||
return f"`{str(default).lower()}`"
|
||||
elif isinstance(default, (int, float)):
|
||||
if isinstance(default, (int, float)):
|
||||
return f"`{default}`"
|
||||
elif isinstance(default, str):
|
||||
if isinstance(default, str):
|
||||
if default == "":
|
||||
return "_empty string_"
|
||||
return f"`{default}`"
|
||||
elif isinstance(default, list):
|
||||
if isinstance(default, list):
|
||||
if not default:
|
||||
return "_empty list_"
|
||||
# For simple lists, show comma-separated values
|
||||
if all(isinstance(item, str) for item in default):
|
||||
return f"`{','.join(default)}`"
|
||||
# For complex lists (e.g., OrderableListField defaults), summarize
|
||||
return f"_see UI for defaults_"
|
||||
else:
|
||||
return f"`{default}`"
|
||||
return "_see UI for defaults_"
|
||||
return f"`{default}`"
|
||||
|
||||
|
||||
def get_select_options(field) -> Optional[List[str]]:
|
||||
def get_select_options(field: Any) -> list[str] | None:
|
||||
"""Get the available options for a SelectField.
|
||||
|
||||
Returns options formatted as 'value (label)' or just 'value' if they match,
|
||||
@@ -119,7 +119,7 @@ def get_select_options(field) -> Optional[List[str]]:
|
||||
return result
|
||||
|
||||
|
||||
def _generate_bootstrap_env_docs() -> List[str]:
|
||||
def _generate_bootstrap_env_docs() -> list[str]:
|
||||
"""Generate documentation for bootstrap environment variables from env.py."""
|
||||
# These are environment variables defined in env.py that are used before
|
||||
# the settings registry is available
|
||||
@@ -178,6 +178,12 @@ def _generate_bootstrap_env_docs() -> List[str]:
|
||||
"type": "boolean",
|
||||
"default": "false",
|
||||
},
|
||||
{
|
||||
"name": "ONBOARDING",
|
||||
"description": "Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage).",
|
||||
"type": "boolean",
|
||||
"default": "true",
|
||||
},
|
||||
]
|
||||
|
||||
lines = [
|
||||
@@ -189,8 +195,10 @@ def _generate_bootstrap_env_docs() -> List[str]:
|
||||
"|----------|-------------|------|---------|",
|
||||
]
|
||||
|
||||
for var in bootstrap_vars:
|
||||
lines.append(f"| `{var['name']}` | {var['description']} | {var['type']} | `{var['default']}` |")
|
||||
lines.extend(
|
||||
f"| `{var['name']}` | {var['description']} | {var['type']} | `{var['default']}` |"
|
||||
for var in bootstrap_vars
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append("<details>")
|
||||
@@ -215,16 +223,14 @@ def _generate_bootstrap_env_docs() -> List[str]:
|
||||
def generate_env_docs() -> str:
|
||||
"""Generate markdown documentation for all environment variables."""
|
||||
# Import settings modules to ensure all settings are registered
|
||||
import shelfmark.config.settings # noqa: F401
|
||||
import shelfmark.release_sources.irc.settings # noqa: F401
|
||||
import shelfmark.config.security
|
||||
import shelfmark.config.settings
|
||||
import shelfmark.metadata_providers.googlebooks
|
||||
import shelfmark.metadata_providers.hardcover
|
||||
import shelfmark.metadata_providers.openlibrary
|
||||
import shelfmark.release_sources.irc.settings
|
||||
import shelfmark.release_sources.prowlarr.settings # noqa: F401
|
||||
import shelfmark.metadata_providers.hardcover # noqa: F401
|
||||
import shelfmark.metadata_providers.openlibrary # noqa: F401
|
||||
import shelfmark.metadata_providers.googlebooks # noqa: F401
|
||||
|
||||
from shelfmark.core.settings_registry import (
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
get_all_groups,
|
||||
get_all_settings_tabs,
|
||||
)
|
||||
@@ -233,7 +239,7 @@ def generate_env_docs() -> str:
|
||||
groups = {g.name: g for g in get_all_groups()}
|
||||
|
||||
# Organize tabs by group
|
||||
grouped_tabs: Dict[Optional[str], List] = {None: []}
|
||||
grouped_tabs: dict[str | None, list] = {None: []}
|
||||
for group_name in groups:
|
||||
grouped_tabs[group_name] = []
|
||||
|
||||
@@ -302,33 +308,24 @@ def generate_env_docs() -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]:
|
||||
def _generate_tab_docs(tab: Any, group_prefix: str | None = None) -> list[str]:
|
||||
"""Generate documentation for a single settings tab."""
|
||||
from shelfmark.core.settings_registry import ActionButton, HeadingField
|
||||
from shelfmark.core.settings_registry import iter_value_fields
|
||||
|
||||
lines = []
|
||||
|
||||
# Section header
|
||||
if group_prefix:
|
||||
lines.append(f"### {group_prefix}: {tab.display_name}")
|
||||
anchor_id = f"{group_prefix}-{tab.display_name}".lower().replace(" ", "-")
|
||||
else:
|
||||
lines.append(f"## {tab.display_name}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Collect env-supported fields
|
||||
env_fields = []
|
||||
for field in tab.fields:
|
||||
# Skip non-value fields
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
continue
|
||||
|
||||
# Skip fields that don't support ENV vars
|
||||
if not getattr(field, "env_supported", True):
|
||||
continue
|
||||
|
||||
env_fields.append(field)
|
||||
env_fields = [
|
||||
field for field in iter_value_fields(tab) if getattr(field, "env_supported", True)
|
||||
]
|
||||
|
||||
if not env_fields:
|
||||
lines.append("_No environment variables for this section._")
|
||||
@@ -384,6 +381,7 @@ def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]:
|
||||
|
||||
# Show constraints for NumberField
|
||||
from shelfmark.core.settings_registry import NumberField
|
||||
|
||||
if isinstance(field, NumberField):
|
||||
constraints = []
|
||||
if field.min_value is not None:
|
||||
@@ -401,7 +399,7 @@ def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]:
|
||||
return lines
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate markdown documentation for environment variables"
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ Usage:
|
||||
2. Wait for containers to initialize (first run takes ~30s)
|
||||
|
||||
3. Run this script to verify clients are accessible:
|
||||
python scripts/test_clients.py
|
||||
uv run python scripts/test_clients.py
|
||||
|
||||
4. Access cwabd at http://localhost:8084
|
||||
- Go to Settings > Prowlarr > Download Clients
|
||||
@@ -26,7 +26,7 @@ Web UIs:
|
||||
- rTorrent: http://localhost:8000 (web ui http://localhost:8089 via ruTorrent)
|
||||
|
||||
Prerequisites (for running this script locally):
|
||||
pip install requests transmission-rpc qbittorrent-api
|
||||
uv sync --locked
|
||||
|
||||
First-Time Setup:
|
||||
qBittorrent:
|
||||
@@ -51,7 +51,8 @@ First-Time Setup:
|
||||
|
||||
import sys
|
||||
import time
|
||||
from xmlrpc import client
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Test configuration - matches docker-compose.test-clients.yml
|
||||
CONFIG = {
|
||||
@@ -89,7 +90,7 @@ CONFIG = {
|
||||
TEST_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=ubuntu-22.04.3-live-server-amd64.iso"
|
||||
|
||||
|
||||
def test_nzbget():
|
||||
def test_nzbget() -> bool:
|
||||
"""Test NZBGet connection."""
|
||||
import requests
|
||||
|
||||
@@ -138,7 +139,7 @@ def test_nzbget():
|
||||
return False
|
||||
|
||||
|
||||
def test_sabnzbd():
|
||||
def test_sabnzbd() -> bool:
|
||||
"""Test SABnzbd connection."""
|
||||
import requests
|
||||
|
||||
@@ -152,10 +153,9 @@ def test_sabnzbd():
|
||||
# Try to get API key from config if not set
|
||||
if not api_key:
|
||||
try:
|
||||
import os
|
||||
ini_path = ".local/test-clients/sabnzbd/config/sabnzbd.ini"
|
||||
if os.path.exists(ini_path):
|
||||
with open(ini_path) as f:
|
||||
ini_path = Path(".local/test-clients/sabnzbd/config/sabnzbd.ini")
|
||||
if ini_path.exists():
|
||||
with ini_path.open() as f:
|
||||
for line in f:
|
||||
if line.startswith("api_key"):
|
||||
api_key = line.split("=")[1].strip()
|
||||
@@ -204,7 +204,7 @@ def test_sabnzbd():
|
||||
return False
|
||||
|
||||
|
||||
def test_qbittorrent():
|
||||
def test_qbittorrent() -> bool:
|
||||
"""Test qBittorrent connection."""
|
||||
print("\n" + "=" * 50)
|
||||
print("Testing qBittorrent")
|
||||
@@ -219,6 +219,7 @@ def test_qbittorrent():
|
||||
|
||||
# Parse URL for host/port
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
client = qbittorrentapi.Client(
|
||||
@@ -260,7 +261,7 @@ def test_qbittorrent():
|
||||
|
||||
except ImportError:
|
||||
print(" ERROR: qbittorrent-api not installed")
|
||||
print(" Run: pip install qbittorrent-api")
|
||||
print(" Run: uv sync --locked")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
@@ -271,16 +272,17 @@ def test_qbittorrent():
|
||||
return False
|
||||
|
||||
|
||||
def test_transmission():
|
||||
def test_transmission() -> bool:
|
||||
"""Test Transmission connection."""
|
||||
print("\n" + "=" * 50)
|
||||
print("Testing Transmission")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
from transmission_rpc import Client
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from transmission_rpc import Client
|
||||
|
||||
url = CONFIG["transmission"]["url"]
|
||||
parsed = urlparse(url)
|
||||
|
||||
@@ -317,14 +319,14 @@ def test_transmission():
|
||||
|
||||
except ImportError:
|
||||
print(" ERROR: transmission-rpc not installed")
|
||||
print(" Run: pip install transmission-rpc")
|
||||
print(" Run: uv sync --locked")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_deluge():
|
||||
def test_deluge() -> bool:
|
||||
"""Test Deluge Web UI (JSON-RPC) connection."""
|
||||
import requests
|
||||
|
||||
@@ -336,7 +338,7 @@ def test_deluge():
|
||||
password = CONFIG["deluge"]["password"]
|
||||
rpc_url = f"{base_url}/json"
|
||||
|
||||
def rpc_call(session: requests.Session, rpc_id: int, method: str, *params):
|
||||
def rpc_call(session: requests.Session, rpc_id: int, method: str, *params: Any) -> Any:
|
||||
payload = {"id": rpc_id, "method": method, "params": list(params)}
|
||||
resp = session.post(rpc_url, json=payload, timeout=10)
|
||||
resp.raise_for_status()
|
||||
@@ -366,7 +368,11 @@ def test_deluge():
|
||||
|
||||
host_id = hosts[0][0]
|
||||
for entry in hosts:
|
||||
if isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"}:
|
||||
if (
|
||||
isinstance(entry, list)
|
||||
and len(entry) >= 2
|
||||
and entry[1] in {"127.0.0.1", "localhost"}
|
||||
):
|
||||
host_id = entry[0]
|
||||
break
|
||||
|
||||
@@ -386,13 +392,18 @@ def test_deluge():
|
||||
|
||||
# Test adding a torrent (then remove it)
|
||||
print(" Testing add/remove torrent...")
|
||||
torrent_id = rpc_call(session, 8, "core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True})
|
||||
torrent_id = rpc_call(
|
||||
session, 8, "core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True}
|
||||
)
|
||||
|
||||
if torrent_id:
|
||||
torrent_id = str(torrent_id)
|
||||
print(f" Added test torrent: {torrent_id[:20]}...")
|
||||
|
||||
status = rpc_call(session, 9, "core.get_torrent_status", torrent_id, ["state", "progress"]) or {}
|
||||
status = (
|
||||
rpc_call(session, 9, "core.get_torrent_status", torrent_id, ["state", "progress"])
|
||||
or {}
|
||||
)
|
||||
state = status.get("state", "unknown") if isinstance(status, dict) else "unknown"
|
||||
progress = status.get("progress", 0) if isinstance(status, dict) else 0
|
||||
print(f" Status: {state} ({progress:.1f}%)")
|
||||
@@ -418,7 +429,8 @@ def test_deluge():
|
||||
print(" Check Deluge Web UI password (default: deluge)")
|
||||
return False
|
||||
|
||||
def test_rtorrent():
|
||||
|
||||
def test_rtorrent() -> bool:
|
||||
"""Test rTorrent connection."""
|
||||
print("\n" + "=" * 50)
|
||||
print("Testing rTorrent")
|
||||
@@ -434,6 +446,10 @@ def test_rtorrent():
|
||||
version = client.system.library_version()
|
||||
print(f" Connected to rTorrent {version}")
|
||||
|
||||
# default download directory test
|
||||
default_dir = client.directory.default()
|
||||
print(f" Default download directory: {default_dir}")
|
||||
|
||||
# Get torrent list
|
||||
torrents = client.download_list()
|
||||
print(f" Active torrents: {len(torrents)}")
|
||||
@@ -453,16 +469,18 @@ def test_rtorrent():
|
||||
|
||||
# rtorrent is weird in that it doesn't return the torrent ID/hash on add
|
||||
client.load.start("", TEST_MAGNET, ";".join(commands))
|
||||
|
||||
|
||||
# but we know that it is 3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0 from the magnet link
|
||||
torrent_id = "3B245504CF5F11BBDBE1201CEA6A6BF45AEE1BC0" # rtorrent uses uppercase hashes
|
||||
torrent_id = "3B245504CF5F11BBDBE1201CEA6A6BF45AEE1BC0" # rtorrent uses uppercase hashes
|
||||
print(f" Added test torrent: {torrent_id}")
|
||||
|
||||
torrents = client.download_list()
|
||||
print(f" Active torrents: {len(torrents)}")
|
||||
|
||||
torrent_list = client.d.multicall.filtered(
|
||||
"",
|
||||
"default",
|
||||
f"equal=d.hash=,cat={torrent_id}",
|
||||
"d.hash=",
|
||||
f"equal={{d.hash=,cat={torrent_id}}}d.hash=",
|
||||
"d.state=",
|
||||
"d.completed_bytes=",
|
||||
"d.size_bytes=",
|
||||
@@ -472,11 +490,22 @@ def test_rtorrent():
|
||||
"d.complete=",
|
||||
)
|
||||
torrent = torrent_list[0]
|
||||
|
||||
if not torrent:
|
||||
print(" ERROR: Could not find added torrent in list")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
# let's test the base path call
|
||||
details = client.d.multicall.filtered(
|
||||
"",
|
||||
"default",
|
||||
f"equal=d.hash=,cat={torrent_id}",
|
||||
"d.base_path=",
|
||||
)
|
||||
|
||||
base_path = details[0][0] if details else None
|
||||
|
||||
print(f" Base path: {base_path}")
|
||||
client.d.erase(torrent_id)
|
||||
print(" Removed test torrent")
|
||||
|
||||
@@ -493,7 +522,7 @@ def test_rtorrent():
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> int:
|
||||
print("Download Client Test Suite")
|
||||
print("=" * 50)
|
||||
print("Make sure containers are running:")
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
"""Package entry point for `python -m shelfmark`."""
|
||||
|
||||
from shelfmark.main import app, socketio
|
||||
from shelfmark.config.env import FLASK_HOST, FLASK_PORT
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.main import app, socketio
|
||||
|
||||
|
||||
def _resolve_debug_flag(value: object) -> bool:
|
||||
"""Normalize DEBUG config values for Flask-SocketIO startup."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
socketio.run(app, host=FLASK_HOST, port=FLASK_PORT, debug=config.get("DEBUG", False))
|
||||
socketio.run(
|
||||
app,
|
||||
host=FLASK_HOST,
|
||||
port=FLASK_PORT,
|
||||
debug=_resolve_debug_flag(config.get("DEBUG", False)),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"""WebSocket manager for real-time status updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional, Dict, Any, Callable, List
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from flask_socketio import SocketIO
|
||||
from flask_socketio import SocketIO, join_room, leave_room
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from flask import Flask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -12,138 +19,174 @@ logger = logging.getLogger(__name__)
|
||||
class WebSocketManager:
|
||||
"""Manages WebSocket connections and broadcasts."""
|
||||
|
||||
def __init__(self):
|
||||
self.socketio: Optional[SocketIO] = None
|
||||
def __init__(self) -> None:
|
||||
"""Initialize in-memory connection and room tracking."""
|
||||
self.socketio: SocketIO | None = None
|
||||
self._enabled = False
|
||||
self._connection_count = 0
|
||||
self._connection_lock = threading.Lock()
|
||||
self._on_first_connect_callbacks: List[Callable[[], None]] = []
|
||||
self._on_all_disconnect_callbacks: List[Callable[[], None]] = []
|
||||
self._needs_rewarm = False # Flag to trigger warmup callbacks on next connect
|
||||
self._user_rooms: dict[str, int] = {} # room_name -> ref count
|
||||
self._sid_rooms: dict[str, str] = {} # sid -> room_name
|
||||
self._rooms_lock = threading.Lock()
|
||||
self._queue_status_fn: Callable | None = None # Reference to queue_status()
|
||||
|
||||
def init_app(self, app, socketio: SocketIO):
|
||||
def init_app(self, app: Flask, socketio: SocketIO) -> None:
|
||||
"""Initialize the WebSocket manager with Flask-SocketIO instance."""
|
||||
self.socketio = socketio
|
||||
self._enabled = True
|
||||
logger.info("WebSocket manager initialized")
|
||||
|
||||
def register_on_first_connect(self, callback: Callable[[], None]):
|
||||
"""Register a callback for when the first client connects."""
|
||||
self._on_first_connect_callbacks.append(callback)
|
||||
logger.debug(f"Registered on_first_connect callback: {callback.__name__}")
|
||||
|
||||
def register_on_all_disconnect(self, callback: Callable[[], None]):
|
||||
"""Register a callback for when all clients disconnect."""
|
||||
self._on_all_disconnect_callbacks.append(callback)
|
||||
logger.debug(f"Registered on_all_disconnect callback: {callback.__name__}")
|
||||
|
||||
def request_warmup_on_next_connect(self):
|
||||
"""Request warmup callbacks on the next client connect (e.g., after idle shutdown)."""
|
||||
with self._connection_lock:
|
||||
self._needs_rewarm = True
|
||||
logger.debug("Warmup requested for next client connect")
|
||||
|
||||
def client_connected(self):
|
||||
def client_connected(self) -> None:
|
||||
"""Track a new client connection. Call this from the connect event handler."""
|
||||
with self._connection_lock:
|
||||
was_zero = self._connection_count == 0
|
||||
needs_rewarm = self._needs_rewarm
|
||||
self._connection_count += 1
|
||||
current_count = self._connection_count
|
||||
# Clear rewarm flag if we're going to trigger warmup
|
||||
if was_zero or needs_rewarm:
|
||||
self._needs_rewarm = False
|
||||
|
||||
logger.debug(f"Client connected. Active connections: {current_count}")
|
||||
logger.debug("Client connected. Active connections: %s", current_count)
|
||||
|
||||
# Trigger warmup callbacks if this is the first connection OR if rewarm was requested
|
||||
# (rewarm is requested when bypasser shuts down due to idle while clients are connected)
|
||||
if was_zero or needs_rewarm:
|
||||
reason = "First client connected" if was_zero else "Rewarm requested after idle shutdown"
|
||||
logger.info(f"{reason}, triggering warmup callbacks...")
|
||||
for callback in self._on_first_connect_callbacks:
|
||||
try:
|
||||
# Run callbacks in a separate thread to not block the connection
|
||||
thread = threading.Thread(target=callback, daemon=True)
|
||||
thread.start()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_first_connect callback {callback.__name__}: {e}")
|
||||
|
||||
def client_disconnected(self):
|
||||
def client_disconnected(self) -> None:
|
||||
"""Track a client disconnection. Call this from the disconnect event handler."""
|
||||
with self._connection_lock:
|
||||
self._connection_count = max(0, self._connection_count - 1)
|
||||
current_count = self._connection_count
|
||||
is_now_zero = current_count == 0
|
||||
|
||||
logger.debug(f"Client disconnected. Active connections: {current_count}")
|
||||
|
||||
# If all clients have disconnected, trigger cleanup callbacks
|
||||
if is_now_zero:
|
||||
logger.info("All clients disconnected, triggering disconnect callbacks...")
|
||||
for callback in self._on_all_disconnect_callbacks:
|
||||
try:
|
||||
callback()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_all_disconnect callback {callback.__name__}: {e}")
|
||||
|
||||
def get_connection_count(self) -> int:
|
||||
"""Get the current number of active WebSocket connections."""
|
||||
with self._connection_lock:
|
||||
return self._connection_count
|
||||
|
||||
def has_active_connections(self) -> bool:
|
||||
"""Check if there are any active WebSocket connections."""
|
||||
return self.get_connection_count() > 0
|
||||
logger.debug("Client disconnected. Active connections: %s", current_count)
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if WebSocket is enabled and ready."""
|
||||
return self._enabled and self.socketio is not None
|
||||
|
||||
def broadcast_status_update(self, status_data: Dict[str, Any]):
|
||||
"""Broadcast status update to all connected clients."""
|
||||
if not self.is_enabled():
|
||||
def _get_socketio(self) -> SocketIO | None:
|
||||
if not self._enabled:
|
||||
return None
|
||||
return self.socketio
|
||||
|
||||
def set_queue_status_fn(self, fn: Callable) -> None:
|
||||
"""Set the queue_status function reference for per-room filtering."""
|
||||
self._queue_status_fn = fn
|
||||
|
||||
def _increment_user_room_locked(self, room: str) -> None:
|
||||
self._user_rooms[room] = self._user_rooms.get(room, 0) + 1
|
||||
|
||||
def _decrement_user_room_locked(self, room: str) -> None:
|
||||
count = self._user_rooms.get(room, 1) - 1
|
||||
if count <= 0:
|
||||
self._user_rooms.pop(room, None)
|
||||
else:
|
||||
self._user_rooms[room] = count
|
||||
|
||||
def _set_sid_room_locked(self, sid: str, room: str | None) -> None:
|
||||
current_room = self._sid_rooms.get(sid)
|
||||
if current_room == room:
|
||||
return
|
||||
|
||||
if current_room is not None:
|
||||
leave_room(current_room, sid=sid)
|
||||
if current_room.startswith("user_"):
|
||||
self._decrement_user_room_locked(current_room)
|
||||
self._sid_rooms.pop(sid, None)
|
||||
|
||||
if room is not None:
|
||||
join_room(room, sid=sid)
|
||||
self._sid_rooms[sid] = room
|
||||
if room.startswith("user_"):
|
||||
self._increment_user_room_locked(room)
|
||||
|
||||
def sync_user_room(
|
||||
self,
|
||||
sid: str,
|
||||
is_admin: bool,
|
||||
db_user_id: int | None = None,
|
||||
) -> None:
|
||||
"""Ensure a SID is in exactly one room matching the current session scope."""
|
||||
room: str | None = None
|
||||
if is_admin:
|
||||
room = "admins"
|
||||
elif db_user_id is not None:
|
||||
room = f"user_{db_user_id}"
|
||||
|
||||
with self._rooms_lock:
|
||||
self._set_sid_room_locked(sid, room)
|
||||
|
||||
def join_user_room(
|
||||
self,
|
||||
sid: str,
|
||||
is_admin: bool,
|
||||
db_user_id: int | None = None,
|
||||
) -> None:
|
||||
"""Join the appropriate room based on user role."""
|
||||
self.sync_user_room(sid, is_admin=is_admin, db_user_id=db_user_id)
|
||||
|
||||
def leave_user_room(
|
||||
self,
|
||||
sid: str,
|
||||
*,
|
||||
is_admin: bool = False,
|
||||
db_user_id: int | None = None,
|
||||
) -> None:
|
||||
"""Leave whichever room the SID currently belongs to."""
|
||||
del is_admin, db_user_id # Backward-compatible signature; routing is SID-based.
|
||||
with self._rooms_lock:
|
||||
self._set_sid_room_locked(sid, None)
|
||||
|
||||
def broadcast_status_update(self, status_data: dict[str, Any]) -> None:
|
||||
"""Broadcast status update to all connected clients, filtered by user room."""
|
||||
socketio = self._get_socketio()
|
||||
if socketio is None:
|
||||
return
|
||||
|
||||
try:
|
||||
# When calling socketio.emit() outside event handlers, it broadcasts by default
|
||||
self.socketio.emit('status_update', status_data)
|
||||
logger.debug(f"Broadcasted status update to all clients")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting status update: {e}")
|
||||
# Admins (and no-auth users) get full status
|
||||
socketio.emit("status_update", status_data, to="admins")
|
||||
|
||||
def broadcast_download_progress(self, book_id: str, progress: float, status: str):
|
||||
# Each user room gets filtered status
|
||||
with self._rooms_lock:
|
||||
active_rooms = list(self._user_rooms.keys())
|
||||
|
||||
if active_rooms and self._queue_status_fn:
|
||||
for room in active_rooms:
|
||||
self._broadcast_status_update_to_room(room)
|
||||
|
||||
logger.debug("Broadcasted status update to all rooms")
|
||||
except Exception:
|
||||
logger.exception("Error broadcasting status update")
|
||||
|
||||
def _broadcast_status_update_to_room(self, room: str) -> None:
|
||||
"""Broadcast status update to one user room."""
|
||||
socketio = self._get_socketio()
|
||||
if socketio is None:
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract user_id from room name "user_123"
|
||||
uid = int(room.split("_", 1)[1])
|
||||
filtered = self._queue_status_fn(user_id=uid) if self._queue_status_fn else None
|
||||
if filtered is not None:
|
||||
socketio.emit("status_update", filtered, to=room)
|
||||
except Exception:
|
||||
logger.exception("Failed to send status update for room %s", room)
|
||||
|
||||
def broadcast_download_progress(
|
||||
self, book_id: str, progress: float, status: str, user_id: int | None = None
|
||||
) -> None:
|
||||
"""Broadcast download progress update for a specific book."""
|
||||
if not self.is_enabled():
|
||||
socketio = self._get_socketio()
|
||||
if socketio is None:
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
'book_id': book_id,
|
||||
'progress': progress,
|
||||
'status': status
|
||||
}
|
||||
# When calling socketio.emit() outside event handlers, it broadcasts by default
|
||||
self.socketio.emit('download_progress', data)
|
||||
logger.debug(f"Broadcasted progress for book {book_id}: {progress}%")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting download progress: {e}")
|
||||
|
||||
def broadcast_notification(self, message: str, notification_type: str = 'info'):
|
||||
"""Broadcast a notification message to all clients."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
'message': message,
|
||||
'type': notification_type
|
||||
}
|
||||
# When calling socketio.emit() outside event handlers, it broadcasts by default
|
||||
self.socketio.emit('notification', data)
|
||||
logger.debug(f"Broadcasted notification: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting notification: {e}")
|
||||
data = {"book_id": book_id, "progress": progress, "status": status}
|
||||
# Admins always see all progress
|
||||
socketio.emit("download_progress", data, to="admins")
|
||||
# If task belongs to a specific user, send to their room too
|
||||
if user_id is not None:
|
||||
room = f"user_{user_id}"
|
||||
with self._rooms_lock:
|
||||
if room in self._user_rooms:
|
||||
socketio.emit("download_progress", data, to=room)
|
||||
logger.debug("Broadcasted progress for book %s: %s%%", book_id, progress)
|
||||
except Exception:
|
||||
logger.exception("Error broadcasting download progress")
|
||||
|
||||
def broadcast_search_status(
|
||||
self,
|
||||
@@ -151,23 +194,24 @@ class WebSocketManager:
|
||||
provider: str,
|
||||
book_id: str,
|
||||
message: str,
|
||||
phase: str = 'searching'
|
||||
):
|
||||
phase: str = "searching",
|
||||
) -> None:
|
||||
"""Broadcast search status update for a release source search."""
|
||||
if not self.is_enabled():
|
||||
socketio = self._get_socketio()
|
||||
if socketio is None:
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
'source': source,
|
||||
'provider': provider,
|
||||
'book_id': book_id,
|
||||
'message': message,
|
||||
'phase': phase,
|
||||
"source": source,
|
||||
"provider": provider,
|
||||
"book_id": book_id,
|
||||
"message": message,
|
||||
"phase": phase,
|
||||
}
|
||||
self.socketio.emit('search_status', data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting search status: {e}")
|
||||
socketio.emit("search_status", data)
|
||||
except Exception:
|
||||
logger.exception("Error broadcasting search status")
|
||||
|
||||
|
||||
# Global WebSocket manager instance
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Cloudflare bypass utilities."""
|
||||
|
||||
|
||||
class BypassCancelledException(Exception):
|
||||
class BypassCancelledError(Exception):
|
||||
"""Raised when a bypass operation is cancelled."""
|
||||
|
||||
@@ -2,20 +2,23 @@
|
||||
|
||||
import random
|
||||
import time
|
||||
from threading import Event
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.bypass import BypassCancelledException
|
||||
from shelfmark.bypass import BypassCancelledError
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from threading import Event
|
||||
|
||||
from shelfmark.download import network
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
_RNG = random.SystemRandom()
|
||||
|
||||
# Timeout constants (seconds)
|
||||
CONNECT_TIMEOUT = 10
|
||||
@@ -28,15 +31,36 @@ BACKOFF_BASE = 1.0
|
||||
BACKOFF_CAP = 10.0
|
||||
|
||||
|
||||
def _fetch_via_bypasser(target_url: str) -> Optional[str]:
|
||||
def _coerce_config_str(value: object, default: str) -> str:
|
||||
"""Return a string config value or a safe default."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_timeout_ms(value: object, default: int) -> int:
|
||||
"""Return a positive timeout in milliseconds or the default."""
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, int) and value > 0:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _fetch_via_bypasser(target_url: str) -> str | None:
|
||||
"""Make a single request to the external bypasser service. Returns HTML or None."""
|
||||
raw_bypasser_url = config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191")
|
||||
bypasser_path = config.get("EXT_BYPASSER_PATH", "/v1")
|
||||
bypasser_timeout = config.get("EXT_BYPASSER_TIMEOUT", 60000)
|
||||
raw_bypasser_url = _coerce_config_str(
|
||||
config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191"),
|
||||
"http://flaresolverr:8191",
|
||||
)
|
||||
bypasser_path = _coerce_config_str(config.get("EXT_BYPASSER_PATH", "/v1"), "/v1")
|
||||
bypasser_timeout = _coerce_timeout_ms(config.get("EXT_BYPASSER_TIMEOUT", 60000), 60000)
|
||||
|
||||
bypasser_url = normalize_http_url(raw_bypasser_url)
|
||||
if not bypasser_url or not bypasser_path:
|
||||
logger.error("External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH.")
|
||||
logger.error(
|
||||
"External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH."
|
||||
)
|
||||
return None
|
||||
|
||||
read_timeout = min((bypasser_timeout / 1000) + READ_TIMEOUT_BUFFER, MAX_READ_TIMEOUT)
|
||||
@@ -45,47 +69,63 @@ def _fetch_via_bypasser(target_url: str) -> Optional[str]:
|
||||
response = requests.post(
|
||||
f"{bypasser_url}{bypasser_path}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={"cmd": "request.get", "url": target_url, "maxTimeout": bypasser_timeout},
|
||||
timeout=(CONNECT_TIMEOUT, read_timeout)
|
||||
json={
|
||||
"cmd": "request.get",
|
||||
"url": target_url,
|
||||
"maxTimeout": bypasser_timeout,
|
||||
},
|
||||
timeout=(CONNECT_TIMEOUT, read_timeout),
|
||||
verify=get_ssl_verify(bypasser_url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
status = result.get('status', 'unknown')
|
||||
message = result.get('message', '')
|
||||
logger.debug(f"External bypasser response for '{target_url}': {status} - {message}")
|
||||
status = result.get("status", "unknown")
|
||||
message = result.get("message", "")
|
||||
logger.debug("External bypasser response for '%s': %s - %s", target_url, status, message)
|
||||
|
||||
if status != 'ok':
|
||||
logger.warning(f"External bypasser failed for '{target_url}': {status} - {message}")
|
||||
if status != "ok":
|
||||
logger.warning(
|
||||
"External bypasser failed for '%s': %s - %s",
|
||||
target_url,
|
||||
status,
|
||||
message,
|
||||
)
|
||||
return None
|
||||
|
||||
solution = result.get('solution')
|
||||
html = solution.get('response', '') if solution else ''
|
||||
solution = result.get("solution")
|
||||
html = solution.get("response", "") if solution else ""
|
||||
|
||||
if not html:
|
||||
logger.warning(f"External bypasser returned empty response for '{target_url}'")
|
||||
logger.warning("External bypasser returned empty response for '%s'", target_url)
|
||||
return None
|
||||
|
||||
return html
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"External bypasser timed out for '{target_url}' (connect: {CONNECT_TIMEOUT}s, read: {read_timeout:.0f}s)")
|
||||
logger.warning(
|
||||
"External bypasser timed out for '%s' (connect: %ss, read: %.0fs)",
|
||||
target_url,
|
||||
CONNECT_TIMEOUT,
|
||||
read_timeout,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"External bypasser request failed for '{target_url}': {e}")
|
||||
logger.warning("External bypasser request failed for '%s': %s", target_url, e)
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
logger.warning(f"External bypasser returned malformed response for '{target_url}': {e}")
|
||||
logger.warning("External bypasser returned malformed response for '%s': %s", target_url, e)
|
||||
else:
|
||||
return html
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _check_cancelled(cancel_flag: Optional[Event], context: str) -> None:
|
||||
def _check_cancelled(cancel_flag: Event | None, context: str) -> None:
|
||||
"""Check if operation was cancelled and raise exception if so."""
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
logger.info(f"External bypasser cancelled {context}")
|
||||
raise BypassCancelledException("Bypass cancelled")
|
||||
logger.info("External bypasser cancelled %s", context)
|
||||
msg = "Bypass cancelled"
|
||||
raise BypassCancelledError(msg)
|
||||
|
||||
|
||||
def _sleep_with_cancellation(seconds: float, cancel_flag: Optional[Event]) -> None:
|
||||
def _sleep_with_cancellation(seconds: float, cancel_flag: Event | None) -> None:
|
||||
"""Sleep for the specified duration, checking for cancellation each second."""
|
||||
for _ in range(int(seconds)):
|
||||
_check_cancelled(cancel_flag, "during backoff")
|
||||
@@ -97,9 +137,9 @@ def _sleep_with_cancellation(seconds: float, cancel_flag: Optional[Event]) -> No
|
||||
|
||||
def get_bypassed_page(
|
||||
url: str,
|
||||
selector: Optional["network.AAMirrorSelector"] = None,
|
||||
cancel_flag: Optional[Event] = None
|
||||
) -> Optional[str]:
|
||||
selector: network.AAMirrorSelector | None = None,
|
||||
cancel_flag: Event | None = None,
|
||||
) -> str | None:
|
||||
"""Fetch HTML via external bypasser with retries and mirror rotation."""
|
||||
from shelfmark.download import network as network_module
|
||||
|
||||
@@ -116,13 +156,18 @@ def get_bypassed_page(
|
||||
if attempt == MAX_RETRY:
|
||||
break
|
||||
|
||||
delay = min(BACKOFF_CAP, BACKOFF_BASE * (2 ** (attempt - 1))) + random.random()
|
||||
logger.info(f"External bypasser attempt {attempt}/{MAX_RETRY} failed, retrying in {delay:.1f}s")
|
||||
delay = min(BACKOFF_CAP, BACKOFF_BASE * (2 ** (attempt - 1))) + _RNG.random()
|
||||
logger.info(
|
||||
"External bypasser attempt %s/%s failed, retrying in %.1fs",
|
||||
attempt,
|
||||
MAX_RETRY,
|
||||
delay,
|
||||
)
|
||||
|
||||
_sleep_with_cancellation(delay, cancel_flag)
|
||||
|
||||
new_base, action = sel.next_mirror_or_rotate_dns()
|
||||
if action in ("mirror", "dns") and new_base:
|
||||
logger.info(f"Rotated {action} for retry")
|
||||
logger.info("Rotated %s for retry", action)
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,52 +1,65 @@
|
||||
"""Browser fingerprint profile management for bypass stealth."""
|
||||
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
COMMON_RESOLUTIONS = [
|
||||
(1920, 1080, 0.35),
|
||||
(1366, 768, 0.18),
|
||||
(1536, 864, 0.10),
|
||||
(1440, 900, 0.08),
|
||||
(1280, 720, 0.07),
|
||||
(1600, 900, 0.06),
|
||||
(1280, 800, 0.05),
|
||||
(2560, 1440, 0.04),
|
||||
(1680, 1050, 0.04),
|
||||
(1920, 1200, 0.03),
|
||||
(1920, 1080, 0.35),
|
||||
(1366, 768, 0.18),
|
||||
(1536, 864, 0.10),
|
||||
(1440, 900, 0.08),
|
||||
(1280, 720, 0.07),
|
||||
(1600, 900, 0.06),
|
||||
(1280, 800, 0.05),
|
||||
(2560, 1440, 0.04),
|
||||
(1680, 1050, 0.04),
|
||||
(1920, 1200, 0.03),
|
||||
]
|
||||
|
||||
# Current screen size (module-level singleton)
|
||||
_current_screen_size: Optional[tuple[int, int]] = None
|
||||
_current_screen_size: tuple[int, int] | None = None
|
||||
_RNG = random.SystemRandom()
|
||||
|
||||
|
||||
def get_screen_size() -> tuple[int, int]:
|
||||
"""Return the current synthetic screen size, generating one if needed."""
|
||||
global _current_screen_size
|
||||
if _current_screen_size is None:
|
||||
_current_screen_size = _generate_screen_size()
|
||||
logger.debug(f"Generated initial screen size: {_current_screen_size[0]}x{_current_screen_size[1]}")
|
||||
logger.debug(
|
||||
"Generated initial screen size: %sx%s",
|
||||
_current_screen_size[0],
|
||||
_current_screen_size[1],
|
||||
)
|
||||
return _current_screen_size
|
||||
|
||||
|
||||
def rotate_screen_size() -> tuple[int, int]:
|
||||
"""Rotate to a new synthetic screen size and return it."""
|
||||
global _current_screen_size
|
||||
old_size = _current_screen_size
|
||||
_current_screen_size = _generate_screen_size()
|
||||
width, height = _current_screen_size
|
||||
|
||||
if old_size:
|
||||
logger.info(f"Rotated screen size: {old_size[0]}x{old_size[1]} -> {width}x{height}")
|
||||
logger.info(
|
||||
"Rotated screen size: %sx%s -> %sx%s",
|
||||
old_size[0],
|
||||
old_size[1],
|
||||
width,
|
||||
height,
|
||||
)
|
||||
else:
|
||||
logger.info(f"Generated screen size: {width}x{height}")
|
||||
logger.info("Generated screen size: %sx%s", width, height)
|
||||
|
||||
return _current_screen_size
|
||||
|
||||
|
||||
def clear_screen_size() -> None:
|
||||
"""Clear the cached synthetic screen size."""
|
||||
global _current_screen_size
|
||||
_current_screen_size = None
|
||||
|
||||
@@ -54,4 +67,4 @@ def clear_screen_size() -> None:
|
||||
def _generate_screen_size() -> tuple[int, int]:
|
||||
resolutions = [(w, h) for w, h, _ in COMMON_RESOLUTIONS]
|
||||
weights = [weight for _, _, weight in COMMON_RESOLUTIONS]
|
||||
return random.choices(resolutions, weights=weights)[0]
|
||||
return _RNG.choices(resolutions, weights=weights)[0]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Helpers for Booklore settings validation, option loading, and connection tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
@@ -121,7 +123,7 @@ def get_booklore_library_options() -> list[dict[str, Any]]:
|
||||
|
||||
base_url = str(config.get("BOOKLORE_HOST", "") or "").strip().rstrip("/")
|
||||
username = str(config.get("BOOKLORE_USERNAME", "") or "").strip()
|
||||
password = config.get("BOOKLORE_PASSWORD", "") or ""
|
||||
password = str(config.get("BOOKLORE_PASSWORD", "") or "")
|
||||
|
||||
if not base_url or not username or not password:
|
||||
return []
|
||||
@@ -130,12 +132,13 @@ def get_booklore_library_options() -> list[dict[str, Any]]:
|
||||
|
||||
try:
|
||||
library_options, _ = _get_booklore_cached_options(base_url, username, password)
|
||||
return library_options
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to fetch Booklore libraries: {exc}")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Booklore libraries")
|
||||
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
|
||||
return _BOOKLORE_OPTIONS_CACHE.get("library_options", [])
|
||||
return []
|
||||
else:
|
||||
return library_options
|
||||
|
||||
|
||||
def get_booklore_path_options() -> list[dict[str, Any]]:
|
||||
@@ -145,7 +148,7 @@ def get_booklore_path_options() -> list[dict[str, Any]]:
|
||||
|
||||
base_url = str(config.get("BOOKLORE_HOST", "") or "").strip().rstrip("/")
|
||||
username = str(config.get("BOOKLORE_USERNAME", "") or "").strip()
|
||||
password = config.get("BOOKLORE_PASSWORD", "") or ""
|
||||
password = str(config.get("BOOKLORE_PASSWORD", "") or "")
|
||||
|
||||
if not base_url or not username or not password:
|
||||
return []
|
||||
@@ -154,19 +157,22 @@ def get_booklore_path_options() -> list[dict[str, Any]]:
|
||||
|
||||
try:
|
||||
_, path_options = _get_booklore_cached_options(base_url, username, password)
|
||||
return path_options
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to fetch Booklore paths: {exc}")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Booklore paths")
|
||||
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
|
||||
return _BOOKLORE_OPTIONS_CACHE.get("path_options", [])
|
||||
return []
|
||||
else:
|
||||
return path_options
|
||||
|
||||
|
||||
def test_booklore_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
def check_booklore_connection(
|
||||
current_values: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Test the Booklore connection using current form values."""
|
||||
current_values = current_values or {}
|
||||
|
||||
def _get_value(key: str, default: Any = None) -> Any:
|
||||
def _get_value(key: str, default: object = None) -> object:
|
||||
value = current_values.get(key)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
@@ -176,22 +182,22 @@ def test_booklore_connection(current_values: dict[str, Any] | None = None) -> di
|
||||
|
||||
base_url = str(_get_value("BOOKLORE_HOST", "") or "").strip().rstrip("/")
|
||||
username = str(_get_value("BOOKLORE_USERNAME", "") or "").strip()
|
||||
password = _get_value("BOOKLORE_PASSWORD", "") or ""
|
||||
password = str(_get_value("BOOKLORE_PASSWORD", "") or "")
|
||||
|
||||
if not base_url:
|
||||
return {"success": False, "message": "Booklore URL is required"}
|
||||
return {"success": False, "message": "Grimmory URL is required"}
|
||||
if not username:
|
||||
return {"success": False, "message": "Booklore username is required"}
|
||||
return {"success": False, "message": "Grimmory username is required"}
|
||||
if not password:
|
||||
return {"success": False, "message": "Booklore password is required"}
|
||||
return {"success": False, "message": "Grimmory password is required"}
|
||||
|
||||
try:
|
||||
library_options, _ = _get_booklore_select_options(base_url, username, password)
|
||||
|
||||
message = "Connected to Booklore"
|
||||
if library_options:
|
||||
message = f"Connected to Booklore ({len(library_options)} libraries)"
|
||||
|
||||
return {"success": True, "message": message}
|
||||
except BookloreError as exc:
|
||||
return {"success": False, "message": str(exc)}
|
||||
else:
|
||||
message = "Connected to Grimmory"
|
||||
if library_options:
|
||||
message = f"Connected to Grimmory ({len(library_options)} libraries)"
|
||||
|
||||
return {"success": True, "message": message}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_USER_PLACEHOLDER_PATTERN = re.compile(r"\{user\}", re.IGNORECASE)
|
||||
|
||||
|
||||
def _get_download_setting_value(
|
||||
current_values: dict[str, Any] | None,
|
||||
key: str,
|
||||
*,
|
||||
default: object = None,
|
||||
) -> object:
|
||||
"""Read a downloads setting from unsaved form values first, then persisted config."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
current_values = current_values or {}
|
||||
if key in current_values:
|
||||
return current_values[key]
|
||||
if default is None:
|
||||
return config.get(key)
|
||||
return config.get(key, default)
|
||||
|
||||
|
||||
def _resolve_destination_test_path(
|
||||
configured_path: str,
|
||||
) -> tuple[Path, str | None]:
|
||||
"""Resolve a safe path to validate for destination test actions."""
|
||||
stripped_path = configured_path.strip()
|
||||
|
||||
if not _USER_PLACEHOLDER_PATTERN.search(stripped_path):
|
||||
return Path(stripped_path), None
|
||||
|
||||
base_prefix = _USER_PLACEHOLDER_PATTERN.split(stripped_path, maxsplit=1)[0].rstrip("/")
|
||||
if not base_prefix and not stripped_path.startswith("/"):
|
||||
return Path(stripped_path), None
|
||||
|
||||
base_path = base_prefix or "/"
|
||||
return Path(base_path), (
|
||||
f" (tested base path {base_path} from configured template {stripped_path})"
|
||||
)
|
||||
|
||||
|
||||
def _test_folder_destination(
|
||||
*,
|
||||
current_values: dict[str, Any] | None = None,
|
||||
is_audiobook: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate a folder destination using current form values."""
|
||||
from shelfmark.download.postprocess.destination import validate_destination
|
||||
|
||||
destination_value = _get_download_setting_value(
|
||||
current_values,
|
||||
"DESTINATION",
|
||||
default="/books",
|
||||
)
|
||||
destination = str(destination_value or "").strip()
|
||||
|
||||
label = "Books destination"
|
||||
message_suffix = ""
|
||||
|
||||
if is_audiobook:
|
||||
audiobook_value = _get_download_setting_value(
|
||||
current_values,
|
||||
"DESTINATION_AUDIOBOOK",
|
||||
default="",
|
||||
)
|
||||
audiobook_destination = str(audiobook_value or "").strip()
|
||||
if audiobook_destination:
|
||||
destination = audiobook_destination
|
||||
label = "Audiobook destination"
|
||||
else:
|
||||
label = "Audiobook destination"
|
||||
message_suffix = " (using the Books destination)"
|
||||
|
||||
if not destination:
|
||||
return {"success": False, "message": f"{label} is required"}
|
||||
|
||||
test_path, path_message = _resolve_destination_test_path(destination)
|
||||
if path_message:
|
||||
message_suffix += path_message
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
def _status_callback(status: str, message: str | None) -> None:
|
||||
if status == "error" and message:
|
||||
errors.append(message)
|
||||
|
||||
if not validate_destination(test_path, _status_callback):
|
||||
message = errors[-1] if errors else f"Cannot access destination: {test_path}"
|
||||
if message_suffix:
|
||||
message = f"{message}{message_suffix}"
|
||||
return {"success": False, "message": message}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{label} is writable: {test_path}{message_suffix}",
|
||||
}
|
||||
|
||||
|
||||
def check_books_destination(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Validate the configured books destination."""
|
||||
return _test_folder_destination(current_values=current_values, is_audiobook=False)
|
||||
|
||||
|
||||
def check_audiobook_destination(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Validate the configured audiobook destination."""
|
||||
return _test_folder_destination(current_values=current_values, is_audiobook=True)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Helpers for email settings validation and SMTP connection tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.download.outputs.email import (
|
||||
EmailOutputError,
|
||||
build_email_smtp_config,
|
||||
test_smtp_connection,
|
||||
)
|
||||
|
||||
|
||||
def check_email_connection(
|
||||
current_values: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Test SMTP connectivity using current form values (including unsaved changes)."""
|
||||
current_values = current_values or {}
|
||||
|
||||
def _get_value(key: str, default: object = None) -> object:
|
||||
value = current_values.get(key)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
if default is None:
|
||||
return config.get(key)
|
||||
return config.get(key, default)
|
||||
|
||||
settings = {
|
||||
"EMAIL_SMTP_HOST": _get_value("EMAIL_SMTP_HOST", ""),
|
||||
"EMAIL_SMTP_PORT": _get_value("EMAIL_SMTP_PORT", 587),
|
||||
"EMAIL_SMTP_SECURITY": _get_value("EMAIL_SMTP_SECURITY", "starttls"),
|
||||
"EMAIL_SMTP_USERNAME": _get_value("EMAIL_SMTP_USERNAME", ""),
|
||||
"EMAIL_SMTP_PASSWORD": _get_value("EMAIL_SMTP_PASSWORD", ""),
|
||||
"EMAIL_FROM": _get_value("EMAIL_FROM", ""),
|
||||
"EMAIL_SUBJECT_TEMPLATE": _get_value("EMAIL_SUBJECT_TEMPLATE", "{Title}"),
|
||||
"EMAIL_SMTP_TIMEOUT_SECONDS": _get_value("EMAIL_SMTP_TIMEOUT_SECONDS", 60),
|
||||
"EMAIL_ALLOW_UNVERIFIED_TLS": _get_value("EMAIL_ALLOW_UNVERIFIED_TLS", default=False),
|
||||
}
|
||||
|
||||
try:
|
||||
smtp_config = build_email_smtp_config(settings)
|
||||
test_smtp_connection(smtp_config)
|
||||
except EmailOutputError as exc:
|
||||
return {"success": False, "message": str(exc)}
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
return {"success": False, "message": f"SMTP test failed: {exc}"}
|
||||
else:
|
||||
return {"success": True, "message": "Connected to SMTP server"}
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -23,11 +24,11 @@ def _read_debug_from_config() -> bool:
|
||||
|
||||
if config_file.exists():
|
||||
try:
|
||||
with open(config_file, "r") as f:
|
||||
with config_file.open() as f:
|
||||
config = json.load(f)
|
||||
if "DEBUG" in config:
|
||||
return bool(config["DEBUG"])
|
||||
except (json.JSONDecodeError, OSError):
|
||||
except json.JSONDecodeError, OSError:
|
||||
pass
|
||||
|
||||
return False
|
||||
@@ -36,10 +37,10 @@ def _read_debug_from_config() -> bool:
|
||||
def _is_sqlite_file(path: Path) -> bool:
|
||||
"""Check if a file is a valid SQLite database by reading magic bytes."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
with path.open("rb") as f:
|
||||
header = f.read(16)
|
||||
return header[:16] == b"SQLite format 3\x00"
|
||||
except (OSError, PermissionError):
|
||||
except OSError, PermissionError:
|
||||
return False
|
||||
|
||||
|
||||
@@ -67,16 +68,20 @@ def _is_config_dir_writable() -> bool:
|
||||
test_file = CONFIG_DIR / ".write_test"
|
||||
test_file.touch()
|
||||
test_file.unlink()
|
||||
return True
|
||||
except (OSError, PermissionError):
|
||||
except OSError, PermissionError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def is_covers_cache_enabled() -> bool:
|
||||
"""Check if cover caching is enabled (requires setting + writable config dir)."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
setting_enabled = config.get("COVERS_CACHE_ENABLED", True)
|
||||
return setting_enabled and _is_config_dir_writable()
|
||||
if isinstance(setting_enabled, str):
|
||||
return string_to_bool(setting_enabled) and _is_config_dir_writable()
|
||||
return bool(setting_enabled) and _is_config_dir_writable()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -87,7 +92,7 @@ CONFIG_DIR = Path(os.getenv("CONFIG_DIR", "/config"))
|
||||
LOG_ROOT = Path(os.getenv("LOG_ROOT", "/var/log/"))
|
||||
LOG_DIR = LOG_ROOT / "shelfmark"
|
||||
LOG_FILE = LOG_DIR / "shelfmark.log"
|
||||
TMP_DIR = Path(os.getenv("TMP_DIR", "/tmp/shelfmark"))
|
||||
TMP_DIR = Path(os.getenv("TMP_DIR", (Path(tempfile.gettempdir()) / "shelfmark").as_posix()))
|
||||
INGEST_DIR = Path(os.getenv("INGEST_DIR", "/books"))
|
||||
|
||||
|
||||
@@ -113,7 +118,10 @@ FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
|
||||
# =============================================================================
|
||||
|
||||
SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
|
||||
SESSION_COOKIE_NAME = "shelfmark_session"
|
||||
CWA_DB_PATH = _resolve_cwa_db_path()
|
||||
HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false"))
|
||||
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -133,6 +141,14 @@ TOR_VARIANT_AVAILABLE = shutil.which("tor") is not None
|
||||
USING_TOR = string_to_bool(os.getenv("USING_TOR", "false"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Onboarding
|
||||
# =============================================================================
|
||||
|
||||
# Set to false to skip the onboarding wizard entirely (useful for ephemeral storage)
|
||||
ONBOARDING = string_to_bool(os.getenv("ONBOARDING", "true"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Debug/development settings
|
||||
# =============================================================================
|
||||
@@ -140,7 +156,7 @@ USING_TOR = string_to_bool(os.getenv("USING_TOR", "false"))
|
||||
# Debug: skip specific download sources for testing fallback chains
|
||||
# Comma-separated values: aa-fast, aa-slow-nowait, aa-slow-wait, libgen, zlib, welib
|
||||
_DEBUG_SKIP_SOURCES_RAW = os.getenv("DEBUG_SKIP_SOURCES", "").strip().lower()
|
||||
DEBUG_SKIP_SOURCES = set(s.strip() for s in _DEBUG_SKIP_SOURCES_RAW.split(",") if s.strip())
|
||||
DEBUG_SKIP_SOURCES = {s.strip() for s in _DEBUG_SKIP_SOURCES_RAW.split(",") if s.strip()}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Configuration migration helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from os import PathLike
|
||||
|
||||
_DEPRECATED_SETTINGS_RESTRICTION_KEYS = (
|
||||
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN",
|
||||
"CWA_RESTRICT_SETTINGS_TO_ADMIN",
|
||||
"RESTRICT_SETTINGS_TO_ADMIN",
|
||||
)
|
||||
|
||||
|
||||
class MigrationLogger(Protocol):
|
||||
"""Logger surface used by config migration helpers."""
|
||||
|
||||
def info(self, msg: str, *args: object) -> object: ...
|
||||
|
||||
def debug(self, msg: str, *args: object) -> object: ...
|
||||
|
||||
def exception(self, msg: str, *args: object) -> object: ...
|
||||
|
||||
|
||||
def _as_bool(value: object) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _pick_legacy_settings_restriction(config: dict[str, Any]) -> bool | None:
|
||||
"""Pick the best legacy admin-restriction value to migrate."""
|
||||
auth_method = str(config.get("AUTH_METHOD", "")).strip().lower()
|
||||
|
||||
if auth_method == "proxy" and "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config:
|
||||
return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN"))
|
||||
|
||||
if auth_method == "cwa" and "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config:
|
||||
return _as_bool(config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN"))
|
||||
|
||||
if "RESTRICT_SETTINGS_TO_ADMIN" in config:
|
||||
return _as_bool(config.get("RESTRICT_SETTINGS_TO_ADMIN"))
|
||||
|
||||
if "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config:
|
||||
return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN"))
|
||||
|
||||
if "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config:
|
||||
return _as_bool(config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN"))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def migrate_security_settings(
|
||||
*,
|
||||
load_security_config: Callable[[], dict[str, Any]],
|
||||
load_users_config: Callable[[], dict[str, Any]],
|
||||
save_users_config: Callable[[dict[str, Any]], None],
|
||||
ensure_config_dir: Callable[[], None],
|
||||
get_config_path: Callable[[], str | PathLike[str]],
|
||||
sync_builtin_admin_user: Callable[[str, str], None],
|
||||
logger: MigrationLogger,
|
||||
) -> None:
|
||||
"""Migrate legacy security keys and sync builtin admin credentials."""
|
||||
try:
|
||||
config = load_security_config()
|
||||
users_config = load_users_config()
|
||||
migrated_security = False
|
||||
migrated_users = False
|
||||
|
||||
if "USE_CWA_AUTH" in config:
|
||||
old_value = config.pop("USE_CWA_AUTH")
|
||||
if "AUTH_METHOD" not in config:
|
||||
if old_value:
|
||||
config["AUTH_METHOD"] = "cwa"
|
||||
logger.info("Migrated USE_CWA_AUTH=True to AUTH_METHOD='cwa'")
|
||||
elif config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"):
|
||||
config["AUTH_METHOD"] = "builtin"
|
||||
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='builtin'")
|
||||
else:
|
||||
config["AUTH_METHOD"] = "none"
|
||||
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='none'")
|
||||
migrated_security = True
|
||||
else:
|
||||
logger.info("Removed deprecated USE_CWA_AUTH setting (AUTH_METHOD already exists)")
|
||||
migrated_security = True
|
||||
|
||||
# Backfill AUTH_METHOD for configs that have builtin credentials but
|
||||
# were never migrated from USE_CWA_AUTH (e.g. dev builds that predated
|
||||
# the AUTH_METHOD field).
|
||||
if (
|
||||
"AUTH_METHOD" not in config
|
||||
and config.get("BUILTIN_USERNAME")
|
||||
and config.get("BUILTIN_PASSWORD_HASH")
|
||||
):
|
||||
config["AUTH_METHOD"] = "builtin"
|
||||
migrated_security = True
|
||||
logger.info(
|
||||
"Backfilled AUTH_METHOD='builtin' from legacy "
|
||||
"BUILTIN_USERNAME/BUILTIN_PASSWORD_HASH credentials"
|
||||
)
|
||||
|
||||
if "RESTRICT_SETTINGS_TO_ADMIN" not in users_config:
|
||||
legacy_restrict = _pick_legacy_settings_restriction(config)
|
||||
if legacy_restrict is not None:
|
||||
save_users_config({"RESTRICT_SETTINGS_TO_ADMIN": legacy_restrict})
|
||||
migrated_users = True
|
||||
logger.info(
|
||||
"Migrated legacy settings-admin restriction to users.RESTRICT_SETTINGS_TO_ADMIN=%s",
|
||||
legacy_restrict,
|
||||
)
|
||||
|
||||
for deprecated_key in _DEPRECATED_SETTINGS_RESTRICTION_KEYS:
|
||||
if deprecated_key in config:
|
||||
config.pop(deprecated_key, None)
|
||||
migrated_security = True
|
||||
logger.info("Removed deprecated security setting: %s", deprecated_key)
|
||||
|
||||
try:
|
||||
sync_builtin_admin_user(
|
||||
config.get("BUILTIN_USERNAME", ""),
|
||||
config.get("BUILTIN_PASSWORD_HASH", ""),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to sync builtin credentials to users database during migration"
|
||||
)
|
||||
|
||||
if migrated_security:
|
||||
ensure_config_dir()
|
||||
config_path = Path(get_config_path())
|
||||
with config_path.open("w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
logger.info("Security settings migration completed successfully")
|
||||
elif migrated_users:
|
||||
logger.info("Users settings migration completed successfully")
|
||||
else:
|
||||
logger.debug("No security settings migration needed")
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.debug("No existing security config file found - nothing to migrate")
|
||||
except Exception:
|
||||
logger.exception("Failed to migrate security settings")
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Notifications settings tab registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.notifications import NotificationEvent, send_test_notification
|
||||
from shelfmark.core.settings_registry import (
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
SettingsField,
|
||||
TableField,
|
||||
load_config_file,
|
||||
register_on_save,
|
||||
register_settings,
|
||||
)
|
||||
|
||||
_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*$")
|
||||
|
||||
_ROUTE_EVENT_ALL = "all"
|
||||
_ADMIN_EVENT_OPTIONS = [
|
||||
{"value": NotificationEvent.REQUEST_CREATED.value, "label": "New request submitted"},
|
||||
{"value": NotificationEvent.REQUEST_FULFILLED.value, "label": "Request approved"},
|
||||
{"value": NotificationEvent.REQUEST_REJECTED.value, "label": "Request rejected"},
|
||||
{"value": NotificationEvent.DOWNLOAD_COMPLETE.value, "label": "Download complete"},
|
||||
{"value": NotificationEvent.DOWNLOAD_FAILED.value, "label": "Download failed"},
|
||||
]
|
||||
_ROUTE_EVENT_OPTIONS = [
|
||||
{"value": _ROUTE_EVENT_ALL, "label": "All"},
|
||||
*_ADMIN_EVENT_OPTIONS,
|
||||
]
|
||||
_ROUTE_EVENT_ORDER = [option["value"] for option in _ROUTE_EVENT_OPTIONS]
|
||||
_ROUTE_EVENT_INDEX = {event: index for index, event in enumerate(_ROUTE_EVENT_ORDER)}
|
||||
_ALLOWED_ROUTE_EVENTS = set(_ROUTE_EVENT_ORDER)
|
||||
|
||||
_DEFAULT_ROUTE_ROWS = [{"event": [_ROUTE_EVENT_ALL], "url": ""}]
|
||||
|
||||
|
||||
def _looks_like_apprise_url(url: str) -> bool:
|
||||
split = urlsplit(url)
|
||||
if not split.scheme:
|
||||
return False
|
||||
if not _URL_SCHEME_RE.match(split.scheme):
|
||||
return False
|
||||
return " " not in url
|
||||
|
||||
|
||||
def _coerce_route_rows(value: Any) -> list[dict[str, Any]]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [row for row in value if isinstance(row, dict)]
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
return []
|
||||
|
||||
|
||||
def _coerce_route_event_values(value: Any) -> list[Any]:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, (tuple, set)):
|
||||
return list(value)
|
||||
return [value]
|
||||
|
||||
|
||||
def _normalize_route_events(value: Any) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for raw_event in _coerce_route_event_values(value):
|
||||
event = str(raw_event or "").strip().lower()
|
||||
if not event or event not in _ALLOWED_ROUTE_EVENTS:
|
||||
continue
|
||||
if event in seen:
|
||||
continue
|
||||
seen.add(event)
|
||||
normalized.append(event)
|
||||
|
||||
if _ROUTE_EVENT_ALL in seen:
|
||||
return [_ROUTE_EVENT_ALL]
|
||||
|
||||
return sorted(normalized, key=lambda event: _ROUTE_EVENT_INDEX[event])
|
||||
|
||||
|
||||
def _normalize_routes(value: Any) -> list[dict[str, Any]]:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[tuple[tuple[str, ...], str]] = set()
|
||||
|
||||
for row in _coerce_route_rows(value):
|
||||
events = _normalize_route_events(row.get("event"))
|
||||
if not events:
|
||||
continue
|
||||
|
||||
url = str(row.get("url") or "").strip()
|
||||
key = (tuple(events), url)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
normalized.append({"event": events, "url": url})
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _count_invalid_route_events(value: Any) -> int:
|
||||
invalid = 0
|
||||
for row in _coerce_route_rows(value):
|
||||
raw_events = _coerce_route_event_values(row.get("event"))
|
||||
if not raw_events:
|
||||
invalid += 1
|
||||
continue
|
||||
|
||||
for raw_event in raw_events:
|
||||
event = str(raw_event or "").strip().lower()
|
||||
if not event or event not in _ALLOWED_ROUTE_EVENTS:
|
||||
invalid += 1
|
||||
return invalid
|
||||
|
||||
|
||||
def _count_invalid_route_urls(routes: list[dict[str, Any]]) -> int:
|
||||
return sum(1 for row in routes if row["url"] and not _looks_like_apprise_url(row["url"]))
|
||||
|
||||
|
||||
def _ensure_default_route_row(routes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return routes or [dict(row) for row in _DEFAULT_ROUTE_ROWS]
|
||||
|
||||
|
||||
def _extract_unique_route_urls(routes: list[dict[str, Any]]) -> list[str]:
|
||||
urls: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for row in routes:
|
||||
url = row.get("url", "")
|
||||
if not url:
|
||||
continue
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
urls.append(url)
|
||||
return urls
|
||||
|
||||
|
||||
def build_notification_test_result(routes_input: Any, *, scope_label: str) -> dict[str, Any]:
|
||||
"""Validate routes and return a test-notification result payload."""
|
||||
invalid_event_count = _count_invalid_route_events(routes_input)
|
||||
if invalid_event_count:
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
f"Found {invalid_event_count} invalid {scope_label} notification route event value(s). "
|
||||
"Fix route events before running a test."
|
||||
),
|
||||
}
|
||||
|
||||
normalized_routes = _normalize_routes(routes_input)
|
||||
invalid_url_count = _count_invalid_route_urls(normalized_routes)
|
||||
if invalid_url_count:
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
f"Found {invalid_url_count} invalid {scope_label} notification URL(s). "
|
||||
"Fix route URLs before running a test."
|
||||
),
|
||||
}
|
||||
|
||||
urls = _extract_unique_route_urls(normalized_routes)
|
||||
if not urls:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Add at least one {scope_label} notification URL route first.",
|
||||
}
|
||||
|
||||
return send_test_notification(urls)
|
||||
|
||||
|
||||
def normalize_notification_routes(value: Any) -> list[dict[str, Any]]:
|
||||
"""Normalize route table rows for notification preferences."""
|
||||
return _normalize_routes(value)
|
||||
|
||||
|
||||
def is_valid_notification_url(url: str) -> bool:
|
||||
"""Shared URL validation for notifications preferences."""
|
||||
return _looks_like_apprise_url(url)
|
||||
|
||||
|
||||
def _on_save_notifications(values: dict[str, Any]) -> dict[str, Any]:
|
||||
existing = load_config_file("notifications")
|
||||
effective: dict[str, Any] = dict(existing)
|
||||
effective.update(values)
|
||||
|
||||
admin_routes_input = effective.get("ADMIN_NOTIFICATION_ROUTES", [])
|
||||
invalid_admin_event_count = _count_invalid_route_events(admin_routes_input)
|
||||
if invalid_admin_event_count:
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
f"Found {invalid_admin_event_count} invalid global notification route event value(s)."
|
||||
),
|
||||
"values": values,
|
||||
}
|
||||
|
||||
normalized_admin_routes = _normalize_routes(admin_routes_input)
|
||||
invalid_admin_url_count = _count_invalid_route_urls(normalized_admin_routes)
|
||||
if invalid_admin_url_count:
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
f"Found {invalid_admin_url_count} invalid global notification URL(s). "
|
||||
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
|
||||
),
|
||||
"values": values,
|
||||
}
|
||||
|
||||
user_routes_input = effective.get("USER_NOTIFICATION_ROUTES", [])
|
||||
invalid_user_event_count = _count_invalid_route_events(user_routes_input)
|
||||
if invalid_user_event_count:
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
f"Found {invalid_user_event_count} invalid personal notification route event value(s)."
|
||||
),
|
||||
"values": values,
|
||||
}
|
||||
|
||||
normalized_user_routes = _normalize_routes(user_routes_input)
|
||||
invalid_user_url_count = _count_invalid_route_urls(normalized_user_routes)
|
||||
if invalid_user_url_count:
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
f"Found {invalid_user_url_count} invalid personal notification URL(s). "
|
||||
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
|
||||
),
|
||||
"values": values,
|
||||
}
|
||||
|
||||
admin_routes_touched = "ADMIN_NOTIFICATION_ROUTES" in values
|
||||
if admin_routes_touched:
|
||||
values["ADMIN_NOTIFICATION_ROUTES"] = _ensure_default_route_row(normalized_admin_routes)
|
||||
|
||||
user_routes_touched = "USER_NOTIFICATION_ROUTES" in values
|
||||
if user_routes_touched:
|
||||
values["USER_NOTIFICATION_ROUTES"] = _ensure_default_route_row(normalized_user_routes)
|
||||
|
||||
return {"error": False, "values": values}
|
||||
|
||||
|
||||
def _test_admin_notification_action(current_values: dict[str, Any]) -> dict[str, Any]:
|
||||
effective: dict[str, Any] = {
|
||||
"ADMIN_NOTIFICATION_ROUTES": app_config.get("ADMIN_NOTIFICATION_ROUTES", []),
|
||||
}
|
||||
if isinstance(current_values, dict):
|
||||
effective.update(current_values)
|
||||
|
||||
routes_input = effective.get("ADMIN_NOTIFICATION_ROUTES", [])
|
||||
return build_notification_test_result(routes_input, scope_label="global")
|
||||
|
||||
|
||||
register_on_save("notifications", _on_save_notifications)
|
||||
|
||||
|
||||
@register_settings("notifications", "Notifications", icon="bell", order=7)
|
||||
def notifications_settings() -> list[SettingsField]:
|
||||
"""Global notifications settings."""
|
||||
return [
|
||||
HeadingField(
|
||||
key="notifications_heading",
|
||||
title="Global Notifications",
|
||||
description=(
|
||||
"Global notifications send selected events for all users to configured routes. "
|
||||
"Users can manage personal notifications in User Preferences."
|
||||
),
|
||||
),
|
||||
TableField(
|
||||
key="ADMIN_NOTIFICATION_ROUTES",
|
||||
label="",
|
||||
description=(
|
||||
"Create one route per URL. Start with All, then add event-specific routes "
|
||||
"for targeted delivery. Need format examples? "
|
||||
"[View Apprise URL formats](https://appriseit.com/services/)."
|
||||
),
|
||||
columns=[
|
||||
{
|
||||
"key": "event",
|
||||
"label": "Event",
|
||||
"type": "multiselect",
|
||||
"options": _ROUTE_EVENT_OPTIONS,
|
||||
"defaultValue": [_ROUTE_EVENT_ALL],
|
||||
"placeholder": "Select events...",
|
||||
},
|
||||
{
|
||||
"key": "url",
|
||||
"label": "Notification URL",
|
||||
"type": "text",
|
||||
"placeholder": "e.g. ntfys://ntfy.sh/shelfmark",
|
||||
},
|
||||
],
|
||||
default=[dict(row) for row in _DEFAULT_ROUTE_ROWS],
|
||||
add_label="Add Route",
|
||||
empty_message="No routes configured.",
|
||||
),
|
||||
ActionButton(
|
||||
key="test_admin_notification",
|
||||
label="Test Notification",
|
||||
description="Send a test notification to all configured global route URLs.",
|
||||
style="primary",
|
||||
callback=_test_admin_notification_action,
|
||||
),
|
||||
TableField(
|
||||
key="USER_NOTIFICATION_ROUTES",
|
||||
label="",
|
||||
description=(
|
||||
"Create one route per URL. Start with All, then add event-specific routes "
|
||||
"for targeted delivery. Need format examples? "
|
||||
"[View Apprise URL formats](https://appriseit.com/services/)."
|
||||
),
|
||||
columns=[
|
||||
{
|
||||
"key": "event",
|
||||
"label": "Event",
|
||||
"type": "multiselect",
|
||||
"options": _ROUTE_EVENT_OPTIONS,
|
||||
"defaultValue": [_ROUTE_EVENT_ALL],
|
||||
"placeholder": "Select events...",
|
||||
},
|
||||
{
|
||||
"key": "url",
|
||||
"label": "Notification URL",
|
||||
"type": "text",
|
||||
"placeholder": "e.g. ntfys://ntfy.sh/username-topic",
|
||||
},
|
||||
],
|
||||
default=[dict(row) for row in _DEFAULT_ROUTE_ROWS],
|
||||
add_label="Add Route",
|
||||
empty_message="No routes configured.",
|
||||
user_overridable=True,
|
||||
hidden_in_ui=True,
|
||||
),
|
||||
]
|
||||
@@ -1,164 +1,80 @@
|
||||
"""Authentication settings registration."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.config.migrations import migrate_security_settings
|
||||
from shelfmark.config.security_handlers import (
|
||||
check_oidc_connection,
|
||||
on_save_security,
|
||||
)
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.settings_registry import (
|
||||
register_settings,
|
||||
register_on_save,
|
||||
load_config_file,
|
||||
TextField,
|
||||
SelectField,
|
||||
PasswordField,
|
||||
CheckboxField,
|
||||
ActionButton,
|
||||
CheckboxField,
|
||||
CustomComponentField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
SettingsField,
|
||||
TagListField,
|
||||
TextField,
|
||||
load_config_file,
|
||||
register_on_save,
|
||||
register_settings,
|
||||
)
|
||||
from shelfmark.core.user_db import sync_builtin_admin_user
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _auth_condition(auth_method: str) -> dict[str, str]:
|
||||
return {"field": "AUTH_METHOD", "value": auth_method}
|
||||
|
||||
|
||||
def _auth_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) -> Any:
|
||||
return factory(show_when=_auth_condition(auth_method), **kwargs)
|
||||
|
||||
|
||||
def _migrate_security_settings() -> None:
|
||||
import json
|
||||
from shelfmark.core.settings_registry import _get_config_file_path, _ensure_config_dir
|
||||
from shelfmark.core.settings_registry import (
|
||||
_ensure_config_dir,
|
||||
_get_config_file_path,
|
||||
save_config_file,
|
||||
)
|
||||
|
||||
try:
|
||||
config = load_config_file("security")
|
||||
migrated = False
|
||||
|
||||
# Migrate USE_CWA_AUTH to AUTH_METHOD
|
||||
if "USE_CWA_AUTH" in config:
|
||||
old_value = config.pop("USE_CWA_AUTH")
|
||||
|
||||
# Only set AUTH_METHOD if it doesn't already exist
|
||||
if "AUTH_METHOD" not in config:
|
||||
if old_value:
|
||||
config["AUTH_METHOD"] = "cwa"
|
||||
logger.info("Migrated USE_CWA_AUTH=True to AUTH_METHOD='cwa'")
|
||||
else:
|
||||
# If USE_CWA_AUTH was False, determine auth method from credentials
|
||||
if config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"):
|
||||
config["AUTH_METHOD"] = "builtin"
|
||||
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='builtin'")
|
||||
else:
|
||||
config["AUTH_METHOD"] = "none"
|
||||
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='none'")
|
||||
migrated = True
|
||||
else:
|
||||
logger.info("Removed deprecated USE_CWA_AUTH setting (AUTH_METHOD already exists)")
|
||||
migrated = True
|
||||
|
||||
# Migrate RESTRICT_SETTINGS_TO_ADMIN to CWA_RESTRICT_SETTINGS_TO_ADMIN
|
||||
if "RESTRICT_SETTINGS_TO_ADMIN" in config:
|
||||
old_value = config.pop("RESTRICT_SETTINGS_TO_ADMIN")
|
||||
|
||||
# Only migrate if new key doesn't exist
|
||||
if "CWA_RESTRICT_SETTINGS_TO_ADMIN" not in config:
|
||||
config["CWA_RESTRICT_SETTINGS_TO_ADMIN"] = old_value
|
||||
logger.info(f"Migrated RESTRICT_SETTINGS_TO_ADMIN={old_value} to CWA_RESTRICT_SETTINGS_TO_ADMIN={old_value}")
|
||||
migrated = True
|
||||
else:
|
||||
logger.info("Removed deprecated RESTRICT_SETTINGS_TO_ADMIN setting (CWA_RESTRICT_SETTINGS_TO_ADMIN already exists)")
|
||||
migrated = True
|
||||
|
||||
# Save config if any migrations occurred
|
||||
if migrated:
|
||||
_ensure_config_dir("security")
|
||||
config_path = _get_config_file_path("security")
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
logger.info("Security settings migration completed successfully")
|
||||
else:
|
||||
logger.debug("No security settings migration needed")
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.debug("No existing security config file found - nothing to migrate")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to migrate security settings: {e}")
|
||||
def _save_users_config(values: dict[str, Any]) -> None:
|
||||
save_config_file("users", values)
|
||||
|
||||
migrate_security_settings(
|
||||
load_security_config=lambda: load_config_file("security"),
|
||||
load_users_config=lambda: load_config_file("users"),
|
||||
save_users_config=_save_users_config,
|
||||
ensure_config_dir=lambda: _ensure_config_dir("security"),
|
||||
get_config_path=lambda: _get_config_file_path("security"),
|
||||
sync_builtin_admin_user=sync_builtin_admin_user,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
def _clear_builtin_credentials() -> Dict[str, Any]:
|
||||
"""Clear built-in credentials to allow public access."""
|
||||
import json
|
||||
from shelfmark.core.settings_registry import _get_config_file_path, _ensure_config_dir
|
||||
|
||||
try:
|
||||
config = load_config_file("security")
|
||||
config.pop("BUILTIN_USERNAME", None)
|
||||
config.pop("BUILTIN_PASSWORD_HASH", None)
|
||||
|
||||
_ensure_config_dir("security")
|
||||
config_path = _get_config_file_path("security")
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
logger.info("Cleared credentials")
|
||||
return {"success": True, "message": "Credentials cleared. The app is now publicly accessible."}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear credentials: {e}")
|
||||
return {"success": False, "message": f"Failed to clear credentials: {str(e)}"}
|
||||
def _on_save_security(values: dict[str, Any]) -> dict[str, Any]:
|
||||
return on_save_security(values)
|
||||
|
||||
|
||||
def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Custom save handler for security settings.
|
||||
|
||||
Handles password validation and hashing:
|
||||
- If new password is provided, validate confirmation and hash it
|
||||
- If password fields are empty, preserve existing hash
|
||||
- Never store raw passwords
|
||||
- Ensure username is present if password is set
|
||||
|
||||
Returns:
|
||||
Dict with processed values to save and any validation errors.
|
||||
"""
|
||||
password = values.get("BUILTIN_PASSWORD", "")
|
||||
password_confirm = values.get("BUILTIN_PASSWORD_CONFIRM", "")
|
||||
|
||||
# Remove raw password fields - they should never be persisted
|
||||
values.pop("BUILTIN_PASSWORD", None)
|
||||
values.pop("BUILTIN_PASSWORD_CONFIRM", None)
|
||||
|
||||
# If password is provided, validate and hash it
|
||||
if password:
|
||||
if not values.get("BUILTIN_USERNAME"):
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Username cannot be empty",
|
||||
"values": values
|
||||
}
|
||||
|
||||
if password != password_confirm:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Passwords do not match",
|
||||
"values": values
|
||||
}
|
||||
|
||||
if len(password) < 4:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Password must be at least 4 characters",
|
||||
"values": values
|
||||
}
|
||||
|
||||
# Hash the password
|
||||
values["BUILTIN_PASSWORD_HASH"] = generate_password_hash(password)
|
||||
logger.info("Password hash updated")
|
||||
|
||||
# If no password provided but username is being set, preserve existing hash
|
||||
elif "BUILTIN_USERNAME" in values:
|
||||
existing = load_config_file("security")
|
||||
if "BUILTIN_PASSWORD_HASH" in existing:
|
||||
values["BUILTIN_PASSWORD_HASH"] = existing["BUILTIN_PASSWORD_HASH"]
|
||||
|
||||
return {"error": False, "values": values}
|
||||
def _test_oidc_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return check_oidc_connection(
|
||||
load_security_config=lambda: {
|
||||
"OIDC_DISCOVERY_URL": app_config.get("OIDC_DISCOVERY_URL", ""),
|
||||
},
|
||||
current_values=current_values or {},
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
@register_settings("security", "Security", icon="shield", order=5)
|
||||
def security_settings():
|
||||
def security_settings() -> list[SettingsField]:
|
||||
"""Security and authentication settings."""
|
||||
from shelfmark.config.env import CWA_DB_PATH
|
||||
|
||||
@@ -166,125 +82,219 @@ def security_settings():
|
||||
|
||||
auth_method_options = [
|
||||
{"label": "No Authentication", "value": "none"},
|
||||
{"label": "Username/Password", "value": "builtin"},
|
||||
{"label": "Local", "value": "builtin"},
|
||||
{"label": "Proxy Authentication", "value": "proxy"},
|
||||
{"label": "OIDC (OpenID Connect)", "value": "oidc"},
|
||||
{"label": "Calibre-Web Database", "value": "cwa"},
|
||||
]
|
||||
if cwa_db_available:
|
||||
auth_method_options.append({"label": "Calibre-Web Database", "value": "cwa"})
|
||||
|
||||
auth_method_description = "Select the authentication method for accessing Shelfmark."
|
||||
if not cwa_db_available:
|
||||
auth_method_description += " Calibre-Web database option requires mounting your Calibre-Web app.db to /auth/app.db."
|
||||
|
||||
fields = [
|
||||
SelectField(
|
||||
key="AUTH_METHOD",
|
||||
label="Authentication Method",
|
||||
description=auth_method_description,
|
||||
description=(
|
||||
"Select the authentication method for accessing Shelfmark. "
|
||||
"Restart container after changing Calibre-Web passwords."
|
||||
),
|
||||
options=auth_method_options,
|
||||
default="none",
|
||||
env_supported=False,
|
||||
),
|
||||
TextField(
|
||||
key="BUILTIN_USERNAME",
|
||||
label="Username",
|
||||
description="Set a username and password to require login. Leave both empty for public access.",
|
||||
placeholder="Enter username",
|
||||
env_supported=False,
|
||||
show_when={"field": "AUTH_METHOD", "value": "builtin"},
|
||||
CustomComponentField(
|
||||
key="builtin_admin_requirement",
|
||||
component="oidc_admin_hint",
|
||||
label=(
|
||||
"Local authentication is inactive until a local admin account with a "
|
||||
"password is created."
|
||||
),
|
||||
show_when=_auth_condition("builtin"),
|
||||
),
|
||||
PasswordField(
|
||||
key="BUILTIN_PASSWORD",
|
||||
label="Set Password",
|
||||
description="Fill in to set or change the password.",
|
||||
placeholder="Enter new password",
|
||||
env_supported=False,
|
||||
show_when={"field": "AUTH_METHOD", "value": "builtin"},
|
||||
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"),
|
||||
),
|
||||
PasswordField(
|
||||
key="BUILTIN_PASSWORD_CONFIRM",
|
||||
label="Confirm Password",
|
||||
placeholder="Confirm new password",
|
||||
env_supported=False,
|
||||
show_when={"field": "AUTH_METHOD", "value": "builtin"},
|
||||
*(
|
||||
[]
|
||||
if cwa_db_available
|
||||
else [
|
||||
CustomComponentField(
|
||||
key="cwa_db_missing",
|
||||
component="oidc_admin_hint",
|
||||
label=(
|
||||
"Calibre-Web database not detected. Mount your app.db to "
|
||||
"/auth/app.db to enable this method. Authentication will fall "
|
||||
"back to none until the database is available."
|
||||
),
|
||||
show_when=_auth_condition("cwa"),
|
||||
),
|
||||
]
|
||||
),
|
||||
ActionButton(
|
||||
key="clear_credentials",
|
||||
label="Clear Credentials",
|
||||
description="Remove login requirement and make the app publicly accessible.",
|
||||
style="danger",
|
||||
callback=_clear_builtin_credentials,
|
||||
show_when={"field": "AUTH_METHOD", "value": "builtin"},
|
||||
key="open_users_tab",
|
||||
label="Go to Users",
|
||||
description="Configure local users and admin access in the Users tab.",
|
||||
style="primary",
|
||||
show_when={"field": "AUTH_METHOD", "value": ["builtin", "oidc"]},
|
||||
),
|
||||
TextField(
|
||||
_auth_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_USER_HEADER",
|
||||
label="Proxy Auth User Header",
|
||||
description=(
|
||||
"The HTTP header your proxy uses to pass the authenticated username."
|
||||
),
|
||||
description="The HTTP header your proxy uses to pass the authenticated username.",
|
||||
placeholder="e.g. X-Auth-User",
|
||||
default="X-Auth-User",
|
||||
env_supported=False,
|
||||
show_when={"field": "AUTH_METHOD", "value": "proxy"},
|
||||
),
|
||||
TextField(
|
||||
_auth_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_LOGOUT_URL",
|
||||
label="Proxy Auth Logout URL",
|
||||
description=(
|
||||
"The URL to redirect users to for logging out."
|
||||
" Leave empty to disable logout functionality."
|
||||
),
|
||||
description="The URL to redirect users to for logging out. Leave empty to disable logout functionality.",
|
||||
placeholder="https://myauth.example.com/logout",
|
||||
default="",
|
||||
env_supported=False,
|
||||
show_when={"field": "AUTH_METHOD", "value": "proxy"},
|
||||
),
|
||||
CheckboxField(
|
||||
key="PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN",
|
||||
label="Restrict Settings to Admins authenticated via Proxy",
|
||||
description=(
|
||||
"Only users in the admin group can access settings."
|
||||
),
|
||||
default=False,
|
||||
env_supported=False,
|
||||
show_when={"field": "AUTH_METHOD", "value": "proxy"},
|
||||
),
|
||||
TextField(
|
||||
_auth_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_ADMIN_GROUP_HEADER",
|
||||
label="Proxy Auth Admin Group Header",
|
||||
description=(
|
||||
"The HTTP header your proxy uses to pass the user's groups/roles."
|
||||
),
|
||||
description="Optional: header your proxy uses to pass user groups/roles.",
|
||||
placeholder="e.g. X-Auth-Groups",
|
||||
default="X-Auth-Groups",
|
||||
env_supported=False,
|
||||
show_when={"field": "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", "value": True},
|
||||
),
|
||||
TextField(
|
||||
_auth_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_ADMIN_GROUP_NAME",
|
||||
label="Proxy Auth Admin Group Name",
|
||||
description=(
|
||||
"The name of the group/role that should have admin access."
|
||||
),
|
||||
label="Proxy Auth Admin Group",
|
||||
description="Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection.",
|
||||
placeholder="e.g. admins",
|
||||
default="admins",
|
||||
env_supported=False,
|
||||
show_when={"field": "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="CWA_RESTRICT_SETTINGS_TO_ADMIN",
|
||||
label="Restrict Settings to Admins authenticated via Calibre-Web",
|
||||
description=(
|
||||
"Only users with admin role in Calibre-Web can access settings."
|
||||
),
|
||||
default=False,
|
||||
env_supported=False,
|
||||
show_when={"field": "AUTH_METHOD", "value": "cwa"},
|
||||
default="",
|
||||
),
|
||||
]
|
||||
|
||||
fields.append(
|
||||
CustomComponentField(
|
||||
key="oidc_callback_url",
|
||||
component="settings_label",
|
||||
label="Callback URL",
|
||||
description="{origin}/api/auth/oidc/callback",
|
||||
show_when=_auth_condition("oidc"),
|
||||
)
|
||||
)
|
||||
|
||||
oidc_specs = [
|
||||
(
|
||||
TextField,
|
||||
{
|
||||
"key": "OIDC_DISCOVERY_URL",
|
||||
"label": "Discovery URL",
|
||||
"description": "OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration.",
|
||||
"placeholder": "https://auth.example.com/.well-known/openid-configuration",
|
||||
"required": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
TextField,
|
||||
{
|
||||
"key": "OIDC_CLIENT_ID",
|
||||
"label": "Client ID",
|
||||
"description": "OAuth2 client ID from your identity provider.",
|
||||
"placeholder": "shelfmark",
|
||||
"required": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
PasswordField,
|
||||
{
|
||||
"key": "OIDC_CLIENT_SECRET",
|
||||
"label": "Client Secret",
|
||||
"description": "OAuth2 client secret from your identity provider.",
|
||||
"required": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
TagListField,
|
||||
{
|
||||
"key": "OIDC_SCOPES",
|
||||
"label": "Scopes",
|
||||
"description": "OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization.",
|
||||
"default": ["openid", "email", "profile"],
|
||||
},
|
||||
),
|
||||
(
|
||||
TextField,
|
||||
{
|
||||
"key": "OIDC_GROUP_CLAIM",
|
||||
"label": "Group Claim Name",
|
||||
"description": "The name of the claim in the ID token that contains user groups.",
|
||||
"placeholder": "groups",
|
||||
"default": "groups",
|
||||
},
|
||||
),
|
||||
(
|
||||
TextField,
|
||||
{
|
||||
"key": "OIDC_ADMIN_GROUP",
|
||||
"label": "Admin Group Name",
|
||||
"description": "Users in this group will be given admin access (if enabled below). Leave empty to use database roles only.",
|
||||
"placeholder": "shelfmark-admins",
|
||||
"default": "",
|
||||
},
|
||||
),
|
||||
(
|
||||
CheckboxField,
|
||||
{
|
||||
"key": "OIDC_USE_ADMIN_GROUP",
|
||||
"label": "Use Admin Group for Authorization",
|
||||
"description": "When enabled, users in the Admin Group are granted admin access. When disabled, admin access is determined solely by database roles.",
|
||||
"default": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
CheckboxField,
|
||||
{
|
||||
"key": "OIDC_AUTO_PROVISION",
|
||||
"label": "Auto-Provision Users",
|
||||
"description": "Automatically create a user account on first OIDC login. When disabled, users must be pre-created by an admin.",
|
||||
"default": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
TextField,
|
||||
{
|
||||
"key": "OIDC_BUTTON_LABEL",
|
||||
"label": "Login Button Label",
|
||||
"description": "Custom label for the OIDC sign-in button on the login page.",
|
||||
"placeholder": "Sign in with OIDC",
|
||||
"default": "",
|
||||
},
|
||||
),
|
||||
]
|
||||
fields.extend(_auth_field(factory, "oidc", **spec) for factory, spec in oidc_specs)
|
||||
fields.append(
|
||||
ActionButton(
|
||||
key="test_oidc",
|
||||
label="Test Connection",
|
||||
description="Fetch the OIDC discovery document and validate configuration.",
|
||||
style="primary",
|
||||
callback=_test_oidc_connection,
|
||||
show_when=_auth_condition("oidc"),
|
||||
)
|
||||
)
|
||||
fields.append(
|
||||
CustomComponentField(
|
||||
key="oidc_env_info",
|
||||
component="oidc_env_info",
|
||||
label="Environment-Only Options",
|
||||
description="These options can only be set via environment variables because changing them through the UI could lock you out of the application.",
|
||||
wrap_in_field_wrapper=True,
|
||||
show_when=_auth_condition("oidc"),
|
||||
)
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
# Register the on_save handler for this tab
|
||||
register_on_save("security", _on_save_security)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Operational handlers for security settings (save/actions)."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_OIDC_LOCKOUT_MESSAGE = "A local admin account with a password is required before enabling OIDC. Use the 'Go to Users' button above to create one. This ensures you can still sign in if your identity provider is unavailable."
|
||||
_OIDC_REQUIRED_FIELDS = (
|
||||
("OIDC_DISCOVERY_URL", "Discovery URL"),
|
||||
("OIDC_CLIENT_ID", "Client ID"),
|
||||
("OIDC_CLIENT_SECRET", "Client Secret"),
|
||||
)
|
||||
|
||||
|
||||
def _has_local_password_admin() -> bool:
|
||||
root = os.environ.get("CONFIG_DIR", "/config")
|
||||
user_db = UserDB(str(Path(root) / "users.db"))
|
||||
user_db.initialize()
|
||||
return any(
|
||||
user.get("password_hash") and user.get("role") == "admin" for user in user_db.list_users()
|
||||
)
|
||||
|
||||
|
||||
def _load_effective_security_values(values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Merge the current save payload onto the persisted security config."""
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
|
||||
effective_values = load_config_file("security")
|
||||
effective_values.update(values)
|
||||
return effective_values
|
||||
|
||||
|
||||
def _get_missing_oidc_required_fields(effective_values: dict[str, Any]) -> list[str]:
|
||||
"""Return missing required OIDC field labels from the effective config."""
|
||||
missing_fields: list[str] = []
|
||||
|
||||
for key, label in _OIDC_REQUIRED_FIELDS:
|
||||
value = effective_values.get(key)
|
||||
if value is None:
|
||||
missing_fields.append(label)
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
missing_fields.append(label)
|
||||
|
||||
return missing_fields
|
||||
|
||||
|
||||
def on_save_security(
|
||||
values: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Validate security values before persistence."""
|
||||
normalized_values = values.copy()
|
||||
|
||||
discovery_url = normalized_values.get("OIDC_DISCOVERY_URL")
|
||||
if discovery_url is not None:
|
||||
normalized_values["OIDC_DISCOVERY_URL"] = normalize_http_url(
|
||||
str(discovery_url),
|
||||
default_scheme="https",
|
||||
strip_trailing_slash=False,
|
||||
)
|
||||
|
||||
proxy_logout_url = normalized_values.get("PROXY_AUTH_LOGOUT_URL")
|
||||
if proxy_logout_url is not None:
|
||||
normalized_values["PROXY_AUTH_LOGOUT_URL"] = normalize_http_url(
|
||||
str(proxy_logout_url),
|
||||
default_scheme="https",
|
||||
strip_trailing_slash=False,
|
||||
)
|
||||
|
||||
effective_values = _load_effective_security_values(normalized_values)
|
||||
auth_method = str(effective_values.get("AUTH_METHOD", "") or "").strip().lower()
|
||||
|
||||
if auth_method == "oidc":
|
||||
if not _has_local_password_admin():
|
||||
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": normalized_values}
|
||||
|
||||
missing_fields = _get_missing_oidc_required_fields(effective_values)
|
||||
if missing_fields:
|
||||
missing_fields_text = ", ".join(missing_fields)
|
||||
return {
|
||||
"error": True,
|
||||
"message": f"OIDC configuration is incomplete: missing {missing_fields_text}.",
|
||||
"values": normalized_values,
|
||||
}
|
||||
|
||||
return {"error": False, "values": normalized_values}
|
||||
|
||||
|
||||
def check_oidc_connection(
|
||||
*,
|
||||
load_security_config: Callable[[], dict[str, Any]],
|
||||
current_values: dict[str, Any] | None = None,
|
||||
logger: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch and validate the configured OIDC discovery document."""
|
||||
import requests
|
||||
|
||||
try:
|
||||
# Prefer the current (unsaved) form value over the saved config
|
||||
discovery_url = (current_values or {}).get(
|
||||
"OIDC_DISCOVERY_URL"
|
||||
) or load_security_config().get("OIDC_DISCOVERY_URL", "")
|
||||
if not discovery_url:
|
||||
return {"success": False, "message": "Discovery URL is not configured."}
|
||||
|
||||
response = requests.get(discovery_url, timeout=10, verify=get_ssl_verify(discovery_url))
|
||||
response.raise_for_status()
|
||||
document = response.json()
|
||||
|
||||
required_fields = ["issuer", "authorization_endpoint", "token_endpoint"]
|
||||
missing_fields = [field for field in required_fields if field not in document]
|
||||
if missing_fields:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Discovery document missing fields: {', '.join(missing_fields)}",
|
||||
}
|
||||
|
||||
return {"success": True, "message": f"Connected to {document['issuer']}"}
|
||||
except Exception as exc:
|
||||
logger.exception("OIDC connection test failed")
|
||||
return {"success": False, "message": f"Connection failed: {exc!s}"}
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Users settings tab registration.
|
||||
|
||||
This registers a 'users' tab in the settings sidebar.
|
||||
The actual user management is handled by a custom frontend component
|
||||
that talks to /api/admin/users endpoints.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.request_policy import (
|
||||
get_source_content_type_capabilities,
|
||||
parse_policy_mode,
|
||||
validate_policy_rules,
|
||||
)
|
||||
from shelfmark.core.settings_registry import (
|
||||
CheckboxField,
|
||||
CustomComponentField,
|
||||
HeadingField,
|
||||
MultiSelectField,
|
||||
NumberField,
|
||||
SelectField,
|
||||
SettingsField,
|
||||
TableField,
|
||||
register_on_save,
|
||||
register_settings,
|
||||
)
|
||||
|
||||
_REQUEST_DEFAULT_MODE_OPTIONS = [
|
||||
{
|
||||
"value": "download",
|
||||
"label": "Download",
|
||||
"description": "Everything can be downloaded directly.",
|
||||
},
|
||||
{
|
||||
"value": "request_release",
|
||||
"label": "Request Release",
|
||||
"description": "Users must request a specific release.",
|
||||
},
|
||||
{
|
||||
"value": "request_book",
|
||||
"label": "Request Book",
|
||||
"description": "Users request a book, admin picks the release.",
|
||||
},
|
||||
{
|
||||
"value": "blocked",
|
||||
"label": "Blocked",
|
||||
"description": "No downloads or requests allowed.",
|
||||
},
|
||||
]
|
||||
|
||||
_REQUEST_MATRIX_MODE_OPTIONS = [
|
||||
option for option in _REQUEST_DEFAULT_MODE_OPTIONS if option["value"] != "request_book"
|
||||
]
|
||||
|
||||
_SELF_SETTINGS_SECTION_OPTIONS = [
|
||||
{
|
||||
"value": "delivery",
|
||||
"label": "Delivery Preferences",
|
||||
"description": "Show personal delivery output and destination settings.",
|
||||
},
|
||||
{
|
||||
"value": "search",
|
||||
"label": "Search Preferences",
|
||||
"description": "Show personal search mode and provider settings.",
|
||||
},
|
||||
{
|
||||
"value": "notifications",
|
||||
"label": "Notifications",
|
||||
"description": "Show personal notification route settings.",
|
||||
},
|
||||
]
|
||||
_SELF_SETTINGS_SECTION_VALUES = {option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS}
|
||||
_SELF_SETTINGS_SECTION_DEFAULTS = [option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS]
|
||||
_SEARCH_MODE_VALUES = {"direct", "universal"}
|
||||
_SEARCH_PREFERENCE_PROVIDER_KEYS = {
|
||||
"METADATA_PROVIDER",
|
||||
"METADATA_PROVIDER_AUDIOBOOK",
|
||||
"METADATA_PROVIDER_COMBINED",
|
||||
}
|
||||
_SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
|
||||
"SEARCH_MODE",
|
||||
"DEFAULT_RELEASE_SOURCE",
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||
"SHOW_COMBINED_SELECTOR",
|
||||
*_SEARCH_PREFERENCE_PROVIDER_KEYS,
|
||||
}
|
||||
|
||||
_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE = {
|
||||
"builtin": (
|
||||
"Create and manage user accounts directly. Passwords are stored locally and users sign in "
|
||||
"with their username and password."
|
||||
),
|
||||
"oidc": (
|
||||
"Users sign in through your identity provider. New accounts can be created automatically on "
|
||||
"first login when auto-provisioning is enabled, or you can pre-create users here and they\u2019ll "
|
||||
"be linked by email on first sign-in."
|
||||
),
|
||||
"proxy": (
|
||||
"Users are authenticated by your reverse proxy. Accounts are automatically created on first "
|
||||
"sign-in. If a local user with a matching username already exists, it will be linked instead."
|
||||
),
|
||||
"cwa": (
|
||||
"User accounts are synced from your Calibre-Web database. Users are matched by email, and new "
|
||||
"accounts are created here when new CWA users are found."
|
||||
),
|
||||
"none": "Authentication is disabled. Anyone can access Shelfmark without signing in.",
|
||||
"default": "Authentication is disabled. Anyone can access Shelfmark without signing in.",
|
||||
}
|
||||
|
||||
|
||||
def _get_request_source_options() -> list[dict[str, str]]:
|
||||
"""Build request-policy source options from registered release sources."""
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
|
||||
return [
|
||||
{
|
||||
"value": source["name"],
|
||||
"label": source["display_name"],
|
||||
}
|
||||
for source in list_available_sources()
|
||||
]
|
||||
|
||||
|
||||
def _get_valid_release_source_names_for_content_type(content_type: str) -> set[str]:
|
||||
"""Return registered release source names that support the requested content type."""
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
|
||||
valid_sources: set[str] = set()
|
||||
for source in list_available_sources():
|
||||
supported_types = source.get("supported_content_types", ["ebook", "audiobook"])
|
||||
if content_type in supported_types:
|
||||
valid_sources.add(source["name"])
|
||||
return valid_sources
|
||||
|
||||
|
||||
def _get_request_policy_rule_columns() -> list[dict[str, object]]:
|
||||
source_capabilities = get_source_content_type_capabilities()
|
||||
content_type_options = []
|
||||
|
||||
for source_name, supported_types in source_capabilities.items():
|
||||
normalized_types = [t for t in ("ebook", "audiobook") if t in supported_types]
|
||||
content_type_options.extend(
|
||||
{
|
||||
"value": content_type,
|
||||
"label": "Ebook" if content_type == "ebook" else "Audiobook",
|
||||
"childOf": source_name,
|
||||
}
|
||||
for content_type in normalized_types
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"key": "source",
|
||||
"label": "Source",
|
||||
"type": "select",
|
||||
"options": _get_request_source_options(),
|
||||
"defaultValue": "",
|
||||
"placeholder": "Select source...",
|
||||
},
|
||||
{
|
||||
"key": "content_type",
|
||||
"label": "Content Type",
|
||||
"type": "select",
|
||||
"options": content_type_options,
|
||||
"defaultValue": "",
|
||||
"placeholder": "Select content type...",
|
||||
"filterByField": "source",
|
||||
},
|
||||
{
|
||||
"key": "mode",
|
||||
"label": "Mode",
|
||||
"type": "select",
|
||||
"options": _REQUEST_MATRIX_MODE_OPTIONS,
|
||||
"defaultValue": "",
|
||||
"placeholder": "Select mode...",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | None]:
|
||||
"""Validate and normalize a search preference value for user overrides."""
|
||||
if key not in _SEARCH_PREFERENCE_VALIDATABLE_KEYS:
|
||||
return value, None
|
||||
|
||||
if value is None:
|
||||
return None, None
|
||||
|
||||
normalized_value = str(value).strip()
|
||||
|
||||
if key == "SEARCH_MODE":
|
||||
normalized_mode = normalized_value.lower()
|
||||
if normalized_mode not in _SEARCH_MODE_VALUES:
|
||||
return value, "SEARCH_MODE must be 'direct' or 'universal'"
|
||||
return normalized_mode, None
|
||||
|
||||
if key in _SEARCH_PREFERENCE_PROVIDER_KEYS:
|
||||
if normalized_value == "":
|
||||
return "", None
|
||||
from shelfmark.metadata_providers import is_provider_registered
|
||||
|
||||
if not is_provider_registered(normalized_value):
|
||||
return (
|
||||
value,
|
||||
f"{key} must be a valid metadata provider name or empty",
|
||||
)
|
||||
return normalized_value, None
|
||||
|
||||
if key in {"DEFAULT_RELEASE_SOURCE", "DEFAULT_RELEASE_SOURCE_AUDIOBOOK"}:
|
||||
if normalized_value == "":
|
||||
return "", None
|
||||
valid_sources = _get_valid_release_source_names_for_content_type(
|
||||
"audiobook" if key == "DEFAULT_RELEASE_SOURCE_AUDIOBOOK" else "ebook"
|
||||
)
|
||||
if normalized_value not in valid_sources:
|
||||
return (
|
||||
value,
|
||||
f"{key} must be a valid release source name or empty",
|
||||
)
|
||||
return normalized_value, None
|
||||
|
||||
if key == "SHOW_COMBINED_SELECTOR":
|
||||
if isinstance(value, bool):
|
||||
return value, None
|
||||
return bool(value), None
|
||||
|
||||
return value, None
|
||||
|
||||
|
||||
def _on_save_users(values: dict[str, object]) -> dict[str, object]:
|
||||
"""Validate users/request-policy settings before persistence."""
|
||||
if "VISIBLE_SELF_SETTINGS_SECTIONS" in values:
|
||||
raw_sections = values["VISIBLE_SELF_SETTINGS_SECTIONS"]
|
||||
if raw_sections is None:
|
||||
candidate_sections: list[str] = []
|
||||
elif isinstance(raw_sections, str):
|
||||
candidate_sections = [s.strip() for s in raw_sections.split(",") if s.strip()]
|
||||
elif isinstance(raw_sections, (list, tuple, set)):
|
||||
candidate_sections = [
|
||||
str(section).strip() for section in raw_sections if str(section).strip()
|
||||
]
|
||||
else:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "VISIBLE_SELF_SETTINGS_SECTIONS must be a list of section identifiers",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
normalized_sections: list[str] = []
|
||||
for section in candidate_sections:
|
||||
if section not in _SELF_SETTINGS_SECTION_VALUES:
|
||||
allowed = ", ".join(sorted(_SELF_SETTINGS_SECTION_VALUES))
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
"VISIBLE_SELF_SETTINGS_SECTIONS contains an unsupported section "
|
||||
f"'{section}'. Supported values: {allowed}"
|
||||
),
|
||||
"values": values,
|
||||
}
|
||||
if section not in normalized_sections:
|
||||
normalized_sections.append(section)
|
||||
|
||||
values["VISIBLE_SELF_SETTINGS_SECTIONS"] = normalized_sections
|
||||
|
||||
if (
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK" in values
|
||||
and parse_policy_mode(values["REQUEST_POLICY_DEFAULT_EBOOK"]) is None
|
||||
):
|
||||
return {
|
||||
"error": True,
|
||||
"message": "REQUEST_POLICY_DEFAULT_EBOOK must be a valid policy mode",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
if (
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK" in values
|
||||
and parse_policy_mode(values["REQUEST_POLICY_DEFAULT_AUDIOBOOK"]) is None
|
||||
):
|
||||
return {
|
||||
"error": True,
|
||||
"message": "REQUEST_POLICY_DEFAULT_AUDIOBOOK must be a valid policy mode",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
if "REQUEST_POLICY_RULES" in values:
|
||||
normalized_rules, errors = validate_policy_rules(values["REQUEST_POLICY_RULES"])
|
||||
if errors:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "; ".join(errors),
|
||||
"values": values,
|
||||
}
|
||||
values["REQUEST_POLICY_RULES"] = normalized_rules
|
||||
|
||||
for key in _SEARCH_PREFERENCE_VALIDATABLE_KEYS:
|
||||
if key not in values:
|
||||
continue
|
||||
normalized_value, validation_error = validate_search_preference_value(key, values[key])
|
||||
if validation_error:
|
||||
return {
|
||||
"error": True,
|
||||
"message": validation_error,
|
||||
"values": values,
|
||||
}
|
||||
values[key] = normalized_value
|
||||
|
||||
return {"error": False, "values": values}
|
||||
|
||||
|
||||
register_on_save("users", _on_save_users)
|
||||
|
||||
|
||||
@register_settings("users", "Users & Requests", icon="users", order=6)
|
||||
def users_settings() -> list[SettingsField]:
|
||||
"""User management tab - rendered as a custom component on the frontend."""
|
||||
return [
|
||||
HeadingField(
|
||||
key="users_heading",
|
||||
title="Users",
|
||||
description=_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE["default"],
|
||||
description_by_auth_mode=_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE,
|
||||
),
|
||||
CustomComponentField(
|
||||
key="users_management",
|
||||
component="users_management",
|
||||
),
|
||||
MultiSelectField(
|
||||
key="VISIBLE_SELF_SETTINGS_SECTIONS",
|
||||
label="Visible Self-Settings Sections",
|
||||
description=(
|
||||
"Choose which personal settings sections are shown in My Account for non-admin users."
|
||||
),
|
||||
options=_SELF_SETTINGS_SECTION_OPTIONS,
|
||||
default=_SELF_SETTINGS_SECTION_DEFAULTS,
|
||||
variant="dropdown",
|
||||
env_supported=False,
|
||||
),
|
||||
HeadingField(
|
||||
key="requests_heading",
|
||||
title="Requests",
|
||||
description=("Choose what users can download directly and what needs approval first."),
|
||||
),
|
||||
CheckboxField(
|
||||
key="REQUESTS_ENABLED",
|
||||
label="Enable Requests",
|
||||
description=(
|
||||
"Turn this off to let everyone download directly without needing approval."
|
||||
),
|
||||
default=False,
|
||||
user_overridable=True,
|
||||
),
|
||||
CustomComponentField(
|
||||
key="request_policy_editor",
|
||||
component="request_policy_grid",
|
||||
label="Request Rules",
|
||||
description=(
|
||||
"Fine-tune access per source. Source rules can only be the same or more restrictive than the default above."
|
||||
),
|
||||
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||
wrap_in_field_wrapper=True,
|
||||
value_fields=[
|
||||
SelectField(
|
||||
key="REQUEST_POLICY_DEFAULT_EBOOK",
|
||||
label="Default Ebook Mode",
|
||||
description=("Sets the baseline for all ebook sources."),
|
||||
options=_REQUEST_DEFAULT_MODE_OPTIONS,
|
||||
default="download",
|
||||
user_overridable=True,
|
||||
),
|
||||
SelectField(
|
||||
key="REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
label="Default Audiobook Mode",
|
||||
description=("Sets the baseline for all audiobook sources."),
|
||||
options=_REQUEST_DEFAULT_MODE_OPTIONS,
|
||||
default="download",
|
||||
user_overridable=True,
|
||||
),
|
||||
TableField(
|
||||
key="REQUEST_POLICY_RULES",
|
||||
label="Request Rules",
|
||||
description=(
|
||||
"Fine-tune access per source. Source rules can only be the same or more restrictive than the default above."
|
||||
),
|
||||
columns=_get_request_policy_rule_columns,
|
||||
default=[],
|
||||
add_label="Add Rule",
|
||||
empty_message="No request policy rules configured.",
|
||||
env_supported=False,
|
||||
user_overridable=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
NumberField(
|
||||
key="MAX_PENDING_REQUESTS_PER_USER",
|
||||
label="Max pending requests per user",
|
||||
description="How many open requests a user can have at a time.",
|
||||
default=20,
|
||||
min_value=1,
|
||||
max_value=1000,
|
||||
user_overridable=True,
|
||||
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="REQUESTS_ALLOW_NOTES",
|
||||
label="Allow notes on requests",
|
||||
description="Let users add a note when they submit a request.",
|
||||
default=True,
|
||||
user_overridable=True,
|
||||
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,14 @@
|
||||
"""Core module - shared models, queue, and utilities."""
|
||||
|
||||
from shelfmark.core.models import BookInfo, QueueItem, SearchFilters, QueueStatus
|
||||
from shelfmark.core.queue import BookQueue, book_queue
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import QueueItem, QueueStatus, SearchFilters
|
||||
from shelfmark.core.queue import BookQueue, book_queue
|
||||
|
||||
__all__ = [
|
||||
"BookQueue",
|
||||
"QueueItem",
|
||||
"QueueStatus",
|
||||
"SearchFilters",
|
||||
"book_queue",
|
||||
"setup_logger",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Persistence helpers for per-viewer activity visibility state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.request_helpers import now_utc_iso
|
||||
|
||||
VALID_ACTIVITY_ITEM_TYPES = frozenset({"download", "request"})
|
||||
ADMIN_VIEWER_SCOPE = "admin:shared"
|
||||
NOAUTH_VIEWER_SCOPE = "noauth:shared"
|
||||
USER_VIEWER_SCOPE_PREFIX = "user:"
|
||||
|
||||
|
||||
def user_viewer_scope(user_id: int) -> str:
|
||||
"""Build the persisted viewer scope string for a specific user."""
|
||||
if not isinstance(user_id, int) or user_id < 1:
|
||||
msg = "user_id must be a positive integer"
|
||||
raise ValueError(msg)
|
||||
return f"{USER_VIEWER_SCOPE_PREFIX}{user_id}"
|
||||
|
||||
|
||||
def normalize_viewer_scope(viewer_scope: object) -> str:
|
||||
"""Validate and normalize a persisted viewer scope string."""
|
||||
if not isinstance(viewer_scope, str) or not viewer_scope.strip():
|
||||
msg = "viewer_scope must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
|
||||
normalized = viewer_scope.strip()
|
||||
if normalized in {ADMIN_VIEWER_SCOPE, NOAUTH_VIEWER_SCOPE}:
|
||||
return normalized
|
||||
|
||||
if not normalized.startswith(USER_VIEWER_SCOPE_PREFIX):
|
||||
msg = "viewer_scope must be one of: admin:shared, noauth:shared, or user:<id>"
|
||||
raise ValueError(msg)
|
||||
|
||||
raw_user_id = normalized[len(USER_VIEWER_SCOPE_PREFIX) :].strip()
|
||||
try:
|
||||
parsed_user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
msg = "viewer_scope user id must be a positive integer"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
return user_viewer_scope(parsed_user_id)
|
||||
|
||||
|
||||
def _normalize_item_type(item_type: object) -> str:
|
||||
if not isinstance(item_type, str) or not item_type.strip():
|
||||
msg = "item_type must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
normalized = item_type.strip().lower()
|
||||
if normalized not in VALID_ACTIVITY_ITEM_TYPES:
|
||||
msg = "item_type must be one of: download, request"
|
||||
raise ValueError(msg)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_item_key(item_key: object, *, item_type: str) -> str:
|
||||
if not isinstance(item_key, str) or not item_key.strip():
|
||||
msg = "item_key must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
|
||||
normalized = item_key.strip()
|
||||
expected_prefix = f"{item_type}:"
|
||||
if not normalized.startswith(expected_prefix):
|
||||
msg_0 = f"item_key must be in the format {expected_prefix}<id>"
|
||||
raise ValueError(msg_0)
|
||||
if not normalized.split(":", 1)[1].strip():
|
||||
msg_0 = f"item_key must be in the format {expected_prefix}<id>"
|
||||
raise ValueError(msg_0)
|
||||
return normalized
|
||||
|
||||
|
||||
class ActivityViewStateService:
|
||||
"""Service for per-viewer activity dismissal and history visibility."""
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
"""Initialize the service with the SQLite state database path."""
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
def list_hidden(
|
||||
self,
|
||||
*,
|
||||
viewer_scope: str,
|
||||
limit: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return dismissed rows for a viewer, including cleared history entries."""
|
||||
normalized_scope = normalize_viewer_scope(viewer_scope)
|
||||
normalized_limit = None if limit is None else max(1, int(limit))
|
||||
query = """
|
||||
SELECT item_type, item_key, dismissed_at, cleared_at
|
||||
FROM activity_view_state
|
||||
WHERE viewer_scope = ?
|
||||
AND dismissed_at IS NOT NULL
|
||||
ORDER BY COALESCE(cleared_at, dismissed_at) DESC, id DESC
|
||||
"""
|
||||
params: list[Any] = [normalized_scope]
|
||||
if normalized_limit is not None:
|
||||
query += "\nLIMIT ?"
|
||||
params.append(normalized_limit)
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_history(
|
||||
self,
|
||||
*,
|
||||
viewer_scope: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return active dismissal history rows for a viewer."""
|
||||
normalized_scope = normalize_viewer_scope(viewer_scope)
|
||||
normalized_limit = max(1, min(int(limit), 5000))
|
||||
normalized_offset = max(0, int(offset))
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT item_type, item_key, dismissed_at
|
||||
FROM activity_view_state
|
||||
WHERE viewer_scope = ?
|
||||
AND dismissed_at IS NOT NULL
|
||||
AND cleared_at IS NULL
|
||||
ORDER BY dismissed_at DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(normalized_scope, normalized_limit, normalized_offset),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def dismiss(
|
||||
self,
|
||||
*,
|
||||
viewer_scope: str,
|
||||
item_type: str,
|
||||
item_key: str,
|
||||
) -> int:
|
||||
"""Mark a single activity item as dismissed for a viewer."""
|
||||
normalized_scope = normalize_viewer_scope(viewer_scope)
|
||||
normalized_type = _normalize_item_type(item_type)
|
||||
normalized_key = _normalize_item_key(item_key, item_type=normalized_type)
|
||||
dismissed_at = now_utc_iso()
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO activity_view_state (
|
||||
viewer_scope,
|
||||
item_type,
|
||||
item_key,
|
||||
dismissed_at,
|
||||
cleared_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(viewer_scope, item_type, item_key) DO UPDATE SET
|
||||
dismissed_at = excluded.dismissed_at,
|
||||
cleared_at = NULL
|
||||
""",
|
||||
(normalized_scope, normalized_type, normalized_key, dismissed_at),
|
||||
)
|
||||
conn.commit()
|
||||
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
|
||||
return max(rowcount, 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def dismiss_many(
|
||||
self,
|
||||
*,
|
||||
viewer_scope: str,
|
||||
items: list[dict[str, str]],
|
||||
) -> int:
|
||||
"""Mark multiple activity items as dismissed for a viewer."""
|
||||
normalized_scope = normalize_viewer_scope(viewer_scope)
|
||||
if not items:
|
||||
return 0
|
||||
|
||||
seen: set[tuple[str, str]] = set()
|
||||
normalized_items: list[tuple[str, str]] = []
|
||||
for item in items:
|
||||
normalized_type = _normalize_item_type(item.get("item_type"))
|
||||
normalized_key = _normalize_item_key(item.get("item_key"), item_type=normalized_type)
|
||||
marker = (normalized_type, normalized_key)
|
||||
if marker in seen:
|
||||
continue
|
||||
seen.add(marker)
|
||||
normalized_items.append(marker)
|
||||
|
||||
if not normalized_items:
|
||||
return 0
|
||||
|
||||
dismissed_at = now_utc_iso()
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
total = 0
|
||||
for normalized_type, normalized_key in normalized_items:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO activity_view_state (
|
||||
viewer_scope,
|
||||
item_type,
|
||||
item_key,
|
||||
dismissed_at,
|
||||
cleared_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(viewer_scope, item_type, item_key) DO UPDATE SET
|
||||
dismissed_at = excluded.dismissed_at,
|
||||
cleared_at = NULL
|
||||
""",
|
||||
(
|
||||
normalized_scope,
|
||||
normalized_type,
|
||||
normalized_key,
|
||||
dismissed_at,
|
||||
),
|
||||
)
|
||||
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
|
||||
total += max(rowcount, 0)
|
||||
conn.commit()
|
||||
return total
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def clear_history(self, *, viewer_scope: str) -> int:
|
||||
"""Mark all dismissed items as cleared for a viewer."""
|
||||
normalized_scope = normalize_viewer_scope(viewer_scope)
|
||||
cleared_at = now_utc_iso()
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE activity_view_state
|
||||
SET cleared_at = ?
|
||||
WHERE viewer_scope = ?
|
||||
AND dismissed_at IS NOT NULL
|
||||
AND cleared_at IS NULL
|
||||
""",
|
||||
(cleared_at, normalized_scope),
|
||||
)
|
||||
conn.commit()
|
||||
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
|
||||
return max(rowcount, 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def clear_item_for_all_viewers(self, *, item_type: str, item_key: str) -> int:
|
||||
"""Delete a dismissed item record for every viewer."""
|
||||
normalized_type = _normalize_item_type(item_type)
|
||||
normalized_key = _normalize_item_key(item_key, item_type=normalized_type)
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
DELETE FROM activity_view_state
|
||||
WHERE item_type = ? AND item_key = ?
|
||||
""",
|
||||
(normalized_type, normalized_key),
|
||||
)
|
||||
conn.commit()
|
||||
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
|
||||
return max(rowcount, 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_viewer_scope(self, *, viewer_scope: str) -> int:
|
||||
"""Delete all activity-view state rows for a viewer scope."""
|
||||
normalized_scope = normalize_viewer_scope(viewer_scope)
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM activity_view_state WHERE viewer_scope = ?",
|
||||
(normalized_scope,),
|
||||
)
|
||||
conn.commit()
|
||||
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
|
||||
return max(rowcount, 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_items(self, *, item_type: str, item_keys: list[str]) -> int:
|
||||
"""Delete multiple dismissed item records for a given item type."""
|
||||
normalized_type = _normalize_item_type(item_type)
|
||||
normalized_keys = [
|
||||
_normalize_item_key(item_key, item_type=normalized_type) for item_key in item_keys
|
||||
]
|
||||
if not normalized_keys:
|
||||
return 0
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.executemany(
|
||||
"DELETE FROM activity_view_state WHERE item_type = ? AND item_key = ?",
|
||||
[(normalized_type, normalized_key) for normalized_key in normalized_keys],
|
||||
)
|
||||
conn.commit()
|
||||
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
|
||||
return max(rowcount, 0)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Admin user management API routes.
|
||||
|
||||
Registers /api/admin/users CRUD endpoints for managing users.
|
||||
All endpoints require admin session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, ParamSpec
|
||||
|
||||
from flask import Flask, Response, g, jsonify, request, session
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shelfmark.config.booklore_settings import (
|
||||
get_booklore_library_options,
|
||||
get_booklore_path_options,
|
||||
)
|
||||
from shelfmark.config.env import CWA_DB_PATH
|
||||
from shelfmark.core.admin_settings_routes import (
|
||||
register_admin_settings_routes,
|
||||
validate_user_settings,
|
||||
)
|
||||
from shelfmark.core.auth_modes import (
|
||||
AUTH_SOURCE_BUILTIN,
|
||||
AUTH_SOURCE_CWA,
|
||||
AUTH_SOURCE_OIDC,
|
||||
AUTH_SOURCE_PROXY,
|
||||
is_user_active_for_auth_mode,
|
||||
load_active_auth_mode,
|
||||
normalize_auth_source,
|
||||
)
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.cwa_user_sync import sync_cwa_users_from_rows
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
P = ParamSpec("P")
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
MIN_PASSWORD_LENGTH = 4
|
||||
_CONFIG_REFRESH_ERRORS = (ImportError, OSError, RuntimeError, TypeError, ValueError)
|
||||
|
||||
__all__ = [
|
||||
"get_booklore_library_options",
|
||||
"get_booklore_path_options",
|
||||
"register_admin_routes",
|
||||
"validate_user_settings",
|
||||
]
|
||||
|
||||
|
||||
def _get_user_edit_capabilities(
|
||||
user: dict[str, Any],
|
||||
security_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return backend-authored capability flags for the user edit form."""
|
||||
auth_source = normalize_auth_source(
|
||||
user.get("auth_source"),
|
||||
user.get("oidc_subject"),
|
||||
)
|
||||
oidc_use_admin_group = bool(
|
||||
(security_config or {}).get(
|
||||
"OIDC_USE_ADMIN_GROUP",
|
||||
app_config.get("OIDC_USE_ADMIN_GROUP", True),
|
||||
)
|
||||
)
|
||||
role_managed_by_oidc_group = auth_source == AUTH_SOURCE_OIDC and oidc_use_admin_group
|
||||
can_edit_role = auth_source == AUTH_SOURCE_BUILTIN or (
|
||||
auth_source == AUTH_SOURCE_OIDC and not role_managed_by_oidc_group
|
||||
)
|
||||
|
||||
return {
|
||||
"authSource": auth_source,
|
||||
"canSetPassword": auth_source == AUTH_SOURCE_BUILTIN,
|
||||
"canEditRole": can_edit_role,
|
||||
"canEditEmail": auth_source in {AUTH_SOURCE_BUILTIN, AUTH_SOURCE_PROXY},
|
||||
"canEditDisplayName": auth_source != AUTH_SOURCE_OIDC,
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_user(user: dict) -> dict:
|
||||
"""Remove sensitive fields from user dict before returning to client."""
|
||||
sanitized = dict(user)
|
||||
sanitized.pop("password_hash", None)
|
||||
return sanitized
|
||||
|
||||
|
||||
def _oidc_role_management_message(security_config: dict[str, Any] | None = None) -> str:
|
||||
admin_group = (security_config or {}).get(
|
||||
"OIDC_ADMIN_GROUP",
|
||||
app_config.get("OIDC_ADMIN_GROUP", ""),
|
||||
)
|
||||
if admin_group:
|
||||
return (
|
||||
"Admin roles for OIDC users are managed by the "
|
||||
f"'{admin_group}' group in your identity provider"
|
||||
)
|
||||
return (
|
||||
"Disable 'Use Admin Group for Authorization' in security settings to manage roles manually"
|
||||
)
|
||||
|
||||
|
||||
def _serialize_user(
|
||||
user: dict[str, Any],
|
||||
auth_method: str,
|
||||
security_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Sanitize and enrich a user payload for API responses."""
|
||||
payload = _sanitize_user(user)
|
||||
payload["auth_source"] = normalize_auth_source(
|
||||
payload.get("auth_source"),
|
||||
payload.get("oidc_subject"),
|
||||
)
|
||||
payload["is_active"] = is_user_active_for_auth_mode(payload, auth_method)
|
||||
payload["edit_capabilities"] = _get_user_edit_capabilities(
|
||||
payload,
|
||||
security_config=security_config,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _sync_all_cwa_users(user_db: UserDB) -> dict[str, int]:
|
||||
"""Sync all users from the Calibre-Web database into users.db."""
|
||||
if not CWA_DB_PATH or not CWA_DB_PATH.exists():
|
||||
msg = "Calibre-Web database is not available"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
db_path = os.fspath(CWA_DB_PATH)
|
||||
db_uri = f"file:{db_path}?mode=ro&immutable=1"
|
||||
conn = sqlite3.connect(db_uri, uri=True)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name, role, email FROM user")
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return sync_cwa_users_from_rows(user_db, rows)
|
||||
|
||||
|
||||
def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
"""Register admin user management routes on the Flask app."""
|
||||
|
||||
def _require_admin(
|
||||
f: Callable[P, ResponseReturnValue],
|
||||
) -> Callable[P, ResponseReturnValue]:
|
||||
"""Require an admin session for admin routes.
|
||||
|
||||
In no-auth mode, everyone has access (is_admin defaults True).
|
||||
In auth-required modes, requires an authenticated session with admin role.
|
||||
Caches the resolved auth_mode in ``g.auth_mode`` for the request.
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs) -> ResponseReturnValue:
|
||||
auth_mode = load_active_auth_mode(CWA_DB_PATH, user_db=user_db)
|
||||
g.auth_mode = auth_mode
|
||||
if auth_mode != "none":
|
||||
if "user_id" not in session:
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
if not session.get("is_admin", False):
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
@app.route("/api/admin/users", methods=["GET"])
|
||||
@_require_admin
|
||||
def admin_list_users() -> Response | tuple[Response, int]:
|
||||
"""List all users."""
|
||||
users = user_db.list_users()
|
||||
auth_mode = g.auth_mode
|
||||
return jsonify([_serialize_user(u, auth_mode) for u in users])
|
||||
|
||||
@app.route("/api/admin/users", methods=["POST"])
|
||||
@_require_admin
|
||||
def admin_create_user() -> Response | tuple[Response, int]:
|
||||
"""Create a new user with password authentication."""
|
||||
data = request.get_json() or {}
|
||||
auth_mode = g.auth_mode
|
||||
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password", "")
|
||||
email = (data.get("email") or "").strip() or None
|
||||
display_name = (data.get("display_name") or "").strip() or None
|
||||
role = data.get("role", "user")
|
||||
|
||||
if auth_mode in {AUTH_SOURCE_PROXY, AUTH_SOURCE_CWA}:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Local user creation is disabled in this authentication mode",
|
||||
"message": (
|
||||
"Users are provisioned by your external authentication source. "
|
||||
"Switch to builtin or OIDC mode to create local users."
|
||||
),
|
||||
}
|
||||
), 400
|
||||
|
||||
if not username:
|
||||
return jsonify({"error": "Username is required"}), 400
|
||||
if not password or len(password) < MIN_PASSWORD_LENGTH:
|
||||
return jsonify(
|
||||
{"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}
|
||||
), 400
|
||||
if role not in ("admin", "user"):
|
||||
return jsonify({"error": "Role must be 'admin' or 'user'"}), 400
|
||||
|
||||
# First user is always admin
|
||||
existing_users = user_db.list_users()
|
||||
if not existing_users:
|
||||
role = "admin"
|
||||
|
||||
# Check if username already exists
|
||||
if user_db.get_user(username=username):
|
||||
return jsonify({"error": "Username already exists"}), 409
|
||||
|
||||
password_hash = generate_password_hash(password)
|
||||
try:
|
||||
user = user_db.create_user(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
auth_source=AUTH_SOURCE_BUILTIN,
|
||||
role=role,
|
||||
)
|
||||
except ValueError:
|
||||
return jsonify({"error": "Username already exists"}), 409
|
||||
logger.info(
|
||||
"Shelfmark user created (source=manual_admin_create, created_by=%s, username=%s, role=%s, auth_source=%s)",
|
||||
session.get("user_id", "unknown"),
|
||||
username,
|
||||
role,
|
||||
AUTH_SOURCE_BUILTIN,
|
||||
)
|
||||
return jsonify(
|
||||
_serialize_user(
|
||||
user,
|
||||
g.auth_mode,
|
||||
)
|
||||
), 201
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>", methods=["GET"])
|
||||
@_require_admin
|
||||
def admin_get_user(user_id: int) -> Response | tuple[Response, int]:
|
||||
"""Get a user by ID with their settings."""
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
result = _serialize_user(
|
||||
user,
|
||||
g.auth_mode,
|
||||
)
|
||||
result["settings"] = user_db.get_user_settings(user_id)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>", methods=["PUT"])
|
||||
@_require_admin
|
||||
def admin_update_user(user_id: int) -> Response | tuple[Response, int]:
|
||||
"""Update user fields and/or settings."""
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
auth_source = normalize_auth_source(
|
||||
user.get("auth_source"),
|
||||
user.get("oidc_subject"),
|
||||
)
|
||||
capabilities = _get_user_edit_capabilities(user)
|
||||
|
||||
# Handle optional password update
|
||||
password = data.get("password", "")
|
||||
if password:
|
||||
if not capabilities["canSetPassword"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Cannot set password for {auth_source.upper()} users",
|
||||
"message": "Password authentication is only available for local users.",
|
||||
}
|
||||
), 400
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return jsonify(
|
||||
{"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}
|
||||
), 400
|
||||
user_db.update_user(user_id, password_hash=generate_password_hash(password))
|
||||
|
||||
# Update user fields
|
||||
user_fields = {}
|
||||
for field in ("role", "email", "display_name"):
|
||||
if field in data:
|
||||
user_fields[field] = data[field]
|
||||
|
||||
if "role" in user_fields and user_fields["role"] not in ("admin", "user"):
|
||||
return jsonify({"error": "Role must be 'admin' or 'user'"}), 400
|
||||
|
||||
role_changed = "role" in user_fields and user_fields["role"] != user.get("role")
|
||||
email_changed = "email" in user_fields and user_fields["email"] != user.get("email")
|
||||
display_name_changed = "display_name" in user_fields and user_fields[
|
||||
"display_name"
|
||||
] != user.get("display_name")
|
||||
|
||||
if role_changed and not capabilities["canEditRole"]:
|
||||
if auth_source == AUTH_SOURCE_OIDC:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change role for OIDC user when group-based authorization is enabled",
|
||||
"message": _oidc_role_management_message(),
|
||||
}
|
||||
), 400
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Cannot change role for {auth_source.upper()} users",
|
||||
"message": "Role is managed by the external authentication source.",
|
||||
}
|
||||
), 400
|
||||
|
||||
if email_changed and not capabilities["canEditEmail"]:
|
||||
if auth_source == AUTH_SOURCE_CWA:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change email for CWA users",
|
||||
"message": "Email is synced from Calibre-Web.",
|
||||
}
|
||||
), 400
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change email for OIDC users",
|
||||
"message": "Email is managed by your identity provider.",
|
||||
}
|
||||
), 400
|
||||
|
||||
if display_name_changed and not capabilities["canEditDisplayName"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change display name for OIDC users",
|
||||
"message": "Display name is managed by your identity provider.",
|
||||
}
|
||||
), 400
|
||||
|
||||
# Allow demoting the last admin account.
|
||||
# Auth mode resolution automatically falls back to "none" when no
|
||||
# local password admin remains.
|
||||
|
||||
# Avoid unnecessary writes for no-op field updates.
|
||||
for field in ("role", "email", "display_name"):
|
||||
if field in user_fields and user_fields[field] == user.get(field):
|
||||
user_fields.pop(field)
|
||||
|
||||
if user_fields:
|
||||
user_db.update_user(user_id, **user_fields)
|
||||
|
||||
# Update per-user settings
|
||||
if "settings" in data:
|
||||
if not isinstance(data["settings"], dict):
|
||||
return jsonify({"error": "Settings must be an object"}), 400
|
||||
|
||||
validated_settings, validation_errors = validate_user_settings(data["settings"])
|
||||
if validation_errors:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Invalid settings payload",
|
||||
"details": validation_errors,
|
||||
}
|
||||
), 400
|
||||
|
||||
user_db.set_user_settings(user_id, validated_settings)
|
||||
# Ensure runtime reads see updated per-user overrides immediately.
|
||||
try:
|
||||
app_config.refresh(force=True)
|
||||
except _CONFIG_REFRESH_ERRORS as exc:
|
||||
logger.warning(
|
||||
"Updated settings for user %s but failed to refresh runtime config: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
updated = user_db.get_user(user_id=user_id)
|
||||
if not isinstance(updated, dict):
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
result = _serialize_user(
|
||||
updated,
|
||||
g.auth_mode,
|
||||
)
|
||||
result["settings"] = user_db.get_user_settings(user_id)
|
||||
logger.info("Admin updated user %s", user_id)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/api/admin/users/sync-cwa", methods=["POST"])
|
||||
@_require_admin
|
||||
def admin_sync_cwa_users() -> Response | tuple[Response, int]:
|
||||
"""Manually sync users from Calibre-Web into users.db."""
|
||||
if g.auth_mode != AUTH_SOURCE_CWA:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "CWA sync is only available when CWA authentication is enabled",
|
||||
}
|
||||
), 400
|
||||
|
||||
try:
|
||||
summary = _sync_all_cwa_users(user_db)
|
||||
except FileNotFoundError:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Calibre-Web database is not available",
|
||||
"message": "Verify app.db is mounted and readable at /auth/app.db.",
|
||||
}
|
||||
), 503
|
||||
except Exception:
|
||||
logger.exception("Failed to sync CWA users")
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Failed to sync users from Calibre-Web",
|
||||
}
|
||||
), 500
|
||||
|
||||
message = (
|
||||
f"Synced {summary['total']} CWA users "
|
||||
f"({summary['created']} created, {summary['updated']} updated, "
|
||||
f"{summary.get('deleted', 0)} deleted)."
|
||||
)
|
||||
logger.info(message)
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": message,
|
||||
**summary,
|
||||
}
|
||||
)
|
||||
|
||||
register_admin_settings_routes(app, user_db, _require_admin)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>", methods=["DELETE"])
|
||||
@_require_admin
|
||||
def admin_delete_user(user_id: int) -> Response | tuple[Response, int]:
|
||||
"""Delete a user."""
|
||||
# Prevent self-deletion
|
||||
if session.get("db_user_id") == user_id:
|
||||
return jsonify({"error": "Cannot delete your own account"}), 400
|
||||
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
auth_source = normalize_auth_source(
|
||||
user.get("auth_source"),
|
||||
user.get("oidc_subject"),
|
||||
)
|
||||
if auth_source == AUTH_SOURCE_CWA and auth_source == g.auth_mode:
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Cannot delete active {auth_source.upper()} users",
|
||||
"message": f"{auth_source.upper()} users are automatically re-provisioned on login.",
|
||||
}
|
||||
), 400
|
||||
|
||||
# Allow deleting the last local admin account.
|
||||
# Auth mode resolution automatically falls back to "none" when no
|
||||
# local password admin remains.
|
||||
|
||||
user_db.delete_user(user_id)
|
||||
logger.info("Admin deleted user %s: %s", user_id, user["username"])
|
||||
return jsonify({"success": True})
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Admin settings-introspection routes and settings validation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
from shelfmark.config.notifications_settings import (
|
||||
build_notification_test_result,
|
||||
is_valid_notification_url,
|
||||
normalize_notification_routes,
|
||||
)
|
||||
from shelfmark.config.users_settings import validate_search_preference_value
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.request_policy import parse_policy_mode, validate_policy_rules
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_settings_overrides import (
|
||||
build_user_preferences_payload as _build_user_preferences_payload,
|
||||
)
|
||||
from shelfmark.core.user_settings_overrides import (
|
||||
get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields,
|
||||
)
|
||||
from shelfmark.core.user_settings_overrides import (
|
||||
get_settings_registry as _get_settings_registry,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
def validate_user_settings(
|
||||
settings: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Validate and normalize per-user settings overrides."""
|
||||
settings_registry = _get_settings_registry()
|
||||
field_map = settings_registry.get_settings_field_map()
|
||||
overridable_map = settings_registry.get_user_overridable_fields()
|
||||
|
||||
valid: dict[str, Any] = {}
|
||||
errors: list[str] = []
|
||||
for key, value in settings.items():
|
||||
if key not in field_map:
|
||||
errors.append(f"Unknown setting: {key}")
|
||||
elif key not in overridable_map:
|
||||
errors.append(f"Setting not user-overridable: {key}")
|
||||
else:
|
||||
# null means "clear the per-user override; use global default"
|
||||
if value is None:
|
||||
valid[key] = None
|
||||
continue
|
||||
|
||||
if (
|
||||
key in {"REQUEST_POLICY_DEFAULT_EBOOK", "REQUEST_POLICY_DEFAULT_AUDIOBOOK"}
|
||||
and parse_policy_mode(value) is None
|
||||
):
|
||||
errors.append(f"Invalid policy mode for {key}: {value}")
|
||||
continue
|
||||
|
||||
if key == "REQUEST_POLICY_RULES":
|
||||
normalized_rules, rule_errors = validate_policy_rules(value)
|
||||
if rule_errors:
|
||||
errors.extend(rule_errors)
|
||||
continue
|
||||
valid[key] = normalized_rules
|
||||
continue
|
||||
|
||||
if key == "USER_NOTIFICATION_ROUTES":
|
||||
normalized_routes = normalize_notification_routes(value)
|
||||
invalid_count = sum(
|
||||
1
|
||||
for row in normalized_routes
|
||||
if row.get("url") and not is_valid_notification_url(str(row.get("url")))
|
||||
)
|
||||
if invalid_count:
|
||||
errors.append(
|
||||
f"Invalid value for {key}: found {invalid_count} invalid URL(s). "
|
||||
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
|
||||
)
|
||||
continue
|
||||
valid[key] = normalized_routes
|
||||
continue
|
||||
|
||||
normalized_search_value, search_validation_error = validate_search_preference_value(
|
||||
key, value
|
||||
)
|
||||
if search_validation_error:
|
||||
errors.append(search_validation_error)
|
||||
continue
|
||||
if key in {
|
||||
"SEARCH_MODE",
|
||||
"METADATA_PROVIDER",
|
||||
"METADATA_PROVIDER_AUDIOBOOK",
|
||||
"DEFAULT_RELEASE_SOURCE",
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||
}:
|
||||
valid[key] = normalized_search_value
|
||||
continue
|
||||
|
||||
if key == "DOWNLOAD_TO_BROWSER_CONTENT_TYPES":
|
||||
if not isinstance(value, list):
|
||||
errors.append(f"Invalid value for {key}: must be a list")
|
||||
continue
|
||||
|
||||
candidate_values = [
|
||||
str(entry).strip().lower() for entry in value if str(entry).strip()
|
||||
]
|
||||
normalized_values: list[str] = []
|
||||
has_invalid_value = False
|
||||
for entry in candidate_values:
|
||||
if entry not in {"book", "audiobook"}:
|
||||
errors.append(
|
||||
f"Invalid value for {key}: unsupported content type '{entry}'"
|
||||
)
|
||||
has_invalid_value = True
|
||||
continue
|
||||
if entry not in normalized_values:
|
||||
normalized_values.append(entry)
|
||||
|
||||
if has_invalid_value:
|
||||
continue
|
||||
|
||||
valid[key] = normalized_values
|
||||
continue
|
||||
|
||||
valid[key] = value
|
||||
|
||||
return valid, errors
|
||||
|
||||
|
||||
def build_user_notification_test_response(
|
||||
*,
|
||||
user_id: int,
|
||||
payload: object,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
"""Build a notification test response using effective per-user routes."""
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
routes_input = app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=user_id)
|
||||
if isinstance(payload, dict):
|
||||
if "USER_NOTIFICATION_ROUTES" in payload:
|
||||
routes_input = payload.get("USER_NOTIFICATION_ROUTES")
|
||||
elif "routes" in payload:
|
||||
routes_input = payload.get("routes")
|
||||
|
||||
result = build_notification_test_result(routes_input, scope_label="personal")
|
||||
status_code = 200 if result.get("success", False) else 400
|
||||
return result, status_code
|
||||
|
||||
|
||||
def register_admin_settings_routes(
|
||||
app: Flask,
|
||||
user_db: UserDB,
|
||||
require_admin: Callable[
|
||||
[Callable[..., ResponseReturnValue]], Callable[..., ResponseReturnValue]
|
||||
],
|
||||
) -> None:
|
||||
"""Register admin endpoints for user-specific settings and defaults."""
|
||||
|
||||
@app.route("/api/admin/download-defaults", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_download_defaults() -> ResponseReturnValue:
|
||||
defaults = {
|
||||
key: ("" if (value := app_config.get(key, field.default)) is None else value)
|
||||
for key, field in _get_ordered_user_overridable_fields("downloads")
|
||||
}
|
||||
|
||||
defaults["OIDC_ADMIN_GROUP"] = app_config.get("OIDC_ADMIN_GROUP", "")
|
||||
defaults["OIDC_USE_ADMIN_GROUP"] = app_config.get("OIDC_USE_ADMIN_GROUP", True)
|
||||
defaults["OIDC_AUTO_PROVISION"] = app_config.get("OIDC_AUTO_PROVISION", True)
|
||||
return jsonify(defaults)
|
||||
|
||||
@app.route("/api/admin/booklore-options", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_booklore_options() -> ResponseReturnValue:
|
||||
from shelfmark.core import admin_routes
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"libraries": admin_routes.get_booklore_library_options(),
|
||||
"paths": admin_routes.get_booklore_path_options(),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/delivery-preferences", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_delivery_preferences(user_id: int) -> ResponseReturnValue:
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
try:
|
||||
payload = _build_user_preferences_payload(user_db, user_id, "downloads")
|
||||
except ValueError:
|
||||
return jsonify({"error": "Downloads settings tab not found"}), 500
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/search-preferences", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_search_preferences(user_id: int) -> ResponseReturnValue:
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
try:
|
||||
payload = _build_user_preferences_payload(user_db, user_id, "search_mode")
|
||||
except ValueError:
|
||||
return jsonify({"error": "Search mode settings tab not found"}), 500
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/notification-preferences", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_notification_preferences(user_id: int) -> ResponseReturnValue:
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
try:
|
||||
payload = _build_user_preferences_payload(user_db, user_id, "notifications")
|
||||
except ValueError:
|
||||
return jsonify({"error": "Notifications settings tab not found"}), 500
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/notification-preferences/test", methods=["POST"])
|
||||
@require_admin
|
||||
def admin_test_notification_preferences(user_id: int) -> ResponseReturnValue:
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
result, status_code = build_user_notification_test_response(
|
||||
user_id=user_id,
|
||||
payload=payload,
|
||||
)
|
||||
return jsonify(result), status_code
|
||||
|
||||
@app.route("/api/admin/settings/overrides-summary", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_settings_overrides_summary() -> ResponseReturnValue:
|
||||
settings_registry = _get_settings_registry()
|
||||
|
||||
tab_name = (request.args.get("tab") or "downloads").strip()
|
||||
if not settings_registry.get_settings_tab(tab_name):
|
||||
return jsonify({"error": f"Unknown settings tab: {tab_name}"}), 404
|
||||
|
||||
overridable_keys = list(settings_registry.get_user_overridable_fields(tab_name=tab_name))
|
||||
keys_payload: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for user_record in user_db.list_users():
|
||||
user_settings = user_db.get_user_settings(user_record["id"])
|
||||
if not isinstance(user_settings, dict):
|
||||
continue
|
||||
|
||||
for key in overridable_keys:
|
||||
if key not in user_settings or user_settings[key] is None:
|
||||
continue
|
||||
entry = keys_payload.setdefault(key, {"count": 0, "users": []})
|
||||
entry["users"].append(
|
||||
{
|
||||
"userId": user_record["id"],
|
||||
"username": user_record["username"],
|
||||
"value": user_settings[key],
|
||||
}
|
||||
)
|
||||
|
||||
for summary in keys_payload.values():
|
||||
summary["count"] = len(summary["users"])
|
||||
|
||||
return jsonify({"tab": tab_name, "keys": keys_payload})
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/effective-settings", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_effective_settings(user_id: int) -> ResponseReturnValue:
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.settings_registry import is_value_from_env
|
||||
|
||||
field_map = _get_settings_registry().get_user_overridable_fields()
|
||||
user_settings = user_db.get_user_settings(user_id)
|
||||
tab_config_cache: dict[str, dict[str, Any]] = {}
|
||||
effective: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for key, (field, tab_name) in sorted(field_map.items()):
|
||||
value = app_config.get(key, field.default, user_id=user_id)
|
||||
source = "default"
|
||||
|
||||
if field.env_supported and is_value_from_env(field):
|
||||
source = "env_var"
|
||||
elif key in user_settings and user_settings[key] is not None:
|
||||
source = "user_override"
|
||||
value = user_settings[key]
|
||||
else:
|
||||
tab_config = tab_config_cache.setdefault(tab_name, load_config_file(tab_name))
|
||||
if key in tab_config:
|
||||
source = "global_config"
|
||||
|
||||
effective[key] = {"value": value, "source": source}
|
||||
|
||||
return jsonify(effective)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Authentication mode, auth-source normalization, and admin access policy helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Protocol, TypeGuard
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
AUTH_SOURCE_BUILTIN = "builtin"
|
||||
AUTH_SOURCE_OIDC = "oidc"
|
||||
AUTH_SOURCE_PROXY = "proxy"
|
||||
AUTH_SOURCE_CWA = "cwa"
|
||||
AUTH_SOURCES = (
|
||||
AUTH_SOURCE_BUILTIN,
|
||||
AUTH_SOURCE_OIDC,
|
||||
AUTH_SOURCE_PROXY,
|
||||
AUTH_SOURCE_CWA,
|
||||
)
|
||||
AUTH_SOURCE_SET = frozenset(AUTH_SOURCES)
|
||||
_ALWAYS_ADMIN_SETTINGS_TABS = frozenset({"security", "users"})
|
||||
|
||||
|
||||
class _UserDBWithAdminPassword(Protocol):
|
||||
"""Minimal user DB surface needed for local-admin checks."""
|
||||
|
||||
def has_admin_with_password(self) -> bool: ...
|
||||
|
||||
|
||||
def _has_admin_password_api(candidate: object) -> TypeGuard[_UserDBWithAdminPassword]:
|
||||
"""Return True when *candidate* exposes the admin-password lookup we need."""
|
||||
return callable(getattr(candidate, "has_admin_with_password", None))
|
||||
|
||||
|
||||
def has_local_password_admin(user_db: object | None = None) -> bool:
|
||||
"""Return True when at least one local admin with a password exists."""
|
||||
try:
|
||||
db = user_db
|
||||
if db is None:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
config_root = os.environ.get("CONFIG_DIR", "/config")
|
||||
db = UserDB(str(Path(config_root) / "users.db"))
|
||||
db.initialize()
|
||||
|
||||
if not _has_admin_password_api(db):
|
||||
return False
|
||||
return db.has_admin_with_password()
|
||||
except AttributeError, ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error:
|
||||
return False
|
||||
|
||||
|
||||
def normalize_auth_source(
|
||||
source: object,
|
||||
oidc_subject: object = None,
|
||||
) -> str:
|
||||
"""Resolve a stable auth source value from persisted fields."""
|
||||
normalized = str(source or "").strip().lower()
|
||||
if normalized in AUTH_SOURCE_SET:
|
||||
return normalized
|
||||
if oidc_subject:
|
||||
return AUTH_SOURCE_OIDC
|
||||
return AUTH_SOURCE_BUILTIN
|
||||
|
||||
|
||||
def determine_auth_mode(
|
||||
security_config: Mapping[str, Any],
|
||||
cwa_db_path: object | None,
|
||||
*,
|
||||
has_local_admin: bool = True,
|
||||
) -> str:
|
||||
"""Determine active auth mode from security config and runtime prerequisites."""
|
||||
auth_mode = security_config.get("AUTH_METHOD", "none")
|
||||
|
||||
if auth_mode == AUTH_SOURCE_CWA and cwa_db_path:
|
||||
return AUTH_SOURCE_CWA
|
||||
|
||||
if auth_mode == AUTH_SOURCE_BUILTIN and has_local_admin:
|
||||
return AUTH_SOURCE_BUILTIN
|
||||
|
||||
if auth_mode == AUTH_SOURCE_PROXY and security_config.get("PROXY_AUTH_USER_HEADER"):
|
||||
return AUTH_SOURCE_PROXY
|
||||
|
||||
if (
|
||||
auth_mode == AUTH_SOURCE_OIDC
|
||||
and has_local_admin
|
||||
and security_config.get("OIDC_DISCOVERY_URL")
|
||||
and security_config.get("OIDC_CLIENT_ID")
|
||||
):
|
||||
return AUTH_SOURCE_OIDC
|
||||
|
||||
return "none"
|
||||
|
||||
|
||||
def load_active_auth_mode(
|
||||
cwa_db_path: object | None,
|
||||
*,
|
||||
user_db: object | None = None,
|
||||
) -> str:
|
||||
"""Resolve active auth mode using current security config and runtime prerequisites."""
|
||||
try:
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
security_config = {
|
||||
"AUTH_METHOD": app_config.get("AUTH_METHOD", "none"),
|
||||
"PROXY_AUTH_USER_HEADER": app_config.get("PROXY_AUTH_USER_HEADER", ""),
|
||||
"OIDC_DISCOVERY_URL": app_config.get("OIDC_DISCOVERY_URL", ""),
|
||||
"OIDC_CLIENT_ID": app_config.get("OIDC_CLIENT_ID", ""),
|
||||
}
|
||||
return determine_auth_mode(
|
||||
security_config,
|
||||
cwa_db_path,
|
||||
has_local_admin=has_local_password_admin(user_db),
|
||||
)
|
||||
except ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error:
|
||||
return "none"
|
||||
|
||||
|
||||
def is_user_active_for_auth_mode(user: Mapping[str, Any], auth_mode: str) -> bool:
|
||||
"""Return whether a user can authenticate under the current auth mode."""
|
||||
source = normalize_auth_source(user.get("auth_source"), user.get("oidc_subject"))
|
||||
if source == AUTH_SOURCE_BUILTIN:
|
||||
return auth_mode in (AUTH_SOURCE_BUILTIN, AUTH_SOURCE_OIDC)
|
||||
return source == auth_mode
|
||||
|
||||
|
||||
def is_settings_or_onboarding_path(path: str) -> bool:
|
||||
"""Return True when request path targets protected admin settings routes."""
|
||||
return path.startswith(("/api/settings", "/api/onboarding"))
|
||||
|
||||
|
||||
def get_settings_tab_from_path(path: str) -> str | None:
|
||||
"""Extract tab name from /api/settings/<tab>[...] paths."""
|
||||
if not path.startswith("/api/settings/"):
|
||||
return None
|
||||
|
||||
suffix = path[len("/api/settings/") :]
|
||||
if not suffix:
|
||||
return None
|
||||
|
||||
return suffix.split("/", 1)[0] or None
|
||||
|
||||
|
||||
def should_restrict_settings_to_admin(
|
||||
_users_config: Mapping[str, Any],
|
||||
) -> bool:
|
||||
"""Settings/onboarding is always admin-only."""
|
||||
return True
|
||||
|
||||
|
||||
def requires_admin_for_settings_access(
|
||||
path: str,
|
||||
users_config: Mapping[str, Any],
|
||||
) -> bool:
|
||||
"""Return whether this settings/onboarding request requires admin privileges."""
|
||||
tab_name = get_settings_tab_from_path(path)
|
||||
if tab_name in _ALWAYS_ADMIN_SETTINGS_TABS:
|
||||
return True
|
||||
|
||||
return should_restrict_settings_to_admin(users_config)
|
||||
|
||||
|
||||
def get_auth_check_admin_status(
|
||||
_auth_mode: str,
|
||||
_users_config: Mapping[str, Any],
|
||||
session_data: Mapping[str, Any],
|
||||
) -> bool:
|
||||
"""Resolve /api/auth/check `is_admin` as the session's real admin role."""
|
||||
if "user_id" not in session_data:
|
||||
return False
|
||||
|
||||
return bool(session_data.get("is_admin", False))
|
||||
@@ -4,32 +4,37 @@ import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Dict, Optional, TypeVar
|
||||
from typing import TYPE_CHECKING, ParamSpec, TypeVar, cast
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheEntry:
|
||||
"""A cached value with expiration time."""
|
||||
value: Any
|
||||
|
||||
value: object
|
||||
expires_at: float
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""Thread-safe in-memory cache with TTL support."""
|
||||
|
||||
def __init__(self, max_size: int = 1000):
|
||||
def __init__(self, max_size: int = 1000) -> None:
|
||||
"""Initialize cache with max_size entries before eviction."""
|
||||
self._cache: Dict[str, CacheEntry] = {}
|
||||
self._cache: dict[str, CacheEntry] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_size = max_size
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
def get(self, key: str) -> object | None:
|
||||
"""Get cached value if not expired."""
|
||||
with self._lock:
|
||||
entry = self._cache.get(key)
|
||||
@@ -42,17 +47,14 @@ class CacheService:
|
||||
|
||||
return entry.value
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int) -> None:
|
||||
def set(self, key: str, value: object, ttl: int) -> None:
|
||||
"""Cache value with TTL in seconds."""
|
||||
with self._lock:
|
||||
# Evict oldest entries if at capacity
|
||||
if len(self._cache) >= self._max_size:
|
||||
self._evict_oldest()
|
||||
|
||||
self._cache[key] = CacheEntry(
|
||||
value=value,
|
||||
expires_at=time.time() + ttl
|
||||
)
|
||||
self._cache[key] = CacheEntry(value=value, expires_at=time.time() + ttl)
|
||||
|
||||
def invalidate(self, key: str) -> bool:
|
||||
"""Remove specific cache entry. Returns True if found."""
|
||||
@@ -62,6 +64,14 @@ class CacheService:
|
||||
return True
|
||||
return False
|
||||
|
||||
def invalidate_prefix(self, prefix: str) -> int:
|
||||
"""Remove all cache entries whose keys start with prefix."""
|
||||
with self._lock:
|
||||
matching_keys = [key for key in self._cache if key.startswith(prefix)]
|
||||
for key in matching_keys:
|
||||
del self._cache[key]
|
||||
return len(matching_keys)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cache entries."""
|
||||
with self._lock:
|
||||
@@ -71,10 +81,7 @@ class CacheService:
|
||||
"""Remove all expired entries. Returns count removed."""
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
expired_keys = [
|
||||
key for key, entry in self._cache.items()
|
||||
if entry.expires_at < now
|
||||
]
|
||||
expired_keys = [key for key, entry in self._cache.items() if entry.expires_at < now]
|
||||
for key in expired_keys:
|
||||
del self._cache[key]
|
||||
return len(expired_keys)
|
||||
@@ -86,21 +93,15 @@ class CacheService:
|
||||
|
||||
# Remove ~10% of entries, oldest first
|
||||
entries_to_remove = max(1, len(self._cache) // 10)
|
||||
sorted_entries = sorted(
|
||||
self._cache.items(),
|
||||
key=lambda x: x[1].expires_at
|
||||
)
|
||||
sorted_entries = sorted(self._cache.items(), key=lambda x: x[1].expires_at)
|
||||
|
||||
for key, _ in sorted_entries[:entries_to_remove]:
|
||||
del self._cache[key]
|
||||
|
||||
def stats(self) -> Dict[str, int]:
|
||||
def stats(self) -> dict[str, int]:
|
||||
"""Get cache statistics (size, max_size)."""
|
||||
with self._lock:
|
||||
return {
|
||||
"size": len(self._cache),
|
||||
"max_size": self._max_size
|
||||
}
|
||||
return {"size": len(self._cache), "max_size": self._max_size}
|
||||
|
||||
|
||||
# Global cache instance for metadata providers
|
||||
@@ -112,23 +113,38 @@ def get_metadata_cache() -> CacheService:
|
||||
return _metadata_cache
|
||||
|
||||
|
||||
def cache_key(*args, **kwargs) -> str:
|
||||
def cache_key(*args: object, **kwargs: object) -> str:
|
||||
"""Generate cache key from arguments."""
|
||||
parts = [str(arg) for arg in args]
|
||||
parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
|
||||
return ":".join(parts)
|
||||
|
||||
|
||||
def _coerce_ttl_seconds(value: object, *, default: int) -> int:
|
||||
"""Normalize cache TTL values read from config or decorator arguments."""
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, int):
|
||||
return value if value > 0 else default
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped.isdigit():
|
||||
parsed = int(stripped)
|
||||
return parsed if parsed > 0 else default
|
||||
return default
|
||||
|
||||
|
||||
def cacheable(
|
||||
ttl: Optional[int] = None,
|
||||
ttl_key: Optional[str] = None,
|
||||
ttl: int | None = None,
|
||||
ttl_key: str | None = None,
|
||||
ttl_default: int = 300,
|
||||
key_prefix: str = ""
|
||||
):
|
||||
"""Decorator for caching function results. Use ttl (static) or ttl_key (from config)."""
|
||||
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
||||
key_prefix: str = "",
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""Cache function results with a static or config-backed TTL."""
|
||||
|
||||
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> T:
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
# Check if metadata caching is enabled
|
||||
from shelfmark.core.config import config
|
||||
|
||||
@@ -140,7 +156,10 @@ def cacheable(
|
||||
if ttl is not None:
|
||||
effective_ttl = ttl
|
||||
elif ttl_key:
|
||||
effective_ttl = config.get(ttl_key, ttl_default)
|
||||
effective_ttl = _coerce_ttl_seconds(
|
||||
config.get(ttl_key, ttl_default),
|
||||
default=ttl_default,
|
||||
)
|
||||
else:
|
||||
effective_ttl = ttl_default
|
||||
|
||||
@@ -148,16 +167,12 @@ def cacheable(
|
||||
# Skip 'self' argument if present (first arg of method)
|
||||
cache_args = args[1:] if args and hasattr(args[0], func.__name__) else args
|
||||
|
||||
key = cache_key(
|
||||
key_prefix or func.__name__,
|
||||
*cache_args,
|
||||
**kwargs
|
||||
)
|
||||
key = cache_key(key_prefix or func.__name__, *cache_args, **kwargs)
|
||||
|
||||
# Check cache
|
||||
cached = _metadata_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
return cast("R", cached)
|
||||
|
||||
# Execute function and cache result
|
||||
result = func(*args, **kwargs)
|
||||
@@ -169,4 +184,5 @@ def cacheable(
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -1,58 +1,93 @@
|
||||
"""Configuration singleton with ENV > config file > default resolution."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
# Import lazily to avoid circular imports
|
||||
_registry_module = None
|
||||
_env_module = None
|
||||
_user_db_module = None
|
||||
|
||||
_SETTINGS_REFRESH_COOLDOWN_SECONDS = 0.05
|
||||
|
||||
|
||||
def _get_registry():
|
||||
def _get_registry() -> ModuleType:
|
||||
"""Lazy import of settings registry to avoid circular imports."""
|
||||
global _registry_module
|
||||
if _registry_module is None:
|
||||
from shelfmark.core import settings_registry
|
||||
|
||||
_registry_module = settings_registry
|
||||
return _registry_module
|
||||
|
||||
|
||||
def _get_env():
|
||||
def _get_env() -> ModuleType:
|
||||
"""Lazy import of env module for fallback values."""
|
||||
global _env_module
|
||||
if _env_module is None:
|
||||
from shelfmark.config import env
|
||||
|
||||
_env_module = env
|
||||
return _env_module
|
||||
|
||||
|
||||
def _get_user_db_module() -> type[UserDB]:
|
||||
"""Lazy import of user DB module to avoid optional dependency loops."""
|
||||
global _user_db_module
|
||||
if _user_db_module is None:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
_user_db_module = UserDB
|
||||
return _user_db_module
|
||||
|
||||
|
||||
class Config:
|
||||
"""
|
||||
Dynamic configuration singleton that provides live settings access.
|
||||
"""Dynamic configuration singleton that provides live settings access.
|
||||
|
||||
Settings are resolved with priority: ENV var > config file > default.
|
||||
Values are cached for performance and can be refreshed when settings change.
|
||||
"""
|
||||
|
||||
_instance: Optional['Config'] = None
|
||||
_instance: Self | None = None
|
||||
_lock = Lock()
|
||||
|
||||
def __new__(cls) -> 'Config':
|
||||
def __new__(cls) -> Self:
|
||||
"""Return the shared configuration singleton instance."""
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
instance = cls._instance
|
||||
if instance is None:
|
||||
msg = "Config singleton failed to initialize"
|
||||
raise RuntimeError(msg)
|
||||
return instance
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize caches and backing stores for the singleton."""
|
||||
if self._initialized:
|
||||
return
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._field_map: Dict[str, tuple] = {} # key -> (field, tab_name)
|
||||
self._cache: dict[str, Any] = {}
|
||||
self._field_map: dict[str, tuple] = {} # key -> (field, tab_name)
|
||||
self._cache_lock = Lock()
|
||||
self._user_settings_cache: dict[int, dict[str, Any]] = {}
|
||||
self._user_settings_cache_lock = Lock()
|
||||
self._user_db = None
|
||||
self._user_db_load_attempted = False
|
||||
self._initialized = True
|
||||
self._loaded = False
|
||||
self._last_refresh_time: float = 0.0
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure settings are loaded from the registry."""
|
||||
@@ -68,9 +103,12 @@ class Config:
|
||||
# Ensure all settings modules are imported before loading
|
||||
# This handles cases where config is accessed before settings are registered
|
||||
try:
|
||||
import shelfmark.config.settings # noqa: F401 - main app settings
|
||||
import shelfmark.release_sources # noqa: F401 - plugin settings
|
||||
import shelfmark.metadata_providers # noqa: F401 - plugin settings
|
||||
import_module("shelfmark.config.notifications_settings")
|
||||
import_module("shelfmark.config.security")
|
||||
import_module("shelfmark.config.settings")
|
||||
import_module("shelfmark.config.users_settings")
|
||||
import_module("shelfmark.metadata_providers")
|
||||
import_module("shelfmark.release_sources")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -78,7 +116,7 @@ class Config:
|
||||
|
||||
# On first load, sync ENV values to config files
|
||||
# This ensures ENV values persist even if ENV vars are later removed
|
||||
if not hasattr(self, '_env_synced'):
|
||||
if not hasattr(self, "_env_synced"):
|
||||
registry.sync_env_to_config()
|
||||
self._env_synced = True
|
||||
|
||||
@@ -86,55 +124,122 @@ class Config:
|
||||
self._field_map.clear()
|
||||
self._cache.clear()
|
||||
|
||||
for tab in registry.get_all_settings_tabs():
|
||||
for field in tab.fields:
|
||||
# Skip action buttons and headings - they don't have values
|
||||
if isinstance(field, (registry.ActionButton, registry.HeadingField)):
|
||||
continue
|
||||
|
||||
key = field.key
|
||||
self._field_map[key] = (field, tab.name)
|
||||
|
||||
# Load current value
|
||||
value = registry.get_setting_value(field, tab.name)
|
||||
self._cache[key] = value
|
||||
for key, (field, tab_name) in registry.get_settings_field_map().items():
|
||||
self._field_map[key] = (field, tab_name)
|
||||
self._cache[key] = registry.get_setting_value(field, tab_name)
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""
|
||||
Refresh all cached settings from config files.
|
||||
def refresh(self, *, force: bool = False) -> None:
|
||||
"""Refresh all cached settings from config files.
|
||||
|
||||
Call this after settings are updated via the UI to ensure
|
||||
the config singleton reflects the new values.
|
||||
|
||||
Multiple calls within a short window (50 ms) are coalesced to
|
||||
avoid redundant disk I/O when several helpers each call refresh()
|
||||
during the same request. Pass ``force=True`` to bypass the guard
|
||||
(e.g. after a settings write).
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if not force and (now - self._last_refresh_time) < _SETTINGS_REFRESH_COOLDOWN_SECONDS:
|
||||
return
|
||||
|
||||
with self._cache_lock:
|
||||
self._loaded = False
|
||||
self._load_settings()
|
||||
with self._user_settings_cache_lock:
|
||||
self._user_settings_cache.clear()
|
||||
self._user_db = None
|
||||
self._user_db_load_attempted = False
|
||||
self._last_refresh_time = time.monotonic()
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get a setting value by key.
|
||||
def _get_user_db(self) -> UserDB | None:
|
||||
"""Get or initialize a UserDB handle if available."""
|
||||
if self._user_db is not None:
|
||||
return self._user_db
|
||||
if self._user_db_load_attempted:
|
||||
return None
|
||||
|
||||
self._user_db_load_attempted = True
|
||||
try:
|
||||
user_db_cls = _get_user_db_module()
|
||||
db_path = str(Path(os.environ.get("CONFIG_DIR", "/config")) / "users.db")
|
||||
user_db = user_db_cls(db_path)
|
||||
user_db.initialize()
|
||||
except ImportError, OSError, sqlite3.Error:
|
||||
# Multi-user support is optional; fall back to global config when unavailable.
|
||||
return None
|
||||
else:
|
||||
self._user_db = user_db
|
||||
return self._user_db
|
||||
|
||||
def _get_user_settings(self, user_id: int) -> dict[str, Any]:
|
||||
"""Get cached per-user settings from user DB."""
|
||||
with self._user_settings_cache_lock:
|
||||
if user_id in self._user_settings_cache:
|
||||
return self._user_settings_cache[user_id]
|
||||
|
||||
user_db = self._get_user_db()
|
||||
if user_db is None:
|
||||
return {}
|
||||
|
||||
try:
|
||||
settings = user_db.get_user_settings(user_id)
|
||||
except sqlite3.OperationalError, OSError, ValueError, TypeError:
|
||||
return {}
|
||||
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
|
||||
with self._user_settings_cache_lock:
|
||||
self._user_settings_cache[user_id] = settings
|
||||
return settings
|
||||
|
||||
def _get_user_override(self, user_id: int, key: str) -> object:
|
||||
"""Get a user override for a specific key."""
|
||||
user_settings = self._get_user_settings(user_id)
|
||||
return user_settings.get(key)
|
||||
|
||||
def get(self, key: str, default: object = None, user_id: int | None = None) -> object:
|
||||
"""Get a setting value by key.
|
||||
|
||||
Args:
|
||||
key: The setting key (e.g., 'MAX_RETRY')
|
||||
default: Default value if setting not found
|
||||
user_id: Optional DB user ID for per-user setting overrides
|
||||
|
||||
Returns:
|
||||
The setting value, or default if not found
|
||||
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
if key in self._field_map:
|
||||
field, _ = self._field_map[key]
|
||||
registry = _get_registry()
|
||||
|
||||
# Deployment-level ENV values always win.
|
||||
if field.env_supported and registry.is_value_from_env(field):
|
||||
return self._cache.get(key, default)
|
||||
|
||||
# User overrides are only available for explicitly overridable fields.
|
||||
if user_id is not None and getattr(field, "user_overridable", False):
|
||||
user_value = self._get_user_override(user_id, key)
|
||||
if user_value is not None:
|
||||
return user_value
|
||||
|
||||
return self._cache.get(key, default)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""
|
||||
Allow attribute-style access to settings.
|
||||
def __getattr__(self, name: str) -> object:
|
||||
"""Allow attribute-style access to settings.
|
||||
|
||||
Example: config.MAX_RETRY instead of config.get('MAX_RETRY')
|
||||
"""
|
||||
# Avoid recursion for internal attributes
|
||||
if name.startswith('_'):
|
||||
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
||||
if name.startswith("_"):
|
||||
msg = f"'{type(self).__name__}' object has no attribute '{name}'"
|
||||
raise AttributeError(msg)
|
||||
|
||||
self._ensure_loaded()
|
||||
|
||||
@@ -147,17 +252,18 @@ class Config:
|
||||
if hasattr(env, name):
|
||||
return getattr(env, name)
|
||||
|
||||
raise AttributeError(f"Setting '{name}' not found in config or env")
|
||||
msg = f"Setting '{name}' not found in config or env"
|
||||
raise AttributeError(msg)
|
||||
|
||||
def is_from_env(self, key: str) -> bool:
|
||||
"""
|
||||
Check if a setting's value comes from an environment variable.
|
||||
"""Check if a setting's value comes from an environment variable.
|
||||
|
||||
Args:
|
||||
key: The setting key
|
||||
|
||||
Returns:
|
||||
True if the value is set via ENV var, False otherwise
|
||||
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
@@ -168,12 +274,12 @@ class Config:
|
||||
registry = _get_registry()
|
||||
return registry.is_value_from_env(field)
|
||||
|
||||
def get_all(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all cached settings as a dictionary.
|
||||
def get_all(self) -> dict[str, Any]:
|
||||
"""Get all cached settings as a dictionary.
|
||||
|
||||
Returns:
|
||||
Dict of all setting keys to their current values
|
||||
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
return dict(self._cache)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Helpers for provisioning and syncing Calibre-Web users into users.db."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.auth_modes import AUTH_SOURCE_CWA, normalize_auth_source
|
||||
from shelfmark.core.external_user_linking import upsert_external_user
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
_CWA_ALIAS_SUFFIX = "__cwa"
|
||||
|
||||
|
||||
def _normalize_email(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
email = str(value).strip()
|
||||
return email or None
|
||||
|
||||
|
||||
def upsert_cwa_user(
|
||||
user_db: UserDB,
|
||||
cwa_username: str,
|
||||
cwa_email: str | None,
|
||||
role: str,
|
||||
context: str | None = None,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
"""Create/update a CWA-backed user with collision-safe matching."""
|
||||
normalized_email = _normalize_email(cwa_email)
|
||||
collision_strategy = "alias" if normalized_email else "takeover"
|
||||
user, action = upsert_external_user(
|
||||
user_db,
|
||||
auth_source="cwa",
|
||||
username=cwa_username,
|
||||
email=normalized_email,
|
||||
role=role,
|
||||
allow_email_link=True,
|
||||
collision_strategy=collision_strategy,
|
||||
alias_suffix=_CWA_ALIAS_SUFFIX,
|
||||
context=context,
|
||||
)
|
||||
if user is None:
|
||||
msg = "Unexpected CWA user sync result: no user returned"
|
||||
raise RuntimeError(msg)
|
||||
return user, action
|
||||
|
||||
|
||||
def sync_cwa_users_from_rows(
|
||||
user_db: UserDB,
|
||||
rows: Iterable[tuple[Any, Any, Any]],
|
||||
) -> dict[str, int]:
|
||||
"""Sync CWA users from raw `(name, role_flags, email)` rows."""
|
||||
created = 0
|
||||
updated = 0
|
||||
active_cwa_user_ids: set[int] = set()
|
||||
for username, role_flags, email in rows:
|
||||
normalized_username = str(username or "").strip()
|
||||
if not normalized_username:
|
||||
continue
|
||||
|
||||
role = "admin" if (int(role_flags or 0) & 1) == 1 else "user"
|
||||
user, action = upsert_cwa_user(
|
||||
user_db,
|
||||
cwa_username=normalized_username,
|
||||
cwa_email=_normalize_email(email),
|
||||
role=role,
|
||||
context="cwa_manual_sync",
|
||||
)
|
||||
active_cwa_user_ids.add(int(user["id"]))
|
||||
if action == "created":
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
deleted = 0
|
||||
for existing_user in user_db.list_users():
|
||||
if (
|
||||
normalize_auth_source(
|
||||
existing_user.get("auth_source"),
|
||||
existing_user.get("oidc_subject"),
|
||||
)
|
||||
!= AUTH_SOURCE_CWA
|
||||
):
|
||||
continue
|
||||
|
||||
existing_id = int(existing_user.get("id") or 0)
|
||||
if existing_id in active_cwa_user_ids:
|
||||
continue
|
||||
|
||||
user_db.delete_user(existing_id)
|
||||
deleted += 1
|
||||
|
||||
return {
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"deleted": deleted,
|
||||
"total": created + updated,
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
"""Persistence helpers for canonical download activity rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, SupportsIndex, SupportsInt, TypeGuard
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import TERMINAL_QUEUE_STATUSES
|
||||
from shelfmark.core.request_helpers import (
|
||||
normalize_optional_positive_int,
|
||||
normalize_optional_text,
|
||||
now_utc_iso,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
VALID_TERMINAL_STATUSES = frozenset(s.value for s in TERMINAL_QUEUE_STATUSES)
|
||||
ACTIVE_DOWNLOAD_STATUS = "active"
|
||||
VALID_ORIGINS = frozenset({"direct", "requested"})
|
||||
|
||||
|
||||
def _is_convertible_to_int(
|
||||
value: object,
|
||||
) -> TypeGuard[str | bytes | bytearray | SupportsInt | SupportsIndex]:
|
||||
"""Return True when *value* can be safely passed to ``int``."""
|
||||
return (
|
||||
isinstance(value, (str, bytes, bytearray))
|
||||
or hasattr(value, "__int__")
|
||||
or hasattr(value, "__index__")
|
||||
)
|
||||
|
||||
|
||||
def _coerce_int_value(value: object) -> int:
|
||||
"""Normalize int-like values and raise TypeError for unsupported inputs."""
|
||||
if isinstance(value, bool) or not _is_convertible_to_int(value):
|
||||
msg = "limit must be an integer"
|
||||
raise TypeError(msg)
|
||||
return int(value)
|
||||
|
||||
|
||||
def _normalize_task_id(task_id: object) -> str:
|
||||
normalized = normalize_optional_text(task_id)
|
||||
if normalized is None:
|
||||
msg = "task_id must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_origin(origin: object) -> str:
|
||||
normalized = normalize_optional_text(origin)
|
||||
if normalized is None:
|
||||
return "direct"
|
||||
lowered = normalized.lower()
|
||||
if lowered not in VALID_ORIGINS:
|
||||
msg = "origin must be one of: direct, requested"
|
||||
raise ValueError(msg)
|
||||
return lowered
|
||||
|
||||
|
||||
def _normalize_final_status(final_status: object) -> str:
|
||||
normalized = normalize_optional_text(final_status)
|
||||
if normalized is None:
|
||||
msg = "final_status must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
lowered = normalized.lower()
|
||||
if lowered not in VALID_TERMINAL_STATUSES:
|
||||
msg = "final_status must be one of: complete, error, cancelled"
|
||||
raise ValueError(msg)
|
||||
return lowered
|
||||
|
||||
|
||||
def _normalize_limit(value: object, *, default: int, minimum: int, maximum: int) -> int:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
parsed = _coerce_int_value(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
msg = "limit must be an integer"
|
||||
raise ValueError(msg) from exc
|
||||
if parsed < minimum:
|
||||
return minimum
|
||||
if parsed > maximum:
|
||||
return maximum
|
||||
return parsed
|
||||
|
||||
|
||||
class DownloadHistoryService:
|
||||
"""Service for persisted canonical download activity rows."""
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
"""Initialize the service with the SQLite history database path."""
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
@classmethod
|
||||
def _normalize_row_dict(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
normalized = dict(row)
|
||||
normalized["retry_payload"] = cls._deserialize_retry_payload(
|
||||
normalized.get("retry_payload")
|
||||
)
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _row_to_dict(cls, row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
return cls._normalize_row_dict(dict(row) if row is not None else None)
|
||||
|
||||
@staticmethod
|
||||
def _to_item_key(task_id: str) -> str:
|
||||
return f"download:{task_id}"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_existing_download_path(value: object) -> str | None:
|
||||
normalized = normalize_optional_text(value)
|
||||
if normalized is None:
|
||||
return None
|
||||
return normalized if Path(normalized).exists() else None
|
||||
|
||||
@staticmethod
|
||||
def _serialize_retry_payload(payload: object) -> str | None:
|
||||
if payload is None:
|
||||
return None
|
||||
try:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
msg = "retry_payload must be JSON-serializable"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
@staticmethod
|
||||
def _deserialize_retry_payload(value: object) -> dict[str, Any] | None:
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
normalized = normalize_optional_text(value)
|
||||
if normalized is None:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(normalized)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
@staticmethod
|
||||
def _has_staged_retry_source(retry_payload: dict[str, Any]) -> bool:
|
||||
staged_path = retry_payload.get("staged_path")
|
||||
normalized_staged_path = normalize_optional_text(staged_path)
|
||||
if normalized_staged_path is None:
|
||||
return False
|
||||
return Path(normalized_staged_path).exists()
|
||||
|
||||
@staticmethod
|
||||
def _can_retry_without_staged_source(retry_payload: dict[str, Any]) -> bool:
|
||||
return bool(retry_payload.get("can_retry_without_staged_source", True))
|
||||
|
||||
@staticmethod
|
||||
def is_retry_available(row: dict[str, Any]) -> bool:
|
||||
"""Return whether a persisted download row can be retried."""
|
||||
final_status = (
|
||||
str(row.get("retry_final_status") or row.get("final_status") or "").strip().lower()
|
||||
)
|
||||
retry_payload = DownloadHistoryService._deserialize_retry_payload(row.get("retry_payload"))
|
||||
if retry_payload is None:
|
||||
return False
|
||||
|
||||
has_staged_retry_source = DownloadHistoryService._has_staged_retry_source(retry_payload)
|
||||
can_retry_without_staged_source = DownloadHistoryService._can_retry_without_staged_source(
|
||||
retry_payload
|
||||
)
|
||||
request_id = normalize_optional_positive_int(row.get("request_id"), "request_id")
|
||||
if request_id is None:
|
||||
if final_status in {ACTIVE_DOWNLOAD_STATUS, "cancelled"}:
|
||||
return can_retry_without_staged_source
|
||||
if final_status == "error":
|
||||
return has_staged_retry_source or can_retry_without_staged_source
|
||||
return False
|
||||
|
||||
if final_status in {ACTIVE_DOWNLOAD_STATUS, "cancelled"}:
|
||||
return can_retry_without_staged_source
|
||||
|
||||
if final_status != "error":
|
||||
return False
|
||||
|
||||
return has_staged_retry_source
|
||||
|
||||
@staticmethod
|
||||
def to_download_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the sidebar/history download payload for a persisted row."""
|
||||
return {
|
||||
"id": row.get("task_id"),
|
||||
"title": row.get("title"),
|
||||
"author": row.get("author"),
|
||||
"format": row.get("format"),
|
||||
"size": row.get("size"),
|
||||
"preview": row.get("preview"),
|
||||
"content_type": row.get("content_type"),
|
||||
"source": row.get("source"),
|
||||
"source_display_name": row.get("source_display_name"),
|
||||
"status_message": row.get("status_message"),
|
||||
"download_path": DownloadHistoryService._resolve_existing_download_path(
|
||||
row.get("download_path")
|
||||
),
|
||||
"added_time": DownloadHistoryService._iso_to_epoch(row.get("queued_at")),
|
||||
"user_id": row.get("user_id"),
|
||||
"username": row.get("username"),
|
||||
"request_id": row.get("request_id"),
|
||||
"retry_available": DownloadHistoryService.is_retry_available(row),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _iso_to_epoch(value: object) -> float | None:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
normalized = value.strip().replace("Z", "+00:00")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.timestamp()
|
||||
|
||||
@classmethod
|
||||
def to_history_row(cls, row: dict[str, Any], *, dismissed_at: str) -> dict[str, Any]:
|
||||
"""Build the activity-history payload for a persisted download row."""
|
||||
task_id = str(row.get("task_id") or "").strip()
|
||||
item_key = cls._to_item_key(task_id)
|
||||
download_payload = cls.to_download_payload(row)
|
||||
# Clear stale progress messages for non-error terminal states.
|
||||
if row.get("final_status") in ("complete", "cancelled"):
|
||||
download_payload["status_message"] = None
|
||||
return {
|
||||
"id": item_key,
|
||||
"user_id": row.get("user_id"),
|
||||
"item_type": "download",
|
||||
"item_key": item_key,
|
||||
"dismissed_at": dismissed_at,
|
||||
"snapshot": {
|
||||
"kind": "download",
|
||||
"download": download_payload,
|
||||
},
|
||||
"origin": row.get("origin"),
|
||||
"final_status": row.get("final_status"),
|
||||
"terminal_at": row.get("terminal_at"),
|
||||
"request_id": row.get("request_id"),
|
||||
"source_id": task_id or None,
|
||||
}
|
||||
|
||||
def record_download(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
user_id: int | None,
|
||||
username: str | None,
|
||||
request_id: int | None,
|
||||
source: str,
|
||||
source_display_name: str | None,
|
||||
title: str,
|
||||
author: str | None,
|
||||
file_format: str | None,
|
||||
size: str | None,
|
||||
preview: str | None,
|
||||
content_type: str | None,
|
||||
origin: str,
|
||||
retry_payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Record a download at queue time with final_status='active'.
|
||||
|
||||
On first queue: inserts a new row.
|
||||
On retry (row already exists): resets the row back to 'active'
|
||||
so the normal finalize path works when the retry completes.
|
||||
"""
|
||||
normalized_task_id = _normalize_task_id(task_id)
|
||||
normalized_user_id = normalize_optional_positive_int(user_id, "user_id")
|
||||
normalized_request_id = normalize_optional_positive_int(request_id, "request_id")
|
||||
normalized_source = normalize_optional_text(source)
|
||||
if normalized_source is None:
|
||||
msg = "source must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
normalized_title = normalize_optional_text(title)
|
||||
if normalized_title is None:
|
||||
msg = "title must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
normalized_origin = _normalize_origin(origin)
|
||||
normalized_retry_payload = self._serialize_retry_payload(retry_payload)
|
||||
recorded_at = now_utc_iso()
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_history (
|
||||
task_id, user_id, username, request_id,
|
||||
source, source_display_name,
|
||||
title, author, format, size, preview, content_type,
|
||||
origin, final_status,
|
||||
status_message, download_path, retry_payload,
|
||||
queued_at, terminal_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, ?, ?)
|
||||
ON CONFLICT(task_id) DO UPDATE SET
|
||||
final_status = 'active',
|
||||
status_message = NULL,
|
||||
download_path = NULL,
|
||||
retry_payload = excluded.retry_payload,
|
||||
terminal_at = ?
|
||||
""",
|
||||
(
|
||||
normalized_task_id,
|
||||
normalized_user_id,
|
||||
normalize_optional_text(username),
|
||||
normalized_request_id,
|
||||
normalized_source,
|
||||
normalize_optional_text(source_display_name),
|
||||
normalized_title,
|
||||
normalize_optional_text(author),
|
||||
normalize_optional_text(file_format),
|
||||
normalize_optional_text(size),
|
||||
normalize_optional_text(preview),
|
||||
normalize_optional_text(content_type),
|
||||
normalized_origin,
|
||||
normalized_retry_payload,
|
||||
recorded_at,
|
||||
recorded_at,
|
||||
recorded_at,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def finalize_download(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
final_status: str,
|
||||
status_message: str | None = None,
|
||||
download_path: str | None = None,
|
||||
retry_payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Update an existing download row to its terminal state."""
|
||||
normalized_task_id = _normalize_task_id(task_id)
|
||||
normalized_final_status = _normalize_final_status(final_status)
|
||||
normalized_status_message = normalize_optional_text(status_message)
|
||||
normalized_download_path = normalize_optional_text(download_path)
|
||||
normalized_retry_payload = self._serialize_retry_payload(retry_payload)
|
||||
effective_terminal_at = now_utc_iso()
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE download_history
|
||||
SET final_status = ?,
|
||||
status_message = ?,
|
||||
download_path = ?,
|
||||
retry_payload = COALESCE(?, retry_payload),
|
||||
terminal_at = ?
|
||||
WHERE task_id = ? AND final_status = 'active'
|
||||
""",
|
||||
(
|
||||
normalized_final_status,
|
||||
normalized_status_message,
|
||||
normalized_download_path,
|
||||
normalized_retry_payload,
|
||||
effective_terminal_at,
|
||||
normalized_task_id,
|
||||
),
|
||||
)
|
||||
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
|
||||
if rowcount < 1:
|
||||
logger.warning(
|
||||
"finalize_download: no active row found for task_id=%s (may have been missed at queue time)",
|
||||
normalized_task_id,
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_by_task_id(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""Return a persisted download row for the given task id."""
|
||||
normalized_task_id = _normalize_task_id(task_id)
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_history WHERE task_id = ?",
|
||||
(normalized_task_id,),
|
||||
).fetchone()
|
||||
return self._row_to_dict(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_recent(
|
||||
self,
|
||||
*,
|
||||
user_id: int | None,
|
||||
limit: int = 200,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return recent persisted download rows, optionally scoped to one user."""
|
||||
normalized_user_id = normalize_optional_positive_int(user_id, "user_id")
|
||||
normalized_limit = _normalize_limit(limit, default=200, minimum=1, maximum=1000)
|
||||
query = "SELECT * FROM download_history"
|
||||
params: list[Any] = []
|
||||
if normalized_user_id is not None:
|
||||
query += " WHERE user_id = ?"
|
||||
params.append(normalized_user_id)
|
||||
query += " ORDER BY terminal_at DESC, id DESC LIMIT ?"
|
||||
params.append(normalized_limit)
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
result: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
normalized = self._normalize_row_dict(dict(row))
|
||||
if normalized is not None:
|
||||
result.append(normalized)
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Shared external identity matching and provisioning helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from shelfmark.core.auth_modes import normalize_auth_source
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
UNSET = object()
|
||||
|
||||
CollisionStrategy = Literal["takeover", "suffix", "alias"]
|
||||
MatchReason = Literal[
|
||||
"subject_match",
|
||||
"existing_source_username_match",
|
||||
"unique_email_match",
|
||||
]
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _normalize_username(value: object) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _normalize_email(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
email = str(value).strip()
|
||||
return email or None
|
||||
|
||||
|
||||
def _normalize_display_name(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
name = str(value).strip()
|
||||
return name or None
|
||||
|
||||
|
||||
def _email_key(value: str | None) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _normalize_role(value: object) -> str:
|
||||
return "admin" if str(value or "").strip().lower() == "admin" else "user"
|
||||
|
||||
|
||||
def _get_by_subject(
|
||||
user_db: UserDB, subject_field: str | None, subject: str | None
|
||||
) -> dict[str, Any] | None:
|
||||
if not subject_field or not subject:
|
||||
return None
|
||||
if subject_field == "oidc_subject":
|
||||
return user_db.get_user(oidc_subject=subject)
|
||||
return None
|
||||
|
||||
|
||||
def find_unique_user_by_email(user_db: UserDB, email: str | None) -> dict[str, Any] | None:
|
||||
"""Return the unique local user matching an email address, if any."""
|
||||
key = _email_key(_normalize_email(email))
|
||||
if not key:
|
||||
return None
|
||||
|
||||
matches = [u for u in user_db.list_users() if _email_key(u.get("email")) == key]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def find_external_user_match(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
auth_source: str,
|
||||
username: str,
|
||||
email: str | None,
|
||||
subject_field: str | None = None,
|
||||
subject: str | None = None,
|
||||
allow_email_link: bool = False,
|
||||
) -> tuple[dict[str, Any] | None, MatchReason | None]:
|
||||
"""Find an existing local user that should be linked to an external identity."""
|
||||
normalized_username = _normalize_username(username)
|
||||
normalized_email = _normalize_email(email)
|
||||
|
||||
by_subject = _get_by_subject(user_db, subject_field, subject)
|
||||
if by_subject is not None:
|
||||
return by_subject, "subject_match"
|
||||
|
||||
by_username = user_db.get_user(username=normalized_username)
|
||||
if (
|
||||
by_username
|
||||
and normalize_auth_source(
|
||||
by_username.get("auth_source"),
|
||||
by_username.get("oidc_subject"),
|
||||
)
|
||||
== auth_source
|
||||
):
|
||||
return by_username, "existing_source_username_match"
|
||||
|
||||
if allow_email_link:
|
||||
return find_unique_user_by_email(user_db, normalized_email), "unique_email_match"
|
||||
return None, None
|
||||
|
||||
|
||||
def _build_updates(
|
||||
*,
|
||||
auth_source: str,
|
||||
role: str,
|
||||
sync_role: bool,
|
||||
email: str | None | object,
|
||||
display_name: str | None | object,
|
||||
subject_field: str | None,
|
||||
subject: str | None,
|
||||
) -> dict[str, Any]:
|
||||
updates: dict[str, Any] = {"auth_source": auth_source}
|
||||
if sync_role:
|
||||
updates["role"] = _normalize_role(role)
|
||||
if email is not UNSET:
|
||||
updates["email"] = _normalize_email(email)
|
||||
if display_name is not UNSET:
|
||||
updates["display_name"] = _normalize_display_name(display_name)
|
||||
if subject_field == "oidc_subject" and subject:
|
||||
updates["oidc_subject"] = subject
|
||||
return updates
|
||||
|
||||
|
||||
def _next_suffix_username(user_db: UserDB, base_username: str) -> str:
|
||||
candidate = base_username
|
||||
suffix = 1
|
||||
while user_db.get_user(username=candidate):
|
||||
candidate = f"{base_username}_{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _find_existing_alias_user(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
auth_source: str,
|
||||
alias_base: str,
|
||||
) -> dict[str, Any] | None:
|
||||
pattern = re.compile(rf"^{re.escape(alias_base)}(?:_\d+)?$")
|
||||
candidates = [
|
||||
user
|
||||
for user in user_db.list_users()
|
||||
if pattern.match(str(user.get("username") or ""))
|
||||
and normalize_auth_source(user.get("auth_source"), user.get("oidc_subject")) == auth_source
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return sorted(candidates, key=lambda user: int(user.get("id") or 0))[0]
|
||||
|
||||
|
||||
def _resolve_create_username(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
auth_source: str,
|
||||
requested_username: str,
|
||||
strategy: CollisionStrategy,
|
||||
alias_suffix: str,
|
||||
) -> tuple[str | None, dict[str, Any] | None, str]:
|
||||
existing = user_db.get_user(username=requested_username)
|
||||
if not existing:
|
||||
return requested_username, None, "new_username_available"
|
||||
|
||||
if strategy == "takeover":
|
||||
return None, existing, "username_collision_takeover"
|
||||
|
||||
if strategy == "suffix":
|
||||
return (
|
||||
_next_suffix_username(user_db, requested_username),
|
||||
None,
|
||||
"username_collision_suffix",
|
||||
)
|
||||
|
||||
alias_base = f"{requested_username}{alias_suffix}"
|
||||
alias_existing = _find_existing_alias_user(
|
||||
user_db,
|
||||
auth_source=auth_source,
|
||||
alias_base=alias_base,
|
||||
)
|
||||
if alias_existing is not None:
|
||||
return None, alias_existing, "reuse_existing_alias"
|
||||
return _next_suffix_username(user_db, alias_base), None, "username_collision_alias"
|
||||
|
||||
|
||||
def upsert_external_user(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
auth_source: str,
|
||||
username: str,
|
||||
role: str,
|
||||
email: str | None | object = UNSET,
|
||||
display_name: str | None | object = UNSET,
|
||||
subject_field: str | None = None,
|
||||
subject: str | None = None,
|
||||
allow_email_link: bool = False,
|
||||
sync_role: bool = True,
|
||||
allow_create: bool = True,
|
||||
collision_strategy: CollisionStrategy = "takeover",
|
||||
alias_suffix: str | None = None,
|
||||
context: str | None = None,
|
||||
) -> tuple[dict[str, Any] | None, str]:
|
||||
"""Create/update a user from an external auth identity.
|
||||
|
||||
Returns `(user, action)` where action is one of:
|
||||
- `"updated"`
|
||||
- `"created"`
|
||||
- `"not_found"` (when `allow_create=False` and no link target exists)
|
||||
"""
|
||||
normalized_username = _normalize_username(username)
|
||||
if not normalized_username:
|
||||
msg = "External username is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
normalized_email = _normalize_email(email) if email is not UNSET else None
|
||||
normalized_display_name = (
|
||||
_normalize_display_name(display_name) if display_name is not UNSET else None
|
||||
)
|
||||
normalized_role = _normalize_role(role)
|
||||
|
||||
matched, match_reason = find_external_user_match(
|
||||
user_db,
|
||||
auth_source=auth_source,
|
||||
username=normalized_username,
|
||||
email=normalized_email,
|
||||
subject_field=subject_field,
|
||||
subject=subject,
|
||||
allow_email_link=allow_email_link,
|
||||
)
|
||||
updates = _build_updates(
|
||||
auth_source=auth_source,
|
||||
role=normalized_role,
|
||||
sync_role=sync_role,
|
||||
email=normalized_email if email is not UNSET else UNSET,
|
||||
display_name=normalized_display_name if display_name is not UNSET else UNSET,
|
||||
subject_field=subject_field,
|
||||
subject=subject,
|
||||
)
|
||||
if matched is not None:
|
||||
user_db.update_user(matched["id"], **updates)
|
||||
mapped = user_db.get_user(user_id=matched["id"]) or matched
|
||||
logger.info(
|
||||
"External user mapped to existing Shelfmark user (source=%s, context=%s, reason=%s, external_username=%s, shelfmark_user_id=%s, shelfmark_username=%s)",
|
||||
auth_source,
|
||||
context or "unspecified",
|
||||
match_reason,
|
||||
normalized_username,
|
||||
mapped["id"],
|
||||
mapped["username"],
|
||||
)
|
||||
return mapped, "updated"
|
||||
|
||||
if not allow_create:
|
||||
logger.info(
|
||||
"External user could not be mapped and creation is disabled (source=%s, context=%s, external_username=%s)",
|
||||
auth_source,
|
||||
context or "unspecified",
|
||||
normalized_username,
|
||||
)
|
||||
return None, "not_found"
|
||||
|
||||
resolved_alias_suffix = alias_suffix or f"__{auth_source}"
|
||||
create_username, takeover_target, create_reason = _resolve_create_username(
|
||||
user_db,
|
||||
auth_source=auth_source,
|
||||
requested_username=normalized_username,
|
||||
strategy=collision_strategy,
|
||||
alias_suffix=resolved_alias_suffix,
|
||||
)
|
||||
if takeover_target is not None:
|
||||
user_db.update_user(takeover_target["id"], **updates)
|
||||
mapped = user_db.get_user(user_id=takeover_target["id"]) or takeover_target
|
||||
logger.info(
|
||||
"External user mapped to existing Shelfmark user (source=%s, context=%s, reason=%s, external_username=%s, shelfmark_user_id=%s, shelfmark_username=%s)",
|
||||
auth_source,
|
||||
context or "unspecified",
|
||||
create_reason,
|
||||
normalized_username,
|
||||
mapped["id"],
|
||||
mapped["username"],
|
||||
)
|
||||
return mapped, "updated"
|
||||
|
||||
create_kwargs: dict[str, Any] = {
|
||||
"username": create_username,
|
||||
"auth_source": auth_source,
|
||||
"role": normalized_role,
|
||||
}
|
||||
if email is not UNSET:
|
||||
create_kwargs["email"] = normalized_email
|
||||
if display_name is not UNSET:
|
||||
create_kwargs["display_name"] = normalized_display_name
|
||||
if subject_field == "oidc_subject" and subject:
|
||||
create_kwargs["oidc_subject"] = subject
|
||||
|
||||
created = user_db.create_user(**create_kwargs)
|
||||
logger.info(
|
||||
"External user created Shelfmark user (source=%s, context=%s, reason=%s, external_username=%s, shelfmark_user_id=%s, shelfmark_username=%s)",
|
||||
auth_source,
|
||||
context or "unspecified",
|
||||
create_reason,
|
||||
normalized_username,
|
||||
created["id"],
|
||||
created["username"],
|
||||
)
|
||||
return created, "created"
|
||||
@@ -1,33 +1,40 @@
|
||||
"""Disk-based image cache with LRU eviction."""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import coerce_int
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Image type detection via magic bytes
|
||||
IMAGE_SIGNATURES = {
|
||||
b'\xff\xd8\xff': ('image/jpeg', 'jpg'),
|
||||
b'\x89PNG\r\n\x1a\n': ('image/png', 'png'),
|
||||
b'GIF87a': ('image/gif', 'gif'),
|
||||
b'GIF89a': ('image/gif', 'gif'),
|
||||
b'RIFF': ('image/webp', 'webp'), # WebP starts with RIFF
|
||||
b"\xff\xd8\xff": ("image/jpeg", "jpg"),
|
||||
b"\x89PNG\r\n\x1a\n": ("image/png", "png"),
|
||||
b"GIF87a": ("image/gif", "gif"),
|
||||
b"GIF89a": ("image/gif", "gif"),
|
||||
b"RIFF": ("image/webp", "webp"), # WebP starts with RIFF
|
||||
}
|
||||
|
||||
# HTTP headers for image fetching
|
||||
FETCH_HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/129.0.0.0 Safari/537.36',
|
||||
'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/129.0.0.0 Safari/537.36",
|
||||
"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
}
|
||||
|
||||
# Maximum image size to fetch (5 MB)
|
||||
@@ -40,8 +47,11 @@ NEGATIVE_CACHE_TTL = 3600
|
||||
# Short enough to retry soon, long enough to prevent spam during one page view
|
||||
TRANSIENT_CACHE_TTL = 60
|
||||
|
||||
_MIN_WEBP_HEADER_LENGTH = 12
|
||||
HTTP_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
|
||||
def _detect_image_type(data: bytes) -> Optional[Tuple[str, str]]:
|
||||
|
||||
def _detect_image_type(data: bytes) -> tuple[str, str] | None:
|
||||
"""Detect image type from magic bytes.
|
||||
|
||||
Args:
|
||||
@@ -49,14 +59,15 @@ def _detect_image_type(data: bytes) -> Optional[Tuple[str, str]]:
|
||||
|
||||
Returns:
|
||||
Tuple of (content_type, extension) or None if not recognized
|
||||
|
||||
"""
|
||||
for signature, (content_type, ext) in IMAGE_SIGNATURES.items():
|
||||
if data.startswith(signature):
|
||||
return content_type, ext
|
||||
|
||||
# Special case for WebP - check for WEBP after RIFF
|
||||
if data.startswith(b'RIFF') and len(data) > 12 and data[8:12] == b'WEBP':
|
||||
return 'image/webp', 'webp'
|
||||
if data.startswith(b"RIFF") and len(data) > _MIN_WEBP_HEADER_LENGTH and data[8:12] == b"WEBP":
|
||||
return "image/webp", "webp"
|
||||
|
||||
return None
|
||||
|
||||
@@ -64,20 +75,21 @@ def _detect_image_type(data: bytes) -> Optional[Tuple[str, str]]:
|
||||
class ImageCacheService:
|
||||
"""Persistent image cache with LRU eviction and TTL support."""
|
||||
|
||||
def __init__(self, cache_dir: Path, max_size_mb: int = 500, ttl_seconds: int = 0):
|
||||
def __init__(self, cache_dir: Path, max_size_mb: int = 500, ttl_seconds: int = 0) -> None:
|
||||
"""Initialize the image cache.
|
||||
|
||||
Args:
|
||||
cache_dir: Directory to store cached images
|
||||
max_size_mb: Maximum cache size in megabytes
|
||||
ttl_seconds: Time-to-live in seconds (0 = forever)
|
||||
|
||||
"""
|
||||
self.cache_dir = cache_dir
|
||||
self.max_size_bytes = max_size_mb * 1024 * 1024
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.index_path = cache_dir / "cache_index.json"
|
||||
self._lock = threading.RLock()
|
||||
self._index: Dict[str, Dict[str, Any]] = {}
|
||||
self._index: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Stats tracking
|
||||
self._hits = 0
|
||||
@@ -97,9 +109,9 @@ class ImageCacheService:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.index_path, 'r') as f:
|
||||
with self.index_path.open() as f:
|
||||
self._index = json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
except OSError, json.JSONDecodeError:
|
||||
self._index = {}
|
||||
|
||||
def _sync_index_with_files(self) -> None:
|
||||
@@ -109,12 +121,12 @@ class ImageCacheService:
|
||||
- Removes entries for files that no longer exist (non-negative only)
|
||||
- Preserves negative cache entries (they have no files)
|
||||
"""
|
||||
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
|
||||
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
added_count = 0
|
||||
removed_count = 0
|
||||
|
||||
# Build set of files that exist on disk
|
||||
existing_files: Dict[str, Path] = {}
|
||||
existing_files: dict[str, Path] = {}
|
||||
for file_path in self.cache_dir.iterdir():
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
@@ -127,31 +139,31 @@ class ImageCacheService:
|
||||
if cache_id in self._index:
|
||||
continue
|
||||
|
||||
ext = file_path.suffix.lstrip('.')
|
||||
ext = file_path.suffix.lstrip(".")
|
||||
stat = file_path.stat()
|
||||
|
||||
# Detect content type
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
with file_path.open("rb") as f:
|
||||
header = f.read(16)
|
||||
detected = _detect_image_type(header)
|
||||
content_type = detected[0] if detected else f'image/{ext}'
|
||||
except IOError:
|
||||
content_type = f'image/{ext}'
|
||||
content_type = detected[0] if detected else f"image/{ext}"
|
||||
except OSError:
|
||||
content_type = f"image/{ext}"
|
||||
|
||||
self._index[cache_id] = {
|
||||
'ext': ext,
|
||||
'content_type': content_type,
|
||||
'size': stat.st_size,
|
||||
'cached_at': stat.st_mtime,
|
||||
'accessed_at': stat.st_mtime,
|
||||
"ext": ext,
|
||||
"content_type": content_type,
|
||||
"size": stat.st_size,
|
||||
"cached_at": stat.st_mtime,
|
||||
"accessed_at": stat.st_mtime,
|
||||
}
|
||||
added_count += 1
|
||||
|
||||
# Remove index entries for missing files (skip negative cache entries)
|
||||
stale_entries = []
|
||||
for cache_id, entry in self._index.items():
|
||||
if entry.get('negative', False):
|
||||
if entry.get("negative", False):
|
||||
continue # Negative entries don't have files
|
||||
if cache_id not in existing_files:
|
||||
stale_entries.append(cache_id)
|
||||
@@ -167,39 +179,39 @@ class ImageCacheService:
|
||||
"""Save cache index to disk."""
|
||||
try:
|
||||
# Write to temp file first, then rename for atomicity
|
||||
temp_path = self.index_path.with_suffix('.tmp')
|
||||
with open(temp_path, 'w') as f:
|
||||
temp_path = self.index_path.with_suffix(".tmp")
|
||||
with temp_path.open("w") as f:
|
||||
json.dump(self._index, f)
|
||||
temp_path.rename(self.index_path)
|
||||
except IOError:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _get_image_path(self, cache_id: str, ext: str) -> Path:
|
||||
"""Get the file path for a cached image."""
|
||||
return self.cache_dir / f"{cache_id}.{ext}"
|
||||
|
||||
def _is_expired(self, entry: Dict[str, Any]) -> bool:
|
||||
def _is_expired(self, entry: dict[str, Any]) -> bool:
|
||||
"""Check if a cache entry is expired."""
|
||||
if self.ttl_seconds == 0:
|
||||
return False
|
||||
return (time.time() - entry.get('cached_at', 0)) > self.ttl_seconds
|
||||
return (time.time() - entry.get("cached_at", 0)) > self.ttl_seconds
|
||||
|
||||
def _is_negative_expired(self, entry: Dict[str, Any]) -> bool:
|
||||
def _is_negative_expired(self, entry: dict[str, Any]) -> bool:
|
||||
"""Check if a negative cache entry is expired.
|
||||
|
||||
Transient failures (timeouts) expire after TRANSIENT_CACHE_TTL (60s).
|
||||
Permanent failures (404s) expire after NEGATIVE_CACHE_TTL (1 hour).
|
||||
"""
|
||||
if not entry.get('negative', False):
|
||||
if not entry.get("negative", False):
|
||||
return False
|
||||
|
||||
cached_at = entry.get('cached_at', 0)
|
||||
ttl = TRANSIENT_CACHE_TTL if entry.get('transient', False) else NEGATIVE_CACHE_TTL
|
||||
cached_at = entry.get("cached_at", 0)
|
||||
ttl = TRANSIENT_CACHE_TTL if entry.get("transient", False) else NEGATIVE_CACHE_TTL
|
||||
return (time.time() - cached_at) > ttl
|
||||
|
||||
def _calculate_total_size(self) -> int:
|
||||
"""Calculate total size of cached images."""
|
||||
return sum(entry.get('size', 0) for entry in self._index.values())
|
||||
return sum(entry.get("size", 0) for entry in self._index.values())
|
||||
|
||||
def _evict_if_needed(self, required_space: int = 0) -> None:
|
||||
"""Evict old entries if cache is over size limit.
|
||||
@@ -213,10 +225,7 @@ class ImageCacheService:
|
||||
return
|
||||
|
||||
# Sort entries by accessed_at (oldest first)
|
||||
sorted_entries = sorted(
|
||||
self._index.items(),
|
||||
key=lambda x: x[1].get('accessed_at', 0)
|
||||
)
|
||||
sorted_entries = sorted(self._index.items(), key=lambda x: x[1].get("accessed_at", 0))
|
||||
|
||||
evicted_count = 0
|
||||
for cache_id, entry in sorted_entries:
|
||||
@@ -224,23 +233,23 @@ class ImageCacheService:
|
||||
break
|
||||
|
||||
# Delete the image file
|
||||
ext = entry.get('ext', 'jpg')
|
||||
ext = entry.get("ext", "jpg")
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
except IOError:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Update tracking
|
||||
current_size -= entry.get('size', 0)
|
||||
current_size -= entry.get("size", 0)
|
||||
del self._index[cache_id]
|
||||
evicted_count += 1
|
||||
|
||||
if evicted_count > 0:
|
||||
self._save_index()
|
||||
|
||||
def get(self, cache_id: str) -> Optional[Tuple[bytes, str]]:
|
||||
def get(self, cache_id: str) -> tuple[bytes, str] | None:
|
||||
"""Get a cached image.
|
||||
|
||||
Args:
|
||||
@@ -248,6 +257,7 @@ class ImageCacheService:
|
||||
|
||||
Returns:
|
||||
Tuple of (image_data, content_type) or None if not cached/expired
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self._index.get(cache_id)
|
||||
@@ -261,7 +271,7 @@ class ImageCacheService:
|
||||
return None
|
||||
|
||||
# Check for negative cache (failed fetch)
|
||||
if entry.get('negative', False):
|
||||
if entry.get("negative", False):
|
||||
if self._is_negative_expired(entry):
|
||||
# Negative cache expired, allow retry
|
||||
del self._index[cache_id]
|
||||
@@ -274,12 +284,12 @@ class ImageCacheService:
|
||||
# Check for expired entry
|
||||
if self._is_expired(entry):
|
||||
# Remove expired entry
|
||||
ext = entry.get('ext', 'jpg')
|
||||
ext = entry.get("ext", "jpg")
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
except IOError:
|
||||
except OSError:
|
||||
pass
|
||||
del self._index[cache_id]
|
||||
self._save_index()
|
||||
@@ -287,9 +297,10 @@ class ImageCacheService:
|
||||
return None
|
||||
|
||||
# Try to read the cached image
|
||||
ext = entry.get('ext', 'jpg')
|
||||
content_type = entry.get('content_type', 'image/jpeg')
|
||||
ext = entry.get("ext", "jpg")
|
||||
content_type = entry.get("content_type", "image/jpeg")
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
result: tuple[bytes, str] | None = None
|
||||
|
||||
try:
|
||||
if not image_path.exists():
|
||||
@@ -299,19 +310,20 @@ class ImageCacheService:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
with open(image_path, 'rb') as f:
|
||||
with image_path.open("rb") as f:
|
||||
data = f.read()
|
||||
|
||||
# Update accessed time
|
||||
entry['accessed_at'] = time.time()
|
||||
entry["accessed_at"] = time.time()
|
||||
self._save_index()
|
||||
result = data, content_type
|
||||
|
||||
self._hits += 1
|
||||
return data, content_type
|
||||
|
||||
except IOError:
|
||||
except OSError:
|
||||
self._misses += 1
|
||||
return None
|
||||
else:
|
||||
self._hits += 1
|
||||
return result
|
||||
|
||||
def put(self, cache_id: str, data: bytes, content_type: str) -> bool:
|
||||
"""Store an image in the cache.
|
||||
@@ -323,24 +335,24 @@ class ImageCacheService:
|
||||
|
||||
Returns:
|
||||
True if stored successfully
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
# Detect image type for extension
|
||||
detected = _detect_image_type(data)
|
||||
if detected:
|
||||
content_type, ext = detected
|
||||
# Fall back to content-type header
|
||||
elif "jpeg" in content_type or "jpg" in content_type:
|
||||
ext = "jpg"
|
||||
elif "png" in content_type:
|
||||
ext = "png"
|
||||
elif "gif" in content_type:
|
||||
ext = "gif"
|
||||
elif "webp" in content_type:
|
||||
ext = "webp"
|
||||
else:
|
||||
# Fall back to content-type header
|
||||
if 'jpeg' in content_type or 'jpg' in content_type:
|
||||
ext = 'jpg'
|
||||
elif 'png' in content_type:
|
||||
ext = 'png'
|
||||
elif 'gif' in content_type:
|
||||
ext = 'gif'
|
||||
elif 'webp' in content_type:
|
||||
ext = 'webp'
|
||||
else:
|
||||
ext = 'jpg' # Default
|
||||
ext = "jpg" # Default
|
||||
|
||||
image_size = len(data)
|
||||
|
||||
@@ -350,36 +362,37 @@ class ImageCacheService:
|
||||
# Write image to disk
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
with open(image_path, 'wb') as f:
|
||||
with image_path.open("wb") as f:
|
||||
f.write(data)
|
||||
except IOError:
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# Update index
|
||||
now = time.time()
|
||||
self._index[cache_id] = {
|
||||
'ext': ext,
|
||||
'content_type': content_type,
|
||||
'size': image_size,
|
||||
'cached_at': now,
|
||||
'accessed_at': now,
|
||||
'negative': False,
|
||||
"ext": ext,
|
||||
"content_type": content_type,
|
||||
"size": image_size,
|
||||
"cached_at": now,
|
||||
"accessed_at": now,
|
||||
"negative": False,
|
||||
}
|
||||
self._save_index()
|
||||
return True
|
||||
|
||||
def put_negative(self, cache_id: str, transient: bool = False) -> None:
|
||||
def put_negative(self, cache_id: str, *, transient: bool = False) -> None:
|
||||
"""Store a negative cache entry (failed fetch).
|
||||
|
||||
Args:
|
||||
cache_id: Cache key
|
||||
transient: If True, uses shorter TTL (for timeouts/connection errors)
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
self._index[cache_id] = {
|
||||
'negative': True,
|
||||
'transient': transient,
|
||||
'cached_at': time.time(),
|
||||
"negative": True,
|
||||
"transient": transient,
|
||||
"cached_at": time.time(),
|
||||
}
|
||||
self._save_index()
|
||||
|
||||
@@ -391,6 +404,7 @@ class ImageCacheService:
|
||||
|
||||
Returns:
|
||||
True if entry existed and was deleted
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self._index.get(cache_id)
|
||||
@@ -398,13 +412,13 @@ class ImageCacheService:
|
||||
return False
|
||||
|
||||
# Delete file if it exists
|
||||
if not entry.get('negative', False):
|
||||
ext = entry.get('ext', 'jpg')
|
||||
if not entry.get("negative", False):
|
||||
ext = entry.get("ext", "jpg")
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
except IOError:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
del self._index[cache_id]
|
||||
@@ -416,19 +430,20 @@ class ImageCacheService:
|
||||
|
||||
Returns:
|
||||
Number of entries cleared
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
count = len(self._index)
|
||||
|
||||
# Delete all image files
|
||||
for cache_id, entry in self._index.items():
|
||||
if not entry.get('negative', False):
|
||||
ext = entry.get('ext', 'jpg')
|
||||
if not entry.get("negative", False):
|
||||
ext = entry.get("ext", "jpg")
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
except IOError:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Clear index
|
||||
@@ -441,31 +456,57 @@ class ImageCacheService:
|
||||
|
||||
return count
|
||||
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""Get cache statistics.
|
||||
|
||||
Returns:
|
||||
Dict with size, count, hit rate, etc.
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
total_size = self._calculate_total_size()
|
||||
entry_count = len(self._index)
|
||||
negative_count = sum(1 for e in self._index.values() if e.get('negative', False))
|
||||
negative_count = sum(1 for e in self._index.values() if e.get("negative", False))
|
||||
total_requests = self._hits + self._misses
|
||||
hit_rate = (self._hits / total_requests * 100) if total_requests > 0 else 0
|
||||
|
||||
return {
|
||||
'entry_count': entry_count,
|
||||
'negative_count': negative_count,
|
||||
'total_size_bytes': total_size,
|
||||
'total_size_mb': round(total_size / (1024 * 1024), 2),
|
||||
'max_size_mb': self.max_size_bytes / (1024 * 1024),
|
||||
'hits': self._hits,
|
||||
'misses': self._misses,
|
||||
'hit_rate': round(hit_rate, 1),
|
||||
"entry_count": entry_count,
|
||||
"negative_count": negative_count,
|
||||
"total_size_bytes": total_size,
|
||||
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||
"max_size_mb": self.max_size_bytes / (1024 * 1024),
|
||||
"hits": self._hits,
|
||||
"misses": self._misses,
|
||||
"hit_rate": round(hit_rate, 1),
|
||||
}
|
||||
|
||||
def fetch_and_cache(self, cache_id: str, url: str) -> Optional[Tuple[bytes, str]]:
|
||||
@staticmethod
|
||||
def _is_safe_url(url: str) -> bool:
|
||||
"""Check that a URL is safe to fetch (no SSRF to internal resources)."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
if not hostname:
|
||||
return False
|
||||
|
||||
try:
|
||||
resolved = socket.getaddrinfo(hostname, None)
|
||||
for _, _, _, _, sockaddr in resolved:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return False
|
||||
except socket.gaierror, ValueError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def fetch_and_cache(self, cache_id: str, url: str) -> tuple[bytes, str] | None:
|
||||
"""Fetch an image from URL and cache it.
|
||||
|
||||
Args:
|
||||
@@ -474,20 +515,26 @@ class ImageCacheService:
|
||||
|
||||
Returns:
|
||||
Tuple of (image_data, content_type) or None on failure
|
||||
|
||||
"""
|
||||
cached_data: tuple[bytes, str] | None = None
|
||||
try:
|
||||
if not self._is_safe_url(url):
|
||||
logger.warning("Blocked request to disallowed URL: %s", url)
|
||||
return None
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
timeout=(5, 10),
|
||||
headers=FETCH_HEADERS,
|
||||
stream=True,
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Validate content type
|
||||
content_type = response.headers.get('content-type', '')
|
||||
if not content_type.startswith('image/'):
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if not content_type.startswith("image/"):
|
||||
self.put_negative(cache_id)
|
||||
return None
|
||||
|
||||
@@ -511,9 +558,7 @@ class ImageCacheService:
|
||||
detected = _detect_image_type(image_data)
|
||||
if detected:
|
||||
content_type = detected[0]
|
||||
return image_data, content_type
|
||||
|
||||
return None
|
||||
cached_data = image_data, content_type
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
self.put_negative(cache_id, transient=True)
|
||||
@@ -522,15 +567,17 @@ class ImageCacheService:
|
||||
self.put_negative(cache_id, transient=True)
|
||||
return None
|
||||
except requests.exceptions.HTTPError as e:
|
||||
is_404 = e.response is not None and e.response.status_code == 404
|
||||
is_404 = e.response is not None and e.response.status_code == HTTP_NOT_FOUND
|
||||
self.put_negative(cache_id, transient=not is_404)
|
||||
return None
|
||||
except Exception:
|
||||
except requests.exceptions.RequestException:
|
||||
return None
|
||||
else:
|
||||
return cached_data
|
||||
|
||||
|
||||
# Singleton instance (initialized lazily when config is available)
|
||||
_instance: Optional[ImageCacheService] = None
|
||||
_instance: ImageCacheService | None = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
|
||||
@@ -544,12 +591,12 @@ def get_image_cache() -> ImageCacheService:
|
||||
if _instance is None:
|
||||
with _instance_lock:
|
||||
if _instance is None:
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.config.env import CONFIG_DIR
|
||||
from shelfmark.core.config import config
|
||||
|
||||
cache_dir = CONFIG_DIR / "covers"
|
||||
max_size_mb = config.get("COVERS_CACHE_MAX_SIZE_MB", 500)
|
||||
ttl_days = config.get("COVERS_CACHE_TTL", 0)
|
||||
max_size_mb = coerce_int(config.get("COVERS_CACHE_MAX_SIZE_MB", 500), 500)
|
||||
ttl_days = coerce_int(config.get("COVERS_CACHE_TTL", 0), 0)
|
||||
ttl_seconds = ttl_days * 86400 if ttl_days > 0 else 0
|
||||
|
||||
_instance = ImageCacheService(
|
||||
@@ -557,7 +604,12 @@ def get_image_cache() -> ImageCacheService:
|
||||
max_size_mb=max_size_mb,
|
||||
ttl_seconds=ttl_seconds,
|
||||
)
|
||||
logger.debug(f"Initialized image cache: {cache_dir} (max {max_size_mb}MB, TTL {ttl_days} days)")
|
||||
logger.debug(
|
||||
"Initialized image cache: %s (max %sMB, TTL %s days)",
|
||||
cache_dir,
|
||||
max_size_mb,
|
||||
ttl_days,
|
||||
)
|
||||
|
||||
return _instance
|
||||
|
||||
|
||||
@@ -2,59 +2,124 @@
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from collections.abc import Mapping
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.config.env import LOG_FILE, ENABLE_LOGGING, LOG_LEVEL
|
||||
from shelfmark.config.env import ENABLE_LOGGING, LOG_FILE, LOG_LEVEL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class CustomLogger(logging.Logger):
|
||||
"""Custom logger class with additional error_trace method."""
|
||||
|
||||
def error_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
def error_trace(self, msg: object, *args: object, **kwargs: object) -> None:
|
||||
"""Log an error message with full stack trace."""
|
||||
self.log_resource_usage()
|
||||
kwargs.pop('exc_info', None)
|
||||
self.error(msg, *args, exc_info=True, **kwargs)
|
||||
stack_info, stacklevel, extra = _extract_log_kwargs(kwargs)
|
||||
self.error(
|
||||
msg,
|
||||
*args,
|
||||
exc_info=True,
|
||||
stack_info=stack_info,
|
||||
stacklevel=stacklevel,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
def warning_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log a warning message with full stack trace."""
|
||||
self.log_resource_usage()
|
||||
kwargs.pop('exc_info', None)
|
||||
self.warning(msg, *args, exc_info=True, **kwargs)
|
||||
|
||||
def info_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log an info message (stack trace only if exception active)."""
|
||||
kwargs.pop('exc_info', None)
|
||||
# Only include exc_info if there's actually an exception
|
||||
has_exception = sys.exc_info()[0] is not None
|
||||
self.info(msg, *args, exc_info=has_exception, **kwargs)
|
||||
|
||||
def debug_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
def debug_trace(self, msg: object, *args: object, **kwargs: object) -> None:
|
||||
"""Log a debug message (stack trace only if exception active)."""
|
||||
kwargs.pop('exc_info', None)
|
||||
stack_info, stacklevel, extra = _extract_log_kwargs(kwargs)
|
||||
# Only include exc_info if there's actually an exception
|
||||
has_exception = sys.exc_info()[0] is not None
|
||||
self.debug(msg, *args, exc_info=has_exception, **kwargs)
|
||||
self.debug(
|
||||
msg,
|
||||
*args,
|
||||
exc_info=has_exception,
|
||||
stack_info=stack_info,
|
||||
stacklevel=stacklevel,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
def log_resource_usage(self):
|
||||
import psutil
|
||||
def log_resource_usage(self) -> None:
|
||||
"""Log best-effort CPU and memory usage for the current container."""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
# Sum RSS of all processes for actual app memory
|
||||
app_memory_mb = 0
|
||||
for proc in psutil.process_iter(['memory_info']):
|
||||
# Best-effort only; this should never raise during exception logging.
|
||||
try:
|
||||
|
||||
def _get_process_rss_mb(proc: object) -> float | None:
|
||||
try:
|
||||
proc_info = getattr(proc, "info", None)
|
||||
if not isinstance(proc_info, Mapping):
|
||||
return None
|
||||
mem = proc_info.get("memory_info")
|
||||
rss = getattr(mem, "rss", None)
|
||||
if isinstance(rss, int | float):
|
||||
return rss / (1024 * 1024)
|
||||
except (
|
||||
psutil.NoSuchProcess,
|
||||
psutil.AccessDenied,
|
||||
KeyError,
|
||||
AttributeError,
|
||||
):
|
||||
return None
|
||||
return None
|
||||
|
||||
# Sum RSS of all processes for actual app memory (container-friendly),
|
||||
# but fall back gracefully on platforms that restrict process enumeration.
|
||||
app_memory_mb = 0.0
|
||||
try:
|
||||
if proc.info['memory_info']:
|
||||
app_memory_mb += proc.info['memory_info'].rss / (1024 * 1024)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
for proc in psutil.process_iter(["memory_info"]):
|
||||
proc_rss_mb = _get_process_rss_mb(proc)
|
||||
if proc_rss_mb is not None:
|
||||
app_memory_mb += proc_rss_mb
|
||||
except PermissionError, psutil.AccessDenied, OSError:
|
||||
try:
|
||||
app_memory_mb = psutil.Process().memory_info().rss / (1024 * 1024)
|
||||
except AttributeError, OSError, psutil.Error:
|
||||
app_memory_mb = 0.0
|
||||
|
||||
memory = psutil.virtual_memory()
|
||||
system_used_mb = memory.used / (1024 * 1024)
|
||||
available_mb = memory.available / (1024 * 1024)
|
||||
cpu_percent = psutil.cpu_percent()
|
||||
self.debug(f"Container Memory: App={app_memory_mb:.2f} MB, System={system_used_mb:.2f} MB, Available={available_mb:.2f} MB, CPU: {cpu_percent:.2f}%")
|
||||
memory = psutil.virtual_memory()
|
||||
system_used_mb = memory.used / (1024 * 1024)
|
||||
available_mb = memory.available / (1024 * 1024)
|
||||
cpu_percent = psutil.cpu_percent()
|
||||
self.debug(
|
||||
f"Container Memory: App={app_memory_mb:.2f} MB, System={system_used_mb:.2f} MB, "
|
||||
f"Available={available_mb:.2f} MB, CPU: {cpu_percent:.2f}%"
|
||||
)
|
||||
except AttributeError, OSError, psutil.Error:
|
||||
# Avoid breaking the original log call if psutil is missing or restricted.
|
||||
return
|
||||
|
||||
|
||||
def _extract_log_kwargs(
|
||||
kwargs: Mapping[str, object],
|
||||
) -> tuple[bool, int, Mapping[str, object] | None]:
|
||||
stack_info = kwargs.get("stack_info")
|
||||
normalized_stack_info = stack_info if isinstance(stack_info, bool) else False
|
||||
|
||||
stacklevel = kwargs.get("stacklevel")
|
||||
normalized_stacklevel = stacklevel if isinstance(stacklevel, int) else 1
|
||||
|
||||
extra = kwargs.get("extra")
|
||||
normalized_extra = _normalize_log_extra(extra)
|
||||
|
||||
return normalized_stack_info, normalized_stacklevel, normalized_extra
|
||||
|
||||
|
||||
def _normalize_log_extra(value: object) -> Mapping[str, object] | None:
|
||||
if not isinstance(value, Mapping):
|
||||
return None
|
||||
|
||||
if all(isinstance(key, str) for key in value):
|
||||
return value
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
@@ -66,6 +131,7 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
|
||||
Returns:
|
||||
CustomLogger: Configured logger instance with error_trace method
|
||||
|
||||
"""
|
||||
# Register our custom logger class
|
||||
logging.setLoggerClass(CustomLogger)
|
||||
@@ -76,19 +142,21 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
logger.setLevel(log_level)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s'
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
|
||||
)
|
||||
|
||||
# Console handler for Docker output
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.addFilter(lambda record: record.levelno < logging.ERROR) # Only allow logs below ERROR to stdout
|
||||
console_handler.addFilter(
|
||||
lambda record: record.levelno < logging.ERROR
|
||||
) # Only allow logs below ERROR to stdout
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# Error handler for stderr
|
||||
error_handler = logging.StreamHandler(sys.stderr)
|
||||
error_handler.setLevel(logging.ERROR) # Error and above go to stderr
|
||||
error_handler.setLevel(logging.ERROR) # Error and above go to stderr
|
||||
error_handler.setFormatter(formatter)
|
||||
logger.addHandler(error_handler)
|
||||
|
||||
@@ -101,11 +169,11 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10485760, # 10MB
|
||||
backupCount=5
|
||||
backupCount=5,
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
except Exception as e:
|
||||
except (OSError, TypeError, ValueError) as e:
|
||||
logger.error_trace(f"Failed to create log file: {e}", exc_info=True)
|
||||
|
||||
return logger
|
||||
|
||||
@@ -1,231 +1,263 @@
|
||||
"""Centralized mirror configuration for all download sources."""
|
||||
"""Centralized mirror configuration for direct-download sources."""
|
||||
|
||||
from typing import List
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
|
||||
# Lazy import to avoid circular imports
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.config import Config
|
||||
|
||||
_config_module = None
|
||||
|
||||
|
||||
def _get_config():
|
||||
def _get_config() -> Config:
|
||||
"""Lazy import of config module to avoid circular imports."""
|
||||
global _config_module
|
||||
if _config_module is None:
|
||||
from shelfmark.core.config import config
|
||||
|
||||
_config_module = config
|
||||
return _config_module
|
||||
|
||||
|
||||
# Default mirror lists (hardcoded fallbacks)
|
||||
DEFAULT_AA_MIRRORS = [
|
||||
"https://annas-archive.se",
|
||||
"https://annas-archive.li",
|
||||
"https://annas-archive.pm",
|
||||
"https://annas-archive.in",
|
||||
]
|
||||
# Mirror URLs are intentionally user-supplied only.
|
||||
DEFAULT_AA_MIRRORS: list[str] = []
|
||||
DEFAULT_LIBGEN_MIRRORS: list[str] = []
|
||||
DEFAULT_ZLIB_MIRRORS: list[str] = []
|
||||
DEFAULT_WELIB_MIRRORS: list[str] = []
|
||||
|
||||
DEFAULT_LIBGEN_MIRRORS = [
|
||||
"https://libgen.gl",
|
||||
"https://libgen.li",
|
||||
"https://libgen.bz",
|
||||
"https://libgen.la",
|
||||
"https://libgen.vg",
|
||||
]
|
||||
|
||||
DEFAULT_ZLIB_MIRRORS = [
|
||||
"https://z-lib.fm",
|
||||
"https://z-lib.gs",
|
||||
"https://z-lib.id",
|
||||
"https://z-library.sk",
|
||||
"https://zlibrary-global.se",
|
||||
]
|
||||
|
||||
DEFAULT_WELIB_MIRRORS = [
|
||||
"https://welib.org",
|
||||
]
|
||||
_DOWNLOAD_SOURCE_MIRROR_LABELS = {
|
||||
"aa-fast": "Anna's Archive",
|
||||
"aa-slow": "Anna's Archive",
|
||||
"aa-slow-nowait": "Anna's Archive",
|
||||
"aa-slow-wait": "Anna's Archive",
|
||||
"libgen": "LibGen",
|
||||
"zlib": "Z-Library",
|
||||
"welib": "Welib",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_mirror_url(url: str) -> str:
|
||||
return normalize_http_url(url, default_scheme="https")
|
||||
|
||||
|
||||
def get_aa_mirrors() -> List[str]:
|
||||
"""
|
||||
Get Anna's Archive mirrors from config + defaults.
|
||||
def _string_config_value(value: object) -> str:
|
||||
"""Normalize mirror-related config values to strings."""
|
||||
return value if isinstance(value, str) else str(value or "")
|
||||
|
||||
Returns:
|
||||
List of AA mirror URLs, starting with defaults then custom additions.
|
||||
"""
|
||||
mirrors = [_normalize_mirror_url(url) for url in DEFAULT_AA_MIRRORS]
|
||||
mirrors = [url for url in mirrors if url]
|
||||
|
||||
def _normalize_configured_urls(value: object) -> list[str]:
|
||||
"""Normalize list or comma-separated mirror config into unique URLs."""
|
||||
if isinstance(value, list):
|
||||
parts = value
|
||||
elif isinstance(value, str) and value.strip():
|
||||
parts = value.split(",")
|
||||
else:
|
||||
return []
|
||||
|
||||
normalized_urls: list[str] = []
|
||||
for raw_url in parts:
|
||||
normalized = _normalize_mirror_url(str(raw_url))
|
||||
if normalized and normalized not in normalized_urls:
|
||||
normalized_urls.append(normalized)
|
||||
return normalized_urls
|
||||
|
||||
|
||||
def _get_primary_mirror_url(key: str) -> str | None:
|
||||
"""Return a configured primary mirror URL, if present."""
|
||||
config = _get_config()
|
||||
primary = _normalize_mirror_url(_string_config_value(config.get(key, "")))
|
||||
return primary or None
|
||||
|
||||
additional = config.get("AA_ADDITIONAL_URLS", "")
|
||||
if additional:
|
||||
for url in additional.split(","):
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized not in mirrors:
|
||||
mirrors.append(normalized)
|
||||
|
||||
def _build_primary_and_additional_mirrors(primary_key: str, additional_key: str) -> list[str]:
|
||||
"""Build an ordered mirror list from primary + additional config values."""
|
||||
config = _get_config()
|
||||
mirrors: list[str] = []
|
||||
|
||||
primary = _get_primary_mirror_url(primary_key)
|
||||
if primary:
|
||||
mirrors.append(primary)
|
||||
|
||||
for url in _normalize_configured_urls(config.get(additional_key, "")):
|
||||
if url not in mirrors:
|
||||
mirrors.append(url)
|
||||
|
||||
return mirrors
|
||||
|
||||
|
||||
def get_libgen_mirrors() -> List[str]:
|
||||
"""
|
||||
Get LibGen mirrors: defaults + any additional from config.
|
||||
def get_aa_mirrors() -> list[str]:
|
||||
"""Get Anna's Archive mirrors.
|
||||
|
||||
Returns:
|
||||
List of LibGen mirror URLs (defaults first, then custom additions).
|
||||
Ordered list of user-configured AA mirror URLs.
|
||||
|
||||
Notes:
|
||||
- The list is used to populate the AA mirror dropdown in Settings.
|
||||
- When AA_BASE_URL is set to 'auto', mirrors are tried in the order listed.
|
||||
|
||||
"""
|
||||
mirrors = [_normalize_mirror_url(url) for url in DEFAULT_LIBGEN_MIRRORS]
|
||||
mirrors = [url for url in mirrors if url]
|
||||
config = _get_config()
|
||||
|
||||
additional = config.get("LIBGEN_ADDITIONAL_URLS", "")
|
||||
if additional:
|
||||
for url in additional.split(","):
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized not in mirrors:
|
||||
mirrors.append(normalized)
|
||||
|
||||
return mirrors
|
||||
configured_list = _normalize_configured_urls(config.get("AA_MIRROR_URLS", None))
|
||||
if configured_list:
|
||||
return configured_list
|
||||
return _normalize_configured_urls(config.get("AA_ADDITIONAL_URLS", ""))
|
||||
|
||||
|
||||
def get_zlib_mirrors() -> List[str]:
|
||||
def has_aa_mirror_configuration() -> bool:
|
||||
"""Return True when direct-download search has at least one AA base URL to use."""
|
||||
if get_aa_mirrors():
|
||||
return True
|
||||
|
||||
configured_base_url = normalize_http_url(
|
||||
_string_config_value(_get_config().get("AA_BASE_URL", "auto")),
|
||||
default_scheme="https",
|
||||
allow_special=("auto",),
|
||||
)
|
||||
return bool(configured_base_url and configured_base_url != "auto")
|
||||
|
||||
|
||||
def get_libgen_mirrors() -> list[str]:
|
||||
"""Get user-configured LibGen mirrors.
|
||||
|
||||
Returns:
|
||||
List of LibGen mirror URLs.
|
||||
|
||||
"""
|
||||
Get Z-Library mirrors, with primary first.
|
||||
config = _get_config()
|
||||
configured_list = _normalize_configured_urls(config.get("LIBGEN_MIRROR_URLS", None))
|
||||
if configured_list:
|
||||
return configured_list
|
||||
return _normalize_configured_urls(config.get("LIBGEN_ADDITIONAL_URLS", ""))
|
||||
|
||||
|
||||
def has_libgen_mirror_configuration() -> bool:
|
||||
"""Return True when at least one LibGen mirror URL is configured."""
|
||||
return bool(get_libgen_mirrors())
|
||||
|
||||
|
||||
def get_zlib_mirrors() -> list[str]:
|
||||
"""Get user-configured Z-Library mirrors, with primary first.
|
||||
|
||||
Returns:
|
||||
List of Z-Library mirror URLs, primary first.
|
||||
|
||||
"""
|
||||
config = _get_config()
|
||||
|
||||
primary = _normalize_mirror_url(config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0]))
|
||||
if not primary:
|
||||
primary = _normalize_mirror_url(DEFAULT_ZLIB_MIRRORS[0])
|
||||
mirrors = [primary]
|
||||
|
||||
# Add other defaults (excluding primary)
|
||||
for url in DEFAULT_ZLIB_MIRRORS:
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized != primary:
|
||||
mirrors.append(normalized)
|
||||
|
||||
# Add custom mirrors
|
||||
additional = config.get("ZLIB_ADDITIONAL_URLS", "")
|
||||
if additional:
|
||||
for url in additional.split(","):
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized not in mirrors:
|
||||
mirrors.append(normalized)
|
||||
|
||||
return mirrors
|
||||
configured_list = _normalize_configured_urls(config.get("ZLIB_MIRROR_URLS", None))
|
||||
if configured_list:
|
||||
return configured_list
|
||||
return _build_primary_and_additional_mirrors("ZLIB_PRIMARY_URL", "ZLIB_ADDITIONAL_URLS")
|
||||
|
||||
|
||||
def get_zlib_primary_url() -> str:
|
||||
"""
|
||||
Get the primary Z-Library mirror URL.
|
||||
def has_zlib_mirror_configuration() -> bool:
|
||||
"""Return True when at least one Z-Library mirror URL is configured."""
|
||||
return bool(get_zlib_mirrors())
|
||||
|
||||
|
||||
def get_zlib_primary_url() -> str | None:
|
||||
"""Get the primary Z-Library mirror URL.
|
||||
|
||||
Returns:
|
||||
Primary Z-Library mirror URL.
|
||||
Primary Z-Library mirror URL, if configured.
|
||||
|
||||
"""
|
||||
config = _get_config()
|
||||
primary = _normalize_mirror_url(config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0]))
|
||||
return primary or _normalize_mirror_url(DEFAULT_ZLIB_MIRRORS[0])
|
||||
mirrors = get_zlib_mirrors()
|
||||
return mirrors[0] if mirrors else None
|
||||
|
||||
|
||||
def get_zlib_url_template() -> str:
|
||||
"""
|
||||
Get Z-Library URL template using configured primary mirror.
|
||||
def get_zlib_url_template() -> str | None:
|
||||
"""Get Z-Library URL template using configured primary mirror.
|
||||
|
||||
Returns:
|
||||
URL template with {md5} placeholder.
|
||||
URL template with {md5} placeholder, if configured.
|
||||
|
||||
"""
|
||||
primary = get_zlib_primary_url()
|
||||
return f"{primary}/md5/{{md5}}"
|
||||
return f"{primary}/md5/{{md5}}" if primary else None
|
||||
|
||||
|
||||
def get_welib_mirrors() -> List[str]:
|
||||
"""
|
||||
Get Welib mirrors, with primary first.
|
||||
def get_welib_mirrors() -> list[str]:
|
||||
"""Get user-configured Welib mirrors, with primary first.
|
||||
|
||||
Returns:
|
||||
List of Welib mirror URLs, primary first.
|
||||
|
||||
"""
|
||||
config = _get_config()
|
||||
|
||||
primary = _normalize_mirror_url(config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0]))
|
||||
if not primary:
|
||||
primary = _normalize_mirror_url(DEFAULT_WELIB_MIRRORS[0])
|
||||
mirrors = [primary]
|
||||
|
||||
# Add other defaults (excluding primary)
|
||||
for url in DEFAULT_WELIB_MIRRORS:
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized != primary:
|
||||
mirrors.append(normalized)
|
||||
|
||||
# Add custom mirrors
|
||||
additional = config.get("WELIB_ADDITIONAL_URLS", "")
|
||||
if additional:
|
||||
for url in additional.split(","):
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized not in mirrors:
|
||||
mirrors.append(normalized)
|
||||
|
||||
return mirrors
|
||||
configured_list = _normalize_configured_urls(config.get("WELIB_MIRROR_URLS", None))
|
||||
if configured_list:
|
||||
return configured_list
|
||||
return _build_primary_and_additional_mirrors("WELIB_PRIMARY_URL", "WELIB_ADDITIONAL_URLS")
|
||||
|
||||
|
||||
def get_welib_primary_url() -> str:
|
||||
"""
|
||||
Get the primary Welib mirror URL.
|
||||
def has_welib_mirror_configuration() -> bool:
|
||||
"""Return True when at least one Welib mirror URL is configured."""
|
||||
return bool(get_welib_mirrors())
|
||||
|
||||
|
||||
def has_download_source_mirror_configuration(source_id: str) -> bool:
|
||||
"""Return True when the requested direct-download source has mirror config."""
|
||||
if source_id in {"aa-fast", "aa-slow", "aa-slow-nowait", "aa-slow-wait"}:
|
||||
return has_aa_mirror_configuration()
|
||||
if source_id == "libgen":
|
||||
return has_libgen_mirror_configuration()
|
||||
if source_id == "zlib":
|
||||
return has_zlib_mirror_configuration()
|
||||
if source_id == "welib":
|
||||
return has_welib_mirror_configuration()
|
||||
return False
|
||||
|
||||
|
||||
def get_download_source_missing_mirror_reason(source_id: str) -> str | None:
|
||||
"""Return a user-facing reason when a direct-download source has no mirror config."""
|
||||
if has_download_source_mirror_configuration(source_id):
|
||||
return None
|
||||
|
||||
label = _DOWNLOAD_SOURCE_MIRROR_LABELS.get(source_id)
|
||||
if not label:
|
||||
return None
|
||||
|
||||
return f"Add at least one {label} mirror in Mirrors"
|
||||
|
||||
|
||||
def get_welib_primary_url() -> str | None:
|
||||
"""Get the primary Welib mirror URL.
|
||||
|
||||
Returns:
|
||||
Primary Welib mirror URL.
|
||||
Primary Welib mirror URL, if configured.
|
||||
|
||||
"""
|
||||
config = _get_config()
|
||||
primary = _normalize_mirror_url(config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0]))
|
||||
return primary or _normalize_mirror_url(DEFAULT_WELIB_MIRRORS[0])
|
||||
mirrors = get_welib_mirrors()
|
||||
return mirrors[0] if mirrors else None
|
||||
|
||||
|
||||
def get_welib_url_template() -> str:
|
||||
"""
|
||||
Get Welib URL template using configured primary mirror.
|
||||
def get_welib_url_template() -> str | None:
|
||||
"""Get Welib URL template using configured primary mirror.
|
||||
|
||||
Returns:
|
||||
URL template with {md5} placeholder.
|
||||
URL template with {md5} placeholder, if configured.
|
||||
|
||||
"""
|
||||
primary = get_welib_primary_url()
|
||||
return f"{primary}/md5/{{md5}}"
|
||||
return f"{primary}/md5/{{md5}}" if primary else None
|
||||
|
||||
|
||||
def get_zlib_cookie_domains() -> set:
|
||||
"""
|
||||
Get set of Z-Library domains that need full cookie handling.
|
||||
"""Get set of Z-Library domains that need full cookie handling.
|
||||
|
||||
Used by internal_bypasser for CF bypass cookie management.
|
||||
|
||||
Returns:
|
||||
Set of domain strings (without protocol).
|
||||
|
||||
"""
|
||||
domains = set()
|
||||
|
||||
# Add all default domains
|
||||
for url in DEFAULT_ZLIB_MIRRORS:
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized:
|
||||
domain = normalized.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
domains.add(domain)
|
||||
|
||||
# Add custom domains
|
||||
config = _get_config()
|
||||
additional = config.get("ZLIB_ADDITIONAL_URLS", "")
|
||||
if additional:
|
||||
for url in additional.split(","):
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized:
|
||||
domain = normalized.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
domains.add(domain)
|
||||
for url in get_zlib_mirrors():
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
domains.add(domain)
|
||||
|
||||
return domains
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
"""Data structures and models used across the application."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from enum import Enum
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_filename(
|
||||
title: str,
|
||||
author: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
fmt: Optional[str] = None,
|
||||
author: str | None = None,
|
||||
year: str | None = None,
|
||||
fmt: str | None = None,
|
||||
) -> str:
|
||||
"""Build a filesystem-safe filename from book metadata."""
|
||||
parts = []
|
||||
if author:
|
||||
parts.append(author)
|
||||
@@ -23,7 +24,7 @@ def build_filename(
|
||||
parts.append(f" ({year})")
|
||||
|
||||
filename = "".join(parts)
|
||||
filename = re.sub(r'[\\/:*?"<>|]', '_', filename.strip())[:245]
|
||||
filename = re.sub(r'[\\/:*?"<>|]', "_", filename.strip())[:245]
|
||||
|
||||
if fmt:
|
||||
filename = f"{filename}.{fmt}"
|
||||
@@ -31,19 +32,39 @@ def build_filename(
|
||||
return filename
|
||||
|
||||
|
||||
class QueueStatus(str, Enum):
|
||||
class QueueStatus(StrEnum):
|
||||
"""Enum for possible book queue statuses."""
|
||||
|
||||
QUEUED = "queued"
|
||||
RESOLVING = "resolving"
|
||||
LOCATING = "locating"
|
||||
DOWNLOADING = "downloading"
|
||||
COMPLETE = "complete"
|
||||
AVAILABLE = "available"
|
||||
ERROR = "error"
|
||||
DONE = "done"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class SearchMode(str, Enum):
|
||||
TERMINAL_QUEUE_STATUSES: frozenset[QueueStatus] = frozenset(
|
||||
{
|
||||
QueueStatus.COMPLETE,
|
||||
QueueStatus.ERROR,
|
||||
QueueStatus.CANCELLED,
|
||||
}
|
||||
)
|
||||
|
||||
ACTIVE_QUEUE_STATUSES: frozenset[QueueStatus] = frozenset(
|
||||
{
|
||||
QueueStatus.QUEUED,
|
||||
QueueStatus.RESOLVING,
|
||||
QueueStatus.LOCATING,
|
||||
QueueStatus.DOWNLOADING,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SearchMode(StrEnum):
|
||||
"""Search modes supported by the Shelfmark UI and API."""
|
||||
|
||||
DIRECT = "direct"
|
||||
UNIVERSAL = "universal"
|
||||
|
||||
@@ -51,11 +72,12 @@ class SearchMode(str, Enum):
|
||||
@dataclass
|
||||
class QueueItem:
|
||||
"""Queue item with priority and metadata."""
|
||||
|
||||
book_id: str
|
||||
priority: int
|
||||
added_time: float
|
||||
|
||||
def __lt__(self, other):
|
||||
def __lt__(self, other: QueueItem) -> bool:
|
||||
"""Compare items for priority queue (lower priority number = higher precedence)."""
|
||||
if self.priority != other.priority:
|
||||
return self.priority < other.priority
|
||||
@@ -64,39 +86,69 @@ class QueueItem:
|
||||
|
||||
@dataclass
|
||||
class DownloadTask:
|
||||
task_id: str # Unique ID (e.g., AA MD5 hash, Prowlarr GUID)
|
||||
source: str # Handler name ("direct_download", "prowlarr")
|
||||
title: str # Display title for queue sidebar
|
||||
"""Mutable download task state tracked throughout the pipeline."""
|
||||
|
||||
task_id: str # Unique ID (e.g., AA MD5 hash, Prowlarr GUID)
|
||||
source: str # Handler name ("direct_download", "prowlarr")
|
||||
title: str # Display title for queue sidebar
|
||||
|
||||
# Display info for queue sidebar
|
||||
author: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
format: Optional[str] = None
|
||||
size: Optional[str] = None
|
||||
preview: Optional[str] = None
|
||||
content_type: Optional[str] = None # "book (fiction)", "audiobook", "magazine", etc.
|
||||
author: str | None = None
|
||||
year: str | None = None
|
||||
format: str | None = None
|
||||
size: str | None = None
|
||||
preview: str | None = None
|
||||
content_type: str | None = None # "book (fiction)", "audiobook", "magazine", etc.
|
||||
source_url: str | None = None # Original release URL used by source-specific handlers
|
||||
retry_download_url: str | None = None # Resolved download URL for restart-safe retries
|
||||
retry_download_protocol: str | None = (
|
||||
None # Protocol for retry_download_url (e.g. torrent, usenet)
|
||||
)
|
||||
retry_release_name: str | None = None # Display name to send back to external download clients
|
||||
retry_expected_hash: str | None = None # Optional torrent hash used to match client downloads
|
||||
retry_ratio_limit: float | None = None # Optional post-download seeding ratio
|
||||
retry_seeding_time_limit_minutes: int | None = None # Optional post-download seeding time limit
|
||||
can_retry_without_staged_source: bool = (
|
||||
True # Whether the source can restart without a preserved staged file
|
||||
)
|
||||
|
||||
# Series info (for library naming templates)
|
||||
series_name: Optional[str] = None
|
||||
series_position: Optional[float] = None # Float for novellas (e.g., 1.5)
|
||||
subtitle: Optional[str] = None # Book subtitle for naming templates
|
||||
series_name: str | None = None
|
||||
series_position: float | None = None # Float for novellas (e.g., 1.5)
|
||||
subtitle: str | None = None # Book subtitle for naming templates
|
||||
|
||||
# Hardlinking support
|
||||
original_download_path: Optional[str] = None # Path in download client (for hardlinking)
|
||||
original_download_path: str | None = None # Path in download client (for hardlinking)
|
||||
|
||||
# Search mode - determines post-download processing behavior
|
||||
# See SearchMode enum for behavioral differences
|
||||
search_mode: Optional[SearchMode] = None
|
||||
search_mode: SearchMode | None = None
|
||||
|
||||
# Output selection for post-processing.
|
||||
# This is captured at queue time so in-flight tasks are not affected if the user changes settings later.
|
||||
output_mode: str | None = None
|
||||
|
||||
output_args: dict[str, Any] = field(
|
||||
default_factory=dict
|
||||
) # Per-output parameters (e.g. email recipient)
|
||||
|
||||
# User association (multi-user support)
|
||||
user_id: int | None = None # DB user ID who queued this download
|
||||
username: str | None = None # Username for {User} template variable
|
||||
request_id: int | None = None # Origin request ID when queued from request fulfilment
|
||||
|
||||
# Runtime state
|
||||
priority: int = 0
|
||||
added_time: float = field(default_factory=time.time)
|
||||
progress: float = 0.0
|
||||
status: QueueStatus = QueueStatus.QUEUED
|
||||
status_message: Optional[str] = None
|
||||
download_path: Optional[str] = None
|
||||
status_message: str | None = None
|
||||
download_path: str | None = None
|
||||
last_error_message: str | None = None
|
||||
last_error_type: str | None = None
|
||||
staged_path: str | None = None
|
||||
|
||||
def __lt__(self, other):
|
||||
def __lt__(self, other: DownloadTask) -> bool:
|
||||
"""Compare tasks for priority queue (lower priority number = higher precedence)."""
|
||||
if self.priority != other.priority:
|
||||
return self.priority < other.priority
|
||||
@@ -109,62 +161,14 @@ class DownloadTask:
|
||||
return build_filename(self.title, self.author, self.year, self.format)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookInfo:
|
||||
"""Data class representing book information."""
|
||||
id: str
|
||||
title: str
|
||||
preview: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
publisher: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
format: Optional[str] = None
|
||||
size: Optional[str] = None
|
||||
info: Optional[Dict[str, List[str]]] = None
|
||||
description: Optional[str] = None
|
||||
download_urls: List[str] = field(default_factory=list)
|
||||
download_path: Optional[str] = None
|
||||
priority: int = 0
|
||||
progress: Optional[float] = None
|
||||
status_message: Optional[str] = None # Detailed status message for UI display
|
||||
added_time: Optional[float] = None # Timestamp when added to queue
|
||||
source: str = "direct_download" # Release source handler to use for downloads
|
||||
source_url: Optional[str] = None # Link to source page (e.g., Anna's Archive)
|
||||
|
||||
def get_filename(self, fallback_url: Optional[str] = None) -> str:
|
||||
"""Build sanitized filename: 'Author - Title (Year).format'
|
||||
|
||||
Resolves format from self.format, download_urls, or fallback_url.
|
||||
|
||||
Args:
|
||||
fallback_url: URL to extract format from if not already known
|
||||
|
||||
Returns:
|
||||
Sanitized filename safe for filesystem use
|
||||
"""
|
||||
# Resolve format if needed
|
||||
if not self.format:
|
||||
urls = [self.download_urls[0]] if self.download_urls else []
|
||||
if fallback_url:
|
||||
urls.append(fallback_url)
|
||||
for url in urls:
|
||||
ext = url.split(".")[-1].lower()
|
||||
if ext and len(ext) <= 5 and ext.isalnum():
|
||||
self.format = ext
|
||||
break
|
||||
|
||||
return build_filename(self.title, self.author, self.year, self.format)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchFilters:
|
||||
"""Filters for book search queries."""
|
||||
isbn: Optional[List[str]] = None
|
||||
author: Optional[List[str]] = None
|
||||
title: Optional[List[str]] = None
|
||||
lang: Optional[List[str]] = None
|
||||
sort: Optional[str] = None
|
||||
content: Optional[List[str]] = None
|
||||
format: Optional[List[str]] = None
|
||||
|
||||
isbn: list[str] | None = None
|
||||
author: list[str] | None = None
|
||||
title: list[str] | None = None
|
||||
lang: list[str] | None = None
|
||||
sort: str | None = None
|
||||
content: list[str] | None = None
|
||||
format: list[str] | None = None
|
||||
|
||||
@@ -1,37 +1,51 @@
|
||||
"""Template-based naming for library organization."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Union, Mapping
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
TOKEN_PATTERN = re.compile(
|
||||
r'\{([- ._/\[(]*)' # prefix: space, dash, dot, underscore, slash, brackets
|
||||
r'([A-Za-z]+)' # token name
|
||||
r'([- ._/\])]*)\}' # suffix: space, dash, dot, underscore, slash, brackets
|
||||
)
|
||||
# Known variable tokens, sorted longest-first to avoid partial matches
|
||||
# e.g., "SeriesPosition" must match before "Series"
|
||||
KNOWN_TOKENS = [
|
||||
"seriesposition",
|
||||
"primarytitle",
|
||||
"originalname",
|
||||
"partnumber",
|
||||
"subtitle",
|
||||
"author",
|
||||
"series",
|
||||
"title",
|
||||
"year",
|
||||
"user",
|
||||
]
|
||||
|
||||
# Match any {...} block for template parsing
|
||||
BRACE_PATTERN = re.compile(r"\{([^}]+)\}")
|
||||
|
||||
# Characters that are invalid in filenames on various filesystems
|
||||
INVALID_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||
|
||||
|
||||
def _sanitize(name: Optional[str], max_length: int = 245) -> str:
|
||||
def _sanitize(name: str | None, max_length: int = 245) -> str:
|
||||
"""Sanitize a string for filesystem use."""
|
||||
if not name:
|
||||
return ""
|
||||
|
||||
sanitized = INVALID_CHARS.sub('_', name)
|
||||
sanitized = re.sub(r'^[\s.]+|[\s.]+$', '', sanitized) # Strip whitespace and dots
|
||||
sanitized = re.sub(r'_+', '_', sanitized) # Collapse underscores
|
||||
sanitized = INVALID_CHARS.sub("_", name)
|
||||
sanitized = re.sub(r"^[\s.]+|[\s.]+$", "", sanitized) # Strip whitespace and dots
|
||||
sanitized = re.sub(r"_+", "_", sanitized) # Collapse underscores
|
||||
return sanitized[:max_length]
|
||||
|
||||
|
||||
def sanitize_filename(name: Optional[str], max_length: int = 245) -> str:
|
||||
def sanitize_filename(name: str | None, max_length: int = 245) -> str:
|
||||
"""Sanitize a string for use as a filename or path component."""
|
||||
return _sanitize(name, max_length)
|
||||
|
||||
@@ -40,7 +54,8 @@ def sanitize_filename(name: Optional[str], max_length: int = 245) -> str:
|
||||
sanitize_path_component = sanitize_filename
|
||||
|
||||
|
||||
def format_series_position(position: Optional[Union[str, int, float]]) -> str:
|
||||
def format_series_position(position: str | float | None) -> str:
|
||||
"""Format a series position for naming templates."""
|
||||
if position is None:
|
||||
return ""
|
||||
|
||||
@@ -51,11 +66,30 @@ def format_series_position(position: Optional[Union[str, int, float]]) -> str:
|
||||
return str(position)
|
||||
|
||||
|
||||
def derive_primary_title(title: str | None, subtitle: str | None) -> str:
|
||||
"""Return the title without an explicit subtitle suffix when possible."""
|
||||
title_value = " ".join(str(title or "").split()).strip()
|
||||
if not title_value:
|
||||
return ""
|
||||
|
||||
subtitle_value = " ".join(str(subtitle or "").split()).strip()
|
||||
if not subtitle_value:
|
||||
return title_value
|
||||
|
||||
pattern = rf"^(?P<primary>.+?)(?:\s*:\s*|\s+-\s+){re.escape(subtitle_value)}$"
|
||||
match = re.match(pattern, title_value, flags=re.IGNORECASE)
|
||||
if not match:
|
||||
return title_value
|
||||
|
||||
primary = match.group("primary").strip()
|
||||
return primary or title_value
|
||||
|
||||
|
||||
# Pads numbers to 9 digits for natural sorting (e.g., "Part 2" -> "Part 000000002")
|
||||
PAD_NUMBERS_PATTERN = re.compile(r'\d+')
|
||||
PAD_NUMBERS_PATTERN = re.compile(r"\d+")
|
||||
|
||||
|
||||
def natural_sort_key(path: Union[str, Path]) -> str:
|
||||
def natural_sort_key(path: str | Path) -> str:
|
||||
"""Generate a sort key with padded numbers for natural sorting."""
|
||||
filename = Path(path).name.lower()
|
||||
return PAD_NUMBERS_PATTERN.sub(lambda m: m.group().zfill(9), filename)
|
||||
@@ -78,84 +112,120 @@ def assign_part_numbers(
|
||||
|
||||
def parse_naming_template(
|
||||
template: str,
|
||||
metadata: Mapping[str, Optional[Union[str, int, float]]],
|
||||
metadata: Mapping[str, str | int | float | None],
|
||||
*,
|
||||
allow_path_separators: bool = True,
|
||||
) -> str:
|
||||
"""Render a naming template with Shelfmark metadata placeholders."""
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
# Normalize metadata keys to lowercase for case-insensitive matching
|
||||
normalized = {k.lower(): v for k, v in metadata.items()}
|
||||
|
||||
def replace_token(match: re.Match) -> str:
|
||||
prefix = match.group(1)
|
||||
token_name = match.group(2).lower()
|
||||
suffix = match.group(3)
|
||||
def find_placeholder(content: str) -> tuple[str | None, int]:
|
||||
content_lower = content.lower()
|
||||
for placeholder_name in KNOWN_TOKENS:
|
||||
idx = content_lower.find(placeholder_name)
|
||||
if idx != -1:
|
||||
return placeholder_name, idx
|
||||
return None, -1
|
||||
|
||||
# Get the value for this token
|
||||
value = normalized.get(token_name)
|
||||
|
||||
# Special handling for series position
|
||||
if token_name == 'seriesposition':
|
||||
def placeholder_value(placeholder_name: str) -> str:
|
||||
value = normalized.get(placeholder_name)
|
||||
if placeholder_name == "seriesposition":
|
||||
value = format_series_position(value)
|
||||
|
||||
# Convert to string
|
||||
if value is None:
|
||||
value = ""
|
||||
else:
|
||||
value = str(value).strip()
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
# If value is empty, return empty string (no prefix/suffix)
|
||||
def render_block(content: str) -> str | None:
|
||||
placeholder_name, idx = find_placeholder(content)
|
||||
if placeholder_name is None:
|
||||
return None
|
||||
|
||||
prefix = content[:idx]
|
||||
suffix = content[idx + len(placeholder_name) :]
|
||||
value = placeholder_value(placeholder_name)
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
if not allow_path_separators:
|
||||
value = value.replace("/", "_")
|
||||
# Sanitize the value
|
||||
value = sanitize_filename(value)
|
||||
|
||||
return f"{prefix}{value}{suffix}"
|
||||
|
||||
# Replace all tokens
|
||||
result = TOKEN_PATTERN.sub(replace_token, template)
|
||||
# Process brace blocks in order so we can support conditional literal blocks like:
|
||||
# { - Part }{PartNumber}
|
||||
matches = list(BRACE_PATTERN.finditer(template))
|
||||
if not matches:
|
||||
result = template
|
||||
else:
|
||||
parts: list[str] = []
|
||||
cursor = 0
|
||||
for idx, match in enumerate(matches):
|
||||
parts.append(template[cursor : match.start()])
|
||||
content = match.group(1)
|
||||
rendered = render_block(content)
|
||||
|
||||
if rendered is not None:
|
||||
parts.append(rendered)
|
||||
else:
|
||||
conditional_literal = False
|
||||
include_literal = False
|
||||
if idx + 1 < len(matches) and match.end() == matches[idx + 1].start():
|
||||
next_content = matches[idx + 1].group(1)
|
||||
next_placeholder_name, _next_idx = find_placeholder(next_content)
|
||||
if next_placeholder_name is not None:
|
||||
conditional_literal = True
|
||||
include_literal = bool(placeholder_value(next_placeholder_name))
|
||||
if include_literal:
|
||||
parts.append(content)
|
||||
elif not conditional_literal and re.search(r"\s", content):
|
||||
# Preserve blocks that look like literal text, but treat bare unknown
|
||||
# placeholders as missing variables.
|
||||
parts.append(match.group(0))
|
||||
|
||||
cursor = match.end()
|
||||
|
||||
parts.append(template[cursor:])
|
||||
result = "".join(parts)
|
||||
|
||||
# Clean up any double slashes that might result from empty tokens
|
||||
result = re.sub(r'/+', '/', result)
|
||||
result = re.sub(r"/+", "/", result)
|
||||
|
||||
# Remove leading/trailing slashes
|
||||
result = result.strip('/')
|
||||
result = result.strip("/")
|
||||
|
||||
# Clean up any orphaned separators (e.g., " - " at start/end, or " - - ")
|
||||
result = re.sub(r'^[\s\-_.]+', '', result)
|
||||
result = re.sub(r'[\s\-_.]+$', '', result)
|
||||
result = re.sub(r'(\s*-\s*){2,}', ' - ', result)
|
||||
result = re.sub(r"^[\s\-_.]+", "", result)
|
||||
result = re.sub(r"[\s\-_.]+$", "", result)
|
||||
result = re.sub(r"(\s*-\s*){2,}", " - ", result)
|
||||
|
||||
# Clean up empty parentheses/brackets
|
||||
result = re.sub(r'\(\s*\)', '', result)
|
||||
result = re.sub(r'\[\s*\]', '', result)
|
||||
result = re.sub(r"\(\s*\)", "", result)
|
||||
result = re.sub(r"\[\s*\]", "", result)
|
||||
|
||||
# Final trim of any trailing separators left after cleanup
|
||||
result = re.sub(r'[\s\-_.]+$', '', result)
|
||||
|
||||
return result
|
||||
return re.sub(r"[\s\-_.]+$", "", result)
|
||||
|
||||
|
||||
def build_library_path(
|
||||
base_path: str,
|
||||
template: str,
|
||||
metadata: Mapping[str, Optional[Union[str, int, float]]],
|
||||
extension: Optional[str] = None,
|
||||
metadata: Mapping[str, str | int | float | None],
|
||||
extension: str | None = None,
|
||||
) -> Path:
|
||||
"""Build a final library path from a template and metadata."""
|
||||
relative = parse_naming_template(template, metadata, allow_path_separators=True)
|
||||
|
||||
if not relative:
|
||||
# Fallback to title if template produces empty result
|
||||
title = metadata.get('Title') or metadata.get('title') or 'Unknown'
|
||||
title = metadata.get("Title") or metadata.get("title") or "Unknown"
|
||||
relative = sanitize_filename(str(title))
|
||||
|
||||
# Remove any path traversal attempts
|
||||
relative = relative.replace('..', '')
|
||||
relative = relative.replace("..", "")
|
||||
|
||||
base = Path(base_path).resolve()
|
||||
full_path = (base / relative).resolve()
|
||||
@@ -163,11 +233,12 @@ def build_library_path(
|
||||
# Verify the path is within the base directory
|
||||
try:
|
||||
full_path.relative_to(base)
|
||||
except ValueError:
|
||||
raise ValueError(f"Path traversal detected: template would escape library directory")
|
||||
except ValueError as exc:
|
||||
msg = "Path traversal detected: template would escape library directory"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
if extension:
|
||||
ext = extension.lstrip('.')
|
||||
ext = extension.lstrip(".")
|
||||
# Don't use with_suffix() - it replaces everything after the first dot
|
||||
# e.g., "2.5 - Title" would become "2.epub" instead of "2.5 - Title.epub"
|
||||
full_path = Path(f"{full_path}.{ext}")
|
||||
@@ -175,27 +246,27 @@ def build_library_path(
|
||||
return full_path
|
||||
|
||||
|
||||
def same_filesystem(path1: Union[str, Path], path2: Union[str, Path]) -> bool:
|
||||
def same_filesystem(path1: str | Path, path2: str | Path) -> bool:
|
||||
"""Check if two paths are on the same filesystem."""
|
||||
path1 = Path(path1)
|
||||
path2 = Path(path2)
|
||||
|
||||
def get_device(p: Path) -> Optional[int]:
|
||||
def get_device(p: Path) -> int | None:
|
||||
try:
|
||||
while not p.exists():
|
||||
p = p.parent
|
||||
if p == p.parent:
|
||||
break
|
||||
return os.stat(p).st_dev
|
||||
return p.stat().st_dev
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.debug(f"Cannot stat {p}: {e}")
|
||||
logger.debug("Cannot stat %s: %s", p, e)
|
||||
return None
|
||||
|
||||
dev1 = get_device(path1)
|
||||
dev2 = get_device(path2)
|
||||
|
||||
if dev1 is None or dev2 is None:
|
||||
logger.warning(f"Cannot determine filesystem for hardlink check, falling back to copy")
|
||||
logger.warning("Cannot determine filesystem for hardlink check, falling back to copy")
|
||||
return False
|
||||
|
||||
return dev1 == dev2
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
"""Apprise notification dispatch for global and per-user events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any, Protocol, TypeGuard
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
try:
|
||||
import apprise
|
||||
except ImportError: # pragma: no cover - exercised in tests via monkeypatch
|
||||
apprise = None # type: ignore[assignment]
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import normalize_positive_int
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Iterator
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Small pool for non-blocking dispatch. Notification sends are I/O bound and infrequent.
|
||||
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="Notify")
|
||||
_ROUTE_EVENT_ALL = "all"
|
||||
_APPRISE_APP_ID = "Shelfmark"
|
||||
_APPRISE_APP_DESC = "Shelfmark notifications"
|
||||
_APPRISE_LOGO_URL = (
|
||||
"https://raw.githubusercontent.com/calibrain/shelfmark/main/src/frontend/public/logo.png"
|
||||
)
|
||||
_APPRISE_LOGGER_NAME = "apprise"
|
||||
_APPRISE_DISPATCH_ERRORS = (RuntimeError, TypeError, ValueError)
|
||||
|
||||
|
||||
class _ApprisePluginWithUrl(Protocol):
|
||||
app_id: object
|
||||
|
||||
def url(self, *, privacy: bool = False) -> str:
|
||||
_ = privacy
|
||||
return ""
|
||||
|
||||
|
||||
class _AppriseClient(Protocol):
|
||||
asset: object
|
||||
|
||||
def add(self, plugin: object) -> object: ...
|
||||
|
||||
def notify(self, *, title: str, body: str, notify_type: object) -> object: ...
|
||||
|
||||
|
||||
def _is_apprise_client(candidate: object) -> TypeGuard[_AppriseClient]:
|
||||
return callable(getattr(candidate, "add", None)) and callable(
|
||||
getattr(candidate, "notify", None)
|
||||
)
|
||||
|
||||
|
||||
def _has_plugin_url(candidate: object) -> TypeGuard[_ApprisePluginWithUrl]:
|
||||
return callable(getattr(candidate, "url", None))
|
||||
|
||||
|
||||
class NotificationEvent(StrEnum):
|
||||
"""Global notification event identifiers."""
|
||||
|
||||
REQUEST_CREATED = "request_created"
|
||||
REQUEST_FULFILLED = "request_fulfilled"
|
||||
REQUEST_REJECTED = "request_rejected"
|
||||
DOWNLOAD_COMPLETE = "download_complete"
|
||||
DOWNLOAD_FAILED = "download_failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationContext:
|
||||
"""Context used to render notification templates."""
|
||||
|
||||
event: NotificationEvent
|
||||
title: str
|
||||
author: str
|
||||
username: str | None = None
|
||||
content_type: str | None = None
|
||||
format: str | None = None
|
||||
source: str | None = None
|
||||
admin_note: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
def _normalize_urls(value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
raw_values: list[Any]
|
||||
if isinstance(value, list):
|
||||
raw_values = value
|
||||
elif isinstance(value, str):
|
||||
# Support legacy/manual configs.
|
||||
raw_values = [segment for part in value.splitlines() for segment in part.split(",")]
|
||||
else:
|
||||
raw_values = [value]
|
||||
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw_url in raw_values:
|
||||
url = str(raw_url or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
# Strip invisible/non-ASCII characters that can sneak in via copy-paste
|
||||
# (zero-width spaces, smart quotes, non-breaking spaces, etc.).
|
||||
# These pass Apprise URL validation but cause UnicodeEncodeError when
|
||||
# requests tries to latin-1 encode credentials for Basic Auth headers.
|
||||
url = url.encode("ascii", errors="ignore").decode("ascii").strip()
|
||||
if not url:
|
||||
continue
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
normalized.append(url)
|
||||
return normalized
|
||||
|
||||
|
||||
def _extract_url_schemes(urls: Iterable[str]) -> list[str]:
|
||||
schemes: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw_url in urls:
|
||||
scheme = urlsplit(str(raw_url or "")).scheme.lower()
|
||||
if not scheme or scheme in seen:
|
||||
continue
|
||||
seen.add(scheme)
|
||||
schemes.append(scheme)
|
||||
return schemes
|
||||
|
||||
|
||||
class _AppriseLogCapture(logging.Handler):
|
||||
def __init__(self, *, thread_id: int) -> None:
|
||||
super().__init__(level=logging.INFO)
|
||||
self.records: list[tuple[int, str, str, str]] = []
|
||||
self._thread_id = thread_id
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
if record.thread != self._thread_id:
|
||||
return
|
||||
|
||||
message = record.getMessage()
|
||||
if message:
|
||||
exception_summary = ""
|
||||
if record.exc_info and record.exc_info[0]:
|
||||
exc_type = getattr(record.exc_info[0], "__name__", "Exception")
|
||||
exc = record.exc_info[1]
|
||||
exception_summary = f"{exc_type}: {exc}"
|
||||
elif record.exc_text:
|
||||
exception_summary = str(record.exc_text).strip()
|
||||
|
||||
self.records.append((record.levelno, record.name, str(message), exception_summary))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _capture_apprise_logs(
|
||||
*, min_level: int = logging.INFO
|
||||
) -> Iterator[list[tuple[int, str, str, str]]]:
|
||||
apprise_logger = logging.getLogger(_APPRISE_LOGGER_NAME)
|
||||
previous_level = apprise_logger.level
|
||||
handler = _AppriseLogCapture(thread_id=threading.get_ident())
|
||||
apprise_logger.addHandler(handler)
|
||||
|
||||
if previous_level == logging.NOTSET or previous_level > min_level:
|
||||
apprise_logger.setLevel(min_level)
|
||||
|
||||
try:
|
||||
yield handler.records
|
||||
finally:
|
||||
apprise_logger.removeHandler(handler)
|
||||
apprise_logger.setLevel(previous_level)
|
||||
|
||||
|
||||
def _log_apprise_records(records: Iterable[tuple[int, str, str, str]]) -> None:
|
||||
seen: set[tuple[int, str, str, str]] = set()
|
||||
for level, source, raw_message, raw_exception_summary in records:
|
||||
message = str(raw_message or "").strip()
|
||||
source_name = str(source or "").strip() or _APPRISE_LOGGER_NAME
|
||||
exception_summary = str(raw_exception_summary or "").strip()
|
||||
key = (int(level), source_name, message, exception_summary)
|
||||
if not message or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
full_message = message if not exception_summary else f"{message} ({exception_summary})"
|
||||
|
||||
if level >= logging.ERROR:
|
||||
logger.error("Apprise source [%s]: %s", source_name, full_message)
|
||||
elif level >= logging.WARNING:
|
||||
logger.warning("Apprise source [%s]: %s", source_name, full_message)
|
||||
else:
|
||||
logger.info("Apprise source [%s]: %s", source_name, full_message)
|
||||
|
||||
|
||||
def _log_apprise_exception_debug(*, action: str, scheme: str, exc: Exception) -> None:
|
||||
logger.debug(
|
||||
"Apprise %s raised %s for scheme '%s': %s",
|
||||
action,
|
||||
type(exc).__name__,
|
||||
scheme,
|
||||
exc,
|
||||
exc_info=(type(exc), exc, exc.__traceback__),
|
||||
)
|
||||
|
||||
|
||||
def _build_apprise_warning_detail(
|
||||
records: Iterable[tuple[int, str, str, str]],
|
||||
*,
|
||||
scheme: str,
|
||||
) -> str | None:
|
||||
for level, source, raw_message, raw_exception_summary in records:
|
||||
if level < logging.WARNING:
|
||||
continue
|
||||
|
||||
message = str(raw_message or "").strip()
|
||||
if not message:
|
||||
continue
|
||||
|
||||
source_name = str(source or "").strip()
|
||||
exception_summary = str(raw_exception_summary or "").strip()
|
||||
full_message = message if not exception_summary else f"{message} ({exception_summary})"
|
||||
|
||||
if source_name and source_name != _APPRISE_LOGGER_NAME:
|
||||
return f"{scheme}: {source_name}: {full_message}"
|
||||
return f"{scheme}: {full_message}"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_routes(value: object) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
allowed_events = {_ROUTE_EVENT_ALL, *(event.value for event in NotificationEvent)}
|
||||
normalized: list[dict[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
for row in value:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
|
||||
raw_events = row.get("event")
|
||||
if isinstance(raw_events, list):
|
||||
event_values = raw_events
|
||||
elif isinstance(raw_events, (tuple, set)):
|
||||
event_values = list(raw_events)
|
||||
else:
|
||||
event_values = [raw_events]
|
||||
|
||||
url = str(row.get("url") or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
|
||||
row_events: list[str] = []
|
||||
for raw_event in event_values:
|
||||
event = str(raw_event or "").strip().lower()
|
||||
if event not in allowed_events:
|
||||
continue
|
||||
if event in row_events:
|
||||
continue
|
||||
row_events.append(event)
|
||||
|
||||
if _ROUTE_EVENT_ALL in row_events:
|
||||
row_events = [_ROUTE_EVENT_ALL]
|
||||
|
||||
for event in row_events:
|
||||
key = (event, url)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
normalized.append({"event": event, "url": url})
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _resolve_admin_routes() -> list[dict[str, str]]:
|
||||
return _normalize_routes(app_config.get("ADMIN_NOTIFICATION_ROUTES", []))
|
||||
|
||||
|
||||
def _normalize_user_id(value: object) -> int | None:
|
||||
return normalize_positive_int(value)
|
||||
|
||||
|
||||
def _resolve_user_routes(user_id: int | None) -> list[dict[str, str]]:
|
||||
normalized_user_id = _normalize_user_id(user_id)
|
||||
if normalized_user_id is None:
|
||||
return []
|
||||
|
||||
return _normalize_routes(
|
||||
app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=normalized_user_id)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_route_urls_for_event(
|
||||
routes: list[dict[str, str]],
|
||||
event: NotificationEvent,
|
||||
) -> list[str]:
|
||||
selected: list[str] = []
|
||||
seen: set[str] = set()
|
||||
event_value = event.value
|
||||
|
||||
for row in routes:
|
||||
row_event = row.get("event", "")
|
||||
if row_event not in {_ROUTE_EVENT_ALL, event_value}:
|
||||
continue
|
||||
|
||||
url = row.get("url", "")
|
||||
if not url or url in seen:
|
||||
continue
|
||||
|
||||
seen.add(url)
|
||||
selected.append(url)
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
def _resolve_notify_type(event: NotificationEvent) -> object:
|
||||
if apprise is None:
|
||||
fallback = {
|
||||
NotificationEvent.REQUEST_CREATED: "info",
|
||||
NotificationEvent.REQUEST_FULFILLED: "success",
|
||||
NotificationEvent.REQUEST_REJECTED: "warning",
|
||||
NotificationEvent.DOWNLOAD_COMPLETE: "success",
|
||||
NotificationEvent.DOWNLOAD_FAILED: "failure",
|
||||
}
|
||||
return fallback[event]
|
||||
|
||||
mapping = {
|
||||
NotificationEvent.REQUEST_CREATED: apprise.NotifyType.INFO,
|
||||
NotificationEvent.REQUEST_FULFILLED: apprise.NotifyType.SUCCESS,
|
||||
NotificationEvent.REQUEST_REJECTED: apprise.NotifyType.WARNING,
|
||||
NotificationEvent.DOWNLOAD_COMPLETE: apprise.NotifyType.SUCCESS,
|
||||
NotificationEvent.DOWNLOAD_FAILED: apprise.NotifyType.FAILURE,
|
||||
}
|
||||
return mapping[event]
|
||||
|
||||
|
||||
def _clean_text(value: object, fallback: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
return text or fallback
|
||||
|
||||
|
||||
def _render_message(context: NotificationContext) -> tuple[str, str]:
|
||||
event = context.event
|
||||
title = _clean_text(context.title, "Unknown title")
|
||||
author = _clean_text(context.author, "Unknown author")
|
||||
username = _clean_text(context.username, "A user")
|
||||
|
||||
if event == NotificationEvent.REQUEST_CREATED:
|
||||
return "New Request", f'{username} requested "{title}" by {author}'
|
||||
if event == NotificationEvent.REQUEST_FULFILLED:
|
||||
return "Request Approved", f'Request for "{title}" by {author} was approved.'
|
||||
if event == NotificationEvent.REQUEST_REJECTED:
|
||||
note = _clean_text(context.admin_note, "")
|
||||
note_line = f"\nNote: {note}" if note else ""
|
||||
return (
|
||||
"Request Rejected",
|
||||
f'Request for "{title}" by {author} was rejected.{note_line}',
|
||||
)
|
||||
if event == NotificationEvent.DOWNLOAD_COMPLETE:
|
||||
return "Download Complete", f'"{title}" by {author} downloaded successfully.'
|
||||
|
||||
error_message = _clean_text(context.error_message, "")
|
||||
error_line = f"\nError: {error_message}" if error_message else ""
|
||||
return "Download Failed", f'Failed to download "{title}" by {author}.{error_line}'
|
||||
|
||||
|
||||
def _plugin_label(plugin: object, fallback_scheme: str) -> str:
|
||||
"""Build a human-readable label from a validated Apprise plugin.
|
||||
|
||||
Combines the URL scheme with the plugin's service name (app_id) and
|
||||
privacy-safe URL for richer diagnostics, e.g.
|
||||
``"slack (Slack - slack://TokenA/To...n/To...n/)"``
|
||||
"""
|
||||
parts: list[str] = [fallback_scheme]
|
||||
|
||||
app_id = getattr(plugin, "app_id", None)
|
||||
if app_id and str(app_id) != fallback_scheme:
|
||||
privacy_url: str | None = None
|
||||
if _has_plugin_url(plugin):
|
||||
with suppress(Exception):
|
||||
privacy_url = plugin.url(privacy=True)
|
||||
|
||||
suffix = str(app_id)
|
||||
if privacy_url:
|
||||
suffix = f"{suffix} - {privacy_url}"
|
||||
parts.append(f"({suffix})")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _dispatch_to_apprise(
|
||||
urls: Iterable[str],
|
||||
*,
|
||||
title: str,
|
||||
body: str,
|
||||
notify_type: object,
|
||||
) -> dict[str, Any]:
|
||||
normalized_urls = _normalize_urls(list(urls))
|
||||
url_schemes = _extract_url_schemes(normalized_urls)
|
||||
if not normalized_urls:
|
||||
return {"success": False, "message": "No notification URLs configured"}
|
||||
|
||||
if apprise is None:
|
||||
return {"success": False, "message": "Apprise is not installed"}
|
||||
|
||||
valid_urls = 0
|
||||
invalid_urls = 0
|
||||
delivered_urls = 0
|
||||
failed_delivery_urls = 0
|
||||
failure_details: list[str] = []
|
||||
|
||||
for url in normalized_urls:
|
||||
scheme = urlsplit(url).scheme or "unknown"
|
||||
apobj = _create_apprise_client()
|
||||
if apobj is None:
|
||||
return {"success": False, "message": "Apprise is not installed"}
|
||||
|
||||
registration_failure_detail: str | None = None
|
||||
with _capture_apprise_logs(min_level=logging.INFO) as apprise_records:
|
||||
try:
|
||||
plugin = apprise.Apprise.instantiate(url, asset=getattr(apobj, "asset", None))
|
||||
except _APPRISE_DISPATCH_ERRORS as exc:
|
||||
logger.warning(
|
||||
"Failed to register notification route URL for scheme '%s': %s",
|
||||
scheme,
|
||||
exc,
|
||||
)
|
||||
_log_apprise_exception_debug(
|
||||
action="route registration",
|
||||
scheme=scheme,
|
||||
exc=exc,
|
||||
)
|
||||
registration_failure_detail = (
|
||||
f"{scheme}: route registration failed ({type(exc).__name__}: {exc})"
|
||||
)
|
||||
failure_details.append(registration_failure_detail)
|
||||
plugin = None
|
||||
|
||||
if plugin is None:
|
||||
invalid_urls += 1
|
||||
logger.warning("Apprise rejected notification route URL for scheme '%s'", scheme)
|
||||
_log_apprise_records(apprise_records)
|
||||
warning_detail = _build_apprise_warning_detail(apprise_records, scheme=scheme)
|
||||
if warning_detail:
|
||||
failure_details.append(warning_detail)
|
||||
elif registration_failure_detail is None:
|
||||
failure_details.append(f"{scheme}: route URL rejected by Apprise")
|
||||
continue
|
||||
|
||||
plugin_label = _plugin_label(plugin, scheme)
|
||||
apobj.add(plugin)
|
||||
valid_urls += 1
|
||||
|
||||
try:
|
||||
delivered = bool(apobj.notify(title=title, body=body, notify_type=notify_type))
|
||||
except _APPRISE_DISPATCH_ERRORS as exc:
|
||||
_log_apprise_records(apprise_records)
|
||||
failed_delivery_urls += 1
|
||||
logger.warning(
|
||||
"Apprise notify raised %s for %s: %s",
|
||||
type(exc).__name__,
|
||||
plugin_label,
|
||||
exc,
|
||||
)
|
||||
_log_apprise_exception_debug(action="notify", scheme=scheme, exc=exc)
|
||||
warning_detail = _build_apprise_warning_detail(apprise_records, scheme=scheme)
|
||||
if warning_detail:
|
||||
failure_details.append(warning_detail)
|
||||
else:
|
||||
failure_details.append(f"{scheme}: notify raised {type(exc).__name__}: {exc}")
|
||||
continue
|
||||
|
||||
_log_apprise_records(apprise_records)
|
||||
if delivered:
|
||||
delivered_urls += 1
|
||||
logger.debug("Notification delivered via %s", plugin_label)
|
||||
continue
|
||||
|
||||
failed_delivery_urls += 1
|
||||
logger.warning("Apprise notify returned False for %s", plugin_label)
|
||||
warning_detail = _build_apprise_warning_detail(apprise_records, scheme=scheme)
|
||||
if warning_detail:
|
||||
failure_details.append(warning_detail)
|
||||
else:
|
||||
failure_details.append(f"{scheme}: delivery failed")
|
||||
|
||||
scheme_summary = ", ".join(url_schemes) if url_schemes else "unknown"
|
||||
if valid_urls == 0:
|
||||
logger.warning(
|
||||
"No valid Apprise notification routes after registration for scheme(s): %s",
|
||||
scheme_summary,
|
||||
)
|
||||
result: dict[str, Any] = {
|
||||
"success": False,
|
||||
"message": "No valid notification URLs configured",
|
||||
}
|
||||
if failure_details:
|
||||
result["details"] = failure_details
|
||||
return result
|
||||
|
||||
if delivered_urls == 0:
|
||||
logger.warning(
|
||||
(
|
||||
"Apprise notify returned False for scheme(s): %s "
|
||||
"(valid_urls=%s invalid_urls=%s failed_deliveries=%s)"
|
||||
),
|
||||
scheme_summary,
|
||||
valid_urls,
|
||||
invalid_urls,
|
||||
failed_delivery_urls,
|
||||
)
|
||||
result = {"success": False, "message": "Notification delivery failed"}
|
||||
if failure_details:
|
||||
result["details"] = failure_details
|
||||
return result
|
||||
|
||||
message = f"Notification sent to {delivered_urls} URL(s)"
|
||||
failed_urls = invalid_urls + failed_delivery_urls
|
||||
if failed_urls:
|
||||
message += f" ({failed_urls} URL(s) failed)"
|
||||
result = {"success": True, "message": message}
|
||||
if failure_details:
|
||||
result["details"] = failure_details
|
||||
return result
|
||||
|
||||
|
||||
def _create_apprise_client() -> _AppriseClient | None:
|
||||
if apprise is None:
|
||||
return None
|
||||
|
||||
apprise_cls = getattr(apprise, "Apprise", None)
|
||||
if apprise_cls is None:
|
||||
return None
|
||||
|
||||
apprise_asset_cls = getattr(apprise, "AppriseAsset", None)
|
||||
if apprise_asset_cls is None:
|
||||
client = apprise_cls()
|
||||
return client if _is_apprise_client(client) else None
|
||||
|
||||
try:
|
||||
asset = apprise_asset_cls(
|
||||
app_id=_APPRISE_APP_ID,
|
||||
app_desc=_APPRISE_APP_DESC,
|
||||
image_url_logo=_APPRISE_LOGO_URL,
|
||||
)
|
||||
except TypeError:
|
||||
# Support older Apprise versions that do not expose image_url_logo.
|
||||
try:
|
||||
asset = apprise_asset_cls(
|
||||
app_id=_APPRISE_APP_ID,
|
||||
app_desc=_APPRISE_APP_DESC,
|
||||
)
|
||||
except TypeError:
|
||||
client = apprise_cls()
|
||||
return client if _is_apprise_client(client) else None
|
||||
|
||||
try:
|
||||
client = apprise_cls(asset=asset)
|
||||
except TypeError:
|
||||
client = apprise_cls()
|
||||
return client if _is_apprise_client(client) else None
|
||||
|
||||
|
||||
def _send_admin_event(
|
||||
event: NotificationEvent, context: NotificationContext, urls: list[str]
|
||||
) -> dict[str, Any]:
|
||||
title, body = _render_message(context)
|
||||
notify_type = _resolve_notify_type(event)
|
||||
return _dispatch_to_apprise(urls, title=title, body=body, notify_type=notify_type)
|
||||
|
||||
|
||||
def notify_admin(event: NotificationEvent, context: NotificationContext) -> None:
|
||||
"""Send a global admin notification for an event if subscribed."""
|
||||
routes = _resolve_admin_routes()
|
||||
urls = _resolve_route_urls_for_event(routes, event)
|
||||
if not urls:
|
||||
return
|
||||
|
||||
try:
|
||||
_executor.submit(_dispatch_admin_async, event, context, urls)
|
||||
except RuntimeError as exc:
|
||||
logger.warning("Failed to queue admin notification '%s': %s", event.value, exc)
|
||||
|
||||
|
||||
def notify_user(
|
||||
user_id: int | None, event: NotificationEvent, context: NotificationContext
|
||||
) -> None:
|
||||
"""Send a per-user notification for an event if subscribed."""
|
||||
normalized_user_id = _normalize_user_id(user_id)
|
||||
if normalized_user_id is None:
|
||||
return
|
||||
|
||||
routes = _resolve_user_routes(normalized_user_id)
|
||||
urls = _resolve_route_urls_for_event(routes, event)
|
||||
if not urls:
|
||||
return
|
||||
|
||||
try:
|
||||
_executor.submit(_dispatch_user_async, normalized_user_id, event, context, urls)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(
|
||||
"Failed to queue user notification '%s' for user_id=%s: %s",
|
||||
event.value,
|
||||
normalized_user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def _dispatch_admin_async(
|
||||
event: NotificationEvent, context: NotificationContext, urls: list[str]
|
||||
) -> None:
|
||||
result = _send_admin_event(event, context, urls)
|
||||
if not result.get("success", False):
|
||||
logger.warning(
|
||||
"Admin notification failed for event '%s': %s",
|
||||
event.value,
|
||||
result.get("message"),
|
||||
)
|
||||
|
||||
|
||||
def _dispatch_user_async(
|
||||
user_id: int,
|
||||
event: NotificationEvent,
|
||||
context: NotificationContext,
|
||||
urls: list[str],
|
||||
) -> None:
|
||||
result = _send_admin_event(event, context, urls)
|
||||
if not result.get("success", False):
|
||||
logger.warning(
|
||||
"User notification failed for event '%s' (user_id=%s): %s",
|
||||
event.value,
|
||||
user_id,
|
||||
result.get("message"),
|
||||
)
|
||||
|
||||
|
||||
def send_test_notification(urls: list[str]) -> dict[str, Any]:
|
||||
"""Send a synchronous test notification to the provided URLs."""
|
||||
normalized_urls = _normalize_urls(urls)
|
||||
if not normalized_urls:
|
||||
return {"success": False, "message": "No notification URLs configured"}
|
||||
|
||||
test_context = NotificationContext(
|
||||
event=NotificationEvent.REQUEST_CREATED,
|
||||
title="Shelfmark Test Notification",
|
||||
author="Shelfmark",
|
||||
username="Shelfmark",
|
||||
)
|
||||
return _send_admin_event(NotificationEvent.REQUEST_CREATED, test_context, normalized_urls)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""OIDC authentication helpers.
|
||||
|
||||
Handles group claim parsing, user info extraction, and user provisioning.
|
||||
Flask route handlers are registered separately in main.py.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.external_user_linking import upsert_external_user
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
def parse_group_claims(id_token: dict[str, Any], group_claim: str) -> list[str]:
|
||||
"""Extract group list from an ID token claim.
|
||||
|
||||
Supports list, comma-separated string, or pipe-separated string.
|
||||
Returns empty list if claim is missing.
|
||||
"""
|
||||
raw = id_token.get(group_claim)
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return [str(g).strip() for g in raw if str(g).strip()]
|
||||
if isinstance(raw, str):
|
||||
delimiter = "," if "," in raw else "|"
|
||||
return [g.strip() for g in raw.split(delimiter) if g.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def extract_user_info(id_token: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Extract user info from OIDC ID token claims.
|
||||
|
||||
Returns a dict with keys: oidc_subject, username, email, display_name.
|
||||
Falls back through preferred_username -> email -> sub for username.
|
||||
"""
|
||||
sub = id_token.get("sub", "")
|
||||
email = id_token.get("email")
|
||||
display_name = id_token.get("name")
|
||||
username = id_token.get("preferred_username") or email or sub
|
||||
|
||||
return {
|
||||
"oidc_subject": sub,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"display_name": display_name,
|
||||
}
|
||||
|
||||
|
||||
def provision_oidc_user(
|
||||
db: UserDB,
|
||||
user_info: dict[str, Any],
|
||||
*,
|
||||
is_admin: bool | None = None,
|
||||
allow_email_link: bool = False,
|
||||
allow_create: bool = True,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Create or update a user from OIDC claims.
|
||||
|
||||
Matching and collision handling use the shared external user linker:
|
||||
- OIDC subject first
|
||||
- optionally unique email linking (when `allow_email_link=True`)
|
||||
- username conflict resolution via numeric suffix.
|
||||
|
||||
Returns None when no existing user is matchable and `allow_create=False`.
|
||||
"""
|
||||
oidc_subject = user_info["oidc_subject"]
|
||||
user, _ = upsert_external_user(
|
||||
db,
|
||||
auth_source="oidc",
|
||||
username=user_info["username"] or oidc_subject,
|
||||
role="admin" if is_admin else "user",
|
||||
email=user_info.get("email"),
|
||||
display_name=user_info.get("display_name"),
|
||||
subject_field="oidc_subject",
|
||||
subject=oidc_subject,
|
||||
allow_email_link=allow_email_link,
|
||||
sync_role=is_admin is not None,
|
||||
allow_create=allow_create,
|
||||
collision_strategy="suffix",
|
||||
context="oidc_login",
|
||||
)
|
||||
return user
|
||||
@@ -0,0 +1,326 @@
|
||||
"""OIDC Flask route handlers using Authlib.
|
||||
|
||||
Registers /api/auth/oidc/login and /api/auth/oidc/callback endpoints.
|
||||
Business logic remains in oidc_auth.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Protocol, TypeGuard
|
||||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||
|
||||
from authlib.integrations.base_client.errors import OAuthError
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
from authlib.jose.errors import InvalidClaimError
|
||||
from flask import Flask, jsonify, redirect, request, session
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.oidc_auth import (
|
||||
extract_user_info,
|
||||
parse_group_claims,
|
||||
provision_oidc_user,
|
||||
)
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
oauth = OAuth()
|
||||
_RETURN_TO_SESSION_KEY = "oidc_return_to"
|
||||
_OIDC_CLIENT_ERRORS = (OAuthError, OSError, RuntimeError, TypeError, ValueError)
|
||||
|
||||
|
||||
class _ClaimsMappingLike(Protocol):
|
||||
"""Protocol for Authlib claims payloads that expose a to_dict method."""
|
||||
|
||||
def to_dict(self) -> Mapping[object, object]: ...
|
||||
|
||||
|
||||
def _has_claims_to_dict(candidate: object) -> TypeGuard[_ClaimsMappingLike]:
|
||||
"""Return True when a claims object exposes a callable to_dict method."""
|
||||
return callable(getattr(candidate, "to_dict", None))
|
||||
|
||||
|
||||
def _normalize_claim_mapping(raw_claims: Mapping[object, object]) -> dict[str, Any]:
|
||||
"""Return only string-keyed claims for downstream OIDC helpers."""
|
||||
return {key: value for key, value in raw_claims.items() if isinstance(key, str)}
|
||||
|
||||
|
||||
def _normalize_claims(raw_claims: object) -> dict[str, Any]:
|
||||
"""Return a plain dict for claims from Authlib token/userinfo payloads."""
|
||||
if raw_claims is None:
|
||||
return {}
|
||||
if isinstance(raw_claims, Mapping):
|
||||
return _normalize_claim_mapping(raw_claims)
|
||||
if _has_claims_to_dict(raw_claims):
|
||||
converted_claims = raw_claims.to_dict()
|
||||
if isinstance(converted_claims, Mapping):
|
||||
return _normalize_claim_mapping(converted_claims)
|
||||
return {}
|
||||
|
||||
|
||||
def _has_username_or_email(claims: dict[str, Any]) -> bool:
|
||||
"""Return True when claims include a usable username or email."""
|
||||
for key in ("preferred_username", "email"):
|
||||
value = claims.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _login_error_url(message: str) -> str:
|
||||
"""Build a login URL (with script_root) that includes an OIDC error message."""
|
||||
script_root = request.script_root.rstrip("/")
|
||||
login_url = f"{script_root}/login" if script_root else "/login"
|
||||
params = {"oidc_error": message}
|
||||
return_to = _get_pending_return_to()
|
||||
if return_to and return_to != "/":
|
||||
params["return_to"] = return_to
|
||||
return f"{login_url}?{urlencode(params)}"
|
||||
|
||||
|
||||
def _normalize_return_to(raw_return_to: object) -> str | None:
|
||||
"""Return a safe app-relative post-login target."""
|
||||
if not isinstance(raw_return_to, str):
|
||||
return None
|
||||
|
||||
value = raw_return_to.strip()
|
||||
if not value or not value.startswith("/") or value.startswith("//"):
|
||||
return None
|
||||
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return None
|
||||
|
||||
script_root = request.script_root.rstrip("/")
|
||||
path = parsed.path or "/"
|
||||
if script_root:
|
||||
if path == script_root:
|
||||
path = "/"
|
||||
elif path.startswith(f"{script_root}/"):
|
||||
path = path[len(script_root) :] or "/"
|
||||
|
||||
if path in {"/login", "/api"} or path.startswith(("/login/", "/api/")):
|
||||
return None
|
||||
|
||||
return urlunsplit(("", "", path, parsed.query, parsed.fragment))
|
||||
|
||||
|
||||
def _get_pending_return_to(*, clear: bool = False) -> str | None:
|
||||
"""Read the pending post-login target from the session."""
|
||||
raw_return_to = (
|
||||
session.pop(_RETURN_TO_SESSION_KEY, None) if clear else session.get(_RETURN_TO_SESSION_KEY)
|
||||
)
|
||||
normalized = _normalize_return_to(raw_return_to)
|
||||
if normalized is None and not clear:
|
||||
session.pop(_RETURN_TO_SESSION_KEY, None)
|
||||
return normalized
|
||||
|
||||
|
||||
def _post_login_redirect_target(return_to: str | None) -> str:
|
||||
"""Build the final redirect target, honoring script_root when present."""
|
||||
normalized = _normalize_return_to(return_to) or "/"
|
||||
script_root = request.script_root.rstrip("/")
|
||||
if not script_root:
|
||||
return normalized
|
||||
if normalized == "/":
|
||||
return f"{script_root}/"
|
||||
return f"{script_root}{normalized}"
|
||||
|
||||
|
||||
def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
|
||||
"""Register and return an OIDC client from the current security config."""
|
||||
discovery_url = str(app_config.get("OIDC_DISCOVERY_URL", "") or "")
|
||||
client_id = str(app_config.get("OIDC_CLIENT_ID", "") or "")
|
||||
|
||||
if not discovery_url or not client_id:
|
||||
msg = "OIDC not configured"
|
||||
raise ValueError(msg)
|
||||
|
||||
configured_scopes = app_config.get("OIDC_SCOPES", ["openid", "email", "profile"])
|
||||
if isinstance(configured_scopes, list):
|
||||
scope_values = [str(scope).strip() for scope in configured_scopes if str(scope).strip()]
|
||||
elif isinstance(configured_scopes, str):
|
||||
delimiter = "," if "," in configured_scopes else " "
|
||||
scope_values = [
|
||||
scope.strip() for scope in configured_scopes.split(delimiter) if scope.strip()
|
||||
]
|
||||
else:
|
||||
scope_values = []
|
||||
|
||||
scopes = list(dict.fromkeys(["openid", *scope_values]))
|
||||
|
||||
admin_group_value = app_config.get("OIDC_ADMIN_GROUP", "")
|
||||
admin_group = admin_group_value.strip() if isinstance(admin_group_value, str) else ""
|
||||
group_claim_value = app_config.get("OIDC_GROUP_CLAIM", "groups")
|
||||
group_claim = (
|
||||
group_claim_value.strip()
|
||||
if isinstance(group_claim_value, str) and group_claim_value.strip()
|
||||
else "groups"
|
||||
)
|
||||
use_admin_group = app_config.get("OIDC_USE_ADMIN_GROUP", True)
|
||||
if admin_group and use_admin_group and group_claim and group_claim not in scopes:
|
||||
scopes.append(group_claim)
|
||||
|
||||
def _ssl_compliance_fix(session: Any, **kwargs: Any) -> Any:
|
||||
"""Set session.verify based on the Certificate Validation setting."""
|
||||
session.verify = get_ssl_verify(discovery_url)
|
||||
return session
|
||||
|
||||
oauth._clients.pop("shelfmark_idp", None)
|
||||
oauth.register(
|
||||
name="shelfmark_idp",
|
||||
client_id=client_id,
|
||||
client_secret=app_config.get("OIDC_CLIENT_SECRET", ""),
|
||||
server_metadata_url=discovery_url,
|
||||
client_kwargs={
|
||||
"scope": " ".join(scopes),
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
compliance_fix=_ssl_compliance_fix,
|
||||
overwrite=True,
|
||||
)
|
||||
|
||||
client = oauth.create_client("shelfmark_idp")
|
||||
if client is None:
|
||||
msg = "OIDC client initialization failed"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
return client, {
|
||||
"OIDC_DISCOVERY_URL": discovery_url,
|
||||
"OIDC_GROUP_CLAIM": group_claim,
|
||||
"OIDC_ADMIN_GROUP": admin_group,
|
||||
"OIDC_AUTO_PROVISION": app_config.get("OIDC_AUTO_PROVISION", True),
|
||||
"OIDC_USE_ADMIN_GROUP": use_admin_group,
|
||||
}
|
||||
|
||||
|
||||
def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
"""Register OIDC authentication routes on the Flask app."""
|
||||
oauth.init_app(app)
|
||||
|
||||
@app.route("/api/auth/oidc/login", methods=["GET"])
|
||||
def oidc_login() -> ResponseReturnValue:
|
||||
"""Initiate OIDC login flow and redirect to the provider."""
|
||||
try:
|
||||
client, _ = _get_oidc_client()
|
||||
return_to = _normalize_return_to(request.args.get("return_to"))
|
||||
if return_to and return_to != "/":
|
||||
session[_RETURN_TO_SESSION_KEY] = return_to
|
||||
else:
|
||||
session.pop(_RETURN_TO_SESSION_KEY, None)
|
||||
redirect_uri = request.url_root.rstrip("/") + "/api/auth/oidc/callback"
|
||||
return client.authorize_redirect(redirect_uri)
|
||||
except ValueError:
|
||||
return jsonify({"error": "OIDC not configured"}), 500
|
||||
except Exception:
|
||||
logger.exception("OIDC login error")
|
||||
return jsonify({"error": "OIDC login failed"}), 500
|
||||
|
||||
@app.route("/api/auth/oidc/callback", methods=["GET"])
|
||||
def oidc_callback() -> ResponseReturnValue:
|
||||
"""Handle OIDC callback from identity provider."""
|
||||
try:
|
||||
error = request.args.get("error")
|
||||
if error:
|
||||
logger.warning("OIDC callback error from IdP: %s", error)
|
||||
return redirect(_login_error_url("Authentication failed"))
|
||||
|
||||
client, config = _get_oidc_client()
|
||||
try:
|
||||
token = client.authorize_access_token()
|
||||
except InvalidClaimError as e:
|
||||
claim_name = getattr(e, "claim_name", "unknown")
|
||||
discovery_url = str(config.get("OIDC_DISCOVERY_URL", ""))
|
||||
provider_issuer = ""
|
||||
try:
|
||||
metadata = client.load_server_metadata()
|
||||
if isinstance(metadata, dict):
|
||||
provider_issuer = str(metadata.get("issuer", ""))
|
||||
except _OIDC_CLIENT_ERRORS as metadata_error:
|
||||
logger.debug(
|
||||
"OIDC metadata lookup failed during claim diagnostics: %s",
|
||||
metadata_error,
|
||||
)
|
||||
|
||||
logger.exception(
|
||||
"OIDC callback claim validation failed: claim=%s discovery_url=%s provider_issuer=%s",
|
||||
claim_name,
|
||||
discovery_url or "<unset>",
|
||||
provider_issuer or "<unknown>",
|
||||
)
|
||||
if claim_name == "iss":
|
||||
msg = (
|
||||
"OIDC issuer validation failed. Verify your discovery URL and IdP issuer/"
|
||||
"external URL configuration."
|
||||
)
|
||||
return redirect(_login_error_url(msg))
|
||||
|
||||
return redirect(
|
||||
_login_error_url(f"OIDC token claim validation failed: {claim_name}")
|
||||
)
|
||||
claims = _normalize_claims(token.get("userinfo"))
|
||||
|
||||
# If userinfo is missing or claims are too sparse, request it explicitly.
|
||||
if not claims or not _has_username_or_email(claims):
|
||||
fetched_claims: dict[str, Any] = {}
|
||||
try:
|
||||
fetched_claims = _normalize_claims(client.userinfo(token=token))
|
||||
except TypeError:
|
||||
fetched_claims = _normalize_claims(client.userinfo())
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch OIDC userinfo")
|
||||
if fetched_claims:
|
||||
claims = {**claims, **fetched_claims}
|
||||
|
||||
if not claims:
|
||||
msg = "OIDC authentication failed: missing user claims"
|
||||
logger.error(msg)
|
||||
return redirect(_login_error_url(msg))
|
||||
|
||||
group_claim = config.get("OIDC_GROUP_CLAIM", "groups")
|
||||
admin_group = config.get("OIDC_ADMIN_GROUP", "")
|
||||
use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True)
|
||||
auto_provision = config.get("OIDC_AUTO_PROVISION", True)
|
||||
|
||||
user_info = extract_user_info(claims)
|
||||
groups = parse_group_claims(claims, group_claim)
|
||||
|
||||
is_admin = None
|
||||
if admin_group and use_admin_group:
|
||||
is_admin = admin_group in groups
|
||||
|
||||
allow_email_link = bool(user_info.get("email"))
|
||||
user = provision_oidc_user(
|
||||
user_db,
|
||||
user_info,
|
||||
is_admin=is_admin,
|
||||
allow_email_link=allow_email_link,
|
||||
allow_create=bool(auto_provision),
|
||||
)
|
||||
if user is None:
|
||||
logger.warning(
|
||||
"OIDC login rejected: auto-provision disabled for %s",
|
||||
user_info["username"],
|
||||
)
|
||||
return redirect(_login_error_url("Account not found. Contact your administrator."))
|
||||
|
||||
session["user_id"] = user["username"]
|
||||
session["is_admin"] = user.get("role") == "admin"
|
||||
session["db_user_id"] = user["id"]
|
||||
session.permanent = True
|
||||
|
||||
logger.info("OIDC login successful: %s (admin=%s)", user["username"], is_admin)
|
||||
return redirect(_post_login_redirect_target(_get_pending_return_to(clear=True)))
|
||||
|
||||
except ValueError as e:
|
||||
logger.exception("OIDC callback error")
|
||||
return redirect(_login_error_url(str(e)))
|
||||
except Exception:
|
||||
logger.exception("OIDC callback error")
|
||||
return redirect(_login_error_url("Authentication failed"))
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
Onboarding wizard configuration.
|
||||
"""Onboarding wizard configuration.
|
||||
|
||||
Defines the steps and fields for the first-run onboarding experience.
|
||||
Reuses field definitions from the settings registry where possible.
|
||||
@@ -8,42 +7,53 @@ Reuses field definitions from the settings registry where possible.
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.settings_registry import (
|
||||
HeadingField,
|
||||
MultiSelectField,
|
||||
SettingsField,
|
||||
get_settings_tab,
|
||||
serialize_field,
|
||||
save_config_file,
|
||||
get_setting_value,
|
||||
get_settings_field_map,
|
||||
get_settings_tab,
|
||||
save_config_file,
|
||||
serialize_field,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
ONBOARDING_STORAGE_KEY = "onboarding_complete"
|
||||
ONBOARDING_RELEASE_SOURCES_KEY = "ONBOARDING_RELEASE_SOURCES"
|
||||
_ONBOARDING_VIRTUAL_KEYS = {ONBOARDING_RELEASE_SOURCES_KEY}
|
||||
|
||||
|
||||
def _get_config_dir() -> Path:
|
||||
"""Get the config directory path."""
|
||||
from shelfmark.config.env import CONFIG_DIR
|
||||
|
||||
return Path(CONFIG_DIR)
|
||||
|
||||
|
||||
def is_onboarding_complete() -> bool:
|
||||
"""Check if onboarding has been completed."""
|
||||
from shelfmark.config.env import ONBOARDING
|
||||
|
||||
# If onboarding is disabled via env var, treat as complete
|
||||
if not ONBOARDING:
|
||||
return True
|
||||
|
||||
config_file = _get_config_dir() / "settings.json"
|
||||
if not config_file.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
with config_file.open() as f:
|
||||
config = json.load(f)
|
||||
return config.get(ONBOARDING_STORAGE_KEY, False)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning(f"Could not read onboarding status from settings.json: {e}")
|
||||
logger.warning("Could not read onboarding status from settings.json: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
@@ -51,14 +61,13 @@ def mark_onboarding_complete() -> bool:
|
||||
"""Mark onboarding as complete."""
|
||||
try:
|
||||
return save_config_file("general", {ONBOARDING_STORAGE_KEY: True})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mark onboarding complete: {e}")
|
||||
except Exception:
|
||||
logger.exception("Failed to mark onboarding complete")
|
||||
return False
|
||||
|
||||
|
||||
def _get_field_from_tab(tab_name: str, field_key: str) -> Optional[SettingsField]:
|
||||
"""
|
||||
Extract a specific field from a registered settings tab.
|
||||
def _get_field_from_tab(tab_name: str, field_key: str) -> SettingsField | None:
|
||||
"""Extract a specific field from a registered settings tab.
|
||||
|
||||
Args:
|
||||
tab_name: Name of the settings tab (e.g., 'search_mode', 'hardcover')
|
||||
@@ -66,37 +75,145 @@ def _get_field_from_tab(tab_name: str, field_key: str) -> Optional[SettingsField
|
||||
|
||||
Returns:
|
||||
The field if found, None otherwise
|
||||
|
||||
"""
|
||||
tab = get_settings_tab(tab_name)
|
||||
if not tab:
|
||||
logger.warning(f"Settings tab not found: {tab_name}")
|
||||
logger.warning("Settings tab not found: %s", tab_name)
|
||||
return None
|
||||
|
||||
for field in tab.fields:
|
||||
if hasattr(field, 'key') and field.key == field_key:
|
||||
if hasattr(field, "key") and field.key == field_key:
|
||||
return field
|
||||
|
||||
logger.warning(f"Field {field_key} not found in tab {tab_name}")
|
||||
logger.warning("Field %s not found in tab %s", field_key, tab_name)
|
||||
return None
|
||||
|
||||
|
||||
def _clone_field_with_overrides(field: SettingsField, **overrides) -> SettingsField:
|
||||
"""
|
||||
Clone a field with optional attribute overrides.
|
||||
def _get_field_tab_name(field: SettingsField, fallback_tab_name: str) -> str:
|
||||
"""Return the owning settings tab for a value field."""
|
||||
field_key = getattr(field, "key", None)
|
||||
if not field_key:
|
||||
return fallback_tab_name
|
||||
|
||||
field_map = get_settings_field_map()
|
||||
field_entry = field_map.get(field_key)
|
||||
if field_entry is None:
|
||||
return fallback_tab_name
|
||||
|
||||
return field_entry[1]
|
||||
|
||||
|
||||
def _clone_field_with_overrides(field: SettingsField, **overrides: object) -> SettingsField:
|
||||
"""Clone a field with optional attribute overrides.
|
||||
|
||||
Useful for customizing labels, descriptions, or defaults for onboarding context.
|
||||
"""
|
||||
return replace(field, **overrides)
|
||||
|
||||
|
||||
def _get_fields_from_tab(
|
||||
tab_name: str,
|
||||
field_keys: list[str],
|
||||
*,
|
||||
strip_show_when_keys: set[str] | None = None,
|
||||
) -> list[SettingsField]:
|
||||
"""Return the requested fields from a settings tab in the supplied order."""
|
||||
fields: list[SettingsField] = []
|
||||
for field_key in field_keys:
|
||||
field = _get_field_from_tab(tab_name, field_key)
|
||||
if field:
|
||||
show_when = getattr(field, "show_when", None)
|
||||
stripped_show_when = _strip_show_when_keys(show_when, strip_show_when_keys or set())
|
||||
if stripped_show_when != show_when:
|
||||
field = replace(field, show_when=stripped_show_when)
|
||||
fields.append(field)
|
||||
return fields
|
||||
|
||||
|
||||
def _strip_show_when_keys(
|
||||
show_when: dict[str, Any] | list[dict[str, Any]] | None,
|
||||
field_keys: set[str],
|
||||
) -> dict[str, Any] | list[dict[str, Any]] | None:
|
||||
"""Remove conditions tied to fields that onboarding handles implicitly."""
|
||||
if not show_when or not field_keys:
|
||||
return show_when
|
||||
|
||||
if isinstance(show_when, list):
|
||||
remaining = [
|
||||
condition for condition in show_when if condition.get("field") not in field_keys
|
||||
]
|
||||
return remaining or None
|
||||
|
||||
if show_when.get("field") in field_keys:
|
||||
return None
|
||||
|
||||
return show_when
|
||||
|
||||
|
||||
def _is_release_source_selected(values: dict[str, Any], source_name: str) -> bool:
|
||||
"""Return True when a release source has been chosen during onboarding."""
|
||||
raw_sources = values.get(ONBOARDING_RELEASE_SOURCES_KEY, [])
|
||||
if not isinstance(raw_sources, list):
|
||||
return False
|
||||
return source_name in raw_sources
|
||||
|
||||
|
||||
def _evaluate_show_when_condition(condition: dict[str, Any], values: dict[str, Any]) -> bool:
|
||||
"""Evaluate one onboarding show_when condition against submitted values."""
|
||||
current_value = values.get(condition["field"])
|
||||
expected_value = condition.get("value")
|
||||
|
||||
if condition.get("notEmpty"):
|
||||
if isinstance(current_value, list):
|
||||
return len(current_value) > 0
|
||||
return current_value not in (None, "")
|
||||
|
||||
if isinstance(current_value, list):
|
||||
if isinstance(expected_value, list):
|
||||
return all(item in current_value for item in expected_value)
|
||||
return expected_value in current_value
|
||||
|
||||
if isinstance(expected_value, list):
|
||||
return current_value in expected_value
|
||||
|
||||
return current_value == expected_value
|
||||
|
||||
|
||||
def _is_step_visible(step_config: dict[str, Any], values: dict[str, Any]) -> bool:
|
||||
"""Return True when a step should be included for the provided values."""
|
||||
show_when = step_config.get("show_when")
|
||||
if not show_when:
|
||||
return True
|
||||
return all(_evaluate_show_when_condition(condition, values) for condition in show_when)
|
||||
|
||||
|
||||
def _is_field_visible(field: SettingsField, values: dict[str, Any]) -> bool:
|
||||
"""Return True when a field should be included in the onboarding save."""
|
||||
if getattr(field, "hidden_in_ui", False):
|
||||
return False
|
||||
|
||||
if getattr(field, "universal_only", False) and values.get("SEARCH_MODE") != "universal":
|
||||
return False
|
||||
|
||||
show_when = getattr(field, "show_when", None)
|
||||
if not show_when:
|
||||
return True
|
||||
|
||||
if isinstance(show_when, list):
|
||||
return all(_evaluate_show_when_condition(condition, values) for condition in show_when)
|
||||
|
||||
return _evaluate_show_when_condition(show_when, values)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Step Definitions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_search_mode_fields() -> List[SettingsField]:
|
||||
def get_search_mode_fields() -> list[SettingsField]:
|
||||
"""Step 1: Choose search mode - uses actual SEARCH_MODE field from settings."""
|
||||
fields: List[SettingsField] = [
|
||||
fields: list[SettingsField] = [
|
||||
HeadingField(
|
||||
key="welcome_heading",
|
||||
title="Welcome to Shelfmark",
|
||||
@@ -108,17 +225,19 @@ def get_search_mode_fields() -> List[SettingsField]:
|
||||
search_mode_field = _get_field_from_tab("search_mode", "SEARCH_MODE")
|
||||
if search_mode_field:
|
||||
# Clone with onboarding-specific description
|
||||
fields.append(_clone_field_with_overrides(
|
||||
search_mode_field,
|
||||
description="Choose how you want to find books.",
|
||||
))
|
||||
fields.append(
|
||||
_clone_field_with_overrides(
|
||||
search_mode_field,
|
||||
description="Choose how you want to find books.",
|
||||
)
|
||||
)
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def get_metadata_provider_fields() -> List[SettingsField]:
|
||||
def get_metadata_provider_fields() -> list[SettingsField]:
|
||||
"""Step 2: Choose metadata provider - uses actual METADATA_PROVIDER field."""
|
||||
fields: List[SettingsField] = [
|
||||
fields: list[SettingsField] = [
|
||||
HeadingField(
|
||||
key="metadata_heading",
|
||||
title="Metadata Provider",
|
||||
@@ -149,18 +268,20 @@ def get_metadata_provider_fields() -> List[SettingsField]:
|
||||
]
|
||||
|
||||
# Clone with onboarding-specific options and default
|
||||
fields.append(_clone_field_with_overrides(
|
||||
provider_field,
|
||||
default="hardcover",
|
||||
options=onboarding_options,
|
||||
))
|
||||
fields.append(
|
||||
_clone_field_with_overrides(
|
||||
provider_field,
|
||||
default="hardcover",
|
||||
options=onboarding_options,
|
||||
)
|
||||
)
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def get_hardcover_setup_fields() -> List[SettingsField]:
|
||||
def get_hardcover_setup_fields() -> list[SettingsField]:
|
||||
"""Step 3a: Configure Hardcover - uses actual API key and test connection fields."""
|
||||
fields: List[SettingsField] = [
|
||||
fields: list[SettingsField] = [
|
||||
HeadingField(
|
||||
key="hardcover_setup_heading",
|
||||
title="Hardcover Setup",
|
||||
@@ -183,9 +304,9 @@ def get_hardcover_setup_fields() -> List[SettingsField]:
|
||||
return fields
|
||||
|
||||
|
||||
def get_googlebooks_setup_fields() -> List[SettingsField]:
|
||||
def get_googlebooks_setup_fields() -> list[SettingsField]:
|
||||
"""Step 3b: Configure Google Books - uses actual API key and test connection fields."""
|
||||
fields: List[SettingsField] = [
|
||||
fields: list[SettingsField] = [
|
||||
HeadingField(
|
||||
key="googlebooks_setup_heading",
|
||||
title="Google Books Setup",
|
||||
@@ -208,129 +329,270 @@ def get_googlebooks_setup_fields() -> List[SettingsField]:
|
||||
return fields
|
||||
|
||||
|
||||
def get_prowlarr_fields() -> List[SettingsField]:
|
||||
"""Step 4: Configure Prowlarr connection - uses actual Prowlarr fields."""
|
||||
fields: List[SettingsField] = [
|
||||
def get_release_source_selection_fields() -> list[SettingsField]:
|
||||
"""Choose which release sources to configure during onboarding."""
|
||||
fields: list[SettingsField] = [
|
||||
HeadingField(
|
||||
key="prowlarr_heading",
|
||||
title="Prowlarr Integration (Optional)",
|
||||
description="Connect to Prowlarr to search your indexers for torrents and NZBs. Skip this step if you only want to use Direct Download.",
|
||||
key="release_sources_heading",
|
||||
title="Release Sources",
|
||||
description=(
|
||||
"Choose the release sources you want to configure now. You can always add or "
|
||||
"change sources later in Settings."
|
||||
),
|
||||
),
|
||||
MultiSelectField(
|
||||
key=ONBOARDING_RELEASE_SOURCES_KEY,
|
||||
label="Sources to Set Up",
|
||||
description="Select one or more release sources to configure now.",
|
||||
default=[],
|
||||
variant="dropdown",
|
||||
env_supported=False,
|
||||
options=[
|
||||
{
|
||||
"value": "direct_download",
|
||||
"label": "Direct Download",
|
||||
"description": "Configure your own Anna's Archive mirror URLs for direct ebook downloads.",
|
||||
},
|
||||
{
|
||||
"value": "prowlarr",
|
||||
"label": "Prowlarr",
|
||||
"description": "Search your torrent and Usenet indexers through Prowlarr.",
|
||||
},
|
||||
{
|
||||
"value": "audiobookbay",
|
||||
"label": "AudiobookBay",
|
||||
"description": "Search AudiobookBay directly for audiobook releases.",
|
||||
},
|
||||
{
|
||||
"value": "irc",
|
||||
"label": "IRC",
|
||||
"description": "Connect to IRC for ebook and audiobook release searches.",
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
# Get actual Prowlarr connection fields
|
||||
prowlarr_fields = ["PROWLARR_ENABLED", "PROWLARR_URL", "PROWLARR_API_KEY", "test_prowlarr"]
|
||||
for field_key in prowlarr_fields:
|
||||
field = _get_field_from_tab("prowlarr_config", field_key)
|
||||
if field:
|
||||
fields.append(field)
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def get_prowlarr_indexers_fields() -> List[SettingsField]:
|
||||
"""Step 5: Select Prowlarr indexers to search."""
|
||||
fields: List[SettingsField] = [
|
||||
def get_direct_download_setup_fields() -> list[SettingsField]:
|
||||
"""Render trimmed direct-download essentials for onboarding."""
|
||||
fields: list[SettingsField] = [
|
||||
HeadingField(
|
||||
key="prowlarr_indexers_heading",
|
||||
title="Select Indexers",
|
||||
description="Choose which indexers to search for books. Leave empty to search all available indexers.",
|
||||
),
|
||||
key="direct_download_setup_onboarding_heading",
|
||||
title="Direct Download Setup",
|
||||
description=(
|
||||
"Add at least one Anna's Archive mirror URL to enable Direct Download. If you "
|
||||
"have an Anna's Archive donator key, you can add it here too. You can configure "
|
||||
"alternative mirrors later in Settings."
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
# Get the indexers multi-select field
|
||||
indexers_field = _get_field_from_tab("prowlarr_config", "PROWLARR_INDEXERS")
|
||||
if indexers_field:
|
||||
fields.append(indexers_field)
|
||||
|
||||
fields.extend(_get_fields_from_tab("download_sources", ["AA_DONATOR_KEY"]))
|
||||
fields.extend(_get_fields_from_tab("mirrors", ["AA_MIRROR_URLS"]))
|
||||
return fields
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Step Configuration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
ONBOARDING_STEPS = [
|
||||
{
|
||||
"id": "search_mode",
|
||||
"title": "Search Mode",
|
||||
"tab": "search_mode",
|
||||
"get_fields": get_search_mode_fields,
|
||||
},
|
||||
{
|
||||
"id": "metadata_provider",
|
||||
"title": "Metadata Provider",
|
||||
"tab": "search_mode",
|
||||
"get_fields": get_metadata_provider_fields,
|
||||
"show_when": [{"field": "SEARCH_MODE", "value": "universal"}],
|
||||
},
|
||||
{
|
||||
"id": "hardcover_setup",
|
||||
"title": "Hardcover Setup",
|
||||
"tab": "hardcover",
|
||||
"get_fields": get_hardcover_setup_fields,
|
||||
# Must be universal mode AND hardcover selected
|
||||
"show_when": [
|
||||
{"field": "SEARCH_MODE", "value": "universal"},
|
||||
{"field": "METADATA_PROVIDER", "value": "hardcover"},
|
||||
def get_direct_download_bypass_fields() -> list[SettingsField]:
|
||||
"""Render only the core Cloudflare bypass fields for onboarding."""
|
||||
return _get_fields_from_tab(
|
||||
"cloudflare_bypass",
|
||||
[
|
||||
"USE_CF_BYPASS",
|
||||
"USING_EXTERNAL_BYPASSER",
|
||||
"EXT_BYPASSER_URL",
|
||||
"EXT_BYPASSER_PATH",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "googlebooks_setup",
|
||||
"title": "Google Books Setup",
|
||||
"tab": "googlebooks",
|
||||
"get_fields": get_googlebooks_setup_fields,
|
||||
# Must be universal mode AND googlebooks selected
|
||||
"show_when": [
|
||||
{"field": "SEARCH_MODE", "value": "universal"},
|
||||
{"field": "METADATA_PROVIDER", "value": "googlebooks"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "prowlarr",
|
||||
"title": "Prowlarr",
|
||||
"tab": "prowlarr_config",
|
||||
"get_fields": get_prowlarr_fields,
|
||||
"show_when": [{"field": "SEARCH_MODE", "value": "universal"}],
|
||||
"optional": True,
|
||||
},
|
||||
{
|
||||
"id": "prowlarr_indexers",
|
||||
"title": "Indexers",
|
||||
"tab": "prowlarr_config",
|
||||
"get_fields": get_prowlarr_indexers_fields,
|
||||
# Only show when Prowlarr is enabled
|
||||
"show_when": [
|
||||
{"field": "SEARCH_MODE", "value": "universal"},
|
||||
{"field": "PROWLARR_ENABLED", "value": True},
|
||||
],
|
||||
"optional": True,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_onboarding_config() -> Dict[str, Any]:
|
||||
"""
|
||||
Get the full onboarding configuration including steps and current values.
|
||||
"""
|
||||
def get_prowlarr_fields() -> list[SettingsField]:
|
||||
"""Render trimmed Prowlarr setup fields for onboarding."""
|
||||
return _get_fields_from_tab(
|
||||
"prowlarr_config",
|
||||
[
|
||||
"prowlarr_heading",
|
||||
"PROWLARR_URL",
|
||||
"PROWLARR_API_KEY",
|
||||
"test_prowlarr",
|
||||
"PROWLARR_INDEXERS",
|
||||
],
|
||||
strip_show_when_keys={"PROWLARR_ENABLED"},
|
||||
)
|
||||
|
||||
|
||||
def get_audiobookbay_fields() -> list[SettingsField]:
|
||||
"""Render trimmed AudiobookBay setup fields for onboarding."""
|
||||
return [
|
||||
HeadingField(
|
||||
key="audiobookbay_onboarding_heading",
|
||||
title="AudiobookBay",
|
||||
description="Add the AudiobookBay domain you want Shelfmark to search.",
|
||||
),
|
||||
*_get_fields_from_tab(
|
||||
"audiobookbay_config",
|
||||
["ABB_HOSTNAME"],
|
||||
strip_show_when_keys={"ABB_ENABLED"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_irc_fields() -> list[SettingsField]:
|
||||
"""Render trimmed IRC setup fields for onboarding."""
|
||||
return _get_fields_from_tab(
|
||||
"irc",
|
||||
[
|
||||
"heading",
|
||||
"IRC_SERVER",
|
||||
"IRC_PORT",
|
||||
"IRC_USE_TLS",
|
||||
"IRC_CHANNEL",
|
||||
"IRC_NICK",
|
||||
"IRC_SEARCH_BOT",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def get_onboarding_steps() -> list[dict[str, Any]]:
|
||||
"""Return the full onboarding step configuration."""
|
||||
return [
|
||||
{
|
||||
"id": "search_mode",
|
||||
"title": "Search Mode",
|
||||
"tab": "search_mode",
|
||||
"get_fields": get_search_mode_fields,
|
||||
},
|
||||
{
|
||||
"id": "metadata_provider",
|
||||
"title": "Metadata Provider",
|
||||
"tab": "search_mode",
|
||||
"get_fields": get_metadata_provider_fields,
|
||||
"show_when": [{"field": "SEARCH_MODE", "value": "universal"}],
|
||||
},
|
||||
{
|
||||
"id": "hardcover_setup",
|
||||
"title": "Hardcover Setup",
|
||||
"tab": "hardcover",
|
||||
"get_fields": get_hardcover_setup_fields,
|
||||
"show_when": [
|
||||
{"field": "SEARCH_MODE", "value": "universal"},
|
||||
{"field": "METADATA_PROVIDER", "value": "hardcover"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "googlebooks_setup",
|
||||
"title": "Google Books Setup",
|
||||
"tab": "googlebooks",
|
||||
"get_fields": get_googlebooks_setup_fields,
|
||||
"show_when": [
|
||||
{"field": "SEARCH_MODE", "value": "universal"},
|
||||
{"field": "METADATA_PROVIDER", "value": "googlebooks"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "release_sources",
|
||||
"title": "Release Sources",
|
||||
"tab": "search_mode",
|
||||
"get_fields": get_release_source_selection_fields,
|
||||
"show_when": [{"field": "SEARCH_MODE", "value": "universal"}],
|
||||
"optional": True,
|
||||
},
|
||||
{
|
||||
"id": "direct_download_setup_direct_mode",
|
||||
"title": "Direct Download Setup",
|
||||
"tab": "download_sources",
|
||||
"get_fields": get_direct_download_setup_fields,
|
||||
"show_when": [{"field": "SEARCH_MODE", "value": "direct"}],
|
||||
},
|
||||
{
|
||||
"id": "direct_download_cloudflare_bypass_direct_mode",
|
||||
"title": "Cloudflare Bypass",
|
||||
"tab": "cloudflare_bypass",
|
||||
"get_fields": get_direct_download_bypass_fields,
|
||||
"show_when": [{"field": "SEARCH_MODE", "value": "direct"}],
|
||||
},
|
||||
{
|
||||
"id": "direct_download_setup",
|
||||
"title": "Direct Download Setup",
|
||||
"tab": "download_sources",
|
||||
"get_fields": get_direct_download_setup_fields,
|
||||
"show_when": [
|
||||
{"field": "SEARCH_MODE", "value": "universal"},
|
||||
{"field": ONBOARDING_RELEASE_SOURCES_KEY, "value": "direct_download"},
|
||||
],
|
||||
"optional": True,
|
||||
},
|
||||
{
|
||||
"id": "direct_download_cloudflare_bypass",
|
||||
"title": "Cloudflare Bypass",
|
||||
"tab": "cloudflare_bypass",
|
||||
"get_fields": get_direct_download_bypass_fields,
|
||||
"show_when": [
|
||||
{"field": "SEARCH_MODE", "value": "universal"},
|
||||
{"field": ONBOARDING_RELEASE_SOURCES_KEY, "value": "direct_download"},
|
||||
],
|
||||
"optional": True,
|
||||
},
|
||||
{
|
||||
"id": "prowlarr",
|
||||
"title": "Prowlarr",
|
||||
"tab": "prowlarr_config",
|
||||
"get_fields": get_prowlarr_fields,
|
||||
"show_when": [
|
||||
{"field": "SEARCH_MODE", "value": "universal"},
|
||||
{"field": ONBOARDING_RELEASE_SOURCES_KEY, "value": "prowlarr"},
|
||||
],
|
||||
"optional": True,
|
||||
},
|
||||
{
|
||||
"id": "audiobookbay",
|
||||
"title": "AudiobookBay",
|
||||
"tab": "audiobookbay_config",
|
||||
"get_fields": get_audiobookbay_fields,
|
||||
"show_when": [
|
||||
{"field": "SEARCH_MODE", "value": "universal"},
|
||||
{"field": ONBOARDING_RELEASE_SOURCES_KEY, "value": "audiobookbay"},
|
||||
],
|
||||
"optional": True,
|
||||
},
|
||||
{
|
||||
"id": "irc",
|
||||
"title": "IRC",
|
||||
"tab": "irc",
|
||||
"get_fields": get_irc_fields,
|
||||
"show_when": [
|
||||
{"field": "SEARCH_MODE", "value": "universal"},
|
||||
{"field": ONBOARDING_RELEASE_SOURCES_KEY, "value": "irc"},
|
||||
],
|
||||
"optional": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_onboarding_config() -> dict[str, Any]:
|
||||
"""Get the full onboarding configuration including steps and current values."""
|
||||
steps = []
|
||||
all_values = {}
|
||||
|
||||
for step_config in ONBOARDING_STEPS:
|
||||
for step_config in get_onboarding_steps():
|
||||
fields = step_config["get_fields"]()
|
||||
tab_name = step_config["tab"]
|
||||
|
||||
# Serialize fields with current values
|
||||
serialized_fields = []
|
||||
for field in fields:
|
||||
serialized = serialize_field(field, tab_name, include_value=True)
|
||||
field_tab_name = _get_field_tab_name(field, tab_name)
|
||||
serialized = serialize_field(field, field_tab_name, include_value=True)
|
||||
serialized_fields.append(serialized)
|
||||
|
||||
# Collect values (skip HeadingFields)
|
||||
if hasattr(field, 'key') and field.key and not isinstance(field, HeadingField):
|
||||
value = get_setting_value(field, tab_name)
|
||||
all_values[field.key] = value if value is not None else getattr(field, 'default', '')
|
||||
if hasattr(field, "env_supported") and getattr(field, "key", None):
|
||||
if field.key in _ONBOARDING_VIRTUAL_KEYS:
|
||||
value = getattr(field, "default", "")
|
||||
else:
|
||||
value = get_setting_value(field, field_tab_name)
|
||||
all_values[field.key] = (
|
||||
value if value is not None else getattr(field, "default", "")
|
||||
)
|
||||
|
||||
step = {
|
||||
"id": step_config["id"],
|
||||
@@ -353,22 +615,24 @@ def get_onboarding_config() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Save onboarding settings and mark as complete.
|
||||
def save_onboarding_settings(values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Save onboarding settings and mark as complete.
|
||||
|
||||
Args:
|
||||
values: Dict of field key -> value
|
||||
|
||||
Returns:
|
||||
Dict with success status and message
|
||||
|
||||
"""
|
||||
try:
|
||||
# Group values by their target tab
|
||||
tab_values: Dict[str, Dict[str, Any]] = {}
|
||||
tab_values: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for step_config in get_onboarding_steps():
|
||||
if not _is_step_visible(step_config, values):
|
||||
continue
|
||||
|
||||
for step_config in ONBOARDING_STEPS:
|
||||
tab_name = step_config["tab"]
|
||||
fields = step_config["get_fields"]()
|
||||
|
||||
for field in fields:
|
||||
@@ -376,7 +640,12 @@ def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
continue
|
||||
|
||||
key = field.key
|
||||
if key in _ONBOARDING_VIRTUAL_KEYS:
|
||||
continue
|
||||
if not _is_field_visible(field, values):
|
||||
continue
|
||||
if key in values:
|
||||
tab_name = _get_field_tab_name(field, step_config["tab"])
|
||||
if tab_name not in tab_values:
|
||||
tab_values[tab_name] = {}
|
||||
tab_values[tab_name][key] = values[key]
|
||||
@@ -385,10 +654,9 @@ def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
for tab_name, tab_data in tab_values.items():
|
||||
if tab_data:
|
||||
save_config_file(tab_name, tab_data)
|
||||
logger.info(f"Saved onboarding settings to {tab_name}: {list(tab_data.keys())}")
|
||||
logger.info("Saved onboarding settings to %s: %s", tab_name, list(tab_data.keys()))
|
||||
|
||||
# Enable the selected metadata provider
|
||||
search_mode = values.get("SEARCH_MODE", "direct")
|
||||
search_mode = values.get("SEARCH_MODE", "universal")
|
||||
if search_mode == "universal":
|
||||
provider = values.get("METADATA_PROVIDER", "hardcover")
|
||||
if provider:
|
||||
@@ -410,7 +678,52 @@ def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
provider_config["GOOGLEBOOKS_API_KEY"] = values["GOOGLEBOOKS_API_KEY"]
|
||||
|
||||
save_config_file(provider, provider_config)
|
||||
logger.info(f"Enabled metadata provider: {provider} with keys: {list(provider_config.keys())}")
|
||||
logger.info(
|
||||
"Enabled metadata provider: %s with keys: %s",
|
||||
provider,
|
||||
list(provider_config.keys()),
|
||||
)
|
||||
|
||||
selected_release_sources = values.get(ONBOARDING_RELEASE_SOURCES_KEY, [])
|
||||
if not isinstance(selected_release_sources, list):
|
||||
selected_release_sources = []
|
||||
|
||||
source_updates: dict[str, dict[str, Any]] = {}
|
||||
|
||||
if search_mode == "direct":
|
||||
source_updates.setdefault("download_sources", {})["DIRECT_DOWNLOAD_ENABLED"] = True
|
||||
else:
|
||||
if _is_release_source_selected(values, "direct_download"):
|
||||
source_updates.setdefault("download_sources", {})["DIRECT_DOWNLOAD_ENABLED"] = True
|
||||
if _is_release_source_selected(values, "prowlarr"):
|
||||
source_updates.setdefault("prowlarr_config", {})["PROWLARR_ENABLED"] = True
|
||||
if _is_release_source_selected(values, "audiobookbay"):
|
||||
source_updates.setdefault("audiobookbay_config", {})["ABB_ENABLED"] = True
|
||||
|
||||
if not values.get("DEFAULT_RELEASE_SOURCE"):
|
||||
for source_name in selected_release_sources:
|
||||
if source_name in {"direct_download", "prowlarr", "irc"}:
|
||||
source_updates.setdefault("search_mode", {})["DEFAULT_RELEASE_SOURCE"] = (
|
||||
source_name
|
||||
)
|
||||
break
|
||||
|
||||
if not values.get("DEFAULT_RELEASE_SOURCE_AUDIOBOOK"):
|
||||
for source_name in selected_release_sources:
|
||||
if source_name in {"prowlarr", "audiobookbay", "irc"}:
|
||||
source_updates.setdefault("search_mode", {})[
|
||||
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK"
|
||||
] = source_name
|
||||
break
|
||||
|
||||
for tab_name, tab_data in source_updates.items():
|
||||
if tab_data:
|
||||
save_config_file(tab_name, tab_data)
|
||||
logger.info(
|
||||
"Enabled onboarding release source settings for %s: %s",
|
||||
tab_name,
|
||||
list(tab_data.keys()),
|
||||
)
|
||||
|
||||
# Mark onboarding as complete
|
||||
mark_onboarding_complete()
|
||||
@@ -418,12 +731,13 @@ def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Refresh config
|
||||
try:
|
||||
from shelfmark.core.config import config
|
||||
|
||||
config.refresh()
|
||||
except ImportError as e:
|
||||
logger.debug(f"Could not refresh config after onboarding: {e}")
|
||||
|
||||
return {"success": True, "message": "Onboarding complete!"}
|
||||
logger.debug("Could not refresh config after onboarding: %s", e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save onboarding settings: {e}")
|
||||
logger.exception("Failed to save onboarding settings")
|
||||
return {"success": False, "message": str(e)}
|
||||
else:
|
||||
return {"success": True, "message": "Onboarding complete!"}
|
||||
|
||||
@@ -11,11 +11,18 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
_WINDOWS_DRIVE_PREFIX_LENGTH = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemotePathMapping:
|
||||
"""Mapping from a remote path prefix to a local path prefix."""
|
||||
|
||||
host: str
|
||||
remote_path: str
|
||||
local_path: str
|
||||
@@ -36,14 +43,15 @@ def _normalize_prefix(path: str) -> str:
|
||||
|
||||
def _is_windows_path(path: str) -> bool:
|
||||
"""Check if a path looks like a Windows path (has a drive letter like C:/)."""
|
||||
return len(path) >= 2 and path[1] == ":" and path[0].isalpha()
|
||||
return len(path) >= _WINDOWS_DRIVE_PREFIX_LENGTH and path[1] == ":" and path[0].isalpha()
|
||||
|
||||
|
||||
def _normalize_host(host: str) -> str:
|
||||
return str(host or "").strip().lower()
|
||||
|
||||
|
||||
def parse_remote_path_mappings(value: Any) -> list[RemotePathMapping]:
|
||||
def parse_remote_path_mappings(value: object) -> list[RemotePathMapping]:
|
||||
"""Parse configured remote-path mapping rows into normalized mappings."""
|
||||
if not value or not isinstance(value, list):
|
||||
return []
|
||||
|
||||
@@ -60,7 +68,9 @@ def parse_remote_path_mappings(value: Any) -> list[RemotePathMapping]:
|
||||
if not host or not remote_path or not local_path:
|
||||
continue
|
||||
|
||||
mappings.append(RemotePathMapping(host=host, remote_path=remote_path, local_path=local_path))
|
||||
mappings.append(
|
||||
RemotePathMapping(host=host, remote_path=remote_path, local_path=local_path)
|
||||
)
|
||||
|
||||
mappings.sort(key=lambda m: len(m.remote_path), reverse=True)
|
||||
return mappings
|
||||
@@ -72,6 +82,7 @@ def remap_remote_to_local_with_match(
|
||||
host: str,
|
||||
remote_path: str | Path,
|
||||
) -> tuple[Path, bool]:
|
||||
"""Remap a remote path and report whether a configured mapping matched."""
|
||||
host_normalized = _normalize_host(host)
|
||||
remote_normalized = _normalize_prefix(str(remote_path))
|
||||
|
||||
@@ -96,16 +107,17 @@ def remap_remote_to_local_with_match(
|
||||
prefix_lower = remote_prefix.lower()
|
||||
matches = remote_lower == prefix_lower or remote_lower.startswith(prefix_lower + "/")
|
||||
else:
|
||||
matches = remote_normalized == remote_prefix or remote_normalized.startswith(remote_prefix + "/")
|
||||
matches = remote_normalized == remote_prefix or remote_normalized.startswith(
|
||||
remote_prefix + "/"
|
||||
)
|
||||
|
||||
if matches:
|
||||
# Use the length of the original prefix to extract remainder
|
||||
# This preserves the original case in folder names
|
||||
remainder = remote_normalized[len(remote_prefix):]
|
||||
remainder = remote_normalized[len(remote_prefix) :]
|
||||
local_prefix = _normalize_prefix(mapping.local_path)
|
||||
|
||||
if remainder.startswith("/"):
|
||||
remainder = remainder[1:]
|
||||
remainder = remainder.removeprefix("/")
|
||||
|
||||
remapped = Path(local_prefix) / remainder if remainder else Path(local_prefix)
|
||||
return remapped, True
|
||||
@@ -113,7 +125,10 @@ def remap_remote_to_local_with_match(
|
||||
return Path(remote_normalized), False
|
||||
|
||||
|
||||
def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path) -> Path:
|
||||
def remap_remote_to_local(
|
||||
*, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path
|
||||
) -> Path:
|
||||
"""Remap a remote path to a local path using the configured mappings."""
|
||||
remapped, _ = remap_remote_to_local_with_match(
|
||||
mappings=mappings,
|
||||
host=host,
|
||||
@@ -122,13 +137,12 @@ def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, r
|
||||
return remapped
|
||||
|
||||
|
||||
def get_client_host_identifier(client: Any) -> Optional[str]:
|
||||
def get_client_host_identifier(client: object) -> str | None:
|
||||
"""Return a stable identifier used by the mapping UI.
|
||||
|
||||
Sonarr uses the download client's configured host. Shelfmark currently uses
|
||||
the download client 'name' (e.g. qbittorrent, sabnzbd).
|
||||
"""
|
||||
|
||||
name = getattr(client, "name", None)
|
||||
if isinstance(name, str) and name.strip():
|
||||
return name.strip().lower()
|
||||
|
||||
@@ -2,19 +2,30 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
|
||||
class PrefixMiddleware:
|
||||
"""Strip a configured URL prefix from PATH_INFO before routing."""
|
||||
|
||||
def __init__(self, app, prefix: str, bypass_paths: Optional[Iterable[str]] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
app: Callable[[dict[str, object], Callable[..., object]], object],
|
||||
prefix: str,
|
||||
bypass_paths: Iterable[str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the middleware with a prefix and optional bypass paths."""
|
||||
self.app = app
|
||||
self.prefix = prefix.rstrip("/")
|
||||
self.bypass_paths = set(bypass_paths or [])
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
path = environ.get("PATH_INFO", "") or ""
|
||||
def __call__(self, environ: dict[str, object], start_response: Callable[..., object]) -> object:
|
||||
"""Rewrite prefixed requests before handing them to the wrapped app."""
|
||||
raw_path = environ.get("PATH_INFO", "")
|
||||
path = raw_path if isinstance(raw_path, str) else str(raw_path or "")
|
||||
|
||||
if path in self.bypass_paths:
|
||||
return self.app(environ, start_response)
|
||||
@@ -24,7 +35,7 @@ class PrefixMiddleware:
|
||||
|
||||
if path == self.prefix or path.startswith(self.prefix + "/"):
|
||||
environ["SCRIPT_NAME"] = self.prefix
|
||||
environ["PATH_INFO"] = path[len(self.prefix):] or "/"
|
||||
environ["PATH_INFO"] = path[len(self.prefix) :] or "/"
|
||||
return self.app(environ, start_response)
|
||||
|
||||
start_response("404 Not Found", [("Content-Type", "text/plain")])
|
||||
|
||||
@@ -2,19 +2,46 @@
|
||||
|
||||
import queue
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Lock, Event
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from threading import Event, Lock
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.models import QueueStatus, QueueItem, DownloadTask
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import (
|
||||
TERMINAL_QUEUE_STATUSES,
|
||||
DownloadTask,
|
||||
QueueItem,
|
||||
QueueStatus,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
_QUEUE_HOOK_ERRORS = (OSError, RuntimeError, TypeError, ValueError)
|
||||
|
||||
|
||||
def _coerce_status_timeout_seconds(value: object, *, default: int) -> int:
|
||||
"""Normalize STATUS_TIMEOUT into a usable positive integer."""
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, int):
|
||||
return value if value > 0 else default
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped.isdigit():
|
||||
parsed = int(stripped)
|
||||
return parsed if parsed > 0 else default
|
||||
return default
|
||||
|
||||
|
||||
class BookQueue:
|
||||
"""Thread-safe download queue manager with priority support and cancellation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize queue state, locks, and lifecycle hooks."""
|
||||
self._queue: queue.PriorityQueue[QueueItem] = queue.PriorityQueue()
|
||||
self._lock = Lock()
|
||||
self._status: dict[str, QueueStatus] = {}
|
||||
@@ -22,19 +49,30 @@ class BookQueue:
|
||||
self._status_timestamps: dict[str, datetime] = {} # Track when each status was last updated
|
||||
self._cancel_flags: dict[str, Event] = {} # Cancellation flags for active downloads
|
||||
self._active_downloads: dict[str, bool] = {} # Track currently downloading tasks
|
||||
self._terminal_status_hook: Callable[[str, QueueStatus, DownloadTask], None] | None = None
|
||||
self._queue_hook: Callable[[str, DownloadTask], None] | None = None
|
||||
|
||||
@property
|
||||
def _status_timeout(self) -> timedelta:
|
||||
"""Get status timeout from config (allows live updates)."""
|
||||
return timedelta(seconds=app_config.get("STATUS_TIMEOUT", 3600))
|
||||
return timedelta(
|
||||
seconds=_coerce_status_timeout_seconds(
|
||||
app_config.get("STATUS_TIMEOUT", 3600),
|
||||
default=3600,
|
||||
)
|
||||
)
|
||||
|
||||
def add(self, task: DownloadTask) -> bool:
|
||||
"""Add a download task to the queue. Returns False if already exists."""
|
||||
hook: Callable[[str, DownloadTask], None] | None = None
|
||||
with self._lock:
|
||||
task_id = task.task_id
|
||||
|
||||
# Don't add if already exists and not in error/done state
|
||||
if task_id in self._status and self._status[task_id] not in [QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
||||
# Don't add if already exists and not in error/cancelled state
|
||||
if task_id in self._status and self._status[task_id] not in [
|
||||
QueueStatus.ERROR,
|
||||
QueueStatus.CANCELLED,
|
||||
]:
|
||||
return False
|
||||
|
||||
# Ensure added_time is set
|
||||
@@ -45,9 +83,16 @@ class BookQueue:
|
||||
self._queue.put(queue_item)
|
||||
self._task_data[task_id] = task
|
||||
self._update_status(task_id, QueueStatus.QUEUED)
|
||||
return True
|
||||
hook = self._queue_hook
|
||||
|
||||
def get_next(self) -> Optional[Tuple[str, Event]]:
|
||||
if hook is not None:
|
||||
try:
|
||||
hook(task_id, task)
|
||||
except _QUEUE_HOOK_ERRORS as exc:
|
||||
logger.warning("Queue hook failed while adding task %s: %s", task_id, exc)
|
||||
return True
|
||||
|
||||
def get_next(self) -> tuple[str, Event] | None:
|
||||
"""Get next task ID from queue with cancellation flag."""
|
||||
# Use iterative approach to avoid stack overflow if many items are cancelled
|
||||
while True:
|
||||
@@ -64,31 +109,68 @@ class BookQueue:
|
||||
cancel_flag = Event()
|
||||
self._cancel_flags[task_id] = cancel_flag
|
||||
self._active_downloads[task_id] = True
|
||||
|
||||
return task_id, cancel_flag
|
||||
except queue.Empty:
|
||||
return None
|
||||
else:
|
||||
return task_id, cancel_flag
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[DownloadTask]:
|
||||
def get_task(self, task_id: str) -> DownloadTask | None:
|
||||
"""Get a task by its ID."""
|
||||
with self._lock:
|
||||
return self._task_data.get(task_id)
|
||||
|
||||
def get_task_status(self, task_id: str) -> QueueStatus | None:
|
||||
"""Get queue status for a task id."""
|
||||
with self._lock:
|
||||
return self._status.get(task_id)
|
||||
|
||||
def _update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||
"""Internal method to update status and timestamp."""
|
||||
"""Update the status and timestamp for a task."""
|
||||
self._status[book_id] = status
|
||||
self._status_timestamps[book_id] = datetime.now()
|
||||
self._status_timestamps[book_id] = datetime.now(UTC)
|
||||
|
||||
def set_terminal_status_hook(
|
||||
self,
|
||||
hook: Callable[[str, QueueStatus, DownloadTask], None] | None,
|
||||
) -> None:
|
||||
"""Register a callback invoked when a task first enters a terminal status."""
|
||||
with self._lock:
|
||||
self._terminal_status_hook = hook
|
||||
|
||||
def set_queue_hook(
|
||||
self,
|
||||
hook: Callable[[str, DownloadTask], None] | None,
|
||||
) -> None:
|
||||
"""Register a callback invoked when a task is added to the queue."""
|
||||
with self._lock:
|
||||
self._queue_hook = hook
|
||||
|
||||
def update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||
"""Update status of a book in the queue."""
|
||||
hook: Callable[[str, QueueStatus, DownloadTask], None] | None = None
|
||||
hook_task: DownloadTask | None = None
|
||||
with self._lock:
|
||||
previous_status = self._status.get(book_id)
|
||||
self._update_status(book_id, status)
|
||||
|
||||
if (
|
||||
status in TERMINAL_QUEUE_STATUSES
|
||||
and previous_status != status
|
||||
and self._terminal_status_hook is not None
|
||||
):
|
||||
current_task = self._task_data.get(book_id)
|
||||
if current_task is not None:
|
||||
hook = self._terminal_status_hook
|
||||
hook_task = current_task
|
||||
|
||||
# Clean up active download tracking when finished
|
||||
if status in [QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
||||
if status in TERMINAL_QUEUE_STATUSES:
|
||||
self._active_downloads.pop(book_id, None)
|
||||
self._cancel_flags.pop(book_id, None)
|
||||
|
||||
if hook is not None and hook_task is not None:
|
||||
hook(book_id, status, hook_task)
|
||||
|
||||
def update_download_path(self, task_id: str, download_path: str) -> None:
|
||||
"""Update the download path of a task in the queue."""
|
||||
with self._lock:
|
||||
@@ -107,17 +189,28 @@ class BookQueue:
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].status_message = message
|
||||
|
||||
def get_status(self) -> Dict[QueueStatus, Dict[str, DownloadTask]]:
|
||||
"""Get current queue status grouped by status."""
|
||||
def get_status(self, user_id: int | None = None) -> dict[QueueStatus, dict[str, DownloadTask]]:
|
||||
"""Get current queue status grouped by status.
|
||||
|
||||
Args:
|
||||
user_id: If provided, only return tasks belonging to this user.
|
||||
If None, return all.
|
||||
|
||||
"""
|
||||
self.refresh()
|
||||
with self._lock:
|
||||
result: Dict[QueueStatus, Dict[str, DownloadTask]] = {status: {} for status in QueueStatus}
|
||||
result: dict[QueueStatus, dict[str, DownloadTask]] = {
|
||||
status: {} for status in QueueStatus
|
||||
}
|
||||
for task_id, status in self._status.items():
|
||||
if task_id in self._task_data:
|
||||
result[status][task_id] = self._task_data[task_id]
|
||||
task = self._task_data[task_id]
|
||||
if user_id is not None and task.user_id != user_id:
|
||||
continue
|
||||
result[status][task_id] = task
|
||||
return result
|
||||
|
||||
def get_queue_order(self) -> List[Dict[str, Any]]:
|
||||
def get_queue_order(self) -> list[dict[str, Any]]:
|
||||
"""Get current queue order for display."""
|
||||
with self._lock:
|
||||
queue_items = []
|
||||
@@ -125,55 +218,47 @@ class BookQueue:
|
||||
# Get items from priority queue without removing them
|
||||
temp_items = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
temp_items.append(item)
|
||||
task_id = item.book_id # QueueItem uses book_id as the ID field
|
||||
if task_id in self._task_data:
|
||||
task = self._task_data[task_id]
|
||||
queue_items.append({
|
||||
'id': task_id,
|
||||
'title': task.title,
|
||||
'author': task.author,
|
||||
'priority': item.priority,
|
||||
'added_time': item.added_time,
|
||||
'status': self._status.get(task_id, QueueStatus.QUEUED)
|
||||
})
|
||||
except queue.Empty:
|
||||
break
|
||||
item = self._queue.get_nowait()
|
||||
temp_items.append(item)
|
||||
task_id = item.book_id # QueueItem uses book_id as the ID field
|
||||
if task_id in self._task_data:
|
||||
task = self._task_data[task_id]
|
||||
queue_items.append(
|
||||
{
|
||||
"id": task_id,
|
||||
"title": task.title,
|
||||
"author": task.author,
|
||||
"priority": item.priority,
|
||||
"added_time": item.added_time,
|
||||
"status": self._status.get(task_id, QueueStatus.QUEUED),
|
||||
}
|
||||
)
|
||||
|
||||
# Put items back in queue
|
||||
for item in temp_items:
|
||||
self._queue.put(item)
|
||||
|
||||
return sorted(queue_items, key=lambda x: (x['priority'], x['added_time']))
|
||||
return sorted(queue_items, key=lambda x: (x["priority"], x["added_time"]))
|
||||
|
||||
def cancel_download(self, task_id: str) -> bool:
|
||||
"""Cancel a download or clear a completed/errored item."""
|
||||
"""Cancel an active or queued download."""
|
||||
with self._lock:
|
||||
current_status = self._status.get(task_id)
|
||||
|
||||
# Allow cancellation during any active state
|
||||
if current_status in [QueueStatus.RESOLVING, QueueStatus.DOWNLOADING]:
|
||||
if current_status in [
|
||||
QueueStatus.RESOLVING,
|
||||
QueueStatus.LOCATING,
|
||||
QueueStatus.DOWNLOADING,
|
||||
]:
|
||||
# Signal active download to stop
|
||||
if task_id in self._cancel_flags:
|
||||
self._cancel_flags[task_id].set()
|
||||
self._update_status(task_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
elif current_status == QueueStatus.QUEUED:
|
||||
# Remove from queue and mark as cancelled
|
||||
self._update_status(task_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
elif current_status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
|
||||
# Clear completed/errored/cancelled items from tracking
|
||||
self._status.pop(task_id, None)
|
||||
self._status_timestamps.pop(task_id, None)
|
||||
self._task_data.pop(task_id, None)
|
||||
self._cancel_flags.pop(task_id, None)
|
||||
self._active_downloads.pop(task_id, None)
|
||||
return True
|
||||
elif current_status != QueueStatus.QUEUED:
|
||||
# Not in a cancellable state
|
||||
return False
|
||||
|
||||
return False
|
||||
self.update_status(task_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
|
||||
def set_priority(self, task_id: str, new_priority: int) -> bool:
|
||||
"""Change the priority of a queued task (lower = higher priority)."""
|
||||
@@ -186,20 +271,17 @@ class BookQueue:
|
||||
found = False
|
||||
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
if item.book_id == task_id: # QueueItem uses book_id as the ID field
|
||||
# Create new item with updated priority
|
||||
new_item = QueueItem(task_id, new_priority, item.added_time)
|
||||
temp_items.append(new_item)
|
||||
found = True
|
||||
# Update task data priority
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].priority = new_priority
|
||||
else:
|
||||
temp_items.append(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
item = self._queue.get_nowait()
|
||||
if item.book_id == task_id: # QueueItem uses book_id as the ID field
|
||||
# Create new item with updated priority
|
||||
new_item = QueueItem(task_id, new_priority, item.added_time)
|
||||
temp_items.append(new_item)
|
||||
found = True
|
||||
# Update task data priority
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].priority = new_priority
|
||||
else:
|
||||
temp_items.append(item)
|
||||
|
||||
# Put all items back
|
||||
for item in temp_items:
|
||||
@@ -207,25 +289,64 @@ class BookQueue:
|
||||
|
||||
return found
|
||||
|
||||
def reorder_queue(self, task_priorities: Dict[str, int]) -> bool:
|
||||
def enqueue_existing(self, task_id: str, *, priority: int | None = None) -> bool:
|
||||
"""Requeue an existing task regardless of current status.
|
||||
|
||||
This is used for retries where task metadata should be preserved.
|
||||
"""
|
||||
hook: Callable[[str, DownloadTask], None] | None = None
|
||||
hook_task: DownloadTask | None = None
|
||||
with self._lock:
|
||||
task = self._task_data.get(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
|
||||
if priority is not None:
|
||||
task.priority = priority
|
||||
|
||||
# Ensure task doesn't appear active while waiting for retry.
|
||||
self._active_downloads.pop(task_id, None)
|
||||
self._cancel_flags.pop(task_id, None)
|
||||
|
||||
# De-duplicate queue entries for this task id.
|
||||
temp_items: list[QueueItem] = []
|
||||
while not self._queue.empty():
|
||||
item = self._queue.get_nowait()
|
||||
if item.book_id != task_id:
|
||||
temp_items.append(item)
|
||||
|
||||
for item in temp_items:
|
||||
self._queue.put(item)
|
||||
|
||||
queue_item = QueueItem(task_id, task.priority, time.time())
|
||||
self._queue.put(queue_item)
|
||||
self._update_status(task_id, QueueStatus.QUEUED)
|
||||
hook = self._queue_hook
|
||||
hook_task = task
|
||||
|
||||
if hook is not None and hook_task is not None:
|
||||
try:
|
||||
hook(task_id, hook_task)
|
||||
except _QUEUE_HOOK_ERRORS as exc:
|
||||
logger.warning("Queue hook failed while requeueing task %s: %s", task_id, exc)
|
||||
return True
|
||||
|
||||
def reorder_queue(self, task_priorities: dict[str, int]) -> bool:
|
||||
"""Bulk reorder queue by mapping task_id to new priority."""
|
||||
with self._lock:
|
||||
# Extract all items from queue
|
||||
all_items = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
task_id = item.book_id # QueueItem uses book_id as the ID field
|
||||
# Update priority if specified
|
||||
if task_id in task_priorities:
|
||||
new_priority = task_priorities[task_id]
|
||||
item = QueueItem(task_id, new_priority, item.added_time)
|
||||
# Update task data priority
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].priority = new_priority
|
||||
all_items.append(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
item = self._queue.get_nowait()
|
||||
task_id = item.book_id # QueueItem uses book_id as the ID field
|
||||
# Update priority if specified
|
||||
if task_id in task_priorities:
|
||||
new_priority = task_priorities[task_id]
|
||||
item = QueueItem(task_id, new_priority, item.added_time)
|
||||
# Update task data priority
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].priority = new_priority
|
||||
all_items.append(item)
|
||||
|
||||
# Put all items back with updated priorities
|
||||
for item in all_items:
|
||||
@@ -233,7 +354,7 @@ class BookQueue:
|
||||
|
||||
return True
|
||||
|
||||
def get_active_downloads(self) -> List[str]:
|
||||
def get_active_downloads(self) -> list[str]:
|
||||
"""Get list of currently active download task IDs."""
|
||||
with self._lock:
|
||||
return list(self._active_downloads.keys())
|
||||
@@ -245,26 +366,11 @@ class BookQueue:
|
||||
return True
|
||||
return any(status == QueueStatus.QUEUED for status in self._status.values())
|
||||
|
||||
def clear_completed(self) -> int:
|
||||
"""Remove all completed, errored, or cancelled tasks from tracking."""
|
||||
terminal_statuses = {QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED}
|
||||
with self._lock:
|
||||
to_remove = [task_id for task_id, status in self._status.items() if status in terminal_statuses]
|
||||
|
||||
for task_id in to_remove:
|
||||
self._status.pop(task_id, None)
|
||||
self._status_timestamps.pop(task_id, None)
|
||||
self._task_data.pop(task_id, None)
|
||||
self._cancel_flags.pop(task_id, None)
|
||||
self._active_downloads.pop(task_id, None)
|
||||
|
||||
return len(to_remove)
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Remove any tasks that are done downloading or have stale status."""
|
||||
terminal_statuses = {QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.AVAILABLE, QueueStatus.CANCELLED}
|
||||
terminal_statuses = TERMINAL_QUEUE_STATUSES
|
||||
with self._lock:
|
||||
current_time = datetime.now()
|
||||
current_time = datetime.now(UTC)
|
||||
to_remove = []
|
||||
|
||||
for task_id, status in self._status.items():
|
||||
@@ -276,15 +382,14 @@ class BookQueue:
|
||||
if task.download_path and not Path(task.download_path).exists():
|
||||
task.download_path = None
|
||||
|
||||
# Mark available downloads as done if file is gone
|
||||
if status == QueueStatus.AVAILABLE and not task.download_path:
|
||||
self._update_status(task_id, QueueStatus.DONE)
|
||||
|
||||
# Check for stale status entries
|
||||
last_update = self._status_timestamps.get(task_id)
|
||||
if last_update and (current_time - last_update) > self._status_timeout:
|
||||
if status in terminal_statuses:
|
||||
to_remove.append(task_id)
|
||||
if (
|
||||
last_update
|
||||
and (current_time - last_update) > self._status_timeout
|
||||
and status in terminal_statuses
|
||||
):
|
||||
to_remove.append(task_id)
|
||||
|
||||
# Remove stale entries
|
||||
for task_id in to_remove:
|
||||
@@ -292,5 +397,6 @@ class BookQueue:
|
||||
self._status_timestamps.pop(task_id, None)
|
||||
self._task_data.pop(task_id, None)
|
||||
|
||||
|
||||
# Global instance of BookQueue
|
||||
book_queue = BookQueue()
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Shared request-related helper functions used by routes and services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol, SupportsIndex, SupportsInt, TypeGuard
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
_logger = setup_logger(__name__)
|
||||
|
||||
type _ConvertibleToInt = str | bytes | bytearray | SupportsInt | SupportsIndex
|
||||
|
||||
|
||||
class _MappingWithGet(Protocol):
|
||||
"""Minimal mapping protocol for session-like objects."""
|
||||
|
||||
def get(self, key: str, default: object = None, /) -> object: ...
|
||||
|
||||
|
||||
class _UserDBLike(Protocol):
|
||||
"""Minimal user DB protocol for username population helpers."""
|
||||
|
||||
def get_user(self, *, user_id: int) -> dict[str, Any] | None: ...
|
||||
|
||||
|
||||
def _is_mapping_with_get(candidate: object) -> TypeGuard[_MappingWithGet]:
|
||||
"""Return True when *candidate* exposes a mapping-style get method."""
|
||||
return callable(getattr(candidate, "get", None))
|
||||
|
||||
|
||||
def _is_user_db_like(candidate: object) -> TypeGuard[_UserDBLike]:
|
||||
"""Return True when *candidate* exposes the user lookup API we need."""
|
||||
return callable(getattr(candidate, "get_user", None))
|
||||
|
||||
|
||||
def _is_convertible_to_int(value: object) -> TypeGuard[_ConvertibleToInt]:
|
||||
"""Return True when *value* can be passed to ``int`` safely."""
|
||||
return (
|
||||
isinstance(value, (str, bytes, bytearray))
|
||||
or hasattr(value, "__int__")
|
||||
or hasattr(value, "__index__")
|
||||
)
|
||||
|
||||
|
||||
def now_utc_iso() -> str:
|
||||
"""Return the current UTC time as a seconds-precision ISO 8601 string."""
|
||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def emit_ws_event(
|
||||
ws_manager: object,
|
||||
*,
|
||||
event_name: str,
|
||||
payload: dict[str, Any],
|
||||
room: str,
|
||||
) -> None:
|
||||
"""Emit a WebSocket event via the shared manager, swallowing failures."""
|
||||
if ws_manager is None:
|
||||
return
|
||||
try:
|
||||
socketio = getattr(ws_manager, "socketio", None)
|
||||
is_enabled = getattr(ws_manager, "is_enabled", None)
|
||||
if socketio is None or not callable(is_enabled) or not is_enabled():
|
||||
return
|
||||
socketio.emit(event_name, payload, to=room)
|
||||
except (AttributeError, RuntimeError, TypeError, ValueError) as exc:
|
||||
_logger.warning(
|
||||
"Failed to emit WebSocket event '%s' to room '%s': %s",
|
||||
event_name,
|
||||
room,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def load_users_request_policy_settings() -> dict[str, Any]:
|
||||
"""Load global request-policy settings from the users config file."""
|
||||
from shelfmark.core.request_policy import REQUEST_POLICY_KEYS
|
||||
|
||||
return {key: app_config.get(key) for key in REQUEST_POLICY_KEYS}
|
||||
|
||||
|
||||
def coerce_bool(value: object, *, default: bool = False) -> bool:
|
||||
"""Coerce arbitrary values into booleans with string-friendly semantics."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off", ""}:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def get_session_db_user_id(session_obj: object) -> int | None:
|
||||
"""Extract and coerce `db_user_id` from a Flask session to ``int | None``."""
|
||||
raw = session_obj.get("db_user_id") if _is_mapping_with_get(session_obj) else None
|
||||
try:
|
||||
return int(raw) if raw is not None and _is_convertible_to_int(raw) else None
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def coerce_int(value: object, default: int) -> int:
|
||||
"""Best-effort integer coercion with fallback to default."""
|
||||
if not _is_convertible_to_int(value):
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except TypeError, ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def normalize_optional_text(value: object) -> str | None:
|
||||
"""Return a trimmed string or None for empty/non-string input."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def normalize_positive_int(value: object) -> int | None:
|
||||
"""Parse *value* as a positive integer, returning ``None`` on failure."""
|
||||
if not _is_convertible_to_int(value):
|
||||
return None
|
||||
try:
|
||||
parsed = int(value)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def normalize_optional_positive_int(value: object, field_name: str = "value") -> int | None:
|
||||
"""Parse *value* as a positive integer or ``None``.
|
||||
|
||||
Raises ``ValueError`` when *value* is present but not a valid
|
||||
positive integer.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if not _is_convertible_to_int(value):
|
||||
msg = f"{field_name} must be a positive integer when provided"
|
||||
raise ValueError(msg)
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
msg = f"{field_name} must be a positive integer when provided"
|
||||
raise ValueError(msg) from exc
|
||||
if parsed < 1:
|
||||
msg = f"{field_name} must be a positive integer when provided"
|
||||
raise ValueError(msg)
|
||||
return parsed
|
||||
|
||||
|
||||
def populate_request_usernames(rows: list[dict[str, Any]], user_db: object) -> None:
|
||||
"""Add 'username' to each request row by looking up user_id."""
|
||||
if not _is_user_db_like(user_db):
|
||||
return
|
||||
|
||||
cache: dict[int, str] = {}
|
||||
for row in rows:
|
||||
requester_id = normalize_positive_int(row.get("user_id"))
|
||||
if requester_id is None:
|
||||
row["username"] = ""
|
||||
continue
|
||||
if requester_id not in cache:
|
||||
requester = user_db.get_user(user_id=requester_id)
|
||||
cache[requester_id] = requester.get("username", "") if requester else ""
|
||||
row["username"] = cache[requester_id]
|
||||
|
||||
|
||||
def extract_release_source_id(release_data: object) -> str | None:
|
||||
"""Extract and normalize release_data.source_id."""
|
||||
if not isinstance(release_data, dict):
|
||||
return None
|
||||
source_id = release_data.get("source_id")
|
||||
if not isinstance(source_id, str):
|
||||
return None
|
||||
normalized = source_id.strip()
|
||||
return normalized or None
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Request-policy resolution helpers.
|
||||
|
||||
This module is intentionally pure and side-effect free so it can be reused by
|
||||
routes/services and tested independently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class PolicyMode(StrEnum):
|
||||
"""Allowed request-policy modes.
|
||||
|
||||
Ordered from most to least permissive. The content-type default acts as a
|
||||
ceiling — matrix rules can only match or restrict further, never upgrade
|
||||
beyond the default.
|
||||
"""
|
||||
|
||||
DOWNLOAD = "download"
|
||||
REQUEST_RELEASE = "request_release"
|
||||
REQUEST_BOOK = "request_book"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
|
||||
# Permissiveness ordering: lower index = more permissive.
|
||||
_MODE_PERMISSIVENESS: dict[PolicyMode, int] = {
|
||||
PolicyMode.DOWNLOAD: 0,
|
||||
PolicyMode.REQUEST_RELEASE: 1,
|
||||
PolicyMode.REQUEST_BOOK: 2,
|
||||
PolicyMode.BLOCKED: 3,
|
||||
}
|
||||
|
||||
# Modes allowed in REQUEST_POLICY_RULES matrix rows.
|
||||
MATRIX_ALLOWED_MODES = frozenset(
|
||||
{PolicyMode.DOWNLOAD, PolicyMode.REQUEST_RELEASE, PolicyMode.BLOCKED}
|
||||
)
|
||||
|
||||
|
||||
def cap_mode(mode: PolicyMode, ceiling: PolicyMode) -> PolicyMode:
|
||||
"""Cap a resolved mode so it cannot be more permissive than the ceiling."""
|
||||
if _MODE_PERMISSIVENESS[mode] < _MODE_PERMISSIVENESS[ceiling]:
|
||||
return ceiling
|
||||
return mode
|
||||
|
||||
|
||||
def _source_results_are_releases(source: Any) -> bool:
|
||||
normalized_source = normalize_source(source)
|
||||
if normalized_source in {"", "*"}:
|
||||
return False
|
||||
from shelfmark.release_sources import source_results_are_releases
|
||||
|
||||
return source_results_are_releases(normalized_source)
|
||||
|
||||
|
||||
def _normalize_release_result_mode(source: Any, mode: PolicyMode) -> PolicyMode:
|
||||
"""Concrete release browse results cannot fall back to request_book semantics."""
|
||||
if mode == PolicyMode.REQUEST_BOOK and _source_results_are_releases(source):
|
||||
return PolicyMode.REQUEST_RELEASE
|
||||
return mode
|
||||
|
||||
|
||||
REQUEST_POLICY_KEYS = frozenset(
|
||||
{
|
||||
"REQUESTS_ENABLED",
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
"REQUEST_POLICY_RULES",
|
||||
"MAX_PENDING_REQUESTS_PER_USER",
|
||||
"REQUESTS_ALLOW_NOTES",
|
||||
}
|
||||
)
|
||||
|
||||
REQUEST_POLICY_DEFAULT_FALLBACK_MODE = PolicyMode.REQUEST_BOOK
|
||||
|
||||
DEFAULT_SUPPORTED_CONTENT_TYPES = ("ebook", "audiobook")
|
||||
|
||||
|
||||
def filter_request_policy_settings(settings: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return only uppercase request-policy keys from a settings JSON object."""
|
||||
if not isinstance(settings, Mapping):
|
||||
return {}
|
||||
return {key: settings[key] for key in REQUEST_POLICY_KEYS if key in settings}
|
||||
|
||||
|
||||
def merge_request_policy_settings(
|
||||
global_settings: Mapping[str, Any] | None,
|
||||
user_settings: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge global settings with per-user request-policy overrides."""
|
||||
merged = filter_request_policy_settings(global_settings)
|
||||
user_filtered = filter_request_policy_settings(user_settings)
|
||||
|
||||
# Preserve global rules by default and treat user rules as per-cell overlays.
|
||||
# This allows per-user REQUEST_POLICY_RULES payloads to store only explicit
|
||||
# differences instead of replacing the full global matrix.
|
||||
global_rules = list(_iter_rules(merged.get("REQUEST_POLICY_RULES", [])))
|
||||
user_has_rules = "REQUEST_POLICY_RULES" in user_filtered
|
||||
|
||||
for key, value in user_filtered.items():
|
||||
if key == "REQUEST_POLICY_RULES":
|
||||
continue
|
||||
merged[key] = value
|
||||
|
||||
if user_has_rules:
|
||||
merged_rules: dict[tuple[str, str], tuple[str, str, PolicyMode]] = {
|
||||
(source, content_type): (source, content_type, mode)
|
||||
for source, content_type, mode in global_rules
|
||||
}
|
||||
for source, content_type, mode in _iter_rules(
|
||||
user_filtered.get("REQUEST_POLICY_RULES", [])
|
||||
):
|
||||
merged_rules[(source, content_type)] = (source, content_type, mode)
|
||||
merged["REQUEST_POLICY_RULES"] = [
|
||||
{"source": source, "content_type": content_type, "mode": mode.value}
|
||||
for source, content_type, mode in merged_rules.values()
|
||||
]
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def normalize_content_type(content_type: Any) -> str:
|
||||
"""Normalize arbitrary content type values to `ebook` or `audiobook`."""
|
||||
if not isinstance(content_type, str):
|
||||
return "ebook"
|
||||
|
||||
value = content_type.strip().lower()
|
||||
if not value:
|
||||
return "ebook"
|
||||
|
||||
if value in {"audiobook", "audiobooks", "audio", "book (audiobook)"}:
|
||||
return "audiobook"
|
||||
|
||||
return "ebook"
|
||||
|
||||
|
||||
def normalize_source(source: Any) -> str:
|
||||
"""Normalize source values for policy matching."""
|
||||
if not isinstance(source, str):
|
||||
return "*"
|
||||
|
||||
value = source.strip().lower()
|
||||
return value or "*"
|
||||
|
||||
|
||||
def parse_policy_mode(mode: Any) -> PolicyMode | None:
|
||||
"""Parse an arbitrary mode value into a PolicyMode enum member."""
|
||||
if isinstance(mode, PolicyMode):
|
||||
return mode
|
||||
if not isinstance(mode, str):
|
||||
return None
|
||||
try:
|
||||
return PolicyMode(mode.strip().lower())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_rule_content_type(content_type: Any) -> str | None:
|
||||
if not isinstance(content_type, str):
|
||||
return None
|
||||
value = content_type.strip().lower()
|
||||
if not value:
|
||||
return None
|
||||
if value in {"*", "any"}:
|
||||
return "*"
|
||||
if value in {"ebook", "book", "books", "book (fiction)"}:
|
||||
return "ebook"
|
||||
if value in {"audiobook", "audiobooks", "audio", "book (audiobook)"}:
|
||||
return "audiobook"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_rule_source(source: Any) -> str | None:
|
||||
if not isinstance(source, str):
|
||||
return None
|
||||
value = source.strip().lower()
|
||||
if not value:
|
||||
return None
|
||||
if value in {"*", "any"}:
|
||||
return "*"
|
||||
return value
|
||||
|
||||
|
||||
def get_source_content_type_capabilities() -> dict[str, set[str]]:
|
||||
"""Return source -> supported content type map from registered sources."""
|
||||
try:
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
except ImportError:
|
||||
return {}
|
||||
|
||||
capabilities: dict[str, set[str]] = {}
|
||||
for source in list_available_sources():
|
||||
raw_name = source.get("name")
|
||||
name = normalize_source(raw_name)
|
||||
if not name or name == "*":
|
||||
continue
|
||||
|
||||
raw_types = source.get("supported_content_types", DEFAULT_SUPPORTED_CONTENT_TYPES)
|
||||
if isinstance(raw_types, str) or not isinstance(raw_types, Sequence):
|
||||
raw_types = DEFAULT_SUPPORTED_CONTENT_TYPES
|
||||
|
||||
normalized_types: set[str] = set()
|
||||
for content_type in raw_types:
|
||||
normalized_type = _normalize_rule_content_type(content_type)
|
||||
if normalized_type and normalized_type != "*":
|
||||
normalized_types.add(normalized_type)
|
||||
|
||||
if not normalized_types:
|
||||
normalized_types = set(DEFAULT_SUPPORTED_CONTENT_TYPES)
|
||||
capabilities[name] = normalized_types
|
||||
return capabilities
|
||||
|
||||
|
||||
def validate_policy_rules(
|
||||
rules: Any,
|
||||
source_capabilities: Mapping[str, set[str]] | None = None,
|
||||
) -> tuple[list[dict[str, str]], list[str]]:
|
||||
"""Validate and normalize policy rule rows.
|
||||
|
||||
Validation covers:
|
||||
- row shape and required keys
|
||||
- valid mode/content_type values
|
||||
- known source names
|
||||
- source/content-type compatibility from source declarations
|
||||
"""
|
||||
capabilities = (
|
||||
source_capabilities
|
||||
if source_capabilities is not None
|
||||
else get_source_content_type_capabilities()
|
||||
)
|
||||
normalized_capabilities = {
|
||||
normalize_source(source): {
|
||||
normalize_content_type(content_type) for content_type in content_types
|
||||
}
|
||||
for source, content_types in capabilities.items()
|
||||
}
|
||||
|
||||
normalized_rules: list[dict[str, str]] = []
|
||||
errors: list[str] = []
|
||||
|
||||
if rules is None:
|
||||
return normalized_rules, errors
|
||||
if not isinstance(rules, list):
|
||||
return normalized_rules, ["REQUEST_POLICY_RULES must be a list"]
|
||||
|
||||
for index, rule in enumerate(rules):
|
||||
row_label = f"Rule {index + 1}"
|
||||
if not isinstance(rule, Mapping):
|
||||
errors.append(f"{row_label}: must be an object")
|
||||
continue
|
||||
|
||||
source = _normalize_rule_source(rule.get("source"))
|
||||
raw_content_type = rule.get("content_type")
|
||||
content_type = _normalize_rule_content_type(rule.get("content_type"))
|
||||
raw_mode = rule.get("mode")
|
||||
mode = parse_policy_mode(rule.get("mode"))
|
||||
|
||||
if source is None:
|
||||
errors.append(f"{row_label}: source is required")
|
||||
continue
|
||||
if raw_content_type is None or (
|
||||
isinstance(raw_content_type, str) and not raw_content_type.strip()
|
||||
):
|
||||
errors.append(f"{row_label}: content_type is required")
|
||||
continue
|
||||
if content_type is None:
|
||||
errors.append(f"{row_label}: invalid content_type '{rule.get('content_type')}'")
|
||||
continue
|
||||
if raw_mode is None or (isinstance(raw_mode, str) and not raw_mode.strip()):
|
||||
errors.append(f"{row_label}: mode is required")
|
||||
continue
|
||||
if mode is None:
|
||||
errors.append(f"{row_label}: invalid mode '{rule.get('mode')}'")
|
||||
continue
|
||||
if mode not in MATRIX_ALLOWED_MODES:
|
||||
errors.append(
|
||||
f"{row_label}: mode '{mode.value}' is not allowed in matrix rules (use content-type defaults instead)"
|
||||
)
|
||||
continue
|
||||
|
||||
if source != "*" and source not in normalized_capabilities:
|
||||
errors.append(f"{row_label}: unknown source '{source}'")
|
||||
continue
|
||||
|
||||
if (
|
||||
source != "*"
|
||||
and content_type != "*"
|
||||
and source in normalized_capabilities
|
||||
and content_type not in normalized_capabilities[source]
|
||||
):
|
||||
errors.append(
|
||||
f"{row_label}: source '{source}' does not support content_type '{content_type}'"
|
||||
)
|
||||
continue
|
||||
|
||||
normalized_rules.append(
|
||||
{
|
||||
"source": source,
|
||||
"content_type": content_type,
|
||||
"mode": mode.value,
|
||||
}
|
||||
)
|
||||
|
||||
return normalized_rules, errors
|
||||
|
||||
|
||||
def _iter_rules(rules: Any) -> Iterable[tuple[str, str, PolicyMode]]:
|
||||
if not isinstance(rules, list):
|
||||
return []
|
||||
|
||||
normalized: list[tuple[str, str, PolicyMode]] = []
|
||||
for rule in rules:
|
||||
if not isinstance(rule, Mapping):
|
||||
continue
|
||||
source = _normalize_rule_source(rule.get("source"))
|
||||
content_type = _normalize_rule_content_type(rule.get("content_type"))
|
||||
mode = parse_policy_mode(rule.get("mode"))
|
||||
if (
|
||||
source is None
|
||||
or content_type is None
|
||||
or mode is None
|
||||
or mode not in MATRIX_ALLOWED_MODES
|
||||
):
|
||||
continue
|
||||
normalized.append((source, content_type, mode))
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_policy_mode(
|
||||
*,
|
||||
source: Any,
|
||||
content_type: Any,
|
||||
global_settings: Mapping[str, Any] | None,
|
||||
user_settings: Mapping[str, Any] | None = None,
|
||||
) -> PolicyMode:
|
||||
"""Resolve an effective policy mode for a request context.
|
||||
|
||||
Resolution:
|
||||
1. Resolve the content-type default (ceiling).
|
||||
2. Match rules in specificity order.
|
||||
3. Cap the matched rule at the ceiling.
|
||||
4. If no rule matches, return the ceiling.
|
||||
|
||||
The content-type default acts as a ceiling — matrix rules can only
|
||||
match or restrict further, never upgrade beyond the default.
|
||||
|
||||
Concrete-release browse exception:
|
||||
- sources whose browse results are already concrete releases normalize
|
||||
request_book to request_release.
|
||||
"""
|
||||
effective = merge_request_policy_settings(global_settings, user_settings)
|
||||
normalized_source = normalize_source(source)
|
||||
normalized_content_type = normalize_content_type(content_type)
|
||||
|
||||
# Resolve the content-type default (ceiling)
|
||||
default_key = (
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK"
|
||||
if normalized_content_type == "audiobook"
|
||||
else "REQUEST_POLICY_DEFAULT_EBOOK"
|
||||
)
|
||||
default_mode = parse_policy_mode(effective.get(default_key))
|
||||
ceiling = default_mode if default_mode is not None else REQUEST_POLICY_DEFAULT_FALLBACK_MODE
|
||||
|
||||
# Match rules in specificity order
|
||||
rules = tuple(_iter_rules(effective.get("REQUEST_POLICY_RULES", [])))
|
||||
candidates = (
|
||||
(normalized_source, normalized_content_type),
|
||||
(normalized_source, "*"),
|
||||
("*", normalized_content_type),
|
||||
("*", "*"),
|
||||
)
|
||||
for candidate_source, candidate_content_type in candidates:
|
||||
for rule_source, rule_content_type, rule_mode in rules:
|
||||
if rule_source == candidate_source and rule_content_type == candidate_content_type:
|
||||
return _normalize_release_result_mode(
|
||||
normalized_source,
|
||||
cap_mode(rule_mode, ceiling),
|
||||
)
|
||||
|
||||
return _normalize_release_result_mode(normalized_source, ceiling)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Shared request validation and normalization helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from shelfmark.core.models import QueueStatus
|
||||
from shelfmark.core.request_policy import parse_policy_mode
|
||||
|
||||
|
||||
class RequestStatus(StrEnum):
|
||||
"""Enum for request lifecycle statuses."""
|
||||
|
||||
PENDING = "pending"
|
||||
FULFILLED = "fulfilled"
|
||||
REJECTED = "rejected"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
DELIVERY_STATE_NONE = "none"
|
||||
|
||||
VALID_REQUEST_STATUSES = frozenset(RequestStatus)
|
||||
TERMINAL_REQUEST_STATUSES = frozenset(
|
||||
{
|
||||
RequestStatus.FULFILLED,
|
||||
RequestStatus.REJECTED,
|
||||
RequestStatus.CANCELLED,
|
||||
}
|
||||
)
|
||||
VALID_REQUEST_LEVELS = frozenset({"book", "release"})
|
||||
VALID_DELIVERY_STATES = frozenset({DELIVERY_STATE_NONE} | set(QueueStatus))
|
||||
|
||||
|
||||
def normalize_request_status(status: object) -> str:
|
||||
"""Validate and normalize request status values."""
|
||||
if not isinstance(status, str):
|
||||
msg = f"Invalid request status: {status}"
|
||||
raise TypeError(msg)
|
||||
normalized = status.strip().lower()
|
||||
if normalized not in VALID_REQUEST_STATUSES:
|
||||
msg = f"Invalid request status: {status}"
|
||||
raise ValueError(msg)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_policy_mode(mode: object) -> str:
|
||||
"""Validate and normalize policy mode values."""
|
||||
parsed = parse_policy_mode(mode)
|
||||
if parsed is None:
|
||||
msg = f"Invalid policy_mode: {mode}"
|
||||
raise ValueError(msg)
|
||||
return parsed.value
|
||||
|
||||
|
||||
def normalize_request_level(request_level: object) -> str:
|
||||
"""Validate and normalize request level values."""
|
||||
if not isinstance(request_level, str):
|
||||
msg = f"Invalid request_level: {request_level}"
|
||||
raise TypeError(msg)
|
||||
normalized = request_level.strip().lower()
|
||||
if normalized not in VALID_REQUEST_LEVELS:
|
||||
msg = f"Invalid request_level: {request_level}"
|
||||
raise ValueError(msg)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_delivery_state(state: object) -> str:
|
||||
"""Validate and normalize delivery-state values."""
|
||||
if not isinstance(state, str):
|
||||
msg = f"Invalid delivery_state: {state}"
|
||||
raise TypeError(msg)
|
||||
normalized = state.strip().lower()
|
||||
if normalized not in VALID_DELIVERY_STATES:
|
||||
msg = f"Invalid delivery_state: {state}"
|
||||
raise ValueError(msg)
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_request_level_payload(request_level: object, release_data: object) -> str:
|
||||
"""Validate request_level and release_data shape coupling."""
|
||||
normalized_level = normalize_request_level(request_level)
|
||||
if normalized_level == "release" and release_data is None:
|
||||
msg = "request_level=release requires non-null release_data"
|
||||
raise ValueError(msg)
|
||||
if normalized_level == "book" and release_data is not None:
|
||||
msg = "request_level=book requires null release_data"
|
||||
raise ValueError(msg)
|
||||
return normalized_level
|
||||
|
||||
|
||||
def validate_status_transition(current_status: object, new_status: object) -> tuple[str, str]:
|
||||
"""Validate request status transitions and terminal immutability."""
|
||||
current = normalize_request_status(current_status)
|
||||
new = normalize_request_status(new_status)
|
||||
if current in TERMINAL_REQUEST_STATUSES and new != current:
|
||||
msg = "Terminal request statuses are immutable"
|
||||
raise ValueError(msg)
|
||||
return current, new
|
||||
@@ -0,0 +1,625 @@
|
||||
"""Request lifecycle helpers and service-level validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.models import QueueStatus
|
||||
from shelfmark.core.request_helpers import (
|
||||
extract_release_source_id,
|
||||
normalize_positive_int,
|
||||
)
|
||||
from shelfmark.core.request_policy import normalize_content_type
|
||||
from shelfmark.core.request_validation import (
|
||||
DELIVERY_STATE_NONE,
|
||||
RequestStatus,
|
||||
normalize_policy_mode,
|
||||
validate_request_level_payload,
|
||||
)
|
||||
|
||||
MAX_REQUEST_NOTE_LENGTH = 1000
|
||||
MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
class RequestServiceError(ValueError):
|
||||
"""Structured error raised by request lifecycle service methods."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int = 400,
|
||||
code: str | None = None,
|
||||
required_mode: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the error with HTTP metadata for API callers."""
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
self.required_mode = required_mode
|
||||
|
||||
|
||||
def _normalize_match_text(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def normalize_note(note: object) -> str | None:
|
||||
"""Validate request notes and normalize empty strings to None."""
|
||||
if note is None:
|
||||
return None
|
||||
if not isinstance(note, str):
|
||||
msg = "note must be a string"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
normalized = note.strip()
|
||||
if len(normalized) > MAX_REQUEST_NOTE_LENGTH:
|
||||
msg_0 = f"note must be <= {MAX_REQUEST_NOTE_LENGTH} characters"
|
||||
raise RequestServiceError(
|
||||
msg_0,
|
||||
status_code=400,
|
||||
)
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _validate_book_data(book_data: object) -> dict[str, Any]:
|
||||
if not isinstance(book_data, dict):
|
||||
msg = "book_data must be an object"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
|
||||
required_fields = ("title", "author", "provider", "provider_id")
|
||||
missing = [
|
||||
field for field in required_fields if not _normalize_match_text(book_data.get(field))
|
||||
]
|
||||
if missing:
|
||||
msg_0 = f"book_data missing required field(s): {', '.join(missing)}"
|
||||
raise RequestServiceError(
|
||||
msg_0,
|
||||
status_code=400,
|
||||
)
|
||||
return dict(book_data)
|
||||
|
||||
|
||||
def _validate_json_blob_size(field: str, payload: object) -> None:
|
||||
if payload is None:
|
||||
return
|
||||
|
||||
try:
|
||||
serialized = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
except (TypeError, ValueError) as exc:
|
||||
msg = f"{field} must be JSON-serializable"
|
||||
raise RequestServiceError(msg, status_code=400) from exc
|
||||
|
||||
payload_size = len(serialized.encode("utf-8"))
|
||||
if payload_size > MAX_REQUEST_JSON_BLOB_BYTES:
|
||||
msg = f"{field} must be <= {MAX_REQUEST_JSON_BLOB_BYTES} bytes"
|
||||
raise RequestServiceError(
|
||||
msg,
|
||||
status_code=400,
|
||||
code="request_payload_too_large",
|
||||
)
|
||||
|
||||
|
||||
def _find_duplicate_pending_request(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
user_id: int,
|
||||
title: str,
|
||||
author: str,
|
||||
content_type: str,
|
||||
) -> dict[str, Any] | None:
|
||||
pending_rows = user_db.list_requests(user_id=user_id, status=RequestStatus.PENDING)
|
||||
for row in pending_rows:
|
||||
row_book_data = row.get("book_data") or {}
|
||||
if not isinstance(row_book_data, dict):
|
||||
continue
|
||||
|
||||
row_title = _normalize_match_text(row_book_data.get("title"))
|
||||
row_author = _normalize_match_text(row_book_data.get("author"))
|
||||
row_content_type = normalize_content_type(
|
||||
row.get("content_type") or row_book_data.get("content_type")
|
||||
)
|
||||
if row_title == title and row_author == author and row_content_type == content_type:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _now_timestamp() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _normalize_admin_note(admin_note: object) -> str | None:
|
||||
if admin_note is None:
|
||||
return None
|
||||
if not isinstance(admin_note, str):
|
||||
msg = "admin_note must be a string"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
return admin_note.strip() or None
|
||||
|
||||
|
||||
def _prepare_request_create(
|
||||
*,
|
||||
user_id: int,
|
||||
source_hint: str | None,
|
||||
content_type: object,
|
||||
request_level: object,
|
||||
policy_mode: object,
|
||||
book_data: object,
|
||||
release_data: object = None,
|
||||
note: object = None,
|
||||
) -> dict[str, Any]:
|
||||
validated_book_data = _validate_book_data(book_data)
|
||||
normalized_note = normalize_note(note)
|
||||
normalized_content_type = normalize_content_type(
|
||||
content_type or validated_book_data.get("content_type")
|
||||
)
|
||||
validated_book_data["content_type"] = normalized_content_type
|
||||
|
||||
try:
|
||||
normalized_request_level = validate_request_level_payload(request_level, release_data)
|
||||
normalized_policy_mode = normalize_policy_mode(policy_mode)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
|
||||
_validate_json_blob_size("book_data", validated_book_data)
|
||||
_validate_json_blob_size("release_data", release_data)
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"source_hint": source_hint,
|
||||
"content_type": normalized_content_type,
|
||||
"request_level": normalized_request_level,
|
||||
"policy_mode": normalized_policy_mode,
|
||||
"book_data": validated_book_data,
|
||||
"release_data": release_data,
|
||||
"note": normalized_note,
|
||||
}
|
||||
|
||||
|
||||
def sync_delivery_states_from_queue_status(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
queue_status: dict[str, dict[str, Any]],
|
||||
user_id: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Persist delivery-state transitions for fulfilled requests based on queue status."""
|
||||
fulfilled_rows = user_db.list_requests(user_id=user_id, status=RequestStatus.FULFILLED)
|
||||
if not fulfilled_rows:
|
||||
return []
|
||||
|
||||
unique_request_ids_by_source: dict[str, int] = {}
|
||||
ambiguous_source_ids: set[str] = set()
|
||||
for row in fulfilled_rows:
|
||||
source_id = extract_release_source_id(row.get("release_data"))
|
||||
if source_id is None:
|
||||
continue
|
||||
if source_id in unique_request_ids_by_source:
|
||||
ambiguous_source_ids.add(source_id)
|
||||
continue
|
||||
unique_request_ids_by_source[source_id] = int(row["id"])
|
||||
for source_id in ambiguous_source_ids:
|
||||
unique_request_ids_by_source.pop(source_id, None)
|
||||
|
||||
request_delivery_states: dict[int, str] = {}
|
||||
request_delivery_payloads: dict[int, dict[str, Any]] = {}
|
||||
for status_key in QueueStatus:
|
||||
status_bucket = queue_status.get(status_key)
|
||||
if not isinstance(status_bucket, dict):
|
||||
continue
|
||||
for source_id, task_payload in status_bucket.items():
|
||||
request_id = None
|
||||
if isinstance(task_payload, dict):
|
||||
request_id = normalize_positive_int(task_payload.get("request_id"))
|
||||
if request_id is None:
|
||||
request_id = unique_request_ids_by_source.get(str(source_id).strip())
|
||||
if request_id is None:
|
||||
continue
|
||||
request_delivery_states[request_id] = status_key
|
||||
if isinstance(task_payload, dict):
|
||||
request_delivery_payloads[request_id] = dict(task_payload)
|
||||
|
||||
if not request_delivery_states:
|
||||
return []
|
||||
updated: list[dict[str, Any]] = []
|
||||
|
||||
for row in fulfilled_rows:
|
||||
request_id = int(row["id"])
|
||||
delivery_state = request_delivery_states.get(request_id)
|
||||
if delivery_state is None:
|
||||
continue
|
||||
|
||||
task_payload = request_delivery_payloads.get(request_id) or {}
|
||||
retry_available = task_payload.get("retry_available")
|
||||
if delivery_state == QueueStatus.ERROR and retry_available is False:
|
||||
raw_status_message = task_payload.get("status_message")
|
||||
failure_reason = (
|
||||
raw_status_message.strip()
|
||||
if isinstance(raw_status_message, str) and raw_status_message.strip()
|
||||
else "Download failed"
|
||||
)
|
||||
reopened = user_db.reopen_failed_request(
|
||||
request_id,
|
||||
failure_reason=failure_reason,
|
||||
)
|
||||
if reopened is not None:
|
||||
updated.append(reopened)
|
||||
continue
|
||||
|
||||
if row.get("delivery_state", DELIVERY_STATE_NONE) == delivery_state:
|
||||
continue
|
||||
|
||||
updated.append(
|
||||
user_db.update_request(
|
||||
request_id,
|
||||
delivery_state=delivery_state,
|
||||
delivery_updated_at=_now_timestamp(),
|
||||
)
|
||||
)
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
def create_request(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
user_id: int,
|
||||
source_hint: str | None,
|
||||
content_type: object,
|
||||
request_level: object,
|
||||
policy_mode: object,
|
||||
book_data: object,
|
||||
release_data: object = None,
|
||||
note: object = None,
|
||||
max_pending_per_user: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a pending request after service-level validation."""
|
||||
prepared_request = _prepare_request_create(
|
||||
user_id=user_id,
|
||||
source_hint=source_hint,
|
||||
content_type=content_type,
|
||||
request_level=request_level,
|
||||
policy_mode=policy_mode,
|
||||
book_data=book_data,
|
||||
release_data=release_data,
|
||||
note=note,
|
||||
)
|
||||
|
||||
if max_pending_per_user is not None:
|
||||
pending_count = user_db.count_user_pending_requests(user_id)
|
||||
if pending_count >= max_pending_per_user:
|
||||
msg = "Maximum pending requests reached for this user"
|
||||
raise RequestServiceError(
|
||||
msg,
|
||||
status_code=409,
|
||||
code="max_pending_reached",
|
||||
)
|
||||
|
||||
duplicate = _find_duplicate_pending_request(
|
||||
user_db,
|
||||
user_id=user_id,
|
||||
title=_normalize_match_text(prepared_request["book_data"].get("title")),
|
||||
author=_normalize_match_text(prepared_request["book_data"].get("author")),
|
||||
content_type=prepared_request["content_type"],
|
||||
)
|
||||
if duplicate is not None:
|
||||
msg = "Duplicate pending request exists for this title/author/content_type"
|
||||
raise RequestServiceError(
|
||||
msg,
|
||||
status_code=409,
|
||||
code="duplicate_pending_request",
|
||||
)
|
||||
|
||||
try:
|
||||
return user_db.create_request(**prepared_request)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
|
||||
|
||||
def create_requests(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
requests: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Create multiple pending requests atomically after validation."""
|
||||
if not isinstance(requests, list) or len(requests) == 0:
|
||||
msg = "requests must contain at least one request"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
|
||||
prepared_requests: list[dict[str, Any]] = []
|
||||
pending_counts_by_user: dict[int, int] = {}
|
||||
seen_request_keys: set[tuple[int, str, str, str]] = set()
|
||||
|
||||
for request in requests:
|
||||
if not isinstance(request, dict):
|
||||
msg = "requests must contain objects"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
|
||||
user_id = int(request["user_id"])
|
||||
prepared_request = _prepare_request_create(
|
||||
user_id=user_id,
|
||||
source_hint=request.get("source_hint"),
|
||||
content_type=request.get("content_type"),
|
||||
request_level=request.get("request_level"),
|
||||
policy_mode=request.get("policy_mode"),
|
||||
book_data=request.get("book_data"),
|
||||
release_data=request.get("release_data"),
|
||||
note=request.get("note"),
|
||||
)
|
||||
|
||||
request_key = (
|
||||
user_id,
|
||||
_normalize_match_text(prepared_request["book_data"].get("title")),
|
||||
_normalize_match_text(prepared_request["book_data"].get("author")),
|
||||
prepared_request["content_type"],
|
||||
)
|
||||
if request_key in seen_request_keys:
|
||||
msg = "Duplicate pending request exists for this title/author/content_type"
|
||||
raise RequestServiceError(
|
||||
msg,
|
||||
status_code=409,
|
||||
code="duplicate_pending_request",
|
||||
)
|
||||
seen_request_keys.add(request_key)
|
||||
|
||||
max_pending_per_user = request.get("max_pending_per_user")
|
||||
if max_pending_per_user is not None:
|
||||
existing_pending = pending_counts_by_user.get(user_id)
|
||||
if existing_pending is None:
|
||||
existing_pending = user_db.count_user_pending_requests(user_id)
|
||||
if existing_pending >= max_pending_per_user:
|
||||
msg = "Maximum pending requests reached for this user"
|
||||
raise RequestServiceError(
|
||||
msg,
|
||||
status_code=409,
|
||||
code="max_pending_reached",
|
||||
)
|
||||
pending_counts_by_user[user_id] = existing_pending + 1
|
||||
|
||||
duplicate = _find_duplicate_pending_request(
|
||||
user_db,
|
||||
user_id=user_id,
|
||||
title=request_key[1],
|
||||
author=request_key[2],
|
||||
content_type=request_key[3],
|
||||
)
|
||||
if duplicate is not None:
|
||||
msg = "Duplicate pending request exists for this title/author/content_type"
|
||||
raise RequestServiceError(
|
||||
msg,
|
||||
status_code=409,
|
||||
code="duplicate_pending_request",
|
||||
)
|
||||
|
||||
prepared_requests.append(prepared_request)
|
||||
|
||||
try:
|
||||
return user_db.create_requests(prepared_requests)
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
|
||||
|
||||
def ensure_request_access(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
request_id: int,
|
||||
actor_user_id: int | None,
|
||||
is_admin: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Get request by ID and enforce ownership for non-admin actors."""
|
||||
request_row = user_db.get_request(request_id)
|
||||
if request_row is None:
|
||||
msg = "Request not found"
|
||||
raise RequestServiceError(msg, status_code=404)
|
||||
|
||||
if not is_admin and (actor_user_id is None or request_row["user_id"] != actor_user_id):
|
||||
msg = "Forbidden"
|
||||
raise RequestServiceError(msg, status_code=403)
|
||||
|
||||
return request_row
|
||||
|
||||
|
||||
def _require_pending(request_row: dict[str, Any]) -> None:
|
||||
if request_row["status"] != RequestStatus.PENDING:
|
||||
msg = "Request is already in a terminal state"
|
||||
raise RequestServiceError(
|
||||
msg,
|
||||
status_code=409,
|
||||
code="stale_transition",
|
||||
)
|
||||
|
||||
|
||||
def cancel_request(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
request_id: int,
|
||||
actor_user_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Cancel a pending request owned by the actor."""
|
||||
request_row = ensure_request_access(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
actor_user_id=actor_user_id,
|
||||
is_admin=False,
|
||||
)
|
||||
_require_pending(request_row)
|
||||
|
||||
try:
|
||||
return user_db.update_request(
|
||||
request_id,
|
||||
expected_current_status=RequestStatus.PENDING,
|
||||
status=RequestStatus.CANCELLED,
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
|
||||
def reject_request(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
request_id: int,
|
||||
admin_user_id: int,
|
||||
admin_note: object = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reject a pending request as admin."""
|
||||
request_row = ensure_request_access(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
actor_user_id=admin_user_id,
|
||||
is_admin=True,
|
||||
)
|
||||
_require_pending(request_row)
|
||||
|
||||
normalized_admin_note = _normalize_admin_note(admin_note)
|
||||
|
||||
try:
|
||||
return user_db.update_request(
|
||||
request_id,
|
||||
expected_current_status=RequestStatus.PENDING,
|
||||
status=RequestStatus.REJECTED,
|
||||
admin_note=normalized_admin_note,
|
||||
reviewed_by=admin_user_id,
|
||||
reviewed_at=_now_timestamp(),
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
|
||||
def fulfil_request(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
request_id: int,
|
||||
admin_user_id: int,
|
||||
queue_release: Callable[..., tuple[bool, str | None]],
|
||||
release_data: object = None,
|
||||
admin_note: object = None,
|
||||
manual_approval: object = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Fulfil a pending request and queue the release under requesting-user identity."""
|
||||
request_row = ensure_request_access(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
actor_user_id=admin_user_id,
|
||||
is_admin=True,
|
||||
)
|
||||
_require_pending(request_row)
|
||||
|
||||
normalized_admin_note = _normalize_admin_note(admin_note)
|
||||
|
||||
if not isinstance(manual_approval, bool):
|
||||
msg = "manual_approval must be a boolean"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
|
||||
selected_release_data = (
|
||||
release_data if release_data is not None else request_row.get("release_data")
|
||||
)
|
||||
if selected_release_data is not None and not isinstance(selected_release_data, dict):
|
||||
msg = "release_data must be an object"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
|
||||
if selected_release_data is None and manual_approval:
|
||||
try:
|
||||
return user_db.update_request(
|
||||
request_id,
|
||||
expected_current_status=RequestStatus.PENDING,
|
||||
status=RequestStatus.FULFILLED,
|
||||
release_data=None,
|
||||
delivery_state=QueueStatus.COMPLETE,
|
||||
delivery_updated_at=_now_timestamp(),
|
||||
last_failure_reason=None,
|
||||
admin_note=normalized_admin_note,
|
||||
reviewed_by=admin_user_id,
|
||||
reviewed_at=_now_timestamp(),
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
if selected_release_data is None:
|
||||
msg = "release_data is required to fulfil requests"
|
||||
raise RequestServiceError(
|
||||
msg,
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
_validate_json_blob_size("release_data", selected_release_data)
|
||||
|
||||
requester = user_db.get_user(user_id=request_row["user_id"])
|
||||
if requester is None:
|
||||
msg = "Requesting user not found"
|
||||
raise RequestServiceError(msg, status_code=404)
|
||||
|
||||
original_release_data = request_row.get("release_data")
|
||||
try:
|
||||
claimed_request = user_db.update_request(
|
||||
request_id,
|
||||
expected_current_status=RequestStatus.PENDING,
|
||||
status=RequestStatus.FULFILLED,
|
||||
release_data=selected_release_data,
|
||||
delivery_state=QueueStatus.QUEUED,
|
||||
delivery_updated_at=_now_timestamp(),
|
||||
last_failure_reason=None,
|
||||
admin_note=normalized_admin_note,
|
||||
reviewed_by=admin_user_id,
|
||||
reviewed_at=_now_timestamp(),
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
queued_release_data = dict(selected_release_data)
|
||||
queued_release_data["_request_id"] = request_id
|
||||
|
||||
try:
|
||||
success, error = queue_release(
|
||||
queued_release_data,
|
||||
0,
|
||||
user_id=request_row["user_id"],
|
||||
username=requester.get("username"),
|
||||
)
|
||||
except Exception:
|
||||
user_db.rollback_request_fulfilment(
|
||||
request_id,
|
||||
release_data=original_release_data,
|
||||
last_failure_reason="Queue dispatch raised an exception",
|
||||
)
|
||||
raise
|
||||
if not success:
|
||||
user_db.rollback_request_fulfilment(
|
||||
request_id,
|
||||
release_data=original_release_data,
|
||||
last_failure_reason=error,
|
||||
)
|
||||
raise RequestServiceError(
|
||||
error or "Failed to queue release",
|
||||
status_code=409,
|
||||
code="queue_failed",
|
||||
)
|
||||
|
||||
return claimed_request
|
||||
|
||||
|
||||
def reopen_failed_request(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
request_id: int,
|
||||
failure_reason: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Reopen a failed fulfilled request so admins can re-approve with a new release."""
|
||||
return user_db.reopen_failed_request(
|
||||
request_id,
|
||||
failure_reason=failure_reason,
|
||||
)
|
||||
@@ -1,17 +1,23 @@
|
||||
"""Helpers for building release search plans from metadata and user input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
MANUAL_QUERY_MAX_LEN = 256
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
group_languages_by_localized_title,
|
||||
build_localized_search_titles,
|
||||
group_languages_by_localized_title,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.models import SearchFilters
|
||||
|
||||
MANUAL_QUERY_MAX_LEN = 256
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseSearchVariant:
|
||||
@@ -19,10 +25,11 @@ class ReleaseSearchVariant:
|
||||
|
||||
title: str
|
||||
author: str
|
||||
languages: Optional[List[str]] = None
|
||||
languages: list[str] | None = None
|
||||
|
||||
@property
|
||||
def query(self) -> str:
|
||||
"""Return the combined title-and-author query for this variant."""
|
||||
return " ".join(part for part in [self.title, self.author] if part).strip()
|
||||
|
||||
|
||||
@@ -30,26 +37,33 @@ class ReleaseSearchVariant:
|
||||
class ReleaseSearchPlan:
|
||||
"""Pre-computed search inputs shared across release sources."""
|
||||
|
||||
languages: Optional[List[str]]
|
||||
isbn_candidates: List[str]
|
||||
languages: list[str] | None
|
||||
isbn_candidates: list[str]
|
||||
author: str
|
||||
title_variants: List[ReleaseSearchVariant]
|
||||
grouped_title_variants: List[ReleaseSearchVariant]
|
||||
manual_query: Optional[str] = None
|
||||
title_variants: list[ReleaseSearchVariant]
|
||||
grouped_title_variants: list[ReleaseSearchVariant]
|
||||
manual_query: str | None = None
|
||||
indexers: list[str] | None = None # Indexer names for Prowlarr (overrides settings)
|
||||
source_filters: SearchFilters | None = None
|
||||
|
||||
@property
|
||||
def primary_query(self) -> str:
|
||||
"""Return the first expanded title query, if one exists."""
|
||||
return self.title_variants[0].query if self.title_variants else ""
|
||||
|
||||
|
||||
def _normalize_languages(languages: Optional[List[str]]) -> Optional[List[str]]:
|
||||
def _normalize_languages(languages: list[str] | None) -> list[str] | None:
|
||||
if not languages:
|
||||
default = config.BOOK_LANGUAGE
|
||||
if not default:
|
||||
default = getattr(config, "BOOK_LANGUAGE", None)
|
||||
if isinstance(default, str):
|
||||
default_values: list[object] = [default]
|
||||
elif isinstance(default, Iterable) and not isinstance(default, (bytes, bytearray, dict)):
|
||||
default_values = list(default)
|
||||
else:
|
||||
return None
|
||||
return [str(lang).strip() for lang in default if str(lang).strip()]
|
||||
return [str(lang).strip() for lang in default_values if str(lang).strip()]
|
||||
|
||||
normalized: List[str] = []
|
||||
normalized: list[str] = []
|
||||
for lang in languages:
|
||||
if not lang:
|
||||
continue
|
||||
@@ -84,9 +98,12 @@ def _pick_search_title(book: BookMetadata) -> str:
|
||||
|
||||
def build_release_search_plan(
|
||||
book: BookMetadata,
|
||||
languages: Optional[List[str]] = None,
|
||||
manual_query: Optional[str] = None,
|
||||
languages: list[str] | None = None,
|
||||
manual_query: str | None = None,
|
||||
indexers: list[str] | None = None,
|
||||
source_filters: SearchFilters | None = None,
|
||||
) -> ReleaseSearchPlan:
|
||||
"""Build normalized search variants shared across release sources."""
|
||||
resolved_languages = _normalize_languages(languages)
|
||||
|
||||
resolved_manual_query = None
|
||||
@@ -106,9 +123,11 @@ def build_release_search_plan(
|
||||
title_variants=[variant],
|
||||
grouped_title_variants=[variant],
|
||||
manual_query=resolved_manual_query,
|
||||
indexers=indexers,
|
||||
source_filters=source_filters,
|
||||
)
|
||||
|
||||
isbn_candidates: List[str] = []
|
||||
isbn_candidates: list[str] = []
|
||||
if book.isbn_13:
|
||||
isbn_candidates.append(book.isbn_13)
|
||||
if book.isbn_10 and book.isbn_10 not in isbn_candidates:
|
||||
@@ -128,7 +147,7 @@ def build_release_search_plan(
|
||||
titles_by_language=titles_by_language,
|
||||
)
|
||||
|
||||
grouped_variants: List[ReleaseSearchVariant] = [
|
||||
grouped_variants: list[ReleaseSearchVariant] = [
|
||||
ReleaseSearchVariant(title=title, author=author, languages=langs)
|
||||
for title, langs in grouped
|
||||
if title
|
||||
@@ -141,7 +160,7 @@ def build_release_search_plan(
|
||||
excluded_languages={"en", "eng", "english"},
|
||||
)
|
||||
|
||||
title_variants: List[ReleaseSearchVariant] = [
|
||||
title_variants: list[ReleaseSearchVariant] = [
|
||||
ReleaseSearchVariant(title=title, author=author, languages=None)
|
||||
for title in expanded_titles
|
||||
if title
|
||||
@@ -150,8 +169,7 @@ def build_release_search_plan(
|
||||
# If no titles could be built, fall back to ISBN queries.
|
||||
if not title_variants and isbn_candidates:
|
||||
title_variants = [
|
||||
ReleaseSearchVariant(title=isbn, author="", languages=None)
|
||||
for isbn in isbn_candidates
|
||||
ReleaseSearchVariant(title=isbn, author="", languages=None) for isbn in isbn_candidates
|
||||
]
|
||||
|
||||
return ReleaseSearchPlan(
|
||||
@@ -161,4 +179,6 @@ def build_release_search_plan(
|
||||
title_variants=title_variants,
|
||||
grouped_title_variants=grouped_variants,
|
||||
manual_query=None,
|
||||
indexers=indexers,
|
||||
source_filters=source_filters,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Self-service user account routes."""
|
||||
|
||||
import sqlite3
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from flask import Flask, Response, g, jsonify, request, session
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shelfmark.config.env import CWA_DB_PATH
|
||||
from shelfmark.core.admin_settings_routes import (
|
||||
build_user_notification_test_response,
|
||||
validate_user_settings,
|
||||
)
|
||||
from shelfmark.core.auth_modes import (
|
||||
AUTH_SOURCE_BUILTIN,
|
||||
AUTH_SOURCE_CWA,
|
||||
AUTH_SOURCE_OIDC,
|
||||
AUTH_SOURCE_PROXY,
|
||||
is_user_active_for_auth_mode,
|
||||
load_active_auth_mode,
|
||||
normalize_auth_source,
|
||||
)
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.user_settings_overrides import (
|
||||
build_user_preferences_payload as _build_user_preferences_payload,
|
||||
)
|
||||
from shelfmark.core.user_settings_overrides import (
|
||||
get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Mapping
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
MIN_PASSWORD_LENGTH = 4
|
||||
_VISIBLE_SELF_SETTINGS_SECTIONS_KEY = "VISIBLE_SELF_SETTINGS_SECTIONS"
|
||||
_SELF_SETTINGS_SECTION_DELIVERY = "delivery"
|
||||
_SELF_SETTINGS_SECTION_SEARCH = "search"
|
||||
_SELF_SETTINGS_SECTION_NOTIFICATIONS = "notifications"
|
||||
_VALID_SELF_SETTINGS_SECTIONS = (
|
||||
_SELF_SETTINGS_SECTION_DELIVERY,
|
||||
_SELF_SETTINGS_SECTION_SEARCH,
|
||||
_SELF_SETTINGS_SECTION_NOTIFICATIONS,
|
||||
)
|
||||
_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS = list(_VALID_SELF_SETTINGS_SECTIONS)
|
||||
_USER_PREFERENCES_FALLBACK_ERRORS = (ImportError, OSError, RuntimeError, TypeError, sqlite3.Error)
|
||||
_CONFIG_REFRESH_ERRORS = (ImportError, OSError, RuntimeError, TypeError, ValueError)
|
||||
|
||||
|
||||
def _get_current_user(
|
||||
user_db: UserDB,
|
||||
) -> tuple[int | None, dict[str, Any] | None, tuple[Response, int] | None]:
|
||||
raw_user_id = session.get("db_user_id")
|
||||
if raw_user_id is None:
|
||||
return None, None, (jsonify({"error": "Invalid user context"}), 400)
|
||||
try:
|
||||
user_id = int(raw_user_id)
|
||||
except TypeError, ValueError:
|
||||
return None, None, (jsonify({"error": "Invalid user context"}), 400)
|
||||
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return None, None, (jsonify({"error": "User not found"}), 404)
|
||||
return user_id, user, None
|
||||
|
||||
|
||||
def _get_self_edit_capabilities(user: Mapping[str, Any]) -> dict[str, Any]:
|
||||
auth_source = normalize_auth_source(
|
||||
user.get("auth_source"),
|
||||
user.get("oidc_subject"),
|
||||
)
|
||||
|
||||
return {
|
||||
"authSource": auth_source,
|
||||
"canSetPassword": auth_source == AUTH_SOURCE_BUILTIN,
|
||||
"canEditRole": False,
|
||||
"canEditEmail": auth_source in {AUTH_SOURCE_BUILTIN, AUTH_SOURCE_PROXY},
|
||||
"canEditDisplayName": auth_source != AUTH_SOURCE_OIDC,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_self_user(user: Mapping[str, Any], auth_mode: str) -> dict[str, Any]:
|
||||
payload = dict(user)
|
||||
payload.pop("password_hash", None)
|
||||
payload["auth_source"] = normalize_auth_source(
|
||||
payload.get("auth_source"),
|
||||
payload.get("oidc_subject"),
|
||||
)
|
||||
payload["is_active"] = is_user_active_for_auth_mode(payload, auth_mode)
|
||||
payload["edit_capabilities"] = _get_self_edit_capabilities(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _build_optional_user_preferences(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
user_id: int,
|
||||
tab_name: str,
|
||||
missing_tab_error: str,
|
||||
preference_label: str,
|
||||
) -> tuple[dict[str, Any] | None, tuple[Response, int] | None]:
|
||||
try:
|
||||
return _build_user_preferences_payload(user_db, user_id, tab_name), None
|
||||
except ValueError as exc:
|
||||
if str(exc) == missing_tab_error:
|
||||
return None, (jsonify({"error": missing_tab_error}), 500)
|
||||
logger.warning(
|
||||
"Failed to build user %s preferences for user_id=%s: %s",
|
||||
preference_label,
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
return None, None
|
||||
except _USER_PREFERENCES_FALLBACK_ERRORS as exc:
|
||||
logger.warning(
|
||||
"Failed to build user %s preferences for user_id=%s: %s",
|
||||
preference_label,
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
return None, None
|
||||
|
||||
|
||||
def _normalize_visible_self_settings_sections(raw_sections: object) -> list[str]:
|
||||
"""Normalize users.VISIBLE_SELF_SETTINGS_SECTIONS to a safe ordered list."""
|
||||
if raw_sections is None:
|
||||
return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS)
|
||||
|
||||
if isinstance(raw_sections, str):
|
||||
candidate_sections = [s.strip() for s in raw_sections.split(",") if s.strip()]
|
||||
elif isinstance(raw_sections, (list, tuple, set)):
|
||||
candidate_sections = [
|
||||
str(section).strip() for section in raw_sections if str(section).strip()
|
||||
]
|
||||
else:
|
||||
return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS)
|
||||
|
||||
normalized_sections: list[str] = []
|
||||
for section in candidate_sections:
|
||||
if section in _VALID_SELF_SETTINGS_SECTIONS and section not in normalized_sections:
|
||||
normalized_sections.append(section)
|
||||
|
||||
if not normalized_sections and candidate_sections:
|
||||
# Invalid non-empty config should fail-safe to showing defaults.
|
||||
return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS)
|
||||
|
||||
return normalized_sections
|
||||
|
||||
|
||||
def _get_visible_self_settings_sections() -> list[str]:
|
||||
raw_sections = app_config.get(
|
||||
_VISIBLE_SELF_SETTINGS_SECTIONS_KEY,
|
||||
list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS),
|
||||
)
|
||||
return _normalize_visible_self_settings_sections(raw_sections)
|
||||
|
||||
|
||||
def _get_allowed_self_settings_keys(visible_sections: list[str]) -> set[str]:
|
||||
allowed_keys: set[str] = set()
|
||||
visible_sections_set = set(visible_sections)
|
||||
|
||||
if _SELF_SETTINGS_SECTION_DELIVERY in visible_sections_set:
|
||||
allowed_keys |= {key for key, _field in _get_ordered_user_overridable_fields("downloads")}
|
||||
|
||||
if _SELF_SETTINGS_SECTION_SEARCH in visible_sections_set:
|
||||
allowed_keys |= {key for key, _field in _get_ordered_user_overridable_fields("search_mode")}
|
||||
|
||||
if _SELF_SETTINGS_SECTION_NOTIFICATIONS in visible_sections_set:
|
||||
allowed_keys |= {
|
||||
key for key, _field in _get_ordered_user_overridable_fields("notifications")
|
||||
}
|
||||
|
||||
return allowed_keys
|
||||
|
||||
|
||||
def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
"""Register self-service user endpoints."""
|
||||
|
||||
def _require_authenticated_user(
|
||||
f: Callable[..., Response | tuple[Response, int]],
|
||||
) -> Callable[..., Response | tuple[Response, int]]:
|
||||
"""Require an authenticated session linked to a local user row.
|
||||
|
||||
Caches the resolved auth_mode in ``g.auth_mode`` for the request.
|
||||
"""
|
||||
|
||||
@wraps(f)
|
||||
def decorated(*args: object, **kwargs: object) -> Response | tuple[Response, int]:
|
||||
auth_mode = load_active_auth_mode(CWA_DB_PATH, user_db=user_db)
|
||||
g.auth_mode = auth_mode
|
||||
if auth_mode != "none" and "user_id" not in session:
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
if "db_user_id" not in session:
|
||||
return jsonify(
|
||||
{"error": "Authenticated session is missing local user context"}
|
||||
), 403
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
@app.route("/api/users/me/edit-context", methods=["GET"])
|
||||
@_require_authenticated_user
|
||||
def users_me_edit_context() -> Response | tuple[Response, int]:
|
||||
user_id, user, user_error = _get_current_user(user_db)
|
||||
if user_error:
|
||||
return user_error
|
||||
if user_id is None or user is None:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
serialized_user = _serialize_self_user(user, g.auth_mode)
|
||||
serialized_user["settings"] = user_db.get_user_settings(user_id)
|
||||
visible_self_settings_sections = _get_visible_self_settings_sections()
|
||||
|
||||
delivery_preferences = None
|
||||
if _SELF_SETTINGS_SECTION_DELIVERY in visible_self_settings_sections:
|
||||
delivery_preferences, error_response = _build_optional_user_preferences(
|
||||
user_db,
|
||||
user_id=user_id,
|
||||
tab_name="downloads",
|
||||
missing_tab_error="Downloads settings tab not found",
|
||||
preference_label="delivery",
|
||||
)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
search_preferences = None
|
||||
if _SELF_SETTINGS_SECTION_SEARCH in visible_self_settings_sections:
|
||||
search_preferences, error_response = _build_optional_user_preferences(
|
||||
user_db,
|
||||
user_id=user_id,
|
||||
tab_name="search_mode",
|
||||
missing_tab_error="Search mode settings tab not found",
|
||||
preference_label="search",
|
||||
)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
notification_preferences = None
|
||||
if _SELF_SETTINGS_SECTION_NOTIFICATIONS in visible_self_settings_sections:
|
||||
notification_preferences, error_response = _build_optional_user_preferences(
|
||||
user_db,
|
||||
user_id=user_id,
|
||||
tab_name="notifications",
|
||||
missing_tab_error="Notifications settings tab not found",
|
||||
preference_label="notification",
|
||||
)
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
user_overridable_keys = sorted(
|
||||
set(delivery_preferences.get("keys", []) if delivery_preferences else [])
|
||||
| set(search_preferences.get("keys", []) if search_preferences else [])
|
||||
| set(notification_preferences.get("keys", []) if notification_preferences else [])
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"user": serialized_user,
|
||||
"deliveryPreferences": delivery_preferences,
|
||||
"searchPreferences": search_preferences,
|
||||
"notificationPreferences": notification_preferences,
|
||||
"userOverridableKeys": user_overridable_keys,
|
||||
"visibleUserSettingsSections": visible_self_settings_sections,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/users/me/notification-preferences/test", methods=["POST"])
|
||||
@_require_authenticated_user
|
||||
def users_me_test_notification_preferences() -> Response | tuple[Response, int]:
|
||||
user_id, _user, user_error = _get_current_user(user_db)
|
||||
if user_error:
|
||||
return user_error
|
||||
if user_id is None:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
result, status_code = build_user_notification_test_response(
|
||||
user_id=user_id,
|
||||
payload=payload,
|
||||
)
|
||||
return jsonify(result), status_code
|
||||
|
||||
@app.route("/api/users/me", methods=["PUT"])
|
||||
@_require_authenticated_user
|
||||
def users_me_update() -> Response | tuple[Response, int]:
|
||||
user_id, user, user_error = _get_current_user(user_db)
|
||||
if user_error:
|
||||
return user_error
|
||||
if user_id is None or user is None:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Request body must be a JSON object"}), 400
|
||||
|
||||
capabilities = _get_self_edit_capabilities(user)
|
||||
auth_source = capabilities["authSource"]
|
||||
|
||||
password = data.get("password", "")
|
||||
if password:
|
||||
if not capabilities["canSetPassword"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Cannot set password for {auth_source.upper()} users",
|
||||
"message": "Password authentication is only available for local users.",
|
||||
}
|
||||
), 400
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return jsonify(
|
||||
{"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}
|
||||
), 400
|
||||
user_db.update_user(user_id, password_hash=generate_password_hash(password))
|
||||
|
||||
user_fields: dict[str, Any] = {}
|
||||
if "email" in data:
|
||||
incoming_email = data.get("email")
|
||||
if incoming_email is None:
|
||||
user_fields["email"] = None
|
||||
else:
|
||||
user_fields["email"] = str(incoming_email).strip() or None
|
||||
if "display_name" in data:
|
||||
incoming_display_name = data.get("display_name")
|
||||
user_fields["display_name"] = (
|
||||
str(incoming_display_name).strip() or None
|
||||
if incoming_display_name is not None
|
||||
else None
|
||||
)
|
||||
|
||||
email_changed = "email" in user_fields and user_fields["email"] != user.get("email")
|
||||
display_name_changed = "display_name" in user_fields and user_fields[
|
||||
"display_name"
|
||||
] != user.get("display_name")
|
||||
|
||||
if email_changed and not capabilities["canEditEmail"]:
|
||||
if auth_source == AUTH_SOURCE_CWA:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change email for CWA users",
|
||||
"message": "Email is synced from Calibre-Web.",
|
||||
}
|
||||
), 400
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change email for OIDC users",
|
||||
"message": "Email is managed by your identity provider.",
|
||||
}
|
||||
), 400
|
||||
|
||||
if display_name_changed and not capabilities["canEditDisplayName"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change display name for OIDC users",
|
||||
"message": "Display name is managed by your identity provider.",
|
||||
}
|
||||
), 400
|
||||
|
||||
for field in ("email", "display_name"):
|
||||
if field in user_fields and user_fields[field] == user.get(field):
|
||||
user_fields.pop(field)
|
||||
|
||||
if user_fields:
|
||||
user_db.update_user(user_id, **user_fields)
|
||||
|
||||
if "settings" in data:
|
||||
settings_payload = data["settings"]
|
||||
if not isinstance(settings_payload, dict):
|
||||
return jsonify({"error": "Settings must be an object"}), 400
|
||||
|
||||
visible_self_settings_sections = _get_visible_self_settings_sections()
|
||||
allowed_user_settings_keys = _get_allowed_self_settings_keys(
|
||||
visible_self_settings_sections
|
||||
)
|
||||
disallowed_keys = sorted(
|
||||
key for key in settings_payload if key not in allowed_user_settings_keys
|
||||
)
|
||||
if disallowed_keys:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Some settings are admin-only",
|
||||
"details": [
|
||||
f"Setting not user-overridable: {key}" for key in disallowed_keys
|
||||
],
|
||||
}
|
||||
), 400
|
||||
|
||||
validated_settings, validation_errors = validate_user_settings(settings_payload)
|
||||
if validation_errors:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Invalid settings payload",
|
||||
"details": validation_errors,
|
||||
}
|
||||
), 400
|
||||
|
||||
user_db.set_user_settings(user_id, validated_settings)
|
||||
try:
|
||||
app_config.refresh(force=True)
|
||||
except _CONFIG_REFRESH_ERRORS as exc:
|
||||
logger.warning(
|
||||
"Updated settings for user %s but failed to refresh runtime config: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
updated = user_db.get_user(user_id=user_id)
|
||||
if not updated:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
result = _serialize_self_user(updated, g.auth_mode)
|
||||
result["settings"] = user_db.get_user_settings(user_id)
|
||||
logger.info("User %s updated their own account", user_id)
|
||||
return jsonify(result)
|
||||
@@ -0,0 +1,971 @@
|
||||
"""SQLite user database for multi-user support."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from shelfmark.core.activity_view_state_service import user_viewer_scope
|
||||
from shelfmark.core.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import QueueStatus
|
||||
from shelfmark.core.request_validation import (
|
||||
DELIVERY_STATE_NONE,
|
||||
RequestStatus,
|
||||
normalize_delivery_state,
|
||||
normalize_policy_mode,
|
||||
normalize_request_level,
|
||||
normalize_request_status,
|
||||
validate_request_level_payload,
|
||||
validate_status_transition,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_CREATE_TABLES_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
password_hash TEXT,
|
||||
oidc_subject TEXT UNIQUE,
|
||||
auth_source TEXT NOT NULL DEFAULT 'builtin',
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
delivery_state TEXT NOT NULL DEFAULT 'none',
|
||||
source_hint TEXT,
|
||||
content_type TEXT NOT NULL,
|
||||
request_level TEXT NOT NULL,
|
||||
policy_mode TEXT NOT NULL,
|
||||
book_data TEXT NOT NULL,
|
||||
release_data TEXT,
|
||||
note TEXT,
|
||||
admin_note TEXT,
|
||||
reviewed_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
reviewed_at TIMESTAMP,
|
||||
delivery_updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_user_status_created_at
|
||||
ON download_requests (user_id, status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_status_created_at
|
||||
ON download_requests (status, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT UNIQUE NOT NULL,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
username TEXT,
|
||||
request_id INTEGER,
|
||||
source TEXT NOT NULL,
|
||||
source_display_name TEXT,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
format TEXT,
|
||||
size TEXT,
|
||||
preview TEXT,
|
||||
content_type TEXT,
|
||||
origin TEXT NOT NULL DEFAULT 'direct',
|
||||
final_status TEXT NOT NULL,
|
||||
status_message TEXT,
|
||||
download_path TEXT,
|
||||
retry_payload TEXT,
|
||||
queued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
terminal_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_history_user_status
|
||||
ON download_history (user_id, final_status, terminal_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_history_recent
|
||||
ON download_history (user_id, terminal_at DESC, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_view_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
viewer_scope TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
dismissed_at TIMESTAMP,
|
||||
cleared_at TIMESTAMP,
|
||||
UNIQUE(viewer_scope, item_type, item_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_view_state_history
|
||||
ON activity_view_state (viewer_scope, dismissed_at DESC, id DESC)
|
||||
WHERE dismissed_at IS NOT NULL AND cleared_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_view_state_hidden
|
||||
ON activity_view_state (viewer_scope, item_type, item_key)
|
||||
WHERE dismissed_at IS NOT NULL;
|
||||
"""
|
||||
|
||||
|
||||
def _require_loaded_user(user: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return a loaded user row or raise when the DB insert result is inconsistent."""
|
||||
if user is None:
|
||||
msg = "Failed to load newly created user"
|
||||
raise RuntimeError(msg)
|
||||
return user
|
||||
|
||||
|
||||
def get_users_db_path(config_dir: str | None = None) -> str:
|
||||
"""Return the configured users database path."""
|
||||
root = config_dir or os.environ.get("CONFIG_DIR", "/config")
|
||||
return str(Path(root) / "users.db")
|
||||
|
||||
|
||||
def sync_builtin_admin_user(
|
||||
username: str,
|
||||
password_hash: str,
|
||||
db_path: str | None = None,
|
||||
) -> None:
|
||||
"""Ensure a local admin user exists for configured builtin credentials."""
|
||||
normalized_username = (username or "").strip()
|
||||
normalized_hash = password_hash or ""
|
||||
if not normalized_username or not normalized_hash:
|
||||
return
|
||||
|
||||
user_db = UserDB(db_path or get_users_db_path())
|
||||
user_db.initialize()
|
||||
|
||||
existing = user_db.get_user(username=normalized_username)
|
||||
if existing:
|
||||
existing_auth_source = (
|
||||
str(existing.get("auth_source") or AUTH_SOURCE_BUILTIN).strip().lower()
|
||||
)
|
||||
if existing_auth_source != AUTH_SOURCE_BUILTIN:
|
||||
logger.warning(
|
||||
"Skipped builtin admin sync for username '%s' because it belongs to auth_source='%s'",
|
||||
normalized_username,
|
||||
existing_auth_source,
|
||||
)
|
||||
return
|
||||
updates: dict[str, Any] = {}
|
||||
if existing.get("password_hash") != normalized_hash:
|
||||
updates["password_hash"] = normalized_hash
|
||||
if existing.get("role") != "admin":
|
||||
updates["role"] = "admin"
|
||||
if existing.get("auth_source") != AUTH_SOURCE_BUILTIN:
|
||||
updates["auth_source"] = AUTH_SOURCE_BUILTIN
|
||||
if updates:
|
||||
user_db.update_user(existing["id"], **updates)
|
||||
logger.info("Updated local admin user '%s' from builtin settings", normalized_username)
|
||||
return
|
||||
|
||||
user_db.create_user(
|
||||
username=normalized_username,
|
||||
password_hash=normalized_hash,
|
||||
auth_source=AUTH_SOURCE_BUILTIN,
|
||||
role="admin",
|
||||
)
|
||||
logger.info("Created local admin user '%s' from builtin settings", normalized_username)
|
||||
|
||||
|
||||
class UserDB:
|
||||
"""Thread-safe SQLite user database."""
|
||||
|
||||
_VALID_AUTH_SOURCES: ClassVar[frozenset[str]] = frozenset(AUTH_SOURCE_SET)
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
"""Initialize the user database wrapper for the given SQLite path."""
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Create database and tables if they don't exist."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.executescript(_CREATE_TABLES_SQL)
|
||||
self._migrate_auth_source_column(conn)
|
||||
self._migrate_request_delivery_columns(conn)
|
||||
self._migrate_download_history_queued_at(conn)
|
||||
self._migrate_download_history_retry_payload(conn)
|
||||
conn.commit()
|
||||
# WAL mode must be changed outside an open transaction.
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _migrate_auth_source_column(self, conn: sqlite3.Connection) -> None:
|
||||
"""Ensure users.auth_source exists and backfill historical rows."""
|
||||
columns = conn.execute("PRAGMA table_info(users)").fetchall()
|
||||
column_names = {str(col["name"]) for col in columns}
|
||||
|
||||
if "auth_source" not in column_names:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'builtin'")
|
||||
|
||||
# Backfill OIDC-origin users created before auth_source existed.
|
||||
conn.execute("UPDATE users SET auth_source = 'oidc' WHERE oidc_subject IS NOT NULL")
|
||||
# Defensive cleanup for any legacy null/blank values.
|
||||
conn.execute(
|
||||
"UPDATE users SET auth_source = 'builtin' WHERE auth_source IS NULL OR auth_source = ''"
|
||||
)
|
||||
|
||||
def _migrate_request_delivery_columns(self, conn: sqlite3.Connection) -> None:
|
||||
"""Ensure request delivery-state columns exist and backfill historical rows."""
|
||||
columns = conn.execute("PRAGMA table_info(download_requests)").fetchall()
|
||||
column_names = {str(col["name"]) for col in columns}
|
||||
|
||||
if "delivery_state" not in column_names:
|
||||
conn.execute(
|
||||
"ALTER TABLE download_requests ADD COLUMN delivery_state TEXT NOT NULL DEFAULT 'none'"
|
||||
)
|
||||
if "delivery_updated_at" not in column_names:
|
||||
conn.execute("ALTER TABLE download_requests ADD COLUMN delivery_updated_at TIMESTAMP")
|
||||
if "last_failure_reason" not in column_names:
|
||||
conn.execute("ALTER TABLE download_requests ADD COLUMN last_failure_reason TEXT")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE download_requests
|
||||
SET delivery_state = 'none'
|
||||
WHERE delivery_state IS NULL OR TRIM(delivery_state) = '' OR delivery_state IN ('unknown', 'available', 'done')
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE download_requests
|
||||
SET delivery_updated_at = COALESCE(delivery_updated_at, reviewed_at, created_at)
|
||||
WHERE delivery_state != 'none' AND delivery_updated_at IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
def _migrate_download_history_queued_at(self, conn: sqlite3.Connection) -> None:
|
||||
"""Ensure download_history.queued_at exists for queue-time recording."""
|
||||
columns = conn.execute("PRAGMA table_info(download_history)").fetchall()
|
||||
column_names = {str(col["name"]) for col in columns}
|
||||
if "queued_at" not in column_names:
|
||||
conn.execute("ALTER TABLE download_history ADD COLUMN queued_at TIMESTAMP")
|
||||
conn.execute(
|
||||
"UPDATE download_history SET queued_at = CURRENT_TIMESTAMP WHERE queued_at IS NULL"
|
||||
)
|
||||
|
||||
def _migrate_download_history_retry_payload(self, conn: sqlite3.Connection) -> None:
|
||||
"""Ensure download_history.retry_payload exists for restart-safe retries."""
|
||||
columns = conn.execute("PRAGMA table_info(download_history)").fetchall()
|
||||
column_names = {str(col["name"]) for col in columns}
|
||||
if "retry_payload" not in column_names:
|
||||
conn.execute("ALTER TABLE download_history ADD COLUMN retry_payload TEXT")
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
email: str | None = None,
|
||||
display_name: str | None = None,
|
||||
password_hash: str | None = None,
|
||||
oidc_subject: str | None = None,
|
||||
auth_source: str = "builtin",
|
||||
role: str = "user",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new user. Raises ValueError if username or oidc_subject already exists."""
|
||||
if auth_source not in self._VALID_AUTH_SOURCES:
|
||||
msg = f"Invalid auth_source: {auth_source}"
|
||||
raise ValueError(msg)
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO users (
|
||||
username, email, display_name, password_hash, oidc_subject, auth_source, role
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
username,
|
||||
email,
|
||||
display_name,
|
||||
password_hash,
|
||||
oidc_subject,
|
||||
auth_source,
|
||||
role,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
user_id = cursor.lastrowid
|
||||
if not isinstance(user_id, int):
|
||||
msg = "Failed to create user"
|
||||
raise TypeError(msg)
|
||||
created_user = self._get_user_by_id(conn, user_id)
|
||||
return _require_loaded_user(created_user)
|
||||
except sqlite3.IntegrityError as e:
|
||||
msg = f"User already exists: {e}"
|
||||
raise ValueError(msg) from e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_user(
|
||||
self,
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
oidc_subject: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get a user by id, username, or oidc_subject. Returns None if not found."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
if user_id is not None:
|
||||
return self._get_user_by_id(conn, user_id)
|
||||
if username is not None:
|
||||
row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
|
||||
elif oidc_subject is not None:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE oidc_subject = ?", (oidc_subject,)
|
||||
).fetchone()
|
||||
else:
|
||||
return None
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _get_user_by_id(self, conn: sqlite3.Connection, user_id: int) -> dict[str, Any] | None:
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
_ALLOWED_UPDATE_COLUMNS: ClassVar[frozenset[str]] = frozenset(
|
||||
{
|
||||
"email",
|
||||
"display_name",
|
||||
"password_hash",
|
||||
"oidc_subject",
|
||||
"auth_source",
|
||||
"role",
|
||||
}
|
||||
)
|
||||
_USER_UPDATE_STATEMENTS: ClassVar[dict[str, str]] = {
|
||||
"email": "UPDATE users SET email = ? WHERE id = ?",
|
||||
"display_name": "UPDATE users SET display_name = ? WHERE id = ?",
|
||||
"password_hash": "UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
"oidc_subject": "UPDATE users SET oidc_subject = ? WHERE id = ?",
|
||||
"auth_source": "UPDATE users SET auth_source = ? WHERE id = ?",
|
||||
"role": "UPDATE users SET role = ? WHERE id = ?",
|
||||
}
|
||||
|
||||
def update_user(self, user_id: int, **kwargs: object) -> None:
|
||||
"""Update user fields. Raises ValueError if user not found or invalid column."""
|
||||
if not kwargs:
|
||||
return
|
||||
for k in kwargs:
|
||||
if k not in self._ALLOWED_UPDATE_COLUMNS:
|
||||
msg = f"Invalid column: {k}"
|
||||
raise ValueError(msg)
|
||||
if "auth_source" in kwargs and kwargs["auth_source"] not in self._VALID_AUTH_SOURCES:
|
||||
msg = f"Invalid auth_source: {kwargs['auth_source']}"
|
||||
raise ValueError(msg)
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
# Verify user exists
|
||||
if not self._get_user_by_id(conn, user_id):
|
||||
msg = f"User {user_id} not found"
|
||||
raise ValueError(msg)
|
||||
for column, value in kwargs.items():
|
||||
conn.execute(self._USER_UPDATE_STATEMENTS[column], (value, user_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_user(self, user_id: int) -> None:
|
||||
"""Delete a user and their settings."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
request_rows = conn.execute(
|
||||
"SELECT id FROM download_requests WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
request_item_keys = [f"request:{row['id']}" for row in request_rows]
|
||||
if request_item_keys:
|
||||
conn.executemany(
|
||||
"DELETE FROM activity_view_state WHERE item_type = 'request' AND item_key = ?",
|
||||
[(item_key,) for item_key in request_item_keys],
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM activity_view_state WHERE viewer_scope = ?",
|
||||
(user_viewer_scope(user_id),),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE download_requests SET reviewed_by = NULL WHERE reviewed_by = ?",
|
||||
(user_id,),
|
||||
)
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_users(self) -> list[dict[str, Any]]:
|
||||
"""List all users."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute("SELECT * FROM users ORDER BY id").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def has_admin_with_password(self) -> bool:
|
||||
"""Return True when at least one admin user with a password hash exists."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM users WHERE role = 'admin'"
|
||||
" AND password_hash IS NOT NULL AND password_hash != ''"
|
||||
" LIMIT 1",
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_user_settings(self, user_id: int) -> dict[str, Any]:
|
||||
"""Get per-user settings. Returns empty dict if none set."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT settings_json FROM user_settings WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return json.loads(row["settings_json"])
|
||||
return {}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def set_user_settings(self, user_id: int, settings: dict[str, Any]) -> None:
|
||||
"""Merge settings into user's existing settings."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
existing = {}
|
||||
row = conn.execute(
|
||||
"SELECT settings_json FROM user_settings WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
existing = json.loads(row["settings_json"])
|
||||
|
||||
existing.update(settings)
|
||||
# Remove keys set to None (meaning "clear this override")
|
||||
existing = {k: v for k, v in existing.items() if v is not None}
|
||||
settings_json = json.dumps(existing)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO user_settings (user_id, settings_json) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET settings_json = ?""",
|
||||
(user_id, settings_json, settings_json),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _serialize_json(value: Any, field: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return json.dumps(value)
|
||||
except TypeError as exc:
|
||||
msg = f"{field} must be JSON-serializable"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_request_row(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
payload = dict(row)
|
||||
for key in ("book_data", "release_data"):
|
||||
raw_value = payload.get(key)
|
||||
if raw_value is None:
|
||||
payload[key] = None
|
||||
continue
|
||||
try:
|
||||
payload[key] = json.loads(raw_value)
|
||||
except ValueError, TypeError:
|
||||
payload[key] = None
|
||||
return payload
|
||||
|
||||
def _insert_request(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
user_id: int,
|
||||
content_type: str,
|
||||
request_level: str,
|
||||
policy_mode: str,
|
||||
book_data: dict[str, Any],
|
||||
release_data: dict[str, Any] | None = None,
|
||||
status: str = RequestStatus.PENDING,
|
||||
source_hint: str | None = None,
|
||||
note: str | None = None,
|
||||
admin_note: str | None = None,
|
||||
reviewed_by: int | None = None,
|
||||
reviewed_at: str | None = None,
|
||||
delivery_state: str = DELIVERY_STATE_NONE,
|
||||
delivery_updated_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO download_requests (
|
||||
user_id,
|
||||
status,
|
||||
delivery_state,
|
||||
source_hint,
|
||||
content_type,
|
||||
request_level,
|
||||
policy_mode,
|
||||
book_data,
|
||||
release_data,
|
||||
note,
|
||||
admin_note,
|
||||
reviewed_by,
|
||||
reviewed_at,
|
||||
delivery_updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
status,
|
||||
delivery_state,
|
||||
source_hint,
|
||||
content_type,
|
||||
request_level,
|
||||
policy_mode,
|
||||
self._serialize_json(book_data, "book_data"),
|
||||
self._serialize_json(release_data, "release_data"),
|
||||
note,
|
||||
admin_note,
|
||||
reviewed_by,
|
||||
reviewed_at,
|
||||
delivery_updated_at,
|
||||
),
|
||||
)
|
||||
request_id = cursor.lastrowid
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
parsed = self._parse_request_row(row)
|
||||
if parsed is None:
|
||||
msg = f"Request {request_id} not found after creation"
|
||||
raise ValueError(msg)
|
||||
return parsed
|
||||
|
||||
def create_request(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
content_type: str,
|
||||
request_level: str,
|
||||
policy_mode: str,
|
||||
book_data: dict[str, Any],
|
||||
release_data: dict[str, Any] | None = None,
|
||||
status: str = RequestStatus.PENDING,
|
||||
source_hint: str | None = None,
|
||||
note: str | None = None,
|
||||
admin_note: str | None = None,
|
||||
reviewed_by: int | None = None,
|
||||
reviewed_at: str | None = None,
|
||||
delivery_state: str = DELIVERY_STATE_NONE,
|
||||
delivery_updated_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a download request row and return the created record."""
|
||||
if not isinstance(book_data, dict):
|
||||
msg = "book_data must be an object"
|
||||
raise TypeError(msg)
|
||||
if release_data is not None and not isinstance(release_data, dict):
|
||||
msg = "release_data must be an object when provided"
|
||||
raise TypeError(msg)
|
||||
if not content_type:
|
||||
msg = "content_type is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
normalized_status = normalize_request_status(status)
|
||||
normalized_delivery_state = normalize_delivery_state(delivery_state)
|
||||
normalized_policy_mode = normalize_policy_mode(policy_mode)
|
||||
normalized_request_level = validate_request_level_payload(request_level, release_data)
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
created = self._insert_request(
|
||||
conn,
|
||||
user_id=user_id,
|
||||
content_type=content_type,
|
||||
request_level=normalized_request_level,
|
||||
policy_mode=normalized_policy_mode,
|
||||
book_data=book_data,
|
||||
release_data=release_data,
|
||||
status=normalized_status,
|
||||
source_hint=source_hint,
|
||||
note=note,
|
||||
admin_note=admin_note,
|
||||
reviewed_by=reviewed_by,
|
||||
reviewed_at=reviewed_at,
|
||||
delivery_state=normalized_delivery_state,
|
||||
delivery_updated_at=delivery_updated_at,
|
||||
)
|
||||
conn.commit()
|
||||
return created
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def create_requests(self, requests: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Create multiple request rows atomically and return them in input order."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
created = [self._insert_request(conn, **request) for request in requests]
|
||||
conn.commit()
|
||||
return created
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_request(self, request_id: int) -> dict[str, Any] | None:
|
||||
"""Get a request row by ID."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
return self._parse_request_row(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_requests(
|
||||
self,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
status: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List requests with optional user/status filters."""
|
||||
where_clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if user_id is not None:
|
||||
where_clauses.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
|
||||
if status is not None:
|
||||
where_clauses.append("status = ?")
|
||||
params.append(normalize_request_status(status))
|
||||
|
||||
query = "SELECT * FROM download_requests"
|
||||
if where_clauses:
|
||||
query += " WHERE " + " AND ".join(where_clauses)
|
||||
query += " ORDER BY created_at DESC, id DESC"
|
||||
|
||||
if limit is not None:
|
||||
query += " LIMIT ?"
|
||||
params.append(int(limit))
|
||||
if offset:
|
||||
query += " OFFSET ?"
|
||||
params.append(offset)
|
||||
elif offset:
|
||||
query += " LIMIT -1 OFFSET ?"
|
||||
params.append(offset)
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
results: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
parsed = self._parse_request_row(row)
|
||||
if parsed is not None:
|
||||
results.append(parsed)
|
||||
return results
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
_ALLOWED_REQUEST_UPDATE_COLUMNS: ClassVar[frozenset[str]] = frozenset(
|
||||
{
|
||||
"status",
|
||||
"source_hint",
|
||||
"content_type",
|
||||
"request_level",
|
||||
"policy_mode",
|
||||
"book_data",
|
||||
"release_data",
|
||||
"note",
|
||||
"admin_note",
|
||||
"reviewed_by",
|
||||
"reviewed_at",
|
||||
"delivery_state",
|
||||
"delivery_updated_at",
|
||||
"last_failure_reason",
|
||||
}
|
||||
)
|
||||
_REQUEST_UPDATE_STATEMENTS: ClassVar[dict[str, str]] = {
|
||||
"status": "UPDATE download_requests SET status = ? WHERE id = ?",
|
||||
"source_hint": "UPDATE download_requests SET source_hint = ? WHERE id = ?",
|
||||
"content_type": "UPDATE download_requests SET content_type = ? WHERE id = ?",
|
||||
"request_level": "UPDATE download_requests SET request_level = ? WHERE id = ?",
|
||||
"policy_mode": "UPDATE download_requests SET policy_mode = ? WHERE id = ?",
|
||||
"book_data": "UPDATE download_requests SET book_data = ? WHERE id = ?",
|
||||
"release_data": "UPDATE download_requests SET release_data = ? WHERE id = ?",
|
||||
"note": "UPDATE download_requests SET note = ? WHERE id = ?",
|
||||
"admin_note": "UPDATE download_requests SET admin_note = ? WHERE id = ?",
|
||||
"reviewed_by": "UPDATE download_requests SET reviewed_by = ? WHERE id = ?",
|
||||
"reviewed_at": "UPDATE download_requests SET reviewed_at = ? WHERE id = ?",
|
||||
"delivery_state": "UPDATE download_requests SET delivery_state = ? WHERE id = ?",
|
||||
"delivery_updated_at": "UPDATE download_requests SET delivery_updated_at = ? WHERE id = ?",
|
||||
"last_failure_reason": "UPDATE download_requests SET last_failure_reason = ? WHERE id = ?",
|
||||
}
|
||||
|
||||
def update_request(
|
||||
self,
|
||||
request_id: int,
|
||||
expected_current_status: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> dict[str, Any]:
|
||||
"""Update request fields and return the updated record."""
|
||||
if not kwargs:
|
||||
request = self.get_request(request_id)
|
||||
if request is None:
|
||||
msg = f"Request {request_id} not found"
|
||||
raise ValueError(msg)
|
||||
if expected_current_status is not None:
|
||||
normalized_expected_status = normalize_request_status(expected_current_status)
|
||||
if request["status"] != normalized_expected_status:
|
||||
msg = "Request state changed before update"
|
||||
raise ValueError(msg)
|
||||
return request
|
||||
|
||||
for key in kwargs:
|
||||
if key not in self._ALLOWED_REQUEST_UPDATE_COLUMNS:
|
||||
msg = f"Invalid request column: {key}"
|
||||
raise ValueError(msg)
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
current = self._parse_request_row(row)
|
||||
if current is None:
|
||||
msg = f"Request {request_id} not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
if expected_current_status is not None:
|
||||
normalized_expected_status = normalize_request_status(expected_current_status)
|
||||
if current["status"] != normalized_expected_status:
|
||||
msg = "Request state changed before update"
|
||||
raise ValueError(msg)
|
||||
|
||||
updates = dict(kwargs)
|
||||
|
||||
if "status" in updates:
|
||||
_, normalized_status = validate_status_transition(
|
||||
current["status"],
|
||||
updates["status"],
|
||||
)
|
||||
updates["status"] = normalized_status
|
||||
|
||||
if "policy_mode" in updates:
|
||||
updates["policy_mode"] = normalize_policy_mode(updates["policy_mode"])
|
||||
|
||||
if "delivery_state" in updates:
|
||||
updates["delivery_state"] = normalize_delivery_state(updates["delivery_state"])
|
||||
|
||||
if "delivery_updated_at" in updates:
|
||||
delivery_updated_at = updates["delivery_updated_at"]
|
||||
if delivery_updated_at is not None and not isinstance(delivery_updated_at, str):
|
||||
msg = "delivery_updated_at must be a string when provided"
|
||||
raise TypeError(msg)
|
||||
|
||||
if "content_type" in updates and not updates["content_type"]:
|
||||
msg = "content_type is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
if "request_level" in updates:
|
||||
updates["request_level"] = normalize_request_level(updates["request_level"])
|
||||
|
||||
if "book_data" in updates:
|
||||
if not isinstance(updates["book_data"], dict):
|
||||
msg = "book_data must be an object"
|
||||
raise TypeError(msg)
|
||||
updates["book_data"] = self._serialize_json(updates["book_data"], "book_data")
|
||||
|
||||
if "release_data" in updates:
|
||||
if updates["release_data"] is not None and not isinstance(
|
||||
updates["release_data"], dict
|
||||
):
|
||||
msg = "release_data must be an object when provided"
|
||||
raise TypeError(msg)
|
||||
updates["release_data"] = self._serialize_json(
|
||||
updates["release_data"],
|
||||
"release_data",
|
||||
)
|
||||
|
||||
for column, value in updates.items():
|
||||
conn.execute(self._REQUEST_UPDATE_STATEMENTS[column], (value, request_id))
|
||||
conn.commit()
|
||||
|
||||
updated_row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
parsed = self._parse_request_row(updated_row)
|
||||
if parsed is None:
|
||||
msg = f"Request {request_id} not found after update"
|
||||
raise ValueError(msg)
|
||||
return parsed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def reopen_failed_request(
|
||||
self,
|
||||
request_id: int,
|
||||
*,
|
||||
failure_reason: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Reopen a failed fulfilled request so admins can re-approve it."""
|
||||
normalized_failure_reason = None
|
||||
if isinstance(failure_reason, str):
|
||||
normalized_failure_reason = failure_reason.strip() or None
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
current_row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
current_request = self._parse_request_row(current_row)
|
||||
if current_request is None:
|
||||
return None
|
||||
|
||||
if current_request.get("status") != RequestStatus.FULFILLED:
|
||||
return None
|
||||
|
||||
current_delivery_state = current_request.get("delivery_state", DELIVERY_STATE_NONE)
|
||||
|
||||
# Terminal hook callbacks can run before delivery-state sync persists "error".
|
||||
# Allow reopening fulfilled requests unless they are already complete.
|
||||
if current_delivery_state == QueueStatus.COMPLETE:
|
||||
return None
|
||||
if (
|
||||
current_delivery_state not in {QueueStatus.ERROR, QueueStatus.CANCELLED}
|
||||
and normalized_failure_reason is None
|
||||
):
|
||||
return None
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE download_requests
|
||||
SET status = 'pending',
|
||||
delivery_state = 'none',
|
||||
delivery_updated_at = NULL,
|
||||
release_data = NULL,
|
||||
last_failure_reason = ?,
|
||||
reviewed_by = NULL,
|
||||
reviewed_at = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(normalized_failure_reason, request_id),
|
||||
)
|
||||
updated_row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
return self._parse_request_row(updated_row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def rollback_request_fulfilment(
|
||||
self,
|
||||
request_id: int,
|
||||
*,
|
||||
release_data: dict[str, Any] | None,
|
||||
last_failure_reason: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Restore a request to pending after fulfilment claimed it but queueing failed."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
current = self._parse_request_row(row)
|
||||
if current is None:
|
||||
msg = f"Request {request_id} not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE download_requests
|
||||
SET status = 'pending',
|
||||
release_data = ?,
|
||||
admin_note = NULL,
|
||||
reviewed_by = NULL,
|
||||
reviewed_at = NULL,
|
||||
delivery_state = 'none',
|
||||
delivery_updated_at = NULL,
|
||||
last_failure_reason = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
self._serialize_json(release_data, "release_data"),
|
||||
last_failure_reason,
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
updated_row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
parsed = self._parse_request_row(updated_row)
|
||||
if parsed is None:
|
||||
msg = f"Request {request_id} not found after rollback"
|
||||
raise ValueError(msg)
|
||||
return parsed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def count_pending_requests(self) -> int:
|
||||
"""Count all pending requests."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS count FROM download_requests WHERE status = 'pending'"
|
||||
).fetchone()
|
||||
return int(row["count"]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def count_user_pending_requests(self, user_id: int) -> int:
|
||||
"""Count pending requests for a specific user."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS count FROM download_requests WHERE user_id = ? AND status = 'pending'",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return int(row["count"]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Shared helpers for user-overridable settings metadata and payloads."""
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
def get_settings_registry() -> ModuleType:
|
||||
"""Load settings modules and return the shared settings registry module."""
|
||||
# Ensure settings modules are loaded before reading registry metadata.
|
||||
import_module("shelfmark.config.notifications_settings")
|
||||
import_module("shelfmark.config.security")
|
||||
import_module("shelfmark.config.settings")
|
||||
import_module("shelfmark.config.users_settings")
|
||||
from shelfmark.core import settings_registry
|
||||
|
||||
return settings_registry
|
||||
|
||||
|
||||
def get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]:
|
||||
"""Return user-overridable fields for a tab in UI display order."""
|
||||
settings_registry = get_settings_registry()
|
||||
tab = settings_registry.get_settings_tab(tab_name)
|
||||
if not tab:
|
||||
return []
|
||||
overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name)
|
||||
return [(field.key, field) for field in tab.fields if field.key in overridable_map]
|
||||
|
||||
|
||||
def build_user_preferences_payload(user_db: UserDB, user_id: int, tab_name: str) -> dict[str, Any]:
|
||||
"""Build the effective user-preferences payload for a settings tab."""
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
settings_registry = get_settings_registry()
|
||||
ordered_fields = get_ordered_user_overridable_fields(tab_name)
|
||||
if not ordered_fields:
|
||||
tab_label = tab_name.capitalize()
|
||||
msg = f"{tab_label} settings tab not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
tab_config = load_config_file(tab_name)
|
||||
user_settings = user_db.get_user_settings(user_id)
|
||||
ordered_keys = [key for key, _ in ordered_fields]
|
||||
|
||||
fields_payload: list[dict[str, Any]] = []
|
||||
global_values: dict[str, Any] = {}
|
||||
effective: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for key, field in ordered_fields:
|
||||
serialized = settings_registry.serialize_field(field, tab_name, include_value=False)
|
||||
serialized["fromEnv"] = bool(
|
||||
field.env_supported and settings_registry.is_value_from_env(field)
|
||||
)
|
||||
fields_payload.append(serialized)
|
||||
|
||||
global_values[key] = app_config.get(key, field.default)
|
||||
|
||||
source = "default"
|
||||
value = app_config.get(key, field.default, user_id=user_id)
|
||||
if field.env_supported and settings_registry.is_value_from_env(field):
|
||||
source = "env_var"
|
||||
elif key in user_settings and user_settings[key] is not None:
|
||||
source = "user_override"
|
||||
value = user_settings[key]
|
||||
elif key in tab_config:
|
||||
source = "global_config"
|
||||
|
||||
effective[key] = {"value": value, "source": source}
|
||||
|
||||
user_overrides = {
|
||||
key: user_settings[key]
|
||||
for key in ordered_keys
|
||||
if key in user_settings and user_settings[key] is not None
|
||||
}
|
||||
|
||||
return {
|
||||
"tab": tab_name,
|
||||
"keys": ordered_keys,
|
||||
"fields": fields_payload,
|
||||
"globalValues": global_values,
|
||||
"userOverrides": user_overrides,
|
||||
"effective": effective,
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
"""Shared utility functions for the Shelfmark."""
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from shelfmark.core.request_helpers import normalize_optional_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
def normalize_http_url(
|
||||
url: Optional[str],
|
||||
url: str | None,
|
||||
*,
|
||||
default_scheme: str = "http",
|
||||
strip_trailing_slash: bool = True,
|
||||
@@ -21,7 +31,7 @@ def normalize_http_url(
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
if (normalized.startswith("\"") and normalized.endswith("\"")) or (
|
||||
if (normalized.startswith('"') and normalized.endswith('"')) or (
|
||||
normalized.startswith("'") and normalized.endswith("'")
|
||||
):
|
||||
normalized = normalized[1:-1].strip()
|
||||
@@ -29,11 +39,7 @@ def normalize_http_url(
|
||||
return ""
|
||||
|
||||
if allow_special:
|
||||
special_map = {
|
||||
value.lower(): value
|
||||
for value in allow_special
|
||||
if isinstance(value, str)
|
||||
}
|
||||
special_map = {value.lower(): value for value in allow_special if isinstance(value, str)}
|
||||
special_match = special_map.get(normalized.lower())
|
||||
if special_match is not None:
|
||||
return special_match
|
||||
@@ -52,7 +58,30 @@ def normalize_http_url(
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_base_path(value: Optional[str]) -> str:
|
||||
_xmlrpc_patch_lock = Lock()
|
||||
_xmlrpc_patch_applied = False
|
||||
_XMLRPC_PATCH_ERRORS = (ImportError, AttributeError, OSError, RuntimeError)
|
||||
|
||||
|
||||
def get_hardened_xmlrpc_client() -> ModuleType:
|
||||
"""Return ``xmlrpc.client`` after best-effort defusedxml monkey patching."""
|
||||
global _xmlrpc_patch_applied
|
||||
if not _xmlrpc_patch_applied:
|
||||
with _xmlrpc_patch_lock:
|
||||
if not _xmlrpc_patch_applied:
|
||||
try:
|
||||
from defusedxml.xmlrpc import monkey_patch
|
||||
|
||||
monkey_patch()
|
||||
_xmlrpc_patch_applied = True
|
||||
except _XMLRPC_PATCH_ERRORS:
|
||||
# Keep runtime behavior unchanged if defusedxml is unavailable.
|
||||
_xmlrpc_patch_applied = False
|
||||
|
||||
return importlib.import_module("xmlrpc.client")
|
||||
|
||||
|
||||
def normalize_base_path(value: str | None) -> str:
|
||||
"""Normalize a URL base path for reverse proxy subpath deployments."""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
@@ -74,7 +103,7 @@ def normalize_base_path(value: Optional[str]) -> str:
|
||||
return path.rstrip("/")
|
||||
|
||||
|
||||
def is_audiobook(content_type: Optional[str]) -> bool:
|
||||
def is_audiobook(content_type: str | None) -> bool:
|
||||
"""Check if content type indicates an audiobook."""
|
||||
return bool(content_type and "audiobook" in content_type.lower())
|
||||
|
||||
@@ -118,29 +147,101 @@ _LEGACY_CONTENT_TYPE_TO_CONFIG_KEY = {
|
||||
"other": "INGEST_DIR_OTHER",
|
||||
}
|
||||
|
||||
_USER_PLACEHOLDER_PATTERN = re.compile(r"\{user\}", re.IGNORECASE)
|
||||
_INVALID_USER_PATH_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||
|
||||
def get_destination(is_audiobook: bool = False) -> Path:
|
||||
|
||||
def _sanitize_user_for_path(username: str) -> str:
|
||||
"""Sanitize username for path usage in destination placeholders."""
|
||||
sanitized = _INVALID_USER_PATH_CHARS.sub("_", username.strip())
|
||||
return sanitized.strip(" .")
|
||||
|
||||
|
||||
def _resolve_destination_username(
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
) -> str:
|
||||
explicit = str(username or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
if user_id is None:
|
||||
return ""
|
||||
|
||||
try:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
user_db = UserDB(str(Path(os.environ.get("CONFIG_DIR", "/config")) / "users.db"))
|
||||
user_db.initialize()
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return ""
|
||||
return str(user.get("username") or "").strip()
|
||||
except ImportError, OSError, sqlite3.Error:
|
||||
return ""
|
||||
|
||||
|
||||
def _expand_user_destination_placeholder(
|
||||
path_value: str,
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
) -> str:
|
||||
"""Expand `{User}` placeholders in destination paths."""
|
||||
if not isinstance(path_value, str):
|
||||
return path_value
|
||||
|
||||
if not _USER_PLACEHOLDER_PATTERN.search(path_value):
|
||||
return path_value
|
||||
|
||||
resolved_username = _sanitize_user_for_path(
|
||||
_resolve_destination_username(user_id=user_id, username=username)
|
||||
)
|
||||
return _USER_PLACEHOLDER_PATTERN.sub(resolved_username, path_value)
|
||||
|
||||
|
||||
def get_destination(
|
||||
*,
|
||||
is_audiobook: bool = False,
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
) -> Path:
|
||||
"""Get base destination directory. Audiobooks fall back to main destination."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
if is_audiobook:
|
||||
# Audiobook destination with fallback to main destination
|
||||
audiobook_dest = config.get("DESTINATION_AUDIOBOOK", "")
|
||||
audiobook_dest = config.get("DESTINATION_AUDIOBOOK", "", user_id=user_id)
|
||||
if audiobook_dest:
|
||||
return Path(audiobook_dest)
|
||||
return Path(
|
||||
_expand_user_destination_placeholder(
|
||||
str(audiobook_dest),
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
)
|
||||
|
||||
# Main destination (also fallback for audiobooks)
|
||||
# Check new setting first, then legacy INGEST_DIR
|
||||
destination = config.get("DESTINATION", "") or config.get("INGEST_DIR", "/books")
|
||||
return Path(destination)
|
||||
destination = config.get("DESTINATION", "", user_id=user_id) or config.get(
|
||||
"INGEST_DIR", "/books"
|
||||
)
|
||||
return Path(
|
||||
_expand_user_destination_placeholder(
|
||||
str(destination),
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path]:
|
||||
def get_aa_content_type_dir(content_type: str | None = None) -> Path | None:
|
||||
"""Get override directory for AA content-type routing if configured."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
# Check if content-type routing is enabled (new or legacy setting)
|
||||
if not config.get("AA_CONTENT_TYPE_ROUTING", False) and not config.get("USE_CONTENT_TYPE_DIRECTORIES", False):
|
||||
if not config.get("AA_CONTENT_TYPE_ROUTING", False) and not config.get(
|
||||
"USE_CONTENT_TYPE_DIRECTORIES", False
|
||||
):
|
||||
return None
|
||||
|
||||
if not content_type:
|
||||
@@ -152,19 +253,23 @@ def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path
|
||||
for mapping in (_AA_CONTENT_TYPE_TO_CONFIG_KEY, _LEGACY_CONTENT_TYPE_TO_CONFIG_KEY):
|
||||
config_key = mapping.get(content_type_lower)
|
||||
if config_key:
|
||||
custom_dir = config.get(config_key, "")
|
||||
if custom_dir:
|
||||
return Path(custom_dir)
|
||||
custom_dir = _coerce_config_path(config.get(config_key, ""))
|
||||
if custom_dir is not None:
|
||||
return custom_dir
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_ingest_dir(content_type: Optional[str] = None) -> Path:
|
||||
"""DEPRECATED: Use get_destination() and get_aa_content_type_dir() instead."""
|
||||
def get_ingest_dir(content_type: str | None = None) -> Path:
|
||||
"""Return the legacy ingest directory for a content type."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
# Check new DESTINATION setting first, then legacy INGEST_DIR
|
||||
default_ingest_dir = Path(config.get("DESTINATION", "") or config.get("INGEST_DIR", "/books"))
|
||||
default_ingest_dir = _coerce_config_path(config.get("DESTINATION", "")) or _coerce_config_path(
|
||||
config.get("INGEST_DIR", "/books")
|
||||
)
|
||||
if default_ingest_dir is None:
|
||||
default_ingest_dir = Path("/books")
|
||||
|
||||
if not content_type:
|
||||
return default_ingest_dir
|
||||
@@ -177,20 +282,45 @@ def get_ingest_dir(content_type: Optional[str] = None) -> Path:
|
||||
return default_ingest_dir
|
||||
|
||||
|
||||
def transform_cover_url(cover_url: Optional[str], cache_id: str) -> Optional[str]:
|
||||
def transform_cover_url(cover_url: str | None, cache_id: str) -> str | None:
|
||||
"""Transform external cover URL to local proxy URL when caching is enabled."""
|
||||
if not cover_url:
|
||||
return cover_url
|
||||
|
||||
# Skip if already a local URL (starts with /)
|
||||
if cover_url.startswith('/'):
|
||||
if cover_url.startswith("/"):
|
||||
return cover_url
|
||||
|
||||
# Check if cover caching is enabled
|
||||
from shelfmark.config.env import is_covers_cache_enabled
|
||||
|
||||
if not is_covers_cache_enabled():
|
||||
return cover_url
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
# Encode the original URL and create a proxy URL
|
||||
encoded_url = base64.urlsafe_b64encode(cover_url.encode()).decode()
|
||||
base_path = normalize_base_path(normalize_optional_text(app_config.get("URL_BASE", "")))
|
||||
if base_path:
|
||||
return f"{base_path}/api/covers/{cache_id}?url={encoded_url}"
|
||||
return f"/api/covers/{cache_id}?url={encoded_url}"
|
||||
|
||||
|
||||
def _coerce_config_path(value: object) -> Path | None:
|
||||
if isinstance(value, os.PathLike):
|
||||
path_value = os.fspath(value)
|
||||
if isinstance(path_value, str):
|
||||
normalized = path_value.strip()
|
||||
if normalized:
|
||||
return Path(normalized)
|
||||
return None
|
||||
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
return Path(normalized)
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
"""Archive extraction utilities for downloaded book archives."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.download.fs import atomic_write
|
||||
from shelfmark.download.postprocess.policy import (
|
||||
get_supported_audiobook_formats,
|
||||
get_supported_formats,
|
||||
)
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.download.fs import atomic_write
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import rarfile
|
||||
|
||||
ArchiveType = zipfile.ZipFile | rarfile.RarFile
|
||||
else:
|
||||
ArchiveType = zipfile.ZipFile
|
||||
|
||||
|
||||
def _delete_file_with_logging(file_path: Path, file_type_label: str, *, rejected: bool) -> None:
|
||||
"""Delete a file and log the outcome."""
|
||||
try:
|
||||
file_path.unlink()
|
||||
if rejected:
|
||||
logger.debug("Deleted rejected %s file: %s", file_type_label, file_path.name)
|
||||
else:
|
||||
logger.debug("Deleted non-%s file: %s", file_type_label, file_path.name)
|
||||
except OSError as e:
|
||||
if rejected:
|
||||
logger.warning(
|
||||
"Failed to delete rejected %s file %s: %s", file_type_label, file_path, e
|
||||
)
|
||||
else:
|
||||
logger.warning("Failed to delete non-%s file %s: %s", file_type_label, file_path, e)
|
||||
|
||||
|
||||
# Check for rarfile availability at module load
|
||||
try:
|
||||
@@ -30,20 +52,14 @@ except ImportError:
|
||||
class ArchiveExtractionError(Exception):
|
||||
"""Raised when archive extraction fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PasswordProtectedError(ArchiveExtractionError):
|
||||
"""Raised when archive requires a password."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CorruptedArchiveError(ArchiveExtractionError):
|
||||
"""Raised when archive is corrupted."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def is_archive(file_path: Path) -> bool:
|
||||
"""Check if file is a supported archive format."""
|
||||
@@ -51,7 +67,7 @@ def is_archive(file_path: Path) -> bool:
|
||||
return suffix in ("zip", "rar")
|
||||
|
||||
|
||||
def _is_supported_file(file_path: Path, content_type: Optional[str] = None) -> bool:
|
||||
def _is_supported_file(file_path: Path, content_type: str | None = None) -> bool:
|
||||
"""Check if file matches user's supported formats setting based on content type."""
|
||||
ext = file_path.suffix.lower().lstrip(".")
|
||||
if check_audiobook(content_type):
|
||||
@@ -62,16 +78,30 @@ def _is_supported_file(file_path: Path, content_type: Optional[str] = None) -> b
|
||||
|
||||
|
||||
# All known ebook extensions (superset of what user might enable)
|
||||
ALL_EBOOK_EXTENSIONS = {'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr', '.doc', '.docx', '.rtf', '.txt'}
|
||||
ALL_EBOOK_EXTENSIONS = {
|
||||
".pdf",
|
||||
".epub",
|
||||
".mobi",
|
||||
".azw",
|
||||
".azw3",
|
||||
".fb2",
|
||||
".djvu",
|
||||
".cbz",
|
||||
".cbr",
|
||||
".doc",
|
||||
".docx",
|
||||
".rtf",
|
||||
".txt",
|
||||
}
|
||||
|
||||
# All known audio extensions (superset of what user might enable for audiobooks)
|
||||
ALL_AUDIO_EXTENSIONS = {'.m4b', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.wma', '.wav', '.opus'}
|
||||
ALL_AUDIO_EXTENSIONS = {".m4b", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".wma", ".wav", ".opus"}
|
||||
|
||||
|
||||
def _filter_files(
|
||||
extracted_files: List[Path],
|
||||
content_type: Optional[str] = None,
|
||||
) -> Tuple[List[Path], List[Path], List[Path]]:
|
||||
extracted_files: list[Path],
|
||||
content_type: str | None = None,
|
||||
) -> tuple[list[Path], list[Path], list[Path]]:
|
||||
"""Filter files by content type. Returns (matched, rejected_format, other)."""
|
||||
is_audiobook = check_audiobook(content_type)
|
||||
known_extensions = ALL_AUDIO_EXTENSIONS if is_audiobook else ALL_EBOOK_EXTENSIONS
|
||||
@@ -94,8 +124,8 @@ def _filter_files(
|
||||
def extract_archive(
|
||||
archive_path: Path,
|
||||
output_dir: Path,
|
||||
content_type: Optional[str] = None,
|
||||
) -> Tuple[List[Path], List[str], List[Path]]:
|
||||
content_type: str | None = None,
|
||||
) -> tuple[list[Path], list[str], list[Path]]:
|
||||
"""Extract archive and filter by content type. Returns (matched, warnings, rejected)."""
|
||||
suffix = archive_path.suffix.lower().lstrip(".")
|
||||
|
||||
@@ -104,7 +134,8 @@ def extract_archive(
|
||||
elif suffix == "rar":
|
||||
extracted_files, warnings = _extract_rar(archive_path, output_dir)
|
||||
else:
|
||||
raise ArchiveExtractionError(f"Unsupported archive format: {suffix}")
|
||||
msg = f"Unsupported archive format: {suffix}"
|
||||
raise ArchiveExtractionError(msg)
|
||||
|
||||
is_audiobook = check_audiobook(content_type)
|
||||
file_type_label = "audiobook" if is_audiobook else "book"
|
||||
@@ -114,23 +145,17 @@ def extract_archive(
|
||||
|
||||
# Delete rejected files (valid formats but not enabled by user)
|
||||
for rejected_file in rejected_files:
|
||||
try:
|
||||
rejected_file.unlink()
|
||||
logger.debug(f"Deleted rejected {file_type_label} file: {rejected_file.name}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to delete rejected {file_type_label} file {rejected_file}: {e}")
|
||||
_delete_file_with_logging(rejected_file, file_type_label, rejected=True)
|
||||
|
||||
if rejected_files:
|
||||
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
|
||||
warnings.append(f"Skipped {len(rejected_files)} {file_type_label}(s) with unsupported format: {', '.join(rejected_exts)}")
|
||||
rejected_exts = sorted({f.suffix.lower() for f in rejected_files})
|
||||
warnings.append(
|
||||
f"Skipped {len(rejected_files)} {file_type_label}(s) with unsupported format: {', '.join(rejected_exts)}"
|
||||
)
|
||||
|
||||
# Delete other files (images, html, etc)
|
||||
for other_file in other_files:
|
||||
try:
|
||||
other_file.unlink()
|
||||
logger.debug(f"Deleted non-{file_type_label} file: {other_file.name}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to delete non-{file_type_label} file {other_file}: {e}")
|
||||
_delete_file_with_logging(other_file, file_type_label, rejected=False)
|
||||
|
||||
if other_files:
|
||||
warnings.append(f"Skipped {len(other_files)} non-{file_type_label} file(s)")
|
||||
@@ -141,7 +166,7 @@ def extract_archive(
|
||||
def extract_archive_raw(
|
||||
archive_path: Path,
|
||||
output_dir: Path,
|
||||
) -> Tuple[List[Path], List[str]]:
|
||||
) -> tuple[list[Path], list[str]]:
|
||||
"""Extract archive without filtering (returns all extracted files)."""
|
||||
suffix = archive_path.suffix.lower().lstrip(".")
|
||||
|
||||
@@ -150,10 +175,11 @@ def extract_archive_raw(
|
||||
if suffix == "rar":
|
||||
return _extract_rar(archive_path, output_dir)
|
||||
|
||||
raise ArchiveExtractionError(f"Unsupported archive format: {suffix}")
|
||||
msg = f"Unsupported archive format: {suffix}"
|
||||
raise ArchiveExtractionError(msg)
|
||||
|
||||
|
||||
def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]:
|
||||
def _extract_files_from_archive(archive: ArchiveType, output_dir: Path) -> list[Path]:
|
||||
"""Extract files from ZipFile or RarFile to output_dir with security checks."""
|
||||
extracted_files = []
|
||||
|
||||
@@ -169,7 +195,7 @@ def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]:
|
||||
# Security: reject filenames with null bytes or path separators
|
||||
# Check both / and \ since archives may be created on different OSes
|
||||
if "\x00" in filename or "/" in filename or "\\" in filename:
|
||||
logger.warning(f"Skipping suspicious filename in archive: {info.filename!r}")
|
||||
logger.warning("Skipping suspicious filename in archive: %r", info.filename)
|
||||
continue
|
||||
|
||||
# Extract to output_dir with flat structure
|
||||
@@ -179,50 +205,56 @@ def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]:
|
||||
try:
|
||||
target_path.resolve().relative_to(output_dir.resolve())
|
||||
except ValueError:
|
||||
logger.warning(f"Path traversal attempt blocked: {info.filename!r}")
|
||||
logger.warning("Path traversal attempt blocked: %r", info.filename)
|
||||
continue
|
||||
|
||||
with archive.open(info) as src:
|
||||
data = src.read()
|
||||
final_path = atomic_write(target_path, data)
|
||||
extracted_files.append(final_path)
|
||||
logger.debug(f"Extracted: {filename}")
|
||||
logger.debug("Extracted: %s", filename)
|
||||
|
||||
return extracted_files
|
||||
|
||||
|
||||
def _extract_zip(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List[str]]:
|
||||
def _extract_zip(archive_path: Path, output_dir: Path) -> tuple[list[Path], list[str]]:
|
||||
"""Extract files from a ZIP archive."""
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||
# Check for password protection
|
||||
for info in zf.infolist():
|
||||
if info.flag_bits & 0x1: # Encrypted flag
|
||||
raise PasswordProtectedError("ZIP archive is password protected")
|
||||
msg = "ZIP archive is password protected"
|
||||
raise PasswordProtectedError(msg)
|
||||
|
||||
# Test archive integrity
|
||||
bad_file = zf.testzip()
|
||||
if bad_file:
|
||||
raise CorruptedArchiveError(f"Corrupted file in archive: {bad_file}")
|
||||
msg = f"Corrupted file in archive: {bad_file}"
|
||||
raise CorruptedArchiveError(msg)
|
||||
|
||||
return _extract_files_from_archive(zf, output_dir), []
|
||||
|
||||
except zipfile.BadZipFile as e:
|
||||
raise CorruptedArchiveError(f"Invalid or corrupted ZIP: {e}")
|
||||
msg = f"Invalid or corrupted ZIP: {e}"
|
||||
raise CorruptedArchiveError(msg) from e
|
||||
except PermissionError as e:
|
||||
raise ArchiveExtractionError(f"Permission denied: {e}")
|
||||
msg = f"Permission denied: {e}"
|
||||
raise ArchiveExtractionError(msg) from e
|
||||
|
||||
|
||||
def _extract_rar(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List[str]]:
|
||||
def _extract_rar(archive_path: Path, output_dir: Path) -> tuple[list[Path], list[str]]:
|
||||
"""Extract files from a RAR archive."""
|
||||
if not RAR_AVAILABLE:
|
||||
raise ArchiveExtractionError("RAR extraction not available - rarfile library not installed")
|
||||
msg = "RAR extraction not available - rarfile library not installed"
|
||||
raise ArchiveExtractionError(msg)
|
||||
|
||||
try:
|
||||
with rarfile.RarFile(archive_path, "r") as rf:
|
||||
# Check for password protection
|
||||
if rf.needs_password():
|
||||
raise PasswordProtectedError("RAR archive is password protected")
|
||||
msg = "RAR archive is password protected"
|
||||
raise PasswordProtectedError(msg)
|
||||
|
||||
# Test archive integrity
|
||||
rf.testrar()
|
||||
@@ -230,10 +262,11 @@ def _extract_rar(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List
|
||||
return _extract_files_from_archive(rf, output_dir), []
|
||||
|
||||
except rarfile.BadRarFile as e:
|
||||
raise CorruptedArchiveError(f"Invalid or corrupted RAR: {e}")
|
||||
except rarfile.RarCannotExec:
|
||||
raise ArchiveExtractionError("unrar binary not found - install unrar package")
|
||||
msg = f"Invalid or corrupted RAR: {e}"
|
||||
raise CorruptedArchiveError(msg) from e
|
||||
except rarfile.RarCannotExec as e:
|
||||
msg = "unrar binary not found - install unrar package"
|
||||
raise ArchiveExtractionError(msg) from e
|
||||
except PermissionError as e:
|
||||
raise ArchiveExtractionError(f"Permission denied: {e}")
|
||||
|
||||
|
||||
msg = f"Permission denied: {e}"
|
||||
raise ArchiveExtractionError(msg) from e
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
Download client infrastructure for Prowlarr integration.
|
||||
"""Shared download client infrastructure for external release sources.
|
||||
|
||||
This module provides:
|
||||
- DownloadState: Enum of valid download states
|
||||
@@ -18,14 +17,19 @@ from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union, cast, Any
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, TypeVar, cast
|
||||
|
||||
import requests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Type variable for generic return type
|
||||
T = TypeVar('T')
|
||||
T = TypeVar("T")
|
||||
|
||||
# Exceptions that should trigger a retry
|
||||
RETRYABLE_EXCEPTIONS = (
|
||||
@@ -33,6 +37,10 @@ RETRYABLE_EXCEPTIONS = (
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.HTTPError,
|
||||
)
|
||||
_MIN_RETRYABLE_STATUS = 500
|
||||
_MIN_PROGRESS_PERCENT = 0
|
||||
_MAX_PROGRESS_PERCENT = 100
|
||||
_RNG = random.SystemRandom()
|
||||
|
||||
|
||||
def with_retry(
|
||||
@@ -41,8 +49,7 @@ def with_retry(
|
||||
max_delay: float = 10.0,
|
||||
jitter: float = 0.5,
|
||||
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
||||
"""
|
||||
Decorator for retrying API calls with exponential backoff.
|
||||
"""Retry API calls with exponential backoff.
|
||||
|
||||
Args:
|
||||
max_attempts: Maximum number of attempts (default 3)
|
||||
@@ -58,10 +65,12 @@ def with_retry(
|
||||
Does NOT retry on:
|
||||
- HTTP 4xx client errors (bad request, auth failures)
|
||||
- Other exceptions (programming errors)
|
||||
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> T:
|
||||
def wrapper(*args: object, **kwargs: object) -> T:
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
@@ -69,7 +78,7 @@ def with_retry(
|
||||
return func(*args, **kwargs)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
# Only retry on server errors (5xx), not client errors (4xx)
|
||||
if e.response is not None and e.response.status_code < 500:
|
||||
if e.response is not None and e.response.status_code < _MIN_RETRYABLE_STATUS:
|
||||
raise
|
||||
last_exception = e
|
||||
except RETRYABLE_EXCEPTIONS as e:
|
||||
@@ -79,19 +88,25 @@ def with_retry(
|
||||
# Calculate delay with exponential backoff
|
||||
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
||||
# Add jitter to prevent thundering herd
|
||||
delay += random.uniform(0, delay * jitter)
|
||||
delay += _RNG.uniform(0, delay * jitter)
|
||||
_logger.debug(
|
||||
f"Retry {attempt}/{max_attempts} for {func.__name__} "
|
||||
f"after {delay:.1f}s (error: {last_exception})"
|
||||
"Retry %s/%s for %s after %.1fs (error: %s)",
|
||||
attempt,
|
||||
max_attempts,
|
||||
func.__name__,
|
||||
delay,
|
||||
last_exception,
|
||||
)
|
||||
time.sleep(delay)
|
||||
|
||||
# All retries exhausted
|
||||
if last_exception is None:
|
||||
raise RuntimeError("Retry failed without exception")
|
||||
raise cast(Exception, last_exception)
|
||||
msg = "Retry failed without exception"
|
||||
raise RuntimeError(msg)
|
||||
raise cast("Exception", last_exception)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -114,15 +129,15 @@ class DownloadStatus:
|
||||
"""Status of an external download (immutable)."""
|
||||
|
||||
progress: float # 0-100
|
||||
state: Union[DownloadState, str] # Prefer DownloadState enum; strings auto-normalized
|
||||
message: Optional[str] # Status message
|
||||
state: DownloadState | str # Prefer DownloadState enum; strings auto-normalized
|
||||
message: str | None # Status message
|
||||
complete: bool # True when download finished
|
||||
file_path: Optional[str] # Path in client's download dir (when complete)
|
||||
download_speed: Optional[int] = None # Bytes per second
|
||||
eta: Optional[int] = None # Seconds remaining
|
||||
file_path: str | None # Path in client's download dir (when complete)
|
||||
download_speed: int | None = None # Bytes per second
|
||||
eta: int | None = None # Seconds remaining
|
||||
|
||||
@classmethod
|
||||
def error(cls, message: str) -> "DownloadStatus":
|
||||
def error(cls, message: str) -> DownloadStatus:
|
||||
"""Create an error status."""
|
||||
return cls(
|
||||
progress=0,
|
||||
@@ -132,21 +147,25 @@ class DownloadStatus:
|
||||
file_path=None,
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate and normalize state."""
|
||||
# Normalize string states to enum
|
||||
if isinstance(self.state, str):
|
||||
try:
|
||||
normalized_state = DownloadState(self.state)
|
||||
object.__setattr__(self, 'state', normalized_state)
|
||||
object.__setattr__(self, "state", normalized_state)
|
||||
except ValueError:
|
||||
# Unknown state string - keep as-is for backwards compatibility
|
||||
_logger.warning(f"Unknown download state '{self.state}', keeping as string")
|
||||
_logger.warning(
|
||||
_logger.warning("Unknown download state '%s', keeping as string", self.state)
|
||||
)
|
||||
|
||||
# Validate progress is in range
|
||||
if not 0 <= self.progress <= 100:
|
||||
_logger.debug(f"Progress {self.progress} out of range, clamping to [0, 100]")
|
||||
object.__setattr__(self, 'progress', max(0, min(100, self.progress)))
|
||||
if not _MIN_PROGRESS_PERCENT <= self.progress <= _MAX_PROGRESS_PERCENT:
|
||||
_logger.debug(
|
||||
_logger.debug("Progress %s out of range, clamping to [0, 100]", self.progress)
|
||||
)
|
||||
object.__setattr__(self, "progress", max(0, min(100, self.progress)))
|
||||
|
||||
@property
|
||||
def state_value(self) -> str:
|
||||
@@ -157,8 +176,7 @@ class DownloadStatus:
|
||||
|
||||
|
||||
class DownloadClient(ABC):
|
||||
"""
|
||||
Base class for external download clients.
|
||||
"""Base class for external download clients.
|
||||
|
||||
Subclasses implement protocol-specific download management:
|
||||
- Torrent clients: qBittorrent, Transmission, Deluge
|
||||
@@ -174,8 +192,7 @@ class DownloadClient(ABC):
|
||||
name: str
|
||||
|
||||
def _log_error(self, method: str, e: Exception, level: str = "error") -> str:
|
||||
"""
|
||||
Log a client error with consistent formatting.
|
||||
"""Log a client error with consistent formatting.
|
||||
|
||||
Args:
|
||||
method: Name of the method that failed (e.g., "get_status")
|
||||
@@ -184,6 +201,7 @@ class DownloadClient(ABC):
|
||||
|
||||
Returns:
|
||||
Formatted error message string (for use in DownloadStatus.error())
|
||||
|
||||
"""
|
||||
error_type = type(e).__name__
|
||||
msg = f"{self.name} {method} failed ({error_type}): {e}"
|
||||
@@ -198,15 +216,15 @@ class DownloadClient(ABC):
|
||||
|
||||
return f"{error_type}: {e}"
|
||||
|
||||
def _build_path(self, *components: str) -> Optional[str]:
|
||||
"""
|
||||
Safely build a file path from components.
|
||||
def _build_path(self, *components: str) -> str | None:
|
||||
"""Safely build a file path from components.
|
||||
|
||||
Args:
|
||||
*components: Path components to join (e.g., save_path, name)
|
||||
|
||||
Returns:
|
||||
Normalized path string, or None if any component is empty/None.
|
||||
|
||||
"""
|
||||
# Filter out empty/None components
|
||||
valid = [c for c in components if c]
|
||||
@@ -214,9 +232,9 @@ class DownloadClient(ABC):
|
||||
return None
|
||||
|
||||
# Join and normalize
|
||||
return os.path.normpath(os.path.join(*valid))
|
||||
return os.path.normpath(str(Path(valid[0]).joinpath(*valid[1:])))
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
def __init_subclass__(cls, **kwargs: object) -> None:
|
||||
"""Validate that subclasses define required class attributes."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
@@ -225,48 +243,46 @@ class DownloadClient(ABC):
|
||||
return
|
||||
|
||||
# Validate protocol attribute
|
||||
if not hasattr(cls, 'protocol') or not cls.protocol:
|
||||
raise TypeError(f"{cls.__name__} must define 'protocol' class attribute")
|
||||
if cls.protocol not in ('torrent', 'usenet'):
|
||||
raise TypeError(
|
||||
f"{cls.__name__}.protocol must be 'torrent' or 'usenet', got '{cls.protocol}'"
|
||||
)
|
||||
if not hasattr(cls, "protocol") or not cls.protocol:
|
||||
msg = f"{cls.__name__} must define 'protocol' class attribute"
|
||||
raise TypeError(msg)
|
||||
if cls.protocol not in ("torrent", "usenet"):
|
||||
msg = f"{cls.__name__}.protocol must be 'torrent' or 'usenet', got '{cls.protocol}'"
|
||||
raise TypeError(msg)
|
||||
|
||||
# Validate name attribute
|
||||
if not hasattr(cls, 'name') or not cls.name:
|
||||
raise TypeError(f"{cls.__name__} must define 'name' class attribute")
|
||||
if not hasattr(cls, "name") or not cls.name:
|
||||
msg = f"{cls.__name__} must define 'name' class attribute"
|
||||
raise TypeError(msg)
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def is_configured() -> bool:
|
||||
"""
|
||||
Check if this client is configured.
|
||||
"""Check if this client is configured.
|
||||
|
||||
Returns:
|
||||
True if required settings (URL, etc.) are present.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
Test connectivity to the client.
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Test connectivity to the client.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message).
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
|
||||
"""Add a download to the client.
|
||||
|
||||
Args:
|
||||
@@ -274,32 +290,31 @@ class DownloadClient(ABC):
|
||||
name: Display name for the download
|
||||
category: Category/label for organization (None = client default)
|
||||
expected_hash: Optional info_hash hint (torrents only)
|
||||
**kwargs: Client-specific options passed through to the implementation.
|
||||
|
||||
Returns:
|
||||
Client-specific download ID (hash for torrents, ID for NZBGet).
|
||||
|
||||
Raises:
|
||||
Exception: If adding fails.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""
|
||||
Get status of a download.
|
||||
"""Get status of a download.
|
||||
|
||||
Args:
|
||||
download_id: The ID returned by add_download()
|
||||
|
||||
Returns:
|
||||
Current download status.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
"""
|
||||
Remove a download from the client.
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
"""Remove a download from the client.
|
||||
|
||||
Args:
|
||||
download_id: The ID returned by add_download()
|
||||
@@ -307,27 +322,25 @@ class DownloadClient(ABC):
|
||||
|
||||
Returns:
|
||||
True if removal succeeded.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get the path where files were downloaded.
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where files were downloaded.
|
||||
|
||||
Args:
|
||||
download_id: The ID returned by add_download()
|
||||
|
||||
Returns:
|
||||
File or directory path, or None if not available.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
"""
|
||||
Check if a download for this URL already exists in the client.
|
||||
self, url: str, category: str | None = None
|
||||
) -> tuple[str, DownloadStatus] | None:
|
||||
"""Check if a download for this URL already exists in the client.
|
||||
|
||||
This is useful for detecting already-completed downloads so we can
|
||||
skip re-downloading and just copy the existing file.
|
||||
@@ -339,17 +352,39 @@ class DownloadClient(ABC):
|
||||
Returns:
|
||||
Tuple of (download_id, status) if found, None if not found.
|
||||
Default implementation returns None.
|
||||
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
# Client registry: protocol -> list of client classes
|
||||
_CLIENTS: Dict[str, List[Type[DownloadClient]]] = {}
|
||||
_CLIENTS: dict[str, list[type[DownloadClient]]] = {}
|
||||
_BUILTIN_CLIENT_MODULES = (
|
||||
"shelfmark.download.clients.deluge",
|
||||
"shelfmark.download.clients.nzbget",
|
||||
"shelfmark.download.clients.qbittorrent",
|
||||
"shelfmark.download.clients.rtorrent",
|
||||
"shelfmark.download.clients.sabnzbd",
|
||||
"shelfmark.download.clients.transmission",
|
||||
)
|
||||
_builtin_client_state = {"loaded": False}
|
||||
|
||||
|
||||
def register_client(protocol: str):
|
||||
"""
|
||||
Decorator to register a download client for a protocol.
|
||||
def _ensure_builtin_clients_registered() -> None:
|
||||
"""Import built-in client modules once to populate the registry."""
|
||||
if _builtin_client_state["loaded"]:
|
||||
return
|
||||
|
||||
for module_name in _BUILTIN_CLIENT_MODULES:
|
||||
import_module(module_name)
|
||||
|
||||
_builtin_client_state["loaded"] = True
|
||||
|
||||
|
||||
def register_client(
|
||||
protocol: str,
|
||||
) -> Callable[[type[DownloadClient]], type[DownloadClient]]:
|
||||
"""Register a download client for a protocol.
|
||||
|
||||
Multiple clients can be registered for the same protocol.
|
||||
The `is_configured()` method determines which one is active.
|
||||
@@ -361,9 +396,10 @@ def register_client(protocol: str):
|
||||
@register_client("torrent")
|
||||
class QBittorrentClient(DownloadClient):
|
||||
...
|
||||
|
||||
"""
|
||||
|
||||
def decorator(cls: Type[DownloadClient]) -> Type[DownloadClient]:
|
||||
def decorator(cls: type[DownloadClient]) -> type[DownloadClient]:
|
||||
if protocol not in _CLIENTS:
|
||||
_CLIENTS[protocol] = []
|
||||
_CLIENTS[protocol].append(cls)
|
||||
@@ -372,9 +408,8 @@ def register_client(protocol: str):
|
||||
return decorator
|
||||
|
||||
|
||||
def get_client(protocol: str) -> Optional[DownloadClient]:
|
||||
"""
|
||||
Get a configured client instance for the given protocol.
|
||||
def get_client(protocol: str) -> DownloadClient | None:
|
||||
"""Get a configured client instance for the given protocol.
|
||||
|
||||
Iterates through all registered clients for the protocol and
|
||||
returns the first one that is configured.
|
||||
@@ -384,7 +419,10 @@ def get_client(protocol: str) -> Optional[DownloadClient]:
|
||||
|
||||
Returns:
|
||||
Configured client instance, or None if not available/configured.
|
||||
|
||||
"""
|
||||
_ensure_builtin_clients_registered()
|
||||
|
||||
if protocol not in _CLIENTS:
|
||||
return None
|
||||
|
||||
@@ -395,13 +433,15 @@ def get_client(protocol: str) -> Optional[DownloadClient]:
|
||||
return None
|
||||
|
||||
|
||||
def list_configured_clients() -> List[str]:
|
||||
"""
|
||||
List protocols that have configured clients.
|
||||
def list_configured_clients() -> list[str]:
|
||||
"""List protocols that have configured clients.
|
||||
|
||||
Returns:
|
||||
List of protocol names (e.g., ["torrent", "usenet"]).
|
||||
|
||||
"""
|
||||
_ensure_builtin_clients_registered()
|
||||
|
||||
result = []
|
||||
for protocol, client_classes in _CLIENTS.items():
|
||||
for cls in client_classes:
|
||||
@@ -411,21 +451,15 @@ def list_configured_clients() -> List[str]:
|
||||
return result
|
||||
|
||||
|
||||
def get_all_clients() -> Dict[str, List[Type[DownloadClient]]]:
|
||||
"""
|
||||
Get all registered client classes.
|
||||
def get_all_clients() -> dict[str, list[type[DownloadClient]]]:
|
||||
"""Get all registered client classes.
|
||||
|
||||
Returns:
|
||||
Dict of protocol -> list of client classes.
|
||||
|
||||
"""
|
||||
_ensure_builtin_clients_registered()
|
||||
return dict(_CLIENTS)
|
||||
|
||||
|
||||
# Import client implementations to trigger registration
|
||||
# These imports are at the bottom to avoid circular imports
|
||||
from shelfmark.release_sources.prowlarr.clients import qbittorrent # noqa: F401, E402
|
||||
from shelfmark.release_sources.prowlarr.clients import nzbget # noqa: F401, E402
|
||||
from shelfmark.release_sources.prowlarr.clients import sabnzbd # noqa: F401, E402
|
||||
from shelfmark.release_sources.prowlarr.clients import transmission # noqa: F401, E402
|
||||
from shelfmark.release_sources.prowlarr.clients import deluge # noqa: F401, E402
|
||||
from shelfmark.release_sources.prowlarr.clients import rtorrent # noqa: F401, E402
|
||||
_ensure_builtin_clients_registered()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Shared coercion helpers for download client config and option values."""
|
||||
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
|
||||
|
||||
def config_text(value: object, default: str = "") -> str:
|
||||
"""Coerce config values to strings without losing explicit empty defaults."""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def normalize_http_config_url(value: object, *, require_string: bool = False) -> str:
|
||||
"""Normalize HTTP(S) config URLs with optional strict string-only input handling."""
|
||||
if require_string and not isinstance(value, str):
|
||||
return ""
|
||||
return normalize_http_url(config_text(value))
|
||||
|
||||
|
||||
def coerce_optional_int(value: object) -> int | None:
|
||||
"""Convert optional numeric inputs to ints."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
return int(value)
|
||||
msg = f"Expected int-compatible value, got {type(value).__name__}"
|
||||
raise TypeError(msg)
|
||||
|
||||
|
||||
def coerce_optional_float(value: object) -> float | None:
|
||||
"""Convert optional numeric inputs to floats."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
return float(value)
|
||||
msg = f"Expected float-compatible value, got {type(value).__name__}"
|
||||
raise TypeError(msg)
|
||||
@@ -0,0 +1,898 @@
|
||||
"""Shared download handler for external torrent/usenet clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import shutil
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Protocol, TypeGuard
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import normalize_optional_text
|
||||
from shelfmark.core.utils import is_audiobook
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadState,
|
||||
DownloadStatus,
|
||||
get_client,
|
||||
list_configured_clients,
|
||||
)
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
from shelfmark.download.permissions_debug import log_path_permission_context
|
||||
from shelfmark.release_sources import DownloadHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from threading import Event
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
_CLIENT_CLEANUP_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError)
|
||||
|
||||
|
||||
class _SabnzbdLikeClient(Protocol):
|
||||
name: str
|
||||
|
||||
def remove(
|
||||
self, download_id: str, *, delete_files: bool = False, archive: bool = True
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
def _is_sabnzbd_like_client(candidate: DownloadClient) -> TypeGuard[_SabnzbdLikeClient]:
|
||||
return getattr(candidate, "name", "") == "sabnzbd"
|
||||
|
||||
|
||||
# How often to poll the download client for status (seconds)
|
||||
POLL_INTERVAL = 2
|
||||
WINDOWS_DRIVE_PREFIX_LENGTH = 2
|
||||
SECONDS_PER_MINUTE = 60
|
||||
SECONDS_PER_HOUR = 3600
|
||||
# How long to wait for completed files to appear (seconds)
|
||||
COMPLETED_PATH_RETRY_INTERVAL = 5
|
||||
COMPLETED_PATH_MAX_ATTEMPTS = 12 # 12 attempts * 5s = 60s grace period
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadRequest:
|
||||
"""Source-specific download parameters resolved before sending to a client."""
|
||||
|
||||
url: str
|
||||
protocol: str
|
||||
release_name: str
|
||||
expected_hash: str | None
|
||||
seeding_time_limit: int | None = None # minutes
|
||||
ratio_limit: float | None = None
|
||||
|
||||
|
||||
def _diagnose_path_issue(path: str) -> str:
|
||||
"""Analyze a path and return diagnostic hints for common issues.
|
||||
|
||||
Args:
|
||||
path: The path that failed to be accessed
|
||||
|
||||
Returns:
|
||||
A hint string to help users diagnose the issue.
|
||||
|
||||
"""
|
||||
# Detect Windows-style paths (won't work in Linux containers)
|
||||
if len(path) >= WINDOWS_DRIVE_PREFIX_LENGTH and path[1] == ":":
|
||||
return (
|
||||
f"Path '{path}' appears to be a Windows path. "
|
||||
f"Shelfmark runs in Linux and cannot access Windows paths directly. "
|
||||
f"Ensure your download client uses Linux-style paths (/path/to/files)."
|
||||
)
|
||||
|
||||
# Detect backslashes (Windows path separators)
|
||||
if "\\" in path:
|
||||
return (
|
||||
f"Path '{path}' contains backslashes. "
|
||||
f"This may indicate a Windows path or incorrect path escaping. "
|
||||
f"Linux paths should use forward slashes (/)."
|
||||
)
|
||||
|
||||
# Generic hint for Linux paths
|
||||
return (
|
||||
f"Path '{path}' is not accessible from Shelfmark's container. "
|
||||
f"Ensure both containers have matching volume mounts for this directory, "
|
||||
f"or configure Remote Path Mappings in Settings > Advanced."
|
||||
)
|
||||
|
||||
|
||||
def _format_probe_error(error: OSError | None) -> str:
|
||||
"""Render an OSError for inclusion in log/status messages."""
|
||||
if error is None:
|
||||
return "none"
|
||||
code = errno.errorcode.get(error.errno, str(error.errno)) if error.errno else "?"
|
||||
return f"{code}: {error.strerror or error}"
|
||||
|
||||
|
||||
def _probe_completed_path(path: Path) -> tuple[bool, OSError | None]:
|
||||
"""Probe a completed download path and preserve the underlying stat error.
|
||||
|
||||
`Path.exists()` silently converts every `OSError` to `False`, which hides
|
||||
whether a failure is ENOENT (not yet written), EACCES (permission denied),
|
||||
ESTALE (NFS stale handle), or something else. Callers need the real errno
|
||||
to decide whether the condition is retryable and to surface diagnostics.
|
||||
"""
|
||||
try:
|
||||
run_blocking_io(path.stat)
|
||||
except OSError as error:
|
||||
return False, error
|
||||
return True, None
|
||||
|
||||
|
||||
class ExternalClientHandler(DownloadHandler, ABC):
|
||||
"""Shared lifecycle handler for sources that hand off to torrent/usenet clients."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize cleanup tracking for client-managed downloads."""
|
||||
# Track downloads that may need client-side cleanup after Shelfmark completes import.
|
||||
# task_id -> (client, download_id, protocol)
|
||||
self._cleanup_refs: dict[str, tuple[DownloadClient, str, str]] = {}
|
||||
|
||||
@abstractmethod
|
||||
def _resolve_download(
|
||||
self,
|
||||
task: DownloadTask,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> DownloadRequest | None:
|
||||
"""Resolve source-specific task metadata into a client download request."""
|
||||
|
||||
def _on_download_complete(self, task: DownloadTask) -> None:
|
||||
"""Run post-completion source cleanup hooks."""
|
||||
return
|
||||
|
||||
def _get_client(self, protocol: str) -> DownloadClient | None:
|
||||
"""Resolve the active client for a protocol."""
|
||||
return get_client(protocol)
|
||||
|
||||
def _list_configured_clients(self) -> list[str]:
|
||||
"""List protocols with configured clients."""
|
||||
return list_configured_clients()
|
||||
|
||||
def _poll_interval(self) -> float:
|
||||
"""Return the polling interval for status checks."""
|
||||
return POLL_INTERVAL
|
||||
|
||||
def _completed_path_retry_interval(self) -> float:
|
||||
"""Retry interval while waiting for completed files (seconds)."""
|
||||
return COMPLETED_PATH_RETRY_INTERVAL
|
||||
|
||||
def _completed_path_max_attempts(self) -> int:
|
||||
"""Maximum attempts when waiting for completed files."""
|
||||
return COMPLETED_PATH_MAX_ATTEMPTS
|
||||
|
||||
def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> str | None:
|
||||
"""Get audiobook category if configured and applicable, else None for default."""
|
||||
if not is_audiobook(task.content_type):
|
||||
return None
|
||||
|
||||
# Client-specific audiobook category config keys
|
||||
audiobook_keys = {
|
||||
"qbittorrent": "QBITTORRENT_CATEGORY_AUDIOBOOK",
|
||||
"transmission": "TRANSMISSION_CATEGORY_AUDIOBOOK",
|
||||
"deluge": "DELUGE_CATEGORY_AUDIOBOOK",
|
||||
"nzbget": "NZBGET_CATEGORY_AUDIOBOOK",
|
||||
"sabnzbd": "SABNZBD_CATEGORY_AUDIOBOOK",
|
||||
}
|
||||
audiobook_key = audiobook_keys.get(client.name)
|
||||
if audiobook_key is None:
|
||||
return None
|
||||
configured_category = config.get(audiobook_key, "")
|
||||
normalized_category = normalize_optional_text(configured_category)
|
||||
if normalized_category is not None:
|
||||
return normalized_category
|
||||
if configured_category is None:
|
||||
return None
|
||||
fallback_category = str(configured_category).strip()
|
||||
return fallback_category or None
|
||||
|
||||
def post_process_cleanup(self, task: DownloadTask, *, success: bool) -> None:
|
||||
"""Clean up external-client state after post-processing finishes."""
|
||||
if not success:
|
||||
self._cleanup_refs.pop(task.task_id, None)
|
||||
return
|
||||
|
||||
client_ref = self._cleanup_refs.pop(task.task_id, None)
|
||||
if client_ref is None:
|
||||
return
|
||||
|
||||
client, download_id, protocol = client_ref
|
||||
|
||||
if protocol == "usenet":
|
||||
# "Move" means copy into ingest then let the usenet client delete its own files.
|
||||
if config.get("PROWLARR_USENET_ACTION", "move") != "move":
|
||||
return
|
||||
try:
|
||||
self._delete_local_download_data(client, download_id)
|
||||
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
|
||||
except _CLIENT_CLEANUP_ERRORS as e:
|
||||
logger.warning(
|
||||
"Failed to cleanup usenet download %s in %s: %s",
|
||||
download_id,
|
||||
getattr(client, "name", "client"),
|
||||
e,
|
||||
)
|
||||
|
||||
elif protocol == "torrent":
|
||||
if config.get("PROWLARR_TORRENT_ACTION", "keep") != "remove":
|
||||
return
|
||||
try:
|
||||
client.remove(download_id, delete_files=False)
|
||||
except _CLIENT_CLEANUP_ERRORS as e:
|
||||
logger.warning(
|
||||
"Failed to remove torrent %s from %s: %s",
|
||||
download_id,
|
||||
getattr(client, "name", "client"),
|
||||
e,
|
||||
)
|
||||
|
||||
def _remove_usenet_download(
|
||||
self,
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
*,
|
||||
delete_files: bool,
|
||||
archive: bool = True,
|
||||
) -> None:
|
||||
"""Remove a usenet download with SABnzbd-specific archive handling."""
|
||||
if _is_sabnzbd_like_client(client):
|
||||
client.remove(download_id, delete_files=delete_files, archive=archive)
|
||||
else:
|
||||
client.remove(download_id, delete_files=delete_files)
|
||||
|
||||
def _delete_local_download_data(self, client: DownloadClient, download_id: str) -> None:
|
||||
"""Best-effort local deletion of client download data."""
|
||||
try:
|
||||
raw_path = client.get_download_path(download_id)
|
||||
except _CLIENT_CLEANUP_ERRORS as e:
|
||||
logger.debug(
|
||||
"Failed to resolve download path for %s %s: %s", client.name, download_id, e
|
||||
)
|
||||
return
|
||||
|
||||
if not raw_path:
|
||||
logger.debug("No download path available for %s %s", client.name, download_id)
|
||||
return
|
||||
|
||||
from shelfmark.core.path_mappings import (
|
||||
get_client_host_identifier,
|
||||
parse_remote_path_mappings,
|
||||
remap_remote_to_local_with_match,
|
||||
)
|
||||
|
||||
source_path_obj = Path(raw_path)
|
||||
host = get_client_host_identifier(client) or ""
|
||||
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
|
||||
mappings = parse_remote_path_mappings(mapping_value)
|
||||
remapped, matched_mapping = remap_remote_to_local_with_match(
|
||||
mappings=mappings,
|
||||
host=host,
|
||||
remote_path=source_path_obj,
|
||||
)
|
||||
|
||||
delete_path = remapped if matched_mapping else source_path_obj
|
||||
|
||||
if str(delete_path) in ("", "/"):
|
||||
logger.warning(
|
||||
"Refusing to delete unsafe path for %s %s: %s",
|
||||
client.name,
|
||||
download_id,
|
||||
delete_path,
|
||||
)
|
||||
return
|
||||
|
||||
if not run_blocking_io(delete_path.exists):
|
||||
logger.debug("Local download path does not exist for cleanup: %s", delete_path)
|
||||
return
|
||||
|
||||
try:
|
||||
if run_blocking_io(delete_path.is_dir):
|
||||
run_blocking_io(shutil.rmtree, delete_path)
|
||||
else:
|
||||
run_blocking_io(delete_path.unlink)
|
||||
logger.info(
|
||||
"Deleted local download data for %s %s: %s", client.name, download_id, delete_path
|
||||
)
|
||||
except _CLIENT_CLEANUP_ERRORS as e:
|
||||
logger.warning(
|
||||
"Failed to delete local download data for %s %s: %s", client.name, download_id, e
|
||||
)
|
||||
|
||||
def _safe_remove_download(
|
||||
self,
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
protocol: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Best-effort removal of a failed/cancelled download from the client.
|
||||
|
||||
Safety policy:
|
||||
- torrents: never remove or delete client data (avoid breaking seeding)
|
||||
- usenet: keep legacy behavior (delete client files on removal)
|
||||
"""
|
||||
if protocol != "usenet":
|
||||
logger.info(
|
||||
"Skipping download client cleanup for protocol=%s after %s (client=%s id=%s)",
|
||||
protocol,
|
||||
reason,
|
||||
getattr(client, "name", "client"),
|
||||
download_id,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Permanent delete for failed usenet downloads (SABnzbd archive=0).
|
||||
self._delete_local_download_data(client, download_id)
|
||||
self._remove_usenet_download(client, download_id, delete_files=True, archive=False)
|
||||
except _CLIENT_CLEANUP_ERRORS as e:
|
||||
logger.warning(
|
||||
"Failed to remove download %s from %s after %s: %s",
|
||||
download_id,
|
||||
client.name,
|
||||
reason,
|
||||
e,
|
||||
)
|
||||
|
||||
def _handle_cancelled_download(
|
||||
self,
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
protocol: str,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> None:
|
||||
if protocol == "usenet":
|
||||
logger.info("Download cancelled, removing from %s: %s", client.name, download_id)
|
||||
try:
|
||||
self._delete_local_download_data(client, download_id)
|
||||
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
|
||||
except _CLIENT_CLEANUP_ERRORS as e:
|
||||
logger.warning(
|
||||
"Failed to remove download %s from %s after cancellation: %s",
|
||||
download_id,
|
||||
client.name,
|
||||
e,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Download cancelled for protocol=%s; leaving in %s: %s",
|
||||
protocol,
|
||||
client.name,
|
||||
download_id,
|
||||
)
|
||||
status_callback("cancelled", "Cancelled")
|
||||
|
||||
def _resolve_download_path_once(
|
||||
self,
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
*,
|
||||
log_details: bool,
|
||||
) -> tuple[Path | None, str | None]:
|
||||
"""Resolve and validate the completed download path once."""
|
||||
try:
|
||||
raw_path = client.get_download_path(download_id)
|
||||
except Exception as e:
|
||||
message = (
|
||||
f"Could not locate completed download in {client.name} (path not returned). "
|
||||
f"Check volume mappings and category settings."
|
||||
)
|
||||
if log_details:
|
||||
logger.exception(
|
||||
"Failed to resolve download path for %s %s", client.name, download_id
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Failed to resolve download path for %s %s: %s", client.name, download_id, e
|
||||
)
|
||||
return None, message
|
||||
|
||||
if not raw_path:
|
||||
message = (
|
||||
f"Could not locate completed download in {client.name} (path not returned). "
|
||||
f"Check volume mappings and category settings."
|
||||
)
|
||||
if log_details:
|
||||
logger.error(
|
||||
"Download client returned empty path for %s %s", client.name, download_id
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Download client returned empty path for %s %s", client.name, download_id
|
||||
)
|
||||
return None, message
|
||||
|
||||
from shelfmark.core.path_mappings import (
|
||||
get_client_host_identifier,
|
||||
parse_remote_path_mappings,
|
||||
remap_remote_to_local_with_match,
|
||||
)
|
||||
|
||||
source_path_obj = Path(raw_path)
|
||||
host = get_client_host_identifier(client) or ""
|
||||
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
|
||||
mappings = parse_remote_path_mappings(mapping_value)
|
||||
|
||||
if log_details:
|
||||
logger.debug(
|
||||
"Attempting path remap: client=%s, host=%s, path=%s, mappings=%s",
|
||||
client.name,
|
||||
host,
|
||||
source_path_obj,
|
||||
[(m.host, m.remote_path, m.local_path) for m in mappings],
|
||||
)
|
||||
|
||||
remapped, matched_mapping = remap_remote_to_local_with_match(
|
||||
mappings=mappings,
|
||||
host=host,
|
||||
remote_path=source_path_obj,
|
||||
)
|
||||
|
||||
if matched_mapping:
|
||||
remapped_exists, remapped_error = _probe_completed_path(remapped)
|
||||
|
||||
if log_details:
|
||||
logger.debug(
|
||||
"Remap result: %s -> %s (exists=%s, probe_error=%s, changed=%s, matched=%s)",
|
||||
source_path_obj,
|
||||
remapped,
|
||||
remapped_exists,
|
||||
_format_probe_error(remapped_error),
|
||||
remapped != source_path_obj,
|
||||
matched_mapping,
|
||||
)
|
||||
|
||||
if remapped_exists:
|
||||
logger.info(
|
||||
"Remapped download path for %s (%s): %s -> %s",
|
||||
client.name,
|
||||
download_id,
|
||||
source_path_obj,
|
||||
remapped,
|
||||
)
|
||||
return remapped, None
|
||||
|
||||
message = (
|
||||
f"Remapped path '{remapped}' does not exist. "
|
||||
f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings."
|
||||
)
|
||||
failure_log = "Download path does not exist after remapping: %s -> %s (probe_error=%s). Client: %s, ID: %s."
|
||||
failure_args = (
|
||||
raw_path,
|
||||
remapped,
|
||||
_format_probe_error(remapped_error),
|
||||
client.name,
|
||||
download_id,
|
||||
)
|
||||
if log_details:
|
||||
log_path_permission_context("completed_download_remap", remapped)
|
||||
logger.error(failure_log, *failure_args)
|
||||
else:
|
||||
logger.debug(failure_log, *failure_args)
|
||||
return None, message
|
||||
|
||||
source_exists, source_error = _probe_completed_path(source_path_obj)
|
||||
|
||||
if log_details:
|
||||
logger.debug(
|
||||
"Remap result: %s -> %s (exists=%s, probe_error=%s, changed=%s, matched=%s)",
|
||||
source_path_obj,
|
||||
remapped,
|
||||
source_exists,
|
||||
_format_probe_error(source_error),
|
||||
remapped != source_path_obj,
|
||||
matched_mapping,
|
||||
)
|
||||
|
||||
if source_exists:
|
||||
if mappings:
|
||||
logger.info(
|
||||
"No remote path mapping matched for %s (%s); using client path: %s",
|
||||
client.name,
|
||||
download_id,
|
||||
source_path_obj,
|
||||
)
|
||||
return source_path_obj, None
|
||||
|
||||
hint = _diagnose_path_issue(raw_path)
|
||||
if mappings:
|
||||
message = f"{hint} No remote path mapping matched for client '{client.name}'."
|
||||
failure_label = "completed_download_original"
|
||||
failure_log = "Download path does not exist and no remote path mapping matched for %s (%s): %s (probe_error=%s). %s"
|
||||
failure_args = (
|
||||
client.name,
|
||||
download_id,
|
||||
raw_path,
|
||||
_format_probe_error(source_error),
|
||||
hint,
|
||||
)
|
||||
else:
|
||||
message = hint
|
||||
failure_label = "completed_download_direct"
|
||||
failure_log = (
|
||||
"Download path does not exist: %s (probe_error=%s). Client: %s, ID: %s. %s"
|
||||
)
|
||||
failure_args = (
|
||||
raw_path,
|
||||
_format_probe_error(source_error),
|
||||
client.name,
|
||||
download_id,
|
||||
hint,
|
||||
)
|
||||
|
||||
if log_details:
|
||||
log_path_permission_context(failure_label, source_path_obj)
|
||||
logger.error(failure_log, *failure_args)
|
||||
else:
|
||||
logger.debug(failure_log, *failure_args)
|
||||
return None, message
|
||||
|
||||
def _wait_for_completed_path(
|
||||
self,
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
*,
|
||||
cancel_flag: Event | None,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> tuple[Path | None, str | None]:
|
||||
"""Wait briefly for completed files to appear on disk."""
|
||||
last_error: str | None = None
|
||||
max_attempts = self._completed_path_max_attempts()
|
||||
retry_interval = self._completed_path_retry_interval()
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
return None, last_error
|
||||
|
||||
log_details = attempt == max_attempts
|
||||
resolved_path, error = self._resolve_download_path_once(
|
||||
client,
|
||||
download_id,
|
||||
log_details=log_details,
|
||||
)
|
||||
if resolved_path:
|
||||
return resolved_path, None
|
||||
|
||||
last_error = error
|
||||
|
||||
if attempt < max_attempts:
|
||||
status_callback("locating", "Waiting for completed files...")
|
||||
logger.debug(
|
||||
"Completed files not available yet for %s (%s) (attempt %d/%d)",
|
||||
client.name,
|
||||
download_id,
|
||||
attempt,
|
||||
max_attempts,
|
||||
)
|
||||
|
||||
if cancel_flag:
|
||||
if cancel_flag.wait(timeout=retry_interval):
|
||||
return None, last_error
|
||||
else:
|
||||
time.sleep(retry_interval)
|
||||
|
||||
return None, last_error
|
||||
|
||||
def _build_progress_message(self, status: DownloadStatus) -> str:
|
||||
"""Build a progress message from download status."""
|
||||
msg = f"{status.progress:.0f}%"
|
||||
|
||||
if status.download_speed and status.download_speed > 0:
|
||||
speed_mb = status.download_speed / 1024 / 1024
|
||||
msg += f" ({speed_mb:.1f} MB/s)"
|
||||
|
||||
if status.eta and status.eta > 0:
|
||||
if status.eta < SECONDS_PER_MINUTE:
|
||||
msg += f" - {status.eta}s left"
|
||||
elif status.eta < SECONDS_PER_HOUR:
|
||||
msg += f" - {status.eta // SECONDS_PER_MINUTE}m left"
|
||||
else:
|
||||
msg += (
|
||||
f" - {status.eta // SECONDS_PER_HOUR}h "
|
||||
f"{(status.eta % SECONDS_PER_HOUR) // SECONDS_PER_MINUTE}m left"
|
||||
)
|
||||
|
||||
return msg
|
||||
|
||||
def download(
|
||||
self,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Execute download via configured torrent/usenet client. Returns file path or None."""
|
||||
try:
|
||||
if cancel_flag.is_set():
|
||||
status_callback("cancelled", "Cancelled")
|
||||
return None
|
||||
|
||||
request = self._resolve_download(task, status_callback)
|
||||
if not request:
|
||||
return None
|
||||
|
||||
client = self._get_client(request.protocol)
|
||||
if not client:
|
||||
configured = self._list_configured_clients()
|
||||
if not configured:
|
||||
status_callback(
|
||||
"error",
|
||||
"No download clients configured. Configure qBittorrent or NZBGet in settings.",
|
||||
)
|
||||
else:
|
||||
status_callback("error", f"No {request.protocol} client configured")
|
||||
return None
|
||||
|
||||
# Check if this download already exists in the client
|
||||
status_callback("resolving", f"Checking {client.name}")
|
||||
category = self._get_category_for_task(client, task)
|
||||
existing = client.find_existing(request.url, category=category)
|
||||
|
||||
if existing:
|
||||
download_id, existing_status = existing
|
||||
logger.info("Found existing download in %s: %s", client.name, download_id)
|
||||
|
||||
# If already complete, skip straight to file handling
|
||||
if existing_status.complete:
|
||||
logger.info("Existing download is complete, copying file directly")
|
||||
status_callback("resolving", "Found existing download, copying to library")
|
||||
|
||||
source_path_obj, path_error = self._wait_for_completed_path(
|
||||
client=client,
|
||||
download_id=download_id,
|
||||
cancel_flag=cancel_flag,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
if not source_path_obj:
|
||||
if cancel_flag.is_set():
|
||||
return None
|
||||
status_callback(
|
||||
"error",
|
||||
path_error
|
||||
or f"Could not locate existing download in {client.name}. Check that the file still exists.",
|
||||
)
|
||||
return None
|
||||
|
||||
result = self._handle_completed_file(
|
||||
source_path=source_path_obj,
|
||||
protocol=request.protocol,
|
||||
task=task,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
|
||||
if result:
|
||||
self._on_download_complete(task)
|
||||
self._cleanup_refs[task.task_id] = (client, download_id, request.protocol)
|
||||
return result
|
||||
|
||||
# Existing but still downloading - join the progress polling
|
||||
logger.info("Existing download in progress, joining poll loop")
|
||||
status_callback("downloading", "Resuming existing download")
|
||||
else:
|
||||
# No existing download - add new
|
||||
status_callback("resolving", f"Sending to {client.name}")
|
||||
try:
|
||||
download_id = client.add_download(
|
||||
url=request.url,
|
||||
name=request.release_name,
|
||||
category=category,
|
||||
expected_hash=request.expected_hash,
|
||||
seeding_time_limit=request.seeding_time_limit,
|
||||
ratio_limit=request.ratio_limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to add to %s", client.name)
|
||||
status_callback("error", f"Failed to add to {client.name}: {e}")
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Added to %s: %s for '%s'", client.name, download_id, request.release_name
|
||||
)
|
||||
|
||||
# Poll for progress
|
||||
return self._poll_and_complete(
|
||||
client=client,
|
||||
download_id=download_id,
|
||||
protocol=request.protocol,
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=progress_callback,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("External client download error")
|
||||
status_callback("error", str(e))
|
||||
return None
|
||||
|
||||
def _poll_and_complete(
|
||||
self,
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
protocol: str,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Poll the download client for progress and handle completion."""
|
||||
poll_interval = self._poll_interval()
|
||||
# Track consecutive "not found" errors - torrents may take time to appear in client
|
||||
not_found_count = 0
|
||||
max_not_found_retries = 15 # 15 retries * poll interval ~= 30s grace period
|
||||
|
||||
try:
|
||||
result: str | None = None
|
||||
logger.debug("Starting poll for %s (content_type=%s)", download_id, task.content_type)
|
||||
while not cancel_flag.is_set():
|
||||
status = client.get_status(download_id)
|
||||
progress_callback(status.progress)
|
||||
|
||||
# Check for completion
|
||||
if status.complete:
|
||||
if status.state == DownloadState.ERROR:
|
||||
logger.error(
|
||||
"Download %s completed with error: %s", download_id, status.message
|
||||
)
|
||||
status_callback("error", status.message or "Download failed")
|
||||
self._safe_remove_download(
|
||||
client, download_id, protocol, "completion error"
|
||||
)
|
||||
return None
|
||||
# Download complete - break to handle file
|
||||
logger.debug(
|
||||
"Download %s complete, file_path=%s", download_id, status.file_path
|
||||
)
|
||||
break
|
||||
|
||||
# Check for error state
|
||||
if status.state == DownloadState.ERROR:
|
||||
message = (status.message or "").strip()
|
||||
message_lower = message.lower()
|
||||
|
||||
# Only treat *actual* "not found" as retryable.
|
||||
# qBittorrent auth/network/API failures should surface immediately (more actionable)
|
||||
# and must not be confused with "torrent missing".
|
||||
retryable_not_found = any(
|
||||
token in message_lower
|
||||
for token in (
|
||||
"torrent not found",
|
||||
"not found in qbittorrent",
|
||||
"download not found",
|
||||
)
|
||||
)
|
||||
|
||||
non_retryable = any(
|
||||
token in message_lower
|
||||
for token in (
|
||||
"authentication failed",
|
||||
"cannot connect",
|
||||
"timed out",
|
||||
"api request failed",
|
||||
)
|
||||
)
|
||||
|
||||
if retryable_not_found and not non_retryable:
|
||||
not_found_count += 1
|
||||
if not_found_count < max_not_found_retries:
|
||||
logger.debug(
|
||||
"Download %s not yet visible in client (attempt %s/%s)",
|
||||
download_id,
|
||||
not_found_count,
|
||||
max_not_found_retries,
|
||||
)
|
||||
status_callback("resolving", "Waiting for download client...")
|
||||
if cancel_flag.wait(timeout=poll_interval):
|
||||
break
|
||||
continue
|
||||
|
||||
logger.error(
|
||||
"Download %s not found after %s attempts",
|
||||
download_id,
|
||||
max_not_found_retries,
|
||||
)
|
||||
else:
|
||||
# Fail fast on actionable errors (auth, connectivity, API issues)
|
||||
logger.error("Download %s error state: %s", download_id, status.message)
|
||||
|
||||
status_callback("error", status.message or "Download failed")
|
||||
self._safe_remove_download(client, download_id, protocol, "download error")
|
||||
return None
|
||||
|
||||
# Reset not-found counter on successful status check
|
||||
not_found_count = 0
|
||||
|
||||
# Build status message - use client message if provided, else build progress
|
||||
msg = status.message or self._build_progress_message(status)
|
||||
if status.state == DownloadState.PROCESSING:
|
||||
# Post-processing (e.g., SABnzbd verifying/extracting)
|
||||
status_callback("resolving", msg)
|
||||
else:
|
||||
status_callback("downloading", msg)
|
||||
|
||||
# Wait for next poll (interruptible by cancel)
|
||||
if cancel_flag.wait(timeout=poll_interval):
|
||||
break
|
||||
|
||||
# Handle cancellation
|
||||
if cancel_flag.is_set():
|
||||
self._handle_cancelled_download(client, download_id, protocol, status_callback)
|
||||
return None
|
||||
|
||||
# Handle completed file (wait briefly for files to appear)
|
||||
source_path_obj, path_error = self._wait_for_completed_path(
|
||||
client=client,
|
||||
download_id=download_id,
|
||||
cancel_flag=cancel_flag,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
if not source_path_obj:
|
||||
if cancel_flag.is_set():
|
||||
self._handle_cancelled_download(client, download_id, protocol, status_callback)
|
||||
return None
|
||||
status_callback(
|
||||
"error",
|
||||
path_error
|
||||
or f"Could not locate completed download in {client.name} (path not returned). Check volume mappings and category settings.",
|
||||
)
|
||||
return None
|
||||
|
||||
result = self._handle_completed_file(
|
||||
source_path=source_path_obj,
|
||||
protocol=protocol,
|
||||
task=task,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error during download polling")
|
||||
status_callback("error", str(e))
|
||||
self._safe_remove_download(client, download_id, protocol, "polling exception")
|
||||
return None
|
||||
|
||||
# Clean up on success
|
||||
if result:
|
||||
self._on_download_complete(task)
|
||||
self._cleanup_refs[task.task_id] = (client, download_id, protocol)
|
||||
|
||||
return result
|
||||
|
||||
def _handle_completed_file(
|
||||
self,
|
||||
source_path: Path,
|
||||
protocol: str,
|
||||
task: DownloadTask,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Handle a completed download and return its path.
|
||||
|
||||
For external download clients (torrents/usenet), staging large payloads into TMP_DIR
|
||||
is expensive (and can duplicate multi-GB files). Instead, return the client's
|
||||
completed path and let the orchestrator perform any required transfer (copy/move/
|
||||
hardlink) directly from that source.
|
||||
|
||||
Torrents also set ``task.original_download_path`` so the orchestrator can detect
|
||||
seeding data and enable hardlinking when configured.
|
||||
"""
|
||||
try:
|
||||
if protocol == "torrent":
|
||||
task.original_download_path = str(source_path)
|
||||
|
||||
logger.debug("Download complete, returning original path: %s", source_path)
|
||||
return str(source_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to finalize completed download at %s", source_path)
|
||||
status_callback("error", f"Failed to finalize completed download: {e}")
|
||||
return None
|
||||
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
"""Default cancellation (primary cancellation happens via cancel_flag)."""
|
||||
logger.debug("Cancel requested for external client task: %s", task_id)
|
||||
return True
|
||||
@@ -12,7 +12,8 @@ Requirements:
|
||||
"""
|
||||
|
||||
import base64
|
||||
from typing import Any, Optional, Tuple
|
||||
from contextlib import suppress
|
||||
from typing import Any, NoReturn
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
@@ -20,30 +21,59 @@ import requests
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.release_sources.prowlarr.clients import (
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.release_sources.prowlarr.clients.torrent_utils import (
|
||||
from shelfmark.download.clients._coercion import (
|
||||
coerce_optional_float,
|
||||
coerce_optional_int,
|
||||
config_text,
|
||||
)
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
)
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
MIN_DAEMON_HOST_ENTRY_LENGTH = 2
|
||||
MIN_DAEMON_HOST_STATUS_ENTRY_LENGTH = 4
|
||||
DOWNLOAD_COMPLETE_PROGRESS = 100
|
||||
ONE_WEEK_IN_SECONDS = 604800
|
||||
|
||||
|
||||
class DelugeRpcError(RuntimeError):
|
||||
def __init__(self, message: str, code: int | None = None):
|
||||
"""Raised when Deluge returns a JSON-RPC error response."""
|
||||
|
||||
def __init__(self, message: str, code: int | None = None) -> None:
|
||||
"""Initialize the RPC error with an optional Deluge error code."""
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _get_error_message(error: Any) -> Tuple[str, int | None]:
|
||||
_DELUGE_CLIENT_ERRORS = (
|
||||
AttributeError,
|
||||
DelugeRpcError,
|
||||
OSError,
|
||||
requests.exceptions.RequestException,
|
||||
RuntimeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
)
|
||||
|
||||
|
||||
def _get_error_message(error: object) -> tuple[str, int | None]:
|
||||
if isinstance(error, dict):
|
||||
return str(error.get("message") or error), error.get("code")
|
||||
return str(error), None
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class DelugeClient(DownloadClient):
|
||||
"""Deluge download client using Deluge Web UI JSON-RPC."""
|
||||
@@ -51,15 +81,18 @@ class DelugeClient(DownloadClient):
|
||||
protocol = "torrent"
|
||||
name = "deluge"
|
||||
|
||||
def __init__(self):
|
||||
raw_host = str(config.get("DELUGE_HOST", "localhost") or "")
|
||||
raw_port = str(config.get("DELUGE_PORT", "8112") or "8112")
|
||||
password = str(config.get("DELUGE_PASSWORD", "") or "")
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the client from the configured Deluge connection settings."""
|
||||
raw_host = config_text(config.get("DELUGE_HOST", "localhost"))
|
||||
raw_port = config_text(config.get("DELUGE_PORT", "8112"), "8112")
|
||||
password = config_text(config.get("DELUGE_PASSWORD", ""))
|
||||
|
||||
if not raw_host:
|
||||
raise ValueError("DELUGE_HOST is required")
|
||||
msg = "DELUGE_HOST is required"
|
||||
raise ValueError(msg)
|
||||
if not password:
|
||||
raise ValueError("DELUGE_PASSWORD is required")
|
||||
msg = "DELUGE_PASSWORD is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
scheme = "http"
|
||||
base_path = ""
|
||||
@@ -68,7 +101,8 @@ class DelugeClient(DownloadClient):
|
||||
# (useful when Deluge is behind a reverse proxy path).
|
||||
raw_host = normalize_http_url(raw_host, strip_trailing_slash=False) if raw_host else ""
|
||||
if not raw_host:
|
||||
raise ValueError("DELUGE_HOST is invalid")
|
||||
msg = "DELUGE_HOST is invalid"
|
||||
raise ValueError(msg)
|
||||
|
||||
host = raw_host
|
||||
port = int(raw_port)
|
||||
@@ -80,13 +114,12 @@ class DelugeClient(DownloadClient):
|
||||
if parsed.port is not None:
|
||||
port = parsed.port
|
||||
base_path = (parsed.path or "").rstrip("/")
|
||||
else:
|
||||
# Allow "host:port" in DELUGE_HOST for convenience.
|
||||
if ":" in raw_host and raw_host.count(":") == 1:
|
||||
host_part, port_part = raw_host.split(":", 1)
|
||||
if host_part and port_part.isdigit():
|
||||
host = host_part
|
||||
port = int(port_part)
|
||||
# Allow "host:port" in DELUGE_HOST for convenience.
|
||||
elif ":" in raw_host and raw_host.count(":") == 1:
|
||||
host_part, port_part = raw_host.split(":", 1)
|
||||
if host_part and port_part.isdigit():
|
||||
host = host_part
|
||||
port = int(port_part)
|
||||
|
||||
self._rpc_url = f"{scheme}://{host}:{port}{base_path}/json"
|
||||
self._password = password
|
||||
@@ -96,20 +129,26 @@ class DelugeClient(DownloadClient):
|
||||
self._connected = False
|
||||
self._rpc_id = 0
|
||||
|
||||
self._category = str(config.get("DELUGE_CATEGORY", "books") or "books")
|
||||
self._category = config_text(config.get("DELUGE_CATEGORY", "books"), "books")
|
||||
self._download_dir = config_text(config.get("DELUGE_DOWNLOAD_DIR", ""))
|
||||
|
||||
def _next_rpc_id(self) -> int:
|
||||
self._rpc_id += 1
|
||||
return self._rpc_id
|
||||
|
||||
def _rpc_call(self, method: str, *params: Any, timeout: int = 15) -> Any:
|
||||
def _rpc_call(self, method: str, *params: object, timeout: int = 15) -> Any:
|
||||
payload = {
|
||||
"id": self._next_rpc_id(),
|
||||
"method": method,
|
||||
"params": list(params),
|
||||
}
|
||||
|
||||
response = self._session.post(self._rpc_url, json=payload, timeout=timeout)
|
||||
response = self._session.post(
|
||||
self._rpc_url,
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
verify=get_ssl_verify(self._rpc_url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
@@ -122,20 +161,28 @@ class DelugeClient(DownloadClient):
|
||||
def _login(self) -> None:
|
||||
result = self._rpc_call("auth.login", self._password)
|
||||
if result is not True:
|
||||
raise DelugeRpcError("Deluge Web UI authentication failed")
|
||||
msg = "Deluge Web UI authentication failed"
|
||||
raise DelugeRpcError(msg)
|
||||
self._authenticated = True
|
||||
|
||||
def _select_daemon_host_id(self, hosts: list) -> str:
|
||||
# Hosts returned by web.get_hosts look like:
|
||||
# [[host_id, host, port, status], ...]
|
||||
# Deluge returns entries containing host id, host, port, and status.
|
||||
preferred_hosts = {"127.0.0.1", "localhost"}
|
||||
|
||||
for entry in hosts:
|
||||
if isinstance(entry, list) and len(entry) >= 2 and entry[1] in preferred_hosts:
|
||||
if (
|
||||
isinstance(entry, list)
|
||||
and len(entry) >= MIN_DAEMON_HOST_ENTRY_LENGTH
|
||||
and entry[1] in preferred_hosts
|
||||
):
|
||||
return str(entry[0])
|
||||
|
||||
for entry in hosts:
|
||||
if isinstance(entry, list) and len(entry) >= 4 and str(entry[3]).lower() == "online":
|
||||
if (
|
||||
isinstance(entry, list)
|
||||
and len(entry) >= MIN_DAEMON_HOST_STATUS_ENTRY_LENGTH
|
||||
and str(entry[3]).lower() == "online"
|
||||
):
|
||||
return str(entry[0])
|
||||
|
||||
return str(hosts[0][0])
|
||||
@@ -153,31 +200,30 @@ class DelugeClient(DownloadClient):
|
||||
|
||||
hosts = self._rpc_call("web.get_hosts") or []
|
||||
if not hosts:
|
||||
raise DelugeRpcError(
|
||||
msg = (
|
||||
"Deluge Web UI isn't connected to Deluge core (no hosts configured). "
|
||||
"Add/connect a daemon in Deluge Web UI → Connection Manager."
|
||||
)
|
||||
raise DelugeRpcError(msg)
|
||||
|
||||
host_id = self._select_daemon_host_id(hosts)
|
||||
self._rpc_call("web.connect", host_id)
|
||||
|
||||
if self._rpc_call("web.connected") is not True:
|
||||
raise DelugeRpcError(
|
||||
msg = (
|
||||
"Deluge Web UI couldn't connect to Deluge core. "
|
||||
"Check daemon status in Deluge Web UI → Connection Manager."
|
||||
)
|
||||
raise DelugeRpcError(msg)
|
||||
|
||||
self._connected = True
|
||||
|
||||
def _get_daemon_version(self) -> Any:
|
||||
def _get_daemon_version(self) -> object:
|
||||
"""Fetch daemon version, preferring daemon.get_version when available."""
|
||||
try:
|
||||
with suppress(*_DELUGE_CLIENT_ERRORS):
|
||||
methods = self._rpc_call("system.listMethods")
|
||||
if isinstance(methods, list) and "daemon.get_version" in methods:
|
||||
return self._rpc_call("daemon.get_version")
|
||||
except Exception:
|
||||
# Fall back to daemon.info to preserve existing behavior.
|
||||
pass
|
||||
|
||||
return self._rpc_call("daemon.info")
|
||||
|
||||
@@ -188,40 +234,42 @@ class DelugeClient(DownloadClient):
|
||||
|
||||
try:
|
||||
# label.add will error if the plugin is unavailable or the label exists.
|
||||
try:
|
||||
with suppress(*_DELUGE_CLIENT_ERRORS):
|
||||
self._rpc_call("label.add", label)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._rpc_call("label.set_torrent", torrent_id, label)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not set Deluge label '{label}' for {torrent_id}: {e}")
|
||||
except _DELUGE_CLIENT_ERRORS as e:
|
||||
logger.debug("Could not set Deluge label '%s' for %s: %s", label, torrent_id, e)
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
client = config.get("PROWLARR_TORRENT_CLIENT", "")
|
||||
host = config.get("DELUGE_HOST", "")
|
||||
password = config.get("DELUGE_PASSWORD", "")
|
||||
"""Return whether Deluge is the active configured torrent client."""
|
||||
client = config_text(config.get("PROWLARR_TORRENT_CLIENT", ""))
|
||||
host = config_text(config.get("DELUGE_HOST", ""))
|
||||
password = config_text(config.get("DELUGE_PASSWORD", ""))
|
||||
return client == "deluge" and bool(host) and bool(password)
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Test connectivity and authentication against the Deluge server."""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
version = self._get_daemon_version()
|
||||
return True, f"Connected to Deluge {version}"
|
||||
except Exception as e:
|
||||
except _DELUGE_CLIENT_ERRORS as e:
|
||||
self._authenticated = False
|
||||
self._connected = False
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
return False, f"Connection failed: {e!s}"
|
||||
else:
|
||||
return True, f"Connected to Deluge {version}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
**kwargs,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""Add a torrent to Deluge and return the torrent id."""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
@@ -229,9 +277,20 @@ class DelugeClient(DownloadClient):
|
||||
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
if not torrent_info.is_magnet and not torrent_info.torrent_data:
|
||||
raise Exception("Failed to fetch torrent file")
|
||||
_raise_runtime_error("Failed to fetch torrent file")
|
||||
|
||||
options: dict[str, Any] = {}
|
||||
if self._download_dir:
|
||||
options["download_location"] = self._download_dir
|
||||
|
||||
# Per-torrent seeding limits from indexer
|
||||
seeding_time_limit = coerce_optional_int(kwargs.get("seeding_time_limit"))
|
||||
if seeding_time_limit is not None:
|
||||
options["seed_time_limit"] = seeding_time_limit
|
||||
ratio_limit = coerce_optional_float(kwargs.get("ratio_limit"))
|
||||
if ratio_limit is not None:
|
||||
options["stop_at_ratio"] = ratio_limit
|
||||
options["stop_at_ratio_enabled"] = True
|
||||
|
||||
if torrent_info.is_magnet:
|
||||
magnet_url = torrent_info.magnet_url or url
|
||||
@@ -239,7 +298,7 @@ class DelugeClient(DownloadClient):
|
||||
else:
|
||||
torrent_data = torrent_info.torrent_data
|
||||
if torrent_data is None:
|
||||
raise Exception("Failed to fetch torrent file")
|
||||
_raise_runtime_error("Failed to fetch torrent file")
|
||||
|
||||
torrent_data_bytes: bytes = torrent_data
|
||||
filedump = base64.b64encode(torrent_data_bytes).decode("ascii")
|
||||
@@ -251,28 +310,37 @@ class DelugeClient(DownloadClient):
|
||||
)
|
||||
|
||||
if not torrent_id:
|
||||
raise Exception("Deluge returned no torrent ID")
|
||||
_raise_runtime_error("Deluge returned no torrent ID")
|
||||
|
||||
torrent_id = str(torrent_id).lower()
|
||||
self._try_set_label(torrent_id, category_value)
|
||||
|
||||
logger.info(f"Added torrent to Deluge: {torrent_id}")
|
||||
return torrent_id
|
||||
logger.info("Added torrent to Deluge: %s", torrent_id)
|
||||
|
||||
except Exception as e:
|
||||
except _DELUGE_CLIENT_ERRORS:
|
||||
self._authenticated = False
|
||||
self._connected = False
|
||||
logger.error(f"Deluge add failed: {e}")
|
||||
logger.exception("Deluge add failed")
|
||||
raise
|
||||
else:
|
||||
return torrent_id
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""Return the current Deluge status for a torrent."""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
status = self._rpc_call(
|
||||
"core.get_torrent_status",
|
||||
download_id,
|
||||
["state", "progress", "download_payload_rate", "eta", "save_path", "name"],
|
||||
[
|
||||
"state",
|
||||
"progress",
|
||||
"download_payload_rate",
|
||||
"eta",
|
||||
"save_path",
|
||||
"name",
|
||||
],
|
||||
)
|
||||
|
||||
if not status:
|
||||
@@ -295,7 +363,7 @@ class DelugeClient(DownloadClient):
|
||||
|
||||
progress = float(status.get("progress", 0))
|
||||
# Don't mark complete while files are being moved
|
||||
complete = progress >= 100 and deluge_state != "Moving"
|
||||
complete = progress >= DOWNLOAD_COMPLETE_PROGRESS and deluge_state != "Moving"
|
||||
|
||||
if complete:
|
||||
message = "Complete"
|
||||
@@ -304,10 +372,10 @@ class DelugeClient(DownloadClient):
|
||||
if eta is not None:
|
||||
try:
|
||||
eta = int(eta)
|
||||
except Exception:
|
||||
except TypeError, ValueError:
|
||||
eta = None
|
||||
|
||||
if eta is not None and (eta < 0 or eta > 604800):
|
||||
if eta is not None and (eta < 0 or eta > ONE_WEEK_IN_SECONDS):
|
||||
eta = None
|
||||
|
||||
file_path = None
|
||||
@@ -328,27 +396,31 @@ class DelugeClient(DownloadClient):
|
||||
eta=eta,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
except _DELUGE_CLIENT_ERRORS as e:
|
||||
return DownloadStatus.error(self._log_error("get_status", e))
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
"""Remove a torrent from Deluge, optionally deleting its files."""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
result = self._rpc_call("core.remove_torrent", download_id, delete_files)
|
||||
if result:
|
||||
logger.info(
|
||||
f"Removed torrent from Deluge: {download_id}"
|
||||
+ (" (with files)" if delete_files else "")
|
||||
"Removed torrent from Deluge: %s%s",
|
||||
download_id,
|
||||
" (with files)" if delete_files else "",
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
except _DELUGE_CLIENT_ERRORS as e:
|
||||
self._log_error("remove", e)
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Return the resolved download path for a Deluge torrent."""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
@@ -363,15 +435,17 @@ class DelugeClient(DownloadClient):
|
||||
str(status.get("save_path", "")),
|
||||
str(status.get("name", "")),
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
except _DELUGE_CLIENT_ERRORS as e:
|
||||
self._log_error("get_download_path", e, level="debug")
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
self, url: str, category: str | None = None
|
||||
) -> tuple[str, DownloadStatus] | None:
|
||||
"""Find an existing Deluge torrent matching a release URL."""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
@@ -389,10 +463,10 @@ class DelugeClient(DownloadClient):
|
||||
full_status = self.get_status(torrent_info.info_hash)
|
||||
return (torrent_info.info_hash, full_status)
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
except _DELUGE_CLIENT_ERRORS as e:
|
||||
self._authenticated = False
|
||||
self._connected = False
|
||||
logger.debug(f"Error checking for existing torrent: {e}")
|
||||
logger.debug("Error checking for existing torrent: %s", e)
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
@@ -1,25 +1,30 @@
|
||||
"""
|
||||
NZBGet download client for Prowlarr integration.
|
||||
"""NZBGet download client for Prowlarr integration.
|
||||
|
||||
Uses NZBGet's JSON-RPC API directly via requests (no external dependency).
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, NoReturn
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.release_sources.prowlarr.clients import (
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
with_retry,
|
||||
)
|
||||
from shelfmark.download.clients._coercion import config_text, normalize_http_config_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
_NZBGET_CLIENT_ERRORS = (AttributeError, OSError, RuntimeError, TypeError, ValueError)
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
@register_client("usenet")
|
||||
@@ -29,30 +34,44 @@ class NZBGetClient(DownloadClient):
|
||||
protocol = "usenet"
|
||||
name = "nzbget"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize NZBGet client with settings from config."""
|
||||
raw_url = config.get("NZBGET_URL", "")
|
||||
raw_url = config_text(config.get("NZBGET_URL", ""))
|
||||
if not raw_url:
|
||||
raise ValueError("NZBGET_URL is required")
|
||||
msg = "NZBGET_URL is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
self.url = normalize_http_url(raw_url)
|
||||
self.url = normalize_http_config_url(raw_url)
|
||||
if not self.url:
|
||||
raise ValueError("NZBGET_URL is invalid")
|
||||
self.username = config.get("NZBGET_USERNAME", "nzbget")
|
||||
self.password = config.get("NZBGET_PASSWORD", "")
|
||||
self._category = config.get("NZBGET_CATEGORY", "Books")
|
||||
msg = "NZBGET_URL is invalid"
|
||||
raise ValueError(msg)
|
||||
self.username = config_text(config.get("NZBGET_USERNAME", "nzbget"), "nzbget")
|
||||
self.password = config_text(config.get("NZBGET_PASSWORD", ""))
|
||||
self._category = config_text(config.get("NZBGET_CATEGORY", "Books"), "Books")
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Check if NZBGet is configured and selected as the usenet client."""
|
||||
client = config.get("PROWLARR_USENET_CLIENT", "")
|
||||
url = normalize_http_url(config.get("NZBGET_URL", ""))
|
||||
client = config_text(config.get("PROWLARR_USENET_CLIENT", ""))
|
||||
url = normalize_http_config_url(config.get("NZBGET_URL", ""))
|
||||
return client == "nzbget" and bool(url)
|
||||
|
||||
def _try_remove_command(
|
||||
self, command: str, nzb_id: int, download_id: str
|
||||
) -> tuple[bool, Exception | None]:
|
||||
"""Try one NZBGet delete command and return any error."""
|
||||
try:
|
||||
result = self._rpc_call("editqueue", [command, 0, "", nzb_id])
|
||||
if result:
|
||||
logger.info("Removed NZB from NZBGet (%s): %s", command, download_id)
|
||||
return True, None
|
||||
except _NZBGET_CLIENT_ERRORS as e:
|
||||
return False, e
|
||||
return False, None
|
||||
|
||||
@with_retry()
|
||||
def _rpc_call(self, method: str, params: Optional[list] = None) -> Any:
|
||||
"""
|
||||
Make a JSON-RPC call to NZBGet.
|
||||
def _rpc_call(self, method: str, params: list[object] | None = None) -> Any:
|
||||
"""Make a JSON-RPC call to NZBGet.
|
||||
|
||||
Args:
|
||||
method: RPC method name
|
||||
@@ -63,15 +82,19 @@ class NZBGetClient(DownloadClient):
|
||||
|
||||
Raises:
|
||||
Exception: If RPC call fails after retries.
|
||||
|
||||
"""
|
||||
rpc_url = f"{self.url}/jsonrpc"
|
||||
|
||||
payload = json.dumps({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params or [],
|
||||
}, separators=(',', ':'))
|
||||
payload = json.dumps(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params or [],
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
rpc_url,
|
||||
@@ -79,38 +102,39 @@ class NZBGetClient(DownloadClient):
|
||||
headers={"Content-Type": "application/json"},
|
||||
auth=(self.username, self.password),
|
||||
timeout=30,
|
||||
verify=get_ssl_verify(rpc_url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
if "error" in result and result["error"]:
|
||||
raise Exception(result["error"].get("message", "RPC error"))
|
||||
if result.get("error"):
|
||||
raise RuntimeError(result["error"].get("message", "RPC error"))
|
||||
|
||||
return result.get("result")
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Test connection to NZBGet."""
|
||||
try:
|
||||
status = self._rpc_call("status")
|
||||
version = status.get("Version", "unknown")
|
||||
return True, f"Connected to NZBGet {version}"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, "Could not connect to NZBGet"
|
||||
except requests.exceptions.Timeout:
|
||||
return False, "Connection timed out"
|
||||
except Exception as e:
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
except _NZBGET_CLIENT_ERRORS as e:
|
||||
return False, f"Connection failed: {e!s}"
|
||||
else:
|
||||
return True, f"Connected to NZBGet {version}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
**kwargs,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""
|
||||
Add NZB by URL.
|
||||
"""Add NZB by URL.
|
||||
|
||||
Fetches the NZB content from the URL (e.g., Prowlarr proxy) and sends
|
||||
it base64-encoded to NZBGet, since NZBGet may not handle redirects well.
|
||||
@@ -120,27 +144,29 @@ class NZBGetClient(DownloadClient):
|
||||
name: Display name for the download
|
||||
category: Category for organization (uses configured default if not specified)
|
||||
expected_hash: Optional info_hash hint (unused)
|
||||
**kwargs: Client-specific options passed through to the implementation.
|
||||
|
||||
Returns:
|
||||
NZBGet download ID (NZBID).
|
||||
|
||||
Raises:
|
||||
Exception: If adding fails.
|
||||
|
||||
"""
|
||||
import base64
|
||||
|
||||
# Use configured category if not explicitly provided
|
||||
category = category or self._category
|
||||
resolved_category = category or self._category
|
||||
|
||||
try:
|
||||
# Fetch NZB content from the URL (handles Prowlarr proxy redirects)
|
||||
logger.debug(f"Fetching NZB from: {url}")
|
||||
response = requests.get(url, timeout=30)
|
||||
logger.debug("Fetching NZB from: %s", url)
|
||||
response = requests.get(url, timeout=30, verify=get_ssl_verify(url))
|
||||
response.raise_for_status()
|
||||
nzb_content = base64.b64encode(response.content).decode('ascii')
|
||||
nzb_content = base64.b64encode(response.content).decode("ascii")
|
||||
|
||||
# Ensure filename has .nzb extension
|
||||
nzb_filename = name if name.endswith('.nzb') else f"{name}.nzb"
|
||||
nzb_filename = name if name.endswith(".nzb") else f"{name}.nzb"
|
||||
|
||||
# NZBGet append method parameters (all 10 required):
|
||||
# NZBFilename, Content, Category, Priority, AddToTop, AddPaused,
|
||||
@@ -150,7 +176,7 @@ class NZBGetClient(DownloadClient):
|
||||
[
|
||||
nzb_filename, # NZBFilename
|
||||
nzb_content, # Content (base64-encoded NZB)
|
||||
category, # Category
|
||||
resolved_category, # Category
|
||||
0, # Priority (0 = normal)
|
||||
False, # AddToTop
|
||||
False, # AddPaused
|
||||
@@ -161,27 +187,34 @@ class NZBGetClient(DownloadClient):
|
||||
],
|
||||
)
|
||||
|
||||
if nzb_id and nzb_id > 0:
|
||||
logger.info(f"Added NZB to NZBGet: {nzb_id}")
|
||||
if isinstance(nzb_id, int) and nzb_id > 0:
|
||||
logger.info("Added NZB to NZBGet: %s", nzb_id)
|
||||
return str(nzb_id)
|
||||
|
||||
raise Exception("NZBGet returned invalid ID")
|
||||
if isinstance(nzb_id, str):
|
||||
stripped_nzb_id = nzb_id.strip()
|
||||
if stripped_nzb_id.isdigit() and int(stripped_nzb_id) > 0:
|
||||
logger.info("Added NZB to NZBGet: %s", stripped_nzb_id)
|
||||
return stripped_nzb_id
|
||||
|
||||
_raise_runtime_error("NZBGet returned invalid ID")
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch NZB from URL: {e}")
|
||||
raise Exception(f"Failed to fetch NZB: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"NZBGet add failed: {e}")
|
||||
logger.exception("Failed to fetch NZB from URL")
|
||||
msg = f"Failed to fetch NZB: {e}"
|
||||
raise RuntimeError(msg) from e
|
||||
except _NZBGET_CLIENT_ERRORS:
|
||||
logger.exception("NZBGet add failed")
|
||||
raise
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""
|
||||
Get NZB status by ID.
|
||||
"""Get NZB status by ID.
|
||||
|
||||
Args:
|
||||
download_id: NZBGet NZBID
|
||||
|
||||
Returns:
|
||||
Current download status.
|
||||
|
||||
"""
|
||||
try:
|
||||
nzb_id = int(download_id)
|
||||
@@ -193,18 +226,12 @@ class NZBGetClient(DownloadClient):
|
||||
if group.get("NZBID") == nzb_id:
|
||||
# Calculate progress
|
||||
# NZBGet uses Hi/Lo for 64-bit values on 32-bit systems
|
||||
file_size = (group.get("FileSizeHi", 0) << 32) + group.get(
|
||||
"FileSizeLo", 0
|
||||
)
|
||||
file_size = (group.get("FileSizeHi", 0) << 32) + group.get("FileSizeLo", 0)
|
||||
remaining = (group.get("RemainingSizeHi", 0) << 32) + group.get(
|
||||
"RemainingSizeLo", 0
|
||||
)
|
||||
|
||||
progress = (
|
||||
((file_size - remaining) / file_size * 100)
|
||||
if file_size > 0
|
||||
else 0
|
||||
)
|
||||
progress = ((file_size - remaining) / file_size * 100) if file_size > 0 else 0
|
||||
status = group.get("Status", "")
|
||||
|
||||
# Map NZBGet status to our states
|
||||
@@ -227,9 +254,7 @@ class NZBGetClient(DownloadClient):
|
||||
file_path=None,
|
||||
download_speed=group.get("DownloadRate"),
|
||||
eta=(
|
||||
group.get("RemainingSec")
|
||||
if group.get("RemainingSec", 0) > 0
|
||||
else None
|
||||
group.get("RemainingSec") if group.get("RemainingSec", 0) > 0 else None
|
||||
),
|
||||
)
|
||||
|
||||
@@ -252,7 +277,6 @@ class NZBGetClient(DownloadClient):
|
||||
else:
|
||||
file_path = None
|
||||
|
||||
|
||||
if "SUCCESS" in status:
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
@@ -261,21 +285,20 @@ class NZBGetClient(DownloadClient):
|
||||
complete=True,
|
||||
file_path=file_path,
|
||||
)
|
||||
else:
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
state="error",
|
||||
message=f"Download failed: {status}",
|
||||
complete=True,
|
||||
file_path=file_path,
|
||||
)
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
state="error",
|
||||
message=f"Download failed: {status}",
|
||||
complete=True,
|
||||
file_path=file_path,
|
||||
)
|
||||
|
||||
# Not found in queue or history
|
||||
return DownloadStatus.error("Download not found")
|
||||
except Exception as e:
|
||||
except _NZBGET_CLIENT_ERRORS as e:
|
||||
return DownloadStatus.error(self._log_error("get_status", e))
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
"""Remove a download from NZBGet.
|
||||
|
||||
NZBGet can remove items from either the active queue (Group* commands) or from
|
||||
@@ -287,6 +310,7 @@ class NZBGetClient(DownloadClient):
|
||||
|
||||
Returns:
|
||||
True if successful.
|
||||
|
||||
"""
|
||||
try:
|
||||
nzb_id = int(download_id)
|
||||
@@ -301,29 +325,27 @@ class NZBGetClient(DownloadClient):
|
||||
else:
|
||||
commands = ["GroupDelete", "HistoryDelete"]
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
last_error: Exception | None = None
|
||||
for command in commands:
|
||||
try:
|
||||
result = self._rpc_call("editqueue", [command, 0, "", nzb_id])
|
||||
if result:
|
||||
logger.info(f"Removed NZB from NZBGet ({command}): {download_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
success, error = self._try_remove_command(command, nzb_id, download_id)
|
||||
if success:
|
||||
return True
|
||||
if error is not None:
|
||||
last_error = error
|
||||
|
||||
if last_error is not None:
|
||||
self._log_error("remove", last_error)
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get the path where NZB files are located.
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where NZB files are located.
|
||||
|
||||
Args:
|
||||
download_id: NZBGet NZBID
|
||||
|
||||
Returns:
|
||||
Destination directory, or None.
|
||||
|
||||
"""
|
||||
status = self.get_status(download_id)
|
||||
return status.file_path
|
||||
@@ -1,41 +1,146 @@
|
||||
"""qBittorrent download client for Prowlarr integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional, Tuple
|
||||
from typing import NoReturn, TypedDict
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.release_sources.prowlarr.clients import (
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.release_sources.prowlarr.clients.torrent_utils import (
|
||||
from shelfmark.download.clients._coercion import (
|
||||
coerce_optional_float,
|
||||
coerce_optional_int,
|
||||
config_text,
|
||||
normalize_http_config_url,
|
||||
)
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
)
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
try:
|
||||
import qbittorrentapi as _qbittorrentapi
|
||||
except ImportError:
|
||||
_ImportedQBittorrentApiError = RuntimeError
|
||||
_ImportedQBittorrentLoginFailed = RuntimeError
|
||||
else:
|
||||
_ImportedQBittorrentApiError = getattr(_qbittorrentapi, "APIError", RuntimeError)
|
||||
_ImportedQBittorrentLoginFailed = getattr(_qbittorrentapi, "LoginFailed", RuntimeError)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_HASH_LENGTH_40 = 40
|
||||
_HASH_LENGTH_ED2K = 32
|
||||
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
|
||||
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
_ONE_WEEK_IN_SECONDS = 604800
|
||||
|
||||
|
||||
class _QBittorrentAddKwargs(TypedDict, total=False):
|
||||
rename: str
|
||||
category: str
|
||||
save_path: str
|
||||
tags: str
|
||||
seeding_time_limit: int
|
||||
ratio_limit: float
|
||||
|
||||
|
||||
def _resolve_qbittorrent_exception_type(candidate: object) -> type[Exception]:
|
||||
if isinstance(candidate, type) and issubclass(candidate, Exception):
|
||||
return candidate
|
||||
return RuntimeError
|
||||
|
||||
|
||||
_QBittorrentApiError = _resolve_qbittorrent_exception_type(_ImportedQBittorrentApiError)
|
||||
_QBittorrentLoginFailed = _resolve_qbittorrent_exception_type(_ImportedQBittorrentLoginFailed)
|
||||
_QBITTORRENT_CLIENT_ERRORS = (
|
||||
_QBittorrentLoginFailed,
|
||||
_QBittorrentApiError,
|
||||
AttributeError,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
)
|
||||
|
||||
|
||||
def _hashes_match(hash1: str, hash2: str) -> bool:
|
||||
"""Compare hashes, handling Amarr's 40-char zero-padded hashes vs 32-char ed2k hashes."""
|
||||
h1, h2 = hash1.lower(), hash2.lower()
|
||||
if h1 == h2:
|
||||
return True
|
||||
if len(h1) == 40 and len(h2) == 32 and h1.endswith("00000000"):
|
||||
return h1[:32] == h2
|
||||
if len(h2) == 40 and len(h1) == 32 and h2.endswith("00000000"):
|
||||
return h2[:32] == h1
|
||||
if len(h1) == _HASH_LENGTH_40 and len(h2) == _HASH_LENGTH_ED2K and h1.endswith("00000000"):
|
||||
return h1[:_HASH_LENGTH_ED2K] == h2
|
||||
if len(h2) == _HASH_LENGTH_40 and len(h1) == _HASH_LENGTH_ED2K and h2.endswith("00000000"):
|
||||
return h2[:_HASH_LENGTH_ED2K] == h1
|
||||
return False
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def _normalize_tags(raw_tags: object) -> list[str]:
|
||||
"""Normalize tag input to a clean, de-duplicated list of strings."""
|
||||
if raw_tags is None:
|
||||
return []
|
||||
|
||||
if isinstance(raw_tags, str):
|
||||
parts = [part.strip() for part in raw_tags.split(",")]
|
||||
elif isinstance(raw_tags, (list, tuple, set)):
|
||||
parts = []
|
||||
for item in raw_tags:
|
||||
if item is None:
|
||||
continue
|
||||
parts.append(str(item).strip())
|
||||
else:
|
||||
parts = [str(raw_tags).strip()] if raw_tags else []
|
||||
|
||||
tags: list[str] = []
|
||||
seen = set()
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
if part in seen:
|
||||
continue
|
||||
seen.add(part)
|
||||
tags.append(part)
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
def _normalize_add_result(raw_result: object) -> str:
|
||||
"""Normalize qBittorrent add responses to a comparable string."""
|
||||
if raw_result is None:
|
||||
return ""
|
||||
|
||||
if isinstance(raw_result, bytes):
|
||||
return raw_result.decode("utf-8", errors="replace").strip()
|
||||
|
||||
return str(raw_result).strip()
|
||||
|
||||
|
||||
def _is_explicit_add_failure(raw_result: object) -> bool:
|
||||
"""Detect add responses that clearly indicate failure."""
|
||||
normalized = _normalize_add_result(raw_result).rstrip(".").lower()
|
||||
return normalized in {"fail", "fails", "error", "errors"}
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class QBittorrentClient(DownloadClient):
|
||||
"""qBittorrent download client."""
|
||||
|
||||
def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, Optional[str]]:
|
||||
def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, str | None]:
|
||||
"""Check whether qBittorrent has registered a torrent yet.
|
||||
|
||||
Uses `/api/v2/torrents/properties?hash=<hash>`.
|
||||
@@ -45,9 +150,8 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
Notes:
|
||||
A false result with no error means "not loaded yet".
|
||||
"""
|
||||
import requests
|
||||
|
||||
"""
|
||||
url = f"{self._base_url}/api/v2/torrents/properties"
|
||||
params = {"hash": torrent_hash}
|
||||
|
||||
@@ -56,23 +160,24 @@ class QBittorrentClient(DownloadClient):
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
# Re-authenticate and retry once on 403
|
||||
if response.status_code == 403:
|
||||
logger.debug("qBittorrent returned 403 for properties; re-authenticating and retrying")
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
logger.debug(
|
||||
"qBittorrent returned 403 for properties; re-authenticating and retrying"
|
||||
)
|
||||
self._client.auth_log_in()
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == 403:
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
return False, "qBittorrent authentication failed (HTTP 403)"
|
||||
|
||||
# qBittorrent returns 404/409-ish responses depending on version when missing.
|
||||
if response.status_code == 404:
|
||||
if response.status_code == _HTTP_STATUS_NOT_FOUND:
|
||||
return False, None
|
||||
|
||||
response.raise_for_status()
|
||||
return True, None
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
if status == 404:
|
||||
if status == _HTTP_STATUS_NOT_FOUND:
|
||||
return False, None
|
||||
if status:
|
||||
return False, f"qBittorrent API request failed (HTTP {status})"
|
||||
@@ -81,39 +186,54 @@ class QBittorrentClient(DownloadClient):
|
||||
return False, f"Cannot connect to qBittorrent at {self._base_url}"
|
||||
except requests.exceptions.Timeout:
|
||||
return False, f"qBittorrent request timed out at {self._base_url}"
|
||||
except Exception as e:
|
||||
except requests.exceptions.InvalidSchema:
|
||||
return (
|
||||
False,
|
||||
"qBittorrent URL is invalid (missing http:// or https://). "
|
||||
f"Configured: {self._base_url}",
|
||||
)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
return False, f"qBittorrent API error: {type(e).__name__}: {e}"
|
||||
else:
|
||||
return True, None
|
||||
|
||||
protocol = "torrent"
|
||||
name = "qbittorrent"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize qBittorrent client with settings from config."""
|
||||
# Lazy import to avoid dependency issues if not using torrents
|
||||
from qbittorrentapi import Client
|
||||
|
||||
raw_url = config.get("QBITTORRENT_URL", "")
|
||||
if not raw_url:
|
||||
raise ValueError("QBITTORRENT_URL is required")
|
||||
msg = "QBITTORRENT_URL is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
# We use `_base_url` for direct HTTP calls, so it must be a fully-qualified URL.
|
||||
self._base_url = normalize_http_url(raw_url)
|
||||
self._base_url = normalize_http_config_url(raw_url, require_string=True)
|
||||
if not self._base_url:
|
||||
raise ValueError("QBITTORRENT_URL is invalid")
|
||||
msg = "QBITTORRENT_URL is invalid"
|
||||
raise ValueError(msg)
|
||||
|
||||
username = config_text(config.get("QBITTORRENT_USERNAME", ""))
|
||||
password = config_text(config.get("QBITTORRENT_PASSWORD", ""))
|
||||
|
||||
# qbittorrent-api accepts either a full URL or host:port; prefer the normalized URL
|
||||
# for consistency.
|
||||
self._client = Client(
|
||||
host=self._base_url,
|
||||
username=config.get("QBITTORRENT_USERNAME", ""),
|
||||
password=config.get("QBITTORRENT_PASSWORD", ""),
|
||||
username=username,
|
||||
password=password,
|
||||
VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(self._base_url),
|
||||
)
|
||||
self._category = config.get("QBITTORRENT_CATEGORY", "books")
|
||||
|
||||
self._category = config_text(config.get("QBITTORRENT_CATEGORY", "books"))
|
||||
self._download_dir = config_text(config.get("QBITTORRENT_DOWNLOAD_DIR", ""))
|
||||
self._tags = _normalize_tags(config.get("QBITTORRENT_TAG", []))
|
||||
|
||||
def _get_torrents_info(
|
||||
self, torrent_hash: Optional[str] = None
|
||||
) -> tuple[list[SimpleNamespace], Optional[str]]:
|
||||
self, torrent_hash: str | None = None
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
"""Get torrent info using GET.
|
||||
|
||||
Behaviors:
|
||||
@@ -124,9 +244,8 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
Returns:
|
||||
(torrents, error_message)
|
||||
"""
|
||||
import requests
|
||||
|
||||
"""
|
||||
url = f"{self._base_url}/api/v2/torrents/info"
|
||||
|
||||
def do_request(params: dict[str, str]) -> requests.Response:
|
||||
@@ -138,13 +257,13 @@ class QBittorrentClient(DownloadClient):
|
||||
response: requests.Response,
|
||||
*,
|
||||
request_params: dict[str, str],
|
||||
) -> tuple[list[SimpleNamespace], Optional[str]]:
|
||||
if response.status_code == 403:
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
|
||||
self._client.auth_log_in()
|
||||
response = self._client._session.get(url, params=request_params, timeout=10)
|
||||
|
||||
if response.status_code == 403:
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
logger.warning("qBittorrent authentication failed (HTTP 403)")
|
||||
return [], "qBittorrent authentication failed (HTTP 403)"
|
||||
|
||||
@@ -186,48 +305,49 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
return all_torrents, None
|
||||
|
||||
return torrents, None
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
if status:
|
||||
logger.warning(f"qBittorrent API error (HTTP {status}): {e}")
|
||||
logger.warning("qBittorrent API error (HTTP %s): %s", status, e)
|
||||
return [], f"qBittorrent API request failed (HTTP {status})"
|
||||
|
||||
logger.warning(f"qBittorrent API error: {e}")
|
||||
logger.warning("qBittorrent API error: %s", e)
|
||||
return [], "qBittorrent API request failed"
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.warning(f"Cannot connect to qBittorrent at {self._base_url}")
|
||||
logger.warning("Cannot connect to qBittorrent at %s", self._base_url)
|
||||
return [], f"Cannot connect to qBittorrent at {self._base_url}"
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"qBittorrent request timed out at {self._base_url}")
|
||||
logger.warning("qBittorrent request timed out at %s", self._base_url)
|
||||
return [], f"qBittorrent request timed out at {self._base_url}"
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get torrents info: {e}")
|
||||
# requests raises InvalidSchema when the base URL doesn't include http(s)
|
||||
if type(e).__name__ == "InvalidSchema":
|
||||
return (
|
||||
[],
|
||||
"qBittorrent URL is invalid (missing http:// or https://). "
|
||||
f"Configured: {self._base_url}",
|
||||
)
|
||||
except requests.exceptions.InvalidSchema:
|
||||
logger.debug("Failed to get torrents info: invalid qBittorrent URL: %s", self._base_url)
|
||||
return (
|
||||
[],
|
||||
"qBittorrent URL is invalid (missing http:// or https://). "
|
||||
f"Configured: {self._base_url}",
|
||||
)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
logger.debug("Failed to get torrents info: %s", e)
|
||||
return [], f"qBittorrent API error: {type(e).__name__}: {e}"
|
||||
else:
|
||||
return torrents, None
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Check if qBittorrent is configured and selected as the torrent client."""
|
||||
client = config.get("PROWLARR_TORRENT_CLIENT", "")
|
||||
url = normalize_http_url(config.get("QBITTORRENT_URL", ""))
|
||||
client = config_text(config.get("PROWLARR_TORRENT_CLIENT", ""))
|
||||
url = normalize_http_config_url(config.get("QBITTORRENT_URL", ""), require_string=True)
|
||||
return client == "qbittorrent" and bool(url)
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Test connection to qBittorrent."""
|
||||
try:
|
||||
self._client.auth_log_in()
|
||||
api_version = self._client.app.web_api_version
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
return False, f"Connection failed: {e!s}"
|
||||
else:
|
||||
return True, f"Connected to qBittorrent (API v{api_version})"
|
||||
except Exception as e:
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
@@ -235,92 +355,121 @@ class QBittorrentClient(DownloadClient):
|
||||
name: str,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""
|
||||
Add torrent by URL (magnet or .torrent).
|
||||
"""Add torrent by URL (magnet or .torrent).
|
||||
|
||||
Args:
|
||||
url: Magnet link or .torrent URL
|
||||
name: Display name for the torrent
|
||||
category: Category for organization (uses configured default if not specified)
|
||||
expected_hash: Optional info_hash hint (from Prowlarr)
|
||||
**kwargs: Client-specific options passed through to the implementation.
|
||||
|
||||
Returns:
|
||||
Torrent hash (info_hash).
|
||||
|
||||
Raises:
|
||||
Exception: If adding fails.
|
||||
|
||||
"""
|
||||
try:
|
||||
# Use configured category if not explicitly provided
|
||||
category = category or self._category
|
||||
tags = self._tags
|
||||
seeding_time_limit: int | None = None
|
||||
ratio_limit: float | None = None
|
||||
|
||||
# Ensure category exists (may already exist, which is fine)
|
||||
try:
|
||||
self._client.torrents_create_category(name=category)
|
||||
except Exception as e:
|
||||
# Conflict409Error means category exists - that's expected
|
||||
# Log other errors but continue since download may still work
|
||||
if "Conflict" not in type(e).__name__ and "409" not in str(e):
|
||||
logger.debug(f"Could not create category '{category}': {type(e).__name__}: {e}")
|
||||
if category:
|
||||
try:
|
||||
self._client.torrents_create_category(name=category)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
# Conflict409Error means category exists - that's expected
|
||||
# Log other errors but continue since download may still work
|
||||
if "Conflict" not in type(e).__name__ and "409" not in str(e):
|
||||
logger.debug(
|
||||
"Could not create category '%s': %s: %s",
|
||||
category,
|
||||
type(e).__name__,
|
||||
e,
|
||||
)
|
||||
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
expected_hash = torrent_info.info_hash
|
||||
torrent_data = torrent_info.torrent_data
|
||||
|
||||
# Add the torrent - use file content if we have it, otherwise URL
|
||||
# Per-torrent seeding limits from indexer
|
||||
seeding_time_limit_value = kwargs.get("seeding_time_limit")
|
||||
seeding_time_limit = coerce_optional_int(seeding_time_limit_value)
|
||||
ratio_limit_value = kwargs.get("ratio_limit")
|
||||
ratio_limit = coerce_optional_float(ratio_limit_value)
|
||||
|
||||
add_kwargs: _QBittorrentAddKwargs = {"rename": name}
|
||||
if category:
|
||||
add_kwargs["category"] = category
|
||||
if self._download_dir:
|
||||
add_kwargs["save_path"] = self._download_dir
|
||||
if tags:
|
||||
add_kwargs["tags"] = ",".join(tags)
|
||||
if seeding_time_limit is not None:
|
||||
add_kwargs["seeding_time_limit"] = seeding_time_limit
|
||||
if ratio_limit is not None:
|
||||
add_kwargs["ratio_limit"] = ratio_limit
|
||||
|
||||
if torrent_data:
|
||||
result = self._client.torrents_add(
|
||||
torrent_files=torrent_data,
|
||||
category=category,
|
||||
rename=name,
|
||||
**add_kwargs,
|
||||
)
|
||||
else:
|
||||
# Use magnet URL if available, otherwise original URL
|
||||
add_url = torrent_info.magnet_url or url
|
||||
result = self._client.torrents_add(
|
||||
urls=add_url,
|
||||
category=category,
|
||||
rename=name,
|
||||
**add_kwargs,
|
||||
)
|
||||
|
||||
logger.debug(f"qBittorrent add result: {result}")
|
||||
result_text = _normalize_add_result(result)
|
||||
logger.debug("qBittorrent add result: %s", result_text)
|
||||
|
||||
if result == "Ok.":
|
||||
if not expected_hash:
|
||||
raise Exception("Could not determine torrent hash from URL")
|
||||
if not expected_hash:
|
||||
_raise_runtime_error("Could not determine torrent hash from URL")
|
||||
|
||||
# Wait for torrent to appear in client.
|
||||
# Use `/torrents/properties?hash=` rather than relying on `torrents/info`
|
||||
# listing being immediately consistent.
|
||||
for _ in range(10):
|
||||
loaded, error = self._is_torrent_loaded(expected_hash)
|
||||
if error:
|
||||
logger.debug(f"qBittorrent add_download: {error}")
|
||||
if loaded:
|
||||
logger.info(f"Added torrent: {expected_hash}")
|
||||
return expected_hash.lower()
|
||||
time.sleep(0.5)
|
||||
if _is_explicit_add_failure(result):
|
||||
_raise_runtime_error(f"Failed to add torrent: {result_text}")
|
||||
|
||||
# Client said Ok, trust it
|
||||
logger.warning(f"Torrent not yet visible, returning expected hash")
|
||||
return expected_hash
|
||||
# Some qBittorrent-compatible clients return HTTP 200 with an empty body
|
||||
# instead of qBittorrent's literal "Ok." response. Prefer verifying that
|
||||
# the torrent becomes visible over trusting the response body alone.
|
||||
for _ in range(10):
|
||||
loaded, error = self._is_torrent_loaded(expected_hash)
|
||||
if error:
|
||||
logger.debug("qBittorrent add_download: %s", error)
|
||||
if loaded:
|
||||
logger.info("Added torrent: %s", expected_hash)
|
||||
return expected_hash.lower()
|
||||
time.sleep(0.5)
|
||||
|
||||
raise Exception(f"Failed to add torrent: {result}")
|
||||
except Exception as e:
|
||||
logger.error(f"qBittorrent add failed: {e}")
|
||||
logger.warning(
|
||||
"Torrent add was not confirmed within the visibility grace period (response=%s), returning expected hash",
|
||||
result_text,
|
||||
)
|
||||
except _QBITTORRENT_CLIENT_ERRORS:
|
||||
logger.exception("qBittorrent add failed")
|
||||
raise
|
||||
else:
|
||||
return expected_hash
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""
|
||||
Get torrent status by hash.
|
||||
"""Get torrent status by hash.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
|
||||
Returns:
|
||||
Current download status.
|
||||
|
||||
"""
|
||||
try:
|
||||
torrents, error = self._get_torrents_info(download_id)
|
||||
@@ -332,7 +481,7 @@ class QBittorrentClient(DownloadClient):
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(getattr(t, "hash"), download_id)
|
||||
and _hashes_match(t.hash, download_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -341,7 +490,10 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
# Map qBittorrent states to our states and user-friendly messages
|
||||
state_info = {
|
||||
"downloading": ("downloading", None), # None = use default progress message
|
||||
"downloading": (
|
||||
"downloading",
|
||||
None,
|
||||
), # None = use default progress message
|
||||
"stalledDL": ("downloading", "Stalled"),
|
||||
"metaDL": ("downloading", "Fetching metadata"),
|
||||
"forcedDL": ("downloading", None),
|
||||
@@ -376,7 +528,11 @@ class QBittorrentClient(DownloadClient):
|
||||
message = "Complete"
|
||||
|
||||
torrent_eta = getattr(torrent, "eta", 0)
|
||||
eta = torrent_eta if isinstance(torrent_eta, int) and 0 < torrent_eta < 604800 else None
|
||||
eta = (
|
||||
torrent_eta
|
||||
if isinstance(torrent_eta, int) and 0 < torrent_eta < _ONE_WEEK_IN_SECONDS
|
||||
else None
|
||||
)
|
||||
|
||||
# Get file path for completed downloads
|
||||
file_path = None
|
||||
@@ -395,12 +551,11 @@ class QBittorrentClient(DownloadClient):
|
||||
download_speed=torrent_speed,
|
||||
eta=eta,
|
||||
)
|
||||
except Exception as e:
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
return DownloadStatus.error(self._log_error("get_status", e))
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
"""
|
||||
Remove a torrent from qBittorrent.
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
"""Remove a torrent from qBittorrent.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
@@ -408,21 +563,22 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
Returns:
|
||||
True if successful.
|
||||
|
||||
"""
|
||||
try:
|
||||
self._client.torrents_delete(
|
||||
torrent_hashes=download_id, delete_files=delete_files
|
||||
)
|
||||
self._client.torrents_delete(torrent_hashes=download_id, delete_files=delete_files)
|
||||
logger.info(
|
||||
f"Removed torrent from qBittorrent: {download_id}"
|
||||
+ (" (with files)" if delete_files else "")
|
||||
"Removed torrent from qBittorrent: %s%s",
|
||||
download_id,
|
||||
" (with files)" if delete_files else "",
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
self._log_error("remove", e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where torrent files are located.
|
||||
|
||||
Prefer `content_path` when available.
|
||||
@@ -433,12 +589,10 @@ class QBittorrentClient(DownloadClient):
|
||||
- `/api/v2/torrents/files?hash=<hash>` for the first file name
|
||||
- join `save_path` with the torrent's top-level directory
|
||||
"""
|
||||
import os
|
||||
|
||||
try:
|
||||
torrents, error = self._get_torrents_info(download_id)
|
||||
if error:
|
||||
logger.debug(f"qBittorrent get_download_path: {error}")
|
||||
logger.debug("qBittorrent get_download_path: %s", error)
|
||||
return None
|
||||
|
||||
torrent = next(
|
||||
@@ -446,7 +600,7 @@ class QBittorrentClient(DownloadClient):
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(getattr(t, "hash"), download_id)
|
||||
and _hashes_match(t.hash, download_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -454,11 +608,11 @@ class QBittorrentClient(DownloadClient):
|
||||
return None
|
||||
|
||||
return self._resolve_completed_download_path(torrent)
|
||||
except Exception as e:
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
self._log_error("get_download_path", e, level="debug")
|
||||
return None
|
||||
|
||||
def _resolve_completed_download_path(self, torrent: SimpleNamespace) -> Optional[str]:
|
||||
def _resolve_completed_download_path(self, torrent: SimpleNamespace) -> str | None:
|
||||
"""Resolve the completed path for a torrent.
|
||||
|
||||
Centralizes the logic shared by `get_status()` and `get_download_path()`:
|
||||
@@ -466,7 +620,6 @@ class QBittorrentClient(DownloadClient):
|
||||
- otherwise derive via properties+files
|
||||
- finally fall back to `save_path + name`
|
||||
"""
|
||||
|
||||
# Prefer content_path, but treat content_path == save_path as invalid.
|
||||
content_path = getattr(torrent, "content_path", "")
|
||||
save_path = getattr(torrent, "save_path", "")
|
||||
@@ -485,19 +638,18 @@ class QBittorrentClient(DownloadClient):
|
||||
getattr(torrent, "name", ""),
|
||||
)
|
||||
|
||||
def _derive_download_path_from_files(self, download_id: str) -> Optional[str]:
|
||||
def _derive_download_path_from_files(self, download_id: str) -> str | None:
|
||||
"""Derive completed download path using `/torrents/properties` + `/torrents/files`.
|
||||
|
||||
This mirrors how common automation apps derive the path when
|
||||
`content_path` isn't provided.
|
||||
"""
|
||||
import os
|
||||
import requests
|
||||
|
||||
def get_with_auth(url: str, params: dict[str, str]) -> requests.Response:
|
||||
self._client.auth_log_in()
|
||||
resp = self._client._session.get(url, params=params, timeout=10)
|
||||
if resp.status_code == 403:
|
||||
if resp.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
|
||||
self._client.auth_log_in()
|
||||
resp = self._client._session.get(url, params=params, timeout=10)
|
||||
@@ -508,7 +660,7 @@ class QBittorrentClient(DownloadClient):
|
||||
files_url = f"{self._base_url}/api/v2/torrents/files"
|
||||
|
||||
props_resp = get_with_auth(properties_url, {"hash": download_id})
|
||||
if props_resp.status_code == 404:
|
||||
if props_resp.status_code == _HTTP_STATUS_NOT_FOUND:
|
||||
return None
|
||||
props_resp.raise_for_status()
|
||||
props = props_resp.json() if isinstance(props_resp.json(), dict) else {}
|
||||
@@ -518,7 +670,7 @@ class QBittorrentClient(DownloadClient):
|
||||
return None
|
||||
|
||||
files_resp = get_with_auth(files_url, {"hash": download_id})
|
||||
if files_resp.status_code == 404:
|
||||
if files_resp.status_code == _HTTP_STATUS_NOT_FOUND:
|
||||
return None
|
||||
files_resp.raise_for_status()
|
||||
files = files_resp.json() if isinstance(files_resp.json(), list) else []
|
||||
@@ -535,14 +687,18 @@ class QBittorrentClient(DownloadClient):
|
||||
if not top_level:
|
||||
return None
|
||||
|
||||
return os.path.normpath(os.path.join(save_path, top_level))
|
||||
except Exception as e:
|
||||
logger.debug(f"qBittorrent could not derive path from files: {type(e).__name__}: {e}")
|
||||
return os.path.normpath(str(Path(save_path) / top_level))
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
logger.debug(
|
||||
"qBittorrent could not derive path from files: %s: %s",
|
||||
type(e).__name__,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
self, url: str, category: str | None = None
|
||||
) -> tuple[str, DownloadStatus] | None:
|
||||
"""Check if a torrent for this URL already exists in qBittorrent."""
|
||||
try:
|
||||
torrent_info = extract_torrent_info(url)
|
||||
@@ -551,7 +707,7 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
torrents, error = self._get_torrents_info(torrent_info.info_hash)
|
||||
if error:
|
||||
logger.debug(f"qBittorrent find_existing: {error}")
|
||||
logger.debug("qBittorrent find_existing: %s", error)
|
||||
return None
|
||||
|
||||
torrent = next(
|
||||
@@ -559,15 +715,15 @@ class QBittorrentClient(DownloadClient):
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(getattr(t, "hash"), torrent_info.info_hash)
|
||||
and _hashes_match(t.hash, torrent_info.info_hash)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if torrent and isinstance(getattr(torrent, "hash", None), str):
|
||||
torrent_hash = getattr(torrent, "hash")
|
||||
torrent_hash = torrent.hash
|
||||
return (torrent_hash.lower(), self.get_status(torrent_hash.lower()))
|
||||
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
logger.debug("Error checking for existing torrent: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking for existing torrent: {e}")
|
||||
else:
|
||||
return None
|
||||
@@ -0,0 +1,407 @@
|
||||
"""rTorrent download client for Prowlarr integration.
|
||||
|
||||
Uses xmlrpc to communicate with rTorrent's RPC interface.
|
||||
"""
|
||||
|
||||
import ssl
|
||||
import xmlrpc.client as stdlib_xmlrpc_client
|
||||
from typing import Any, NoReturn, Protocol, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import get_hardened_xmlrpc_client
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.download.clients._coercion import config_text, normalize_http_config_url
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
)
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
_ETA_MAX_SECONDS = 604800
|
||||
_RTORRENT_CLIENT_ERRORS = (
|
||||
AttributeError,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
stdlib_xmlrpc_client.Error,
|
||||
)
|
||||
|
||||
|
||||
class _RTorrentSystemProtocol(Protocol):
|
||||
def client_version(self) -> object: ...
|
||||
|
||||
|
||||
class _RTorrentLoadProtocol(Protocol):
|
||||
def raw_start(self, target: str, torrent_data: bytes, commands: str) -> object: ...
|
||||
|
||||
def start(self, target: str, url: str, commands: str) -> object: ...
|
||||
|
||||
|
||||
class _RTorrentDownloadProtocol(Protocol):
|
||||
def multicall2(self, *args: object) -> list[list[Any]]: ...
|
||||
|
||||
def delete_tied(self, download_id: str) -> object: ...
|
||||
|
||||
def erase(self, download_id: str) -> object: ...
|
||||
|
||||
def stop(self, download_id: str) -> object: ...
|
||||
|
||||
|
||||
class _RTorrentDirectoryProtocol(Protocol):
|
||||
def default(self) -> str: ...
|
||||
|
||||
|
||||
class _RTorrentRpcProtocol(Protocol):
|
||||
system: _RTorrentSystemProtocol
|
||||
load: _RTorrentLoadProtocol
|
||||
d: _RTorrentDownloadProtocol
|
||||
directory: _RTorrentDirectoryProtocol
|
||||
|
||||
|
||||
def _create_rtorrent_server_proxy(url: str) -> _RTorrentRpcProtocol:
|
||||
"""Create an XML-RPC ServerProxy honoring certificate validation mode."""
|
||||
xmlrpc_client = get_hardened_xmlrpc_client()
|
||||
|
||||
verify = get_ssl_verify(url)
|
||||
if url.startswith("https://") and not verify:
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
transport = xmlrpc_client.SafeTransport(context=ssl_context)
|
||||
return cast(_RTorrentRpcProtocol, xmlrpc_client.ServerProxy(url, transport=transport))
|
||||
|
||||
return cast(_RTorrentRpcProtocol, xmlrpc_client.ServerProxy(url))
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class RTorrentClient(DownloadClient):
|
||||
"""rTorrent download client using xmlrpc."""
|
||||
|
||||
protocol = "torrent"
|
||||
name = "rtorrent"
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize rTorrent client with settings from config."""
|
||||
raw_url = config_text(config.get("RTORRENT_URL", ""))
|
||||
if not raw_url:
|
||||
msg = "RTORRENT_URL is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
self._base_url = normalize_http_config_url(raw_url)
|
||||
if not self._base_url:
|
||||
msg = "RTORRENT_URL is invalid"
|
||||
raise ValueError(msg)
|
||||
|
||||
username = config_text(config.get("RTORRENT_USERNAME", ""))
|
||||
password = config_text(config.get("RTORRENT_PASSWORD", ""))
|
||||
|
||||
if username and password:
|
||||
parsed = urlparse(self._base_url)
|
||||
self._base_url = f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}"
|
||||
|
||||
self._rpc = _create_rtorrent_server_proxy(self._base_url)
|
||||
self._download_dir = config_text(config.get("RTORRENT_DOWNLOAD_DIR", ""))
|
||||
self._label = config_text(config.get("RTORRENT_LABEL", ""))
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Check if rTorrent is configured and selected as the torrent client."""
|
||||
client = config_text(config.get("PROWLARR_TORRENT_CLIENT", ""))
|
||||
url = normalize_http_config_url(config.get("RTORRENT_URL", ""))
|
||||
return client == "rtorrent" and bool(url)
|
||||
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Test connection to rTorrent."""
|
||||
try:
|
||||
version = self._rpc.system.client_version()
|
||||
except _RTORRENT_CLIENT_ERRORS as e:
|
||||
return False, f"Connection failed: {e!s}"
|
||||
else:
|
||||
return True, f"Connected to rTorrent {version}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""Add torrent by URL (magnet or .torrent).
|
||||
|
||||
Args:
|
||||
url: Magnet link or .torrent URL
|
||||
name: Display name for the torrent
|
||||
category: Category for organization (uses configured label if not specified)
|
||||
expected_hash: Optional info_hash hint (from Prowlarr)
|
||||
**kwargs: Client-specific options passed through to the implementation.
|
||||
|
||||
Returns:
|
||||
Torrent hash (info_hash).
|
||||
|
||||
Raises:
|
||||
Exception: If adding fails.
|
||||
|
||||
"""
|
||||
try:
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
|
||||
commands = []
|
||||
|
||||
label = category or self._label
|
||||
if label:
|
||||
logger.debug("Setting rTorrent label: %s", label)
|
||||
commands.append(f"d.custom1.set={label}")
|
||||
|
||||
download_dir = self._download_dir or self._get_download_dir()
|
||||
if download_dir:
|
||||
logger.debug("Setting rTorrent download directory: %s", download_dir)
|
||||
commands.append(f"d.directory.set={download_dir}")
|
||||
|
||||
if torrent_info.torrent_data:
|
||||
logger.debug(
|
||||
"Adding torrent data directly to rTorrent for: %s with commands: %s with data size: %s",
|
||||
name,
|
||||
commands,
|
||||
len(torrent_info.torrent_data),
|
||||
)
|
||||
self._rpc.load.raw_start("", torrent_info.torrent_data, ";".join(commands))
|
||||
else:
|
||||
logger.debug(
|
||||
"Adding torrent URL to rTorrent for: %s with commands: %s with URL: %s",
|
||||
name,
|
||||
commands,
|
||||
url,
|
||||
)
|
||||
add_url = torrent_info.magnet_url or url
|
||||
self._rpc.load.start("", add_url, ";".join(commands))
|
||||
|
||||
torrent_hash = torrent_info.info_hash or expected_hash
|
||||
if not torrent_hash:
|
||||
_raise_runtime_error("Could not determine torrent hash from URL")
|
||||
|
||||
logger.debug("Added torrent to rTorrent: %s", torrent_hash)
|
||||
|
||||
except _RTORRENT_CLIENT_ERRORS:
|
||||
logger.exception("rTorrent add failed")
|
||||
raise
|
||||
else:
|
||||
return torrent_hash
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""Get torrent status by hash.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
|
||||
Returns:
|
||||
Current download status.
|
||||
|
||||
"""
|
||||
try:
|
||||
# rtorrent is somehow case sensitive and requires uppercase hashes for look
|
||||
download_id = download_id.upper()
|
||||
all_torrents = self._rpc.d.multicall2(
|
||||
"",
|
||||
"",
|
||||
"d.hash=",
|
||||
"d.state=",
|
||||
"d.completed_bytes=",
|
||||
"d.size_bytes=",
|
||||
"d.down.rate=",
|
||||
"d.up.rate=",
|
||||
"d.custom1=",
|
||||
"d.complete=",
|
||||
)
|
||||
torrent_list = [t for t in all_torrents if t and t[0] == download_id]
|
||||
logger.debug(
|
||||
"Fetched torrent status from rTorrent for: %s - %s",
|
||||
download_id,
|
||||
torrent_list,
|
||||
)
|
||||
if not torrent_list:
|
||||
logger.warning("Torrent not found in rTorrent: %s", download_id)
|
||||
return DownloadStatus.error("Torrent not found")
|
||||
|
||||
torrent = torrent_list[0]
|
||||
if not torrent:
|
||||
logger.warning("Torrent data is empty for: %s", download_id)
|
||||
return DownloadStatus.error("Torrent not found")
|
||||
|
||||
logger.debug("Torrent data for %s: %s", download_id, torrent)
|
||||
(
|
||||
_torrent_hash,
|
||||
state,
|
||||
bytes_downloaded,
|
||||
bytes_total,
|
||||
down_rate,
|
||||
_up_rate,
|
||||
_custom_category,
|
||||
complete,
|
||||
) = torrent
|
||||
|
||||
try:
|
||||
state = int(state)
|
||||
except TypeError, ValueError:
|
||||
state = 0
|
||||
|
||||
complete = bool(complete)
|
||||
|
||||
progress = (bytes_downloaded / bytes_total) * 100 if bytes_total > 0 else 0
|
||||
|
||||
bytes_left = max(0, bytes_total - bytes_downloaded)
|
||||
|
||||
state_map = {
|
||||
0: ("paused", "Paused"),
|
||||
1: ("downloading", "Downloading"),
|
||||
2: ("downloading", "Downloading"),
|
||||
3: ("downloading", "Downloading"),
|
||||
4: ("seeding", "Seeding"),
|
||||
}
|
||||
|
||||
state_str, message = state_map.get(state, ("unknown", "Unknown state"))
|
||||
|
||||
if complete and not message:
|
||||
message = "Complete"
|
||||
|
||||
eta = None
|
||||
if down_rate > 0 and bytes_left > 0:
|
||||
eta_seconds = bytes_left / down_rate
|
||||
if eta_seconds < _ETA_MAX_SECONDS:
|
||||
eta = int(eta_seconds)
|
||||
|
||||
file_path = None
|
||||
if complete:
|
||||
file_path = self._get_torrent_path(download_id)
|
||||
|
||||
return DownloadStatus(
|
||||
progress=min(100, progress),
|
||||
state="complete" if complete else state_str,
|
||||
message=message,
|
||||
complete=complete,
|
||||
file_path=file_path,
|
||||
download_speed=down_rate if down_rate > 0 else None,
|
||||
eta=eta,
|
||||
)
|
||||
|
||||
except _RTORRENT_CLIENT_ERRORS as e:
|
||||
error_type = type(e).__name__
|
||||
logger.exception("rTorrent get_status failed (%s)", error_type)
|
||||
return DownloadStatus.error(f"{error_type}: {e}")
|
||||
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
"""Remove a torrent from rTorrent.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
delete_files: Whether to also delete files
|
||||
|
||||
Returns:
|
||||
True if successful.
|
||||
|
||||
"""
|
||||
try:
|
||||
if delete_files:
|
||||
self._rpc.d.delete_tied(download_id)
|
||||
self._rpc.d.erase(download_id)
|
||||
else:
|
||||
self._rpc.d.stop(download_id)
|
||||
self._rpc.d.erase(download_id)
|
||||
|
||||
logger.info(
|
||||
"Removed torrent from rTorrent: %s%s",
|
||||
download_id,
|
||||
" (with files)" if delete_files else "",
|
||||
)
|
||||
except _RTORRENT_CLIENT_ERRORS as e:
|
||||
error_type = type(e).__name__
|
||||
logger.exception("rTorrent remove failed (%s)", error_type)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where torrent files are located.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
|
||||
Returns:
|
||||
Content path (file or directory), or None.
|
||||
|
||||
"""
|
||||
try:
|
||||
return self._get_torrent_path(download_id)
|
||||
except _RTORRENT_CLIENT_ERRORS as e:
|
||||
error_type = type(e).__name__
|
||||
logger.debug("rTorrent get_download_path failed (%s): %s", error_type, e)
|
||||
return None
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: str | None = None
|
||||
) -> tuple[str, DownloadStatus] | None:
|
||||
"""Check if a torrent for this URL already exists in rTorrent."""
|
||||
try:
|
||||
torrent_info = extract_torrent_info(url)
|
||||
if not torrent_info.info_hash:
|
||||
return None
|
||||
|
||||
try:
|
||||
status = self.get_status(torrent_info.info_hash)
|
||||
if status.state != DownloadStatus.error("").state:
|
||||
return (torrent_info.info_hash, status)
|
||||
except _RTORRENT_CLIENT_ERRORS as exc:
|
||||
logger.debug(
|
||||
"Could not fetch existing rTorrent status for %s: %s",
|
||||
torrent_info.info_hash,
|
||||
exc,
|
||||
)
|
||||
except _RTORRENT_CLIENT_ERRORS as e:
|
||||
logger.debug("Error checking for existing torrent: %s", e)
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
def _get_download_dir(self) -> str:
|
||||
"""Get the download directory from rTorrent config."""
|
||||
try:
|
||||
return self._rpc.directory.default()
|
||||
except _RTORRENT_CLIENT_ERRORS:
|
||||
return "/downloads"
|
||||
|
||||
def _get_torrent_path(self, download_id: str) -> str | None:
|
||||
"""Get the file path of a torrent by hash.
|
||||
|
||||
Uses `d.base_path` for the item output path. In the xmlrpc interface
|
||||
this corresponds to `d.get_base_path()`.
|
||||
"""
|
||||
try:
|
||||
# rTorrent is case sensitive for hashes; use uppercase as in get_status()
|
||||
download_hash = download_id.upper()
|
||||
all_torrents = self._rpc.d.multicall2(
|
||||
"",
|
||||
"",
|
||||
"d.hash=",
|
||||
"d.base_path=",
|
||||
)
|
||||
details = [t[1:] for t in all_torrents if t and t[0] == download_hash]
|
||||
if not details:
|
||||
return None
|
||||
path = details[0][0]
|
||||
except _RTORRENT_CLIENT_ERRORS:
|
||||
return None
|
||||
else:
|
||||
return str(path) if path else None
|
||||