mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,6 +1,7 @@
|
||||
.git
|
||||
.github
|
||||
.vscode
|
||||
.local
|
||||
.mypy_cache
|
||||
README_images
|
||||
.gitignore
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Python dependencies
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
# Frontend npm dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/src/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
# Dockerfile base images
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
permissions: read-all
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/shelfmark
|
||||
@@ -29,18 +31,18 @@ jobs:
|
||||
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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
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 +52,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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Build and push ${{ matrix.target }} Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
@@ -67,10 +69,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 +91,14 @@ jobs:
|
||||
LEGACY_NAME: calibre-web-automated-book-downloader
|
||||
steps:
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Create legacy aliases
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements-base.txt
|
||||
pip install -r requirements-shelfmark.txt
|
||||
pip install pytest
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -x --tb=short
|
||||
|
||||
frontend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: src/frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: src/frontend
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Unit tests
|
||||
working-directory: src/frontend
|
||||
run: npm run test:unit
|
||||
@@ -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@820e3160e279568db735cee8ed8f8e77a6da7818 # v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@820e3160e279568db735cee8ed8f8e77a6da7818 # v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@820e3160e279568db735cee8ed8f8e77a6da7818 # v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -233,3 +233,4 @@ AGENTS.md
|
||||
.claude/
|
||||
.playwright-mcp/
|
||||
frontend-dist/
|
||||
node_modules/
|
||||
|
||||
+3
-4
@@ -25,7 +25,7 @@ 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
|
||||
|
||||
# Add build argument for version
|
||||
ARG BUILD_VERSION
|
||||
@@ -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 \
|
||||
@@ -151,8 +151,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install -r requirements-shelfmark.txt
|
||||
|
||||
# Grant read/execute permissions to others
|
||||
RUN chmod -R o+rx /usr/bin/chromium && \
|
||||
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 dev build preview typecheck frontend-test clean up down docker-build refresh restart build-serve
|
||||
|
||||
# Frontend directory
|
||||
FRONTEND_DIR := src/frontend
|
||||
@@ -14,8 +14,10 @@ help:
|
||||
@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-test - Run frontend unit tests"
|
||||
@echo " clean - Remove node_modules and build artifacts"
|
||||
@echo ""
|
||||
@echo "Backend (Docker):"
|
||||
@@ -40,6 +42,13 @@ 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..."
|
||||
@@ -50,6 +59,11 @@ typecheck:
|
||||
@echo "Running TypeScript type checking..."
|
||||
cd $(FRONTEND_DIR) && npm run typecheck
|
||||
|
||||
# Run frontend unit tests
|
||||
frontend-test:
|
||||
@echo "Running frontend unit tests..."
|
||||
cd $(FRONTEND_DIR) && npm run test:unit
|
||||
|
||||
# Clean build artifacts and dependencies
|
||||
clean:
|
||||
@echo "Cleaning build artifacts and dependencies..."
|
||||
|
||||
@@ -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,39 +0,0 @@
|
||||
# Bypass testing - switch between dev build and v1.0.1
|
||||
# Usage:
|
||||
# Test dev build: docker compose -f docker-compose.bypass-test.yml up shelfmark-dev
|
||||
# Test v1.0.1: docker compose -f docker-compose.bypass-test.yml up shelfmark-stable
|
||||
# Pull latest dev: docker compose -f docker-compose.bypass-test.yml build shelfmark-dev
|
||||
# Pull v1.0.1: docker compose -f docker-compose.bypass-test.yml pull shelfmark-stable
|
||||
|
||||
services:
|
||||
# Dev image from registry
|
||||
shelfmark-dev:
|
||||
image: ghcr.io/calibrain/shelfmark:dev
|
||||
container_name: shelfmark-bypass-dev
|
||||
environment:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
DEBUG: true
|
||||
ports:
|
||||
- 8084:8084
|
||||
volumes:
|
||||
- ./.local/bypass-test/config-dev:/config
|
||||
- ./.local/bypass-test/books:/books
|
||||
- ./.local/bypass-test/log-dev:/var/log/shelfmark
|
||||
- ./.local/bypass-test/tmp:/tmp/shelfmark
|
||||
|
||||
# Stable v1.0.1 for comparison
|
||||
shelfmark-stable:
|
||||
image: ghcr.io/calibrain/shelfmark:1.0.1
|
||||
container_name: shelfmark-bypass-stable
|
||||
environment:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
DEBUG: true
|
||||
ports:
|
||||
- 8085:8084
|
||||
volumes:
|
||||
- ./.local/bypass-test/config-stable:/config
|
||||
- ./.local/bypass-test/books:/books
|
||||
- ./.local/bypass-test/log-stable:/var/log/shelfmark
|
||||
- ./.local/bypass-test/tmp:/tmp/shelfmark
|
||||
@@ -20,5 +20,6 @@ services:
|
||||
- ./.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
|
||||
|
||||
+221
-22
@@ -10,6 +10,7 @@ This document lists all configuration options that can be set via environment va
|
||||
- [General](#general)
|
||||
- [Search Mode](#search-mode)
|
||||
- [Downloads](#downloads)
|
||||
- [Security](#security)
|
||||
- [Network](#network)
|
||||
- [Advanced](#advanced)
|
||||
- [Prowlarr](#prowlarr)
|
||||
@@ -20,6 +21,7 @@ This document lists all configuration options that can be set via environment va
|
||||
- [Hardcover](#metadata-providers-hardcover)
|
||||
- [Open Library](#metadata-providers-open-library)
|
||||
- [Google Books](#metadata-providers-google-books)
|
||||
|
||||
- [Direct Download](#direct-download)
|
||||
- [Download Sources](#direct-download-download-sources)
|
||||
- [Cloudflare Bypass](#direct-download-cloudflare-bypass)
|
||||
@@ -185,9 +187,11 @@ Default language filter for searches.
|
||||
|----------|-------------|------|---------|
|
||||
| `SEARCH_MODE` | How you want to search for and download books. | string (choice) | `direct` |
|
||||
| `AA_DEFAULT_SORT` | Default sort order for search results. | string (choice) | `relevance` |
|
||||
| `SHOW_RELEASE_SOURCE_LINKS` | Show clickable release-source links in release and details modals. Metadata provider links stay enabled. | boolean | `true` |
|
||||
| `METADATA_PROVIDER` | Choose which metadata provider to use for book searches. | string (choice) | `openlibrary` |
|
||||
| `METADATA_PROVIDER_AUDIOBOOK` | Metadata provider for audiobook searches. Uses the book provider if not set. | string (choice) | _empty string_ |
|
||||
| `DEFAULT_RELEASE_SOURCE` | The release source tab to open by default in the release modal. | string (choice) | `direct_download` |
|
||||
| `DEFAULT_RELEASE_SOURCE` | The release source tab to open by default in the release modal for books. | string (choice) | `direct_download` |
|
||||
| `DEFAULT_RELEASE_SOURCE_AUDIOBOOK` | The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set. | string (choice) | _empty string_ |
|
||||
|
||||
<details>
|
||||
<summary>Detailed descriptions</summary>
|
||||
@@ -212,6 +216,15 @@ Default sort order for search results.
|
||||
- **Default:** `relevance`
|
||||
- **Options:** `relevance` (Most relevant), `newest` (Newest (publication year)), `oldest` (Oldest (publication year)), `largest` (Largest (filesize)), `smallest` (Smallest (filesize)), `newest_added` (Newest (open sourced)), `oldest_added` (Oldest (open sourced))
|
||||
|
||||
#### `SHOW_RELEASE_SOURCE_LINKS`
|
||||
|
||||
**Show Release Source Links**
|
||||
|
||||
Show clickable release-source links in release and details modals. Metadata provider links stay enabled.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
#### `METADATA_PROVIDER`
|
||||
|
||||
**Book Metadata Provider**
|
||||
@@ -220,7 +233,7 @@ Choose which metadata provider to use for book searches.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `openlibrary`
|
||||
- **Options:** `""` (No providers enabled)
|
||||
- **Options:** `hardcover` (Hardcover), `openlibrary` (Open Library), `googlebooks` (Google Books)
|
||||
|
||||
#### `METADATA_PROVIDER_AUDIOBOOK`
|
||||
|
||||
@@ -230,17 +243,27 @@ Metadata provider for audiobook searches. Uses the book provider if not set.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** _empty string_
|
||||
- **Options:** `""` (Use book provider), `""` (No providers enabled)
|
||||
- **Options:** `""` (Use book provider), `hardcover` (Hardcover), `openlibrary` (Open Library), `googlebooks` (Google Books)
|
||||
|
||||
#### `DEFAULT_RELEASE_SOURCE`
|
||||
|
||||
**Default Release Source**
|
||||
**Default Book Release Source**
|
||||
|
||||
The release source tab to open by default in the release modal.
|
||||
The release source tab to open by default in the release modal for books.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `direct_download`
|
||||
- **Options:** `direct_download` (Direct Download), `prowlarr` (Prowlarr), `audiobookbay` (AudiobookBay)
|
||||
- **Options:** `direct_download` (Direct Download), `prowlarr` (Prowlarr)
|
||||
|
||||
#### `DEFAULT_RELEASE_SOURCE_AUDIOBOOK`
|
||||
|
||||
**Default Audiobook Release Source**
|
||||
|
||||
The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** _empty string_
|
||||
- **Options:** `""` (Use book release source), `prowlarr` (Prowlarr), `audiobookbay` (AudiobookBay)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -251,8 +274,8 @@ The release source tab to open by default in the release modal.
|
||||
| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` |
|
||||
| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` |
|
||||
| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` |
|
||||
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. | string | `{Author} - {Title} ({Year})` |
|
||||
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
|
||||
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
|
||||
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
|
||||
| `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` |
|
||||
| `BOOKLORE_HOST` | Base URL of your Booklore instance | string | _none_ |
|
||||
| `BOOKLORE_USERNAME` | Booklore account username | string | _none_ |
|
||||
@@ -273,11 +296,11 @@ The release source tab to open by default in the release modal.
|
||||
| `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` |
|
||||
| `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ |
|
||||
| `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` |
|
||||
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. | string | `{Author} - {Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}` |
|
||||
| `HARDLINK_TORRENTS_AUDIOBOOK` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `true` |
|
||||
| `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` |
|
||||
| `DOWNLOAD_TO_BROWSER` | Automatically download completed files to your browser. | boolean | `false` |
|
||||
| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ |
|
||||
| `MAX_CONCURRENT_DOWNLOADS` | Maximum number of simultaneous downloads. | number | `3` |
|
||||
| `STATUS_TIMEOUT` | How long to keep completed/failed downloads in the queue display. | number | `3600` |
|
||||
|
||||
@@ -318,7 +341,7 @@ Choose how downloaded book files are named and organized.
|
||||
|
||||
**Naming Template**
|
||||
|
||||
Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders.
|
||||
Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author} - {Title} ({Year})`
|
||||
@@ -327,7 +350,7 @@ Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesP
|
||||
|
||||
**Path Template**
|
||||
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author}/{Title} ({Year})`
|
||||
@@ -528,7 +551,7 @@ Choose how downloaded audiobook files are named and organized.
|
||||
|
||||
**Naming Template**
|
||||
|
||||
Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders.
|
||||
Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author} - {Title}`
|
||||
@@ -537,7 +560,7 @@ Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subti
|
||||
|
||||
**Path Template**
|
||||
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author}/{Title}`
|
||||
@@ -560,14 +583,14 @@ Automatically open the downloads sidebar when a new download is queued.
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `DOWNLOAD_TO_BROWSER`
|
||||
#### `DOWNLOAD_TO_BROWSER_CONTENT_TYPES`
|
||||
|
||||
**Download to Browser**
|
||||
|
||||
Automatically download completed files to your browser.
|
||||
Automatically download completed files to your browser for the selected content types.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
- **Type:** string (comma-separated)
|
||||
- **Default:** _empty list_
|
||||
|
||||
#### `MAX_CONCURRENT_DOWNLOADS`
|
||||
|
||||
@@ -592,10 +615,165 @@ How long to keep completed/failed downloads in the queue display.
|
||||
|
||||
</details>
|
||||
|
||||
## Security
|
||||
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `AUTH_METHOD` | Select the authentication method for accessing Shelfmark. | string (choice) | `none` |
|
||||
| `PROXY_AUTH_USER_HEADER` | The HTTP header your proxy uses to pass the authenticated username. | string | `X-Auth-User` |
|
||||
| `PROXY_AUTH_LOGOUT_URL` | The URL to redirect users to for logging out. Leave empty to disable logout functionality. | string | _empty string_ |
|
||||
| `PROXY_AUTH_ADMIN_GROUP_HEADER` | Optional: header your proxy uses to pass user groups/roles. | string | `X-Auth-Groups` |
|
||||
| `PROXY_AUTH_ADMIN_GROUP_NAME` | Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection. | string | _empty string_ |
|
||||
| `OIDC_DISCOVERY_URL` | OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration. | string | _none_ |
|
||||
| `OIDC_CLIENT_ID` | OAuth2 client ID from your identity provider. | string | _none_ |
|
||||
| `OIDC_CLIENT_SECRET` | OAuth2 client secret from your identity provider. | string (secret) | _none_ |
|
||||
| `OIDC_SCOPES` | OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization. | string | `openid,email,profile` |
|
||||
| `OIDC_GROUP_CLAIM` | The name of the claim in the ID token that contains user groups. | string | `groups` |
|
||||
| `OIDC_ADMIN_GROUP` | Users in this group will be given admin access (if enabled below). Leave empty to use database roles only. | string | _empty string_ |
|
||||
| `OIDC_USE_ADMIN_GROUP` | When enabled, users in the Admin Group are granted admin access. When disabled, admin access is determined solely by database roles. | boolean | `true` |
|
||||
| `OIDC_AUTO_PROVISION` | Automatically create a user account on first OIDC login. When disabled, users must be pre-created by an admin. | boolean | `true` |
|
||||
| `OIDC_BUTTON_LABEL` | Custom label for the OIDC sign-in button on the login page. | string | _empty string_ |
|
||||
|
||||
<details>
|
||||
<summary>Detailed descriptions</summary>
|
||||
|
||||
#### `AUTH_METHOD`
|
||||
|
||||
**Authentication Method**
|
||||
|
||||
Select the authentication method for accessing Shelfmark.
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `none`
|
||||
- **Options:** `none` (No Authentication), `builtin` (Local), `proxy` (Proxy Authentication), `oidc` (OIDC (OpenID Connect)), `cwa` (Calibre-Web Database)
|
||||
|
||||
#### `PROXY_AUTH_USER_HEADER`
|
||||
|
||||
**Proxy Auth User Header**
|
||||
|
||||
The HTTP header your proxy uses to pass the authenticated username.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `X-Auth-User`
|
||||
|
||||
#### `PROXY_AUTH_LOGOUT_URL`
|
||||
|
||||
**Proxy Auth Logout URL**
|
||||
|
||||
The URL to redirect users to for logging out. Leave empty to disable logout functionality.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `PROXY_AUTH_ADMIN_GROUP_HEADER`
|
||||
|
||||
**Proxy Auth Admin Group Header**
|
||||
|
||||
Optional: header your proxy uses to pass user groups/roles.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `X-Auth-Groups`
|
||||
|
||||
#### `PROXY_AUTH_ADMIN_GROUP_NAME`
|
||||
|
||||
**Proxy Auth Admin Group**
|
||||
|
||||
Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `OIDC_DISCOVERY_URL`
|
||||
|
||||
**Discovery URL**
|
||||
|
||||
OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
- **Required:** Yes
|
||||
|
||||
#### `OIDC_CLIENT_ID`
|
||||
|
||||
**Client ID**
|
||||
|
||||
OAuth2 client ID from your identity provider.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
- **Required:** Yes
|
||||
|
||||
#### `OIDC_CLIENT_SECRET`
|
||||
|
||||
**Client Secret**
|
||||
|
||||
OAuth2 client secret from your identity provider.
|
||||
|
||||
- **Type:** string (secret)
|
||||
- **Default:** _none_
|
||||
- **Required:** Yes
|
||||
|
||||
#### `OIDC_SCOPES`
|
||||
|
||||
**Scopes**
|
||||
|
||||
OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `openid,email,profile`
|
||||
|
||||
#### `OIDC_GROUP_CLAIM`
|
||||
|
||||
**Group Claim Name**
|
||||
|
||||
The name of the claim in the ID token that contains user groups.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `groups`
|
||||
|
||||
#### `OIDC_ADMIN_GROUP`
|
||||
|
||||
**Admin Group Name**
|
||||
|
||||
Users in this group will be given admin access (if enabled below). Leave empty to use database roles only.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `OIDC_USE_ADMIN_GROUP`
|
||||
|
||||
**Use Admin Group for Authorization**
|
||||
|
||||
When enabled, users in the Admin Group are granted admin access. When disabled, admin access is determined solely by database roles.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
#### `OIDC_AUTO_PROVISION`
|
||||
|
||||
**Auto-Provision Users**
|
||||
|
||||
Automatically create a user account on first OIDC login. When disabled, users must be pre-created by an admin.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `true`
|
||||
|
||||
#### `OIDC_BUTTON_LABEL`
|
||||
|
||||
**Login Button Label**
|
||||
|
||||
Custom label for the OIDC sign-in button on the login page.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _empty string_
|
||||
|
||||
</details>
|
||||
|
||||
## Network
|
||||
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `CERTIFICATE_VALIDATION` | Controls SSL/TLS certificate verification for outbound connections. Disable for self-signed certificates on internal services (e.g. OIDC providers, Prowlarr). | string (choice) | `enabled` |
|
||||
| `CUSTOM_DNS` | DNS provider for domain resolution. 'Auto' rotates through providers on failure. | string (choice) | `auto` |
|
||||
| `CUSTOM_DNS_MANUAL` | Comma-separated list of DNS server IP addresses (e.g., 8.8.8.8, 1.1.1.1). | string | _none_ |
|
||||
| `USE_DOH` | Use encrypted DNS queries for improved reliability and privacy. | boolean | `true` |
|
||||
@@ -609,6 +787,16 @@ How long to keep completed/failed downloads in the queue display.
|
||||
<details>
|
||||
<summary>Detailed descriptions</summary>
|
||||
|
||||
#### `CERTIFICATE_VALIDATION`
|
||||
|
||||
**Certificate Validation**
|
||||
|
||||
Controls SSL/TLS certificate verification for outbound connections. Disable for self-signed certificates on internal services (e.g. OIDC providers, Prowlarr).
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `enabled`
|
||||
- **Options:** `enabled` (Enabled (Recommended)), `disabled_local` (Disabled for Local Addresses), `disabled` (Disabled)
|
||||
|
||||
#### `CUSTOM_DNS`
|
||||
|
||||
**DNS Provider**
|
||||
@@ -1083,6 +1271,7 @@ How long to keep cached search results before they expire.
|
||||
| `RTORRENT_PASSWORD` | HTTP Basic auth password | string (secret) | _none_ |
|
||||
| `RTORRENT_LABEL` | Label to assign to book downloads in rTorrent | string | `cwabd` |
|
||||
| `RTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
|
||||
| `PROWLARR_TORRENT_ACTION` | Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding | string (choice) | `keep` |
|
||||
| `PROWLARR_USENET_CLIENT` | Choose which usenet client to use | string (choice) | _empty string_ |
|
||||
| `NZBGET_URL` | URL of your NZBGet instance | string | _none_ |
|
||||
| `NZBGET_USERNAME` | NZBGet control username | string | `nzbget` |
|
||||
@@ -1324,6 +1513,16 @@ Server-side directory where torrents are downloaded (optional, uses rTorrent def
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `PROWLARR_TORRENT_ACTION`
|
||||
|
||||
**Torrent Completion Action**
|
||||
|
||||
Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `keep`
|
||||
- **Options:** `keep` (Keep), `remove` (Remove)
|
||||
|
||||
#### `PROWLARR_USENET_CLIENT`
|
||||
|
||||
**Usenet Client**
|
||||
@@ -1773,7 +1972,7 @@ Timeout for external bypasser requests in milliseconds.
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
| `AA_BASE_URL` | Select 'Auto' to try mirrors from your list on startup and fall back on failures. Choosing a specific mirror locks Shelfmark to that mirror (no fallback). | string (choice) | `auto` |
|
||||
| `AA_MIRROR_URLS` | Editable list of AA mirrors. Used to populate the Primary Mirror dropdown and the order used when Auto is selected. Type a URL and press Enter to add. Order matters for auto-rotation | string | `https://annas-archive.gl,https://annas-archive.li` |
|
||||
| `AA_MIRROR_URLS` | Editable list of AA mirrors. Used to populate the Primary Mirror dropdown and the order used when Auto is selected. Type a URL and press Enter to add. Order matters for auto-rotation | string | `https://annas-archive.gl,https://annas-archive.pk,https://annas-archive.vg,https://annas-archive.gd` |
|
||||
| `AA_ADDITIONAL_URLS` | Deprecated. Use Mirrors instead. This is kept for backwards compatibility with existing installs and environment variables. | string | _none_ |
|
||||
| `LIBGEN_ADDITIONAL_URLS` | Comma-separated list of custom LibGen mirrors to add to the defaults. | string | _none_ |
|
||||
| `ZLIB_PRIMARY_URL` | Z-Library mirror to use for downloads. | string (choice) | `https://z-lib.fm` |
|
||||
@@ -1792,7 +1991,7 @@ Select 'Auto' to try mirrors from your list on startup and fall back on failures
|
||||
|
||||
- **Type:** string (choice)
|
||||
- **Default:** `auto`
|
||||
- **Options:** `auto` (Auto (Recommended)), `https://annas-archive.gl` (annas-archive.gl), `https://annas-archive.li` (annas-archive.li)
|
||||
- **Options:** `auto` (Auto (Recommended)), `https://annas-archive.gl` (annas-archive.gl), `https://annas-archive.pk` (annas-archive.pk), `https://annas-archive.vg` (annas-archive.vg), `https://annas-archive.gd` (annas-archive.gd)
|
||||
|
||||
#### `AA_MIRROR_URLS`
|
||||
|
||||
@@ -1801,7 +2000,7 @@ Select 'Auto' to try mirrors from your list on startup and fall back on failures
|
||||
Editable list of AA mirrors. Used to populate the Primary Mirror dropdown and the order used when Auto is selected. Type a URL and press Enter to add. Order matters for auto-rotation
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `https://annas-archive.gl,https://annas-archive.li`
|
||||
- **Default:** `https://annas-archive.gl,https://annas-archive.pk,https://annas-archive.vg,https://annas-archive.gd`
|
||||
|
||||
#### `AA_ADDITIONAL_URLS`
|
||||
|
||||
|
||||
+24
-3
@@ -6,6 +6,15 @@ Shelfmark can run behind a reverse proxy at the root path (recommended) or under
|
||||
|
||||
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.
|
||||
|
||||
Define this once in your Nginx `http` block so websocket upgrades are only sent when the client actually requests them:
|
||||
|
||||
```nginx
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
```
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
@@ -19,7 +28,7 @@ server {
|
||||
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 "upgrade";
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -53,7 +62,7 @@ location /shelfmark/ {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
proxy_buffering off;
|
||||
@@ -133,7 +142,7 @@ location /shelfmark/ {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
proxy_buffering off;
|
||||
@@ -142,6 +151,18 @@ location /shelfmark/ {
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
All parameters are used to filter results from the direct download 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
|
||||
|
||||
|
||||
+74
-16
@@ -45,9 +45,9 @@ 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"
|
||||
|
||||
# Cleanup any existing files or folders in the log directory
|
||||
rm -rf "$LOG_DIR"/*
|
||||
# Keep the previous entrypoint log instead of deleting all history on boot.
|
||||
[ -f "${LOG_FILE}.prev" ] && rm -f "${LOG_FILE}.prev"
|
||||
[ -f "$LOG_FILE" ] && mv "$LOG_FILE" "${LOG_FILE}.prev"
|
||||
fi
|
||||
|
||||
(
|
||||
@@ -127,14 +127,24 @@ USERNAME=$(getent passwd "$RUN_UID" | cut -d: -f1)
|
||||
echo "Username for UID $RUN_UID is $USERNAME"
|
||||
|
||||
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 ! gosu "$USERNAME" 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
|
||||
@@ -190,14 +200,62 @@ change_ownership() {
|
||||
chown -R "${RUN_UID}:${RUN_GID}" "${folder}" || echo "Failed to change ownership for ${folder}, continuing..."
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fix_misowned /app
|
||||
fix_misowned /var/log/shelfmark
|
||||
fix_misowned /tmp/shelfmark
|
||||
|
||||
# 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.
|
||||
# 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=$(python3 -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')" 2>/dev/null)
|
||||
set -e
|
||||
@@ -205,7 +263,7 @@ if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
|
||||
if [ -n "$SELENIUMBASE_DRIVERS_DIR" ] && [ -d "$SELENIUMBASE_DRIVERS_DIR" ]; then
|
||||
change_ownership "$SELENIUMBASE_DRIVERS_DIR"
|
||||
|
||||
# If the driver already exists, ensure it's executable for the runtime user.
|
||||
# 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
|
||||
@@ -342,4 +400,4 @@ echo "Setting umask to $UMASK_VALUE"
|
||||
umask $UMASK_VALUE
|
||||
|
||||
stop_file_logging
|
||||
exec sudo -E -u "$USERNAME" HOME=/app $command
|
||||
exec gosu "$USERNAME" env HOME=/app $command
|
||||
|
||||
@@ -10,7 +10,7 @@ Shelfmark is a self-hosted web interface for searching and downloading books and
|
||||
- [Calibre](https://calibre-ebook.com/)
|
||||
- [Calibre-Web](https://github.com/janeczku/calibre-web)
|
||||
- [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated)
|
||||
- [Booklore](https://github.com/booklore-app/booklore)
|
||||
- [Grimmory](https://github.com/grimmory-tools/grimmory)
|
||||
- [Audiobookshelf](https://github.com/advplyr/audiobookshelf)
|
||||
|
||||
## ✨ Features
|
||||
@@ -71,7 +71,7 @@ volumes:
|
||||
- /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.
|
||||
|
||||
@@ -110,7 +110,7 @@ Some of the additional options available in Settings:
|
||||
- **Prowlarr** - Configure indexers and download clients to download books and audiobooks
|
||||
- **AudiobookBay** - Web scraping source for audiobook torrents (audiobooks only)
|
||||
- **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.
|
||||
- **Format & Language** - Filter downloads by preferred formats, languages and sorting order
|
||||
@@ -179,6 +179,25 @@ volumes:
|
||||
|
||||
With any authentication method enabled, Shelfmark supports multi-user management with admin/user roles. Users can have per-user settings for download destinations, email recipients, and notification preferences. Non-admin users only see their own downloads and can submit book requests for admin review. Admins can configure request policies per source to control whether users can download directly, must submit a request, or are blocked entirely.
|
||||
|
||||
## 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:
|
||||
@@ -217,55 +236,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
|
||||
|
||||
Shelfmark's core feature set is now largely complete. Development going forward will focus on stability, bug fixes, and maintenance rather than major new features. Contributions in these areas are welcome - please file issues or submit pull requests on GitHub.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ flask-cors
|
||||
flask-socketio
|
||||
python-socketio
|
||||
requests[socks]
|
||||
defusedxml
|
||||
beautifulsoup4
|
||||
tqdm
|
||||
dnspython
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pyvirtualdisplay
|
||||
pyautogui
|
||||
seleniumbase==4.45.10
|
||||
seleniumbase==4.47.3
|
||||
python-xlib
|
||||
|
||||
Executable
+246
@@ -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 "id appuser >/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 appuser -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 appuser:appuser /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 "$@"
|
||||
@@ -222,6 +222,7 @@ 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.config.security # noqa: F401
|
||||
import shelfmark.release_sources.irc.settings # noqa: F401
|
||||
import shelfmark.release_sources.prowlarr.settings # noqa: F401
|
||||
import shelfmark.metadata_providers.hardcover # noqa: F401
|
||||
@@ -310,7 +311,7 @@ def generate_env_docs() -> str:
|
||||
|
||||
def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]:
|
||||
"""Generate documentation for a single settings tab."""
|
||||
from shelfmark.core.settings_registry import ActionButton, HeadingField
|
||||
from shelfmark.core.settings_registry import ActionButton, CustomComponentField, HeadingField
|
||||
|
||||
lines = []
|
||||
|
||||
@@ -327,7 +328,7 @@ def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]:
|
||||
env_fields = []
|
||||
for field in tab.fields:
|
||||
# Skip non-value fields
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
if isinstance(field, (ActionButton, CustomComponentField, HeadingField)):
|
||||
continue
|
||||
|
||||
# Skip fields that don't support ENV vars
|
||||
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
import random
|
||||
import signal
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -27,6 +28,9 @@ from shelfmark.download.network import get_proxies, get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
SELENIUMBASE_RUNTIME_ROOT = "/tmp/shelfmark/seleniumbase"
|
||||
SELENIUMBASE_DOWNLOADS_DIR = os.path.join(SELENIUMBASE_RUNTIME_ROOT, "downloaded_files")
|
||||
|
||||
# Challenge detection indicators
|
||||
CLOUDFLARE_INDICATORS = [
|
||||
"just a moment",
|
||||
@@ -50,6 +54,21 @@ DISPLAY = {
|
||||
LOCKED = threading.Lock()
|
||||
|
||||
|
||||
def _describe_runtime_path(path: str) -> str:
|
||||
"""Return compact ownership/mode info for a runtime path."""
|
||||
try:
|
||||
link_target = ""
|
||||
if os.path.islink(path):
|
||||
link_target = f" -> {os.readlink(path)}"
|
||||
st = os.stat(path)
|
||||
mode = stat.S_IMODE(st.st_mode)
|
||||
return f"{path}{link_target} exists uid={st.st_uid} gid={st.st_gid} mode={oct(mode)}"
|
||||
except FileNotFoundError:
|
||||
return f"{path} missing"
|
||||
except Exception as e:
|
||||
return f"{path} error={type(e).__name__}: {e}"
|
||||
|
||||
|
||||
class _CdpWorker:
|
||||
def __init__(self) -> None:
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
@@ -771,18 +790,30 @@ async def _create_cdp_browser(url: str) -> Any:
|
||||
logger.debug(f"Creating Pure CDP browser with args: {browser_args}")
|
||||
logger.debug(f"Browser screen size: {screen_width}x{screen_height}")
|
||||
|
||||
driver = await cdp_driver.start_async(
|
||||
headless=False,
|
||||
headed=False,
|
||||
xvfb=True,
|
||||
xvfb_metrics=f"{display_width},{display_height}",
|
||||
sandbox=False,
|
||||
lang="en",
|
||||
incognito=True,
|
||||
ad_block=True,
|
||||
proxy=proxy,
|
||||
browser_args=browser_args,
|
||||
)
|
||||
try:
|
||||
driver = await cdp_driver.start_async(
|
||||
headless=False,
|
||||
headed=False,
|
||||
xvfb=True,
|
||||
xvfb_metrics=f"{display_width},{display_height}",
|
||||
sandbox=False,
|
||||
lang="en",
|
||||
incognito=True,
|
||||
ad_block=True,
|
||||
proxy=proxy,
|
||||
browser_args=browser_args,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Pure CDP browser startup failed: {type(e).__name__}: {e}")
|
||||
logger.warning(
|
||||
"SeleniumBase runtime paths: "
|
||||
f"cwd={os.getcwd()}; "
|
||||
f"{_describe_runtime_path(SELENIUMBASE_DOWNLOADS_DIR)}; "
|
||||
f"{_describe_runtime_path('/app/downloaded_files')}; "
|
||||
f"{_describe_runtime_path('downloaded_files')}; "
|
||||
f"{_describe_runtime_path('/tmp')}"
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
await driver.page.set_window_rect(0, 0, screen_width, screen_height)
|
||||
|
||||
@@ -179,18 +179,18 @@ def test_booklore_connection(current_values: dict[str, Any] | None = None) -> di
|
||||
password = _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"
|
||||
message = "Connected to Grimmory"
|
||||
if library_options:
|
||||
message = f"Connected to Booklore ({len(library_options)} libraries)"
|
||||
message = f"Connected to Grimmory ({len(library_options)} libraries)"
|
||||
|
||||
return {"success": True, "message": message}
|
||||
except BookloreError as exc:
|
||||
|
||||
@@ -79,6 +79,18 @@ def migrate_security_settings(
|
||||
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:
|
||||
if 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:
|
||||
|
||||
@@ -29,12 +29,8 @@ def _auth_condition(auth_method: str) -> dict[str, str]:
|
||||
return {"field": "AUTH_METHOD", "value": auth_method}
|
||||
|
||||
|
||||
def _ui_field(factory: Callable[..., Any], **kwargs: Any) -> Any:
|
||||
return factory(env_supported=False, **kwargs)
|
||||
|
||||
|
||||
def _auth_ui_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) -> Any:
|
||||
return _ui_field(factory, show_when=_auth_condition(auth_method), **kwargs)
|
||||
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:
|
||||
@@ -80,22 +76,25 @@ def security_settings():
|
||||
{"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.",
|
||||
options=auth_method_options,
|
||||
default="none",
|
||||
env_supported=False,
|
||||
),
|
||||
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"),
|
||||
),
|
||||
CustomComponentField(
|
||||
key="oidc_admin_requirement",
|
||||
@@ -103,6 +102,18 @@ def security_settings():
|
||||
label="A local admin account is required before OIDC can be enabled.",
|
||||
show_when=_auth_condition("oidc"),
|
||||
),
|
||||
*([] 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="open_users_tab",
|
||||
label="Go to Users",
|
||||
@@ -110,7 +121,7 @@ def security_settings():
|
||||
style="primary",
|
||||
show_when={"field": "AUTH_METHOD", "value": ["builtin", "oidc"]},
|
||||
),
|
||||
_auth_ui_field(
|
||||
_auth_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_USER_HEADER",
|
||||
@@ -119,7 +130,7 @@ def security_settings():
|
||||
placeholder="e.g. X-Auth-User",
|
||||
default="X-Auth-User",
|
||||
),
|
||||
_auth_ui_field(
|
||||
_auth_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_LOGOUT_URL",
|
||||
@@ -128,7 +139,7 @@ def security_settings():
|
||||
placeholder="https://myauth.example.com/logout",
|
||||
default="",
|
||||
),
|
||||
_auth_ui_field(
|
||||
_auth_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_ADMIN_GROUP_HEADER",
|
||||
@@ -137,7 +148,7 @@ def security_settings():
|
||||
placeholder="e.g. X-Auth-Groups",
|
||||
default="X-Auth-Groups",
|
||||
),
|
||||
_auth_ui_field(
|
||||
_auth_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_ADMIN_GROUP_NAME",
|
||||
@@ -246,7 +257,7 @@ def security_settings():
|
||||
},
|
||||
),
|
||||
]
|
||||
fields.extend(_auth_ui_field(factory, "oidc", **spec) for factory, spec in oidc_specs)
|
||||
fields.extend(_auth_field(factory, "oidc", **spec) for factory, spec in oidc_specs)
|
||||
fields.append(
|
||||
ActionButton(
|
||||
key="test_oidc",
|
||||
|
||||
+134
-27
@@ -185,6 +185,23 @@ _AUDIOBOOK_FORMAT_OPTIONS = [
|
||||
{"value": "rar", "label": "RAR"},
|
||||
]
|
||||
|
||||
_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS = [
|
||||
{
|
||||
"value": "book",
|
||||
"label": "Books",
|
||||
"description": "Automatically download completed book files to this browser.",
|
||||
},
|
||||
{
|
||||
"value": "audiobook",
|
||||
"label": "Audiobooks",
|
||||
"description": "Automatically download completed audiobook files to this browser.",
|
||||
},
|
||||
]
|
||||
|
||||
_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES = {
|
||||
option["value"] for option in _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS
|
||||
}
|
||||
|
||||
|
||||
def _get_metadata_provider_options():
|
||||
"""Build metadata provider options dynamically from enabled providers only."""
|
||||
@@ -210,17 +227,30 @@ def _get_metadata_provider_options_with_none():
|
||||
return [{"value": "", "label": "Use book provider"}] + _get_metadata_provider_options()
|
||||
|
||||
|
||||
def _get_release_source_options():
|
||||
"""Build release source options dynamically from registered sources."""
|
||||
def _get_release_source_options_for_content_type(content_type: str):
|
||||
"""Build release source options dynamically for a specific content type."""
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
|
||||
return [
|
||||
{"value": source["name"], "label": source["display_name"]}
|
||||
for source in list_available_sources()
|
||||
if source.get("can_be_default", True)
|
||||
and content_type in source.get("supported_content_types", ["ebook", "audiobook"])
|
||||
]
|
||||
|
||||
|
||||
def _get_book_release_source_options():
|
||||
"""Build default release source options for book searches."""
|
||||
return _get_release_source_options_for_content_type("ebook")
|
||||
|
||||
|
||||
def _get_audiobook_release_source_options():
|
||||
"""Build default release source options for audiobook searches."""
|
||||
return [{"value": "", "label": "Use book release source"}] + _get_release_source_options_for_content_type(
|
||||
"audiobook"
|
||||
)
|
||||
|
||||
|
||||
|
||||
_LANGUAGE_OPTIONS = [{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE]
|
||||
|
||||
@@ -355,7 +385,7 @@ def general_settings():
|
||||
TextField(
|
||||
key="CALIBRE_WEB_URL",
|
||||
label="Library URL",
|
||||
description="Adds a navigation button to your book library (Calibre-Web Automated, Booklore, etc).",
|
||||
description="Adds a navigation button to your book library (Calibre-Web Automated, Grimmory, etc).",
|
||||
placeholder="http://calibre-web:8083",
|
||||
),
|
||||
TextField(
|
||||
@@ -419,6 +449,7 @@ def search_mode_settings():
|
||||
},
|
||||
],
|
||||
default="direct",
|
||||
user_overridable=True,
|
||||
),
|
||||
SelectField(
|
||||
key="AA_DEFAULT_SORT",
|
||||
@@ -428,6 +459,23 @@ def search_mode_settings():
|
||||
default="relevance",
|
||||
show_when={"field": "SEARCH_MODE", "value": "direct"},
|
||||
),
|
||||
CheckboxField(
|
||||
key="SHOW_RELEASE_SOURCE_LINKS",
|
||||
label="Show Release Source Links",
|
||||
description=(
|
||||
"Show clickable release-source links in release and details modals. "
|
||||
"Metadata provider links stay enabled."
|
||||
),
|
||||
default=True,
|
||||
),
|
||||
CheckboxField(
|
||||
key="SHOW_COMBINED_SELECTOR",
|
||||
label="Show Combined Download Selector",
|
||||
description="Show the option to search for and download both a book and audiobook together.",
|
||||
default=True,
|
||||
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
||||
user_overridable=True,
|
||||
),
|
||||
HeadingField(
|
||||
key="universal_mode_heading",
|
||||
title="Universal Mode Settings",
|
||||
@@ -441,6 +489,7 @@ def search_mode_settings():
|
||||
options=_get_metadata_provider_options, # Callable - evaluated lazily to avoid circular imports
|
||||
default="openlibrary",
|
||||
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
||||
user_overridable=True,
|
||||
),
|
||||
SelectField(
|
||||
key="METADATA_PROVIDER_AUDIOBOOK",
|
||||
@@ -449,14 +498,34 @@ def search_mode_settings():
|
||||
options=_get_metadata_provider_options_with_none, # Callable - includes "Use main provider" option
|
||||
default="",
|
||||
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
||||
user_overridable=True,
|
||||
),
|
||||
SelectField(
|
||||
key="METADATA_PROVIDER_COMBINED",
|
||||
label="Combined Mode Metadata Provider",
|
||||
description="Metadata provider for combined mode searches. Uses the book provider if not set.",
|
||||
options=_get_metadata_provider_options_with_none, # Callable - includes "Use main provider" option
|
||||
default="",
|
||||
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
||||
user_overridable=True,
|
||||
),
|
||||
SelectField(
|
||||
key="DEFAULT_RELEASE_SOURCE",
|
||||
label="Default Release Source",
|
||||
description="The release source tab to open by default in the release modal.",
|
||||
options=_get_release_source_options, # Callable - evaluated lazily to avoid circular imports
|
||||
label="Default Book Release Source",
|
||||
description="The release source tab to open by default in the release modal for books.",
|
||||
options=_get_book_release_source_options, # Callable - evaluated lazily to avoid circular imports
|
||||
default="direct_download",
|
||||
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
||||
user_overridable=True,
|
||||
),
|
||||
SelectField(
|
||||
key="DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
|
||||
label="Default Audiobook Release Source",
|
||||
description="The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set.",
|
||||
options=_get_audiobook_release_source_options, # Callable - evaluated lazily to avoid circular imports
|
||||
default="",
|
||||
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
||||
user_overridable=True,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -615,6 +684,41 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
|
||||
effective: dict[str, Any] = dict(existing)
|
||||
effective.update(values)
|
||||
|
||||
if "DOWNLOAD_TO_BROWSER_CONTENT_TYPES" in effective:
|
||||
raw_content_types = effective.get("DOWNLOAD_TO_BROWSER_CONTENT_TYPES")
|
||||
if raw_content_types is None:
|
||||
normalized_content_types: list[str] = []
|
||||
elif isinstance(raw_content_types, list):
|
||||
normalized_content_types = [
|
||||
str(value).strip().lower()
|
||||
for value in raw_content_types
|
||||
if str(value).strip()
|
||||
]
|
||||
else:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Download to Browser must be a list.",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
deduped_content_types: list[str] = []
|
||||
for content_type in normalized_content_types:
|
||||
if content_type not in _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES:
|
||||
allowed = ", ".join(sorted(_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES))
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
"Download to Browser contains an unsupported content type "
|
||||
f"'{content_type}'. Supported values: {allowed}"
|
||||
),
|
||||
"values": values,
|
||||
}
|
||||
if content_type not in deduped_content_types:
|
||||
deduped_content_types.append(content_type)
|
||||
|
||||
values["DOWNLOAD_TO_BROWSER_CONTENT_TYPES"] = deduped_content_types
|
||||
effective["DOWNLOAD_TO_BROWSER_CONTENT_TYPES"] = deduped_content_types
|
||||
|
||||
# Books: only validate templates when saving to a folder.
|
||||
books_output_mode = effective.get("BOOKS_OUTPUT_MODE", "folder")
|
||||
if books_output_mode == "folder" and effective.get("FILE_ORGANIZATION", "rename") == "rename":
|
||||
@@ -769,8 +873,8 @@ def download_settings():
|
||||
},
|
||||
{
|
||||
"value": "booklore",
|
||||
"label": "Booklore (API)",
|
||||
"description": "Upload files directly to Booklore",
|
||||
"label": "Grimmory (API)",
|
||||
"description": "Upload files directly to Grimmory",
|
||||
},
|
||||
],
|
||||
default="folder",
|
||||
@@ -802,7 +906,7 @@ def download_settings():
|
||||
{
|
||||
"value": "rename",
|
||||
"label": "Rename Only",
|
||||
"description": "Rename files using a template"
|
||||
"description": "Rename single-file downloads; multi-file keeps original names."
|
||||
},
|
||||
{
|
||||
"value": "organize",
|
||||
@@ -820,7 +924,7 @@ def download_settings():
|
||||
TextField(
|
||||
key="TEMPLATE_RENAME",
|
||||
label="Naming Template",
|
||||
description="Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.",
|
||||
description="Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders. Applies to single-file downloads.",
|
||||
default="{Author} - {Title} ({Year})",
|
||||
placeholder="{Author} - {Title} ({Year})",
|
||||
show_when=[
|
||||
@@ -832,7 +936,7 @@ def download_settings():
|
||||
TextField(
|
||||
key="TEMPLATE_ORGANIZE",
|
||||
label="Path Template",
|
||||
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.",
|
||||
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.",
|
||||
default="{Author}/{Title} ({Year})",
|
||||
placeholder="{Author}/{Series/}{Title} ({Year})",
|
||||
show_when=[
|
||||
@@ -853,14 +957,14 @@ def download_settings():
|
||||
),
|
||||
HeadingField(
|
||||
key="booklore_heading",
|
||||
title="Booklore",
|
||||
description="Upload books directly to Booklore via API. Audiobooks always use folder mode.",
|
||||
title="Grimmory",
|
||||
description="Upload books directly to Grimmory (Formerly Booklore) via API. Audiobooks always use folder mode.",
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
),
|
||||
TextField(
|
||||
key="BOOKLORE_HOST",
|
||||
label="Booklore URL",
|
||||
description="Base URL of your Booklore instance",
|
||||
label="Grimmory URL",
|
||||
description="Base URL of your Grimmory instance",
|
||||
placeholder="http://booklore:6060",
|
||||
required=True,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
@@ -868,14 +972,14 @@ def download_settings():
|
||||
TextField(
|
||||
key="BOOKLORE_USERNAME",
|
||||
label="Username",
|
||||
description="Booklore account username",
|
||||
description="Grimmory account username",
|
||||
required=True,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
),
|
||||
PasswordField(
|
||||
key="BOOKLORE_PASSWORD",
|
||||
label="Password",
|
||||
description="Booklore account password",
|
||||
description="Grimmory account password",
|
||||
required=True,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
),
|
||||
@@ -901,7 +1005,7 @@ def download_settings():
|
||||
SelectField(
|
||||
key="BOOKLORE_LIBRARY_ID",
|
||||
label="Library",
|
||||
description="Booklore library to upload into.",
|
||||
description="Grimmory library to upload into.",
|
||||
options=get_booklore_library_options,
|
||||
required=True,
|
||||
user_overridable=True,
|
||||
@@ -913,7 +1017,7 @@ def download_settings():
|
||||
SelectField(
|
||||
key="BOOKLORE_PATH_ID",
|
||||
label="Path",
|
||||
description="Booklore library path for uploads.",
|
||||
description="Grimmory library path for uploads.",
|
||||
options=get_booklore_path_options,
|
||||
required=True,
|
||||
filter_by_field="BOOKLORE_LIBRARY_ID",
|
||||
@@ -926,7 +1030,7 @@ def download_settings():
|
||||
ActionButton(
|
||||
key="test_booklore",
|
||||
label="Test Connection",
|
||||
description="Verify your Booklore configuration",
|
||||
description="Verify your Grimmory configuration",
|
||||
style="primary",
|
||||
callback=test_booklore_connection,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
@@ -1057,7 +1161,7 @@ def download_settings():
|
||||
description="Choose how downloaded audiobook files are named and organized.",
|
||||
options=[
|
||||
{"value": "none", "label": "None", "description": "Keep original filename from source"},
|
||||
{"value": "rename", "label": "Rename Only", "description": "Rename files using a template"},
|
||||
{"value": "rename", "label": "Rename Only", "description": "Rename single-file downloads; multi-file keeps original names."},
|
||||
{"value": "organize", "label": "Rename and Organize", "description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders."},
|
||||
],
|
||||
default="rename",
|
||||
@@ -1067,7 +1171,7 @@ def download_settings():
|
||||
TextField(
|
||||
key="TEMPLATE_AUDIOBOOK_RENAME",
|
||||
label="Naming Template",
|
||||
description="Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.",
|
||||
description="Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders. Applies to single-file downloads.",
|
||||
default="{Author} - {Title}",
|
||||
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
|
||||
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
|
||||
@@ -1077,7 +1181,7 @@ def download_settings():
|
||||
TextField(
|
||||
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
|
||||
label="Path Template",
|
||||
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.",
|
||||
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.",
|
||||
default="{Author}/{Title}",
|
||||
placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}",
|
||||
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"},
|
||||
@@ -1102,11 +1206,14 @@ def download_settings():
|
||||
description="Automatically open the downloads sidebar when a new download is queued.",
|
||||
default=False,
|
||||
),
|
||||
CheckboxField(
|
||||
key="DOWNLOAD_TO_BROWSER",
|
||||
MultiSelectField(
|
||||
key="DOWNLOAD_TO_BROWSER_CONTENT_TYPES",
|
||||
label="Download to Browser",
|
||||
description="Automatically download completed files to your browser.",
|
||||
default=False,
|
||||
description="Automatically download completed files to your browser for the selected content types.",
|
||||
options=_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS,
|
||||
default=[],
|
||||
variant="dropdown",
|
||||
user_overridable=True,
|
||||
),
|
||||
NumberField(
|
||||
key="MAX_CONCURRENT_DOWNLOADS",
|
||||
|
||||
@@ -5,6 +5,8 @@ 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.settings_registry import (
|
||||
CheckboxField,
|
||||
CustomComponentField,
|
||||
@@ -56,6 +58,11 @@ _SELF_SETTINGS_SECTION_OPTIONS = [
|
||||
"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",
|
||||
@@ -64,6 +71,15 @@ _SELF_SETTINGS_SECTION_OPTIONS = [
|
||||
]
|
||||
_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": (
|
||||
@@ -103,6 +119,18 @@ def _get_request_source_options():
|
||||
return options
|
||||
|
||||
|
||||
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():
|
||||
source_capabilities = get_source_content_type_capabilities()
|
||||
content_type_options = []
|
||||
@@ -147,6 +175,55 @@ def _get_request_policy_rule_columns():
|
||||
]
|
||||
|
||||
|
||||
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):
|
||||
"""Validate users/request-policy settings before persistence."""
|
||||
if "VISIBLE_SELF_SETTINGS_SECTIONS" in values:
|
||||
@@ -207,6 +284,18 @@ def _on_save_users(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}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Core module - shared models, queue, and utilities."""
|
||||
|
||||
from shelfmark.core.models import BookInfo, QueueItem, SearchFilters, QueueStatus
|
||||
from shelfmark.core.models import QueueItem, SearchFilters, QueueStatus
|
||||
from shelfmark.core.queue import BookQueue, book_queue
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
+838
-367
File diff suppressed because it is too large
Load Diff
@@ -1,639 +0,0 @@
|
||||
"""Persistence helpers for Activity dismissals and terminal snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
VALID_ITEM_TYPES = frozenset({"download", "request"})
|
||||
VALID_ORIGINS = frozenset({"direct", "request", "requested"})
|
||||
VALID_FINAL_STATUSES = frozenset({"complete", "error", "cancelled", "rejected"})
|
||||
|
||||
|
||||
def _now_timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _normalize_item_type(item_type: Any) -> str:
|
||||
if not isinstance(item_type, str):
|
||||
raise ValueError("item_type must be a string")
|
||||
normalized = item_type.strip().lower()
|
||||
if normalized not in VALID_ITEM_TYPES:
|
||||
raise ValueError("item_type must be one of: download, request")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_item_key(item_key: Any) -> str:
|
||||
if not isinstance(item_key, str):
|
||||
raise ValueError("item_key must be a string")
|
||||
normalized = item_key.strip()
|
||||
if not normalized:
|
||||
raise ValueError("item_key must not be empty")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_origin(origin: Any) -> str:
|
||||
if not isinstance(origin, str):
|
||||
raise ValueError("origin must be a string")
|
||||
normalized = origin.strip().lower()
|
||||
if normalized not in VALID_ORIGINS:
|
||||
raise ValueError("origin must be one of: direct, request, requested")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_final_status(final_status: Any) -> str:
|
||||
if not isinstance(final_status, str):
|
||||
raise ValueError("final_status must be a string")
|
||||
normalized = final_status.strip().lower()
|
||||
if normalized not in VALID_FINAL_STATUSES:
|
||||
raise ValueError("final_status must be one of: complete, error, cancelled, rejected")
|
||||
return normalized
|
||||
|
||||
|
||||
def build_item_key(item_type: str, raw_id: Any) -> str:
|
||||
"""Build a stable item key used by dismiss/history APIs."""
|
||||
normalized_type = _normalize_item_type(item_type)
|
||||
if normalized_type == "request":
|
||||
try:
|
||||
request_id = int(raw_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("request item IDs must be integers") from exc
|
||||
if request_id < 1:
|
||||
raise ValueError("request item IDs must be positive integers")
|
||||
return f"request:{request_id}"
|
||||
|
||||
if not isinstance(raw_id, str):
|
||||
raise ValueError("download item IDs must be strings")
|
||||
task_id = raw_id.strip()
|
||||
if not task_id:
|
||||
raise ValueError("download item IDs must not be empty")
|
||||
return f"download:{task_id}"
|
||||
|
||||
|
||||
def build_request_item_key(request_id: int) -> str:
|
||||
"""Build a request item key."""
|
||||
return build_item_key("request", request_id)
|
||||
|
||||
|
||||
def build_download_item_key(task_id: str) -> str:
|
||||
"""Build a download item key."""
|
||||
return build_item_key("download", task_id)
|
||||
|
||||
|
||||
def _parse_request_id_from_item_key(item_key: Any) -> int | None:
|
||||
if not isinstance(item_key, str) or not item_key.startswith("request:"):
|
||||
return None
|
||||
raw_value = item_key.split(":", 1)[1].strip()
|
||||
try:
|
||||
parsed = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def _request_final_status(request_status: Any, delivery_state: Any) -> str | None:
|
||||
status = str(request_status or "").strip().lower()
|
||||
if status == "pending":
|
||||
return None
|
||||
if status == "rejected":
|
||||
return "rejected"
|
||||
if status == "cancelled":
|
||||
return "cancelled"
|
||||
if status != "fulfilled":
|
||||
return None
|
||||
|
||||
delivery = str(delivery_state or "").strip().lower()
|
||||
if delivery in {"error", "cancelled"}:
|
||||
return delivery
|
||||
return "complete"
|
||||
|
||||
|
||||
class ActivityService:
|
||||
"""Service for per-user activity dismissals and terminal history snapshots."""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
self._db_path = db_path
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
@staticmethod
|
||||
def _coerce_positive_int(value: Any, field: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field} must be an integer") from exc
|
||||
if parsed < 1:
|
||||
raise ValueError(f"{field} must be a positive integer")
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_column(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _build_legacy_request_snapshot(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
request_id: int,
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
request_row = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
status,
|
||||
delivery_state,
|
||||
request_level,
|
||||
book_data,
|
||||
release_data,
|
||||
note,
|
||||
admin_note,
|
||||
created_at,
|
||||
reviewed_at
|
||||
FROM download_requests
|
||||
WHERE id = ?
|
||||
""",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
if request_row is None:
|
||||
return None, None
|
||||
|
||||
row_dict = dict(request_row)
|
||||
book_data = self._parse_json_column(row_dict.get("book_data"))
|
||||
release_data = self._parse_json_column(row_dict.get("release_data"))
|
||||
if not isinstance(book_data, dict):
|
||||
book_data = {}
|
||||
if not isinstance(release_data, dict):
|
||||
release_data = {}
|
||||
|
||||
snapshot = {
|
||||
"kind": "request",
|
||||
"request": {
|
||||
"id": int(row_dict["id"]),
|
||||
"user_id": row_dict.get("user_id"),
|
||||
"status": row_dict.get("status"),
|
||||
"delivery_state": row_dict.get("delivery_state"),
|
||||
"request_level": row_dict.get("request_level"),
|
||||
"book_data": book_data,
|
||||
"release_data": release_data,
|
||||
"note": row_dict.get("note"),
|
||||
"admin_note": row_dict.get("admin_note"),
|
||||
"created_at": row_dict.get("created_at"),
|
||||
"updated_at": row_dict.get("reviewed_at") or row_dict.get("created_at"),
|
||||
},
|
||||
}
|
||||
final_status = _request_final_status(row_dict.get("status"), row_dict.get("delivery_state"))
|
||||
return snapshot, final_status
|
||||
|
||||
def record_terminal_snapshot(
|
||||
self,
|
||||
*,
|
||||
user_id: int | None,
|
||||
item_type: str,
|
||||
item_key: str,
|
||||
origin: str,
|
||||
final_status: str,
|
||||
snapshot: dict[str, Any],
|
||||
request_id: int | None = None,
|
||||
source_id: str | None = None,
|
||||
terminal_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record a durable terminal-state snapshot for an activity item."""
|
||||
normalized_item_type = _normalize_item_type(item_type)
|
||||
normalized_item_key = _normalize_item_key(item_key)
|
||||
normalized_origin = _normalize_origin(origin)
|
||||
normalized_final_status = _normalize_final_status(final_status)
|
||||
if not isinstance(snapshot, dict):
|
||||
raise ValueError("snapshot must be an object")
|
||||
|
||||
if user_id is not None:
|
||||
user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
if request_id is not None:
|
||||
request_id = self._coerce_positive_int(request_id, "request_id")
|
||||
if source_id is not None and not isinstance(source_id, str):
|
||||
raise ValueError("source_id must be a string when provided")
|
||||
if source_id is not None:
|
||||
source_id = source_id.strip() or None
|
||||
|
||||
effective_terminal_at = terminal_at if isinstance(terminal_at, str) and terminal_at.strip() else _now_timestamp()
|
||||
serialized_snapshot = json.dumps(snapshot, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO activity_log (
|
||||
user_id,
|
||||
item_type,
|
||||
item_key,
|
||||
request_id,
|
||||
source_id,
|
||||
origin,
|
||||
final_status,
|
||||
snapshot_json,
|
||||
terminal_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
normalized_item_type,
|
||||
normalized_item_key,
|
||||
request_id,
|
||||
source_id,
|
||||
normalized_origin,
|
||||
normalized_final_status,
|
||||
serialized_snapshot,
|
||||
effective_terminal_at,
|
||||
),
|
||||
)
|
||||
snapshot_id = int(cursor.lastrowid)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM activity_log WHERE id = ?",
|
||||
(snapshot_id,),
|
||||
).fetchone()
|
||||
payload = self._row_to_dict(row)
|
||||
if payload is None:
|
||||
raise ValueError("Failed to read back recorded activity snapshot")
|
||||
return payload
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_latest_activity_log_id(self, *, item_type: str, item_key: str) -> int | None:
|
||||
"""Get the newest snapshot ID for an item key."""
|
||||
normalized_item_type = _normalize_item_type(item_type)
|
||||
normalized_item_key = _normalize_item_key(item_key)
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM activity_log
|
||||
WHERE item_type = ? AND item_key = ?
|
||||
ORDER BY terminal_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(normalized_item_type, normalized_item_key),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return int(row["id"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def dismiss_item(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
item_type: str,
|
||||
item_key: str,
|
||||
activity_log_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Dismiss an item for a specific user (upsert)."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
normalized_item_type = _normalize_item_type(item_type)
|
||||
normalized_item_key = _normalize_item_key(item_key)
|
||||
normalized_log_id = (
|
||||
self._coerce_positive_int(activity_log_id, "activity_log_id")
|
||||
if activity_log_id is not None
|
||||
else self.get_latest_activity_log_id(
|
||||
item_type=normalized_item_type,
|
||||
item_key=normalized_item_key,
|
||||
)
|
||||
)
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO activity_dismissals (
|
||||
user_id,
|
||||
item_type,
|
||||
item_key,
|
||||
activity_log_id,
|
||||
dismissed_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, item_type, item_key)
|
||||
DO UPDATE SET
|
||||
activity_log_id = excluded.activity_log_id,
|
||||
dismissed_at = excluded.dismissed_at
|
||||
""",
|
||||
(
|
||||
normalized_user_id,
|
||||
normalized_item_type,
|
||||
normalized_item_key,
|
||||
normalized_log_id,
|
||||
_now_timestamp(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM activity_dismissals
|
||||
WHERE user_id = ? AND item_type = ? AND item_key = ?
|
||||
""",
|
||||
(normalized_user_id, normalized_item_type, normalized_item_key),
|
||||
).fetchone()
|
||||
payload = self._row_to_dict(row)
|
||||
if payload is None:
|
||||
raise ValueError("Failed to read back dismissal row")
|
||||
return payload
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def dismiss_many(self, *, user_id: int, items: Iterable[dict[str, Any]]) -> int:
|
||||
"""Dismiss many items for one user."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
normalized_items: list[tuple[str, str, int | None]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("items must contain objects")
|
||||
normalized_item_type = _normalize_item_type(item.get("item_type"))
|
||||
normalized_item_key = _normalize_item_key(item.get("item_key"))
|
||||
raw_log_id = item.get("activity_log_id")
|
||||
normalized_log_id = (
|
||||
self._coerce_positive_int(raw_log_id, "activity_log_id")
|
||||
if raw_log_id is not None
|
||||
else self.get_latest_activity_log_id(
|
||||
item_type=normalized_item_type,
|
||||
item_key=normalized_item_key,
|
||||
)
|
||||
)
|
||||
normalized_items.append((normalized_item_type, normalized_item_key, normalized_log_id))
|
||||
|
||||
if not normalized_items:
|
||||
return 0
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
timestamp = _now_timestamp()
|
||||
for item_type, item_key, activity_log_id in normalized_items:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO activity_dismissals (
|
||||
user_id,
|
||||
item_type,
|
||||
item_key,
|
||||
activity_log_id,
|
||||
dismissed_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, item_type, item_key)
|
||||
DO UPDATE SET
|
||||
activity_log_id = excluded.activity_log_id,
|
||||
dismissed_at = excluded.dismissed_at
|
||||
""",
|
||||
(
|
||||
normalized_user_id,
|
||||
item_type,
|
||||
item_key,
|
||||
activity_log_id,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return len(normalized_items)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_dismissal_set(self, user_id: int) -> list[dict[str, str]]:
|
||||
"""Return dismissed item keys for one user."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT item_type, item_key
|
||||
FROM activity_dismissals
|
||||
WHERE user_id = ?
|
||||
ORDER BY dismissed_at DESC, id DESC
|
||||
""",
|
||||
(normalized_user_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"item_type": str(row["item_type"]),
|
||||
"item_key": str(row["item_key"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def clear_dismissals_for_item_keys(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
item_type: str,
|
||||
item_keys: Iterable[str],
|
||||
) -> int:
|
||||
"""Clear dismissals for one user + item type + item keys."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
normalized_item_type = _normalize_item_type(item_type)
|
||||
normalized_keys = {
|
||||
_normalize_item_key(item_key)
|
||||
for item_key in item_keys
|
||||
if isinstance(item_key, str) and item_key.strip()
|
||||
}
|
||||
if not normalized_keys:
|
||||
return 0
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.executemany(
|
||||
"""
|
||||
DELETE FROM activity_dismissals
|
||||
WHERE user_id = ? AND item_type = ? AND item_key = ?
|
||||
""",
|
||||
(
|
||||
(normalized_user_id, normalized_item_type, item_key)
|
||||
for item_key in normalized_keys
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cursor.rowcount or 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_history(self, user_id: int, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
|
||||
"""Return paged dismissal history for one user."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
normalized_limit = max(1, min(int(limit), 200))
|
||||
normalized_offset = max(0, int(offset))
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
d.id,
|
||||
d.user_id,
|
||||
d.item_type,
|
||||
d.item_key,
|
||||
d.activity_log_id,
|
||||
d.dismissed_at,
|
||||
l.snapshot_json,
|
||||
l.origin,
|
||||
l.final_status,
|
||||
l.terminal_at,
|
||||
l.request_id,
|
||||
l.source_id
|
||||
FROM activity_dismissals d
|
||||
LEFT JOIN activity_log l ON l.id = d.activity_log_id
|
||||
WHERE d.user_id = ?
|
||||
ORDER BY d.dismissed_at DESC, d.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(normalized_user_id, normalized_limit, normalized_offset),
|
||||
).fetchall()
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
row_dict = dict(row)
|
||||
raw_snapshot_json = row_dict.pop("snapshot_json", None)
|
||||
snapshot_payload = None
|
||||
if isinstance(raw_snapshot_json, str):
|
||||
try:
|
||||
snapshot_payload = json.loads(raw_snapshot_json)
|
||||
except (ValueError, TypeError):
|
||||
snapshot_payload = None
|
||||
|
||||
if snapshot_payload is None and row_dict.get("item_type") == "request":
|
||||
request_id = row_dict.get("request_id")
|
||||
if request_id is None:
|
||||
request_id = _parse_request_id_from_item_key(row_dict.get("item_key"))
|
||||
try:
|
||||
normalized_request_id = int(request_id) if request_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
normalized_request_id = None
|
||||
|
||||
if normalized_request_id and normalized_request_id > 0:
|
||||
fallback_snapshot, fallback_final_status = self._build_legacy_request_snapshot(
|
||||
conn,
|
||||
normalized_request_id,
|
||||
)
|
||||
if fallback_snapshot is not None:
|
||||
snapshot_payload = fallback_snapshot
|
||||
if not row_dict.get("origin"):
|
||||
row_dict["origin"] = "request"
|
||||
if not row_dict.get("final_status") and fallback_final_status is not None:
|
||||
row_dict["final_status"] = fallback_final_status
|
||||
|
||||
row_dict["snapshot"] = snapshot_payload
|
||||
payload.append(row_dict)
|
||||
return payload
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_undismissed_terminal_downloads(
|
||||
self,
|
||||
viewer_user_id: int,
|
||||
*,
|
||||
owner_user_id: int | None,
|
||||
limit: int = 200,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return latest undismissed terminal download snapshots for a viewer.
|
||||
|
||||
`viewer_user_id` controls which dismissals are applied.
|
||||
`owner_user_id` scopes activity rows to one owner when provided; when
|
||||
omitted, rows across all owners are considered.
|
||||
"""
|
||||
normalized_viewer_user_id = self._coerce_positive_int(viewer_user_id, "viewer_user_id")
|
||||
normalized_owner_user_id = (
|
||||
self._coerce_positive_int(owner_user_id, "owner_user_id")
|
||||
if owner_user_id is not None
|
||||
else None
|
||||
)
|
||||
normalized_limit = max(1, min(int(limit), 500))
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
l.id,
|
||||
l.user_id,
|
||||
l.item_type,
|
||||
l.item_key,
|
||||
l.request_id,
|
||||
l.source_id,
|
||||
l.origin,
|
||||
l.final_status,
|
||||
l.snapshot_json,
|
||||
l.terminal_at
|
||||
FROM activity_log l
|
||||
LEFT JOIN activity_dismissals d
|
||||
ON d.user_id = ?
|
||||
AND d.item_type = l.item_type
|
||||
AND d.item_key = l.item_key
|
||||
WHERE (? IS NULL OR l.user_id = ?)
|
||||
AND l.item_type = 'download'
|
||||
AND l.final_status IN ('complete', 'error', 'cancelled')
|
||||
AND d.id IS NULL
|
||||
ORDER BY l.terminal_at DESC, l.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(
|
||||
normalized_viewer_user_id,
|
||||
normalized_owner_user_id,
|
||||
normalized_owner_user_id,
|
||||
normalized_limit * 2,
|
||||
),
|
||||
).fetchall()
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
seen_item_keys: set[str] = set()
|
||||
for row in rows:
|
||||
row_dict = dict(row)
|
||||
item_key = str(row_dict.get("item_key") or "")
|
||||
if not item_key or item_key in seen_item_keys:
|
||||
continue
|
||||
seen_item_keys.add(item_key)
|
||||
|
||||
raw_snapshot_json = row_dict.pop("snapshot_json", None)
|
||||
snapshot_payload = None
|
||||
if isinstance(raw_snapshot_json, str):
|
||||
try:
|
||||
snapshot_payload = json.loads(raw_snapshot_json)
|
||||
except (ValueError, TypeError):
|
||||
snapshot_payload = None
|
||||
row_dict["snapshot"] = snapshot_payload
|
||||
payload.append(row_dict)
|
||||
if len(payload) >= normalized_limit:
|
||||
break
|
||||
|
||||
return payload
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def clear_history(self, user_id: int) -> int:
|
||||
"""Delete all dismissals for a user and return deleted row count."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM activity_dismissals WHERE user_id = ?",
|
||||
(normalized_user_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cursor.rowcount or 0)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,310 @@
|
||||
"""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:
|
||||
if not isinstance(user_id, int) or user_id < 1:
|
||||
raise ValueError("user_id must be a positive integer")
|
||||
return f"{USER_VIEWER_SCOPE_PREFIX}{user_id}"
|
||||
|
||||
|
||||
def normalize_viewer_scope(viewer_scope: Any) -> str:
|
||||
if not isinstance(viewer_scope, str) or not viewer_scope.strip():
|
||||
raise ValueError("viewer_scope must be a non-empty string")
|
||||
|
||||
normalized = viewer_scope.strip()
|
||||
if normalized in {ADMIN_VIEWER_SCOPE, NOAUTH_VIEWER_SCOPE}:
|
||||
return normalized
|
||||
|
||||
if not normalized.startswith(USER_VIEWER_SCOPE_PREFIX):
|
||||
raise ValueError(
|
||||
"viewer_scope must be one of: admin:shared, noauth:shared, or user:<id>"
|
||||
)
|
||||
|
||||
raw_user_id = normalized[len(USER_VIEWER_SCOPE_PREFIX):].strip()
|
||||
try:
|
||||
parsed_user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("viewer_scope user id must be a positive integer") from exc
|
||||
|
||||
return user_viewer_scope(parsed_user_id)
|
||||
|
||||
|
||||
def _normalize_item_type(item_type: Any) -> str:
|
||||
if not isinstance(item_type, str) or not item_type.strip():
|
||||
raise ValueError("item_type must be a non-empty string")
|
||||
normalized = item_type.strip().lower()
|
||||
if normalized not in VALID_ACTIVITY_ITEM_TYPES:
|
||||
raise ValueError("item_type must be one of: download, request")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_item_key(item_key: Any, *, item_type: str) -> str:
|
||||
if not isinstance(item_key, str) or not item_key.strip():
|
||||
raise ValueError("item_key must be a non-empty string")
|
||||
|
||||
normalized = item_key.strip()
|
||||
expected_prefix = f"{item_type}:"
|
||||
if not normalized.startswith(expected_prefix):
|
||||
raise ValueError(f"item_key must be in the format {expected_prefix}<id>")
|
||||
if not normalized.split(":", 1)[1].strip():
|
||||
raise ValueError(f"item_key must be in the format {expected_prefix}<id>")
|
||||
return normalized
|
||||
|
||||
|
||||
class ActivityViewStateService:
|
||||
"""Service for per-viewer activity dismissal and history visibility."""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
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]]:
|
||||
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]]:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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
|
||||
|
||||
placeholders = ",".join("?" for _ in normalized_keys)
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
f"""
|
||||
DELETE FROM activity_view_state
|
||||
WHERE item_type = ? AND item_key IN ({placeholders})
|
||||
""",
|
||||
(normalized_type, *normalized_keys),
|
||||
)
|
||||
conn.commit()
|
||||
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
|
||||
return max(rowcount, 0)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, jsonify, request, session
|
||||
from flask import Flask, g, jsonify, request, session
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shelfmark.config.booklore_settings import (
|
||||
@@ -26,8 +26,8 @@ from shelfmark.core.auth_modes import (
|
||||
AUTH_SOURCE_CWA,
|
||||
AUTH_SOURCE_OIDC,
|
||||
AUTH_SOURCE_PROXY,
|
||||
determine_auth_mode,
|
||||
has_local_password_admin,
|
||||
is_user_active_for_auth_mode,
|
||||
load_active_auth_mode,
|
||||
normalize_auth_source,
|
||||
)
|
||||
from shelfmark.core.cwa_user_sync import sync_cwa_users_from_rows
|
||||
@@ -65,37 +65,6 @@ def _get_user_edit_capabilities(
|
||||
}
|
||||
|
||||
|
||||
def _get_auth_mode():
|
||||
"""Get current auth mode from config."""
|
||||
try:
|
||||
config = load_config_file("security")
|
||||
return determine_auth_mode(
|
||||
config,
|
||||
CWA_DB_PATH,
|
||||
has_local_admin=has_local_password_admin(),
|
||||
)
|
||||
except Exception:
|
||||
return "none"
|
||||
|
||||
|
||||
def _require_admin(f):
|
||||
"""Decorator to require 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.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_mode = _get_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
|
||||
|
||||
|
||||
def _sanitize_user(user: dict) -> dict:
|
||||
"""Remove sensitive fields from user dict before returning to client."""
|
||||
sanitized = dict(user)
|
||||
@@ -116,14 +85,6 @@ def _oidc_role_management_message(security_config: dict[str, Any]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _is_user_active(user: dict[str, Any], auth_method: str) -> bool:
|
||||
"""Determine whether a user can authenticate in the current auth mode."""
|
||||
source = normalize_auth_source(user.get("auth_source"), user.get("oidc_subject"))
|
||||
if source == AUTH_SOURCE_BUILTIN:
|
||||
return auth_method in (AUTH_SOURCE_BUILTIN, AUTH_SOURCE_OIDC)
|
||||
return source == auth_method
|
||||
|
||||
|
||||
def _serialize_user(
|
||||
user: dict[str, Any],
|
||||
auth_method: str,
|
||||
@@ -135,7 +96,7 @@ def _serialize_user(
|
||||
payload.get("auth_source"),
|
||||
payload.get("oidc_subject"),
|
||||
)
|
||||
payload["is_active"] = _is_user_active(payload, auth_method)
|
||||
payload["is_active"] = is_user_active_for_auth_mode(payload, auth_method)
|
||||
payload["edit_capabilities"] = _get_user_edit_capabilities(
|
||||
payload,
|
||||
security_config=security_config,
|
||||
@@ -143,6 +104,8 @@ def _serialize_user(
|
||||
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():
|
||||
@@ -164,12 +127,31 @@ def _sync_all_cwa_users(user_db: UserDB) -> dict[str, int]:
|
||||
def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
"""Register admin user management routes on the Flask app."""
|
||||
|
||||
def _require_admin(f):
|
||||
"""Decorator to require 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, **kwargs):
|
||||
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():
|
||||
"""List all users."""
|
||||
users = user_db.list_users()
|
||||
auth_mode = _get_auth_mode()
|
||||
auth_mode = g.auth_mode
|
||||
security_config = load_config_file("security")
|
||||
return jsonify([
|
||||
_serialize_user(u, auth_mode, security_config=security_config)
|
||||
@@ -181,7 +163,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
def admin_create_user():
|
||||
"""Create a new user with password authentication."""
|
||||
data = request.get_json() or {}
|
||||
auth_mode = _get_auth_mode()
|
||||
auth_mode = g.auth_mode
|
||||
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password", "")
|
||||
@@ -206,7 +188,8 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
return jsonify({"error": "Role must be 'admin' or 'user'"}), 400
|
||||
|
||||
# First user is always admin
|
||||
if not user_db.list_users():
|
||||
existing_users = user_db.list_users()
|
||||
if not existing_users:
|
||||
role = "admin"
|
||||
|
||||
# Check if username already exists
|
||||
@@ -233,7 +216,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
return jsonify(
|
||||
_serialize_user(
|
||||
user,
|
||||
_get_auth_mode(),
|
||||
g.auth_mode,
|
||||
security_config=load_config_file("security"),
|
||||
)
|
||||
), 201
|
||||
@@ -248,7 +231,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
|
||||
result = _serialize_user(
|
||||
user,
|
||||
_get_auth_mode(),
|
||||
g.auth_mode,
|
||||
security_config=load_config_file("security"),
|
||||
)
|
||||
result["settings"] = user_db.get_user_settings(user_id)
|
||||
@@ -356,14 +339,14 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
# Ensure runtime reads see updated per-user overrides immediately.
|
||||
try:
|
||||
from shelfmark.core.config import config as app_config
|
||||
app_config.refresh()
|
||||
app_config.refresh(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
updated = user_db.get_user(user_id=user_id)
|
||||
result = _serialize_user(
|
||||
updated,
|
||||
_get_auth_mode(),
|
||||
g.auth_mode,
|
||||
security_config=security_config,
|
||||
)
|
||||
result["settings"] = user_db.get_user_settings(user_id)
|
||||
@@ -374,8 +357,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
@_require_admin
|
||||
def admin_sync_cwa_users():
|
||||
"""Manually sync users from Calibre-Web into users.db."""
|
||||
auth_mode = _get_auth_mode()
|
||||
if auth_mode != AUTH_SOURCE_CWA:
|
||||
if g.auth_mode != AUTH_SOURCE_CWA:
|
||||
return jsonify({
|
||||
"error": "CWA sync is only available when CWA authentication is enabled",
|
||||
}), 400
|
||||
@@ -419,12 +401,11 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
auth_mode = _get_auth_mode()
|
||||
auth_source = normalize_auth_source(
|
||||
user.get("auth_source"),
|
||||
user.get("oidc_subject"),
|
||||
)
|
||||
if auth_source == AUTH_SOURCE_CWA and auth_source == auth_mode:
|
||||
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.",
|
||||
|
||||
@@ -9,6 +9,7 @@ from shelfmark.config.notifications_settings import (
|
||||
is_valid_notification_url,
|
||||
normalize_notification_routes,
|
||||
)
|
||||
from shelfmark.config.users_settings import validate_search_preference_value
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_settings_overrides import (
|
||||
build_user_preferences_payload as _build_user_preferences_payload,
|
||||
@@ -68,6 +69,48 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li
|
||||
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
|
||||
@@ -136,6 +179,20 @@ def register_admin_settings_routes(
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/search-preferences", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_search_preferences(user_id):
|
||||
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):
|
||||
|
||||
@@ -28,10 +28,7 @@ def has_local_password_admin(user_db: Any | None = None) -> bool:
|
||||
db = UserDB(os.path.join(config_root, "users.db"))
|
||||
db.initialize()
|
||||
|
||||
return any(
|
||||
user.get("password_hash") and user.get("role") == "admin"
|
||||
for user in db.list_users()
|
||||
)
|
||||
return db.has_admin_with_password()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -78,6 +75,55 @@ def determine_auth_mode(
|
||||
return "none"
|
||||
|
||||
|
||||
def _load_security_config() -> dict[str, Any]:
|
||||
"""Load security settings with environment-backed values applied."""
|
||||
from shelfmark.core.settings_registry import (
|
||||
get_setting_value,
|
||||
get_settings_field_map,
|
||||
load_config_file,
|
||||
)
|
||||
|
||||
try:
|
||||
import shelfmark.config.security # noqa: F401
|
||||
except Exception:
|
||||
return load_config_file("security")
|
||||
|
||||
config = load_config_file("security")
|
||||
field_map = get_settings_field_map(tab_name="security")
|
||||
if not field_map:
|
||||
return config
|
||||
|
||||
resolved = dict(config)
|
||||
for key, (field, tab_name) in field_map.items():
|
||||
resolved[key] = get_setting_value(field, tab_name)
|
||||
return resolved
|
||||
|
||||
|
||||
def load_active_auth_mode(
|
||||
cwa_db_path: Any | None,
|
||||
*,
|
||||
user_db: Any | None = None,
|
||||
) -> str:
|
||||
"""Resolve active auth mode using current security config and runtime prerequisites."""
|
||||
try:
|
||||
security_config = _load_security_config()
|
||||
return determine_auth_mode(
|
||||
security_config,
|
||||
cwa_db_path,
|
||||
has_local_admin=has_local_password_admin(user_db),
|
||||
)
|
||||
except Exception:
|
||||
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") or path.startswith("/api/onboarding")
|
||||
|
||||
@@ -62,6 +62,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:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -68,6 +69,7 @@ class Config:
|
||||
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."""
|
||||
@@ -84,7 +86,9 @@ class Config:
|
||||
# This handles cases where config is accessed before settings are registered
|
||||
try:
|
||||
import shelfmark.config.settings # noqa: F401 - main app settings
|
||||
import shelfmark.config.security # noqa: F401 - security/auth settings
|
||||
import shelfmark.config.notifications_settings # noqa: F401 - notifications settings
|
||||
import shelfmark.config.users_settings # noqa: F401 - users/request settings
|
||||
import shelfmark.release_sources # noqa: F401 - plugin settings
|
||||
import shelfmark.metadata_providers # noqa: F401 - plugin settings
|
||||
except ImportError:
|
||||
@@ -117,13 +121,22 @@ class Config:
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def refresh(self) -> None:
|
||||
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) < 0.05:
|
||||
return
|
||||
|
||||
with self._cache_lock:
|
||||
self._loaded = False
|
||||
self._load_settings()
|
||||
@@ -131,6 +144,7 @@ class Config:
|
||||
self._user_settings_cache.clear()
|
||||
self._user_db = None
|
||||
self._user_db_load_attempted = False
|
||||
self._last_refresh_time = time.monotonic()
|
||||
|
||||
def _get_user_db(self):
|
||||
"""Get or initialize a UserDB handle if available."""
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Persistence helpers for canonical download activity rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
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 _normalize_task_id(task_id: Any) -> str:
|
||||
normalized = normalize_optional_text(task_id)
|
||||
if normalized is None:
|
||||
raise ValueError("task_id must be a non-empty string")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_origin(origin: Any) -> str:
|
||||
normalized = normalize_optional_text(origin)
|
||||
if normalized is None:
|
||||
return "direct"
|
||||
lowered = normalized.lower()
|
||||
if lowered not in VALID_ORIGINS:
|
||||
raise ValueError("origin must be one of: direct, requested")
|
||||
return lowered
|
||||
|
||||
|
||||
def _normalize_final_status(final_status: Any) -> str:
|
||||
normalized = normalize_optional_text(final_status)
|
||||
if normalized is None:
|
||||
raise ValueError("final_status must be a non-empty string")
|
||||
lowered = normalized.lower()
|
||||
if lowered not in VALID_TERMINAL_STATUSES:
|
||||
raise ValueError("final_status must be one of: complete, error, cancelled")
|
||||
return lowered
|
||||
|
||||
|
||||
def _normalize_limit(value: Any, *, default: int, minimum: int, maximum: int) -> int:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("limit must be an integer") 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):
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
return 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: Any) -> str | None:
|
||||
normalized = normalize_optional_text(value)
|
||||
if normalized is None:
|
||||
return None
|
||||
return normalized if os.path.exists(normalized) else None
|
||||
|
||||
@staticmethod
|
||||
def to_download_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||||
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"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _iso_to_epoch(value: Any) -> 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=timezone.utc)
|
||||
return parsed.timestamp()
|
||||
|
||||
@classmethod
|
||||
def to_history_row(cls, row: dict[str, Any], *, dismissed_at: str) -> dict[str, Any]:
|
||||
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,
|
||||
format: str | None,
|
||||
size: str | None,
|
||||
preview: str | None,
|
||||
content_type: str | None,
|
||||
origin: str,
|
||||
) -> 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:
|
||||
raise ValueError("source must be a non-empty string")
|
||||
normalized_title = normalize_optional_text(title)
|
||||
if normalized_title is None:
|
||||
raise ValueError("title must be a non-empty string")
|
||||
normalized_origin = _normalize_origin(origin)
|
||||
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,
|
||||
queued_at, terminal_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, ?)
|
||||
ON CONFLICT(task_id) DO UPDATE SET
|
||||
final_status = 'active',
|
||||
status_message = NULL,
|
||||
download_path = NULL,
|
||||
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(format),
|
||||
normalize_optional_text(size),
|
||||
normalize_optional_text(preview),
|
||||
normalize_optional_text(content_type),
|
||||
normalized_origin,
|
||||
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,
|
||||
) -> 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)
|
||||
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 = ?,
|
||||
terminal_at = ?
|
||||
WHERE task_id = ? AND final_status = 'active'
|
||||
""",
|
||||
(
|
||||
normalized_final_status,
|
||||
normalized_status_message,
|
||||
normalized_download_path,
|
||||
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:
|
||||
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]]:
|
||||
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()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1,12 +1,15 @@
|
||||
"""Disk-based image cache with LRU eviction."""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
@@ -466,6 +469,32 @@ class ImageCacheService:
|
||||
'hit_rate': round(hit_rate, 1),
|
||||
}
|
||||
|
||||
@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)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
return False
|
||||
|
||||
hostname = parsed.hostname
|
||||
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) -> Optional[Tuple[bytes, str]]:
|
||||
"""Fetch an image from URL and cache it.
|
||||
|
||||
@@ -477,6 +506,9 @@ class ImageCacheService:
|
||||
Tuple of (image_data, content_type) or None on failure
|
||||
"""
|
||||
try:
|
||||
if not self._is_safe_url(url):
|
||||
logger.warning(f"Blocked request to disallowed URL: {url}")
|
||||
return None
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
|
||||
@@ -20,7 +20,9 @@ def _get_config():
|
||||
# Default mirror lists (hardcoded fallbacks)
|
||||
DEFAULT_AA_MIRRORS = [
|
||||
"https://annas-archive.gl",
|
||||
"https://annas-archive.li",
|
||||
"https://annas-archive.pk",
|
||||
"https://annas-archive.vg",
|
||||
"https://annas-archive.gd",
|
||||
]
|
||||
|
||||
DEFAULT_LIBGEN_MIRRORS = [
|
||||
|
||||
+12
-51
@@ -38,12 +38,19 @@ class QueueStatus(str, Enum):
|
||||
LOCATING = "locating"
|
||||
DOWNLOADING = "downloading"
|
||||
COMPLETE = "complete"
|
||||
AVAILABLE = "available"
|
||||
ERROR = "error"
|
||||
DONE = "done"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
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(str, Enum):
|
||||
DIRECT = "direct"
|
||||
UNIVERSAL = "universal"
|
||||
@@ -107,6 +114,9 @@ class DownloadTask:
|
||||
status: QueueStatus = QueueStatus.QUEUED
|
||||
status_message: Optional[str] = None
|
||||
download_path: Optional[str] = None
|
||||
last_error_message: Optional[str] = None
|
||||
last_error_type: Optional[str] = None
|
||||
staged_path: Optional[str] = None
|
||||
|
||||
def __lt__(self, other):
|
||||
"""Compare tasks for priority queue (lower priority number = higher precedence)."""
|
||||
@@ -121,55 +131,6 @@ 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."""
|
||||
|
||||
@@ -14,6 +14,7 @@ logger = setup_logger(__name__)
|
||||
# e.g., "SeriesPosition" must match before "Series"
|
||||
KNOWN_TOKENS = [
|
||||
'seriesposition',
|
||||
'originalname',
|
||||
'partnumber',
|
||||
'subtitle',
|
||||
'author',
|
||||
|
||||
+258
-25
@@ -2,10 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Iterable, Iterator
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
try:
|
||||
import apprise
|
||||
@@ -25,6 +29,7 @@ _APPRISE_APP_DESC = "Shelfmark notifications"
|
||||
_APPRISE_LOGO_URL = (
|
||||
"https://raw.githubusercontent.com/calibrain/shelfmark/main/src/frontend/public/logo.png"
|
||||
)
|
||||
_APPRISE_LOGGER_NAME = "apprise"
|
||||
|
||||
|
||||
class NotificationEvent(str, Enum):
|
||||
@@ -69,6 +74,13 @@ def _normalize_urls(value: Any) -> 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:
|
||||
@@ -78,6 +90,113 @@ def _normalize_urls(value: Any) -> list[str]:
|
||||
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):
|
||||
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=True,
|
||||
)
|
||||
|
||||
|
||||
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: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
@@ -220,6 +339,31 @@ def _render_message(context: NotificationContext) -> tuple[str, str]:
|
||||
return "Download Failed", f'Failed to download "{title}" by {author}.{error_line}'
|
||||
|
||||
|
||||
def _plugin_label(plugin: Any, 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
|
||||
try:
|
||||
privacy_url = plugin.url(privacy=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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],
|
||||
*,
|
||||
@@ -228,45 +372,134 @@ def _dispatch_to_apprise(
|
||||
notify_type: Any,
|
||||
) -> 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"}
|
||||
|
||||
apobj = _create_apprise_client()
|
||||
if apobj is None:
|
||||
return {"success": False, "message": "Apprise is not installed"}
|
||||
valid_urls = 0
|
||||
invalid_urls = 0
|
||||
for url in normalized_urls:
|
||||
try:
|
||||
added = bool(apobj.add(url))
|
||||
except Exception:
|
||||
added = False
|
||||
if added:
|
||||
valid_urls += 1
|
||||
else:
|
||||
invalid_urls += 1
|
||||
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 Exception 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 Exception 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:
|
||||
return {
|
||||
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
|
||||
|
||||
try:
|
||||
delivered = bool(apobj.notify(title=title, body=body, notify_type=notify_type))
|
||||
except Exception as exc:
|
||||
return {"success": False, "message": f"Notification send failed: {type(exc).__name__}: {exc}"}
|
||||
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
|
||||
|
||||
if not delivered:
|
||||
return {"success": False, "message": "Notification delivery failed"}
|
||||
|
||||
message = f"Notification sent to {valid_urls} URL(s)"
|
||||
if invalid_urls:
|
||||
message += f" ({invalid_urls} invalid URL(s) skipped)"
|
||||
return {"success": True, "message": message}
|
||||
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() -> Any:
|
||||
|
||||
@@ -5,7 +5,7 @@ Business logic remains in oidc_auth.py.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||
|
||||
from authlib.jose.errors import InvalidClaimError
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
@@ -23,6 +23,7 @@ from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
oauth = OAuth()
|
||||
_RETURN_TO_SESSION_KEY = "oidc_return_to"
|
||||
|
||||
|
||||
def _normalize_claims(raw_claims: Any) -> dict[str, Any]:
|
||||
@@ -52,7 +53,67 @@ 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"
|
||||
return f"{login_url}?oidc_error={quote(message)}"
|
||||
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: Any) -> 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 == "/login"
|
||||
or path.startswith("/login/")
|
||||
or path == "/api"
|
||||
or path.startswith("/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]]:
|
||||
@@ -116,6 +177,11 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
"""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:
|
||||
@@ -213,7 +279,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
session.permanent = True
|
||||
|
||||
logger.info(f"OIDC login successful: {user['username']} (admin={is_admin})")
|
||||
return redirect(request.script_root or "/")
|
||||
return redirect(_post_login_redirect_target(_get_pending_return_to(clear=True)))
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"OIDC callback error: {e}")
|
||||
|
||||
+86
-70
@@ -8,7 +8,10 @@ from threading import Lock, Event
|
||||
from typing import Dict, List, Optional, Tuple, Any, Callable
|
||||
|
||||
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 QueueStatus, QueueItem, DownloadTask, TERMINAL_QUEUE_STATUSES
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
class BookQueue:
|
||||
@@ -25,6 +28,7 @@ class BookQueue:
|
||||
self._terminal_status_hook: Optional[
|
||||
Callable[[str, QueueStatus, DownloadTask], None]
|
||||
] = None
|
||||
self._queue_hook: Optional[Callable[[str, DownloadTask], None]] = None
|
||||
|
||||
@property
|
||||
def _status_timeout(self) -> timedelta:
|
||||
@@ -33,11 +37,12 @@ class BookQueue:
|
||||
|
||||
def add(self, task: DownloadTask) -> bool:
|
||||
"""Add a download task to the queue. Returns False if already exists."""
|
||||
hook: Optional[Callable[[str, DownloadTask], 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
|
||||
@@ -48,7 +53,14 @@ 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
|
||||
|
||||
if hook is not None:
|
||||
try:
|
||||
hook(task_id, task)
|
||||
except Exception as exc:
|
||||
logger.warning("Queue hook failed while adding task %s: %s", task_id, exc)
|
||||
return True
|
||||
|
||||
def get_next(self) -> Optional[Tuple[str, Event]]:
|
||||
"""Get next task ID from queue with cancellation flag."""
|
||||
@@ -77,6 +89,11 @@ class BookQueue:
|
||||
with self._lock:
|
||||
return self._task_data.get(task_id)
|
||||
|
||||
def get_task_status(self, task_id: str) -> Optional[QueueStatus]:
|
||||
"""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."""
|
||||
self._status[book_id] = status
|
||||
@@ -90,6 +107,14 @@ class BookQueue:
|
||||
with self._lock:
|
||||
self._terminal_status_hook = hook
|
||||
|
||||
def set_queue_hook(
|
||||
self,
|
||||
hook: Optional[Callable[[str, DownloadTask], 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: Optional[Callable[[str, QueueStatus, DownloadTask], None]] = None
|
||||
@@ -98,15 +123,8 @@ class BookQueue:
|
||||
previous_status = self._status.get(book_id)
|
||||
self._update_status(book_id, status)
|
||||
|
||||
terminal_statuses = {
|
||||
QueueStatus.COMPLETE,
|
||||
QueueStatus.AVAILABLE,
|
||||
QueueStatus.ERROR,
|
||||
QueueStatus.DONE,
|
||||
QueueStatus.CANCELLED,
|
||||
}
|
||||
if (
|
||||
status in terminal_statuses
|
||||
status in TERMINAL_QUEUE_STATUSES
|
||||
and previous_status != status
|
||||
and self._terminal_status_hook is not None
|
||||
):
|
||||
@@ -116,7 +134,7 @@ class BookQueue:
|
||||
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)
|
||||
|
||||
@@ -145,8 +163,8 @@ class BookQueue:
|
||||
"""Get current queue status grouped by status.
|
||||
|
||||
Args:
|
||||
user_id: If provided, only return tasks belonging to this user
|
||||
(plus legacy tasks with no user_id). If None, return all.
|
||||
user_id: If provided, only return tasks belonging to this user.
|
||||
If None, return all.
|
||||
"""
|
||||
self.refresh()
|
||||
with self._lock:
|
||||
@@ -154,7 +172,7 @@ class BookQueue:
|
||||
for task_id, status in self._status.items():
|
||||
if task_id in self._task_data:
|
||||
task = self._task_data[task_id]
|
||||
if user_id is not None and task.user_id is not None and task.user_id != user_id:
|
||||
if user_id is not None and task.user_id != user_id:
|
||||
continue
|
||||
result[status][task_id] = task
|
||||
return result
|
||||
@@ -191,29 +209,20 @@ class BookQueue:
|
||||
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.LOCATING, QueueStatus.DOWNLOADING]:
|
||||
# Signal active download to stop
|
||||
if task_id in self._cancel_flags:
|
||||
self._cancel_flags[task_id].set()
|
||||
if 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 not in [QueueStatus.QUEUED]:
|
||||
# Not in a cancellable state
|
||||
return False
|
||||
|
||||
if current_status in [QueueStatus.RESOLVING, QueueStatus.LOCATING, QueueStatus.DOWNLOADING, QueueStatus.QUEUED]:
|
||||
self.update_status(task_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
|
||||
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)."""
|
||||
@@ -247,6 +256,51 @@ class BookQueue:
|
||||
|
||||
return found
|
||||
|
||||
def enqueue_existing(self, task_id: str, *, priority: Optional[int] = None) -> bool:
|
||||
"""Requeue an existing task regardless of current status.
|
||||
|
||||
This is used for retries where task metadata should be preserved.
|
||||
"""
|
||||
hook: Optional[Callable[[str, DownloadTask], None]] = None
|
||||
hook_task: Optional[DownloadTask] = 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():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
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 Exception 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:
|
||||
@@ -285,43 +339,9 @@ class BookQueue:
|
||||
return True
|
||||
return any(status == QueueStatus.QUEUED for status in self._status.values())
|
||||
|
||||
def clear_completed(self, user_id: Optional[int] = None) -> int:
|
||||
"""Remove terminal tasks from tracking, optionally scoped to one user.
|
||||
|
||||
Args:
|
||||
user_id: If provided, only clear tasks belonging to this user,
|
||||
plus legacy tasks with no user_id. If None, clear all.
|
||||
"""
|
||||
terminal_statuses = {QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED}
|
||||
with self._lock:
|
||||
to_remove: list[str] = []
|
||||
for task_id, status in self._status.items():
|
||||
if status not in terminal_statuses:
|
||||
continue
|
||||
|
||||
if user_id is None:
|
||||
to_remove.append(task_id)
|
||||
continue
|
||||
|
||||
task = self._task_data.get(task_id)
|
||||
if task is None:
|
||||
# Without task ownership metadata we cannot safely scope removal.
|
||||
continue
|
||||
if task.user_id is None or task.user_id == user_id:
|
||||
to_remove.append(task_id)
|
||||
|
||||
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()
|
||||
to_remove = []
|
||||
@@ -335,10 +355,6 @@ 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:
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Shared request-related helper functions used by routes and services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
|
||||
_logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def now_utc_iso() -> str:
|
||||
"""Return the current UTC time as a seconds-precision ISO 8601 string."""
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def emit_ws_event(
|
||||
ws_manager: Any,
|
||||
*,
|
||||
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 Exception 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."""
|
||||
return load_config_file("users")
|
||||
|
||||
|
||||
def coerce_bool(value: Any, 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: Any) -> int | None:
|
||||
"""Extract and coerce `db_user_id` from a Flask session to ``int | None``."""
|
||||
raw = session_obj.get("db_user_id") if session_obj is not None else None
|
||||
try:
|
||||
return int(raw) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def coerce_int(value: Any, default: int) -> int:
|
||||
"""Best-effort integer coercion with fallback to default."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def normalize_optional_text(value: Any) -> 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: Any) -> int | None:
|
||||
"""Parse *value* as a positive integer, returning ``None`` on failure."""
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def normalize_optional_positive_int(value: Any, 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
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field_name} must be a positive integer when provided") from exc
|
||||
if parsed < 1:
|
||||
raise ValueError(f"{field_name} must be a positive integer when provided")
|
||||
return parsed
|
||||
|
||||
|
||||
def populate_request_usernames(rows: list[dict[str, Any]], user_db: Any) -> None:
|
||||
"""Add 'username' to each request row by looking up user_id."""
|
||||
cache: dict[int, str] = {}
|
||||
for row in rows:
|
||||
requester_id = row["user_id"]
|
||||
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: Any) -> 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
|
||||
@@ -43,6 +43,21 @@ def cap_mode(mode: PolicyMode, ceiling: PolicyMode) -> PolicyMode:
|
||||
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",
|
||||
@@ -320,6 +335,10 @@ def resolve_policy_mode(
|
||||
|
||||
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)
|
||||
@@ -346,6 +365,9 @@ def resolve_policy_mode(
|
||||
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 cap_mode(rule_mode, ceiling)
|
||||
return _normalize_release_result_mode(
|
||||
normalized_source,
|
||||
cap_mode(rule_mode, ceiling),
|
||||
)
|
||||
|
||||
return ceiling
|
||||
return _normalize_release_result_mode(normalized_source, ceiling)
|
||||
|
||||
+372
-311
@@ -17,53 +17,35 @@ from shelfmark.core.request_policy import (
|
||||
parse_policy_mode,
|
||||
resolve_policy_mode,
|
||||
)
|
||||
from shelfmark.core.request_validation import RequestStatus
|
||||
from shelfmark.core.requests_service import (
|
||||
RequestServiceError,
|
||||
cancel_request,
|
||||
create_request,
|
||||
create_requests,
|
||||
fulfil_request,
|
||||
reject_request,
|
||||
)
|
||||
from shelfmark.core.activity_service import ActivityService, build_request_item_key
|
||||
from shelfmark.core.notifications import (
|
||||
NotificationContext,
|
||||
NotificationEvent,
|
||||
notify_admin,
|
||||
notify_user,
|
||||
)
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.request_helpers import (
|
||||
coerce_bool,
|
||||
coerce_int,
|
||||
emit_ws_event,
|
||||
load_users_request_policy_settings,
|
||||
normalize_optional_text,
|
||||
normalize_positive_int,
|
||||
populate_request_usernames,
|
||||
)
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _load_users_request_policy_settings() -> dict[str, Any]:
|
||||
"""Load global request-policy settings from users config."""
|
||||
return load_config_file("users")
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool = False) -> bool:
|
||||
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 _as_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed
|
||||
|
||||
|
||||
def _error_response(
|
||||
message: str,
|
||||
status_code: int,
|
||||
@@ -110,111 +92,127 @@ def _require_db_user_id() -> tuple[int | None, Any | None]:
|
||||
)
|
||||
|
||||
|
||||
def _require_admin_user_id() -> tuple[int | None, Any | None]:
|
||||
if not session.get("is_admin", False):
|
||||
return None, (jsonify({"error": "Admin access required"}), 403)
|
||||
raw_admin_id = session.get("db_user_id")
|
||||
if raw_admin_id is None:
|
||||
return None, (jsonify({"error": "Admin user identity unavailable"}), 403)
|
||||
try:
|
||||
return int(raw_admin_id), None
|
||||
except (TypeError, ValueError):
|
||||
return None, (jsonify({"error": "Admin user identity unavailable"}), 403)
|
||||
|
||||
|
||||
def _resolve_effective_policy(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
db_user_id: int | None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], bool]:
|
||||
global_settings = _load_users_request_policy_settings()
|
||||
global_settings = load_users_request_policy_settings()
|
||||
user_settings = user_db.get_user_settings(db_user_id) if db_user_id is not None else {}
|
||||
effective = merge_request_policy_settings(global_settings, user_settings)
|
||||
requests_enabled = _as_bool(effective.get("REQUESTS_ENABLED"), False)
|
||||
requests_enabled = coerce_bool(effective.get("REQUESTS_ENABLED"), False)
|
||||
return global_settings, user_settings, effective, requests_enabled
|
||||
|
||||
|
||||
def _emit_request_event(
|
||||
ws_manager: Any,
|
||||
*,
|
||||
event_name: str,
|
||||
payload: dict[str, Any],
|
||||
room: str,
|
||||
) -> None:
|
||||
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 Exception as exc:
|
||||
logger.warning(f"Failed to emit WebSocket event '{event_name}' to room '{room}': {exc}")
|
||||
|
||||
|
||||
def _extract_release_source_id(release_data: Any) -> str | None:
|
||||
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
|
||||
|
||||
|
||||
def _record_terminal_request_snapshot(
|
||||
activity_service: ActivityService | None,
|
||||
*,
|
||||
request_row: dict[str, Any],
|
||||
) -> None:
|
||||
if activity_service is None:
|
||||
return
|
||||
|
||||
request_status = request_row.get("status")
|
||||
if request_status not in {"rejected", "cancelled"}:
|
||||
return
|
||||
|
||||
raw_request_id = request_row.get("id")
|
||||
try:
|
||||
request_id = int(raw_request_id)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if request_id < 1:
|
||||
return
|
||||
|
||||
raw_user_id = request_row.get("user_id")
|
||||
try:
|
||||
user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
user_id = None
|
||||
|
||||
source_id = _extract_release_source_id(request_row.get("release_data"))
|
||||
|
||||
try:
|
||||
activity_service.record_terminal_snapshot(
|
||||
user_id=user_id,
|
||||
item_type="request",
|
||||
item_key=build_request_item_key(request_id),
|
||||
origin="request",
|
||||
final_status=request_status,
|
||||
snapshot={"kind": "request", "request": request_row},
|
||||
request_id=request_id,
|
||||
source_id=source_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to record terminal request snapshot for request %s: %s", request_id, exc)
|
||||
|
||||
|
||||
def _normalize_optional_text(value: Any) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _resolve_title_from_book_data(book_data: Any) -> str:
|
||||
if isinstance(book_data, dict):
|
||||
title = _normalize_optional_text(book_data.get("title"))
|
||||
title = normalize_optional_text(book_data.get("title"))
|
||||
if title is not None:
|
||||
return title
|
||||
return "Unknown title"
|
||||
|
||||
|
||||
def _normalize_optional_source_id(value: Any) -> str | None:
|
||||
"""Normalize source identifiers while allowing integer provider ids."""
|
||||
if isinstance(value, bool) or value is None:
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
value = str(value)
|
||||
return normalize_optional_text(value)
|
||||
|
||||
|
||||
def _build_release_result_data_from_book_data(
|
||||
*,
|
||||
source: str,
|
||||
book_data: dict[str, Any],
|
||||
content_type: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build release-level payload fields for sources whose browse results are releases."""
|
||||
source_id = _normalize_optional_source_id(book_data.get("provider_id")) or _normalize_optional_source_id(
|
||||
book_data.get("id")
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"source": source,
|
||||
"source_id": source_id,
|
||||
"title": book_data.get("title"),
|
||||
"author": book_data.get("author"),
|
||||
"year": book_data.get("year"),
|
||||
"format": book_data.get("format"),
|
||||
"size": book_data.get("size"),
|
||||
"preview": book_data.get("preview"),
|
||||
"content_type": content_type,
|
||||
"source_url": book_data.get("source_url"),
|
||||
"search_mode": "direct",
|
||||
}
|
||||
return {key: value for key, value in payload.items() if value is not None}
|
||||
|
||||
|
||||
def _source_results_are_releases(source: str) -> 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_request_payload(
|
||||
*,
|
||||
source: str,
|
||||
request_level: Any,
|
||||
book_data: Any,
|
||||
release_data: Any,
|
||||
content_type: str,
|
||||
) -> tuple[Any, Any]:
|
||||
"""Concrete-release browse results are always handled as release-level requests."""
|
||||
if not _source_results_are_releases(source):
|
||||
return request_level, release_data
|
||||
|
||||
normalized_release_data = release_data
|
||||
if normalized_release_data is None and isinstance(book_data, dict):
|
||||
normalized_release_data = _build_release_result_data_from_book_data(
|
||||
source=source,
|
||||
book_data=book_data,
|
||||
content_type=content_type,
|
||||
)
|
||||
elif isinstance(normalized_release_data, dict):
|
||||
normalized_release_data = dict(normalized_release_data)
|
||||
|
||||
if isinstance(normalized_release_data, dict):
|
||||
normalized_release_data["source"] = source
|
||||
if normalized_release_data.get("content_type") is None:
|
||||
normalized_release_data["content_type"] = content_type
|
||||
|
||||
normalized_source_id = _normalize_optional_source_id(normalized_release_data.get("source_id"))
|
||||
if normalized_source_id is not None:
|
||||
normalized_release_data["source_id"] = normalized_source_id
|
||||
elif isinstance(book_data, dict):
|
||||
fallback_source_id = _normalize_optional_source_id(book_data.get("provider_id")) or _normalize_optional_source_id(
|
||||
book_data.get("id")
|
||||
)
|
||||
if fallback_source_id is not None:
|
||||
normalized_release_data["source_id"] = fallback_source_id
|
||||
|
||||
return "release", normalized_release_data
|
||||
|
||||
|
||||
def _resolve_request_title(request_row: dict[str, Any]) -> str:
|
||||
return _resolve_title_from_book_data(request_row.get("book_data"))
|
||||
|
||||
|
||||
def _format_user_label(username: str | None, user_id: int | None = None) -> str:
|
||||
normalized_username = _normalize_optional_text(username)
|
||||
normalized_username = normalize_optional_text(username)
|
||||
if normalized_username is not None:
|
||||
return normalized_username
|
||||
if user_id is not None and user_id > 0:
|
||||
@@ -222,30 +220,175 @@ def _format_user_label(username: str | None, user_id: int | None = None) -> str:
|
||||
return "unknown user"
|
||||
|
||||
|
||||
def _resolve_request_username(
|
||||
def _format_requester_label(user_db: UserDB, request_row: dict[str, Any]) -> str:
|
||||
"""Resolve a display label for the user who created a request."""
|
||||
user_id = normalize_positive_int(request_row.get("user_id"))
|
||||
if user_id is not None:
|
||||
requester = user_db.get_user(user_id=user_id)
|
||||
if isinstance(requester, dict):
|
||||
username = normalize_optional_text(requester.get("username"))
|
||||
if username is not None:
|
||||
return username
|
||||
return _format_user_label(None, user_id)
|
||||
|
||||
|
||||
def _resolve_request_user_context(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
request_row: dict[str, Any],
|
||||
fallback_username: str | None = None,
|
||||
) -> str | None:
|
||||
normalized_fallback = _normalize_optional_text(fallback_username)
|
||||
raw_user_id = request_row.get("user_id")
|
||||
try:
|
||||
request_user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
return normalized_fallback
|
||||
actor_user_id: int,
|
||||
actor_username: str | None,
|
||||
on_behalf_of_user_id: Any,
|
||||
) -> tuple[int, str | None, str]:
|
||||
if on_behalf_of_user_id in (None, ""):
|
||||
actor_label = _format_user_label(actor_username, actor_user_id)
|
||||
return actor_user_id, actor_username, actor_label
|
||||
|
||||
requester = user_db.get_user(user_id=request_user_id)
|
||||
if not isinstance(requester, dict):
|
||||
return normalized_fallback
|
||||
return _normalize_optional_text(requester.get("username")) or normalized_fallback
|
||||
if not session.get("is_admin", False):
|
||||
raise RequestServiceError("Admin required", status_code=403)
|
||||
|
||||
try:
|
||||
target_user_id = int(on_behalf_of_user_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RequestServiceError("Invalid on_behalf_of_user_id", status_code=400) from exc
|
||||
|
||||
if target_user_id <= 0:
|
||||
raise RequestServiceError("Invalid on_behalf_of_user_id", status_code=400)
|
||||
|
||||
target_user = user_db.get_user(user_id=target_user_id)
|
||||
if not target_user:
|
||||
raise RequestServiceError("User not found", status_code=404)
|
||||
|
||||
target_username = normalize_optional_text(target_user.get("username"))
|
||||
actor_label = _format_user_label(actor_username, actor_user_id)
|
||||
target_label = _format_user_label(target_username, target_user_id)
|
||||
return target_user_id, target_username, f"{actor_label} on behalf of {target_label}"
|
||||
|
||||
|
||||
def _prepare_request_create_arguments(
|
||||
user_db: UserDB,
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
db_user_id, db_gate = _require_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
raise RequestServiceError(
|
||||
"User identity is unavailable for request workflow",
|
||||
status_code=403,
|
||||
code="user_identity_unavailable",
|
||||
)
|
||||
|
||||
actor_username = normalize_optional_text(session.get("user_id"))
|
||||
target_user_id, _, actor_label = _resolve_request_user_context(
|
||||
user_db,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
on_behalf_of_user_id=data.get("on_behalf_of_user_id"),
|
||||
)
|
||||
|
||||
context = data.get("context") or {}
|
||||
if not isinstance(context, dict):
|
||||
raise RequestServiceError("context must be an object", status_code=400)
|
||||
|
||||
source = normalize_source(context.get("source"))
|
||||
release_data = data.get("release_data")
|
||||
request_level = context.get("request_level")
|
||||
if request_level is None:
|
||||
request_level = "book" if release_data is None else "release"
|
||||
|
||||
book_data = data.get("book_data")
|
||||
if not isinstance(book_data, dict):
|
||||
raise RequestServiceError("book_data must be an object", status_code=400)
|
||||
request_title = _resolve_title_from_book_data(book_data)
|
||||
|
||||
content_type = normalize_content_type(
|
||||
context.get("content_type")
|
||||
or data.get("content_type")
|
||||
or book_data.get("content_type")
|
||||
)
|
||||
request_level, release_data = _normalize_release_result_request_payload(
|
||||
source=source,
|
||||
request_level=request_level,
|
||||
book_data=book_data,
|
||||
release_data=release_data,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
|
||||
user_db,
|
||||
db_user_id=target_user_id,
|
||||
)
|
||||
if not requests_enabled:
|
||||
raise RequestServiceError(
|
||||
"Request workflow is disabled by policy",
|
||||
status_code=403,
|
||||
code="requests_unavailable",
|
||||
)
|
||||
|
||||
max_pending = coerce_int(
|
||||
effective.get("MAX_PENDING_REQUESTS_PER_USER"),
|
||||
default=20,
|
||||
)
|
||||
if max_pending < 1:
|
||||
max_pending = 1
|
||||
if max_pending > 1000:
|
||||
max_pending = 1000
|
||||
allow_notes = coerce_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True)
|
||||
note_value = data.get("note") if allow_notes else None
|
||||
|
||||
resolved_mode = resolve_policy_mode(
|
||||
source=source,
|
||||
content_type=content_type,
|
||||
global_settings=global_settings,
|
||||
user_settings=user_settings,
|
||||
)
|
||||
logger.debug(
|
||||
"request create policy actor=%s target_user_id=%s source=%s content_type=%s request_level=%s resolved_mode=%s",
|
||||
session.get("user_id"),
|
||||
target_user_id,
|
||||
source,
|
||||
content_type,
|
||||
request_level,
|
||||
resolved_mode.value,
|
||||
)
|
||||
|
||||
if resolved_mode == PolicyMode.BLOCKED:
|
||||
raise RequestServiceError(
|
||||
"Requesting is blocked by policy",
|
||||
status_code=403,
|
||||
code="policy_blocked",
|
||||
required_mode=PolicyMode.BLOCKED.value,
|
||||
)
|
||||
|
||||
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
|
||||
if resolved_mode == PolicyMode.REQUEST_BOOK and requested_level != "book":
|
||||
raise RequestServiceError(
|
||||
"Policy requires book-level requests",
|
||||
status_code=403,
|
||||
code="policy_requires_request",
|
||||
required_mode=PolicyMode.REQUEST_BOOK.value,
|
||||
)
|
||||
|
||||
return {
|
||||
"create_args": {
|
||||
"user_id": target_user_id,
|
||||
"source_hint": source,
|
||||
"content_type": content_type,
|
||||
"request_level": request_level,
|
||||
"policy_mode": resolved_mode.value,
|
||||
"book_data": book_data,
|
||||
"release_data": release_data,
|
||||
"note": note_value,
|
||||
"max_pending_per_user": max_pending,
|
||||
},
|
||||
"actor_label": actor_label,
|
||||
"request_title": request_title,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_request_source_and_format(request_row: dict[str, Any]) -> tuple[str, str | None]:
|
||||
release_data = request_row.get("release_data")
|
||||
if isinstance(release_data, dict):
|
||||
source = normalize_source(release_data.get("source") or request_row.get("source_hint"))
|
||||
release_format = _normalize_optional_text(
|
||||
release_format = normalize_optional_text(
|
||||
release_data.get("format")
|
||||
or release_data.get("filetype")
|
||||
or release_data.get("extension")
|
||||
@@ -254,13 +397,6 @@ def _resolve_request_source_and_format(request_row: dict[str, Any]) -> tuple[str
|
||||
return normalize_source(request_row.get("source_hint")), None
|
||||
|
||||
|
||||
def _resolve_request_user_id(request_row: dict[str, Any]) -> int | None:
|
||||
raw_user_id = request_row.get("user_id")
|
||||
try:
|
||||
user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return user_id if user_id > 0 else None
|
||||
|
||||
|
||||
def _notify_admin_for_request_event(
|
||||
@@ -268,7 +404,6 @@ def _notify_admin_for_request_event(
|
||||
*,
|
||||
event: NotificationEvent,
|
||||
request_row: dict[str, Any],
|
||||
fallback_username: str | None = None,
|
||||
) -> None:
|
||||
book_data = request_row.get("book_data")
|
||||
if not isinstance(book_data, dict):
|
||||
@@ -279,21 +414,17 @@ def _notify_admin_for_request_event(
|
||||
event=event,
|
||||
title=str(book_data.get("title") or "Unknown title"),
|
||||
author=str(book_data.get("author") or "Unknown author"),
|
||||
username=_resolve_request_username(
|
||||
user_db,
|
||||
request_row=request_row,
|
||||
fallback_username=fallback_username,
|
||||
),
|
||||
username=_format_requester_label(user_db, request_row),
|
||||
content_type=normalize_content_type(
|
||||
request_row.get("content_type") or book_data.get("content_type")
|
||||
),
|
||||
format=release_format,
|
||||
source=source,
|
||||
admin_note=_normalize_optional_text(request_row.get("admin_note")),
|
||||
admin_note=normalize_optional_text(request_row.get("admin_note")),
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
owner_user_id = _resolve_request_user_id(request_row)
|
||||
owner_user_id = normalize_positive_int(request_row.get("user_id"))
|
||||
try:
|
||||
notify_admin(event, context)
|
||||
except Exception as exc:
|
||||
@@ -321,7 +452,6 @@ def register_request_routes(
|
||||
*,
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
queue_release: Callable[..., tuple[bool, str | None]],
|
||||
activity_service: ActivityService | None = None,
|
||||
ws_manager: Any | None = None,
|
||||
) -> None:
|
||||
"""Register request policy and request lifecycle routes."""
|
||||
@@ -355,6 +485,7 @@ def register_request_routes(
|
||||
default_audio_mode = parse_policy_mode(effective.get("REQUEST_POLICY_DEFAULT_AUDIOBOOK"))
|
||||
|
||||
source_capabilities = get_source_content_type_capabilities()
|
||||
from shelfmark.release_sources import source_results_are_releases
|
||||
source_modes = []
|
||||
for source_name in sorted(source_capabilities):
|
||||
supported_types = sorted(
|
||||
@@ -374,6 +505,7 @@ def register_request_routes(
|
||||
{
|
||||
"source": source_name,
|
||||
"supported_content_types": supported_types,
|
||||
"browse_results_are_releases": source_results_are_releases(source_name),
|
||||
"modes": modes,
|
||||
}
|
||||
)
|
||||
@@ -382,7 +514,7 @@ def register_request_routes(
|
||||
{
|
||||
"requests_enabled": requests_enabled,
|
||||
"is_admin": is_admin,
|
||||
"allow_notes": _as_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True),
|
||||
"allow_notes": coerce_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True),
|
||||
"defaults": {
|
||||
"ebook": (
|
||||
default_ebook_mode.value
|
||||
@@ -406,126 +538,20 @@ def register_request_routes(
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
db_user_id, db_gate = _require_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
return db_gate
|
||||
actor_username = _normalize_optional_text(session.get("user_id"))
|
||||
actor_label = _format_user_label(actor_username, db_user_id)
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
context = data.get("context") or {}
|
||||
if not isinstance(context, dict):
|
||||
return jsonify({"error": "context must be an object"}), 400
|
||||
|
||||
source = normalize_source(context.get("source"))
|
||||
release_data = data.get("release_data")
|
||||
request_level = context.get("request_level")
|
||||
if request_level is None:
|
||||
request_level = "book" if release_data is None else "release"
|
||||
|
||||
book_data = data.get("book_data")
|
||||
if not isinstance(book_data, dict):
|
||||
return jsonify({"error": "book_data must be an object"}), 400
|
||||
request_title = _resolve_title_from_book_data(book_data)
|
||||
|
||||
content_type = normalize_content_type(
|
||||
context.get("content_type")
|
||||
or data.get("content_type")
|
||||
or book_data.get("content_type")
|
||||
)
|
||||
|
||||
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
|
||||
user_db,
|
||||
db_user_id=db_user_id,
|
||||
)
|
||||
if not requests_enabled:
|
||||
logger.debug(
|
||||
"Request not created for '%s' by %s: requests are disabled",
|
||||
request_title,
|
||||
actor_label,
|
||||
)
|
||||
return _error_response(
|
||||
"Request workflow is disabled by policy",
|
||||
403,
|
||||
code="requests_unavailable",
|
||||
)
|
||||
|
||||
max_pending = _as_int(
|
||||
effective.get("MAX_PENDING_REQUESTS_PER_USER"),
|
||||
default=20,
|
||||
)
|
||||
if max_pending < 1:
|
||||
max_pending = 1
|
||||
if max_pending > 1000:
|
||||
max_pending = 1000
|
||||
allow_notes = _as_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True)
|
||||
note_value = data.get("note") if allow_notes else None
|
||||
|
||||
resolved_mode = resolve_policy_mode(
|
||||
source=source,
|
||||
content_type=content_type,
|
||||
global_settings=global_settings,
|
||||
user_settings=user_settings,
|
||||
)
|
||||
logger.debug(
|
||||
"request create policy user=%s db_user_id=%s source=%s content_type=%s request_level=%s resolved_mode=%s",
|
||||
session.get("user_id"),
|
||||
db_user_id,
|
||||
source,
|
||||
content_type,
|
||||
request_level,
|
||||
resolved_mode.value,
|
||||
)
|
||||
|
||||
if resolved_mode == PolicyMode.BLOCKED:
|
||||
logger.debug(
|
||||
"Request blocked by policy for '%s' by %s",
|
||||
request_title,
|
||||
actor_label,
|
||||
)
|
||||
return _error_response(
|
||||
"Requesting is blocked by policy",
|
||||
403,
|
||||
code="policy_blocked",
|
||||
required_mode=PolicyMode.BLOCKED.value,
|
||||
)
|
||||
|
||||
if resolved_mode == PolicyMode.REQUEST_BOOK:
|
||||
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
|
||||
# Direct search results are already concrete releases, so allow release-level
|
||||
# request payloads even when the policy default is request_book.
|
||||
allow_direct_release_payload = source == "direct_download" and requested_level == "release"
|
||||
if requested_level != "book" and not allow_direct_release_payload:
|
||||
logger.debug(
|
||||
"Request not created for '%s' by %s: policy requires book-level requests",
|
||||
request_title,
|
||||
actor_label,
|
||||
)
|
||||
return _error_response(
|
||||
"Policy requires book-level requests",
|
||||
403,
|
||||
code="policy_requires_request",
|
||||
required_mode=PolicyMode.REQUEST_BOOK.value,
|
||||
)
|
||||
|
||||
try:
|
||||
created = create_request(
|
||||
user_db,
|
||||
user_id=db_user_id,
|
||||
source_hint=source,
|
||||
content_type=content_type,
|
||||
request_level=request_level,
|
||||
policy_mode=resolved_mode.value,
|
||||
book_data=book_data,
|
||||
release_data=release_data,
|
||||
note=note_value,
|
||||
max_pending_per_user=max_pending,
|
||||
)
|
||||
prepared = _prepare_request_create_arguments(user_db, data)
|
||||
created = create_request(user_db, **prepared["create_args"])
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
return _error_response(
|
||||
str(exc),
|
||||
exc.status_code,
|
||||
code=exc.code,
|
||||
required_mode=exc.required_mode,
|
||||
)
|
||||
|
||||
event_payload = {
|
||||
"request_id": created["id"],
|
||||
@@ -536,30 +562,92 @@ def register_request_routes(
|
||||
"Request created #%s for '%s' by %s",
|
||||
created["id"],
|
||||
event_payload["title"],
|
||||
actor_label,
|
||||
prepared["actor_label"],
|
||||
)
|
||||
_emit_request_event(
|
||||
emit_ws_event(
|
||||
ws_manager,
|
||||
event_name="new_request",
|
||||
payload=event_payload,
|
||||
room="admins",
|
||||
)
|
||||
_emit_request_event(
|
||||
emit_ws_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{db_user_id}",
|
||||
room=f"user_{created['user_id']}",
|
||||
)
|
||||
|
||||
_notify_admin_for_request_event(
|
||||
user_db,
|
||||
event=NotificationEvent.REQUEST_CREATED,
|
||||
request_row=created,
|
||||
fallback_username=actor_username,
|
||||
)
|
||||
|
||||
return jsonify(created), 201
|
||||
|
||||
@app.route("/api/requests/batch", methods=["POST"])
|
||||
def api_create_requests_batch():
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
raw_requests = data.get("requests")
|
||||
if not isinstance(raw_requests, list) or len(raw_requests) == 0:
|
||||
return jsonify({"error": "requests must contain at least one request"}), 400
|
||||
|
||||
try:
|
||||
prepared_requests = [
|
||||
_prepare_request_create_arguments(user_db, raw_request)
|
||||
for raw_request in raw_requests
|
||||
]
|
||||
created_rows = create_requests(
|
||||
user_db,
|
||||
requests=[prepared["create_args"] for prepared in prepared_requests],
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(
|
||||
str(exc),
|
||||
exc.status_code,
|
||||
code=exc.code,
|
||||
required_mode=exc.required_mode,
|
||||
)
|
||||
|
||||
for created, prepared in zip(created_rows, prepared_requests):
|
||||
event_payload = {
|
||||
"request_id": created["id"],
|
||||
"status": created["status"],
|
||||
"title": _resolve_request_title(created),
|
||||
}
|
||||
logger.info(
|
||||
"Request created #%s for '%s' by %s",
|
||||
created["id"],
|
||||
event_payload["title"],
|
||||
prepared["actor_label"],
|
||||
)
|
||||
emit_ws_event(
|
||||
ws_manager,
|
||||
event_name="new_request",
|
||||
payload=event_payload,
|
||||
room="admins",
|
||||
)
|
||||
emit_ws_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{created['user_id']}",
|
||||
)
|
||||
_notify_admin_for_request_event(
|
||||
user_db,
|
||||
event=NotificationEvent.REQUEST_CREATED,
|
||||
request_row=created,
|
||||
)
|
||||
|
||||
return jsonify(created_rows), 201
|
||||
|
||||
@app.route("/api/requests", methods=["GET"])
|
||||
def api_list_requests():
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
@@ -604,27 +692,25 @@ def register_request_routes(
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
|
||||
_record_terminal_request_snapshot(activity_service, request_row=updated)
|
||||
|
||||
event_payload = {
|
||||
"request_id": updated["id"],
|
||||
"status": updated["status"],
|
||||
"title": _resolve_request_title(updated),
|
||||
}
|
||||
actor_label = _format_user_label(_normalize_optional_text(session.get("user_id")), db_user_id)
|
||||
actor_label = _format_user_label(normalize_optional_text(session.get("user_id")), db_user_id)
|
||||
logger.info(
|
||||
"Request cancelled #%s for '%s' by %s",
|
||||
updated["id"],
|
||||
event_payload["title"],
|
||||
actor_label,
|
||||
)
|
||||
_emit_request_event(
|
||||
emit_ws_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{db_user_id}",
|
||||
)
|
||||
_emit_request_event(
|
||||
emit_ws_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
@@ -650,13 +736,7 @@ def register_request_routes(
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
user_cache: dict[int, str] = {}
|
||||
for row in rows:
|
||||
requester_id = row["user_id"]
|
||||
if requester_id not in user_cache:
|
||||
requester = user_db.get_user(user_id=requester_id)
|
||||
user_cache[requester_id] = requester.get("username", "") if requester else ""
|
||||
row["username"] = user_cache[requester_id]
|
||||
populate_request_usernames(rows, user_db)
|
||||
|
||||
return jsonify(rows)
|
||||
|
||||
@@ -670,11 +750,11 @@ def register_request_routes(
|
||||
|
||||
by_status = {
|
||||
status: len(user_db.list_requests(status=status))
|
||||
for status in ("pending", "fulfilled", "rejected", "cancelled")
|
||||
for status in RequestStatus
|
||||
}
|
||||
return jsonify(
|
||||
{
|
||||
"pending": by_status["pending"],
|
||||
"pending": by_status[RequestStatus.PENDING],
|
||||
"total": sum(by_status.values()),
|
||||
"by_status": by_status,
|
||||
}
|
||||
@@ -685,16 +765,10 @@ def register_request_routes(
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
if not session.get("is_admin", False):
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
|
||||
raw_admin_id = session.get("db_user_id")
|
||||
if raw_admin_id is None:
|
||||
return jsonify({"error": "Admin user identity unavailable"}), 403
|
||||
try:
|
||||
admin_user_id = int(raw_admin_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Admin user identity unavailable"}), 403
|
||||
admin_user_id, admin_gate = _require_admin_user_id()
|
||||
if admin_gate is not None:
|
||||
return admin_gate
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not isinstance(data, dict):
|
||||
@@ -708,6 +782,7 @@ def register_request_routes(
|
||||
queue_release=queue_release,
|
||||
release_data=data.get("release_data"),
|
||||
admin_note=data.get("admin_note"),
|
||||
manual_approval=data.get("manual_approval", False),
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
@@ -717,11 +792,8 @@ def register_request_routes(
|
||||
"status": updated["status"],
|
||||
"title": _resolve_request_title(updated),
|
||||
}
|
||||
admin_label = _format_user_label(_normalize_optional_text(session.get("user_id")), admin_user_id)
|
||||
requester_label = _format_user_label(
|
||||
_resolve_request_username(user_db, request_row=updated),
|
||||
_resolve_request_user_id(updated),
|
||||
)
|
||||
admin_label = _format_user_label(normalize_optional_text(session.get("user_id")), admin_user_id)
|
||||
requester_label = _format_requester_label(user_db, updated)
|
||||
logger.info(
|
||||
"Request fulfilled #%s for '%s' by %s (requested by %s)",
|
||||
updated["id"],
|
||||
@@ -729,13 +801,13 @@ def register_request_routes(
|
||||
admin_label,
|
||||
requester_label,
|
||||
)
|
||||
_emit_request_event(
|
||||
emit_ws_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{updated['user_id']}",
|
||||
)
|
||||
_emit_request_event(
|
||||
emit_ws_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
@@ -755,16 +827,10 @@ def register_request_routes(
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
if not session.get("is_admin", False):
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
|
||||
raw_admin_id = session.get("db_user_id")
|
||||
if raw_admin_id is None:
|
||||
return jsonify({"error": "Admin user identity unavailable"}), 403
|
||||
try:
|
||||
admin_user_id = int(raw_admin_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Admin user identity unavailable"}), 403
|
||||
admin_user_id, admin_gate = _require_admin_user_id()
|
||||
if admin_gate is not None:
|
||||
return admin_gate
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not isinstance(data, dict):
|
||||
@@ -780,18 +846,13 @@ def register_request_routes(
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
|
||||
_record_terminal_request_snapshot(activity_service, request_row=updated)
|
||||
|
||||
event_payload = {
|
||||
"request_id": updated["id"],
|
||||
"status": updated["status"],
|
||||
"title": _resolve_request_title(updated),
|
||||
}
|
||||
admin_label = _format_user_label(_normalize_optional_text(session.get("user_id")), admin_user_id)
|
||||
requester_label = _format_user_label(
|
||||
_resolve_request_username(user_db, request_row=updated),
|
||||
_resolve_request_user_id(updated),
|
||||
)
|
||||
admin_label = _format_user_label(normalize_optional_text(session.get("user_id")), admin_user_id)
|
||||
requester_label = _format_requester_label(user_db, updated)
|
||||
logger.info(
|
||||
"Request rejected #%s for '%s' by %s (requested by %s)",
|
||||
updated["id"],
|
||||
@@ -799,13 +860,13 @@ def register_request_routes(
|
||||
admin_label,
|
||||
requester_label,
|
||||
)
|
||||
_emit_request_event(
|
||||
emit_ws_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{updated['user_id']}",
|
||||
)
|
||||
_emit_request_event(
|
||||
emit_ws_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Shared request validation and normalization helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.models import QueueStatus
|
||||
from shelfmark.core.request_policy import parse_policy_mode
|
||||
|
||||
|
||||
class RequestStatus(str, Enum):
|
||||
"""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: Any) -> str:
|
||||
"""Validate and normalize request status values."""
|
||||
if not isinstance(status, str):
|
||||
raise ValueError(f"Invalid request status: {status}")
|
||||
normalized = status.strip().lower()
|
||||
if normalized not in VALID_REQUEST_STATUSES:
|
||||
raise ValueError(f"Invalid request status: {status}")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_policy_mode(mode: Any) -> str:
|
||||
"""Validate and normalize policy mode values."""
|
||||
parsed = parse_policy_mode(mode)
|
||||
if parsed is None:
|
||||
raise ValueError(f"Invalid policy_mode: {mode}")
|
||||
return parsed.value
|
||||
|
||||
|
||||
def normalize_request_level(request_level: Any) -> str:
|
||||
"""Validate and normalize request level values."""
|
||||
if not isinstance(request_level, str):
|
||||
raise ValueError(f"Invalid request_level: {request_level}")
|
||||
normalized = request_level.strip().lower()
|
||||
if normalized not in VALID_REQUEST_LEVELS:
|
||||
raise ValueError(f"Invalid request_level: {request_level}")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_delivery_state(state: Any) -> str:
|
||||
"""Validate and normalize delivery-state values."""
|
||||
if not isinstance(state, str):
|
||||
raise ValueError(f"Invalid delivery_state: {state}")
|
||||
normalized = state.strip().lower()
|
||||
if normalized not in VALID_DELIVERY_STATES:
|
||||
raise ValueError(f"Invalid delivery_state: {state}")
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_request_level_payload(request_level: Any, release_data: Any) -> 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:
|
||||
raise ValueError("request_level=release requires non-null release_data")
|
||||
if normalized_level == "book" and release_data is not None:
|
||||
raise ValueError("request_level=book requires null release_data")
|
||||
return normalized_level
|
||||
|
||||
|
||||
def validate_status_transition(current_status: Any, new_status: Any) -> 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:
|
||||
raise ValueError("Terminal request statuses are immutable")
|
||||
return current, new
|
||||
+259
-233
@@ -6,25 +6,20 @@ from datetime import datetime, timezone
|
||||
import json
|
||||
from typing import Any, Callable, TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.request_policy import normalize_content_type, parse_policy_mode
|
||||
|
||||
|
||||
VALID_REQUEST_STATUSES = frozenset({"pending", "fulfilled", "rejected", "cancelled"})
|
||||
TERMINAL_REQUEST_STATUSES = frozenset({"fulfilled", "rejected", "cancelled"})
|
||||
VALID_REQUEST_LEVELS = frozenset({"book", "release"})
|
||||
VALID_DELIVERY_STATES = frozenset(
|
||||
{
|
||||
"none",
|
||||
"unknown",
|
||||
"queued",
|
||||
"resolving",
|
||||
"locating",
|
||||
"downloading",
|
||||
"complete",
|
||||
"error",
|
||||
"cancelled",
|
||||
}
|
||||
from shelfmark.core.request_policy import normalize_content_type
|
||||
from shelfmark.core.models import QueueStatus
|
||||
from shelfmark.core.request_validation import (
|
||||
DELIVERY_STATE_NONE,
|
||||
RequestStatus,
|
||||
normalize_policy_mode,
|
||||
normalize_request_level,
|
||||
normalize_request_status,
|
||||
validate_request_level_payload,
|
||||
validate_status_transition,
|
||||
)
|
||||
from shelfmark.core.request_helpers import extract_release_source_id, normalize_positive_int
|
||||
|
||||
|
||||
MAX_REQUEST_NOTE_LENGTH = 1000
|
||||
MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024
|
||||
|
||||
@@ -42,67 +37,12 @@ class RequestServiceError(ValueError):
|
||||
*,
|
||||
status_code: int = 400,
|
||||
code: str | None = None,
|
||||
required_mode: str | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
|
||||
|
||||
def normalize_request_status(status: Any) -> str:
|
||||
"""Validate and normalize request status values."""
|
||||
if not isinstance(status, str):
|
||||
raise ValueError(f"Invalid request status: {status}")
|
||||
normalized = status.strip().lower()
|
||||
if normalized not in VALID_REQUEST_STATUSES:
|
||||
raise ValueError(f"Invalid request status: {status}")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_policy_mode(mode: Any) -> str:
|
||||
"""Validate and normalize policy mode values."""
|
||||
parsed = parse_policy_mode(mode)
|
||||
if parsed is None:
|
||||
raise ValueError(f"Invalid policy_mode: {mode}")
|
||||
return parsed.value
|
||||
|
||||
|
||||
def normalize_request_level(request_level: Any) -> str:
|
||||
"""Validate and normalize request level values."""
|
||||
if not isinstance(request_level, str):
|
||||
raise ValueError(f"Invalid request_level: {request_level}")
|
||||
normalized = request_level.strip().lower()
|
||||
if normalized not in VALID_REQUEST_LEVELS:
|
||||
raise ValueError(f"Invalid request_level: {request_level}")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_delivery_state(state: Any) -> str:
|
||||
"""Validate and normalize delivery-state values."""
|
||||
if not isinstance(state, str):
|
||||
raise ValueError(f"Invalid delivery_state: {state}")
|
||||
normalized = state.strip().lower()
|
||||
if normalized not in VALID_DELIVERY_STATES:
|
||||
raise ValueError(f"Invalid delivery_state: {state}")
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_request_level_payload(request_level: Any, release_data: Any) -> 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:
|
||||
raise ValueError("request_level=release requires non-null release_data")
|
||||
if normalized_level == "book" and release_data is not None:
|
||||
raise ValueError("request_level=book requires null release_data")
|
||||
return normalized_level
|
||||
|
||||
|
||||
def validate_status_transition(current_status: Any, new_status: Any) -> 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:
|
||||
raise ValueError("Terminal request statuses are immutable")
|
||||
return current, new
|
||||
self.required_mode = required_mode
|
||||
|
||||
|
||||
def _normalize_match_text(value: Any) -> str:
|
||||
@@ -166,7 +106,7 @@ def _find_duplicate_pending_request(
|
||||
author: str,
|
||||
content_type: str,
|
||||
) -> dict[str, Any] | None:
|
||||
pending_rows = user_db.list_requests(user_id=user_id, status="pending")
|
||||
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):
|
||||
@@ -186,22 +126,51 @@ def _now_timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _extract_release_source_id(release_data: Any) -> str | None:
|
||||
if not isinstance(release_data, dict):
|
||||
def _normalize_admin_note(admin_note: Any) -> str | None:
|
||||
if admin_note is None:
|
||||
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
|
||||
if not isinstance(admin_note, str):
|
||||
raise RequestServiceError("admin_note must be a string", status_code=400)
|
||||
return admin_note.strip() or None
|
||||
|
||||
|
||||
def _existing_delivery_state(request_row: dict[str, Any]) -> str:
|
||||
raw_state = request_row.get("delivery_state")
|
||||
if not isinstance(raw_state, str):
|
||||
return "none"
|
||||
normalized = raw_state.strip().lower()
|
||||
return normalized if normalized in VALID_DELIVERY_STATES else "none"
|
||||
def _prepare_request_create(
|
||||
*,
|
||||
user_id: int,
|
||||
source_hint: str | None,
|
||||
content_type: Any,
|
||||
request_level: Any,
|
||||
policy_mode: Any,
|
||||
book_data: Any,
|
||||
release_data: Any = None,
|
||||
note: Any = 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 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(
|
||||
@@ -211,30 +180,48 @@ def sync_delivery_states_from_queue_status(
|
||||
user_id: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Persist delivery-state transitions for fulfilled requests based on queue status."""
|
||||
source_delivery_states: dict[str, str] = {}
|
||||
for status_key in ("queued", "resolving", "locating", "downloading", "complete", "error", "cancelled"):
|
||||
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] = {}
|
||||
for status_key in QueueStatus:
|
||||
status_bucket = queue_status.get(status_key)
|
||||
if not isinstance(status_bucket, dict):
|
||||
continue
|
||||
for source_id in status_bucket:
|
||||
source_delivery_states[source_id] = status_key
|
||||
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 not source_delivery_states:
|
||||
if not request_delivery_states:
|
||||
return []
|
||||
|
||||
fulfilled_rows = user_db.list_requests(user_id=user_id, status="fulfilled")
|
||||
updated: list[dict[str, Any]] = []
|
||||
|
||||
for row in fulfilled_rows:
|
||||
source_id = _extract_release_source_id(row.get("release_data"))
|
||||
if source_id is None:
|
||||
continue
|
||||
|
||||
delivery_state = source_delivery_states.get(source_id)
|
||||
delivery_state = request_delivery_states.get(int(row["id"]))
|
||||
if delivery_state is None:
|
||||
continue
|
||||
|
||||
if _existing_delivery_state(row) == delivery_state:
|
||||
if row.get("delivery_state", DELIVERY_STATE_NONE) == delivery_state:
|
||||
continue
|
||||
|
||||
updated.append(
|
||||
@@ -262,21 +249,16 @@ def create_request(
|
||||
max_pending_per_user: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a pending request after service-level validation."""
|
||||
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")
|
||||
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,
|
||||
)
|
||||
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 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)
|
||||
|
||||
if max_pending_per_user is not None:
|
||||
pending_count = user_db.count_user_pending_requests(user_id)
|
||||
@@ -290,9 +272,9 @@ def create_request(
|
||||
duplicate = _find_duplicate_pending_request(
|
||||
user_db,
|
||||
user_id=user_id,
|
||||
title=_normalize_match_text(validated_book_data.get("title")),
|
||||
author=_normalize_match_text(validated_book_data.get("author")),
|
||||
content_type=normalized_content_type,
|
||||
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:
|
||||
raise RequestServiceError(
|
||||
@@ -302,16 +284,85 @@ def create_request(
|
||||
)
|
||||
|
||||
try:
|
||||
return user_db.create_request(
|
||||
return user_db.create_request(**prepared_request)
|
||||
except ValueError 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:
|
||||
raise RequestServiceError("requests must contain at least one request", 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):
|
||||
raise RequestServiceError("requests must contain objects", status_code=400)
|
||||
|
||||
user_id = int(request["user_id"])
|
||||
prepared_request = _prepare_request_create(
|
||||
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,
|
||||
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:
|
||||
raise RequestServiceError(
|
||||
"Duplicate pending request exists for this title/author/content_type",
|
||||
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:
|
||||
raise RequestServiceError(
|
||||
"Maximum pending requests reached for this user",
|
||||
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:
|
||||
raise RequestServiceError(
|
||||
"Duplicate pending request exists for this title/author/content_type",
|
||||
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
|
||||
|
||||
@@ -335,6 +386,15 @@ def ensure_request_access(
|
||||
return request_row
|
||||
|
||||
|
||||
def _require_pending(request_row: dict[str, Any]) -> None:
|
||||
if request_row["status"] != RequestStatus.PENDING:
|
||||
raise RequestServiceError(
|
||||
"Request is already in a terminal state",
|
||||
status_code=409,
|
||||
code="stale_transition",
|
||||
)
|
||||
|
||||
|
||||
def cancel_request(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
@@ -348,18 +408,13 @@ def cancel_request(
|
||||
actor_user_id=actor_user_id,
|
||||
is_admin=False,
|
||||
)
|
||||
if request_row["status"] != "pending":
|
||||
raise RequestServiceError(
|
||||
"Request is already in a terminal state",
|
||||
status_code=409,
|
||||
code="stale_transition",
|
||||
)
|
||||
_require_pending(request_row)
|
||||
|
||||
try:
|
||||
return user_db.update_request(
|
||||
request_id,
|
||||
expected_current_status="pending",
|
||||
status="cancelled",
|
||||
expected_current_status=RequestStatus.PENDING,
|
||||
status=RequestStatus.CANCELLED,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
@@ -379,24 +434,15 @@ def reject_request(
|
||||
actor_user_id=admin_user_id,
|
||||
is_admin=True,
|
||||
)
|
||||
if request_row["status"] != "pending":
|
||||
raise RequestServiceError(
|
||||
"Request is already in a terminal state",
|
||||
status_code=409,
|
||||
code="stale_transition",
|
||||
)
|
||||
_require_pending(request_row)
|
||||
|
||||
normalized_admin_note = None
|
||||
if admin_note is not None:
|
||||
if not isinstance(admin_note, str):
|
||||
raise RequestServiceError("admin_note must be a string", status_code=400)
|
||||
normalized_admin_note = admin_note.strip() or None
|
||||
normalized_admin_note = _normalize_admin_note(admin_note)
|
||||
|
||||
try:
|
||||
return user_db.update_request(
|
||||
request_id,
|
||||
expected_current_status="pending",
|
||||
status="rejected",
|
||||
expected_current_status=RequestStatus.PENDING,
|
||||
status=RequestStatus.REJECTED,
|
||||
admin_note=normalized_admin_note,
|
||||
reviewed_by=admin_user_id,
|
||||
reviewed_at=_now_timestamp(),
|
||||
@@ -413,6 +459,7 @@ def fulfil_request(
|
||||
queue_release: Callable[..., tuple[bool, str | None]],
|
||||
release_data: Any = None,
|
||||
admin_note: Any = None,
|
||||
manual_approval: Any = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Fulfil a pending request and queue the release under requesting-user identity."""
|
||||
request_row = ensure_request_access(
|
||||
@@ -421,31 +468,37 @@ def fulfil_request(
|
||||
actor_user_id=admin_user_id,
|
||||
is_admin=True,
|
||||
)
|
||||
if request_row["status"] != "pending":
|
||||
raise RequestServiceError(
|
||||
"Request is already in a terminal state",
|
||||
status_code=409,
|
||||
code="stale_transition",
|
||||
)
|
||||
_require_pending(request_row)
|
||||
|
||||
normalized_admin_note = None
|
||||
if admin_note is not None:
|
||||
if not isinstance(admin_note, str):
|
||||
raise RequestServiceError("admin_note must be a string", status_code=400)
|
||||
normalized_admin_note = admin_note.strip() or None
|
||||
normalized_admin_note = _normalize_admin_note(admin_note)
|
||||
|
||||
if not isinstance(manual_approval, bool):
|
||||
raise RequestServiceError("manual_approval must be a boolean", 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):
|
||||
raise RequestServiceError("release_data must be an object", status_code=400)
|
||||
|
||||
if request_row["request_level"] == "book" and selected_release_data is None:
|
||||
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 ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
if selected_release_data is None:
|
||||
raise RequestServiceError(
|
||||
"release_data is required to fulfil book-level requests",
|
||||
status_code=400,
|
||||
)
|
||||
if request_row["request_level"] == "release" and selected_release_data is None:
|
||||
raise RequestServiceError(
|
||||
"release_data is required to fulfil release-level requests",
|
||||
"release_data is required to fulfil requests",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -455,29 +508,14 @@ def fulfil_request(
|
||||
if requester is None:
|
||||
raise RequestServiceError("Requesting user not found", status_code=404)
|
||||
|
||||
queued_release_data = dict(selected_release_data)
|
||||
queued_release_data["_request_id"] = request_id
|
||||
|
||||
success, error = queue_release(
|
||||
queued_release_data,
|
||||
0,
|
||||
user_id=request_row["user_id"],
|
||||
username=requester.get("username"),
|
||||
)
|
||||
if not success:
|
||||
raise RequestServiceError(
|
||||
error or "Failed to queue release",
|
||||
status_code=409,
|
||||
code="queue_failed",
|
||||
)
|
||||
|
||||
original_release_data = request_row.get("release_data")
|
||||
try:
|
||||
return user_db.update_request(
|
||||
claimed_request = user_db.update_request(
|
||||
request_id,
|
||||
expected_current_status="pending",
|
||||
status="fulfilled",
|
||||
expected_current_status=RequestStatus.PENDING,
|
||||
status=RequestStatus.FULFILLED,
|
||||
release_data=selected_release_data,
|
||||
delivery_state="queued",
|
||||
delivery_state=QueueStatus.QUEUED,
|
||||
delivery_updated_at=_now_timestamp(),
|
||||
last_failure_reason=None,
|
||||
admin_note=normalized_admin_note,
|
||||
@@ -487,6 +525,37 @@ def fulfil_request(
|
||||
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",
|
||||
@@ -495,50 +564,7 @@ def reopen_failed_request(
|
||||
failure_reason: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Reopen a failed fulfilled request so admins can re-approve with a new release."""
|
||||
normalized_failure_reason = None
|
||||
if isinstance(failure_reason, str):
|
||||
normalized_failure_reason = failure_reason.strip() or None
|
||||
|
||||
with user_db._lock:
|
||||
conn = user_db._connect()
|
||||
try:
|
||||
current_row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
current_request = user_db._parse_request_row(current_row)
|
||||
if current_request is None:
|
||||
return None
|
||||
|
||||
if current_request.get("status") != "fulfilled":
|
||||
return None
|
||||
current_delivery_state = _existing_delivery_state(current_request)
|
||||
# Terminal hook callbacks can run before delivery-state sync persists "error".
|
||||
# Allow reopening fulfilled requests unless they are already complete.
|
||||
if current_delivery_state == "complete":
|
||||
return None
|
||||
if current_delivery_state not in {"error", "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 user_db._parse_request_row(updated_row)
|
||||
finally:
|
||||
conn.close()
|
||||
return user_db.reopen_failed_request(
|
||||
request_id,
|
||||
failure_reason=failure_reason,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import List, Optional
|
||||
MANUAL_QUERY_MAX_LEN = 256
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.models import SearchFilters
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
group_languages_by_localized_title,
|
||||
@@ -37,6 +38,7 @@ class ReleaseSearchPlan:
|
||||
grouped_title_variants: List[ReleaseSearchVariant]
|
||||
manual_query: Optional[str] = None
|
||||
indexers: Optional[List[str]] = None # Indexer names for Prowlarr (overrides settings)
|
||||
source_filters: Optional[SearchFilters] = None
|
||||
|
||||
@property
|
||||
def primary_query(self) -> str:
|
||||
@@ -88,6 +90,7 @@ def build_release_search_plan(
|
||||
languages: Optional[List[str]] = None,
|
||||
manual_query: Optional[str] = None,
|
||||
indexers: Optional[List[str]] = None,
|
||||
source_filters: Optional[SearchFilters] = None,
|
||||
) -> ReleaseSearchPlan:
|
||||
resolved_languages = _normalize_languages(languages)
|
||||
|
||||
@@ -109,6 +112,7 @@ def build_release_search_plan(
|
||||
grouped_title_variants=[variant],
|
||||
manual_query=resolved_manual_query,
|
||||
indexers=indexers,
|
||||
source_filters=source_filters,
|
||||
)
|
||||
|
||||
isbn_candidates: List[str] = []
|
||||
@@ -165,4 +169,5 @@ def build_release_search_plan(
|
||||
grouped_title_variants=grouped_variants,
|
||||
manual_query=None,
|
||||
indexers=indexers,
|
||||
source_filters=source_filters,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from flask import Flask, jsonify, request, session
|
||||
from flask import Flask, g, jsonify, request, session
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shelfmark.config.env import CWA_DB_PATH
|
||||
@@ -16,8 +16,8 @@ from shelfmark.core.auth_modes import (
|
||||
AUTH_SOURCE_CWA,
|
||||
AUTH_SOURCE_OIDC,
|
||||
AUTH_SOURCE_PROXY,
|
||||
determine_auth_mode,
|
||||
has_local_password_admin,
|
||||
is_user_active_for_auth_mode,
|
||||
load_active_auth_mode,
|
||||
normalize_auth_source,
|
||||
)
|
||||
from shelfmark.core.logger import setup_logger
|
||||
@@ -33,42 +33,16 @@ 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)
|
||||
|
||||
|
||||
def _get_auth_mode() -> str:
|
||||
"""Get current auth mode from config."""
|
||||
try:
|
||||
config = load_config_file("security")
|
||||
return determine_auth_mode(
|
||||
config,
|
||||
CWA_DB_PATH,
|
||||
has_local_admin=has_local_password_admin(),
|
||||
)
|
||||
except Exception:
|
||||
return "none"
|
||||
|
||||
|
||||
def _require_authenticated_user(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Decorator requiring an authenticated session linked to a local user row."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_mode = _get_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
|
||||
|
||||
|
||||
def _get_current_user(user_db: UserDB) -> tuple[int | None, dict[str, Any] | None, tuple[Any, int] | None]:
|
||||
raw_user_id = session.get("db_user_id")
|
||||
try:
|
||||
@@ -82,13 +56,6 @@ def _get_current_user(user_db: UserDB) -> tuple[int | None, dict[str, Any] | Non
|
||||
return user_id, user, None
|
||||
|
||||
|
||||
def _is_user_active(user: Mapping[str, Any], auth_method: str) -> bool:
|
||||
source = normalize_auth_source(user.get("auth_source"), user.get("oidc_subject"))
|
||||
if source == AUTH_SOURCE_BUILTIN:
|
||||
return auth_method in (AUTH_SOURCE_BUILTIN, AUTH_SOURCE_OIDC)
|
||||
return source == auth_method
|
||||
|
||||
|
||||
def _get_self_edit_capabilities(user: Mapping[str, Any]) -> dict[str, Any]:
|
||||
auth_source = normalize_auth_source(
|
||||
user.get("auth_source"),
|
||||
@@ -111,7 +78,7 @@ def _serialize_self_user(user: Mapping[str, Any], auth_mode: str) -> dict[str, A
|
||||
payload.get("auth_source"),
|
||||
payload.get("oidc_subject"),
|
||||
)
|
||||
payload["is_active"] = _is_user_active(payload, auth_mode)
|
||||
payload["is_active"] = is_user_active_for_auth_mode(payload, auth_mode)
|
||||
payload["edit_capabilities"] = _get_self_edit_capabilities(payload)
|
||||
return payload
|
||||
|
||||
@@ -155,6 +122,11 @@ def _get_allowed_self_settings_keys(visible_sections: list[str]) -> set[str]:
|
||||
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")
|
||||
@@ -166,6 +138,22 @@ def _get_allowed_self_settings_keys(visible_sections: list[str]) -> set[str]:
|
||||
def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
"""Register self-service user endpoints."""
|
||||
|
||||
def _require_authenticated_user(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Decorator requiring 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, **kwargs):
|
||||
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():
|
||||
@@ -173,8 +161,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
if user_error:
|
||||
return user_error
|
||||
|
||||
auth_mode = _get_auth_mode()
|
||||
serialized_user = _serialize_self_user(user, auth_mode)
|
||||
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()
|
||||
|
||||
@@ -188,6 +175,16 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
logger.warning(f"Failed to build user delivery preferences for user_id={user_id}: {exc}")
|
||||
delivery_preferences = None
|
||||
|
||||
search_preferences = None
|
||||
if _SELF_SETTINGS_SECTION_SEARCH in visible_self_settings_sections:
|
||||
try:
|
||||
search_preferences = _build_user_preferences_payload(user_db, user_id, "search_mode")
|
||||
except ValueError:
|
||||
return jsonify({"error": "Search mode settings tab not found"}), 500
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to build user search preferences for user_id={user_id}: {exc}")
|
||||
search_preferences = None
|
||||
|
||||
notification_preferences = None
|
||||
if _SELF_SETTINGS_SECTION_NOTIFICATIONS in visible_self_settings_sections:
|
||||
try:
|
||||
@@ -200,6 +197,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
|
||||
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 [])
|
||||
)
|
||||
|
||||
@@ -207,6 +205,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
{
|
||||
"user": serialized_user,
|
||||
"deliveryPreferences": delivery_preferences,
|
||||
"searchPreferences": search_preferences,
|
||||
"notificationPreferences": notification_preferences,
|
||||
"userOverridableKeys": user_overridable_keys,
|
||||
"visibleUserSettingsSections": visible_self_settings_sections,
|
||||
@@ -340,7 +339,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
try:
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
app_config.refresh()
|
||||
app_config.refresh(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -348,7 +347,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
if not updated:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
result = _serialize_self_user(updated, _get_auth_mode())
|
||||
result = _serialize_self_user(updated, g.auth_mode)
|
||||
result["settings"] = user_db.get_user_settings(user_id)
|
||||
logger.info(f"User {user_id} updated their own account")
|
||||
return jsonify(result)
|
||||
|
||||
@@ -348,7 +348,11 @@ def _get_config_file_path(tab_name: str) -> Path:
|
||||
# Core settings tabs share the main settings.json file
|
||||
if tab_name in ("general", "search_mode"):
|
||||
return config_dir / "settings.json"
|
||||
return config_dir / "plugins" / f"{tab_name}.json"
|
||||
# Sanitize tab_name to prevent path traversal
|
||||
safe_name = Path(tab_name).name
|
||||
if not safe_name or safe_name != tab_name:
|
||||
raise ValueError(f"Invalid tab name: {tab_name}")
|
||||
return config_dir / "plugins" / f"{safe_name}.json"
|
||||
|
||||
|
||||
def _ensure_config_dir(tab_name: str) -> None:
|
||||
@@ -481,27 +485,26 @@ def sync_env_to_config() -> None:
|
||||
logger.debug(f"Synced {len(values_to_sync)} ENV values to {tab.name} config: {list(values_to_sync.keys())}")
|
||||
|
||||
migrate_legacy_settings()
|
||||
migrate_download_to_browser_settings()
|
||||
migrate_mirror_settings()
|
||||
|
||||
|
||||
def migrate_mirror_settings() -> None:
|
||||
"""
|
||||
Migrate legacy AA mirror config into the new editable mirror list setting.
|
||||
Sync AA mirror list when code defaults change between versions.
|
||||
|
||||
Legacy:
|
||||
- AA_ADDITIONAL_URLS: comma-separated extra URLs appended to defaults
|
||||
On startup, compares a hash of DEFAULT_AA_MIRRORS against the hash stored
|
||||
in the config file. If they differ (i.e., an update shipped new defaults),
|
||||
the config is overwritten with the new defaults. If they match, the user's
|
||||
customizations are left untouched.
|
||||
|
||||
New:
|
||||
- AA_MIRROR_URLS: full ordered list of available mirrors (used for Auto mode and for Settings options)
|
||||
Also handles legacy migration from AA_ADDITIONAL_URLS.
|
||||
"""
|
||||
mirrors_config = load_config_file("mirrors")
|
||||
import hashlib
|
||||
|
||||
from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
|
||||
raw_list = mirrors_config.get("AA_MIRROR_URLS")
|
||||
raw_additional = mirrors_config.get("AA_ADDITIONAL_URLS", "")
|
||||
|
||||
def _normalize_list(values: list[str]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for item in values:
|
||||
@@ -512,12 +515,42 @@ def migrate_mirror_settings() -> None:
|
||||
out.append(norm)
|
||||
return out
|
||||
|
||||
def _hash_mirrors(mirrors: list[str]) -> str:
|
||||
return hashlib.sha256(",".join(mirrors).encode()).hexdigest()
|
||||
|
||||
normalized_defaults = _normalize_list(DEFAULT_AA_MIRRORS)
|
||||
current_defaults_hash = _hash_mirrors(normalized_defaults)
|
||||
|
||||
mirrors_config = load_config_file("mirrors")
|
||||
stored_hash = mirrors_config.get("_AA_MIRRORS_DEFAULTS_HASH")
|
||||
raw_list = mirrors_config.get("AA_MIRROR_URLS")
|
||||
raw_additional = mirrors_config.get("AA_ADDITIONAL_URLS", "")
|
||||
|
||||
def _save_mirrors(values: dict[str, Any]) -> None:
|
||||
merged = dict(mirrors_config)
|
||||
merged.update(values)
|
||||
save_config_file("mirrors", merged)
|
||||
mirrors_config.update(values)
|
||||
|
||||
# Defaults changed since last startup — push new mirrors to config
|
||||
if stored_hash != current_defaults_hash:
|
||||
_save_mirrors({
|
||||
"AA_MIRROR_URLS": normalized_defaults,
|
||||
"_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash,
|
||||
})
|
||||
return
|
||||
|
||||
# --- Legacy migration (only runs if hash already matches / first time) ---
|
||||
|
||||
# If already a proper list, just ensure it's non-empty.
|
||||
if isinstance(raw_list, list):
|
||||
normalized = _normalize_list([str(v) for v in raw_list])
|
||||
if normalized:
|
||||
return
|
||||
save_config_file("mirrors", {"AA_MIRROR_URLS": _normalize_list(DEFAULT_AA_MIRRORS)})
|
||||
_save_mirrors({
|
||||
"AA_MIRROR_URLS": normalized_defaults,
|
||||
"_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash,
|
||||
})
|
||||
return
|
||||
|
||||
# If saved as a string, convert to list.
|
||||
@@ -525,17 +558,33 @@ def migrate_mirror_settings() -> None:
|
||||
parts = [p.strip() for p in raw_list.split(",") if p.strip()]
|
||||
normalized = _normalize_list(parts)
|
||||
if normalized:
|
||||
save_config_file("mirrors", {"AA_MIRROR_URLS": normalized})
|
||||
_save_mirrors({
|
||||
"AA_MIRROR_URLS": normalized,
|
||||
"_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash,
|
||||
})
|
||||
return
|
||||
save_config_file("mirrors", {"AA_MIRROR_URLS": _normalize_list(DEFAULT_AA_MIRRORS)})
|
||||
_save_mirrors({
|
||||
"AA_MIRROR_URLS": normalized_defaults,
|
||||
"_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash,
|
||||
})
|
||||
return
|
||||
|
||||
# If there's legacy additional mirrors, seed the full list so the UI reflects reality.
|
||||
# If there's legacy additional mirrors, seed the full list.
|
||||
if isinstance(raw_additional, str) and raw_additional.strip():
|
||||
additional_parts = [p.strip() for p in raw_additional.split(",") if p.strip()]
|
||||
combined = _normalize_list(DEFAULT_AA_MIRRORS + additional_parts)
|
||||
if combined:
|
||||
save_config_file("mirrors", {"AA_MIRROR_URLS": combined})
|
||||
_save_mirrors({
|
||||
"AA_MIRROR_URLS": combined,
|
||||
"_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash,
|
||||
})
|
||||
return
|
||||
|
||||
# No config at all yet — write defaults
|
||||
_save_mirrors({
|
||||
"AA_MIRROR_URLS": normalized_defaults,
|
||||
"_AA_MIRRORS_DEFAULTS_HASH": current_defaults_hash,
|
||||
})
|
||||
|
||||
|
||||
def migrate_legacy_settings() -> None:
|
||||
@@ -647,6 +696,57 @@ def migrate_legacy_settings() -> None:
|
||||
logger.info(f"Migrated content-type routing settings: {list(migrated_sources.keys())}")
|
||||
|
||||
|
||||
def migrate_download_to_browser_settings() -> None:
|
||||
"""Migrate the legacy download-to-browser toggle to content-type selection."""
|
||||
downloads_config = load_config_file("downloads")
|
||||
legacy_key = "DOWNLOAD_TO_BROWSER"
|
||||
new_key = "DOWNLOAD_TO_BROWSER_CONTENT_TYPES"
|
||||
config_path = _get_config_file_path("downloads")
|
||||
|
||||
legacy_value: Any = None
|
||||
legacy_present = False
|
||||
|
||||
if legacy_key in downloads_config:
|
||||
legacy_value = downloads_config.get(legacy_key)
|
||||
legacy_present = True
|
||||
elif new_key not in downloads_config and os.environ.get(new_key) is None and legacy_key in os.environ:
|
||||
legacy_value = os.environ.get(legacy_key)
|
||||
legacy_present = True
|
||||
|
||||
if not legacy_present and legacy_key not in downloads_config:
|
||||
return
|
||||
|
||||
updated_downloads = dict(downloads_config)
|
||||
changed = False
|
||||
|
||||
if new_key not in updated_downloads and legacy_present:
|
||||
enabled = False
|
||||
if isinstance(legacy_value, bool):
|
||||
enabled = legacy_value
|
||||
elif isinstance(legacy_value, str):
|
||||
enabled = legacy_value.strip().lower() in {"true", "1", "yes", "on"}
|
||||
else:
|
||||
enabled = bool(legacy_value)
|
||||
|
||||
updated_downloads[new_key] = ["book", "audiobook"] if enabled else []
|
||||
changed = True
|
||||
|
||||
if legacy_key in updated_downloads:
|
||||
updated_downloads.pop(legacy_key, None)
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
return
|
||||
|
||||
try:
|
||||
_ensure_config_dir("downloads")
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(updated_downloads, f, indent=2)
|
||||
logger.info("Migrated download-to-browser setting to content-type selection")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to migrate download-to-browser settings: {exc}")
|
||||
|
||||
|
||||
def get_setting_value(field: SettingsField, tab_name: str) -> Any:
|
||||
if isinstance(field, (ActionButton, HeadingField, CustomComponentField)):
|
||||
return None # Actions and headings don't have values
|
||||
|
||||
+299
-144
@@ -7,8 +7,13 @@ import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from shelfmark.core.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET
|
||||
from shelfmark.core.activity_view_state_service import user_viewer_scope
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.requests_service import (
|
||||
from shelfmark.core.request_helpers import normalize_optional_positive_int
|
||||
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,
|
||||
@@ -62,38 +67,51 @@ 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 activity_log (
|
||||
CREATE TABLE IF NOT EXISTS download_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_type TEXT NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
task_id TEXT UNIQUE NOT NULL,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
username TEXT,
|
||||
request_id INTEGER,
|
||||
source_id TEXT,
|
||||
origin TEXT NOT NULL,
|
||||
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,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
terminal_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
status_message TEXT,
|
||||
download_path TEXT,
|
||||
queued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
terminal_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_log_user_terminal
|
||||
ON activity_log (user_id, terminal_at DESC);
|
||||
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_activity_log_lookup
|
||||
ON activity_log (user_id, item_type, item_key, id 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_dismissals (
|
||||
CREATE TABLE IF NOT EXISTS activity_view_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
viewer_scope TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
activity_log_id INTEGER REFERENCES activity_log(id) ON DELETE SET NULL,
|
||||
dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, item_type, item_key)
|
||||
dismissed_at TIMESTAMP,
|
||||
cleared_at TIMESTAMP,
|
||||
UNIQUE(viewer_scope, item_type, item_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_dismissals_user_dismissed_at
|
||||
ON activity_dismissals (user_id, dismissed_at DESC);
|
||||
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;
|
||||
"""
|
||||
|
||||
|
||||
@@ -119,6 +137,14 @@ def sync_builtin_admin_user(
|
||||
|
||||
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
|
||||
@@ -163,7 +189,7 @@ class UserDB:
|
||||
conn.executescript(_CREATE_TABLES_SQL)
|
||||
self._migrate_auth_source_column(conn)
|
||||
self._migrate_request_delivery_columns(conn)
|
||||
self._migrate_activity_tables(conn)
|
||||
self._migrate_download_history_queued_at(conn)
|
||||
conn.commit()
|
||||
# WAL mode must be changed outside an open transaction.
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
@@ -203,18 +229,11 @@ class UserDB:
|
||||
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 = 'unknown'
|
||||
WHERE status = 'fulfilled' AND (delivery_state IS NULL OR TRIM(delivery_state) = '' OR delivery_state = 'none')
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE download_requests
|
||||
SET delivery_state = 'none'
|
||||
WHERE status != 'fulfilled' AND (delivery_state IS NULL OR TRIM(delivery_state) = '')
|
||||
WHERE delivery_state IS NULL OR TRIM(delivery_state) = '' OR delivery_state IN ('unknown', 'available', 'done')
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
@@ -224,57 +243,16 @@ class UserDB:
|
||||
WHERE delivery_state != 'none' AND delivery_updated_at IS NULL
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE download_requests
|
||||
SET delivery_state = 'complete'
|
||||
WHERE delivery_state = 'cleared'
|
||||
"""
|
||||
)
|
||||
|
||||
def _migrate_activity_tables(self, conn: sqlite3.Connection) -> None:
|
||||
"""Ensure activity log and dismissal tables exist with current columns/indexes."""
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS activity_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_type TEXT NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
request_id INTEGER,
|
||||
source_id TEXT,
|
||||
origin TEXT NOT NULL,
|
||||
final_status TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
terminal_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_log_user_terminal
|
||||
ON activity_log (user_id, terminal_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_log_lookup
|
||||
ON activity_log (user_id, item_type, item_key, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_dismissals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_type TEXT NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
activity_log_id INTEGER REFERENCES activity_log(id) ON DELETE SET NULL,
|
||||
dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, item_type, item_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_dismissals_user_dismissed_at
|
||||
ON activity_dismissals (user_id, dismissed_at DESC);
|
||||
"""
|
||||
)
|
||||
|
||||
dismissal_columns = conn.execute("PRAGMA table_info(activity_dismissals)").fetchall()
|
||||
dismissal_column_names = {str(col["name"]) for col in dismissal_columns}
|
||||
if "activity_log_id" not in dismissal_column_names:
|
||||
conn.execute("ALTER TABLE activity_dismissals ADD COLUMN activity_log_id INTEGER")
|
||||
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 create_user(
|
||||
self,
|
||||
@@ -380,6 +358,25 @@ class UserDB:
|
||||
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:
|
||||
placeholders = ",".join("?" for _ in request_item_keys)
|
||||
conn.execute(
|
||||
f"""
|
||||
DELETE FROM activity_view_state
|
||||
WHERE item_type = 'request'
|
||||
AND item_key IN ({placeholders})
|
||||
""",
|
||||
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()
|
||||
@@ -395,6 +392,19 @@ class UserDB:
|
||||
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()
|
||||
@@ -460,6 +470,72 @@ class UserDB:
|
||||
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: Optional[Dict[str, Any]] = None,
|
||||
status: str = RequestStatus.PENDING,
|
||||
source_hint: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
admin_note: Optional[str] = None,
|
||||
reviewed_by: Optional[int] = None,
|
||||
reviewed_at: Optional[str] = None,
|
||||
delivery_state: str = DELIVERY_STATE_NONE,
|
||||
delivery_updated_at: Optional[str] = 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:
|
||||
raise ValueError(f"Request {request_id} not found after creation")
|
||||
return parsed
|
||||
|
||||
def create_request(
|
||||
self,
|
||||
*,
|
||||
@@ -469,13 +545,13 @@ class UserDB:
|
||||
policy_mode: str,
|
||||
book_data: Dict[str, Any],
|
||||
release_data: Optional[Dict[str, Any]] = None,
|
||||
status: str = "pending",
|
||||
status: str = RequestStatus.PENDING,
|
||||
source_hint: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
admin_note: Optional[str] = None,
|
||||
reviewed_by: Optional[int] = None,
|
||||
reviewed_at: Optional[str] = None,
|
||||
delivery_state: str = "none",
|
||||
delivery_state: str = DELIVERY_STATE_NONE,
|
||||
delivery_updated_at: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a download request row and return the created record."""
|
||||
@@ -494,53 +570,38 @@ class UserDB:
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
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,
|
||||
normalized_status,
|
||||
normalized_delivery_state,
|
||||
source_hint,
|
||||
content_type,
|
||||
normalized_request_level,
|
||||
normalized_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,
|
||||
),
|
||||
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()
|
||||
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:
|
||||
raise ValueError(f"Request {request_id} not found after creation")
|
||||
return parsed
|
||||
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: List[Dict[str, Any]] = []
|
||||
for request in requests:
|
||||
created.append(self._insert_request(conn, **request))
|
||||
conn.commit()
|
||||
return created
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -680,24 +741,8 @@ class UserDB:
|
||||
if "content_type" in updates and not updates["content_type"]:
|
||||
raise ValueError("content_type is required")
|
||||
|
||||
candidate_request_level = updates.get("request_level", current["request_level"])
|
||||
candidate_release_data = (
|
||||
updates["release_data"] if "release_data" in updates else current["release_data"]
|
||||
)
|
||||
candidate_status = updates.get("status", current["status"])
|
||||
normalized_request_level = normalize_request_level(candidate_request_level)
|
||||
normalized_candidate_status = normalize_request_status(candidate_status)
|
||||
|
||||
if normalized_request_level == "release" and candidate_release_data is None:
|
||||
raise ValueError("request_level=release requires non-null release_data")
|
||||
if (
|
||||
normalized_request_level == "book"
|
||||
and candidate_release_data is not None
|
||||
and normalized_candidate_status != "fulfilled"
|
||||
):
|
||||
raise ValueError("request_level=book requires null release_data")
|
||||
if "request_level" in updates:
|
||||
updates["request_level"] = normalized_request_level
|
||||
updates["request_level"] = normalize_request_level(updates["request_level"])
|
||||
|
||||
if "book_data" in updates:
|
||||
if not isinstance(updates["book_data"], dict):
|
||||
@@ -731,6 +776,116 @@ class UserDB:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def reopen_failed_request(
|
||||
self,
|
||||
request_id: int,
|
||||
*,
|
||||
failure_reason: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""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: Optional[Dict[str, Any]],
|
||||
last_failure_reason: Optional[str] = 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:
|
||||
raise ValueError(f"Request {request_id} not found")
|
||||
|
||||
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:
|
||||
raise ValueError(f"Request {request_id} not found after rollback")
|
||||
return parsed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def count_pending_requests(self) -> int:
|
||||
"""Count all pending requests."""
|
||||
conn = self._connect()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Shared utility functions for the Shelfmark."""
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import os
|
||||
import re
|
||||
from threading import Lock
|
||||
from types import ModuleType
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
@@ -54,6 +57,28 @@ def normalize_http_url(
|
||||
return normalized
|
||||
|
||||
|
||||
_xmlrpc_patch_lock = Lock()
|
||||
_xmlrpc_patch_applied = False
|
||||
|
||||
|
||||
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 Exception:
|
||||
# Keep runtime behavior unchanged if defusedxml is unavailable.
|
||||
_xmlrpc_patch_applied = False
|
||||
|
||||
return importlib.import_module("xmlrpc.client")
|
||||
|
||||
|
||||
def normalize_base_path(value: Optional[str]) -> str:
|
||||
"""Normalize a URL base path for reverse proxy subpath deployments."""
|
||||
if not isinstance(value, str):
|
||||
|
||||
@@ -38,6 +38,8 @@ class DownloadRequest:
|
||||
protocol: str
|
||||
release_name: str
|
||||
expected_hash: Optional[str]
|
||||
seeding_time_limit: Optional[int] = None # minutes
|
||||
ratio_limit: Optional[float] = None
|
||||
|
||||
|
||||
def _diagnose_path_issue(path: str) -> str:
|
||||
@@ -140,20 +142,28 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
return
|
||||
|
||||
client, download_id, protocol = client_ref
|
||||
if protocol != "usenet":
|
||||
return
|
||||
|
||||
# "Move" means copy into ingest then let the usenet client delete its own files.
|
||||
if config.get("PROWLARR_USENET_ACTION", "move") != "move":
|
||||
return
|
||||
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 Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
self._delete_local_download_data(client, download_id)
|
||||
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to cleanup usenet download {download_id} in {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 Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to remove torrent {download_id} from {getattr(client, 'name', 'client')}: {e}"
|
||||
)
|
||||
|
||||
def _remove_usenet_download(
|
||||
self,
|
||||
@@ -553,6 +563,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
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.error(f"Failed to add to {client.name}: {e}")
|
||||
|
||||
@@ -237,6 +237,15 @@ class DelugeClient(DownloadClient):
|
||||
if self._download_dir:
|
||||
options["download_location"] = self._download_dir
|
||||
|
||||
# Per-torrent seeding limits from indexer
|
||||
seeding_time_limit = kwargs.get("seeding_time_limit")
|
||||
if seeding_time_limit is not None:
|
||||
options["seed_time_limit"] = int(seeding_time_limit)
|
||||
ratio_limit = kwargs.get("ratio_limit")
|
||||
if ratio_limit is not None:
|
||||
options["stop_at_ratio"] = float(ratio_limit)
|
||||
options["stop_at_ratio_enabled"] = True
|
||||
|
||||
if torrent_info.is_magnet:
|
||||
magnet_url = torrent_info.magnet_url or url
|
||||
torrent_id = self._rpc_call("core.add_torrent_magnet", magnet_url, options)
|
||||
|
||||
@@ -61,6 +61,23 @@ def _normalize_tags(raw_tags: object) -> list[str]:
|
||||
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."""
|
||||
@@ -291,13 +308,16 @@ class QBittorrentClient(DownloadClient):
|
||||
tags = self._tags
|
||||
|
||||
# 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 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}"
|
||||
)
|
||||
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
expected_hash = torrent_info.info_hash
|
||||
@@ -305,14 +325,23 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
# Add the torrent - use file content if we have it, otherwise URL
|
||||
add_kwargs = {
|
||||
"category": category,
|
||||
"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)
|
||||
|
||||
# Per-torrent seeding limits from indexer
|
||||
seeding_time_limit = kwargs.get("seeding_time_limit")
|
||||
if seeding_time_limit is not None:
|
||||
add_kwargs["seeding_time_limit"] = int(seeding_time_limit)
|
||||
ratio_limit = kwargs.get("ratio_limit")
|
||||
if ratio_limit is not None:
|
||||
add_kwargs["ratio_limit"] = float(ratio_limit)
|
||||
|
||||
if torrent_data:
|
||||
result = self._client.torrents_add(
|
||||
torrent_files=torrent_data,
|
||||
@@ -326,29 +355,32 @@ class QBittorrentClient(DownloadClient):
|
||||
**add_kwargs,
|
||||
)
|
||||
|
||||
logger.debug(f"qBittorrent add result: {result}")
|
||||
result_text = _normalize_add_result(result)
|
||||
logger.debug(f"qBittorrent add result: {result_text}")
|
||||
|
||||
if result == "Ok.":
|
||||
if not expected_hash:
|
||||
raise Exception("Could not determine torrent hash from URL")
|
||||
if not expected_hash:
|
||||
raise Exception("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 Exception(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(f"qBittorrent add_download: {error}")
|
||||
if loaded:
|
||||
logger.info(f"Added torrent: {expected_hash}")
|
||||
return expected_hash.lower()
|
||||
time.sleep(0.5)
|
||||
|
||||
raise Exception(f"Failed to add torrent: {result}")
|
||||
logger.warning(
|
||||
"Torrent add was not confirmed within the visibility grace period "
|
||||
f"(response={result_text or '<empty>'}), returning expected hash"
|
||||
)
|
||||
return expected_hash
|
||||
except Exception as e:
|
||||
logger.error(f"qBittorrent add failed: {e}")
|
||||
raise
|
||||
|
||||
@@ -10,7 +10,7 @@ from urllib.parse import urlparse
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.core.utils import normalize_http_url, get_hardened_xmlrpc_client
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
@@ -26,17 +26,17 @@ logger = setup_logger(__name__)
|
||||
|
||||
def _create_rtorrent_server_proxy(url: str) -> Any:
|
||||
"""Create an XML-RPC ServerProxy honoring certificate validation mode."""
|
||||
from xmlrpc.client import SafeTransport, ServerProxy
|
||||
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 = SafeTransport(context=ssl_context)
|
||||
return ServerProxy(url, transport=transport)
|
||||
transport = xmlrpc_client.SafeTransport(context=ssl_context)
|
||||
return xmlrpc_client.ServerProxy(url, transport=transport)
|
||||
|
||||
return ServerProxy(url)
|
||||
return xmlrpc_client.ServerProxy(url)
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
@@ -120,7 +120,7 @@ class RTorrentClient(DownloadClient):
|
||||
download_dir = self._download_dir or self._get_download_dir()
|
||||
if download_dir:
|
||||
logger.debug(f"Setting rTorrent download directory: {download_dir}")
|
||||
commands.append(f"d.directory_base.set={download_dir}")
|
||||
commands.append(f"d.directory.set={download_dir}")
|
||||
|
||||
if torrent_info.torrent_data:
|
||||
logger.debug(f"Adding torrent data directly to rTorrent for: {name} with commands: {commands} with data size: {len(torrent_info.torrent_data)}")
|
||||
@@ -156,10 +156,9 @@ class RTorrentClient(DownloadClient):
|
||||
try:
|
||||
# rtorrent is somehow case sensitive and requires uppercase hashes for look
|
||||
download_id = download_id.upper()
|
||||
torrent_list = self._rpc.d.multicall.filtered(
|
||||
all_torrents = self._rpc.d.multicall2(
|
||||
"",
|
||||
"",
|
||||
"default",
|
||||
f"equal={{d.hash=,cat={download_id}}}",
|
||||
"d.hash=",
|
||||
"d.state=",
|
||||
"d.completed_bytes=",
|
||||
@@ -169,6 +168,7 @@ class RTorrentClient(DownloadClient):
|
||||
"d.custom1=",
|
||||
"d.complete=",
|
||||
)
|
||||
torrent_list = [t for t in all_torrents if t and t[0] == download_id]
|
||||
logger.debug(f"Fetched torrent status from rTorrent for: {download_id} - {torrent_list}")
|
||||
if not torrent_list:
|
||||
logger.warning(f"Torrent not found in rTorrent: {download_id}")
|
||||
@@ -327,12 +327,13 @@ class RTorrentClient(DownloadClient):
|
||||
try:
|
||||
# rTorrent is case sensitive for hashes; use uppercase as in get_status()
|
||||
download_hash = download_id.upper()
|
||||
details = self._rpc.d.multicall.filtered(
|
||||
all_torrents = self._rpc.d.multicall2(
|
||||
"",
|
||||
"default",
|
||||
f"equal={{d.hash=,cat={download_hash}}}",
|
||||
"",
|
||||
"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]
|
||||
|
||||
@@ -12,7 +12,7 @@ from shelfmark.core.settings_registry import (
|
||||
SelectField,
|
||||
TagListField,
|
||||
)
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.core.utils import normalize_http_url, get_hardened_xmlrpc_client
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
|
||||
@@ -254,7 +254,6 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -
|
||||
from shelfmark.core.config import config
|
||||
import ssl
|
||||
from urllib.parse import urlparse
|
||||
from xmlrpc.client import SafeTransport, ServerProxy
|
||||
|
||||
current_values = current_values or {}
|
||||
|
||||
@@ -270,6 +269,8 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -
|
||||
return {"success": False, "message": "rTorrent URL is invalid"}
|
||||
|
||||
try:
|
||||
xmlrpc_client = get_hardened_xmlrpc_client()
|
||||
|
||||
# Add HTTP auth to URL if credentials provided
|
||||
if username and password:
|
||||
parsed = urlparse(url)
|
||||
@@ -281,9 +282,12 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
rpc = ServerProxy(rpc_url, transport=SafeTransport(context=ssl_context))
|
||||
rpc = xmlrpc_client.ServerProxy(
|
||||
rpc_url,
|
||||
transport=xmlrpc_client.SafeTransport(context=ssl_context),
|
||||
)
|
||||
else:
|
||||
rpc = ServerProxy(rpc_url)
|
||||
rpc = xmlrpc_client.ServerProxy(rpc_url)
|
||||
|
||||
version = rpc.system.client_version()
|
||||
return {"success": True, "message": f"Connected to rTorrent {version}"}
|
||||
@@ -606,7 +610,17 @@ def prowlarr_clients_settings():
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
||||
),
|
||||
# Note: Torrent client download path must be mounted identically in both containers.
|
||||
# Torrents are always copied (not moved) to preserve seeding capability.
|
||||
SelectField(
|
||||
key="PROWLARR_TORRENT_ACTION",
|
||||
label="Torrent Completion Action",
|
||||
description="Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding",
|
||||
options=[
|
||||
{"value": "keep", "label": "Keep"},
|
||||
{"value": "remove", "label": "Remove"},
|
||||
],
|
||||
default="keep",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
|
||||
),
|
||||
|
||||
# --- Usenet Client Selection ---
|
||||
HeadingField(
|
||||
|
||||
@@ -188,6 +188,22 @@ class TransmissionClient(DownloadClient):
|
||||
torrent_hash = torrent.hashString.lower()
|
||||
logger.info(f"Added torrent to Transmission: {torrent_hash}")
|
||||
|
||||
# Apply per-torrent seeding limits from indexer
|
||||
seed_kwargs = {}
|
||||
seeding_time_limit = kwargs.get("seeding_time_limit")
|
||||
if seeding_time_limit is not None:
|
||||
seed_kwargs["seed_idle_limit"] = int(seeding_time_limit)
|
||||
seed_kwargs["seed_idle_mode"] = 1 # per-torrent
|
||||
ratio_limit = kwargs.get("ratio_limit")
|
||||
if ratio_limit is not None:
|
||||
seed_kwargs["seed_ratio_limit"] = float(ratio_limit)
|
||||
seed_kwargs["seed_ratio_mode"] = 1 # per-torrent
|
||||
if seed_kwargs:
|
||||
try:
|
||||
self._client.change_torrent(ids=torrent_hash, **seed_kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set seeding limits for {torrent_hash}: {e}")
|
||||
|
||||
return torrent_hash
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+132
-34
@@ -52,6 +52,20 @@ def _call_and_capture(func: Callable[..., T], args: tuple[Any, ...], kwargs: dic
|
||||
return False, exc
|
||||
|
||||
|
||||
def _must_avoid_gevent_threadpool(func: Callable[..., Any]) -> bool:
|
||||
"""Return True when `func` is unsafe to execute inside gevent's threadpool."""
|
||||
if not _use_gevent_threadpool() or not _gevent_monkey:
|
||||
return False
|
||||
|
||||
# gevent.subprocess requires child watchers on the default event loop.
|
||||
# Executing patched subprocess functions in a worker thread can raise:
|
||||
# "TypeError: child watchers are only available on the default loop".
|
||||
if _gevent_monkey.is_object_patched("subprocess", "run") and func is subprocess.run:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
"""Run blocking I/O in a native thread when under gevent.
|
||||
|
||||
@@ -60,6 +74,9 @@ def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
collision retries, EXDEV for cross-device moves). Capture and re-raise in the
|
||||
caller to avoid noisy, misleading tracebacks.
|
||||
"""
|
||||
if _must_avoid_gevent_threadpool(func):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
if _use_gevent_threadpool():
|
||||
ok, result = _get_io_threadpool().apply(_call_and_capture, (func, args, kwargs))
|
||||
if ok:
|
||||
@@ -71,6 +88,7 @@ def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
|
||||
|
||||
_VERIFY_IO_WAIT_SECONDS = 3.0
|
||||
_PUBLISH_VERIFY_RETRY_SECONDS = 0.25
|
||||
|
||||
|
||||
def _verify_transfer_size(
|
||||
@@ -102,6 +120,44 @@ def _verify_transfer_size(
|
||||
)
|
||||
|
||||
|
||||
def _is_stale_handle_error(error: Exception) -> bool:
|
||||
return isinstance(error, OSError) and error.errno == getattr(errno, "ESTALE", 116)
|
||||
|
||||
|
||||
def _verify_published_file(
|
||||
dest: Path,
|
||||
expected_size: int,
|
||||
action: str,
|
||||
) -> None:
|
||||
"""Best-effort verify after publishing a temp file into place.
|
||||
|
||||
The temp file was already verified before publish. Some NFS mounts can report
|
||||
a transient stale handle immediately after `os.replace()` makes the final path
|
||||
visible, so retry once and then trust the successful publish instead of
|
||||
turning the handoff into a false failure.
|
||||
"""
|
||||
try:
|
||||
_verify_transfer_size(dest, expected_size, action)
|
||||
return
|
||||
except OSError as error:
|
||||
if not _is_stale_handle_error(error):
|
||||
raise
|
||||
|
||||
time.sleep(_PUBLISH_VERIFY_RETRY_SECONDS)
|
||||
|
||||
try:
|
||||
_verify_transfer_size(dest, expected_size, action)
|
||||
except OSError as retry_error:
|
||||
if not _is_stale_handle_error(retry_error):
|
||||
raise
|
||||
logger.warning(
|
||||
"Skipping post-publish verification for %s after stale handle on %s: %s",
|
||||
action,
|
||||
dest,
|
||||
retry_error,
|
||||
)
|
||||
|
||||
|
||||
def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path:
|
||||
"""Write data to a file with atomic collision detection.
|
||||
|
||||
@@ -200,6 +256,28 @@ def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def _is_enoent_error(error: Exception) -> bool:
|
||||
return isinstance(error, FileNotFoundError) or (
|
||||
isinstance(error, OSError) and error.errno == errno.ENOENT
|
||||
)
|
||||
|
||||
|
||||
def _can_use_partial_copy_after_enoent(
|
||||
temp_path: Optional[Path],
|
||||
expected_size: int,
|
||||
action: str,
|
||||
) -> bool:
|
||||
"""Recover when copy2 writes bytes but fails while copying source metadata."""
|
||||
if not temp_path or not run_blocking_io(temp_path.exists):
|
||||
return False
|
||||
|
||||
try:
|
||||
_verify_transfer_size(temp_path, expected_size, action)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _claim_destination(path: Path) -> bool:
|
||||
"""Atomically claim a destination path by creating a placeholder file.
|
||||
|
||||
@@ -224,10 +302,12 @@ def _hardlink_not_supported(error: OSError) -> bool:
|
||||
return err in {
|
||||
errno.EXDEV,
|
||||
errno.EMLINK,
|
||||
errno.EIO,
|
||||
errno.EPERM,
|
||||
errno.EACCES,
|
||||
getattr(errno, "ENOTSUP", errno.EPERM),
|
||||
getattr(errno, "EOPNOTSUPP", errno.EPERM),
|
||||
getattr(errno, "ENOSYS", errno.EPERM),
|
||||
errno.EINVAL,
|
||||
}
|
||||
|
||||
@@ -248,36 +328,33 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
|
||||
|
||||
Returns True on success, False if the destination already exists.
|
||||
"""
|
||||
try:
|
||||
run_blocking_io(os.link, str(temp_path), str(dest_path))
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
return True
|
||||
except FileExistsError:
|
||||
claimed = _claim_destination(dest_path)
|
||||
if not claimed:
|
||||
return False
|
||||
except OSError as e:
|
||||
|
||||
try:
|
||||
# Publish by renaming the fully-written temp file into place. This gives
|
||||
# watchers an IN_MOVED_TO-style event on the final path instead of relying
|
||||
# on hardlink support in the destination filesystem.
|
||||
run_blocking_io(os.replace, str(temp_path), str(dest_path))
|
||||
|
||||
# Best-effort nudge for watchers that only react to close-write on the
|
||||
# final filename rather than rename/move events.
|
||||
try:
|
||||
fd = run_blocking_io(os.open, str(dest_path), os.O_WRONLY)
|
||||
run_blocking_io(os.close, fd)
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
except Exception as e:
|
||||
if _is_permission_error(e):
|
||||
log_transfer_permission_context(
|
||||
"publish_hardlink",
|
||||
"publish_replace",
|
||||
source=temp_path,
|
||||
dest=dest_path,
|
||||
error=e,
|
||||
)
|
||||
if _hardlink_not_supported(e):
|
||||
logger.debug(
|
||||
"Hardlink publish unsupported; falling back to claim+replace: %s -> %s (%s)",
|
||||
temp_path,
|
||||
dest_path,
|
||||
e,
|
||||
)
|
||||
claimed = _claim_destination(dest_path)
|
||||
if not claimed:
|
||||
return False
|
||||
try:
|
||||
run_blocking_io(os.replace, str(temp_path), str(dest_path))
|
||||
except Exception:
|
||||
run_blocking_io(dest_path.unlink, missing_ok=True)
|
||||
raise
|
||||
return True
|
||||
run_blocking_io(dest_path.unlink, missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
@@ -359,6 +436,16 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
copy_error,
|
||||
)
|
||||
_perform_nfs_fallback(source_path, temp_path, is_move=False)
|
||||
elif _is_enoent_error(copy_error) and _can_use_partial_copy_after_enoent(
|
||||
temp_path,
|
||||
expected_size,
|
||||
"move",
|
||||
):
|
||||
logger.warning(
|
||||
"Source vanished during move-copy metadata step; preserving copied data: %s -> %s",
|
||||
source_path,
|
||||
temp_path,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -369,7 +456,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
continue
|
||||
|
||||
try:
|
||||
_verify_transfer_size(try_path, expected_size, "move")
|
||||
_verify_published_file(try_path, expected_size, "move")
|
||||
except Exception:
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
raise
|
||||
@@ -449,14 +536,15 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100)
|
||||
except FileExistsError:
|
||||
continue
|
||||
except OSError as e:
|
||||
if _is_permission_error(e) or e.errno in (errno.EXDEV, errno.EMLINK):
|
||||
if _is_permission_error(e):
|
||||
log_transfer_permission_context(
|
||||
"atomic_hardlink",
|
||||
source=source_path,
|
||||
dest=try_path,
|
||||
error=e,
|
||||
)
|
||||
permission_error = _is_permission_error(e)
|
||||
if permission_error:
|
||||
log_transfer_permission_context(
|
||||
"atomic_hardlink",
|
||||
source=source_path,
|
||||
dest=try_path,
|
||||
error=e,
|
||||
)
|
||||
if permission_error or _hardlink_not_supported(e):
|
||||
logger.debug(
|
||||
"Hardlink failed (%s), falling back to copy: %s -> %s",
|
||||
e,
|
||||
@@ -472,7 +560,7 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100)
|
||||
def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path:
|
||||
"""Copy a file with atomic collision detection.
|
||||
|
||||
Uses a temp file in the destination directory and publishes it atomically,
|
||||
Uses a temp file in the destination directory and publishes it via rename,
|
||||
avoiding partial files on failure.
|
||||
|
||||
Args:
|
||||
@@ -525,6 +613,16 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
fallback_error,
|
||||
)
|
||||
raise e from fallback_error
|
||||
elif _is_enoent_error(e) and _can_use_partial_copy_after_enoent(
|
||||
temp_path,
|
||||
expected_size,
|
||||
"copy",
|
||||
):
|
||||
logger.warning(
|
||||
"Source vanished during copy2 metadata step; preserving copied data: %s -> %s",
|
||||
source_path,
|
||||
temp_path,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -535,7 +633,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
continue
|
||||
|
||||
try:
|
||||
_verify_transfer_size(try_path, expected_size, "copy")
|
||||
_verify_published_file(try_path, expected_size, "copy")
|
||||
except Exception:
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
raise
|
||||
|
||||
+182
-166
@@ -16,14 +16,17 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import BookInfo, DownloadTask, QueueStatus, SearchFilters, SearchMode
|
||||
from shelfmark.core.models import DownloadTask, QueueStatus, SearchMode
|
||||
from shelfmark.core.queue import book_queue
|
||||
from shelfmark.core.utils import transform_cover_url, is_audiobook as check_audiobook
|
||||
from shelfmark.config import env as env_config
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path
|
||||
from shelfmark.download.postprocess.router import post_process_download
|
||||
from shelfmark.release_sources import direct_download, get_handler, get_source_display_name
|
||||
from shelfmark.release_sources.direct_download import SearchUnavailable
|
||||
from shelfmark.release_sources import (
|
||||
get_handler,
|
||||
get_source_display_name,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -56,26 +59,6 @@ _last_activity: Dict[str, float] = {}
|
||||
_last_status_event: Dict[str, Tuple[str, Optional[str]]] = {}
|
||||
STALL_TIMEOUT = 300 # 5 minutes without progress/status update = stalled
|
||||
|
||||
def search_books(query: str, filters: SearchFilters) -> List[Dict[str, Any]]:
|
||||
"""Search for books matching the query."""
|
||||
try:
|
||||
books = direct_download.search_books(query, filters)
|
||||
return [_book_info_to_dict(book) for book in books]
|
||||
except SearchUnavailable:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error searching books: {e}")
|
||||
raise
|
||||
|
||||
def get_book_info(book_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get detailed information for a specific book."""
|
||||
try:
|
||||
book = direct_download.get_book_info(book_id)
|
||||
return _book_info_to_dict(book)
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error getting book info: {e}")
|
||||
raise
|
||||
|
||||
def _is_plain_email_address(value: str) -> bool:
|
||||
parsed = parseaddr(value or "")[1]
|
||||
return bool(parsed) and "@" in parsed and parsed == value
|
||||
@@ -96,76 +79,17 @@ def _resolve_email_destination(
|
||||
return None, "Configured email recipient is invalid"
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def queue_book(
|
||||
book_id: str,
|
||||
priority: int = 0,
|
||||
source: str = "direct_download",
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""Add a book to the download queue. Returns (success, error_message)."""
|
||||
try:
|
||||
book_info = direct_download.get_book_info(book_id, fetch_download_count=False)
|
||||
if not book_info:
|
||||
error_msg = f"Could not fetch book info for {book_id}"
|
||||
logger.warning(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
books_output_mode = str(
|
||||
config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder"
|
||||
).strip().lower()
|
||||
is_audiobook = check_audiobook(book_info.content)
|
||||
|
||||
# Capture output mode at queue time so tasks aren't affected if settings change later.
|
||||
output_mode = "folder" if is_audiobook else books_output_mode
|
||||
output_args: Dict[str, Any] = {}
|
||||
|
||||
if output_mode == "email" and not is_audiobook:
|
||||
email_to, email_error = _resolve_email_destination(user_id=user_id)
|
||||
if email_error:
|
||||
return False, email_error
|
||||
if email_to:
|
||||
output_args = {"to": email_to}
|
||||
|
||||
# Create a source-agnostic download task
|
||||
task = DownloadTask(
|
||||
task_id=book_id,
|
||||
source=source,
|
||||
title=book_info.title,
|
||||
author=book_info.author,
|
||||
format=book_info.format,
|
||||
size=book_info.size,
|
||||
preview=book_info.preview,
|
||||
content_type=book_info.content,
|
||||
search_mode=SearchMode.DIRECT,
|
||||
output_mode=output_mode,
|
||||
output_args=output_args,
|
||||
priority=priority,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
if not book_queue.add(task):
|
||||
logger.info(f"Book already in queue: {book_info.title}")
|
||||
return False, "Book is already in the download queue"
|
||||
|
||||
logger.info(f"Book queued with priority {priority}: {book_info.title}")
|
||||
|
||||
# Broadcast status update via WebSocket
|
||||
if ws_manager:
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
return True, None
|
||||
except SearchUnavailable as e:
|
||||
error_msg = f"Search service unavailable: {e}"
|
||||
logger.warning(error_msg)
|
||||
return False, error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Error queueing book: {e}"
|
||||
logger.error_trace(error_msg)
|
||||
return False, error_msg
|
||||
def _parse_release_search_mode(value: Any) -> SearchMode:
|
||||
if isinstance(value, SearchMode):
|
||||
return value
|
||||
if value is None:
|
||||
return SearchMode.UNIVERSAL
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return SearchMode(value.strip().lower())
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid search_mode: {value}") from exc
|
||||
raise ValueError(f"Invalid search_mode: {value}")
|
||||
|
||||
|
||||
def queue_release(
|
||||
@@ -176,12 +100,13 @@ def queue_release(
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""Add a release to the download queue. Returns (success, error_message)."""
|
||||
try:
|
||||
source = release_data.get('source', 'direct_download')
|
||||
source = release_data['source']
|
||||
extra = release_data.get('extra', {})
|
||||
raw_request_id = release_data.get('_request_id')
|
||||
request_id: Optional[int] = None
|
||||
if isinstance(raw_request_id, int) and raw_request_id > 0:
|
||||
request_id = raw_request_id
|
||||
search_mode = _parse_release_search_mode(release_data.get("search_mode"))
|
||||
|
||||
# Get author, year, preview, and content_type from top-level (preferred) or extra (fallback)
|
||||
author = release_data.get('author') or extra.get('author')
|
||||
@@ -234,7 +159,7 @@ def queue_release(
|
||||
series_name=series_name,
|
||||
series_position=series_position,
|
||||
subtitle=subtitle,
|
||||
search_mode=SearchMode.UNIVERSAL,
|
||||
search_mode=search_mode,
|
||||
output_mode=output_mode,
|
||||
output_args=output_args,
|
||||
priority=priority,
|
||||
@@ -256,8 +181,7 @@ def queue_release(
|
||||
return True, None
|
||||
|
||||
except ValueError as e:
|
||||
# Handler not found for this source
|
||||
error_msg = f"Unknown release source: {e}"
|
||||
error_msg = str(e)
|
||||
logger.warning(error_msg)
|
||||
return False, error_msg
|
||||
except KeyError as e:
|
||||
@@ -306,20 +230,6 @@ def get_book_data(task_id: str) -> Tuple[Optional[bytes], Optional[DownloadTask]
|
||||
task.download_path = None
|
||||
return None, task
|
||||
|
||||
def _book_info_to_dict(book: BookInfo) -> Dict[str, Any]:
|
||||
"""Convert BookInfo to dict, transforming cover URLs for caching."""
|
||||
result = {
|
||||
key: value for key, value in book.__dict__.items()
|
||||
if value is not None
|
||||
}
|
||||
|
||||
# Transform external preview URLs to local proxy URLs
|
||||
if result.get('preview'):
|
||||
result['preview'] = transform_cover_url(result['preview'], book.id)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _task_to_dict(task: DownloadTask) -> Dict[str, Any]:
|
||||
"""Convert DownloadTask to dict for frontend, transforming cover URLs."""
|
||||
# Transform external preview URLs to local proxy URLs
|
||||
@@ -347,6 +257,36 @@ def _task_to_dict(task: DownloadTask) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _clear_task_error_state(task: DownloadTask) -> None:
|
||||
task.last_error_message = None
|
||||
task.last_error_type = None
|
||||
|
||||
|
||||
def _capture_task_error(
|
||||
task: DownloadTask,
|
||||
*,
|
||||
message: Optional[str] = None,
|
||||
exc_type: Optional[str] = None,
|
||||
) -> None:
|
||||
if isinstance(message, str):
|
||||
normalized = message.strip()
|
||||
if normalized:
|
||||
task.last_error_message = normalized
|
||||
book_queue.update_status_message(task.task_id, normalized)
|
||||
if isinstance(exc_type, str):
|
||||
normalized_type = exc_type.strip()
|
||||
if normalized_type:
|
||||
task.last_error_type = normalized_type
|
||||
|
||||
|
||||
def _format_download_exception_message(exc: Exception) -> str:
|
||||
if isinstance(exc, PermissionError) and "/cwa-book-ingest" in str(exc):
|
||||
return "Destination misconfigured. Go to Settings → Downloads to update."
|
||||
if isinstance(exc, PermissionError):
|
||||
return f"Permission denied: {exc}"
|
||||
return f"Download failed: {type(exc).__name__}"
|
||||
|
||||
|
||||
def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
"""Download a task via appropriate handler, then post-process to ingest."""
|
||||
try:
|
||||
@@ -372,25 +312,57 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
update_download_progress(task_id, progress)
|
||||
|
||||
def status_callback(status: str, message: Optional[str] = None) -> None:
|
||||
status_key = status.lower()
|
||||
if status_key == "error":
|
||||
_capture_task_error(
|
||||
task,
|
||||
message=message or "Download failed",
|
||||
exc_type="StatusCallbackError",
|
||||
)
|
||||
return
|
||||
# Don't propagate terminal statuses to the queue here. Output modules
|
||||
# call status_callback("complete") before returning the download path,
|
||||
# but _process_single_download needs to set download_path on the task
|
||||
# first so the terminal hook captures it for history persistence.
|
||||
if status_key in ("complete", "cancelled"):
|
||||
if message is not None:
|
||||
book_queue.update_status_message(task_id, message)
|
||||
return
|
||||
update_download_status(task_id, status, message)
|
||||
|
||||
# Get the download handler based on the task's source
|
||||
handler = get_handler(task.source)
|
||||
temp_path = handler.download(
|
||||
task,
|
||||
cancel_flag,
|
||||
progress_callback,
|
||||
status_callback
|
||||
)
|
||||
temp_file: Optional[Path] = None
|
||||
|
||||
# Handler returns temp path - orchestrator handles post-processing
|
||||
if not temp_path:
|
||||
return None
|
||||
if task.staged_path:
|
||||
staged_file = Path(task.staged_path)
|
||||
if run_blocking_io(staged_file.exists):
|
||||
temp_file = staged_file
|
||||
logger.info("Task %s: reusing staged file for retry: %s", task_id, staged_file)
|
||||
else:
|
||||
task.staged_path = None
|
||||
|
||||
temp_file = Path(temp_path)
|
||||
if not run_blocking_io(temp_file.exists):
|
||||
logger.error(f"Handler returned non-existent path: {temp_path}")
|
||||
return None
|
||||
if temp_file is None:
|
||||
temp_path = handler.download(
|
||||
task,
|
||||
cancel_flag,
|
||||
progress_callback,
|
||||
status_callback,
|
||||
)
|
||||
|
||||
# Handler returns temp path - orchestrator handles post-processing
|
||||
if not temp_path:
|
||||
return None
|
||||
|
||||
temp_file = Path(temp_path)
|
||||
if not run_blocking_io(temp_file.exists):
|
||||
logger.error(f"Handler returned non-existent path: {temp_path}")
|
||||
_capture_task_error(
|
||||
task,
|
||||
message=f"Download file missing: {temp_path}",
|
||||
exc_type="MissingDownloadPath",
|
||||
)
|
||||
return None
|
||||
|
||||
# Check cancellation before post-processing
|
||||
if cancel_flag.is_set():
|
||||
@@ -401,9 +373,17 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
|
||||
logger.info("Task %s: download finished; starting post-processing", task_id)
|
||||
logger.debug("Task %s: post-processing input path: %s", task_id, temp_file)
|
||||
task.staged_path = str(temp_file)
|
||||
preserve_source_on_failure = True
|
||||
|
||||
# Post-processing: output routing + file processing pipeline
|
||||
result = post_process_download(temp_file, task, cancel_flag, status_callback)
|
||||
result = post_process_download(
|
||||
temp_file,
|
||||
task,
|
||||
cancel_flag,
|
||||
status_callback,
|
||||
preserve_source_on_failure=preserve_source_on_failure,
|
||||
)
|
||||
|
||||
if cancel_flag.is_set():
|
||||
logger.info("Task %s: post-processing cancelled", task_id)
|
||||
@@ -412,12 +392,22 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
logger.debug("Task %s: post-processing result: %s", task_id, result)
|
||||
else:
|
||||
logger.warning("Task %s: post-processing failed", task_id)
|
||||
if not task.last_error_message:
|
||||
_capture_task_error(
|
||||
task,
|
||||
message="Download failed",
|
||||
exc_type="UnknownFailure",
|
||||
)
|
||||
|
||||
try:
|
||||
handler.post_process_cleanup(task, success=bool(result))
|
||||
except Exception as e:
|
||||
logger.warning("Post-processing cleanup hook failed for %s: %s", task_id, e)
|
||||
|
||||
if result:
|
||||
task.staged_path = None
|
||||
_clear_task_error_state(task)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
@@ -425,21 +415,13 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
logger.info("Task %s: cancelled during error handling", task_id)
|
||||
else:
|
||||
logger.error_trace("Task %s: error downloading: %s", task_id, e)
|
||||
# Update task status so user sees the failure
|
||||
task = book_queue.get_task(task_id)
|
||||
if task:
|
||||
book_queue.update_status(task_id, QueueStatus.ERROR)
|
||||
# Check for known misconfiguration from earlier versions
|
||||
if isinstance(e, PermissionError) and "/cwa-book-ingest" in str(e):
|
||||
book_queue.update_status_message(
|
||||
task_id,
|
||||
"Destination misconfigured. Go to Settings → Downloads to update."
|
||||
)
|
||||
else:
|
||||
if isinstance(e, PermissionError):
|
||||
book_queue.update_status_message(task_id, f"Permission denied: {e}")
|
||||
else:
|
||||
book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}")
|
||||
_capture_task_error(
|
||||
task,
|
||||
message=_format_download_exception_message(e),
|
||||
exc_type=type(e).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -483,22 +465,10 @@ def update_download_progress(book_id: str, progress: float) -> None:
|
||||
|
||||
def update_download_status(book_id: str, status: str, message: Optional[str] = None) -> None:
|
||||
"""Update download status with optional message for UI display."""
|
||||
# Map string status to QueueStatus enum
|
||||
status_map = {
|
||||
'queued': QueueStatus.QUEUED,
|
||||
'resolving': QueueStatus.RESOLVING,
|
||||
'locating': QueueStatus.LOCATING,
|
||||
'downloading': QueueStatus.DOWNLOADING,
|
||||
'complete': QueueStatus.COMPLETE,
|
||||
'available': QueueStatus.AVAILABLE,
|
||||
'error': QueueStatus.ERROR,
|
||||
'done': QueueStatus.DONE,
|
||||
'cancelled': QueueStatus.CANCELLED,
|
||||
}
|
||||
|
||||
status_key = status.lower()
|
||||
queue_status_enum = status_map.get(status_key)
|
||||
if not queue_status_enum:
|
||||
try:
|
||||
queue_status_enum = QueueStatus(status_key)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
# Always update activity timestamp (used by stall detection) even if the status
|
||||
@@ -531,6 +501,38 @@ def cancel_download(book_id: str) -> bool:
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def retry_download(book_id: str) -> Tuple[bool, Optional[str]]:
|
||||
"""Retry a failed or cancelled download.
|
||||
|
||||
Request-linked downloads can only be retried when cancelled (errors
|
||||
reopen the request for admin re-approval instead).
|
||||
"""
|
||||
task = book_queue.get_task(book_id)
|
||||
if task is None:
|
||||
return False, "Download not found"
|
||||
|
||||
status = book_queue.get_task_status(book_id)
|
||||
if status not in (QueueStatus.ERROR, QueueStatus.CANCELLED):
|
||||
return False, "Download is not in an error or cancelled state"
|
||||
|
||||
if task.request_id and status != QueueStatus.CANCELLED:
|
||||
return False, "Request-linked downloads must be retried from requests"
|
||||
|
||||
task.last_error_message = None
|
||||
task.last_error_type = None
|
||||
task.priority = -10
|
||||
|
||||
if not book_queue.enqueue_existing(book_id, priority=-10):
|
||||
return False, "Failed to requeue download"
|
||||
|
||||
book_queue.update_status_message(book_id, "Retrying now")
|
||||
|
||||
if ws_manager:
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
return True, None
|
||||
|
||||
def set_book_priority(book_id: str, priority: int) -> bool:
|
||||
"""Set priority for a queued book (lower = higher priority)."""
|
||||
return book_queue.set_priority(book_id, priority)
|
||||
@@ -547,10 +549,6 @@ def get_active_downloads() -> List[str]:
|
||||
"""Get list of currently active downloads."""
|
||||
return book_queue.get_active_downloads()
|
||||
|
||||
def clear_completed(user_id: Optional[int] = None) -> int:
|
||||
"""Clear completed downloads from tracking (optionally user-scoped)."""
|
||||
return book_queue.clear_completed(user_id=user_id)
|
||||
|
||||
def _cleanup_progress_tracking(task_id: str) -> None:
|
||||
"""Clean up progress tracking data for a completed/cancelled download."""
|
||||
with _progress_lock:
|
||||
@@ -560,6 +558,24 @@ def _cleanup_progress_tracking(task_id: str) -> None:
|
||||
_last_status_event.pop(task_id, None)
|
||||
|
||||
|
||||
def _finalize_download_failure(task_id: str) -> None:
|
||||
task = book_queue.get_task(task_id)
|
||||
if not task:
|
||||
return
|
||||
|
||||
message = task.last_error_message or task.status_message or ""
|
||||
normalized_message = message.strip()
|
||||
if not normalized_message:
|
||||
normalized_message = (
|
||||
f"Download failed: {task.last_error_type}"
|
||||
if task.last_error_type
|
||||
else "Download failed"
|
||||
)
|
||||
|
||||
book_queue.update_status_message(task_id, normalized_message)
|
||||
book_queue.update_status(task_id, QueueStatus.ERROR)
|
||||
|
||||
|
||||
def _process_single_download(task_id: str, cancel_flag: Event) -> None:
|
||||
"""Process a single download job."""
|
||||
try:
|
||||
@@ -579,12 +595,9 @@ def _process_single_download(task_id: str, cancel_flag: Event) -> None:
|
||||
|
||||
if download_path:
|
||||
book_queue.update_download_path(task_id, download_path)
|
||||
# Only update status if not already set (e.g., by archive extraction callback)
|
||||
task = book_queue.get_task(task_id)
|
||||
if not task or task.status != QueueStatus.COMPLETE:
|
||||
book_queue.update_status(task_id, QueueStatus.COMPLETE)
|
||||
book_queue.update_status(task_id, QueueStatus.COMPLETE)
|
||||
else:
|
||||
book_queue.update_status(task_id, QueueStatus.ERROR)
|
||||
_finalize_download_failure(task_id)
|
||||
|
||||
# Broadcast final status (completed or error)
|
||||
if ws_manager:
|
||||
@@ -596,11 +609,14 @@ def _process_single_download(task_id: str, cancel_flag: Event) -> None:
|
||||
|
||||
if not cancel_flag.is_set():
|
||||
logger.error_trace(f"Error in download processing: {e}")
|
||||
book_queue.update_status(task_id, QueueStatus.ERROR)
|
||||
# Set error message if not already set by handler
|
||||
task = book_queue.get_task(task_id)
|
||||
if task and not task.status_message:
|
||||
book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}: {str(e)}")
|
||||
if task:
|
||||
_capture_task_error(
|
||||
task,
|
||||
message=f"Download failed: {type(e).__name__}: {str(e)}",
|
||||
exc_type=type(e).__name__,
|
||||
)
|
||||
_finalize_download_failure(task_id)
|
||||
else:
|
||||
logger.info(f"Download cancelled: {task_id}")
|
||||
book_queue.update_status(task_id, QueueStatus.CANCELLED)
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Callable, Optional
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
StatusCallback = Callable[[str, Optional[str]], None]
|
||||
OutputHandler = Callable[[Path, DownloadTask, Event, StatusCallback], Optional[str]]
|
||||
OutputHandler = Callable[[Path, DownloadTask, Event, StatusCallback, bool], Optional[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -13,7 +13,7 @@ from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.download.outputs import register_output
|
||||
from shelfmark.download.staging import STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir
|
||||
from shelfmark.download.staging import STAGE_COPY, STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -24,6 +24,7 @@ BOOKLORE_SUPPORTED_EXTENSIONS = {".azw", ".azw3", ".cb7", ".cbr", ".cbz", ".epub
|
||||
BOOKLORE_SUPPORTED_FORMATS_LABEL = ", ".join(
|
||||
ext.lstrip(".").upper() for ext in sorted(BOOKLORE_SUPPORTED_EXTENSIONS)
|
||||
)
|
||||
BOOKLORE_DISPLAY_NAME = "Grimmory"
|
||||
|
||||
|
||||
class BookloreError(Exception):
|
||||
@@ -67,11 +68,11 @@ def build_booklore_config(
|
||||
password = values.get("BOOKLORE_PASSWORD", "") or ""
|
||||
|
||||
if not base_url:
|
||||
raise BookloreError("Booklore URL is required")
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} URL is required")
|
||||
if not username:
|
||||
raise BookloreError("Booklore username is required")
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} username is required")
|
||||
if not password:
|
||||
raise BookloreError("Booklore password is required")
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} password is required")
|
||||
|
||||
destination = _parse_destination(
|
||||
values.get("BOOKLORE_DESTINATION", BOOKLORE_DESTINATION_LIBRARY)
|
||||
@@ -97,8 +98,8 @@ def build_booklore_config(
|
||||
library_id_val = values.get("BOOKLORE_LIBRARY_ID")
|
||||
path_id_val = values.get("BOOKLORE_PATH_ID")
|
||||
|
||||
library_id = _parse_int(library_id_val, "Booklore library ID")
|
||||
path_id = _parse_int(path_id_val, "Booklore path ID")
|
||||
library_id = _parse_int(library_id_val, f"{BOOKLORE_DISPLAY_NAME} library ID")
|
||||
path_id = _parse_int(path_id_val, f"{BOOKLORE_DISPLAY_NAME} path ID")
|
||||
|
||||
return BookloreConfig(
|
||||
base_url=base_url.rstrip("/"),
|
||||
@@ -119,28 +120,28 @@ def booklore_login(booklore_config: BookloreConfig) -> str:
|
||||
try:
|
||||
response = requests.post(url, json=payload, timeout=30, verify=booklore_config.verify_tls)
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
raise BookloreError("Could not connect to Booklore") from exc
|
||||
raise BookloreError(f"Could not connect to {BOOKLORE_DISPLAY_NAME}") from exc
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise BookloreError("Booklore connection timed out") from exc
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} connection timed out") from exc
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise BookloreError(f"Booklore login failed: {exc}") from exc
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} login failed: {exc}") from exc
|
||||
|
||||
if response.status_code in {401, 403}:
|
||||
raise BookloreError("Booklore authentication failed")
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} authentication failed")
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
raise BookloreError(f"Booklore login failed ({response.status_code})") from exc
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} login failed ({response.status_code})") from exc
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise BookloreError("Invalid Booklore login response") from exc
|
||||
raise BookloreError(f"Invalid {BOOKLORE_DISPLAY_NAME} login response") from exc
|
||||
|
||||
token = data.get("accessToken")
|
||||
if not token:
|
||||
raise BookloreError("Booklore did not return an access token")
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} did not return an access token")
|
||||
|
||||
return token
|
||||
|
||||
@@ -153,12 +154,12 @@ def booklore_list_libraries(booklore_config: BookloreConfig, token: str) -> list
|
||||
response = requests.get(url, headers=headers, timeout=30, verify=booklore_config.verify_tls)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise BookloreError(f"Failed to fetch Booklore libraries: {exc}") from exc
|
||||
raise BookloreError(f"Failed to fetch {BOOKLORE_DISPLAY_NAME} libraries: {exc}") from exc
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise BookloreError("Invalid Booklore libraries response") from exc
|
||||
raise BookloreError(f"Invalid {BOOKLORE_DISPLAY_NAME} libraries response") from exc
|
||||
|
||||
|
||||
def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path: Path) -> None:
|
||||
@@ -189,13 +190,13 @@ def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path:
|
||||
if message:
|
||||
message = f": {message[:200]}"
|
||||
status_code = response.status_code if response is not None else "unknown"
|
||||
raise BookloreError(f"Booklore upload failed ({status_code}){message}") from exc
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} upload failed ({status_code}){message}") from exc
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
raise BookloreError("Could not connect to Booklore") from exc
|
||||
raise BookloreError(f"Could not connect to {BOOKLORE_DISPLAY_NAME}") from exc
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise BookloreError("Booklore upload timed out") from exc
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} upload timed out") from exc
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise BookloreError(f"Booklore upload failed: {exc}") from exc
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} upload failed: {exc}") from exc
|
||||
|
||||
|
||||
def booklore_refresh_library(booklore_config: BookloreConfig, token: str) -> None:
|
||||
@@ -206,7 +207,7 @@ def booklore_refresh_library(booklore_config: BookloreConfig, token: str) -> Non
|
||||
response = requests.put(url, headers=headers, timeout=30, verify=booklore_config.verify_tls)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise BookloreError(f"Booklore refresh failed: {exc}") from exc
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} refresh failed: {exc}") from exc
|
||||
|
||||
|
||||
def _supports_booklore(task: DownloadTask) -> bool:
|
||||
@@ -231,7 +232,7 @@ def _booklore_format_error(rejected_files: List[Path]) -> str:
|
||||
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
|
||||
rejected_list = ", ".join(rejected_exts)
|
||||
return (
|
||||
f"Booklore does not support {rejected_list}. "
|
||||
f"{BOOKLORE_DISPLAY_NAME} does not support {rejected_list}. "
|
||||
f"Supported formats: {BOOKLORE_SUPPORTED_FORMATS_LABEL}"
|
||||
)
|
||||
|
||||
@@ -241,6 +242,7 @@ def _post_process_booklore(
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[str]:
|
||||
from shelfmark.download.postprocess.pipeline import (
|
||||
CustomScriptContext,
|
||||
@@ -249,6 +251,7 @@ def _post_process_booklore(
|
||||
is_managed_workspace_path,
|
||||
maybe_run_custom_script,
|
||||
prepare_output_files,
|
||||
safe_cleanup_path,
|
||||
)
|
||||
|
||||
if cancel_flag.is_set():
|
||||
@@ -265,9 +268,11 @@ def _post_process_booklore(
|
||||
status_callback("error", str(e))
|
||||
return None
|
||||
|
||||
status_callback("resolving", "Preparing Booklore upload")
|
||||
status_callback("resolving", f"Preparing {BOOKLORE_DISPLAY_NAME} upload")
|
||||
|
||||
stage_action = STAGE_MOVE if is_managed_workspace_path(temp_file) else STAGE_NONE
|
||||
stage_action = STAGE_NONE
|
||||
if is_managed_workspace_path(temp_file):
|
||||
stage_action = STAGE_COPY if preserve_source_on_failure else STAGE_MOVE
|
||||
staging_dir = build_staging_dir("booklore", task.task_id) if stage_action != STAGE_NONE else get_staging_dir()
|
||||
|
||||
output_plan = OutputPlan(
|
||||
@@ -283,12 +288,14 @@ def _post_process_booklore(
|
||||
BOOKLORE_OUTPUT_MODE,
|
||||
status_callback,
|
||||
output_plan=output_plan,
|
||||
preserve_source_on_failure=preserve_source_on_failure,
|
||||
)
|
||||
if not prepared:
|
||||
return None
|
||||
|
||||
logger.debug("Task %s: prepared %d file(s) for Booklore upload", task.task_id, len(prepared.files))
|
||||
|
||||
success = False
|
||||
try:
|
||||
unsupported_files = [
|
||||
file_path
|
||||
@@ -308,7 +315,7 @@ def _post_process_booklore(
|
||||
if cancel_flag.is_set():
|
||||
logger.info("Task %s: cancelled during Booklore upload", task.task_id)
|
||||
return None
|
||||
status_callback("resolving", f"Uploading to Booklore ({index}/{len(prepared.files)})")
|
||||
status_callback("resolving", f"Uploading to {BOOKLORE_DISPLAY_NAME} ({index}/{len(prepared.files)})")
|
||||
booklore_upload_file(booklore_config, token, file_path)
|
||||
|
||||
if booklore_config.refresh_after_upload:
|
||||
@@ -355,10 +362,11 @@ def _post_process_booklore(
|
||||
if not maybe_run_custom_script(script_context, status_callback=status_callback):
|
||||
return None
|
||||
|
||||
message = "Uploaded to Booklore"
|
||||
message = f"Uploaded to {BOOKLORE_DISPLAY_NAME}"
|
||||
if len(prepared.files) > 1:
|
||||
message = f"Uploaded to Booklore ({len(prepared.files)} files)"
|
||||
message = f"Uploaded to {BOOKLORE_DISPLAY_NAME} ({len(prepared.files)} files)"
|
||||
status_callback("complete", message)
|
||||
success = True
|
||||
return f"booklore://{task.task_id}"
|
||||
|
||||
except BookloreError as e:
|
||||
@@ -367,7 +375,7 @@ def _post_process_booklore(
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error_trace("Task %s: unexpected error uploading to Booklore: %s", task.task_id, e)
|
||||
status_callback("error", f"Booklore upload failed: {e}")
|
||||
status_callback("error", f"{BOOKLORE_DISPLAY_NAME} upload failed: {e}")
|
||||
return None
|
||||
finally:
|
||||
cleanup_output_staging(
|
||||
@@ -376,6 +384,8 @@ def _post_process_booklore(
|
||||
task,
|
||||
prepared.cleanup_paths,
|
||||
)
|
||||
if preserve_source_on_failure and success:
|
||||
safe_cleanup_path(temp_file, task)
|
||||
|
||||
|
||||
@register_output(BOOKLORE_OUTPUT_MODE, supports_task=_supports_booklore, priority=10)
|
||||
@@ -384,5 +394,12 @@ def process_booklore_output(
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[str]:
|
||||
return _post_process_booklore(temp_file, task, cancel_flag, status_callback)
|
||||
return _post_process_booklore(
|
||||
temp_file,
|
||||
task,
|
||||
cancel_flag,
|
||||
status_callback,
|
||||
preserve_source_on_failure=preserve_source_on_failure,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.download.outputs import register_output
|
||||
from shelfmark.download.staging import STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir
|
||||
from shelfmark.download.staging import STAGE_COPY, STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -268,6 +268,7 @@ def _post_process_email(
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[str]:
|
||||
from shelfmark.download.postprocess.pipeline import (
|
||||
CustomScriptContext,
|
||||
@@ -276,6 +277,7 @@ def _post_process_email(
|
||||
is_managed_workspace_path,
|
||||
maybe_run_custom_script,
|
||||
prepare_output_files,
|
||||
safe_cleanup_path,
|
||||
)
|
||||
|
||||
if cancel_flag.is_set():
|
||||
@@ -304,7 +306,9 @@ def _post_process_email(
|
||||
|
||||
status_callback("resolving", "Preparing email")
|
||||
|
||||
stage_action = STAGE_MOVE if is_managed_workspace_path(temp_file) else STAGE_NONE
|
||||
stage_action = STAGE_NONE
|
||||
if is_managed_workspace_path(temp_file):
|
||||
stage_action = STAGE_COPY if preserve_source_on_failure else STAGE_MOVE
|
||||
staging_dir = build_staging_dir("email", task.task_id) if stage_action != STAGE_NONE else get_staging_dir()
|
||||
|
||||
output_plan = OutputPlan(
|
||||
@@ -320,10 +324,12 @@ def _post_process_email(
|
||||
EMAIL_OUTPUT_MODE,
|
||||
status_callback,
|
||||
output_plan=output_plan,
|
||||
preserve_source_on_failure=preserve_source_on_failure,
|
||||
)
|
||||
if not prepared:
|
||||
return None
|
||||
|
||||
success = False
|
||||
try:
|
||||
limit_mb_raw = core_config.config.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25)
|
||||
try:
|
||||
@@ -399,6 +405,7 @@ def _post_process_email(
|
||||
return None
|
||||
|
||||
status_callback("complete", f"Sent to {label}")
|
||||
success = True
|
||||
return f"email://{task.task_id}"
|
||||
|
||||
except EmailOutputError as exc:
|
||||
@@ -416,6 +423,8 @@ def _post_process_email(
|
||||
task,
|
||||
prepared.cleanup_paths,
|
||||
)
|
||||
if preserve_source_on_failure and success:
|
||||
safe_cleanup_path(temp_file, task)
|
||||
|
||||
|
||||
@register_output(EMAIL_OUTPUT_MODE, supports_task=_supports_email, priority=10)
|
||||
@@ -424,5 +433,12 @@ def process_email_output(
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[str]:
|
||||
return _post_process_email(temp_file, task, cancel_flag, status_callback)
|
||||
return _post_process_email(
|
||||
temp_file,
|
||||
task,
|
||||
cancel_flag,
|
||||
status_callback,
|
||||
preserve_source_on_failure=preserve_source_on_failure,
|
||||
)
|
||||
|
||||
@@ -88,6 +88,7 @@ def process_folder_output(
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Post-process download to the configured folder destination."""
|
||||
from shelfmark.download.postprocess.pipeline import (
|
||||
@@ -122,6 +123,7 @@ def process_folder_output(
|
||||
output_mode=plan.output_mode,
|
||||
status_callback=status_callback,
|
||||
destination=plan.destination,
|
||||
preserve_source_on_failure=preserve_source_on_failure,
|
||||
)
|
||||
if not prepared:
|
||||
return None
|
||||
@@ -143,7 +145,7 @@ def process_folder_output(
|
||||
|
||||
# For external usenet downloads, always copy from the client path.
|
||||
# "Move" is implemented as a client-side cleanup after import.
|
||||
preserve_source = is_usenet
|
||||
preserve_source = is_usenet or preserve_source_on_failure
|
||||
|
||||
copy_for_label = is_torrent or preserve_source or prepared.output_plan.stage_action != STAGE_NONE
|
||||
|
||||
@@ -227,12 +229,13 @@ def process_folder_output(
|
||||
)
|
||||
|
||||
if not maybe_run_custom_script(script_context, status_callback=status_callback, steps=steps):
|
||||
cleanup_output_staging(
|
||||
prepared.output_plan,
|
||||
prepared.working_path,
|
||||
task,
|
||||
prepared.cleanup_paths,
|
||||
)
|
||||
if not preserve_source_on_failure:
|
||||
cleanup_output_staging(
|
||||
prepared.output_plan,
|
||||
prepared.working_path,
|
||||
task,
|
||||
prepared.cleanup_paths,
|
||||
)
|
||||
return None
|
||||
|
||||
cleanup_output_staging(
|
||||
|
||||
@@ -6,12 +6,12 @@ from pathlib import Path
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.utils import (
|
||||
get_aa_content_type_dir,
|
||||
get_destination,
|
||||
is_audiobook as check_audiobook,
|
||||
)
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
from shelfmark.download.permissions_debug import log_path_permission_context
|
||||
from shelfmark.release_sources import get_source
|
||||
|
||||
logger = setup_logger("shelfmark.download.postprocess.pipeline")
|
||||
|
||||
@@ -63,9 +63,12 @@ def get_final_destination(task: DownloadTask) -> Path:
|
||||
|
||||
is_audiobook = check_audiobook(task.content_type)
|
||||
|
||||
if task.source == "direct_download" and not is_audiobook:
|
||||
override = get_aa_content_type_dir(task.content_type)
|
||||
if override:
|
||||
return override
|
||||
try:
|
||||
override = get_source(task.source).get_destination_override(task)
|
||||
except ValueError:
|
||||
override = None
|
||||
|
||||
if override:
|
||||
return override
|
||||
|
||||
return get_destination(is_audiobook, user_id=task.user_id, username=task.username)
|
||||
|
||||
@@ -43,6 +43,7 @@ def prepare_output_files(
|
||||
status_callback,
|
||||
destination: Optional[Path] = None,
|
||||
output_plan: Optional[OutputPlan] = None,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[PreparedFiles]:
|
||||
if output_plan is None:
|
||||
output_plan = build_output_plan(
|
||||
@@ -59,19 +60,23 @@ def prepare_output_files(
|
||||
status_callback("resolving", step_label)
|
||||
working_path = stage_path(working_path, output_plan.staging_dir, output_plan.stage_action)
|
||||
|
||||
can_delete_source_archives = output_plan.stage_action != STAGE_NONE or is_managed_workspace_path(working_path)
|
||||
can_delete_source_archives = output_plan.stage_action != STAGE_NONE or is_managed_workspace_path(
|
||||
working_path
|
||||
)
|
||||
cleanup_archives = can_delete_source_archives and not preserve_source_on_failure
|
||||
|
||||
files, rejected_files, cleanup_paths, error = collect_staged_files(
|
||||
working_path=working_path,
|
||||
task=task,
|
||||
allow_archive_extraction=output_plan.allow_archive_extraction,
|
||||
status_callback=status_callback,
|
||||
cleanup_archives=can_delete_source_archives,
|
||||
cleanup_archives=cleanup_archives,
|
||||
)
|
||||
|
||||
if error:
|
||||
status_callback("error", error)
|
||||
cleanup_output_staging(output_plan, working_path, task, cleanup_paths)
|
||||
if not preserve_source_on_failure:
|
||||
cleanup_output_staging(output_plan, working_path, task, cleanup_paths)
|
||||
return None
|
||||
|
||||
if output_plan.stage_action == STAGE_NONE and is_managed_workspace_path(working_path):
|
||||
|
||||
@@ -26,6 +26,7 @@ def post_process_download(
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Post-process download using the selected output handler."""
|
||||
|
||||
@@ -44,9 +45,21 @@ def post_process_download(
|
||||
output_handler = resolve_output_handler(task)
|
||||
if output_handler:
|
||||
logger.info("Task %s: using output mode %s", task.task_id, output_handler.mode)
|
||||
return output_handler.handler(temp_file, task, cancel_flag, status_callback)
|
||||
return output_handler.handler(
|
||||
temp_file,
|
||||
task,
|
||||
cancel_flag,
|
||||
status_callback,
|
||||
preserve_source_on_failure,
|
||||
)
|
||||
|
||||
from shelfmark.download.outputs.folder import process_folder_output
|
||||
|
||||
logger.info("Task %s: using output mode folder", task.task_id)
|
||||
return process_folder_output(temp_file, task, cancel_flag, status_callback)
|
||||
return process_folder_output(
|
||||
temp_file,
|
||||
task,
|
||||
cancel_flag,
|
||||
status_callback,
|
||||
preserve_source_on_failure,
|
||||
)
|
||||
|
||||
@@ -57,6 +57,14 @@ def build_metadata_dict(task: DownloadTask) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def build_file_metadata(task: DownloadTask, source_file: Path, part_number: Optional[str] = None) -> dict:
|
||||
metadata = build_metadata_dict(task)
|
||||
metadata["OriginalName"] = source_file.stem
|
||||
if part_number is not None:
|
||||
metadata["PartNumber"] = part_number
|
||||
return metadata
|
||||
|
||||
|
||||
def resolve_hardlink_source(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
@@ -157,16 +165,16 @@ def transfer_book_files(
|
||||
|
||||
if organization_mode == "organize":
|
||||
template = get_template(is_audiobook, "organize")
|
||||
metadata = build_metadata_dict(task)
|
||||
|
||||
if len(book_files) == 1:
|
||||
source_file = book_files[0]
|
||||
ext = source_file.suffix.lstrip(".") or task.format or ""
|
||||
file_metadata = build_file_metadata(task, source_file)
|
||||
dest_path = run_blocking_io(
|
||||
build_library_path,
|
||||
str(destination),
|
||||
template,
|
||||
metadata,
|
||||
file_metadata,
|
||||
extension=ext or None,
|
||||
)
|
||||
run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
@@ -188,7 +196,7 @@ def transfer_book_files(
|
||||
|
||||
for source_file, part_number in files_with_parts:
|
||||
ext = source_file.suffix.lstrip(".") or task.format or ""
|
||||
file_metadata = {**metadata, "PartNumber": part_number}
|
||||
file_metadata = build_file_metadata(task, source_file, part_number=part_number)
|
||||
dest_path = run_blocking_io(
|
||||
build_library_path,
|
||||
str(destination),
|
||||
@@ -218,7 +226,7 @@ def transfer_book_files(
|
||||
task.format = book_file.suffix.lower().lstrip(".")
|
||||
|
||||
template = get_template(is_audiobook, "rename")
|
||||
metadata = build_metadata_dict(task)
|
||||
metadata = build_file_metadata(task, book_file)
|
||||
extension = book_file.suffix.lstrip(".") or task.format or ""
|
||||
|
||||
filename = parse_naming_template(template, metadata, allow_path_separators=False)
|
||||
@@ -311,7 +319,9 @@ def transfer_file_to_library(
|
||||
use_hardlink: bool,
|
||||
) -> Optional[str]:
|
||||
extension = source_path.suffix.lstrip(".") or task.format
|
||||
dest_path = run_blocking_io(build_library_path, library_base, template, metadata, extension)
|
||||
template_metadata = dict(metadata)
|
||||
template_metadata.setdefault("OriginalName", source_path.stem)
|
||||
dest_path = run_blocking_io(build_library_path, library_base, template, template_metadata, extension)
|
||||
run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
is_torrent = is_torrent_source(source_path, task)
|
||||
|
||||
+775
-360
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ Metadata providers allow searching for books and retrieving detailed metadata (t
|
||||
|----------|---------------|-------------|
|
||||
| **Hardcover** | Yes (API key) | Modern book tracking platform with GraphQL API. Get your key at [hardcover.app/account/api](https://hardcover.app/account/api) |
|
||||
| **Open Library** | No | Free, open-source library catalog from the Internet Archive. Rate limited to ~100 requests/minute |
|
||||
| **Google Books** | Yes (API key) | Google's book database with broad coverage and a free API key option |
|
||||
|
||||
|
||||
## Core Components
|
||||
|
||||
|
||||
@@ -35,6 +35,14 @@ SORT_LABELS: Dict[SortOrder, str] = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetadataCapability:
|
||||
"""Declarative provider capability consumed by shared UI code."""
|
||||
key: str
|
||||
field_key: Optional[str] = None
|
||||
sort: Optional[SortOrder] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextSearchField:
|
||||
"""Text input search field."""
|
||||
@@ -42,6 +50,8 @@ class TextSearchField:
|
||||
label: str # Display label in UI
|
||||
placeholder: str = "" # Placeholder text
|
||||
description: str = "" # Help text
|
||||
suggestions_endpoint: Optional[str] = None # Remote suggestions endpoint for typeahead
|
||||
suggestions_min_query_length: int = 2 # Minimum chars before requesting suggestions
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -75,8 +85,39 @@ class CheckboxSearchField:
|
||||
default: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class DynamicSelectSearchField:
|
||||
"""Single-choice dropdown field with options loaded from an API endpoint."""
|
||||
key: str
|
||||
label: str
|
||||
options_endpoint: str
|
||||
placeholder: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
# Type alias for all search field types
|
||||
SearchField = Union[TextSearchField, NumberSearchField, SelectSearchField, CheckboxSearchField]
|
||||
SearchField = Union[
|
||||
TextSearchField,
|
||||
NumberSearchField,
|
||||
SelectSearchField,
|
||||
CheckboxSearchField,
|
||||
DynamicSelectSearchField,
|
||||
]
|
||||
|
||||
|
||||
def serialize_metadata_capability(capability: MetadataCapability) -> Dict[str, Any]:
|
||||
"""Serialize a provider capability for API responses."""
|
||||
result: Dict[str, Any] = {
|
||||
"key": capability.key,
|
||||
}
|
||||
|
||||
if capability.field_key:
|
||||
result["field_key"] = capability.field_key
|
||||
|
||||
if capability.sort:
|
||||
result["sort"] = capability.sort.value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def serialize_search_field(search_field: SearchField) -> Dict[str, Any]:
|
||||
@@ -94,10 +135,16 @@ def serialize_search_field(search_field: SearchField) -> Dict[str, Any]:
|
||||
result["min"] = search_field.min_value
|
||||
result["max"] = search_field.max_value
|
||||
result["step"] = search_field.step
|
||||
elif isinstance(search_field, TextSearchField):
|
||||
if search_field.suggestions_endpoint:
|
||||
result["suggestions_endpoint"] = search_field.suggestions_endpoint
|
||||
result["suggestions_min_query_length"] = search_field.suggestions_min_query_length
|
||||
elif isinstance(search_field, SelectSearchField):
|
||||
result["options"] = search_field.options
|
||||
elif isinstance(search_field, CheckboxSearchField):
|
||||
result["default"] = search_field.default
|
||||
elif isinstance(search_field, DynamicSelectSearchField):
|
||||
result["options_endpoint"] = search_field.options_endpoint
|
||||
|
||||
return result
|
||||
|
||||
@@ -147,10 +194,14 @@ class BookMetadata:
|
||||
search_title: Optional[str] = None # Cleaner title for search queries (provider-specific)
|
||||
search_author: Optional[str] = None # Cleaner author for search queries (provider-specific)
|
||||
|
||||
# Cover aspect ratio hint for the frontend ("portrait" or "square")
|
||||
cover_aspect: Optional[str] = None
|
||||
|
||||
# Provider-specific display fields for cards/lists
|
||||
display_fields: List[DisplayField] = field(default_factory=list)
|
||||
|
||||
# Series info (if book is part of a series)
|
||||
series_id: Optional[str] = None # Provider-specific series ID
|
||||
series_name: Optional[str] = None # Name of the series
|
||||
series_position: Optional[float] = None # This book's position (e.g., 3, 1.5 for novellas)
|
||||
series_count: Optional[int] = None # Total books in the series
|
||||
@@ -262,6 +313,8 @@ class SearchResult:
|
||||
page: int = 1
|
||||
total_found: int = 0 # Total matching results (if known)
|
||||
has_more: bool = False # True if more results available
|
||||
source_url: Optional[str] = None # External URL for the result set (e.g. Hardcover list page)
|
||||
source_title: Optional[str] = None # Display title for the result set (e.g. list name)
|
||||
|
||||
|
||||
class MetadataProvider(ABC):
|
||||
@@ -276,12 +329,14 @@ class MetadataProvider(ABC):
|
||||
requires_auth: True if API key/authentication is required
|
||||
supported_sorts: List of SortOrder values this provider supports
|
||||
search_fields: List of provider-specific search fields
|
||||
capabilities: Declarative capabilities exposed to shared UI code
|
||||
"""
|
||||
name: str
|
||||
display_name: str
|
||||
requires_auth: bool
|
||||
supported_sorts: List[SortOrder] = [SortOrder.RELEVANCE]
|
||||
search_fields: List[SearchField] = []
|
||||
capabilities: List[MetadataCapability] = []
|
||||
|
||||
@abstractmethod
|
||||
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
|
||||
@@ -315,6 +370,44 @@ class MetadataProvider(ABC):
|
||||
has_more=has_more
|
||||
)
|
||||
|
||||
def get_search_field_options(
|
||||
self,
|
||||
field_key: str,
|
||||
query: Optional[str] = None,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Get dynamic options for a provider-specific search field."""
|
||||
return []
|
||||
|
||||
def get_book_targets(self, book_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get provider-managed list or status targets for a specific book."""
|
||||
raise NotImplementedError(f"{self.display_name} does not support book targets")
|
||||
|
||||
def get_book_targets_batch(self, book_ids: List[str]) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get provider-managed targets for multiple books.
|
||||
|
||||
Returns a dict mapping each book_id to its list of target options.
|
||||
Default implementation calls get_book_targets per book.
|
||||
"""
|
||||
results: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for book_id in book_ids:
|
||||
try:
|
||||
results[book_id] = self.get_book_targets(book_id)
|
||||
except (NotImplementedError, ValueError):
|
||||
results[book_id] = []
|
||||
return results
|
||||
|
||||
def set_book_target_state(
|
||||
self,
|
||||
book_id: str,
|
||||
target: str,
|
||||
selected: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""Set whether a book belongs to a provider-managed list or shelf.
|
||||
|
||||
Returns a dict with at least ``{"changed": bool}``.
|
||||
"""
|
||||
raise NotImplementedError(f"{self.display_name} does not support book targets")
|
||||
|
||||
|
||||
# Provider registry
|
||||
_PROVIDERS: Dict[str, Type[MetadataProvider]] = {}
|
||||
@@ -393,7 +486,10 @@ def get_enabled_providers() -> List[str]:
|
||||
return [name for name in _PROVIDERS if is_provider_enabled(name)]
|
||||
|
||||
|
||||
def get_configured_provider(content_type: str = "ebook") -> Optional[MetadataProvider]:
|
||||
def get_configured_provider(
|
||||
content_type: str = "ebook",
|
||||
user_id: Optional[int] = None,
|
||||
) -> Optional[MetadataProvider]:
|
||||
"""Get the currently configured metadata provider for the content type."""
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
@@ -402,11 +498,11 @@ def get_configured_provider(content_type: str = "ebook") -> Optional[MetadataPro
|
||||
|
||||
# For audiobooks, try audiobook-specific provider first, then fall back to main provider
|
||||
if content_type == "audiobook":
|
||||
metadata_provider = app_config.get("METADATA_PROVIDER_AUDIOBOOK", "")
|
||||
metadata_provider = app_config.get("METADATA_PROVIDER_AUDIOBOOK", "", user_id=user_id)
|
||||
if not metadata_provider:
|
||||
metadata_provider = app_config.get("METADATA_PROVIDER", "")
|
||||
metadata_provider = app_config.get("METADATA_PROVIDER", "", user_id=user_id)
|
||||
else:
|
||||
metadata_provider = app_config.get("METADATA_PROVIDER", "")
|
||||
metadata_provider = app_config.get("METADATA_PROVIDER", "", user_id=user_id)
|
||||
|
||||
if not metadata_provider:
|
||||
return None
|
||||
@@ -422,17 +518,44 @@ def get_configured_provider(content_type: str = "ebook") -> Optional[MetadataPro
|
||||
return get_provider(metadata_provider, **kwargs)
|
||||
|
||||
|
||||
def _get_configured_provider_name() -> str:
|
||||
"""Get the currently configured metadata provider name from config."""
|
||||
def get_configured_provider_name(
|
||||
content_type: str = "ebook",
|
||||
user_id: Optional[int] = None,
|
||||
fallback_to_main: bool = True,
|
||||
) -> str:
|
||||
"""Get the configured metadata provider name for a content type."""
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
app_config.refresh()
|
||||
return app_config.get("METADATA_PROVIDER", "")
|
||||
|
||||
if content_type == "combined":
|
||||
combined_provider = app_config.get(
|
||||
"METADATA_PROVIDER_COMBINED",
|
||||
"",
|
||||
user_id=user_id,
|
||||
)
|
||||
if combined_provider or not fallback_to_main:
|
||||
return combined_provider
|
||||
|
||||
if content_type == "audiobook":
|
||||
audiobook_provider = app_config.get(
|
||||
"METADATA_PROVIDER_AUDIOBOOK",
|
||||
"",
|
||||
user_id=user_id,
|
||||
)
|
||||
if audiobook_provider or not fallback_to_main:
|
||||
return audiobook_provider
|
||||
|
||||
return app_config.get("METADATA_PROVIDER", "", user_id=user_id)
|
||||
|
||||
|
||||
def get_provider_sort_options(provider_name: Optional[str] = None) -> List[Dict[str, str]]:
|
||||
def get_provider_sort_options(
|
||||
provider_name: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Get sort options for a metadata provider as {value, label} dicts."""
|
||||
if provider_name is None:
|
||||
provider_name = _get_configured_provider_name()
|
||||
provider_name = get_configured_provider_name(user_id=user_id)
|
||||
|
||||
if provider_name and provider_name in _PROVIDERS:
|
||||
provider_class = _PROVIDERS[provider_name]
|
||||
@@ -446,10 +569,13 @@ def get_provider_sort_options(provider_name: Optional[str] = None) -> List[Dict[
|
||||
]
|
||||
|
||||
|
||||
def get_provider_search_fields(provider_name: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
def get_provider_search_fields(
|
||||
provider_name: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get search fields for a metadata provider as serialized dicts."""
|
||||
if provider_name is None:
|
||||
provider_name = _get_configured_provider_name()
|
||||
provider_name = get_configured_provider_name(user_id=user_id)
|
||||
|
||||
if provider_name and provider_name in _PROVIDERS:
|
||||
provider_class = _PROVIDERS[provider_name]
|
||||
@@ -460,19 +586,39 @@ def get_provider_search_fields(provider_name: Optional[str] = None) -> List[Dict
|
||||
return [serialize_search_field(f) for f in fields]
|
||||
|
||||
|
||||
def get_provider_default_sort(provider_name: Optional[str] = None) -> str:
|
||||
def get_provider_capabilities(
|
||||
provider_name: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get declarative capabilities for a metadata provider."""
|
||||
if provider_name is None:
|
||||
provider_name = get_configured_provider_name(user_id=user_id)
|
||||
|
||||
if provider_name and provider_name in _PROVIDERS:
|
||||
provider_class = _PROVIDERS[provider_name]
|
||||
capabilities = getattr(provider_class, "capabilities", [])
|
||||
else:
|
||||
capabilities = []
|
||||
|
||||
return [serialize_metadata_capability(capability) for capability in capabilities]
|
||||
|
||||
|
||||
def get_provider_default_sort(
|
||||
provider_name: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Get the default sort order for a metadata provider."""
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
if provider_name is None:
|
||||
provider_name = _get_configured_provider_name()
|
||||
provider_name = get_configured_provider_name(user_id=user_id)
|
||||
|
||||
if not provider_name:
|
||||
return "relevance"
|
||||
|
||||
# Look up provider-specific default sort setting
|
||||
setting_key = f"{provider_name.upper()}_DEFAULT_SORT"
|
||||
return app_config.get(setting_key, "relevance")
|
||||
return app_config.get(setting_key, "relevance", user_id=user_id)
|
||||
|
||||
|
||||
def sync_metadata_provider_selection() -> None:
|
||||
@@ -502,7 +648,7 @@ def sync_metadata_provider_selection() -> None:
|
||||
general_config = load_config_file("general")
|
||||
general_config["METADATA_PROVIDER"] = new_provider
|
||||
save_config_file("general", general_config)
|
||||
app_config.refresh()
|
||||
app_config.refresh(force=True)
|
||||
|
||||
|
||||
# Import provider implementations to trigger registration
|
||||
@@ -521,3 +667,4 @@ try:
|
||||
from shelfmark.metadata_providers import googlebooks # noqa: F401, E402
|
||||
except ImportError:
|
||||
pass # Google Books provider is optional
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import List, Optional, Dict, Type, Callable, Literal, Any, TYPE_CHECKING
|
||||
|
||||
@@ -21,6 +22,35 @@ class ReleaseProtocol(str, Enum):
|
||||
DCC = "dcc" # IRC DCC
|
||||
|
||||
|
||||
class SourceUnavailableError(Exception):
|
||||
"""Raised when a source is configured but currently unreachable."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowseRecord:
|
||||
"""Source-native browse/search record used before normalization to Release."""
|
||||
id: str
|
||||
title: str
|
||||
source: 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
|
||||
added_time: Optional[float] = None
|
||||
source_url: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Release:
|
||||
"""A downloadable release - all sources return this same structure."""
|
||||
@@ -281,6 +311,23 @@ class ReleaseSource(ABC):
|
||||
"""Get column configuration for release list UI. Override for custom columns."""
|
||||
return _default_column_config()
|
||||
|
||||
def get_record(
|
||||
self,
|
||||
record_id: str,
|
||||
*,
|
||||
fetch_download_count: bool = True,
|
||||
) -> Optional[BrowseRecord]:
|
||||
"""Resolve a source-native record for browse flows."""
|
||||
raise NotImplementedError(f"{self.display_name} does not support record lookup")
|
||||
|
||||
def search_results_are_releases(self) -> bool:
|
||||
"""Whether source-native browse results already represent concrete releases."""
|
||||
return False
|
||||
|
||||
def get_destination_override(self, task: DownloadTask) -> Optional[Path]:
|
||||
"""Return a source-specific destination override for a queued download."""
|
||||
return None
|
||||
|
||||
|
||||
class DownloadHandler(ABC):
|
||||
"""Interface for executing downloads.
|
||||
@@ -364,6 +411,7 @@ def list_available_sources() -> List[dict]:
|
||||
"display_name": instance.display_name,
|
||||
"enabled": instance.is_available(),
|
||||
"supported_content_types": getattr(instance, 'supported_content_types', ["ebook", "audiobook"]),
|
||||
"browse_results_are_releases": instance.search_results_are_releases(),
|
||||
"can_be_default": getattr(instance, 'can_be_default', True),
|
||||
})
|
||||
return result
|
||||
@@ -376,6 +424,49 @@ def get_source_display_name(name: str) -> str:
|
||||
return name.replace('_', ' ').title()
|
||||
|
||||
|
||||
def browse_record_to_book_metadata(
|
||||
record: BrowseRecord,
|
||||
*,
|
||||
title_override: Optional[str] = None,
|
||||
author_override: Optional[str] = None,
|
||||
) -> BookMetadata:
|
||||
"""Convert a source-native browse record into generic book metadata."""
|
||||
resolved_title = title_override or str(record.title or "").strip() or "Unknown title"
|
||||
resolved_author = author_override or str(record.author or "").strip()
|
||||
authors = [part.strip() for part in resolved_author.split(",") if part.strip()]
|
||||
publish_year = None
|
||||
|
||||
if isinstance(record.year, int):
|
||||
publish_year = record.year
|
||||
elif isinstance(record.year, str):
|
||||
normalized_year = record.year.strip()
|
||||
if normalized_year.isdigit():
|
||||
publish_year = int(normalized_year)
|
||||
|
||||
return BookMetadata(
|
||||
provider=record.source,
|
||||
provider_id=record.id,
|
||||
provider_display_name=get_source_display_name(record.source),
|
||||
title=resolved_title,
|
||||
search_title=resolved_title,
|
||||
search_author=resolved_author or None,
|
||||
authors=authors,
|
||||
cover_url=record.preview,
|
||||
description=record.description,
|
||||
publisher=record.publisher,
|
||||
publish_year=publish_year,
|
||||
language=record.language,
|
||||
source_url=record.source_url,
|
||||
)
|
||||
|
||||
|
||||
def source_results_are_releases(name: str) -> bool:
|
||||
"""Whether a source's browse/search results already map to concrete releases."""
|
||||
if name not in _SOURCES:
|
||||
return False
|
||||
return _SOURCES[name]().search_results_are_releases()
|
||||
|
||||
|
||||
# Import source implementations to trigger registration
|
||||
# These must be imported AFTER the base classes and registry are defined
|
||||
from shelfmark.release_sources import direct_download # noqa: F401, E402
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Direct download source - Anna's Archive/Libgen with fallback cascade."""
|
||||
|
||||
from dataclasses import replace
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
@@ -17,15 +18,17 @@ from shelfmark.download import http as downloader
|
||||
from shelfmark.download import network
|
||||
from shelfmark.config.env import DEBUG_SKIP_SOURCES, TMP_DIR
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.utils import CONTENT_TYPES
|
||||
from shelfmark.core.utils import CONTENT_TYPES, get_aa_content_type_dir, is_audiobook as check_audiobook
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import BookInfo, SearchFilters, DownloadTask
|
||||
from shelfmark.core.models import SearchFilters, DownloadTask, build_filename
|
||||
from shelfmark.metadata_providers import BookMetadata, group_languages_by_localized_title
|
||||
from shelfmark.release_sources import (
|
||||
BrowseRecord,
|
||||
Release,
|
||||
ReleaseProtocol,
|
||||
ReleaseSource,
|
||||
DownloadHandler,
|
||||
SourceUnavailableError,
|
||||
register_source,
|
||||
register_handler,
|
||||
ReleaseColumnConfig,
|
||||
@@ -136,11 +139,11 @@ def _normalize_size(size_str: str) -> str:
|
||||
return _SIZE_UNIT_PATTERN.sub(lambda m: m.group(1).upper(), size_str.strip())
|
||||
|
||||
|
||||
class SearchUnavailable(Exception):
|
||||
class SearchUnavailable(SourceUnavailableError):
|
||||
"""Raised when Anna's Archive cannot be reached via any mirror/DNS."""
|
||||
|
||||
|
||||
def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
|
||||
def search_books(query: str, filters: SearchFilters) -> List[BrowseRecord]:
|
||||
"""Search for books matching the query.
|
||||
|
||||
Args:
|
||||
@@ -148,7 +151,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
|
||||
filters: Search filters (language, format, content type, etc.)
|
||||
|
||||
Returns:
|
||||
List[BookInfo]: List of matching books
|
||||
List[BrowseRecord]: List of matching books
|
||||
|
||||
Raises:
|
||||
SearchUnavailable: If Anna's Archive cannot be reached
|
||||
@@ -164,8 +167,8 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
|
||||
|
||||
filters_query = ""
|
||||
|
||||
for value in filters.lang if filters.lang is not None else config.BOOK_LANGUAGE:
|
||||
if value != "all":
|
||||
for value in filters.lang or []:
|
||||
if value and value != "all":
|
||||
filters_query += f"&lang={quote(value)}"
|
||||
|
||||
if filters.sort and filters.sort != "relevance":
|
||||
@@ -232,7 +235,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
|
||||
return books
|
||||
|
||||
|
||||
def get_book_info(book_id: str, fetch_download_count: bool = True) -> BookInfo:
|
||||
def get_book_info(book_id: str, fetch_download_count: bool = True) -> BrowseRecord:
|
||||
"""Get detailed information for a specific book.
|
||||
|
||||
Args:
|
||||
@@ -241,22 +244,22 @@ def get_book_info(book_id: str, fetch_download_count: bool = True) -> BookInfo:
|
||||
Only needed for display in DetailsModal, not for downloads.
|
||||
|
||||
Returns:
|
||||
BookInfo: Detailed book information including download URLs
|
||||
BrowseRecord: Detailed book information including download URLs
|
||||
"""
|
||||
url = f"{network.get_aa_base_url()}/md5/{book_id}"
|
||||
selector = network.AAMirrorSelector()
|
||||
html = downloader.html_get_page(url, selector=selector, allow_bypasser_fallback=False)
|
||||
|
||||
if not html:
|
||||
raise Exception(f"Failed to fetch book info for ID: {book_id}")
|
||||
raise SearchUnavailable("Unable to reach download source. Network restricted or mirrors are blocked.")
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
return _parse_book_info_page(soup, book_id, fetch_download_count)
|
||||
|
||||
|
||||
def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
|
||||
"""Parse a single search result row into a BookInfo object."""
|
||||
def _parse_search_result_row(row: Tag) -> Optional[BrowseRecord]:
|
||||
"""Parse a single search result row into a browse record."""
|
||||
try:
|
||||
if row.text.strip().lower().startswith("your ad here"):
|
||||
return None
|
||||
@@ -264,10 +267,11 @@ def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
|
||||
preview_img = cells[0].find("img")
|
||||
preview = preview_img["src"] if preview_img else None
|
||||
|
||||
return BookInfo(
|
||||
return BrowseRecord(
|
||||
id=row.find_all("a")[0]["href"].split("/")[-1],
|
||||
preview=preview,
|
||||
title=cells[1].find("span").next,
|
||||
source="direct_download",
|
||||
preview=preview,
|
||||
author=cells[2].find("span").next,
|
||||
publisher=cells[3].find("span").next,
|
||||
year=cells[4].find("span").next,
|
||||
@@ -281,8 +285,8 @@ def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_count: bool = True) -> BookInfo:
|
||||
"""Parse the book info page HTML into a BookInfo object."""
|
||||
def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_count: bool = True) -> BrowseRecord:
|
||||
"""Parse the book info page HTML into a browse record."""
|
||||
data = soup.select_one("body > main > div:nth-of-type(1)")
|
||||
|
||||
if not data:
|
||||
@@ -379,10 +383,11 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_coun
|
||||
# Extract basic information
|
||||
description = _extract_book_description(soup)
|
||||
|
||||
book_info = BookInfo(
|
||||
book_info = BrowseRecord(
|
||||
id=book_id,
|
||||
preview=preview,
|
||||
title=book_title,
|
||||
source="direct_download",
|
||||
preview=preview,
|
||||
content=content,
|
||||
publisher=(_find_in_divs(divs, "icon-[mdi--company]", is_class=True) or [""])[0],
|
||||
author=(_find_in_divs(divs, "icon-[mdi--user-edit]", is_class=True) or [""])[0],
|
||||
@@ -538,7 +543,7 @@ def _group_urls_by_source(urls: List[str], urls_by_source: Dict[str, List[str]])
|
||||
urls_by_source.setdefault(source_type, []).append(url)
|
||||
|
||||
|
||||
def _fetch_aa_page_urls(book_info: BookInfo, urls_by_source: Dict[str, List[str]]) -> None:
|
||||
def _fetch_aa_page_urls(book_info: BrowseRecord, urls_by_source: Dict[str, List[str]]) -> None:
|
||||
"""Fetch and parse AA page, populating urls_by_source dict.
|
||||
|
||||
Groups existing book_info.download_urls by source type. If book_info
|
||||
@@ -557,7 +562,7 @@ def _fetch_aa_page_urls(book_info: BookInfo, urls_by_source: Dict[str, List[str]
|
||||
|
||||
def _get_urls_for_source(
|
||||
source_id: str,
|
||||
book_info: BookInfo,
|
||||
book_info: BrowseRecord,
|
||||
selector: network.AAMirrorSelector,
|
||||
cancel_flag: Optional[Event],
|
||||
status_callback: Optional[Callable[[str, Optional[str]], None]],
|
||||
@@ -608,7 +613,7 @@ def _get_urls_for_source(
|
||||
def _try_download_url(
|
||||
url: str,
|
||||
source_id: str,
|
||||
book_info: BookInfo,
|
||||
book_info: BrowseRecord,
|
||||
book_path: Path,
|
||||
progress_callback: Optional[Callable[[float], None]],
|
||||
cancel_flag: Optional[Event],
|
||||
@@ -747,7 +752,7 @@ def _extract_libgen_download_url(link: str, cancel_flag: Optional[Event] = None)
|
||||
|
||||
|
||||
def _download_book(
|
||||
book_info: BookInfo,
|
||||
book_info: BrowseRecord,
|
||||
book_path: Path,
|
||||
progress_callback: Optional[Callable[[float], None]] = None,
|
||||
cancel_flag: Optional[Event] = None,
|
||||
@@ -1046,33 +1051,32 @@ def _extract_countdown_seconds(soup: BeautifulSoup, html_str: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _book_info_to_release(book_info: BookInfo) -> Release:
|
||||
"""Convert a BookInfo object to a Release object.
|
||||
def _browse_record_to_release(record: BrowseRecord) -> Release:
|
||||
"""Convert a browse record to a Release object.
|
||||
|
||||
This bridges the existing BookInfo model (which combines metadata + release info)
|
||||
to the new Release model (release info only).
|
||||
This bridges the direct source's browse data to the generic release model.
|
||||
"""
|
||||
return Release(
|
||||
source="direct_download",
|
||||
source_id=book_info.id,
|
||||
title=book_info.title,
|
||||
format=book_info.format,
|
||||
language=book_info.language, # Top-level language for filtering
|
||||
size=book_info.size,
|
||||
download_url=book_info.download_urls[0] if book_info.download_urls else None,
|
||||
info_url=f"{network.get_aa_base_url()}/md5/{book_info.id}",
|
||||
source=record.source,
|
||||
source_id=record.id,
|
||||
title=record.title,
|
||||
format=record.format,
|
||||
language=record.language, # Top-level language for filtering
|
||||
size=record.size,
|
||||
download_url=record.download_urls[0] if record.download_urls else None,
|
||||
info_url=f"{network.get_aa_base_url()}/md5/{record.id}",
|
||||
protocol=ReleaseProtocol.HTTP,
|
||||
indexer="Direct Download",
|
||||
content_type=book_info.content, # Preserve content type from source
|
||||
content_type=record.content, # Preserve content type from source
|
||||
extra={
|
||||
"author": book_info.author,
|
||||
"publisher": book_info.publisher,
|
||||
"year": book_info.year,
|
||||
"language": book_info.language,
|
||||
"preview": book_info.preview,
|
||||
"description": book_info.description,
|
||||
"download_urls": book_info.download_urls,
|
||||
"info": book_info.info,
|
||||
"author": record.author,
|
||||
"publisher": record.publisher,
|
||||
"year": record.year,
|
||||
"language": record.language,
|
||||
"preview": record.preview,
|
||||
"description": record.description,
|
||||
"download_urls": record.download_urls,
|
||||
"info": record.info,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1140,6 +1144,42 @@ class DirectDownloadSource(ReleaseSource):
|
||||
supported_filters=["format", "language"], # AA has reliable language metadata
|
||||
)
|
||||
|
||||
def get_record(
|
||||
self,
|
||||
record_id: str,
|
||||
*,
|
||||
fetch_download_count: bool = True,
|
||||
) -> Optional[BrowseRecord]:
|
||||
"""Resolve a direct-download record for direct-mode info/download flows."""
|
||||
return get_book_info(record_id, fetch_download_count=fetch_download_count)
|
||||
|
||||
def search_results_are_releases(self) -> bool:
|
||||
"""Direct search results already represent concrete downloadable releases."""
|
||||
return True
|
||||
|
||||
def get_destination_override(self, task: DownloadTask) -> Optional[Path]:
|
||||
"""Apply Anna's Archive content-type routing when configured."""
|
||||
if check_audiobook(task.content_type):
|
||||
return None
|
||||
return get_aa_content_type_dir(task.content_type)
|
||||
|
||||
def _search_books_with_language_fallback(
|
||||
self,
|
||||
query: str,
|
||||
filters: SearchFilters,
|
||||
*,
|
||||
search_label: str,
|
||||
) -> List[BrowseRecord]:
|
||||
"""Retry AA queries without a language filter when filtered search returns nothing."""
|
||||
results = search_books(query, filters)
|
||||
if results or not filters.lang:
|
||||
return results
|
||||
|
||||
logger.debug(
|
||||
f"No {search_label} results with langs={filters.lang}, retrying without language filter"
|
||||
)
|
||||
return search_books(query, replace(filters, lang=None))
|
||||
|
||||
def search(
|
||||
self,
|
||||
book: BookMetadata,
|
||||
@@ -1164,6 +1204,15 @@ class DirectDownloadSource(ReleaseSource):
|
||||
# Reset search type tracking
|
||||
self._last_search_type = "title_author"
|
||||
|
||||
if plan.source_filters is not None:
|
||||
query = plan.manual_query or ""
|
||||
logger.debug(f"Searching direct_download: source_query='{query}', langs={lang_filter}")
|
||||
filters = plan.source_filters or SearchFilters()
|
||||
filters.lang = lang_filter if lang_filter is not None else (filters.lang or [])
|
||||
results = self._search_books_with_language_fallback(query, filters, search_label="manual")
|
||||
self._last_search_type = "manual" if query else "title_author"
|
||||
return [_browse_record_to_release(record) for record in results]
|
||||
|
||||
# ISBN search first (unless expand_search requested)
|
||||
if plan.manual_query:
|
||||
expand_search = True
|
||||
@@ -1179,7 +1228,7 @@ class DirectDownloadSource(ReleaseSource):
|
||||
if results:
|
||||
logger.info(f"Found {len(results)} releases via ISBN")
|
||||
self._last_search_type = "isbn"
|
||||
return [_book_info_to_release(bi) for bi in results]
|
||||
return [_browse_record_to_release(record) for record in results]
|
||||
logger.debug("No ISBN results, falling back to title+author")
|
||||
except SearchUnavailable:
|
||||
raise
|
||||
@@ -1192,7 +1241,7 @@ class DirectDownloadSource(ReleaseSource):
|
||||
|
||||
# Execute searches with deduplication
|
||||
seen_ids: set = set()
|
||||
all_results: List[BookInfo] = []
|
||||
all_results: List[BrowseRecord] = []
|
||||
|
||||
for title, langs in searches:
|
||||
query = f"{title} {author}".strip()
|
||||
@@ -1211,8 +1260,26 @@ class DirectDownloadSource(ReleaseSource):
|
||||
except Exception as e:
|
||||
logger.error(f"Search error: {e}")
|
||||
|
||||
if not all_results and any(langs for _, langs in searches):
|
||||
logger.debug("No title+author results with language filter, retrying without language filter")
|
||||
for title, _langs in searches:
|
||||
query = f"{title} {author}".strip()
|
||||
if not query:
|
||||
continue
|
||||
|
||||
logger.debug(f"Searching direct_download: title_author='{query}', langs=[]")
|
||||
try:
|
||||
for bi in search_books(query, SearchFilters()):
|
||||
if bi.id not in seen_ids:
|
||||
seen_ids.add(bi.id)
|
||||
all_results.append(bi)
|
||||
except SearchUnavailable:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Search error: {e}")
|
||||
|
||||
logger.info(f"Found {len(all_results)} releases via title+author")
|
||||
return [_book_info_to_release(bi) for bi in all_results]
|
||||
return [_browse_record_to_release(record) for record in all_results]
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Direct download is always available."""
|
||||
@@ -1258,13 +1325,15 @@ class DirectDownloadHandler(DownloadHandler):
|
||||
status_callback("cancelled", "Cancelled")
|
||||
return None
|
||||
|
||||
# Create BookInfo from task data - NO AA page fetch here
|
||||
# Create browse record from task data - NO AA page fetch here
|
||||
# AA page is fetched lazily by _fetch_aa_page_urls only when
|
||||
# we actually reach an AA slow source in the priority order
|
||||
book_info = BookInfo(
|
||||
book_info = BrowseRecord(
|
||||
id=task.task_id,
|
||||
title=task.title,
|
||||
source="direct_download",
|
||||
author=task.author,
|
||||
year=task.year,
|
||||
format=task.format,
|
||||
size=task.size,
|
||||
preview=task.preview,
|
||||
@@ -1288,13 +1357,13 @@ class DirectDownloadHandler(DownloadHandler):
|
||||
|
||||
def _execute_download(
|
||||
self,
|
||||
book_info: BookInfo,
|
||||
book_info: BrowseRecord,
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, Optional[str]], None]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Internal method to execute the download with fetched BookInfo.
|
||||
Internal method to execute the download with fetched browse record.
|
||||
|
||||
This contains the core download logic: cascade through sources,
|
||||
handle bypass, move to final location.
|
||||
@@ -1308,7 +1377,12 @@ class DirectDownloadHandler(DownloadHandler):
|
||||
if file_org == "none":
|
||||
book_name = f"{book_info.id}.{book_info.format or 'bin'}"
|
||||
else:
|
||||
book_name = book_info.get_filename()
|
||||
book_name = build_filename(
|
||||
book_info.title,
|
||||
book_info.author,
|
||||
book_info.year,
|
||||
book_info.format,
|
||||
)
|
||||
book_path = TMP_DIR / book_name
|
||||
|
||||
# Check cancellation before download
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""IRC release source plugin.
|
||||
|
||||
Searches and downloads ebooks from IRC channels via DCC protocol.
|
||||
Searches and downloads ebook and audiobook releases from IRC channels via DCC protocol.
|
||||
Available when IRC server, channel, and nickname are configured in settings.
|
||||
|
||||
Based on OpenBooks (https://github.com/evan-buss/openbooks).
|
||||
|
||||
@@ -4,7 +4,6 @@ Stores search results in CONFIG_DIR to survive container restarts.
|
||||
IRC searches are slow and resource-intensive, so we cache aggressively.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
@@ -14,6 +13,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from shelfmark.config import env
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.release_sources import Release, ReleaseProtocol
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -28,9 +28,10 @@ DEFAULT_CACHE_TTL = 30 * 24 * 60 * 60
|
||||
_cache_lock = Lock()
|
||||
|
||||
|
||||
def _generate_cache_key(provider: str, provider_id: str) -> str:
|
||||
"""Generate a cache key from provider and provider_id."""
|
||||
return f"{provider}:{provider_id}"
|
||||
def _generate_cache_key(provider: str, provider_id: str, content_type: Optional[str] = None) -> str:
|
||||
"""Generate a cache key from provider, provider_id, and content type."""
|
||||
normalized_content_type = "audiobook" if check_audiobook(content_type) else "ebook"
|
||||
return f"{provider}:{provider_id}:{normalized_content_type}"
|
||||
|
||||
|
||||
def _load_cache() -> Dict[str, Any]:
|
||||
@@ -74,6 +75,7 @@ def _dict_to_release(data: Dict[str, Any]) -> Release:
|
||||
def get_cached_results(
|
||||
provider: str,
|
||||
provider_id: str,
|
||||
content_type: Optional[str] = None,
|
||||
ttl_seconds: Optional[int] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -82,6 +84,7 @@ def get_cached_results(
|
||||
Args:
|
||||
provider: Metadata provider name (e.g., "hardcover", "openlibrary")
|
||||
provider_id: Book ID in the provider's system
|
||||
content_type: Search content type for cache isolation
|
||||
ttl_seconds: Cache TTL in seconds (from settings)
|
||||
|
||||
Returns:
|
||||
@@ -99,7 +102,7 @@ def get_cached_results(
|
||||
if ttl_seconds == 0:
|
||||
ttl_seconds = float('inf')
|
||||
|
||||
cache_key = _generate_cache_key(provider, provider_id)
|
||||
cache_key = _generate_cache_key(provider, provider_id, content_type)
|
||||
|
||||
with _cache_lock:
|
||||
cache = _load_cache()
|
||||
@@ -113,6 +116,7 @@ def get_cached_results(
|
||||
age = time.time() - cached_at
|
||||
|
||||
if age > ttl_seconds:
|
||||
title = entry.get("title", cache_key)
|
||||
logger.debug(f"IRC cache expired for '{title}' (age: {age:.0f}s > TTL: {ttl_seconds}s)")
|
||||
# Don't delete here - let cleanup handle it
|
||||
return None
|
||||
@@ -136,6 +140,7 @@ def cache_results(
|
||||
provider_id: str,
|
||||
title: str,
|
||||
releases: List[Release],
|
||||
content_type: Optional[str] = None,
|
||||
online_servers: Optional[List[str]] = None
|
||||
) -> None:
|
||||
"""
|
||||
@@ -146,9 +151,10 @@ def cache_results(
|
||||
provider_id: Book ID in the provider's system
|
||||
title: Book title (for logging/display)
|
||||
releases: List of Release objects from search
|
||||
content_type: Search content type for cache isolation
|
||||
online_servers: List of online server nicks (optional)
|
||||
"""
|
||||
cache_key = _generate_cache_key(provider, provider_id)
|
||||
cache_key = _generate_cache_key(provider, provider_id, content_type)
|
||||
|
||||
with _cache_lock:
|
||||
cache = _load_cache()
|
||||
@@ -159,6 +165,7 @@ def cache_results(
|
||||
cache["entries"][cache_key] = {
|
||||
"provider": provider,
|
||||
"provider_id": provider_id,
|
||||
"content_type": "audiobook" if check_audiobook(content_type) else "ebook",
|
||||
"title": title,
|
||||
"releases": [_release_to_dict(r) for r in releases],
|
||||
"online_servers": list(online_servers) if online_servers else [],
|
||||
@@ -169,18 +176,19 @@ def cache_results(
|
||||
logger.info(f"Cached {len(releases)} IRC releases for '{title}'")
|
||||
|
||||
|
||||
def invalidate_cache(provider: str, provider_id: str) -> bool:
|
||||
def invalidate_cache(provider: str, provider_id: str, content_type: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Remove a specific entry from the cache.
|
||||
|
||||
Args:
|
||||
provider: Metadata provider name
|
||||
provider_id: Book ID in the provider's system
|
||||
content_type: Search content type for cache isolation
|
||||
|
||||
Returns:
|
||||
True if entry was found and removed
|
||||
"""
|
||||
cache_key = _generate_cache_key(provider, provider_id)
|
||||
cache_key = _generate_cache_key(provider, provider_id, content_type)
|
||||
|
||||
with _cache_lock:
|
||||
cache = _load_cache()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""IRC client implementation using raw sockets.
|
||||
|
||||
Minimal IRC client for ebook searches.
|
||||
Minimal IRC client for Shelfmark release searches.
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -64,7 +64,7 @@ class IRCConnectionError(IRCError):
|
||||
|
||||
|
||||
class IRCClient:
|
||||
"""Minimal IRC client for per-request ebook searches."""
|
||||
"""Minimal IRC client for per-request IRC release searches."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""IRC DCC download handler.
|
||||
|
||||
Handles downloading books via IRC DCC protocol.
|
||||
Handles downloading IRC releases via DCC protocol.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -29,7 +29,7 @@ class IRCDownloadHandler(DownloadHandler):
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> Optional[str]:
|
||||
"""Download a book via IRC DCC. task.task_id contains the IRC request string."""
|
||||
"""Download a release via IRC DCC. task.task_id contains the IRC request string."""
|
||||
download_request = task.task_id
|
||||
logger.info(f"IRC download: {download_request[:60]}...")
|
||||
|
||||
|
||||
@@ -11,14 +11,13 @@ from typing import Optional
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# All recognized formats for parsing IRC result lines.
|
||||
# This comprehensive list is used to identify file extensions in results.
|
||||
# User's configured formats are used separately for filtering.
|
||||
# Note: IRC source currently only supports ebooks, but audiobook formats
|
||||
# are included for future-proofing and format detection consistency.
|
||||
# User-configured formats are used separately for filtering.
|
||||
ALL_RECOGNIZED_FORMATS = {
|
||||
# Ebook formats
|
||||
'epub', 'mobi', 'azw3', 'azw', 'pdf', 'doc', 'docx',
|
||||
@@ -29,9 +28,13 @@ ALL_RECOGNIZED_FORMATS = {
|
||||
}
|
||||
|
||||
|
||||
def _get_supported_formats() -> set[str]:
|
||||
"""Get user's configured supported formats from settings."""
|
||||
formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"])
|
||||
def _get_supported_formats(content_type: Optional[str] = None) -> set[str]:
|
||||
"""Get the supported formats for the requested content type."""
|
||||
if check_audiobook(content_type):
|
||||
formats = config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"])
|
||||
else:
|
||||
formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"])
|
||||
|
||||
if isinstance(formats, str):
|
||||
return {fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()}
|
||||
return {fmt.lower() for fmt in formats}
|
||||
@@ -140,10 +143,10 @@ def parse_result_line(line: str) -> Optional[SearchResult]:
|
||||
return None
|
||||
|
||||
|
||||
def parse_results_file(content: str) -> list[SearchResult]:
|
||||
def parse_results_file(content: str, content_type: Optional[str] = None) -> list[SearchResult]:
|
||||
"""Parse a search results file into SearchResult objects."""
|
||||
results = []
|
||||
supported = _get_supported_formats()
|
||||
supported = _get_supported_formats(content_type)
|
||||
|
||||
for line in content.splitlines():
|
||||
result = parse_result_line(line)
|
||||
|
||||
@@ -39,7 +39,7 @@ def irc_settings():
|
||||
key="heading",
|
||||
title="IRC",
|
||||
description=(
|
||||
"Search and download books from IRC ebook channels. "
|
||||
"Search and download ebook and audiobook releases from IRC channels. "
|
||||
"This source connects via IRC and uses DCC for file transfers. "
|
||||
"Configure the connection details below to enable IRC search. "
|
||||
"Note: DCC requires direct TCP connections to arbitrary ports, "
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""IRC release source plugin.
|
||||
|
||||
Searches IRC ebook channels for book releases.
|
||||
Searches IRC channels for ebook and audiobook releases.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
@@ -66,11 +66,11 @@ def _enforce_rate_limit() -> None:
|
||||
|
||||
@register_source("irc")
|
||||
class IRCReleaseSource(ReleaseSource):
|
||||
"""Search IRC channels for book releases."""
|
||||
"""Search IRC channels for ebook and audiobook releases."""
|
||||
|
||||
name = "irc"
|
||||
display_name = "IRC"
|
||||
supported_content_types = ["ebook"] # IRC only supports ebooks
|
||||
supported_content_types = ["ebook", "audiobook"]
|
||||
can_be_default = False # Exclude from default source options (requires deliberate selection)
|
||||
|
||||
def __init__(self):
|
||||
@@ -141,7 +141,7 @@ class IRCReleaseSource(ReleaseSource):
|
||||
|
||||
# Check cache first (unless expand_search/refresh is requested)
|
||||
if not expand_search:
|
||||
cached = get_cached_results(book.provider, book.provider_id)
|
||||
cached = get_cached_results(book.provider, book.provider_id, content_type=content_type)
|
||||
if cached:
|
||||
_emit_status("Using cached results", phase='complete')
|
||||
self._online_servers = set(cached.get("online_servers", []))
|
||||
@@ -199,7 +199,8 @@ class IRCReleaseSource(ReleaseSource):
|
||||
book.provider_id,
|
||||
book.title,
|
||||
[],
|
||||
list(self._online_servers) if self._online_servers else None
|
||||
content_type=content_type,
|
||||
online_servers=list(self._online_servers) if self._online_servers else None,
|
||||
)
|
||||
return []
|
||||
|
||||
@@ -219,8 +220,8 @@ class IRCReleaseSource(ReleaseSource):
|
||||
connection_manager.release_connection(client)
|
||||
|
||||
# Convert to Release objects
|
||||
results = parse_results_file(content)
|
||||
releases = self._convert_to_releases(results)
|
||||
results = parse_results_file(content, content_type=content_type)
|
||||
releases = self._convert_to_releases(results, content_type=content_type)
|
||||
|
||||
# Cache results
|
||||
cache_results(
|
||||
@@ -228,7 +229,8 @@ class IRCReleaseSource(ReleaseSource):
|
||||
book.provider_id,
|
||||
book.title,
|
||||
releases,
|
||||
list(self._online_servers) if self._online_servers else None
|
||||
content_type=content_type,
|
||||
online_servers=list(self._online_servers) if self._online_servers else None,
|
||||
)
|
||||
|
||||
return releases
|
||||
@@ -263,7 +265,7 @@ class IRCReleaseSource(ReleaseSource):
|
||||
return ' '.join(parts)
|
||||
|
||||
# Format priority for sorting (lower = higher priority)
|
||||
FORMAT_PRIORITY = {
|
||||
EBOOK_FORMAT_PRIORITY = {
|
||||
'epub': 0,
|
||||
'mobi': 1,
|
||||
'azw3': 2,
|
||||
@@ -283,10 +285,33 @@ class IRCReleaseSource(ReleaseSource):
|
||||
'zip': 16,
|
||||
}
|
||||
|
||||
def _convert_to_releases(self, results: List[SearchResult]) -> List[Release]:
|
||||
AUDIOBOOK_FORMAT_PRIORITY = {
|
||||
'm4b': 0,
|
||||
'mp3': 1,
|
||||
'm4a': 2,
|
||||
'flac': 3,
|
||||
'opus': 4,
|
||||
'ogg': 5,
|
||||
'aac': 6,
|
||||
'wav': 7,
|
||||
'wma': 8,
|
||||
'rar': 9,
|
||||
'zip': 10,
|
||||
}
|
||||
|
||||
def _convert_to_releases(
|
||||
self,
|
||||
results: List[SearchResult],
|
||||
content_type: str = "ebook",
|
||||
) -> List[Release]:
|
||||
"""Convert parsed results to Release objects, sorted by online/format/server."""
|
||||
releases = []
|
||||
online_servers = self._online_servers if self._online_servers else set()
|
||||
format_priority_map = (
|
||||
self.AUDIOBOOK_FORMAT_PRIORITY
|
||||
if content_type == "audiobook"
|
||||
else self.EBOOK_FORMAT_PRIORITY
|
||||
)
|
||||
|
||||
for result in results:
|
||||
release = Release(
|
||||
@@ -298,6 +323,7 @@ class IRCReleaseSource(ReleaseSource):
|
||||
size_bytes=self._parse_size(result.size) if result.size else None,
|
||||
protocol=ReleaseProtocol.DCC,
|
||||
indexer=f"IRC:{result.server}",
|
||||
content_type=content_type,
|
||||
extra={
|
||||
"server": result.server,
|
||||
"author": result.author,
|
||||
@@ -311,7 +337,7 @@ class IRCReleaseSource(ReleaseSource):
|
||||
server = release.extra.get("server", "")
|
||||
is_online = server in online_servers
|
||||
fmt = release.format.lower() if release.format else ""
|
||||
format_priority = self.FORMAT_PRIORITY.get(fmt, 99)
|
||||
format_priority = format_priority_map.get(fmt, 99)
|
||||
return (
|
||||
0 if is_online else 1, # Online first
|
||||
format_priority, # Then by format
|
||||
|
||||
@@ -74,11 +74,19 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
release_name = prowlarr_result.get("title") or task.title or "Unknown"
|
||||
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
|
||||
|
||||
# Seed criteria from the indexer (Torznab attributes)
|
||||
raw_seed_time = prowlarr_result.get("minimumSeedTime")
|
||||
seeding_time_limit = int(raw_seed_time) if raw_seed_time is not None else None
|
||||
raw_ratio = prowlarr_result.get("minimumRatio")
|
||||
ratio_limit = float(raw_ratio) if raw_ratio is not None else None
|
||||
|
||||
return DownloadRequest(
|
||||
url=download_url,
|
||||
protocol=protocol,
|
||||
release_name=release_name,
|
||||
expected_hash=expected_hash,
|
||||
seeding_time_limit=seeding_time_limit,
|
||||
ratio_limit=ratio_limit,
|
||||
)
|
||||
|
||||
def _on_download_complete(self, task: DownloadTask) -> None:
|
||||
|
||||
@@ -7,6 +7,8 @@ from typing import List, Optional, TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||
|
||||
from shelfmark.core.search_plan import ReleaseSearchVariant
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
@@ -562,13 +564,15 @@ class ProwlarrSource(ReleaseSource):
|
||||
logger.warning("Prowlarr not configured - skipping search")
|
||||
return []
|
||||
|
||||
queries = [v.title for v in plan.title_variants if v.title]
|
||||
queries = [q for q in queries if q]
|
||||
variants = [v for v in plan.title_variants if v.title]
|
||||
|
||||
if not queries and plan.isbn_candidates:
|
||||
queries = list(plan.isbn_candidates)
|
||||
if not variants and plan.isbn_candidates:
|
||||
variants = [
|
||||
ReleaseSearchVariant(title=isbn, author="", languages=None)
|
||||
for isbn in plan.isbn_candidates
|
||||
]
|
||||
|
||||
if not queries:
|
||||
if not variants:
|
||||
logger.warning("No search query available for book")
|
||||
return []
|
||||
|
||||
@@ -601,13 +605,13 @@ class ProwlarrSource(ReleaseSource):
|
||||
query_type = "title"
|
||||
|
||||
indexer_desc = f"indexers={indexer_ids}" if indexer_ids else "all enabled indexers"
|
||||
if len(queries) == 1:
|
||||
if len(variants) == 1:
|
||||
logger.debug(
|
||||
f"Searching Prowlarr: {query_type}='{queries[0]}', {indexer_desc}, categories={categories}"
|
||||
f"Searching Prowlarr: {query_type}='{variants[0].title}', {indexer_desc}, categories={categories}"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Searching Prowlarr: {query_type} ({len(queries)} variants), {indexer_desc}, categories={categories}"
|
||||
f"Searching Prowlarr: {query_type} ({len(variants)} variants), {indexer_desc}, categories={categories}"
|
||||
)
|
||||
|
||||
# Identify indexers that should be enriched via Torznab/Newznab.
|
||||
@@ -616,9 +620,17 @@ class ProwlarrSource(ReleaseSource):
|
||||
if indexer_ids:
|
||||
non_enriched_indexer_ids = [i for i in indexer_ids if i not in enriched_indexer_ids]
|
||||
|
||||
def search_indexers(query: str, cats: Optional[List[int]]) -> List[dict]:
|
||||
"""Search indexers with given categories, collecting results."""
|
||||
def search_indexers(query: str, cats: Optional[List[int]], *, enriched_query: Optional[str] = None) -> List[dict]:
|
||||
"""Search indexers with given categories, collecting results.
|
||||
|
||||
Args:
|
||||
query: Query string for standard indexers (title only).
|
||||
cats: Category filter list.
|
||||
enriched_query: Optional query for enriched indexers (title + author).
|
||||
Falls back to ``query`` when not provided.
|
||||
"""
|
||||
results = []
|
||||
eq = enriched_query or query
|
||||
|
||||
# Search standard indexers via JSON endpoint.
|
||||
if indexer_ids:
|
||||
@@ -652,14 +664,15 @@ class ProwlarrSource(ReleaseSource):
|
||||
logger.warning(f"Search failed for all indexers: {e}")
|
||||
|
||||
# Search enriched indexers via Torznab/Newznab for richer metadata.
|
||||
# Use enriched_query (title + author) for better results on these indexers.
|
||||
for indexer_id in enriched_indexer_ids:
|
||||
raw = client.torznab_search(indexer_id=indexer_id, query=query, categories=cats, search_type="book")
|
||||
raw = client.torznab_search(indexer_id=indexer_id, query=eq, categories=cats, search_type="book")
|
||||
if raw:
|
||||
results.extend(raw)
|
||||
else:
|
||||
# Fallback to JSON search for enriched indexers if Torznab fails.
|
||||
try:
|
||||
raw_fallback = client.search(query=query, indexer_ids=[indexer_id], categories=cats)
|
||||
raw_fallback = client.search(query=eq, indexer_ids=[indexer_id], categories=cats)
|
||||
if raw_fallback:
|
||||
results.extend(raw_fallback)
|
||||
except Exception as e:
|
||||
@@ -679,18 +692,21 @@ class ProwlarrSource(ReleaseSource):
|
||||
seen_keys: set[str] = set()
|
||||
all_results: List[dict] = []
|
||||
|
||||
for idx, query in enumerate(queries, start=1):
|
||||
for idx, variant in enumerate(variants, start=1):
|
||||
_check_timeout()
|
||||
if len(queries) > 1:
|
||||
logger.debug(f"Prowlarr query {idx}/{len(queries)}: '{query}'")
|
||||
query = variant.title
|
||||
enriched_query = variant.query # title + author
|
||||
|
||||
raw_results = search_indexers(query=query, cats=categories)
|
||||
if len(variants) > 1:
|
||||
logger.debug(f"Prowlarr query {idx}/{len(variants)}: '{query}'")
|
||||
|
||||
raw_results = search_indexers(query=query, cats=categories, enriched_query=enriched_query)
|
||||
|
||||
# Auto-expand: if no results with categories and auto-expand enabled, retry without
|
||||
if not raw_results and categories and auto_expand_enabled:
|
||||
_check_timeout()
|
||||
logger.info(f"Prowlarr: no results for query '{query}' with category filter, auto-expanding search")
|
||||
raw_results = search_indexers(query=query, cats=None)
|
||||
raw_results = search_indexers(query=query, cats=None, enriched_query=enriched_query)
|
||||
self.last_search_type = "expanded"
|
||||
|
||||
for r in raw_results:
|
||||
|
||||
@@ -8,7 +8,9 @@ isn't available via Prowlarr's JSON search endpoint.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from defusedxml import ElementTree as DefusedElementTree
|
||||
from defusedxml.common import DefusedXmlException
|
||||
|
||||
|
||||
def _local_name(tag: str) -> str:
|
||||
@@ -67,8 +69,8 @@ def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError:
|
||||
root = DefusedElementTree.fromstring(xml_text)
|
||||
except (DefusedElementTree.ParseError, DefusedXmlException):
|
||||
return []
|
||||
|
||||
items = root.findall(".//item")
|
||||
@@ -168,4 +170,3 @@ def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]:
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
+2
-17
@@ -19,24 +19,9 @@
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="logo.png" />
|
||||
<title>Shelfmark</title>
|
||||
<script>
|
||||
// Apply theme immediately before first paint to prevent flash
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('preferred-theme') || 'auto';
|
||||
let theme = savedTheme;
|
||||
|
||||
if (savedTheme === 'auto') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Add class to prevent transitions on initial load
|
||||
document.documentElement.classList.add('preload');
|
||||
})();
|
||||
</script>
|
||||
<script src="theme-init.js"></script>
|
||||
</head>
|
||||
<body style="background: var(--bg); color: var(--text);">
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
Generated
+1216
-1896
File diff suppressed because it is too large
Load Diff
+14
-13
@@ -8,24 +8,25 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:unit": "npm run test:unit:build && node --experimental-specifier-resolution=node --test ../../.local/frontend-test-dist/tests/**/*.node.test.js",
|
||||
"test:unit:build": "tsc -p tsconfig.tests.json"
|
||||
"test:unit": "npm run test:unit:build && node --experimental-specifier-resolution=node --test ../../.local/frontend-test-dist/tests/*.node.test.js",
|
||||
"test:unit:build": "rm -rf ../../.local/frontend-test-dist && tsc -p tsconfig.tests.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.2",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0"
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Apply theme immediately before first paint to prevent flash.
|
||||
// This runs as a blocking script before the app bundle loads.
|
||||
(function() {
|
||||
var saved = localStorage.getItem('preferred-theme') || 'auto';
|
||||
var theme = saved === 'auto'
|
||||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: saved;
|
||||
|
||||
var dark = theme === 'dark';
|
||||
var bg = dark ? '#121212' : '#f8f8f8';
|
||||
var fg = dark ? '#fff' : '#333';
|
||||
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
|
||||
var s = document.createElement('style');
|
||||
s.id = 'theme-init';
|
||||
s.textContent = 'html,body,#root{background:' + bg + ';color:' + fg + '}';
|
||||
document.head.appendChild(s);
|
||||
|
||||
document.documentElement.classList.add('preload');
|
||||
})();
|
||||
+1463
-256
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,15 @@
|
||||
import { ReactNode, KeyboardEvent } from 'react';
|
||||
import { AdvancedFilterState, Language, MetadataSearchField } from '../types';
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
AdvancedFilterState,
|
||||
ContentType,
|
||||
Language,
|
||||
MetadataProviderSummary,
|
||||
SearchMode,
|
||||
} from '../types';
|
||||
import { normalizeLanguageSelection } from '../utils/languageFilters';
|
||||
import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
import { LanguageMultiSelect } from './LanguageMultiSelect';
|
||||
import { DropdownList } from './DropdownList';
|
||||
import { CONTENT_OPTIONS } from '../data/filterOptions';
|
||||
import { SearchFieldRenderer } from './shared';
|
||||
|
||||
const FORMAT_TYPES = ['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr', 'zip', 'rar'] as const;
|
||||
|
||||
@@ -13,42 +17,45 @@ interface AdvancedFiltersProps {
|
||||
visible: boolean;
|
||||
bookLanguages: Language[];
|
||||
defaultLanguage: string[];
|
||||
supportedFormats: string[];
|
||||
filters: AdvancedFilterState;
|
||||
onFiltersChange: (updates: Partial<AdvancedFilterState>) => void;
|
||||
formClassName?: string;
|
||||
renderWrapper?: (form: ReactNode) => ReactNode;
|
||||
// Universal mode props
|
||||
metadataSearchFields?: MetadataSearchField[];
|
||||
searchFieldValues?: Record<string, string | number | boolean>;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
|
||||
// Submit handler for Enter key
|
||||
onSubmit?: () => void;
|
||||
searchMode: SearchMode;
|
||||
onSearchModeChange: (mode: SearchMode) => void;
|
||||
metadataProviders?: MetadataProviderSummary[];
|
||||
activeMetadataProvider?: string | null;
|
||||
onMetadataProviderChange?: (provider: string) => void;
|
||||
contentType?: ContentType;
|
||||
combinedMode?: boolean;
|
||||
isAdmin?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const SEARCH_MODE_OPTIONS = [
|
||||
{ value: 'direct', label: 'Direct', description: 'Search web sources for books and download directly. Works out of the box.' },
|
||||
{ value: 'universal', label: 'Universal', description: 'Metadata-based search with downloads from all sources. Book and Audiobook support.' },
|
||||
];
|
||||
|
||||
export const AdvancedFilters = ({
|
||||
visible,
|
||||
bookLanguages,
|
||||
defaultLanguage,
|
||||
supportedFormats,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
formClassName,
|
||||
renderWrapper,
|
||||
metadataSearchFields = [],
|
||||
searchFieldValues = {},
|
||||
onSearchFieldChange,
|
||||
onSubmit,
|
||||
searchMode,
|
||||
onSearchModeChange,
|
||||
metadataProviders = [],
|
||||
activeMetadataProvider,
|
||||
onMetadataProviderChange,
|
||||
contentType = 'ebook',
|
||||
combinedMode = false,
|
||||
isAdmin = false,
|
||||
onClose,
|
||||
}: AdvancedFiltersProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
const { isbn, author, title, lang, content, formats } = filters;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && onSubmit) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
const { lang, content, formats } = filters;
|
||||
|
||||
const handleLangChange = (next: string[]) => {
|
||||
const normalized = normalizeLanguageSelection(next);
|
||||
@@ -68,149 +75,106 @@ export const AdvancedFilters = ({
|
||||
const formatOptions = FORMAT_TYPES.map(format => ({
|
||||
value: format,
|
||||
label: format.toUpperCase(),
|
||||
disabled: !supportedFormats.includes(format),
|
||||
}));
|
||||
|
||||
const providerOptions = metadataProviders.map((provider) => {
|
||||
const details: string[] = [];
|
||||
if (!provider.enabled) details.push('Disabled in Settings');
|
||||
if (provider.enabled && !provider.available) details.push('Not configured');
|
||||
if (provider.requires_auth) details.push('API key required');
|
||||
|
||||
return {
|
||||
value: provider.name,
|
||||
label: provider.display_name,
|
||||
description: details.length > 0 ? details.join(' • ') : undefined,
|
||||
disabled: !provider.enabled || !provider.available,
|
||||
};
|
||||
});
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
// Universal search mode: render dynamic provider fields
|
||||
if (searchMode === 'universal') {
|
||||
// If no fields defined for this provider, don't show the section
|
||||
if (metadataSearchFields.length === 0) return null;
|
||||
const wrapperClassName = formClassName
|
||||
? 'px-2'
|
||||
: 'px-2 lg:ml-16 lg:w-[calc(50vw+4rem)]';
|
||||
|
||||
const universalForm = (
|
||||
<form
|
||||
id="search-filters"
|
||||
className={
|
||||
formClassName ??
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
|
||||
}
|
||||
>
|
||||
{metadataSearchFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{field.type !== 'CheckboxSearchField' && (
|
||||
<label htmlFor={`${field.key}-input`} className="block text-sm mb-1 opacity-80">
|
||||
{field.label}
|
||||
</label>
|
||||
)}
|
||||
<SearchFieldRenderer
|
||||
field={field}
|
||||
value={searchFieldValues[field.key] ?? (field.type === 'CheckboxSearchField' ? false : '')}
|
||||
onChange={(value) => onSearchFieldChange?.(field.key, value)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{field.description && (
|
||||
<p className="text-xs mt-1 opacity-60">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
);
|
||||
|
||||
const wrappedUniversalForm = renderWrapper ? (
|
||||
renderWrapper(universalForm)
|
||||
) : (
|
||||
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8">{universalForm}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return wrappedUniversalForm;
|
||||
}
|
||||
|
||||
// Direct download mode: render existing hardcoded filters
|
||||
const form = (
|
||||
<form
|
||||
id="search-filters"
|
||||
className={
|
||||
formClassName ??
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="isbn-input" className="block text-sm mb-1 opacity-80">
|
||||
ISBN
|
||||
</label>
|
||||
<input
|
||||
id="isbn-input"
|
||||
type="text"
|
||||
placeholder="ISBN"
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={isbn}
|
||||
onChange={e => {
|
||||
onFiltersChange({ isbn: e.target.value });
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="author-input" className="block text-sm mb-1 opacity-80">
|
||||
Author
|
||||
</label>
|
||||
<input
|
||||
id="author-input"
|
||||
type="text"
|
||||
placeholder="Author"
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={author}
|
||||
onChange={e => {
|
||||
onFiltersChange({ author: e.target.value });
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title-input" className="block text-sm mb-1 opacity-80">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id="title-input"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={title}
|
||||
onChange={e => {
|
||||
onFiltersChange({ title: e.target.value });
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<LanguageMultiSelect
|
||||
options={bookLanguages}
|
||||
value={lang}
|
||||
onChange={handleLangChange}
|
||||
defaultLanguageCodes={defaultLanguage}
|
||||
label="Language"
|
||||
/>
|
||||
const settingsForm = (
|
||||
<div className={wrapperClassName}>
|
||||
{onClose && (
|
||||
<div className="flex justify-end mb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-full hover-action transition-colors"
|
||||
aria-label="Close filters"
|
||||
title="Close filters"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<DropdownList
|
||||
label="Content"
|
||||
options={CONTENT_OPTIONS}
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder="All"
|
||||
label="Search Mode"
|
||||
options={SEARCH_MODE_OPTIONS}
|
||||
value={searchMode}
|
||||
onChange={(value) => {
|
||||
const next = Array.isArray(value) ? value[0] ?? 'direct' : value;
|
||||
onSearchModeChange(next === 'universal' ? 'universal' : 'direct');
|
||||
}}
|
||||
placeholder="Choose a mode"
|
||||
widthClassName="w-full"
|
||||
/>
|
||||
<div>
|
||||
|
||||
{searchMode === 'universal' && (
|
||||
<DropdownList
|
||||
label={combinedMode ? 'Combined Metadata Provider' : contentType === 'audiobook' ? 'Audiobook Metadata Provider' : 'Book Metadata Provider'}
|
||||
options={providerOptions}
|
||||
value={activeMetadataProvider ?? ''}
|
||||
onChange={(value) => {
|
||||
const next = Array.isArray(value) ? value[0] ?? '' : value;
|
||||
onMetadataProviderChange?.(next);
|
||||
}}
|
||||
placeholder="Choose a provider"
|
||||
widthClassName="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMode === 'direct' && (
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
id="search-filters"
|
||||
className={
|
||||
formClassName ??
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
}
|
||||
>
|
||||
<LanguageMultiSelect
|
||||
options={bookLanguages}
|
||||
value={lang}
|
||||
onChange={handleLangChange}
|
||||
defaultLanguageCodes={defaultLanguage}
|
||||
label="Language"
|
||||
/>
|
||||
<DropdownList
|
||||
label="Content"
|
||||
options={CONTENT_OPTIONS}
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder="All"
|
||||
/>
|
||||
<DropdownList
|
||||
label="Formats"
|
||||
placeholder="Any"
|
||||
@@ -221,17 +185,17 @@ export const AdvancedFilters = ({
|
||||
showCheckboxes
|
||||
keepOpenOnSelect
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
const wrappedForm = renderWrapper ? (
|
||||
renderWrapper(form)
|
||||
) : (
|
||||
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8">{form}</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return wrappedForm;
|
||||
return renderWrapper ? (
|
||||
renderWrapper(settingsForm)
|
||||
) : (
|
||||
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8">{settingsForm}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -102,8 +102,8 @@ export const BookDownloadButton = ({
|
||||
|
||||
const baseClasses =
|
||||
variant === 'icon'
|
||||
? 'flex items-center justify-center rounded-full transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500'
|
||||
: 'inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500';
|
||||
? 'flex items-center justify-center rounded-full transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500'
|
||||
: 'inline-flex items-center justify-center gap-1.5 rounded-sm text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500';
|
||||
|
||||
const sizeClass = variant === 'icon' ? iconVariantSizeClasses[size] : sizeClasses[size];
|
||||
const iconSizes = variant === 'icon' ? iconVariantIconSizes[size] : undefined;
|
||||
|
||||
@@ -169,7 +169,7 @@ export const BookGetButton = ({
|
||||
if (isIconVariant) {
|
||||
return (
|
||||
<button
|
||||
className={`flex items-center justify-center rounded-full transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${getButtonClasses()} ${className}`.trim()}
|
||||
className={`flex items-center justify-center rounded-full transition-all duration-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${getButtonClasses()} ${className}`.trim()}
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
style={style}
|
||||
@@ -182,7 +182,7 @@ export const BookGetButton = ({
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${widthClasses} ${getButtonClasses()} ${className}`.trim()}
|
||||
className={`inline-flex items-center justify-center gap-1.5 rounded-sm text-white transition-all duration-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${widthClasses} ${getButtonClasses()} ${className}`.trim()}
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
style={style}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { DropdownList, type DropdownListOption } from './DropdownList';
|
||||
import {
|
||||
setBookTargetState,
|
||||
type BookTargetOption,
|
||||
} from '../services/api';
|
||||
import { loadBookTargets } from '../utils/bookTargetLoader';
|
||||
import { emitBookTargetChange, onBookTargetChange } from '../utils/bookTargetEvents';
|
||||
|
||||
interface BookTargetDropdownProps {
|
||||
provider: string;
|
||||
bookId: string;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
widthClassName?: string;
|
||||
variant?: 'default' | 'pill' | 'icon';
|
||||
align?: 'left' | 'right' | 'auto';
|
||||
className?: string;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const stripCountSuffix = (label: string): string => {
|
||||
return label.replace(/\s+\(\d+\)\s*$/, '');
|
||||
};
|
||||
|
||||
const BookmarkIcon = ({ className = 'h-4 w-4' }: { className?: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
className={`${className} shrink-0`}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const renderSummary = (selectedOptions: DropdownListOption[]) => {
|
||||
const count = selectedOptions.length;
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<BookmarkIcon />
|
||||
<span>Hardcover Lists{count > 0 ? ` (${count})` : ''}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const STATUS_PREFIX = 'status:';
|
||||
|
||||
const isStatusTarget = (value: string): boolean => value.startsWith(STATUS_PREFIX);
|
||||
|
||||
const updateOptionChecked = (
|
||||
prev: BookTargetOption[],
|
||||
target: string,
|
||||
checked: boolean,
|
||||
): BookTargetOption[] =>
|
||||
prev.map((option) => {
|
||||
if (option.value === target) return { ...option, checked };
|
||||
// Statuses are mutually exclusive — uncheck other statuses when one is selected
|
||||
if (checked && isStatusTarget(target) && isStatusTarget(option.value)) {
|
||||
return { ...option, checked: false };
|
||||
}
|
||||
return option;
|
||||
});
|
||||
|
||||
export const BookTargetDropdown = ({
|
||||
provider,
|
||||
bookId,
|
||||
onShowToast,
|
||||
widthClassName = 'w-full sm:w-56',
|
||||
variant = 'default',
|
||||
align = 'auto',
|
||||
className,
|
||||
onOpenChange,
|
||||
}: BookTargetDropdownProps) => {
|
||||
const [options, setOptions] = useState<BookTargetOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [pendingTargets, setPendingTargets] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const loaded = await loadBookTargets(provider, bookId);
|
||||
if (!isMounted) return;
|
||||
setOptions(loaded);
|
||||
setLoadError(null);
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
const message = error instanceof Error ? error.message : 'Failed to load Hardcover lists';
|
||||
setOptions([]);
|
||||
setLoadError(message);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setLoadError(null);
|
||||
setPendingTargets(new Set());
|
||||
setIsLoading(true);
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [provider, bookId]);
|
||||
|
||||
// Sync from changes made by other BookTargetDropdown instances for the same book
|
||||
useEffect(() => {
|
||||
return onBookTargetChange((event) => {
|
||||
if (event.provider !== provider || event.bookId !== bookId) return;
|
||||
setOptions((prev) => updateOptionChecked(prev, event.target, event.selected));
|
||||
});
|
||||
}, [provider, bookId]);
|
||||
|
||||
const selectedValues = useMemo(
|
||||
() => options.filter((option) => option.checked).map((option) => option.value),
|
||||
[options],
|
||||
);
|
||||
|
||||
const dropdownOptions = useMemo<DropdownListOption[]>(() => {
|
||||
if (isLoading) {
|
||||
return [{ value: '__loading', label: 'Loading…', disabled: true }];
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return [{ value: '__error', label: loadError, disabled: true }];
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return [{ value: '__empty', label: 'No writable Hardcover targets', disabled: true }];
|
||||
}
|
||||
|
||||
return options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
group: option.group,
|
||||
disabled: !option.writable || pendingTargets.has(option.value),
|
||||
}));
|
||||
}, [isLoading, loadError, options, pendingTargets]);
|
||||
|
||||
const handleChange = useCallback((nextValue: string[] | string) => {
|
||||
if (!Array.isArray(nextValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSelected = new Set(nextValue);
|
||||
const currentSelected = new Set(selectedValues);
|
||||
const toggledTarget =
|
||||
nextValue.find((value) => !currentSelected.has(value))
|
||||
?? selectedValues.find((value) => !nextSelected.has(value));
|
||||
|
||||
if (!toggledTarget || pendingTargets.has(toggledTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = nextSelected.has(toggledTarget);
|
||||
const toggledOption = options.find((option) => option.value === toggledTarget);
|
||||
if (!toggledOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingTargets((prev) => new Set(prev).add(toggledTarget));
|
||||
setOptions((prev) => updateOptionChecked(prev, toggledTarget, selected));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await setBookTargetState(provider, bookId, toggledTarget, selected);
|
||||
setOptions((prev) => updateOptionChecked(prev, toggledTarget, result.selected));
|
||||
|
||||
if (result.changed) {
|
||||
emitBookTargetChange({
|
||||
provider,
|
||||
bookId,
|
||||
target: toggledTarget,
|
||||
selected: result.selected,
|
||||
});
|
||||
// When a status was implicitly deselected, sync other instances
|
||||
if (result.deselectedTarget) {
|
||||
setOptions((prev) => updateOptionChecked(prev, result.deselectedTarget!, false));
|
||||
emitBookTargetChange({
|
||||
provider,
|
||||
bookId,
|
||||
target: result.deselectedTarget,
|
||||
selected: false,
|
||||
});
|
||||
}
|
||||
const label = stripCountSuffix(toggledOption.label);
|
||||
onShowToast?.(
|
||||
`${result.selected ? 'Added to' : 'Removed from'} ${label}`,
|
||||
'success',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setOptions((prev) => updateOptionChecked(prev, toggledTarget, !selected));
|
||||
const message = error instanceof Error ? error.message : 'Failed to update Hardcover list';
|
||||
onShowToast?.(message, 'error');
|
||||
} finally {
|
||||
setPendingTargets((prev) => {
|
||||
const nextPending = new Set(prev);
|
||||
nextPending.delete(toggledTarget);
|
||||
return nextPending;
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [bookId, onShowToast, options, pendingTargets, provider, selectedValues]);
|
||||
|
||||
const customTrigger = variant === 'pill'
|
||||
? ({ toggle }: { isOpen: boolean; toggle: () => void }) => {
|
||||
const count = selectedValues.length;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full transition-colors text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 focus:outline-hidden`}
|
||||
>
|
||||
<BookmarkIcon className="w-3 h-3" />
|
||||
Hardcover Lists{count > 0 ? ` (${count})` : ''}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
: variant === 'icon'
|
||||
? ({ toggle }: { isOpen: boolean; toggle: () => void }) => {
|
||||
const count = selectedValues.length;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); toggle(); }}
|
||||
className={`flex items-center justify-center rounded-full transition-colors duration-200 focus:outline-hidden ${className ?? 'p-1.5 sm:p-2 text-gray-600 dark:text-gray-200 hover-action'}`}
|
||||
aria-label="Hardcover Lists"
|
||||
title={count > 0 ? `On ${count} Hardcover list${count > 1 ? 's' : ''}` : 'Hardcover Lists'}
|
||||
>
|
||||
<BookmarkIcon className={`w-4 h-4 sm:w-5 sm:h-5 ${count > 0 ? 'fill-current' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<DropdownList
|
||||
options={dropdownOptions}
|
||||
value={selectedValues}
|
||||
onChange={handleChange}
|
||||
placeholder={isLoading ? 'Loading…' : 'Hardcover'}
|
||||
widthClassName={variant !== 'default' ? 'w-auto' : widthClassName}
|
||||
buttonClassName={variant !== 'default' ? '' : 'py-1.5 leading-none'}
|
||||
panelClassName={variant !== 'default' ? 'w-56' : undefined}
|
||||
align={align}
|
||||
multiple
|
||||
showCheckboxes
|
||||
keepOpenOnSelect
|
||||
summaryFormatter={(selectedOptions) => renderSummary(selectedOptions)}
|
||||
renderTrigger={customTrigger}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -65,7 +65,7 @@ export const ConfigSetupBanner = ({
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
|
||||
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150
|
||||
${isClosing ? 'opacity-0' : 'opacity-100'}`}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
@@ -73,7 +73,7 @@ export const ConfigSetupBanner = ({
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`relative w-full max-w-lg rounded-xl
|
||||
border border-[var(--border-muted)] shadow-2xl
|
||||
border border-(--border-muted) shadow-2xl
|
||||
overflow-hidden
|
||||
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
@@ -82,13 +82,13 @@ export const ConfigSetupBanner = ({
|
||||
aria-label="Settings Setup Information"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border-muted)]">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-(--border-muted)">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{showContinueButton ? 'Config Volume Required' : 'New Feature: Settings Page'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors"
|
||||
className="p-1.5 rounded-lg hover:bg-(--hover-surface) transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
@@ -113,8 +113,8 @@ export const ConfigSetupBanner = ({
|
||||
</p>
|
||||
|
||||
{/* Code snippet */}
|
||||
<div className="rounded-lg overflow-hidden border border-[var(--border-muted)]">
|
||||
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-[var(--border-muted)]"
|
||||
<div className="rounded-lg overflow-hidden border border-(--border-muted)">
|
||||
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-(--border-muted)"
|
||||
style={{ background: 'var(--bg-soft)' }}>
|
||||
docker-compose.yml
|
||||
</div>
|
||||
@@ -139,22 +139,22 @@ export const ConfigSetupBanner = ({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-[var(--border-muted)] flex justify-end gap-3">
|
||||
<div className="px-5 py-4 border-t border-(--border-muted) flex justify-end gap-3">
|
||||
{showContinueButton ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-[var(--bg-soft)] border border-[var(--border-muted)]
|
||||
hover:bg-[var(--hover-surface)] transition-colors"
|
||||
bg-(--bg-soft) border border-(--border-muted)
|
||||
hover:bg-(--hover-surface) transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-[var(--primary-color)] text-white
|
||||
hover:bg-[var(--primary-dark)] transition-colors"
|
||||
bg-(--primary-color) text-white
|
||||
hover:bg-(--primary-dark) transition-colors"
|
||||
>
|
||||
Continue to Settings
|
||||
</button>
|
||||
@@ -163,8 +163,8 @@ export const ConfigSetupBanner = ({
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-[var(--primary-color)] text-white
|
||||
hover:bg-[var(--primary-dark)] transition-colors"
|
||||
bg-(--primary-color) text-white
|
||||
hover:bg-(--primary-dark) transition-colors"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
|
||||
@@ -2,17 +2,30 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Book, ButtonStateInfo, isMetadataBook } from '../types';
|
||||
import { isUserCancelledError } from '../utils/errors';
|
||||
import { BookTargetDropdown } from './BookTargetDropdown';
|
||||
import { bookSupportsTargets } from '../utils/bookTargetLoader';
|
||||
|
||||
interface DetailsModalProps {
|
||||
book: Book | null;
|
||||
onClose: () => void;
|
||||
onDownload: (book: Book) => Promise<void>;
|
||||
onFindDownloads?: (book: Book) => void; // For Universal mode
|
||||
onSearchSeries?: (seriesName: string) => void; // Callback to search for series
|
||||
onSearchSeries?: (seriesName: string, seriesId?: string) => void; // Callback to search for series
|
||||
buttonState: ButtonStateInfo;
|
||||
showReleaseSourceLinks?: boolean;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSearchSeries, buttonState }: DetailsModalProps) => {
|
||||
export const DetailsModal = ({
|
||||
book,
|
||||
onClose,
|
||||
onDownload,
|
||||
onFindDownloads,
|
||||
onSearchSeries,
|
||||
buttonState,
|
||||
showReleaseSourceLinks = true,
|
||||
onShowToast,
|
||||
}: DetailsModalProps) => {
|
||||
const [isQueuing, setIsQueuing] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
@@ -56,6 +69,8 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
}
|
||||
}, [book]);
|
||||
|
||||
const hasBookTargets = Boolean(book && isMetadataBook(book) && bookSupportsTargets(book));
|
||||
|
||||
if (!book && !isClosing) return null;
|
||||
if (!book) return null;
|
||||
|
||||
@@ -78,6 +93,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
|
||||
// Determine if this is a metadata book (Universal mode) vs a release (Direct Download)
|
||||
const isMetadata = isMetadataBook(book);
|
||||
const showBookSourceLink = Boolean(book.source_url) && (isMetadata || showReleaseSourceLinks);
|
||||
const metadataActionText =
|
||||
isMetadata && buttonState.state === 'download' && buttonState.text === 'Get'
|
||||
? 'Find Downloads'
|
||||
@@ -112,8 +128,11 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
// Use provider display name from backend, fall back to capitalized provider name
|
||||
const providerDisplay = book.provider_display_name
|
||||
|| (book.provider ? book.provider.charAt(0).toUpperCase() + book.provider.slice(1) : '');
|
||||
const isSquareCover = book.cover_aspect === 'square';
|
||||
const artworkMaxHeight = 'calc(90vh - 220px)';
|
||||
const artworkMaxWidth = 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
|
||||
const artworkMaxWidth = isSquareCover
|
||||
? 'min(45vw, 400px, calc(90vh - 220px))'
|
||||
: 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
|
||||
const additionalInfo =
|
||||
book.info && Object.keys(book.info).length > 0
|
||||
? Object.entries(book.info).filter(([key]) => {
|
||||
@@ -122,7 +141,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
})
|
||||
: [];
|
||||
const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo];
|
||||
const infoCardClass = 'rounded-2xl border border-[var(--border-muted)] px-4 py-3 text-sm bg-[var(--bg-soft)] sm:bg-[var(--bg)]';
|
||||
const infoCardClass = 'rounded-2xl border border-(--border-muted) px-4 py-3 text-sm bg-(--bg-soft) sm:bg-(--bg)';
|
||||
const infoLabelClass = 'text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400';
|
||||
const infoValueClass = 'text-gray-900 dark:text-gray-100';
|
||||
|
||||
@@ -134,13 +153,13 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`details-container w-full max-w-4xl h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
className={`details-container w-full h-full sm:h-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className="flex h-full sm:h-[90vh] sm:max-h-[90vh] flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] text-[var(--text)] shadow-none sm:shadow-2xl">
|
||||
<header className="flex items-start gap-4 border-b border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] px-5 py-4">
|
||||
<div className="flex h-full sm:h-[90vh] sm:max-h-[90vh] flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) text-(--text) shadow-none sm:shadow-2xl">
|
||||
<header className="flex items-start gap-4 border-b border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-4">
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Book</p>
|
||||
<h3 id={titleId} className="text-lg font-semibold leading-snug">
|
||||
@@ -185,7 +204,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex w-full items-center justify-center rounded-xl border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 p-6 text-sm text-gray-500 lg:h-full lg:max-w-none"
|
||||
className="flex w-full items-center justify-center rounded-xl border border-dashed border-(--border-muted) bg-(--bg)/60 p-6 text-sm text-gray-500 lg:h-full lg:max-w-none"
|
||||
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
|
||||
>
|
||||
No cover
|
||||
@@ -235,29 +254,15 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Other display fields (pages, editions) - Universal mode only */}
|
||||
{otherDisplayFields && otherDisplayFields.length > 0 && (
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{otherDisplayFields.map(field => (
|
||||
<span key={field.label} className="flex items-center gap-1.5">
|
||||
{field.icon === 'book' && (
|
||||
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
)}
|
||||
{field.icon === 'editions' && (
|
||||
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6.878V6a2.25 2.25 0 0 1 2.25-2.25h7.5A2.25 2.25 0 0 1 18 6v.878m-12 0c.235-.083.487-.128.75-.128h10.5c.263 0 .515.045.75.128m-12 0A2.25 2.25 0 0 0 4.5 9v.878m13.5-3A2.25 2.25 0 0 1 19.5 9v.878m0 0a2.246 2.246 0 0 0-.75-.128H5.25c-.263 0-.515.045-.75.128m15 0A2.25 2.25 0 0 1 21 12v6a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 18v-6c0-.98.626-1.813 1.5-2.122" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-gray-500 dark:text-gray-400">{field.label}:</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">{field.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Other display fields (length, narrator, format, etc.) - Universal mode only */}
|
||||
{otherDisplayFields && otherDisplayFields.map(field => (
|
||||
<div key={field.label} className={`${infoCardClass} space-y-1`}>
|
||||
<p className={infoLabelClass}>{field.label}</p>
|
||||
<p className={infoValueClass}>{field.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ISBN - Universal mode only */}
|
||||
{isMetadata && (book.isbn_13 || book.isbn_10) && (
|
||||
@@ -283,10 +288,10 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSearchSeries(book.series_name!);
|
||||
onSearchSeries(book.series_name!, book.series_id);
|
||||
handleClose();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors flex-shrink-0"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors shrink-0"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
@@ -316,17 +321,16 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
</div>
|
||||
|
||||
<footer
|
||||
className="border-t border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)] px-5 py-4"
|
||||
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
|
||||
className="border-t border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Source link - shown for both Universal and Direct Download modes */}
|
||||
{book.source_url && (
|
||||
{showBookSourceLink && (
|
||||
<a
|
||||
href={book.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--border-muted)] bg-[var(--bg)] px-3 py-2 text-xs font-medium text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-200"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
View on {isMetadata ? providerDisplay : "Source"}
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -339,22 +343,32 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{/* Action button - mirrors search result action state/flow */}
|
||||
<button
|
||||
onClick={isMetadata ? () => onFindDownloads?.(book) : handleDownload}
|
||||
disabled={isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'}
|
||||
className={`ml-auto rounded-full px-6 py-2.5 text-sm font-medium text-white transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isMetadata
|
||||
? buttonState.state === 'blocked'
|
||||
? 'bg-gray-500 focus:ring-gray-400'
|
||||
: 'bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500'
|
||||
: buttonState.state === 'blocked'
|
||||
? 'bg-gray-500 focus:ring-gray-400'
|
||||
: 'bg-sky-700 hover:bg-sky-800 focus:ring-sky-500'
|
||||
}`}
|
||||
>
|
||||
{isMetadata ? metadataActionText : buttonState.text}
|
||||
</button>
|
||||
<div className="flex w-full flex-col gap-3 sm:ml-auto sm:w-auto sm:flex-row sm:items-center">
|
||||
{hasBookTargets && book.provider_id && (
|
||||
<BookTargetDropdown
|
||||
provider={book.provider!}
|
||||
bookId={book.provider_id}
|
||||
onShowToast={onShowToast}
|
||||
widthClassName="w-full sm:w-56"
|
||||
/>
|
||||
)}
|
||||
{/* Action button - mirrors search result action state/flow */}
|
||||
<button
|
||||
onClick={isMetadata ? () => onFindDownloads?.(book) : handleDownload}
|
||||
disabled={isMetadata ? buttonState.state === 'blocked' : buttonState.state !== 'download'}
|
||||
className={`rounded-lg px-5 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isMetadata
|
||||
? buttonState.state === 'blocked'
|
||||
? 'bg-gray-500'
|
||||
: 'bg-emerald-600 hover:bg-emerald-700'
|
||||
: buttonState.state === 'blocked'
|
||||
? 'bg-gray-500'
|
||||
: 'bg-sky-700 hover:bg-sky-800'
|
||||
}`}
|
||||
>
|
||||
{isMetadata ? metadataActionText : buttonState.text}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { StatusData, Book } from '../types';
|
||||
import { withBasePath } from '../utils/basePath';
|
||||
|
||||
interface DownloadsSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
status: StatusData;
|
||||
onClearCompleted: () => void;
|
||||
onCancel: (id: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string; waveColor: string }> = {
|
||||
queued: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300', label: 'Queued', waveColor: 'rgba(217, 119, 6, 0.3)' },
|
||||
resolving: { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300', label: 'Resolving', waveColor: 'rgba(79, 70, 229, 0.3)' },
|
||||
downloading: { bg: 'bg-sky-500/20', text: 'text-sky-700 dark:text-sky-300', label: 'Downloading', waveColor: 'rgba(2, 132, 199, 0.3)' },
|
||||
locating: { bg: 'bg-teal-500/20', text: 'text-teal-700 dark:text-teal-300', label: 'Locating files', waveColor: 'rgba(13, 148, 136, 0.3)' },
|
||||
complete: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300', label: 'Complete', waveColor: '' },
|
||||
error: { bg: 'bg-red-500/20', text: 'text-red-700 dark:text-red-300', label: 'Error', waveColor: '' },
|
||||
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300', label: 'Cancelled', waveColor: '' },
|
||||
};
|
||||
|
||||
// Book thumbnail component with fallback
|
||||
const BookThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
|
||||
if (!preview) {
|
||||
return (
|
||||
<div
|
||||
className="w-16 h-24 rounded-tl bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400"
|
||||
style={{ aspectRatio: '2/3' }}
|
||||
>
|
||||
No Cover
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={preview}
|
||||
alt={title || 'Book cover'}
|
||||
className="w-16 h-24 object-cover rounded-tl shadow-sm"
|
||||
style={{ aspectRatio: '2/3' }}
|
||||
onError={(e) => {
|
||||
// Replace with placeholder on error
|
||||
const target = e.target as HTMLImageElement;
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'w-16 h-24 rounded-tl bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400';
|
||||
placeholder.style.aspectRatio = '2/3';
|
||||
placeholder.textContent = 'No Cover';
|
||||
target.replaceWith(placeholder);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to get progress percentage based on status
|
||||
const getStatusProgress = (statusName: string, bookProgress?: number): number => {
|
||||
switch (statusName) {
|
||||
case 'queued':
|
||||
return 5;
|
||||
case 'resolving':
|
||||
return 15;
|
||||
case 'downloading':
|
||||
// Map actual progress (0-100) to 20-100 range
|
||||
if (typeof bookProgress === 'number') {
|
||||
return 20 + (bookProgress * 0.8);
|
||||
}
|
||||
return 20;
|
||||
case 'locating':
|
||||
return 90;
|
||||
case 'complete':
|
||||
case 'error':
|
||||
return 100;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get progress bar color based on status
|
||||
const getProgressBarColor = (statusName: string): string => {
|
||||
if (statusName === 'complete') return 'bg-green-600';
|
||||
if (statusName === 'error') return 'bg-red-600';
|
||||
if (statusName === 'queued') return 'bg-amber-600';
|
||||
if (statusName === 'resolving') return 'bg-indigo-600';
|
||||
if (statusName === 'downloading') return 'bg-sky-600';
|
||||
if (statusName === 'locating') return 'bg-teal-600';
|
||||
return 'bg-sky-600';
|
||||
};
|
||||
|
||||
|
||||
export const DownloadsSidebar = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
status,
|
||||
onClearCompleted,
|
||||
onCancel,
|
||||
}: DownloadsSidebarProps) => {
|
||||
// Handle ESC key to close sidebar
|
||||
useEffect(() => {
|
||||
if (!isOpen) return; // Only listen when sidebar is open
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Collect all download items from different status sections
|
||||
const allDownloadItems: Array<{ book: Book; status: string }> = [];
|
||||
|
||||
const statusTypes = ['downloading', 'locating', 'resolving', 'queued', 'error', 'complete', 'cancelled'];
|
||||
|
||||
statusTypes.forEach((statusName) => {
|
||||
const items = (status as any)[statusName];
|
||||
if (items && Object.keys(items).length > 0) {
|
||||
Object.values(items).forEach((book: any) => {
|
||||
allDownloadItems.push({ book, status: statusName });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by added_time descending (newest first)
|
||||
allDownloadItems.sort((a, b) => (b.book.added_time || 0) - (a.book.added_time || 0));
|
||||
|
||||
const renderDownloadItem = (item: { book: Book; status: string }) => {
|
||||
const { book, status: statusName } = item;
|
||||
const statusStyle = STATUS_STYLES[statusName] || {
|
||||
bg: 'bg-gray-500/10',
|
||||
text: 'text-gray-600',
|
||||
label: statusName.charAt(0).toUpperCase() + statusName.slice(1),
|
||||
};
|
||||
|
||||
const isInProgress = ['queued', 'resolving', 'locating', 'downloading'].includes(statusName);
|
||||
const isQueued = statusName === 'queued';
|
||||
const isActive = statusName === 'resolving' || statusName === 'locating' || statusName === 'downloading';
|
||||
const isCompleted = statusName === 'complete';
|
||||
const hasError = statusName === 'error';
|
||||
|
||||
// Get progress information
|
||||
const progress = getStatusProgress(statusName, book.progress);
|
||||
const progressBarColor = getProgressBarColor(statusName);
|
||||
|
||||
// Format progress text - use status_message from backend if available
|
||||
let progressText = book.status_message || statusStyle.label;
|
||||
if (statusName === 'downloading' && !book.status_message && book.progress && book.size) {
|
||||
// Fallback: calculate size progress only if backend didn't provide a message
|
||||
const sizeValue = parseFloat(book.size.replace(/[^\d.]/g, ''));
|
||||
const sizeUnit = book.size.replace(/[\d.\s]/g, '');
|
||||
const downloadedSize = (book.progress / 100) * sizeValue;
|
||||
progressText = `${downloadedSize.toFixed(1)}${sizeUnit} / ${book.size}`;
|
||||
} else if (isCompleted) {
|
||||
progressText = book.status_message || 'Complete';
|
||||
} else if (hasError) {
|
||||
progressText = book.status_message || 'Failed';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={book.id}
|
||||
className="relative rounded-lg border hover:shadow-md transition-shadow overflow-hidden"
|
||||
style={{ borderColor: 'var(--border-muted)', background: 'var(--bg-soft)' }}
|
||||
>
|
||||
{/* Action Button - top right corner */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancel(book.id);
|
||||
}}
|
||||
className={`absolute top-1 right-1 z-10 flex h-8 w-8 items-center justify-center rounded-full transition-colors ${
|
||||
isActive || isQueued
|
||||
? 'text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30'
|
||||
: 'text-gray-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30'
|
||||
}`}
|
||||
title={isActive ? 'Stop download' : isQueued ? 'Remove from queue' : 'Clear from list'}
|
||||
aria-label={isActive ? 'Stop download' : isQueued ? 'Remove from queue' : 'Clear from list'}
|
||||
>
|
||||
{isActive ? (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex gap-2">
|
||||
{/* Book Thumbnail - left side */}
|
||||
<div className="flex-shrink-0">
|
||||
<BookThumbnail preview={book.preview} title={book.title} />
|
||||
</div>
|
||||
|
||||
{/* Book Info - right side */}
|
||||
<div className="flex-1 min-w-0 flex flex-col pl-1.5 pr-3 pt-2 pb-2">
|
||||
{/* Title & Author - with safe area for cancel/clear button */}
|
||||
<div className="pr-6">
|
||||
<h3 className="font-semibold text-sm truncate" title={book.title}>
|
||||
{isCompleted && book.download_path ? (
|
||||
<a
|
||||
href={withBasePath(`/api/localdownload?id=${encodeURIComponent(book.id)}`)}
|
||||
className="text-sky-600 hover:underline"
|
||||
>
|
||||
{book.title || 'Unknown Title'}
|
||||
</a>
|
||||
) : (
|
||||
book.title || 'Unknown Title'
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs opacity-70 truncate" title={book.author}>
|
||||
{book.author || 'Unknown Author'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Format, Size, Source */}
|
||||
<div className="text-xs opacity-70 mt-1">
|
||||
{book.format && <span className="uppercase">{book.format}</span>}
|
||||
{book.format && book.size && <span> • </span>}
|
||||
{book.size && <span>{book.size}</span>}
|
||||
{book.source_display_name && (
|
||||
<>
|
||||
<span> • </span>
|
||||
<span>{book.source_display_name}</span>
|
||||
</>
|
||||
)}
|
||||
{book.username && (
|
||||
<>
|
||||
<span> • </span>
|
||||
<span>{book.username}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="flex justify-end mt-auto pt-1">
|
||||
<span
|
||||
className={`relative px-2 py-0.5 rounded-lg text-xs font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
||||
>
|
||||
{/* Wave animation overlay for in-progress states */}
|
||||
{isInProgress && statusStyle.waveColor && (
|
||||
<span
|
||||
key={statusName}
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, transparent 0%, ${statusStyle.waveColor} 50%, transparent 100%)`,
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'wave 2s linear infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative">{progressText}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar - at bottom */}
|
||||
<div className="h-1.5 bg-gray-200 dark:bg-gray-700 overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full ${progressBarColor} transition-all duration-300 relative overflow-hidden`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
|
||||
>
|
||||
{/* Animated wave effect for in-progress states */}
|
||||
{isInProgress && progress < 100 && (
|
||||
<div
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.5) 50%, transparent 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'wave 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/50 z-40 transition-opacity duration-300 ${
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-full sm:w-96 z-50 flex flex-col shadow-2xl transition-transform duration-300 ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4 border-b"
|
||||
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))', borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">
|
||||
Downloads{allDownloadItems.length > 0 && ` (${allDownloadItems.length})`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full hover-action transition-colors"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Queue Items */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-4 space-y-3"
|
||||
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
{allDownloadItems.length > 0 ? (
|
||||
allDownloadItems.map((item) => renderDownloadItem(item))
|
||||
) : (
|
||||
<div className="text-center text-sm opacity-70 mt-8">
|
||||
No downloads in queue
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="p-3 border-t flex items-center justify-center"
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearCompleted}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Clear Completed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ function getScrollableAncestor(element: HTMLElement | null): HTMLElement | null
|
||||
while (current) {
|
||||
const style = getComputedStyle(current);
|
||||
const overflowY = style.overflowY;
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'hidden') {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
@@ -41,7 +41,7 @@ interface DropdownProps {
|
||||
label?: string;
|
||||
summary?: ReactNode;
|
||||
children: (helpers: { close: () => void }) => ReactNode;
|
||||
align?: 'left' | 'right';
|
||||
align?: 'left' | 'right' | 'auto';
|
||||
widthClassName?: string;
|
||||
buttonClassName?: string;
|
||||
panelClassName?: string;
|
||||
@@ -49,6 +49,8 @@ interface DropdownProps {
|
||||
renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode;
|
||||
/** Disable max-height and overflow scrolling (for panels with nested dropdowns) */
|
||||
noScrollLimit?: boolean;
|
||||
triggerChrome?: 'default' | 'minimal';
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const Dropdown = ({
|
||||
@@ -62,18 +64,28 @@ export const Dropdown = ({
|
||||
disabled = false,
|
||||
renderTrigger,
|
||||
noScrollLimit = false,
|
||||
triggerChrome = 'default',
|
||||
onOpenChange,
|
||||
}: DropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [panelDirection, setPanelDirection] = useState<'down' | 'up'>('down');
|
||||
const [resolvedAlign, setResolvedAlign] = useState<'left' | 'right'>(align === 'right' ? 'right' : 'left');
|
||||
|
||||
const toggleOpen = () => {
|
||||
if (disabled) return;
|
||||
setIsOpen(prev => !prev);
|
||||
setIsOpen(prev => {
|
||||
const next = !prev;
|
||||
onOpenChange?.(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => setIsOpen(false);
|
||||
const close = () => {
|
||||
setIsOpen(false);
|
||||
onOpenChange?.(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -122,7 +134,24 @@ export const Dropdown = ({
|
||||
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
|
||||
|
||||
setPanelDirection(shouldOpenUp ? 'up' : 'down');
|
||||
}, []);
|
||||
|
||||
// Auto horizontal alignment: check if panel overflows viewport right/left
|
||||
if (align === 'auto') {
|
||||
const panelWidth = panelRef.current.offsetWidth || panelRef.current.scrollWidth;
|
||||
const overflowsRight = rect.left + panelWidth > window.innerWidth - 8;
|
||||
const overflowsLeft = rect.right - panelWidth < 8;
|
||||
|
||||
if (overflowsRight && !overflowsLeft) {
|
||||
setResolvedAlign('right');
|
||||
} else if (overflowsLeft && !overflowsRight) {
|
||||
setResolvedAlign('left');
|
||||
} else {
|
||||
setResolvedAlign('left');
|
||||
}
|
||||
} else {
|
||||
setResolvedAlign(align === 'right' ? 'right' : 'left');
|
||||
}
|
||||
}, [align]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -155,23 +184,27 @@ export const Dropdown = ({
|
||||
type="button"
|
||||
onClick={toggleOpen}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border flex items-center justify-between text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 transition-[border-radius] duration-150 ${buttonClassName}`}
|
||||
className={`w-full px-3 py-2 text-sm border flex items-center justify-between gap-2 text-left focus:outline-hidden focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 ${triggerChrome !== 'minimal' ? 'dropdown-trigger' : ''} ${buttonClassName}`}
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
borderColor: triggerChrome === 'minimal' ? 'transparent' : 'var(--border-muted)',
|
||||
borderWidth: triggerChrome === 'minimal' ? 0 : undefined,
|
||||
borderRadius: isOpen
|
||||
? panelDirection === 'down'
|
||||
? '0.5rem 0.5rem 0 0'
|
||||
: '0 0 0.5rem 0.5rem'
|
||||
: '0.5rem',
|
||||
? triggerChrome === 'minimal'
|
||||
? '0'
|
||||
: panelDirection === 'down'
|
||||
? '0.5rem 0.5rem 0 0'
|
||||
: '0 0 0.5rem 0.5rem'
|
||||
: triggerChrome === 'minimal'
|
||||
? '0'
|
||||
: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{summary ?? <span className="opacity-60">Select an option</span>}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
className={`h-4 w-4 shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -185,7 +218,7 @@ export const Dropdown = ({
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} ${
|
||||
className={`absolute ${resolvedAlign === 'right' ? 'right-0' : 'left-0'} ${
|
||||
panelDirection === 'down'
|
||||
? renderTrigger ? 'mt-2' : ''
|
||||
: renderTrigger ? 'bottom-full mb-2' : 'bottom-full'
|
||||
@@ -211,4 +244,3 @@ export const Dropdown = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface DropdownListOption {
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
icon?: ReactNode;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface DropdownListProps {
|
||||
@@ -17,11 +18,15 @@ interface DropdownListProps {
|
||||
showCheckboxes?: boolean;
|
||||
value: string[] | string | null | undefined;
|
||||
onChange: (value: string[] | string) => void;
|
||||
align?: 'left' | 'right';
|
||||
align?: 'left' | 'right' | 'auto';
|
||||
widthClassName?: string;
|
||||
buttonClassName?: string;
|
||||
panelClassName?: string;
|
||||
summaryFormatter?: (selected: DropdownListOption[], placeholder: string) => ReactNode;
|
||||
keepOpenOnSelect?: boolean;
|
||||
triggerChrome?: 'default' | 'minimal';
|
||||
renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const DropdownList = ({
|
||||
@@ -35,8 +40,12 @@ export const DropdownList = ({
|
||||
align,
|
||||
widthClassName,
|
||||
buttonClassName,
|
||||
panelClassName,
|
||||
summaryFormatter,
|
||||
keepOpenOnSelect,
|
||||
triggerChrome = 'default',
|
||||
renderTrigger,
|
||||
onOpenChange,
|
||||
}: DropdownListProps) => {
|
||||
const selectedValues = normalizeValue(value, multiple);
|
||||
const selectedOptions = options.filter(opt => selectedValues.includes(opt.value));
|
||||
@@ -102,38 +111,55 @@ export const DropdownList = ({
|
||||
align={align}
|
||||
widthClassName={widthClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
panelClassName={panelClassName}
|
||||
triggerChrome={triggerChrome}
|
||||
renderTrigger={renderTrigger}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div role="listbox" aria-multiselectable={multiple}>
|
||||
{options.map(option => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover-surface ${
|
||||
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
onClick={() => handleOptionClick(option, close)}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{checkboxEnabled && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(option.value)}
|
||||
readOnly
|
||||
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
{option.icon}
|
||||
<div className="flex flex-col">
|
||||
<span>{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="text-xs opacity-70">{option.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{({ close }) => {
|
||||
let lastGroup: string | undefined;
|
||||
return (
|
||||
<div role="listbox" aria-multiselectable={multiple}>
|
||||
{options.map(option => {
|
||||
const showGroupHeader = option.group != null && option.group !== lastGroup;
|
||||
if (option.group != null) lastGroup = option.group;
|
||||
return (
|
||||
<div key={option.value}>
|
||||
{showGroupHeader && (
|
||||
<div className="px-3 pt-2 pb-1 text-xs font-medium uppercase tracking-wide opacity-60 select-none">
|
||||
{option.group}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover-surface ${
|
||||
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
onClick={() => handleOptionClick(option, close)}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{checkboxEnabled && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(option.value)}
|
||||
readOnly
|
||||
className="h-4 w-4 rounded-sm border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
{option.icon}
|
||||
<div className="flex flex-col">
|
||||
<span>{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="text-xs opacity-70">{option.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@@ -159,4 +185,3 @@ const normalizeValue = (value: string[] | string | null | undefined, multiple: b
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user