mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 11:10:19 +01:00
Add new python tooling + apply ruff linter cleanup (#845)
- Adds `uv`, `ruff`, `pyright`, `vulture` and `pytest-xdist` - Move project, lockfile, docker build etc to uv - Align python tooling on 3.14 - Huge bulk of ruff linter fixes applied. Still in progress but all the core types are now enforced - Update CI and test helpers
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Python dependencies
|
||||
- package-ecosystem: "pip"
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
+14
-10
@@ -14,20 +14,24 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: "pip"
|
||||
version: "0.11.3"
|
||||
python-version: "3.14"
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r requirements-base.txt
|
||||
pip install -r requirements-shelfmark.txt
|
||||
pip install pytest
|
||||
- name: Sync dependencies
|
||||
run: uv sync --locked --extra browser
|
||||
|
||||
- name: Lint backend
|
||||
run: uv run ruff check shelfmark
|
||||
|
||||
- name: Check backend formatting
|
||||
run: uv run ruff format --check shelfmark
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -x --tb=short
|
||||
run: uv run pytest tests/ -x --tb=short
|
||||
|
||||
frontend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+11
-12
@@ -27,6 +27,8 @@ RUN npm run build
|
||||
# Use python-slim as the base image
|
||||
FROM python:3.14-slim AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.3 /uv /uvx /bin/
|
||||
|
||||
# Add build argument for version
|
||||
ARG BUILD_VERSION
|
||||
ENV BUILD_VERSION=${BUILD_VERSION}
|
||||
@@ -39,13 +41,12 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
# Consistent environment variables grouped together
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
DOCKERMODE=true \
|
||||
UV_LINK_MODE=copy \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONIOENCODING=UTF-8 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_DEFAULT_TIMEOUT=100 \
|
||||
NAME=Shelfmark \
|
||||
PATH=/app/.venv/bin:$PATH \
|
||||
PYTHONPATH=/app \
|
||||
# PUID/PGID will be handled by entrypoint script, but TZ/Locale are still needed
|
||||
LANG=en_US.UTF-8 \
|
||||
@@ -91,12 +92,10 @@ RUN apt-get update && \
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies using pip
|
||||
# Copying requirements files separately leverages build cache
|
||||
# Cache mount persists pip cache between builds for faster installs
|
||||
COPY requirements-base.txt requirements-shelfmark.txt ./
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install -r requirements-base.txt
|
||||
# Install core Python dependencies first for better layer caching
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-default-groups
|
||||
|
||||
# Copy application code *after* dependencies are installed
|
||||
COPY . .
|
||||
@@ -146,9 +145,9 @@ RUN apt-get update && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install additional dependencies (requirements file already copied in base stage)
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install -r requirements-shelfmark.txt
|
||||
# Install the browser automation stack used by the full image
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-default-groups --extra browser
|
||||
|
||||
# Grant read/execute permissions to others
|
||||
RUN chmod -R o+rx /usr/bin/chromium
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install dev build preview typecheck frontend-test clean up down docker-build refresh restart build-serve
|
||||
.PHONY: help install install-python-dev dev build preview typecheck frontend-test clean up up down docker-build refresh restart build-serve python-lint python-lint-fix python-format python-format-check
|
||||
|
||||
# Frontend directory
|
||||
FRONTEND_DIR := src/frontend
|
||||
@@ -18,6 +18,11 @@ help:
|
||||
@echo " preview - Preview production build"
|
||||
@echo " typecheck - Run TypeScript type checking"
|
||||
@echo " frontend-test - Run frontend unit tests"
|
||||
@echo " install-python-dev - Sync Python runtime + dev tooling with uv"
|
||||
@echo " python-lint - Run Ruff against Python backend code"
|
||||
@echo " python-lint-fix - Run Ruff with safe auto-fixes"
|
||||
@echo " python-format - Format Python backend code with Ruff"
|
||||
@echo " python-format-check - Check Python backend formatting with Ruff"
|
||||
@echo " clean - Remove node_modules and build artifacts"
|
||||
@echo ""
|
||||
@echo "Backend (Docker):"
|
||||
@@ -32,6 +37,11 @@ install:
|
||||
@echo "Installing frontend dependencies..."
|
||||
cd $(FRONTEND_DIR) && npm install
|
||||
|
||||
# Install Python development dependencies
|
||||
install-python-dev:
|
||||
@echo "Syncing Python runtime and dev tooling with uv..."
|
||||
uv sync --locked --extra browser
|
||||
|
||||
# Start development server
|
||||
dev:
|
||||
@echo "Starting development server..."
|
||||
@@ -59,6 +69,23 @@ typecheck:
|
||||
@echo "Running TypeScript type checking..."
|
||||
cd $(FRONTEND_DIR) && npm run typecheck
|
||||
|
||||
# Python linting
|
||||
python-lint:
|
||||
@echo "Running Ruff..."
|
||||
uv run ruff check shelfmark
|
||||
|
||||
python-lint-fix:
|
||||
@echo "Running Ruff with safe auto-fixes..."
|
||||
uv run ruff check shelfmark --fix
|
||||
|
||||
python-format:
|
||||
@echo "Formatting Python backend code with Ruff..."
|
||||
uv run ruff format shelfmark
|
||||
|
||||
python-format-check:
|
||||
@echo "Checking Python backend formatting with Ruff..."
|
||||
uv run ruff format --check shelfmark
|
||||
|
||||
# Run frontend unit tests
|
||||
frontend-test:
|
||||
@echo "Running frontend unit tests..."
|
||||
|
||||
@@ -54,6 +54,7 @@ services:
|
||||
# Mount tests for running pytest in container
|
||||
- ./tests:/app/tests:ro
|
||||
- ./pyproject.toml:/app/pyproject.toml:ro
|
||||
- ./uv.lock:/app/uv.lock:ro
|
||||
# Mount client configs for integration tests to read credentials
|
||||
- ./.local/test-clients/qbittorrent/config:/qbittorrent-config:ro
|
||||
- ./.local/test-clients/sabnzbd/config:/sabnzbd-config:ro
|
||||
|
||||
+8
-3
@@ -68,6 +68,11 @@ else
|
||||
fi
|
||||
set -e
|
||||
|
||||
PYTHON_BIN="/app/.venv/bin/python"
|
||||
if [ ! -x "$PYTHON_BIN" ]; then
|
||||
PYTHON_BIN="python3"
|
||||
fi
|
||||
|
||||
# Print build version
|
||||
echo "Build version: $BUILD_VERSION"
|
||||
echo "Release version: $RELEASE_VERSION"
|
||||
@@ -294,7 +299,7 @@ if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
|
||||
# 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)
|
||||
SELENIUMBASE_DRIVERS_DIR=$("$PYTHON_BIN" -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')" 2>/dev/null)
|
||||
set -e
|
||||
|
||||
if [ -n "$SELENIUMBASE_DRIVERS_DIR" ] && [ -d "$SELENIUMBASE_DRIVERS_DIR" ]; then
|
||||
@@ -317,7 +322,7 @@ make_writable "${INGEST_DIR:-/books}" root
|
||||
# Check any additional configured destination roots from saved settings
|
||||
echo "Checking for additional configured destination roots..."
|
||||
if [ -f /app/scripts/fix_permissions.py ]; then
|
||||
configured_dirs=$(python3 /app/scripts/fix_permissions.py 2>/dev/null || echo "")
|
||||
configured_dirs=$("$PYTHON_BIN" /app/scripts/fix_permissions.py 2>/dev/null || echo "")
|
||||
if [ -n "$configured_dirs" ]; then
|
||||
echo "$configured_dirs" | while read -r dir; do
|
||||
if [ -n "$dir" ] && [ -d "$dir" ]; then
|
||||
@@ -372,7 +377,7 @@ if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
|
||||
set -x
|
||||
echo "vvvvvvvvvvvv DEBUG MODE vvvvvvvvvvvv"
|
||||
echo "Starting Xvfb for debugging"
|
||||
python3 -c "from pyvirtualdisplay import Display; Display(visible=False, size=(1440,1880)).start()"
|
||||
"$PYTHON_BIN" -c "from pyvirtualdisplay import Display; Display(visible=False, size=(1440,1880)).start()"
|
||||
id
|
||||
free -h
|
||||
uname -a
|
||||
|
||||
+87
-6
@@ -2,7 +2,44 @@
|
||||
name = "shelfmark"
|
||||
version = "0.1.0"
|
||||
description = "Shelfmark - Book Downloader"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"flask",
|
||||
"flask-cors",
|
||||
"flask-socketio",
|
||||
"python-socketio",
|
||||
"requests[socks]",
|
||||
"defusedxml",
|
||||
"beautifulsoup4",
|
||||
"tqdm",
|
||||
"dnspython",
|
||||
"gunicorn",
|
||||
"gevent",
|
||||
"gevent-websocket",
|
||||
"psutil",
|
||||
"emoji",
|
||||
"rarfile",
|
||||
"qbittorrent-api",
|
||||
"transmission-rpc",
|
||||
"authlib>=1.6.6,<1.7",
|
||||
"apprise>=1.9.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
browser = [
|
||||
"pyvirtualdisplay",
|
||||
"pyautogui",
|
||||
"seleniumbase==4.47.9",
|
||||
"python-xlib",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pyright>=1.1.408",
|
||||
"pytest",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"ruff==0.15.9",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -12,6 +49,8 @@ python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"-v",
|
||||
"--tb=short",
|
||||
"-n",
|
||||
"auto",
|
||||
]
|
||||
markers = [
|
||||
"integration: marks tests that require running services (deselect with '-m \"not integration\"')",
|
||||
@@ -19,8 +58,50 @@ markers = [
|
||||
"e2e: marks end-to-end tests that require the full application stack",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
ignore_missing_imports = true
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
extend-exclude = [".local"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"F", "I", "UP", "B", "C4", "SIM", "PTH", "RET", "PIE", "FURB", "PERF", "TRY",
|
||||
"ANN001", "ANN201", "ANN202", "ANN204",
|
||||
"E731",
|
||||
"FBT002", "FBT003",
|
||||
"G003", "G004",
|
||||
"PLR1714",
|
||||
"PLW0108",
|
||||
"Q000",
|
||||
"RUF005", "RUF012", "RUF013", "RUF059", "RUF100",
|
||||
"TC001", "TC003",
|
||||
]
|
||||
ignore = ["UP035", "TRY003", "E501"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["shelfmark"]
|
||||
exclude = [".local", "tests", "**/__pycache__", "**/node_modules"]
|
||||
pythonVersion = "3.14"
|
||||
typeCheckingMode = "off"
|
||||
|
||||
[tool.vulture]
|
||||
paths = ["shelfmark"]
|
||||
exclude = [".local", "tests"]
|
||||
ignore_decorators = [
|
||||
"@app.route",
|
||||
"@app.before_request",
|
||||
"@app.after_request",
|
||||
"@app.errorhandler",
|
||||
"@socketio.on",
|
||||
"@register_provider",
|
||||
"@register_provider_kwargs",
|
||||
"@register_settings",
|
||||
"@register_source",
|
||||
"@register_handler",
|
||||
"@register_client",
|
||||
"@register_output",
|
||||
]
|
||||
min_confidence = 90
|
||||
sort_by_size = true
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
flask
|
||||
flask-cors
|
||||
flask-socketio
|
||||
python-socketio
|
||||
requests[socks]
|
||||
defusedxml
|
||||
beautifulsoup4
|
||||
tqdm
|
||||
dnspython
|
||||
gunicorn
|
||||
gevent
|
||||
gevent-websocket
|
||||
psutil
|
||||
emoji
|
||||
rarfile
|
||||
qbittorrent-api
|
||||
transmission-rpc
|
||||
authlib>=1.6.6,<1.7
|
||||
apprise>=1.9.0
|
||||
@@ -1,4 +0,0 @@
|
||||
pyvirtualdisplay
|
||||
pyautogui
|
||||
seleniumbase==4.47.9
|
||||
python-xlib
|
||||
@@ -9,7 +9,7 @@ Usage:
|
||||
2. Wait for containers to initialize (first run takes ~30s)
|
||||
|
||||
3. Run this script to verify clients are accessible:
|
||||
python scripts/test_clients.py
|
||||
uv run python scripts/test_clients.py
|
||||
|
||||
4. Access cwabd at http://localhost:8084
|
||||
- Go to Settings > Prowlarr > Download Clients
|
||||
@@ -26,7 +26,7 @@ Web UIs:
|
||||
- rTorrent: http://localhost:8000 (web ui http://localhost:8089 via ruTorrent)
|
||||
|
||||
Prerequisites (for running this script locally):
|
||||
pip install requests transmission-rpc qbittorrent-api
|
||||
uv sync --locked
|
||||
|
||||
First-Time Setup:
|
||||
qBittorrent:
|
||||
@@ -260,7 +260,7 @@ def test_qbittorrent():
|
||||
|
||||
except ImportError:
|
||||
print(" ERROR: qbittorrent-api not installed")
|
||||
print(" Run: pip install qbittorrent-api")
|
||||
print(" Run: uv sync --locked")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
@@ -317,7 +317,7 @@ def test_transmission():
|
||||
|
||||
except ImportError:
|
||||
print(" ERROR: transmission-rpc not installed")
|
||||
print(" Run: pip install transmission-rpc")
|
||||
print(" Run: uv sync --locked")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Package entry point for `python -m shelfmark`."""
|
||||
|
||||
from shelfmark.main import app, socketio
|
||||
from shelfmark.config.env import FLASK_HOST, FLASK_PORT
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.main import app, socketio
|
||||
|
||||
if __name__ == "__main__":
|
||||
socketio.run(app, host=FLASK_HOST, port=FLASK_PORT, debug=config.get("DEBUG", False))
|
||||
|
||||
+75
-124
@@ -2,123 +2,71 @@
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional, Dict, Any, Callable, List
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from flask import Flask
|
||||
from flask_socketio import SocketIO, join_room, leave_room
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
"""Manages WebSocket connections and broadcasts."""
|
||||
|
||||
def __init__(self):
|
||||
self.socketio: Optional[SocketIO] = None
|
||||
def __init__(self) -> None:
|
||||
self.socketio: SocketIO | None = None
|
||||
self._enabled = False
|
||||
self._connection_count = 0
|
||||
self._connection_lock = threading.Lock()
|
||||
self._on_first_connect_callbacks: List[Callable[[], None]] = []
|
||||
self._on_all_disconnect_callbacks: List[Callable[[], None]] = []
|
||||
self._needs_rewarm = False # Flag to trigger warmup callbacks on next connect
|
||||
self._user_rooms: Dict[str, int] = {} # room_name -> ref count
|
||||
self._sid_rooms: Dict[str, str] = {} # sid -> room_name
|
||||
self._user_rooms: dict[str, int] = {} # room_name -> ref count
|
||||
self._sid_rooms: dict[str, str] = {} # sid -> room_name
|
||||
self._rooms_lock = threading.Lock()
|
||||
self._queue_status_fn: Optional[Callable] = None # Reference to queue_status()
|
||||
self._queue_status_fn: Callable | None = None # Reference to queue_status()
|
||||
|
||||
def init_app(self, app, socketio: SocketIO):
|
||||
def init_app(self, app: Flask, socketio: SocketIO) -> None:
|
||||
"""Initialize the WebSocket manager with Flask-SocketIO instance."""
|
||||
self.socketio = socketio
|
||||
self._enabled = True
|
||||
logger.info("WebSocket manager initialized")
|
||||
|
||||
def register_on_first_connect(self, callback: Callable[[], None]):
|
||||
"""Register a callback for when the first client connects."""
|
||||
self._on_first_connect_callbacks.append(callback)
|
||||
logger.debug(f"Registered on_first_connect callback: {callback.__name__}")
|
||||
|
||||
def register_on_all_disconnect(self, callback: Callable[[], None]):
|
||||
"""Register a callback for when all clients disconnect."""
|
||||
self._on_all_disconnect_callbacks.append(callback)
|
||||
logger.debug(f"Registered on_all_disconnect callback: {callback.__name__}")
|
||||
|
||||
def request_warmup_on_next_connect(self):
|
||||
"""Request warmup callbacks on the next client connect (e.g., after idle shutdown)."""
|
||||
with self._connection_lock:
|
||||
self._needs_rewarm = True
|
||||
logger.debug("Warmup requested for next client connect")
|
||||
|
||||
def client_connected(self):
|
||||
def client_connected(self) -> None:
|
||||
"""Track a new client connection. Call this from the connect event handler."""
|
||||
with self._connection_lock:
|
||||
was_zero = self._connection_count == 0
|
||||
needs_rewarm = self._needs_rewarm
|
||||
self._connection_count += 1
|
||||
current_count = self._connection_count
|
||||
# Clear rewarm flag if we're going to trigger warmup
|
||||
if was_zero or needs_rewarm:
|
||||
self._needs_rewarm = False
|
||||
|
||||
logger.debug(f"Client connected. Active connections: {current_count}")
|
||||
logger.debug("Client connected. Active connections: %s", current_count)
|
||||
|
||||
# Trigger warmup callbacks if this is the first connection OR if rewarm was requested
|
||||
# (rewarm is requested when bypasser shuts down due to idle while clients are connected)
|
||||
if was_zero or needs_rewarm:
|
||||
reason = "First client connected" if was_zero else "Rewarm requested after idle shutdown"
|
||||
logger.info(f"{reason}, triggering warmup callbacks...")
|
||||
for callback in self._on_first_connect_callbacks:
|
||||
try:
|
||||
# Run callbacks in a separate thread to not block the connection
|
||||
thread = threading.Thread(target=callback, daemon=True)
|
||||
thread.start()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_first_connect callback {callback.__name__}: {e}")
|
||||
|
||||
def client_disconnected(self):
|
||||
def client_disconnected(self) -> None:
|
||||
"""Track a client disconnection. Call this from the disconnect event handler."""
|
||||
with self._connection_lock:
|
||||
self._connection_count = max(0, self._connection_count - 1)
|
||||
current_count = self._connection_count
|
||||
is_now_zero = current_count == 0
|
||||
|
||||
logger.debug(f"Client disconnected. Active connections: {current_count}")
|
||||
|
||||
# If all clients have disconnected, trigger cleanup callbacks
|
||||
if is_now_zero:
|
||||
logger.info("All clients disconnected, triggering disconnect callbacks...")
|
||||
for callback in self._on_all_disconnect_callbacks:
|
||||
try:
|
||||
callback()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_all_disconnect callback {callback.__name__}: {e}")
|
||||
|
||||
def get_connection_count(self) -> int:
|
||||
"""Get the current number of active WebSocket connections."""
|
||||
with self._connection_lock:
|
||||
return self._connection_count
|
||||
|
||||
def has_active_connections(self) -> bool:
|
||||
"""Check if there are any active WebSocket connections."""
|
||||
return self.get_connection_count() > 0
|
||||
logger.debug("Client disconnected. Active connections: %s", current_count)
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if WebSocket is enabled and ready."""
|
||||
return self._enabled and self.socketio is not None
|
||||
|
||||
def set_queue_status_fn(self, fn: Callable):
|
||||
def set_queue_status_fn(self, fn: Callable) -> None:
|
||||
"""Set the queue_status function reference for per-room filtering."""
|
||||
self._queue_status_fn = fn
|
||||
|
||||
def _increment_user_room_locked(self, room: str):
|
||||
def _increment_user_room_locked(self, room: str) -> None:
|
||||
self._user_rooms[room] = self._user_rooms.get(room, 0) + 1
|
||||
|
||||
def _decrement_user_room_locked(self, room: str):
|
||||
def _decrement_user_room_locked(self, room: str) -> None:
|
||||
count = self._user_rooms.get(room, 1) - 1
|
||||
if count <= 0:
|
||||
self._user_rooms.pop(room, None)
|
||||
else:
|
||||
self._user_rooms[room] = count
|
||||
|
||||
def _set_sid_room_locked(self, sid: str, room: Optional[str]):
|
||||
def _set_sid_room_locked(self, sid: str, room: str | None) -> None:
|
||||
current_room = self._sid_rooms.get(sid)
|
||||
if current_room == room:
|
||||
return
|
||||
@@ -135,9 +83,14 @@ class WebSocketManager:
|
||||
if room.startswith("user_"):
|
||||
self._increment_user_room_locked(room)
|
||||
|
||||
def sync_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None):
|
||||
def sync_user_room(
|
||||
self,
|
||||
sid: str,
|
||||
is_admin: bool,
|
||||
db_user_id: int | None = None,
|
||||
) -> None:
|
||||
"""Ensure a SID is in exactly one room matching the current session scope."""
|
||||
room: Optional[str] = None
|
||||
room: str | None = None
|
||||
if is_admin:
|
||||
room = "admins"
|
||||
elif db_user_id is not None:
|
||||
@@ -146,24 +99,35 @@ class WebSocketManager:
|
||||
with self._rooms_lock:
|
||||
self._set_sid_room_locked(sid, room)
|
||||
|
||||
def join_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None):
|
||||
def join_user_room(
|
||||
self,
|
||||
sid: str,
|
||||
is_admin: bool,
|
||||
db_user_id: int | None = None,
|
||||
) -> None:
|
||||
"""Join the appropriate room based on user role."""
|
||||
self.sync_user_room(sid, is_admin, db_user_id)
|
||||
self.sync_user_room(sid, is_admin=is_admin, db_user_id=db_user_id)
|
||||
|
||||
def leave_user_room(self, sid: str, is_admin: bool = False, db_user_id: Optional[int] = None):
|
||||
def leave_user_room(
|
||||
self,
|
||||
sid: str,
|
||||
*,
|
||||
is_admin: bool = False,
|
||||
db_user_id: int | None = None,
|
||||
) -> None:
|
||||
"""Leave whichever room the SID currently belongs to."""
|
||||
del is_admin, db_user_id # Backward-compatible signature; routing is SID-based.
|
||||
with self._rooms_lock:
|
||||
self._set_sid_room_locked(sid, None)
|
||||
|
||||
def broadcast_status_update(self, status_data: Dict[str, Any]):
|
||||
def broadcast_status_update(self, status_data: dict[str, Any]) -> None:
|
||||
"""Broadcast status update to all connected clients, filtered by user room."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
# Admins (and no-auth users) get full status
|
||||
self.socketio.emit('status_update', status_data, to="admins")
|
||||
self.socketio.emit("status_update", status_data, to="admins")
|
||||
|
||||
# Each user room gets filtered status
|
||||
with self._rooms_lock:
|
||||
@@ -171,56 +135,43 @@ class WebSocketManager:
|
||||
|
||||
if active_rooms and self._queue_status_fn:
|
||||
for room in active_rooms:
|
||||
try:
|
||||
# Extract user_id from room name "user_123"
|
||||
uid = int(room.split("_", 1)[1])
|
||||
filtered = self._queue_status_fn(user_id=uid)
|
||||
self.socketio.emit('status_update', filtered, to=room)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send status update for room {room}: {e}")
|
||||
self._broadcast_status_update_to_room(room)
|
||||
|
||||
logger.debug("Broadcasted status update to all rooms")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting status update: {e}")
|
||||
except Exception:
|
||||
logger.exception("Error broadcasting status update")
|
||||
|
||||
def broadcast_download_progress(self, book_id: str, progress: float, status: str, user_id: Optional[int] = None):
|
||||
def _broadcast_status_update_to_room(self, room: str) -> None:
|
||||
"""Broadcast status update to one user room."""
|
||||
try:
|
||||
# Extract user_id from room name "user_123"
|
||||
uid = int(room.split("_", 1)[1])
|
||||
filtered = self._queue_status_fn(user_id=uid) if self._queue_status_fn else None
|
||||
if filtered is not None:
|
||||
self.socketio.emit("status_update", filtered, to=room)
|
||||
except Exception:
|
||||
logger.exception("Failed to send status update for room %s", room)
|
||||
|
||||
def broadcast_download_progress(
|
||||
self, book_id: str, progress: float, status: str, user_id: int | None = None
|
||||
) -> None:
|
||||
"""Broadcast download progress update for a specific book."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
'book_id': book_id,
|
||||
'progress': progress,
|
||||
'status': status
|
||||
}
|
||||
data = {"book_id": book_id, "progress": progress, "status": status}
|
||||
# Admins always see all progress
|
||||
self.socketio.emit('download_progress', data, to="admins")
|
||||
self.socketio.emit("download_progress", data, to="admins")
|
||||
# If task belongs to a specific user, send to their room too
|
||||
if user_id is not None:
|
||||
room = f"user_{user_id}"
|
||||
with self._rooms_lock:
|
||||
if room in self._user_rooms:
|
||||
self.socketio.emit('download_progress', data, to=room)
|
||||
logger.debug(f"Broadcasted progress for book {book_id}: {progress}%")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting download progress: {e}")
|
||||
|
||||
def broadcast_notification(self, message: str, notification_type: str = 'info'):
|
||||
"""Broadcast a notification message to all clients."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
'message': message,
|
||||
'type': notification_type
|
||||
}
|
||||
# When calling socketio.emit() outside event handlers, it broadcasts by default
|
||||
self.socketio.emit('notification', data)
|
||||
logger.debug(f"Broadcasted notification: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting notification: {e}")
|
||||
self.socketio.emit("download_progress", data, to=room)
|
||||
logger.debug("Broadcasted progress for book %s: %s%%", book_id, progress)
|
||||
except Exception:
|
||||
logger.exception("Error broadcasting download progress")
|
||||
|
||||
def broadcast_search_status(
|
||||
self,
|
||||
@@ -228,23 +179,23 @@ class WebSocketManager:
|
||||
provider: str,
|
||||
book_id: str,
|
||||
message: str,
|
||||
phase: str = 'searching'
|
||||
):
|
||||
phase: str = "searching",
|
||||
) -> None:
|
||||
"""Broadcast search status update for a release source search."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
'source': source,
|
||||
'provider': provider,
|
||||
'book_id': book_id,
|
||||
'message': message,
|
||||
'phase': phase,
|
||||
"source": source,
|
||||
"provider": provider,
|
||||
"book_id": book_id,
|
||||
"message": message,
|
||||
"phase": phase,
|
||||
}
|
||||
self.socketio.emit('search_status', data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting search status: {e}")
|
||||
self.socketio.emit("search_status", data)
|
||||
except Exception:
|
||||
logger.exception("Error broadcasting search status")
|
||||
|
||||
|
||||
# Global WebSocket manager instance
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import random
|
||||
import time
|
||||
from threading import Event
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import requests
|
||||
|
||||
@@ -14,6 +13,8 @@ from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from threading import Event
|
||||
|
||||
from shelfmark.download import network
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -29,7 +30,7 @@ BACKOFF_BASE = 1.0
|
||||
BACKOFF_CAP = 10.0
|
||||
|
||||
|
||||
def _fetch_via_bypasser(target_url: str) -> Optional[str]:
|
||||
def _fetch_via_bypasser(target_url: str) -> str | None:
|
||||
"""Make a single request to the external bypasser service. Returns HTML or None."""
|
||||
raw_bypasser_url = config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191")
|
||||
bypasser_path = config.get("EXT_BYPASSER_PATH", "/v1")
|
||||
@@ -37,7 +38,9 @@ def _fetch_via_bypasser(target_url: str) -> Optional[str]:
|
||||
|
||||
bypasser_url = normalize_http_url(raw_bypasser_url)
|
||||
if not bypasser_url or not bypasser_path:
|
||||
logger.error("External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH.")
|
||||
logger.error(
|
||||
"External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH."
|
||||
)
|
||||
return None
|
||||
|
||||
read_timeout = min((bypasser_timeout / 1000) + READ_TIMEOUT_BUFFER, MAX_READ_TIMEOUT)
|
||||
@@ -46,48 +49,63 @@ def _fetch_via_bypasser(target_url: str) -> Optional[str]:
|
||||
response = requests.post(
|
||||
f"{bypasser_url}{bypasser_path}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={"cmd": "request.get", "url": target_url, "maxTimeout": bypasser_timeout},
|
||||
json={
|
||||
"cmd": "request.get",
|
||||
"url": target_url,
|
||||
"maxTimeout": bypasser_timeout,
|
||||
},
|
||||
timeout=(CONNECT_TIMEOUT, read_timeout),
|
||||
verify=get_ssl_verify(bypasser_url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
status = result.get('status', 'unknown')
|
||||
message = result.get('message', '')
|
||||
logger.debug(f"External bypasser response for '{target_url}': {status} - {message}")
|
||||
status = result.get("status", "unknown")
|
||||
message = result.get("message", "")
|
||||
logger.debug("External bypasser response for '%s': %s - %s", target_url, status, message)
|
||||
|
||||
if status != 'ok':
|
||||
logger.warning(f"External bypasser failed for '{target_url}': {status} - {message}")
|
||||
if status != "ok":
|
||||
logger.warning(
|
||||
"External bypasser failed for '%s': %s - %s",
|
||||
target_url,
|
||||
status,
|
||||
message,
|
||||
)
|
||||
return None
|
||||
|
||||
solution = result.get('solution')
|
||||
html = solution.get('response', '') if solution else ''
|
||||
solution = result.get("solution")
|
||||
html = solution.get("response", "") if solution else ""
|
||||
|
||||
if not html:
|
||||
logger.warning(f"External bypasser returned empty response for '{target_url}'")
|
||||
logger.warning("External bypasser returned empty response for '%s'", target_url)
|
||||
return None
|
||||
|
||||
return html
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"External bypasser timed out for '{target_url}' (connect: {CONNECT_TIMEOUT}s, read: {read_timeout:.0f}s)")
|
||||
logger.warning(
|
||||
"External bypasser timed out for '%s' (connect: %ss, read: %.0fs)",
|
||||
target_url,
|
||||
CONNECT_TIMEOUT,
|
||||
read_timeout,
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"External bypasser request failed for '{target_url}': {e}")
|
||||
logger.warning("External bypasser request failed for '%s': %s", target_url, e)
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
logger.warning(f"External bypasser returned malformed response for '{target_url}': {e}")
|
||||
logger.warning("External bypasser returned malformed response for '%s': %s", target_url, e)
|
||||
else:
|
||||
return html
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _check_cancelled(cancel_flag: Optional[Event], context: str) -> None:
|
||||
def _check_cancelled(cancel_flag: Event | None, context: str) -> None:
|
||||
"""Check if operation was cancelled and raise exception if so."""
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
logger.info(f"External bypasser cancelled {context}")
|
||||
raise BypassCancelledException("Bypass cancelled")
|
||||
logger.info("External bypasser cancelled %s", context)
|
||||
msg = "Bypass cancelled"
|
||||
raise BypassCancelledException(msg)
|
||||
|
||||
|
||||
def _sleep_with_cancellation(seconds: float, cancel_flag: Optional[Event]) -> None:
|
||||
def _sleep_with_cancellation(seconds: float, cancel_flag: Event | None) -> None:
|
||||
"""Sleep for the specified duration, checking for cancellation each second."""
|
||||
for _ in range(int(seconds)):
|
||||
_check_cancelled(cancel_flag, "during backoff")
|
||||
@@ -99,9 +117,9 @@ def _sleep_with_cancellation(seconds: float, cancel_flag: Optional[Event]) -> No
|
||||
|
||||
def get_bypassed_page(
|
||||
url: str,
|
||||
selector: Optional["network.AAMirrorSelector"] = None,
|
||||
cancel_flag: Optional[Event] = None
|
||||
) -> Optional[str]:
|
||||
selector: network.AAMirrorSelector | None = None,
|
||||
cancel_flag: Event | None = None,
|
||||
) -> str | None:
|
||||
"""Fetch HTML via external bypasser with retries and mirror rotation."""
|
||||
from shelfmark.download import network as network_module
|
||||
|
||||
@@ -119,12 +137,17 @@ def get_bypassed_page(
|
||||
break
|
||||
|
||||
delay = min(BACKOFF_CAP, BACKOFF_BASE * (2 ** (attempt - 1))) + random.random()
|
||||
logger.info(f"External bypasser attempt {attempt}/{MAX_RETRY} failed, retrying in {delay:.1f}s")
|
||||
logger.info(
|
||||
"External bypasser attempt %s/%s failed, retrying in %.1fs",
|
||||
attempt,
|
||||
MAX_RETRY,
|
||||
delay,
|
||||
)
|
||||
|
||||
_sleep_with_cancellation(delay, cancel_flag)
|
||||
|
||||
new_base, action = sel.next_mirror_or_rotate_dns()
|
||||
if action in ("mirror", "dns") and new_base:
|
||||
logger.info(f"Rotated {action} for retry")
|
||||
logger.info("Rotated %s for retry", action)
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
"""Browser fingerprint profile management for bypass stealth."""
|
||||
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
COMMON_RESOLUTIONS = [
|
||||
(1920, 1080, 0.35),
|
||||
(1366, 768, 0.18),
|
||||
(1536, 864, 0.10),
|
||||
(1440, 900, 0.08),
|
||||
(1280, 720, 0.07),
|
||||
(1600, 900, 0.06),
|
||||
(1280, 800, 0.05),
|
||||
(2560, 1440, 0.04),
|
||||
(1680, 1050, 0.04),
|
||||
(1920, 1200, 0.03),
|
||||
(1920, 1080, 0.35),
|
||||
(1366, 768, 0.18),
|
||||
(1536, 864, 0.10),
|
||||
(1440, 900, 0.08),
|
||||
(1280, 720, 0.07),
|
||||
(1600, 900, 0.06),
|
||||
(1280, 800, 0.05),
|
||||
(2560, 1440, 0.04),
|
||||
(1680, 1050, 0.04),
|
||||
(1920, 1200, 0.03),
|
||||
]
|
||||
|
||||
# Current screen size (module-level singleton)
|
||||
_current_screen_size: Optional[tuple[int, int]] = None
|
||||
_current_screen_size: tuple[int, int] | None = None
|
||||
|
||||
|
||||
def get_screen_size() -> tuple[int, int]:
|
||||
global _current_screen_size
|
||||
if _current_screen_size is None:
|
||||
_current_screen_size = _generate_screen_size()
|
||||
logger.debug(f"Generated initial screen size: {_current_screen_size[0]}x{_current_screen_size[1]}")
|
||||
logger.debug(
|
||||
"Generated initial screen size: %sx%s",
|
||||
_current_screen_size[0],
|
||||
_current_screen_size[1],
|
||||
)
|
||||
return _current_screen_size
|
||||
|
||||
|
||||
@@ -39,9 +42,15 @@ def rotate_screen_size() -> tuple[int, int]:
|
||||
width, height = _current_screen_size
|
||||
|
||||
if old_size:
|
||||
logger.info(f"Rotated screen size: {old_size[0]}x{old_size[1]} -> {width}x{height}")
|
||||
logger.info(
|
||||
"Rotated screen size: %sx%s -> %sx%s",
|
||||
old_size[0],
|
||||
old_size[1],
|
||||
width,
|
||||
height,
|
||||
)
|
||||
else:
|
||||
logger.info(f"Generated screen size: {width}x{height}")
|
||||
logger.info("Generated screen size: %sx%s", width, height)
|
||||
|
||||
return _current_screen_size
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
@@ -28,8 +30,8 @@ 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")
|
||||
SELENIUMBASE_RUNTIME_ROOT = Path("/tmp/shelfmark/seleniumbase")
|
||||
SELENIUMBASE_DOWNLOADS_DIR = SELENIUMBASE_RUNTIME_ROOT / "downloaded_files"
|
||||
|
||||
# Challenge detection indicators
|
||||
CLOUDFLARE_INDICATORS = [
|
||||
@@ -54,13 +56,14 @@ DISPLAY = {
|
||||
LOCKED = threading.Lock()
|
||||
|
||||
|
||||
def _describe_runtime_path(path: str) -> str:
|
||||
def _describe_runtime_path(path: str | Path) -> str:
|
||||
"""Return compact ownership/mode info for a runtime path."""
|
||||
try:
|
||||
path = Path(path)
|
||||
link_target = ""
|
||||
if os.path.islink(path):
|
||||
link_target = f" -> {os.readlink(path)}"
|
||||
st = os.stat(path)
|
||||
if path.is_symlink():
|
||||
link_target = f" -> {path.readlink()}"
|
||||
st = path.stat()
|
||||
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:
|
||||
@@ -71,8 +74,8 @@ def _describe_runtime_path(path: str) -> str:
|
||||
|
||||
class _CdpWorker:
|
||||
def __init__(self) -> None:
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._ready = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@@ -107,7 +110,7 @@ class _CdpWorker:
|
||||
if not self._ready.wait(timeout=10):
|
||||
raise RuntimeError("CDP worker loop failed to start")
|
||||
|
||||
def run(self, coro: Any, timeout: Optional[float] = None) -> Any:
|
||||
def run(self, coro: Any, timeout: float | None = None) -> Any:
|
||||
self.start()
|
||||
if not self._loop or self._loop.is_closed():
|
||||
raise RuntimeError("CDP worker loop not available")
|
||||
@@ -126,24 +129,34 @@ _cf_cookies_lock = threading.Lock()
|
||||
_cf_user_agents: dict[str, str] = {}
|
||||
|
||||
# Protection cookie names we care about (Cloudflare and DDoS-Guard)
|
||||
CF_COOKIE_NAMES = {'cf_clearance', '__cf_bm', 'cf_chl_2', 'cf_chl_prog'}
|
||||
DDG_COOKIE_NAMES = {'__ddg1_', '__ddg2_', '__ddg5_', '__ddg8_', '__ddg9_', '__ddg10_', '__ddgid_', '__ddgmark_', 'ddg_last_challenge'}
|
||||
CF_COOKIE_NAMES = {"cf_clearance", "__cf_bm", "cf_chl_2", "cf_chl_prog"}
|
||||
DDG_COOKIE_NAMES = {
|
||||
"__ddg1_",
|
||||
"__ddg2_",
|
||||
"__ddg5_",
|
||||
"__ddg8_",
|
||||
"__ddg9_",
|
||||
"__ddg10_",
|
||||
"__ddgid_",
|
||||
"__ddgmark_",
|
||||
"ddg_last_challenge",
|
||||
}
|
||||
|
||||
# Domains requiring full session cookies (not just protection cookies)
|
||||
FULL_COOKIE_DOMAINS = {'z-lib.fm', 'z-lib.gs', 'z-lib.id', 'z-library.sk', 'zlibrary-global.se'}
|
||||
FULL_COOKIE_DOMAINS = {"z-lib.fm", "z-lib.gs", "z-lib.id", "z-library.sk", "zlibrary-global.se"}
|
||||
|
||||
|
||||
def _get_base_domain(domain: str) -> str:
|
||||
"""Extract base domain from hostname (e.g., 'www.example.com' -> 'example.com')."""
|
||||
return '.'.join(domain.split('.')[-2:]) if '.' in domain else domain
|
||||
return ".".join(domain.split(".")[-2:]) if "." in domain else domain
|
||||
|
||||
|
||||
def _should_extract_cookie(name: str, extract_all: bool) -> bool:
|
||||
def _should_extract_cookie(name: str, *, extract_all: bool) -> bool:
|
||||
"""Determine if a cookie should be extracted based on its name."""
|
||||
if extract_all:
|
||||
return True
|
||||
is_cf = name in CF_COOKIE_NAMES or name.startswith('cf_')
|
||||
is_ddg = name in DDG_COOKIE_NAMES or name.startswith('__ddg')
|
||||
is_cf = name in CF_COOKIE_NAMES or name.startswith("cf_")
|
||||
is_ddg = name in DDG_COOKIE_NAMES or name.startswith("__ddg")
|
||||
return is_cf or is_ddg
|
||||
|
||||
|
||||
@@ -151,7 +164,7 @@ def _store_extracted_cookies(
|
||||
*,
|
||||
url: str,
|
||||
cookies: list[Any],
|
||||
user_agent: Optional[str] = None,
|
||||
user_agent: str | None = None,
|
||||
) -> None:
|
||||
"""Store filtered bypass cookies (and optional UA) for a URL domain."""
|
||||
parsed = urlparse(url)
|
||||
@@ -165,7 +178,7 @@ def _store_extracted_cookies(
|
||||
cookies_found: dict[str, dict[str, Any]] = {}
|
||||
for cookie in cookies:
|
||||
name = getattr(cookie, "name", "") or ""
|
||||
if not _should_extract_cookie(name, extract_all):
|
||||
if not _should_extract_cookie(name, extract_all=extract_all):
|
||||
continue
|
||||
expires = getattr(cookie, "expires", None)
|
||||
if expires is not None and expires <= 0:
|
||||
@@ -186,21 +199,21 @@ def _store_extracted_cookies(
|
||||
_cf_cookies[base_domain] = cookies_found
|
||||
if user_agent:
|
||||
_cf_user_agents[base_domain] = user_agent
|
||||
logger.debug(f"Stored UA for {base_domain}: {str(user_agent)[:60]}...")
|
||||
logger.debug("Stored UA for %s: %s...", base_domain, str(user_agent)[:60])
|
||||
else:
|
||||
logger.debug(f"No UA captured for {base_domain}")
|
||||
logger.debug("No UA captured for %s", base_domain)
|
||||
|
||||
cookie_type = "all" if extract_all else "protection"
|
||||
logger.debug(f"Extracted {len(cookies_found)} {cookie_type} cookies for {base_domain}")
|
||||
logger.debug("Extracted %s %s cookies for %s", len(cookies_found), cookie_type, base_domain)
|
||||
|
||||
|
||||
async def _extract_cookies_from_cdp(driver, page, url: str) -> None:
|
||||
async def _extract_cookies_from_cdp(driver: Any, page: Any, url: str) -> None:
|
||||
"""Extract cookies from a CDP browser after successful bypass."""
|
||||
try:
|
||||
try:
|
||||
all_cookies = await driver.cookies.get_all(requests_cookie_format=True)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get cookies via CDP: {e}")
|
||||
logger.debug("Failed to get cookies via CDP: %s", e)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -211,7 +224,8 @@ async def _extract_cookies_from_cdp(driver, page, url: str) -> None:
|
||||
_store_extracted_cookies(url=url, cookies=all_cookies, user_agent=user_agent)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to extract cookies: {e}")
|
||||
logger.debug("Failed to extract cookies: %s", e)
|
||||
|
||||
|
||||
def get_cf_cookies_for_domain(domain: str) -> dict[str, str]:
|
||||
"""Get stored cookies for a domain. Returns empty dict if none available."""
|
||||
@@ -225,17 +239,17 @@ def get_cf_cookies_for_domain(domain: str) -> dict[str, str]:
|
||||
if not cookies:
|
||||
return {}
|
||||
|
||||
cf_clearance = cookies.get('cf_clearance', {})
|
||||
cf_clearance = cookies.get("cf_clearance", {})
|
||||
if cf_clearance:
|
||||
expiry = cf_clearance.get('expiry')
|
||||
expiry = cf_clearance.get("expiry")
|
||||
if expiry is None:
|
||||
expiry = cf_clearance.get('expires')
|
||||
expiry = cf_clearance.get("expires")
|
||||
if expiry and expiry > 0 and time.time() > expiry:
|
||||
logger.debug(f"CF cookies expired for {base_domain}")
|
||||
logger.debug("CF cookies expired for %s", base_domain)
|
||||
_cf_cookies.pop(base_domain, None)
|
||||
return {}
|
||||
|
||||
return {name: c['value'] for name, c in cookies.items()}
|
||||
return {name: c["value"] for name, c in cookies.items()}
|
||||
|
||||
|
||||
def has_valid_cf_cookies(domain: str) -> bool:
|
||||
@@ -243,7 +257,7 @@ def has_valid_cf_cookies(domain: str) -> bool:
|
||||
return bool(get_cf_cookies_for_domain(domain))
|
||||
|
||||
|
||||
def get_cf_user_agent_for_domain(domain: str) -> Optional[str]:
|
||||
def get_cf_user_agent_for_domain(domain: str) -> str | None:
|
||||
"""Get the User-Agent that was used during bypass for a domain."""
|
||||
if not domain:
|
||||
return None
|
||||
@@ -251,7 +265,7 @@ def get_cf_user_agent_for_domain(domain: str) -> Optional[str]:
|
||||
return _cf_user_agents.get(_get_base_domain(domain))
|
||||
|
||||
|
||||
def clear_cf_cookies(domain: str = None) -> None:
|
||||
def clear_cf_cookies(domain: str | None = None) -> None:
|
||||
"""Clear stored Cloudflare cookies and User-Agent. If domain is None, clear all."""
|
||||
with _cf_cookies_lock:
|
||||
if domain:
|
||||
@@ -279,43 +293,39 @@ def _cleanup_orphan_processes() -> int:
|
||||
for proc_name in processes_to_kill:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", proc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
["pgrep", "-f", proc_name], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
continue
|
||||
|
||||
pids = result.stdout.strip().split('\n')
|
||||
pids = result.stdout.strip().split("\n")
|
||||
count = len(pids)
|
||||
logger.info(f"Found {count} orphan {proc_name} process(es), killing...")
|
||||
logger.info("Found %s orphan %s process(es), killing...", count, proc_name)
|
||||
|
||||
kill_result = subprocess.run(
|
||||
["pkill", "-9", "-f", proc_name],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
["pkill", "-9", "-f", proc_name], capture_output=True, timeout=5
|
||||
)
|
||||
if kill_result.returncode == 0:
|
||||
total_killed += count
|
||||
else:
|
||||
logger.warning(f"pkill for {proc_name} returned {kill_result.returncode}")
|
||||
logger.warning("pkill for %s returned %s", proc_name, kill_result.returncode)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(f"Timeout while checking for {proc_name} processes")
|
||||
logger.warning("Timeout while checking for %s processes", proc_name)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking for {proc_name} processes: {e}")
|
||||
logger.debug("Error checking for %s processes: %s", proc_name, e)
|
||||
|
||||
if total_killed > 0:
|
||||
time.sleep(1)
|
||||
logger.info(f"Cleaned up {total_killed} orphan process(es)")
|
||||
logger.info("Cleaned up %s orphan process(es)", total_killed)
|
||||
logger.log_resource_usage()
|
||||
else:
|
||||
logger.debug("No orphan processes found")
|
||||
|
||||
return total_killed
|
||||
|
||||
async def _get_page_info(page) -> tuple[str, str, str]:
|
||||
|
||||
async def _get_page_info(page: Any) -> tuple[str, str, str]:
|
||||
"""Extract page title, body text, and current URL safely."""
|
||||
try:
|
||||
title = (await page.get_title() or "").lower()
|
||||
@@ -333,30 +343,35 @@ async def _get_page_info(page) -> tuple[str, str, str]:
|
||||
return title, body, current_url
|
||||
|
||||
|
||||
def _check_indicators(title: str, body: str, indicators: list[str]) -> Optional[str]:
|
||||
def _check_indicators(title: str, body: str, indicators: list[str]) -> str | None:
|
||||
"""Check if any indicator is present in title or body. Returns the found indicator or None."""
|
||||
for indicator in indicators:
|
||||
if indicator in title or indicator in body:
|
||||
return indicator
|
||||
return None
|
||||
|
||||
|
||||
def _has_cloudflare_patterns(body: str, url: str) -> bool:
|
||||
"""Check for Cloudflare-specific patterns in body or URL."""
|
||||
return "cf-" in body or "cloudflare" in url.lower() or "/cdn-cgi/" in url
|
||||
|
||||
async def _detect_challenge_type(page) -> str:
|
||||
|
||||
async def _detect_challenge_type(page: Any) -> str:
|
||||
"""Detect challenge type: 'cloudflare', 'ddos_guard', or 'none'."""
|
||||
try:
|
||||
title, body, current_url = await _get_page_info(page)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Error detecting challenge type: %s", e)
|
||||
return "none"
|
||||
else:
|
||||
# DDOS-Guard indicators
|
||||
if found := _check_indicators(title, body, DDOS_GUARD_INDICATORS):
|
||||
logger.debug(f"DDOS-Guard indicator found: '{found}'")
|
||||
logger.debug("DDOS-Guard indicator found: '%s'", found)
|
||||
return "ddos_guard"
|
||||
|
||||
# Cloudflare indicators
|
||||
if found := _check_indicators(title, body, CLOUDFLARE_INDICATORS):
|
||||
logger.debug(f"Cloudflare indicator found: '{found}'")
|
||||
logger.debug("Cloudflare indicator found: '%s'", found)
|
||||
return "cloudflare"
|
||||
|
||||
# Check URL patterns
|
||||
@@ -364,24 +379,28 @@ async def _detect_challenge_type(page) -> str:
|
||||
return "cloudflare"
|
||||
|
||||
return "none"
|
||||
except Exception as e:
|
||||
logger.warning(f"Error detecting challenge type: {e}")
|
||||
return "none"
|
||||
|
||||
async def _is_bypassed(page, escape_emojis: bool = True) -> bool:
|
||||
|
||||
async def _is_bypassed(page: Any, *, escape_emojis: bool = True) -> bool:
|
||||
"""Check if the protection has been bypassed."""
|
||||
try:
|
||||
title, body, current_url = await _get_page_info(page)
|
||||
except Exception as e:
|
||||
logger.warning("Error checking bypass status: %s", e)
|
||||
return False
|
||||
|
||||
else:
|
||||
body_len = len(body.strip())
|
||||
|
||||
# Long page content = probably bypassed
|
||||
if body_len > 100000:
|
||||
logger.debug(f"Page content too long, probably bypassed (len: {body_len})")
|
||||
logger.debug("Page content too long, probably bypassed (len: %s)", body_len)
|
||||
return True
|
||||
|
||||
# Multiple emojis = probably real content
|
||||
if escape_emojis:
|
||||
import emoji
|
||||
|
||||
if len(emoji.emoji_list(body)) >= 3:
|
||||
logger.debug("Detected emojis in page, probably bypassed")
|
||||
return True
|
||||
@@ -400,14 +419,11 @@ async def _is_bypassed(page, escape_emojis: bool = True) -> bool:
|
||||
logger.debug("Page content too short, might still be loading")
|
||||
return False
|
||||
|
||||
logger.debug(f"Bypass check passed - Title: '{title[:100]}', Body length: {body_len}")
|
||||
logger.debug("Bypass check passed - Title: '%s', Body length: %s", title[:100], body_len)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking bypass status: {e}")
|
||||
return False
|
||||
|
||||
async def _bypass_method_humanlike(page) -> bool:
|
||||
async def _bypass_method_humanlike(page: Any) -> bool:
|
||||
"""Human-like behavior with scroll, wait, and reload."""
|
||||
try:
|
||||
logger.debug("Attempting bypass: human-like interaction")
|
||||
@@ -421,7 +437,7 @@ async def _bypass_method_humanlike(page) -> bool:
|
||||
await page.wait()
|
||||
await asyncio.sleep(random.uniform(2, 3))
|
||||
except Exception as e:
|
||||
logger.debug(f"Scroll behavior failed: {e}")
|
||||
logger.debug("Scroll behavior failed: %s", e)
|
||||
|
||||
if await _is_bypassed(page):
|
||||
return True
|
||||
@@ -437,15 +453,15 @@ async def _bypass_method_humanlike(page) -> bool:
|
||||
await page.solve_captcha()
|
||||
await asyncio.sleep(random.uniform(3, 5))
|
||||
except Exception as e:
|
||||
logger.debug(f"Final captcha click failed: {e}")
|
||||
logger.debug("Final captcha click failed: %s", e)
|
||||
|
||||
return await _is_bypassed(page)
|
||||
except Exception as e:
|
||||
logger.debug(f"Human-like method failed: {e}")
|
||||
logger.debug("Human-like method failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def _bypass_method_cdp_solve(page) -> bool:
|
||||
async def _bypass_method_cdp_solve(page: Any) -> bool:
|
||||
"""CDP Mode with solve_captcha() - auto-detects challenge type."""
|
||||
try:
|
||||
logger.debug("Attempting bypass: CDP solve_captcha")
|
||||
@@ -453,21 +469,21 @@ async def _bypass_method_cdp_solve(page) -> bool:
|
||||
await asyncio.sleep(random.uniform(3, 5))
|
||||
return await _is_bypassed(page)
|
||||
except Exception as e:
|
||||
logger.debug(f"CDP solve_captcha failed: {e}")
|
||||
logger.debug("CDP solve_captcha failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
CDP_CLICK_SELECTORS = [
|
||||
"#turnstile-widget div", # Cloudflare Turnstile
|
||||
"#cf-turnstile div", # Alternative CF Turnstile
|
||||
"#turnstile-widget div", # Cloudflare Turnstile
|
||||
"#cf-turnstile div", # Alternative CF Turnstile
|
||||
"iframe[src*='challenges']", # CF challenge iframe
|
||||
"input[type='checkbox']", # Generic checkbox (DDOS-Guard)
|
||||
"[class*='checkbox']", # Class-based checkbox
|
||||
"#challenge-running", # CF challenge indicator
|
||||
"input[type='checkbox']", # Generic checkbox (DDOS-Guard)
|
||||
"[class*='checkbox']", # Class-based checkbox
|
||||
"#challenge-running", # CF challenge indicator
|
||||
]
|
||||
|
||||
|
||||
async def _bypass_method_cdp_click(page) -> bool:
|
||||
async def _bypass_method_cdp_click(page: Any) -> bool:
|
||||
"""CDP Mode with native clicking - no PyAutoGUI dependency."""
|
||||
try:
|
||||
logger.debug("Attempting bypass: CDP native click")
|
||||
@@ -477,31 +493,31 @@ async def _bypass_method_cdp_click(page) -> bool:
|
||||
if not await page.is_element_visible(selector):
|
||||
continue
|
||||
|
||||
logger.debug(f"CDP clicking: {selector}")
|
||||
logger.debug("CDP clicking: %s", selector)
|
||||
await page.click(selector)
|
||||
await asyncio.sleep(random.uniform(2, 4))
|
||||
|
||||
if await _is_bypassed(page):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"CDP click on '{selector}' failed: {e}")
|
||||
logger.debug("CDP click on '%s' failed: %s", selector, e)
|
||||
|
||||
return await _is_bypassed(page)
|
||||
except Exception as e:
|
||||
logger.debug(f"CDP Mode click failed: {e}")
|
||||
logger.debug("CDP Mode click failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
CDP_GUI_CLICK_SELECTORS = [
|
||||
"#turnstile-widget div", # Cloudflare Turnstile
|
||||
"#cf-turnstile div", # Alternative CF Turnstile
|
||||
"#challenge-stage div", # CF challenge stage
|
||||
"input[type='checkbox']", # Generic checkbox
|
||||
"[class*='cb-i']", # DDOS-Guard checkbox
|
||||
"#turnstile-widget div", # Cloudflare Turnstile
|
||||
"#cf-turnstile div", # Alternative CF Turnstile
|
||||
"#challenge-stage div", # CF challenge stage
|
||||
"input[type='checkbox']", # Generic checkbox
|
||||
"[class*='cb-i']", # DDOS-Guard checkbox
|
||||
]
|
||||
|
||||
|
||||
async def _bypass_method_cdp_gui_click(page) -> bool:
|
||||
async def _bypass_method_cdp_gui_click(page: Any) -> bool:
|
||||
"""CDP Mode with gui_click-style behavior."""
|
||||
try:
|
||||
logger.debug("Attempting bypass: CDP gui_click (mouse-based)")
|
||||
@@ -514,25 +530,25 @@ async def _bypass_method_cdp_gui_click(page) -> bool:
|
||||
if await _is_bypassed(page):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"solve_captcha() failed: {e}")
|
||||
logger.debug("solve_captcha() failed: %s", e)
|
||||
|
||||
for selector in CDP_GUI_CLICK_SELECTORS:
|
||||
try:
|
||||
if not await page.is_element_visible(selector):
|
||||
continue
|
||||
|
||||
logger.debug(f"CDP click_with_offset: {selector}")
|
||||
logger.debug("CDP click_with_offset: %s", selector)
|
||||
await page.click_with_offset(selector, 0, 0, center=True)
|
||||
await asyncio.sleep(random.uniform(3, 5))
|
||||
|
||||
if await _is_bypassed(page):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"CDP gui_click on '{selector}' failed: {e}")
|
||||
logger.debug("CDP gui_click on '%s' failed: %s", selector, e)
|
||||
|
||||
return await _is_bypassed(page)
|
||||
except Exception as e:
|
||||
logger.debug(f"CDP Mode gui_click failed: {e}")
|
||||
logger.debug("CDP Mode gui_click failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
@@ -546,14 +562,16 @@ BYPASS_METHODS = [
|
||||
MAX_CONSECUTIVE_SAME_CHALLENGE = 3
|
||||
|
||||
|
||||
def _check_cancellation(cancel_flag: Optional[Event], message: str) -> None:
|
||||
def _check_cancellation(cancel_flag: Event | None, message: str) -> None:
|
||||
"""Check if cancellation was requested and raise if so."""
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
logger.info(message)
|
||||
raise BypassCancelledException("Bypass cancelled")
|
||||
|
||||
|
||||
async def _bypass(page, max_retries: Optional[int] = None, cancel_flag: Optional[Event] = None) -> bool:
|
||||
async def _bypass(
|
||||
page: Any, max_retries: int | None = None, cancel_flag: Event | None = None
|
||||
) -> bool:
|
||||
"""Attempt to bypass Cloudflare/DDOS-Guard protection using multiple methods."""
|
||||
max_retries = max_retries if max_retries is not None else app_config.MAX_RETRY
|
||||
|
||||
@@ -571,7 +589,7 @@ async def _bypass(page, max_retries: Optional[int] = None, cancel_flag: Optional
|
||||
return True
|
||||
|
||||
challenge_type = await _detect_challenge_type(page)
|
||||
logger.debug(f"Challenge detected: {challenge_type}")
|
||||
logger.debug("Challenge detected: %s", challenge_type)
|
||||
|
||||
# No challenge detected but page doesn't look bypassed - wait and retry
|
||||
if challenge_type == "none":
|
||||
@@ -587,14 +605,16 @@ async def _bypass(page, max_retries: Optional[int] = None, cancel_flag: Optional
|
||||
logger.info("Bypass successful after refresh")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Refresh during no-challenge wait failed: {e}")
|
||||
logger.debug("Refresh during no-challenge wait failed: %s", e)
|
||||
continue
|
||||
|
||||
if challenge_type == last_challenge_type:
|
||||
consecutive_same_challenge += 1
|
||||
if consecutive_same_challenge >= min_same_challenge_before_abort:
|
||||
logger.warning(
|
||||
f"Same challenge ({challenge_type}) detected {consecutive_same_challenge} times - aborting"
|
||||
"Same challenge (%s) detected %s times - aborting",
|
||||
challenge_type,
|
||||
consecutive_same_challenge,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
@@ -602,11 +622,11 @@ async def _bypass(page, max_retries: Optional[int] = None, cancel_flag: Optional
|
||||
last_challenge_type = challenge_type
|
||||
|
||||
method = BYPASS_METHODS[try_count % len(BYPASS_METHODS)]
|
||||
logger.info(f"Bypass attempt {try_count + 1}/{max_retries} using {method.__name__}")
|
||||
logger.info("Bypass attempt %s/%s using %s", try_count + 1, max_retries, method.__name__)
|
||||
|
||||
if try_count > 0:
|
||||
wait_time = min(random.uniform(2, 4) * try_count, 12)
|
||||
logger.info(f"Waiting {wait_time:.1f}s before trying...")
|
||||
logger.info("Waiting %0.1fs before trying...", wait_time)
|
||||
for _ in range(int(wait_time)):
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled during wait")
|
||||
await asyncio.sleep(1)
|
||||
@@ -614,18 +634,19 @@ async def _bypass(page, max_retries: Optional[int] = None, cancel_flag: Optional
|
||||
|
||||
try:
|
||||
if await method(page):
|
||||
logger.info(f"Bypass successful using {method.__name__}")
|
||||
logger.info("Bypass successful using %s", method.__name__)
|
||||
return True
|
||||
except BypassCancelledException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception in {method.__name__}: {e}")
|
||||
logger.warning("Exception in %s: %s", method.__name__, e)
|
||||
|
||||
logger.info(f"Bypass method {method.__name__} failed.")
|
||||
logger.info("Bypass method %s failed.", method.__name__)
|
||||
|
||||
logger.warning("Exceeded maximum retries. Bypass failed.")
|
||||
return False
|
||||
|
||||
|
||||
def _get_browser_args() -> list[str]:
|
||||
"""Build extra Chrome arguments, pre-resolving hostnames via patched DNS.
|
||||
|
||||
@@ -645,16 +666,14 @@ def _get_browser_args() -> list[str]:
|
||||
]
|
||||
|
||||
if app_config.get("DEBUG", False):
|
||||
arguments.extend([
|
||||
"--enable-logging",
|
||||
"--v=1",
|
||||
"--log-file=" + str(LOG_DIR / "chrome_browser.log")
|
||||
])
|
||||
arguments.extend(
|
||||
["--enable-logging", "--v=1", "--log-file=" + str(LOG_DIR / "chrome_browser.log")]
|
||||
)
|
||||
|
||||
host_rules = _build_host_resolver_rules()
|
||||
if host_rules:
|
||||
arguments.append(f'--host-resolver-rules={", ".join(host_rules)}')
|
||||
logger.debug(f"Chrome: Using host resolver rules for {len(host_rules)} hosts")
|
||||
arguments.append(f"--host-resolver-rules={', '.join(host_rules)}")
|
||||
logger.debug("Chrome: Using host resolver rules for %s hosts", len(host_rules))
|
||||
else:
|
||||
logger.warning("Chrome: No hosts could be pre-resolved")
|
||||
|
||||
@@ -676,40 +695,39 @@ def _build_host_resolver_rules() -> list[str]:
|
||||
if results:
|
||||
ip = results[0][4][0]
|
||||
host_rules.append(f"MAP {hostname} {ip}")
|
||||
logger.debug(f"Chrome: Pre-resolved {hostname} -> {ip}")
|
||||
logger.debug("Chrome: Pre-resolved %s -> %s", hostname, ip)
|
||||
else:
|
||||
logger.warning(f"Chrome: No addresses returned for {hostname}")
|
||||
logger.warning("Chrome: No addresses returned for %s", hostname)
|
||||
except socket.gaierror as e:
|
||||
logger.warning(f"Chrome: Could not pre-resolve {hostname}: {e}")
|
||||
logger.warning("Chrome: Could not pre-resolve %s: %s", hostname, e)
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error pre-resolving hostnames for Chrome: {e}")
|
||||
|
||||
return host_rules
|
||||
|
||||
|
||||
DRIVER_RESET_ERRORS = {"ProtocolException", "RuntimeError", "TimeoutError"}
|
||||
|
||||
|
||||
async def _get(url: str, driver, cancel_flag: Optional[Event] = None) -> str:
|
||||
async def _get(url: str, driver: Any, cancel_flag: Event | None = None) -> str:
|
||||
"""Fetch URL with Cloudflare bypass using a CDP browser."""
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled before starting")
|
||||
|
||||
logger.debug(f"CDP_GET: {url}")
|
||||
logger.debug("CDP_GET: %s", url)
|
||||
|
||||
logger.debug("Opening URL with SeleniumBase CDP...")
|
||||
page = await driver.get(url)
|
||||
try:
|
||||
with suppress(Exception):
|
||||
await page.wait()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_check_cancellation(cancel_flag, "Bypass cancelled after page load")
|
||||
|
||||
try:
|
||||
current_url = await page.get_current_url()
|
||||
title = await page.get_title()
|
||||
logger.debug(f"Page loaded - URL: {current_url}, Title: {title}")
|
||||
logger.debug("Page loaded - URL: %s, Title: %s", current_url, title)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get page info: {e}")
|
||||
logger.debug("Could not get page info: %s", e)
|
||||
|
||||
logger.debug("Starting bypass process...")
|
||||
if await _bypass(page, cancel_flag=cancel_flag):
|
||||
@@ -727,7 +745,7 @@ async def _get(url: str, driver, cancel_flag: Optional[Event] = None) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def get(url: str, retry: Optional[int] = None, cancel_flag: Optional[Event] = None) -> str:
|
||||
def get(url: str, retry: int | None = None, cancel_flag: Event | None = None) -> str:
|
||||
"""Fetch a URL with protection bypass. Creates fresh Chrome instance for each bypass."""
|
||||
retry = retry if retry is not None else app_config.MAX_RETRY
|
||||
|
||||
@@ -753,8 +771,10 @@ def get(url: str, retry: Optional[int] = None, cancel_flag: Optional[Event] = No
|
||||
raise
|
||||
except Exception as e:
|
||||
error_details = f"{type(e).__name__}: {e}"
|
||||
logger.warning(f"Bypass failed (attempt {attempt + 1}/{retry}): {error_details}")
|
||||
logger.debug(f"Stack trace: {traceback.format_exc()}")
|
||||
logger.warning(
|
||||
"Bypass failed (attempt %s/%s): %s", attempt + 1, retry, error_details
|
||||
)
|
||||
logger.debug("Stack trace: %s", traceback.format_exc())
|
||||
|
||||
# On CDP errors, quit and create a fresh browser
|
||||
if type(e).__name__ in DRIVER_RESET_ERRORS:
|
||||
@@ -762,7 +782,7 @@ def get(url: str, retry: Optional[int] = None, cancel_flag: Optional[Event] = No
|
||||
await _close_cdp_driver(driver)
|
||||
driver = await _create_cdp_browser(url)
|
||||
|
||||
logger.error(f"Bypass failed after {retry} attempts")
|
||||
logger.error("Bypass failed after %s attempts", retry)
|
||||
return ""
|
||||
finally:
|
||||
if driver:
|
||||
@@ -770,7 +790,8 @@ def get(url: str, retry: Optional[int] = None, cancel_flag: Optional[Event] = No
|
||||
|
||||
return _CDP_WORKER.run(_run_bypass())
|
||||
|
||||
def _get_proxy_string(url: str) -> Optional[str]:
|
||||
|
||||
def _get_proxy_string(url: str) -> str | None:
|
||||
"""Return a single proxy string for CDP, honoring NO_PROXY."""
|
||||
proxies = get_proxies(url)
|
||||
if not proxies:
|
||||
@@ -787,8 +808,8 @@ async def _create_cdp_browser(url: str) -> Any:
|
||||
display_height = screen_height + 150
|
||||
proxy = _get_proxy_string(url)
|
||||
|
||||
logger.debug(f"Creating Pure CDP browser with args: {browser_args}")
|
||||
logger.debug(f"Browser screen size: {screen_width}x{screen_height}")
|
||||
logger.debug("Creating Pure CDP browser with args: %s", browser_args)
|
||||
logger.debug("Browser screen size: %sx%s", screen_width, screen_height)
|
||||
|
||||
try:
|
||||
driver = await cdp_driver.start_async(
|
||||
@@ -804,21 +825,21 @@ async def _create_cdp_browser(url: str) -> Any:
|
||||
browser_args=browser_args,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Pure CDP browser startup failed: {type(e).__name__}: {e}")
|
||||
logger.warning("Pure CDP browser startup failed: %s: %s", 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')}"
|
||||
"SeleniumBase runtime paths: cwd=%s; %s; %s; %s; %s",
|
||||
Path.cwd(),
|
||||
_describe_runtime_path(SELENIUMBASE_DOWNLOADS_DIR),
|
||||
_describe_runtime_path("/app/downloaded_files"),
|
||||
_describe_runtime_path("downloaded_files"),
|
||||
_describe_runtime_path("/tmp"),
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
await driver.page.set_window_rect(0, 0, screen_width, screen_height)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to set window size: {e}")
|
||||
logger.debug("Failed to set window size: %s", e)
|
||||
|
||||
# Start FFmpeg recording if debug mode (record each bypass session)
|
||||
if app_config.get("DEBUG", False) and not DISPLAY.get("ffmpeg"):
|
||||
@@ -830,7 +851,7 @@ async def _create_cdp_browser(url: str) -> Any:
|
||||
return driver
|
||||
|
||||
|
||||
async def _close_cdp_driver(driver) -> None:
|
||||
async def _close_cdp_driver(driver: Any) -> None:
|
||||
"""Close CDP connections and stop the browser."""
|
||||
if not driver:
|
||||
return
|
||||
@@ -846,18 +867,15 @@ async def _close_cdp_driver(driver) -> None:
|
||||
if hasattr(driver, "targets") and driver.targets:
|
||||
connections.extend(driver.targets)
|
||||
for conn in connections:
|
||||
try:
|
||||
await conn.aclose()
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to close websocket connection: {e}")
|
||||
await _close_websocket_connection(conn)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error during connection cleanup: {e}")
|
||||
logger.debug("Error during connection cleanup: %s", e)
|
||||
|
||||
try:
|
||||
driver.stop()
|
||||
logger.debug("Stopped CDP browser")
|
||||
except Exception as e:
|
||||
logger.debug(f"CDP stop: {e}")
|
||||
logger.debug("CDP stop: %s", e)
|
||||
|
||||
if env.DOCKERMODE:
|
||||
await asyncio.sleep(0.3)
|
||||
@@ -879,15 +897,23 @@ async def _close_cdp_driver(driver) -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
if _pid_alive(pid):
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
logger.debug(f"Killed Chrome pid {pid}")
|
||||
logger.debug("Killed Chrome pid %s", pid)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to kill Chrome pid {pid}: {e}")
|
||||
logger.debug("Failed to kill Chrome pid %s: %s", pid, e)
|
||||
except Exception as e:
|
||||
logger.debug(f"Process cleanup failed: {e}")
|
||||
logger.debug("Process cleanup failed: %s", e)
|
||||
|
||||
logger.log_resource_usage()
|
||||
|
||||
|
||||
async def _close_websocket_connection(conn: Any) -> None:
|
||||
"""Close one websocket-like connection, ignoring best-effort failures."""
|
||||
try:
|
||||
await conn.aclose()
|
||||
except Exception as e:
|
||||
logger.debug("Failed to close websocket connection: %s", e)
|
||||
|
||||
|
||||
def _start_ffmpeg_recording(display: str) -> None:
|
||||
"""Start FFmpeg screen recording for debug mode."""
|
||||
global DISPLAY
|
||||
@@ -900,16 +926,37 @@ def _start_ffmpeg_recording(display: str) -> None:
|
||||
display_height = screen_height + 150
|
||||
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg", "-y", "-f", "x11grab",
|
||||
"-video_size", f"{display_width}x{display_height}",
|
||||
"-i", display,
|
||||
"-c:v", "libx264", "-preset", "ultrafast",
|
||||
"-maxrate", "700k", "-bufsize", "1400k", "-crf", "36",
|
||||
"-pix_fmt", "yuv420p", "-tune", "animation",
|
||||
"-x264-params", "bframes=0:deblock=-1,-1",
|
||||
"-r", "15", "-an",
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"x11grab",
|
||||
"-video_size",
|
||||
f"{display_width}x{display_height}",
|
||||
"-i",
|
||||
display,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-maxrate",
|
||||
"700k",
|
||||
"-bufsize",
|
||||
"1400k",
|
||||
"-crf",
|
||||
"36",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-tune",
|
||||
"animation",
|
||||
"-x264-params",
|
||||
"bframes=0:deblock=-1,-1",
|
||||
"-r",
|
||||
"15",
|
||||
"-an",
|
||||
output_file.as_posix(),
|
||||
"-nostats", "-loglevel", "0"
|
||||
"-nostats",
|
||||
"-loglevel",
|
||||
"0",
|
||||
]
|
||||
logger.debug("Starting FFmpeg recording to %s", output_file)
|
||||
logger.debug_trace(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
|
||||
@@ -920,9 +967,9 @@ def _start_ffmpeg_recording(display: str) -> None:
|
||||
def _stop_ffmpeg_recording() -> None:
|
||||
"""Stop FFmpeg screen recording if running."""
|
||||
import signal
|
||||
|
||||
global DISPLAY
|
||||
proc = DISPLAY.get("ffmpeg")
|
||||
output_file = DISPLAY.get("ffmpeg_output")
|
||||
if not proc:
|
||||
return
|
||||
if proc.poll() is not None:
|
||||
@@ -935,21 +982,17 @@ def _stop_ffmpeg_recording() -> None:
|
||||
proc.wait(timeout=5)
|
||||
logger.debug("Stopped ffmpeg recording")
|
||||
except Exception as e:
|
||||
logger.debug(f"ffmpeg stop: {e}")
|
||||
try:
|
||||
logger.debug("ffmpeg stop: %s", e)
|
||||
with suppress(Exception):
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with suppress(Exception):
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
DISPLAY["ffmpeg"] = None
|
||||
DISPLAY["ffmpeg_output"] = None
|
||||
|
||||
|
||||
def _try_with_cached_cookies(url: str, hostname: str) -> Optional[str]:
|
||||
def _try_with_cached_cookies(url: str, hostname: str) -> str | None:
|
||||
"""Attempt request with cached cookies before using Chrome."""
|
||||
cookies = get_cf_cookies_for_domain(hostname)
|
||||
if not cookies:
|
||||
@@ -959,10 +1002,17 @@ def _try_with_cached_cookies(url: str, hostname: str) -> Optional[str]:
|
||||
headers = {}
|
||||
stored_ua = get_cf_user_agent_for_domain(hostname)
|
||||
if stored_ua:
|
||||
headers['User-Agent'] = stored_ua
|
||||
headers["User-Agent"] = stored_ua
|
||||
|
||||
logger.debug(f"Trying request with cached cookies: {url}")
|
||||
response = requests.get(url, cookies=cookies, headers=headers, proxies=get_proxies(url), timeout=(5, 10), verify=get_ssl_verify(url))
|
||||
logger.debug("Trying request with cached cookies: %s", url)
|
||||
response = requests.get(
|
||||
url,
|
||||
cookies=cookies,
|
||||
headers=headers,
|
||||
proxies=get_proxies(url),
|
||||
timeout=(5, 10),
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
if response.status_code == 200:
|
||||
logger.debug("Cached cookies worked, skipped Chrome bypass")
|
||||
return response.text
|
||||
@@ -973,10 +1023,8 @@ def _try_with_cached_cookies(url: str, hostname: str) -> Optional[str]:
|
||||
|
||||
|
||||
def get_bypassed_page(
|
||||
url: str,
|
||||
selector: Optional[network.AAMirrorSelector] = None,
|
||||
cancel_flag: Optional[Event] = None
|
||||
) -> Optional[str]:
|
||||
url: str, selector: network.AAMirrorSelector | None = None, cancel_flag: Event | None = None
|
||||
) -> str | None:
|
||||
"""Fetch HTML content from a URL using the internal Cloudflare Bypasser."""
|
||||
sel = selector or network.AAMirrorSelector()
|
||||
attempt_url = sel.rewrite(url)
|
||||
|
||||
@@ -130,12 +130,13 @@ def get_booklore_library_options() -> list[dict[str, Any]]:
|
||||
|
||||
try:
|
||||
library_options, _ = _get_booklore_cached_options(base_url, username, password)
|
||||
return library_options
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to fetch Booklore libraries: {exc}")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Booklore libraries")
|
||||
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
|
||||
return _BOOKLORE_OPTIONS_CACHE.get("library_options", [])
|
||||
return []
|
||||
else:
|
||||
return library_options
|
||||
|
||||
|
||||
def get_booklore_path_options() -> list[dict[str, Any]]:
|
||||
@@ -154,19 +155,22 @@ def get_booklore_path_options() -> list[dict[str, Any]]:
|
||||
|
||||
try:
|
||||
_, path_options = _get_booklore_cached_options(base_url, username, password)
|
||||
return path_options
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to fetch Booklore paths: {exc}")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Booklore paths")
|
||||
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
|
||||
return _BOOKLORE_OPTIONS_CACHE.get("path_options", [])
|
||||
return []
|
||||
else:
|
||||
return path_options
|
||||
|
||||
|
||||
def test_booklore_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
def test_booklore_connection(
|
||||
current_values: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Test the Booklore connection using current form values."""
|
||||
current_values = current_values or {}
|
||||
|
||||
def _get_value(key: str, default: Any = None) -> Any:
|
||||
def _get_value(key: str, default: object = None) -> object:
|
||||
value = current_values.get(key)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
@@ -187,11 +191,11 @@ def test_booklore_connection(current_values: dict[str, Any] | None = None) -> di
|
||||
|
||||
try:
|
||||
library_options, _ = _get_booklore_select_options(base_url, username, password)
|
||||
|
||||
except BookloreError as exc:
|
||||
return {"success": False, "message": str(exc)}
|
||||
else:
|
||||
message = "Connected to Grimmory"
|
||||
if library_options:
|
||||
message = f"Connected to Grimmory ({len(library_options)} libraries)"
|
||||
|
||||
return {"success": True, "message": message}
|
||||
except BookloreError as exc:
|
||||
return {"success": False, "message": str(exc)}
|
||||
|
||||
@@ -3,15 +3,20 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.download.outputs.email import EmailOutputError, build_email_smtp_config, test_smtp_connection
|
||||
from shelfmark.download.outputs.email import (
|
||||
EmailOutputError,
|
||||
build_email_smtp_config,
|
||||
test_smtp_connection,
|
||||
)
|
||||
|
||||
|
||||
def test_email_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
def test_email_connection(
|
||||
current_values: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Test SMTP connectivity using current form values (including unsaved changes)."""
|
||||
|
||||
current_values = current_values or {}
|
||||
|
||||
def _get_value(key: str, default: Any = None) -> Any:
|
||||
def _get_value(key: str, default: object = None) -> object:
|
||||
value = current_values.get(key)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
@@ -28,15 +33,15 @@ def test_email_connection(current_values: dict[str, Any] | None = None) -> dict[
|
||||
"EMAIL_FROM": _get_value("EMAIL_FROM", ""),
|
||||
"EMAIL_SUBJECT_TEMPLATE": _get_value("EMAIL_SUBJECT_TEMPLATE", "{Title}"),
|
||||
"EMAIL_SMTP_TIMEOUT_SECONDS": _get_value("EMAIL_SMTP_TIMEOUT_SECONDS", 60),
|
||||
"EMAIL_ALLOW_UNVERIFIED_TLS": _get_value("EMAIL_ALLOW_UNVERIFIED_TLS", False),
|
||||
"EMAIL_ALLOW_UNVERIFIED_TLS": _get_value("EMAIL_ALLOW_UNVERIFIED_TLS", default=False),
|
||||
}
|
||||
|
||||
try:
|
||||
smtp_config = build_email_smtp_config(settings)
|
||||
test_smtp_connection(smtp_config)
|
||||
return {"success": True, "message": "Connected to SMTP server"}
|
||||
except EmailOutputError as exc:
|
||||
return {"success": False, "message": str(exc)}
|
||||
except Exception as exc:
|
||||
return {"success": False, "message": f"SMTP test failed: {exc}"}
|
||||
|
||||
else:
|
||||
return {"success": True, "message": "Connected to SMTP server"}
|
||||
|
||||
@@ -23,11 +23,11 @@ def _read_debug_from_config() -> bool:
|
||||
|
||||
if config_file.exists():
|
||||
try:
|
||||
with open(config_file, "r") as f:
|
||||
with config_file.open() as f:
|
||||
config = json.load(f)
|
||||
if "DEBUG" in config:
|
||||
return bool(config["DEBUG"])
|
||||
except (json.JSONDecodeError, OSError):
|
||||
except json.JSONDecodeError, OSError:
|
||||
pass
|
||||
|
||||
return False
|
||||
@@ -36,10 +36,10 @@ def _read_debug_from_config() -> bool:
|
||||
def _is_sqlite_file(path: Path) -> bool:
|
||||
"""Check if a file is a valid SQLite database by reading magic bytes."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
with path.open("rb") as f:
|
||||
header = f.read(16)
|
||||
return header[:16] == b"SQLite format 3\x00"
|
||||
except (OSError, PermissionError):
|
||||
except OSError, PermissionError:
|
||||
return False
|
||||
|
||||
|
||||
@@ -67,14 +67,16 @@ def _is_config_dir_writable() -> bool:
|
||||
test_file = CONFIG_DIR / ".write_test"
|
||||
test_file.touch()
|
||||
test_file.unlink()
|
||||
return True
|
||||
except (OSError, PermissionError):
|
||||
except OSError, PermissionError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def is_covers_cache_enabled() -> bool:
|
||||
"""Check if cover caching is enabled (requires setting + writable config dir)."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
setting_enabled = config.get("COVERS_CACHE_ENABLED", True)
|
||||
return setting_enabled and _is_config_dir_writable()
|
||||
|
||||
@@ -151,7 +153,7 @@ ONBOARDING = string_to_bool(os.getenv("ONBOARDING", "true"))
|
||||
# Debug: skip specific download sources for testing fallback chains
|
||||
# Comma-separated values: aa-fast, aa-slow-nowait, aa-slow-wait, libgen, zlib, welib
|
||||
_DEBUG_SKIP_SOURCES_RAW = os.getenv("DEBUG_SKIP_SOURCES", "").strip().lower()
|
||||
DEBUG_SKIP_SOURCES = set(s.strip() for s in _DEBUG_SKIP_SOURCES_RAW.split(",") if s.strip())
|
||||
DEBUG_SKIP_SOURCES = {s.strip() for s in _DEBUG_SKIP_SOURCES_RAW.split(",") if s.strip()}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Configuration migration helpers."""
|
||||
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_DEPRECATED_SETTINGS_RESTRICTION_KEYS = (
|
||||
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN",
|
||||
@@ -11,7 +14,7 @@ _DEPRECATED_SETTINGS_RESTRICTION_KEYS = (
|
||||
)
|
||||
|
||||
|
||||
def _as_bool(value: Any) -> bool:
|
||||
def _as_bool(value: object) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
@@ -23,10 +26,7 @@ def _pick_legacy_settings_restriction(config: dict[str, Any]) -> bool | None:
|
||||
"""Pick the best legacy admin-restriction value to migrate."""
|
||||
auth_method = str(config.get("AUTH_METHOD", "")).strip().lower()
|
||||
|
||||
if (
|
||||
auth_method == "proxy"
|
||||
and "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config
|
||||
):
|
||||
if auth_method == "proxy" and "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config:
|
||||
return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN"))
|
||||
|
||||
if auth_method == "cwa" and "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config:
|
||||
@@ -50,9 +50,9 @@ def migrate_security_settings(
|
||||
load_users_config: Callable[[], dict[str, Any]],
|
||||
save_users_config: Callable[[dict[str, Any]], None],
|
||||
ensure_config_dir: Callable[[], None],
|
||||
get_config_path: Callable[[], Any],
|
||||
get_config_path: Callable[[], object],
|
||||
sync_builtin_admin_user: Callable[[str, str], None],
|
||||
logger: Any,
|
||||
logger: object,
|
||||
) -> None:
|
||||
"""Migrate legacy security keys and sync builtin admin credentials."""
|
||||
try:
|
||||
@@ -67,13 +67,12 @@ def migrate_security_settings(
|
||||
if old_value:
|
||||
config["AUTH_METHOD"] = "cwa"
|
||||
logger.info("Migrated USE_CWA_AUTH=True to AUTH_METHOD='cwa'")
|
||||
elif config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"):
|
||||
config["AUTH_METHOD"] = "builtin"
|
||||
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='builtin'")
|
||||
else:
|
||||
if config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"):
|
||||
config["AUTH_METHOD"] = "builtin"
|
||||
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='builtin'")
|
||||
else:
|
||||
config["AUTH_METHOD"] = "none"
|
||||
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='none'")
|
||||
config["AUTH_METHOD"] = "none"
|
||||
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='none'")
|
||||
migrated_security = True
|
||||
else:
|
||||
logger.info("Removed deprecated USE_CWA_AUTH setting (AUTH_METHOD already exists)")
|
||||
@@ -82,14 +81,17 @@ def migrate_security_settings(
|
||||
# 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 (
|
||||
"AUTH_METHOD" not in config
|
||||
and config.get("BUILTIN_USERNAME")
|
||||
and config.get("BUILTIN_PASSWORD_HASH")
|
||||
):
|
||||
config["AUTH_METHOD"] = "builtin"
|
||||
migrated_security = True
|
||||
logger.info(
|
||||
"Backfilled AUTH_METHOD='builtin' from legacy "
|
||||
"BUILTIN_USERNAME/BUILTIN_PASSWORD_HASH credentials"
|
||||
)
|
||||
|
||||
if "RESTRICT_SETTINGS_TO_ADMIN" not in users_config:
|
||||
legacy_restrict = _pick_legacy_settings_restriction(config)
|
||||
@@ -97,31 +99,30 @@ def migrate_security_settings(
|
||||
save_users_config({"RESTRICT_SETTINGS_TO_ADMIN": legacy_restrict})
|
||||
migrated_users = True
|
||||
logger.info(
|
||||
"Migrated legacy settings-admin restriction to users.RESTRICT_SETTINGS_TO_ADMIN="
|
||||
f"{legacy_restrict}"
|
||||
"Migrated legacy settings-admin restriction to users.RESTRICT_SETTINGS_TO_ADMIN=%s",
|
||||
legacy_restrict,
|
||||
)
|
||||
|
||||
for deprecated_key in _DEPRECATED_SETTINGS_RESTRICTION_KEYS:
|
||||
if deprecated_key in config:
|
||||
config.pop(deprecated_key, None)
|
||||
migrated_security = True
|
||||
logger.info(f"Removed deprecated security setting: {deprecated_key}")
|
||||
logger.info("Removed deprecated security setting: %s", deprecated_key)
|
||||
|
||||
try:
|
||||
sync_builtin_admin_user(
|
||||
config.get("BUILTIN_USERNAME", ""),
|
||||
config.get("BUILTIN_PASSWORD_HASH", ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to sync builtin credentials to users database during migration: "
|
||||
f"{exc}"
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to sync builtin credentials to users database during migration"
|
||||
)
|
||||
|
||||
if migrated_security:
|
||||
ensure_config_dir()
|
||||
config_path = get_config_path()
|
||||
with open(config_path, "w") as f:
|
||||
config_path = Path(get_config_path())
|
||||
with config_path.open("w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
logger.info("Security settings migration completed successfully")
|
||||
elif migrated_users:
|
||||
@@ -131,5 +132,5 @@ def migrate_security_settings(
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.debug("No existing security config file found - nothing to migrate")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to migrate security settings: {exc}")
|
||||
except Exception:
|
||||
logger.exception("Failed to migrate security settings")
|
||||
|
||||
@@ -11,6 +11,7 @@ from shelfmark.core.notifications import NotificationEvent, send_test_notificati
|
||||
from shelfmark.core.settings_registry import (
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
SettingsField,
|
||||
TableField,
|
||||
load_config_file,
|
||||
register_on_save,
|
||||
@@ -124,7 +125,7 @@ def _count_invalid_route_urls(routes: list[dict[str, Any]]) -> int:
|
||||
|
||||
|
||||
def _ensure_default_route_row(routes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return routes if routes else [dict(row) for row in _DEFAULT_ROUTE_ROWS]
|
||||
return routes or [dict(row) for row in _DEFAULT_ROUTE_ROWS]
|
||||
|
||||
|
||||
def _extract_unique_route_urls(routes: list[dict[str, Any]]) -> list[str]:
|
||||
@@ -260,7 +261,7 @@ register_on_save("notifications", _on_save_notifications)
|
||||
|
||||
|
||||
@register_settings("notifications", "Notifications", icon="bell", order=7)
|
||||
def notifications_settings():
|
||||
def notifications_settings() -> list[SettingsField]:
|
||||
"""Global notifications settings."""
|
||||
return [
|
||||
HeadingField(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Authentication settings registration."""
|
||||
|
||||
from typing import Any, Dict, Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.config.migrations import migrate_security_settings
|
||||
from shelfmark.config.security_handlers import (
|
||||
@@ -10,19 +10,23 @@ from shelfmark.config.security_handlers import (
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.settings_registry import (
|
||||
register_settings,
|
||||
register_on_save,
|
||||
load_config_file,
|
||||
TextField,
|
||||
SelectField,
|
||||
PasswordField,
|
||||
CheckboxField,
|
||||
ActionButton,
|
||||
TagListField,
|
||||
CheckboxField,
|
||||
CustomComponentField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
SettingsField,
|
||||
TagListField,
|
||||
TextField,
|
||||
load_config_file,
|
||||
register_on_save,
|
||||
register_settings,
|
||||
)
|
||||
from shelfmark.core.user_db import sync_builtin_admin_user
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
@@ -36,8 +40,8 @@ def _auth_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) ->
|
||||
|
||||
def _migrate_security_settings() -> None:
|
||||
from shelfmark.core.settings_registry import (
|
||||
_get_config_file_path,
|
||||
_ensure_config_dir,
|
||||
_get_config_file_path,
|
||||
save_config_file,
|
||||
)
|
||||
|
||||
@@ -52,12 +56,11 @@ def _migrate_security_settings() -> None:
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _on_save_security(values: dict[str, Any]) -> dict[str, Any]:
|
||||
return on_save_security(values)
|
||||
|
||||
|
||||
def _test_oidc_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
def _test_oidc_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return test_oidc_connection(
|
||||
load_security_config=lambda: {
|
||||
"OIDC_DISCOVERY_URL": app_config.get("OIDC_DISCOVERY_URL", ""),
|
||||
@@ -68,7 +71,7 @@ def _test_oidc_connection(current_values: Dict[str, Any] = None) -> Dict[str, An
|
||||
|
||||
|
||||
@register_settings("security", "Security", icon="shield", order=5)
|
||||
def security_settings():
|
||||
def security_settings() -> list[SettingsField]:
|
||||
"""Security and authentication settings."""
|
||||
from shelfmark.config.env import CWA_DB_PATH
|
||||
|
||||
@@ -105,18 +108,22 @@ 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."
|
||||
*(
|
||||
[]
|
||||
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"),
|
||||
),
|
||||
show_when=_auth_condition("cwa"),
|
||||
),
|
||||
]),
|
||||
]
|
||||
),
|
||||
ActionButton(
|
||||
key="open_users_tab",
|
||||
label="Go to Users",
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
"""Operational handlers for security settings (save/actions)."""
|
||||
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_OIDC_LOCKOUT_MESSAGE = "A local admin account with a password is required before enabling OIDC. Use the 'Go to Users' button above to create one. This ensures you can still sign in if your identity provider is unavailable."
|
||||
|
||||
|
||||
def _has_local_password_admin() -> bool:
|
||||
root = os.environ.get("CONFIG_DIR", "/config")
|
||||
user_db = UserDB(os.path.join(root, "users.db"))
|
||||
user_db = UserDB(str(Path(root) / "users.db"))
|
||||
user_db.initialize()
|
||||
return any(user.get("password_hash") and user.get("role") == "admin" for user in user_db.list_users())
|
||||
return any(
|
||||
user.get("password_hash") and user.get("role") == "admin" for user in user_db.list_users()
|
||||
)
|
||||
|
||||
|
||||
def on_save_security(
|
||||
@@ -56,7 +61,9 @@ def test_oidc_connection(
|
||||
|
||||
try:
|
||||
# Prefer the current (unsaved) form value over the saved config
|
||||
discovery_url = (current_values or {}).get("OIDC_DISCOVERY_URL") or load_security_config().get("OIDC_DISCOVERY_URL", "")
|
||||
discovery_url = (current_values or {}).get(
|
||||
"OIDC_DISCOVERY_URL"
|
||||
) or load_security_config().get("OIDC_DISCOVERY_URL", "")
|
||||
if not discovery_url:
|
||||
return {"success": False, "message": "Discovery URL is not configured."}
|
||||
|
||||
@@ -67,9 +74,12 @@ def test_oidc_connection(
|
||||
required_fields = ["issuer", "authorization_endpoint", "token_endpoint"]
|
||||
missing_fields = [field for field in required_fields if field not in document]
|
||||
if missing_fields:
|
||||
return {"success": False, "message": f"Discovery document missing fields: {', '.join(missing_fields)}"}
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Discovery document missing fields: {', '.join(missing_fields)}",
|
||||
}
|
||||
|
||||
return {"success": True, "message": f"Connected to {document['issuer']}"}
|
||||
except Exception as exc:
|
||||
logger.error(f"OIDC connection test failed: {exc}")
|
||||
return {"success": False, "message": f"Connection failed: {str(exc)}"}
|
||||
logger.exception("OIDC connection test failed")
|
||||
return {"success": False, "message": f"Connection failed: {exc!s}"}
|
||||
|
||||
+151
-105
@@ -1,12 +1,11 @@
|
||||
"""Core settings registration and derived configuration values."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _on_save_advanced(values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate advanced settings before persisting."""
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
@@ -37,7 +36,7 @@ def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
if not host or not remote_path or not local_path:
|
||||
logger.debug(
|
||||
"Skipping entry %d: missing field(s) - host=%r, remotePath=%r, localPath=%r",
|
||||
"Skipping entry %d: missing field(s) - host=%s, remotePath=%s, localPath=%s",
|
||||
i,
|
||||
host,
|
||||
remote_path,
|
||||
@@ -57,7 +56,9 @@ def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
logger.info("Saved %d remote path mapping(s)", len(cleaned))
|
||||
if cleaned:
|
||||
for m in cleaned:
|
||||
logger.debug(" Mapping: %s -> %s (client: %s)", m["remotePath"], m["localPath"], m["host"])
|
||||
logger.debug(
|
||||
" Mapping: %s -> %s (client: %s)", m["remotePath"], m["localPath"], m["host"]
|
||||
)
|
||||
|
||||
values["PROWLARR_REMOTE_PATH_MAPPINGS"] = cleaned
|
||||
return {"error": False, "values": values}
|
||||
@@ -76,19 +77,19 @@ logger = setup_logger(__name__)
|
||||
|
||||
# Log bootstrap configuration values at DEBUG level
|
||||
logger.debug("Bootstrap configuration:")
|
||||
for key in ['CONFIG_DIR', 'LOG_DIR', 'TMP_DIR', 'INGEST_DIR', 'DEBUG', 'DOCKERMODE']:
|
||||
for key in ["CONFIG_DIR", "LOG_DIR", "TMP_DIR", "INGEST_DIR", "DEBUG", "DOCKERMODE"]:
|
||||
if hasattr(env, key):
|
||||
logger.debug(f" {key}: {getattr(env, key)}")
|
||||
logger.debug(" %s: %s", key, getattr(env, key))
|
||||
|
||||
# Load supported book languages from data file
|
||||
# Path is relative to the package root, not this file
|
||||
_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
|
||||
with open(_DATA_DIR / "book-languages.json") as file:
|
||||
with (_DATA_DIR / "book-languages.json").open() as file:
|
||||
_SUPPORTED_BOOK_LANGUAGE = json.load(file)
|
||||
|
||||
# Directory settings
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
logger.debug(f"BASE_DIR: {BASE_DIR}")
|
||||
logger.debug("BASE_DIR: %s", BASE_DIR)
|
||||
if env.ENABLE_LOGGING:
|
||||
env.LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
@@ -106,6 +107,7 @@ RECORDING_DIR = env.LOG_DIR / "recording"
|
||||
def _log_external_bypasser_warning() -> None:
|
||||
"""Log warning about external bypasser DNS limitations (called after config is available)."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
if config.get("USING_EXTERNAL_BYPASSER", False) and config.get("USE_CF_BYPASS", True):
|
||||
logger.warning(
|
||||
"Using external bypasser (FlareSolverr). Note: FlareSolverr uses its own DNS resolution, "
|
||||
@@ -116,36 +118,31 @@ def _log_external_bypasser_warning() -> None:
|
||||
|
||||
|
||||
from shelfmark.core.settings_registry import (
|
||||
register_settings,
|
||||
ActionButton,
|
||||
CheckboxField,
|
||||
HeadingField,
|
||||
MultiSelectField,
|
||||
NumberField,
|
||||
OrderableListField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
SettingsField,
|
||||
TableField,
|
||||
TagListField,
|
||||
TextField,
|
||||
load_config_file,
|
||||
register_group,
|
||||
register_on_save,
|
||||
load_config_file,
|
||||
TextField,
|
||||
PasswordField,
|
||||
NumberField,
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
MultiSelectField,
|
||||
TagListField,
|
||||
OrderableListField,
|
||||
TableField,
|
||||
HeadingField,
|
||||
ActionButton,
|
||||
register_settings,
|
||||
)
|
||||
|
||||
|
||||
register_group(
|
||||
"direct_download",
|
||||
"Direct Download",
|
||||
icon="download",
|
||||
order=20
|
||||
)
|
||||
register_group("direct_download", "Direct Download", icon="download", order=20)
|
||||
|
||||
register_group(
|
||||
"metadata_providers",
|
||||
"Metadata Providers",
|
||||
icon="book",
|
||||
order=12 # Between Network (10) and Advanced (15)
|
||||
order=12, # Between Network (10) and Advanced (15)
|
||||
)
|
||||
|
||||
|
||||
@@ -203,15 +200,15 @@ _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES = {
|
||||
}
|
||||
|
||||
|
||||
def _get_metadata_provider_options():
|
||||
def _get_metadata_provider_options() -> list[dict[str, str]]:
|
||||
"""Build metadata provider options dynamically from enabled providers only."""
|
||||
from shelfmark.metadata_providers import list_providers, is_provider_enabled
|
||||
from shelfmark.metadata_providers import is_provider_enabled, list_providers
|
||||
|
||||
options = []
|
||||
for provider in list_providers():
|
||||
# Only show providers that are enabled
|
||||
if is_provider_enabled(provider["name"]):
|
||||
options.append({"value": provider["name"], "label": provider["display_name"]})
|
||||
options = [
|
||||
{"value": provider["name"], "label": provider["display_name"]}
|
||||
for provider in list_providers()
|
||||
if is_provider_enabled(provider["name"])
|
||||
]
|
||||
|
||||
# If no providers enabled, show a placeholder option
|
||||
if not options:
|
||||
@@ -222,12 +219,12 @@ def _get_metadata_provider_options():
|
||||
return options
|
||||
|
||||
|
||||
def _get_metadata_provider_options_with_none():
|
||||
def _get_metadata_provider_options_with_none() -> list[dict[str, str]]:
|
||||
"""Build metadata provider options with a 'Use main provider' option first."""
|
||||
return [{"value": "", "label": "Use book provider"}] + _get_metadata_provider_options()
|
||||
return [{"value": "", "label": "Use book provider"}, *_get_metadata_provider_options()]
|
||||
|
||||
|
||||
def _get_release_source_options_for_content_type(content_type: str):
|
||||
def _get_release_source_options_for_content_type(content_type: str) -> list[dict[str, str]]:
|
||||
"""Build release source options dynamically for a specific content type."""
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
|
||||
@@ -239,25 +236,28 @@ def _get_release_source_options_for_content_type(content_type: str):
|
||||
]
|
||||
|
||||
|
||||
def _get_book_release_source_options():
|
||||
def _get_book_release_source_options() -> list[dict[str, str]]:
|
||||
"""Build default release source options for book searches."""
|
||||
return _get_release_source_options_for_content_type("ebook")
|
||||
|
||||
|
||||
def _get_audiobook_release_source_options():
|
||||
def _get_audiobook_release_source_options() -> list[dict[str, str]]:
|
||||
"""Build default release source options for audiobook searches."""
|
||||
return [{"value": "", "label": "Use book release source"}] + _get_release_source_options_for_content_type(
|
||||
"audiobook"
|
||||
)
|
||||
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
|
||||
]
|
||||
|
||||
_LANGUAGE_OPTIONS = [{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE]
|
||||
|
||||
def _get_aa_base_url_options():
|
||||
def _get_aa_base_url_options() -> list[dict[str, str]]:
|
||||
"""Build AA URL options dynamically, including additional mirrors from config."""
|
||||
from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS, get_aa_mirrors
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS, get_aa_mirrors
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
|
||||
options = [{"value": "auto", "label": "Auto (Recommended)"}]
|
||||
@@ -273,7 +273,7 @@ def _get_aa_base_url_options():
|
||||
allow_special=("auto",),
|
||||
)
|
||||
if configured_url and configured_url != "auto" and configured_url not in all_mirrors:
|
||||
all_mirrors = [configured_url] + all_mirrors
|
||||
all_mirrors = [configured_url, *all_mirrors]
|
||||
|
||||
for url in all_mirrors:
|
||||
domain = url.replace("https://", "").replace("http://", "")
|
||||
@@ -286,10 +286,10 @@ def _get_aa_base_url_options():
|
||||
return options
|
||||
|
||||
|
||||
def _get_zlib_mirror_options():
|
||||
def _get_zlib_mirror_options() -> list[dict[str, str]]:
|
||||
"""Build Z-Library mirror options for SelectField."""
|
||||
from shelfmark.core.mirrors import DEFAULT_ZLIB_MIRRORS
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.mirrors import DEFAULT_ZLIB_MIRRORS
|
||||
|
||||
options = []
|
||||
|
||||
@@ -310,10 +310,10 @@ def _get_zlib_mirror_options():
|
||||
return options
|
||||
|
||||
|
||||
def _get_welib_mirror_options():
|
||||
def _get_welib_mirror_options() -> list[dict[str, str]]:
|
||||
"""Build Welib mirror options for SelectField."""
|
||||
from shelfmark.core.mirrors import DEFAULT_WELIB_MIRRORS
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.mirrors import DEFAULT_WELIB_MIRRORS
|
||||
|
||||
options = []
|
||||
|
||||
@@ -345,16 +345,17 @@ def _clear_covers_cache(current_values: dict) -> dict:
|
||||
# Reset the singleton so it reinitializes with fresh state
|
||||
reset_image_cache()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to clear cover cache")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Failed to clear cache: {e!s}",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Cleared {count} cached cover images.",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear cover cache: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Failed to clear cache: {str(e)}",
|
||||
}
|
||||
|
||||
|
||||
def _clear_metadata_cache(current_values: dict) -> dict:
|
||||
@@ -371,15 +372,15 @@ def _clear_metadata_cache(current_values: dict) -> dict:
|
||||
"message": f"Cleared {stats_before['size']} cached entries.",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear metadata cache: {e}")
|
||||
logger.exception("Failed to clear metadata cache")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Failed to clear cache: {str(e)}",
|
||||
"message": f"Failed to clear cache: {e!s}",
|
||||
}
|
||||
|
||||
|
||||
@register_settings("general", "General", icon="settings", order=0)
|
||||
def general_settings():
|
||||
def general_settings() -> list[SettingsField]:
|
||||
"""Core application settings."""
|
||||
return [
|
||||
TextField(
|
||||
@@ -424,7 +425,7 @@ def general_settings():
|
||||
|
||||
|
||||
@register_settings("search_mode", "Search Mode", icon="search", order=1)
|
||||
def search_mode_settings():
|
||||
def search_mode_settings() -> list[SettingsField]:
|
||||
"""Configure how you search for and download books."""
|
||||
return [
|
||||
HeadingField(
|
||||
@@ -531,10 +532,9 @@ def search_mode_settings():
|
||||
|
||||
|
||||
@register_settings("network", "Network", icon="globe", order=10)
|
||||
def network_settings():
|
||||
def network_settings() -> list[SettingsField]:
|
||||
"""Network and connectivity settings."""
|
||||
# Check if Tor variant is available and if Tor is currently enabled
|
||||
tor_available = env.TOR_VARIANT_AVAILABLE
|
||||
# Check if Tor is currently enabled.
|
||||
tor_enabled = env.USING_TOR
|
||||
|
||||
# When Tor is enabled, DNS/proxy settings are overridden by iptables rules
|
||||
@@ -595,7 +595,10 @@ def network_settings():
|
||||
disabled=tor_overrides_network,
|
||||
disabled_reason="DNS over HTTPS is not used when Tor routing is enabled.",
|
||||
# Hide for manual and system (no DoH endpoint available for custom IPs or system DNS)
|
||||
show_when={"field": "CUSTOM_DNS", "value": ["auto", "google", "cloudflare", "quad9", "opendns"]},
|
||||
show_when={
|
||||
"field": "CUSTOM_DNS",
|
||||
"value": ["auto", "google", "cloudflare", "quad9", "opendns"],
|
||||
},
|
||||
# Disable for auto (always uses DoH)
|
||||
disabled_when={
|
||||
"field": "CUSTOM_DNS",
|
||||
@@ -690,9 +693,7 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
|
||||
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()
|
||||
str(value).strip().lower() for value in raw_content_types if str(value).strip()
|
||||
]
|
||||
else:
|
||||
return {
|
||||
@@ -773,28 +774,44 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
try:
|
||||
port = int(effective.get("EMAIL_SMTP_PORT", 587))
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
return {"error": True, "message": "SMTP port must be a number", "values": values}
|
||||
|
||||
if port < 1 or port > 65535:
|
||||
return {"error": True, "message": "SMTP port must be between 1 and 65535", "values": values}
|
||||
return {
|
||||
"error": True,
|
||||
"message": "SMTP port must be between 1 and 65535",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
try:
|
||||
timeout_seconds = int(effective.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60))
|
||||
except (TypeError, ValueError):
|
||||
return {"error": True, "message": "SMTP timeout (seconds) must be a number", "values": values}
|
||||
except TypeError, ValueError:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "SMTP timeout (seconds) must be a number",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
if timeout_seconds < 1:
|
||||
return {"error": True, "message": "SMTP timeout (seconds) must be >= 1", "values": values}
|
||||
return {
|
||||
"error": True,
|
||||
"message": "SMTP timeout (seconds) must be >= 1",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
username = str(effective.get("EMAIL_SMTP_USERNAME", "") or "").strip()
|
||||
password = effective.get("EMAIL_SMTP_PASSWORD", "") or ""
|
||||
if username and not password:
|
||||
return {"error": True, "message": "SMTP password is required when username is set", "values": values}
|
||||
return {
|
||||
"error": True,
|
||||
"message": "SMTP password is required when username is set",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
try:
|
||||
attachment_limit_mb = int(effective.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25))
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Attachment size limit (MB) must be a number",
|
||||
@@ -846,7 +863,7 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
@register_settings("downloads", "Downloads", icon="folder", order=5)
|
||||
def download_settings():
|
||||
def download_settings() -> list[SettingsField]:
|
||||
"""Configure download behavior and file locations."""
|
||||
return [
|
||||
# === BOOKS SECTION ===
|
||||
@@ -901,17 +918,17 @@ def download_settings():
|
||||
{
|
||||
"value": "none",
|
||||
"label": "None",
|
||||
"description": "Keep original filename from source"
|
||||
"description": "Keep original filename from source",
|
||||
},
|
||||
{
|
||||
"value": "rename",
|
||||
"label": "Rename Only",
|
||||
"description": "Rename single-file downloads; multi-file keeps original names."
|
||||
"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. Do not use with ingest folders."
|
||||
"description": "Create folders and rename files using a template. Do not use with ingest folders.",
|
||||
},
|
||||
],
|
||||
default="rename",
|
||||
@@ -1081,7 +1098,11 @@ def download_settings():
|
||||
description="Transport security mode for SMTP.",
|
||||
options=[
|
||||
{"value": "none", "label": "None", "description": "No TLS (not recommended)."},
|
||||
{"value": "starttls", "label": "STARTTLS", "description": "Upgrade to TLS after connecting (recommended)."},
|
||||
{
|
||||
"value": "starttls",
|
||||
"label": "STARTTLS",
|
||||
"description": "Upgrade to TLS after connecting (recommended).",
|
||||
},
|
||||
{"value": "ssl", "label": "SSL/TLS", "description": "Connect using TLS (SMTPS)."},
|
||||
],
|
||||
default="starttls",
|
||||
@@ -1139,7 +1160,6 @@ def download_settings():
|
||||
callback=test_email_connection,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
|
||||
# === AUDIOBOOKS SECTION ===
|
||||
# Universal mode only
|
||||
HeadingField(
|
||||
@@ -1160,9 +1180,21 @@ def download_settings():
|
||||
label="File Organization",
|
||||
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 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."},
|
||||
{
|
||||
"value": "none",
|
||||
"label": "None",
|
||||
"description": "Keep original filename from source",
|
||||
},
|
||||
{
|
||||
"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",
|
||||
universal_only=True,
|
||||
@@ -1194,7 +1226,6 @@ def download_settings():
|
||||
default=True,
|
||||
universal_only=True,
|
||||
),
|
||||
|
||||
# === OPTIONS SECTION ===
|
||||
HeadingField(
|
||||
key="options_heading",
|
||||
@@ -1239,7 +1270,7 @@ def download_settings():
|
||||
register_on_save("downloads", _on_save_downloads)
|
||||
|
||||
|
||||
def _get_fast_source_options():
|
||||
def _get_fast_source_options() -> list[dict[str, str | bool | int | None]]:
|
||||
"""Fast download sources - configurable list shown in settings."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
@@ -1263,7 +1294,7 @@ def _get_fast_source_options():
|
||||
]
|
||||
|
||||
|
||||
def _get_fast_source_defaults():
|
||||
def _get_fast_source_defaults() -> list[dict[str, str | bool]]:
|
||||
"""Default values for fast sources display."""
|
||||
return [
|
||||
{"id": "aa-fast", "enabled": True},
|
||||
@@ -1271,7 +1302,7 @@ def _get_fast_source_defaults():
|
||||
]
|
||||
|
||||
|
||||
def _get_slow_source_options():
|
||||
def _get_slow_source_options() -> list[dict[str, str | bool | None]]:
|
||||
"""Slow download sources - configurable order. All require bypasser."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
@@ -1311,7 +1342,7 @@ def _get_slow_source_options():
|
||||
]
|
||||
|
||||
|
||||
def _get_slow_source_defaults():
|
||||
def _get_slow_source_defaults() -> list[dict[str, str | bool]]:
|
||||
"""Default source priority order for slow sources."""
|
||||
from shelfmark.config.env import _LEGACY_ALLOW_USE_WELIB
|
||||
|
||||
@@ -1323,8 +1354,10 @@ def _get_slow_source_defaults():
|
||||
]
|
||||
|
||||
|
||||
@register_settings("download_sources", "Download Sources", icon="download", order=21, group="direct_download")
|
||||
def download_source_settings():
|
||||
@register_settings(
|
||||
"download_sources", "Download Sources", icon="download", order=21, group="direct_download"
|
||||
)
|
||||
def download_source_settings() -> list[SettingsField]:
|
||||
"""Settings for download source behavior."""
|
||||
return [
|
||||
PasswordField(
|
||||
@@ -1429,8 +1462,10 @@ def download_source_settings():
|
||||
]
|
||||
|
||||
|
||||
@register_settings("cloudflare_bypass", "Cloudflare Bypass", icon="shield", order=22, group="direct_download")
|
||||
def cloudflare_bypass_settings():
|
||||
@register_settings(
|
||||
"cloudflare_bypass", "Cloudflare Bypass", icon="shield", order=22, group="direct_download"
|
||||
)
|
||||
def cloudflare_bypass_settings() -> list[SettingsField]:
|
||||
"""Settings for Cloudflare bypass behavior."""
|
||||
return [
|
||||
CheckboxField(
|
||||
@@ -1477,7 +1512,8 @@ def cloudflare_bypass_settings():
|
||||
),
|
||||
]
|
||||
|
||||
def _on_save_mirrors(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
def _on_save_mirrors(values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize mirror list settings before persisting."""
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS
|
||||
@@ -1512,14 +1548,19 @@ def _on_save_mirrors(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
values["AA_MIRROR_URLS"] = normalized
|
||||
return {"error": False, "values": values}
|
||||
|
||||
|
||||
# Register the on_save handler for this tab
|
||||
register_on_save("mirrors", _on_save_mirrors)
|
||||
|
||||
|
||||
@register_settings("mirrors", "Mirrors", icon="globe", order=23, group="direct_download")
|
||||
def mirror_settings():
|
||||
def mirror_settings() -> list[SettingsField]:
|
||||
"""Configure download source mirrors."""
|
||||
from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS, DEFAULT_ZLIB_MIRRORS, DEFAULT_WELIB_MIRRORS
|
||||
from shelfmark.core.mirrors import (
|
||||
DEFAULT_AA_MIRRORS,
|
||||
DEFAULT_WELIB_MIRRORS,
|
||||
DEFAULT_ZLIB_MIRRORS,
|
||||
)
|
||||
|
||||
return [
|
||||
# === PRIMARY SOURCE ===
|
||||
@@ -1548,7 +1589,6 @@ def mirror_settings():
|
||||
description="Deprecated. Use Mirrors instead. This is kept for backwards compatibility with existing installs and environment variables.",
|
||||
show_when={"field": "AA_ADDITIONAL_URLS", "notEmpty": True},
|
||||
),
|
||||
|
||||
# === LIBGEN ===
|
||||
HeadingField(
|
||||
key="libgen_mirrors_heading",
|
||||
@@ -1560,7 +1600,6 @@ def mirror_settings():
|
||||
label="Additional Mirrors",
|
||||
description="Comma-separated list of custom LibGen mirrors to add to the defaults.",
|
||||
),
|
||||
|
||||
# === Z-LIBRARY ===
|
||||
HeadingField(
|
||||
key="zlib_mirrors_heading",
|
||||
@@ -1579,7 +1618,6 @@ def mirror_settings():
|
||||
label="Additional Mirrors",
|
||||
description="Comma-separated list of custom Z-Library mirror URLs.",
|
||||
),
|
||||
|
||||
# === WELIB ===
|
||||
HeadingField(
|
||||
key="welib_mirrors_heading",
|
||||
@@ -1602,7 +1640,7 @@ def mirror_settings():
|
||||
|
||||
|
||||
@register_settings("advanced", "Advanced", icon="cog", order=15)
|
||||
def advanced_settings():
|
||||
def advanced_settings() -> list[SettingsField]:
|
||||
"""Advanced settings for power users."""
|
||||
return [
|
||||
TextField(
|
||||
@@ -1648,8 +1686,16 @@ def advanced_settings():
|
||||
label="Custom Script Path Mode",
|
||||
description="Pass the path to the custom script as an absolute path or relative to the destination folder.",
|
||||
options=[
|
||||
{"value": "absolute", "label": "Absolute", "description": "Pass the full destination path (default)."},
|
||||
{"value": "relative", "label": "Relative", "description": "Pass the path relative to the destination folder."},
|
||||
{
|
||||
"value": "absolute",
|
||||
"label": "Absolute",
|
||||
"description": "Pass the full destination path (default).",
|
||||
},
|
||||
{
|
||||
"value": "relative",
|
||||
"label": "Relative",
|
||||
"description": "Pass the path relative to the destination folder.",
|
||||
},
|
||||
],
|
||||
default="absolute",
|
||||
),
|
||||
|
||||
@@ -7,6 +7,11 @@ that talks to /api/admin/users endpoints.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.request_policy import (
|
||||
get_source_content_type_capabilities,
|
||||
parse_policy_mode,
|
||||
validate_policy_rules,
|
||||
)
|
||||
from shelfmark.core.settings_registry import (
|
||||
CheckboxField,
|
||||
CustomComponentField,
|
||||
@@ -14,16 +19,11 @@ from shelfmark.core.settings_registry import (
|
||||
MultiSelectField,
|
||||
NumberField,
|
||||
SelectField,
|
||||
SettingsField,
|
||||
TableField,
|
||||
register_on_save,
|
||||
register_settings,
|
||||
)
|
||||
from shelfmark.core.request_policy import (
|
||||
get_source_content_type_capabilities,
|
||||
parse_policy_mode,
|
||||
validate_policy_rules,
|
||||
)
|
||||
|
||||
|
||||
_REQUEST_DEFAULT_MODE_OPTIONS = [
|
||||
{
|
||||
@@ -72,7 +72,11 @@ _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_PROVIDER_KEYS = {
|
||||
"METADATA_PROVIDER",
|
||||
"METADATA_PROVIDER_AUDIOBOOK",
|
||||
"METADATA_PROVIDER_COMBINED",
|
||||
}
|
||||
_SEARCH_PREFERENCE_VALIDATABLE_KEYS = {
|
||||
"SEARCH_MODE",
|
||||
"DEFAULT_RELEASE_SOURCE",
|
||||
@@ -104,19 +108,17 @@ _USERS_HEADING_DESCRIPTION_BY_AUTH_MODE = {
|
||||
}
|
||||
|
||||
|
||||
def _get_request_source_options():
|
||||
def _get_request_source_options() -> list[dict[str, str]]:
|
||||
"""Build request-policy source options from registered release sources."""
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
|
||||
options = []
|
||||
for source in list_available_sources():
|
||||
options.append(
|
||||
{
|
||||
"value": source["name"],
|
||||
"label": source["display_name"],
|
||||
}
|
||||
)
|
||||
return options
|
||||
return [
|
||||
{
|
||||
"value": source["name"],
|
||||
"label": source["display_name"],
|
||||
}
|
||||
for source in list_available_sources()
|
||||
]
|
||||
|
||||
|
||||
def _get_valid_release_source_names_for_content_type(content_type: str) -> set[str]:
|
||||
@@ -131,20 +133,20 @@ def _get_valid_release_source_names_for_content_type(content_type: str) -> set[s
|
||||
return valid_sources
|
||||
|
||||
|
||||
def _get_request_policy_rule_columns():
|
||||
def _get_request_policy_rule_columns() -> list[dict[str, object]]:
|
||||
source_capabilities = get_source_content_type_capabilities()
|
||||
content_type_options = []
|
||||
|
||||
for source_name, supported_types in source_capabilities.items():
|
||||
normalized_types = [t for t in ("ebook", "audiobook") if t in supported_types]
|
||||
for content_type in normalized_types:
|
||||
content_type_options.append(
|
||||
{
|
||||
"value": content_type,
|
||||
"label": "Ebook" if content_type == "ebook" else "Audiobook",
|
||||
"childOf": source_name,
|
||||
}
|
||||
)
|
||||
content_type_options.extend(
|
||||
{
|
||||
"value": content_type,
|
||||
"label": "Ebook" if content_type == "ebook" else "Audiobook",
|
||||
"childOf": source_name,
|
||||
}
|
||||
for content_type in normalized_types
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -224,7 +226,7 @@ def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | N
|
||||
return value, None
|
||||
|
||||
|
||||
def _on_save_users(values):
|
||||
def _on_save_users(values: dict[str, object]) -> dict[str, object]:
|
||||
"""Validate users/request-policy settings before persistence."""
|
||||
if "VISIBLE_SELF_SETTINGS_SECTIONS" in values:
|
||||
raw_sections = values["VISIBLE_SELF_SETTINGS_SECTIONS"]
|
||||
@@ -233,7 +235,9 @@ def _on_save_users(values):
|
||||
elif isinstance(raw_sections, str):
|
||||
candidate_sections = [s.strip() for s in raw_sections.split(",") if s.strip()]
|
||||
elif isinstance(raw_sections, (list, tuple, set)):
|
||||
candidate_sections = [str(section).strip() for section in raw_sections if str(section).strip()]
|
||||
candidate_sections = [
|
||||
str(section).strip() for section in raw_sections if str(section).strip()
|
||||
]
|
||||
else:
|
||||
return {
|
||||
"error": True,
|
||||
@@ -258,21 +262,25 @@ def _on_save_users(values):
|
||||
|
||||
values["VISIBLE_SELF_SETTINGS_SECTIONS"] = normalized_sections
|
||||
|
||||
if "REQUEST_POLICY_DEFAULT_EBOOK" in values:
|
||||
if parse_policy_mode(values["REQUEST_POLICY_DEFAULT_EBOOK"]) is None:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "REQUEST_POLICY_DEFAULT_EBOOK must be a valid policy mode",
|
||||
"values": values,
|
||||
}
|
||||
if (
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK" in values
|
||||
and parse_policy_mode(values["REQUEST_POLICY_DEFAULT_EBOOK"]) is None
|
||||
):
|
||||
return {
|
||||
"error": True,
|
||||
"message": "REQUEST_POLICY_DEFAULT_EBOOK must be a valid policy mode",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
if "REQUEST_POLICY_DEFAULT_AUDIOBOOK" in values:
|
||||
if parse_policy_mode(values["REQUEST_POLICY_DEFAULT_AUDIOBOOK"]) is None:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "REQUEST_POLICY_DEFAULT_AUDIOBOOK must be a valid policy mode",
|
||||
"values": values,
|
||||
}
|
||||
if (
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK" in values
|
||||
and parse_policy_mode(values["REQUEST_POLICY_DEFAULT_AUDIOBOOK"]) is None
|
||||
):
|
||||
return {
|
||||
"error": True,
|
||||
"message": "REQUEST_POLICY_DEFAULT_AUDIOBOOK must be a valid policy mode",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
if "REQUEST_POLICY_RULES" in values:
|
||||
normalized_rules, errors = validate_policy_rules(values["REQUEST_POLICY_RULES"])
|
||||
@@ -303,7 +311,7 @@ register_on_save("users", _on_save_users)
|
||||
|
||||
|
||||
@register_settings("users", "Users & Requests", icon="users", order=6)
|
||||
def users_settings():
|
||||
def users_settings() -> list[SettingsField]:
|
||||
"""User management tab - rendered as a custom component on the frontend."""
|
||||
return [
|
||||
HeadingField(
|
||||
@@ -330,9 +338,7 @@ def users_settings():
|
||||
HeadingField(
|
||||
key="requests_heading",
|
||||
title="Requests",
|
||||
description=(
|
||||
"Choose what users can download directly and what needs approval first."
|
||||
),
|
||||
description=("Choose what users can download directly and what needs approval first."),
|
||||
),
|
||||
CheckboxField(
|
||||
key="REQUESTS_ENABLED",
|
||||
@@ -356,9 +362,7 @@ def users_settings():
|
||||
SelectField(
|
||||
key="REQUEST_POLICY_DEFAULT_EBOOK",
|
||||
label="Default Ebook Mode",
|
||||
description=(
|
||||
"Sets the baseline for all ebook sources."
|
||||
),
|
||||
description=("Sets the baseline for all ebook sources."),
|
||||
options=_REQUEST_DEFAULT_MODE_OPTIONS,
|
||||
default="download",
|
||||
user_overridable=True,
|
||||
@@ -366,9 +370,7 @@ def users_settings():
|
||||
SelectField(
|
||||
key="REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
label="Default Audiobook Mode",
|
||||
description=(
|
||||
"Sets the baseline for all audiobook sources."
|
||||
),
|
||||
description=("Sets the baseline for all audiobook sources."),
|
||||
options=_REQUEST_DEFAULT_MODE_OPTIONS,
|
||||
default="download",
|
||||
user_overridable=True,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
"""Core module - shared models, queue, and utilities."""
|
||||
|
||||
from shelfmark.core.models import QueueItem, SearchFilters, QueueStatus
|
||||
from shelfmark.core.queue import BookQueue, book_queue
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import QueueItem, QueueStatus, SearchFilters
|
||||
from shelfmark.core.queue import BookQueue, book_queue
|
||||
|
||||
__all__ = [
|
||||
"BookQueue",
|
||||
"QueueItem",
|
||||
"QueueStatus",
|
||||
"SearchFilters",
|
||||
"book_queue",
|
||||
"setup_logger",
|
||||
]
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, NamedTuple
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
from flask import Flask, jsonify, request, session
|
||||
from flask import Flask, Response, jsonify, request, session
|
||||
|
||||
from shelfmark.core.activity_view_state_service import (
|
||||
ADMIN_VIEWER_SCOPE,
|
||||
@@ -12,22 +12,34 @@ from shelfmark.core.activity_view_state_service import (
|
||||
ActivityViewStateService,
|
||||
user_viewer_scope,
|
||||
)
|
||||
from shelfmark.core.download_history_service import ACTIVE_DOWNLOAD_STATUS, DownloadHistoryService, VALID_TERMINAL_STATUSES
|
||||
from shelfmark.core.download_history_service import (
|
||||
ACTIVE_DOWNLOAD_STATUS,
|
||||
VALID_TERMINAL_STATUSES,
|
||||
DownloadHistoryService,
|
||||
)
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import ACTIVE_QUEUE_STATUSES, QueueStatus, TERMINAL_QUEUE_STATUSES
|
||||
from shelfmark.core.request_validation import RequestStatus
|
||||
from shelfmark.core.models import (
|
||||
ACTIVE_QUEUE_STATUSES,
|
||||
TERMINAL_QUEUE_STATUSES,
|
||||
QueueStatus,
|
||||
)
|
||||
from shelfmark.core.request_helpers import (
|
||||
emit_ws_event,
|
||||
extract_release_source_id,
|
||||
normalize_positive_int,
|
||||
populate_request_usernames,
|
||||
)
|
||||
from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.core.request_validation import RequestStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _normalize_log_field(value: Any) -> str:
|
||||
def _normalize_log_field(value: object) -> str:
|
||||
if value is None:
|
||||
return "-"
|
||||
text = str(value).strip()
|
||||
@@ -39,15 +51,15 @@ def _log_activity_rejection(
|
||||
*,
|
||||
status_code: int,
|
||||
reason: str,
|
||||
auth_mode: Any = None,
|
||||
viewer_scope: Any = None,
|
||||
item_type: Any = None,
|
||||
item_key: Any = None,
|
||||
auth_mode: object = None,
|
||||
viewer_scope: object = None,
|
||||
item_type: object = None,
|
||||
item_key: object = None,
|
||||
item_count: int | None = None,
|
||||
missing_item_keys: list[str] | None = None,
|
||||
owner_user_id: Any = None,
|
||||
final_status: Any = None,
|
||||
request_id: Any = None,
|
||||
owner_user_id: object = None,
|
||||
final_status: object = None,
|
||||
request_id: object = None,
|
||||
) -> None:
|
||||
parts = [
|
||||
f"Activity {action} rejected",
|
||||
@@ -86,16 +98,16 @@ def _activity_error_response(
|
||||
status_code: int,
|
||||
error: str,
|
||||
code: str | None = None,
|
||||
auth_mode: Any = None,
|
||||
viewer_scope: Any = None,
|
||||
item_type: Any = None,
|
||||
item_key: Any = None,
|
||||
auth_mode: object = None,
|
||||
viewer_scope: object = None,
|
||||
item_type: object = None,
|
||||
item_key: object = None,
|
||||
item_count: int | None = None,
|
||||
missing_item_keys: list[str] | None = None,
|
||||
owner_user_id: Any = None,
|
||||
final_status: Any = None,
|
||||
request_id: Any = None,
|
||||
):
|
||||
owner_user_id: object = None,
|
||||
final_status: object = None,
|
||||
request_id: object = None,
|
||||
) -> tuple[Response, int]:
|
||||
_log_activity_rejection(
|
||||
action,
|
||||
status_code=status_code,
|
||||
@@ -119,7 +131,9 @@ def _activity_error_response(
|
||||
return jsonify(payload), status_code
|
||||
|
||||
|
||||
def _require_authenticated(resolve_auth_mode: Callable[[], str], *, action: str):
|
||||
def _require_authenticated(
|
||||
resolve_auth_mode: Callable[[], str], *, action: str
|
||||
) -> tuple[Response, int] | None:
|
||||
auth_mode = resolve_auth_mode()
|
||||
if auth_mode == "none":
|
||||
return None
|
||||
@@ -134,12 +148,12 @@ def _require_authenticated(resolve_auth_mode: Callable[[], str], *, action: str)
|
||||
|
||||
|
||||
def _resolve_db_user_id(
|
||||
require_in_auth_mode: bool = True,
|
||||
*,
|
||||
require_in_auth_mode: bool = True,
|
||||
user_db: UserDB | None = None,
|
||||
action: str | None = None,
|
||||
auth_mode: str | None = None,
|
||||
):
|
||||
) -> tuple[int | None, tuple[Response, int] | None]:
|
||||
raw_db_user_id = session.get("db_user_id")
|
||||
if raw_db_user_id is None:
|
||||
if not require_in_auth_mode:
|
||||
@@ -153,7 +167,7 @@ def _resolve_db_user_id(
|
||||
)
|
||||
try:
|
||||
parsed_db_user_id = int(raw_db_user_id)
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
if not require_in_auth_mode:
|
||||
return None, None
|
||||
return None, _activity_error_response(
|
||||
@@ -208,7 +222,7 @@ def _resolve_activity_actor(
|
||||
user_db: UserDB,
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
action: str,
|
||||
) -> tuple[_ActorContext | None, Any | None]:
|
||||
) -> tuple[_ActorContext | None, object | None]:
|
||||
"""Resolve acting user identity for activity mutations.
|
||||
|
||||
Returns (actor, error_response). On success actor is non-None.
|
||||
@@ -251,7 +265,7 @@ def _activity_ws_room(actor: _ActorContext) -> str:
|
||||
return "admins"
|
||||
|
||||
|
||||
def _check_item_ownership(actor: _ActorContext, row: dict[str, Any]) -> Any | None:
|
||||
def _check_item_ownership(actor: _ActorContext, row: dict[str, Any]) -> object | None:
|
||||
"""Return an error string if the actor doesn't own the item, else None."""
|
||||
if actor.is_admin:
|
||||
return None
|
||||
@@ -261,14 +275,14 @@ def _check_item_ownership(actor: _ActorContext, row: dict[str, Any]) -> Any | No
|
||||
return None
|
||||
|
||||
|
||||
def _check_terminal_download(row: dict[str, Any]) -> Any | None:
|
||||
def _check_terminal_download(row: dict[str, Any]) -> object | None:
|
||||
final_status = str(row.get("final_status") or "").strip().lower()
|
||||
if final_status not in VALID_TERMINAL_STATUSES:
|
||||
return "Only terminal downloads can be dismissed"
|
||||
return None
|
||||
|
||||
|
||||
def _check_terminal_request(row: dict[str, Any]) -> Any | None:
|
||||
def _check_terminal_request(row: dict[str, Any]) -> object | None:
|
||||
if _request_terminal_status(row) is None:
|
||||
return "Only terminal requests can be dismissed"
|
||||
return None
|
||||
@@ -289,7 +303,9 @@ def _request_row_log_context(row: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _list_visible_requests(user_db: UserDB, *, is_admin: bool, db_user_id: int | None) -> list[dict[str, Any]]:
|
||||
def _list_visible_requests(
|
||||
user_db: UserDB, *, is_admin: bool, db_user_id: int | None
|
||||
) -> list[dict[str, Any]]:
|
||||
if is_admin:
|
||||
request_rows = user_db.list_requests()
|
||||
populate_request_usernames(request_rows, user_db)
|
||||
@@ -300,7 +316,7 @@ def _list_visible_requests(user_db: UserDB, *, is_admin: bool, db_user_id: int |
|
||||
return user_db.list_requests(user_id=db_user_id)
|
||||
|
||||
|
||||
def _parse_item_key(item_key: Any, prefix: str) -> str | None:
|
||||
def _parse_item_key(item_key: object, prefix: str) -> str | None:
|
||||
"""Extract the value after 'prefix:' from an item_key string."""
|
||||
if not isinstance(item_key, str) or not item_key.startswith(f"{prefix}:"):
|
||||
return None
|
||||
@@ -311,7 +327,9 @@ def _parse_item_key(item_key: Any, prefix: str) -> str | None:
|
||||
_ALL_BUCKET_KEYS = (*ACTIVE_QUEUE_STATUSES, *TERMINAL_QUEUE_STATUSES)
|
||||
|
||||
|
||||
def _build_queue_index(queue_status: dict[str, dict[str, Any]]) -> dict[str, tuple[str, dict[str, Any]]]:
|
||||
def _build_queue_index(
|
||||
queue_status: dict[str, dict[str, Any]],
|
||||
) -> dict[str, tuple[str, dict[str, Any]]]:
|
||||
"""Index live queue entries by task id for fast activity lookups."""
|
||||
queue_index: dict[str, tuple[str, dict[str, Any]]] = {}
|
||||
for bucket_key in _ALL_BUCKET_KEYS:
|
||||
@@ -319,7 +337,9 @@ def _build_queue_index(queue_status: dict[str, dict[str, Any]]) -> dict[str, tup
|
||||
if not isinstance(bucket, dict):
|
||||
continue
|
||||
for task_id, payload in bucket.items():
|
||||
normalized_bucket_key = bucket_key.value if isinstance(bucket_key, QueueStatus) else str(bucket_key)
|
||||
normalized_bucket_key = (
|
||||
bucket_key.value if isinstance(bucket_key, QueueStatus) else str(bucket_key)
|
||||
)
|
||||
queue_index[str(task_id)] = (normalized_bucket_key, payload)
|
||||
return queue_index
|
||||
|
||||
@@ -474,12 +494,12 @@ def register_activity_routes(
|
||||
queue_status: Callable[..., dict[str, dict[str, Any]]],
|
||||
sync_request_delivery_states: Callable[..., list[dict[str, Any]]],
|
||||
emit_request_updates: Callable[[list[dict[str, Any]]], None],
|
||||
ws_manager: Any | None = None,
|
||||
ws_manager: object | None = None,
|
||||
) -> None:
|
||||
"""Register activity routes."""
|
||||
|
||||
@app.route("/api/activity/snapshot", methods=["GET"])
|
||||
def api_activity_snapshot():
|
||||
def api_activity_snapshot() -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_authenticated(resolve_auth_mode, action="snapshot")
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -548,7 +568,7 @@ def register_activity_routes(
|
||||
)
|
||||
|
||||
@app.route("/api/activity/dismiss", methods=["POST"])
|
||||
def api_activity_dismiss():
|
||||
def api_activity_dismiss() -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_authenticated(resolve_auth_mode, action="dismiss")
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -630,7 +650,10 @@ def register_activity_routes(
|
||||
item_type="download",
|
||||
item_key=f"download:{task_id}",
|
||||
)
|
||||
dismissal_item = {"item_type": "download", "item_key": f"download:{task_id}"}
|
||||
dismissal_item = {
|
||||
"item_type": "download",
|
||||
"item_key": f"download:{task_id}",
|
||||
}
|
||||
|
||||
elif item_type == "request":
|
||||
request_id = normalize_positive_int(_parse_item_key(item_key, "request"))
|
||||
@@ -688,7 +711,10 @@ def register_activity_routes(
|
||||
item_type="request",
|
||||
item_key=f"request:{request_id}",
|
||||
)
|
||||
dismissal_item = {"item_type": "request", "item_key": f"request:{request_id}"}
|
||||
dismissal_item = {
|
||||
"item_type": "request",
|
||||
"item_key": f"request:{request_id}",
|
||||
}
|
||||
else:
|
||||
return _activity_error_response(
|
||||
"dismiss",
|
||||
@@ -715,7 +741,7 @@ def register_activity_routes(
|
||||
return jsonify({"status": "dismissed", "item": dismissal_item})
|
||||
|
||||
@app.route("/api/activity/dismiss-many", methods=["POST"])
|
||||
def api_activity_dismiss_many():
|
||||
def api_activity_dismiss_many() -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_authenticated(resolve_auth_mode, action="dismiss_many")
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -860,7 +886,9 @@ def register_activity_routes(
|
||||
item_count=len(items),
|
||||
**_request_row_log_context(request_row),
|
||||
)
|
||||
dismissal_items.append({"item_type": "request", "item_key": f"request:{request_id}"})
|
||||
dismissal_items.append(
|
||||
{"item_type": "request", "item_key": f"request:{request_id}"}
|
||||
)
|
||||
continue
|
||||
|
||||
return _activity_error_response(
|
||||
@@ -904,7 +932,7 @@ def register_activity_routes(
|
||||
return jsonify({"status": "dismissed", "count": dismissed_count})
|
||||
|
||||
@app.route("/api/activity/history", methods=["GET"])
|
||||
def api_activity_history():
|
||||
def api_activity_history() -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_authenticated(resolve_auth_mode, action="history")
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -924,9 +952,15 @@ def register_activity_routes(
|
||||
if offset is None:
|
||||
offset = 0
|
||||
if limit < 1:
|
||||
return _activity_error_response("history", status_code=400, error="limit must be a positive integer")
|
||||
return _activity_error_response(
|
||||
"history", status_code=400, error="limit must be a positive integer"
|
||||
)
|
||||
if offset < 0:
|
||||
return _activity_error_response("history", status_code=400, error="offset must be a non-negative integer")
|
||||
return _activity_error_response(
|
||||
"history",
|
||||
status_code=400,
|
||||
error="offset must be a non-negative integer",
|
||||
)
|
||||
|
||||
history_rows = activity_view_state_service.list_history(
|
||||
viewer_scope=actor.viewer_scope,
|
||||
@@ -942,21 +976,25 @@ def register_activity_routes(
|
||||
dismissed_at = history_row.get("dismissed_at")
|
||||
|
||||
if not isinstance(dismissed_at, str) or not dismissed_at.strip():
|
||||
raise RuntimeError(f"Activity history state missing dismissed_at for {item_key}")
|
||||
msg = f"Activity history state missing dismissed_at for {item_key}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if item_type == "download":
|
||||
task_id = _parse_item_key(item_key, "download")
|
||||
if task_id is None:
|
||||
raise RuntimeError(f"Invalid activity history item_key: {item_key}")
|
||||
msg = f"Invalid activity history item_key: {item_key}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
download_row = download_history_service.get_by_task_id(task_id)
|
||||
if download_row is None:
|
||||
raise RuntimeError(f"Download history row not found for {item_key}")
|
||||
msg = f"Download history row not found for {item_key}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if not actor.is_admin:
|
||||
owner_user_id = normalize_positive_int(download_row.get("user_id"))
|
||||
if owner_user_id != actor.db_user_id:
|
||||
raise RuntimeError(f"Viewer state out of scope for {item_key}")
|
||||
msg = f"Viewer state out of scope for {item_key}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
effective_download_row = _effective_download_row_for_activity(
|
||||
download_row,
|
||||
@@ -973,16 +1011,19 @@ def register_activity_routes(
|
||||
if item_type == "request":
|
||||
request_id = normalize_positive_int(_parse_item_key(item_key, "request"))
|
||||
if request_id is None:
|
||||
raise RuntimeError(f"Invalid activity history item_key: {item_key}")
|
||||
msg = f"Invalid activity history item_key: {item_key}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
request_row = user_db.get_request(request_id)
|
||||
if request_row is None:
|
||||
raise RuntimeError(f"Request row not found for {item_key}")
|
||||
msg = f"Request row not found for {item_key}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if not actor.is_admin:
|
||||
owner_user_id = normalize_positive_int(request_row.get("user_id"))
|
||||
if owner_user_id != actor.db_user_id:
|
||||
raise RuntimeError(f"Viewer state out of scope for {item_key}")
|
||||
msg = f"Viewer state out of scope for {item_key}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
populate_request_usernames([request_row], user_db)
|
||||
entry = _request_history_entry(
|
||||
@@ -990,16 +1031,18 @@ def register_activity_routes(
|
||||
dismissed_at=dismissed_at,
|
||||
)
|
||||
if entry is None:
|
||||
raise RuntimeError(f"Failed to build request history entry for {item_key}")
|
||||
msg = f"Failed to build request history entry for {item_key}"
|
||||
raise RuntimeError(msg)
|
||||
payload.append(entry)
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"Unknown activity history item_type: {item_type}")
|
||||
msg = f"Unknown activity history item_type: {item_type}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
@app.route("/api/activity/history", methods=["DELETE"])
|
||||
def api_activity_history_clear():
|
||||
def api_activity_history_clear() -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_authenticated(resolve_auth_mode, action="history_clear")
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
@@ -8,7 +8,6 @@ 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"
|
||||
@@ -17,58 +16,65 @@ 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")
|
||||
msg = "user_id must be a positive integer"
|
||||
raise ValueError(msg)
|
||||
return f"{USER_VIEWER_SCOPE_PREFIX}{user_id}"
|
||||
|
||||
|
||||
def normalize_viewer_scope(viewer_scope: Any) -> str:
|
||||
def normalize_viewer_scope(viewer_scope: object) -> str:
|
||||
if not isinstance(viewer_scope, str) or not viewer_scope.strip():
|
||||
raise ValueError("viewer_scope must be a non-empty string")
|
||||
msg = "viewer_scope must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
|
||||
normalized = viewer_scope.strip()
|
||||
if normalized in {ADMIN_VIEWER_SCOPE, NOAUTH_VIEWER_SCOPE}:
|
||||
return normalized
|
||||
|
||||
if not normalized.startswith(USER_VIEWER_SCOPE_PREFIX):
|
||||
raise ValueError(
|
||||
"viewer_scope must be one of: admin:shared, noauth:shared, or user:<id>"
|
||||
)
|
||||
msg = "viewer_scope must be one of: admin:shared, noauth:shared, or user:<id>"
|
||||
raise ValueError(msg)
|
||||
|
||||
raw_user_id = normalized[len(USER_VIEWER_SCOPE_PREFIX):].strip()
|
||||
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
|
||||
msg = "viewer_scope user id must be a positive integer"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
return user_viewer_scope(parsed_user_id)
|
||||
|
||||
|
||||
def _normalize_item_type(item_type: Any) -> str:
|
||||
def _normalize_item_type(item_type: object) -> str:
|
||||
if not isinstance(item_type, str) or not item_type.strip():
|
||||
raise ValueError("item_type must be a non-empty string")
|
||||
msg = "item_type must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
normalized = item_type.strip().lower()
|
||||
if normalized not in VALID_ACTIVITY_ITEM_TYPES:
|
||||
raise ValueError("item_type must be one of: download, request")
|
||||
msg = "item_type must be one of: download, request"
|
||||
raise ValueError(msg)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_item_key(item_key: Any, *, item_type: str) -> str:
|
||||
def _normalize_item_key(item_key: object, *, 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")
|
||||
msg = "item_key must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
|
||||
normalized = item_key.strip()
|
||||
expected_prefix = f"{item_type}:"
|
||||
if not normalized.startswith(expected_prefix):
|
||||
raise ValueError(f"item_key must be in the format {expected_prefix}<id>")
|
||||
msg_0 = f"item_key must be in the format {expected_prefix}<id>"
|
||||
raise ValueError(msg_0)
|
||||
if not normalized.split(":", 1)[1].strip():
|
||||
raise ValueError(f"item_key must be in the format {expected_prefix}<id>")
|
||||
msg_0 = f"item_key must be in the format {expected_prefix}<id>"
|
||||
raise ValueError(msg_0)
|
||||
return normalized
|
||||
|
||||
|
||||
class ActivityViewStateService:
|
||||
"""Service for per-viewer activity dismissal and history visibility."""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@@ -215,7 +221,12 @@ class ActivityViewStateService:
|
||||
dismissed_at = excluded.dismissed_at,
|
||||
cleared_at = NULL
|
||||
""",
|
||||
(normalized_scope, normalized_type, normalized_key, dismissed_at),
|
||||
(
|
||||
normalized_scope,
|
||||
normalized_type,
|
||||
normalized_key,
|
||||
dismissed_at,
|
||||
),
|
||||
)
|
||||
rowcount = int(cursor.rowcount) if cursor.rowcount is not None else 0
|
||||
total += max(rowcount, 0)
|
||||
@@ -286,8 +297,7 @@ class ActivityViewStateService:
|
||||
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
|
||||
_normalize_item_key(item_key, item_type=normalized_type) for item_key in item_keys
|
||||
]
|
||||
if not normalized_keys:
|
||||
return 0
|
||||
|
||||
+123
-86
@@ -4,12 +4,12 @@ Registers /api/admin/users CRUD endpoints for managing users.
|
||||
All endpoints require admin session.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from flask import Flask, g, jsonify, request, session
|
||||
from flask import Flask, Response, g, jsonify, request, session
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shelfmark.config.booklore_settings import (
|
||||
@@ -17,7 +17,6 @@ from shelfmark.config.booklore_settings import (
|
||||
get_booklore_path_options,
|
||||
)
|
||||
from shelfmark.config.env import CWA_DB_PATH
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.admin_settings_routes import (
|
||||
register_admin_settings_routes,
|
||||
validate_user_settings,
|
||||
@@ -31,12 +30,24 @@ from shelfmark.core.auth_modes import (
|
||||
load_active_auth_mode,
|
||||
normalize_auth_source,
|
||||
)
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.cwa_user_sync import sync_cwa_users_from_rows
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"get_booklore_library_options",
|
||||
"get_booklore_path_options",
|
||||
"register_admin_routes",
|
||||
"validate_user_settings",
|
||||
]
|
||||
|
||||
|
||||
def _get_user_edit_capabilities(
|
||||
user: dict[str, Any],
|
||||
@@ -85,8 +96,7 @@ def _oidc_role_management_message(security_config: dict[str, Any] | None = None)
|
||||
f"'{admin_group}' group in your identity provider"
|
||||
)
|
||||
return (
|
||||
"Disable 'Use Admin Group for Authorization' in security settings "
|
||||
"to manage roles manually"
|
||||
"Disable 'Use Admin Group for Authorization' in security settings to manage roles manually"
|
||||
)
|
||||
|
||||
|
||||
@@ -109,8 +119,6 @@ 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():
|
||||
@@ -132,15 +140,18 @@ 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):
|
||||
def _require_admin(
|
||||
f: Callable[..., Response | tuple[Response, int]],
|
||||
) -> Callable[..., Response | tuple[Response, int]]:
|
||||
"""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):
|
||||
def decorated(*args, **kwargs) -> Response | tuple[Response, int]:
|
||||
auth_mode = load_active_auth_mode(CWA_DB_PATH, user_db=user_db)
|
||||
g.auth_mode = auth_mode
|
||||
if auth_mode != "none":
|
||||
@@ -149,22 +160,20 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
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():
|
||||
def admin_list_users() -> Response | tuple[Response, int]:
|
||||
"""List all users."""
|
||||
users = user_db.list_users()
|
||||
auth_mode = g.auth_mode
|
||||
return jsonify([
|
||||
_serialize_user(u, auth_mode)
|
||||
for u in users
|
||||
])
|
||||
return jsonify([_serialize_user(u, auth_mode) for u in users])
|
||||
|
||||
@app.route("/api/admin/users", methods=["POST"])
|
||||
@_require_admin
|
||||
def admin_create_user():
|
||||
def admin_create_user() -> Response | tuple[Response, int]:
|
||||
"""Create a new user with password authentication."""
|
||||
data = request.get_json() or {}
|
||||
auth_mode = g.auth_mode
|
||||
@@ -176,13 +185,15 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
role = data.get("role", "user")
|
||||
|
||||
if auth_mode in {AUTH_SOURCE_PROXY, AUTH_SOURCE_CWA}:
|
||||
return jsonify({
|
||||
"error": "Local user creation is disabled in this authentication mode",
|
||||
"message": (
|
||||
"Users are provisioned by your external authentication source. "
|
||||
"Switch to builtin or OIDC mode to create local users."
|
||||
),
|
||||
}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Local user creation is disabled in this authentication mode",
|
||||
"message": (
|
||||
"Users are provisioned by your external authentication source. "
|
||||
"Switch to builtin or OIDC mode to create local users."
|
||||
),
|
||||
}
|
||||
), 400
|
||||
|
||||
if not username:
|
||||
return jsonify({"error": "Username is required"}), 400
|
||||
@@ -213,9 +224,11 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
except ValueError:
|
||||
return jsonify({"error": "Username already exists"}), 409
|
||||
logger.info(
|
||||
"Shelfmark user created "
|
||||
f"(source=manual_admin_create, created_by={session.get('user_id', 'unknown')}, "
|
||||
f"username={username}, role={role}, auth_source={AUTH_SOURCE_BUILTIN})"
|
||||
"Shelfmark user created (source=manual_admin_create, created_by=%s, username=%s, role=%s, auth_source=%s)",
|
||||
session.get("user_id", "unknown"),
|
||||
username,
|
||||
role,
|
||||
AUTH_SOURCE_BUILTIN,
|
||||
)
|
||||
return jsonify(
|
||||
_serialize_user(
|
||||
@@ -226,7 +239,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>", methods=["GET"])
|
||||
@_require_admin
|
||||
def admin_get_user(user_id):
|
||||
def admin_get_user(user_id: int) -> Response | tuple[Response, int]:
|
||||
"""Get a user by ID with their settings."""
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
@@ -241,7 +254,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>", methods=["PUT"])
|
||||
@_require_admin
|
||||
def admin_update_user(user_id):
|
||||
def admin_update_user(user_id: int) -> Response | tuple[Response, int]:
|
||||
"""Update user fields and/or settings."""
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
@@ -258,10 +271,12 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
password = data.get("password", "")
|
||||
if password:
|
||||
if not capabilities["canSetPassword"]:
|
||||
return jsonify({
|
||||
"error": f"Cannot set password for {auth_source.upper()} users",
|
||||
"message": "Password authentication is only available for local users.",
|
||||
}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Cannot set password for {auth_source.upper()} users",
|
||||
"message": "Password authentication is only available for local users.",
|
||||
}
|
||||
), 400
|
||||
if len(password) < 4:
|
||||
return jsonify({"error": "Password must be at least 4 characters"}), 400
|
||||
user_db.update_user(user_id, password_hash=generate_password_hash(password))
|
||||
@@ -277,40 +292,49 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
|
||||
role_changed = "role" in user_fields and user_fields["role"] != user.get("role")
|
||||
email_changed = "email" in user_fields and user_fields["email"] != user.get("email")
|
||||
display_name_changed = (
|
||||
"display_name" in user_fields
|
||||
and user_fields["display_name"] != user.get("display_name")
|
||||
)
|
||||
display_name_changed = "display_name" in user_fields and user_fields[
|
||||
"display_name"
|
||||
] != user.get("display_name")
|
||||
|
||||
if role_changed and not capabilities["canEditRole"]:
|
||||
if auth_source == AUTH_SOURCE_OIDC:
|
||||
return jsonify({
|
||||
"error": "Cannot change role for OIDC user when group-based authorization is enabled",
|
||||
"message": _oidc_role_management_message(),
|
||||
}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change role for OIDC user when group-based authorization is enabled",
|
||||
"message": _oidc_role_management_message(),
|
||||
}
|
||||
), 400
|
||||
|
||||
return jsonify({
|
||||
"error": f"Cannot change role for {auth_source.upper()} users",
|
||||
"message": "Role is managed by the external authentication source.",
|
||||
}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Cannot change role for {auth_source.upper()} users",
|
||||
"message": "Role is managed by the external authentication source.",
|
||||
}
|
||||
), 400
|
||||
|
||||
if email_changed and not capabilities["canEditEmail"]:
|
||||
if auth_source == AUTH_SOURCE_CWA:
|
||||
return jsonify({
|
||||
"error": "Cannot change email for CWA users",
|
||||
"message": "Email is synced from Calibre-Web.",
|
||||
}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change email for CWA users",
|
||||
"message": "Email is synced from Calibre-Web.",
|
||||
}
|
||||
), 400
|
||||
|
||||
return jsonify({
|
||||
"error": "Cannot change email for OIDC users",
|
||||
"message": "Email is managed by your identity provider.",
|
||||
}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change email for OIDC users",
|
||||
"message": "Email is managed by your identity provider.",
|
||||
}
|
||||
), 400
|
||||
|
||||
if display_name_changed and not capabilities["canEditDisplayName"]:
|
||||
return jsonify({
|
||||
"error": "Cannot change display name for OIDC users",
|
||||
"message": "Display name is managed by your identity provider.",
|
||||
}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change display name for OIDC users",
|
||||
"message": "Display name is managed by your identity provider.",
|
||||
}
|
||||
), 400
|
||||
|
||||
# Allow demoting the last admin account.
|
||||
# Auth mode resolution automatically falls back to "none" when no
|
||||
@@ -331,15 +355,18 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
|
||||
validated_settings, validation_errors = validate_user_settings(data["settings"])
|
||||
if validation_errors:
|
||||
return jsonify({
|
||||
"error": "Invalid settings payload",
|
||||
"details": validation_errors,
|
||||
}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Invalid settings payload",
|
||||
"details": validation_errors,
|
||||
}
|
||||
), 400
|
||||
|
||||
user_db.set_user_settings(user_id, validated_settings)
|
||||
# Ensure runtime reads see updated per-user overrides immediately.
|
||||
try:
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
app_config.refresh(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -350,30 +377,36 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
g.auth_mode,
|
||||
)
|
||||
result["settings"] = user_db.get_user_settings(user_id)
|
||||
logger.info(f"Admin updated user {user_id}")
|
||||
logger.info("Admin updated user %s", user_id)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/api/admin/users/sync-cwa", methods=["POST"])
|
||||
@_require_admin
|
||||
def admin_sync_cwa_users():
|
||||
def admin_sync_cwa_users() -> Response | tuple[Response, int]:
|
||||
"""Manually sync users from Calibre-Web into users.db."""
|
||||
if g.auth_mode != AUTH_SOURCE_CWA:
|
||||
return jsonify({
|
||||
"error": "CWA sync is only available when CWA authentication is enabled",
|
||||
}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"error": "CWA sync is only available when CWA authentication is enabled",
|
||||
}
|
||||
), 400
|
||||
|
||||
try:
|
||||
summary = _sync_all_cwa_users(user_db)
|
||||
except FileNotFoundError:
|
||||
return jsonify({
|
||||
"error": "Calibre-Web database is not available",
|
||||
"message": "Verify app.db is mounted and readable at /auth/app.db.",
|
||||
}), 503
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to sync CWA users: {exc}")
|
||||
return jsonify({
|
||||
"error": "Failed to sync users from Calibre-Web",
|
||||
}), 500
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Calibre-Web database is not available",
|
||||
"message": "Verify app.db is mounted and readable at /auth/app.db.",
|
||||
}
|
||||
), 503
|
||||
except Exception:
|
||||
logger.exception("Failed to sync CWA users")
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Failed to sync users from Calibre-Web",
|
||||
}
|
||||
), 500
|
||||
|
||||
message = (
|
||||
f"Synced {summary['total']} CWA users "
|
||||
@@ -381,17 +414,19 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
f"{summary.get('deleted', 0)} deleted)."
|
||||
)
|
||||
logger.info(message)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": message,
|
||||
**summary,
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": message,
|
||||
**summary,
|
||||
}
|
||||
)
|
||||
|
||||
register_admin_settings_routes(app, user_db, _require_admin)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>", methods=["DELETE"])
|
||||
@_require_admin
|
||||
def admin_delete_user(user_id):
|
||||
def admin_delete_user(user_id: int) -> Response | tuple[Response, int]:
|
||||
"""Delete a user."""
|
||||
# Prevent self-deletion
|
||||
if session.get("db_user_id") == user_id:
|
||||
@@ -406,15 +441,17 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
user.get("oidc_subject"),
|
||||
)
|
||||
if auth_source == AUTH_SOURCE_CWA and auth_source == g.auth_mode:
|
||||
return jsonify({
|
||||
"error": f"Cannot delete active {auth_source.upper()} users",
|
||||
"message": f"{auth_source.upper()} users are automatically re-provisioned on login.",
|
||||
}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Cannot delete active {auth_source.upper()} users",
|
||||
"message": f"{auth_source.upper()} users are automatically re-provisioned on login.",
|
||||
}
|
||||
), 400
|
||||
|
||||
# Allow deleting the last local admin account.
|
||||
# Auth mode resolution automatically falls back to "none" when no
|
||||
# local password admin remains.
|
||||
|
||||
user_db.delete_user(user_id)
|
||||
logger.info(f"Admin deleted user {user_id}: {user['username']}")
|
||||
logger.info("Admin deleted user %s: %s", user_id, user["username"])
|
||||
return jsonify({"success": True})
|
||||
|
||||
@@ -1,27 +1,37 @@
|
||||
"""Admin settings-introspection routes and settings validation helpers."""
|
||||
|
||||
from typing import Any, Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from flask import Flask, Response, jsonify, request
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.config.notifications_settings import (
|
||||
build_notification_test_result,
|
||||
is_valid_notification_url,
|
||||
normalize_notification_routes,
|
||||
)
|
||||
from shelfmark.config.users_settings import validate_search_preference_value
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.request_policy import parse_policy_mode, validate_policy_rules
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_settings_overrides import (
|
||||
build_user_preferences_payload as _build_user_preferences_payload,
|
||||
)
|
||||
from shelfmark.core.user_settings_overrides import (
|
||||
get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields,
|
||||
)
|
||||
from shelfmark.core.user_settings_overrides import (
|
||||
get_settings_registry as _get_settings_registry,
|
||||
)
|
||||
from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.core.request_policy import parse_policy_mode, validate_policy_rules
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
def validate_user_settings(
|
||||
settings: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
settings_registry = _get_settings_registry()
|
||||
field_map = settings_registry.get_settings_field_map()
|
||||
overridable_map = settings_registry.get_user_overridable_fields()
|
||||
@@ -39,10 +49,12 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li
|
||||
valid[key] = None
|
||||
continue
|
||||
|
||||
if key in {"REQUEST_POLICY_DEFAULT_EBOOK", "REQUEST_POLICY_DEFAULT_AUDIOBOOK"}:
|
||||
if parse_policy_mode(value) is None:
|
||||
errors.append(f"Invalid policy mode for {key}: {value}")
|
||||
continue
|
||||
if (
|
||||
key in {"REQUEST_POLICY_DEFAULT_EBOOK", "REQUEST_POLICY_DEFAULT_AUDIOBOOK"}
|
||||
and parse_policy_mode(value) is None
|
||||
):
|
||||
errors.append(f"Invalid policy mode for {key}: {value}")
|
||||
continue
|
||||
|
||||
if key == "REQUEST_POLICY_RULES":
|
||||
normalized_rules, rule_errors = validate_policy_rules(value)
|
||||
@@ -61,16 +73,16 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li
|
||||
)
|
||||
if invalid_count:
|
||||
errors.append(
|
||||
(
|
||||
f"Invalid value for {key}: found {invalid_count} invalid URL(s). "
|
||||
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
|
||||
)
|
||||
f"Invalid value for {key}: found {invalid_count} invalid URL(s). "
|
||||
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
|
||||
)
|
||||
continue
|
||||
valid[key] = normalized_routes
|
||||
continue
|
||||
|
||||
normalized_search_value, search_validation_error = validate_search_preference_value(key, value)
|
||||
normalized_search_value, search_validation_error = validate_search_preference_value(
|
||||
key, value
|
||||
)
|
||||
if search_validation_error:
|
||||
errors.append(search_validation_error)
|
||||
continue
|
||||
@@ -90,9 +102,7 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li
|
||||
continue
|
||||
|
||||
candidate_values = [
|
||||
str(entry).strip().lower()
|
||||
for entry in value
|
||||
if str(entry).strip()
|
||||
str(entry).strip().lower() for entry in value if str(entry).strip()
|
||||
]
|
||||
normalized_values: list[str] = []
|
||||
has_invalid_value = False
|
||||
@@ -120,7 +130,7 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li
|
||||
def build_user_notification_test_response(
|
||||
*,
|
||||
user_id: int,
|
||||
payload: Any,
|
||||
payload: object,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
@@ -139,11 +149,11 @@ def build_user_notification_test_response(
|
||||
def register_admin_settings_routes(
|
||||
app: Flask,
|
||||
user_db: UserDB,
|
||||
require_admin: Callable[[Callable[..., Any]], Callable[..., Any]],
|
||||
require_admin: Callable[[Callable[..., object]], Callable[..., object]],
|
||||
) -> None:
|
||||
@app.route("/api/admin/download-defaults", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_download_defaults():
|
||||
def admin_download_defaults() -> Response | tuple[Response, int]:
|
||||
defaults = {
|
||||
key: ("" if (value := app_config.get(key, field.default)) is None else value)
|
||||
for key, field in _get_ordered_user_overridable_fields("downloads")
|
||||
@@ -156,17 +166,19 @@ def register_admin_settings_routes(
|
||||
|
||||
@app.route("/api/admin/booklore-options", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_booklore_options():
|
||||
def admin_booklore_options() -> Response | tuple[Response, int]:
|
||||
from shelfmark.core import admin_routes
|
||||
|
||||
return jsonify({
|
||||
"libraries": admin_routes.get_booklore_library_options(),
|
||||
"paths": admin_routes.get_booklore_path_options(),
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"libraries": admin_routes.get_booklore_library_options(),
|
||||
"paths": admin_routes.get_booklore_path_options(),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/delivery-preferences", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_delivery_preferences(user_id):
|
||||
def admin_get_delivery_preferences(user_id: int) -> Response | tuple[Response, int]:
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
@@ -180,7 +192,7 @@ def register_admin_settings_routes(
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/search-preferences", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_search_preferences(user_id):
|
||||
def admin_get_search_preferences(user_id: int) -> Response | tuple[Response, int]:
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
@@ -194,7 +206,7 @@ def register_admin_settings_routes(
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/notification-preferences", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_notification_preferences(user_id):
|
||||
def admin_get_notification_preferences(user_id: int) -> Response | tuple[Response, int]:
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
@@ -208,7 +220,7 @@ def register_admin_settings_routes(
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/notification-preferences/test", methods=["POST"])
|
||||
@require_admin
|
||||
def admin_test_notification_preferences(user_id):
|
||||
def admin_test_notification_preferences(user_id: int) -> Response | tuple[Response, int]:
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
@@ -222,7 +234,7 @@ def register_admin_settings_routes(
|
||||
|
||||
@app.route("/api/admin/settings/overrides-summary", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_settings_overrides_summary():
|
||||
def admin_settings_overrides_summary() -> Response | tuple[Response, int]:
|
||||
settings_registry = _get_settings_registry()
|
||||
|
||||
tab_name = (request.args.get("tab") or "downloads").strip()
|
||||
@@ -241,11 +253,13 @@ def register_admin_settings_routes(
|
||||
if key not in user_settings or user_settings[key] is None:
|
||||
continue
|
||||
entry = keys_payload.setdefault(key, {"count": 0, "users": []})
|
||||
entry["users"].append({
|
||||
"userId": user_record["id"],
|
||||
"username": user_record["username"],
|
||||
"value": user_settings[key],
|
||||
})
|
||||
entry["users"].append(
|
||||
{
|
||||
"userId": user_record["id"],
|
||||
"username": user_record["username"],
|
||||
"value": user_settings[key],
|
||||
}
|
||||
)
|
||||
|
||||
for summary in keys_payload.values():
|
||||
summary["count"] = len(summary["users"])
|
||||
@@ -254,7 +268,7 @@ def register_admin_settings_routes(
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/effective-settings", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_effective_settings(user_id):
|
||||
def admin_get_effective_settings(user_id: int) -> Response | tuple[Response, int]:
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Authentication mode, auth-source normalization, and admin access policy helpers."""
|
||||
|
||||
import os
|
||||
from typing import Any, Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
AUTH_SOURCE_BUILTIN = "builtin"
|
||||
AUTH_SOURCE_OIDC = "oidc"
|
||||
@@ -17,7 +21,7 @@ AUTH_SOURCE_SET = frozenset(AUTH_SOURCES)
|
||||
_ALWAYS_ADMIN_SETTINGS_TABS = frozenset({"security", "users"})
|
||||
|
||||
|
||||
def has_local_password_admin(user_db: Any | None = None) -> bool:
|
||||
def has_local_password_admin(user_db: object | None = None) -> bool:
|
||||
"""Return True when at least one local admin with a password exists."""
|
||||
try:
|
||||
db = user_db
|
||||
@@ -25,7 +29,7 @@ def has_local_password_admin(user_db: Any | None = None) -> bool:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
config_root = os.environ.get("CONFIG_DIR", "/config")
|
||||
db = UserDB(os.path.join(config_root, "users.db"))
|
||||
db = UserDB(str(Path(config_root) / "users.db"))
|
||||
db.initialize()
|
||||
|
||||
return db.has_admin_with_password()
|
||||
@@ -34,8 +38,8 @@ def has_local_password_admin(user_db: Any | None = None) -> bool:
|
||||
|
||||
|
||||
def normalize_auth_source(
|
||||
source: Any,
|
||||
oidc_subject: Any = None,
|
||||
source: object,
|
||||
oidc_subject: object = None,
|
||||
) -> str:
|
||||
"""Resolve a stable auth source value from persisted fields."""
|
||||
normalized = str(source or "").strip().lower()
|
||||
@@ -48,7 +52,7 @@ def normalize_auth_source(
|
||||
|
||||
def determine_auth_mode(
|
||||
security_config: Mapping[str, Any],
|
||||
cwa_db_path: Any | None,
|
||||
cwa_db_path: object | None,
|
||||
*,
|
||||
has_local_admin: bool = True,
|
||||
) -> str:
|
||||
@@ -76,9 +80,9 @@ def determine_auth_mode(
|
||||
|
||||
|
||||
def load_active_auth_mode(
|
||||
cwa_db_path: Any | None,
|
||||
cwa_db_path: object | None,
|
||||
*,
|
||||
user_db: Any | None = None,
|
||||
user_db: object | None = None,
|
||||
) -> str:
|
||||
"""Resolve active auth mode using current security config and runtime prerequisites."""
|
||||
try:
|
||||
@@ -109,7 +113,7 @@ def is_user_active_for_auth_mode(user: Mapping[str, Any], auth_mode: str) -> boo
|
||||
|
||||
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")
|
||||
return path.startswith(("/api/settings", "/api/onboarding"))
|
||||
|
||||
|
||||
def get_settings_tab_from_path(path: str) -> str | None:
|
||||
@@ -117,7 +121,7 @@ def get_settings_tab_from_path(path: str) -> str | None:
|
||||
if not path.startswith("/api/settings/"):
|
||||
return None
|
||||
|
||||
suffix = path[len("/api/settings/"):]
|
||||
suffix = path[len("/api/settings/") :]
|
||||
if not suffix:
|
||||
return None
|
||||
|
||||
|
||||
+27
-36
@@ -4,32 +4,37 @@ import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Dict, Optional, TypeVar
|
||||
from typing import TYPE_CHECKING, ParamSpec, TypeVar, cast
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheEntry:
|
||||
"""A cached value with expiration time."""
|
||||
value: Any
|
||||
|
||||
value: object
|
||||
expires_at: float
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""Thread-safe in-memory cache with TTL support."""
|
||||
|
||||
def __init__(self, max_size: int = 1000):
|
||||
def __init__(self, max_size: int = 1000) -> None:
|
||||
"""Initialize cache with max_size entries before eviction."""
|
||||
self._cache: Dict[str, CacheEntry] = {}
|
||||
self._cache: dict[str, CacheEntry] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_size = max_size
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
def get(self, key: str) -> object | None:
|
||||
"""Get cached value if not expired."""
|
||||
with self._lock:
|
||||
entry = self._cache.get(key)
|
||||
@@ -42,17 +47,14 @@ class CacheService:
|
||||
|
||||
return entry.value
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int) -> None:
|
||||
def set(self, key: str, value: object, ttl: int) -> None:
|
||||
"""Cache value with TTL in seconds."""
|
||||
with self._lock:
|
||||
# Evict oldest entries if at capacity
|
||||
if len(self._cache) >= self._max_size:
|
||||
self._evict_oldest()
|
||||
|
||||
self._cache[key] = CacheEntry(
|
||||
value=value,
|
||||
expires_at=time.time() + ttl
|
||||
)
|
||||
self._cache[key] = CacheEntry(value=value, expires_at=time.time() + ttl)
|
||||
|
||||
def invalidate(self, key: str) -> bool:
|
||||
"""Remove specific cache entry. Returns True if found."""
|
||||
@@ -79,10 +81,7 @@ class CacheService:
|
||||
"""Remove all expired entries. Returns count removed."""
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
expired_keys = [
|
||||
key for key, entry in self._cache.items()
|
||||
if entry.expires_at < now
|
||||
]
|
||||
expired_keys = [key for key, entry in self._cache.items() if entry.expires_at < now]
|
||||
for key in expired_keys:
|
||||
del self._cache[key]
|
||||
return len(expired_keys)
|
||||
@@ -94,21 +93,15 @@ class CacheService:
|
||||
|
||||
# Remove ~10% of entries, oldest first
|
||||
entries_to_remove = max(1, len(self._cache) // 10)
|
||||
sorted_entries = sorted(
|
||||
self._cache.items(),
|
||||
key=lambda x: x[1].expires_at
|
||||
)
|
||||
sorted_entries = sorted(self._cache.items(), key=lambda x: x[1].expires_at)
|
||||
|
||||
for key, _ in sorted_entries[:entries_to_remove]:
|
||||
del self._cache[key]
|
||||
|
||||
def stats(self) -> Dict[str, int]:
|
||||
def stats(self) -> dict[str, int]:
|
||||
"""Get cache statistics (size, max_size)."""
|
||||
with self._lock:
|
||||
return {
|
||||
"size": len(self._cache),
|
||||
"max_size": self._max_size
|
||||
}
|
||||
return {"size": len(self._cache), "max_size": self._max_size}
|
||||
|
||||
|
||||
# Global cache instance for metadata providers
|
||||
@@ -128,15 +121,16 @@ def cache_key(*args, **kwargs) -> str:
|
||||
|
||||
|
||||
def cacheable(
|
||||
ttl: Optional[int] = None,
|
||||
ttl_key: Optional[str] = None,
|
||||
ttl: int | None = None,
|
||||
ttl_key: str | None = None,
|
||||
ttl_default: int = 300,
|
||||
key_prefix: str = ""
|
||||
):
|
||||
key_prefix: str = "",
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""Decorator for caching function results. Use ttl (static) or ttl_key (from config)."""
|
||||
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
||||
|
||||
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> T:
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
# Check if metadata caching is enabled
|
||||
from shelfmark.core.config import config
|
||||
|
||||
@@ -156,16 +150,12 @@ def cacheable(
|
||||
# Skip 'self' argument if present (first arg of method)
|
||||
cache_args = args[1:] if args and hasattr(args[0], func.__name__) else args
|
||||
|
||||
key = cache_key(
|
||||
key_prefix or func.__name__,
|
||||
*cache_args,
|
||||
**kwargs
|
||||
)
|
||||
key = cache_key(key_prefix or func.__name__, *cache_args, **kwargs)
|
||||
|
||||
# Check cache
|
||||
cached = _metadata_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
return cast("R", cached)
|
||||
|
||||
# Execute function and cache result
|
||||
result = func(*args, **kwargs)
|
||||
@@ -177,4 +167,5 @@ def cacheable(
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
+57
-44
@@ -3,53 +3,65 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
# Import lazily to avoid circular imports
|
||||
_registry_module = None
|
||||
_env_module = None
|
||||
_user_db_module = None
|
||||
|
||||
_SETTINGS_REFRESH_COOLDOWN_SECONDS = 0.05
|
||||
|
||||
def _get_registry():
|
||||
|
||||
def _get_registry() -> ModuleType:
|
||||
"""Lazy import of settings registry to avoid circular imports."""
|
||||
global _registry_module
|
||||
if _registry_module is None:
|
||||
from shelfmark.core import settings_registry
|
||||
|
||||
_registry_module = settings_registry
|
||||
return _registry_module
|
||||
|
||||
|
||||
def _get_env():
|
||||
def _get_env() -> ModuleType:
|
||||
"""Lazy import of env module for fallback values."""
|
||||
global _env_module
|
||||
if _env_module is None:
|
||||
from shelfmark.config import env
|
||||
|
||||
_env_module = env
|
||||
return _env_module
|
||||
|
||||
|
||||
def _get_user_db_module():
|
||||
def _get_user_db_module() -> type[UserDB]:
|
||||
"""Lazy import of user DB module to avoid optional dependency loops."""
|
||||
global _user_db_module
|
||||
if _user_db_module is None:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
_user_db_module = UserDB
|
||||
return _user_db_module
|
||||
|
||||
|
||||
class Config:
|
||||
"""
|
||||
Dynamic configuration singleton that provides live settings access.
|
||||
"""Dynamic configuration singleton that provides live settings access.
|
||||
|
||||
Settings are resolved with priority: ENV var > config file > default.
|
||||
Values are cached for performance and can be refreshed when settings change.
|
||||
"""
|
||||
|
||||
_instance: Optional['Config'] = None
|
||||
_instance: Config | None = None
|
||||
_lock = Lock()
|
||||
def __new__(cls) -> 'Config':
|
||||
|
||||
def __new__(cls) -> Config:
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
@@ -57,13 +69,13 @@ class Config:
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._field_map: Dict[str, tuple] = {} # key -> (field, tab_name)
|
||||
self._cache: dict[str, Any] = {}
|
||||
self._field_map: dict[str, tuple] = {} # key -> (field, tab_name)
|
||||
self._cache_lock = Lock()
|
||||
self._user_settings_cache: Dict[int, Dict[str, Any]] = {}
|
||||
self._user_settings_cache: dict[int, dict[str, Any]] = {}
|
||||
self._user_settings_cache_lock = Lock()
|
||||
self._user_db = None
|
||||
self._user_db_load_attempted = False
|
||||
@@ -85,12 +97,12 @@ class Config:
|
||||
# Ensure all settings modules are imported before loading
|
||||
# This handles cases where config is accessed before settings are registered
|
||||
try:
|
||||
import shelfmark.config.settings # noqa: F401 - main app settings
|
||||
import shelfmark.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
|
||||
import_module("shelfmark.config.notifications_settings")
|
||||
import_module("shelfmark.config.security")
|
||||
import_module("shelfmark.config.settings")
|
||||
import_module("shelfmark.config.users_settings")
|
||||
import_module("shelfmark.metadata_providers")
|
||||
import_module("shelfmark.release_sources")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -98,7 +110,7 @@ class Config:
|
||||
|
||||
# On first load, sync ENV values to config files
|
||||
# This ensures ENV values persist even if ENV vars are later removed
|
||||
if not hasattr(self, '_env_synced'):
|
||||
if not hasattr(self, "_env_synced"):
|
||||
registry.sync_env_to_config()
|
||||
self._env_synced = True
|
||||
|
||||
@@ -112,9 +124,8 @@ class Config:
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def refresh(self, force: bool = False) -> None:
|
||||
"""
|
||||
Refresh all cached settings from config files.
|
||||
def refresh(self, *, force: bool = False) -> None:
|
||||
"""Refresh all cached settings from config files.
|
||||
|
||||
Call this after settings are updated via the UI to ensure
|
||||
the config singleton reflects the new values.
|
||||
@@ -125,7 +136,7 @@ class Config:
|
||||
(e.g. after a settings write).
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if not force and (now - self._last_refresh_time) < 0.05:
|
||||
if not force and (now - self._last_refresh_time) < _SETTINGS_REFRESH_COOLDOWN_SECONDS:
|
||||
return
|
||||
|
||||
with self._cache_lock:
|
||||
@@ -137,7 +148,7 @@ class Config:
|
||||
self._user_db_load_attempted = False
|
||||
self._last_refresh_time = time.monotonic()
|
||||
|
||||
def _get_user_db(self):
|
||||
def _get_user_db(self) -> UserDB | None:
|
||||
"""Get or initialize a UserDB handle if available."""
|
||||
if self._user_db is not None:
|
||||
return self._user_db
|
||||
@@ -147,16 +158,17 @@ class Config:
|
||||
self._user_db_load_attempted = True
|
||||
try:
|
||||
user_db_cls = _get_user_db_module()
|
||||
db_path = os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db")
|
||||
db_path = str(Path(os.environ.get("CONFIG_DIR", "/config")) / "users.db")
|
||||
user_db = user_db_cls(db_path)
|
||||
user_db.initialize()
|
||||
self._user_db = user_db
|
||||
return self._user_db
|
||||
except Exception:
|
||||
# Multi-user support is optional; fall back to global config when unavailable.
|
||||
return None
|
||||
else:
|
||||
self._user_db = user_db
|
||||
return self._user_db
|
||||
|
||||
def _get_user_settings(self, user_id: int) -> Dict[str, Any]:
|
||||
def _get_user_settings(self, user_id: int) -> dict[str, Any]:
|
||||
"""Get cached per-user settings from user DB."""
|
||||
with self._user_settings_cache_lock:
|
||||
if user_id in self._user_settings_cache:
|
||||
@@ -168,7 +180,7 @@ class Config:
|
||||
|
||||
try:
|
||||
settings = user_db.get_user_settings(user_id)
|
||||
except (sqlite3.OperationalError, OSError, ValueError, TypeError):
|
||||
except sqlite3.OperationalError, OSError, ValueError, TypeError:
|
||||
return {}
|
||||
|
||||
if not isinstance(settings, dict):
|
||||
@@ -178,14 +190,13 @@ class Config:
|
||||
self._user_settings_cache[user_id] = settings
|
||||
return settings
|
||||
|
||||
def _get_user_override(self, user_id: int, key: str) -> Any:
|
||||
def _get_user_override(self, user_id: int, key: str) -> object:
|
||||
"""Get a user override for a specific key."""
|
||||
user_settings = self._get_user_settings(user_id)
|
||||
return user_settings.get(key)
|
||||
|
||||
def get(self, key: str, default: Any = None, user_id: Optional[int] = None) -> Any:
|
||||
"""
|
||||
Get a setting value by key.
|
||||
def get(self, key: str, default: object = None, user_id: int | None = None) -> object:
|
||||
"""Get a setting value by key.
|
||||
|
||||
Args:
|
||||
key: The setting key (e.g., 'MAX_RETRY')
|
||||
@@ -194,6 +205,7 @@ class Config:
|
||||
|
||||
Returns:
|
||||
The setting value, or default if not found
|
||||
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
@@ -213,15 +225,15 @@ class Config:
|
||||
|
||||
return self._cache.get(key, default)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""
|
||||
Allow attribute-style access to settings.
|
||||
def __getattr__(self, name: str) -> object:
|
||||
"""Allow attribute-style access to settings.
|
||||
|
||||
Example: config.MAX_RETRY instead of config.get('MAX_RETRY')
|
||||
"""
|
||||
# Avoid recursion for internal attributes
|
||||
if name.startswith('_'):
|
||||
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
||||
if name.startswith("_"):
|
||||
msg = f"'{type(self).__name__}' object has no attribute '{name}'"
|
||||
raise AttributeError(msg)
|
||||
|
||||
self._ensure_loaded()
|
||||
|
||||
@@ -234,17 +246,18 @@ class Config:
|
||||
if hasattr(env, name):
|
||||
return getattr(env, name)
|
||||
|
||||
raise AttributeError(f"Setting '{name}' not found in config or env")
|
||||
msg = f"Setting '{name}' not found in config or env"
|
||||
raise AttributeError(msg)
|
||||
|
||||
def is_from_env(self, key: str) -> bool:
|
||||
"""
|
||||
Check if a setting's value comes from an environment variable.
|
||||
"""Check if a setting's value comes from an environment variable.
|
||||
|
||||
Args:
|
||||
key: The setting key
|
||||
|
||||
Returns:
|
||||
True if the value is set via ENV var, False otherwise
|
||||
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
@@ -255,12 +268,12 @@ class Config:
|
||||
registry = _get_registry()
|
||||
return registry.is_value_from_env(field)
|
||||
|
||||
def get_all(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all cached settings as a dictionary.
|
||||
def get_all(self) -> dict[str, Any]:
|
||||
"""Get all cached settings as a dictionary.
|
||||
|
||||
Returns:
|
||||
Dict of all setting keys to their current values
|
||||
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
return dict(self._cache)
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.auth_modes import AUTH_SOURCE_CWA, normalize_auth_source
|
||||
from shelfmark.core.external_user_linking import upsert_external_user
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
_CWA_ALIAS_SUFFIX = "__cwa"
|
||||
|
||||
|
||||
def _normalize_email(value: Any) -> str | None:
|
||||
def _normalize_email(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
email = str(value).strip()
|
||||
@@ -40,7 +44,8 @@ def upsert_cwa_user(
|
||||
context=context,
|
||||
)
|
||||
if user is None:
|
||||
raise RuntimeError("Unexpected CWA user sync result: no user returned")
|
||||
msg = "Unexpected CWA user sync result: no user returned"
|
||||
raise RuntimeError(msg)
|
||||
return user, action
|
||||
|
||||
|
||||
@@ -73,10 +78,13 @@ def sync_cwa_users_from_rows(
|
||||
|
||||
deleted = 0
|
||||
for existing_user in user_db.list_users():
|
||||
if normalize_auth_source(
|
||||
existing_user.get("auth_source"),
|
||||
existing_user.get("oidc_subject"),
|
||||
) != AUTH_SOURCE_CWA:
|
||||
if (
|
||||
normalize_auth_source(
|
||||
existing_user.get("auth_source"),
|
||||
existing_user.get("oidc_subject"),
|
||||
)
|
||||
!= AUTH_SOURCE_CWA
|
||||
):
|
||||
continue
|
||||
|
||||
existing_id = int(existing_user.get("id") or 0)
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
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
|
||||
from shelfmark.core.request_helpers import (
|
||||
normalize_optional_positive_int,
|
||||
normalize_optional_text,
|
||||
now_utc_iso,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -21,40 +25,45 @@ ACTIVE_DOWNLOAD_STATUS = "active"
|
||||
VALID_ORIGINS = frozenset({"direct", "requested"})
|
||||
|
||||
|
||||
def _normalize_task_id(task_id: Any) -> str:
|
||||
def _normalize_task_id(task_id: object) -> str:
|
||||
normalized = normalize_optional_text(task_id)
|
||||
if normalized is None:
|
||||
raise ValueError("task_id must be a non-empty string")
|
||||
msg = "task_id must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_origin(origin: Any) -> str:
|
||||
def _normalize_origin(origin: object) -> str:
|
||||
normalized = normalize_optional_text(origin)
|
||||
if normalized is None:
|
||||
return "direct"
|
||||
lowered = normalized.lower()
|
||||
if lowered not in VALID_ORIGINS:
|
||||
raise ValueError("origin must be one of: direct, requested")
|
||||
msg = "origin must be one of: direct, requested"
|
||||
raise ValueError(msg)
|
||||
return lowered
|
||||
|
||||
|
||||
def _normalize_final_status(final_status: Any) -> str:
|
||||
def _normalize_final_status(final_status: object) -> str:
|
||||
normalized = normalize_optional_text(final_status)
|
||||
if normalized is None:
|
||||
raise ValueError("final_status must be a non-empty string")
|
||||
msg = "final_status must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
lowered = normalized.lower()
|
||||
if lowered not in VALID_TERMINAL_STATUSES:
|
||||
raise ValueError("final_status must be one of: complete, error, cancelled")
|
||||
msg = "final_status must be one of: complete, error, cancelled"
|
||||
raise ValueError(msg)
|
||||
return lowered
|
||||
|
||||
|
||||
def _normalize_limit(value: Any, *, default: int, minimum: int, maximum: int) -> int:
|
||||
def _normalize_limit(value: object, *, 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
|
||||
msg = "limit must be an integer"
|
||||
raise ValueError(msg) from exc
|
||||
if parsed < minimum:
|
||||
return minimum
|
||||
if parsed > maximum:
|
||||
@@ -65,7 +74,7 @@ def _normalize_limit(value: Any, *, default: int, minimum: int, maximum: int) ->
|
||||
class DownloadHistoryService:
|
||||
"""Service for persisted canonical download activity rows."""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@@ -80,7 +89,9 @@ class DownloadHistoryService:
|
||||
if row is None:
|
||||
return None
|
||||
normalized = dict(row)
|
||||
normalized["retry_payload"] = cls._deserialize_retry_payload(normalized.get("retry_payload"))
|
||||
normalized["retry_payload"] = cls._deserialize_retry_payload(
|
||||
normalized.get("retry_payload")
|
||||
)
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
@@ -92,23 +103,24 @@ class DownloadHistoryService:
|
||||
return f"download:{task_id}"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_existing_download_path(value: Any) -> str | None:
|
||||
def _resolve_existing_download_path(value: object) -> str | None:
|
||||
normalized = normalize_optional_text(value)
|
||||
if normalized is None:
|
||||
return None
|
||||
return normalized if os.path.exists(normalized) else None
|
||||
return normalized if Path(normalized).exists() else None
|
||||
|
||||
@staticmethod
|
||||
def _serialize_retry_payload(payload: Any) -> str | None:
|
||||
def _serialize_retry_payload(payload: object) -> str | None:
|
||||
if payload is None:
|
||||
return None
|
||||
try:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("retry_payload must be JSON-serializable") from exc
|
||||
msg = "retry_payload must be JSON-serializable"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
@staticmethod
|
||||
def _deserialize_retry_payload(value: Any) -> dict[str, Any] | None:
|
||||
def _deserialize_retry_payload(value: object) -> dict[str, Any] | None:
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
normalized = normalize_optional_text(value)
|
||||
@@ -126,7 +138,7 @@ class DownloadHistoryService:
|
||||
normalized_staged_path = normalize_optional_text(staged_path)
|
||||
if normalized_staged_path is None:
|
||||
return False
|
||||
return os.path.exists(normalized_staged_path)
|
||||
return Path(normalized_staged_path).exists()
|
||||
|
||||
@staticmethod
|
||||
def _can_retry_without_staged_source(retry_payload: dict[str, Any]) -> bool:
|
||||
@@ -134,16 +146,16 @@ class DownloadHistoryService:
|
||||
|
||||
@staticmethod
|
||||
def is_retry_available(row: dict[str, Any]) -> bool:
|
||||
final_status = str(
|
||||
row.get("retry_final_status") or row.get("final_status") or ""
|
||||
).strip().lower()
|
||||
final_status = (
|
||||
str(row.get("retry_final_status") or row.get("final_status") or "").strip().lower()
|
||||
)
|
||||
retry_payload = DownloadHistoryService._deserialize_retry_payload(row.get("retry_payload"))
|
||||
if retry_payload is None:
|
||||
return False
|
||||
|
||||
has_staged_retry_source = DownloadHistoryService._has_staged_retry_source(retry_payload)
|
||||
can_retry_without_staged_source = (
|
||||
DownloadHistoryService._can_retry_without_staged_source(retry_payload)
|
||||
can_retry_without_staged_source = DownloadHistoryService._can_retry_without_staged_source(
|
||||
retry_payload
|
||||
)
|
||||
request_id = normalize_optional_positive_int(row.get("request_id"), "request_id")
|
||||
if request_id is None:
|
||||
@@ -174,7 +186,9 @@ class DownloadHistoryService:
|
||||
"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")),
|
||||
"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"),
|
||||
@@ -183,7 +197,7 @@ class DownloadHistoryService:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _iso_to_epoch(value: Any) -> float | None:
|
||||
def _iso_to_epoch(value: object) -> float | None:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
normalized = value.strip().replace("Z", "+00:00")
|
||||
@@ -192,7 +206,7 @@ class DownloadHistoryService:
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.timestamp()
|
||||
|
||||
@classmethod
|
||||
@@ -249,10 +263,12 @@ class DownloadHistoryService:
|
||||
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")
|
||||
msg = "source must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
normalized_title = normalize_optional_text(title)
|
||||
if normalized_title is None:
|
||||
raise ValueError("title must be a non-empty string")
|
||||
msg = "title must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
normalized_origin = _normalize_origin(origin)
|
||||
normalized_retry_payload = self._serialize_retry_payload(retry_payload)
|
||||
recorded_at = now_utc_iso()
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from shelfmark.core.auth_modes import normalize_auth_source
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
UNSET = object()
|
||||
|
||||
@@ -21,18 +23,18 @@ MatchReason = Literal[
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _normalize_username(value: Any) -> str:
|
||||
def _normalize_username(value: object) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _normalize_email(value: Any) -> str | None:
|
||||
def _normalize_email(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
email = str(value).strip()
|
||||
return email or None
|
||||
|
||||
|
||||
def _normalize_display_name(value: Any) -> str | None:
|
||||
def _normalize_display_name(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
name = str(value).strip()
|
||||
@@ -43,11 +45,13 @@ def _email_key(value: str | None) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _normalize_role(value: Any) -> str:
|
||||
def _normalize_role(value: object) -> str:
|
||||
return "admin" if str(value or "").strip().lower() == "admin" else "user"
|
||||
|
||||
|
||||
def _get_by_subject(user_db: UserDB, subject_field: str | None, subject: str | None) -> dict[str, Any] | None:
|
||||
def _get_by_subject(
|
||||
user_db: UserDB, subject_field: str | None, subject: str | None
|
||||
) -> dict[str, Any] | None:
|
||||
if not subject_field or not subject:
|
||||
return None
|
||||
if subject_field == "oidc_subject":
|
||||
@@ -83,10 +87,14 @@ def find_external_user_match(
|
||||
return by_subject, "subject_match"
|
||||
|
||||
by_username = user_db.get_user(username=normalized_username)
|
||||
if by_username and normalize_auth_source(
|
||||
by_username.get("auth_source"),
|
||||
by_username.get("oidc_subject"),
|
||||
) == auth_source:
|
||||
if (
|
||||
by_username
|
||||
and normalize_auth_source(
|
||||
by_username.get("auth_source"),
|
||||
by_username.get("oidc_subject"),
|
||||
)
|
||||
== auth_source
|
||||
):
|
||||
return by_username, "existing_source_username_match"
|
||||
|
||||
if allow_email_link:
|
||||
@@ -133,7 +141,8 @@ def _find_existing_alias_user(
|
||||
) -> dict[str, Any] | None:
|
||||
pattern = re.compile(rf"^{re.escape(alias_base)}(?:_\d+)?$")
|
||||
candidates = [
|
||||
user for user in user_db.list_users()
|
||||
user
|
||||
for user in user_db.list_users()
|
||||
if pattern.match(str(user.get("username") or ""))
|
||||
and normalize_auth_source(user.get("auth_source"), user.get("oidc_subject")) == auth_source
|
||||
]
|
||||
@@ -158,7 +167,11 @@ def _resolve_create_username(
|
||||
return None, existing, "username_collision_takeover"
|
||||
|
||||
if strategy == "suffix":
|
||||
return _next_suffix_username(user_db, requested_username), None, "username_collision_suffix"
|
||||
return (
|
||||
_next_suffix_username(user_db, requested_username),
|
||||
None,
|
||||
"username_collision_suffix",
|
||||
)
|
||||
|
||||
alias_base = f"{requested_username}{alias_suffix}"
|
||||
alias_existing = _find_existing_alias_user(
|
||||
@@ -197,7 +210,8 @@ def upsert_external_user(
|
||||
"""
|
||||
normalized_username = _normalize_username(username)
|
||||
if not normalized_username:
|
||||
raise ValueError("External username is required")
|
||||
msg = "External username is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
normalized_email = _normalize_email(email) if email is not UNSET else None
|
||||
normalized_display_name = (
|
||||
@@ -227,18 +241,22 @@ def upsert_external_user(
|
||||
user_db.update_user(matched["id"], **updates)
|
||||
mapped = user_db.get_user(user_id=matched["id"]) or matched
|
||||
logger.info(
|
||||
"External user mapped to existing Shelfmark user "
|
||||
f"(source={auth_source}, context={context or 'unspecified'}, reason={match_reason}, "
|
||||
f"external_username={normalized_username}, shelfmark_user_id={mapped['id']}, "
|
||||
f"shelfmark_username={mapped['username']})"
|
||||
"External user mapped to existing Shelfmark user (source=%s, context=%s, reason=%s, external_username=%s, shelfmark_user_id=%s, shelfmark_username=%s)",
|
||||
auth_source,
|
||||
context or "unspecified",
|
||||
match_reason,
|
||||
normalized_username,
|
||||
mapped["id"],
|
||||
mapped["username"],
|
||||
)
|
||||
return mapped, "updated"
|
||||
|
||||
if not allow_create:
|
||||
logger.info(
|
||||
"External user could not be mapped and creation is disabled "
|
||||
f"(source={auth_source}, context={context or 'unspecified'}, "
|
||||
f"external_username={normalized_username})"
|
||||
"External user could not be mapped and creation is disabled (source=%s, context=%s, external_username=%s)",
|
||||
auth_source,
|
||||
context or "unspecified",
|
||||
normalized_username,
|
||||
)
|
||||
return None, "not_found"
|
||||
|
||||
@@ -254,10 +272,13 @@ def upsert_external_user(
|
||||
user_db.update_user(takeover_target["id"], **updates)
|
||||
mapped = user_db.get_user(user_id=takeover_target["id"]) or takeover_target
|
||||
logger.info(
|
||||
"External user mapped to existing Shelfmark user "
|
||||
f"(source={auth_source}, context={context or 'unspecified'}, reason={create_reason}, "
|
||||
f"external_username={normalized_username}, shelfmark_user_id={mapped['id']}, "
|
||||
f"shelfmark_username={mapped['username']})"
|
||||
"External user mapped to existing Shelfmark user (source=%s, context=%s, reason=%s, external_username=%s, shelfmark_user_id=%s, shelfmark_username=%s)",
|
||||
auth_source,
|
||||
context or "unspecified",
|
||||
create_reason,
|
||||
normalized_username,
|
||||
mapped["id"],
|
||||
mapped["username"],
|
||||
)
|
||||
return mapped, "updated"
|
||||
|
||||
@@ -275,9 +296,12 @@ def upsert_external_user(
|
||||
|
||||
created = user_db.create_user(**create_kwargs)
|
||||
logger.info(
|
||||
"External user created Shelfmark user "
|
||||
f"(source={auth_source}, context={context or 'unspecified'}, reason={create_reason}, "
|
||||
f"external_username={normalized_username}, shelfmark_user_id={created['id']}, "
|
||||
f"shelfmark_username={created['username']})"
|
||||
"External user created Shelfmark user (source=%s, context=%s, reason=%s, external_username=%s, shelfmark_user_id=%s, shelfmark_username=%s)",
|
||||
auth_source,
|
||||
context or "unspecified",
|
||||
create_reason,
|
||||
normalized_username,
|
||||
created["id"],
|
||||
created["username"],
|
||||
)
|
||||
return created, "created"
|
||||
|
||||
+130
-112
@@ -2,13 +2,12 @@
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
@@ -16,22 +15,25 @@ import requests
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Image type detection via magic bytes
|
||||
IMAGE_SIGNATURES = {
|
||||
b'\xff\xd8\xff': ('image/jpeg', 'jpg'),
|
||||
b'\x89PNG\r\n\x1a\n': ('image/png', 'png'),
|
||||
b'GIF87a': ('image/gif', 'gif'),
|
||||
b'GIF89a': ('image/gif', 'gif'),
|
||||
b'RIFF': ('image/webp', 'webp'), # WebP starts with RIFF
|
||||
b"\xff\xd8\xff": ("image/jpeg", "jpg"),
|
||||
b"\x89PNG\r\n\x1a\n": ("image/png", "png"),
|
||||
b"GIF87a": ("image/gif", "gif"),
|
||||
b"GIF89a": ("image/gif", "gif"),
|
||||
b"RIFF": ("image/webp", "webp"), # WebP starts with RIFF
|
||||
}
|
||||
|
||||
# HTTP headers for image fetching
|
||||
FETCH_HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/129.0.0.0 Safari/537.36',
|
||||
'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/129.0.0.0 Safari/537.36",
|
||||
"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
}
|
||||
|
||||
# Maximum image size to fetch (5 MB)
|
||||
@@ -44,8 +46,11 @@ NEGATIVE_CACHE_TTL = 3600
|
||||
# Short enough to retry soon, long enough to prevent spam during one page view
|
||||
TRANSIENT_CACHE_TTL = 60
|
||||
|
||||
_MIN_WEBP_HEADER_LENGTH = 12
|
||||
HTTP_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
|
||||
def _detect_image_type(data: bytes) -> Optional[Tuple[str, str]]:
|
||||
|
||||
def _detect_image_type(data: bytes) -> tuple[str, str] | None:
|
||||
"""Detect image type from magic bytes.
|
||||
|
||||
Args:
|
||||
@@ -53,14 +58,15 @@ def _detect_image_type(data: bytes) -> Optional[Tuple[str, str]]:
|
||||
|
||||
Returns:
|
||||
Tuple of (content_type, extension) or None if not recognized
|
||||
|
||||
"""
|
||||
for signature, (content_type, ext) in IMAGE_SIGNATURES.items():
|
||||
if data.startswith(signature):
|
||||
return content_type, ext
|
||||
|
||||
# Special case for WebP - check for WEBP after RIFF
|
||||
if data.startswith(b'RIFF') and len(data) > 12 and data[8:12] == b'WEBP':
|
||||
return 'image/webp', 'webp'
|
||||
if data.startswith(b"RIFF") and len(data) > _MIN_WEBP_HEADER_LENGTH and data[8:12] == b"WEBP":
|
||||
return "image/webp", "webp"
|
||||
|
||||
return None
|
||||
|
||||
@@ -68,20 +74,21 @@ def _detect_image_type(data: bytes) -> Optional[Tuple[str, str]]:
|
||||
class ImageCacheService:
|
||||
"""Persistent image cache with LRU eviction and TTL support."""
|
||||
|
||||
def __init__(self, cache_dir: Path, max_size_mb: int = 500, ttl_seconds: int = 0):
|
||||
def __init__(self, cache_dir: Path, max_size_mb: int = 500, ttl_seconds: int = 0) -> None:
|
||||
"""Initialize the image cache.
|
||||
|
||||
Args:
|
||||
cache_dir: Directory to store cached images
|
||||
max_size_mb: Maximum cache size in megabytes
|
||||
ttl_seconds: Time-to-live in seconds (0 = forever)
|
||||
|
||||
"""
|
||||
self.cache_dir = cache_dir
|
||||
self.max_size_bytes = max_size_mb * 1024 * 1024
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.index_path = cache_dir / "cache_index.json"
|
||||
self._lock = threading.RLock()
|
||||
self._index: Dict[str, Dict[str, Any]] = {}
|
||||
self._index: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Stats tracking
|
||||
self._hits = 0
|
||||
@@ -101,9 +108,9 @@ class ImageCacheService:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.index_path, 'r') as f:
|
||||
with self.index_path.open() as f:
|
||||
self._index = json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
except OSError, json.JSONDecodeError:
|
||||
self._index = {}
|
||||
|
||||
def _sync_index_with_files(self) -> None:
|
||||
@@ -113,12 +120,12 @@ class ImageCacheService:
|
||||
- Removes entries for files that no longer exist (non-negative only)
|
||||
- Preserves negative cache entries (they have no files)
|
||||
"""
|
||||
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
|
||||
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
added_count = 0
|
||||
removed_count = 0
|
||||
|
||||
# Build set of files that exist on disk
|
||||
existing_files: Dict[str, Path] = {}
|
||||
existing_files: dict[str, Path] = {}
|
||||
for file_path in self.cache_dir.iterdir():
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
@@ -131,31 +138,31 @@ class ImageCacheService:
|
||||
if cache_id in self._index:
|
||||
continue
|
||||
|
||||
ext = file_path.suffix.lstrip('.')
|
||||
ext = file_path.suffix.lstrip(".")
|
||||
stat = file_path.stat()
|
||||
|
||||
# Detect content type
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
with file_path.open("rb") as f:
|
||||
header = f.read(16)
|
||||
detected = _detect_image_type(header)
|
||||
content_type = detected[0] if detected else f'image/{ext}'
|
||||
except IOError:
|
||||
content_type = f'image/{ext}'
|
||||
content_type = detected[0] if detected else f"image/{ext}"
|
||||
except OSError:
|
||||
content_type = f"image/{ext}"
|
||||
|
||||
self._index[cache_id] = {
|
||||
'ext': ext,
|
||||
'content_type': content_type,
|
||||
'size': stat.st_size,
|
||||
'cached_at': stat.st_mtime,
|
||||
'accessed_at': stat.st_mtime,
|
||||
"ext": ext,
|
||||
"content_type": content_type,
|
||||
"size": stat.st_size,
|
||||
"cached_at": stat.st_mtime,
|
||||
"accessed_at": stat.st_mtime,
|
||||
}
|
||||
added_count += 1
|
||||
|
||||
# Remove index entries for missing files (skip negative cache entries)
|
||||
stale_entries = []
|
||||
for cache_id, entry in self._index.items():
|
||||
if entry.get('negative', False):
|
||||
if entry.get("negative", False):
|
||||
continue # Negative entries don't have files
|
||||
if cache_id not in existing_files:
|
||||
stale_entries.append(cache_id)
|
||||
@@ -171,39 +178,39 @@ class ImageCacheService:
|
||||
"""Save cache index to disk."""
|
||||
try:
|
||||
# Write to temp file first, then rename for atomicity
|
||||
temp_path = self.index_path.with_suffix('.tmp')
|
||||
with open(temp_path, 'w') as f:
|
||||
temp_path = self.index_path.with_suffix(".tmp")
|
||||
with temp_path.open("w") as f:
|
||||
json.dump(self._index, f)
|
||||
temp_path.rename(self.index_path)
|
||||
except IOError:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _get_image_path(self, cache_id: str, ext: str) -> Path:
|
||||
"""Get the file path for a cached image."""
|
||||
return self.cache_dir / f"{cache_id}.{ext}"
|
||||
|
||||
def _is_expired(self, entry: Dict[str, Any]) -> bool:
|
||||
def _is_expired(self, entry: dict[str, Any]) -> bool:
|
||||
"""Check if a cache entry is expired."""
|
||||
if self.ttl_seconds == 0:
|
||||
return False
|
||||
return (time.time() - entry.get('cached_at', 0)) > self.ttl_seconds
|
||||
return (time.time() - entry.get("cached_at", 0)) > self.ttl_seconds
|
||||
|
||||
def _is_negative_expired(self, entry: Dict[str, Any]) -> bool:
|
||||
def _is_negative_expired(self, entry: dict[str, Any]) -> bool:
|
||||
"""Check if a negative cache entry is expired.
|
||||
|
||||
Transient failures (timeouts) expire after TRANSIENT_CACHE_TTL (60s).
|
||||
Permanent failures (404s) expire after NEGATIVE_CACHE_TTL (1 hour).
|
||||
"""
|
||||
if not entry.get('negative', False):
|
||||
if not entry.get("negative", False):
|
||||
return False
|
||||
|
||||
cached_at = entry.get('cached_at', 0)
|
||||
ttl = TRANSIENT_CACHE_TTL if entry.get('transient', False) else NEGATIVE_CACHE_TTL
|
||||
cached_at = entry.get("cached_at", 0)
|
||||
ttl = TRANSIENT_CACHE_TTL if entry.get("transient", False) else NEGATIVE_CACHE_TTL
|
||||
return (time.time() - cached_at) > ttl
|
||||
|
||||
def _calculate_total_size(self) -> int:
|
||||
"""Calculate total size of cached images."""
|
||||
return sum(entry.get('size', 0) for entry in self._index.values())
|
||||
return sum(entry.get("size", 0) for entry in self._index.values())
|
||||
|
||||
def _evict_if_needed(self, required_space: int = 0) -> None:
|
||||
"""Evict old entries if cache is over size limit.
|
||||
@@ -217,10 +224,7 @@ class ImageCacheService:
|
||||
return
|
||||
|
||||
# Sort entries by accessed_at (oldest first)
|
||||
sorted_entries = sorted(
|
||||
self._index.items(),
|
||||
key=lambda x: x[1].get('accessed_at', 0)
|
||||
)
|
||||
sorted_entries = sorted(self._index.items(), key=lambda x: x[1].get("accessed_at", 0))
|
||||
|
||||
evicted_count = 0
|
||||
for cache_id, entry in sorted_entries:
|
||||
@@ -228,23 +232,23 @@ class ImageCacheService:
|
||||
break
|
||||
|
||||
# Delete the image file
|
||||
ext = entry.get('ext', 'jpg')
|
||||
ext = entry.get("ext", "jpg")
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
except IOError:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Update tracking
|
||||
current_size -= entry.get('size', 0)
|
||||
current_size -= entry.get("size", 0)
|
||||
del self._index[cache_id]
|
||||
evicted_count += 1
|
||||
|
||||
if evicted_count > 0:
|
||||
self._save_index()
|
||||
|
||||
def get(self, cache_id: str) -> Optional[Tuple[bytes, str]]:
|
||||
def get(self, cache_id: str) -> tuple[bytes, str] | None:
|
||||
"""Get a cached image.
|
||||
|
||||
Args:
|
||||
@@ -252,6 +256,7 @@ class ImageCacheService:
|
||||
|
||||
Returns:
|
||||
Tuple of (image_data, content_type) or None if not cached/expired
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self._index.get(cache_id)
|
||||
@@ -265,7 +270,7 @@ class ImageCacheService:
|
||||
return None
|
||||
|
||||
# Check for negative cache (failed fetch)
|
||||
if entry.get('negative', False):
|
||||
if entry.get("negative", False):
|
||||
if self._is_negative_expired(entry):
|
||||
# Negative cache expired, allow retry
|
||||
del self._index[cache_id]
|
||||
@@ -278,12 +283,12 @@ class ImageCacheService:
|
||||
# Check for expired entry
|
||||
if self._is_expired(entry):
|
||||
# Remove expired entry
|
||||
ext = entry.get('ext', 'jpg')
|
||||
ext = entry.get("ext", "jpg")
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
except IOError:
|
||||
except OSError:
|
||||
pass
|
||||
del self._index[cache_id]
|
||||
self._save_index()
|
||||
@@ -291,9 +296,10 @@ class ImageCacheService:
|
||||
return None
|
||||
|
||||
# Try to read the cached image
|
||||
ext = entry.get('ext', 'jpg')
|
||||
content_type = entry.get('content_type', 'image/jpeg')
|
||||
ext = entry.get("ext", "jpg")
|
||||
content_type = entry.get("content_type", "image/jpeg")
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
result: tuple[bytes, str] | None = None
|
||||
|
||||
try:
|
||||
if not image_path.exists():
|
||||
@@ -303,19 +309,20 @@ class ImageCacheService:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
with open(image_path, 'rb') as f:
|
||||
with image_path.open("rb") as f:
|
||||
data = f.read()
|
||||
|
||||
# Update accessed time
|
||||
entry['accessed_at'] = time.time()
|
||||
entry["accessed_at"] = time.time()
|
||||
self._save_index()
|
||||
result = data, content_type
|
||||
|
||||
self._hits += 1
|
||||
return data, content_type
|
||||
|
||||
except IOError:
|
||||
except OSError:
|
||||
self._misses += 1
|
||||
return None
|
||||
else:
|
||||
self._hits += 1
|
||||
return result
|
||||
|
||||
def put(self, cache_id: str, data: bytes, content_type: str) -> bool:
|
||||
"""Store an image in the cache.
|
||||
@@ -327,24 +334,24 @@ class ImageCacheService:
|
||||
|
||||
Returns:
|
||||
True if stored successfully
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
# Detect image type for extension
|
||||
detected = _detect_image_type(data)
|
||||
if detected:
|
||||
content_type, ext = detected
|
||||
# Fall back to content-type header
|
||||
elif "jpeg" in content_type or "jpg" in content_type:
|
||||
ext = "jpg"
|
||||
elif "png" in content_type:
|
||||
ext = "png"
|
||||
elif "gif" in content_type:
|
||||
ext = "gif"
|
||||
elif "webp" in content_type:
|
||||
ext = "webp"
|
||||
else:
|
||||
# Fall back to content-type header
|
||||
if 'jpeg' in content_type or 'jpg' in content_type:
|
||||
ext = 'jpg'
|
||||
elif 'png' in content_type:
|
||||
ext = 'png'
|
||||
elif 'gif' in content_type:
|
||||
ext = 'gif'
|
||||
elif 'webp' in content_type:
|
||||
ext = 'webp'
|
||||
else:
|
||||
ext = 'jpg' # Default
|
||||
ext = "jpg" # Default
|
||||
|
||||
image_size = len(data)
|
||||
|
||||
@@ -354,36 +361,37 @@ class ImageCacheService:
|
||||
# Write image to disk
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
with open(image_path, 'wb') as f:
|
||||
with image_path.open("wb") as f:
|
||||
f.write(data)
|
||||
except IOError:
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# Update index
|
||||
now = time.time()
|
||||
self._index[cache_id] = {
|
||||
'ext': ext,
|
||||
'content_type': content_type,
|
||||
'size': image_size,
|
||||
'cached_at': now,
|
||||
'accessed_at': now,
|
||||
'negative': False,
|
||||
"ext": ext,
|
||||
"content_type": content_type,
|
||||
"size": image_size,
|
||||
"cached_at": now,
|
||||
"accessed_at": now,
|
||||
"negative": False,
|
||||
}
|
||||
self._save_index()
|
||||
return True
|
||||
|
||||
def put_negative(self, cache_id: str, transient: bool = False) -> None:
|
||||
def put_negative(self, cache_id: str, *, transient: bool = False) -> None:
|
||||
"""Store a negative cache entry (failed fetch).
|
||||
|
||||
Args:
|
||||
cache_id: Cache key
|
||||
transient: If True, uses shorter TTL (for timeouts/connection errors)
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
self._index[cache_id] = {
|
||||
'negative': True,
|
||||
'transient': transient,
|
||||
'cached_at': time.time(),
|
||||
"negative": True,
|
||||
"transient": transient,
|
||||
"cached_at": time.time(),
|
||||
}
|
||||
self._save_index()
|
||||
|
||||
@@ -395,6 +403,7 @@ class ImageCacheService:
|
||||
|
||||
Returns:
|
||||
True if entry existed and was deleted
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self._index.get(cache_id)
|
||||
@@ -402,13 +411,13 @@ class ImageCacheService:
|
||||
return False
|
||||
|
||||
# Delete file if it exists
|
||||
if not entry.get('negative', False):
|
||||
ext = entry.get('ext', 'jpg')
|
||||
if not entry.get("negative", False):
|
||||
ext = entry.get("ext", "jpg")
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
except IOError:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
del self._index[cache_id]
|
||||
@@ -420,19 +429,20 @@ class ImageCacheService:
|
||||
|
||||
Returns:
|
||||
Number of entries cleared
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
count = len(self._index)
|
||||
|
||||
# Delete all image files
|
||||
for cache_id, entry in self._index.items():
|
||||
if not entry.get('negative', False):
|
||||
ext = entry.get('ext', 'jpg')
|
||||
if not entry.get("negative", False):
|
||||
ext = entry.get("ext", "jpg")
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
except IOError:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Clear index
|
||||
@@ -445,28 +455,29 @@ class ImageCacheService:
|
||||
|
||||
return count
|
||||
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""Get cache statistics.
|
||||
|
||||
Returns:
|
||||
Dict with size, count, hit rate, etc.
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
total_size = self._calculate_total_size()
|
||||
entry_count = len(self._index)
|
||||
negative_count = sum(1 for e in self._index.values() if e.get('negative', False))
|
||||
negative_count = sum(1 for e in self._index.values() if e.get("negative", False))
|
||||
total_requests = self._hits + self._misses
|
||||
hit_rate = (self._hits / total_requests * 100) if total_requests > 0 else 0
|
||||
|
||||
return {
|
||||
'entry_count': entry_count,
|
||||
'negative_count': negative_count,
|
||||
'total_size_bytes': total_size,
|
||||
'total_size_mb': round(total_size / (1024 * 1024), 2),
|
||||
'max_size_mb': self.max_size_bytes / (1024 * 1024),
|
||||
'hits': self._hits,
|
||||
'misses': self._misses,
|
||||
'hit_rate': round(hit_rate, 1),
|
||||
"entry_count": entry_count,
|
||||
"negative_count": negative_count,
|
||||
"total_size_bytes": total_size,
|
||||
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||
"max_size_mb": self.max_size_bytes / (1024 * 1024),
|
||||
"hits": self._hits,
|
||||
"misses": self._misses,
|
||||
"hit_rate": round(hit_rate, 1),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -477,7 +488,7 @@ class ImageCacheService:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
|
||||
hostname = parsed.hostname
|
||||
@@ -490,12 +501,12 @@ class ImageCacheService:
|
||||
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):
|
||||
except socket.gaierror, ValueError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def fetch_and_cache(self, cache_id: str, url: str) -> Optional[Tuple[bytes, str]]:
|
||||
def fetch_and_cache(self, cache_id: str, url: str) -> tuple[bytes, str] | None:
|
||||
"""Fetch an image from URL and cache it.
|
||||
|
||||
Args:
|
||||
@@ -504,10 +515,12 @@ class ImageCacheService:
|
||||
|
||||
Returns:
|
||||
Tuple of (image_data, content_type) or None on failure
|
||||
|
||||
"""
|
||||
cached_data: tuple[bytes, str] | None = None
|
||||
try:
|
||||
if not self._is_safe_url(url):
|
||||
logger.warning(f"Blocked request to disallowed URL: {url}")
|
||||
logger.warning("Blocked request to disallowed URL: %s", url)
|
||||
return None
|
||||
|
||||
response = requests.get(
|
||||
@@ -520,8 +533,8 @@ class ImageCacheService:
|
||||
response.raise_for_status()
|
||||
|
||||
# Validate content type
|
||||
content_type = response.headers.get('content-type', '')
|
||||
if not content_type.startswith('image/'):
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if not content_type.startswith("image/"):
|
||||
self.put_negative(cache_id)
|
||||
return None
|
||||
|
||||
@@ -545,9 +558,7 @@ class ImageCacheService:
|
||||
detected = _detect_image_type(image_data)
|
||||
if detected:
|
||||
content_type = detected[0]
|
||||
return image_data, content_type
|
||||
|
||||
return None
|
||||
cached_data = image_data, content_type
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
self.put_negative(cache_id, transient=True)
|
||||
@@ -556,15 +567,17 @@ class ImageCacheService:
|
||||
self.put_negative(cache_id, transient=True)
|
||||
return None
|
||||
except requests.exceptions.HTTPError as e:
|
||||
is_404 = e.response is not None and e.response.status_code == 404
|
||||
is_404 = e.response is not None and e.response.status_code == HTTP_NOT_FOUND
|
||||
self.put_negative(cache_id, transient=not is_404)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
else:
|
||||
return cached_data
|
||||
|
||||
|
||||
# Singleton instance (initialized lazily when config is available)
|
||||
_instance: Optional[ImageCacheService] = None
|
||||
_instance: ImageCacheService | None = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
|
||||
@@ -578,8 +591,8 @@ def get_image_cache() -> ImageCacheService:
|
||||
if _instance is None:
|
||||
with _instance_lock:
|
||||
if _instance is None:
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.config.env import CONFIG_DIR
|
||||
from shelfmark.core.config import config
|
||||
|
||||
cache_dir = CONFIG_DIR / "covers"
|
||||
max_size_mb = config.get("COVERS_CACHE_MAX_SIZE_MB", 500)
|
||||
@@ -591,7 +604,12 @@ def get_image_cache() -> ImageCacheService:
|
||||
max_size_mb=max_size_mb,
|
||||
ttl_seconds=ttl_seconds,
|
||||
)
|
||||
logger.debug(f"Initialized image cache: {cache_dir} (max {max_size_mb}MB, TTL {ttl_days} days)")
|
||||
logger.debug(
|
||||
"Initialized image cache: %s (max %sMB, TTL %s days)",
|
||||
cache_dir,
|
||||
max_size_mb,
|
||||
ttl_days,
|
||||
)
|
||||
|
||||
return _instance
|
||||
|
||||
|
||||
+36
-33
@@ -2,59 +2,59 @@
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.config.env import LOG_FILE, ENABLE_LOGGING, LOG_LEVEL
|
||||
from shelfmark.config.env import ENABLE_LOGGING, LOG_FILE, LOG_LEVEL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class CustomLogger(logging.Logger):
|
||||
"""Custom logger class with additional error_trace method."""
|
||||
|
||||
def error_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
def error_trace(self, msg: object, *args: object, **kwargs: object) -> None:
|
||||
"""Log an error message with full stack trace."""
|
||||
self.log_resource_usage()
|
||||
kwargs.pop('exc_info', None)
|
||||
kwargs.pop("exc_info", None)
|
||||
self.error(msg, *args, exc_info=True, **kwargs)
|
||||
|
||||
def warning_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log a warning message with full stack trace."""
|
||||
self.log_resource_usage()
|
||||
kwargs.pop('exc_info', None)
|
||||
self.warning(msg, *args, exc_info=True, **kwargs)
|
||||
|
||||
def info_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log an info message (stack trace only if exception active)."""
|
||||
kwargs.pop('exc_info', None)
|
||||
# Only include exc_info if there's actually an exception
|
||||
has_exception = sys.exc_info()[0] is not None
|
||||
self.info(msg, *args, exc_info=has_exception, **kwargs)
|
||||
|
||||
def debug_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
def debug_trace(self, msg: object, *args: object, **kwargs: object) -> None:
|
||||
"""Log a debug message (stack trace only if exception active)."""
|
||||
kwargs.pop('exc_info', None)
|
||||
kwargs.pop("exc_info", None)
|
||||
# Only include exc_info if there's actually an exception
|
||||
has_exception = sys.exc_info()[0] is not None
|
||||
self.debug(msg, *args, exc_info=has_exception, **kwargs)
|
||||
|
||||
def log_resource_usage(self):
|
||||
def log_resource_usage(self) -> None:
|
||||
# Best-effort only; this should never raise during exception logging.
|
||||
try:
|
||||
import psutil
|
||||
|
||||
def _get_process_rss_mb(proc: object) -> float | None:
|
||||
try:
|
||||
mem = proc.info.get("memory_info")
|
||||
if mem:
|
||||
return mem.rss / (1024 * 1024)
|
||||
except (
|
||||
psutil.NoSuchProcess,
|
||||
psutil.AccessDenied,
|
||||
KeyError,
|
||||
AttributeError,
|
||||
):
|
||||
return None
|
||||
return None
|
||||
|
||||
# Sum RSS of all processes for actual app memory (container-friendly),
|
||||
# but fall back gracefully on platforms that restrict process enumeration.
|
||||
app_memory_mb = 0.0
|
||||
try:
|
||||
for proc in psutil.process_iter(['memory_info']):
|
||||
try:
|
||||
mem = proc.info.get('memory_info')
|
||||
if mem:
|
||||
app_memory_mb += mem.rss / (1024 * 1024)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, KeyError, AttributeError):
|
||||
continue
|
||||
except (PermissionError, psutil.AccessDenied, OSError):
|
||||
for proc in psutil.process_iter(["memory_info"]):
|
||||
proc_rss_mb = _get_process_rss_mb(proc)
|
||||
if proc_rss_mb is not None:
|
||||
app_memory_mb += proc_rss_mb
|
||||
except PermissionError, psutil.AccessDenied, OSError:
|
||||
try:
|
||||
app_memory_mb = psutil.Process().memory_info().rss / (1024 * 1024)
|
||||
except Exception:
|
||||
@@ -82,6 +82,7 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
|
||||
Returns:
|
||||
CustomLogger: Configured logger instance with error_trace method
|
||||
|
||||
"""
|
||||
# Register our custom logger class
|
||||
logging.setLoggerClass(CustomLogger)
|
||||
@@ -92,19 +93,21 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
logger.setLevel(log_level)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s'
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
|
||||
)
|
||||
|
||||
# Console handler for Docker output
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.addFilter(lambda record: record.levelno < logging.ERROR) # Only allow logs below ERROR to stdout
|
||||
console_handler.addFilter(
|
||||
lambda record: record.levelno < logging.ERROR
|
||||
) # Only allow logs below ERROR to stdout
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# Error handler for stderr
|
||||
error_handler = logging.StreamHandler(sys.stderr)
|
||||
error_handler.setLevel(logging.ERROR) # Error and above go to stderr
|
||||
error_handler.setLevel(logging.ERROR) # Error and above go to stderr
|
||||
error_handler.setFormatter(formatter)
|
||||
logger.addHandler(error_handler)
|
||||
|
||||
@@ -117,7 +120,7 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10485760, # 10MB
|
||||
backupCount=5
|
||||
backupCount=5,
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
+31
-26
@@ -1,18 +1,23 @@
|
||||
"""Centralized mirror configuration for all download sources."""
|
||||
|
||||
from typing import List
|
||||
# Lazy import to avoid circular imports
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
|
||||
# Lazy import to avoid circular imports
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
_config_module = None
|
||||
|
||||
|
||||
def _get_config():
|
||||
def _get_config() -> ModuleType:
|
||||
"""Lazy import of config module to avoid circular imports."""
|
||||
global _config_module
|
||||
if _config_module is None:
|
||||
from shelfmark.core.config import config
|
||||
|
||||
_config_module = config
|
||||
return _config_module
|
||||
|
||||
@@ -50,9 +55,8 @@ def _normalize_mirror_url(url: str) -> str:
|
||||
return normalize_http_url(url, default_scheme="https")
|
||||
|
||||
|
||||
def get_aa_mirrors() -> List[str]:
|
||||
"""
|
||||
Get Anna's Archive mirrors.
|
||||
def get_aa_mirrors() -> list[str]:
|
||||
"""Get Anna's Archive mirrors.
|
||||
|
||||
Returns:
|
||||
Ordered list of AA mirror URLs.
|
||||
@@ -60,9 +64,10 @@ def get_aa_mirrors() -> List[str]:
|
||||
If AA_MIRROR_URLS is configured, it is treated as the full list.
|
||||
Otherwise, defaults are used and AA_ADDITIONAL_URLS (legacy) is appended.
|
||||
|
||||
Notes:
|
||||
Notes:
|
||||
- The list is used to populate the AA mirror dropdown in Settings.
|
||||
- When AA_BASE_URL is set to 'auto', mirrors are tried in the order listed.
|
||||
|
||||
"""
|
||||
config = _get_config()
|
||||
|
||||
@@ -96,12 +101,12 @@ def get_aa_mirrors() -> List[str]:
|
||||
return mirrors
|
||||
|
||||
|
||||
def get_libgen_mirrors() -> List[str]:
|
||||
"""
|
||||
Get LibGen mirrors: defaults + any additional from config.
|
||||
def get_libgen_mirrors() -> list[str]:
|
||||
"""Get LibGen mirrors: defaults + any additional from config.
|
||||
|
||||
Returns:
|
||||
List of LibGen mirror URLs (defaults first, then custom additions).
|
||||
|
||||
"""
|
||||
mirrors = [_normalize_mirror_url(url) for url in DEFAULT_LIBGEN_MIRRORS]
|
||||
mirrors = [url for url in mirrors if url]
|
||||
@@ -117,12 +122,12 @@ def get_libgen_mirrors() -> List[str]:
|
||||
return mirrors
|
||||
|
||||
|
||||
def get_zlib_mirrors() -> List[str]:
|
||||
"""
|
||||
Get Z-Library mirrors, with primary first.
|
||||
def get_zlib_mirrors() -> list[str]:
|
||||
"""Get Z-Library mirrors, with primary first.
|
||||
|
||||
Returns:
|
||||
List of Z-Library mirror URLs, primary first.
|
||||
|
||||
"""
|
||||
config = _get_config()
|
||||
|
||||
@@ -149,11 +154,11 @@ def get_zlib_mirrors() -> List[str]:
|
||||
|
||||
|
||||
def get_zlib_primary_url() -> str:
|
||||
"""
|
||||
Get the primary Z-Library mirror URL.
|
||||
"""Get the primary Z-Library mirror URL.
|
||||
|
||||
Returns:
|
||||
Primary Z-Library mirror URL.
|
||||
|
||||
"""
|
||||
config = _get_config()
|
||||
primary = _normalize_mirror_url(config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0]))
|
||||
@@ -161,22 +166,22 @@ def get_zlib_primary_url() -> str:
|
||||
|
||||
|
||||
def get_zlib_url_template() -> str:
|
||||
"""
|
||||
Get Z-Library URL template using configured primary mirror.
|
||||
"""Get Z-Library URL template using configured primary mirror.
|
||||
|
||||
Returns:
|
||||
URL template with {md5} placeholder.
|
||||
|
||||
"""
|
||||
primary = get_zlib_primary_url()
|
||||
return f"{primary}/md5/{{md5}}"
|
||||
|
||||
|
||||
def get_welib_mirrors() -> List[str]:
|
||||
"""
|
||||
Get Welib mirrors, with primary first.
|
||||
def get_welib_mirrors() -> list[str]:
|
||||
"""Get Welib mirrors, with primary first.
|
||||
|
||||
Returns:
|
||||
List of Welib mirror URLs, primary first.
|
||||
|
||||
"""
|
||||
config = _get_config()
|
||||
|
||||
@@ -203,11 +208,11 @@ def get_welib_mirrors() -> List[str]:
|
||||
|
||||
|
||||
def get_welib_primary_url() -> str:
|
||||
"""
|
||||
Get the primary Welib mirror URL.
|
||||
"""Get the primary Welib mirror URL.
|
||||
|
||||
Returns:
|
||||
Primary Welib mirror URL.
|
||||
|
||||
"""
|
||||
config = _get_config()
|
||||
primary = _normalize_mirror_url(config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0]))
|
||||
@@ -215,24 +220,24 @@ def get_welib_primary_url() -> str:
|
||||
|
||||
|
||||
def get_welib_url_template() -> str:
|
||||
"""
|
||||
Get Welib URL template using configured primary mirror.
|
||||
"""Get Welib URL template using configured primary mirror.
|
||||
|
||||
Returns:
|
||||
URL template with {md5} placeholder.
|
||||
|
||||
"""
|
||||
primary = get_welib_primary_url()
|
||||
return f"{primary}/md5/{{md5}}"
|
||||
|
||||
|
||||
def get_zlib_cookie_domains() -> set:
|
||||
"""
|
||||
Get set of Z-Library domains that need full cookie handling.
|
||||
"""Get set of Z-Library domains that need full cookie handling.
|
||||
|
||||
Used by internal_bypasser for CF bypass cookie management.
|
||||
|
||||
Returns:
|
||||
Set of domain strings (without protocol).
|
||||
|
||||
"""
|
||||
domains = set()
|
||||
|
||||
|
||||
+76
-57
@@ -1,18 +1,18 @@
|
||||
"""Data structures and models used across the application."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from enum import Enum
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_filename(
|
||||
title: str,
|
||||
author: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
fmt: Optional[str] = None,
|
||||
author: str | None = None,
|
||||
year: str | None = None,
|
||||
fmt: str | None = None,
|
||||
) -> str:
|
||||
parts = []
|
||||
if author:
|
||||
@@ -23,7 +23,7 @@ def build_filename(
|
||||
parts.append(f" ({year})")
|
||||
|
||||
filename = "".join(parts)
|
||||
filename = re.sub(r'[\\/:*?"<>|]', '_', filename.strip())[:245]
|
||||
filename = re.sub(r'[\\/:*?"<>|]', "_", filename.strip())[:245]
|
||||
|
||||
if fmt:
|
||||
filename = f"{filename}.{fmt}"
|
||||
@@ -31,8 +31,9 @@ def build_filename(
|
||||
return filename
|
||||
|
||||
|
||||
class QueueStatus(str, Enum):
|
||||
class QueueStatus(StrEnum):
|
||||
"""Enum for possible book queue statuses."""
|
||||
|
||||
QUEUED = "queued"
|
||||
RESOLVING = "resolving"
|
||||
LOCATING = "locating"
|
||||
@@ -42,16 +43,25 @@ class QueueStatus(str, Enum):
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
TERMINAL_QUEUE_STATUSES: frozenset[QueueStatus] = frozenset({
|
||||
QueueStatus.COMPLETE, QueueStatus.ERROR, QueueStatus.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,
|
||||
})
|
||||
ACTIVE_QUEUE_STATUSES: frozenset[QueueStatus] = frozenset(
|
||||
{
|
||||
QueueStatus.QUEUED,
|
||||
QueueStatus.RESOLVING,
|
||||
QueueStatus.LOCATING,
|
||||
QueueStatus.DOWNLOADING,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SearchMode(str, Enum):
|
||||
class SearchMode(StrEnum):
|
||||
DIRECT = "direct"
|
||||
UNIVERSAL = "universal"
|
||||
|
||||
@@ -59,11 +69,12 @@ class SearchMode(str, Enum):
|
||||
@dataclass
|
||||
class QueueItem:
|
||||
"""Queue item with priority and metadata."""
|
||||
|
||||
book_id: str
|
||||
priority: int
|
||||
added_time: float
|
||||
|
||||
def __lt__(self, other):
|
||||
def __lt__(self, other: QueueItem) -> bool:
|
||||
"""Compare items for priority queue (lower priority number = higher precedence)."""
|
||||
if self.priority != other.priority:
|
||||
return self.priority < other.priority
|
||||
@@ -72,60 +83,67 @@ class QueueItem:
|
||||
|
||||
@dataclass
|
||||
class DownloadTask:
|
||||
task_id: str # Unique ID (e.g., AA MD5 hash, Prowlarr GUID)
|
||||
source: str # Handler name ("direct_download", "prowlarr")
|
||||
title: str # Display title for queue sidebar
|
||||
task_id: str # Unique ID (e.g., AA MD5 hash, Prowlarr GUID)
|
||||
source: str # Handler name ("direct_download", "prowlarr")
|
||||
title: str # Display title for queue sidebar
|
||||
|
||||
# Display info for queue sidebar
|
||||
author: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
format: Optional[str] = None
|
||||
size: Optional[str] = None
|
||||
preview: Optional[str] = None
|
||||
content_type: Optional[str] = None # "book (fiction)", "audiobook", "magazine", etc.
|
||||
source_url: Optional[str] = None # Original release URL used by source-specific handlers
|
||||
retry_download_url: Optional[str] = None # Resolved download URL for restart-safe retries
|
||||
retry_download_protocol: Optional[str] = None # Protocol for retry_download_url (e.g. torrent, usenet)
|
||||
retry_release_name: Optional[str] = None # Display name to send back to external download clients
|
||||
retry_expected_hash: Optional[str] = None # Optional torrent hash used to match client downloads
|
||||
retry_ratio_limit: Optional[float] = None # Optional post-download seeding ratio
|
||||
retry_seeding_time_limit_minutes: Optional[int] = None # Optional post-download seeding time limit
|
||||
can_retry_without_staged_source: bool = True # Whether the source can restart without a preserved staged file
|
||||
author: str | None = None
|
||||
year: str | None = None
|
||||
format: str | None = None
|
||||
size: str | None = None
|
||||
preview: str | None = None
|
||||
content_type: str | None = None # "book (fiction)", "audiobook", "magazine", etc.
|
||||
source_url: str | None = None # Original release URL used by source-specific handlers
|
||||
retry_download_url: str | None = None # Resolved download URL for restart-safe retries
|
||||
retry_download_protocol: str | None = (
|
||||
None # Protocol for retry_download_url (e.g. torrent, usenet)
|
||||
)
|
||||
retry_release_name: str | None = None # Display name to send back to external download clients
|
||||
retry_expected_hash: str | None = None # Optional torrent hash used to match client downloads
|
||||
retry_ratio_limit: float | None = None # Optional post-download seeding ratio
|
||||
retry_seeding_time_limit_minutes: int | None = None # Optional post-download seeding time limit
|
||||
can_retry_without_staged_source: bool = (
|
||||
True # Whether the source can restart without a preserved staged file
|
||||
)
|
||||
|
||||
# Series info (for library naming templates)
|
||||
series_name: Optional[str] = None
|
||||
series_position: Optional[float] = None # Float for novellas (e.g., 1.5)
|
||||
subtitle: Optional[str] = None # Book subtitle for naming templates
|
||||
series_name: str | None = None
|
||||
series_position: float | None = None # Float for novellas (e.g., 1.5)
|
||||
subtitle: str | None = None # Book subtitle for naming templates
|
||||
|
||||
# Hardlinking support
|
||||
original_download_path: Optional[str] = None # Path in download client (for hardlinking)
|
||||
original_download_path: str | None = None # Path in download client (for hardlinking)
|
||||
|
||||
# Search mode - determines post-download processing behavior
|
||||
# See SearchMode enum for behavioral differences
|
||||
search_mode: Optional[SearchMode] = None
|
||||
search_mode: SearchMode | None = None
|
||||
|
||||
# Output selection for post-processing.
|
||||
# This is captured at queue time so in-flight tasks are not affected if the user changes settings later.
|
||||
output_mode: Optional[str] = None # e.g. "folder", "booklore", "email"
|
||||
output_args: Dict[str, Any] = field(default_factory=dict) # Per-output parameters (e.g. email recipient)
|
||||
output_mode: str | None = None
|
||||
|
||||
output_args: dict[str, Any] = field(
|
||||
default_factory=dict
|
||||
) # Per-output parameters (e.g. email recipient)
|
||||
|
||||
# User association (multi-user support)
|
||||
user_id: Optional[int] = None # DB user ID who queued this download
|
||||
username: Optional[str] = None # Username for {User} template variable
|
||||
request_id: Optional[int] = None # Origin request ID when queued from request fulfilment
|
||||
user_id: int | None = None # DB user ID who queued this download
|
||||
username: str | None = None # Username for {User} template variable
|
||||
request_id: int | None = None # Origin request ID when queued from request fulfilment
|
||||
|
||||
# Runtime state
|
||||
priority: int = 0
|
||||
added_time: float = field(default_factory=time.time)
|
||||
progress: float = 0.0
|
||||
status: QueueStatus = QueueStatus.QUEUED
|
||||
status_message: Optional[str] = None
|
||||
download_path: Optional[str] = None
|
||||
last_error_message: Optional[str] = None
|
||||
last_error_type: Optional[str] = None
|
||||
staged_path: Optional[str] = None
|
||||
status_message: str | None = None
|
||||
download_path: str | None = None
|
||||
last_error_message: str | None = None
|
||||
last_error_type: str | None = None
|
||||
staged_path: str | None = None
|
||||
|
||||
def __lt__(self, other):
|
||||
def __lt__(self, other: DownloadTask) -> bool:
|
||||
"""Compare tasks for priority queue (lower priority number = higher precedence)."""
|
||||
if self.priority != other.priority:
|
||||
return self.priority < other.priority
|
||||
@@ -141,10 +159,11 @@ class DownloadTask:
|
||||
@dataclass
|
||||
class SearchFilters:
|
||||
"""Filters for book search queries."""
|
||||
isbn: Optional[List[str]] = None
|
||||
author: Optional[List[str]] = None
|
||||
title: Optional[List[str]] = None
|
||||
lang: Optional[List[str]] = None
|
||||
sort: Optional[str] = None
|
||||
content: Optional[List[str]] = None
|
||||
format: Optional[List[str]] = None
|
||||
|
||||
isbn: list[str] | None = None
|
||||
author: list[str] | None = None
|
||||
title: list[str] | None = None
|
||||
lang: list[str] | None = None
|
||||
sort: str | None = None
|
||||
content: list[str] | None = None
|
||||
format: list[str] | None = None
|
||||
|
||||
+51
-51
@@ -1,48 +1,50 @@
|
||||
"""Template-based naming for library organization."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Union, Mapping
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
# Known variable tokens, sorted longest-first to avoid partial matches
|
||||
# e.g., "SeriesPosition" must match before "Series"
|
||||
KNOWN_TOKENS = [
|
||||
'seriesposition',
|
||||
'originalname',
|
||||
'partnumber',
|
||||
'subtitle',
|
||||
'author',
|
||||
'series',
|
||||
'title',
|
||||
'year',
|
||||
'user',
|
||||
"seriesposition",
|
||||
"originalname",
|
||||
"partnumber",
|
||||
"subtitle",
|
||||
"author",
|
||||
"series",
|
||||
"title",
|
||||
"year",
|
||||
"user",
|
||||
]
|
||||
|
||||
# Match any {...} block for template parsing
|
||||
BRACE_PATTERN = re.compile(r'\{([^}]+)\}')
|
||||
BRACE_PATTERN = re.compile(r"\{([^}]+)\}")
|
||||
|
||||
# Characters that are invalid in filenames on various filesystems
|
||||
INVALID_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||
|
||||
|
||||
def _sanitize(name: Optional[str], max_length: int = 245) -> str:
|
||||
def _sanitize(name: str | None, max_length: int = 245) -> str:
|
||||
"""Sanitize a string for filesystem use."""
|
||||
if not name:
|
||||
return ""
|
||||
|
||||
sanitized = INVALID_CHARS.sub('_', name)
|
||||
sanitized = re.sub(r'^[\s.]+|[\s.]+$', '', sanitized) # Strip whitespace and dots
|
||||
sanitized = re.sub(r'_+', '_', sanitized) # Collapse underscores
|
||||
sanitized = INVALID_CHARS.sub("_", name)
|
||||
sanitized = re.sub(r"^[\s.]+|[\s.]+$", "", sanitized) # Strip whitespace and dots
|
||||
sanitized = re.sub(r"_+", "_", sanitized) # Collapse underscores
|
||||
return sanitized[:max_length]
|
||||
|
||||
|
||||
def sanitize_filename(name: Optional[str], max_length: int = 245) -> str:
|
||||
def sanitize_filename(name: str | None, max_length: int = 245) -> str:
|
||||
"""Sanitize a string for use as a filename or path component."""
|
||||
return _sanitize(name, max_length)
|
||||
|
||||
@@ -51,7 +53,7 @@ def sanitize_filename(name: Optional[str], max_length: int = 245) -> str:
|
||||
sanitize_path_component = sanitize_filename
|
||||
|
||||
|
||||
def format_series_position(position: Optional[Union[str, int, float]]) -> str:
|
||||
def format_series_position(position: str | float | None) -> str:
|
||||
if position is None:
|
||||
return ""
|
||||
|
||||
@@ -63,10 +65,10 @@ def format_series_position(position: Optional[Union[str, int, float]]) -> str:
|
||||
|
||||
|
||||
# Pads numbers to 9 digits for natural sorting (e.g., "Part 2" -> "Part 000000002")
|
||||
PAD_NUMBERS_PATTERN = re.compile(r'\d+')
|
||||
PAD_NUMBERS_PATTERN = re.compile(r"\d+")
|
||||
|
||||
|
||||
def natural_sort_key(path: Union[str, Path]) -> str:
|
||||
def natural_sort_key(path: str | Path) -> str:
|
||||
"""Generate a sort key with padded numbers for natural sorting."""
|
||||
filename = Path(path).name.lower()
|
||||
return PAD_NUMBERS_PATTERN.sub(lambda m: m.group().zfill(9), filename)
|
||||
@@ -89,7 +91,7 @@ def assign_part_numbers(
|
||||
|
||||
def parse_naming_template(
|
||||
template: str,
|
||||
metadata: Mapping[str, Optional[Union[str, int, float]]],
|
||||
metadata: Mapping[str, str | int | float | None],
|
||||
*,
|
||||
allow_path_separators: bool = True,
|
||||
) -> str:
|
||||
@@ -99,7 +101,7 @@ def parse_naming_template(
|
||||
# Normalize metadata keys to lowercase for case-insensitive matching
|
||||
normalized = {k.lower(): v for k, v in metadata.items()}
|
||||
|
||||
def find_token(content: str) -> tuple[Optional[str], int]:
|
||||
def find_token(content: str) -> tuple[str | None, int]:
|
||||
content_lower = content.lower()
|
||||
for token in KNOWN_TOKENS:
|
||||
idx = content_lower.find(token)
|
||||
@@ -109,19 +111,19 @@ def parse_naming_template(
|
||||
|
||||
def token_value(token: str) -> str:
|
||||
value = normalized.get(token)
|
||||
if token == 'seriesposition':
|
||||
if token == "seriesposition":
|
||||
value = format_series_position(value)
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
def render_block(content: str) -> Optional[str]:
|
||||
def render_block(content: str) -> str | None:
|
||||
token, idx = find_token(content)
|
||||
if token is None:
|
||||
return None
|
||||
|
||||
prefix = content[:idx]
|
||||
suffix = content[idx + len(token):]
|
||||
suffix = content[idx + len(token) :]
|
||||
value = token_value(token)
|
||||
if not value:
|
||||
return ""
|
||||
@@ -140,7 +142,7 @@ def parse_naming_template(
|
||||
parts: list[str] = []
|
||||
cursor = 0
|
||||
for idx, match in enumerate(matches):
|
||||
parts.append(template[cursor:match.start()])
|
||||
parts.append(template[cursor : match.start()])
|
||||
content = match.group(1)
|
||||
rendered = render_block(content)
|
||||
|
||||
@@ -157,11 +159,10 @@ def parse_naming_template(
|
||||
include_literal = bool(token_value(next_token))
|
||||
if include_literal:
|
||||
parts.append(content)
|
||||
elif not conditional_literal:
|
||||
elif not conditional_literal and re.search(r"\s", content):
|
||||
# Preserve blocks that look like literal text, but treat bare unknown
|
||||
# placeholders as missing variables.
|
||||
if re.search(r"\s", content):
|
||||
parts.append(match.group(0))
|
||||
parts.append(match.group(0))
|
||||
|
||||
cursor = match.end()
|
||||
|
||||
@@ -169,41 +170,39 @@ def parse_naming_template(
|
||||
result = "".join(parts)
|
||||
|
||||
# Clean up any double slashes that might result from empty tokens
|
||||
result = re.sub(r'/+', '/', result)
|
||||
result = re.sub(r"/+", "/", result)
|
||||
|
||||
# Remove leading/trailing slashes
|
||||
result = result.strip('/')
|
||||
result = result.strip("/")
|
||||
|
||||
# Clean up any orphaned separators (e.g., " - " at start/end, or " - - ")
|
||||
result = re.sub(r'^[\s\-_.]+', '', result)
|
||||
result = re.sub(r'[\s\-_.]+$', '', result)
|
||||
result = re.sub(r'(\s*-\s*){2,}', ' - ', result)
|
||||
result = re.sub(r"^[\s\-_.]+", "", result)
|
||||
result = re.sub(r"[\s\-_.]+$", "", result)
|
||||
result = re.sub(r"(\s*-\s*){2,}", " - ", result)
|
||||
|
||||
# Clean up empty parentheses/brackets
|
||||
result = re.sub(r'\(\s*\)', '', result)
|
||||
result = re.sub(r'\[\s*\]', '', result)
|
||||
result = re.sub(r"\(\s*\)", "", result)
|
||||
result = re.sub(r"\[\s*\]", "", result)
|
||||
|
||||
# Final trim of any trailing separators left after cleanup
|
||||
result = re.sub(r'[\s\-_.]+$', '', result)
|
||||
|
||||
return result
|
||||
return re.sub(r"[\s\-_.]+$", "", result)
|
||||
|
||||
|
||||
def build_library_path(
|
||||
base_path: str,
|
||||
template: str,
|
||||
metadata: Mapping[str, Optional[Union[str, int, float]]],
|
||||
extension: Optional[str] = None,
|
||||
metadata: Mapping[str, str | int | float | None],
|
||||
extension: str | None = None,
|
||||
) -> Path:
|
||||
relative = parse_naming_template(template, metadata, allow_path_separators=True)
|
||||
|
||||
if not relative:
|
||||
# Fallback to title if template produces empty result
|
||||
title = metadata.get('Title') or metadata.get('title') or 'Unknown'
|
||||
title = metadata.get("Title") or metadata.get("title") or "Unknown"
|
||||
relative = sanitize_filename(str(title))
|
||||
|
||||
# Remove any path traversal attempts
|
||||
relative = relative.replace('..', '')
|
||||
relative = relative.replace("..", "")
|
||||
|
||||
base = Path(base_path).resolve()
|
||||
full_path = (base / relative).resolve()
|
||||
@@ -211,11 +210,12 @@ def build_library_path(
|
||||
# Verify the path is within the base directory
|
||||
try:
|
||||
full_path.relative_to(base)
|
||||
except ValueError:
|
||||
raise ValueError(f"Path traversal detected: template would escape library directory")
|
||||
except ValueError as exc:
|
||||
msg = "Path traversal detected: template would escape library directory"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
if extension:
|
||||
ext = extension.lstrip('.')
|
||||
ext = extension.lstrip(".")
|
||||
# Don't use with_suffix() - it replaces everything after the first dot
|
||||
# e.g., "2.5 - Title" would become "2.epub" instead of "2.5 - Title.epub"
|
||||
full_path = Path(f"{full_path}.{ext}")
|
||||
@@ -223,27 +223,27 @@ def build_library_path(
|
||||
return full_path
|
||||
|
||||
|
||||
def same_filesystem(path1: Union[str, Path], path2: Union[str, Path]) -> bool:
|
||||
def same_filesystem(path1: str | Path, path2: str | Path) -> bool:
|
||||
"""Check if two paths are on the same filesystem."""
|
||||
path1 = Path(path1)
|
||||
path2 = Path(path2)
|
||||
|
||||
def get_device(p: Path) -> Optional[int]:
|
||||
def get_device(p: Path) -> int | None:
|
||||
try:
|
||||
while not p.exists():
|
||||
p = p.parent
|
||||
if p == p.parent:
|
||||
break
|
||||
return os.stat(p).st_dev
|
||||
return p.stat().st_dev
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.debug(f"Cannot stat {p}: {e}")
|
||||
logger.debug("Cannot stat %s: %s", p, e)
|
||||
return None
|
||||
|
||||
dev1 = get_device(path1)
|
||||
dev2 = get_device(path2)
|
||||
|
||||
if dev1 is None or dev2 is None:
|
||||
logger.warning(f"Cannot determine filesystem for hardlink check, falling back to copy")
|
||||
logger.warning("Cannot determine filesystem for hardlink check, falling back to copy")
|
||||
return False
|
||||
|
||||
return dev1 == dev2
|
||||
|
||||
@@ -5,20 +5,23 @@ from __future__ import annotations
|
||||
import logging
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from contextlib import contextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Iterable, Iterator
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
try:
|
||||
import apprise
|
||||
except Exception: # pragma: no cover - exercised in tests via monkeypatch
|
||||
except ImportError: # pragma: no cover - exercised in tests via monkeypatch
|
||||
apprise = None # type: ignore[assignment]
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Iterator
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Small pool for non-blocking dispatch. Notification sends are I/O bound and infrequent.
|
||||
@@ -32,7 +35,7 @@ _APPRISE_LOGO_URL = (
|
||||
_APPRISE_LOGGER_NAME = "apprise"
|
||||
|
||||
|
||||
class NotificationEvent(str, Enum):
|
||||
class NotificationEvent(StrEnum):
|
||||
"""Global notification event identifiers."""
|
||||
|
||||
REQUEST_CREATED = "request_created"
|
||||
@@ -57,7 +60,7 @@ class NotificationContext:
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
def _normalize_urls(value: Any) -> list[str]:
|
||||
def _normalize_urls(value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
@@ -103,7 +106,7 @@ def _extract_url_schemes(urls: Iterable[str]) -> list[str]:
|
||||
|
||||
|
||||
class _AppriseLogCapture(logging.Handler):
|
||||
def __init__(self, *, thread_id: int):
|
||||
def __init__(self, *, thread_id: int) -> None:
|
||||
super().__init__(level=logging.INFO)
|
||||
self.records: list[tuple[int, str, str, str]] = []
|
||||
self._thread_id = thread_id
|
||||
@@ -126,7 +129,9 @@ class _AppriseLogCapture(logging.Handler):
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _capture_apprise_logs(*, min_level: int = logging.INFO) -> Iterator[list[tuple[int, str, str, str]]]:
|
||||
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())
|
||||
@@ -170,7 +175,7 @@ def _log_apprise_exception_debug(*, action: str, scheme: str, exc: Exception) ->
|
||||
type(exc).__name__,
|
||||
scheme,
|
||||
exc,
|
||||
exc_info=True,
|
||||
exc_info=(type(exc), exc, exc.__traceback__),
|
||||
)
|
||||
|
||||
|
||||
@@ -197,7 +202,7 @@ def _build_apprise_warning_detail(
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_routes(value: Any) -> list[dict[str, str]]:
|
||||
def _normalize_routes(value: object) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
@@ -248,10 +253,10 @@ def _resolve_admin_routes() -> list[dict[str, str]]:
|
||||
return _normalize_routes(app_config.get("ADMIN_NOTIFICATION_ROUTES", []))
|
||||
|
||||
|
||||
def _normalize_user_id(value: Any) -> int | None:
|
||||
def _normalize_user_id(value: object) -> int | None:
|
||||
try:
|
||||
user_id = int(value)
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
if user_id < 1:
|
||||
return None
|
||||
@@ -291,7 +296,7 @@ def _resolve_route_urls_for_event(
|
||||
return selected
|
||||
|
||||
|
||||
def _resolve_notify_type(event: NotificationEvent) -> Any:
|
||||
def _resolve_notify_type(event: NotificationEvent) -> object:
|
||||
if apprise is None:
|
||||
fallback = {
|
||||
NotificationEvent.REQUEST_CREATED: "info",
|
||||
@@ -312,7 +317,7 @@ def _resolve_notify_type(event: NotificationEvent) -> Any:
|
||||
return mapping[event]
|
||||
|
||||
|
||||
def _clean_text(value: Any, fallback: str) -> str:
|
||||
def _clean_text(value: object, fallback: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
return text or fallback
|
||||
|
||||
@@ -330,7 +335,10 @@ def _render_message(context: NotificationContext) -> tuple[str, str]:
|
||||
if event == NotificationEvent.REQUEST_REJECTED:
|
||||
note = _clean_text(context.admin_note, "")
|
||||
note_line = f"\nNote: {note}" if note else ""
|
||||
return "Request Rejected", f'Request for "{title}" by {author} was rejected.{note_line}'
|
||||
return (
|
||||
"Request Rejected",
|
||||
f'Request for "{title}" by {author} was rejected.{note_line}',
|
||||
)
|
||||
if event == NotificationEvent.DOWNLOAD_COMPLETE:
|
||||
return "Download Complete", f'"{title}" by {author} downloaded successfully.'
|
||||
|
||||
@@ -339,7 +347,7 @@ 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:
|
||||
def _plugin_label(plugin: object, fallback_scheme: str) -> str:
|
||||
"""Build a human-readable label from a validated Apprise plugin.
|
||||
|
||||
Combines the URL scheme with the plugin's service name (app_id) and
|
||||
@@ -351,10 +359,8 @@ def _plugin_label(plugin: Any, fallback_scheme: str) -> str:
|
||||
app_id = getattr(plugin, "app_id", None)
|
||||
if app_id and str(app_id) != fallback_scheme:
|
||||
privacy_url: str | None = None
|
||||
try:
|
||||
with suppress(Exception):
|
||||
privacy_url = plugin.url(privacy=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
suffix = str(app_id)
|
||||
if privacy_url:
|
||||
@@ -369,7 +375,7 @@ def _dispatch_to_apprise(
|
||||
*,
|
||||
title: str,
|
||||
body: str,
|
||||
notify_type: Any,
|
||||
notify_type: object,
|
||||
) -> dict[str, Any]:
|
||||
normalized_urls = _normalize_urls(list(urls))
|
||||
url_schemes = _extract_url_schemes(normalized_urls)
|
||||
@@ -443,9 +449,7 @@ def _dispatch_to_apprise(
|
||||
if warning_detail:
|
||||
failure_details.append(warning_detail)
|
||||
else:
|
||||
failure_details.append(
|
||||
f"{scheme}: notify raised {type(exc).__name__}: {exc}"
|
||||
)
|
||||
failure_details.append(f"{scheme}: notify raised {type(exc).__name__}: {exc}")
|
||||
continue
|
||||
|
||||
_log_apprise_records(apprise_records)
|
||||
@@ -502,7 +506,7 @@ def _dispatch_to_apprise(
|
||||
return result
|
||||
|
||||
|
||||
def _create_apprise_client() -> Any:
|
||||
def _create_apprise_client() -> object:
|
||||
if apprise is None:
|
||||
return None
|
||||
|
||||
@@ -535,7 +539,9 @@ def _create_apprise_client() -> Any:
|
||||
return apprise_cls()
|
||||
|
||||
|
||||
def _send_admin_event(event: NotificationEvent, context: NotificationContext, urls: list[str]) -> dict[str, Any]:
|
||||
def _send_admin_event(
|
||||
event: NotificationEvent, context: NotificationContext, urls: list[str]
|
||||
) -> dict[str, Any]:
|
||||
title, body = _render_message(context)
|
||||
notify_type = _resolve_notify_type(event)
|
||||
return _dispatch_to_apprise(urls, title=title, body=body, notify_type=notify_type)
|
||||
@@ -554,7 +560,9 @@ def notify_admin(event: NotificationEvent, context: NotificationContext) -> None
|
||||
logger.warning("Failed to queue admin notification '%s': %s", event.value, exc)
|
||||
|
||||
|
||||
def notify_user(user_id: int | None, event: NotificationEvent, context: NotificationContext) -> None:
|
||||
def notify_user(
|
||||
user_id: int | None, event: NotificationEvent, context: NotificationContext
|
||||
) -> None:
|
||||
"""Send a per-user notification for an event if subscribed."""
|
||||
normalized_user_id = _normalize_user_id(user_id)
|
||||
if normalized_user_id is None:
|
||||
@@ -576,10 +584,16 @@ def notify_user(user_id: int | None, event: NotificationEvent, context: Notifica
|
||||
)
|
||||
|
||||
|
||||
def _dispatch_admin_async(event: NotificationEvent, context: NotificationContext, urls: list[str]) -> None:
|
||||
def _dispatch_admin_async(
|
||||
event: NotificationEvent, context: NotificationContext, urls: list[str]
|
||||
) -> None:
|
||||
result = _send_admin_event(event, context, urls)
|
||||
if not result.get("success", False):
|
||||
logger.warning("Admin notification failed for event '%s': %s", event.value, result.get("message"))
|
||||
logger.warning(
|
||||
"Admin notification failed for event '%s': %s",
|
||||
event.value,
|
||||
result.get("message"),
|
||||
)
|
||||
|
||||
|
||||
def _dispatch_user_async(
|
||||
|
||||
@@ -4,12 +4,15 @@ Handles group claim parsing, user info extraction, and user provisioning.
|
||||
Flask route handlers are registered separately in main.py.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.external_user_linking import upsert_external_user
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
def parse_group_claims(id_token: Dict[str, Any], group_claim: str) -> List[str]:
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
def parse_group_claims(id_token: dict[str, Any], group_claim: str) -> list[str]:
|
||||
"""Extract group list from an ID token claim.
|
||||
|
||||
Supports list, comma-separated string, or pipe-separated string.
|
||||
@@ -26,7 +29,7 @@ def parse_group_claims(id_token: Dict[str, Any], group_claim: str) -> List[str]:
|
||||
return []
|
||||
|
||||
|
||||
def extract_user_info(id_token: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def extract_user_info(id_token: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Extract user info from OIDC ID token claims.
|
||||
|
||||
Returns a dict with keys: oidc_subject, username, email, display_name.
|
||||
@@ -47,11 +50,12 @@ def extract_user_info(id_token: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
def provision_oidc_user(
|
||||
db: UserDB,
|
||||
user_info: Dict[str, Any],
|
||||
is_admin: Optional[bool] = None,
|
||||
user_info: dict[str, Any],
|
||||
*,
|
||||
is_admin: bool | None = None,
|
||||
allow_email_link: bool = False,
|
||||
allow_create: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
) -> dict[str, Any] | None:
|
||||
"""Create or update a user from OIDC claims.
|
||||
|
||||
Matching and collision handling use the shared external user linker:
|
||||
|
||||
@@ -4,12 +4,12 @@ Registers /api/auth/oidc/login and /api/auth/oidc/callback endpoints.
|
||||
Business logic remains in oidc_auth.py.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||
|
||||
from authlib.jose.errors import InvalidClaimError
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
from flask import Flask, jsonify, redirect, request, session
|
||||
from authlib.jose.errors import InvalidClaimError
|
||||
from flask import Flask, Response, jsonify, redirect, request, session
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
@@ -18,15 +18,17 @@ from shelfmark.core.oidc_auth import (
|
||||
parse_group_claims,
|
||||
provision_oidc_user,
|
||||
)
|
||||
from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
oauth = OAuth()
|
||||
_RETURN_TO_SESSION_KEY = "oidc_return_to"
|
||||
|
||||
|
||||
def _normalize_claims(raw_claims: Any) -> dict[str, Any]:
|
||||
def _normalize_claims(raw_claims: object) -> dict[str, Any]:
|
||||
"""Return a plain dict for claims from Authlib token/userinfo payloads."""
|
||||
if raw_claims is None:
|
||||
return {}
|
||||
@@ -60,7 +62,7 @@ def _login_error_url(message: str) -> str:
|
||||
return f"{login_url}?{urlencode(params)}"
|
||||
|
||||
|
||||
def _normalize_return_to(raw_return_to: Any) -> str | None:
|
||||
def _normalize_return_to(raw_return_to: object) -> str | None:
|
||||
"""Return a safe app-relative post-login target."""
|
||||
if not isinstance(raw_return_to, str):
|
||||
return None
|
||||
@@ -79,14 +81,9 @@ def _normalize_return_to(raw_return_to: Any) -> str | None:
|
||||
if path == script_root:
|
||||
path = "/"
|
||||
elif path.startswith(f"{script_root}/"):
|
||||
path = path[len(script_root):] or "/"
|
||||
path = path[len(script_root) :] or "/"
|
||||
|
||||
if (
|
||||
path == "/login"
|
||||
or path.startswith("/login/")
|
||||
or path == "/api"
|
||||
or path.startswith("/api/")
|
||||
):
|
||||
if path in {"/login", "/api"} or path.startswith(("/login/", "/api/")):
|
||||
return None
|
||||
|
||||
return urlunsplit(("", "", path, parsed.query, parsed.fragment))
|
||||
@@ -95,9 +92,7 @@ def _normalize_return_to(raw_return_to: Any) -> str | None:
|
||||
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)
|
||||
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:
|
||||
@@ -122,18 +117,21 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
|
||||
client_id = str(app_config.get("OIDC_CLIENT_ID", "") or "")
|
||||
|
||||
if not discovery_url or not client_id:
|
||||
raise ValueError("OIDC not configured")
|
||||
msg = "OIDC not configured"
|
||||
raise ValueError(msg)
|
||||
|
||||
configured_scopes = app_config.get("OIDC_SCOPES", ["openid", "email", "profile"])
|
||||
if isinstance(configured_scopes, list):
|
||||
scope_values = [str(scope).strip() for scope in configured_scopes if str(scope).strip()]
|
||||
elif isinstance(configured_scopes, str):
|
||||
delimiter = "," if "," in configured_scopes else " "
|
||||
scope_values = [scope.strip() for scope in configured_scopes.split(delimiter) if scope.strip()]
|
||||
scope_values = [
|
||||
scope.strip() for scope in configured_scopes.split(delimiter) if scope.strip()
|
||||
]
|
||||
else:
|
||||
scope_values = []
|
||||
|
||||
scopes = list(dict.fromkeys(["openid"] + scope_values))
|
||||
scopes = list(dict.fromkeys(["openid", *scope_values]))
|
||||
|
||||
admin_group = app_config.get("OIDC_ADMIN_GROUP", "")
|
||||
group_claim = app_config.get("OIDC_GROUP_CLAIM", "groups")
|
||||
@@ -141,7 +139,7 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
|
||||
if admin_group and use_admin_group and group_claim and group_claim not in scopes:
|
||||
scopes.append(group_claim)
|
||||
|
||||
def _ssl_compliance_fix(session, **kwargs):
|
||||
def _ssl_compliance_fix(session: Any, **kwargs: Any) -> Any:
|
||||
"""Set session.verify based on the Certificate Validation setting."""
|
||||
session.verify = get_ssl_verify(discovery_url)
|
||||
return session
|
||||
@@ -162,7 +160,8 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
|
||||
|
||||
client = oauth.create_client("shelfmark_idp")
|
||||
if client is None:
|
||||
raise RuntimeError("OIDC client initialization failed")
|
||||
msg = "OIDC client initialization failed"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
return client, {
|
||||
"OIDC_DISCOVERY_URL": discovery_url,
|
||||
@@ -178,7 +177,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
oauth.init_app(app)
|
||||
|
||||
@app.route("/api/auth/oidc/login", methods=["GET"])
|
||||
def oidc_login():
|
||||
def oidc_login() -> Response | tuple[Response, int]:
|
||||
"""Initiate OIDC login flow and redirect to the provider."""
|
||||
try:
|
||||
client, _ = _get_oidc_client()
|
||||
@@ -191,17 +190,17 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
return client.authorize_redirect(redirect_uri)
|
||||
except ValueError:
|
||||
return jsonify({"error": "OIDC not configured"}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"OIDC login error: {e}")
|
||||
except Exception:
|
||||
logger.exception("OIDC login error")
|
||||
return jsonify({"error": "OIDC login failed"}), 500
|
||||
|
||||
@app.route("/api/auth/oidc/callback", methods=["GET"])
|
||||
def oidc_callback():
|
||||
def oidc_callback() -> Response | tuple[Response, int]:
|
||||
"""Handle OIDC callback from identity provider."""
|
||||
try:
|
||||
error = request.args.get("error")
|
||||
if error:
|
||||
logger.warning(f"OIDC callback error from IdP: {error}")
|
||||
logger.warning("OIDC callback error from IdP: %s", error)
|
||||
return redirect(_login_error_url("Authentication failed"))
|
||||
|
||||
client, config = _get_oidc_client()
|
||||
@@ -216,12 +215,14 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
if isinstance(metadata, dict):
|
||||
provider_issuer = str(metadata.get("issuer", ""))
|
||||
except Exception as metadata_error:
|
||||
logger.debug(f"OIDC metadata lookup failed during claim diagnostics: {metadata_error}")
|
||||
logger.debug(
|
||||
"OIDC metadata lookup failed during claim diagnostics: %s",
|
||||
metadata_error,
|
||||
)
|
||||
|
||||
logger.error(
|
||||
"OIDC callback claim validation failed: claim=%s error=%s discovery_url=%s provider_issuer=%s",
|
||||
logger.exception(
|
||||
"OIDC callback claim validation failed: claim=%s discovery_url=%s provider_issuer=%s",
|
||||
claim_name,
|
||||
e,
|
||||
discovery_url or "<unset>",
|
||||
provider_issuer or "<unknown>",
|
||||
)
|
||||
@@ -232,7 +233,9 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
)
|
||||
return redirect(_login_error_url(msg))
|
||||
|
||||
return redirect(_login_error_url(f"OIDC token claim validation failed: {claim_name}"))
|
||||
return redirect(
|
||||
_login_error_url(f"OIDC token claim validation failed: {claim_name}")
|
||||
)
|
||||
claims = _normalize_claims(token.get("userinfo"))
|
||||
|
||||
# If userinfo is missing or claims are too sparse, request it explicitly.
|
||||
@@ -242,8 +245,8 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
fetched_claims = _normalize_claims(client.userinfo(token=token))
|
||||
except TypeError:
|
||||
fetched_claims = _normalize_claims(client.userinfo())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch OIDC userinfo: {e}")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch OIDC userinfo")
|
||||
if fetched_claims:
|
||||
claims = {**claims, **fetched_claims}
|
||||
|
||||
@@ -274,7 +277,8 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
)
|
||||
if user is None:
|
||||
logger.warning(
|
||||
f"OIDC login rejected: auto-provision disabled for {user_info['username']}"
|
||||
"OIDC login rejected: auto-provision disabled for %s",
|
||||
user_info["username"],
|
||||
)
|
||||
return redirect(_login_error_url("Account not found. Contact your administrator."))
|
||||
|
||||
@@ -283,12 +287,12 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
session["db_user_id"] = user["id"]
|
||||
session.permanent = True
|
||||
|
||||
logger.info(f"OIDC login successful: {user['username']} (admin={is_admin})")
|
||||
logger.info("OIDC login successful: %s (admin=%s)", user["username"], is_admin)
|
||||
return redirect(_post_login_redirect_target(_get_pending_return_to(clear=True)))
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"OIDC callback error: {e}")
|
||||
logger.exception("OIDC callback error")
|
||||
return redirect(_login_error_url(str(e)))
|
||||
except Exception as e:
|
||||
logger.error(f"OIDC callback error: {e}")
|
||||
except Exception:
|
||||
logger.exception("OIDC callback error")
|
||||
return redirect(_login_error_url("Authentication failed"))
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
Onboarding wizard configuration.
|
||||
"""Onboarding wizard configuration.
|
||||
|
||||
Defines the steps and fields for the first-run onboarding experience.
|
||||
Reuses field definitions from the settings registry where possible.
|
||||
@@ -8,16 +7,16 @@ Reuses field definitions from the settings registry where possible.
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.settings_registry import (
|
||||
HeadingField,
|
||||
SettingsField,
|
||||
get_settings_tab,
|
||||
serialize_field,
|
||||
save_config_file,
|
||||
get_setting_value,
|
||||
get_settings_tab,
|
||||
save_config_file,
|
||||
serialize_field,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -29,6 +28,7 @@ ONBOARDING_STORAGE_KEY = "onboarding_complete"
|
||||
def _get_config_dir() -> Path:
|
||||
"""Get the config directory path."""
|
||||
from shelfmark.config.env import CONFIG_DIR
|
||||
|
||||
return Path(CONFIG_DIR)
|
||||
|
||||
|
||||
@@ -45,11 +45,11 @@ def is_onboarding_complete() -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
with config_file.open() as f:
|
||||
config = json.load(f)
|
||||
return config.get(ONBOARDING_STORAGE_KEY, False)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning(f"Could not read onboarding status from settings.json: {e}")
|
||||
logger.warning("Could not read onboarding status from settings.json: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
@@ -57,14 +57,13 @@ def mark_onboarding_complete() -> bool:
|
||||
"""Mark onboarding as complete."""
|
||||
try:
|
||||
return save_config_file("general", {ONBOARDING_STORAGE_KEY: True})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mark onboarding complete: {e}")
|
||||
except Exception:
|
||||
logger.exception("Failed to mark onboarding complete")
|
||||
return False
|
||||
|
||||
|
||||
def _get_field_from_tab(tab_name: str, field_key: str) -> Optional[SettingsField]:
|
||||
"""
|
||||
Extract a specific field from a registered settings tab.
|
||||
def _get_field_from_tab(tab_name: str, field_key: str) -> SettingsField | None:
|
||||
"""Extract a specific field from a registered settings tab.
|
||||
|
||||
Args:
|
||||
tab_name: Name of the settings tab (e.g., 'search_mode', 'hardcover')
|
||||
@@ -72,23 +71,23 @@ def _get_field_from_tab(tab_name: str, field_key: str) -> Optional[SettingsField
|
||||
|
||||
Returns:
|
||||
The field if found, None otherwise
|
||||
|
||||
"""
|
||||
tab = get_settings_tab(tab_name)
|
||||
if not tab:
|
||||
logger.warning(f"Settings tab not found: {tab_name}")
|
||||
logger.warning("Settings tab not found: %s", tab_name)
|
||||
return None
|
||||
|
||||
for field in tab.fields:
|
||||
if hasattr(field, 'key') and field.key == field_key:
|
||||
if hasattr(field, "key") and field.key == field_key:
|
||||
return field
|
||||
|
||||
logger.warning(f"Field {field_key} not found in tab {tab_name}")
|
||||
logger.warning("Field %s not found in tab %s", field_key, tab_name)
|
||||
return None
|
||||
|
||||
|
||||
def _clone_field_with_overrides(field: SettingsField, **overrides) -> SettingsField:
|
||||
"""
|
||||
Clone a field with optional attribute overrides.
|
||||
"""Clone a field with optional attribute overrides.
|
||||
|
||||
Useful for customizing labels, descriptions, or defaults for onboarding context.
|
||||
"""
|
||||
@@ -100,9 +99,9 @@ def _clone_field_with_overrides(field: SettingsField, **overrides) -> SettingsFi
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_search_mode_fields() -> List[SettingsField]:
|
||||
def get_search_mode_fields() -> list[SettingsField]:
|
||||
"""Step 1: Choose search mode - uses actual SEARCH_MODE field from settings."""
|
||||
fields: List[SettingsField] = [
|
||||
fields: list[SettingsField] = [
|
||||
HeadingField(
|
||||
key="welcome_heading",
|
||||
title="Welcome to Shelfmark",
|
||||
@@ -114,17 +113,19 @@ def get_search_mode_fields() -> List[SettingsField]:
|
||||
search_mode_field = _get_field_from_tab("search_mode", "SEARCH_MODE")
|
||||
if search_mode_field:
|
||||
# Clone with onboarding-specific description
|
||||
fields.append(_clone_field_with_overrides(
|
||||
search_mode_field,
|
||||
description="Choose how you want to find books.",
|
||||
))
|
||||
fields.append(
|
||||
_clone_field_with_overrides(
|
||||
search_mode_field,
|
||||
description="Choose how you want to find books.",
|
||||
)
|
||||
)
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def get_metadata_provider_fields() -> List[SettingsField]:
|
||||
def get_metadata_provider_fields() -> list[SettingsField]:
|
||||
"""Step 2: Choose metadata provider - uses actual METADATA_PROVIDER field."""
|
||||
fields: List[SettingsField] = [
|
||||
fields: list[SettingsField] = [
|
||||
HeadingField(
|
||||
key="metadata_heading",
|
||||
title="Metadata Provider",
|
||||
@@ -155,18 +156,20 @@ def get_metadata_provider_fields() -> List[SettingsField]:
|
||||
]
|
||||
|
||||
# Clone with onboarding-specific options and default
|
||||
fields.append(_clone_field_with_overrides(
|
||||
provider_field,
|
||||
default="hardcover",
|
||||
options=onboarding_options,
|
||||
))
|
||||
fields.append(
|
||||
_clone_field_with_overrides(
|
||||
provider_field,
|
||||
default="hardcover",
|
||||
options=onboarding_options,
|
||||
)
|
||||
)
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def get_hardcover_setup_fields() -> List[SettingsField]:
|
||||
def get_hardcover_setup_fields() -> list[SettingsField]:
|
||||
"""Step 3a: Configure Hardcover - uses actual API key and test connection fields."""
|
||||
fields: List[SettingsField] = [
|
||||
fields: list[SettingsField] = [
|
||||
HeadingField(
|
||||
key="hardcover_setup_heading",
|
||||
title="Hardcover Setup",
|
||||
@@ -189,9 +192,9 @@ def get_hardcover_setup_fields() -> List[SettingsField]:
|
||||
return fields
|
||||
|
||||
|
||||
def get_googlebooks_setup_fields() -> List[SettingsField]:
|
||||
def get_googlebooks_setup_fields() -> list[SettingsField]:
|
||||
"""Step 3b: Configure Google Books - uses actual API key and test connection fields."""
|
||||
fields: List[SettingsField] = [
|
||||
fields: list[SettingsField] = [
|
||||
HeadingField(
|
||||
key="googlebooks_setup_heading",
|
||||
title="Google Books Setup",
|
||||
@@ -214,9 +217,9 @@ def get_googlebooks_setup_fields() -> List[SettingsField]:
|
||||
return fields
|
||||
|
||||
|
||||
def get_prowlarr_fields() -> List[SettingsField]:
|
||||
def get_prowlarr_fields() -> list[SettingsField]:
|
||||
"""Step 4: Configure Prowlarr connection - uses actual Prowlarr fields."""
|
||||
fields: List[SettingsField] = [
|
||||
fields: list[SettingsField] = [
|
||||
HeadingField(
|
||||
key="prowlarr_heading",
|
||||
title="Prowlarr Integration (Optional)",
|
||||
@@ -234,9 +237,9 @@ def get_prowlarr_fields() -> List[SettingsField]:
|
||||
return fields
|
||||
|
||||
|
||||
def get_prowlarr_indexers_fields() -> List[SettingsField]:
|
||||
def get_prowlarr_indexers_fields() -> list[SettingsField]:
|
||||
"""Step 5: Select Prowlarr indexers to search."""
|
||||
fields: List[SettingsField] = [
|
||||
fields: list[SettingsField] = [
|
||||
HeadingField(
|
||||
key="prowlarr_indexers_heading",
|
||||
title="Select Indexers",
|
||||
@@ -316,10 +319,8 @@ ONBOARDING_STEPS = [
|
||||
]
|
||||
|
||||
|
||||
def get_onboarding_config() -> Dict[str, Any]:
|
||||
"""
|
||||
Get the full onboarding configuration including steps and current values.
|
||||
"""
|
||||
def get_onboarding_config() -> dict[str, Any]:
|
||||
"""Get the full onboarding configuration including steps and current values."""
|
||||
steps = []
|
||||
all_values = {}
|
||||
|
||||
@@ -334,9 +335,11 @@ def get_onboarding_config() -> Dict[str, Any]:
|
||||
serialized_fields.append(serialized)
|
||||
|
||||
# Collect values (skip HeadingFields)
|
||||
if hasattr(field, 'key') and field.key and not isinstance(field, HeadingField):
|
||||
if hasattr(field, "key") and field.key and not isinstance(field, HeadingField):
|
||||
value = get_setting_value(field, tab_name)
|
||||
all_values[field.key] = value if value is not None else getattr(field, 'default', '')
|
||||
all_values[field.key] = (
|
||||
value if value is not None else getattr(field, "default", "")
|
||||
)
|
||||
|
||||
step = {
|
||||
"id": step_config["id"],
|
||||
@@ -359,19 +362,19 @@ def get_onboarding_config() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Save onboarding settings and mark as complete.
|
||||
def save_onboarding_settings(values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Save onboarding settings and mark as complete.
|
||||
|
||||
Args:
|
||||
values: Dict of field key -> value
|
||||
|
||||
Returns:
|
||||
Dict with success status and message
|
||||
|
||||
"""
|
||||
try:
|
||||
# Group values by their target tab
|
||||
tab_values: Dict[str, Dict[str, Any]] = {}
|
||||
tab_values: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for step_config in ONBOARDING_STEPS:
|
||||
tab_name = step_config["tab"]
|
||||
@@ -391,7 +394,7 @@ def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
for tab_name, tab_data in tab_values.items():
|
||||
if tab_data:
|
||||
save_config_file(tab_name, tab_data)
|
||||
logger.info(f"Saved onboarding settings to {tab_name}: {list(tab_data.keys())}")
|
||||
logger.info("Saved onboarding settings to %s: %s", tab_name, list(tab_data.keys()))
|
||||
|
||||
# Enable the selected metadata provider
|
||||
search_mode = values.get("SEARCH_MODE", "direct")
|
||||
@@ -416,7 +419,11 @@ def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
provider_config["GOOGLEBOOKS_API_KEY"] = values["GOOGLEBOOKS_API_KEY"]
|
||||
|
||||
save_config_file(provider, provider_config)
|
||||
logger.info(f"Enabled metadata provider: {provider} with keys: {list(provider_config.keys())}")
|
||||
logger.info(
|
||||
"Enabled metadata provider: %s with keys: %s",
|
||||
provider,
|
||||
list(provider_config.keys()),
|
||||
)
|
||||
|
||||
# Mark onboarding as complete
|
||||
mark_onboarding_complete()
|
||||
@@ -424,12 +431,13 @@ def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Refresh config
|
||||
try:
|
||||
from shelfmark.core.config import config
|
||||
|
||||
config.refresh()
|
||||
except ImportError as e:
|
||||
logger.debug(f"Could not refresh config after onboarding: {e}")
|
||||
|
||||
return {"success": True, "message": "Onboarding complete!"}
|
||||
logger.debug("Could not refresh config after onboarding: %s", e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save onboarding settings: {e}")
|
||||
logger.exception("Failed to save onboarding settings")
|
||||
return {"success": False, "message": str(e)}
|
||||
else:
|
||||
return {"success": True, "message": "Onboarding complete!"}
|
||||
|
||||
@@ -11,7 +11,12 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
_WINDOWS_DRIVE_PREFIX_LENGTH = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -36,14 +41,14 @@ def _normalize_prefix(path: str) -> str:
|
||||
|
||||
def _is_windows_path(path: str) -> bool:
|
||||
"""Check if a path looks like a Windows path (has a drive letter like C:/)."""
|
||||
return len(path) >= 2 and path[1] == ":" and path[0].isalpha()
|
||||
return len(path) >= _WINDOWS_DRIVE_PREFIX_LENGTH and path[1] == ":" and path[0].isalpha()
|
||||
|
||||
|
||||
def _normalize_host(host: str) -> str:
|
||||
return str(host or "").strip().lower()
|
||||
|
||||
|
||||
def parse_remote_path_mappings(value: Any) -> list[RemotePathMapping]:
|
||||
def parse_remote_path_mappings(value: object) -> list[RemotePathMapping]:
|
||||
if not value or not isinstance(value, list):
|
||||
return []
|
||||
|
||||
@@ -60,7 +65,9 @@ def parse_remote_path_mappings(value: Any) -> list[RemotePathMapping]:
|
||||
if not host or not remote_path or not local_path:
|
||||
continue
|
||||
|
||||
mappings.append(RemotePathMapping(host=host, remote_path=remote_path, local_path=local_path))
|
||||
mappings.append(
|
||||
RemotePathMapping(host=host, remote_path=remote_path, local_path=local_path)
|
||||
)
|
||||
|
||||
mappings.sort(key=lambda m: len(m.remote_path), reverse=True)
|
||||
return mappings
|
||||
@@ -96,16 +103,17 @@ def remap_remote_to_local_with_match(
|
||||
prefix_lower = remote_prefix.lower()
|
||||
matches = remote_lower == prefix_lower or remote_lower.startswith(prefix_lower + "/")
|
||||
else:
|
||||
matches = remote_normalized == remote_prefix or remote_normalized.startswith(remote_prefix + "/")
|
||||
matches = remote_normalized == remote_prefix or remote_normalized.startswith(
|
||||
remote_prefix + "/"
|
||||
)
|
||||
|
||||
if matches:
|
||||
# Use the length of the original prefix to extract remainder
|
||||
# This preserves the original case in folder names
|
||||
remainder = remote_normalized[len(remote_prefix):]
|
||||
remainder = remote_normalized[len(remote_prefix) :]
|
||||
local_prefix = _normalize_prefix(mapping.local_path)
|
||||
|
||||
if remainder.startswith("/"):
|
||||
remainder = remainder[1:]
|
||||
remainder = remainder.removeprefix("/")
|
||||
|
||||
remapped = Path(local_prefix) / remainder if remainder else Path(local_prefix)
|
||||
return remapped, True
|
||||
@@ -113,7 +121,9 @@ def remap_remote_to_local_with_match(
|
||||
return Path(remote_normalized), False
|
||||
|
||||
|
||||
def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path) -> Path:
|
||||
def remap_remote_to_local(
|
||||
*, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path
|
||||
) -> Path:
|
||||
remapped, _ = remap_remote_to_local_with_match(
|
||||
mappings=mappings,
|
||||
host=host,
|
||||
@@ -122,13 +132,12 @@ def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, r
|
||||
return remapped
|
||||
|
||||
|
||||
def get_client_host_identifier(client: Any) -> Optional[str]:
|
||||
def get_client_host_identifier(client: object) -> str | None:
|
||||
"""Return a stable identifier used by the mapping UI.
|
||||
|
||||
Sonarr uses the download client's configured host. Shelfmark currently uses
|
||||
the download client 'name' (e.g. qbittorrent, sabnzbd).
|
||||
"""
|
||||
|
||||
name = getattr(client, "name", None)
|
||||
if isinstance(name, str) and name.strip():
|
||||
return name.strip().lower()
|
||||
|
||||
@@ -2,18 +2,26 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
|
||||
class PrefixMiddleware:
|
||||
"""Strip a configured URL prefix from PATH_INFO before routing."""
|
||||
|
||||
def __init__(self, app, prefix: str, bypass_paths: Optional[Iterable[str]] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
app: Callable[[dict[str, object], Callable[..., object]], object],
|
||||
prefix: str,
|
||||
bypass_paths: Iterable[str] | None = None,
|
||||
) -> None:
|
||||
self.app = app
|
||||
self.prefix = prefix.rstrip("/")
|
||||
self.bypass_paths = set(bypass_paths or [])
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
def __call__(self, environ: dict[str, object], start_response: Callable[..., object]) -> object:
|
||||
path = environ.get("PATH_INFO", "") or ""
|
||||
|
||||
if path in self.bypass_paths:
|
||||
@@ -24,7 +32,7 @@ class PrefixMiddleware:
|
||||
|
||||
if path == self.prefix or path.startswith(self.prefix + "/"):
|
||||
environ["SCRIPT_NAME"] = self.prefix
|
||||
environ["PATH_INFO"] = path[len(self.prefix):] or "/"
|
||||
environ["PATH_INFO"] = path[len(self.prefix) :] or "/"
|
||||
return self.app(environ, start_response)
|
||||
|
||||
start_response("404 Not Found", [("Content-Type", "text/plain")])
|
||||
|
||||
+89
-79
@@ -4,12 +4,20 @@ import queue
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Lock, Event
|
||||
from typing import Dict, List, Optional, Tuple, Any, Callable
|
||||
from threading import Event, Lock
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import QueueStatus, QueueItem, DownloadTask, TERMINAL_QUEUE_STATUSES
|
||||
from shelfmark.core.models import (
|
||||
TERMINAL_QUEUE_STATUSES,
|
||||
DownloadTask,
|
||||
QueueItem,
|
||||
QueueStatus,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -25,10 +33,8 @@ class BookQueue:
|
||||
self._status_timestamps: dict[str, datetime] = {} # Track when each status was last updated
|
||||
self._cancel_flags: dict[str, Event] = {} # Cancellation flags for active downloads
|
||||
self._active_downloads: dict[str, bool] = {} # Track currently downloading tasks
|
||||
self._terminal_status_hook: Optional[
|
||||
Callable[[str, QueueStatus, DownloadTask], None]
|
||||
] = None
|
||||
self._queue_hook: Optional[Callable[[str, DownloadTask], None]] = None
|
||||
self._terminal_status_hook: Callable[[str, QueueStatus, DownloadTask], None] | None = None
|
||||
self._queue_hook: Callable[[str, DownloadTask], None] | None = None
|
||||
|
||||
@property
|
||||
def _status_timeout(self) -> timedelta:
|
||||
@@ -37,12 +43,15 @@ 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
|
||||
hook: Callable[[str, DownloadTask], None] | None = None
|
||||
with self._lock:
|
||||
task_id = task.task_id
|
||||
|
||||
# Don't add if already exists and not in error/cancelled state
|
||||
if task_id in self._status and self._status[task_id] not in [QueueStatus.ERROR, QueueStatus.CANCELLED]:
|
||||
if task_id in self._status and self._status[task_id] not in [
|
||||
QueueStatus.ERROR,
|
||||
QueueStatus.CANCELLED,
|
||||
]:
|
||||
return False
|
||||
|
||||
# Ensure added_time is set
|
||||
@@ -62,7 +71,7 @@ class BookQueue:
|
||||
logger.warning("Queue hook failed while adding task %s: %s", task_id, exc)
|
||||
return True
|
||||
|
||||
def get_next(self) -> Optional[Tuple[str, Event]]:
|
||||
def get_next(self) -> tuple[str, Event] | None:
|
||||
"""Get next task ID from queue with cancellation flag."""
|
||||
# Use iterative approach to avoid stack overflow if many items are cancelled
|
||||
while True:
|
||||
@@ -79,17 +88,17 @@ class BookQueue:
|
||||
cancel_flag = Event()
|
||||
self._cancel_flags[task_id] = cancel_flag
|
||||
self._active_downloads[task_id] = True
|
||||
|
||||
return task_id, cancel_flag
|
||||
except queue.Empty:
|
||||
return None
|
||||
else:
|
||||
return task_id, cancel_flag
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[DownloadTask]:
|
||||
def get_task(self, task_id: str) -> DownloadTask | None:
|
||||
"""Get a task by its ID."""
|
||||
with self._lock:
|
||||
return self._task_data.get(task_id)
|
||||
|
||||
def get_task_status(self, task_id: str) -> Optional[QueueStatus]:
|
||||
def get_task_status(self, task_id: str) -> QueueStatus | None:
|
||||
"""Get queue status for a task id."""
|
||||
with self._lock:
|
||||
return self._status.get(task_id)
|
||||
@@ -101,7 +110,7 @@ class BookQueue:
|
||||
|
||||
def set_terminal_status_hook(
|
||||
self,
|
||||
hook: Optional[Callable[[str, QueueStatus, DownloadTask], None]],
|
||||
hook: Callable[[str, QueueStatus, DownloadTask], None] | None,
|
||||
) -> None:
|
||||
"""Register a callback invoked when a task first enters a terminal status."""
|
||||
with self._lock:
|
||||
@@ -109,7 +118,7 @@ class BookQueue:
|
||||
|
||||
def set_queue_hook(
|
||||
self,
|
||||
hook: Optional[Callable[[str, DownloadTask], None]],
|
||||
hook: Callable[[str, DownloadTask], None] | None,
|
||||
) -> None:
|
||||
"""Register a callback invoked when a task is added to the queue."""
|
||||
with self._lock:
|
||||
@@ -117,8 +126,8 @@ class BookQueue:
|
||||
|
||||
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
|
||||
hook_task: Optional[DownloadTask] = None
|
||||
hook: Callable[[str, QueueStatus, DownloadTask], None] | None = None
|
||||
hook_task: DownloadTask | None = None
|
||||
with self._lock:
|
||||
previous_status = self._status.get(book_id)
|
||||
self._update_status(book_id, status)
|
||||
@@ -159,16 +168,19 @@ class BookQueue:
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].status_message = message
|
||||
|
||||
def get_status(self, user_id: Optional[int] = None) -> Dict[QueueStatus, Dict[str, DownloadTask]]:
|
||||
def get_status(self, user_id: int | None = None) -> dict[QueueStatus, dict[str, DownloadTask]]:
|
||||
"""Get current queue status grouped by status.
|
||||
|
||||
Args:
|
||||
user_id: If provided, only return tasks belonging to this user.
|
||||
If None, return all.
|
||||
|
||||
"""
|
||||
self.refresh()
|
||||
with self._lock:
|
||||
result: Dict[QueueStatus, Dict[str, DownloadTask]] = {status: {} for status in QueueStatus}
|
||||
result: dict[QueueStatus, dict[str, DownloadTask]] = {
|
||||
status: {} for status in QueueStatus
|
||||
}
|
||||
for task_id, status in self._status.items():
|
||||
if task_id in self._task_data:
|
||||
task = self._task_data[task_id]
|
||||
@@ -177,7 +189,7 @@ class BookQueue:
|
||||
result[status][task_id] = task
|
||||
return result
|
||||
|
||||
def get_queue_order(self) -> List[Dict[str, Any]]:
|
||||
def get_queue_order(self) -> list[dict[str, Any]]:
|
||||
"""Get current queue order for display."""
|
||||
with self._lock:
|
||||
queue_items = []
|
||||
@@ -185,39 +197,42 @@ class BookQueue:
|
||||
# Get items from priority queue without removing them
|
||||
temp_items = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
temp_items.append(item)
|
||||
task_id = item.book_id # QueueItem uses book_id as the ID field
|
||||
if task_id in self._task_data:
|
||||
task = self._task_data[task_id]
|
||||
queue_items.append({
|
||||
'id': task_id,
|
||||
'title': task.title,
|
||||
'author': task.author,
|
||||
'priority': item.priority,
|
||||
'added_time': item.added_time,
|
||||
'status': self._status.get(task_id, QueueStatus.QUEUED)
|
||||
})
|
||||
except queue.Empty:
|
||||
break
|
||||
item = self._queue.get_nowait()
|
||||
temp_items.append(item)
|
||||
task_id = item.book_id # QueueItem uses book_id as the ID field
|
||||
if task_id in self._task_data:
|
||||
task = self._task_data[task_id]
|
||||
queue_items.append(
|
||||
{
|
||||
"id": task_id,
|
||||
"title": task.title,
|
||||
"author": task.author,
|
||||
"priority": item.priority,
|
||||
"added_time": item.added_time,
|
||||
"status": self._status.get(task_id, QueueStatus.QUEUED),
|
||||
}
|
||||
)
|
||||
|
||||
# Put items back in queue
|
||||
for item in temp_items:
|
||||
self._queue.put(item)
|
||||
|
||||
return sorted(queue_items, key=lambda x: (x['priority'], x['added_time']))
|
||||
return sorted(queue_items, key=lambda x: (x["priority"], x["added_time"]))
|
||||
|
||||
def cancel_download(self, task_id: str) -> bool:
|
||||
"""Cancel an active or queued download."""
|
||||
with self._lock:
|
||||
current_status = self._status.get(task_id)
|
||||
|
||||
if current_status in [QueueStatus.RESOLVING, QueueStatus.LOCATING, QueueStatus.DOWNLOADING]:
|
||||
if current_status in [
|
||||
QueueStatus.RESOLVING,
|
||||
QueueStatus.LOCATING,
|
||||
QueueStatus.DOWNLOADING,
|
||||
]:
|
||||
# Signal active download to stop
|
||||
if task_id in self._cancel_flags:
|
||||
self._cancel_flags[task_id].set()
|
||||
elif current_status not in [QueueStatus.QUEUED]:
|
||||
elif current_status != QueueStatus.QUEUED:
|
||||
# Not in a cancellable state
|
||||
return False
|
||||
|
||||
@@ -235,20 +250,17 @@ class BookQueue:
|
||||
found = False
|
||||
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
if item.book_id == task_id: # QueueItem uses book_id as the ID field
|
||||
# Create new item with updated priority
|
||||
new_item = QueueItem(task_id, new_priority, item.added_time)
|
||||
temp_items.append(new_item)
|
||||
found = True
|
||||
# Update task data priority
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].priority = new_priority
|
||||
else:
|
||||
temp_items.append(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
item = self._queue.get_nowait()
|
||||
if item.book_id == task_id: # QueueItem uses book_id as the ID field
|
||||
# Create new item with updated priority
|
||||
new_item = QueueItem(task_id, new_priority, item.added_time)
|
||||
temp_items.append(new_item)
|
||||
found = True
|
||||
# Update task data priority
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].priority = new_priority
|
||||
else:
|
||||
temp_items.append(item)
|
||||
|
||||
# Put all items back
|
||||
for item in temp_items:
|
||||
@@ -256,13 +268,13 @@ class BookQueue:
|
||||
|
||||
return found
|
||||
|
||||
def enqueue_existing(self, task_id: str, *, priority: Optional[int] = None) -> bool:
|
||||
def enqueue_existing(self, task_id: str, *, priority: int | None = None) -> bool:
|
||||
"""Requeue an existing task regardless of current status.
|
||||
|
||||
This is used for retries where task metadata should be preserved.
|
||||
"""
|
||||
hook: Optional[Callable[[str, DownloadTask], None]] = None
|
||||
hook_task: Optional[DownloadTask] = None
|
||||
hook: Callable[[str, DownloadTask], None] | None = None
|
||||
hook_task: DownloadTask | None = None
|
||||
with self._lock:
|
||||
task = self._task_data.get(task_id)
|
||||
if task is None:
|
||||
@@ -278,10 +290,7 @@ class BookQueue:
|
||||
# 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
|
||||
item = self._queue.get_nowait()
|
||||
if item.book_id != task_id:
|
||||
temp_items.append(item)
|
||||
|
||||
@@ -301,25 +310,22 @@ class BookQueue:
|
||||
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:
|
||||
def reorder_queue(self, task_priorities: dict[str, int]) -> bool:
|
||||
"""Bulk reorder queue by mapping task_id to new priority."""
|
||||
with self._lock:
|
||||
# Extract all items from queue
|
||||
all_items = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
task_id = item.book_id # QueueItem uses book_id as the ID field
|
||||
# Update priority if specified
|
||||
if task_id in task_priorities:
|
||||
new_priority = task_priorities[task_id]
|
||||
item = QueueItem(task_id, new_priority, item.added_time)
|
||||
# Update task data priority
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].priority = new_priority
|
||||
all_items.append(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
item = self._queue.get_nowait()
|
||||
task_id = item.book_id # QueueItem uses book_id as the ID field
|
||||
# Update priority if specified
|
||||
if task_id in task_priorities:
|
||||
new_priority = task_priorities[task_id]
|
||||
item = QueueItem(task_id, new_priority, item.added_time)
|
||||
# Update task data priority
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].priority = new_priority
|
||||
all_items.append(item)
|
||||
|
||||
# Put all items back with updated priorities
|
||||
for item in all_items:
|
||||
@@ -327,7 +333,7 @@ class BookQueue:
|
||||
|
||||
return True
|
||||
|
||||
def get_active_downloads(self) -> List[str]:
|
||||
def get_active_downloads(self) -> list[str]:
|
||||
"""Get list of currently active download task IDs."""
|
||||
with self._lock:
|
||||
return list(self._active_downloads.keys())
|
||||
@@ -357,9 +363,12 @@ class BookQueue:
|
||||
|
||||
# Check for stale status entries
|
||||
last_update = self._status_timestamps.get(task_id)
|
||||
if last_update and (current_time - last_update) > self._status_timeout:
|
||||
if status in terminal_statuses:
|
||||
to_remove.append(task_id)
|
||||
if (
|
||||
last_update
|
||||
and (current_time - last_update) > self._status_timeout
|
||||
and status in terminal_statuses
|
||||
):
|
||||
to_remove.append(task_id)
|
||||
|
||||
# Remove stale entries
|
||||
for task_id in to_remove:
|
||||
@@ -367,5 +376,6 @@ class BookQueue:
|
||||
self._status_timestamps.pop(task_id, None)
|
||||
self._task_data.pop(task_id, None)
|
||||
|
||||
|
||||
# Global instance of BookQueue
|
||||
book_queue = BookQueue()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
@@ -13,11 +13,11 @@ _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")
|
||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def emit_ws_event(
|
||||
ws_manager: Any,
|
||||
ws_manager: object,
|
||||
*,
|
||||
event_name: str,
|
||||
payload: dict[str, Any],
|
||||
@@ -33,20 +33,22 @@ def emit_ws_event(
|
||||
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)
|
||||
_logger.warning(
|
||||
"Failed to emit WebSocket event '%s' to room '%s': %s",
|
||||
event_name,
|
||||
room,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def load_users_request_policy_settings() -> dict[str, Any]:
|
||||
"""Load global request-policy settings from the users config file."""
|
||||
from shelfmark.core.request_policy import REQUEST_POLICY_KEYS
|
||||
|
||||
return {
|
||||
key: app_config.get(key)
|
||||
for key in REQUEST_POLICY_KEYS
|
||||
}
|
||||
return {key: app_config.get(key) for key in REQUEST_POLICY_KEYS}
|
||||
|
||||
|
||||
def coerce_bool(value: Any, default: bool = False) -> bool:
|
||||
def coerce_bool(value: object, *, default: bool = False) -> bool:
|
||||
"""Coerce arbitrary values into booleans with string-friendly semantics."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
@@ -61,24 +63,24 @@ def coerce_bool(value: Any, default: bool = False) -> bool:
|
||||
return bool(value)
|
||||
|
||||
|
||||
def get_session_db_user_id(session_obj: Any) -> int | None:
|
||||
def get_session_db_user_id(session_obj: object) -> int | None:
|
||||
"""Extract and coerce `db_user_id` from a Flask session to ``int | None``."""
|
||||
raw = session_obj.get("db_user_id") if session_obj is not None else None
|
||||
try:
|
||||
return int(raw) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def coerce_int(value: Any, default: int) -> int:
|
||||
def coerce_int(value: object, default: int) -> int:
|
||||
"""Best-effort integer coercion with fallback to default."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def normalize_optional_text(value: Any) -> str | None:
|
||||
def normalize_optional_text(value: object) -> str | None:
|
||||
"""Return a trimmed string or None for empty/non-string input."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
@@ -86,16 +88,16 @@ def normalize_optional_text(value: Any) -> str | None:
|
||||
return normalized or None
|
||||
|
||||
|
||||
def normalize_positive_int(value: Any) -> int | None:
|
||||
def normalize_positive_int(value: object) -> int | None:
|
||||
"""Parse *value* as a positive integer, returning ``None`` on failure."""
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
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:
|
||||
def normalize_optional_positive_int(value: object, field_name: str = "value") -> int | None:
|
||||
"""Parse *value* as a positive integer or ``None``.
|
||||
|
||||
Raises ``ValueError`` when *value* is present but not a valid
|
||||
@@ -106,13 +108,15 @@ def normalize_optional_positive_int(value: Any, field_name: str = "value") -> in
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field_name} must be a positive integer when provided") from exc
|
||||
msg = f"{field_name} must be a positive integer when provided"
|
||||
raise ValueError(msg) from exc
|
||||
if parsed < 1:
|
||||
raise ValueError(f"{field_name} must be a positive integer when provided")
|
||||
msg = f"{field_name} must be a positive integer when provided"
|
||||
raise ValueError(msg)
|
||||
return parsed
|
||||
|
||||
|
||||
def populate_request_usernames(rows: list[dict[str, Any]], user_db: Any) -> None:
|
||||
def populate_request_usernames(rows: list[dict[str, Any]], user_db: object) -> None:
|
||||
"""Add 'username' to each request row by looking up user_id."""
|
||||
cache: dict[int, str] = {}
|
||||
for row in rows:
|
||||
@@ -123,7 +127,7 @@ def populate_request_usernames(rows: list[dict[str, Any]], user_db: Any) -> None
|
||||
row["username"] = cache[requester_id]
|
||||
|
||||
|
||||
def extract_release_source_id(release_data: Any) -> str | None:
|
||||
def extract_release_source_id(release_data: object) -> str | None:
|
||||
"""Extract and normalize release_data.source_id."""
|
||||
if not isinstance(release_data, dict):
|
||||
return None
|
||||
|
||||
@@ -6,11 +6,12 @@ routes/services and tested independently.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class PolicyMode(str, Enum):
|
||||
class PolicyMode(StrEnum):
|
||||
"""Allowed request-policy modes.
|
||||
|
||||
Ordered from most to least permissive. The content-type default acts as a
|
||||
@@ -33,7 +34,9 @@ _MODE_PERMISSIVENESS: dict[PolicyMode, int] = {
|
||||
}
|
||||
|
||||
# Modes allowed in REQUEST_POLICY_RULES matrix rows.
|
||||
MATRIX_ALLOWED_MODES = frozenset({PolicyMode.DOWNLOAD, PolicyMode.REQUEST_RELEASE, PolicyMode.BLOCKED})
|
||||
MATRIX_ALLOWED_MODES = frozenset(
|
||||
{PolicyMode.DOWNLOAD, PolicyMode.REQUEST_RELEASE, PolicyMode.BLOCKED}
|
||||
)
|
||||
|
||||
|
||||
def cap_mode(mode: PolicyMode, ceiling: PolicyMode) -> PolicyMode:
|
||||
@@ -48,6 +51,7 @@ def _source_results_are_releases(source: Any) -> bool:
|
||||
if normalized_source in {"", "*"}:
|
||||
return False
|
||||
from shelfmark.release_sources import source_results_are_releases
|
||||
|
||||
return source_results_are_releases(normalized_source)
|
||||
|
||||
|
||||
@@ -105,7 +109,9 @@ def merge_request_policy_settings(
|
||||
(source, content_type): (source, content_type, mode)
|
||||
for source, content_type, mode in global_rules
|
||||
}
|
||||
for source, content_type, mode in _iter_rules(user_filtered.get("REQUEST_POLICY_RULES", [])):
|
||||
for source, content_type, mode in _iter_rules(
|
||||
user_filtered.get("REQUEST_POLICY_RULES", [])
|
||||
):
|
||||
merged_rules[(source, content_type)] = (source, content_type, mode)
|
||||
merged["REQUEST_POLICY_RULES"] = [
|
||||
{"source": source, "content_type": content_type, "mode": mode.value}
|
||||
@@ -181,7 +187,7 @@ def get_source_content_type_capabilities() -> dict[str, set[str]]:
|
||||
"""Return source -> supported content type map from registered sources."""
|
||||
try:
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
except Exception:
|
||||
except ImportError:
|
||||
return {}
|
||||
|
||||
capabilities: dict[str, set[str]] = {}
|
||||
@@ -219,9 +225,15 @@ def validate_policy_rules(
|
||||
- known source names
|
||||
- source/content-type compatibility from source declarations
|
||||
"""
|
||||
capabilities = source_capabilities if source_capabilities is not None else get_source_content_type_capabilities()
|
||||
capabilities = (
|
||||
source_capabilities
|
||||
if source_capabilities is not None
|
||||
else get_source_content_type_capabilities()
|
||||
)
|
||||
normalized_capabilities = {
|
||||
normalize_source(source): {normalize_content_type(content_type) for content_type in content_types}
|
||||
normalize_source(source): {
|
||||
normalize_content_type(content_type) for content_type in content_types
|
||||
}
|
||||
for source, content_types in capabilities.items()
|
||||
}
|
||||
|
||||
@@ -248,26 +260,24 @@ def validate_policy_rules(
|
||||
if source is None:
|
||||
errors.append(f"{row_label}: source is required")
|
||||
continue
|
||||
if (
|
||||
raw_content_type is None
|
||||
or (isinstance(raw_content_type, str) and not raw_content_type.strip())
|
||||
if raw_content_type is None or (
|
||||
isinstance(raw_content_type, str) and not raw_content_type.strip()
|
||||
):
|
||||
errors.append(f"{row_label}: content_type is required")
|
||||
continue
|
||||
if content_type is None:
|
||||
errors.append(f"{row_label}: invalid content_type '{rule.get('content_type')}'")
|
||||
continue
|
||||
if (
|
||||
raw_mode is None
|
||||
or (isinstance(raw_mode, str) and not raw_mode.strip())
|
||||
):
|
||||
if raw_mode is None or (isinstance(raw_mode, str) and not raw_mode.strip()):
|
||||
errors.append(f"{row_label}: mode is required")
|
||||
continue
|
||||
if mode is None:
|
||||
errors.append(f"{row_label}: invalid mode '{rule.get('mode')}'")
|
||||
continue
|
||||
if mode not in MATRIX_ALLOWED_MODES:
|
||||
errors.append(f"{row_label}: mode '{mode.value}' is not allowed in matrix rules (use content-type defaults instead)")
|
||||
errors.append(
|
||||
f"{row_label}: mode '{mode.value}' is not allowed in matrix rules (use content-type defaults instead)"
|
||||
)
|
||||
continue
|
||||
|
||||
if source != "*" and source not in normalized_capabilities:
|
||||
@@ -340,7 +350,6 @@ def resolve_policy_mode(
|
||||
- sources whose browse results are already concrete releases normalize
|
||||
request_book to request_release.
|
||||
"""
|
||||
|
||||
effective = merge_request_policy_settings(global_settings, user_settings)
|
||||
normalized_source = normalize_source(source)
|
||||
normalized_content_type = normalize_content_type(content_type)
|
||||
|
||||
@@ -2,14 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from flask import Flask, jsonify, request, session
|
||||
from flask import Flask, Response, jsonify, request, session
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.notifications import (
|
||||
NotificationContext,
|
||||
NotificationEvent,
|
||||
notify_admin,
|
||||
notify_user,
|
||||
)
|
||||
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.request_policy import (
|
||||
PolicyMode,
|
||||
REQUEST_POLICY_DEFAULT_FALLBACK_MODE,
|
||||
PolicyMode,
|
||||
get_source_content_type_capabilities,
|
||||
merge_request_policy_settings,
|
||||
normalize_content_type,
|
||||
@@ -26,22 +41,11 @@ from shelfmark.core.requests_service import (
|
||||
fulfil_request,
|
||||
reject_request,
|
||||
)
|
||||
from shelfmark.core.notifications import (
|
||||
NotificationContext,
|
||||
NotificationEvent,
|
||||
notify_admin,
|
||||
notify_user,
|
||||
)
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -52,7 +56,7 @@ def _error_response(
|
||||
*,
|
||||
code: str | None = None,
|
||||
required_mode: str | None = None,
|
||||
):
|
||||
) -> tuple[Response, int]:
|
||||
payload: dict[str, Any] = {"error": message}
|
||||
if code is not None:
|
||||
payload["code"] = code
|
||||
@@ -61,7 +65,9 @@ def _error_response(
|
||||
return jsonify(payload), status_code
|
||||
|
||||
|
||||
def _require_request_endpoints_available(resolve_auth_mode: Callable[[], str]):
|
||||
def _require_request_endpoints_available(
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
) -> tuple[Response, int] | None:
|
||||
auth_mode = resolve_auth_mode()
|
||||
if auth_mode == "none":
|
||||
return _error_response(
|
||||
@@ -74,7 +80,7 @@ def _require_request_endpoints_available(resolve_auth_mode: Callable[[], str]):
|
||||
return None
|
||||
|
||||
|
||||
def _require_db_user_id() -> tuple[int | None, Any | None]:
|
||||
def _require_db_user_id() -> tuple[int | None, object | None]:
|
||||
raw_user_id = session.get("db_user_id")
|
||||
if raw_user_id is None:
|
||||
return None, _error_response(
|
||||
@@ -84,7 +90,7 @@ def _require_db_user_id() -> tuple[int | None, Any | None]:
|
||||
)
|
||||
try:
|
||||
return int(raw_user_id), None
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
return None, _error_response(
|
||||
"User identity is unavailable for request workflow",
|
||||
403,
|
||||
@@ -92,7 +98,7 @@ def _require_db_user_id() -> tuple[int | None, Any | None]:
|
||||
)
|
||||
|
||||
|
||||
def _require_admin_user_id() -> tuple[int | None, Any | None]:
|
||||
def _require_admin_user_id() -> tuple[int | None, object | None]:
|
||||
if not session.get("is_admin", False):
|
||||
return None, (jsonify({"error": "Admin access required"}), 403)
|
||||
raw_admin_id = session.get("db_user_id")
|
||||
@@ -100,7 +106,7 @@ def _require_admin_user_id() -> tuple[int | None, Any | None]:
|
||||
return None, (jsonify({"error": "Admin user identity unavailable"}), 403)
|
||||
try:
|
||||
return int(raw_admin_id), None
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
return None, (jsonify({"error": "Admin user identity unavailable"}), 403)
|
||||
|
||||
|
||||
@@ -112,11 +118,11 @@ def _resolve_effective_policy(
|
||||
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 = coerce_bool(effective.get("REQUESTS_ENABLED"), False)
|
||||
requests_enabled = coerce_bool(effective.get("REQUESTS_ENABLED"), default=False)
|
||||
return global_settings, user_settings, effective, requests_enabled
|
||||
|
||||
|
||||
def _resolve_title_from_book_data(book_data: Any) -> str:
|
||||
def _resolve_title_from_book_data(book_data: object) -> str:
|
||||
if isinstance(book_data, dict):
|
||||
title = normalize_optional_text(book_data.get("title"))
|
||||
if title is not None:
|
||||
@@ -124,7 +130,7 @@ def _resolve_title_from_book_data(book_data: Any) -> str:
|
||||
return "Unknown title"
|
||||
|
||||
|
||||
def _normalize_optional_source_id(value: Any) -> str | None:
|
||||
def _normalize_optional_source_id(value: object) -> str | None:
|
||||
"""Normalize source identifiers while allowing integer provider ids."""
|
||||
if isinstance(value, bool) or value is None:
|
||||
return None
|
||||
@@ -140,9 +146,9 @@ def _build_release_result_data_from_book_data(
|
||||
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")
|
||||
)
|
||||
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,
|
||||
@@ -164,15 +170,16 @@ def _source_results_are_releases(source: str) -> bool:
|
||||
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,
|
||||
request_level: object,
|
||||
book_data: object,
|
||||
release_data: object,
|
||||
content_type: str,
|
||||
) -> tuple[Any, Any]:
|
||||
"""Concrete-release browse results are always handled as release-level requests."""
|
||||
@@ -194,13 +201,15 @@ def _normalize_release_result_request_payload(
|
||||
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"))
|
||||
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")
|
||||
)
|
||||
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
|
||||
|
||||
@@ -237,26 +246,30 @@ def _resolve_request_user_context(
|
||||
*,
|
||||
actor_user_id: int,
|
||||
actor_username: str | None,
|
||||
on_behalf_of_user_id: Any,
|
||||
on_behalf_of_user_id: object,
|
||||
) -> 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
|
||||
|
||||
if not session.get("is_admin", False):
|
||||
raise RequestServiceError("Admin required", status_code=403)
|
||||
msg = "Admin required"
|
||||
raise RequestServiceError(msg, 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
|
||||
msg = "Invalid on_behalf_of_user_id"
|
||||
raise RequestServiceError(msg, status_code=400) from exc
|
||||
|
||||
if target_user_id <= 0:
|
||||
raise RequestServiceError("Invalid on_behalf_of_user_id", status_code=400)
|
||||
msg = "Invalid on_behalf_of_user_id"
|
||||
raise RequestServiceError(msg, 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)
|
||||
msg = "User not found"
|
||||
raise RequestServiceError(msg, status_code=404)
|
||||
|
||||
target_username = normalize_optional_text(target_user.get("username"))
|
||||
actor_label = _format_user_label(actor_username, actor_user_id)
|
||||
@@ -270,8 +283,9 @@ def _prepare_request_create_arguments(
|
||||
) -> dict[str, Any]:
|
||||
db_user_id, db_gate = _require_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
msg = "User identity is unavailable for request workflow"
|
||||
raise RequestServiceError(
|
||||
"User identity is unavailable for request workflow",
|
||||
msg,
|
||||
status_code=403,
|
||||
code="user_identity_unavailable",
|
||||
)
|
||||
@@ -286,7 +300,8 @@ def _prepare_request_create_arguments(
|
||||
|
||||
context = data.get("context") or {}
|
||||
if not isinstance(context, dict):
|
||||
raise RequestServiceError("context must be an object", status_code=400)
|
||||
msg = "context must be an object"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
|
||||
source = normalize_source(context.get("source"))
|
||||
release_data = data.get("release_data")
|
||||
@@ -296,13 +311,12 @@ def _prepare_request_create_arguments(
|
||||
|
||||
book_data = data.get("book_data")
|
||||
if not isinstance(book_data, dict):
|
||||
raise RequestServiceError("book_data must be an object", status_code=400)
|
||||
msg = "book_data must be an object"
|
||||
raise RequestServiceError(msg, 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")
|
||||
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,
|
||||
@@ -317,8 +331,9 @@ def _prepare_request_create_arguments(
|
||||
db_user_id=target_user_id,
|
||||
)
|
||||
if not requests_enabled:
|
||||
msg = "Request workflow is disabled by policy"
|
||||
raise RequestServiceError(
|
||||
"Request workflow is disabled by policy",
|
||||
msg,
|
||||
status_code=403,
|
||||
code="requests_unavailable",
|
||||
)
|
||||
@@ -327,10 +342,8 @@ def _prepare_request_create_arguments(
|
||||
effective.get("MAX_PENDING_REQUESTS_PER_USER"),
|
||||
default=20,
|
||||
)
|
||||
if max_pending < 1:
|
||||
max_pending = 1
|
||||
if max_pending > 1000:
|
||||
max_pending = 1000
|
||||
max_pending = max(max_pending, 1)
|
||||
max_pending = min(max_pending, 1000)
|
||||
allow_notes = coerce_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True)
|
||||
note_value = data.get("note") if allow_notes else None
|
||||
|
||||
@@ -342,7 +355,7 @@ def _prepare_request_create_arguments(
|
||||
)
|
||||
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"),
|
||||
actor_label,
|
||||
target_user_id,
|
||||
source,
|
||||
content_type,
|
||||
@@ -351,8 +364,9 @@ def _prepare_request_create_arguments(
|
||||
)
|
||||
|
||||
if resolved_mode == PolicyMode.BLOCKED:
|
||||
msg = "Requesting is blocked by policy"
|
||||
raise RequestServiceError(
|
||||
"Requesting is blocked by policy",
|
||||
msg,
|
||||
status_code=403,
|
||||
code="policy_blocked",
|
||||
required_mode=PolicyMode.BLOCKED.value,
|
||||
@@ -360,8 +374,9 @@ def _prepare_request_create_arguments(
|
||||
|
||||
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
|
||||
if resolved_mode == PolicyMode.REQUEST_BOOK and requested_level != "book":
|
||||
msg = "Policy requires book-level requests"
|
||||
raise RequestServiceError(
|
||||
"Policy requires book-level requests",
|
||||
msg,
|
||||
status_code=403,
|
||||
code="policy_requires_request",
|
||||
required_mode=PolicyMode.REQUEST_BOOK.value,
|
||||
@@ -385,7 +400,9 @@ def _prepare_request_create_arguments(
|
||||
}
|
||||
|
||||
|
||||
def _resolve_request_source_and_format(request_row: dict[str, Any]) -> tuple[str, str | None]:
|
||||
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"))
|
||||
@@ -431,8 +448,9 @@ def _queue_prepared_download_submission(
|
||||
) -> dict[str, Any]:
|
||||
release_data = create_args.get("release_data")
|
||||
if not isinstance(release_data, dict):
|
||||
msg = "Download policy requires a concrete release"
|
||||
raise RequestServiceError(
|
||||
"Download policy requires a concrete release",
|
||||
msg,
|
||||
status_code=400,
|
||||
code="policy_requires_download",
|
||||
required_mode=PolicyMode.DOWNLOAD.value,
|
||||
@@ -440,7 +458,8 @@ def _queue_prepared_download_submission(
|
||||
|
||||
requester = user_db.get_user(user_id=create_args["user_id"])
|
||||
if requester is None:
|
||||
raise RequestServiceError("Requesting user not found", status_code=404)
|
||||
msg = "Requesting user not found"
|
||||
raise RequestServiceError(msg, status_code=404)
|
||||
|
||||
success, error = queue_release(
|
||||
dict(release_data),
|
||||
@@ -461,8 +480,6 @@ def _queue_prepared_download_submission(
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def _notify_admin_for_request_event(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
@@ -516,12 +533,12 @@ def register_request_routes(
|
||||
*,
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
queue_release: Callable[..., tuple[bool, str | None]],
|
||||
ws_manager: Any | None = None,
|
||||
ws_manager: object | None = None,
|
||||
) -> None:
|
||||
"""Register request policy and request lifecycle routes."""
|
||||
|
||||
@app.route("/api/request-policy", methods=["GET"])
|
||||
def api_request_policy():
|
||||
def api_request_policy() -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -537,7 +554,7 @@ def register_request_routes(
|
||||
if raw_id is not None:
|
||||
try:
|
||||
db_user_id = int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
db_user_id = None
|
||||
|
||||
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
|
||||
@@ -550,6 +567,7 @@ def register_request_routes(
|
||||
|
||||
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(
|
||||
@@ -597,7 +615,7 @@ def register_request_routes(
|
||||
)
|
||||
|
||||
@app.route("/api/requests", methods=["POST"])
|
||||
def api_create_request():
|
||||
def api_create_request() -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -663,7 +681,7 @@ def register_request_routes(
|
||||
return jsonify(created), 201
|
||||
|
||||
@app.route("/api/requests/batch", methods=["POST"])
|
||||
def api_create_requests_batch():
|
||||
def api_create_requests_batch() -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -716,7 +734,11 @@ def register_request_routes(
|
||||
|
||||
results_by_index: dict[int, dict[str, Any]] = {}
|
||||
|
||||
for (index, prepared), created in zip(request_prepared_items, created_rows):
|
||||
for (index, prepared), created in zip(
|
||||
request_prepared_items,
|
||||
created_rows,
|
||||
strict=True,
|
||||
):
|
||||
event_payload = {
|
||||
"request_id": created["id"],
|
||||
"status": created["status"],
|
||||
@@ -773,7 +795,7 @@ def register_request_routes(
|
||||
return jsonify(ordered_results), status_code
|
||||
|
||||
@app.route("/api/requests", methods=["GET"])
|
||||
def api_list_requests():
|
||||
def api_list_requests() -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -798,7 +820,7 @@ def register_request_routes(
|
||||
return jsonify(rows)
|
||||
|
||||
@app.route("/api/requests/<int:request_id>", methods=["DELETE"])
|
||||
def api_cancel_request(request_id: int):
|
||||
def api_cancel_request(request_id: int) -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -821,7 +843,9 @@ def register_request_routes(
|
||||
"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"],
|
||||
@@ -844,7 +868,7 @@ def register_request_routes(
|
||||
return jsonify(updated)
|
||||
|
||||
@app.route("/api/admin/requests", methods=["GET"])
|
||||
def api_admin_list_requests():
|
||||
def api_admin_list_requests() -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -865,17 +889,14 @@ def register_request_routes(
|
||||
return jsonify(rows)
|
||||
|
||||
@app.route("/api/admin/requests/count", methods=["GET"])
|
||||
def api_admin_request_counts():
|
||||
def api_admin_request_counts() -> Response | tuple[Response, int]:
|
||||
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
|
||||
|
||||
by_status = {
|
||||
status: len(user_db.list_requests(status=status))
|
||||
for status in RequestStatus
|
||||
}
|
||||
by_status = {status: len(user_db.list_requests(status=status)) for status in RequestStatus}
|
||||
return jsonify(
|
||||
{
|
||||
"pending": by_status[RequestStatus.PENDING],
|
||||
@@ -885,7 +906,7 @@ def register_request_routes(
|
||||
)
|
||||
|
||||
@app.route("/api/admin/requests/<int:request_id>/fulfil", methods=["POST"])
|
||||
def api_admin_fulfil_request(request_id: int):
|
||||
def api_admin_fulfil_request(request_id: int) -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -916,7 +937,9 @@ 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)
|
||||
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)",
|
||||
@@ -947,7 +970,7 @@ def register_request_routes(
|
||||
return jsonify(updated)
|
||||
|
||||
@app.route("/api/admin/requests/<int:request_id>/reject", methods=["POST"])
|
||||
def api_admin_reject_request(request_id: int):
|
||||
def api_admin_reject_request(request_id: int) -> Response | tuple[Response, int]:
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
@@ -975,7 +998,9 @@ 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)
|
||||
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)",
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from enum import StrEnum
|
||||
|
||||
from shelfmark.core.models import QueueStatus
|
||||
from shelfmark.core.request_policy import parse_policy_mode
|
||||
|
||||
|
||||
class RequestStatus(str, Enum):
|
||||
class RequestStatus(StrEnum):
|
||||
"""Enum for request lifecycle statuses."""
|
||||
|
||||
PENDING = "pending"
|
||||
FULFILLED = "fulfilled"
|
||||
REJECTED = "rejected"
|
||||
@@ -20,65 +20,79 @@ class RequestStatus(str, Enum):
|
||||
DELIVERY_STATE_NONE = "none"
|
||||
|
||||
VALID_REQUEST_STATUSES = frozenset(RequestStatus)
|
||||
TERMINAL_REQUEST_STATUSES = frozenset({
|
||||
RequestStatus.FULFILLED, RequestStatus.REJECTED, RequestStatus.CANCELLED,
|
||||
})
|
||||
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:
|
||||
def normalize_request_status(status: object) -> str:
|
||||
"""Validate and normalize request status values."""
|
||||
if not isinstance(status, str):
|
||||
raise ValueError(f"Invalid request status: {status}")
|
||||
msg = f"Invalid request status: {status}"
|
||||
raise TypeError(msg)
|
||||
normalized = status.strip().lower()
|
||||
if normalized not in VALID_REQUEST_STATUSES:
|
||||
raise ValueError(f"Invalid request status: {status}")
|
||||
msg = f"Invalid request status: {status}"
|
||||
raise ValueError(msg)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_policy_mode(mode: Any) -> str:
|
||||
def normalize_policy_mode(mode: object) -> str:
|
||||
"""Validate and normalize policy mode values."""
|
||||
parsed = parse_policy_mode(mode)
|
||||
if parsed is None:
|
||||
raise ValueError(f"Invalid policy_mode: {mode}")
|
||||
msg = f"Invalid policy_mode: {mode}"
|
||||
raise ValueError(msg)
|
||||
return parsed.value
|
||||
|
||||
|
||||
def normalize_request_level(request_level: Any) -> str:
|
||||
def normalize_request_level(request_level: object) -> str:
|
||||
"""Validate and normalize request level values."""
|
||||
if not isinstance(request_level, str):
|
||||
raise ValueError(f"Invalid request_level: {request_level}")
|
||||
msg = f"Invalid request_level: {request_level}"
|
||||
raise TypeError(msg)
|
||||
normalized = request_level.strip().lower()
|
||||
if normalized not in VALID_REQUEST_LEVELS:
|
||||
raise ValueError(f"Invalid request_level: {request_level}")
|
||||
msg = f"Invalid request_level: {request_level}"
|
||||
raise ValueError(msg)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_delivery_state(state: Any) -> str:
|
||||
def normalize_delivery_state(state: object) -> str:
|
||||
"""Validate and normalize delivery-state values."""
|
||||
if not isinstance(state, str):
|
||||
raise ValueError(f"Invalid delivery_state: {state}")
|
||||
msg = f"Invalid delivery_state: {state}"
|
||||
raise TypeError(msg)
|
||||
normalized = state.strip().lower()
|
||||
if normalized not in VALID_DELIVERY_STATES:
|
||||
raise ValueError(f"Invalid delivery_state: {state}")
|
||||
msg = f"Invalid delivery_state: {state}"
|
||||
raise ValueError(msg)
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_request_level_payload(request_level: Any, release_data: Any) -> str:
|
||||
def validate_request_level_payload(request_level: object, release_data: object) -> str:
|
||||
"""Validate request_level and release_data shape coupling."""
|
||||
normalized_level = normalize_request_level(request_level)
|
||||
if normalized_level == "release" and release_data is None:
|
||||
raise ValueError("request_level=release requires non-null release_data")
|
||||
msg = "request_level=release requires non-null release_data"
|
||||
raise ValueError(msg)
|
||||
if normalized_level == "book" and release_data is not None:
|
||||
raise ValueError("request_level=book requires null release_data")
|
||||
msg = "request_level=book requires null release_data"
|
||||
raise ValueError(msg)
|
||||
return normalized_level
|
||||
|
||||
|
||||
def validate_status_transition(current_status: Any, new_status: Any) -> tuple[str, str]:
|
||||
def validate_status_transition(current_status: object, new_status: object) -> tuple[str, str]:
|
||||
"""Validate request status transitions and terminal immutability."""
|
||||
current = normalize_request_status(current_status)
|
||||
new = normalize_request_status(new_status)
|
||||
if current in TERMINAL_REQUEST_STATUSES and new != current:
|
||||
raise ValueError("Terminal request statuses are immutable")
|
||||
msg = "Terminal request statuses are immutable"
|
||||
raise ValueError(msg)
|
||||
return current, new
|
||||
|
||||
@@ -2,29 +2,30 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from typing import Any, Callable, TYPE_CHECKING
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.request_policy import normalize_content_type
|
||||
from shelfmark.core.models import QueueStatus
|
||||
from shelfmark.core.request_helpers import (
|
||||
extract_release_source_id,
|
||||
normalize_positive_int,
|
||||
)
|
||||
from shelfmark.core.request_policy import normalize_content_type
|
||||
from shelfmark.core.request_validation import (
|
||||
DELIVERY_STATE_NONE,
|
||||
RequestStatus,
|
||||
normalize_policy_mode,
|
||||
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
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
@@ -38,68 +39,76 @@ class RequestServiceError(ValueError):
|
||||
status_code: int = 400,
|
||||
code: str | None = None,
|
||||
required_mode: str | None = None,
|
||||
):
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
self.required_mode = required_mode
|
||||
|
||||
|
||||
def _normalize_match_text(value: Any) -> str:
|
||||
def _normalize_match_text(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def normalize_note(note: Any) -> str | None:
|
||||
def normalize_note(note: object) -> str | None:
|
||||
"""Validate request notes and normalize empty strings to None."""
|
||||
if note is None:
|
||||
return None
|
||||
if not isinstance(note, str):
|
||||
raise RequestServiceError("note must be a string", status_code=400)
|
||||
msg = "note must be a string"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
normalized = note.strip()
|
||||
if len(normalized) > MAX_REQUEST_NOTE_LENGTH:
|
||||
msg_0 = f"note must be <= {MAX_REQUEST_NOTE_LENGTH} characters"
|
||||
raise RequestServiceError(
|
||||
f"note must be <= {MAX_REQUEST_NOTE_LENGTH} characters",
|
||||
msg_0,
|
||||
status_code=400,
|
||||
)
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _validate_book_data(book_data: Any) -> dict[str, Any]:
|
||||
def _validate_book_data(book_data: object) -> dict[str, Any]:
|
||||
if not isinstance(book_data, dict):
|
||||
raise RequestServiceError("book_data must be an object", status_code=400)
|
||||
msg = "book_data must be an object"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
|
||||
required_fields = ("title", "author", "provider", "provider_id")
|
||||
missing = [field for field in required_fields if not _normalize_match_text(book_data.get(field))]
|
||||
missing = [
|
||||
field for field in required_fields if not _normalize_match_text(book_data.get(field))
|
||||
]
|
||||
if missing:
|
||||
msg_0 = f"book_data missing required field(s): {', '.join(missing)}"
|
||||
raise RequestServiceError(
|
||||
f"book_data missing required field(s): {', '.join(missing)}",
|
||||
msg_0,
|
||||
status_code=400,
|
||||
)
|
||||
return dict(book_data)
|
||||
|
||||
|
||||
def _validate_json_blob_size(field: str, payload: Any) -> None:
|
||||
def _validate_json_blob_size(field: str, payload: object) -> None:
|
||||
if payload is None:
|
||||
return
|
||||
|
||||
try:
|
||||
serialized = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RequestServiceError(f"{field} must be JSON-serializable", status_code=400) from exc
|
||||
msg = f"{field} must be JSON-serializable"
|
||||
raise RequestServiceError(msg, status_code=400) from exc
|
||||
|
||||
payload_size = len(serialized.encode("utf-8"))
|
||||
if payload_size > MAX_REQUEST_JSON_BLOB_BYTES:
|
||||
msg = f"{field} must be <= {MAX_REQUEST_JSON_BLOB_BYTES} bytes"
|
||||
raise RequestServiceError(
|
||||
f"{field} must be <= {MAX_REQUEST_JSON_BLOB_BYTES} bytes",
|
||||
msg,
|
||||
status_code=400,
|
||||
code="request_payload_too_large",
|
||||
)
|
||||
|
||||
|
||||
def _find_duplicate_pending_request(
|
||||
user_db: "UserDB",
|
||||
user_db: UserDB,
|
||||
*,
|
||||
user_id: int,
|
||||
title: str,
|
||||
@@ -123,14 +132,15 @@ def _find_duplicate_pending_request(
|
||||
|
||||
|
||||
def _now_timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _normalize_admin_note(admin_note: Any) -> str | None:
|
||||
def _normalize_admin_note(admin_note: object) -> str | None:
|
||||
if admin_note is None:
|
||||
return None
|
||||
if not isinstance(admin_note, str):
|
||||
raise RequestServiceError("admin_note must be a string", status_code=400)
|
||||
msg = "admin_note must be a string"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
return admin_note.strip() or None
|
||||
|
||||
|
||||
@@ -138,12 +148,12 @@ 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,
|
||||
content_type: object,
|
||||
request_level: object,
|
||||
policy_mode: object,
|
||||
book_data: object,
|
||||
release_data: object = None,
|
||||
note: object = None,
|
||||
) -> dict[str, Any]:
|
||||
validated_book_data = _validate_book_data(book_data)
|
||||
normalized_note = normalize_note(note)
|
||||
@@ -155,7 +165,7 @@ def _prepare_request_create(
|
||||
try:
|
||||
normalized_request_level = validate_request_level_payload(request_level, release_data)
|
||||
normalized_policy_mode = normalize_policy_mode(policy_mode)
|
||||
except ValueError as exc:
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
|
||||
_validate_json_blob_size("book_data", validated_book_data)
|
||||
@@ -174,7 +184,7 @@ def _prepare_request_create(
|
||||
|
||||
|
||||
def sync_delivery_states_from_queue_status(
|
||||
user_db: "UserDB",
|
||||
user_db: UserDB,
|
||||
*,
|
||||
queue_status: dict[str, dict[str, Any]],
|
||||
user_id: int | None = None,
|
||||
@@ -257,16 +267,16 @@ def sync_delivery_states_from_queue_status(
|
||||
|
||||
|
||||
def create_request(
|
||||
user_db: "UserDB",
|
||||
user_db: UserDB,
|
||||
*,
|
||||
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,
|
||||
content_type: object,
|
||||
request_level: object,
|
||||
policy_mode: object,
|
||||
book_data: object,
|
||||
release_data: object = None,
|
||||
note: object = None,
|
||||
max_pending_per_user: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a pending request after service-level validation."""
|
||||
@@ -284,8 +294,9 @@ def create_request(
|
||||
if max_pending_per_user is not None:
|
||||
pending_count = user_db.count_user_pending_requests(user_id)
|
||||
if pending_count >= max_pending_per_user:
|
||||
msg = "Maximum pending requests reached for this user"
|
||||
raise RequestServiceError(
|
||||
"Maximum pending requests reached for this user",
|
||||
msg,
|
||||
status_code=409,
|
||||
code="max_pending_reached",
|
||||
)
|
||||
@@ -298,26 +309,28 @@ def create_request(
|
||||
content_type=prepared_request["content_type"],
|
||||
)
|
||||
if duplicate is not None:
|
||||
msg = "Duplicate pending request exists for this title/author/content_type"
|
||||
raise RequestServiceError(
|
||||
"Duplicate pending request exists for this title/author/content_type",
|
||||
msg,
|
||||
status_code=409,
|
||||
code="duplicate_pending_request",
|
||||
)
|
||||
|
||||
try:
|
||||
return user_db.create_request(**prepared_request)
|
||||
except ValueError as exc:
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
|
||||
|
||||
def create_requests(
|
||||
user_db: "UserDB",
|
||||
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)
|
||||
msg = "requests must contain at least one request"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
|
||||
prepared_requests: list[dict[str, Any]] = []
|
||||
pending_counts_by_user: dict[int, int] = {}
|
||||
@@ -325,7 +338,8 @@ def create_requests(
|
||||
|
||||
for request in requests:
|
||||
if not isinstance(request, dict):
|
||||
raise RequestServiceError("requests must contain objects", status_code=400)
|
||||
msg = "requests must contain objects"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
|
||||
user_id = int(request["user_id"])
|
||||
prepared_request = _prepare_request_create(
|
||||
@@ -346,8 +360,9 @@ def create_requests(
|
||||
prepared_request["content_type"],
|
||||
)
|
||||
if request_key in seen_request_keys:
|
||||
msg = "Duplicate pending request exists for this title/author/content_type"
|
||||
raise RequestServiceError(
|
||||
"Duplicate pending request exists for this title/author/content_type",
|
||||
msg,
|
||||
status_code=409,
|
||||
code="duplicate_pending_request",
|
||||
)
|
||||
@@ -359,8 +374,9 @@ def create_requests(
|
||||
if existing_pending is None:
|
||||
existing_pending = user_db.count_user_pending_requests(user_id)
|
||||
if existing_pending >= max_pending_per_user:
|
||||
msg = "Maximum pending requests reached for this user"
|
||||
raise RequestServiceError(
|
||||
"Maximum pending requests reached for this user",
|
||||
msg,
|
||||
status_code=409,
|
||||
code="max_pending_reached",
|
||||
)
|
||||
@@ -374,8 +390,9 @@ def create_requests(
|
||||
content_type=request_key[3],
|
||||
)
|
||||
if duplicate is not None:
|
||||
msg = "Duplicate pending request exists for this title/author/content_type"
|
||||
raise RequestServiceError(
|
||||
"Duplicate pending request exists for this title/author/content_type",
|
||||
msg,
|
||||
status_code=409,
|
||||
code="duplicate_pending_request",
|
||||
)
|
||||
@@ -389,7 +406,7 @@ def create_requests(
|
||||
|
||||
|
||||
def ensure_request_access(
|
||||
user_db: "UserDB",
|
||||
user_db: UserDB,
|
||||
*,
|
||||
request_id: int,
|
||||
actor_user_id: int | None,
|
||||
@@ -398,26 +415,28 @@ def ensure_request_access(
|
||||
"""Get request by ID and enforce ownership for non-admin actors."""
|
||||
request_row = user_db.get_request(request_id)
|
||||
if request_row is None:
|
||||
raise RequestServiceError("Request not found", status_code=404)
|
||||
msg = "Request not found"
|
||||
raise RequestServiceError(msg, status_code=404)
|
||||
|
||||
if not is_admin:
|
||||
if actor_user_id is None or request_row["user_id"] != actor_user_id:
|
||||
raise RequestServiceError("Forbidden", status_code=403)
|
||||
if not is_admin and (actor_user_id is None or request_row["user_id"] != actor_user_id):
|
||||
msg = "Forbidden"
|
||||
raise RequestServiceError(msg, status_code=403)
|
||||
|
||||
return request_row
|
||||
|
||||
|
||||
def _require_pending(request_row: dict[str, Any]) -> None:
|
||||
if request_row["status"] != RequestStatus.PENDING:
|
||||
msg = "Request is already in a terminal state"
|
||||
raise RequestServiceError(
|
||||
"Request is already in a terminal state",
|
||||
msg,
|
||||
status_code=409,
|
||||
code="stale_transition",
|
||||
)
|
||||
|
||||
|
||||
def cancel_request(
|
||||
user_db: "UserDB",
|
||||
user_db: UserDB,
|
||||
*,
|
||||
request_id: int,
|
||||
actor_user_id: int,
|
||||
@@ -437,16 +456,18 @@ def cancel_request(
|
||||
expected_current_status=RequestStatus.PENDING,
|
||||
status=RequestStatus.CANCELLED,
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
|
||||
def reject_request(
|
||||
user_db: "UserDB",
|
||||
user_db: UserDB,
|
||||
*,
|
||||
request_id: int,
|
||||
admin_user_id: int,
|
||||
admin_note: Any = None,
|
||||
admin_note: object = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reject a pending request as admin."""
|
||||
request_row = ensure_request_access(
|
||||
@@ -468,19 +489,21 @@ def reject_request(
|
||||
reviewed_by=admin_user_id,
|
||||
reviewed_at=_now_timestamp(),
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
|
||||
def fulfil_request(
|
||||
user_db: "UserDB",
|
||||
user_db: UserDB,
|
||||
*,
|
||||
request_id: int,
|
||||
admin_user_id: int,
|
||||
queue_release: Callable[..., tuple[bool, str | None]],
|
||||
release_data: Any = None,
|
||||
admin_note: Any = None,
|
||||
manual_approval: Any = False,
|
||||
release_data: object = None,
|
||||
admin_note: object = None,
|
||||
manual_approval: object = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Fulfil a pending request and queue the release under requesting-user identity."""
|
||||
request_row = ensure_request_access(
|
||||
@@ -494,11 +517,15 @@ def fulfil_request(
|
||||
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)
|
||||
msg = "manual_approval must be a boolean"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
|
||||
selected_release_data = release_data if release_data is not None else request_row.get("release_data")
|
||||
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)
|
||||
msg = "release_data must be an object"
|
||||
raise RequestServiceError(msg, status_code=400)
|
||||
|
||||
if selected_release_data is None and manual_approval:
|
||||
try:
|
||||
@@ -514,12 +541,15 @@ def fulfil_request(
|
||||
reviewed_by=admin_user_id,
|
||||
reviewed_at=_now_timestamp(),
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
if selected_release_data is None:
|
||||
msg = "release_data is required to fulfil requests"
|
||||
raise RequestServiceError(
|
||||
"release_data is required to fulfil requests",
|
||||
msg,
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -527,7 +557,8 @@ def fulfil_request(
|
||||
|
||||
requester = user_db.get_user(user_id=request_row["user_id"])
|
||||
if requester is None:
|
||||
raise RequestServiceError("Requesting user not found", status_code=404)
|
||||
msg = "Requesting user not found"
|
||||
raise RequestServiceError(msg, status_code=404)
|
||||
|
||||
original_release_data = request_row.get("release_data")
|
||||
try:
|
||||
@@ -543,6 +574,8 @@ def fulfil_request(
|
||||
reviewed_by=admin_user_id,
|
||||
reviewed_at=_now_timestamp(),
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
@@ -579,7 +612,7 @@ def fulfil_request(
|
||||
|
||||
|
||||
def reopen_failed_request(
|
||||
user_db: "UserDB",
|
||||
user_db: UserDB,
|
||||
*,
|
||||
request_id: int,
|
||||
failure_reason: str | None = None,
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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,
|
||||
build_localized_search_titles,
|
||||
group_languages_by_localized_title,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.models import SearchFilters
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseSearchVariant:
|
||||
@@ -20,7 +22,7 @@ class ReleaseSearchVariant:
|
||||
|
||||
title: str
|
||||
author: str
|
||||
languages: Optional[List[str]] = None
|
||||
languages: list[str] | None = None
|
||||
|
||||
@property
|
||||
def query(self) -> str:
|
||||
@@ -31,28 +33,28 @@ class ReleaseSearchVariant:
|
||||
class ReleaseSearchPlan:
|
||||
"""Pre-computed search inputs shared across release sources."""
|
||||
|
||||
languages: Optional[List[str]]
|
||||
isbn_candidates: List[str]
|
||||
languages: list[str] | None
|
||||
isbn_candidates: list[str]
|
||||
author: str
|
||||
title_variants: List[ReleaseSearchVariant]
|
||||
grouped_title_variants: List[ReleaseSearchVariant]
|
||||
manual_query: Optional[str] = None
|
||||
indexers: Optional[List[str]] = None # Indexer names for Prowlarr (overrides settings)
|
||||
source_filters: Optional[SearchFilters] = None
|
||||
title_variants: list[ReleaseSearchVariant]
|
||||
grouped_title_variants: list[ReleaseSearchVariant]
|
||||
manual_query: str | None = None
|
||||
indexers: list[str] | None = None # Indexer names for Prowlarr (overrides settings)
|
||||
source_filters: SearchFilters | None = None
|
||||
|
||||
@property
|
||||
def primary_query(self) -> str:
|
||||
return self.title_variants[0].query if self.title_variants else ""
|
||||
|
||||
|
||||
def _normalize_languages(languages: Optional[List[str]]) -> Optional[List[str]]:
|
||||
def _normalize_languages(languages: list[str] | None) -> list[str] | None:
|
||||
if not languages:
|
||||
default = config.BOOK_LANGUAGE
|
||||
if not default:
|
||||
return None
|
||||
return [str(lang).strip() for lang in default if str(lang).strip()]
|
||||
|
||||
normalized: List[str] = []
|
||||
normalized: list[str] = []
|
||||
for lang in languages:
|
||||
if not lang:
|
||||
continue
|
||||
@@ -87,10 +89,10 @@ def _pick_search_title(book: BookMetadata) -> str:
|
||||
|
||||
def build_release_search_plan(
|
||||
book: BookMetadata,
|
||||
languages: Optional[List[str]] = None,
|
||||
manual_query: Optional[str] = None,
|
||||
indexers: Optional[List[str]] = None,
|
||||
source_filters: Optional[SearchFilters] = None,
|
||||
languages: list[str] | None = None,
|
||||
manual_query: str | None = None,
|
||||
indexers: list[str] | None = None,
|
||||
source_filters: SearchFilters | None = None,
|
||||
) -> ReleaseSearchPlan:
|
||||
resolved_languages = _normalize_languages(languages)
|
||||
|
||||
@@ -115,7 +117,7 @@ def build_release_search_plan(
|
||||
source_filters=source_filters,
|
||||
)
|
||||
|
||||
isbn_candidates: List[str] = []
|
||||
isbn_candidates: list[str] = []
|
||||
if book.isbn_13:
|
||||
isbn_candidates.append(book.isbn_13)
|
||||
if book.isbn_10 and book.isbn_10 not in isbn_candidates:
|
||||
@@ -135,7 +137,7 @@ def build_release_search_plan(
|
||||
titles_by_language=titles_by_language,
|
||||
)
|
||||
|
||||
grouped_variants: List[ReleaseSearchVariant] = [
|
||||
grouped_variants: list[ReleaseSearchVariant] = [
|
||||
ReleaseSearchVariant(title=title, author=author, languages=langs)
|
||||
for title, langs in grouped
|
||||
if title
|
||||
@@ -148,7 +150,7 @@ def build_release_search_plan(
|
||||
excluded_languages={"en", "eng", "english"},
|
||||
)
|
||||
|
||||
title_variants: List[ReleaseSearchVariant] = [
|
||||
title_variants: list[ReleaseSearchVariant] = [
|
||||
ReleaseSearchVariant(title=title, author=author, languages=None)
|
||||
for title in expanded_titles
|
||||
if title
|
||||
@@ -157,8 +159,7 @@ def build_release_search_plan(
|
||||
# If no titles could be built, fall back to ISBN queries.
|
||||
if not title_variants and isbn_candidates:
|
||||
title_variants = [
|
||||
ReleaseSearchVariant(title=isbn, author="", languages=None)
|
||||
for isbn in isbn_candidates
|
||||
ReleaseSearchVariant(title=isbn, author="", languages=None) for isbn in isbn_candidates
|
||||
]
|
||||
|
||||
return ReleaseSearchPlan(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Self-service user account routes."""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Mapping
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from flask import Flask, g, jsonify, request, session
|
||||
from flask import Flask, Response, g, jsonify, request, session
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shelfmark.config.env import CWA_DB_PATH
|
||||
@@ -24,9 +24,15 @@ from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_settings_overrides import (
|
||||
build_user_preferences_payload as _build_user_preferences_payload,
|
||||
)
|
||||
from shelfmark.core.user_settings_overrides import (
|
||||
get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields,
|
||||
)
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Mapping
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -43,11 +49,13 @@ _VALID_SELF_SETTINGS_SECTIONS = (
|
||||
_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS = list(_VALID_SELF_SETTINGS_SECTIONS)
|
||||
|
||||
|
||||
def _get_current_user(user_db: UserDB) -> tuple[int | None, dict[str, Any] | None, tuple[Any, int] | None]:
|
||||
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:
|
||||
user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
return None, None, (jsonify({"error": "Invalid user context"}), 400)
|
||||
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
@@ -83,7 +91,7 @@ def _serialize_self_user(user: Mapping[str, Any], auth_mode: str) -> dict[str, A
|
||||
return payload
|
||||
|
||||
|
||||
def _normalize_visible_self_settings_sections(raw_sections: Any) -> list[str]:
|
||||
def _normalize_visible_self_settings_sections(raw_sections: object) -> list[str]:
|
||||
"""Normalize users.VISIBLE_SELF_SETTINGS_SECTIONS to a safe ordered list."""
|
||||
if raw_sections is None:
|
||||
return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS)
|
||||
@@ -91,7 +99,9 @@ def _normalize_visible_self_settings_sections(raw_sections: Any) -> list[str]:
|
||||
if isinstance(raw_sections, str):
|
||||
candidate_sections = [s.strip() for s in raw_sections.split(",") if s.strip()]
|
||||
elif isinstance(raw_sections, (list, tuple, set)):
|
||||
candidate_sections = [str(section).strip() for section in raw_sections if str(section).strip()]
|
||||
candidate_sections = [
|
||||
str(section).strip() for section in raw_sections if str(section).strip()
|
||||
]
|
||||
else:
|
||||
return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS)
|
||||
|
||||
@@ -118,14 +128,10 @@ def _get_allowed_self_settings_keys(visible_sections: list[str]) -> set[str]:
|
||||
visible_sections_set = set(visible_sections)
|
||||
|
||||
if _SELF_SETTINGS_SECTION_DELIVERY in visible_sections_set:
|
||||
allowed_keys |= {
|
||||
key for key, _field in _get_ordered_user_overridable_fields("downloads")
|
||||
}
|
||||
allowed_keys |= {key for key, _field in _get_ordered_user_overridable_fields("downloads")}
|
||||
|
||||
if _SELF_SETTINGS_SECTION_SEARCH in visible_sections_set:
|
||||
allowed_keys |= {
|
||||
key for key, _field in _get_ordered_user_overridable_fields("search_mode")
|
||||
}
|
||||
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 |= {
|
||||
@@ -138,25 +144,31 @@ 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]:
|
||||
def _require_authenticated_user(
|
||||
f: Callable[..., Response | tuple[Response, int]],
|
||||
) -> Callable[..., Response | tuple[Response, int]]:
|
||||
"""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):
|
||||
def decorated(*args, **kwargs) -> Response | tuple[Response, int]:
|
||||
auth_mode = load_active_auth_mode(CWA_DB_PATH, user_db=user_db)
|
||||
g.auth_mode = auth_mode
|
||||
if auth_mode != "none" and "user_id" not in session:
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
if "db_user_id" not in session:
|
||||
return jsonify({"error": "Authenticated session is missing local user context"}), 403
|
||||
return 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():
|
||||
def users_me_edit_context() -> Response | tuple[Response, int]:
|
||||
user_id, user, user_error = _get_current_user(user_db)
|
||||
if user_error:
|
||||
return user_error
|
||||
@@ -168,31 +180,49 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
delivery_preferences = None
|
||||
if _SELF_SETTINGS_SECTION_DELIVERY in visible_self_settings_sections:
|
||||
try:
|
||||
delivery_preferences = _build_user_preferences_payload(user_db, user_id, "downloads")
|
||||
delivery_preferences = _build_user_preferences_payload(
|
||||
user_db, user_id, "downloads"
|
||||
)
|
||||
except ValueError:
|
||||
return jsonify({"error": "Downloads settings tab not found"}), 500
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to build user delivery preferences for user_id={user_id}: {exc}")
|
||||
logger.warning(
|
||||
"Failed to build user delivery preferences for user_id=%s: %s",
|
||||
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")
|
||||
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}")
|
||||
logger.warning(
|
||||
"Failed to build user search preferences for user_id=%s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
search_preferences = None
|
||||
|
||||
notification_preferences = None
|
||||
if _SELF_SETTINGS_SECTION_NOTIFICATIONS in visible_self_settings_sections:
|
||||
try:
|
||||
notification_preferences = _build_user_preferences_payload(user_db, user_id, "notifications")
|
||||
notification_preferences = _build_user_preferences_payload(
|
||||
user_db, user_id, "notifications"
|
||||
)
|
||||
except ValueError:
|
||||
return jsonify({"error": "Notifications settings tab not found"}), 500
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to build user notification preferences for user_id={user_id}: {exc}")
|
||||
logger.warning(
|
||||
"Failed to build user notification preferences for user_id=%s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
notification_preferences = None
|
||||
|
||||
user_overridable_keys = sorted(
|
||||
@@ -214,7 +244,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
|
||||
@app.route("/api/users/me/notification-preferences/test", methods=["POST"])
|
||||
@_require_authenticated_user
|
||||
def users_me_test_notification_preferences():
|
||||
def users_me_test_notification_preferences() -> Response | tuple[Response, int]:
|
||||
user_id, _user, user_error = _get_current_user(user_db)
|
||||
if user_error:
|
||||
return user_error
|
||||
@@ -230,7 +260,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
|
||||
@app.route("/api/users/me", methods=["PUT"])
|
||||
@_require_authenticated_user
|
||||
def users_me_update():
|
||||
def users_me_update() -> Response | tuple[Response, int]:
|
||||
user_id, user, user_error = _get_current_user(user_db)
|
||||
if user_error:
|
||||
return user_error
|
||||
@@ -252,7 +282,9 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
}
|
||||
), 400
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return jsonify({"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}), 400
|
||||
return jsonify(
|
||||
{"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}
|
||||
), 400
|
||||
user_db.update_user(user_id, password_hash=generate_password_hash(password))
|
||||
|
||||
user_fields: dict[str, Any] = {}
|
||||
@@ -271,10 +303,9 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
)
|
||||
|
||||
email_changed = "email" in user_fields and user_fields["email"] != user.get("email")
|
||||
display_name_changed = (
|
||||
"display_name" in user_fields
|
||||
and user_fields["display_name"] != user.get("display_name")
|
||||
)
|
||||
display_name_changed = "display_name" in user_fields and user_fields[
|
||||
"display_name"
|
||||
] != user.get("display_name")
|
||||
|
||||
if email_changed and not capabilities["canEditEmail"]:
|
||||
if auth_source == AUTH_SOURCE_CWA:
|
||||
@@ -312,7 +343,9 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
return jsonify({"error": "Settings must be an object"}), 400
|
||||
|
||||
visible_self_settings_sections = _get_visible_self_settings_sections()
|
||||
allowed_user_settings_keys = _get_allowed_self_settings_keys(visible_self_settings_sections)
|
||||
allowed_user_settings_keys = _get_allowed_self_settings_keys(
|
||||
visible_self_settings_sections
|
||||
)
|
||||
disallowed_keys = sorted(
|
||||
key for key in settings_payload if key not in allowed_user_settings_keys
|
||||
)
|
||||
@@ -349,5 +382,5 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
|
||||
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")
|
||||
logger.info("User %s updated their own account", user_id)
|
||||
return jsonify(result)
|
||||
|
||||
+350
-257
File diff suppressed because it is too large
Load Diff
+111
-108
@@ -4,12 +4,12 @@ import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
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.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET
|
||||
from shelfmark.core.logger import setup_logger
|
||||
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,
|
||||
@@ -116,16 +116,16 @@ WHERE dismissed_at IS NOT NULL;
|
||||
"""
|
||||
|
||||
|
||||
def get_users_db_path(config_dir: Optional[str] = None) -> str:
|
||||
def get_users_db_path(config_dir: str | None = None) -> str:
|
||||
"""Return the configured users database path."""
|
||||
root = config_dir or os.environ.get("CONFIG_DIR", "/config")
|
||||
return os.path.join(root, "users.db")
|
||||
return str(Path(root) / "users.db")
|
||||
|
||||
|
||||
def sync_builtin_admin_user(
|
||||
username: str,
|
||||
password_hash: str,
|
||||
db_path: Optional[str] = None,
|
||||
db_path: str | None = None,
|
||||
) -> None:
|
||||
"""Ensure a local admin user exists for configured builtin credentials."""
|
||||
normalized_username = (username or "").strip()
|
||||
@@ -138,7 +138,9 @@ 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()
|
||||
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'",
|
||||
@@ -155,7 +157,7 @@ def sync_builtin_admin_user(
|
||||
updates["auth_source"] = AUTH_SOURCE_BUILTIN
|
||||
if updates:
|
||||
user_db.update_user(existing["id"], **updates)
|
||||
logger.info(f"Updated local admin user '{normalized_username}' from builtin settings")
|
||||
logger.info("Updated local admin user '%s' from builtin settings", normalized_username)
|
||||
return
|
||||
|
||||
user_db.create_user(
|
||||
@@ -164,15 +166,15 @@ def sync_builtin_admin_user(
|
||||
auth_source=AUTH_SOURCE_BUILTIN,
|
||||
role="admin",
|
||||
)
|
||||
logger.info(f"Created local admin user '{normalized_username}' from builtin settings")
|
||||
logger.info("Created local admin user '%s' from builtin settings", normalized_username)
|
||||
|
||||
|
||||
class UserDB:
|
||||
"""Thread-safe SQLite user database."""
|
||||
|
||||
_VALID_AUTH_SOURCES = set(AUTH_SOURCE_SET)
|
||||
_VALID_AUTH_SOURCES: ClassVar[frozenset[str]] = frozenset(AUTH_SOURCE_SET)
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
def __init__(self, db_path: str) -> None:
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@@ -204,14 +206,10 @@ class UserDB:
|
||||
column_names = {str(col["name"]) for col in columns}
|
||||
|
||||
if "auth_source" not in column_names:
|
||||
conn.execute(
|
||||
"ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'builtin'"
|
||||
)
|
||||
conn.execute("ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'builtin'")
|
||||
|
||||
# Backfill OIDC-origin users created before auth_source existed.
|
||||
conn.execute(
|
||||
"UPDATE users SET auth_source = 'oidc' WHERE oidc_subject IS NOT NULL"
|
||||
)
|
||||
conn.execute("UPDATE users SET auth_source = 'oidc' WHERE oidc_subject IS NOT NULL")
|
||||
# Defensive cleanup for any legacy null/blank values.
|
||||
conn.execute(
|
||||
"UPDATE users SET auth_source = 'builtin' WHERE auth_source IS NULL OR auth_source = ''"
|
||||
@@ -266,13 +264,13 @@ class UserDB:
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
email: Optional[str] = None,
|
||||
display_name: Optional[str] = None,
|
||||
password_hash: Optional[str] = None,
|
||||
oidc_subject: Optional[str] = None,
|
||||
email: str | None = None,
|
||||
display_name: str | None = None,
|
||||
password_hash: str | None = None,
|
||||
oidc_subject: str | None = None,
|
||||
auth_source: str = "builtin",
|
||||
role: str = "user",
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new user. Raises ValueError if username or oidc_subject already exists."""
|
||||
if auth_source not in self._VALID_AUTH_SOURCES:
|
||||
raise ValueError(f"Invalid auth_source: {auth_source}")
|
||||
@@ -298,25 +296,23 @@ class UserDB:
|
||||
user_id = cursor.lastrowid
|
||||
return self._get_user_by_id(conn, user_id)
|
||||
except sqlite3.IntegrityError as e:
|
||||
raise ValueError(f"User already exists: {e}")
|
||||
raise ValueError(f"User already exists: {e}") from e
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_user(
|
||||
self,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
oidc_subject: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
oidc_subject: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get a user by id, username, or oidc_subject. Returns None if not found."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
if user_id is not None:
|
||||
return self._get_user_by_id(conn, user_id)
|
||||
elif username is not None:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
if username is not None:
|
||||
row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
|
||||
elif oidc_subject is not None:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE oidc_subject = ?", (oidc_subject,)
|
||||
@@ -327,18 +323,20 @@ class UserDB:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _get_user_by_id(self, conn: sqlite3.Connection, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
def _get_user_by_id(self, conn: sqlite3.Connection, user_id: int) -> dict[str, Any] | None:
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
_ALLOWED_UPDATE_COLUMNS = {
|
||||
"email",
|
||||
"display_name",
|
||||
"password_hash",
|
||||
"oidc_subject",
|
||||
"auth_source",
|
||||
"role",
|
||||
}
|
||||
_ALLOWED_UPDATE_COLUMNS: ClassVar[frozenset[str]] = frozenset(
|
||||
{
|
||||
"email",
|
||||
"display_name",
|
||||
"password_hash",
|
||||
"oidc_subject",
|
||||
"auth_source",
|
||||
"role",
|
||||
}
|
||||
)
|
||||
|
||||
def update_user(self, user_id: int, **kwargs) -> None:
|
||||
"""Update user fields. Raises ValueError if user not found or invalid column."""
|
||||
@@ -356,7 +354,7 @@ class UserDB:
|
||||
if not self._get_user_by_id(conn, user_id):
|
||||
raise ValueError(f"User {user_id} not found")
|
||||
sets = ", ".join(f"{k} = ?" for k in kwargs)
|
||||
values = list(kwargs.values()) + [user_id]
|
||||
values = [*list(kwargs.values()), user_id]
|
||||
conn.execute(f"UPDATE users SET {sets} WHERE id = ?", values)
|
||||
conn.commit()
|
||||
finally:
|
||||
@@ -386,13 +384,16 @@ class UserDB:
|
||||
"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(
|
||||
"UPDATE download_requests SET reviewed_by = NULL WHERE reviewed_by = ?",
|
||||
(user_id,),
|
||||
)
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_users(self) -> List[Dict[str, Any]]:
|
||||
def list_users(self) -> list[dict[str, Any]]:
|
||||
"""List all users."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
@@ -414,7 +415,7 @@ class UserDB:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_user_settings(self, user_id: int) -> Dict[str, Any]:
|
||||
def get_user_settings(self, user_id: int) -> dict[str, Any]:
|
||||
"""Get per-user settings. Returns empty dict if none set."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
@@ -427,7 +428,7 @@ class UserDB:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def set_user_settings(self, user_id: int, settings: Dict[str, Any]) -> None:
|
||||
def set_user_settings(self, user_id: int, settings: dict[str, Any]) -> None:
|
||||
"""Merge settings into user's existing settings."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
@@ -454,7 +455,7 @@ class UserDB:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _serialize_json(value: Any, field: str) -> Optional[str]:
|
||||
def _serialize_json(value: Any, field: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
@@ -463,7 +464,7 @@ class UserDB:
|
||||
raise ValueError(f"{field} must be JSON-serializable") from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_request_row(row: Optional[sqlite3.Row]) -> Optional[Dict[str, Any]]:
|
||||
def _parse_request_row(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
@@ -475,7 +476,7 @@ class UserDB:
|
||||
continue
|
||||
try:
|
||||
payload[key] = json.loads(raw_value)
|
||||
except (ValueError, TypeError):
|
||||
except ValueError, TypeError:
|
||||
payload[key] = None
|
||||
return payload
|
||||
|
||||
@@ -487,17 +488,17 @@ class UserDB:
|
||||
content_type: str,
|
||||
request_level: str,
|
||||
policy_mode: str,
|
||||
book_data: Dict[str, Any],
|
||||
release_data: Optional[Dict[str, Any]] = None,
|
||||
book_data: dict[str, Any],
|
||||
release_data: dict[str, Any] | None = 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,
|
||||
source_hint: str | None = None,
|
||||
note: str | None = None,
|
||||
admin_note: str | None = None,
|
||||
reviewed_by: int | None = None,
|
||||
reviewed_at: str | None = None,
|
||||
delivery_state: str = DELIVERY_STATE_NONE,
|
||||
delivery_updated_at: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
delivery_updated_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO download_requests (
|
||||
@@ -552,22 +553,22 @@ class UserDB:
|
||||
content_type: str,
|
||||
request_level: str,
|
||||
policy_mode: str,
|
||||
book_data: Dict[str, Any],
|
||||
release_data: Optional[Dict[str, Any]] = None,
|
||||
book_data: dict[str, Any],
|
||||
release_data: dict[str, Any] | None = 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,
|
||||
source_hint: str | None = None,
|
||||
note: str | None = None,
|
||||
admin_note: str | None = None,
|
||||
reviewed_by: int | None = None,
|
||||
reviewed_at: str | None = None,
|
||||
delivery_state: str = DELIVERY_STATE_NONE,
|
||||
delivery_updated_at: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
delivery_updated_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a download request row and return the created record."""
|
||||
if not isinstance(book_data, dict):
|
||||
raise ValueError("book_data must be an object")
|
||||
raise TypeError("book_data must be an object")
|
||||
if release_data is not None and not isinstance(release_data, dict):
|
||||
raise ValueError("release_data must be an object when provided")
|
||||
raise TypeError("release_data must be an object when provided")
|
||||
if not content_type:
|
||||
raise ValueError("content_type is required")
|
||||
|
||||
@@ -601,20 +602,18 @@ class UserDB:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def create_requests(self, requests: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
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))
|
||||
created = [self._insert_request(conn, **request) for request in requests]
|
||||
conn.commit()
|
||||
return created
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_request(self, request_id: int) -> Optional[Dict[str, Any]]:
|
||||
def get_request(self, request_id: int) -> dict[str, Any] | None:
|
||||
"""Get a request row by ID."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
@@ -629,14 +628,14 @@ class UserDB:
|
||||
def list_requests(
|
||||
self,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
user_id: int | None = None,
|
||||
status: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List requests with optional user/status filters."""
|
||||
where_clauses: List[str] = []
|
||||
params: List[Any] = []
|
||||
where_clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if user_id is not None:
|
||||
where_clauses.append("user_id = ?")
|
||||
@@ -664,7 +663,7 @@ class UserDB:
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
results: List[Dict[str, Any]] = []
|
||||
results: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
parsed = self._parse_request_row(row)
|
||||
if parsed is not None:
|
||||
@@ -673,29 +672,31 @@ class UserDB:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
_ALLOWED_REQUEST_UPDATE_COLUMNS = {
|
||||
"status",
|
||||
"source_hint",
|
||||
"content_type",
|
||||
"request_level",
|
||||
"policy_mode",
|
||||
"book_data",
|
||||
"release_data",
|
||||
"note",
|
||||
"admin_note",
|
||||
"reviewed_by",
|
||||
"reviewed_at",
|
||||
"delivery_state",
|
||||
"delivery_updated_at",
|
||||
"last_failure_reason",
|
||||
}
|
||||
_ALLOWED_REQUEST_UPDATE_COLUMNS: ClassVar[frozenset[str]] = frozenset(
|
||||
{
|
||||
"status",
|
||||
"source_hint",
|
||||
"content_type",
|
||||
"request_level",
|
||||
"policy_mode",
|
||||
"book_data",
|
||||
"release_data",
|
||||
"note",
|
||||
"admin_note",
|
||||
"reviewed_by",
|
||||
"reviewed_at",
|
||||
"delivery_state",
|
||||
"delivery_updated_at",
|
||||
"last_failure_reason",
|
||||
}
|
||||
)
|
||||
|
||||
def update_request(
|
||||
self,
|
||||
request_id: int,
|
||||
expected_current_status: Optional[str] = None,
|
||||
expected_current_status: str | None = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Update request fields and return the updated record."""
|
||||
if not kwargs:
|
||||
request = self.get_request(request_id)
|
||||
@@ -745,7 +746,7 @@ class UserDB:
|
||||
if "delivery_updated_at" in updates:
|
||||
delivery_updated_at = updates["delivery_updated_at"]
|
||||
if delivery_updated_at is not None and not isinstance(delivery_updated_at, str):
|
||||
raise ValueError("delivery_updated_at must be a string when provided")
|
||||
raise TypeError("delivery_updated_at must be a string when provided")
|
||||
|
||||
if "content_type" in updates and not updates["content_type"]:
|
||||
raise ValueError("content_type is required")
|
||||
@@ -755,19 +756,21 @@ class UserDB:
|
||||
|
||||
if "book_data" in updates:
|
||||
if not isinstance(updates["book_data"], dict):
|
||||
raise ValueError("book_data must be an object")
|
||||
raise TypeError("book_data must be an object")
|
||||
updates["book_data"] = self._serialize_json(updates["book_data"], "book_data")
|
||||
|
||||
if "release_data" in updates:
|
||||
if updates["release_data"] is not None and not isinstance(updates["release_data"], dict):
|
||||
raise ValueError("release_data must be an object when provided")
|
||||
if updates["release_data"] is not None and not isinstance(
|
||||
updates["release_data"], dict
|
||||
):
|
||||
raise TypeError("release_data must be an object when provided")
|
||||
updates["release_data"] = self._serialize_json(
|
||||
updates["release_data"],
|
||||
"release_data",
|
||||
)
|
||||
|
||||
set_clause = ", ".join(f"{column} = ?" for column in updates)
|
||||
values = list(updates.values()) + [request_id]
|
||||
values = [*list(updates.values()), request_id]
|
||||
conn.execute(
|
||||
f"UPDATE download_requests SET {set_clause} WHERE id = ?",
|
||||
values,
|
||||
@@ -789,8 +792,8 @@ class UserDB:
|
||||
self,
|
||||
request_id: int,
|
||||
*,
|
||||
failure_reason: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
failure_reason: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Reopen a failed fulfilled request so admins can re-approve it."""
|
||||
normalized_failure_reason = None
|
||||
if isinstance(failure_reason, str):
|
||||
@@ -849,9 +852,9 @@ class UserDB:
|
||||
self,
|
||||
request_id: int,
|
||||
*,
|
||||
release_data: Optional[Dict[str, Any]],
|
||||
last_failure_reason: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
release_data: dict[str, Any] | None,
|
||||
last_failure_reason: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Restore a request to pending after fulfilment claimed it but queueing failed."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
"""Shared helpers for user-overridable settings metadata and payloads."""
|
||||
|
||||
from typing import Any
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
def get_settings_registry():
|
||||
def get_settings_registry() -> ModuleType:
|
||||
# Ensure settings modules are loaded before reading registry metadata.
|
||||
import shelfmark.config.settings # noqa: F401
|
||||
import shelfmark.config.security # noqa: F401
|
||||
import shelfmark.config.notifications_settings # noqa: F401
|
||||
import shelfmark.config.users_settings # noqa: F401
|
||||
import_module("shelfmark.config.notifications_settings")
|
||||
import_module("shelfmark.config.security")
|
||||
import_module("shelfmark.config.settings")
|
||||
import_module("shelfmark.config.users_settings")
|
||||
from shelfmark.core import settings_registry
|
||||
|
||||
return settings_registry
|
||||
@@ -33,7 +38,8 @@ def build_user_preferences_payload(user_db: UserDB, user_id: int, tab_name: str)
|
||||
ordered_fields = get_ordered_user_overridable_fields(tab_name)
|
||||
if not ordered_fields:
|
||||
tab_label = tab_name.capitalize()
|
||||
raise ValueError(f"{tab_label} settings tab not found")
|
||||
msg = f"{tab_label} settings tab not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
tab_config = load_config_file(tab_name)
|
||||
user_settings = user_db.get_user_settings(user_id)
|
||||
@@ -45,7 +51,9 @@ def build_user_preferences_payload(user_db: UserDB, user_id: int, tab_name: str)
|
||||
|
||||
for key, field in ordered_fields:
|
||||
serialized = settings_registry.serialize_field(field, tab_name, include_value=False)
|
||||
serialized["fromEnv"] = bool(field.env_supported and settings_registry.is_value_from_env(field))
|
||||
serialized["fromEnv"] = bool(
|
||||
field.env_supported and settings_registry.is_value_from_env(field)
|
||||
)
|
||||
fields_payload.append(serialized)
|
||||
|
||||
global_values[key] = app_config.get(key, field.default)
|
||||
|
||||
+29
-25
@@ -4,15 +4,17 @@ 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 threading import Lock
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
def normalize_http_url(
|
||||
url: Optional[str],
|
||||
url: str | None,
|
||||
*,
|
||||
default_scheme: str = "http",
|
||||
strip_trailing_slash: bool = True,
|
||||
@@ -26,7 +28,7 @@ def normalize_http_url(
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
if (normalized.startswith("\"") and normalized.endswith("\"")) or (
|
||||
if (normalized.startswith('"') and normalized.endswith('"')) or (
|
||||
normalized.startswith("'") and normalized.endswith("'")
|
||||
):
|
||||
normalized = normalized[1:-1].strip()
|
||||
@@ -34,11 +36,7 @@ def normalize_http_url(
|
||||
return ""
|
||||
|
||||
if allow_special:
|
||||
special_map = {
|
||||
value.lower(): value
|
||||
for value in allow_special
|
||||
if isinstance(value, str)
|
||||
}
|
||||
special_map = {value.lower(): value for value in allow_special if isinstance(value, str)}
|
||||
special_match = special_map.get(normalized.lower())
|
||||
if special_match is not None:
|
||||
return special_match
|
||||
@@ -79,7 +77,7 @@ def get_hardened_xmlrpc_client() -> ModuleType:
|
||||
return importlib.import_module("xmlrpc.client")
|
||||
|
||||
|
||||
def normalize_base_path(value: Optional[str]) -> str:
|
||||
def normalize_base_path(value: str | None) -> str:
|
||||
"""Normalize a URL base path for reverse proxy subpath deployments."""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
@@ -101,7 +99,7 @@ def normalize_base_path(value: Optional[str]) -> str:
|
||||
return path.rstrip("/")
|
||||
|
||||
|
||||
def is_audiobook(content_type: Optional[str]) -> bool:
|
||||
def is_audiobook(content_type: str | None) -> bool:
|
||||
"""Check if content type indicates an audiobook."""
|
||||
return bool(content_type and "audiobook" in content_type.lower())
|
||||
|
||||
@@ -156,8 +154,8 @@ def _sanitize_user_for_path(username: str) -> str:
|
||||
|
||||
|
||||
def _resolve_destination_username(
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
) -> str:
|
||||
explicit = str(username or "").strip()
|
||||
if explicit:
|
||||
@@ -169,7 +167,7 @@ def _resolve_destination_username(
|
||||
try:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
user_db = UserDB(os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db"))
|
||||
user_db = UserDB(str(Path(os.environ.get("CONFIG_DIR", "/config")) / "users.db"))
|
||||
user_db.initialize()
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
@@ -181,8 +179,8 @@ def _resolve_destination_username(
|
||||
|
||||
def _expand_user_destination_placeholder(
|
||||
path_value: str,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
) -> str:
|
||||
"""Expand `{User}` placeholders in destination paths."""
|
||||
if not isinstance(path_value, str):
|
||||
@@ -198,9 +196,10 @@ def _expand_user_destination_placeholder(
|
||||
|
||||
|
||||
def get_destination(
|
||||
*,
|
||||
is_audiobook: bool = False,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
) -> Path:
|
||||
"""Get base destination directory. Audiobooks fall back to main destination."""
|
||||
from shelfmark.core.config import config
|
||||
@@ -219,7 +218,9 @@ def get_destination(
|
||||
|
||||
# Main destination (also fallback for audiobooks)
|
||||
# Check new setting first, then legacy INGEST_DIR
|
||||
destination = config.get("DESTINATION", "", user_id=user_id) or config.get("INGEST_DIR", "/books")
|
||||
destination = config.get("DESTINATION", "", user_id=user_id) or config.get(
|
||||
"INGEST_DIR", "/books"
|
||||
)
|
||||
return Path(
|
||||
_expand_user_destination_placeholder(
|
||||
str(destination),
|
||||
@@ -229,12 +230,14 @@ def get_destination(
|
||||
)
|
||||
|
||||
|
||||
def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path]:
|
||||
def get_aa_content_type_dir(content_type: str | None = None) -> Path | None:
|
||||
"""Get override directory for AA content-type routing if configured."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
# Check if content-type routing is enabled (new or legacy setting)
|
||||
if not config.get("AA_CONTENT_TYPE_ROUTING", False) and not config.get("USE_CONTENT_TYPE_DIRECTORIES", False):
|
||||
if not config.get("AA_CONTENT_TYPE_ROUTING", False) and not config.get(
|
||||
"USE_CONTENT_TYPE_DIRECTORIES", False
|
||||
):
|
||||
return None
|
||||
|
||||
if not content_type:
|
||||
@@ -253,7 +256,7 @@ def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path
|
||||
return None
|
||||
|
||||
|
||||
def get_ingest_dir(content_type: Optional[str] = None) -> Path:
|
||||
def get_ingest_dir(content_type: str | None = None) -> Path:
|
||||
"""DEPRECATED: Use get_destination() and get_aa_content_type_dir() instead."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
@@ -271,17 +274,18 @@ def get_ingest_dir(content_type: Optional[str] = None) -> Path:
|
||||
return default_ingest_dir
|
||||
|
||||
|
||||
def transform_cover_url(cover_url: Optional[str], cache_id: str) -> Optional[str]:
|
||||
def transform_cover_url(cover_url: str | None, cache_id: str) -> str | None:
|
||||
"""Transform external cover URL to local proxy URL when caching is enabled."""
|
||||
if not cover_url:
|
||||
return cover_url
|
||||
|
||||
# Skip if already a local URL (starts with /)
|
||||
if cover_url.startswith('/'):
|
||||
if cover_url.startswith("/"):
|
||||
return cover_url
|
||||
|
||||
# Check if cover caching is enabled
|
||||
from shelfmark.config.env import is_covers_cache_enabled
|
||||
|
||||
if not is_covers_cache_enabled():
|
||||
return cover_url
|
||||
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
"""Archive extraction utilities for downloaded book archives."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.download.fs import atomic_write
|
||||
from shelfmark.download.postprocess.policy import (
|
||||
get_supported_audiobook_formats,
|
||||
get_supported_formats,
|
||||
)
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.download.fs import atomic_write
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import rarfile
|
||||
|
||||
ArchiveType = zipfile.ZipFile | rarfile.RarFile
|
||||
else:
|
||||
ArchiveType = zipfile.ZipFile
|
||||
|
||||
|
||||
def _delete_file_with_logging(file_path: Path, file_type_label: str, *, rejected: bool) -> None:
|
||||
"""Delete a file and log the outcome."""
|
||||
try:
|
||||
file_path.unlink()
|
||||
if rejected:
|
||||
logger.debug("Deleted rejected %s file: %s", file_type_label, file_path.name)
|
||||
else:
|
||||
logger.debug("Deleted non-%s file: %s", file_type_label, file_path.name)
|
||||
except OSError as e:
|
||||
if rejected:
|
||||
logger.warning(
|
||||
"Failed to delete rejected %s file %s: %s", file_type_label, file_path, e
|
||||
)
|
||||
else:
|
||||
logger.warning("Failed to delete non-%s file %s: %s", file_type_label, file_path, e)
|
||||
|
||||
|
||||
# Check for rarfile availability at module load
|
||||
try:
|
||||
@@ -30,20 +52,14 @@ except ImportError:
|
||||
class ArchiveExtractionError(Exception):
|
||||
"""Raised when archive extraction fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PasswordProtectedError(ArchiveExtractionError):
|
||||
"""Raised when archive requires a password."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CorruptedArchiveError(ArchiveExtractionError):
|
||||
"""Raised when archive is corrupted."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def is_archive(file_path: Path) -> bool:
|
||||
"""Check if file is a supported archive format."""
|
||||
@@ -51,7 +67,7 @@ def is_archive(file_path: Path) -> bool:
|
||||
return suffix in ("zip", "rar")
|
||||
|
||||
|
||||
def _is_supported_file(file_path: Path, content_type: Optional[str] = None) -> bool:
|
||||
def _is_supported_file(file_path: Path, content_type: str | None = None) -> bool:
|
||||
"""Check if file matches user's supported formats setting based on content type."""
|
||||
ext = file_path.suffix.lower().lstrip(".")
|
||||
if check_audiobook(content_type):
|
||||
@@ -62,16 +78,30 @@ def _is_supported_file(file_path: Path, content_type: Optional[str] = None) -> b
|
||||
|
||||
|
||||
# All known ebook extensions (superset of what user might enable)
|
||||
ALL_EBOOK_EXTENSIONS = {'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr', '.doc', '.docx', '.rtf', '.txt'}
|
||||
ALL_EBOOK_EXTENSIONS = {
|
||||
".pdf",
|
||||
".epub",
|
||||
".mobi",
|
||||
".azw",
|
||||
".azw3",
|
||||
".fb2",
|
||||
".djvu",
|
||||
".cbz",
|
||||
".cbr",
|
||||
".doc",
|
||||
".docx",
|
||||
".rtf",
|
||||
".txt",
|
||||
}
|
||||
|
||||
# All known audio extensions (superset of what user might enable for audiobooks)
|
||||
ALL_AUDIO_EXTENSIONS = {'.m4b', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.wma', '.wav', '.opus'}
|
||||
ALL_AUDIO_EXTENSIONS = {".m4b", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".wma", ".wav", ".opus"}
|
||||
|
||||
|
||||
def _filter_files(
|
||||
extracted_files: List[Path],
|
||||
content_type: Optional[str] = None,
|
||||
) -> Tuple[List[Path], List[Path], List[Path]]:
|
||||
extracted_files: list[Path],
|
||||
content_type: str | None = None,
|
||||
) -> tuple[list[Path], list[Path], list[Path]]:
|
||||
"""Filter files by content type. Returns (matched, rejected_format, other)."""
|
||||
is_audiobook = check_audiobook(content_type)
|
||||
known_extensions = ALL_AUDIO_EXTENSIONS if is_audiobook else ALL_EBOOK_EXTENSIONS
|
||||
@@ -94,8 +124,8 @@ def _filter_files(
|
||||
def extract_archive(
|
||||
archive_path: Path,
|
||||
output_dir: Path,
|
||||
content_type: Optional[str] = None,
|
||||
) -> Tuple[List[Path], List[str], List[Path]]:
|
||||
content_type: str | None = None,
|
||||
) -> tuple[list[Path], list[str], list[Path]]:
|
||||
"""Extract archive and filter by content type. Returns (matched, warnings, rejected)."""
|
||||
suffix = archive_path.suffix.lower().lstrip(".")
|
||||
|
||||
@@ -114,23 +144,17 @@ def extract_archive(
|
||||
|
||||
# Delete rejected files (valid formats but not enabled by user)
|
||||
for rejected_file in rejected_files:
|
||||
try:
|
||||
rejected_file.unlink()
|
||||
logger.debug(f"Deleted rejected {file_type_label} file: {rejected_file.name}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to delete rejected {file_type_label} file {rejected_file}: {e}")
|
||||
_delete_file_with_logging(rejected_file, file_type_label, rejected=True)
|
||||
|
||||
if rejected_files:
|
||||
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
|
||||
warnings.append(f"Skipped {len(rejected_files)} {file_type_label}(s) with unsupported format: {', '.join(rejected_exts)}")
|
||||
rejected_exts = sorted({f.suffix.lower() for f in rejected_files})
|
||||
warnings.append(
|
||||
f"Skipped {len(rejected_files)} {file_type_label}(s) with unsupported format: {', '.join(rejected_exts)}"
|
||||
)
|
||||
|
||||
# Delete other files (images, html, etc)
|
||||
for other_file in other_files:
|
||||
try:
|
||||
other_file.unlink()
|
||||
logger.debug(f"Deleted non-{file_type_label} file: {other_file.name}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to delete non-{file_type_label} file {other_file}: {e}")
|
||||
_delete_file_with_logging(other_file, file_type_label, rejected=False)
|
||||
|
||||
if other_files:
|
||||
warnings.append(f"Skipped {len(other_files)} non-{file_type_label} file(s)")
|
||||
@@ -141,7 +165,7 @@ def extract_archive(
|
||||
def extract_archive_raw(
|
||||
archive_path: Path,
|
||||
output_dir: Path,
|
||||
) -> Tuple[List[Path], List[str]]:
|
||||
) -> tuple[list[Path], list[str]]:
|
||||
"""Extract archive without filtering (returns all extracted files)."""
|
||||
suffix = archive_path.suffix.lower().lstrip(".")
|
||||
|
||||
@@ -153,7 +177,7 @@ def extract_archive_raw(
|
||||
raise ArchiveExtractionError(f"Unsupported archive format: {suffix}")
|
||||
|
||||
|
||||
def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]:
|
||||
def _extract_files_from_archive(archive: ArchiveType, output_dir: Path) -> list[Path]:
|
||||
"""Extract files from ZipFile or RarFile to output_dir with security checks."""
|
||||
extracted_files = []
|
||||
|
||||
@@ -169,7 +193,7 @@ def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]:
|
||||
# Security: reject filenames with null bytes or path separators
|
||||
# Check both / and \ since archives may be created on different OSes
|
||||
if "\x00" in filename or "/" in filename or "\\" in filename:
|
||||
logger.warning(f"Skipping suspicious filename in archive: {info.filename!r}")
|
||||
logger.warning("Skipping suspicious filename in archive: %r", info.filename)
|
||||
continue
|
||||
|
||||
# Extract to output_dir with flat structure
|
||||
@@ -179,19 +203,19 @@ def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]:
|
||||
try:
|
||||
target_path.resolve().relative_to(output_dir.resolve())
|
||||
except ValueError:
|
||||
logger.warning(f"Path traversal attempt blocked: {info.filename!r}")
|
||||
logger.warning("Path traversal attempt blocked: %r", info.filename)
|
||||
continue
|
||||
|
||||
with archive.open(info) as src:
|
||||
data = src.read()
|
||||
final_path = atomic_write(target_path, data)
|
||||
extracted_files.append(final_path)
|
||||
logger.debug(f"Extracted: {filename}")
|
||||
logger.debug("Extracted: %s", filename)
|
||||
|
||||
return extracted_files
|
||||
|
||||
|
||||
def _extract_zip(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List[str]]:
|
||||
def _extract_zip(archive_path: Path, output_dir: Path) -> tuple[list[Path], list[str]]:
|
||||
"""Extract files from a ZIP archive."""
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||
@@ -208,12 +232,12 @@ def _extract_zip(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List
|
||||
return _extract_files_from_archive(zf, output_dir), []
|
||||
|
||||
except zipfile.BadZipFile as e:
|
||||
raise CorruptedArchiveError(f"Invalid or corrupted ZIP: {e}")
|
||||
raise CorruptedArchiveError(f"Invalid or corrupted ZIP: {e}") from e
|
||||
except PermissionError as e:
|
||||
raise ArchiveExtractionError(f"Permission denied: {e}")
|
||||
raise ArchiveExtractionError(f"Permission denied: {e}") from e
|
||||
|
||||
|
||||
def _extract_rar(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List[str]]:
|
||||
def _extract_rar(archive_path: Path, output_dir: Path) -> tuple[list[Path], list[str]]:
|
||||
"""Extract files from a RAR archive."""
|
||||
if not RAR_AVAILABLE:
|
||||
raise ArchiveExtractionError("RAR extraction not available - rarfile library not installed")
|
||||
@@ -230,10 +254,8 @@ def _extract_rar(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List
|
||||
return _extract_files_from_archive(rf, output_dir), []
|
||||
|
||||
except rarfile.BadRarFile as e:
|
||||
raise CorruptedArchiveError(f"Invalid or corrupted RAR: {e}")
|
||||
except rarfile.RarCannotExec:
|
||||
raise ArchiveExtractionError("unrar binary not found - install unrar package")
|
||||
raise CorruptedArchiveError(f"Invalid or corrupted RAR: {e}") from e
|
||||
except rarfile.RarCannotExec as e:
|
||||
raise ArchiveExtractionError("unrar binary not found - install unrar package") from e
|
||||
except PermissionError as e:
|
||||
raise ArchiveExtractionError(f"Permission denied: {e}")
|
||||
|
||||
|
||||
raise ArchiveExtractionError(f"Permission denied: {e}") from e
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
Shared download client infrastructure for external release sources.
|
||||
"""Shared download client infrastructure for external release sources.
|
||||
|
||||
This module provides:
|
||||
- DownloadState: Enum of valid download states
|
||||
@@ -18,14 +17,18 @@ from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union, cast, Any
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, TypeVar, cast
|
||||
|
||||
import requests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Type variable for generic return type
|
||||
T = TypeVar('T')
|
||||
T = TypeVar("T")
|
||||
|
||||
# Exceptions that should trigger a retry
|
||||
RETRYABLE_EXCEPTIONS = (
|
||||
@@ -33,6 +36,9 @@ RETRYABLE_EXCEPTIONS = (
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.HTTPError,
|
||||
)
|
||||
_MIN_RETRYABLE_STATUS = 500
|
||||
_MIN_PROGRESS_PERCENT = 0
|
||||
_MAX_PROGRESS_PERCENT = 100
|
||||
|
||||
|
||||
def with_retry(
|
||||
@@ -41,8 +47,7 @@ def with_retry(
|
||||
max_delay: float = 10.0,
|
||||
jitter: float = 0.5,
|
||||
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
||||
"""
|
||||
Decorator for retrying API calls with exponential backoff.
|
||||
"""Decorator for retrying API calls with exponential backoff.
|
||||
|
||||
Args:
|
||||
max_attempts: Maximum number of attempts (default 3)
|
||||
@@ -58,7 +63,9 @@ def with_retry(
|
||||
Does NOT retry on:
|
||||
- HTTP 4xx client errors (bad request, auth failures)
|
||||
- Other exceptions (programming errors)
|
||||
|
||||
"""
|
||||
|
||||
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> T:
|
||||
@@ -69,7 +76,7 @@ def with_retry(
|
||||
return func(*args, **kwargs)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
# Only retry on server errors (5xx), not client errors (4xx)
|
||||
if e.response is not None and e.response.status_code < 500:
|
||||
if e.response is not None and e.response.status_code < _MIN_RETRYABLE_STATUS:
|
||||
raise
|
||||
last_exception = e
|
||||
except RETRYABLE_EXCEPTIONS as e:
|
||||
@@ -81,17 +88,23 @@ def with_retry(
|
||||
# Add jitter to prevent thundering herd
|
||||
delay += random.uniform(0, delay * jitter)
|
||||
_logger.debug(
|
||||
f"Retry {attempt}/{max_attempts} for {func.__name__} "
|
||||
f"after {delay:.1f}s (error: {last_exception})"
|
||||
"Retry %s/%s for %s after %.1fs (error: %s)",
|
||||
attempt,
|
||||
max_attempts,
|
||||
func.__name__,
|
||||
delay,
|
||||
last_exception,
|
||||
)
|
||||
time.sleep(delay)
|
||||
|
||||
# All retries exhausted
|
||||
if last_exception is None:
|
||||
raise RuntimeError("Retry failed without exception")
|
||||
raise cast(Exception, last_exception)
|
||||
msg = "Retry failed without exception"
|
||||
raise RuntimeError(msg)
|
||||
raise cast("Exception", last_exception)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -114,15 +127,15 @@ class DownloadStatus:
|
||||
"""Status of an external download (immutable)."""
|
||||
|
||||
progress: float # 0-100
|
||||
state: Union[DownloadState, str] # Prefer DownloadState enum; strings auto-normalized
|
||||
message: Optional[str] # Status message
|
||||
state: DownloadState | str # Prefer DownloadState enum; strings auto-normalized
|
||||
message: str | None # Status message
|
||||
complete: bool # True when download finished
|
||||
file_path: Optional[str] # Path in client's download dir (when complete)
|
||||
download_speed: Optional[int] = None # Bytes per second
|
||||
eta: Optional[int] = None # Seconds remaining
|
||||
file_path: str | None # Path in client's download dir (when complete)
|
||||
download_speed: int | None = None # Bytes per second
|
||||
eta: int | None = None # Seconds remaining
|
||||
|
||||
@classmethod
|
||||
def error(cls, message: str) -> "DownloadStatus":
|
||||
def error(cls, message: str) -> DownloadStatus:
|
||||
"""Create an error status."""
|
||||
return cls(
|
||||
progress=0,
|
||||
@@ -132,21 +145,25 @@ class DownloadStatus:
|
||||
file_path=None,
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate and normalize state."""
|
||||
# Normalize string states to enum
|
||||
if isinstance(self.state, str):
|
||||
try:
|
||||
normalized_state = DownloadState(self.state)
|
||||
object.__setattr__(self, 'state', normalized_state)
|
||||
object.__setattr__(self, "state", normalized_state)
|
||||
except ValueError:
|
||||
# Unknown state string - keep as-is for backwards compatibility
|
||||
_logger.warning(f"Unknown download state '{self.state}', keeping as string")
|
||||
_logger.warning(
|
||||
_logger.warning("Unknown download state '%s', keeping as string", self.state)
|
||||
)
|
||||
|
||||
# Validate progress is in range
|
||||
if not 0 <= self.progress <= 100:
|
||||
_logger.debug(f"Progress {self.progress} out of range, clamping to [0, 100]")
|
||||
object.__setattr__(self, 'progress', max(0, min(100, self.progress)))
|
||||
if not _MIN_PROGRESS_PERCENT <= self.progress <= _MAX_PROGRESS_PERCENT:
|
||||
_logger.debug(
|
||||
_logger.debug("Progress %s out of range, clamping to [0, 100]", self.progress)
|
||||
)
|
||||
object.__setattr__(self, "progress", max(0, min(100, self.progress)))
|
||||
|
||||
@property
|
||||
def state_value(self) -> str:
|
||||
@@ -157,8 +174,7 @@ class DownloadStatus:
|
||||
|
||||
|
||||
class DownloadClient(ABC):
|
||||
"""
|
||||
Base class for external download clients.
|
||||
"""Base class for external download clients.
|
||||
|
||||
Subclasses implement protocol-specific download management:
|
||||
- Torrent clients: qBittorrent, Transmission, Deluge
|
||||
@@ -174,8 +190,7 @@ class DownloadClient(ABC):
|
||||
name: str
|
||||
|
||||
def _log_error(self, method: str, e: Exception, level: str = "error") -> str:
|
||||
"""
|
||||
Log a client error with consistent formatting.
|
||||
"""Log a client error with consistent formatting.
|
||||
|
||||
Args:
|
||||
method: Name of the method that failed (e.g., "get_status")
|
||||
@@ -184,6 +199,7 @@ class DownloadClient(ABC):
|
||||
|
||||
Returns:
|
||||
Formatted error message string (for use in DownloadStatus.error())
|
||||
|
||||
"""
|
||||
error_type = type(e).__name__
|
||||
msg = f"{self.name} {method} failed ({error_type}): {e}"
|
||||
@@ -198,15 +214,15 @@ class DownloadClient(ABC):
|
||||
|
||||
return f"{error_type}: {e}"
|
||||
|
||||
def _build_path(self, *components: str) -> Optional[str]:
|
||||
"""
|
||||
Safely build a file path from components.
|
||||
def _build_path(self, *components: str) -> str | None:
|
||||
"""Safely build a file path from components.
|
||||
|
||||
Args:
|
||||
*components: Path components to join (e.g., save_path, name)
|
||||
|
||||
Returns:
|
||||
Normalized path string, or None if any component is empty/None.
|
||||
|
||||
"""
|
||||
# Filter out empty/None components
|
||||
valid = [c for c in components if c]
|
||||
@@ -214,9 +230,9 @@ class DownloadClient(ABC):
|
||||
return None
|
||||
|
||||
# Join and normalize
|
||||
return os.path.normpath(os.path.join(*valid))
|
||||
return os.path.normpath(str(Path(valid[0]).joinpath(*valid[1:])))
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
def __init_subclass__(cls, **kwargs) -> None:
|
||||
"""Validate that subclasses define required class attributes."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
@@ -225,48 +241,46 @@ class DownloadClient(ABC):
|
||||
return
|
||||
|
||||
# Validate protocol attribute
|
||||
if not hasattr(cls, 'protocol') or not cls.protocol:
|
||||
raise TypeError(f"{cls.__name__} must define 'protocol' class attribute")
|
||||
if cls.protocol not in ('torrent', 'usenet'):
|
||||
raise TypeError(
|
||||
f"{cls.__name__}.protocol must be 'torrent' or 'usenet', got '{cls.protocol}'"
|
||||
)
|
||||
if not hasattr(cls, "protocol") or not cls.protocol:
|
||||
msg = f"{cls.__name__} must define 'protocol' class attribute"
|
||||
raise TypeError(msg)
|
||||
if cls.protocol not in ("torrent", "usenet"):
|
||||
msg = f"{cls.__name__}.protocol must be 'torrent' or 'usenet', got '{cls.protocol}'"
|
||||
raise TypeError(msg)
|
||||
|
||||
# Validate name attribute
|
||||
if not hasattr(cls, 'name') or not cls.name:
|
||||
raise TypeError(f"{cls.__name__} must define 'name' class attribute")
|
||||
if not hasattr(cls, "name") or not cls.name:
|
||||
msg = f"{cls.__name__} must define 'name' class attribute"
|
||||
raise TypeError(msg)
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def is_configured() -> bool:
|
||||
"""
|
||||
Check if this client is configured.
|
||||
"""Check if this client is configured.
|
||||
|
||||
Returns:
|
||||
True if required settings (URL, etc.) are present.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
Test connectivity to the client.
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Test connectivity to the client.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message).
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
|
||||
"""Add a download to the client.
|
||||
|
||||
Args:
|
||||
@@ -280,26 +294,24 @@ class DownloadClient(ABC):
|
||||
|
||||
Raises:
|
||||
Exception: If adding fails.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""
|
||||
Get status of a download.
|
||||
"""Get status of a download.
|
||||
|
||||
Args:
|
||||
download_id: The ID returned by add_download()
|
||||
|
||||
Returns:
|
||||
Current download status.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
"""
|
||||
Remove a download from the client.
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
"""Remove a download from the client.
|
||||
|
||||
Args:
|
||||
download_id: The ID returned by add_download()
|
||||
@@ -307,27 +319,25 @@ class DownloadClient(ABC):
|
||||
|
||||
Returns:
|
||||
True if removal succeeded.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get the path where files were downloaded.
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where files were downloaded.
|
||||
|
||||
Args:
|
||||
download_id: The ID returned by add_download()
|
||||
|
||||
Returns:
|
||||
File or directory path, or None if not available.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
"""
|
||||
Check if a download for this URL already exists in the client.
|
||||
self, url: str, category: str | None = None
|
||||
) -> tuple[str, DownloadStatus] | None:
|
||||
"""Check if a download for this URL already exists in the client.
|
||||
|
||||
This is useful for detecting already-completed downloads so we can
|
||||
skip re-downloading and just copy the existing file.
|
||||
@@ -339,17 +349,19 @@ class DownloadClient(ABC):
|
||||
Returns:
|
||||
Tuple of (download_id, status) if found, None if not found.
|
||||
Default implementation returns None.
|
||||
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
# Client registry: protocol -> list of client classes
|
||||
_CLIENTS: Dict[str, List[Type[DownloadClient]]] = {}
|
||||
_CLIENTS: dict[str, list[type[DownloadClient]]] = {}
|
||||
|
||||
|
||||
def register_client(protocol: str):
|
||||
"""
|
||||
Decorator to register a download client for a protocol.
|
||||
def register_client(
|
||||
protocol: str,
|
||||
) -> Callable[[type[DownloadClient]], type[DownloadClient]]:
|
||||
"""Decorator to register a download client for a protocol.
|
||||
|
||||
Multiple clients can be registered for the same protocol.
|
||||
The `is_configured()` method determines which one is active.
|
||||
@@ -361,9 +373,10 @@ def register_client(protocol: str):
|
||||
@register_client("torrent")
|
||||
class QBittorrentClient(DownloadClient):
|
||||
...
|
||||
|
||||
"""
|
||||
|
||||
def decorator(cls: Type[DownloadClient]) -> Type[DownloadClient]:
|
||||
def decorator(cls: type[DownloadClient]) -> type[DownloadClient]:
|
||||
if protocol not in _CLIENTS:
|
||||
_CLIENTS[protocol] = []
|
||||
_CLIENTS[protocol].append(cls)
|
||||
@@ -372,9 +385,8 @@ def register_client(protocol: str):
|
||||
return decorator
|
||||
|
||||
|
||||
def get_client(protocol: str) -> Optional[DownloadClient]:
|
||||
"""
|
||||
Get a configured client instance for the given protocol.
|
||||
def get_client(protocol: str) -> DownloadClient | None:
|
||||
"""Get a configured client instance for the given protocol.
|
||||
|
||||
Iterates through all registered clients for the protocol and
|
||||
returns the first one that is configured.
|
||||
@@ -384,6 +396,7 @@ def get_client(protocol: str) -> Optional[DownloadClient]:
|
||||
|
||||
Returns:
|
||||
Configured client instance, or None if not available/configured.
|
||||
|
||||
"""
|
||||
if protocol not in _CLIENTS:
|
||||
return None
|
||||
@@ -395,12 +408,12 @@ def get_client(protocol: str) -> Optional[DownloadClient]:
|
||||
return None
|
||||
|
||||
|
||||
def list_configured_clients() -> List[str]:
|
||||
"""
|
||||
List protocols that have configured clients.
|
||||
def list_configured_clients() -> list[str]:
|
||||
"""List protocols that have configured clients.
|
||||
|
||||
Returns:
|
||||
List of protocol names (e.g., ["torrent", "usenet"]).
|
||||
|
||||
"""
|
||||
result = []
|
||||
for protocol, client_classes in _CLIENTS.items():
|
||||
@@ -411,21 +424,21 @@ def list_configured_clients() -> List[str]:
|
||||
return result
|
||||
|
||||
|
||||
def get_all_clients() -> Dict[str, List[Type[DownloadClient]]]:
|
||||
"""
|
||||
Get all registered client classes.
|
||||
def get_all_clients() -> dict[str, list[type[DownloadClient]]]:
|
||||
"""Get all registered client classes.
|
||||
|
||||
Returns:
|
||||
Dict of protocol -> list of client classes.
|
||||
|
||||
"""
|
||||
return dict(_CLIENTS)
|
||||
|
||||
|
||||
# Import client implementations to trigger registration
|
||||
# These imports are at the bottom to avoid circular imports
|
||||
from shelfmark.download.clients import qbittorrent # noqa: F401, E402
|
||||
from shelfmark.download.clients import nzbget # noqa: F401, E402
|
||||
from shelfmark.download.clients import sabnzbd # noqa: F401, E402
|
||||
from shelfmark.download.clients import transmission # noqa: F401, E402
|
||||
from shelfmark.download.clients import deluge # noqa: F401, E402
|
||||
from shelfmark.download.clients import rtorrent # noqa: F401, E402
|
||||
from shelfmark.download.clients import deluge as deluge
|
||||
from shelfmark.download.clients import nzbget as nzbget
|
||||
from shelfmark.download.clients import qbittorrent as qbittorrent
|
||||
from shelfmark.download.clients import rtorrent as rtorrent
|
||||
from shelfmark.download.clients import sabnzbd as sabnzbd
|
||||
from shelfmark.download.clients import transmission as transmission
|
||||
|
||||
@@ -5,22 +5,27 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Callable, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.utils import is_audiobook
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadState,
|
||||
DownloadStatus,
|
||||
get_client,
|
||||
list_configured_clients,
|
||||
)
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
from shelfmark.release_sources import DownloadHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from threading import Event
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# How often to poll the download client for status (seconds)
|
||||
@@ -37,23 +42,23 @@ class DownloadRequest:
|
||||
url: str
|
||||
protocol: str
|
||||
release_name: str
|
||||
expected_hash: Optional[str]
|
||||
seeding_time_limit: Optional[int] = None # minutes
|
||||
ratio_limit: Optional[float] = None
|
||||
expected_hash: str | None
|
||||
seeding_time_limit: int | None = None # minutes
|
||||
ratio_limit: float | None = None
|
||||
|
||||
|
||||
def _diagnose_path_issue(path: str) -> str:
|
||||
"""
|
||||
Analyze a path and return diagnostic hints for common issues.
|
||||
"""Analyze a path and return diagnostic hints for common issues.
|
||||
|
||||
Args:
|
||||
path: The path that failed to be accessed
|
||||
|
||||
Returns:
|
||||
A hint string to help users diagnose the issue.
|
||||
|
||||
"""
|
||||
# Detect Windows-style paths (won't work in Linux containers)
|
||||
if len(path) >= 2 and path[1] == ':':
|
||||
if len(path) >= 2 and path[1] == ":":
|
||||
return (
|
||||
f"Path '{path}' appears to be a Windows path. "
|
||||
f"Shelfmark runs in Linux and cannot access Windows paths directly. "
|
||||
@@ -79,7 +84,7 @@ def _diagnose_path_issue(path: str) -> str:
|
||||
class ExternalClientHandler(DownloadHandler, ABC):
|
||||
"""Shared lifecycle handler for sources that hand off to torrent/usenet clients."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
# Track downloads that may need client-side cleanup after Shelfmark completes import.
|
||||
# task_id -> (client, download_id, protocol)
|
||||
self._cleanup_refs: dict[str, tuple[DownloadClient, str, str]] = {}
|
||||
@@ -88,15 +93,15 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
def _resolve_download(
|
||||
self,
|
||||
task: DownloadTask,
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> Optional[DownloadRequest]:
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> DownloadRequest | None:
|
||||
"""Resolve source-specific task metadata into a client download request."""
|
||||
|
||||
def _on_download_complete(self, task: DownloadTask) -> None:
|
||||
"""Hook called after successful completion; override for source cleanup."""
|
||||
return
|
||||
|
||||
def _get_client(self, protocol: str) -> Optional[DownloadClient]:
|
||||
def _get_client(self, protocol: str) -> DownloadClient | None:
|
||||
"""Resolve the active client for a protocol."""
|
||||
return get_client(protocol)
|
||||
|
||||
@@ -116,7 +121,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
"""Maximum attempts when waiting for completed files."""
|
||||
return COMPLETED_PATH_MAX_ATTEMPTS
|
||||
|
||||
def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> Optional[str]:
|
||||
def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> str | None:
|
||||
"""Get audiobook category if configured and applicable, else None for default."""
|
||||
if not is_audiobook(task.content_type):
|
||||
return None
|
||||
@@ -132,7 +137,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
audiobook_key = audiobook_keys.get(client.name)
|
||||
return config.get(audiobook_key, "") or None if audiobook_key else None
|
||||
|
||||
def post_process_cleanup(self, task: DownloadTask, success: bool) -> None:
|
||||
def post_process_cleanup(self, task: DownloadTask, *, success: bool) -> None:
|
||||
if not success:
|
||||
self._cleanup_refs.pop(task.task_id, None)
|
||||
return
|
||||
@@ -152,7 +157,10 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
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}"
|
||||
"Failed to cleanup usenet download %s in %s: %s",
|
||||
download_id,
|
||||
getattr(client, "name", "client"),
|
||||
e,
|
||||
)
|
||||
|
||||
elif protocol == "torrent":
|
||||
@@ -162,7 +170,10 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
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}"
|
||||
"Failed to remove torrent %s from %s: %s",
|
||||
download_id,
|
||||
getattr(client, "name", "client"),
|
||||
e,
|
||||
)
|
||||
|
||||
def _remove_usenet_download(
|
||||
@@ -184,11 +195,13 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
try:
|
||||
raw_path = client.get_download_path(download_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to resolve download path for {client.name} {download_id}: {e}")
|
||||
logger.debug(
|
||||
"Failed to resolve download path for %s %s: %s", client.name, download_id, e
|
||||
)
|
||||
return
|
||||
|
||||
if not raw_path:
|
||||
logger.debug(f"No download path available for {client.name} {download_id}")
|
||||
logger.debug("No download path available for %s %s", client.name, download_id)
|
||||
return
|
||||
|
||||
from shelfmark.core.path_mappings import (
|
||||
@@ -210,11 +223,16 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
delete_path = remapped if matched_mapping else source_path_obj
|
||||
|
||||
if str(delete_path) in ("", "/"):
|
||||
logger.warning(f"Refusing to delete unsafe path for {client.name} {download_id}: {delete_path}")
|
||||
logger.warning(
|
||||
"Refusing to delete unsafe path for %s %s: %s",
|
||||
client.name,
|
||||
download_id,
|
||||
delete_path,
|
||||
)
|
||||
return
|
||||
|
||||
if not run_blocking_io(delete_path.exists):
|
||||
logger.debug(f"Local download path does not exist for cleanup: {delete_path}")
|
||||
logger.debug("Local download path does not exist for cleanup: %s", delete_path)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -222,18 +240,27 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
run_blocking_io(shutil.rmtree, delete_path)
|
||||
else:
|
||||
run_blocking_io(delete_path.unlink)
|
||||
logger.info(f"Deleted local download data for {client.name} {download_id}: {delete_path}")
|
||||
logger.info(
|
||||
"Deleted local download data for %s %s: %s", client.name, download_id, delete_path
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete local download data for {client.name} {download_id}: {e}")
|
||||
logger.warning(
|
||||
"Failed to delete local download data for %s %s: %s", client.name, download_id, e
|
||||
)
|
||||
|
||||
def _safe_remove_download(self, client, download_id: str, protocol: str, reason: str) -> None:
|
||||
def _safe_remove_download(
|
||||
self,
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
protocol: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Best-effort removal of a failed/cancelled download from the client.
|
||||
|
||||
Safety policy:
|
||||
- torrents: never remove or delete client data (avoid breaking seeding)
|
||||
- usenet: keep legacy behavior (delete client files on removal)
|
||||
"""
|
||||
|
||||
if protocol != "usenet":
|
||||
logger.info(
|
||||
"Skipping download client cleanup for protocol=%s after %s (client=%s id=%s)",
|
||||
@@ -250,7 +277,11 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
self._remove_usenet_download(client, download_id, delete_files=True, archive=False)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to remove download {download_id} from {client.name} after {reason}: {e}"
|
||||
"Failed to remove download %s from %s after %s: %s",
|
||||
download_id,
|
||||
client.name,
|
||||
reason,
|
||||
e,
|
||||
)
|
||||
|
||||
def _handle_cancelled_download(
|
||||
@@ -258,20 +289,26 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
protocol: str,
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> None:
|
||||
if protocol == "usenet":
|
||||
logger.info(f"Download cancelled, removing from {client.name}: {download_id}")
|
||||
logger.info("Download cancelled, removing from %s: %s", client.name, download_id)
|
||||
try:
|
||||
self._delete_local_download_data(client, download_id)
|
||||
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to remove download {download_id} from {client.name} after cancellation: {e}"
|
||||
"Failed to remove download %s from %s after cancellation: %s",
|
||||
download_id,
|
||||
client.name,
|
||||
e,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Download cancelled for protocol={protocol}; leaving in {client.name}: {download_id}"
|
||||
"Download cancelled for protocol=%s; leaving in %s: %s",
|
||||
protocol,
|
||||
client.name,
|
||||
download_id,
|
||||
)
|
||||
status_callback("cancelled", "Cancelled")
|
||||
|
||||
@@ -281,7 +318,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
download_id: str,
|
||||
*,
|
||||
log_details: bool,
|
||||
) -> tuple[Optional[Path], Optional[str]]:
|
||||
) -> tuple[Path | None, str | None]:
|
||||
"""Resolve and validate the completed download path once."""
|
||||
try:
|
||||
raw_path = client.get_download_path(download_id)
|
||||
@@ -291,12 +328,12 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
f"Check volume mappings and category settings."
|
||||
)
|
||||
if log_details:
|
||||
logger.error(
|
||||
f"Failed to resolve download path for {client.name} {download_id}: {e}"
|
||||
logger.exception(
|
||||
"Failed to resolve download path for %s %s", client.name, download_id
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Failed to resolve download path for {client.name} {download_id}: {e}"
|
||||
"Failed to resolve download path for %s %s: %s", client.name, download_id, e
|
||||
)
|
||||
return None, message
|
||||
|
||||
@@ -306,9 +343,13 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
f"Check volume mappings and category settings."
|
||||
)
|
||||
if log_details:
|
||||
logger.error(f"Download client returned empty path for {client.name} {download_id}")
|
||||
logger.error(
|
||||
"Download client returned empty path for %s %s", client.name, download_id
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Download client returned empty path for {client.name} {download_id}")
|
||||
logger.debug(
|
||||
"Download client returned empty path for %s %s", client.name, download_id
|
||||
)
|
||||
return None, message
|
||||
|
||||
from shelfmark.core.path_mappings import (
|
||||
@@ -365,13 +406,19 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
)
|
||||
if log_details:
|
||||
logger.error(
|
||||
f"Download path does not exist after remapping: {raw_path} -> {remapped}. "
|
||||
f"Client: {client.name}, ID: {download_id}."
|
||||
"Download path does not exist after remapping: %s -> %s. Client: %s, ID: %s.",
|
||||
raw_path,
|
||||
remapped,
|
||||
client.name,
|
||||
download_id,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Download path does not exist after remapping: {raw_path} -> {remapped}. "
|
||||
f"Client: {client.name}, ID: {download_id}."
|
||||
"Download path does not exist after remapping: %s -> %s. Client: %s, ID: %s.",
|
||||
raw_path,
|
||||
remapped,
|
||||
client.name,
|
||||
download_id,
|
||||
)
|
||||
return None, message
|
||||
|
||||
@@ -389,13 +436,19 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
message = f"{hint} No remote path mapping matched for client '{client.name}'."
|
||||
if log_details:
|
||||
logger.error(
|
||||
f"Download path does not exist and no remote path mapping matched for {client.name} "
|
||||
f"({download_id}): {raw_path}. {hint}"
|
||||
"Download path does not exist and no remote path mapping matched for %s (%s): %s. %s",
|
||||
client.name,
|
||||
download_id,
|
||||
raw_path,
|
||||
hint,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Download path does not exist and no remote path mapping matched for {client.name} "
|
||||
f"({download_id}): {raw_path}. {hint}"
|
||||
"Download path does not exist and no remote path mapping matched for %s (%s): %s. %s",
|
||||
client.name,
|
||||
download_id,
|
||||
raw_path,
|
||||
hint,
|
||||
)
|
||||
return None, message
|
||||
|
||||
@@ -404,13 +457,19 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
message = hint
|
||||
if log_details:
|
||||
logger.error(
|
||||
f"Download path does not exist: {raw_path}. "
|
||||
f"Client: {client.name}, ID: {download_id}. {hint}"
|
||||
"Download path does not exist: %s. Client: %s, ID: %s. %s",
|
||||
raw_path,
|
||||
client.name,
|
||||
download_id,
|
||||
hint,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Download path does not exist: {raw_path}. "
|
||||
f"Client: {client.name}, ID: {download_id}. {hint}"
|
||||
"Download path does not exist: %s. Client: %s, ID: %s. %s",
|
||||
raw_path,
|
||||
client.name,
|
||||
download_id,
|
||||
hint,
|
||||
)
|
||||
return None, message
|
||||
|
||||
@@ -421,11 +480,11 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
*,
|
||||
cancel_flag: Optional[Event],
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> tuple[Optional[Path], Optional[str]]:
|
||||
cancel_flag: Event | None,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> tuple[Path | None, str | None]:
|
||||
"""Wait briefly for completed files to appear on disk."""
|
||||
last_error: Optional[str] = None
|
||||
last_error: str | None = None
|
||||
max_attempts = self._completed_path_max_attempts()
|
||||
retry_interval = self._completed_path_retry_interval()
|
||||
|
||||
@@ -462,7 +521,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
|
||||
return None, last_error
|
||||
|
||||
def _build_progress_message(self, status) -> str:
|
||||
def _build_progress_message(self, status: DownloadStatus) -> str:
|
||||
"""Build a progress message from download status."""
|
||||
msg = f"{status.progress:.0f}%"
|
||||
|
||||
@@ -485,8 +544,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> Optional[str]:
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Execute download via configured torrent/usenet client. Returns file path or None."""
|
||||
try:
|
||||
if cancel_flag.is_set():
|
||||
@@ -516,7 +575,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
|
||||
if existing:
|
||||
download_id, existing_status = existing
|
||||
logger.info(f"Found existing download in {client.name}: {download_id}")
|
||||
logger.info("Found existing download in %s: %s", client.name, download_id)
|
||||
|
||||
# If already complete, skip straight to file handling
|
||||
if existing_status.complete:
|
||||
@@ -567,11 +626,13 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
ratio_limit=request.ratio_limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add to {client.name}: {e}")
|
||||
logger.exception("Failed to add to %s", client.name)
|
||||
status_callback("error", f"Failed to add to {client.name}: {e}")
|
||||
return None
|
||||
|
||||
logger.info(f"Added to {client.name}: {download_id} for '{request.release_name}'")
|
||||
logger.info(
|
||||
"Added to %s: %s for '%s'", client.name, download_id, request.release_name
|
||||
)
|
||||
|
||||
# Poll for progress
|
||||
return self._poll_and_complete(
|
||||
@@ -585,7 +646,7 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"External client download error: {e}")
|
||||
logger.exception("External client download error")
|
||||
status_callback("error", str(e))
|
||||
return None
|
||||
|
||||
@@ -597,8 +658,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> Optional[str]:
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Poll the download client for progress and handle completion."""
|
||||
poll_interval = self._poll_interval()
|
||||
# Track consecutive "not found" errors - torrents may take time to appear in client
|
||||
@@ -606,7 +667,8 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
max_not_found_retries = 15 # 15 retries * poll interval ~= 30s grace period
|
||||
|
||||
try:
|
||||
logger.debug(f"Starting poll for {download_id} (content_type={task.content_type})")
|
||||
result: str | None = None
|
||||
logger.debug("Starting poll for %s (content_type=%s)", download_id, task.content_type)
|
||||
while not cancel_flag.is_set():
|
||||
status = client.get_status(download_id)
|
||||
progress_callback(status.progress)
|
||||
@@ -614,12 +676,18 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
# Check for completion
|
||||
if status.complete:
|
||||
if status.state == DownloadState.ERROR:
|
||||
logger.error(f"Download {download_id} completed with error: {status.message}")
|
||||
logger.error(
|
||||
"Download %s completed with error: %s", download_id, status.message
|
||||
)
|
||||
status_callback("error", status.message or "Download failed")
|
||||
self._safe_remove_download(client, download_id, protocol, "completion error")
|
||||
self._safe_remove_download(
|
||||
client, download_id, protocol, "completion error"
|
||||
)
|
||||
return None
|
||||
# Download complete - break to handle file
|
||||
logger.debug(f"Download {download_id} complete, file_path={status.file_path}")
|
||||
logger.debug(
|
||||
"Download %s complete, file_path=%s", download_id, status.file_path
|
||||
)
|
||||
break
|
||||
|
||||
# Check for error state
|
||||
@@ -653,8 +721,10 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
not_found_count += 1
|
||||
if not_found_count < max_not_found_retries:
|
||||
logger.debug(
|
||||
f"Download {download_id} not yet visible in client "
|
||||
f"(attempt {not_found_count}/{max_not_found_retries})"
|
||||
"Download %s not yet visible in client (attempt %s/%s)",
|
||||
download_id,
|
||||
not_found_count,
|
||||
max_not_found_retries,
|
||||
)
|
||||
status_callback("resolving", "Waiting for download client...")
|
||||
if cancel_flag.wait(timeout=poll_interval):
|
||||
@@ -662,11 +732,13 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
continue
|
||||
|
||||
logger.error(
|
||||
f"Download {download_id} not found after {max_not_found_retries} attempts"
|
||||
"Download %s not found after %s attempts",
|
||||
download_id,
|
||||
max_not_found_retries,
|
||||
)
|
||||
else:
|
||||
# Fail fast on actionable errors (auth, connectivity, API issues)
|
||||
logger.error(f"Download {download_id} error state: {status.message}")
|
||||
logger.error("Download %s error state: %s", download_id, status.message)
|
||||
|
||||
status_callback("error", status.message or "Download failed")
|
||||
self._safe_remove_download(client, download_id, protocol, "download error")
|
||||
@@ -725,18 +797,20 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during download polling: {e}")
|
||||
logger.exception("Error during download polling")
|
||||
status_callback("error", str(e))
|
||||
self._safe_remove_download(client, download_id, protocol, "polling exception")
|
||||
return None
|
||||
else:
|
||||
return result
|
||||
|
||||
def _handle_completed_file(
|
||||
self,
|
||||
source_path: Path,
|
||||
protocol: str,
|
||||
task: DownloadTask,
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> Optional[str]:
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Handle a completed download and return its path.
|
||||
|
||||
For external download clients (torrents/usenet), staging large payloads into TMP_DIR
|
||||
@@ -751,15 +825,15 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
if protocol == "torrent":
|
||||
task.original_download_path = str(source_path)
|
||||
|
||||
logger.debug(f"Download complete, returning original path: {source_path}")
|
||||
logger.debug("Download complete, returning original path: %s", source_path)
|
||||
return str(source_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to finalize completed download at {source_path}: {e}")
|
||||
logger.exception("Failed to finalize completed download at %s", source_path)
|
||||
status_callback("error", f"Failed to finalize completed download: {e}")
|
||||
return None
|
||||
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
"""Default cancellation (primary cancellation happens via cancel_flag)."""
|
||||
logger.debug(f"Cancel requested for external client task: {task_id}")
|
||||
logger.debug("Cancel requested for external client task: %s", task_id)
|
||||
return True
|
||||
|
||||
@@ -12,13 +12,13 @@ Requirements:
|
||||
"""
|
||||
|
||||
import base64
|
||||
from typing import Any, Optional, Tuple
|
||||
from contextlib import suppress
|
||||
from typing import Any, NoReturn
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.clients import (
|
||||
@@ -29,22 +29,32 @@ from shelfmark.download.clients import (
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
)
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
MIN_DAEMON_HOST_ENTRY_LENGTH = 2
|
||||
MIN_DAEMON_HOST_STATUS_ENTRY_LENGTH = 4
|
||||
DOWNLOAD_COMPLETE_PROGRESS = 100
|
||||
ONE_WEEK_IN_SECONDS = 604800
|
||||
|
||||
|
||||
class DelugeRpcError(RuntimeError):
|
||||
def __init__(self, message: str, code: int | None = None):
|
||||
def __init__(self, message: str, code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _get_error_message(error: Any) -> Tuple[str, int | None]:
|
||||
def _get_error_message(error: object) -> tuple[str, int | None]:
|
||||
if isinstance(error, dict):
|
||||
return str(error.get("message") or error), error.get("code")
|
||||
return str(error), None
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class DelugeClient(DownloadClient):
|
||||
"""Deluge download client using Deluge Web UI JSON-RPC."""
|
||||
@@ -52,15 +62,17 @@ class DelugeClient(DownloadClient):
|
||||
protocol = "torrent"
|
||||
name = "deluge"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
raw_host = str(config.get("DELUGE_HOST", "localhost") or "")
|
||||
raw_port = str(config.get("DELUGE_PORT", "8112") or "8112")
|
||||
password = str(config.get("DELUGE_PASSWORD", "") or "")
|
||||
|
||||
if not raw_host:
|
||||
raise ValueError("DELUGE_HOST is required")
|
||||
msg = "DELUGE_HOST is required"
|
||||
raise ValueError(msg)
|
||||
if not password:
|
||||
raise ValueError("DELUGE_PASSWORD is required")
|
||||
msg = "DELUGE_PASSWORD is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
scheme = "http"
|
||||
base_path = ""
|
||||
@@ -69,7 +81,8 @@ class DelugeClient(DownloadClient):
|
||||
# (useful when Deluge is behind a reverse proxy path).
|
||||
raw_host = normalize_http_url(raw_host, strip_trailing_slash=False) if raw_host else ""
|
||||
if not raw_host:
|
||||
raise ValueError("DELUGE_HOST is invalid")
|
||||
msg = "DELUGE_HOST is invalid"
|
||||
raise ValueError(msg)
|
||||
|
||||
host = raw_host
|
||||
port = int(raw_port)
|
||||
@@ -81,13 +94,12 @@ class DelugeClient(DownloadClient):
|
||||
if parsed.port is not None:
|
||||
port = parsed.port
|
||||
base_path = (parsed.path or "").rstrip("/")
|
||||
else:
|
||||
# Allow "host:port" in DELUGE_HOST for convenience.
|
||||
if ":" in raw_host and raw_host.count(":") == 1:
|
||||
host_part, port_part = raw_host.split(":", 1)
|
||||
if host_part and port_part.isdigit():
|
||||
host = host_part
|
||||
port = int(port_part)
|
||||
# Allow "host:port" in DELUGE_HOST for convenience.
|
||||
elif ":" in raw_host and raw_host.count(":") == 1:
|
||||
host_part, port_part = raw_host.split(":", 1)
|
||||
if host_part and port_part.isdigit():
|
||||
host = host_part
|
||||
port = int(port_part)
|
||||
|
||||
self._rpc_url = f"{scheme}://{host}:{port}{base_path}/json"
|
||||
self._password = password
|
||||
@@ -104,14 +116,19 @@ class DelugeClient(DownloadClient):
|
||||
self._rpc_id += 1
|
||||
return self._rpc_id
|
||||
|
||||
def _rpc_call(self, method: str, *params: Any, timeout: int = 15) -> Any:
|
||||
def _rpc_call(self, method: str, *params: object, timeout: int = 15) -> object:
|
||||
payload = {
|
||||
"id": self._next_rpc_id(),
|
||||
"method": method,
|
||||
"params": list(params),
|
||||
}
|
||||
|
||||
response = self._session.post(self._rpc_url, json=payload, timeout=timeout, verify=get_ssl_verify(self._rpc_url))
|
||||
response = self._session.post(
|
||||
self._rpc_url,
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
verify=get_ssl_verify(self._rpc_url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
@@ -124,7 +141,8 @@ class DelugeClient(DownloadClient):
|
||||
def _login(self) -> None:
|
||||
result = self._rpc_call("auth.login", self._password)
|
||||
if result is not True:
|
||||
raise DelugeRpcError("Deluge Web UI authentication failed")
|
||||
msg = "Deluge Web UI authentication failed"
|
||||
raise DelugeRpcError(msg)
|
||||
self._authenticated = True
|
||||
|
||||
def _select_daemon_host_id(self, hosts: list) -> str:
|
||||
@@ -133,11 +151,19 @@ class DelugeClient(DownloadClient):
|
||||
preferred_hosts = {"127.0.0.1", "localhost"}
|
||||
|
||||
for entry in hosts:
|
||||
if isinstance(entry, list) and len(entry) >= 2 and entry[1] in preferred_hosts:
|
||||
if (
|
||||
isinstance(entry, list)
|
||||
and len(entry) >= MIN_DAEMON_HOST_ENTRY_LENGTH
|
||||
and entry[1] in preferred_hosts
|
||||
):
|
||||
return str(entry[0])
|
||||
|
||||
for entry in hosts:
|
||||
if isinstance(entry, list) and len(entry) >= 4 and str(entry[3]).lower() == "online":
|
||||
if (
|
||||
isinstance(entry, list)
|
||||
and len(entry) >= MIN_DAEMON_HOST_STATUS_ENTRY_LENGTH
|
||||
and str(entry[3]).lower() == "online"
|
||||
):
|
||||
return str(entry[0])
|
||||
|
||||
return str(hosts[0][0])
|
||||
@@ -155,23 +181,25 @@ class DelugeClient(DownloadClient):
|
||||
|
||||
hosts = self._rpc_call("web.get_hosts") or []
|
||||
if not hosts:
|
||||
raise DelugeRpcError(
|
||||
msg = (
|
||||
"Deluge Web UI isn't connected to Deluge core (no hosts configured). "
|
||||
"Add/connect a daemon in Deluge Web UI → Connection Manager."
|
||||
)
|
||||
raise DelugeRpcError(msg)
|
||||
|
||||
host_id = self._select_daemon_host_id(hosts)
|
||||
self._rpc_call("web.connect", host_id)
|
||||
|
||||
if self._rpc_call("web.connected") is not True:
|
||||
raise DelugeRpcError(
|
||||
msg = (
|
||||
"Deluge Web UI couldn't connect to Deluge core. "
|
||||
"Check daemon status in Deluge Web UI → Connection Manager."
|
||||
)
|
||||
raise DelugeRpcError(msg)
|
||||
|
||||
self._connected = True
|
||||
|
||||
def _get_daemon_version(self) -> Any:
|
||||
def _get_daemon_version(self) -> object:
|
||||
"""Fetch daemon version, preferring daemon.get_version when available."""
|
||||
try:
|
||||
methods = self._rpc_call("system.listMethods")
|
||||
@@ -190,14 +218,12 @@ class DelugeClient(DownloadClient):
|
||||
|
||||
try:
|
||||
# label.add will error if the plugin is unavailable or the label exists.
|
||||
try:
|
||||
with suppress(Exception):
|
||||
self._rpc_call("label.add", label)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._rpc_call("label.set_torrent", torrent_id, label)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not set Deluge label '{label}' for {torrent_id}: {e}")
|
||||
logger.debug("Could not set Deluge label '%s' for %s: %s", label, torrent_id, e)
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
@@ -206,22 +232,23 @@ class DelugeClient(DownloadClient):
|
||||
password = config.get("DELUGE_PASSWORD", "")
|
||||
return client == "deluge" and bool(host) and bool(password)
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
try:
|
||||
self._ensure_connected()
|
||||
version = self._get_daemon_version()
|
||||
return True, f"Connected to Deluge {version}"
|
||||
except Exception as e:
|
||||
self._authenticated = False
|
||||
self._connected = False
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
return False, f"Connection failed: {e!s}"
|
||||
else:
|
||||
return True, f"Connected to Deluge {version}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
try:
|
||||
@@ -231,7 +258,7 @@ class DelugeClient(DownloadClient):
|
||||
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
if not torrent_info.is_magnet and not torrent_info.torrent_data:
|
||||
raise Exception("Failed to fetch torrent file")
|
||||
_raise_runtime_error("Failed to fetch torrent file")
|
||||
|
||||
options: dict[str, Any] = {}
|
||||
if self._download_dir:
|
||||
@@ -252,7 +279,7 @@ class DelugeClient(DownloadClient):
|
||||
else:
|
||||
torrent_data = torrent_info.torrent_data
|
||||
if torrent_data is None:
|
||||
raise Exception("Failed to fetch torrent file")
|
||||
_raise_runtime_error("Failed to fetch torrent file")
|
||||
|
||||
torrent_data_bytes: bytes = torrent_data
|
||||
filedump = base64.b64encode(torrent_data_bytes).decode("ascii")
|
||||
@@ -264,19 +291,20 @@ class DelugeClient(DownloadClient):
|
||||
)
|
||||
|
||||
if not torrent_id:
|
||||
raise Exception("Deluge returned no torrent ID")
|
||||
_raise_runtime_error("Deluge returned no torrent ID")
|
||||
|
||||
torrent_id = str(torrent_id).lower()
|
||||
self._try_set_label(torrent_id, category_value)
|
||||
|
||||
logger.info(f"Added torrent to Deluge: {torrent_id}")
|
||||
return torrent_id
|
||||
logger.info("Added torrent to Deluge: %s", torrent_id)
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
self._authenticated = False
|
||||
self._connected = False
|
||||
logger.error(f"Deluge add failed: {e}")
|
||||
logger.exception("Deluge add failed")
|
||||
raise
|
||||
else:
|
||||
return torrent_id
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
try:
|
||||
@@ -285,7 +313,14 @@ class DelugeClient(DownloadClient):
|
||||
status = self._rpc_call(
|
||||
"core.get_torrent_status",
|
||||
download_id,
|
||||
["state", "progress", "download_payload_rate", "eta", "save_path", "name"],
|
||||
[
|
||||
"state",
|
||||
"progress",
|
||||
"download_payload_rate",
|
||||
"eta",
|
||||
"save_path",
|
||||
"name",
|
||||
],
|
||||
)
|
||||
|
||||
if not status:
|
||||
@@ -308,7 +343,7 @@ class DelugeClient(DownloadClient):
|
||||
|
||||
progress = float(status.get("progress", 0))
|
||||
# Don't mark complete while files are being moved
|
||||
complete = progress >= 100 and deluge_state != "Moving"
|
||||
complete = progress >= DOWNLOAD_COMPLETE_PROGRESS and deluge_state != "Moving"
|
||||
|
||||
if complete:
|
||||
message = "Complete"
|
||||
@@ -320,7 +355,7 @@ class DelugeClient(DownloadClient):
|
||||
except Exception:
|
||||
eta = None
|
||||
|
||||
if eta is not None and (eta < 0 or eta > 604800):
|
||||
if eta is not None and (eta < 0 or eta > ONE_WEEK_IN_SECONDS):
|
||||
eta = None
|
||||
|
||||
file_path = None
|
||||
@@ -344,24 +379,26 @@ class DelugeClient(DownloadClient):
|
||||
except Exception as e:
|
||||
return DownloadStatus.error(self._log_error("get_status", e))
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
result = self._rpc_call("core.remove_torrent", download_id, delete_files)
|
||||
if result:
|
||||
logger.info(
|
||||
f"Removed torrent from Deluge: {download_id}"
|
||||
+ (" (with files)" if delete_files else "")
|
||||
"Removed torrent from Deluge: %s%s",
|
||||
download_id,
|
||||
" (with files)" if delete_files else "",
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self._log_error("remove", e)
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
@@ -376,15 +413,16 @@ class DelugeClient(DownloadClient):
|
||||
str(status.get("save_path", "")),
|
||||
str(status.get("name", "")),
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self._log_error("get_download_path", e, level="debug")
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
self, url: str, category: str | None = None
|
||||
) -> tuple[str, DownloadStatus] | None:
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
@@ -402,10 +440,10 @@ class DelugeClient(DownloadClient):
|
||||
full_status = self.get_status(torrent_info.info_hash)
|
||||
return (torrent_info.info_hash, full_status)
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self._authenticated = False
|
||||
self._connected = False
|
||||
logger.debug(f"Error checking for existing torrent: {e}")
|
||||
logger.debug("Error checking for existing torrent: %s", e)
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
"""
|
||||
NZBGet download client for Prowlarr integration.
|
||||
"""NZBGet download client for Prowlarr integration.
|
||||
|
||||
Uses NZBGet's JSON-RPC API directly via requests (no external dependency).
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
with_retry,
|
||||
)
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -30,15 +28,17 @@ class NZBGetClient(DownloadClient):
|
||||
protocol = "usenet"
|
||||
name = "nzbget"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize NZBGet client with settings from config."""
|
||||
raw_url = config.get("NZBGET_URL", "")
|
||||
if not raw_url:
|
||||
raise ValueError("NZBGET_URL is required")
|
||||
msg = "NZBGET_URL is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
self.url = normalize_http_url(raw_url)
|
||||
if not self.url:
|
||||
raise ValueError("NZBGET_URL is invalid")
|
||||
msg = "NZBGET_URL is invalid"
|
||||
raise ValueError(msg)
|
||||
self.username = config.get("NZBGET_USERNAME", "nzbget")
|
||||
self.password = config.get("NZBGET_PASSWORD", "")
|
||||
self._category = config.get("NZBGET_CATEGORY", "Books")
|
||||
@@ -50,10 +50,22 @@ class NZBGetClient(DownloadClient):
|
||||
url = normalize_http_url(config.get("NZBGET_URL", ""))
|
||||
return client == "nzbget" and bool(url)
|
||||
|
||||
def _try_remove_command(
|
||||
self, command: str, nzb_id: int, download_id: str
|
||||
) -> tuple[bool, Exception | None]:
|
||||
"""Try one NZBGet delete command and return any error."""
|
||||
try:
|
||||
result = self._rpc_call("editqueue", [command, 0, "", nzb_id])
|
||||
if result:
|
||||
logger.info("Removed NZB from NZBGet (%s): %s", command, download_id)
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, e
|
||||
return False, None
|
||||
|
||||
@with_retry()
|
||||
def _rpc_call(self, method: str, params: Optional[list] = None) -> Any:
|
||||
"""
|
||||
Make a JSON-RPC call to NZBGet.
|
||||
def _rpc_call(self, method: str, params: list | None = None) -> object:
|
||||
"""Make a JSON-RPC call to NZBGet.
|
||||
|
||||
Args:
|
||||
method: RPC method name
|
||||
@@ -64,15 +76,19 @@ class NZBGetClient(DownloadClient):
|
||||
|
||||
Raises:
|
||||
Exception: If RPC call fails after retries.
|
||||
|
||||
"""
|
||||
rpc_url = f"{self.url}/jsonrpc"
|
||||
|
||||
payload = json.dumps({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params or [],
|
||||
}, separators=(',', ':'))
|
||||
payload = json.dumps(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params or [],
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
rpc_url,
|
||||
@@ -85,34 +101,34 @@ class NZBGetClient(DownloadClient):
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
if "error" in result and result["error"]:
|
||||
raise Exception(result["error"].get("message", "RPC error"))
|
||||
if result.get("error"):
|
||||
raise RuntimeError(result["error"].get("message", "RPC error"))
|
||||
|
||||
return result.get("result")
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Test connection to NZBGet."""
|
||||
try:
|
||||
status = self._rpc_call("status")
|
||||
version = status.get("Version", "unknown")
|
||||
return True, f"Connected to NZBGet {version}"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, "Could not connect to NZBGet"
|
||||
except requests.exceptions.Timeout:
|
||||
return False, "Connection timed out"
|
||||
except Exception as e:
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
return False, f"Connection failed: {e!s}"
|
||||
else:
|
||||
return True, f"Connected to NZBGet {version}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Add NZB by URL.
|
||||
"""Add NZB by URL.
|
||||
|
||||
Fetches the NZB content from the URL (e.g., Prowlarr proxy) and sends
|
||||
it base64-encoded to NZBGet, since NZBGet may not handle redirects well.
|
||||
@@ -128,21 +144,26 @@ class NZBGetClient(DownloadClient):
|
||||
|
||||
Raises:
|
||||
Exception: If adding fails.
|
||||
|
||||
"""
|
||||
import base64
|
||||
|
||||
# Use configured category if not explicitly provided
|
||||
category = category or self._category
|
||||
|
||||
def _raise_invalid_nzb_id() -> None:
|
||||
msg = "NZBGet returned invalid ID"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
try:
|
||||
# Fetch NZB content from the URL (handles Prowlarr proxy redirects)
|
||||
logger.debug(f"Fetching NZB from: {url}")
|
||||
logger.debug("Fetching NZB from: %s", url)
|
||||
response = requests.get(url, timeout=30, verify=get_ssl_verify(url))
|
||||
response.raise_for_status()
|
||||
nzb_content = base64.b64encode(response.content).decode('ascii')
|
||||
nzb_content = base64.b64encode(response.content).decode("ascii")
|
||||
|
||||
# Ensure filename has .nzb extension
|
||||
nzb_filename = name if name.endswith('.nzb') else f"{name}.nzb"
|
||||
nzb_filename = name if name.endswith(".nzb") else f"{name}.nzb"
|
||||
|
||||
# NZBGet append method parameters (all 10 required):
|
||||
# NZBFilename, Content, Category, Priority, AddToTop, AddPaused,
|
||||
@@ -164,26 +185,27 @@ class NZBGetClient(DownloadClient):
|
||||
)
|
||||
|
||||
if nzb_id and nzb_id > 0:
|
||||
logger.info(f"Added NZB to NZBGet: {nzb_id}")
|
||||
logger.info("Added NZB to NZBGet: %s", nzb_id)
|
||||
return str(nzb_id)
|
||||
|
||||
raise Exception("NZBGet returned invalid ID")
|
||||
_raise_invalid_nzb_id()
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch NZB from URL: {e}")
|
||||
raise Exception(f"Failed to fetch NZB: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"NZBGet add failed: {e}")
|
||||
logger.exception("Failed to fetch NZB from URL")
|
||||
msg = f"Failed to fetch NZB: {e}"
|
||||
raise RuntimeError(msg) from e
|
||||
except Exception:
|
||||
logger.exception("NZBGet add failed")
|
||||
raise
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""
|
||||
Get NZB status by ID.
|
||||
"""Get NZB status by ID.
|
||||
|
||||
Args:
|
||||
download_id: NZBGet NZBID
|
||||
|
||||
Returns:
|
||||
Current download status.
|
||||
|
||||
"""
|
||||
try:
|
||||
nzb_id = int(download_id)
|
||||
@@ -195,18 +217,12 @@ class NZBGetClient(DownloadClient):
|
||||
if group.get("NZBID") == nzb_id:
|
||||
# Calculate progress
|
||||
# NZBGet uses Hi/Lo for 64-bit values on 32-bit systems
|
||||
file_size = (group.get("FileSizeHi", 0) << 32) + group.get(
|
||||
"FileSizeLo", 0
|
||||
)
|
||||
file_size = (group.get("FileSizeHi", 0) << 32) + group.get("FileSizeLo", 0)
|
||||
remaining = (group.get("RemainingSizeHi", 0) << 32) + group.get(
|
||||
"RemainingSizeLo", 0
|
||||
)
|
||||
|
||||
progress = (
|
||||
((file_size - remaining) / file_size * 100)
|
||||
if file_size > 0
|
||||
else 0
|
||||
)
|
||||
progress = ((file_size - remaining) / file_size * 100) if file_size > 0 else 0
|
||||
status = group.get("Status", "")
|
||||
|
||||
# Map NZBGet status to our states
|
||||
@@ -229,9 +245,7 @@ class NZBGetClient(DownloadClient):
|
||||
file_path=None,
|
||||
download_speed=group.get("DownloadRate"),
|
||||
eta=(
|
||||
group.get("RemainingSec")
|
||||
if group.get("RemainingSec", 0) > 0
|
||||
else None
|
||||
group.get("RemainingSec") if group.get("RemainingSec", 0) > 0 else None
|
||||
),
|
||||
)
|
||||
|
||||
@@ -254,7 +268,6 @@ class NZBGetClient(DownloadClient):
|
||||
else:
|
||||
file_path = None
|
||||
|
||||
|
||||
if "SUCCESS" in status:
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
@@ -263,21 +276,20 @@ class NZBGetClient(DownloadClient):
|
||||
complete=True,
|
||||
file_path=file_path,
|
||||
)
|
||||
else:
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
state="error",
|
||||
message=f"Download failed: {status}",
|
||||
complete=True,
|
||||
file_path=file_path,
|
||||
)
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
state="error",
|
||||
message=f"Download failed: {status}",
|
||||
complete=True,
|
||||
file_path=file_path,
|
||||
)
|
||||
|
||||
# Not found in queue or history
|
||||
return DownloadStatus.error("Download not found")
|
||||
except Exception as e:
|
||||
return DownloadStatus.error(self._log_error("get_status", e))
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
"""Remove a download from NZBGet.
|
||||
|
||||
NZBGet can remove items from either the active queue (Group* commands) or from
|
||||
@@ -289,6 +301,7 @@ class NZBGetClient(DownloadClient):
|
||||
|
||||
Returns:
|
||||
True if successful.
|
||||
|
||||
"""
|
||||
try:
|
||||
nzb_id = int(download_id)
|
||||
@@ -303,29 +316,27 @@ class NZBGetClient(DownloadClient):
|
||||
else:
|
||||
commands = ["GroupDelete", "HistoryDelete"]
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
last_error: Exception | None = None
|
||||
for command in commands:
|
||||
try:
|
||||
result = self._rpc_call("editqueue", [command, 0, "", nzb_id])
|
||||
if result:
|
||||
logger.info(f"Removed NZB from NZBGet ({command}): {download_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
success, error = self._try_remove_command(command, nzb_id, download_id)
|
||||
if success:
|
||||
return True
|
||||
if error is not None:
|
||||
last_error = error
|
||||
|
||||
if last_error is not None:
|
||||
self._log_error("remove", last_error)
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get the path where NZB files are located.
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where NZB files are located.
|
||||
|
||||
Args:
|
||||
download_id: NZBGet NZBID
|
||||
|
||||
Returns:
|
||||
Destination directory, or None.
|
||||
|
||||
"""
|
||||
status = self.get_status(download_id)
|
||||
return status.file_path
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""qBittorrent download client for Prowlarr integration."""
|
||||
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional, Tuple
|
||||
from typing import NoReturn
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
@@ -16,22 +17,33 @@ from shelfmark.download.clients import (
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
)
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_HASH_LENGTH_40 = 40
|
||||
_HASH_LENGTH_ED2K = 32
|
||||
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
|
||||
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
_ONE_WEEK_IN_SECONDS = 604800
|
||||
|
||||
|
||||
def _hashes_match(hash1: str, hash2: str) -> bool:
|
||||
"""Compare hashes, handling Amarr's 40-char zero-padded hashes vs 32-char ed2k hashes."""
|
||||
h1, h2 = hash1.lower(), hash2.lower()
|
||||
if h1 == h2:
|
||||
return True
|
||||
if len(h1) == 40 and len(h2) == 32 and h1.endswith("00000000"):
|
||||
return h1[:32] == h2
|
||||
if len(h2) == 40 and len(h1) == 32 and h2.endswith("00000000"):
|
||||
return h2[:32] == h1
|
||||
if len(h1) == _HASH_LENGTH_40 and len(h2) == _HASH_LENGTH_ED2K and h1.endswith("00000000"):
|
||||
return h1[:_HASH_LENGTH_ED2K] == h2
|
||||
if len(h2) == _HASH_LENGTH_40 and len(h1) == _HASH_LENGTH_ED2K and h2.endswith("00000000"):
|
||||
return h2[:_HASH_LENGTH_ED2K] == h1
|
||||
return False
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def _normalize_tags(raw_tags: object) -> list[str]:
|
||||
"""Normalize tag input to a clean, de-duplicated list of strings."""
|
||||
if raw_tags is None:
|
||||
@@ -82,7 +94,7 @@ def _is_explicit_add_failure(raw_result: object) -> bool:
|
||||
class QBittorrentClient(DownloadClient):
|
||||
"""qBittorrent download client."""
|
||||
|
||||
def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, Optional[str]]:
|
||||
def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, str | None]:
|
||||
"""Check whether qBittorrent has registered a torrent yet.
|
||||
|
||||
Uses `/api/v2/torrents/properties?hash=<hash>`.
|
||||
@@ -92,6 +104,7 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
Notes:
|
||||
A false result with no error means "not loaded yet".
|
||||
|
||||
"""
|
||||
import requests
|
||||
|
||||
@@ -103,23 +116,24 @@ class QBittorrentClient(DownloadClient):
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
# Re-authenticate and retry once on 403
|
||||
if response.status_code == 403:
|
||||
logger.debug("qBittorrent returned 403 for properties; re-authenticating and retrying")
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
logger.debug(
|
||||
"qBittorrent returned 403 for properties; re-authenticating and retrying"
|
||||
)
|
||||
self._client.auth_log_in()
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == 403:
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
return False, "qBittorrent authentication failed (HTTP 403)"
|
||||
|
||||
# qBittorrent returns 404/409-ish responses depending on version when missing.
|
||||
if response.status_code == 404:
|
||||
if response.status_code == _HTTP_STATUS_NOT_FOUND:
|
||||
return False, None
|
||||
|
||||
response.raise_for_status()
|
||||
return True, None
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
if status == 404:
|
||||
if status == _HTTP_STATUS_NOT_FOUND:
|
||||
return False, None
|
||||
if status:
|
||||
return False, f"qBittorrent API request failed (HTTP {status})"
|
||||
@@ -130,23 +144,27 @@ class QBittorrentClient(DownloadClient):
|
||||
return False, f"qBittorrent request timed out at {self._base_url}"
|
||||
except Exception as e:
|
||||
return False, f"qBittorrent API error: {type(e).__name__}: {e}"
|
||||
else:
|
||||
return True, None
|
||||
|
||||
protocol = "torrent"
|
||||
name = "qbittorrent"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize qBittorrent client with settings from config."""
|
||||
# Lazy import to avoid dependency issues if not using torrents
|
||||
from qbittorrentapi import Client
|
||||
|
||||
raw_url = config.get("QBITTORRENT_URL", "")
|
||||
if not raw_url:
|
||||
raise ValueError("QBITTORRENT_URL is required")
|
||||
msg = "QBITTORRENT_URL is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
# We use `_base_url` for direct HTTP calls, so it must be a fully-qualified URL.
|
||||
self._base_url = normalize_http_url(raw_url)
|
||||
if not self._base_url:
|
||||
raise ValueError("QBITTORRENT_URL is invalid")
|
||||
msg = "QBITTORRENT_URL is invalid"
|
||||
raise ValueError(msg)
|
||||
|
||||
# qbittorrent-api accepts either a full URL or host:port; prefer the normalized URL
|
||||
# for consistency.
|
||||
@@ -160,10 +178,9 @@ class QBittorrentClient(DownloadClient):
|
||||
self._download_dir = config.get("QBITTORRENT_DOWNLOAD_DIR", "")
|
||||
self._tags = _normalize_tags(config.get("QBITTORRENT_TAG", []))
|
||||
|
||||
|
||||
def _get_torrents_info(
|
||||
self, torrent_hash: Optional[str] = None
|
||||
) -> tuple[list[SimpleNamespace], Optional[str]]:
|
||||
self, torrent_hash: str | None = None
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
"""Get torrent info using GET.
|
||||
|
||||
Behaviors:
|
||||
@@ -174,6 +191,7 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
Returns:
|
||||
(torrents, error_message)
|
||||
|
||||
"""
|
||||
import requests
|
||||
|
||||
@@ -188,13 +206,13 @@ class QBittorrentClient(DownloadClient):
|
||||
response: requests.Response,
|
||||
*,
|
||||
request_params: dict[str, str],
|
||||
) -> tuple[list[SimpleNamespace], Optional[str]]:
|
||||
if response.status_code == 403:
|
||||
) -> tuple[list[SimpleNamespace], str | None]:
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
|
||||
self._client.auth_log_in()
|
||||
response = self._client._session.get(url, params=request_params, timeout=10)
|
||||
|
||||
if response.status_code == 403:
|
||||
if response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
logger.warning("qBittorrent authentication failed (HTTP 403)")
|
||||
return [], "qBittorrent authentication failed (HTTP 403)"
|
||||
|
||||
@@ -236,24 +254,22 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
return all_torrents, None
|
||||
|
||||
return torrents, None
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
if status:
|
||||
logger.warning(f"qBittorrent API error (HTTP {status}): {e}")
|
||||
logger.warning("qBittorrent API error (HTTP %s): %s", status, e)
|
||||
return [], f"qBittorrent API request failed (HTTP {status})"
|
||||
|
||||
logger.warning(f"qBittorrent API error: {e}")
|
||||
logger.warning("qBittorrent API error: %s", e)
|
||||
return [], "qBittorrent API request failed"
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.warning(f"Cannot connect to qBittorrent at {self._base_url}")
|
||||
logger.warning("Cannot connect to qBittorrent at %s", self._base_url)
|
||||
return [], f"Cannot connect to qBittorrent at {self._base_url}"
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"qBittorrent request timed out at {self._base_url}")
|
||||
logger.warning("qBittorrent request timed out at %s", self._base_url)
|
||||
return [], f"qBittorrent request timed out at {self._base_url}"
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get torrents info: {e}")
|
||||
logger.debug("Failed to get torrents info: %s", e)
|
||||
# requests raises InvalidSchema when the base URL doesn't include http(s)
|
||||
if type(e).__name__ == "InvalidSchema":
|
||||
return (
|
||||
@@ -262,6 +278,8 @@ class QBittorrentClient(DownloadClient):
|
||||
f"Configured: {self._base_url}",
|
||||
)
|
||||
return [], f"qBittorrent API error: {type(e).__name__}: {e}"
|
||||
else:
|
||||
return torrents, None
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
@@ -270,14 +288,15 @@ class QBittorrentClient(DownloadClient):
|
||||
url = normalize_http_url(config.get("QBITTORRENT_URL", ""))
|
||||
return client == "qbittorrent" and bool(url)
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Test connection to qBittorrent."""
|
||||
try:
|
||||
self._client.auth_log_in()
|
||||
api_version = self._client.app.web_api_version
|
||||
return True, f"Connected to qBittorrent (API v{api_version})"
|
||||
except Exception as e:
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
return False, f"Connection failed: {e!s}"
|
||||
else:
|
||||
return True, f"Connected to qBittorrent (API v{api_version})"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
@@ -287,8 +306,7 @@ class QBittorrentClient(DownloadClient):
|
||||
expected_hash: str | None = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Add torrent by URL (magnet or .torrent).
|
||||
"""Add torrent by URL (magnet or .torrent).
|
||||
|
||||
Args:
|
||||
url: Magnet link or .torrent URL
|
||||
@@ -301,6 +319,7 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
Raises:
|
||||
Exception: If adding fails.
|
||||
|
||||
"""
|
||||
try:
|
||||
# Use configured category if not explicitly provided
|
||||
@@ -316,7 +335,10 @@ class QBittorrentClient(DownloadClient):
|
||||
# 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}"
|
||||
"Could not create category '%s': %s: %s",
|
||||
category,
|
||||
type(e).__name__,
|
||||
e,
|
||||
)
|
||||
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
@@ -356,13 +378,13 @@ class QBittorrentClient(DownloadClient):
|
||||
)
|
||||
|
||||
result_text = _normalize_add_result(result)
|
||||
logger.debug(f"qBittorrent add result: {result_text}")
|
||||
logger.debug("qBittorrent add result: %s", result_text)
|
||||
|
||||
if not expected_hash:
|
||||
raise Exception("Could not determine torrent hash from URL")
|
||||
_raise_runtime_error("Could not determine torrent hash from URL")
|
||||
|
||||
if _is_explicit_add_failure(result):
|
||||
raise Exception(f"Failed to add torrent: {result_text}")
|
||||
_raise_runtime_error(f"Failed to add torrent: {result_text}")
|
||||
|
||||
# Some qBittorrent-compatible clients return HTTP 200 with an empty body
|
||||
# instead of qBittorrent's literal "Ok." response. Prefer verifying that
|
||||
@@ -370,30 +392,31 @@ class QBittorrentClient(DownloadClient):
|
||||
for _ in range(10):
|
||||
loaded, error = self._is_torrent_loaded(expected_hash)
|
||||
if error:
|
||||
logger.debug(f"qBittorrent add_download: {error}")
|
||||
logger.debug("qBittorrent add_download: %s", error)
|
||||
if loaded:
|
||||
logger.info(f"Added torrent: {expected_hash}")
|
||||
logger.info("Added torrent: %s", expected_hash)
|
||||
return expected_hash.lower()
|
||||
time.sleep(0.5)
|
||||
|
||||
logger.warning(
|
||||
"Torrent add was not confirmed within the visibility grace period "
|
||||
f"(response={result_text or '<empty>'}), returning expected hash"
|
||||
"Torrent add was not confirmed within the visibility grace period (response=%s), returning expected hash",
|
||||
result_text,
|
||||
)
|
||||
return expected_hash
|
||||
except Exception as e:
|
||||
logger.error(f"qBittorrent add failed: {e}")
|
||||
except Exception:
|
||||
logger.exception("qBittorrent add failed")
|
||||
raise
|
||||
else:
|
||||
return expected_hash
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""
|
||||
Get torrent status by hash.
|
||||
"""Get torrent status by hash.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
|
||||
Returns:
|
||||
Current download status.
|
||||
|
||||
"""
|
||||
try:
|
||||
torrents, error = self._get_torrents_info(download_id)
|
||||
@@ -405,7 +428,7 @@ class QBittorrentClient(DownloadClient):
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(getattr(t, "hash"), download_id)
|
||||
and _hashes_match(t.hash, download_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -414,7 +437,10 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
# Map qBittorrent states to our states and user-friendly messages
|
||||
state_info = {
|
||||
"downloading": ("downloading", None), # None = use default progress message
|
||||
"downloading": (
|
||||
"downloading",
|
||||
None,
|
||||
), # None = use default progress message
|
||||
"stalledDL": ("downloading", "Stalled"),
|
||||
"metaDL": ("downloading", "Fetching metadata"),
|
||||
"forcedDL": ("downloading", None),
|
||||
@@ -449,7 +475,11 @@ class QBittorrentClient(DownloadClient):
|
||||
message = "Complete"
|
||||
|
||||
torrent_eta = getattr(torrent, "eta", 0)
|
||||
eta = torrent_eta if isinstance(torrent_eta, int) and 0 < torrent_eta < 604800 else None
|
||||
eta = (
|
||||
torrent_eta
|
||||
if isinstance(torrent_eta, int) and 0 < torrent_eta < _ONE_WEEK_IN_SECONDS
|
||||
else None
|
||||
)
|
||||
|
||||
# Get file path for completed downloads
|
||||
file_path = None
|
||||
@@ -471,9 +501,8 @@ class QBittorrentClient(DownloadClient):
|
||||
except Exception as e:
|
||||
return DownloadStatus.error(self._log_error("get_status", e))
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
"""
|
||||
Remove a torrent from qBittorrent.
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
"""Remove a torrent from qBittorrent.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
@@ -481,21 +510,22 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
Returns:
|
||||
True if successful.
|
||||
|
||||
"""
|
||||
try:
|
||||
self._client.torrents_delete(
|
||||
torrent_hashes=download_id, delete_files=delete_files
|
||||
)
|
||||
self._client.torrents_delete(torrent_hashes=download_id, delete_files=delete_files)
|
||||
logger.info(
|
||||
f"Removed torrent from qBittorrent: {download_id}"
|
||||
+ (" (with files)" if delete_files else "")
|
||||
"Removed torrent from qBittorrent: %s%s",
|
||||
download_id,
|
||||
" (with files)" if delete_files else "",
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self._log_error("remove", e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where torrent files are located.
|
||||
|
||||
Prefer `content_path` when available.
|
||||
@@ -506,12 +536,10 @@ class QBittorrentClient(DownloadClient):
|
||||
- `/api/v2/torrents/files?hash=<hash>` for the first file name
|
||||
- join `save_path` with the torrent's top-level directory
|
||||
"""
|
||||
import os
|
||||
|
||||
try:
|
||||
torrents, error = self._get_torrents_info(download_id)
|
||||
if error:
|
||||
logger.debug(f"qBittorrent get_download_path: {error}")
|
||||
logger.debug("qBittorrent get_download_path: %s", error)
|
||||
return None
|
||||
|
||||
torrent = next(
|
||||
@@ -519,7 +547,7 @@ class QBittorrentClient(DownloadClient):
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(getattr(t, "hash"), download_id)
|
||||
and _hashes_match(t.hash, download_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -531,7 +559,7 @@ class QBittorrentClient(DownloadClient):
|
||||
self._log_error("get_download_path", e, level="debug")
|
||||
return None
|
||||
|
||||
def _resolve_completed_download_path(self, torrent: SimpleNamespace) -> Optional[str]:
|
||||
def _resolve_completed_download_path(self, torrent: SimpleNamespace) -> str | None:
|
||||
"""Resolve the completed path for a torrent.
|
||||
|
||||
Centralizes the logic shared by `get_status()` and `get_download_path()`:
|
||||
@@ -539,7 +567,6 @@ class QBittorrentClient(DownloadClient):
|
||||
- otherwise derive via properties+files
|
||||
- finally fall back to `save_path + name`
|
||||
"""
|
||||
|
||||
# Prefer content_path, but treat content_path == save_path as invalid.
|
||||
content_path = getattr(torrent, "content_path", "")
|
||||
save_path = getattr(torrent, "save_path", "")
|
||||
@@ -558,19 +585,20 @@ class QBittorrentClient(DownloadClient):
|
||||
getattr(torrent, "name", ""),
|
||||
)
|
||||
|
||||
def _derive_download_path_from_files(self, download_id: str) -> Optional[str]:
|
||||
def _derive_download_path_from_files(self, download_id: str) -> str | None:
|
||||
"""Derive completed download path using `/torrents/properties` + `/torrents/files`.
|
||||
|
||||
This mirrors how common automation apps derive the path when
|
||||
`content_path` isn't provided.
|
||||
"""
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
def get_with_auth(url: str, params: dict[str, str]) -> requests.Response:
|
||||
self._client.auth_log_in()
|
||||
resp = self._client._session.get(url, params=params, timeout=10)
|
||||
if resp.status_code == 403:
|
||||
if resp.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
|
||||
self._client.auth_log_in()
|
||||
resp = self._client._session.get(url, params=params, timeout=10)
|
||||
@@ -581,7 +609,7 @@ class QBittorrentClient(DownloadClient):
|
||||
files_url = f"{self._base_url}/api/v2/torrents/files"
|
||||
|
||||
props_resp = get_with_auth(properties_url, {"hash": download_id})
|
||||
if props_resp.status_code == 404:
|
||||
if props_resp.status_code == _HTTP_STATUS_NOT_FOUND:
|
||||
return None
|
||||
props_resp.raise_for_status()
|
||||
props = props_resp.json() if isinstance(props_resp.json(), dict) else {}
|
||||
@@ -591,7 +619,7 @@ class QBittorrentClient(DownloadClient):
|
||||
return None
|
||||
|
||||
files_resp = get_with_auth(files_url, {"hash": download_id})
|
||||
if files_resp.status_code == 404:
|
||||
if files_resp.status_code == _HTTP_STATUS_NOT_FOUND:
|
||||
return None
|
||||
files_resp.raise_for_status()
|
||||
files = files_resp.json() if isinstance(files_resp.json(), list) else []
|
||||
@@ -608,14 +636,18 @@ class QBittorrentClient(DownloadClient):
|
||||
if not top_level:
|
||||
return None
|
||||
|
||||
return os.path.normpath(os.path.join(save_path, top_level))
|
||||
return os.path.normpath(str(Path(save_path) / top_level))
|
||||
except Exception as e:
|
||||
logger.debug(f"qBittorrent could not derive path from files: {type(e).__name__}: {e}")
|
||||
logger.debug(
|
||||
"qBittorrent could not derive path from files: %s: %s",
|
||||
type(e).__name__,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
self, url: str, category: str | None = None
|
||||
) -> tuple[str, DownloadStatus] | None:
|
||||
"""Check if a torrent for this URL already exists in qBittorrent."""
|
||||
try:
|
||||
torrent_info = extract_torrent_info(url)
|
||||
@@ -624,7 +656,7 @@ class QBittorrentClient(DownloadClient):
|
||||
|
||||
torrents, error = self._get_torrents_info(torrent_info.info_hash)
|
||||
if error:
|
||||
logger.debug(f"qBittorrent find_existing: {error}")
|
||||
logger.debug("qBittorrent find_existing: %s", error)
|
||||
return None
|
||||
|
||||
torrent = next(
|
||||
@@ -632,15 +664,15 @@ class QBittorrentClient(DownloadClient):
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(getattr(t, "hash"), torrent_info.info_hash)
|
||||
and _hashes_match(t.hash, torrent_info.info_hash)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if torrent and isinstance(getattr(torrent, "hash", None), str):
|
||||
torrent_hash = getattr(torrent, "hash")
|
||||
torrent_hash = torrent.hash
|
||||
return (torrent_hash.lower(), self.get_status(torrent_hash.lower()))
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking for existing torrent: {e}")
|
||||
logger.debug("Error checking for existing torrent: %s", e)
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"""
|
||||
rTorrent download client for Prowlarr integration.
|
||||
"""rTorrent download client for Prowlarr integration.
|
||||
|
||||
Uses xmlrpc to communicate with rTorrent's RPC interface.
|
||||
"""
|
||||
|
||||
import ssl
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import NoReturn
|
||||
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, get_hardened_xmlrpc_client
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.core.utils import get_hardened_xmlrpc_client, normalize_http_url
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
@@ -20,11 +18,15 @@ from shelfmark.download.clients import (
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
)
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _create_rtorrent_server_proxy(url: str) -> Any:
|
||||
_ETA_MAX_SECONDS = 604800
|
||||
|
||||
|
||||
def _create_rtorrent_server_proxy(url: str) -> object:
|
||||
"""Create an XML-RPC ServerProxy honoring certificate validation mode."""
|
||||
xmlrpc_client = get_hardened_xmlrpc_client()
|
||||
|
||||
@@ -39,6 +41,10 @@ def _create_rtorrent_server_proxy(url: str) -> Any:
|
||||
return xmlrpc_client.ServerProxy(url)
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class RTorrentClient(DownloadClient):
|
||||
"""rTorrent download client using xmlrpc."""
|
||||
@@ -46,24 +52,24 @@ class RTorrentClient(DownloadClient):
|
||||
protocol = "torrent"
|
||||
name = "rtorrent"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize rTorrent client with settings from config."""
|
||||
raw_url = config.get("RTORRENT_URL", "")
|
||||
if not raw_url:
|
||||
raise ValueError("RTORRENT_URL is required")
|
||||
msg = "RTORRENT_URL is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
self._base_url = normalize_http_url(raw_url)
|
||||
if not self._base_url:
|
||||
raise ValueError("RTORRENT_URL is invalid")
|
||||
msg = "RTORRENT_URL is invalid"
|
||||
raise ValueError(msg)
|
||||
|
||||
username = config.get("RTORRENT_USERNAME", "")
|
||||
password = config.get("RTORRENT_PASSWORD", "")
|
||||
|
||||
if username and password:
|
||||
parsed = urlparse(self._base_url)
|
||||
self._base_url = (
|
||||
f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}"
|
||||
)
|
||||
self._base_url = f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}"
|
||||
|
||||
self._rpc = _create_rtorrent_server_proxy(self._base_url)
|
||||
self._download_dir = config.get("RTORRENT_DOWNLOAD_DIR", "")
|
||||
@@ -76,24 +82,24 @@ class RTorrentClient(DownloadClient):
|
||||
url = normalize_http_url(config.get("RTORRENT_URL", ""))
|
||||
return client == "rtorrent" and bool(url)
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Test connection to rTorrent."""
|
||||
try:
|
||||
version = self._rpc.system.client_version()
|
||||
return True, f"Connected to rTorrent {version}"
|
||||
except Exception as e:
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
return False, f"Connection failed: {e!s}"
|
||||
else:
|
||||
return True, f"Connected to rTorrent {version}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Add torrent by URL (magnet or .torrent).
|
||||
"""Add torrent by URL (magnet or .torrent).
|
||||
|
||||
Args:
|
||||
url: Magnet link or .torrent URL
|
||||
@@ -106,6 +112,7 @@ class RTorrentClient(DownloadClient):
|
||||
|
||||
Raises:
|
||||
Exception: If adding fails.
|
||||
|
||||
"""
|
||||
try:
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
@@ -114,44 +121,53 @@ class RTorrentClient(DownloadClient):
|
||||
|
||||
label = category or self._label
|
||||
if label:
|
||||
logger.debug(f"Setting rTorrent label: {label}")
|
||||
logger.debug("Setting rTorrent label: %s", label)
|
||||
commands.append(f"d.custom1.set={label}")
|
||||
|
||||
download_dir = self._download_dir or self._get_download_dir()
|
||||
if download_dir:
|
||||
logger.debug(f"Setting rTorrent download directory: {download_dir}")
|
||||
logger.debug("Setting rTorrent download directory: %s", download_dir)
|
||||
commands.append(f"d.directory.set={download_dir}")
|
||||
|
||||
if torrent_info.torrent_data:
|
||||
logger.debug(f"Adding torrent data directly to rTorrent for: {name} with commands: {commands} with data size: {len(torrent_info.torrent_data)}")
|
||||
self._rpc.load.raw_start(
|
||||
"", torrent_info.torrent_data, ";".join(commands)
|
||||
logger.debug(
|
||||
"Adding torrent data directly to rTorrent for: %s with commands: %s with data size: %s",
|
||||
name,
|
||||
commands,
|
||||
len(torrent_info.torrent_data),
|
||||
)
|
||||
self._rpc.load.raw_start("", torrent_info.torrent_data, ";".join(commands))
|
||||
else:
|
||||
logger.debug(f"Adding torrent URL to rTorrent for: {name} with commands: {commands} with URL: {url}")
|
||||
logger.debug(
|
||||
"Adding torrent URL to rTorrent for: %s with commands: %s with URL: %s",
|
||||
name,
|
||||
commands,
|
||||
url,
|
||||
)
|
||||
add_url = torrent_info.magnet_url or url
|
||||
self._rpc.load.start("", add_url, ";".join(commands))
|
||||
|
||||
torrent_hash = torrent_info.info_hash or expected_hash
|
||||
if not torrent_hash:
|
||||
raise Exception("Could not determine torrent hash from URL")
|
||||
_raise_runtime_error("Could not determine torrent hash from URL")
|
||||
|
||||
logger.debug(f"Added torrent to rTorrent: {torrent_hash}")
|
||||
logger.debug("Added torrent to rTorrent: %s", torrent_hash)
|
||||
|
||||
except Exception:
|
||||
logger.exception("rTorrent add failed")
|
||||
raise
|
||||
else:
|
||||
return torrent_hash
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"rTorrent add failed: {e}")
|
||||
raise
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""
|
||||
Get torrent status by hash.
|
||||
"""Get torrent status by hash.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
|
||||
Returns:
|
||||
Current download status.
|
||||
|
||||
"""
|
||||
try:
|
||||
# rtorrent is somehow case sensitive and requires uppercase hashes for look
|
||||
@@ -169,25 +185,29 @@ class RTorrentClient(DownloadClient):
|
||||
"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}")
|
||||
logger.debug(
|
||||
"Fetched torrent status from rTorrent for: %s - %s",
|
||||
download_id,
|
||||
torrent_list,
|
||||
)
|
||||
if not torrent_list:
|
||||
logger.warning(f"Torrent not found in rTorrent: {download_id}")
|
||||
logger.warning("Torrent not found in rTorrent: %s", download_id)
|
||||
return DownloadStatus.error("Torrent not found")
|
||||
|
||||
torrent = torrent_list[0]
|
||||
if not torrent:
|
||||
logger.warning(f"Torrent data is empty for: {download_id}")
|
||||
logger.warning("Torrent data is empty for: %s", download_id)
|
||||
return DownloadStatus.error("Torrent not found")
|
||||
|
||||
logger.debug(f"Torrent data for {download_id}: {torrent}")
|
||||
logger.debug("Torrent data for %s: %s", download_id, torrent)
|
||||
(
|
||||
torrent_hash,
|
||||
_torrent_hash,
|
||||
state,
|
||||
bytes_downloaded,
|
||||
bytes_total,
|
||||
down_rate,
|
||||
up_rate,
|
||||
custom_category,
|
||||
_up_rate,
|
||||
_custom_category,
|
||||
complete,
|
||||
) = torrent
|
||||
|
||||
@@ -198,10 +218,7 @@ class RTorrentClient(DownloadClient):
|
||||
|
||||
complete = bool(complete)
|
||||
|
||||
if bytes_total > 0:
|
||||
progress = (bytes_downloaded / bytes_total) * 100
|
||||
else:
|
||||
progress = 0
|
||||
progress = (bytes_downloaded / bytes_total) * 100 if bytes_total > 0 else 0
|
||||
|
||||
bytes_left = max(0, bytes_total - bytes_downloaded)
|
||||
|
||||
@@ -221,7 +238,7 @@ class RTorrentClient(DownloadClient):
|
||||
eta = None
|
||||
if down_rate > 0 and bytes_left > 0:
|
||||
eta_seconds = bytes_left / down_rate
|
||||
if eta_seconds < 604800:
|
||||
if eta_seconds < _ETA_MAX_SECONDS:
|
||||
eta = int(eta_seconds)
|
||||
|
||||
file_path = None
|
||||
@@ -240,12 +257,11 @@ class RTorrentClient(DownloadClient):
|
||||
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
logger.error(f"rTorrent get_status failed ({error_type}): {e}")
|
||||
logger.exception("rTorrent get_status failed (%s)", error_type)
|
||||
return DownloadStatus.error(f"{error_type}: {e}")
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
"""
|
||||
Remove a torrent from rTorrent.
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
"""Remove a torrent from rTorrent.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
@@ -253,6 +269,7 @@ class RTorrentClient(DownloadClient):
|
||||
|
||||
Returns:
|
||||
True if successful.
|
||||
|
||||
"""
|
||||
try:
|
||||
if delete_files:
|
||||
@@ -263,35 +280,37 @@ class RTorrentClient(DownloadClient):
|
||||
self._rpc.d.erase(download_id)
|
||||
|
||||
logger.info(
|
||||
f"Removed torrent from rTorrent: {download_id}"
|
||||
+ (" (with files)" if delete_files else "")
|
||||
"Removed torrent from rTorrent: %s%s",
|
||||
download_id,
|
||||
" (with files)" if delete_files else "",
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
logger.error(f"rTorrent remove failed ({error_type}): {e}")
|
||||
logger.exception("rTorrent remove failed (%s)", error_type)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get the path where torrent files are located.
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where torrent files are located.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
|
||||
Returns:
|
||||
Content path (file or directory), or None.
|
||||
|
||||
"""
|
||||
try:
|
||||
return self._get_torrent_path(download_id)
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
logger.debug(f"rTorrent get_download_path failed ({error_type}): {e}")
|
||||
logger.debug("rTorrent get_download_path failed (%s): %s", error_type, e)
|
||||
return None
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
self, url: str, category: str | None = None
|
||||
) -> tuple[str, DownloadStatus] | None:
|
||||
"""Check if a torrent for this URL already exists in rTorrent."""
|
||||
try:
|
||||
torrent_info = extract_torrent_info(url)
|
||||
@@ -304,21 +323,20 @@ class RTorrentClient(DownloadClient):
|
||||
return (torrent_info.info_hash, status)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking for existing torrent: {e}")
|
||||
logger.debug("Error checking for existing torrent: %s", e)
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
def _get_download_dir(self) -> str:
|
||||
"""Get the download directory from rTorrent config."""
|
||||
try:
|
||||
download_dir = self._rpc.directory.default()
|
||||
return download_dir
|
||||
return self._rpc.directory.default()
|
||||
except Exception:
|
||||
return "/downloads"
|
||||
|
||||
def _get_torrent_path(self, download_id: str) -> Optional[str]:
|
||||
def _get_torrent_path(self, download_id: str) -> str | None:
|
||||
"""Get the file path of a torrent by hash.
|
||||
|
||||
Uses `d.base_path` for the item output path. In the xmlrpc interface
|
||||
@@ -337,6 +355,7 @@ class RTorrentClient(DownloadClient):
|
||||
if not details:
|
||||
return None
|
||||
path = details[0][0]
|
||||
return path if path else None
|
||||
except Exception:
|
||||
return None
|
||||
else:
|
||||
return path or None
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""
|
||||
SABnzbd download client for Prowlarr integration.
|
||||
"""SABnzbd download client for Prowlarr integration.
|
||||
|
||||
Uses SABnzbd's REST API directly via requests (no external dependency).
|
||||
"""
|
||||
|
||||
from typing import Any, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
@@ -12,38 +10,41 @@ import requests
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
with_retry,
|
||||
)
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_ETA_PART_COUNT = 3
|
||||
_SPEED_PARTS_MIN = 2
|
||||
|
||||
def _parse_eta(eta_str: str) -> Optional[int]:
|
||||
|
||||
def _parse_eta(eta_str: str) -> int | None:
|
||||
"""Parse SABnzbd ETA string (format: 'H:MM:SS') to seconds."""
|
||||
if not eta_str or eta_str == "0:00:00":
|
||||
return None
|
||||
try:
|
||||
parts = eta_str.split(":")
|
||||
if len(parts) == 3:
|
||||
if len(parts) == _ETA_PART_COUNT:
|
||||
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
|
||||
except (ValueError, IndexError):
|
||||
except ValueError, IndexError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_speed(slot: dict) -> Optional[int]:
|
||||
def _parse_speed(slot: dict) -> int | None:
|
||||
"""Parse download speed from SABnzbd slot data, returning bytes/sec."""
|
||||
# Prefer kbpersec field (more reliable numeric value)
|
||||
kbpersec_str = slot.get("kbpersec", "")
|
||||
if kbpersec_str:
|
||||
try:
|
||||
return int(float(kbpersec_str) * 1024)
|
||||
except (ValueError, TypeError):
|
||||
except ValueError, TypeError:
|
||||
pass
|
||||
|
||||
# Fall back to human-readable speed field
|
||||
@@ -53,7 +54,7 @@ def _parse_speed(slot: dict) -> Optional[int]:
|
||||
|
||||
try:
|
||||
speed_parts = speed_str.split()
|
||||
if len(speed_parts) < 2:
|
||||
if len(speed_parts) < _SPEED_PARTS_MIN:
|
||||
return None
|
||||
speed_val = float(speed_parts[0])
|
||||
unit = speed_parts[1].upper()
|
||||
@@ -62,7 +63,7 @@ def _parse_speed(slot: dict) -> Optional[int]:
|
||||
if prefix in unit:
|
||||
return int(speed_val * mult)
|
||||
return int(speed_val)
|
||||
except (ValueError, IndexError):
|
||||
except ValueError, IndexError:
|
||||
return None
|
||||
|
||||
|
||||
@@ -100,19 +101,22 @@ class SABnzbdClient(DownloadClient):
|
||||
protocol = "usenet"
|
||||
name = "sabnzbd"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize SABnzbd client with settings from config."""
|
||||
raw_url = config.get("SABNZBD_URL", "")
|
||||
if not raw_url:
|
||||
raise ValueError("SABNZBD_URL is required")
|
||||
msg = "SABNZBD_URL is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
api_key = config.get("SABNZBD_API_KEY", "")
|
||||
if not api_key:
|
||||
raise ValueError("SABNZBD_API_KEY is required")
|
||||
msg = "SABNZBD_API_KEY is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
self.url = normalize_http_url(raw_url)
|
||||
if not self.url:
|
||||
raise ValueError("SABNZBD_URL is invalid")
|
||||
msg = "SABNZBD_URL is invalid"
|
||||
raise ValueError(msg)
|
||||
self.api_key = api_key
|
||||
self._category = config.get("SABNZBD_CATEGORY", "books")
|
||||
|
||||
@@ -125,9 +129,8 @@ class SABnzbdClient(DownloadClient):
|
||||
return client == "sabnzbd" and bool(url) and bool(api_key)
|
||||
|
||||
@with_retry()
|
||||
def _api_call(self, mode: str, params: Optional[dict] = None) -> Any:
|
||||
"""
|
||||
Make an API call to SABnzbd.
|
||||
def _api_call(self, mode: str, params: dict | None = None) -> object:
|
||||
"""Make an API call to SABnzbd.
|
||||
|
||||
Args:
|
||||
mode: API mode (e.g., "version", "addurl", "queue", "history")
|
||||
@@ -138,6 +141,7 @@ class SABnzbdClient(DownloadClient):
|
||||
|
||||
Raises:
|
||||
Exception: If API call fails after retries.
|
||||
|
||||
"""
|
||||
api_url = f"{self.url}/api"
|
||||
|
||||
@@ -149,7 +153,9 @@ class SABnzbdClient(DownloadClient):
|
||||
if params:
|
||||
request_params.update(params)
|
||||
|
||||
response = requests.get(api_url, params=request_params, timeout=30, verify=get_ssl_verify(api_url))
|
||||
response = requests.get(
|
||||
api_url, params=request_params, timeout=30, verify=get_ssl_verify(api_url)
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
@@ -157,16 +163,19 @@ class SABnzbdClient(DownloadClient):
|
||||
# Check for error in response
|
||||
if isinstance(result, dict) and result.get("status") is False:
|
||||
error = result.get("error", "Unknown error")
|
||||
raise Exception(f"SABnzbd error: {error}")
|
||||
msg = f"SABnzbd error: {error}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
return result
|
||||
|
||||
def _api_post_file(self, nzb_content: bytes, filename: str, nzb_name: str, category: str) -> Any:
|
||||
"""
|
||||
Upload an NZB file to SABnzbd using addfile.
|
||||
def _api_post_file(
|
||||
self, nzb_content: bytes, filename: str, nzb_name: str, category: str
|
||||
) -> object:
|
||||
"""Upload an NZB file to SABnzbd using addfile.
|
||||
|
||||
Returns:
|
||||
JSON response from SABnzbd.
|
||||
|
||||
"""
|
||||
api_url = f"{self.url}/api"
|
||||
request_params = {
|
||||
@@ -178,13 +187,20 @@ class SABnzbdClient(DownloadClient):
|
||||
}
|
||||
files = {"name": (filename, nzb_content, "application/x-nzb")}
|
||||
|
||||
response = requests.post(api_url, params=request_params, files=files, timeout=30, verify=get_ssl_verify(api_url))
|
||||
response = requests.post(
|
||||
api_url,
|
||||
params=request_params,
|
||||
files=files,
|
||||
timeout=30,
|
||||
verify=get_ssl_verify(api_url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if isinstance(result, dict) and result.get("status") is False:
|
||||
error = result.get("error", "Unknown error")
|
||||
raise Exception(f"SABnzbd error: {error}")
|
||||
msg = f"SABnzbd error: {error}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
return result
|
||||
|
||||
@@ -242,9 +258,10 @@ class SABnzbdClient(DownloadClient):
|
||||
return f"{base_name}.nzb"
|
||||
|
||||
@staticmethod
|
||||
def _extract_nzo_id(result: Any) -> str:
|
||||
def _extract_nzo_id(result: object) -> str:
|
||||
if not isinstance(result, dict):
|
||||
raise Exception("SABnzbd returned invalid response")
|
||||
msg = "SABnzbd returned invalid response"
|
||||
raise TypeError(msg)
|
||||
|
||||
nzo_ids = result.get("nzo_ids") or result.get("nzo_id")
|
||||
if isinstance(nzo_ids, list) and nzo_ids:
|
||||
@@ -254,31 +271,32 @@ class SABnzbdClient(DownloadClient):
|
||||
if isinstance(nzo_ids, int):
|
||||
return str(nzo_ids)
|
||||
|
||||
raise Exception("SABnzbd returned no nzo_id")
|
||||
msg = "SABnzbd returned no nzo_id"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Test connection to SABnzbd."""
|
||||
try:
|
||||
result = self._api_call("version")
|
||||
version = result.get("version", "unknown")
|
||||
return True, f"Connected to SABnzbd {version}"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, "Could not connect to SABnzbd"
|
||||
except requests.exceptions.Timeout:
|
||||
return False, "Connection timed out"
|
||||
except Exception as e:
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
return False, f"Connection failed: {e!s}"
|
||||
else:
|
||||
return True, f"Connected to SABnzbd {version}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Add NZB by URL.
|
||||
"""Add NZB by URL.
|
||||
|
||||
Args:
|
||||
url: NZB URL (can be Prowlarr proxy URL)
|
||||
@@ -291,20 +309,22 @@ class SABnzbdClient(DownloadClient):
|
||||
|
||||
Raises:
|
||||
Exception: If adding fails.
|
||||
|
||||
"""
|
||||
# Use configured category if not explicitly provided
|
||||
category = category or self._category
|
||||
|
||||
try:
|
||||
logger.debug(f"Adding NZB to SABnzbd: {name}")
|
||||
logger.debug("Adding NZB to SABnzbd: %s", name)
|
||||
nzb_filename = self._build_nzb_filename(name, url)
|
||||
nzb_content = self._fetch_nzb_content(url)
|
||||
result = self._api_post_file(nzb_content, nzb_filename, name, category)
|
||||
nzo_id = self._extract_nzo_id(result)
|
||||
logger.info(f"Added NZB to SABnzbd: {nzo_id}")
|
||||
return nzo_id
|
||||
logger.info("Added NZB to SABnzbd: %s", nzo_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"SABnzbd addfile failed, falling back to addurl: {e}")
|
||||
logger.warning("SABnzbd addfile failed, falling back to addurl: %s", e)
|
||||
else:
|
||||
return nzo_id
|
||||
|
||||
try:
|
||||
result = self._api_call(
|
||||
@@ -316,21 +336,22 @@ class SABnzbdClient(DownloadClient):
|
||||
},
|
||||
)
|
||||
nzo_id = self._extract_nzo_id(result)
|
||||
logger.info(f"Added NZB to SABnzbd via addurl: {nzo_id}")
|
||||
return nzo_id
|
||||
except Exception as e:
|
||||
logger.error(f"SABnzbd add failed: {e}")
|
||||
logger.info("Added NZB to SABnzbd via addurl: %s", nzo_id)
|
||||
except Exception:
|
||||
logger.exception("SABnzbd add failed")
|
||||
raise
|
||||
else:
|
||||
return nzo_id
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""
|
||||
Get NZB status by nzo_id.
|
||||
"""Get NZB status by nzo_id.
|
||||
|
||||
Args:
|
||||
download_id: SABnzbd nzo_id
|
||||
|
||||
Returns:
|
||||
Current download status.
|
||||
|
||||
"""
|
||||
try:
|
||||
# Check active queue first
|
||||
@@ -383,7 +404,12 @@ class SABnzbdClient(DownloadClient):
|
||||
storage = slot.get("storage", "")
|
||||
if storage is None:
|
||||
storage = ""
|
||||
logger.debug(f"SABnzbd history: {download_id} status={status_text} storage='{storage}'")
|
||||
logger.debug(
|
||||
"SABnzbd history: %s status=%s storage='%s'",
|
||||
download_id,
|
||||
status_text,
|
||||
storage,
|
||||
)
|
||||
|
||||
if status_text == "COMPLETED":
|
||||
title = slot.get("name") or slot.get("nzb_name") or ""
|
||||
@@ -396,7 +422,7 @@ class SABnzbdClient(DownloadClient):
|
||||
complete=True,
|
||||
file_path=resolved_storage,
|
||||
)
|
||||
elif status_text == "FAILED":
|
||||
if status_text == "FAILED":
|
||||
fail_message = slot.get("fail_message", "Download failed")
|
||||
title = slot.get("name") or slot.get("nzb_name") or ""
|
||||
resolved_storage = self._resolve_completed_storage_path(storage, title)
|
||||
@@ -407,27 +433,25 @@ class SABnzbdClient(DownloadClient):
|
||||
complete=True,
|
||||
file_path=resolved_storage,
|
||||
)
|
||||
else:
|
||||
# Post-processing states: Queued, QuickCheck, Verifying,
|
||||
# Repairing, Fetching, Extracting, Moving, Running
|
||||
# Keep polling - not yet complete
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
state="processing",
|
||||
message=status_text.title(),
|
||||
complete=False,
|
||||
file_path=None,
|
||||
)
|
||||
# Post-processing states: Queued, QuickCheck, Verifying,
|
||||
# Repairing, Fetching, Extracting, Moving, Running
|
||||
# Keep polling - not yet complete
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
state="processing",
|
||||
message=status_text.title(),
|
||||
complete=False,
|
||||
file_path=None,
|
||||
)
|
||||
|
||||
# Not found
|
||||
logger.warning(f"SABnzbd: download {download_id} not found in queue or history")
|
||||
logger.warning("SABnzbd: download %s not found in queue or history", download_id)
|
||||
return DownloadStatus.error("Download not found")
|
||||
except Exception as e:
|
||||
return DownloadStatus.error(self._log_error("get_status", e))
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False, archive: bool = True) -> bool:
|
||||
"""
|
||||
Remove a download from SABnzbd.
|
||||
def remove(self, download_id: str, *, delete_files: bool = False, archive: bool = True) -> bool:
|
||||
"""Remove a download from SABnzbd.
|
||||
|
||||
Args:
|
||||
download_id: SABnzbd nzo_id
|
||||
@@ -436,6 +460,7 @@ class SABnzbdClient(DownloadClient):
|
||||
|
||||
Returns:
|
||||
True if successful.
|
||||
|
||||
"""
|
||||
# First try to remove from queue. If it isn't there (common for completed jobs),
|
||||
# fall back to history removal instead of failing fast on a SABnzbd error response.
|
||||
@@ -450,10 +475,10 @@ class SABnzbdClient(DownloadClient):
|
||||
)
|
||||
|
||||
if result.get("status"):
|
||||
logger.info(f"Removed NZB from SABnzbd queue: {download_id}")
|
||||
logger.info("Removed NZB from SABnzbd queue: %s", download_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"SABnzbd queue delete skipped for {download_id}: {e}")
|
||||
logger.debug("SABnzbd queue delete skipped for %s: %s", download_id, e)
|
||||
|
||||
# If not in queue (or queue delete failed), try to remove from history.
|
||||
try:
|
||||
@@ -469,7 +494,7 @@ class SABnzbdClient(DownloadClient):
|
||||
|
||||
if result.get("status"):
|
||||
action = "archived" if archive else "removed"
|
||||
logger.info(f"NZB {action} from SABnzbd history: {download_id}")
|
||||
logger.info("NZB %s from SABnzbd history: %s", action, download_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
self._log_error("remove", e)
|
||||
@@ -477,24 +502,23 @@ class SABnzbdClient(DownloadClient):
|
||||
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get the path where NZB files are located.
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where NZB files are located.
|
||||
|
||||
Args:
|
||||
download_id: SABnzbd nzo_id
|
||||
|
||||
Returns:
|
||||
Storage directory, or None.
|
||||
|
||||
"""
|
||||
status = self.get_status(download_id)
|
||||
return status.file_path
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
"""
|
||||
Check if an NZB for this URL already exists in SABnzbd.
|
||||
self, url: str, category: str | None = None
|
||||
) -> tuple[str, DownloadStatus] | None:
|
||||
"""Check if an NZB for this URL already exists in SABnzbd.
|
||||
|
||||
Note: Unlike torrents which have a unique info_hash, usenet NZBs don't have
|
||||
a universal unique identifier. SABnzbd generates an nzo_id when adding,
|
||||
@@ -507,23 +531,22 @@ class SABnzbdClient(DownloadClient):
|
||||
|
||||
Returns:
|
||||
Tuple of (nzo_id, status) if found, None if not found.
|
||||
|
||||
"""
|
||||
try:
|
||||
# Extract NZB name from URL (last path component without extension)
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
path = unquote(parsed.path)
|
||||
|
||||
# Get filename from path
|
||||
if "/" in path:
|
||||
filename = path.rsplit("/", 1)[-1]
|
||||
else:
|
||||
filename = path
|
||||
filename = path.rsplit("/", 1)[-1] if "/" in path else path
|
||||
|
||||
# Remove common NZB extensions
|
||||
for ext in [".nzb", ".nzb.gz"]:
|
||||
if filename.lower().endswith(ext):
|
||||
filename = filename[:-len(ext)]
|
||||
filename = filename[: -len(ext)]
|
||||
break
|
||||
|
||||
if not filename:
|
||||
@@ -543,7 +566,7 @@ class SABnzbdClient(DownloadClient):
|
||||
nzo_id = slot.get("nzo_id")
|
||||
if nzo_id:
|
||||
status = self.get_status(nzo_id)
|
||||
logger.debug(f"Found existing NZB in SABnzbd queue: {nzo_id}")
|
||||
logger.debug("Found existing NZB in SABnzbd queue: %s", nzo_id)
|
||||
return (nzo_id, status)
|
||||
|
||||
# Search history (SABnzbd uses "category" field in history)
|
||||
@@ -557,11 +580,11 @@ class SABnzbdClient(DownloadClient):
|
||||
nzo_id = slot.get("nzo_id")
|
||||
if nzo_id:
|
||||
status = self.get_status(nzo_id)
|
||||
logger.debug(f"Found existing NZB in SABnzbd history: {nzo_id}")
|
||||
logger.debug("Found existing NZB in SABnzbd history: %s", nzo_id)
|
||||
return (nzo_id, status)
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking for existing NZB: {e}")
|
||||
logger.debug("Error checking for existing NZB: %s", e)
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
"""Shared download client settings registration."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Optional
|
||||
from contextlib import contextmanager, suppress
|
||||
from typing import TYPE_CHECKING, Any, NoReturn
|
||||
|
||||
from shelfmark.core.settings_registry import (
|
||||
register_settings,
|
||||
HeadingField,
|
||||
TextField,
|
||||
PasswordField,
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
SettingsField,
|
||||
TagListField,
|
||||
TextField,
|
||||
register_settings,
|
||||
)
|
||||
from shelfmark.core.utils import normalize_http_url, get_hardened_xmlrpc_client
|
||||
from shelfmark.core.utils import get_hardened_xmlrpc_client, normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
# ==================== Test Connection Callbacks ====================
|
||||
|
||||
|
||||
def _raise_runtime_error(message: str) -> NoReturn:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _transmission_session_verify_override(url: str):
|
||||
def _transmission_session_verify_override(url: str) -> Iterator[None]:
|
||||
"""Ensure transmission-rpc constructor uses the configured TLS verify mode."""
|
||||
verify = get_ssl_verify(url)
|
||||
if verify:
|
||||
@@ -28,7 +36,7 @@ def _transmission_session_verify_override(url: str):
|
||||
|
||||
try:
|
||||
import transmission_rpc.client as transmission_rpc_client
|
||||
except Exception:
|
||||
except ImportError:
|
||||
yield
|
||||
return
|
||||
|
||||
@@ -46,7 +54,7 @@ def _transmission_session_verify_override(url: str):
|
||||
transmission_rpc_client.requests.Session = original_session_factory
|
||||
|
||||
|
||||
def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Test the qBittorrent connection using current form values."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
@@ -66,17 +74,23 @@ def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None
|
||||
if not url:
|
||||
return {"success": False, "message": "qBittorrent URL is invalid"}
|
||||
|
||||
client = Client(host=url, username=username, password=password, VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(url))
|
||||
client = Client(
|
||||
host=url,
|
||||
username=username,
|
||||
password=password,
|
||||
VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(url),
|
||||
)
|
||||
client.auth_log_in()
|
||||
api_version = client.app.web_api_version
|
||||
return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"}
|
||||
except ImportError:
|
||||
return {"success": False, "message": "qbittorrent-api package not installed"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Connection failed: {str(e)}"}
|
||||
return {"success": False, "message": f"Connection failed: {e!s}"}
|
||||
else:
|
||||
return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"}
|
||||
|
||||
|
||||
def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
def _test_transmission_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Test the Transmission connection using current form values."""
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
@@ -86,8 +100,12 @@ def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = Non
|
||||
current_values = current_values or {}
|
||||
|
||||
raw_url = current_values.get("TRANSMISSION_URL") or config.get("TRANSMISSION_URL", "")
|
||||
username = current_values.get("TRANSMISSION_USERNAME") or config.get("TRANSMISSION_USERNAME", "")
|
||||
password = current_values.get("TRANSMISSION_PASSWORD") or config.get("TRANSMISSION_PASSWORD", "")
|
||||
username = current_values.get("TRANSMISSION_USERNAME") or config.get(
|
||||
"TRANSMISSION_USERNAME", ""
|
||||
)
|
||||
password = current_values.get("TRANSMISSION_PASSWORD") or config.get(
|
||||
"TRANSMISSION_PASSWORD", ""
|
||||
)
|
||||
|
||||
if not raw_url:
|
||||
return {"success": False, "message": "Transmission URL is required"}
|
||||
@@ -106,8 +124,8 @@ def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = Non
|
||||
"host": host,
|
||||
"port": port,
|
||||
"path": path,
|
||||
"username": username if username else None,
|
||||
"password": password if password else None,
|
||||
"username": username or None,
|
||||
"password": password or None,
|
||||
"protocol": protocol,
|
||||
}
|
||||
try:
|
||||
@@ -119,11 +137,9 @@ def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = Non
|
||||
client_kwargs.pop("protocol", None)
|
||||
with _transmission_session_verify_override(url):
|
||||
client = Client(**client_kwargs)
|
||||
if protocol == "https" and hasattr(client, "protocol"):
|
||||
try:
|
||||
setattr(client, "protocol", protocol)
|
||||
except Exception:
|
||||
pass
|
||||
if protocol == "https" and hasattr(client, "protocol"):
|
||||
with suppress(Exception):
|
||||
client.protocol = protocol
|
||||
|
||||
# Keep session verify aligned for subsequent calls beyond constructor bootstrap.
|
||||
http_session = getattr(client, "_http_session", None)
|
||||
@@ -132,18 +148,20 @@ def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = Non
|
||||
|
||||
session = client.get_session()
|
||||
version = session.version
|
||||
return {"success": True, "message": f"Connected to Transmission {version}"}
|
||||
except ImportError:
|
||||
return {"success": False, "message": "transmission-rpc package not installed"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Connection failed: {str(e)}"}
|
||||
return {"success": False, "message": f"Connection failed: {e!s}"}
|
||||
else:
|
||||
return {"success": True, "message": f"Connected to Transmission {version}"}
|
||||
|
||||
|
||||
def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
def _test_deluge_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Test Deluge Web UI JSON-RPC connection using current form values."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
|
||||
current_values = current_values or {}
|
||||
@@ -177,13 +195,12 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) ->
|
||||
if parsed.port is not None:
|
||||
port = parsed.port
|
||||
base_path = (parsed.path or "").rstrip("/")
|
||||
else:
|
||||
# Allow "host:port" in DELUGE_HOST for convenience.
|
||||
if ":" in raw_host and raw_host.count(":") == 1:
|
||||
host_part, port_part = raw_host.split(":", 1)
|
||||
if host_part and port_part.isdigit():
|
||||
host = host_part
|
||||
port = int(port_part)
|
||||
# Allow "host:port" in DELUGE_HOST for convenience.
|
||||
elif ":" in raw_host and raw_host.count(":") == 1:
|
||||
host_part, port_part = raw_host.split(":", 1)
|
||||
if host_part and port_part.isdigit():
|
||||
host = host_part
|
||||
port = int(port_part)
|
||||
|
||||
rpc_url = f"{scheme}://{host}:{port}{base_path}/json"
|
||||
|
||||
@@ -195,8 +212,8 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) ->
|
||||
if data.get("error"):
|
||||
error = data["error"]
|
||||
if isinstance(error, dict):
|
||||
raise Exception(error.get("message") or str(error))
|
||||
raise Exception(str(error))
|
||||
raise RuntimeError(error.get("message") or str(error))
|
||||
raise RuntimeError(str(error))
|
||||
return data.get("result")
|
||||
|
||||
def get_daemon_version(session: requests.Session, rpc_id: int) -> Any:
|
||||
@@ -204,7 +221,7 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) ->
|
||||
methods = rpc_call(session, rpc_id, "system.listMethods")
|
||||
if isinstance(methods, list) and "daemon.get_version" in methods:
|
||||
return rpc_call(session, rpc_id + 1, "daemon.get_version")
|
||||
except Exception:
|
||||
except requests.exceptions.RequestException, RuntimeError, ValueError, TypeError:
|
||||
# Fall back to daemon.info to preserve existing behavior.
|
||||
pass
|
||||
|
||||
@@ -226,7 +243,11 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) ->
|
||||
|
||||
host_id = hosts[0][0]
|
||||
for entry in hosts:
|
||||
if isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"}:
|
||||
if (
|
||||
isinstance(entry, list)
|
||||
and len(entry) >= 2
|
||||
and entry[1] in {"127.0.0.1", "localhost"}
|
||||
):
|
||||
host_id = entry[0]
|
||||
break
|
||||
|
||||
@@ -239,22 +260,31 @@ def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) ->
|
||||
}
|
||||
|
||||
version = get_daemon_version(session, 6)
|
||||
return {"success": True, "message": f"Connected to Deluge {version}"}
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
return {"success": False, "message": "Could not connect to Deluge Web UI"}
|
||||
except requests.exceptions.Timeout:
|
||||
return {"success": False, "message": "Connection timed out"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Connection failed: {str(e)}"}
|
||||
except (
|
||||
requests.exceptions.RequestException,
|
||||
RuntimeError,
|
||||
ValueError,
|
||||
TypeError,
|
||||
KeyError,
|
||||
IndexError,
|
||||
AttributeError,
|
||||
) as e:
|
||||
return {"success": False, "message": f"Connection failed: {e!s}"}
|
||||
else:
|
||||
return {"success": True, "message": f"Connected to Deluge {version}"}
|
||||
|
||||
|
||||
def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
def _test_rtorrent_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Test the rTorrent connection using current form values."""
|
||||
from shelfmark.core.config import config
|
||||
import ssl
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from shelfmark.core.config import config
|
||||
|
||||
current_values = current_values or {}
|
||||
|
||||
raw_url = current_values.get("RTORRENT_URL") or config.get("RTORRENT_URL", "")
|
||||
@@ -290,14 +320,17 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -
|
||||
rpc = xmlrpc_client.ServerProxy(rpc_url)
|
||||
|
||||
version = rpc.system.client_version()
|
||||
except (xmlrpc_client.Error, RuntimeError, OSError, ValueError, TypeError) as e:
|
||||
return {"success": False, "message": f"Connection failed: {e!s}"}
|
||||
|
||||
else:
|
||||
return {"success": True, "message": f"Connected to rTorrent {version}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Connection failed: {str(e)}"}
|
||||
|
||||
|
||||
def _test_nzbget_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
def _test_nzbget_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Test the NZBGet connection using current form values."""
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
|
||||
current_values = current_values or {}
|
||||
@@ -316,24 +349,38 @@ def _test_nzbget_connection(current_values: Optional[Dict[str, Any]] = None) ->
|
||||
try:
|
||||
rpc_url = f"{url.rstrip('/')}/jsonrpc"
|
||||
payload = {"jsonrpc": "2.0", "method": "status", "params": [], "id": 1}
|
||||
response = requests.post(rpc_url, json=payload, auth=(username, password), timeout=30, verify=get_ssl_verify(rpc_url))
|
||||
response = requests.post(
|
||||
rpc_url,
|
||||
json=payload,
|
||||
auth=(username, password),
|
||||
timeout=30,
|
||||
verify=get_ssl_verify(rpc_url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if "error" in result and result["error"]:
|
||||
raise Exception(result["error"].get("message", "RPC error"))
|
||||
if result.get("error"):
|
||||
_raise_runtime_error(result["error"].get("message", "RPC error"))
|
||||
version = result.get("result", {}).get("Version", "unknown")
|
||||
return {"success": True, "message": f"Connected to NZBGet {version}"}
|
||||
except requests.exceptions.ConnectionError:
|
||||
return {"success": False, "message": "Could not connect to NZBGet"}
|
||||
except requests.exceptions.Timeout:
|
||||
return {"success": False, "message": "Connection timed out"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Connection failed: {str(e)}"}
|
||||
except (
|
||||
requests.exceptions.RequestException,
|
||||
RuntimeError,
|
||||
ValueError,
|
||||
AttributeError,
|
||||
TypeError,
|
||||
) as e:
|
||||
return {"success": False, "message": f"Connection failed: {e!s}"}
|
||||
else:
|
||||
return {"success": True, "message": f"Connected to NZBGet {version}"}
|
||||
|
||||
|
||||
def _test_sabnzbd_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
def _test_sabnzbd_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Test the SABnzbd connection using current form values."""
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
|
||||
current_values = current_values or {}
|
||||
@@ -357,24 +404,32 @@ def _test_sabnzbd_connection(current_values: Optional[Dict[str, Any]] = None) ->
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
version = result.get("version", "unknown")
|
||||
return {"success": True, "message": f"Connected to SABnzbd {version}"}
|
||||
except requests.exceptions.ConnectionError:
|
||||
return {"success": False, "message": "Could not connect to SABnzbd"}
|
||||
except requests.exceptions.Timeout:
|
||||
return {"success": False, "message": "Connection timed out"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Connection failed: {str(e)}"}
|
||||
except (
|
||||
requests.exceptions.RequestException,
|
||||
RuntimeError,
|
||||
ValueError,
|
||||
AttributeError,
|
||||
TypeError,
|
||||
) as e:
|
||||
return {"success": False, "message": f"Connection failed: {e!s}"}
|
||||
else:
|
||||
return {"success": True, "message": f"Connected to SABnzbd {version}"}
|
||||
|
||||
|
||||
# ==================== Download Clients Tab ====================
|
||||
|
||||
|
||||
@register_settings(
|
||||
name="prowlarr_clients",
|
||||
display_name="Download Clients",
|
||||
icon="cog",
|
||||
order=110,
|
||||
)
|
||||
def prowlarr_clients_settings():
|
||||
def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
"""Download client settings shared by external release sources."""
|
||||
return [
|
||||
# --- Torrent Client Selection ---
|
||||
@@ -396,7 +451,6 @@ def prowlarr_clients_settings():
|
||||
],
|
||||
default="",
|
||||
),
|
||||
|
||||
# --- qBittorrent Settings ---
|
||||
TextField(
|
||||
key="QBITTORRENT_URL",
|
||||
@@ -458,7 +512,6 @@ def prowlarr_clients_settings():
|
||||
normalize_urls=False,
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
|
||||
# --- Transmission Settings ---
|
||||
TextField(
|
||||
key="TRANSMISSION_URL",
|
||||
@@ -510,7 +563,6 @@ def prowlarr_clients_settings():
|
||||
placeholder="/downloads",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
),
|
||||
|
||||
# --- Deluge Settings ---
|
||||
TextField(
|
||||
key="DELUGE_HOST",
|
||||
@@ -565,7 +617,6 @@ def prowlarr_clients_settings():
|
||||
placeholder="/downloads",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
),
|
||||
|
||||
# --- rTorrent Settings ---
|
||||
TextField(
|
||||
key="RTORRENT_URL",
|
||||
@@ -621,7 +672,6 @@ def prowlarr_clients_settings():
|
||||
default="keep",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True},
|
||||
),
|
||||
|
||||
# --- Usenet Client Selection ---
|
||||
HeadingField(
|
||||
key="usenet_heading",
|
||||
@@ -639,7 +689,6 @@ def prowlarr_clients_settings():
|
||||
],
|
||||
default="",
|
||||
),
|
||||
|
||||
# --- NZBGet Settings ---
|
||||
TextField(
|
||||
key="NZBGET_URL",
|
||||
@@ -686,7 +735,6 @@ def prowlarr_clients_settings():
|
||||
default="",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
|
||||
),
|
||||
|
||||
# --- SABnzbd Settings ---
|
||||
TextField(
|
||||
key="SABNZBD_URL",
|
||||
|
||||
@@ -4,7 +4,6 @@ import base64
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import parse_qs, urljoin, urlparse
|
||||
|
||||
import requests
|
||||
@@ -15,24 +14,32 @@ from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_MAGNET_RESPONSE_MAX_BYTES = 2000
|
||||
_BASE32_BTMH_TAG_BYTES = 34
|
||||
_BTIH_INFO_BYTE_HEX = 0x20
|
||||
_BTIH_PREFIX_BYTE = 0x12
|
||||
_BTIH_DIGEST_LENGTH = 32
|
||||
_BTIH_HASH_LENGTH_40 = 40
|
||||
_BTIH_HASH_LENGTH_32 = 32
|
||||
|
||||
|
||||
@dataclass
|
||||
class TorrentInfo:
|
||||
"""Parsed information from a torrent URL."""
|
||||
|
||||
info_hash: Optional[str]
|
||||
info_hash: str | None
|
||||
"""Lowercase hex info_hash (32 or 40 chars), or None if extraction failed."""
|
||||
|
||||
torrent_data: Optional[bytes]
|
||||
torrent_data: bytes | None
|
||||
"""Raw .torrent file content, only populated for .torrent URLs."""
|
||||
|
||||
is_magnet: bool
|
||||
"""True if the URL was a magnet link."""
|
||||
|
||||
magnet_url: Optional[str] = None
|
||||
magnet_url: str | None = None
|
||||
"""The actual magnet URL, if available."""
|
||||
|
||||
def with_info_hash(self, info_hash: Optional[str]) -> "TorrentInfo":
|
||||
def with_info_hash(self, info_hash: str | None) -> TorrentInfo:
|
||||
"""Return a copy with the info_hash replaced when provided."""
|
||||
if info_hash:
|
||||
return TorrentInfo(
|
||||
@@ -46,8 +53,9 @@ class TorrentInfo:
|
||||
|
||||
def extract_torrent_info(
|
||||
url: str,
|
||||
*,
|
||||
fetch_torrent: bool = True,
|
||||
expected_hash: Optional[str] = None,
|
||||
expected_hash: str | None = None,
|
||||
) -> TorrentInfo:
|
||||
"""Extract info_hash from magnet link or .torrent URL.
|
||||
|
||||
@@ -58,6 +66,7 @@ def extract_torrent_info(
|
||||
|
||||
Redirects to magnet links are handled explicitly so we can extract a
|
||||
hash from the magnet when available.
|
||||
|
||||
"""
|
||||
is_magnet = url.startswith("magnet:")
|
||||
|
||||
@@ -85,11 +94,17 @@ def extract_torrent_info(
|
||||
return urljoin(current, location)
|
||||
|
||||
try:
|
||||
logger.debug(f"Fetching torrent file from: {url[:80]}...")
|
||||
logger.debug("Fetching torrent file from: %s...", url[:80])
|
||||
|
||||
# Use allow_redirects=False to handle magnet link redirects manually
|
||||
# Some indexers redirect download URLs to magnet links
|
||||
resp = requests.get(url, timeout=30, allow_redirects=False, headers=headers, verify=get_ssl_verify(url))
|
||||
resp = requests.get(
|
||||
url,
|
||||
timeout=30,
|
||||
allow_redirects=False,
|
||||
headers=headers,
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
|
||||
# Check if this is a redirect to a magnet link
|
||||
if resp.status_code in (301, 302, 303, 307, 308):
|
||||
@@ -100,18 +115,26 @@ def extract_torrent_info(
|
||||
if not info_hash and expected_hash:
|
||||
info_hash = expected_hash
|
||||
return TorrentInfo(
|
||||
info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=redirect_url
|
||||
info_hash=info_hash,
|
||||
torrent_data=None,
|
||||
is_magnet=True,
|
||||
magnet_url=redirect_url,
|
||||
)
|
||||
# Not a magnet redirect, follow it manually
|
||||
logger.debug(f"Following redirect to: {redirect_url[:80]}...")
|
||||
resp = requests.get(redirect_url, timeout=30, headers=headers, verify=get_ssl_verify(redirect_url))
|
||||
logger.debug("Following redirect to: %s...", redirect_url[:80])
|
||||
resp = requests.get(
|
||||
redirect_url,
|
||||
timeout=30,
|
||||
headers=headers,
|
||||
verify=get_ssl_verify(redirect_url),
|
||||
)
|
||||
|
||||
resp.raise_for_status()
|
||||
torrent_data = resp.content
|
||||
|
||||
# Check if response is actually a magnet link (text response)
|
||||
# Some indexers return magnet links as plain text instead of redirecting
|
||||
if len(torrent_data) < 2000: # Magnet links are typically short
|
||||
if len(torrent_data) < _MAGNET_RESPONSE_MAX_BYTES: # Magnet links are typically short
|
||||
try:
|
||||
text_content = torrent_data.decode("utf-8", errors="ignore").strip()
|
||||
if text_content.startswith("magnet:"):
|
||||
@@ -120,23 +143,26 @@ def extract_torrent_info(
|
||||
if not info_hash and expected_hash:
|
||||
info_hash = expected_hash
|
||||
return TorrentInfo(
|
||||
info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=text_content
|
||||
info_hash=info_hash,
|
||||
torrent_data=None,
|
||||
is_magnet=True,
|
||||
magnet_url=text_content,
|
||||
)
|
||||
except Exception:
|
||||
pass # Not text, continue with torrent parsing
|
||||
|
||||
info_hash = extract_info_hash_from_torrent(torrent_data) or expected_hash
|
||||
if info_hash:
|
||||
logger.debug(f"Extracted hash from torrent file: {info_hash}")
|
||||
logger.debug("Extracted hash from torrent file: %s", info_hash)
|
||||
else:
|
||||
logger.warning("Could not extract hash from torrent file")
|
||||
return TorrentInfo(info_hash=info_hash, torrent_data=torrent_data, is_magnet=False)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch torrent file: {e}")
|
||||
logger.debug("Could not fetch torrent file: %s", e)
|
||||
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||
|
||||
|
||||
def parse_transmission_url(url: str) -> Tuple[str, str, int, str]:
|
||||
def parse_transmission_url(url: str) -> tuple[str, str, int, str]:
|
||||
"""Parse Transmission URL into (protocol, host, port, path)."""
|
||||
parsed = urlparse(url)
|
||||
protocol = (parsed.scheme or "http").lower()
|
||||
@@ -155,89 +181,89 @@ def parse_transmission_url(url: str) -> Tuple[str, str, int, str]:
|
||||
|
||||
def bencode_decode(data: bytes) -> tuple:
|
||||
"""Decode bencoded data. Returns (value, remaining_bytes)."""
|
||||
if data[0:1] == b'd':
|
||||
if data[0:1] == b"d":
|
||||
# Dictionary
|
||||
result = {}
|
||||
data = data[1:]
|
||||
while data[0:1] != b'e':
|
||||
while data[0:1] != b"e":
|
||||
key, data = bencode_decode(data)
|
||||
value, data = bencode_decode(data)
|
||||
result[key] = value
|
||||
return result, data[1:]
|
||||
elif data[0:1] == b'l':
|
||||
if data[0:1] == b"l":
|
||||
# List
|
||||
result = []
|
||||
data = data[1:]
|
||||
while data[0:1] != b'e':
|
||||
while data[0:1] != b"e":
|
||||
value, data = bencode_decode(data)
|
||||
result.append(value)
|
||||
return result, data[1:]
|
||||
elif data[0:1] == b'i':
|
||||
if data[0:1] == b"i":
|
||||
# Integer
|
||||
end = data.index(b'e')
|
||||
return int(data[1:end]), data[end + 1:]
|
||||
elif data[0:1].isdigit():
|
||||
end = data.index(b"e")
|
||||
return int(data[1:end]), data[end + 1 :]
|
||||
if data[0:1].isdigit():
|
||||
# Byte string
|
||||
colon = data.index(b':')
|
||||
colon = data.index(b":")
|
||||
length = int(data[:colon])
|
||||
start = colon + 1
|
||||
return data[start:start + length], data[start + length:]
|
||||
else:
|
||||
first_byte = data[0:1]
|
||||
raise ValueError(
|
||||
f"Invalid bencode data: expected 'd', 'l', 'i', or digit, "
|
||||
f"got {first_byte!r}. First 20 bytes: {data[:20]!r}"
|
||||
)
|
||||
return data[start : start + length], data[start + length :]
|
||||
first_byte = data[0:1]
|
||||
msg = (
|
||||
f"Invalid bencode data: expected 'd', 'l', 'i', or digit, "
|
||||
f"got {first_byte!r}. First 20 bytes: {data[:20]!r}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def bencode_encode(data) -> bytes:
|
||||
def bencode_encode(data: dict[str | bytes, object] | list[object] | int | bytes | str) -> bytes:
|
||||
"""Encode data to bencode format."""
|
||||
if isinstance(data, dict):
|
||||
# Keys must be sorted (bencode spec requirement)
|
||||
result = b'd'
|
||||
result = b"d"
|
||||
for key in sorted(data.keys()):
|
||||
result += bencode_encode(key)
|
||||
result += bencode_encode(data[key])
|
||||
result += b'e'
|
||||
result += b"e"
|
||||
return result
|
||||
elif isinstance(data, list):
|
||||
result = b'l'
|
||||
if isinstance(data, list):
|
||||
result = b"l"
|
||||
for item in data:
|
||||
result += bencode_encode(item)
|
||||
result += b'e'
|
||||
result += b"e"
|
||||
return result
|
||||
elif isinstance(data, int):
|
||||
return f'i{data}e'.encode()
|
||||
elif isinstance(data, bytes):
|
||||
return f'{len(data)}:'.encode() + data
|
||||
elif isinstance(data, str):
|
||||
encoded = data.encode('utf-8')
|
||||
return f'{len(encoded)}:'.encode() + encoded
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Cannot bencode type {type(data).__name__}: "
|
||||
f"expected dict, list, int, bytes, or str. Value: {data!r}"
|
||||
)
|
||||
if isinstance(data, int):
|
||||
return f"i{data}e".encode()
|
||||
if isinstance(data, bytes):
|
||||
return f"{len(data)}:".encode() + data
|
||||
if isinstance(data, str):
|
||||
encoded = data.encode("utf-8")
|
||||
return f"{len(encoded)}:".encode() + encoded
|
||||
msg = (
|
||||
f"Cannot bencode type {type(data).__name__}: "
|
||||
f"expected dict, list, int, bytes, or str. Value: {data!r}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def extract_info_hash_from_torrent(torrent_data: bytes) -> Optional[str]:
|
||||
def extract_info_hash_from_torrent(torrent_data: bytes) -> str | None:
|
||||
"""Extract info_hash from .torrent file data."""
|
||||
try:
|
||||
decoded, _ = bencode_decode(torrent_data)
|
||||
if b'info' not in decoded:
|
||||
if b"info" not in decoded:
|
||||
return None
|
||||
|
||||
info_bencoded = bencode_encode(decoded[b'info'])
|
||||
info_dict = decoded[b'info']
|
||||
if isinstance(info_dict, dict) and b'pieces' in info_dict:
|
||||
info_bencoded = bencode_encode(decoded[b"info"])
|
||||
info_dict = decoded[b"info"]
|
||||
if isinstance(info_dict, dict) and b"pieces" in info_dict:
|
||||
return hashlib.sha1(info_bencoded).hexdigest().lower()
|
||||
return hashlib.sha256(info_bencoded).hexdigest().lower()
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse torrent file: {e}")
|
||||
logger.debug("Failed to parse torrent file: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def extract_hash_from_magnet(magnet_url: str) -> Optional[str]:
|
||||
def extract_hash_from_magnet(magnet_url: str) -> str | None:
|
||||
"""Extract info_hash from a magnet URL."""
|
||||
if not magnet_url.startswith("magnet:"):
|
||||
return None
|
||||
@@ -245,12 +271,12 @@ def extract_hash_from_magnet(magnet_url: str) -> Optional[str]:
|
||||
parsed = urlparse(magnet_url)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
def extract_btmh(value: str) -> Optional[str]:
|
||||
def extract_btmh(value: str) -> str | None:
|
||||
raw_value = value.strip()
|
||||
if not raw_value:
|
||||
return None
|
||||
|
||||
data: Optional[bytes] = None
|
||||
data: bytes | None = None
|
||||
if re.fullmatch(r"[a-fA-F0-9]+", raw_value):
|
||||
if len(raw_value) % 2 != 0:
|
||||
return None
|
||||
@@ -268,12 +294,16 @@ def extract_hash_from_magnet(magnet_url: str) -> Optional[str]:
|
||||
if not data:
|
||||
return None
|
||||
|
||||
if len(data) >= 34 and data[0] == 0x12 and data[1] == 0x20:
|
||||
digest = data[2:34]
|
||||
if len(digest) == 32:
|
||||
if (
|
||||
len(data) >= _BASE32_BTMH_TAG_BYTES
|
||||
and data[0] == _BTIH_PREFIX_BYTE
|
||||
and data[1] == _BTIH_INFO_BYTE_HEX
|
||||
):
|
||||
digest = data[2:_BASE32_BTMH_TAG_BYTES]
|
||||
if len(digest) == _BTIH_DIGEST_LENGTH:
|
||||
return digest.hex().lower()
|
||||
|
||||
if len(data) == 32:
|
||||
if len(data) == _BTIH_HASH_LENGTH_32:
|
||||
return data.hex().lower()
|
||||
|
||||
return None
|
||||
@@ -287,11 +317,13 @@ def extract_hash_from_magnet(magnet_url: str) -> Optional[str]:
|
||||
hash_value = match.group(1)
|
||||
|
||||
# 40-char hex or 32-char hex (ED2K) - return as-is
|
||||
if len(hash_value) == 40 or re.match(r'^[a-fA-F0-9]{32}$', hash_value):
|
||||
if len(hash_value) == _BTIH_HASH_LENGTH_40 or re.match(
|
||||
r"^[a-fA-F0-9]{32}$", hash_value
|
||||
):
|
||||
return hash_value.lower()
|
||||
|
||||
# 32-char base32 - decode to hex
|
||||
if re.match(r'^[A-Z2-7]{32}$', hash_value.upper()):
|
||||
if re.match(r"^[A-Z2-7]{32}$", hash_value.upper()):
|
||||
try:
|
||||
return base64.b32decode(hash_value.upper()).hex().lower()
|
||||
except Exception:
|
||||
@@ -302,7 +334,7 @@ def extract_hash_from_magnet(magnet_url: str) -> Optional[str]:
|
||||
|
||||
for xt in xt_values:
|
||||
if xt.startswith("urn:btmh:"):
|
||||
btmh_value = xt[len("urn:btmh:"):]
|
||||
btmh_value = xt[len("urn:btmh:") :]
|
||||
btmh_hash = extract_btmh(btmh_value)
|
||||
if btmh_hash:
|
||||
return btmh_hash
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
"""
|
||||
Transmission download client for Prowlarr integration.
|
||||
"""Transmission download client for Prowlarr integration.
|
||||
|
||||
Uses the transmission-rpc library to communicate with Transmission's RPC API.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Iterator, Optional, Tuple
|
||||
|
||||
from contextlib import contextmanager, suppress
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
@@ -21,9 +18,16 @@ from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
parse_transmission_url,
|
||||
)
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_SEEDING_PROGRESS_PERCENT = 100
|
||||
_ETA_MAX_SECONDS = 604800
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _transmission_session_verify_override(url: str) -> Iterator[None]:
|
||||
@@ -46,7 +50,7 @@ def _transmission_session_verify_override(url: str) -> Iterator[None]:
|
||||
|
||||
original_session_factory = transmission_rpc_client.requests.Session
|
||||
|
||||
def _session_factory(*args: Any, **kwargs: Any) -> Any:
|
||||
def _session_factory(*args: object, **kwargs: object) -> object:
|
||||
session = original_session_factory(*args, **kwargs)
|
||||
session.verify = False
|
||||
return session
|
||||
@@ -58,7 +62,7 @@ def _transmission_session_verify_override(url: str) -> Iterator[None]:
|
||||
transmission_rpc_client.requests.Session = original_session_factory
|
||||
|
||||
|
||||
def _apply_transmission_ssl_verify(client: Any, url: str) -> None:
|
||||
def _apply_transmission_ssl_verify(client: object, url: str) -> None:
|
||||
"""Apply global certificate validation policy to transmission-rpc client."""
|
||||
session = getattr(client, "_http_session", None)
|
||||
if session is None:
|
||||
@@ -76,17 +80,19 @@ class TransmissionClient(DownloadClient):
|
||||
protocol = "torrent"
|
||||
name = "transmission"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Transmission client with settings from config."""
|
||||
from transmission_rpc import Client
|
||||
|
||||
raw_url = config.get("TRANSMISSION_URL", "")
|
||||
if not raw_url:
|
||||
raise ValueError("TRANSMISSION_URL is required")
|
||||
msg = "TRANSMISSION_URL is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
url = normalize_http_url(raw_url)
|
||||
if not url:
|
||||
raise ValueError("TRANSMISSION_URL is invalid")
|
||||
msg = "TRANSMISSION_URL is invalid"
|
||||
raise ValueError(msg)
|
||||
|
||||
username = config.get("TRANSMISSION_USERNAME", "")
|
||||
password = config.get("TRANSMISSION_PASSWORD", "")
|
||||
@@ -98,8 +104,8 @@ class TransmissionClient(DownloadClient):
|
||||
"host": host,
|
||||
"port": port,
|
||||
"path": path,
|
||||
"username": username if username else None,
|
||||
"password": password if password else None,
|
||||
"username": username or None,
|
||||
"password": password or None,
|
||||
"protocol": protocol,
|
||||
}
|
||||
try:
|
||||
@@ -114,10 +120,8 @@ class TransmissionClient(DownloadClient):
|
||||
self._client = Client(**client_kwargs)
|
||||
# Some versions expose protocol as an attribute rather than kwarg.
|
||||
if protocol == "https" and hasattr(self._client, "protocol"):
|
||||
try:
|
||||
setattr(self._client, "protocol", protocol)
|
||||
except Exception:
|
||||
pass
|
||||
with suppress(Exception):
|
||||
self._client.protocol = protocol
|
||||
_apply_transmission_ssl_verify(self._client, url)
|
||||
self._category = config.get("TRANSMISSION_CATEGORY", "books")
|
||||
self._download_dir = config.get("TRANSMISSION_DOWNLOAD_DIR", "")
|
||||
@@ -129,25 +133,25 @@ class TransmissionClient(DownloadClient):
|
||||
url = normalize_http_url(config.get("TRANSMISSION_URL", ""))
|
||||
return client == "transmission" and bool(url)
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
def test_connection(self) -> tuple[bool, str]:
|
||||
"""Test connection to Transmission."""
|
||||
try:
|
||||
session = self._client.get_session()
|
||||
version = session.version
|
||||
return True, f"Connected to Transmission {version}"
|
||||
except Exception as e:
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
return False, f"Connection failed: {e!s}"
|
||||
else:
|
||||
return True, f"Connected to Transmission {version}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Add torrent by URL (magnet or .torrent).
|
||||
"""Add torrent by URL (magnet or .torrent).
|
||||
|
||||
Args:
|
||||
url: Magnet link or .torrent URL
|
||||
@@ -160,6 +164,7 @@ class TransmissionClient(DownloadClient):
|
||||
|
||||
Raises:
|
||||
Exception: If adding fails.
|
||||
|
||||
"""
|
||||
try:
|
||||
resolved_category = category or self._category or ""
|
||||
@@ -186,7 +191,7 @@ class TransmissionClient(DownloadClient):
|
||||
)
|
||||
|
||||
torrent_hash = torrent.hashString.lower()
|
||||
logger.info(f"Added torrent to Transmission: {torrent_hash}")
|
||||
logger.info("Added torrent to Transmission: %s", torrent_hash)
|
||||
|
||||
# Apply per-torrent seeding limits from indexer
|
||||
seed_kwargs = {}
|
||||
@@ -202,23 +207,23 @@ class TransmissionClient(DownloadClient):
|
||||
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}")
|
||||
logger.warning("Failed to set seeding limits for %s: %s", torrent_hash, e)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Transmission add failed")
|
||||
raise
|
||||
else:
|
||||
return torrent_hash
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Transmission add failed: {e}")
|
||||
raise
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""
|
||||
Get torrent status by hash.
|
||||
"""Get torrent status by hash.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
|
||||
Returns:
|
||||
Current download status.
|
||||
|
||||
"""
|
||||
try:
|
||||
torrent = self._client.get_torrent(download_id)
|
||||
@@ -232,7 +237,9 @@ class TransmissionClient(DownloadClient):
|
||||
# 5: seed pending
|
||||
# 6: seeding
|
||||
# torrent.status is an enum with .value as string
|
||||
status_value = torrent.status.value if hasattr(torrent.status, 'value') else str(torrent.status)
|
||||
status_value = (
|
||||
torrent.status.value if hasattr(torrent.status, "value") else str(torrent.status)
|
||||
)
|
||||
status_map = {
|
||||
"stopped": ("paused", "Paused"),
|
||||
"check pending": ("checking", "Waiting to check"),
|
||||
@@ -246,30 +253,30 @@ class TransmissionClient(DownloadClient):
|
||||
state, message = status_map.get(status_value, ("downloading", "Downloading"))
|
||||
progress = torrent.percent_done * 100
|
||||
# Only mark complete when seeding - seed pending means files still being moved
|
||||
complete = progress >= 100 and status_value == "seeding"
|
||||
complete = progress >= _SEEDING_PROGRESS_PERCENT and status_value == "seeding"
|
||||
|
||||
if complete:
|
||||
message = "Complete"
|
||||
|
||||
# Get ETA if available and reasonable (less than 1 week)
|
||||
eta = None
|
||||
if hasattr(torrent, 'eta') and torrent.eta:
|
||||
if hasattr(torrent, "eta") and torrent.eta:
|
||||
eta_seconds = torrent.eta.total_seconds()
|
||||
if 0 < eta_seconds < 604800:
|
||||
if 0 < eta_seconds < _ETA_MAX_SECONDS:
|
||||
eta = int(eta_seconds)
|
||||
|
||||
# Get download speed
|
||||
download_speed = torrent.rate_download if hasattr(torrent, 'rate_download') else None
|
||||
download_speed = torrent.rate_download if hasattr(torrent, "rate_download") else None
|
||||
|
||||
# Get file path for completed downloads
|
||||
file_path = None
|
||||
if complete:
|
||||
# Output path is downloadDir + torrent name (with ':' replaced)
|
||||
torrent_name = getattr(torrent, 'name', '')
|
||||
torrent_name = getattr(torrent, "name", "")
|
||||
if isinstance(torrent_name, str):
|
||||
torrent_name = torrent_name.replace(':', '_')
|
||||
torrent_name = torrent_name.replace(":", "_")
|
||||
file_path = self._build_path(
|
||||
getattr(torrent, 'download_dir', ''),
|
||||
getattr(torrent, "download_dir", ""),
|
||||
torrent_name,
|
||||
)
|
||||
|
||||
@@ -288,9 +295,8 @@ class TransmissionClient(DownloadClient):
|
||||
except Exception as e:
|
||||
return DownloadStatus.error(self._log_error("get_status", e))
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
"""
|
||||
Remove a torrent from Transmission.
|
||||
def remove(self, download_id: str, *, delete_files: bool = False) -> bool:
|
||||
"""Remove a torrent from Transmission.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
@@ -298,6 +304,7 @@ class TransmissionClient(DownloadClient):
|
||||
|
||||
Returns:
|
||||
True if successful.
|
||||
|
||||
"""
|
||||
try:
|
||||
self._client.remove_torrent(
|
||||
@@ -305,40 +312,42 @@ class TransmissionClient(DownloadClient):
|
||||
delete_data=delete_files,
|
||||
)
|
||||
logger.info(
|
||||
f"Removed torrent from Transmission: {download_id}"
|
||||
+ (" (with files)" if delete_files else "")
|
||||
"Removed torrent from Transmission: %s%s",
|
||||
download_id,
|
||||
" (with files)" if delete_files else "",
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self._log_error("remove", e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get the path where torrent files are located.
|
||||
def get_download_path(self, download_id: str) -> str | None:
|
||||
"""Get the path where torrent files are located.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
|
||||
Returns:
|
||||
Content path (file or directory), or None.
|
||||
|
||||
"""
|
||||
try:
|
||||
torrent = self._client.get_torrent(download_id)
|
||||
torrent_name = getattr(torrent, 'name', '')
|
||||
if isinstance(torrent_name, str):
|
||||
torrent_name = torrent_name.replace(':', '_')
|
||||
return self._build_path(
|
||||
getattr(torrent, 'download_dir', ''),
|
||||
torrent_name,
|
||||
)
|
||||
torrent = self._client.get_torrent(download_id)
|
||||
torrent_name = getattr(torrent, "name", "")
|
||||
if isinstance(torrent_name, str):
|
||||
torrent_name = torrent_name.replace(":", "_")
|
||||
return self._build_path(
|
||||
getattr(torrent, "download_dir", ""),
|
||||
torrent_name,
|
||||
)
|
||||
except Exception as e:
|
||||
self._log_error("get_download_path", e, level="debug")
|
||||
return None
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
self, url: str, category: str | None = None
|
||||
) -> tuple[str, DownloadStatus] | None:
|
||||
"""Check if a torrent for this URL already exists in Transmission."""
|
||||
try:
|
||||
torrent_info = extract_torrent_info(url)
|
||||
@@ -348,9 +357,10 @@ class TransmissionClient(DownloadClient):
|
||||
try:
|
||||
self._client.get_torrent(torrent_info.info_hash)
|
||||
status = self.get_status(torrent_info.info_hash)
|
||||
return (torrent_info.info_hash, status)
|
||||
except KeyError:
|
||||
return None
|
||||
else:
|
||||
return (torrent_info.info_hash, status)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking for existing torrent: {e}")
|
||||
logger.debug("Error checking for existing torrent: %s", e)
|
||||
return None
|
||||
|
||||
+70
-51
@@ -11,33 +11,36 @@ import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download.permissions_debug import log_transfer_permission_context
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from gevent.threadpool import ThreadPool
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
try:
|
||||
from gevent import monkey as _gevent_monkey
|
||||
from gevent.threadpool import ThreadPool as _GeventThreadPool
|
||||
except Exception:
|
||||
except ImportError:
|
||||
_gevent_monkey = None
|
||||
_GeventThreadPool = None
|
||||
|
||||
T = TypeVar("T")
|
||||
_IO_THREADPOOL: Optional["_GeventThreadPool"] = None
|
||||
_IO_THREADPOOL: ThreadPool | None = None
|
||||
|
||||
|
||||
def _use_gevent_threadpool() -> bool:
|
||||
return bool(
|
||||
_gevent_monkey
|
||||
and _GeventThreadPool
|
||||
and _gevent_monkey.is_module_patched("threading")
|
||||
_gevent_monkey and _GeventThreadPool and _gevent_monkey.is_module_patched("threading")
|
||||
)
|
||||
|
||||
|
||||
def _get_io_threadpool() -> "_GeventThreadPool":
|
||||
def _get_io_threadpool() -> ThreadPool:
|
||||
global _IO_THREADPOOL
|
||||
if _IO_THREADPOOL is None:
|
||||
pool_size = max(2, min(8, os.cpu_count() or 2))
|
||||
@@ -45,7 +48,9 @@ def _get_io_threadpool() -> "_GeventThreadPool":
|
||||
return _IO_THREADPOOL
|
||||
|
||||
|
||||
def _call_and_capture(func: Callable[..., T], args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[bool, T | Exception]:
|
||||
def _call_and_capture[T](
|
||||
func: Callable[..., T], args: tuple[Any, ...], kwargs: dict[str, Any]
|
||||
) -> tuple[bool, T | Exception]:
|
||||
try:
|
||||
return True, func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
@@ -60,13 +65,10 @@ def _must_avoid_gevent_threadpool(func: Callable[..., Any]) -> bool:
|
||||
# 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
|
||||
return _gevent_monkey.is_object_patched("subprocess", "run") and func is subprocess.run
|
||||
|
||||
|
||||
def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
def run_blocking_io[T](func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
"""Run blocking I/O in a native thread when under gevent.
|
||||
|
||||
gevent's threadpool will eagerly log exceptions raised inside worker threads,
|
||||
@@ -80,13 +82,12 @@ def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
if _use_gevent_threadpool():
|
||||
ok, result = _get_io_threadpool().apply(_call_and_capture, (func, args, kwargs))
|
||||
if ok:
|
||||
return cast(T, result)
|
||||
exc = cast(Exception, result)
|
||||
return cast("T", result)
|
||||
exc = cast("Exception", result)
|
||||
raise exc
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
_VERIFY_IO_WAIT_SECONDS = 3.0
|
||||
_PUBLISH_VERIFY_RETRY_SECONDS = 0.25
|
||||
|
||||
@@ -107,14 +108,17 @@ def _verify_transfer_size(
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"File {action} size mismatch, waiting for filesystem sync: {dest} "
|
||||
f"({actual_size} != {expected_size})"
|
||||
"File %s size mismatch, waiting for filesystem sync: %s (%s != %s)",
|
||||
action,
|
||||
dest,
|
||||
actual_size,
|
||||
expected_size,
|
||||
)
|
||||
time.sleep(_VERIFY_IO_WAIT_SECONDS)
|
||||
|
||||
actual_size = run_blocking_io(dest.stat).st_size
|
||||
if actual_size != expected_size:
|
||||
raise IOError(
|
||||
raise OSError(
|
||||
f"File {action} incomplete, data loss may have occurred. "
|
||||
f"'{dest}' was {actual_size} bytes instead of expected {expected_size}."
|
||||
)
|
||||
@@ -138,10 +142,11 @@ def _verify_published_file(
|
||||
"""
|
||||
try:
|
||||
_verify_transfer_size(dest, expected_size, action)
|
||||
return
|
||||
except OSError as error:
|
||||
if not _is_stale_handle_error(error):
|
||||
raise
|
||||
else:
|
||||
return
|
||||
|
||||
time.sleep(_PUBLISH_VERIFY_RETRY_SECONDS)
|
||||
|
||||
@@ -174,6 +179,7 @@ def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path:
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no unique path found after max_attempts
|
||||
|
||||
"""
|
||||
base = dest_path.stem
|
||||
ext = dest_path.suffix
|
||||
@@ -194,10 +200,11 @@ def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path:
|
||||
finally:
|
||||
run_blocking_io(os.close, fd)
|
||||
if attempt > 0:
|
||||
logger.info(f"File collision resolved: {try_path.name}")
|
||||
return try_path
|
||||
logger.info("File collision resolved: %s", try_path.name)
|
||||
except FileExistsError:
|
||||
continue
|
||||
else:
|
||||
return try_path
|
||||
|
||||
raise RuntimeError(f"Could not write file after {max_attempts} attempts: {dest_path}")
|
||||
|
||||
@@ -219,7 +226,7 @@ def _system_op(op: str, source: Path, dest: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None:
|
||||
def _perform_nfs_fallback(source: Path, dest: Path, *, is_move: bool) -> None:
|
||||
"""Handle NFS/SMB permission errors by falling back to copyfile -> system op."""
|
||||
expected_size = run_blocking_io(source.stat).st_size
|
||||
|
||||
@@ -230,15 +237,16 @@ def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None:
|
||||
|
||||
if is_move:
|
||||
run_blocking_io(source.unlink)
|
||||
return
|
||||
|
||||
except Exception as copy_error:
|
||||
# Clean up failed copy attempt if it exists
|
||||
run_blocking_io(dest.unlink, missing_ok=True)
|
||||
|
||||
if _is_permission_error(copy_error):
|
||||
log_transfer_permission_context("nfs_fallback_copyfile", source=source, dest=dest, error=copy_error)
|
||||
logger.error("Fallback copyfile failed (%s -> %s): %s", source, dest, copy_error)
|
||||
log_transfer_permission_context(
|
||||
"nfs_fallback_copyfile", source=source, dest=dest, error=copy_error
|
||||
)
|
||||
logger.exception("Fallback copyfile failed (%s -> %s)", source, dest)
|
||||
|
||||
# Fallback 2: system command
|
||||
op = "mv" if is_move else "cp"
|
||||
@@ -250,10 +258,14 @@ def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None:
|
||||
if is_move:
|
||||
run_blocking_io(source.unlink, missing_ok=True)
|
||||
except subprocess.CalledProcessError as sys_error:
|
||||
log_transfer_permission_context("nfs_fallback_system", source=source, dest=dest, error=sys_error)
|
||||
logger.error("System %s failed (%s -> %s): %s", op, source, dest, sys_error.stderr)
|
||||
log_transfer_permission_context(
|
||||
"nfs_fallback_system", source=source, dest=dest, error=sys_error
|
||||
)
|
||||
logger.exception("System %s failed (%s -> %s): %s", op, source, dest, sys_error.stderr)
|
||||
run_blocking_io(dest.unlink, missing_ok=True)
|
||||
raise
|
||||
else:
|
||||
return
|
||||
|
||||
|
||||
def _is_enoent_error(error: Exception) -> bool:
|
||||
@@ -263,7 +275,7 @@ def _is_enoent_error(error: Exception) -> bool:
|
||||
|
||||
|
||||
def _can_use_partial_copy_after_enoent(
|
||||
temp_path: Optional[Path],
|
||||
temp_path: Path | None,
|
||||
expected_size: int,
|
||||
action: str,
|
||||
) -> bool:
|
||||
@@ -273,9 +285,10 @@ def _can_use_partial_copy_after_enoent(
|
||||
|
||||
try:
|
||||
_verify_transfer_size(temp_path, expected_size, action)
|
||||
return True
|
||||
except Exception:
|
||||
except OSError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def _claim_destination(path: Path) -> bool:
|
||||
@@ -345,7 +358,6 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
|
||||
run_blocking_io(os.close, fd)
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
except Exception as e:
|
||||
if _is_permission_error(e):
|
||||
log_transfer_permission_context(
|
||||
@@ -356,6 +368,8 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
|
||||
)
|
||||
run_blocking_io(dest_path.unlink, missing_ok=True)
|
||||
raise
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path:
|
||||
@@ -378,6 +392,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no unique path found after max_attempts
|
||||
|
||||
"""
|
||||
base = dest_path.stem
|
||||
ext = dest_path.suffix
|
||||
@@ -402,8 +417,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
else:
|
||||
run_blocking_io(os.rename, str(source_path), str(try_path))
|
||||
if attempt > 0:
|
||||
logger.info(f"File collision resolved: {try_path.name}")
|
||||
return try_path
|
||||
logger.info("File collision resolved: %s", try_path.name)
|
||||
except FileExistsError:
|
||||
# Race condition: file created between exists() check and rename()
|
||||
if claimed:
|
||||
@@ -421,7 +435,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
claimed = False
|
||||
|
||||
temp_path: Optional[Path] = None
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
try:
|
||||
temp_path = _create_temp_path(try_path)
|
||||
@@ -464,9 +478,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
run_blocking_io(source_path.unlink)
|
||||
|
||||
if attempt > 0:
|
||||
logger.info(f"File collision resolved: {try_path.name}")
|
||||
return try_path
|
||||
|
||||
logger.info("File collision resolved: %s", try_path.name)
|
||||
except FileExistsError:
|
||||
if temp_path:
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
@@ -475,6 +487,8 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
if temp_path:
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
raise
|
||||
else:
|
||||
return try_path
|
||||
|
||||
except (PermissionError, OSError) as e:
|
||||
if _is_permission_error(e):
|
||||
@@ -493,17 +507,19 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
try:
|
||||
_perform_nfs_fallback(source_path, try_path, is_move=True)
|
||||
if attempt > 0:
|
||||
logger.info(f"File collision resolved (fallback): {try_path.name}")
|
||||
return try_path
|
||||
logger.info("File collision resolved (fallback): %s", try_path.name)
|
||||
except Exception as fallback_error:
|
||||
logger.error(
|
||||
"NFS fallback also failed (%s -> %s): %s",
|
||||
logger.exception(
|
||||
"NFS fallback also failed (%s -> %s)",
|
||||
source_path,
|
||||
try_path,
|
||||
fallback_error,
|
||||
)
|
||||
raise e from fallback_error
|
||||
else:
|
||||
return try_path
|
||||
raise
|
||||
else:
|
||||
return try_path
|
||||
|
||||
raise RuntimeError(f"Could not move file after {max_attempts} attempts: {dest_path}")
|
||||
|
||||
@@ -521,6 +537,7 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100)
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no unique path found after max_attempts
|
||||
|
||||
"""
|
||||
base = dest_path.stem
|
||||
ext = dest_path.suffix
|
||||
@@ -531,8 +548,7 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100)
|
||||
try:
|
||||
run_blocking_io(os.link, str(source_path), str(try_path))
|
||||
if attempt > 0:
|
||||
logger.info(f"File collision resolved: {try_path.name}")
|
||||
return try_path
|
||||
logger.info("File collision resolved: %s", try_path.name)
|
||||
except FileExistsError:
|
||||
continue
|
||||
except OSError as e:
|
||||
@@ -553,6 +569,8 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100)
|
||||
)
|
||||
return atomic_copy(source_path, dest_path, max_attempts=max_attempts)
|
||||
raise
|
||||
else:
|
||||
return try_path
|
||||
|
||||
raise RuntimeError(f"Could not create hardlink after {max_attempts} attempts: {dest_path}")
|
||||
|
||||
@@ -573,6 +591,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no unique path found after max_attempts
|
||||
|
||||
"""
|
||||
base = dest_path.stem
|
||||
ext = dest_path.suffix
|
||||
@@ -583,7 +602,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
|
||||
if run_blocking_io(try_path.exists):
|
||||
continue
|
||||
temp_path: Optional[Path] = None
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
temp_path = _create_temp_path(try_path)
|
||||
try:
|
||||
@@ -606,11 +625,10 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
try:
|
||||
_perform_nfs_fallback(source_path, temp_path, is_move=False)
|
||||
except Exception as fallback_error:
|
||||
logger.error(
|
||||
"NFS fallback also failed (%s -> %s): %s",
|
||||
logger.exception(
|
||||
"NFS fallback also failed (%s -> %s)",
|
||||
source_path,
|
||||
temp_path,
|
||||
fallback_error,
|
||||
)
|
||||
raise e from fallback_error
|
||||
elif _is_enoent_error(e) and _can_use_partial_copy_after_enoent(
|
||||
@@ -639,11 +657,12 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
raise
|
||||
|
||||
if attempt > 0:
|
||||
logger.info(f"File collision resolved: {try_path.name}")
|
||||
return try_path
|
||||
logger.info("File collision resolved: %s", try_path.name)
|
||||
except Exception:
|
||||
if temp_path:
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
raise
|
||||
else:
|
||||
return try_path
|
||||
|
||||
raise RuntimeError(f"Could not copy file after {max_attempts} attempts: {dest_path}")
|
||||
|
||||
+214
-112
@@ -2,54 +2,76 @@
|
||||
|
||||
import random
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from threading import Event, Thread
|
||||
from typing import Callable, Optional
|
||||
from urllib.parse import urlparse, urljoin
|
||||
from typing import TYPE_CHECKING, NoReturn
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
from shelfmark.download import network
|
||||
from shelfmark.download.network import get_proxies, get_ssl_verify
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download import network
|
||||
from shelfmark.download.network import get_proxies, get_ssl_verify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from types import ModuleType
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_MAX_REDIRECTS = 5
|
||||
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
|
||||
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
_HTTP_STATUS_RATE_LIMITED = HTTPStatus.TOO_MANY_REQUESTS
|
||||
_HTTP_STATUS_OK = HTTPStatus.OK
|
||||
_HTTP_STATUS_RANGE_NOT_SATISFIABLE = HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE
|
||||
_HTTP_STATUS_PARTIAL_CONTENT = HTTPStatus.PARTIAL_CONTENT
|
||||
_HTTP_STATUS_NON_RETRYABLE = (_HTTP_STATUS_FORBIDDEN, _HTTP_STATUS_NOT_FOUND)
|
||||
|
||||
# Bypasser modules are imported lazily to support dynamic selection based on config
|
||||
_internal_bypasser = None
|
||||
_external_bypasser = None
|
||||
|
||||
|
||||
def _get_internal_bypasser():
|
||||
def _raise_too_many_redirects(message: str) -> NoReturn:
|
||||
raise requests.exceptions.TooManyRedirects(message)
|
||||
|
||||
|
||||
def _get_internal_bypasser() -> ModuleType:
|
||||
"""Lazy import of internal bypasser module."""
|
||||
global _internal_bypasser
|
||||
if _internal_bypasser is None:
|
||||
try:
|
||||
from shelfmark.bypass import internal_bypasser
|
||||
|
||||
_internal_bypasser = internal_bypasser
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
msg = (
|
||||
f"Failed to import internal bypasser: {e}. "
|
||||
"Check that all dependencies are installed. "
|
||||
"You may need to disable CF bypass or use the external bypasser."
|
||||
) from e
|
||||
)
|
||||
raise RuntimeError(msg) from e
|
||||
return _internal_bypasser
|
||||
|
||||
|
||||
def _get_external_bypasser():
|
||||
def _get_external_bypasser() -> ModuleType:
|
||||
"""Lazy import of external bypasser module."""
|
||||
global _external_bypasser
|
||||
if _external_bypasser is None:
|
||||
try:
|
||||
from shelfmark.bypass import external_bypasser
|
||||
|
||||
_external_bypasser = external_bypasser
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
msg = (
|
||||
f"Failed to import external bypasser: {e}. "
|
||||
"Check that the external bypasser is properly configured."
|
||||
) from e
|
||||
)
|
||||
raise RuntimeError(msg) from e
|
||||
return _external_bypasser
|
||||
|
||||
|
||||
@@ -63,25 +85,29 @@ def _is_cf_bypass_enabled() -> bool:
|
||||
return app_config.get("USE_CF_BYPASS", True)
|
||||
|
||||
|
||||
def get_bypassed_page(url, selector=None, cancel_flag=None):
|
||||
def get_bypassed_page(
|
||||
url: str,
|
||||
selector: network.AAMirrorSelector | None = None,
|
||||
cancel_flag: Event | None = None,
|
||||
) -> str | None:
|
||||
"""Wrapper that delegates to the appropriate bypasser based on config."""
|
||||
if _is_using_external_bypasser():
|
||||
return _get_external_bypasser().get_bypassed_page(url, selector, cancel_flag)
|
||||
return _get_internal_bypasser().get_bypassed_page(url, selector, cancel_flag)
|
||||
|
||||
|
||||
def get_cf_cookies_for_domain(domain):
|
||||
def get_cf_cookies_for_domain(domain: str) -> dict[str, str]:
|
||||
"""Get CF cookies - only available with internal bypasser."""
|
||||
if _is_using_external_bypasser():
|
||||
logger.debug(f"External bypasser in use, CF cookies not available for {domain}")
|
||||
logger.debug("External bypasser in use, CF cookies not available for %s", domain)
|
||||
return {}
|
||||
return _get_internal_bypasser().get_cf_cookies_for_domain(domain)
|
||||
|
||||
|
||||
def get_cf_user_agent_for_domain(domain):
|
||||
def get_cf_user_agent_for_domain(domain: str) -> str | None:
|
||||
"""Get CF user agent - only available with internal bypasser."""
|
||||
if _is_using_external_bypasser():
|
||||
logger.debug(f"External bypasser in use, CF user agent not available for {domain}")
|
||||
logger.debug("External bypasser in use, CF user agent not available for %s", domain)
|
||||
return None
|
||||
return _get_internal_bypasser().get_cf_user_agent_for_domain(domain)
|
||||
|
||||
@@ -100,7 +126,7 @@ def _apply_cf_bypass(url: str, headers: dict) -> dict:
|
||||
cookies = get_cf_cookies_for_domain(hostname)
|
||||
stored_ua = get_cf_user_agent_for_domain(hostname)
|
||||
if stored_ua:
|
||||
headers['User-Agent'] = stored_ua
|
||||
headers["User-Agent"] = stored_ua
|
||||
return cookies
|
||||
|
||||
|
||||
@@ -110,19 +136,22 @@ MAX_DOWNLOAD_RETRIES = 2
|
||||
MAX_RESUME_ATTEMPTS = 3
|
||||
|
||||
RETRYABLE_CODES = (429, 500, 502, 503, 504)
|
||||
CONNECTION_ERRORS = (requests.exceptions.ConnectionError, requests.exceptions.Timeout,
|
||||
requests.exceptions.SSLError, requests.exceptions.ChunkedEncodingError)
|
||||
CONNECTION_ERRORS = (
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.SSLError,
|
||||
requests.exceptions.ChunkedEncodingError,
|
||||
)
|
||||
DOWNLOAD_HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
}
|
||||
|
||||
|
||||
def parse_size_string(size: str) -> Optional[float]:
|
||||
def parse_size_string(size: str) -> float | None:
|
||||
"""Parse a human-readable size string (e.g., '10.5 MB') into bytes."""
|
||||
if not size:
|
||||
return None
|
||||
@@ -133,20 +162,22 @@ def parse_size_string(size: str) -> Optional[float]:
|
||||
if normalized.endswith(suffix):
|
||||
return float(normalized[:-2]) * mult
|
||||
return float(normalized)
|
||||
except (ValueError, IndexError):
|
||||
except ValueError, IndexError:
|
||||
return None
|
||||
|
||||
|
||||
def _backoff_delay(attempt: int, base: float = 0.25, cap: float = 3.0) -> float:
|
||||
"""Exponential backoff with jitter."""
|
||||
return min(cap, base * (2 ** (attempt - 1))) + random.random() * base
|
||||
|
||||
|
||||
def _get_status_code(e: Exception) -> Optional[int]:
|
||||
def _get_status_code(e: Exception) -> int | None:
|
||||
"""Extract HTTP status code from an exception, or None if not applicable."""
|
||||
if isinstance(e, requests.exceptions.HTTPError) and e.response is not None:
|
||||
return e.response.status_code
|
||||
return None
|
||||
|
||||
|
||||
def _is_retryable_error(e: Exception) -> bool:
|
||||
"""Check if error is retryable (connection error or retryable HTTP status)."""
|
||||
if isinstance(e, CONNECTION_ERRORS):
|
||||
@@ -155,31 +186,34 @@ def _is_retryable_error(e: Exception) -> bool:
|
||||
return status is not None and status in RETRYABLE_CODES
|
||||
|
||||
|
||||
def _try_rotation(original_url: str, current_url: str, selector: network.AAMirrorSelector) -> Optional[str]:
|
||||
def _try_rotation(
|
||||
original_url: str, current_url: str, selector: network.AAMirrorSelector
|
||||
) -> str | None:
|
||||
"""Try mirror/DNS rotation. Returns new URL or None."""
|
||||
if current_url.startswith(network.get_aa_base_url()):
|
||||
new_base, action = selector.next_mirror_or_rotate_dns()
|
||||
if action in ("mirror", "dns") and new_base:
|
||||
new_url = selector.rewrite(original_url)
|
||||
logger.info(f"[{action}] switching to: {new_url}")
|
||||
logger.info("[%s] switching to: %s", action, new_url)
|
||||
return new_url
|
||||
elif network.should_rotate_dns_for_url(current_url) and network.rotate_dns_provider():
|
||||
logger.info(f"[dns-rotate] retrying: {original_url}")
|
||||
logger.info("[dns-rotate] retrying: %s", original_url)
|
||||
return original_url
|
||||
return None
|
||||
|
||||
|
||||
def html_get_page(
|
||||
url: str,
|
||||
retry: Optional[int] = None,
|
||||
retry: int | None = None,
|
||||
selector: network.AAMirrorSelector | None = None,
|
||||
cancel_flag: Event | None = None,
|
||||
status_callback: Callable[[str, str | None], None] | None = None,
|
||||
*,
|
||||
use_bypasser: bool = False,
|
||||
selector: Optional[network.AAMirrorSelector] = None,
|
||||
cancel_flag: Optional[Event] = None,
|
||||
status_callback: Optional[Callable[[str, Optional[str]], None]] = None,
|
||||
allow_bypasser_fallback: bool = True,
|
||||
include_response_url: bool = False,
|
||||
success_delay: float = 1.0,
|
||||
session: Optional[requests.Session] = None,
|
||||
session: requests.Session | None = None,
|
||||
) -> str | tuple[str, str]:
|
||||
"""Fetch HTML content from a URL with retry mechanism.
|
||||
|
||||
@@ -189,7 +223,9 @@ def html_get_page(
|
||||
include_response_url: If True, return `(html, final_url)` to expose the
|
||||
resolved response URL after redirects.
|
||||
success_delay: Optional delay (seconds) after successful fetch.
|
||||
|
||||
"""
|
||||
|
||||
def _result(html: str, response_url: str) -> str | tuple[str, str]:
|
||||
if include_response_url:
|
||||
return html, response_url
|
||||
@@ -204,41 +240,43 @@ def html_get_page(
|
||||
for attempt in range(1, retry + 1):
|
||||
# Check for cancellation before each attempt
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
logger.info(f"html_get_page cancelled before attempt {attempt}")
|
||||
logger.info("html_get_page cancelled before attempt %s", attempt)
|
||||
return _result("", current_url)
|
||||
|
||||
try:
|
||||
if use_bypasser_now and _is_cf_bypass_enabled():
|
||||
logger.debug(f"GET (bypasser): {current_url}")
|
||||
if status_callback:
|
||||
status_callback("resolving", "Bypassing protection...")
|
||||
heartbeat_stop = Event()
|
||||
heartbeat_thread: Optional[Thread] = None
|
||||
heartbeat_thread: Thread | None = None
|
||||
if status_callback:
|
||||
def _heartbeat() -> None:
|
||||
|
||||
def _heartbeat(stop_event: Event = heartbeat_stop) -> None:
|
||||
# Keep the download "alive" during long bypass operations so the orchestrator
|
||||
# doesn't flag it as stalled.
|
||||
while not heartbeat_stop.wait(timeout=30):
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
return
|
||||
try:
|
||||
status_callback("resolving", "Bypassing protection...")
|
||||
except Exception:
|
||||
return
|
||||
heartbeat_thread = Thread(target=_heartbeat, daemon=True, name="BypassHeartbeat")
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
return
|
||||
try:
|
||||
status_callback("resolving", "Bypassing protection...")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
heartbeat_thread = Thread(
|
||||
target=_heartbeat, daemon=True, name="BypassHeartbeat"
|
||||
)
|
||||
heartbeat_thread.start()
|
||||
try:
|
||||
result = get_bypassed_page(current_url, selector, cancel_flag)
|
||||
return _result(result or "", current_url)
|
||||
except Exception as e:
|
||||
logger.warning(f"Bypasser error: {type(e).__name__}: {e}")
|
||||
logger.warning("Bypasser error: %s: %s", type(e).__name__, e)
|
||||
return _result("", current_url)
|
||||
finally:
|
||||
heartbeat_stop.set()
|
||||
if heartbeat_thread:
|
||||
heartbeat_thread.join(timeout=1)
|
||||
|
||||
logger.debug(f"GET: {current_url}")
|
||||
logger.debug("GET: %s", current_url)
|
||||
|
||||
# Use a browser-like UA by default (AA can behave differently for python-requests UA).
|
||||
headers = {"User-Agent": DOWNLOAD_HEADERS["User-Agent"]}
|
||||
@@ -267,7 +305,9 @@ def html_get_page(
|
||||
if is_aa_url and response.is_redirect:
|
||||
location = response.headers.get("Location", "")
|
||||
if not location:
|
||||
raise requests.exceptions.TooManyRedirects(f"Redirect with no Location header: {current_url}")
|
||||
_raise_too_many_redirects(
|
||||
f"Redirect with no Location header: {current_url}"
|
||||
)
|
||||
|
||||
redirect_url = urljoin(current_url, location)
|
||||
current_host = urlparse(current_url).hostname or ""
|
||||
@@ -305,8 +345,8 @@ def html_get_page(
|
||||
|
||||
# Same-host redirect (relative or absolute) - follow manually.
|
||||
redirects_followed += 1
|
||||
if redirects_followed > 5:
|
||||
raise requests.exceptions.TooManyRedirects(f"Too many redirects for {current_url}")
|
||||
if redirects_followed > _MAX_REDIRECTS:
|
||||
_raise_too_many_redirects(f"Too many redirects for {current_url}")
|
||||
current_url = redirect_url
|
||||
continue
|
||||
|
||||
@@ -319,14 +359,14 @@ def html_get_page(
|
||||
status = _get_status_code(e)
|
||||
|
||||
# 403 = Cloudflare/DDoS-Guard protection
|
||||
if status == 403:
|
||||
if status == _HTTP_STATUS_FORBIDDEN:
|
||||
# If bypasser fallback is disabled, try mirrors instead
|
||||
if not allow_bypasser_fallback:
|
||||
new_url = _try_rotation(original_url, current_url, selector)
|
||||
if new_url:
|
||||
current_url = new_url
|
||||
continue
|
||||
logger.warning(f"403 error, mirrors exhausted: {current_url}")
|
||||
logger.warning("403 error, mirrors exhausted: %s", current_url)
|
||||
return _result("", current_url)
|
||||
|
||||
if _is_cf_bypass_enabled() and not use_bypasser_now:
|
||||
@@ -336,19 +376,22 @@ def html_get_page(
|
||||
fresh_cookies = get_cf_cookies_for_domain(parsed.hostname or "")
|
||||
if fresh_cookies and not cookies:
|
||||
# Cookies are now available - retry with cookies before using bypasser
|
||||
logger.debug(f"403 but cookies now available - retrying with cookies: {current_url}")
|
||||
logger.debug(
|
||||
"403 but cookies now available - retrying with cookies: %s",
|
||||
current_url,
|
||||
)
|
||||
continue
|
||||
logger.info(f"403 detected; switching to bypasser: {current_url}")
|
||||
logger.info("403 detected; switching to bypasser: %s", current_url)
|
||||
if status_callback:
|
||||
status_callback("resolving", "Bypassing protection...")
|
||||
use_bypasser_now = True
|
||||
continue
|
||||
logger.warning(f"403 error, giving up: {current_url}")
|
||||
logger.warning("403 error, giving up: %s", current_url)
|
||||
return _result("", current_url)
|
||||
|
||||
# 404 = Not found
|
||||
if status == 404:
|
||||
logger.warning(f"404 error: {current_url}")
|
||||
if status == _HTTP_STATUS_NOT_FOUND:
|
||||
logger.warning("404 error: %s", current_url)
|
||||
return _result("", current_url)
|
||||
|
||||
# Try mirror/DNS rotation on retryable errors
|
||||
@@ -360,10 +403,17 @@ def html_get_page(
|
||||
|
||||
# Retry with backoff
|
||||
if attempt < retry:
|
||||
logger.warning(f"Retry {attempt}/{retry} for {current_url}: {type(e).__name__}: {e}")
|
||||
logger.warning(
|
||||
"Retry %s/%s for %s: %s: %s",
|
||||
attempt,
|
||||
retry,
|
||||
current_url,
|
||||
type(e).__name__,
|
||||
e,
|
||||
)
|
||||
time.sleep(_backoff_delay(attempt))
|
||||
else:
|
||||
logger.error(f"Giving up after {retry} attempts: {current_url}")
|
||||
logger.exception("Giving up after %s attempts: %s", retry, current_url)
|
||||
|
||||
return _result("", current_url)
|
||||
|
||||
@@ -371,12 +421,12 @@ def html_get_page(
|
||||
def download_url(
|
||||
link: str,
|
||||
size: str = "",
|
||||
progress_callback: Optional[Callable[[float], None]] = None,
|
||||
cancel_flag: Optional[Event] = None,
|
||||
_selector: Optional[network.AAMirrorSelector] = None,
|
||||
status_callback: Optional[Callable[[str, Optional[str]], None]] = None,
|
||||
referer: Optional[str] = None,
|
||||
) -> Optional[BytesIO]:
|
||||
progress_callback: Callable[[float], None] | None = None,
|
||||
cancel_flag: Event | None = None,
|
||||
_selector: network.AAMirrorSelector | None = None,
|
||||
status_callback: Callable[[str, str | None], None] | None = None,
|
||||
referer: str | None = None,
|
||||
) -> BytesIO | None:
|
||||
"""Download content from URL with automatic retry and resume support."""
|
||||
selector = _selector or network.AAMirrorSelector()
|
||||
current_url = selector.rewrite(link)
|
||||
@@ -384,7 +434,7 @@ def download_url(
|
||||
# Build headers with optional referer
|
||||
headers = DOWNLOAD_HEADERS.copy()
|
||||
if referer:
|
||||
headers['Referer'] = referer
|
||||
headers["Referer"] = referer
|
||||
total_size = parse_size_string(size) or 0
|
||||
|
||||
attempt = 0
|
||||
@@ -399,19 +449,35 @@ def download_url(
|
||||
|
||||
try:
|
||||
if attempt > 0 and status_callback:
|
||||
status_callback("resolving", f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})")
|
||||
status_callback(
|
||||
"resolving",
|
||||
f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})",
|
||||
)
|
||||
|
||||
logger.info(f"Downloading: {current_url} (attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})")
|
||||
logger.info(
|
||||
"Downloading: %s (attempt %s/%s)",
|
||||
current_url,
|
||||
attempt + 1,
|
||||
MAX_DOWNLOAD_RETRIES,
|
||||
)
|
||||
# Try with CF cookies/UA if available
|
||||
cookies = _apply_cf_bypass(current_url, headers)
|
||||
response = requests.get(current_url, stream=True, proxies=get_proxies(current_url), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers, verify=get_ssl_verify(current_url))
|
||||
response = requests.get(
|
||||
current_url,
|
||||
stream=True,
|
||||
proxies=get_proxies(current_url),
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
cookies=cookies,
|
||||
headers=headers,
|
||||
verify=get_ssl_verify(current_url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
if status_callback:
|
||||
status_callback("downloading", "")
|
||||
|
||||
total_size = total_size or float(response.headers.get('content-length', 0))
|
||||
pbar = tqdm(total=total_size, unit='B', unit_scale=True, desc='Downloading')
|
||||
total_size = total_size or float(response.headers.get("content-length", 0))
|
||||
pbar = tqdm(total=total_size, unit="B", unit_scale=True, desc="Downloading")
|
||||
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
@@ -426,55 +492,69 @@ def download_url(
|
||||
pbar.close()
|
||||
|
||||
# Validate - check we didn't get HTML instead of file
|
||||
if total_size > 0 and bytes_downloaded < total_size * 0.9:
|
||||
if response.headers.get('content-type', '').startswith('text/html'):
|
||||
logger.warning(f"Received HTML instead of file: {current_url}")
|
||||
return None
|
||||
if (
|
||||
total_size > 0
|
||||
and bytes_downloaded < total_size * 0.9
|
||||
and response.headers.get("content-type", "").startswith("text/html")
|
||||
):
|
||||
logger.warning("Received HTML instead of file: %s", current_url)
|
||||
return None
|
||||
|
||||
logger.debug(f"Download completed: {bytes_downloaded} bytes")
|
||||
return buffer
|
||||
logger.debug("Download completed: %s bytes", bytes_downloaded)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
status = _get_status_code(e)
|
||||
retryable = _is_retryable_error(e)
|
||||
|
||||
# Z-Library 403 - try refreshing cookies via bypasser once before giving up
|
||||
if status == 403 and _is_cf_bypass_enabled() and not zlib_cookie_refresh_attempted:
|
||||
if (
|
||||
status == _HTTP_STATUS_FORBIDDEN
|
||||
and _is_cf_bypass_enabled()
|
||||
and not zlib_cookie_refresh_attempted
|
||||
):
|
||||
parsed = urlparse(current_url)
|
||||
if parsed.hostname and 'z-lib' in parsed.hostname and referer:
|
||||
if parsed.hostname and "z-lib" in parsed.hostname and referer:
|
||||
zlib_cookie_refresh_attempted = True
|
||||
logger.info(f"Z-Library 403 - refreshing cookies via referer: {referer}")
|
||||
logger.info("Z-Library 403 - refreshing cookies via referer: %s", referer)
|
||||
try:
|
||||
get_bypassed_page(referer, selector, cancel_flag)
|
||||
time.sleep(0.5)
|
||||
# Retry with fresh cookies (don't increment attempt)
|
||||
continue
|
||||
except Exception as cookie_err:
|
||||
logger.warning(f"Z-Library cookie refresh failed: {cookie_err}")
|
||||
logger.warning("Z-Library cookie refresh failed: %s", cookie_err)
|
||||
|
||||
# Non-retryable errors
|
||||
if status in (403, 404):
|
||||
logger.warning(f"Download failed ({status}): {current_url}")
|
||||
if status in _HTTP_STATUS_NON_RETRYABLE:
|
||||
logger.warning("Download failed (%s): %s", status, current_url)
|
||||
return None
|
||||
|
||||
# Rate limited - skip to next source immediately
|
||||
# (waiting doesn't help with concurrent downloads hitting the same server)
|
||||
if status == 429:
|
||||
logger.info(f"Rate limited (429) - trying next source")
|
||||
if status == _HTTP_STATUS_RATE_LIMITED:
|
||||
logger.info("Rate limited (429) - trying next source")
|
||||
if status_callback:
|
||||
status_callback("resolving", "Server busy, trying next")
|
||||
return None
|
||||
|
||||
# Timeout - don't retry, server likely overloaded
|
||||
if isinstance(e, requests.exceptions.Timeout):
|
||||
logger.warning(f"Timeout: {current_url} - skipping to next source")
|
||||
logger.warning("Timeout: %s - skipping to next source", current_url)
|
||||
if status_callback:
|
||||
status_callback("resolving", "Server timed out, trying next")
|
||||
return None
|
||||
|
||||
# Try to resume if we got some data
|
||||
if bytes_downloaded > 0 and retryable:
|
||||
resumed = _try_resume(current_url, buffer, bytes_downloaded, total_size, progress_callback, cancel_flag, headers)
|
||||
resumed = _try_resume(
|
||||
current_url,
|
||||
buffer,
|
||||
bytes_downloaded,
|
||||
total_size,
|
||||
progress_callback,
|
||||
cancel_flag,
|
||||
headers,
|
||||
)
|
||||
if resumed:
|
||||
return resumed
|
||||
|
||||
@@ -486,12 +566,14 @@ def download_url(
|
||||
attempt += 1
|
||||
continue
|
||||
|
||||
logger.warning(f"Download error: {type(e).__name__}: {e}")
|
||||
logger.warning("Download error: %s: %s", type(e).__name__, e)
|
||||
if attempt < MAX_DOWNLOAD_RETRIES - 1:
|
||||
time.sleep(_backoff_delay(attempt + 1))
|
||||
attempt += 1
|
||||
else:
|
||||
return buffer
|
||||
|
||||
logger.error(f"Download failed after {MAX_DOWNLOAD_RETRIES} attempts: {link}")
|
||||
logger.error("Download failed after %s attempts: %s", MAX_DOWNLOAD_RETRIES, link)
|
||||
return None
|
||||
|
||||
|
||||
@@ -500,35 +582,54 @@ def _try_resume(
|
||||
buffer: BytesIO,
|
||||
start_byte: int,
|
||||
total_size: float,
|
||||
progress_callback: Optional[Callable[[float], None]],
|
||||
cancel_flag: Optional[Event],
|
||||
base_headers: Optional[dict] = None,
|
||||
) -> Optional[BytesIO]:
|
||||
progress_callback: Callable[[float], None] | None,
|
||||
cancel_flag: Event | None,
|
||||
base_headers: dict | None = None,
|
||||
) -> BytesIO | None:
|
||||
"""Try to resume an interrupted download."""
|
||||
for attempt in range(MAX_RESUME_ATTEMPTS):
|
||||
logger.info(f"Resuming from {start_byte} bytes (attempt {attempt + 1}/{MAX_RESUME_ATTEMPTS})")
|
||||
logger.info(
|
||||
"Resuming from %s bytes (attempt %s/%s)",
|
||||
start_byte,
|
||||
attempt + 1,
|
||||
MAX_RESUME_ATTEMPTS,
|
||||
)
|
||||
time.sleep(_backoff_delay(attempt + 1, base=0.5, cap=5.0))
|
||||
|
||||
try:
|
||||
# Try with CF cookies/UA if available
|
||||
resume_headers = {**(base_headers or DOWNLOAD_HEADERS), 'Range': f'bytes={start_byte}-'}
|
||||
resume_headers = {
|
||||
**(base_headers or DOWNLOAD_HEADERS),
|
||||
"Range": f"bytes={start_byte}-",
|
||||
}
|
||||
cookies = _apply_cf_bypass(url, resume_headers)
|
||||
response = requests.get(
|
||||
url, stream=True, proxies=get_proxies(url), timeout=REQUEST_TIMEOUT,
|
||||
headers=resume_headers, cookies=cookies, verify=get_ssl_verify(url)
|
||||
url,
|
||||
stream=True,
|
||||
proxies=get_proxies(url),
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
headers=resume_headers,
|
||||
cookies=cookies,
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
|
||||
|
||||
# Check resume support
|
||||
if response.status_code == 200: # Server doesn't support resume
|
||||
if response.status_code == _HTTP_STATUS_OK: # Server doesn't support resume
|
||||
logger.info("Server doesn't support resume")
|
||||
return None
|
||||
if response.status_code == 416: # Range not satisfiable
|
||||
if response.status_code == _HTTP_STATUS_RANGE_NOT_SATISFIABLE: # Range not satisfiable
|
||||
logger.warning("Range not satisfiable")
|
||||
return None
|
||||
if response.status_code != 206:
|
||||
if response.status_code != _HTTP_STATUS_PARTIAL_CONTENT:
|
||||
response.raise_for_status()
|
||||
|
||||
pbar = tqdm(total=total_size, initial=start_byte, unit='B', unit_scale=True, desc='Resuming')
|
||||
|
||||
pbar = tqdm(
|
||||
total=total_size,
|
||||
initial=start_byte,
|
||||
unit="B",
|
||||
unit_scale=True,
|
||||
desc="Resuming",
|
||||
)
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
buffer.write(chunk)
|
||||
@@ -540,14 +641,15 @@ def _try_resume(
|
||||
pbar.close()
|
||||
return None
|
||||
pbar.close()
|
||||
|
||||
logger.info(f"Resume completed: {start_byte} bytes")
|
||||
return buffer
|
||||
|
||||
|
||||
logger.info("Resume completed: %s bytes", start_byte)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.debug(f"Resume attempt {attempt + 1} failed: {e}")
|
||||
|
||||
logger.warning(f"Resume failed after {MAX_RESUME_ATTEMPTS} attempts")
|
||||
logger.debug("Resume attempt %s failed: %s", attempt + 1, e)
|
||||
else:
|
||||
return buffer
|
||||
|
||||
logger.warning("Resume failed after %s attempts", MAX_RESUME_ATTEMPTS)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+395
-300
File diff suppressed because it is too large
Load Diff
+160
-140
@@ -12,15 +12,18 @@ from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from email.utils import parseaddr
|
||||
from pathlib import Path
|
||||
from threading import Event, Lock
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask, QueueStatus, SearchMode
|
||||
from shelfmark.core.queue import book_queue
|
||||
from shelfmark.core.request_helpers import normalize_optional_text, normalize_positive_int
|
||||
from shelfmark.core.utils import transform_cover_url, is_audiobook as check_audiobook
|
||||
from shelfmark.config import env as env_config
|
||||
from shelfmark.core.request_helpers import (
|
||||
normalize_optional_text,
|
||||
normalize_positive_int,
|
||||
)
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.core.utils import transform_cover_url
|
||||
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
|
||||
@@ -46,21 +49,25 @@ WEBSOCKET_AVAILABLE = True
|
||||
try:
|
||||
from shelfmark.api.websocket import ws_manager
|
||||
except ImportError:
|
||||
logger.error("WebSocket unavailable - real-time updates disabled")
|
||||
logger.exception("WebSocket unavailable - real-time updates disabled")
|
||||
ws_manager = None
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
|
||||
# Progress update throttling - track last broadcast time per book
|
||||
_progress_last_broadcast: Dict[str, float] = {}
|
||||
_progress_last_broadcast: dict[str, float] = {}
|
||||
_progress_lock = Lock()
|
||||
|
||||
# Stall detection - track last activity time per download
|
||||
_last_activity: Dict[str, float] = {}
|
||||
_last_progress_value: Dict[str, float] = {}
|
||||
_last_activity: dict[str, float] = {}
|
||||
_last_progress_value: dict[str, float] = {}
|
||||
# De-duplicate status updates (keep-alive updates shouldn't spam clients)
|
||||
_last_status_event: Dict[str, Tuple[str, Optional[str]]] = {}
|
||||
_last_status_event: dict[str, tuple[str, str | None]] = {}
|
||||
STALL_TIMEOUT = 300 # 5 minutes without progress/status update = stalled
|
||||
COORDINATOR_LOOP_ERROR_RETRY_DELAY = 1.0
|
||||
_PROGRESS_BROADCAST_START_PERCENT = 1
|
||||
_PROGRESS_BROADCAST_COMPLETE_PERCENT = 99
|
||||
_PROGRESS_BROADCAST_MIN_DELTA = 10
|
||||
|
||||
|
||||
def _is_plain_email_address(value: str) -> bool:
|
||||
parsed = parseaddr(value or "")[1]
|
||||
@@ -68,12 +75,13 @@ def _is_plain_email_address(value: str) -> bool:
|
||||
|
||||
|
||||
def _resolve_email_destination(
|
||||
user_id: Optional[int] = None,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
user_id: int | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Resolve the destination email address for email output mode.
|
||||
|
||||
Returns:
|
||||
(email_to, error_message)
|
||||
|
||||
"""
|
||||
configured_recipient = str(config.get("EMAIL_RECIPIENT", "", user_id=user_id) or "").strip()
|
||||
if configured_recipient:
|
||||
@@ -84,7 +92,7 @@ def _resolve_email_destination(
|
||||
return None, None
|
||||
|
||||
|
||||
def _parse_release_search_mode(value: Any) -> SearchMode:
|
||||
def _parse_release_search_mode(value: object) -> SearchMode:
|
||||
if isinstance(value, SearchMode):
|
||||
return value
|
||||
if value is None:
|
||||
@@ -93,29 +101,32 @@ def _parse_release_search_mode(value: Any) -> SearchMode:
|
||||
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}")
|
||||
msg = f"Invalid search_mode: {value}"
|
||||
raise ValueError(msg) from exc
|
||||
msg = f"Invalid search_mode: {value}"
|
||||
raise ValueError(msg)
|
||||
|
||||
def _optional_number(value: Any) -> Optional[float]:
|
||||
|
||||
def _optional_number(value: object) -> float | None:
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return float(value)
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _optional_positive_int(value: Any) -> Optional[int]:
|
||||
def _optional_positive_int(value: object) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def _seed_time_seconds_to_minutes(value: Any) -> Optional[int]:
|
||||
def _seed_time_seconds_to_minutes(value: object) -> int | None:
|
||||
seed_time_seconds = _optional_positive_int(value)
|
||||
if seed_time_seconds is None:
|
||||
return None
|
||||
@@ -124,7 +135,7 @@ def _seed_time_seconds_to_minutes(value: Any) -> Optional[int]:
|
||||
|
||||
def _build_retry_resolution_fields(
|
||||
release_data: dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
"""Persist generic resolved-download data needed for restart-safe retries."""
|
||||
extra = release_data.get("extra")
|
||||
if not isinstance(extra, dict):
|
||||
@@ -139,9 +150,7 @@ def _build_retry_resolution_fields(
|
||||
release_data.get("seeding_time_limit_minutes")
|
||||
)
|
||||
if seeding_time_limit_minutes is None:
|
||||
seeding_time_limit_minutes = _seed_time_seconds_to_minutes(
|
||||
extra.get("minimum_seed_time")
|
||||
)
|
||||
seeding_time_limit_minutes = _seed_time_seconds_to_minutes(extra.get("minimum_seed_time"))
|
||||
|
||||
return {
|
||||
"retry_download_url": normalize_optional_text(release_data.get("download_url")),
|
||||
@@ -159,47 +168,49 @@ def _build_retry_resolution_fields(
|
||||
def queue_release(
|
||||
release_data: dict,
|
||||
priority: int = 0,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Add a release to the download queue. Returns (success, error_message)."""
|
||||
try:
|
||||
source = release_data['source']
|
||||
extra = release_data.get('extra', {})
|
||||
raw_request_id = release_data.get('_request_id')
|
||||
request_id: Optional[int] = None
|
||||
source = release_data["source"]
|
||||
extra = release_data.get("extra", {})
|
||||
raw_request_id = release_data.get("_request_id")
|
||||
request_id: int | None = 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')
|
||||
year = release_data.get('year') or extra.get('year')
|
||||
preview = release_data.get('preview') or extra.get('preview')
|
||||
content_type = release_data.get('content_type') or extra.get('content_type')
|
||||
author = release_data.get("author") or extra.get("author")
|
||||
year = release_data.get("year") or extra.get("year")
|
||||
preview = release_data.get("preview") or extra.get("preview")
|
||||
content_type = release_data.get("content_type") or extra.get("content_type")
|
||||
source_url_raw = (
|
||||
release_data.get('download_url')
|
||||
or release_data.get('source_url')
|
||||
or release_data.get('info_url')
|
||||
or extra.get('detail_url')
|
||||
or extra.get('source_url')
|
||||
release_data.get("download_url")
|
||||
or release_data.get("source_url")
|
||||
or release_data.get("info_url")
|
||||
or extra.get("detail_url")
|
||||
or extra.get("source_url")
|
||||
)
|
||||
source_url = source_url_raw.strip() if isinstance(source_url_raw, str) else None
|
||||
if source_url == "":
|
||||
source_url = None
|
||||
|
||||
# Get series info for library naming templates
|
||||
series_name = release_data.get('series_name') or extra.get('series_name')
|
||||
series_position = release_data.get('series_position') or extra.get('series_position')
|
||||
subtitle = release_data.get('subtitle') or extra.get('subtitle')
|
||||
series_name = release_data.get("series_name") or extra.get("series_name")
|
||||
series_position = release_data.get("series_position") or extra.get("series_position")
|
||||
subtitle = release_data.get("subtitle") or extra.get("subtitle")
|
||||
|
||||
books_output_mode = str(
|
||||
config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder"
|
||||
).strip().lower()
|
||||
books_output_mode = (
|
||||
str(config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
is_audiobook = check_audiobook(content_type)
|
||||
|
||||
output_mode = "folder" if is_audiobook else books_output_mode
|
||||
output_args: Dict[str, Any] = {}
|
||||
output_args: dict[str, Any] = {}
|
||||
retry_resolution_fields = _build_retry_resolution_fields(release_data)
|
||||
|
||||
if output_mode == "email" and not is_audiobook:
|
||||
@@ -211,13 +222,13 @@ def queue_release(
|
||||
|
||||
# Create a source-agnostic download task from release data
|
||||
task = DownloadTask(
|
||||
task_id=release_data['source_id'],
|
||||
task_id=release_data["source_id"],
|
||||
source=source,
|
||||
title=release_data.get('title', 'Unknown'),
|
||||
title=release_data.get("title", "Unknown"),
|
||||
author=author,
|
||||
year=year,
|
||||
format=release_data.get('format'),
|
||||
size=release_data.get('size'),
|
||||
format=release_data.get("format"),
|
||||
size=release_data.get("size"),
|
||||
preview=preview,
|
||||
content_type=content_type,
|
||||
source_url=source_url,
|
||||
@@ -235,17 +246,15 @@ def queue_release(
|
||||
)
|
||||
|
||||
if not book_queue.add(task):
|
||||
logger.info(f"Release already in queue: {task.title}")
|
||||
logger.info("Release already in queue: %s", task.title)
|
||||
return False, "Release is already in the download queue"
|
||||
|
||||
logger.info(f"Release queued with priority {priority}: {task.title}")
|
||||
logger.info("Release queued with priority %s: %s", priority, task.title)
|
||||
|
||||
# Broadcast status update via WebSocket
|
||||
if ws_manager:
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
return True, None
|
||||
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
logger.warning(error_msg)
|
||||
@@ -258,12 +267,15 @@ def queue_release(
|
||||
error_msg = f"Error queueing release: {e}"
|
||||
logger.error_trace(error_msg)
|
||||
return False, error_msg
|
||||
else:
|
||||
return True, None
|
||||
|
||||
def queue_status(user_id: Optional[int] = None) -> Dict[str, Dict[str, Any]]:
|
||||
|
||||
def queue_status(user_id: int | None = None) -> dict[str, dict[str, Any]]:
|
||||
"""Get current status of the download queue."""
|
||||
status = book_queue.get_status(user_id=user_id)
|
||||
for _, tasks in status.items():
|
||||
for _, task in tasks.items():
|
||||
for tasks in status.values():
|
||||
for task in tasks.values():
|
||||
if task.download_path and not run_blocking_io(os.path.exists, task.download_path):
|
||||
task.download_path = None
|
||||
|
||||
@@ -276,7 +288,8 @@ def queue_status(user_id: Optional[int] = None) -> Dict[str, Dict[str, Any]]:
|
||||
for status_type, tasks in status.items()
|
||||
}
|
||||
|
||||
def get_book_data(task_id: str) -> Tuple[Optional[bytes], Optional[DownloadTask]]:
|
||||
|
||||
def get_book_data(task_id: str) -> tuple[bytes | None, DownloadTask | None]:
|
||||
"""Get downloaded file data for a specific task."""
|
||||
task = None
|
||||
try:
|
||||
@@ -288,7 +301,7 @@ def get_book_data(task_id: str) -> Tuple[Optional[bytes], Optional[DownloadTask]
|
||||
if not path:
|
||||
return None, task
|
||||
|
||||
with open(path, "rb") as f:
|
||||
with Path(path).open("rb") as f:
|
||||
return f.read(), task
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error getting book data: {e}")
|
||||
@@ -296,6 +309,7 @@ def get_book_data(task_id: str) -> Tuple[Optional[bytes], Optional[DownloadTask]
|
||||
task.download_path = None
|
||||
return None, task
|
||||
|
||||
|
||||
def _has_staged_retry_source(task: DownloadTask) -> bool:
|
||||
"""Whether a failed task still has a staged file available for retry."""
|
||||
staged_path = task.staged_path.strip() if isinstance(task.staged_path, str) else ""
|
||||
@@ -313,8 +327,8 @@ def _has_fresh_retry_context(task: DownloadTask) -> bool:
|
||||
|
||||
|
||||
def can_retry_download_task(
|
||||
task: Optional[DownloadTask],
|
||||
status: Optional[QueueStatus],
|
||||
task: DownloadTask | None,
|
||||
status: QueueStatus | None,
|
||||
) -> bool:
|
||||
"""Whether the task can be manually retried from the Activity UI."""
|
||||
if task is None or status not in (QueueStatus.ERROR, QueueStatus.CANCELLED):
|
||||
@@ -329,10 +343,10 @@ def can_retry_download_task(
|
||||
return _has_staged_retry_source(task)
|
||||
|
||||
|
||||
def serialize_task_for_retry(task: DownloadTask) -> Dict[str, Any]:
|
||||
def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
|
||||
"""Serialize the task state needed for restart-safe retries."""
|
||||
raw_search_mode = getattr(task, "search_mode", None)
|
||||
search_mode: Optional[str] = None
|
||||
search_mode: str | None = None
|
||||
if isinstance(raw_search_mode, SearchMode):
|
||||
search_mode = raw_search_mode.value
|
||||
elif isinstance(raw_search_mode, str):
|
||||
@@ -374,7 +388,7 @@ def serialize_task_for_retry(task: DownloadTask) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _restore_task_from_retry_payload(payload: Any) -> Optional[DownloadTask]:
|
||||
def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
@@ -423,18 +437,16 @@ def _restore_task_from_retry_payload(payload: Any) -> Optional[DownloadTask]:
|
||||
retry_seeding_time_limit_minutes=_optional_positive_int(
|
||||
payload.get("retry_seeding_time_limit_minutes")
|
||||
),
|
||||
can_retry_without_staged_source=bool(
|
||||
payload.get("can_retry_without_staged_source", True)
|
||||
),
|
||||
can_retry_without_staged_source=bool(payload.get("can_retry_without_staged_source", True)),
|
||||
)
|
||||
|
||||
|
||||
def retry_persisted_download(
|
||||
payload: Any,
|
||||
payload: object,
|
||||
*,
|
||||
final_status: Any,
|
||||
final_status: object,
|
||||
priority: int = -10,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Retry a persisted download row after the in-memory task has been lost."""
|
||||
task = _restore_task_from_retry_payload(payload)
|
||||
if task is None:
|
||||
@@ -453,13 +465,8 @@ def retry_persisted_download(
|
||||
if normalized_status in {"active", "cancelled"} and not has_fresh_retry_context:
|
||||
return False, "Download cannot be retried"
|
||||
|
||||
if (
|
||||
task.request_id is not None
|
||||
and normalized_status == "error"
|
||||
and not has_staged_retry_source
|
||||
):
|
||||
if task.request_id is not None:
|
||||
return False, "Request-linked downloads must be retried from requests"
|
||||
if task.request_id is not None and normalized_status == "error" and not has_staged_retry_source:
|
||||
return False, "Request-linked downloads must be retried from requests"
|
||||
|
||||
if (
|
||||
task.request_id is None
|
||||
@@ -486,33 +493,33 @@ def retry_persisted_download(
|
||||
|
||||
def _task_to_dict(
|
||||
task: DownloadTask,
|
||||
current_status: Optional[QueueStatus] = None,
|
||||
) -> Dict[str, Any]:
|
||||
current_status: QueueStatus | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert DownloadTask to dict for frontend, transforming cover URLs."""
|
||||
# Transform external preview URLs to local proxy URLs
|
||||
preview = transform_cover_url(task.preview, task.task_id)
|
||||
retry_status = current_status or book_queue.get_task_status(task.task_id)
|
||||
|
||||
return {
|
||||
'id': task.task_id,
|
||||
'title': task.title,
|
||||
'author': task.author,
|
||||
'format': task.format,
|
||||
'size': task.size,
|
||||
'preview': preview,
|
||||
'content_type': task.content_type,
|
||||
'source': task.source,
|
||||
'source_display_name': get_source_display_name(task.source),
|
||||
'priority': task.priority,
|
||||
'added_time': task.added_time,
|
||||
'progress': task.progress,
|
||||
'status': task.status,
|
||||
'status_message': task.status_message,
|
||||
'download_path': task.download_path,
|
||||
'user_id': task.user_id,
|
||||
'username': task.username,
|
||||
'request_id': task.request_id,
|
||||
'retry_available': can_retry_download_task(task, retry_status),
|
||||
"id": task.task_id,
|
||||
"title": task.title,
|
||||
"author": task.author,
|
||||
"format": task.format,
|
||||
"size": task.size,
|
||||
"preview": preview,
|
||||
"content_type": task.content_type,
|
||||
"source": task.source,
|
||||
"source_display_name": get_source_display_name(task.source),
|
||||
"priority": task.priority,
|
||||
"added_time": task.added_time,
|
||||
"progress": task.progress,
|
||||
"status": task.status,
|
||||
"status_message": task.status_message,
|
||||
"download_path": task.download_path,
|
||||
"user_id": task.user_id,
|
||||
"username": task.username,
|
||||
"request_id": task.request_id,
|
||||
"retry_available": can_retry_download_task(task, retry_status),
|
||||
}
|
||||
|
||||
|
||||
@@ -524,8 +531,8 @@ def _clear_task_error_state(task: DownloadTask) -> None:
|
||||
def _capture_task_error(
|
||||
task: DownloadTask,
|
||||
*,
|
||||
message: Optional[str] = None,
|
||||
exc_type: Optional[str] = None,
|
||||
message: str | None = None,
|
||||
exc_type: str | None = None,
|
||||
) -> None:
|
||||
if isinstance(message, str):
|
||||
normalized = message.strip()
|
||||
@@ -546,7 +553,7 @@ def _format_download_exception_message(exc: Exception) -> str:
|
||||
return f"Download failed: {type(exc).__name__}"
|
||||
|
||||
|
||||
def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
def _download_task(task_id: str, cancel_flag: Event) -> str | None:
|
||||
"""Download a task via appropriate handler, then post-process to ingest."""
|
||||
try:
|
||||
# Check for cancellation before starting
|
||||
@@ -570,7 +577,7 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
def progress_callback(progress: float) -> None:
|
||||
update_download_progress(task_id, progress)
|
||||
|
||||
def status_callback(status: str, message: Optional[str] = None) -> None:
|
||||
def status_callback(status: str, message: str | None = None) -> None:
|
||||
status_key = status.lower()
|
||||
if status_key == "error":
|
||||
_capture_task_error(
|
||||
@@ -591,7 +598,7 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
|
||||
# Get the download handler based on the task's source
|
||||
handler = get_handler(task.source)
|
||||
temp_file: Optional[Path] = None
|
||||
temp_file: Path | None = None
|
||||
|
||||
if task.staged_path:
|
||||
staged_file = Path(task.staged_path)
|
||||
@@ -615,7 +622,7 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
|
||||
temp_file = Path(temp_path)
|
||||
if not run_blocking_io(temp_file.exists):
|
||||
logger.error(f"Handler returned non-existent path: {temp_path}")
|
||||
logger.error("Handler returned non-existent path: %s", temp_path)
|
||||
_capture_task_error(
|
||||
task,
|
||||
message=f"Download file missing: {temp_path}",
|
||||
@@ -667,8 +674,6 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
task.staged_path = None
|
||||
_clear_task_error_state(task)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
if cancel_flag.is_set():
|
||||
logger.info("Task %s: cancelled during error handling", task_id)
|
||||
@@ -683,6 +688,8 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
)
|
||||
return None
|
||||
|
||||
else:
|
||||
return result
|
||||
|
||||
|
||||
def update_download_progress(book_id: str, progress: float) -> None:
|
||||
@@ -696,37 +703,38 @@ def update_download_progress(book_id: str, progress: float) -> None:
|
||||
if last_progress is None or progress != last_progress:
|
||||
_last_activity[book_id] = time.time()
|
||||
_last_progress_value[book_id] = progress
|
||||
|
||||
|
||||
# Broadcast progress via WebSocket with throttling
|
||||
if ws_manager:
|
||||
current_time = time.time()
|
||||
should_broadcast = False
|
||||
|
||||
|
||||
with _progress_lock:
|
||||
last_broadcast = _progress_last_broadcast.get(book_id, 0)
|
||||
last_progress = _progress_last_broadcast.get(f"{book_id}_progress", 0)
|
||||
time_elapsed = current_time - last_broadcast
|
||||
|
||||
|
||||
# Always broadcast at start (0%) or completion (>=99%)
|
||||
if progress <= 1 or progress >= 99:
|
||||
should_broadcast = True
|
||||
# Broadcast if enough time has passed (convert interval from seconds)
|
||||
elif time_elapsed >= config.DOWNLOAD_PROGRESS_UPDATE_INTERVAL:
|
||||
should_broadcast = True
|
||||
# Broadcast on significant progress jumps (>10%)
|
||||
elif progress - last_progress >= 10:
|
||||
should_broadcast = True
|
||||
|
||||
should_broadcast = (
|
||||
progress <= _PROGRESS_BROADCAST_START_PERCENT
|
||||
or progress >= _PROGRESS_BROADCAST_COMPLETE_PERCENT
|
||||
or time_elapsed >= config.DOWNLOAD_PROGRESS_UPDATE_INTERVAL
|
||||
or progress - last_progress >= _PROGRESS_BROADCAST_MIN_DELTA
|
||||
)
|
||||
|
||||
if should_broadcast:
|
||||
_progress_last_broadcast[book_id] = current_time
|
||||
_progress_last_broadcast[f"{book_id}_progress"] = progress
|
||||
|
||||
|
||||
if should_broadcast:
|
||||
task = book_queue.get_task(book_id)
|
||||
task_user_id = task.user_id if task else None
|
||||
ws_manager.broadcast_download_progress(book_id, progress, 'downloading', user_id=task_user_id)
|
||||
ws_manager.broadcast_download_progress(
|
||||
book_id, progress, "downloading", user_id=task_user_id
|
||||
)
|
||||
|
||||
def update_download_status(book_id: str, status: str, message: Optional[str] = None) -> None:
|
||||
|
||||
def update_download_status(book_id: str, status: str, message: str | None = None) -> None:
|
||||
"""Update download status with optional message for UI display."""
|
||||
status_key = status.lower()
|
||||
try:
|
||||
@@ -752,18 +760,19 @@ def update_download_status(book_id: str, status: str, message: Optional[str] = N
|
||||
if ws_manager:
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
|
||||
def cancel_download(book_id: str) -> bool:
|
||||
"""Cancel a download."""
|
||||
result = book_queue.cancel_download(book_id)
|
||||
|
||||
|
||||
# Broadcast status update via WebSocket
|
||||
if result and ws_manager and ws_manager.is_enabled():
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def retry_download(book_id: str) -> Tuple[bool, Optional[str]]:
|
||||
def retry_download(book_id: str) -> tuple[bool, str | None]:
|
||||
"""Retry a failed or cancelled download.
|
||||
|
||||
Request-linked downloads can only be manually retried when cancelled or
|
||||
@@ -794,22 +803,27 @@ def retry_download(book_id: str) -> Tuple[bool, Optional[str]]:
|
||||
|
||||
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)
|
||||
|
||||
def reorder_queue(book_priorities: Dict[str, int]) -> bool:
|
||||
|
||||
def reorder_queue(book_priorities: dict[str, int]) -> bool:
|
||||
"""Bulk reorder queue by mapping book_id to new priority."""
|
||||
return book_queue.reorder_queue(book_priorities)
|
||||
|
||||
def get_queue_order() -> List[Dict[str, Any]]:
|
||||
|
||||
def get_queue_order() -> list[dict[str, Any]]:
|
||||
"""Get current queue order for display."""
|
||||
return book_queue.get_queue_order()
|
||||
|
||||
def get_active_downloads() -> List[str]:
|
||||
|
||||
def get_active_downloads() -> list[str]:
|
||||
"""Get list of currently active downloads."""
|
||||
return book_queue.get_active_downloads()
|
||||
|
||||
|
||||
def _cleanup_progress_tracking(task_id: str) -> None:
|
||||
"""Clean up progress tracking data for a completed/cancelled download."""
|
||||
with _progress_lock:
|
||||
@@ -875,25 +889,26 @@ def _process_single_download(task_id: str, cancel_flag: Event) -> None:
|
||||
if task:
|
||||
_capture_task_error(
|
||||
task,
|
||||
message=f"Download failed: {type(e).__name__}: {str(e)}",
|
||||
message=f"Download failed: {type(e).__name__}: {e!s}",
|
||||
exc_type=type(e).__name__,
|
||||
)
|
||||
_finalize_download_failure(task_id)
|
||||
else:
|
||||
logger.info(f"Download cancelled: {task_id}")
|
||||
logger.info("Download cancelled: %s", task_id)
|
||||
book_queue.update_status(task_id, QueueStatus.CANCELLED)
|
||||
|
||||
# Broadcast error/cancelled status
|
||||
if ws_manager:
|
||||
ws_manager.broadcast_status_update(queue_status())
|
||||
|
||||
|
||||
def concurrent_download_loop() -> None:
|
||||
"""Main download coordinator using ThreadPoolExecutor for concurrent downloads."""
|
||||
max_workers = config.MAX_CONCURRENT_DOWNLOADS
|
||||
logger.info(f"Starting concurrent download loop with {max_workers} workers")
|
||||
logger.info("Starting concurrent download loop with %s workers", max_workers)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="Download") as executor:
|
||||
active_futures: Dict[Future, str] = {} # Track active download futures
|
||||
active_futures: dict[Future, str] = {} # Track active download futures
|
||||
stalled_tasks: set[str] = set() # Track tasks already cancelled due to stall
|
||||
|
||||
while True:
|
||||
@@ -911,14 +926,17 @@ def concurrent_download_loop() -> None:
|
||||
# Check for stalled downloads (no activity in STALL_TIMEOUT seconds)
|
||||
current_time = time.time()
|
||||
with _progress_lock:
|
||||
for future, task_id in list(active_futures.items()):
|
||||
for _future, task_id in list(active_futures.items()):
|
||||
if task_id in stalled_tasks:
|
||||
continue
|
||||
last_active = _last_activity.get(task_id, current_time)
|
||||
if current_time - last_active > STALL_TIMEOUT:
|
||||
logger.warning(f"Download stalled for {task_id}, cancelling")
|
||||
logger.warning("Download stalled for %s, cancelling", task_id)
|
||||
book_queue.cancel_download(task_id)
|
||||
book_queue.update_status_message(task_id, f"Download stalled (no activity for {STALL_TIMEOUT}s)")
|
||||
book_queue.update_status_message(
|
||||
task_id,
|
||||
f"Download stalled (no activity for {STALL_TIMEOUT}s)",
|
||||
)
|
||||
stalled_tasks.add(task_id)
|
||||
|
||||
# Start new downloads if we have capacity
|
||||
@@ -931,7 +949,7 @@ def concurrent_download_loop() -> None:
|
||||
# Only delay if other downloads are already active
|
||||
if active_futures:
|
||||
stagger_delay = random.uniform(2, 5)
|
||||
logger.debug(f"Staggering download start by {stagger_delay:.1f}s")
|
||||
logger.debug("Staggering download start by %.1fs", stagger_delay)
|
||||
time.sleep(stagger_delay)
|
||||
|
||||
task_id, cancel_flag = next_download
|
||||
@@ -946,8 +964,9 @@ def concurrent_download_loop() -> None:
|
||||
logger.error_trace("Download coordinator loop error: %s", e)
|
||||
time.sleep(COORDINATOR_LOOP_ERROR_RETRY_DELAY)
|
||||
|
||||
|
||||
# Download coordinator thread (started explicitly via start())
|
||||
_coordinator_thread: Optional[threading.Thread] = None
|
||||
_coordinator_thread: threading.Thread | None = None
|
||||
_coordinator_lock = Lock()
|
||||
|
||||
|
||||
@@ -964,10 +983,11 @@ def start() -> None:
|
||||
logger.warning("Download coordinator thread is not alive; starting a new one")
|
||||
|
||||
_coordinator_thread = threading.Thread(
|
||||
target=concurrent_download_loop,
|
||||
daemon=True,
|
||||
name="DownloadCoordinator"
|
||||
target=concurrent_download_loop, daemon=True, name="DownloadCoordinator"
|
||||
)
|
||||
_coordinator_thread.start()
|
||||
|
||||
logger.info(f"Download coordinator started with {config.MAX_CONCURRENT_DOWNLOADS} concurrent workers")
|
||||
logger.info(
|
||||
"Download coordinator started with %s concurrent workers",
|
||||
config.MAX_CONCURRENT_DOWNLOADS,
|
||||
)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Callable, Optional
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
StatusCallback = Callable[[str, Optional[str]], None]
|
||||
OutputHandler = Callable[[Path, DownloadTask, Event, StatusCallback, bool], Optional[str]]
|
||||
StatusCallback = Callable[[str, str | None], None]
|
||||
OutputHandler = Callable[[Path, DownloadTask, Event, StatusCallback, bool], str | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -48,9 +48,9 @@ def load_output_handlers() -> None:
|
||||
if _OUTPUTS_LOADED:
|
||||
return
|
||||
|
||||
from . import booklore # noqa: F401
|
||||
from . import email # noqa: F401
|
||||
from . import folder # noqa: F401
|
||||
from . import booklore as booklore
|
||||
from . import email as email
|
||||
from . import folder as folder
|
||||
|
||||
_OUTPUTS_LOADED = True
|
||||
|
||||
@@ -65,7 +65,6 @@ def _derive_output_mode(task: DownloadTask) -> str:
|
||||
Prefer the mode captured at queue time. Fall back to current config for
|
||||
legacy tasks that do not have `output_mode` populated.
|
||||
"""
|
||||
|
||||
mode = _normalize_output_mode(getattr(task, "output_mode", None))
|
||||
if mode:
|
||||
return mode
|
||||
@@ -80,7 +79,7 @@ def _derive_output_mode(task: DownloadTask) -> str:
|
||||
return _normalize_output_mode(config.get("BOOKS_OUTPUT_MODE", "folder")) or "folder"
|
||||
|
||||
|
||||
def resolve_output_handler(task: DownloadTask) -> Optional[OutputRegistration]:
|
||||
def resolve_output_handler(task: DownloadTask) -> OutputRegistration | None:
|
||||
load_output_handlers()
|
||||
desired_mode = _derive_output_mode(task)
|
||||
|
||||
|
||||
@@ -3,24 +3,44 @@ from __future__ import annotations
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
import shelfmark.core.config as core_config
|
||||
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_COPY, STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir
|
||||
from shelfmark.download.outputs import StatusCallback, register_output
|
||||
from shelfmark.download.staging import (
|
||||
STAGE_COPY,
|
||||
STAGE_MOVE,
|
||||
STAGE_NONE,
|
||||
build_staging_dir,
|
||||
get_staging_dir,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
from threading import Event
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
BOOKLORE_OUTPUT_MODE = "booklore"
|
||||
BOOKLORE_DESTINATION_LIBRARY = "library"
|
||||
BOOKLORE_DESTINATION_BOOKDROP = "bookdrop"
|
||||
BOOKLORE_SUPPORTED_EXTENSIONS = {".azw", ".azw3", ".cb7", ".cbr", ".cbz", ".epub", ".fb2", ".mobi", ".pdf"}
|
||||
BOOKLORE_SUPPORTED_EXTENSIONS = {
|
||||
".azw",
|
||||
".azw3",
|
||||
".cb7",
|
||||
".cbr",
|
||||
".cbz",
|
||||
".epub",
|
||||
".fb2",
|
||||
".mobi",
|
||||
".pdf",
|
||||
}
|
||||
BOOKLORE_SUPPORTED_FORMATS_LABEL = ", ".join(
|
||||
ext.lstrip(".").upper() for ext in sorted(BOOKLORE_SUPPORTED_EXTENSIONS)
|
||||
)
|
||||
@@ -43,16 +63,18 @@ class BookloreConfig:
|
||||
refresh_after_upload: bool = False
|
||||
|
||||
|
||||
def _parse_int(value: Any, label: str) -> int:
|
||||
def _parse_int(value: object, label: str) -> int:
|
||||
if value is None or value == "":
|
||||
raise BookloreError(f"{label} is required")
|
||||
msg = f"{label} is required"
|
||||
raise BookloreError(msg)
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise BookloreError(f"{label} must be a number") from exc
|
||||
msg = f"{label} must be a number"
|
||||
raise BookloreError(msg) from exc
|
||||
|
||||
|
||||
def _parse_destination(value: Any) -> str:
|
||||
def _parse_destination(value: object) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized == BOOKLORE_DESTINATION_BOOKDROP:
|
||||
return BOOKLORE_DESTINATION_BOOKDROP
|
||||
@@ -61,18 +83,21 @@ def _parse_destination(value: Any) -> str:
|
||||
|
||||
def build_booklore_config(
|
||||
values: Mapping[str, Any],
|
||||
user_id: Optional[int] = None,
|
||||
user_id: int | None = None,
|
||||
) -> BookloreConfig:
|
||||
base_url = str(values.get("BOOKLORE_HOST", "")).strip()
|
||||
username = str(values.get("BOOKLORE_USERNAME", "")).strip()
|
||||
password = values.get("BOOKLORE_PASSWORD", "") or ""
|
||||
|
||||
if not base_url:
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} URL is required")
|
||||
msg = f"{BOOKLORE_DISPLAY_NAME} URL is required"
|
||||
raise BookloreError(msg)
|
||||
if not username:
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} username is required")
|
||||
msg = f"{BOOKLORE_DISPLAY_NAME} username is required"
|
||||
raise BookloreError(msg)
|
||||
if not password:
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} password is required")
|
||||
msg = f"{BOOKLORE_DISPLAY_NAME} password is required"
|
||||
raise BookloreError(msg)
|
||||
|
||||
destination = _parse_destination(
|
||||
values.get("BOOKLORE_DESTINATION", BOOKLORE_DESTINATION_LIBRARY)
|
||||
@@ -115,33 +140,43 @@ def build_booklore_config(
|
||||
|
||||
def booklore_login(booklore_config: BookloreConfig) -> str:
|
||||
url = f"{booklore_config.base_url}/api/v1/auth/login"
|
||||
payload = {"username": booklore_config.username, "password": booklore_config.password}
|
||||
payload = {
|
||||
"username": booklore_config.username,
|
||||
"password": booklore_config.password,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload, timeout=30, verify=booklore_config.verify_tls)
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
raise BookloreError(f"Could not connect to {BOOKLORE_DISPLAY_NAME}") from exc
|
||||
msg = f"Could not connect to {BOOKLORE_DISPLAY_NAME}"
|
||||
raise BookloreError(msg) from exc
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} connection timed out") from exc
|
||||
msg = f"{BOOKLORE_DISPLAY_NAME} connection timed out"
|
||||
raise BookloreError(msg) from exc
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} login failed: {exc}") from exc
|
||||
msg = f"{BOOKLORE_DISPLAY_NAME} login failed: {exc}"
|
||||
raise BookloreError(msg) from exc
|
||||
|
||||
if response.status_code in {401, 403}:
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} authentication failed")
|
||||
msg = f"{BOOKLORE_DISPLAY_NAME} authentication failed"
|
||||
raise BookloreError(msg)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} login failed ({response.status_code})") from exc
|
||||
msg = f"{BOOKLORE_DISPLAY_NAME} login failed ({response.status_code})"
|
||||
raise BookloreError(msg) from exc
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise BookloreError(f"Invalid {BOOKLORE_DISPLAY_NAME} login response") from exc
|
||||
msg = f"Invalid {BOOKLORE_DISPLAY_NAME} login response"
|
||||
raise BookloreError(msg) from exc
|
||||
|
||||
token = data.get("accessToken")
|
||||
if not token:
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} did not return an access token")
|
||||
msg = f"{BOOKLORE_DISPLAY_NAME} did not return an access token"
|
||||
raise BookloreError(msg)
|
||||
|
||||
return token
|
||||
|
||||
@@ -154,12 +189,14 @@ 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_DISPLAY_NAME} libraries: {exc}") from exc
|
||||
msg = f"Failed to fetch {BOOKLORE_DISPLAY_NAME} libraries: {exc}"
|
||||
raise BookloreError(msg) from exc
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise BookloreError(f"Invalid {BOOKLORE_DISPLAY_NAME} libraries response") from exc
|
||||
msg = f"Invalid {BOOKLORE_DISPLAY_NAME} libraries response"
|
||||
raise BookloreError(msg) from exc
|
||||
|
||||
|
||||
def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path: Path) -> None:
|
||||
@@ -168,7 +205,10 @@ def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path:
|
||||
params = None
|
||||
else:
|
||||
url = f"{booklore_config.base_url}/api/v1/files/upload"
|
||||
params = {"libraryId": booklore_config.library_id, "pathId": booklore_config.path_id}
|
||||
params = {
|
||||
"libraryId": booklore_config.library_id,
|
||||
"pathId": booklore_config.path_id,
|
||||
}
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
@@ -190,13 +230,17 @@ 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_DISPLAY_NAME} upload failed ({status_code}){message}") from exc
|
||||
msg = f"{BOOKLORE_DISPLAY_NAME} upload failed ({status_code}){message}"
|
||||
raise BookloreError(msg) from exc
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
raise BookloreError(f"Could not connect to {BOOKLORE_DISPLAY_NAME}") from exc
|
||||
msg = f"Could not connect to {BOOKLORE_DISPLAY_NAME}"
|
||||
raise BookloreError(msg) from exc
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} upload timed out") from exc
|
||||
msg = f"{BOOKLORE_DISPLAY_NAME} upload timed out"
|
||||
raise BookloreError(msg) from exc
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise BookloreError(f"{BOOKLORE_DISPLAY_NAME} upload failed: {exc}") from exc
|
||||
msg = f"{BOOKLORE_DISPLAY_NAME} upload failed: {exc}"
|
||||
raise BookloreError(msg) from exc
|
||||
|
||||
|
||||
def booklore_refresh_library(booklore_config: BookloreConfig, token: str) -> None:
|
||||
@@ -207,14 +251,15 @@ 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_DISPLAY_NAME} refresh failed: {exc}") from exc
|
||||
msg = f"{BOOKLORE_DISPLAY_NAME} refresh failed: {exc}"
|
||||
raise BookloreError(msg) from exc
|
||||
|
||||
|
||||
def _supports_booklore(task: DownloadTask) -> bool:
|
||||
return not check_audiobook(task.content_type)
|
||||
|
||||
|
||||
def _get_booklore_settings() -> Dict[str, Any]:
|
||||
def _get_booklore_settings() -> dict[str, Any]:
|
||||
return {
|
||||
"BOOKLORE_HOST": core_config.config.get("BOOKLORE_HOST", ""),
|
||||
"BOOKLORE_USERNAME": core_config.config.get("BOOKLORE_USERNAME", ""),
|
||||
@@ -228,8 +273,8 @@ def _get_booklore_settings() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _booklore_format_error(rejected_files: List[Path]) -> str:
|
||||
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
|
||||
def _booklore_format_error(rejected_files: list[Path]) -> str:
|
||||
rejected_exts = sorted({f.suffix.lower() for f in rejected_files})
|
||||
rejected_list = ", ".join(rejected_exts)
|
||||
return (
|
||||
f"{BOOKLORE_DISPLAY_NAME} does not support {rejected_list}. "
|
||||
@@ -241,9 +286,10 @@ def _post_process_booklore(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
status_callback: StatusCallback,
|
||||
*,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
from shelfmark.download.postprocess.pipeline import (
|
||||
CustomScriptContext,
|
||||
OutputPlan,
|
||||
@@ -273,7 +319,11 @@ def _post_process_booklore(
|
||||
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()
|
||||
staging_dir = (
|
||||
build_staging_dir("booklore", task.task_id)
|
||||
if stage_action != STAGE_NONE
|
||||
else get_staging_dir()
|
||||
)
|
||||
|
||||
output_plan = OutputPlan(
|
||||
mode=BOOKLORE_OUTPUT_MODE,
|
||||
@@ -293,7 +343,11 @@ def _post_process_booklore(
|
||||
if not prepared:
|
||||
return None
|
||||
|
||||
logger.debug("Task %s: prepared %d file(s) for Booklore upload", task.task_id, len(prepared.files))
|
||||
logger.debug(
|
||||
"Task %s: prepared %d file(s) for Booklore upload",
|
||||
task.task_id,
|
||||
len(prepared.files),
|
||||
)
|
||||
|
||||
success = False
|
||||
try:
|
||||
@@ -309,13 +363,20 @@ def _post_process_booklore(
|
||||
return None
|
||||
|
||||
token = booklore_login(booklore_config)
|
||||
logger.info("Task %s: uploading %d file(s) to Booklore", task.task_id, len(prepared.files))
|
||||
logger.info(
|
||||
"Task %s: uploading %d file(s) to Booklore",
|
||||
task.task_id,
|
||||
len(prepared.files),
|
||||
)
|
||||
|
||||
for index, file_path in enumerate(prepared.files, start=1):
|
||||
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_DISPLAY_NAME} ({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:
|
||||
@@ -324,9 +385,13 @@ def _post_process_booklore(
|
||||
except BookloreError as e:
|
||||
logger.warning("Task %s: Booklore refresh failed: %s", task.task_id, e)
|
||||
|
||||
logger.info("Task %s: uploaded %d file(s) to Booklore", task.task_id, len(prepared.files))
|
||||
logger.info(
|
||||
"Task %s: uploaded %d file(s) to Booklore",
|
||||
task.task_id,
|
||||
len(prepared.files),
|
||||
)
|
||||
|
||||
destination: Optional[Path]
|
||||
destination: Path | None
|
||||
if len(prepared.files) == 1:
|
||||
destination = prepared.files[0].parent
|
||||
else:
|
||||
@@ -350,11 +415,11 @@ def _post_process_booklore(
|
||||
else BOOKLORE_DESTINATION_LIBRARY
|
||||
),
|
||||
"library_id": (
|
||||
None
|
||||
if booklore_config.upload_to_bookdrop
|
||||
else booklore_config.library_id
|
||||
None if booklore_config.upload_to_bookdrop else booklore_config.library_id
|
||||
),
|
||||
"path_id": None if booklore_config.upload_to_bookdrop else booklore_config.path_id,
|
||||
"path_id": None
|
||||
if booklore_config.upload_to_bookdrop
|
||||
else booklore_config.path_id,
|
||||
"refresh_after_upload": bool(booklore_config.refresh_after_upload),
|
||||
}
|
||||
},
|
||||
@@ -367,7 +432,7 @@ def _post_process_booklore(
|
||||
message = f"Uploaded to {BOOKLORE_DISPLAY_NAME} ({len(prepared.files)} files)"
|
||||
status_callback("complete", message)
|
||||
success = True
|
||||
return f"booklore://{task.task_id}"
|
||||
output_path = f"booklore://{task.task_id}"
|
||||
|
||||
except BookloreError as e:
|
||||
logger.warning("Task %s: Booklore upload failed: %s", task.task_id, e)
|
||||
@@ -377,6 +442,8 @@ def _post_process_booklore(
|
||||
logger.error_trace("Task %s: unexpected error uploading to Booklore: %s", task.task_id, e)
|
||||
status_callback("error", f"{BOOKLORE_DISPLAY_NAME} upload failed: {e}")
|
||||
return None
|
||||
else:
|
||||
return output_path
|
||||
finally:
|
||||
cleanup_output_staging(
|
||||
prepared.output_plan,
|
||||
@@ -393,9 +460,10 @@ def process_booklore_output(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
status_callback: StatusCallback,
|
||||
*,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
return _post_process_booklore(
|
||||
temp_file,
|
||||
task,
|
||||
|
||||
@@ -3,19 +3,30 @@ from __future__ import annotations
|
||||
import mimetypes
|
||||
import smtplib
|
||||
import ssl
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate, make_msgid, parseaddr
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Any, Dict, Mapping, Optional
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import shelfmark.core.config as core_config
|
||||
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_COPY, 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,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Mapping
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -62,14 +73,18 @@ def build_email_smtp_config(values: Mapping[str, Any]) -> EmailSmtpConfig:
|
||||
|
||||
security = str(values.get("EMAIL_SMTP_SECURITY", SECURITY_STARTTLS) or "").strip().lower()
|
||||
if security not in ALLOWED_SECURITY:
|
||||
raise EmailOutputError(f"SMTP security must be one of: {', '.join(sorted(ALLOWED_SECURITY))}")
|
||||
raise EmailOutputError(
|
||||
f"SMTP security must be one of: {', '.join(sorted(ALLOWED_SECURITY))}"
|
||||
)
|
||||
|
||||
username = str(values.get("EMAIL_SMTP_USERNAME", "") or "").strip()
|
||||
password = values.get("EMAIL_SMTP_PASSWORD", "") or ""
|
||||
|
||||
from_addr = str(values.get("EMAIL_FROM", "") or "").strip()
|
||||
subject_template = str(values.get("EMAIL_SUBJECT_TEMPLATE", "{Title}") or "").strip()
|
||||
timeout_seconds = _parse_int(values.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60), "SMTP timeout (seconds)", minimum=1)
|
||||
timeout_seconds = _parse_int(
|
||||
values.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60), "SMTP timeout (seconds)", minimum=1
|
||||
)
|
||||
allow_unverified_tls = bool(values.get("EMAIL_ALLOW_UNVERIFIED_TLS", False))
|
||||
|
||||
if not host:
|
||||
@@ -83,7 +98,9 @@ def build_email_smtp_config(values: Mapping[str, Any]) -> EmailSmtpConfig:
|
||||
if username_email and "@" in username_email:
|
||||
from_addr = f"Shelfmark <{username_email}>"
|
||||
else:
|
||||
raise EmailOutputError("From address is required (or set SMTP username to an email address).")
|
||||
raise EmailOutputError(
|
||||
"From address is required (or set SMTP username to an email address)."
|
||||
)
|
||||
|
||||
return EmailSmtpConfig(
|
||||
host=host,
|
||||
@@ -98,7 +115,7 @@ def build_email_smtp_config(values: Mapping[str, Any]) -> EmailSmtpConfig:
|
||||
)
|
||||
|
||||
|
||||
def _get_email_settings() -> Dict[str, Any]:
|
||||
def _get_email_settings() -> dict[str, Any]:
|
||||
return {
|
||||
"EMAIL_SMTP_HOST": core_config.config.get("EMAIL_SMTP_HOST", ""),
|
||||
"EMAIL_SMTP_PORT": core_config.config.get("EMAIL_SMTP_PORT", 587),
|
||||
@@ -124,7 +141,7 @@ def _render_subject(template: str, task: DownloadTask) -> str:
|
||||
}
|
||||
try:
|
||||
rendered = template.format(**mapping)
|
||||
except Exception:
|
||||
except IndexError, KeyError, ValueError:
|
||||
rendered = template
|
||||
|
||||
rendered = " ".join(str(rendered).split()).strip()
|
||||
@@ -132,11 +149,8 @@ def _render_subject(template: str, task: DownloadTask) -> str:
|
||||
|
||||
|
||||
def _msgid_domain(from_addr: str) -> str:
|
||||
try:
|
||||
from_email = parseaddr(from_addr)[1]
|
||||
domain = (from_email.partition("@")[2] or "").strip().rstrip(">")
|
||||
except Exception:
|
||||
domain = ""
|
||||
from_email = parseaddr(from_addr)[1]
|
||||
domain = (from_email.partition("@")[2] or "").strip().rstrip(">")
|
||||
return domain or "shelfmark.local"
|
||||
|
||||
|
||||
@@ -171,7 +185,7 @@ def compose_email_message(
|
||||
return message
|
||||
|
||||
|
||||
def _create_tls_context(allow_unverified: bool) -> ssl.SSLContext:
|
||||
def _create_tls_context(*, allow_unverified: bool) -> ssl.SSLContext:
|
||||
context = ssl.create_default_context()
|
||||
if allow_unverified:
|
||||
context.check_hostname = False
|
||||
@@ -181,11 +195,10 @@ def _create_tls_context(allow_unverified: bool) -> ssl.SSLContext:
|
||||
|
||||
def test_smtp_connection(smtp_config: EmailSmtpConfig) -> None:
|
||||
"""Connect and (optionally) authenticate to the SMTP server. Does not send mail."""
|
||||
|
||||
smtp: Optional[smtplib.SMTP] = None
|
||||
smtp: smtplib.SMTP | None = None
|
||||
try:
|
||||
if smtp_config.security == SECURITY_SSL:
|
||||
context = _create_tls_context(smtp_config.allow_unverified_tls)
|
||||
context = _create_tls_context(allow_unverified=smtp_config.allow_unverified_tls)
|
||||
smtp = smtplib.SMTP_SSL(
|
||||
smtp_config.host,
|
||||
smtp_config.port,
|
||||
@@ -193,12 +206,14 @@ def test_smtp_connection(smtp_config: EmailSmtpConfig) -> None:
|
||||
context=context,
|
||||
)
|
||||
else:
|
||||
smtp = smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=smtp_config.timeout_seconds)
|
||||
smtp = smtplib.SMTP(
|
||||
smtp_config.host, smtp_config.port, timeout=smtp_config.timeout_seconds
|
||||
)
|
||||
|
||||
smtp.ehlo()
|
||||
|
||||
if smtp_config.security == SECURITY_STARTTLS:
|
||||
context = _create_tls_context(smtp_config.allow_unverified_tls)
|
||||
context = _create_tls_context(allow_unverified=smtp_config.allow_unverified_tls)
|
||||
smtp.starttls(context=context)
|
||||
smtp.ehlo()
|
||||
|
||||
@@ -210,20 +225,17 @@ def test_smtp_connection(smtp_config: EmailSmtpConfig) -> None:
|
||||
raise EmailOutputError(f"Could not connect to SMTP server: {exc}") from exc
|
||||
finally:
|
||||
if smtp is not None:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
with suppress(Exception):
|
||||
smtp.close()
|
||||
|
||||
|
||||
def send_email_message(smtp_config: EmailSmtpConfig, message: EmailMessage) -> None:
|
||||
smtp: Optional[smtplib.SMTP] = None
|
||||
smtp: smtplib.SMTP | None = None
|
||||
try:
|
||||
if smtp_config.security == SECURITY_SSL:
|
||||
context = _create_tls_context(smtp_config.allow_unverified_tls)
|
||||
context = _create_tls_context(allow_unverified=smtp_config.allow_unverified_tls)
|
||||
smtp = smtplib.SMTP_SSL(
|
||||
smtp_config.host,
|
||||
smtp_config.port,
|
||||
@@ -231,12 +243,14 @@ def send_email_message(smtp_config: EmailSmtpConfig, message: EmailMessage) -> N
|
||||
context=context,
|
||||
)
|
||||
else:
|
||||
smtp = smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=smtp_config.timeout_seconds)
|
||||
smtp = smtplib.SMTP(
|
||||
smtp_config.host, smtp_config.port, timeout=smtp_config.timeout_seconds
|
||||
)
|
||||
|
||||
smtp.ehlo()
|
||||
|
||||
if smtp_config.security == SECURITY_STARTTLS:
|
||||
context = _create_tls_context(smtp_config.allow_unverified_tls)
|
||||
context = _create_tls_context(allow_unverified=smtp_config.allow_unverified_tls)
|
||||
smtp.starttls(context=context)
|
||||
smtp.ehlo()
|
||||
|
||||
@@ -250,13 +264,10 @@ def send_email_message(smtp_config: EmailSmtpConfig, message: EmailMessage) -> N
|
||||
raise EmailOutputError(f"Failed to send email: {exc}") from exc
|
||||
finally:
|
||||
if smtp is not None:
|
||||
try:
|
||||
with suppress(Exception):
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
with suppress(Exception):
|
||||
smtp.close()
|
||||
|
||||
|
||||
def _supports_email(task: DownloadTask) -> bool:
|
||||
@@ -267,9 +278,10 @@ def _post_process_email(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
*,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
from shelfmark.download.postprocess.pipeline import (
|
||||
CustomScriptContext,
|
||||
OutputPlan,
|
||||
@@ -309,7 +321,11 @@ def _post_process_email(
|
||||
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()
|
||||
staging_dir = (
|
||||
build_staging_dir("email", task.task_id)
|
||||
if stage_action != STAGE_NONE
|
||||
else get_staging_dir()
|
||||
)
|
||||
|
||||
output_plan = OutputPlan(
|
||||
mode=EMAIL_OUTPUT_MODE,
|
||||
@@ -334,7 +350,7 @@ def _post_process_email(
|
||||
limit_mb_raw = core_config.config.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25)
|
||||
try:
|
||||
attachment_limit_mb = int(limit_mb_raw)
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
attachment_limit_mb = 25
|
||||
|
||||
if attachment_limit_mb > 0:
|
||||
@@ -406,7 +422,7 @@ def _post_process_email(
|
||||
|
||||
status_callback("complete", f"Sent to {label}")
|
||||
success = True
|
||||
return f"email://{task.task_id}"
|
||||
output_path = f"email://{task.task_id}"
|
||||
|
||||
except EmailOutputError as exc:
|
||||
logger.warning("Task %s: email send failed: %s", task.task_id, exc)
|
||||
@@ -416,6 +432,8 @@ def _post_process_email(
|
||||
logger.error_trace("Task %s: unexpected error sending email: %s", task.task_id, exc)
|
||||
status_callback("error", f"Email send failed: {exc}")
|
||||
return None
|
||||
else:
|
||||
return output_path
|
||||
finally:
|
||||
cleanup_output_staging(
|
||||
prepared.output_plan,
|
||||
@@ -432,9 +450,10 @@ def process_email_output(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
*,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
return _post_process_email(
|
||||
temp_file,
|
||||
task,
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Any, Optional, List
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import shelfmark.core.config as core_config
|
||||
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 StageAction, STAGE_NONE
|
||||
from shelfmark.download.outputs import StatusCallback, register_output
|
||||
from shelfmark.download.staging import STAGE_NONE, StageAction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -31,7 +33,7 @@ class _ProcessingPlan:
|
||||
allow_archive_extraction: bool
|
||||
stage_action: StageAction
|
||||
staging_dir: Path
|
||||
hardlink_source: Optional[Path]
|
||||
hardlink_source: Path | None
|
||||
output_mode: str = FOLDER_OUTPUT_MODE
|
||||
|
||||
|
||||
@@ -42,8 +44,8 @@ def _supports_folder_output(task: DownloadTask) -> bool:
|
||||
def _build_processing_plan(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
status_callback,
|
||||
) -> Optional[_ProcessingPlan]:
|
||||
status_callback: StatusCallback,
|
||||
) -> _ProcessingPlan | None:
|
||||
from shelfmark.download.postprocess.pipeline import (
|
||||
build_output_plan,
|
||||
get_final_destination,
|
||||
@@ -52,7 +54,7 @@ def _build_processing_plan(
|
||||
from shelfmark.download.postprocess.policy import get_file_organization
|
||||
|
||||
is_audiobook = check_audiobook(task.content_type)
|
||||
organization_mode = get_file_organization(is_audiobook)
|
||||
organization_mode = get_file_organization(is_audiobook=is_audiobook)
|
||||
destination = get_final_destination(task)
|
||||
|
||||
if not validate_destination(destination, status_callback):
|
||||
@@ -87,9 +89,10 @@ def process_folder_output(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
status_callback: StatusCallback,
|
||||
*,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
"""Post-process download to the configured folder destination."""
|
||||
from shelfmark.download.postprocess.pipeline import (
|
||||
CustomScriptContext,
|
||||
@@ -97,8 +100,8 @@ def process_folder_output(
|
||||
cleanup_output_staging,
|
||||
is_torrent_source,
|
||||
log_plan_steps,
|
||||
prepare_output_files,
|
||||
maybe_run_custom_script,
|
||||
prepare_output_files,
|
||||
record_step,
|
||||
transfer_book_files,
|
||||
)
|
||||
@@ -128,16 +131,23 @@ def process_folder_output(
|
||||
if not prepared:
|
||||
return None
|
||||
|
||||
steps: List[Any] = []
|
||||
steps: list[Any] = []
|
||||
if prepared.output_plan.stage_action != STAGE_NONE:
|
||||
step_name = f"stage_{prepared.output_plan.stage_action}"
|
||||
record_step(steps, step_name, source=str(temp_file), dest=str(prepared.output_plan.staging_dir))
|
||||
record_step(
|
||||
steps,
|
||||
step_name,
|
||||
source=str(temp_file),
|
||||
dest=str(prepared.output_plan.staging_dir),
|
||||
)
|
||||
|
||||
# Custom script is run post-transfer (see below).
|
||||
|
||||
# If we staged into TMP_DIR, transfer from the staged path and disable hardlinking.
|
||||
use_hardlink = plan.use_hardlink and prepared.output_plan.stage_action == STAGE_NONE
|
||||
source_path = plan.hardlink_source if use_hardlink and plan.hardlink_source else prepared.working_path
|
||||
source_path = (
|
||||
plan.hardlink_source if use_hardlink and plan.hardlink_source else prepared.working_path
|
||||
)
|
||||
is_torrent = is_torrent_source(source_path, task)
|
||||
|
||||
usenet_action = core_config.config.get("PROWLARR_USENET_ACTION", "move")
|
||||
@@ -147,7 +157,9 @@ def process_folder_output(
|
||||
# "Move" is implemented as a client-side cleanup after import.
|
||||
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
|
||||
copy_for_label = (
|
||||
is_torrent or preserve_source or prepared.output_plan.stage_action != STAGE_NONE
|
||||
)
|
||||
|
||||
if cancel_flag.is_set():
|
||||
logger.info("Task %s: cancelled before final transfer", task.task_id)
|
||||
|
||||
@@ -10,13 +10,40 @@ original error.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
def _run_io(func, *args, **kwargs):
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def _log_path_permissions(probe: Path, label: str) -> None:
|
||||
"""Best-effort logging for one path probe."""
|
||||
try:
|
||||
st = _run_io(probe.stat)
|
||||
logger.debug(
|
||||
"Path permissions (%s): path=%s mode=%s owner=%s(%d) group=%s(%d) exists=%s dir=%s",
|
||||
label,
|
||||
probe,
|
||||
oct(st.st_mode & 0o777),
|
||||
_format_uid(st.st_uid),
|
||||
st.st_uid,
|
||||
_format_gid(st.st_gid),
|
||||
st.st_gid,
|
||||
_run_io(probe.exists),
|
||||
_run_io(probe.is_dir),
|
||||
)
|
||||
except Exception as stat_error:
|
||||
logger.debug("Path permissions (%s): stat failed for %s: %s", label, probe, stat_error)
|
||||
|
||||
|
||||
def _run_io[T](func: Callable[..., _T], *args: Any, **kwargs: Any) -> _T:
|
||||
"""Best-effort offload for potentially blocking filesystem calls.
|
||||
|
||||
Keep this module import-cycle safe: `shelfmark.download.fs` imports this module,
|
||||
@@ -57,7 +84,6 @@ def log_path_permission_context(label: str, path: Path) -> None:
|
||||
|
||||
Only call this from failure paths.
|
||||
"""
|
||||
|
||||
try:
|
||||
euid = os.geteuid() if hasattr(os, "geteuid") else None
|
||||
egid = os.getegid() if hasattr(os, "getegid") else None
|
||||
@@ -96,14 +122,18 @@ def log_path_permission_context(label: str, path: Path) -> None:
|
||||
_run_io(probe.is_symlink),
|
||||
)
|
||||
except Exception as stat_error:
|
||||
logger.debug("Path permissions (%s): stat failed for %s: %s", label, probe, stat_error)
|
||||
logger.debug(
|
||||
"Path permissions (%s): stat failed for %s: %s",
|
||||
label,
|
||||
probe,
|
||||
stat_error,
|
||||
)
|
||||
except Exception as context_error:
|
||||
logger.debug("Permission context (%s): failed to collect: %s", label, context_error)
|
||||
|
||||
|
||||
def log_transfer_permission_context(label: str, source: Path, dest: Path, error: Exception) -> None:
|
||||
"""Log useful permission/ownership context when a file transfer fails."""
|
||||
|
||||
try:
|
||||
euid = os.geteuid() if hasattr(os, "geteuid") else None
|
||||
egid = os.getegid() if hasattr(os, "getegid") else None
|
||||
@@ -122,21 +152,6 @@ def log_transfer_permission_context(label: str, source: Path, dest: Path, error:
|
||||
)
|
||||
|
||||
for probe in [source, dest, dest.parent]:
|
||||
try:
|
||||
st = _run_io(probe.stat)
|
||||
logger.debug(
|
||||
"Path permissions (%s): path=%s mode=%s owner=%s(%d) group=%s(%d) exists=%s dir=%s",
|
||||
label,
|
||||
probe,
|
||||
oct(st.st_mode & 0o777),
|
||||
_format_uid(st.st_uid),
|
||||
st.st_uid,
|
||||
_format_gid(st.st_gid),
|
||||
st.st_gid,
|
||||
_run_io(probe.exists),
|
||||
_run_io(probe.is_dir),
|
||||
)
|
||||
except Exception as stat_error:
|
||||
logger.debug("Path permissions (%s): stat failed for %s: %s", label, probe, stat_error)
|
||||
_log_path_permissions(probe, label)
|
||||
except Exception as context_error:
|
||||
logger.debug("Permission context (%s): failed to collect: %s", label, context_error)
|
||||
|
||||
@@ -9,3 +9,5 @@ Output handlers live in `shelfmark.download.outputs` and should depend on
|
||||
"""
|
||||
|
||||
from .router import post_process_download
|
||||
|
||||
__all__ = ["post_process_download"]
|
||||
|
||||
@@ -5,15 +5,20 @@ import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import shelfmark.core.config as core_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
|
||||
from .steps import log_plan_steps, record_step
|
||||
from .types import PlanStep
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
from .types import PlanStep
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -29,7 +34,6 @@ def resolve_custom_script_target(target_path: Path, destination: Path, path_mode
|
||||
target is not within the destination, fall back to just the filename to
|
||||
avoid leaking unrelated absolute paths.
|
||||
"""
|
||||
|
||||
mode = (path_mode or "absolute").strip().lower()
|
||||
if mode != "relative":
|
||||
return target_path
|
||||
@@ -50,7 +54,7 @@ class CustomScriptExecution:
|
||||
destination: Path
|
||||
mode: str
|
||||
phase: str
|
||||
payload_json: Optional[str] = None
|
||||
payload_json: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -66,11 +70,11 @@ class CustomScriptContext:
|
||||
task: DownloadTask
|
||||
phase: str
|
||||
output_mode: str
|
||||
destination: Optional[Path] = None
|
||||
destination: Path | None = None
|
||||
final_paths: list[Path] = field(default_factory=list)
|
||||
target_path: Optional[Path] = None
|
||||
organization_mode: Optional[str] = None
|
||||
transfer: Optional[CustomScriptTransferSummary] = None
|
||||
target_path: Path | None = None
|
||||
organization_mode: str | None = None
|
||||
transfer: CustomScriptTransferSummary | None = None
|
||||
output_details: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -81,7 +85,7 @@ def prepare_custom_script_execution(
|
||||
destination: Path,
|
||||
path_mode: str,
|
||||
phase: str,
|
||||
payload: Optional[dict[str, Any]] = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> CustomScriptExecution:
|
||||
mode = (path_mode or "absolute").strip().lower()
|
||||
if mode != "relative":
|
||||
@@ -103,10 +107,10 @@ def run_custom_script(
|
||||
execution: CustomScriptExecution,
|
||||
*,
|
||||
task_id: str,
|
||||
status_callback,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
timeout_seconds: int = DEFAULT_CUSTOM_SCRIPT_TIMEOUT_SECONDS,
|
||||
) -> bool:
|
||||
cwd: Optional[str] = None
|
||||
cwd: str | None = None
|
||||
if execution.mode == "relative":
|
||||
# Make relative paths unambiguous by running the script from the destination folder.
|
||||
cwd = str(execution.destination)
|
||||
@@ -136,17 +140,18 @@ def run_custom_script(
|
||||
)
|
||||
if result.stdout:
|
||||
logger.debug("Task %s: custom script stdout: %s", task_id, result.stdout.strip())
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.error("Task %s: custom script not found: %s", task_id, execution.script_path)
|
||||
logger.exception("Task %s: custom script not found: %s", task_id, execution.script_path)
|
||||
status_callback("error", f"Custom script not found: {execution.script_path}")
|
||||
return False
|
||||
except PermissionError:
|
||||
logger.error("Task %s: custom script not executable: %s", task_id, execution.script_path)
|
||||
logger.exception(
|
||||
"Task %s: custom script not executable: %s", task_id, execution.script_path
|
||||
)
|
||||
status_callback("error", f"Custom script not executable: {execution.script_path}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(
|
||||
logger.exception(
|
||||
"Task %s: custom script timed out after %ss: %s",
|
||||
task_id,
|
||||
timeout_seconds,
|
||||
@@ -156,7 +161,7 @@ def run_custom_script(
|
||||
return False
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = exc.stderr.strip() if exc.stderr else "No error output"
|
||||
logger.error(
|
||||
logger.exception(
|
||||
"Task %s: custom script failed (exit code %s): %s",
|
||||
task_id,
|
||||
exc.returncode,
|
||||
@@ -164,14 +169,16 @@ def run_custom_script(
|
||||
)
|
||||
status_callback("error", f"Custom script failed: {stderr[:100]}")
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def _choose_custom_script_target(
|
||||
*,
|
||||
explicit_target: Optional[Path],
|
||||
destination: Optional[Path],
|
||||
explicit_target: Path | None,
|
||||
destination: Path | None,
|
||||
final_paths: list[Path],
|
||||
) -> Optional[Path]:
|
||||
) -> Path | None:
|
||||
if explicit_target is not None:
|
||||
return explicit_target
|
||||
|
||||
@@ -187,7 +194,9 @@ def _choose_custom_script_target(
|
||||
return destination
|
||||
|
||||
|
||||
def _build_custom_script_payload(context: CustomScriptContext, *, target_path: Path) -> dict[str, Any]:
|
||||
def _build_custom_script_payload(
|
||||
context: CustomScriptContext, *, target_path: Path
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"version": 1,
|
||||
"phase": context.phase,
|
||||
@@ -233,8 +242,8 @@ def _build_custom_script_payload(context: CustomScriptContext, *, target_path: P
|
||||
def maybe_run_custom_script(
|
||||
context: CustomScriptContext,
|
||||
*,
|
||||
status_callback,
|
||||
steps: Optional[list[PlanStep]] = None,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
steps: list[PlanStep] | None = None,
|
||||
) -> bool:
|
||||
"""Run the custom script hook (if configured).
|
||||
|
||||
@@ -242,7 +251,6 @@ def maybe_run_custom_script(
|
||||
This function is responsible for choosing the script target, building the
|
||||
optional JSON payload, and executing the script.
|
||||
"""
|
||||
|
||||
script_path = getattr(core_config.config, "CUSTOM_SCRIPT", None)
|
||||
if not isinstance(script_path, str) or not script_path.strip():
|
||||
return True
|
||||
@@ -261,7 +269,7 @@ def maybe_run_custom_script(
|
||||
|
||||
path_mode = core_config.config.get("CUSTOM_SCRIPT_PATH_MODE", "absolute")
|
||||
|
||||
payload: Optional[dict[str, Any]] = None
|
||||
payload: dict[str, Any] | None = None
|
||||
if core_config.config.get("CUSTOM_SCRIPT_JSON_PAYLOAD", False):
|
||||
payload = _build_custom_script_payload(context, target_path=target_path)
|
||||
|
||||
@@ -293,4 +301,6 @@ def maybe_run_custom_script(
|
||||
)
|
||||
log_plan_steps(context.task.task_id, steps)
|
||||
|
||||
return run_custom_script(execution, task_id=context.task.task_id, status_callback=status_callback)
|
||||
return run_custom_script(
|
||||
execution, task_id=context.task.task_id, status_callback=status_callback
|
||||
)
|
||||
|
||||
@@ -1,32 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.utils import (
|
||||
get_destination,
|
||||
)
|
||||
from shelfmark.core.utils import (
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger("shelfmark.download.postprocess.pipeline")
|
||||
|
||||
|
||||
def validate_destination(destination: Path, status_callback) -> bool:
|
||||
def validate_destination(
|
||||
destination: Path, status_callback: Callable[[str, str | None], None]
|
||||
) -> bool:
|
||||
"""Validate destination path is absolute, exists, and writable."""
|
||||
|
||||
if not destination.is_absolute():
|
||||
logger.warning(f"Destination must be absolute: {destination}")
|
||||
logger.warning("Destination must be absolute: %s", destination)
|
||||
status_callback("error", f"Destination must be absolute: {destination}")
|
||||
return False
|
||||
|
||||
destination_exists = run_blocking_io(destination.exists)
|
||||
if destination_exists and not run_blocking_io(destination.is_dir):
|
||||
logger.warning(f"Destination is not a directory: {destination}")
|
||||
logger.warning("Destination is not a directory: %s", destination)
|
||||
status_callback("error", f"Destination is not a directory: {destination}")
|
||||
return False
|
||||
|
||||
@@ -35,7 +43,7 @@ def validate_destination(destination: Path, status_callback) -> bool:
|
||||
run_blocking_io(destination.mkdir, parents=True, exist_ok=True)
|
||||
except (OSError, PermissionError) as exc:
|
||||
log_path_permission_context("destination_create", destination)
|
||||
logger.warning(f"Cannot create destination: {destination} ({exc})")
|
||||
logger.warning("Cannot create destination: %s (%s)", destination, exc)
|
||||
status_callback("error", f"Cannot create destination: {destination} ({exc})")
|
||||
return False
|
||||
|
||||
@@ -51,7 +59,7 @@ def validate_destination(destination: Path, status_callback) -> bool:
|
||||
except Exception as exc:
|
||||
logger.debug("Destination write probe path: %s", test_path)
|
||||
log_path_permission_context("destination_write_probe", destination)
|
||||
logger.warning(f"Destination not writable: {destination} ({exc})")
|
||||
logger.warning("Destination not writable: %s (%s)", destination, exc)
|
||||
status_callback("error", f"Destination not writable: {destination} ({exc})")
|
||||
return False
|
||||
|
||||
@@ -60,7 +68,6 @@ def validate_destination(destination: Path, status_callback) -> bool:
|
||||
|
||||
def get_final_destination(task: DownloadTask) -> Path:
|
||||
"""Get final destination directory, with content-type routing support."""
|
||||
|
||||
is_audiobook = check_audiobook(task.content_type)
|
||||
|
||||
try:
|
||||
@@ -71,4 +78,8 @@ def get_final_destination(task: DownloadTask) -> Path:
|
||||
if override:
|
||||
return override
|
||||
|
||||
return get_destination(is_audiobook, user_id=task.user_id, username=task.username)
|
||||
return get_destination(
|
||||
is_audiobook=is_audiobook,
|
||||
user_id=task.user_id,
|
||||
username=task.username,
|
||||
)
|
||||
|
||||
@@ -18,8 +18,8 @@ implementation stay modular.
|
||||
from __future__ import annotations
|
||||
|
||||
from .custom_script import (
|
||||
CustomScriptExecution,
|
||||
CustomScriptContext,
|
||||
CustomScriptExecution,
|
||||
CustomScriptTransferSummary,
|
||||
maybe_run_custom_script,
|
||||
prepare_custom_script_execution,
|
||||
@@ -55,13 +55,13 @@ from .workspace import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CustomScriptContext",
|
||||
"CustomScriptExecution",
|
||||
"CustomScriptTransferSummary",
|
||||
"OutputPlan",
|
||||
"PlanStep",
|
||||
"PreparedFiles",
|
||||
"TransferPlan",
|
||||
"CustomScriptExecution",
|
||||
"CustomScriptContext",
|
||||
"CustomScriptTransferSummary",
|
||||
"build_metadata_dict",
|
||||
"build_output_plan",
|
||||
"cleanup_output_staging",
|
||||
@@ -75,12 +75,13 @@ __all__ = [
|
||||
"is_within_tmp_dir",
|
||||
"log_plan_steps",
|
||||
"maybe_run_custom_script",
|
||||
"prepare_output_files",
|
||||
"prepare_custom_script_execution",
|
||||
"prepare_output_files",
|
||||
"process_directory",
|
||||
"record_step",
|
||||
"resolve_hardlink_source",
|
||||
"resolve_custom_script_target",
|
||||
"resolve_hardlink_source",
|
||||
"run_custom_script",
|
||||
"safe_cleanup_path",
|
||||
"scan_directory_tree",
|
||||
"should_hardlink",
|
||||
@@ -88,5 +89,4 @@ __all__ = [
|
||||
"transfer_directory_to_library",
|
||||
"transfer_file_to_library",
|
||||
"validate_destination",
|
||||
"run_custom_script",
|
||||
]
|
||||
|
||||
@@ -11,18 +11,16 @@ Examples:
|
||||
Implementation note:
|
||||
Keep this module free of dependencies on archive extraction mechanics to avoid
|
||||
circular imports (`archive` is used by the pipeline).
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import shelfmark.core.config as core_config
|
||||
|
||||
|
||||
def get_supported_formats() -> List[str]:
|
||||
def get_supported_formats() -> list[str]:
|
||||
"""Get current supported formats from config singleton."""
|
||||
|
||||
formats = core_config.config.get(
|
||||
"SUPPORTED_FORMATS",
|
||||
["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"],
|
||||
@@ -35,9 +33,8 @@ def get_supported_formats() -> List[str]:
|
||||
return [fmt.lower() for fmt in formats]
|
||||
|
||||
|
||||
def get_supported_audiobook_formats() -> List[str]:
|
||||
def get_supported_audiobook_formats() -> list[str]:
|
||||
"""Get current supported audiobook formats from config singleton."""
|
||||
|
||||
formats = core_config.config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"])
|
||||
|
||||
# Handle both list (from MultiSelectField) and comma-separated string (legacy/env)
|
||||
@@ -49,7 +46,6 @@ def get_supported_audiobook_formats() -> List[str]:
|
||||
|
||||
def get_file_organization(is_audiobook: bool) -> str:
|
||||
"""Get the file organization mode for the content type."""
|
||||
|
||||
key = "FILE_ORGANIZATION_AUDIOBOOK" if is_audiobook else "FILE_ORGANIZATION"
|
||||
mode = core_config.config.get(key, "rename")
|
||||
|
||||
@@ -68,7 +64,6 @@ def get_file_organization(is_audiobook: bool) -> str:
|
||||
|
||||
def get_template(is_audiobook: bool, organization_mode: str) -> str:
|
||||
"""Get the template for the content type and organization mode."""
|
||||
|
||||
# Determine the correct key based on content type and organization mode
|
||||
if is_audiobook:
|
||||
if organization_mode == "organize":
|
||||
@@ -76,10 +71,7 @@ def get_template(is_audiobook: bool, organization_mode: str) -> str:
|
||||
else:
|
||||
key = "TEMPLATE_AUDIOBOOK_RENAME"
|
||||
else:
|
||||
if organization_mode == "organize":
|
||||
key = "TEMPLATE_ORGANIZE"
|
||||
else:
|
||||
key = "TEMPLATE_RENAME"
|
||||
key = "TEMPLATE_ORGANIZE" if organization_mode == "organize" else "TEMPLATE_RENAME"
|
||||
|
||||
template = core_config.config.get(key, "")
|
||||
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.download.staging import STAGE_COPY, STAGE_NONE, get_staging_dir, stage_path
|
||||
from shelfmark.download.staging import (
|
||||
STAGE_COPY,
|
||||
STAGE_NONE,
|
||||
get_staging_dir,
|
||||
stage_path,
|
||||
)
|
||||
|
||||
from .scan import collect_staged_files
|
||||
from .transfer import resolve_hardlink_source
|
||||
from .types import OutputPlan, PreparedFiles
|
||||
from .workspace import cleanup_output_staging, is_managed_workspace_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger("shelfmark.download.postprocess.pipeline")
|
||||
|
||||
|
||||
@@ -19,11 +28,10 @@ def build_output_plan(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
output_mode: str,
|
||||
destination: Optional[Path] = None,
|
||||
status_callback=None,
|
||||
destination: Path | None = None,
|
||||
status_callback: Callable[[str, str | None], None] | None = None,
|
||||
) -> OutputPlan:
|
||||
"""Build an output plan that describes staging behavior for file-based outputs."""
|
||||
|
||||
transfer_plan = resolve_hardlink_source(temp_file, task, destination, status_callback)
|
||||
staging_dir = get_staging_dir()
|
||||
|
||||
@@ -40,11 +48,12 @@ def prepare_output_files(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
output_mode: str,
|
||||
status_callback,
|
||||
destination: Optional[Path] = None,
|
||||
output_plan: Optional[OutputPlan] = None,
|
||||
status_callback: Callable[[str, str | None], None] | None,
|
||||
destination: Path | None = None,
|
||||
output_plan: OutputPlan | None = None,
|
||||
*,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[PreparedFiles]:
|
||||
) -> PreparedFiles | None:
|
||||
if output_plan is None:
|
||||
output_plan = build_output_plan(
|
||||
temp_file,
|
||||
@@ -56,12 +65,14 @@ def prepare_output_files(
|
||||
|
||||
working_path = temp_file
|
||||
if output_plan.stage_action != STAGE_NONE:
|
||||
step_label = "Staging torrent files" if output_plan.stage_action == STAGE_COPY else "Staging files"
|
||||
step_label = (
|
||||
"Staging torrent files" if output_plan.stage_action == STAGE_COPY else "Staging 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
|
||||
|
||||
|
||||
@@ -10,14 +10,17 @@ Keeping this separate from `pipeline.py` avoids circular imports:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask, SearchMode
|
||||
from shelfmark.download.outputs import resolve_output_handler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
@@ -25,11 +28,11 @@ def post_process_download(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
*,
|
||||
preserve_source_on_failure: bool = False,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
"""Post-process download using the selected output handler."""
|
||||
|
||||
if task.search_mode is None:
|
||||
logger.warning(
|
||||
"Task %s: missing search_mode; defaulting to Direct mode behavior",
|
||||
@@ -50,7 +53,7 @@ def post_process_download(
|
||||
task,
|
||||
cancel_flag,
|
||||
status_callback,
|
||||
preserve_source_on_failure,
|
||||
preserve_source_on_failure=preserve_source_on_failure,
|
||||
)
|
||||
|
||||
from shelfmark.download.outputs.folder import process_folder_output
|
||||
@@ -61,5 +64,5 @@ def post_process_download(
|
||||
task,
|
||||
cancel_flag,
|
||||
status_callback,
|
||||
preserve_source_on_failure,
|
||||
preserve_source_on_failure=preserve_source_on_failure,
|
||||
)
|
||||
|
||||
@@ -2,33 +2,39 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.download.archive import ArchiveExtractionError, extract_archive, is_archive
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
from shelfmark.download.permissions_debug import log_path_permission_context
|
||||
from shelfmark.download.postprocess.policy import (
|
||||
get_supported_audiobook_formats,
|
||||
)
|
||||
from shelfmark.download.postprocess.policy import (
|
||||
get_supported_formats as get_book_formats,
|
||||
)
|
||||
from shelfmark.download.staging import build_staging_dir
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger("shelfmark.download.postprocess.pipeline")
|
||||
|
||||
|
||||
def get_supported_formats(content_type: Optional[str] = None) -> List[str]:
|
||||
def get_supported_formats(content_type: str | None = None) -> list[str]:
|
||||
if check_audiobook(content_type):
|
||||
return get_supported_audiobook_formats()
|
||||
return get_book_formats()
|
||||
|
||||
|
||||
def _format_not_supported_error(rejected_files: List[Path], task: DownloadTask) -> str:
|
||||
def _format_not_supported_error(rejected_files: list[Path], task: DownloadTask) -> str:
|
||||
content_type = task.content_type
|
||||
file_type_label = "audiobook" if check_audiobook(content_type) else "book"
|
||||
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
|
||||
rejected_exts = sorted({f.suffix.lower() for f in rejected_files})
|
||||
rejected_list = ", ".join(rejected_exts)
|
||||
supported_formats = get_supported_formats(content_type)
|
||||
|
||||
@@ -51,8 +57,9 @@ def extract_archive_files(
|
||||
archive_path: Path,
|
||||
output_dir: Path,
|
||||
task: DownloadTask,
|
||||
*,
|
||||
cleanup_archive: bool,
|
||||
) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]:
|
||||
) -> tuple[list[Path], list[Path], list[Path], str | None]:
|
||||
content_type = task.content_type
|
||||
|
||||
try:
|
||||
@@ -86,7 +93,12 @@ def extract_archive_files(
|
||||
|
||||
if not extracted_files:
|
||||
if rejected_files:
|
||||
return [], rejected_files, cleanup_paths, _format_not_supported_error(rejected_files, task)
|
||||
return (
|
||||
[],
|
||||
rejected_files,
|
||||
cleanup_paths,
|
||||
_format_not_supported_error(rejected_files, task),
|
||||
)
|
||||
file_type_label = "audiobook" if check_audiobook(content_type) else "book"
|
||||
return [], rejected_files, cleanup_paths, f"No {file_type_label} files found in archive"
|
||||
|
||||
@@ -102,11 +114,11 @@ def extract_archive_files(
|
||||
|
||||
def scan_directory_tree(
|
||||
directory: Path,
|
||||
content_type: Optional[str],
|
||||
) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]:
|
||||
content_type: str | None,
|
||||
) -> tuple[list[Path], list[Path], list[Path], str | None]:
|
||||
"""Scan a directory tree for book files, trackable-but-unsupported files, and archives."""
|
||||
|
||||
try:
|
||||
|
||||
def _probe_dir() -> None:
|
||||
# Force a fast error if the dir is missing/inaccessible.
|
||||
with os.scandir(directory) as it:
|
||||
@@ -115,10 +127,10 @@ def scan_directory_tree(
|
||||
run_blocking_io(_probe_dir)
|
||||
except PermissionError as exc:
|
||||
log_path_permission_context("scan_directory", directory)
|
||||
logger.warning(f"Permission denied scanning directory: {directory} ({exc})")
|
||||
logger.warning("Permission denied scanning directory: %s (%s)", directory, exc)
|
||||
return [], [], [], f"Permission denied accessing download folder: {directory}"
|
||||
except (FileNotFoundError, NotADirectoryError, OSError) as exc:
|
||||
logger.warning(f"Cannot access download folder: {directory} ({exc})")
|
||||
logger.warning("Cannot access download folder: %s (%s)", directory, exc)
|
||||
return [], [], [], f"Cannot access download folder: {directory} ({exc})"
|
||||
|
||||
supported_formats = get_supported_formats(content_type)
|
||||
@@ -126,11 +138,22 @@ def scan_directory_tree(
|
||||
|
||||
is_audiobook = check_audiobook(content_type)
|
||||
if is_audiobook:
|
||||
trackable_exts = {'.m4b', '.mp3', '.m4a', '.flac', '.ogg', '.wma', '.aac', '.wav'}
|
||||
trackable_exts = {".m4b", ".mp3", ".m4a", ".flac", ".ogg", ".wma", ".aac", ".wav"}
|
||||
else:
|
||||
trackable_exts = {
|
||||
'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr',
|
||||
'.doc', '.docx', '.rtf', '.txt',
|
||||
".pdf",
|
||||
".epub",
|
||||
".mobi",
|
||||
".azw",
|
||||
".azw3",
|
||||
".fb2",
|
||||
".djvu",
|
||||
".cbz",
|
||||
".cbr",
|
||||
".doc",
|
||||
".docx",
|
||||
".rtf",
|
||||
".txt",
|
||||
}
|
||||
|
||||
logged_walk_permission_context = False
|
||||
@@ -140,22 +163,19 @@ def scan_directory_tree(
|
||||
|
||||
if isinstance(error, PermissionError):
|
||||
if not logged_walk_permission_context:
|
||||
try:
|
||||
error_path = Path(getattr(error, "filename", "") or str(directory))
|
||||
except Exception:
|
||||
error_path = directory
|
||||
error_path = Path(getattr(error, "filename", "") or str(directory))
|
||||
|
||||
log_path_permission_context("scan_directory_walk", error_path)
|
||||
logged_walk_permission_context = True
|
||||
|
||||
logger.debug(f"Skipping inaccessible path during scan: {error}")
|
||||
logger.debug("Skipping inaccessible path during scan: %s", error)
|
||||
else:
|
||||
logger.debug(f"Error scanning directory tree: {error}")
|
||||
logger.debug("Error scanning directory tree: %s", error)
|
||||
|
||||
def _walk_tree() -> Tuple[List[Path], List[Path], List[Path]]:
|
||||
book_files: List[Path] = []
|
||||
rejected_files: List[Path] = []
|
||||
archive_files: List[Path] = []
|
||||
def _walk_tree() -> tuple[list[Path], list[Path], list[Path]]:
|
||||
book_files: list[Path] = []
|
||||
rejected_files: list[Path] = []
|
||||
archive_files: list[Path] = []
|
||||
|
||||
for root, _, files in os.walk(directory, onerror=onerror):
|
||||
for filename in files:
|
||||
@@ -176,10 +196,10 @@ def scan_directory_tree(
|
||||
book_files, rejected_files, archive_files = run_blocking_io(_walk_tree)
|
||||
except PermissionError as exc:
|
||||
log_path_permission_context("scan_directory_walk", directory)
|
||||
logger.warning(f"Permission denied scanning directory: {directory} ({exc})")
|
||||
logger.warning("Permission denied scanning directory: %s (%s)", directory, exc)
|
||||
return [], [], [], f"Permission denied accessing download folder: {directory}"
|
||||
except (FileNotFoundError, NotADirectoryError, OSError) as exc:
|
||||
logger.warning(f"Cannot access download folder: {directory} ({exc})")
|
||||
logger.warning("Cannot access download folder: %s (%s)", directory, exc)
|
||||
return [], [], [], f"Cannot access download folder: {directory} ({exc})"
|
||||
|
||||
return book_files, rejected_files, archive_files, None
|
||||
@@ -188,12 +208,15 @@ def scan_directory_tree(
|
||||
def collect_directory_files(
|
||||
directory: Path,
|
||||
task: DownloadTask,
|
||||
*,
|
||||
allow_archive_extraction: bool,
|
||||
status_callback=None,
|
||||
status_callback: Callable[[str, str | None], None] | None = None,
|
||||
cleanup_archives: bool = False,
|
||||
) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]:
|
||||
) -> tuple[list[Path], list[Path], list[Path], str | None]:
|
||||
content_type = task.content_type
|
||||
book_files, rejected_files, archive_files, scan_error = scan_directory_tree(directory, content_type)
|
||||
book_files, rejected_files, archive_files, scan_error = scan_directory_tree(
|
||||
directory, content_type
|
||||
)
|
||||
if scan_error:
|
||||
return [], [], [], scan_error
|
||||
|
||||
@@ -206,7 +229,7 @@ def collect_directory_files(
|
||||
len(book_files),
|
||||
)
|
||||
if rejected_files:
|
||||
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
|
||||
rejected_exts = sorted({f.suffix.lower() for f in rejected_files})
|
||||
logger.debug(
|
||||
"Task %s: also found %d file(s) with unsupported formats: %s",
|
||||
task.task_id,
|
||||
@@ -232,9 +255,9 @@ def collect_directory_files(
|
||||
|
||||
logger.info("Task %s: extracting %d archive(s)", task.task_id, len(archive_files))
|
||||
|
||||
all_files: List[Path] = []
|
||||
all_errors: List[str] = []
|
||||
cleanup_paths: List[Path] = []
|
||||
all_files: list[Path] = []
|
||||
all_errors: list[str] = []
|
||||
cleanup_paths: list[Path] = []
|
||||
|
||||
for archive in archive_files:
|
||||
extract_dir = build_staging_dir("extract", task.task_id)
|
||||
@@ -267,7 +290,12 @@ def collect_directory_files(
|
||||
return [], rejected_files, cleanup_paths, "; ".join(all_errors)
|
||||
|
||||
if rejected_files:
|
||||
return [], rejected_files, cleanup_paths, _format_not_supported_error(rejected_files, task)
|
||||
return (
|
||||
[],
|
||||
rejected_files,
|
||||
cleanup_paths,
|
||||
_format_not_supported_error(rejected_files, task),
|
||||
)
|
||||
|
||||
return [], rejected_files, cleanup_paths, "No book files found in archives"
|
||||
|
||||
@@ -280,10 +308,11 @@ def collect_directory_files(
|
||||
def collect_staged_files(
|
||||
working_path: Path,
|
||||
task: DownloadTask,
|
||||
*,
|
||||
allow_archive_extraction: bool,
|
||||
status_callback,
|
||||
status_callback: Callable[[str, str | None], None] | None,
|
||||
cleanup_archives: bool,
|
||||
) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]:
|
||||
) -> tuple[list[Path], list[Path], list[Path], str | None]:
|
||||
if run_blocking_io(working_path.is_dir):
|
||||
if status_callback:
|
||||
status_callback("resolving", "Processing download folder")
|
||||
@@ -332,11 +361,22 @@ def collect_staged_files(
|
||||
|
||||
is_audiobook = check_audiobook(task.content_type)
|
||||
if is_audiobook:
|
||||
trackable_exts = {'.m4b', '.mp3', '.m4a', '.flac', '.ogg', '.wma', '.aac', '.wav'}
|
||||
trackable_exts = {".m4b", ".mp3", ".m4a", ".flac", ".ogg", ".wma", ".aac", ".wav"}
|
||||
else:
|
||||
trackable_exts = {
|
||||
'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr',
|
||||
'.doc', '.docx', '.rtf', '.txt',
|
||||
".pdf",
|
||||
".epub",
|
||||
".mobi",
|
||||
".azw",
|
||||
".azw3",
|
||||
".fb2",
|
||||
".djvu",
|
||||
".cbz",
|
||||
".cbr",
|
||||
".doc",
|
||||
".docx",
|
||||
".rtf",
|
||||
".txt",
|
||||
}
|
||||
|
||||
if suffix in supported_exts:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
from .types import PlanStep
|
||||
@@ -9,11 +7,11 @@ from .types import PlanStep
|
||||
logger = setup_logger("shelfmark.download.postprocess.pipeline")
|
||||
|
||||
|
||||
def record_step(steps: List[PlanStep], name: str, **details: Any) -> None:
|
||||
def record_step(steps: list[PlanStep], name: str, **details: object) -> None:
|
||||
steps.append(PlanStep(name=name, details=details))
|
||||
|
||||
|
||||
def log_plan_steps(task_id: str, steps: List[PlanStep]) -> None:
|
||||
def log_plan_steps(task_id: str, steps: list[PlanStep]) -> None:
|
||||
if not steps:
|
||||
return
|
||||
summary = " -> ".join(step.name for step in steps)
|
||||
|
||||
@@ -2,11 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import shelfmark.core.config as core_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.naming import (
|
||||
assign_part_numbers,
|
||||
build_library_path,
|
||||
@@ -15,19 +14,28 @@ from shelfmark.core.naming import (
|
||||
sanitize_filename,
|
||||
)
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.download.fs import atomic_copy, atomic_hardlink, atomic_move, run_blocking_io
|
||||
from shelfmark.download.fs import (
|
||||
atomic_copy,
|
||||
atomic_hardlink,
|
||||
atomic_move,
|
||||
run_blocking_io,
|
||||
)
|
||||
from shelfmark.download.postprocess.policy import get_file_organization, get_template
|
||||
|
||||
from .scan import collect_directory_files, scan_directory_tree
|
||||
from .types import TransferPlan
|
||||
from .workspace import safe_cleanup_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger("shelfmark.download.postprocess.pipeline")
|
||||
|
||||
|
||||
def should_hardlink(task: DownloadTask) -> bool:
|
||||
"""Check if hardlinking is enabled for this task (Prowlarr torrents only)."""
|
||||
|
||||
if task.source != "prowlarr":
|
||||
return False
|
||||
|
||||
@@ -44,7 +52,6 @@ def should_hardlink(task: DownloadTask) -> bool:
|
||||
return bool(hardlink_enabled)
|
||||
|
||||
|
||||
|
||||
def build_metadata_dict(task: DownloadTask) -> dict:
|
||||
return {
|
||||
"Author": task.author,
|
||||
@@ -57,7 +64,9 @@ def build_metadata_dict(task: DownloadTask) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def build_file_metadata(task: DownloadTask, source_file: Path, part_number: Optional[str] = None) -> dict:
|
||||
def build_file_metadata(
|
||||
task: DownloadTask, source_file: Path, part_number: str | None = None
|
||||
) -> dict:
|
||||
metadata = build_metadata_dict(task)
|
||||
metadata["OriginalName"] = source_file.stem
|
||||
if part_number is not None:
|
||||
@@ -68,11 +77,10 @@ def build_file_metadata(task: DownloadTask, source_file: Path, part_number: Opti
|
||||
def resolve_hardlink_source(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
destination: Optional[Path],
|
||||
status_callback=None,
|
||||
destination: Path | None,
|
||||
status_callback: Callable[[str, str | None], None] | None = None,
|
||||
) -> TransferPlan:
|
||||
"""Resolve hardlink eligibility and source path for transfers."""
|
||||
|
||||
use_hardlink = False
|
||||
source_path = temp_file
|
||||
hardlink_enabled = should_hardlink(task)
|
||||
@@ -80,13 +88,18 @@ def resolve_hardlink_source(
|
||||
if hardlink_enabled and task.original_download_path:
|
||||
hardlink_source = Path(task.original_download_path)
|
||||
hardlink_source_exists = run_blocking_io(hardlink_source.exists)
|
||||
if destination and hardlink_source_exists and run_blocking_io(same_filesystem, hardlink_source, destination):
|
||||
if (
|
||||
destination
|
||||
and hardlink_source_exists
|
||||
and run_blocking_io(same_filesystem, hardlink_source, destination)
|
||||
):
|
||||
use_hardlink = True
|
||||
source_path = hardlink_source
|
||||
elif hardlink_source_exists:
|
||||
logger.warning(
|
||||
f"Cannot hardlink: {hardlink_source} and {destination} are on different filesystems. "
|
||||
"Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination."
|
||||
"Cannot hardlink: %s and %s are on different filesystems. Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination.",
|
||||
hardlink_source,
|
||||
destination,
|
||||
)
|
||||
if status_callback:
|
||||
status_callback("resolving", "Cannot hardlink (different filesystems), using copy")
|
||||
@@ -101,14 +114,13 @@ def resolve_hardlink_source(
|
||||
|
||||
def is_torrent_source(source_path: Path, task: DownloadTask) -> bool:
|
||||
"""Check if source is the torrent client path (needs copy to preserve seeding)."""
|
||||
|
||||
if not task.original_download_path:
|
||||
return False
|
||||
|
||||
original_path = Path(task.original_download_path)
|
||||
try:
|
||||
return run_blocking_io(source_path.resolve) == run_blocking_io(original_path.resolve)
|
||||
except (OSError, ValueError):
|
||||
except OSError, ValueError:
|
||||
try:
|
||||
return os.path.normpath(str(source_path)) == os.path.normpath(str(original_path))
|
||||
except Exception:
|
||||
@@ -124,11 +136,12 @@ def _max_attempts_for_batch(file_count: int, default: int = 100) -> int:
|
||||
def _transfer_single_file(
|
||||
source_path: Path,
|
||||
dest_path: Path,
|
||||
*,
|
||||
use_hardlink: bool,
|
||||
is_torrent: bool,
|
||||
preserve_source: bool = False,
|
||||
max_attempts: int = 100,
|
||||
) -> Tuple[Path, str]:
|
||||
) -> tuple[Path, str]:
|
||||
if use_hardlink:
|
||||
final_path = atomic_hardlink(source_path, dest_path, max_attempts=max_attempts)
|
||||
try:
|
||||
@@ -145,23 +158,24 @@ def _transfer_single_file(
|
||||
|
||||
|
||||
def transfer_book_files(
|
||||
book_files: List[Path],
|
||||
book_files: list[Path],
|
||||
destination: Path,
|
||||
task: DownloadTask,
|
||||
*,
|
||||
use_hardlink: bool,
|
||||
is_torrent: bool,
|
||||
preserve_source: bool = False,
|
||||
organization_mode: Optional[str] = None,
|
||||
) -> Tuple[List[Path], Optional[str], Dict[str, int]]:
|
||||
organization_mode: str | None = None,
|
||||
) -> tuple[list[Path], str | None, dict[str, int]]:
|
||||
if not book_files:
|
||||
return [], "No book files found", {"hardlink": 0, "copy": 0, "move": 0}
|
||||
|
||||
is_audiobook = check_audiobook(task.content_type)
|
||||
organization_mode = organization_mode or get_file_organization(is_audiobook)
|
||||
organization_mode = organization_mode or get_file_organization(is_audiobook=is_audiobook)
|
||||
max_attempts = _max_attempts_for_batch(len(book_files))
|
||||
|
||||
final_paths: List[Path] = []
|
||||
op_counts: Dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0}
|
||||
final_paths: list[Path] = []
|
||||
op_counts: dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0}
|
||||
|
||||
if organization_mode == "organize":
|
||||
template = get_template(is_audiobook, "organize")
|
||||
@@ -182,14 +196,14 @@ def transfer_book_files(
|
||||
final_path, op = _transfer_single_file(
|
||||
source_file,
|
||||
dest_path,
|
||||
use_hardlink,
|
||||
is_torrent,
|
||||
use_hardlink=use_hardlink,
|
||||
is_torrent=is_torrent,
|
||||
preserve_source=preserve_source,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
final_paths.append(final_path)
|
||||
op_counts[op] = op_counts.get(op, 0) + 1
|
||||
logger.debug(f"{op.capitalize()} to destination: {final_path.name}")
|
||||
logger.debug("%s to destination: %s", op.capitalize(), final_path.name)
|
||||
else:
|
||||
zero_pad_width = max(len(str(len(book_files))), 2)
|
||||
files_with_parts = assign_part_numbers(book_files, zero_pad_width)
|
||||
@@ -209,14 +223,14 @@ def transfer_book_files(
|
||||
final_path, op = _transfer_single_file(
|
||||
source_file,
|
||||
dest_path,
|
||||
use_hardlink,
|
||||
is_torrent,
|
||||
use_hardlink=use_hardlink,
|
||||
is_torrent=is_torrent,
|
||||
preserve_source=preserve_source,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
final_paths.append(final_path)
|
||||
op_counts[op] = op_counts.get(op, 0) + 1
|
||||
logger.debug(f"{op.capitalize()} to destination: {final_path.name}")
|
||||
logger.debug("%s to destination: %s", op.capitalize(), final_path.name)
|
||||
|
||||
return final_paths, None, op_counts
|
||||
|
||||
@@ -242,14 +256,14 @@ def transfer_book_files(
|
||||
final_path, op = _transfer_single_file(
|
||||
book_file,
|
||||
dest_path,
|
||||
use_hardlink,
|
||||
is_torrent,
|
||||
use_hardlink=use_hardlink,
|
||||
is_torrent=is_torrent,
|
||||
preserve_source=preserve_source,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
final_paths.append(final_path)
|
||||
op_counts[op] = op_counts.get(op, 0) + 1
|
||||
logger.debug(f"{op.capitalize()} to destination: {final_path.name}")
|
||||
logger.debug("%s to destination: %s", op.capitalize(), final_path.name)
|
||||
|
||||
return final_paths, None, op_counts
|
||||
|
||||
@@ -258,11 +272,11 @@ def process_directory(
|
||||
directory: Path,
|
||||
ingest_dir: Path,
|
||||
task: DownloadTask,
|
||||
*,
|
||||
allow_archive_extraction: bool = True,
|
||||
use_hardlink: Optional[bool] = None,
|
||||
) -> Tuple[List[Path], Optional[str]]:
|
||||
use_hardlink: bool | None = None,
|
||||
) -> tuple[list[Path], str | None]:
|
||||
"""Process staged directory: find book files, extract archives, move to ingest."""
|
||||
|
||||
try:
|
||||
is_torrent = is_torrent_source(directory, task)
|
||||
book_files, _, cleanup_paths, error = collect_directory_files(
|
||||
@@ -299,13 +313,17 @@ def process_directory(
|
||||
for cleanup_path in cleanup_paths:
|
||||
safe_cleanup_path(cleanup_path, task)
|
||||
|
||||
return final_paths, None
|
||||
processed_paths = final_paths
|
||||
|
||||
except Exception as exc:
|
||||
logger.error_trace("Task %s: error processing directory %s: %s", task.task_id, directory, exc)
|
||||
logger.error_trace(
|
||||
"Task %s: error processing directory %s: %s", task.task_id, directory, exc
|
||||
)
|
||||
if not is_torrent_source(directory, task):
|
||||
safe_cleanup_path(directory, task)
|
||||
return [], str(exc)
|
||||
else:
|
||||
return processed_paths, None
|
||||
|
||||
|
||||
def transfer_file_to_library(
|
||||
@@ -314,25 +332,28 @@ def transfer_file_to_library(
|
||||
template: str,
|
||||
metadata: dict,
|
||||
task: DownloadTask,
|
||||
temp_file: Optional[Path],
|
||||
status_callback,
|
||||
temp_file: Path | None,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
*,
|
||||
use_hardlink: bool,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
extension = source_path.suffix.lstrip(".") or task.format
|
||||
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)
|
||||
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)
|
||||
final_path, op = _transfer_single_file(
|
||||
source_path,
|
||||
dest_path,
|
||||
use_hardlink,
|
||||
is_torrent,
|
||||
use_hardlink=use_hardlink,
|
||||
is_torrent=is_torrent,
|
||||
max_attempts=_max_attempts_for_batch(1),
|
||||
)
|
||||
logger.info(f"Library {op}: {final_path}")
|
||||
logger.info("Library %s: %s", op, final_path)
|
||||
if use_hardlink and op != "hardlink":
|
||||
logger.warning(
|
||||
"Library hardlink requested but %s used instead for %s",
|
||||
@@ -353,10 +374,11 @@ def transfer_directory_to_library(
|
||||
template: str,
|
||||
metadata: dict,
|
||||
task: DownloadTask,
|
||||
temp_file: Optional[Path],
|
||||
status_callback,
|
||||
temp_file: Path | None,
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
*,
|
||||
use_hardlink: bool,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
content_type = task.content_type.lower() if task.content_type else None
|
||||
source_files, _, _, scan_error = scan_directory_tree(source_dir, content_type)
|
||||
if scan_error:
|
||||
@@ -367,7 +389,7 @@ def transfer_directory_to_library(
|
||||
return None
|
||||
|
||||
if not source_files:
|
||||
logger.warning(f"No supported files in {source_dir.name}")
|
||||
logger.warning("No supported files in %s", source_dir.name)
|
||||
status_callback("error", "No supported file formats found")
|
||||
if temp_file:
|
||||
safe_cleanup_path(temp_file, task)
|
||||
@@ -383,8 +405,8 @@ def transfer_directory_to_library(
|
||||
run_blocking_io(base_library_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
is_torrent = is_torrent_source(source_dir, task)
|
||||
transferred_paths: List[Path] = []
|
||||
op_counts: Dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0}
|
||||
transferred_paths: list[Path] = []
|
||||
op_counts: dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0}
|
||||
max_attempts = _max_attempts_for_batch(len(source_files))
|
||||
|
||||
if len(source_files) == 1:
|
||||
@@ -394,11 +416,11 @@ def transfer_directory_to_library(
|
||||
final_path, op = _transfer_single_file(
|
||||
source_file,
|
||||
dest_path,
|
||||
use_hardlink,
|
||||
is_torrent,
|
||||
use_hardlink=use_hardlink,
|
||||
is_torrent=is_torrent,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
logger.debug(f"Library {op}: {source_file.name} -> {final_path}")
|
||||
logger.debug("Library %s: %s -> %s", op, source_file.name, final_path)
|
||||
transferred_paths.append(final_path)
|
||||
op_counts[op] = op_counts.get(op, 0) + 1
|
||||
else:
|
||||
@@ -408,23 +430,23 @@ def transfer_directory_to_library(
|
||||
for source_file, part_number in files_with_parts:
|
||||
ext = source_file.suffix.lstrip(".")
|
||||
file_metadata = {**metadata, "PartNumber": part_number}
|
||||
file_path = run_blocking_io(build_library_path, library_base, template, file_metadata, extension=ext)
|
||||
file_path = run_blocking_io(
|
||||
build_library_path, library_base, template, file_metadata, extension=ext
|
||||
)
|
||||
run_blocking_io(file_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
final_path, op = _transfer_single_file(
|
||||
source_file,
|
||||
file_path,
|
||||
use_hardlink,
|
||||
is_torrent,
|
||||
use_hardlink=use_hardlink,
|
||||
is_torrent=is_torrent,
|
||||
max_attempts=max_attempts,
|
||||
)
|
||||
logger.debug(f"Library {op}: {source_file.name} -> {final_path}")
|
||||
logger.debug("Library %s: %s -> %s", op, source_file.name, final_path)
|
||||
transferred_paths.append(final_path)
|
||||
op_counts[op] = op_counts.get(op, 0) + 1
|
||||
|
||||
op_summary = ", ".join(
|
||||
f"{op}={count}" for op, count in op_counts.items() if count
|
||||
) or "none"
|
||||
op_summary = ", ".join(f"{op}={count}" for op, count in op_counts.items() if count) or "none"
|
||||
logger.info(
|
||||
"Created %d library file(s) in %s (ops: %s)",
|
||||
len(transferred_paths),
|
||||
@@ -444,7 +466,9 @@ def transfer_directory_to_library(
|
||||
safe_cleanup_path(temp_file, task)
|
||||
safe_cleanup_path(source_dir, task)
|
||||
|
||||
message = f"Complete ({len(transferred_paths)} files)" if len(transferred_paths) > 1 else "Complete"
|
||||
message = (
|
||||
f"Complete ({len(transferred_paths)} files)" if len(transferred_paths) > 1 else "Complete"
|
||||
)
|
||||
status_callback("complete", message)
|
||||
|
||||
return str(transferred_paths[0])
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.download.staging import StageAction
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from shelfmark.download.staging import StageAction
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -21,19 +23,19 @@ class OutputPlan:
|
||||
stage_action: StageAction
|
||||
staging_dir: Path
|
||||
allow_archive_extraction: bool
|
||||
transfer_plan: Optional[TransferPlan] = None
|
||||
transfer_plan: TransferPlan | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedFiles:
|
||||
output_plan: OutputPlan
|
||||
working_path: Path
|
||||
files: List[Path]
|
||||
rejected_files: List[Path]
|
||||
cleanup_paths: List[Path]
|
||||
files: list[Path]
|
||||
rejected_files: list[Path]
|
||||
cleanup_paths: list[Path]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanStep:
|
||||
name: str
|
||||
details: Dict[str, Any]
|
||||
details: dict[str, Any]
|
||||
|
||||
@@ -2,15 +2,17 @@ from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.config import env as env_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
from shelfmark.download.staging import STAGE_NONE
|
||||
|
||||
from .types import OutputPlan
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
from .types import OutputPlan
|
||||
|
||||
logger = setup_logger("shelfmark.download.postprocess.pipeline")
|
||||
|
||||
@@ -21,24 +23,28 @@ def _tmp_dir() -> Path:
|
||||
|
||||
def is_within_tmp_dir(path: Path) -> bool:
|
||||
"""Legacy helper: True if path is inside TMP_DIR."""
|
||||
|
||||
# Fast path: avoid `resolve()` (can block on NFS) for obviously-non-TMP paths.
|
||||
# This is a *negative* check only; for potential TMP paths we still resolve to
|
||||
# prevent symlink escapes from being treated as managed.
|
||||
tmp_dir = _tmp_dir()
|
||||
try:
|
||||
if path.is_absolute() and tmp_dir.is_absolute():
|
||||
if path != tmp_dir and tmp_dir not in path.parents:
|
||||
return False
|
||||
if (
|
||||
path.is_absolute()
|
||||
and tmp_dir.is_absolute()
|
||||
and path != tmp_dir
|
||||
and tmp_dir not in path.parents
|
||||
):
|
||||
return False
|
||||
except Exception:
|
||||
# Fall back to the slower resolve-based check below.
|
||||
pass
|
||||
|
||||
try:
|
||||
run_blocking_io(path.resolve).relative_to(run_blocking_io(tmp_dir.resolve))
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
except OSError, ValueError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def is_managed_workspace_path(path: Path) -> bool:
|
||||
@@ -47,23 +53,21 @@ def is_managed_workspace_path(path: Path) -> bool:
|
||||
The managed workspace is `TMP_DIR`. Anything outside it should be treated as
|
||||
read-only for safety (e.g. torrent seeding directories).
|
||||
"""
|
||||
|
||||
return is_within_tmp_dir(path)
|
||||
|
||||
|
||||
def _is_original_download(path: Optional[Path], task: DownloadTask) -> bool:
|
||||
def _is_original_download(path: Path | None, task: DownloadTask) -> bool:
|
||||
if not path or not task.original_download_path:
|
||||
return False
|
||||
try:
|
||||
original = Path(task.original_download_path)
|
||||
return run_blocking_io(path.resolve) == run_blocking_io(original.resolve)
|
||||
except (OSError, ValueError):
|
||||
except OSError, ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def safe_cleanup_path(path: Optional[Path], task: DownloadTask) -> None:
|
||||
def safe_cleanup_path(path: Path | None, task: DownloadTask) -> None:
|
||||
"""Remove a temp path only if it is safe and in our managed workspace."""
|
||||
|
||||
if not path or _is_original_download(path, task):
|
||||
return
|
||||
|
||||
@@ -84,7 +88,7 @@ def cleanup_output_staging(
|
||||
output_plan: OutputPlan,
|
||||
working_path: Path,
|
||||
task: DownloadTask,
|
||||
cleanup_paths: Optional[List[Path]] = None,
|
||||
cleanup_paths: list[Path] | None = None,
|
||||
) -> None:
|
||||
if output_plan.stage_action != STAGE_NONE:
|
||||
cleanup_target = output_plan.staging_dir
|
||||
|
||||
@@ -2,13 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from shelfmark.config import env as env_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
StageAction = Literal["none", "copy", "move"]
|
||||
@@ -49,7 +51,7 @@ def build_staging_dir(prefix: str | None, task_id: str) -> Path:
|
||||
return staging_dir
|
||||
|
||||
|
||||
def stage_file(source_path: Path, task_id: str, copy: bool = False) -> Path:
|
||||
def stage_file(source_path: Path, task_id: str, *, copy: bool = False) -> Path:
|
||||
"""Stage a file for ingest processing. Use copy=True for torrents to preserve seeding."""
|
||||
staging_dir = get_staging_dir()
|
||||
return stage_path(source_path, staging_dir, STAGE_COPY if copy else STAGE_MOVE)
|
||||
|
||||
+855
-652
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,37 @@
|
||||
"""Metadata provider plugin system - base classes and registry."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Type, Union
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class SearchType(str, Enum):
|
||||
class SearchType(StrEnum):
|
||||
"""Type of search to perform."""
|
||||
|
||||
GENERAL = "general" # Search all fields (title, author, ISBN, etc.)
|
||||
TITLE = "title" # Search by title only
|
||||
AUTHOR = "author" # Search by author only
|
||||
ISBN = "isbn" # Search by ISBN
|
||||
TITLE = "title" # Search by title only
|
||||
AUTHOR = "author" # Search by author only
|
||||
ISBN = "isbn" # Search by ISBN
|
||||
|
||||
|
||||
class SortOrder(str, Enum):
|
||||
class SortOrder(StrEnum):
|
||||
"""Sort order for search results."""
|
||||
RELEVANCE = "relevance" # Best match first (default)
|
||||
|
||||
RELEVANCE = "relevance" # Best match first (default)
|
||||
POPULARITY = "popularity" # Most popular first
|
||||
RATING = "rating" # Highest rated first
|
||||
NEWEST = "newest" # Most recently published first
|
||||
OLDEST = "oldest" # Oldest published first
|
||||
RATING = "rating" # Highest rated first
|
||||
NEWEST = "newest" # Most recently published first
|
||||
OLDEST = "oldest" # Oldest published first
|
||||
SERIES_ORDER = "series_order" # By series position (requires series field)
|
||||
|
||||
|
||||
# Display labels for sort options
|
||||
SORT_LABELS: Dict[SortOrder, str] = {
|
||||
SORT_LABELS: dict[SortOrder, str] = {
|
||||
SortOrder.RELEVANCE: "Most relevant",
|
||||
SortOrder.POPULARITY: "Most popular",
|
||||
SortOrder.RATING: "Highest rated",
|
||||
@@ -38,40 +44,44 @@ 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
|
||||
field_key: str | None = None
|
||||
sort: SortOrder | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextSearchField:
|
||||
"""Text input search field."""
|
||||
key: str # Field identifier (e.g., "author", "publisher")
|
||||
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
|
||||
|
||||
key: str # Field identifier (e.g., "author", "publisher")
|
||||
label: str # Display label in UI
|
||||
placeholder: str = "" # Placeholder text
|
||||
description: str = "" # Help text
|
||||
suggestions_endpoint: str | None = None # Remote suggestions endpoint for typeahead
|
||||
suggestions_min_query_length: int = 2 # Minimum chars before requesting suggestions
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumberSearchField:
|
||||
"""Numeric input search field."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
placeholder: str = ""
|
||||
description: str = ""
|
||||
min_value: Optional[int] = None
|
||||
max_value: Optional[int] = None
|
||||
min_value: int | None = None
|
||||
max_value: int | None = None
|
||||
step: int = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelectSearchField:
|
||||
"""Single-choice dropdown search field."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
options: List[Dict[str, str]] = field(default_factory=list) # [{value: "", label: ""}]
|
||||
options: list[dict[str, str]] = field(default_factory=list) # [{value: "", label: ""}]
|
||||
placeholder: str = ""
|
||||
description: str = ""
|
||||
|
||||
@@ -79,6 +89,7 @@ class SelectSearchField:
|
||||
@dataclass
|
||||
class CheckboxSearchField:
|
||||
"""Boolean checkbox search field."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
description: str = ""
|
||||
@@ -88,6 +99,7 @@ class CheckboxSearchField:
|
||||
@dataclass
|
||||
class DynamicSelectSearchField:
|
||||
"""Single-choice dropdown field with options loaded from an API endpoint."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
options_endpoint: str
|
||||
@@ -96,18 +108,18 @@ class DynamicSelectSearchField:
|
||||
|
||||
|
||||
# Type alias for all search field types
|
||||
SearchField = Union[
|
||||
TextSearchField,
|
||||
NumberSearchField,
|
||||
SelectSearchField,
|
||||
CheckboxSearchField,
|
||||
DynamicSelectSearchField,
|
||||
]
|
||||
SearchField = (
|
||||
TextSearchField
|
||||
| NumberSearchField
|
||||
| SelectSearchField
|
||||
| CheckboxSearchField
|
||||
| DynamicSelectSearchField
|
||||
)
|
||||
|
||||
|
||||
def serialize_metadata_capability(capability: MetadataCapability) -> Dict[str, Any]:
|
||||
def serialize_metadata_capability(capability: MetadataCapability) -> dict[str, Any]:
|
||||
"""Serialize a provider capability for API responses."""
|
||||
result: Dict[str, Any] = {
|
||||
result: dict[str, Any] = {
|
||||
"key": capability.key,
|
||||
}
|
||||
|
||||
@@ -120,14 +132,14 @@ def serialize_metadata_capability(capability: MetadataCapability) -> Dict[str, A
|
||||
return result
|
||||
|
||||
|
||||
def serialize_search_field(search_field: SearchField) -> Dict[str, Any]:
|
||||
def serialize_search_field(search_field: SearchField) -> dict[str, Any]:
|
||||
"""Serialize a search field to dict for API response."""
|
||||
result: Dict[str, Any] = {
|
||||
result: dict[str, Any] = {
|
||||
"key": search_field.key,
|
||||
"label": search_field.label,
|
||||
"type": search_field.__class__.__name__,
|
||||
"placeholder": getattr(search_field, 'placeholder', ''),
|
||||
"description": getattr(search_field, 'description', ''),
|
||||
"placeholder": getattr(search_field, "placeholder", ""),
|
||||
"description": getattr(search_field, "description", ""),
|
||||
}
|
||||
|
||||
# Add type-specific properties
|
||||
@@ -152,70 +164,73 @@ def serialize_search_field(search_field: SearchField) -> Dict[str, Any]:
|
||||
@dataclass
|
||||
class MetadataSearchOptions:
|
||||
"""Options for metadata search queries across all providers."""
|
||||
|
||||
query: str
|
||||
search_type: SearchType = SearchType.GENERAL
|
||||
language: Optional[str] = None # ISO 639-1 code (e.g., "en", "fr")
|
||||
language: str | None = None # ISO 639-1 code (e.g., "en", "fr")
|
||||
sort: SortOrder = SortOrder.RELEVANCE
|
||||
limit: int = 40
|
||||
page: int = 1
|
||||
fields: Dict[str, Any] = field(default_factory=dict) # Custom search field values
|
||||
fields: dict[str, Any] = field(default_factory=dict) # Custom search field values
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisplayField:
|
||||
"""A display field for metadata cards (ratings, page counts, etc.)."""
|
||||
label: str # e.g., "Rating", "Pages", "Readers"
|
||||
value: str # e.g., "4.5", "496", "8,041"
|
||||
icon: Optional[str] = None # Icon name: "star", "book", "users", "editions"
|
||||
|
||||
label: str # e.g., "Rating", "Pages", "Readers"
|
||||
value: str # e.g., "4.5", "496", "8,041"
|
||||
icon: str | None = None # Icon name: "star", "book", "users", "editions"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookMetadata:
|
||||
"""Book from metadata provider (not a specific release)."""
|
||||
provider: str # Which provider this came from (internal name)
|
||||
provider_id: str # ID in that provider's system
|
||||
|
||||
provider: str # Which provider this came from (internal name)
|
||||
provider_id: str # ID in that provider's system
|
||||
title: str
|
||||
|
||||
# Provider display name for UI (e.g., "Open Library" instead of "openlibrary")
|
||||
provider_display_name: Optional[str] = None
|
||||
provider_display_name: str | None = None
|
||||
|
||||
# Optional - not all providers have all fields
|
||||
authors: List[str] = field(default_factory=list)
|
||||
isbn_10: Optional[str] = None
|
||||
isbn_13: Optional[str] = None
|
||||
cover_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
publisher: Optional[str] = None
|
||||
publish_year: Optional[int] = None
|
||||
language: Optional[str] = None
|
||||
genres: List[str] = field(default_factory=list)
|
||||
source_url: Optional[str] = None # Link to book on provider's site
|
||||
subtitle: Optional[str] = None # Book subtitle, if any
|
||||
search_title: Optional[str] = None # Cleaner title for search queries (provider-specific)
|
||||
search_author: Optional[str] = None # Cleaner author for search queries (provider-specific)
|
||||
authors: list[str] = field(default_factory=list)
|
||||
isbn_10: str | None = None
|
||||
isbn_13: str | None = None
|
||||
cover_url: str | None = None
|
||||
description: str | None = None
|
||||
publisher: str | None = None
|
||||
publish_year: int | None = None
|
||||
language: str | None = None
|
||||
genres: list[str] = field(default_factory=list)
|
||||
source_url: str | None = None # Link to book on provider's site
|
||||
subtitle: str | None = None # Book subtitle, if any
|
||||
search_title: str | None = None # Cleaner title for search queries (provider-specific)
|
||||
search_author: str | None = None # Cleaner author for search queries (provider-specific)
|
||||
|
||||
# Cover aspect ratio hint for the frontend ("portrait" or "square")
|
||||
cover_aspect: Optional[str] = None
|
||||
cover_aspect: str | None = None
|
||||
|
||||
# Provider-specific display fields for cards/lists
|
||||
display_fields: List[DisplayField] = field(default_factory=list)
|
||||
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
|
||||
series_id: str | None = None # Provider-specific series ID
|
||||
series_name: str | None = None # Name of the series
|
||||
series_position: float | None = None # This book's position (e.g., 3, 1.5 for novellas)
|
||||
series_count: int | None = None # Total books in the series
|
||||
|
||||
# Alternative titles by language (for localized searches)
|
||||
# Maps language code (e.g., "de", "German") to localized title
|
||||
titles_by_language: Dict[str, str] = field(default_factory=dict)
|
||||
titles_by_language: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def group_languages_by_localized_title(
|
||||
base_title: str,
|
||||
languages: Optional[List[str]],
|
||||
titles_by_language: Optional[Dict[str, str]] = None,
|
||||
) -> List[tuple[str, Optional[List[str]]]]:
|
||||
languages: list[str] | None,
|
||||
titles_by_language: dict[str, str] | None = None,
|
||||
) -> list[tuple[str, list[str] | None]]:
|
||||
"""Group language codes by localized title.
|
||||
|
||||
Release sources that support language filtering (e.g., Anna's Archive)
|
||||
@@ -230,6 +245,7 @@ def group_languages_by_localized_title(
|
||||
Returns:
|
||||
List of (title, languages) tuples. If languages is None/empty, returns
|
||||
[(base_title, None)].
|
||||
|
||||
"""
|
||||
if not base_title:
|
||||
return []
|
||||
@@ -244,7 +260,7 @@ def group_languages_by_localized_title(
|
||||
if not titles_by_language:
|
||||
return [(base_title, normalized_langs)]
|
||||
|
||||
title_to_langs: Dict[str, List[str]] = {}
|
||||
title_to_langs: dict[str, list[str]] = {}
|
||||
for lang in normalized_langs:
|
||||
localized_title = titles_by_language.get(lang) or base_title
|
||||
title_to_langs.setdefault(localized_title, []).append(lang)
|
||||
@@ -254,10 +270,10 @@ def group_languages_by_localized_title(
|
||||
|
||||
def build_localized_search_titles(
|
||||
base_title: str,
|
||||
languages: Optional[List[str]],
|
||||
titles_by_language: Optional[Dict[str, str]] = None,
|
||||
excluded_languages: Optional[set[str]] = None,
|
||||
) -> List[str]:
|
||||
languages: list[str] | None,
|
||||
titles_by_language: dict[str, str] | None = None,
|
||||
excluded_languages: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Build a list of titles to search for, including localized editions.
|
||||
|
||||
This is useful for release sources that *can't* pass language filters to
|
||||
@@ -274,11 +290,12 @@ def build_localized_search_titles(
|
||||
|
||||
Returns:
|
||||
List of unique titles to search for, in priority order.
|
||||
|
||||
"""
|
||||
if not base_title:
|
||||
return []
|
||||
|
||||
titles: List[str] = [base_title]
|
||||
titles: list[str] = [base_title]
|
||||
seen = {base_title}
|
||||
|
||||
if not languages or not titles_by_language:
|
||||
@@ -309,12 +326,13 @@ def build_localized_search_titles(
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
"""Result from a metadata search with pagination info."""
|
||||
books: List[BookMetadata]
|
||||
|
||||
books: list[BookMetadata]
|
||||
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)
|
||||
source_url: str | None = None # External URL for the result set (e.g. Hardcover list page)
|
||||
source_title: str | None = None # Display title for the result set (e.g. list name)
|
||||
|
||||
|
||||
class MetadataProvider(ABC):
|
||||
@@ -330,33 +348,31 @@ class MetadataProvider(ABC):
|
||||
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] = []
|
||||
supported_sorts: ClassVar[tuple[SortOrder, ...]] = (SortOrder.RELEVANCE,)
|
||||
search_fields: ClassVar[tuple[SearchField, ...]] = ()
|
||||
capabilities: ClassVar[tuple[MetadataCapability, ...]] = ()
|
||||
|
||||
@abstractmethod
|
||||
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
|
||||
def search(self, options: MetadataSearchOptions) -> list[BookMetadata]:
|
||||
"""Search for books using the provided options."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_book(self, book_id: str) -> Optional[BookMetadata]:
|
||||
def get_book(self, book_id: str) -> BookMetadata | None:
|
||||
"""Get a specific book by provider ID."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]:
|
||||
def search_by_isbn(self, isbn: str) -> BookMetadata | None:
|
||||
"""Search for a book by ISBN."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""Check if this provider is configured and available."""
|
||||
pass
|
||||
|
||||
def search_paginated(self, options: MetadataSearchOptions) -> SearchResult:
|
||||
"""Search with pagination info. Override for accurate pagination."""
|
||||
@@ -367,62 +383,71 @@ class MetadataProvider(ABC):
|
||||
books=books,
|
||||
page=options.page,
|
||||
total_found=0, # Unknown without provider-specific implementation
|
||||
has_more=has_more
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
def get_search_field_options(
|
||||
self,
|
||||
field_key: str,
|
||||
query: Optional[str] = None,
|
||||
) -> List[Dict[str, str]]:
|
||||
query: str | None = 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]]:
|
||||
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")
|
||||
msg = f"{self.display_name} does not support book targets"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
def get_book_targets_batch(self, book_ids: List[str]) -> Dict[str, List[Dict[str, Any]]]:
|
||||
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
|
||||
return {book_id: self._get_book_targets_for_batch(book_id) for book_id in book_ids}
|
||||
|
||||
def _get_book_targets_for_batch(self, book_id: str) -> list[dict[str, Any]]:
|
||||
"""Safely fetch targets for one book, falling back to an empty list."""
|
||||
try:
|
||||
return self.get_book_targets(book_id)
|
||||
except NotImplementedError, ValueError:
|
||||
return []
|
||||
|
||||
def set_book_target_state(
|
||||
self,
|
||||
book_id: str,
|
||||
target: str,
|
||||
selected: bool,
|
||||
) -> Dict[str, Any]:
|
||||
) -> 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")
|
||||
msg = f"{self.display_name} does not support book targets"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
|
||||
# Provider registry
|
||||
_PROVIDERS: Dict[str, Type[MetadataProvider]] = {}
|
||||
_PROVIDER_KWARGS_FACTORIES: Dict[str, Any] = {} # Callable[[], Dict]
|
||||
_PROVIDERS: dict[str, type[MetadataProvider]] = {}
|
||||
_PROVIDER_KWARGS_FACTORIES: dict[str, Any] = {} # Callable[[], Dict]
|
||||
|
||||
|
||||
def register_provider(name: str):
|
||||
def register_provider(
|
||||
name: str,
|
||||
) -> Callable[[type[MetadataProvider]], type[MetadataProvider]]:
|
||||
"""Decorator to register a metadata provider."""
|
||||
def decorator(cls):
|
||||
|
||||
def decorator(cls: type[MetadataProvider]) -> type[MetadataProvider]:
|
||||
_PROVIDERS[name] = cls
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_provider_kwargs(name: str):
|
||||
def register_provider_kwargs(
|
||||
name: str,
|
||||
) -> Callable[[Callable[[], dict[str, Any]]], Callable[[], dict[str, Any]]]:
|
||||
"""Decorator to register a provider's kwargs factory.
|
||||
|
||||
The decorated function should return a Dict of kwargs to pass to the
|
||||
@@ -434,21 +459,25 @@ def register_provider_kwargs(name: str):
|
||||
def _hardcover_kwargs() -> Dict:
|
||||
from shelfmark.core.config import config
|
||||
return {"api_key": config.get("HARDCOVER_API_KEY", "")}
|
||||
|
||||
"""
|
||||
def decorator(fn):
|
||||
|
||||
def decorator(fn: Callable[[], dict[str, Any]]) -> Callable[[], dict[str, Any]]:
|
||||
_PROVIDER_KWARGS_FACTORIES[name] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_provider(name: str, **kwargs) -> MetadataProvider:
|
||||
"""Factory - instantiate any registered provider."""
|
||||
if name not in _PROVIDERS:
|
||||
raise ValueError(f"Unknown metadata provider: {name}")
|
||||
msg = f"Unknown metadata provider: {name}"
|
||||
raise ValueError(msg)
|
||||
return _PROVIDERS[name](**kwargs)
|
||||
|
||||
|
||||
def list_providers() -> List[dict]:
|
||||
def list_providers() -> list[dict]:
|
||||
"""For settings UI - list available providers with their requirements."""
|
||||
return [
|
||||
{"name": n, "display_name": c.display_name, "requires_auth": c.requires_auth}
|
||||
@@ -456,7 +485,7 @@ def list_providers() -> List[dict]:
|
||||
]
|
||||
|
||||
|
||||
def get_provider_kwargs(provider_name: str) -> Dict:
|
||||
def get_provider_kwargs(provider_name: str) -> dict:
|
||||
"""Get provider-specific initialization kwargs from registered factory."""
|
||||
factory = _PROVIDER_KWARGS_FACTORIES.get(provider_name)
|
||||
if factory:
|
||||
@@ -481,15 +510,15 @@ def is_provider_enabled(provider_name: str) -> bool:
|
||||
return app_config.get(enabled_key, False) is True
|
||||
|
||||
|
||||
def get_enabled_providers() -> List[str]:
|
||||
def get_enabled_providers() -> list[str]:
|
||||
"""Get list of all enabled provider names."""
|
||||
return [name for name in _PROVIDERS if is_provider_enabled(name)]
|
||||
|
||||
|
||||
def get_configured_provider(
|
||||
content_type: str = "ebook",
|
||||
user_id: Optional[int] = None,
|
||||
) -> Optional[MetadataProvider]:
|
||||
user_id: int | None = None,
|
||||
) -> MetadataProvider | None:
|
||||
"""Get the currently configured metadata provider for the content type."""
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
@@ -520,7 +549,8 @@ def get_configured_provider(
|
||||
|
||||
def get_configured_provider_name(
|
||||
content_type: str = "ebook",
|
||||
user_id: Optional[int] = None,
|
||||
user_id: int | None = None,
|
||||
*,
|
||||
fallback_to_main: bool = True,
|
||||
) -> str:
|
||||
"""Get the configured metadata provider name for a content type."""
|
||||
@@ -550,16 +580,16 @@ def get_configured_provider_name(
|
||||
|
||||
|
||||
def get_provider_sort_options(
|
||||
provider_name: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> List[Dict[str, str]]:
|
||||
provider_name: str | None = None,
|
||||
user_id: int | None = 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(user_id=user_id)
|
||||
|
||||
if provider_name and provider_name in _PROVIDERS:
|
||||
provider_class = _PROVIDERS[provider_name]
|
||||
supported = getattr(provider_class, 'supported_sorts', [SortOrder.RELEVANCE])
|
||||
supported = getattr(provider_class, "supported_sorts", [SortOrder.RELEVANCE])
|
||||
else:
|
||||
supported = [SortOrder.RELEVANCE]
|
||||
|
||||
@@ -570,16 +600,16 @@ def get_provider_sort_options(
|
||||
|
||||
|
||||
def get_provider_search_fields(
|
||||
provider_name: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
provider_name: str | None = None,
|
||||
user_id: int | None = 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(user_id=user_id)
|
||||
|
||||
if provider_name and provider_name in _PROVIDERS:
|
||||
provider_class = _PROVIDERS[provider_name]
|
||||
fields = getattr(provider_class, 'search_fields', [])
|
||||
fields = getattr(provider_class, "search_fields", [])
|
||||
else:
|
||||
fields = []
|
||||
|
||||
@@ -587,9 +617,9 @@ def get_provider_search_fields(
|
||||
|
||||
|
||||
def get_provider_capabilities(
|
||||
provider_name: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
provider_name: str | None = None,
|
||||
user_id: int | None = 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)
|
||||
@@ -604,8 +634,8 @@ def get_provider_capabilities(
|
||||
|
||||
|
||||
def get_provider_default_sort(
|
||||
provider_name: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
provider_name: str | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> str:
|
||||
"""Get the default sort order for a metadata provider."""
|
||||
from shelfmark.core.config import config as app_config
|
||||
@@ -629,7 +659,7 @@ def sync_metadata_provider_selection() -> None:
|
||||
enabling/disabling a provider.
|
||||
"""
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.settings_registry import save_config_file, load_config_file
|
||||
from shelfmark.core.settings_registry import load_config_file, save_config_file
|
||||
|
||||
app_config.refresh()
|
||||
|
||||
@@ -653,18 +683,11 @@ def sync_metadata_provider_selection() -> None:
|
||||
|
||||
# Import provider implementations to trigger registration
|
||||
# These must be imported AFTER the base classes and registry are defined
|
||||
try:
|
||||
from shelfmark.metadata_providers import hardcover # noqa: F401, E402
|
||||
except ImportError:
|
||||
pass # Hardcover provider is optional
|
||||
with suppress(ImportError):
|
||||
from shelfmark.metadata_providers import hardcover as hardcover
|
||||
|
||||
try:
|
||||
from shelfmark.metadata_providers import openlibrary # noqa: F401, E402
|
||||
except ImportError:
|
||||
pass # Open Library provider is optional
|
||||
|
||||
try:
|
||||
from shelfmark.metadata_providers import googlebooks # noqa: F401, E402
|
||||
except ImportError:
|
||||
pass # Google Books provider is optional
|
||||
with suppress(ImportError):
|
||||
from shelfmark.metadata_providers import openlibrary as openlibrary
|
||||
|
||||
with suppress(ImportError):
|
||||
from shelfmark.metadata_providers import googlebooks as googlebooks
|
||||
|
||||
@@ -6,20 +6,24 @@ Requires a free API key from Google Cloud Console (~1000 requests/day quota).
|
||||
API Documentation: https://developers.google.com/books/docs/v1/using
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
from http import HTTPStatus
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import requests
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from shelfmark.core.cache import cacheable
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.settings_registry import (
|
||||
register_settings,
|
||||
ActionButton,
|
||||
CheckboxField,
|
||||
HeadingField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
SettingsField,
|
||||
register_settings,
|
||||
)
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
@@ -28,18 +32,21 @@ from shelfmark.metadata_providers import (
|
||||
MetadataSearchOptions,
|
||||
SearchType,
|
||||
SortOrder,
|
||||
TextSearchField,
|
||||
register_provider,
|
||||
register_provider_kwargs,
|
||||
TextSearchField,
|
||||
)
|
||||
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
|
||||
_HTTP_STATUS_BAD_REQUEST = HTTPStatus.BAD_REQUEST
|
||||
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
|
||||
GOOGLE_BOOKS_BASE_URL = "https://www.googleapis.com/books/v1"
|
||||
|
||||
# Sort mapping - Google only supports "relevance" and "newest"
|
||||
SORT_MAPPING: Dict[SortOrder, Optional[str]] = {
|
||||
SORT_MAPPING: dict[SortOrder, str | None] = {
|
||||
SortOrder.RELEVANCE: None, # Default, no param needed
|
||||
SortOrder.NEWEST: "newest",
|
||||
# POPULARITY, RATING, OLDEST not supported - fall back to relevance
|
||||
@@ -47,7 +54,7 @@ SORT_MAPPING: Dict[SortOrder, Optional[str]] = {
|
||||
|
||||
|
||||
@register_provider_kwargs("googlebooks")
|
||||
def _googlebooks_kwargs() -> Dict[str, Any]:
|
||||
def _googlebooks_kwargs() -> dict[str, Any]:
|
||||
"""Provide Google Books-specific constructor kwargs."""
|
||||
return {"api_key": app_config.get("GOOGLEBOOKS_API_KEY", "")}
|
||||
|
||||
@@ -59,8 +66,11 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
name = "googlebooks"
|
||||
display_name = "Google Books"
|
||||
requires_auth = True
|
||||
supported_sorts = [SortOrder.RELEVANCE, SortOrder.NEWEST]
|
||||
search_fields = [
|
||||
supported_sorts: ClassVar[tuple[SortOrder, ...]] = (
|
||||
SortOrder.RELEVANCE,
|
||||
SortOrder.NEWEST,
|
||||
)
|
||||
search_fields: ClassVar[tuple[TextSearchField, ...]] = (
|
||||
TextSearchField(
|
||||
key="author",
|
||||
label="Author",
|
||||
@@ -71,9 +81,9 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
label="Title",
|
||||
description="Search by book title",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
def __init__(self, api_key: str | None = None) -> None:
|
||||
"""Initialize provider with optional API key (falls back to config)."""
|
||||
self.api_key = api_key or app_config.get("GOOGLEBOOKS_API_KEY", "")
|
||||
self.session = requests.Session()
|
||||
@@ -82,7 +92,7 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
"""Check if provider is configured with an API key."""
|
||||
return bool(self.api_key)
|
||||
|
||||
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
|
||||
def search(self, options: MetadataSearchOptions) -> list[BookMetadata]:
|
||||
"""Search for books using Google Books API."""
|
||||
if not self.api_key:
|
||||
logger.warning("Google Books API key not configured")
|
||||
@@ -106,9 +116,7 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
ttl_default=300,
|
||||
key_prefix="googlebooks:search",
|
||||
)
|
||||
def _search_cached(
|
||||
self, cache_key: str, options: MetadataSearchOptions
|
||||
) -> List[BookMetadata]:
|
||||
def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> list[BookMetadata]:
|
||||
"""Cached search implementation."""
|
||||
# Build query string with Google Books operators
|
||||
author_value = options.fields.get("author", "").strip()
|
||||
@@ -134,7 +142,7 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
query = "+".join(query_parts)
|
||||
|
||||
# Build request params
|
||||
params: Dict[str, Any] = {
|
||||
params: dict[str, Any] = {
|
||||
"q": query,
|
||||
"maxResults": min(options.limit, 40), # Google max is 40
|
||||
"startIndex": (options.page - 1) * options.limit,
|
||||
@@ -150,32 +158,30 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
if options.language:
|
||||
params["langRestrict"] = options.language
|
||||
|
||||
books: list[BookMetadata] = []
|
||||
try:
|
||||
result = self._make_request("/volumes", params)
|
||||
if not result:
|
||||
return []
|
||||
if result:
|
||||
items = result.get("items", [])
|
||||
|
||||
items = result.get("items", [])
|
||||
books = []
|
||||
for item in items:
|
||||
book = self._parse_volume(item)
|
||||
if book:
|
||||
books.append(book)
|
||||
|
||||
for item in items:
|
||||
book = self._parse_volume(item)
|
||||
if book:
|
||||
books.append(book)
|
||||
logger.info("Google Books search '%s' returned %s results", query, len(books))
|
||||
|
||||
logger.info(f"Google Books search '{query}' returned {len(books)} results")
|
||||
return books
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Google Books search error: {e}")
|
||||
except Exception:
|
||||
logger.exception("Google Books search error")
|
||||
return []
|
||||
return books
|
||||
|
||||
@cacheable(
|
||||
ttl_key="METADATA_CACHE_BOOK_TTL",
|
||||
ttl_default=600,
|
||||
key_prefix="googlebooks:book",
|
||||
)
|
||||
def get_book(self, book_id: str) -> Optional[BookMetadata]:
|
||||
def get_book(self, book_id: str) -> BookMetadata | None:
|
||||
"""Get book details by Google Books volume ID."""
|
||||
try:
|
||||
result = self._make_request(f"/volumes/{book_id}", {})
|
||||
@@ -184,8 +190,8 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
|
||||
return self._parse_volume(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Google Books get_book error: {e}")
|
||||
except Exception:
|
||||
logger.exception("Google Books get_book error")
|
||||
return None
|
||||
|
||||
@cacheable(
|
||||
@@ -193,13 +199,13 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
ttl_default=600,
|
||||
key_prefix="googlebooks:isbn",
|
||||
)
|
||||
def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]:
|
||||
def search_by_isbn(self, isbn: str) -> BookMetadata | None:
|
||||
"""Search for a book by ISBN-10 or ISBN-13."""
|
||||
# Clean ISBN (remove hyphens and spaces)
|
||||
clean_isbn = isbn.replace("-", "").replace(" ", "").strip()
|
||||
|
||||
# Use ISBN operator for precise lookup
|
||||
params: Dict[str, Any] = {
|
||||
params: dict[str, Any] = {
|
||||
"q": f"isbn:{clean_isbn}",
|
||||
"maxResults": 1,
|
||||
}
|
||||
@@ -211,18 +217,16 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
|
||||
items = result.get("items", [])
|
||||
if not items:
|
||||
logger.debug(f"No Google Books result for ISBN: {isbn}")
|
||||
logger.debug("No Google Books result for ISBN: %s", isbn)
|
||||
return None
|
||||
|
||||
return self._parse_volume(items[0])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Google Books ISBN search error: {e}")
|
||||
except Exception:
|
||||
logger.exception("Google Books ISBN search error")
|
||||
return None
|
||||
|
||||
def _make_request(
|
||||
self, endpoint: str, params: Dict[str, Any]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
def _make_request(self, endpoint: str, params: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Make authenticated API request to endpoint."""
|
||||
if not self.api_key:
|
||||
logger.warning("Google Books API key not configured")
|
||||
@@ -243,25 +247,25 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
return None
|
||||
except requests.HTTPError as e:
|
||||
if e.response is not None:
|
||||
if e.response.status_code == 403:
|
||||
if e.response.status_code == _HTTP_STATUS_FORBIDDEN:
|
||||
# Quota exceeded or invalid API key
|
||||
logger.error(
|
||||
logger.exception(
|
||||
"Google Books API: quota exceeded or invalid API key (HTTP 403)"
|
||||
)
|
||||
elif e.response.status_code == 400:
|
||||
logger.warning(f"Google Books API: bad request - {e}")
|
||||
elif e.response.status_code == 404:
|
||||
elif e.response.status_code == _HTTP_STATUS_BAD_REQUEST:
|
||||
logger.warning("Google Books API: bad request - %s", e)
|
||||
elif e.response.status_code == _HTTP_STATUS_NOT_FOUND:
|
||||
logger.debug("Google Books: volume not found")
|
||||
else:
|
||||
logger.error(f"Google Books API HTTP error: {e}")
|
||||
logger.exception("Google Books API HTTP error")
|
||||
else:
|
||||
logger.error(f"Google Books API HTTP error: {e}")
|
||||
logger.exception("Google Books API HTTP error")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Google Books API request failed: {e}")
|
||||
except Exception:
|
||||
logger.exception("Google Books API request failed")
|
||||
return None
|
||||
|
||||
def _parse_volume(self, volume: Dict[str, Any]) -> Optional[BookMetadata]:
|
||||
def _parse_volume(self, volume: dict[str, Any]) -> BookMetadata | None:
|
||||
"""Parse a volume object into BookMetadata."""
|
||||
try:
|
||||
volume_id = volume.get("id")
|
||||
@@ -296,9 +300,7 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
)
|
||||
# Remove edge=curl parameter and upgrade to https
|
||||
if cover_url:
|
||||
cover_url = cover_url.replace("&edge=curl", "").replace(
|
||||
"http://", "https://"
|
||||
)
|
||||
cover_url = cover_url.replace("&edge=curl", "").replace("http://", "https://")
|
||||
|
||||
# Publisher
|
||||
publisher = volume_info.get("publisher")
|
||||
@@ -307,10 +309,8 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
publish_year = None
|
||||
published_date = volume_info.get("publishedDate", "")
|
||||
if published_date:
|
||||
try:
|
||||
with suppress(ValueError, TypeError):
|
||||
publish_year = int(published_date[:4])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Language
|
||||
language = volume_info.get("language")
|
||||
@@ -325,7 +325,7 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
source_url = volume_info.get("infoLink")
|
||||
|
||||
# Build display fields - rating only
|
||||
display_fields: List[DisplayField] = []
|
||||
display_fields: list[DisplayField] = []
|
||||
|
||||
average_rating = volume_info.get("averageRating")
|
||||
ratings_count = volume_info.get("ratingsCount")
|
||||
@@ -333,9 +333,7 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
rating_str = f"{average_rating:.1f}"
|
||||
if ratings_count:
|
||||
rating_str += f" ({ratings_count:,})"
|
||||
display_fields.append(
|
||||
DisplayField(label="Rating", value=rating_str, icon="star")
|
||||
)
|
||||
display_fields.append(DisplayField(label="Rating", value=rating_str, icon="star"))
|
||||
|
||||
return BookMetadata(
|
||||
provider="googlebooks",
|
||||
@@ -356,11 +354,13 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse Google Books volume: {e}")
|
||||
logger.debug("Failed to parse Google Books volume: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _test_googlebooks_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
def _test_googlebooks_connection(
|
||||
current_values: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Test the Google Books API connection using current form values."""
|
||||
current_values = current_values or {}
|
||||
|
||||
@@ -377,25 +377,25 @@ def _test_googlebooks_connection(current_values: Dict[str, Any] = None) -> Dict[
|
||||
provider = GoogleBooksProvider(api_key=api_key)
|
||||
# Simple test search
|
||||
result = provider._make_request("/volumes", {"q": "test", "maxResults": 1})
|
||||
test_result = {
|
||||
"success": False,
|
||||
"message": "API request failed - check your API key",
|
||||
}
|
||||
|
||||
if result is not None and "items" in result:
|
||||
return {
|
||||
test_result = {
|
||||
"success": True,
|
||||
"message": "Successfully connected to Google Books API",
|
||||
}
|
||||
elif result is not None:
|
||||
return {
|
||||
test_result = {
|
||||
"success": True,
|
||||
"message": "API connected but returned no results for test query",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "API request failed - check your API key",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("Google Books connection test failed")
|
||||
return {"success": False, "message": f"Connection failed: {str(e)}"}
|
||||
return {"success": False, "message": f"Connection failed: {e!s}"}
|
||||
return test_result
|
||||
|
||||
|
||||
# Sort options for settings UI
|
||||
@@ -405,10 +405,8 @@ _GOOGLEBOOKS_SORT_OPTIONS = [
|
||||
]
|
||||
|
||||
|
||||
@register_settings(
|
||||
"googlebooks", "Google Books", icon="book", order=53, group="metadata_providers"
|
||||
)
|
||||
def googlebooks_settings():
|
||||
@register_settings("googlebooks", "Google Books", icon="book", order=53, group="metadata_providers")
|
||||
def googlebooks_settings() -> list[SettingsField]:
|
||||
"""Google Books metadata provider settings."""
|
||||
return [
|
||||
HeadingField(
|
||||
@@ -431,8 +429,7 @@ def googlebooks_settings():
|
||||
key="GOOGLEBOOKS_API_KEY",
|
||||
label="API Key",
|
||||
description=(
|
||||
"Get your API key from Google Cloud Console "
|
||||
"(APIs & Services > Credentials)"
|
||||
"Get your API key from Google Cloud Console (APIs & Services > Credentials)"
|
||||
),
|
||||
required=True,
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,24 @@
|
||||
"""Open Library metadata provider. No API key required, rate limited."""
|
||||
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any, Deque, Dict, List, Optional
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.cache import cacheable
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.core.settings_registry import (
|
||||
register_settings,
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
ActionButton,
|
||||
CheckboxField,
|
||||
HeadingField,
|
||||
SelectField,
|
||||
SettingsField,
|
||||
register_settings,
|
||||
)
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
DisplayField,
|
||||
@@ -25,8 +26,8 @@ from shelfmark.metadata_providers import (
|
||||
MetadataSearchOptions,
|
||||
SearchType,
|
||||
SortOrder,
|
||||
register_provider,
|
||||
TextSearchField,
|
||||
register_provider,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -43,11 +44,11 @@ RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
class RateLimiter:
|
||||
"""Simple sliding window rate limiter."""
|
||||
|
||||
def __init__(self, max_requests: int, window_seconds: int):
|
||||
def __init__(self, max_requests: int, window_seconds: int) -> None:
|
||||
"""Initialize rate limiter with max requests per time window."""
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self.timestamps: Deque[float] = deque()
|
||||
self.timestamps: deque[float] = deque()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def wait_if_needed(self) -> None:
|
||||
@@ -69,7 +70,7 @@ class RateLimiter:
|
||||
|
||||
# Sleep outside the lock to avoid blocking other threads
|
||||
if wait_time > 0:
|
||||
logger.debug(f"Rate limited, waiting {wait_time:.2f}s")
|
||||
logger.debug("Rate limited, waiting %0.2fs", wait_time)
|
||||
time.sleep(wait_time)
|
||||
|
||||
# Re-acquire lock and record request
|
||||
@@ -90,7 +91,7 @@ _rate_limiter = RateLimiter(RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS)
|
||||
|
||||
# Mapping from abstract sort order to Open Library sort parameter
|
||||
# Note: Open Library only supports relevance (default), new, old, random
|
||||
SORT_MAPPING: Dict[str, Optional[str]] = {
|
||||
SORT_MAPPING: dict[str, str | None] = {
|
||||
SortOrder.RELEVANCE: None, # Default (no sort param)
|
||||
SortOrder.NEWEST: "new",
|
||||
SortOrder.OLDEST: "old",
|
||||
@@ -105,12 +106,12 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
name = "openlibrary"
|
||||
display_name = "Open Library"
|
||||
requires_auth = False
|
||||
supported_sorts = [
|
||||
supported_sorts: ClassVar[tuple[SortOrder, ...]] = (
|
||||
SortOrder.RELEVANCE,
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.OLDEST,
|
||||
]
|
||||
search_fields = [
|
||||
)
|
||||
search_fields: ClassVar[tuple[TextSearchField, ...]] = (
|
||||
TextSearchField(
|
||||
key="author",
|
||||
label="Author",
|
||||
@@ -121,9 +122,9 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
label="Title",
|
||||
description="Search by book title",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize provider."""
|
||||
self.session = requests.Session()
|
||||
|
||||
@@ -131,7 +132,7 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
"""Open Library is always available (no auth required)."""
|
||||
return True
|
||||
|
||||
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
|
||||
def search(self, options: MetadataSearchOptions) -> list[BookMetadata]:
|
||||
"""Search for books using Open Library's search API."""
|
||||
# Handle ISBN search separately
|
||||
if options.search_type == SearchType.ISBN:
|
||||
@@ -143,13 +144,15 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
cache_key = f"{options.query}:{options.search_type.value}:{options.sort.value}:{options.language}:{options.limit}:{options.page}:{fields_key}"
|
||||
return self._search_cached(cache_key, options)
|
||||
|
||||
@cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="openlibrary:search")
|
||||
def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> List[BookMetadata]:
|
||||
@cacheable(
|
||||
ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="openlibrary:search"
|
||||
)
|
||||
def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> list[BookMetadata]:
|
||||
"""Cached search implementation."""
|
||||
_rate_limiter.wait_if_needed()
|
||||
|
||||
# Build query params
|
||||
params: Dict[str, Any] = {
|
||||
params: dict[str, Any] = {
|
||||
"limit": options.limit,
|
||||
"page": options.page,
|
||||
"fields": "key,title,author_name,first_publish_year,cover_i,isbn,publisher,language,subject,ratings_average,ratings_count",
|
||||
@@ -185,6 +188,7 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
if options.language:
|
||||
params["lang"] = options.language
|
||||
|
||||
books: list[BookMetadata] = []
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/search.json",
|
||||
@@ -195,14 +199,12 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
books = []
|
||||
for doc in data.get("docs", []):
|
||||
book = self._parse_search_doc(doc)
|
||||
if book:
|
||||
books.append(book)
|
||||
|
||||
logger.info(f"Open Library search '{options.query}' returned {len(books)} results")
|
||||
return books
|
||||
logger.info("Open Library search '%s' returned %s results", options.query, len(books))
|
||||
|
||||
except requests.Timeout:
|
||||
logger.warning("Open Library search timed out")
|
||||
@@ -211,14 +213,18 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
if e.response.status_code == 503:
|
||||
logger.warning("Open Library service unavailable (503)")
|
||||
else:
|
||||
logger.error(f"Open Library HTTP error: {e}")
|
||||
logger.exception("Open Library HTTP error")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Open Library search error: {e}")
|
||||
except requests.RequestException:
|
||||
logger.exception("Open Library search request failed")
|
||||
return []
|
||||
except TypeError, ValueError:
|
||||
logger.exception("Open Library search parsing error")
|
||||
return []
|
||||
return books
|
||||
|
||||
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="openlibrary:book")
|
||||
def get_book(self, book_id: str) -> Optional[BookMetadata]:
|
||||
def get_book(self, book_id: str) -> BookMetadata | None:
|
||||
"""Get book details by Open Library work ID (e.g., 'OL12345W')."""
|
||||
_rate_limiter.wait_if_needed()
|
||||
|
||||
@@ -244,16 +250,19 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
return None
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Open Library work not found: {book_id}")
|
||||
logger.debug("Open Library work not found: %s", book_id)
|
||||
else:
|
||||
logger.error(f"Open Library HTTP error: {e}")
|
||||
logger.exception("Open Library HTTP error")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Open Library get_book error: {e}")
|
||||
except requests.RequestException:
|
||||
logger.exception("Open Library get_book request failed")
|
||||
return None
|
||||
except TypeError, ValueError:
|
||||
logger.exception("Open Library get_book parsing error")
|
||||
return None
|
||||
|
||||
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="openlibrary:isbn")
|
||||
def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]:
|
||||
def search_by_isbn(self, isbn: str) -> BookMetadata | None:
|
||||
"""Search for a book by ISBN-10 or ISBN-13."""
|
||||
# Clean ISBN
|
||||
clean_isbn = isbn.replace("-", "").strip()
|
||||
@@ -283,6 +292,7 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
# Update with ISBN from edition if not present
|
||||
# Use dataclasses.replace() to avoid mutating cached object
|
||||
from dataclasses import replace
|
||||
|
||||
updates = {}
|
||||
if not book.isbn_10:
|
||||
isbn_10_list = edition.get("isbn_10", [])
|
||||
@@ -301,15 +311,18 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Open Library ISBN not found: {isbn}")
|
||||
logger.debug("Open Library ISBN not found: %s", isbn)
|
||||
else:
|
||||
logger.error(f"Open Library ISBN search HTTP error: {e}")
|
||||
logger.exception("Open Library ISBN search HTTP error")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Open Library ISBN search error: {e}")
|
||||
except requests.RequestException:
|
||||
logger.exception("Open Library ISBN search request failed")
|
||||
return None
|
||||
except TypeError, ValueError:
|
||||
logger.exception("Open Library ISBN search parsing error")
|
||||
return None
|
||||
|
||||
def _parse_search_doc(self, doc: dict) -> Optional[BookMetadata]:
|
||||
def _parse_search_doc(self, doc: dict) -> BookMetadata | None:
|
||||
"""Parse a search document into BookMetadata."""
|
||||
try:
|
||||
# Extract work ID from key
|
||||
@@ -374,11 +387,11 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
display_fields=display_fields,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse Open Library search doc: {e}")
|
||||
except (TypeError, ValueError, AttributeError, KeyError) as e:
|
||||
logger.debug("Failed to parse Open Library search doc: %s", e)
|
||||
return None
|
||||
|
||||
def _parse_work(self, work: dict, work_id: str) -> Optional[BookMetadata]:
|
||||
def _parse_work(self, work: dict, work_id: str) -> BookMetadata | None:
|
||||
"""Parse a work object into BookMetadata."""
|
||||
try:
|
||||
title = work.get("title")
|
||||
@@ -424,11 +437,11 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
source_url=f"{OPENLIBRARY_BASE_URL}/works/{work_id}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse Open Library work: {e}")
|
||||
except (TypeError, ValueError, AttributeError, KeyError) as e:
|
||||
logger.debug("Failed to parse Open Library work: %s", e)
|
||||
return None
|
||||
|
||||
def _parse_edition(self, edition: dict, isbn: str) -> Optional[BookMetadata]:
|
||||
def _parse_edition(self, edition: dict, isbn: str) -> BookMetadata | None:
|
||||
"""Parse an edition object into BookMetadata (fallback for ISBN lookup)."""
|
||||
try:
|
||||
title = edition.get("title")
|
||||
@@ -461,7 +474,7 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
publish_date = edition.get("publish_date", "")
|
||||
if publish_date:
|
||||
# Try to extract year from various formats
|
||||
year_match = re.search(r'\b(19|20)\d{2}\b', publish_date)
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", publish_date)
|
||||
if year_match:
|
||||
publish_year = int(year_match.group())
|
||||
|
||||
@@ -478,11 +491,11 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
source_url=f"{OPENLIBRARY_BASE_URL}{key}" if key else None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse Open Library edition: {e}")
|
||||
except (TypeError, ValueError, AttributeError, KeyError) as e:
|
||||
logger.debug("Failed to parse Open Library edition: %s", e)
|
||||
return None
|
||||
|
||||
def _get_author_name(self, author_key: str) -> Optional[str]:
|
||||
def _get_author_name(self, author_key: str) -> str | None:
|
||||
"""Get author name from author key (e.g., '/authors/OL123A')."""
|
||||
_rate_limiter.wait_if_needed()
|
||||
|
||||
@@ -496,13 +509,14 @@ class OpenLibraryProvider(MetadataProvider):
|
||||
author = response.json()
|
||||
return author.get("name")
|
||||
|
||||
except Exception:
|
||||
except requests.RequestException, ValueError:
|
||||
# Don't log errors for author lookups - they're supplementary
|
||||
return None
|
||||
|
||||
|
||||
def _test_openlibrary_connection() -> Dict[str, Any]:
|
||||
def _test_openlibrary_connection() -> dict[str, Any]:
|
||||
"""Test the Open Library API connection."""
|
||||
connection_result = {"success": False, "message": "Unexpected response from API"}
|
||||
try:
|
||||
provider = OpenLibraryProvider()
|
||||
# Simple API call to test connectivity
|
||||
@@ -515,15 +529,17 @@ def _test_openlibrary_connection() -> Dict[str, Any]:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if "docs" in data:
|
||||
return {"success": True, "message": "Successfully connected to Open Library API"}
|
||||
else:
|
||||
return {"success": False, "message": "Unexpected response from API"}
|
||||
connection_result = {
|
||||
"success": True,
|
||||
"message": "Successfully connected to Open Library API",
|
||||
}
|
||||
except requests.Timeout:
|
||||
return {"success": False, "message": "Connection timed out"}
|
||||
except requests.RequestException as e:
|
||||
return {"success": False, "message": f"Connection failed: {str(e)}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
return {"success": False, "message": f"Connection failed: {e!s}"}
|
||||
except (TypeError, ValueError, AttributeError) as e:
|
||||
return {"success": False, "message": f"Error: {e!s}"}
|
||||
return connection_result
|
||||
|
||||
|
||||
# Open Library sort options for settings UI
|
||||
@@ -534,8 +550,10 @@ _OPENLIBRARY_SORT_OPTIONS = [
|
||||
]
|
||||
|
||||
|
||||
@register_settings("openlibrary", "Open Library", icon="library", order=52, group="metadata_providers")
|
||||
def openlibrary_settings():
|
||||
@register_settings(
|
||||
"openlibrary", "Open Library", icon="library", order=52, group="metadata_providers"
|
||||
)
|
||||
def openlibrary_settings() -> list[SettingsField]:
|
||||
"""Open Library metadata provider settings."""
|
||||
return [
|
||||
HeadingField(
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
"""Release source plugin system - base classes and registry."""
|
||||
|
||||
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
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.search_plan import ReleaseSearchPlan
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
|
||||
class ReleaseProtocol(str, Enum):
|
||||
class ReleaseProtocol(StrEnum):
|
||||
"""Protocol for downloading a release."""
|
||||
HTTP = "http" # Direct HTTP download
|
||||
TORRENT = "torrent" # BitTorrent
|
||||
NZB = "nzb" # Usenet NZB
|
||||
DCC = "dcc" # IRC DCC
|
||||
|
||||
HTTP = "http" # Direct HTTP download
|
||||
TORRENT = "torrent" # BitTorrent
|
||||
NZB = "nzb" # Usenet NZB
|
||||
DCC = "dcc" # IRC DCC
|
||||
|
||||
|
||||
class SourceUnavailableError(Exception):
|
||||
@@ -29,76 +32,82 @@ class SourceUnavailableError(Exception):
|
||||
@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
|
||||
preview: str | None = None
|
||||
author: str | None = None
|
||||
publisher: str | None = None
|
||||
year: str | None = None
|
||||
language: str | None = None
|
||||
content: str | None = None
|
||||
format: str | None = None
|
||||
size: str | None = None
|
||||
info: dict[str, list[str]] | None = None
|
||||
description: str | None = None
|
||||
download_urls: list[str] = field(default_factory=list)
|
||||
download_path: str | None = None
|
||||
priority: int = 0
|
||||
progress: Optional[float] = None
|
||||
status_message: Optional[str] = None
|
||||
added_time: Optional[float] = None
|
||||
source_url: Optional[str] = None
|
||||
progress: float | None = None
|
||||
status_message: str | None = None
|
||||
added_time: float | None = None
|
||||
source_url: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Release:
|
||||
"""A downloadable release - all sources return this same structure."""
|
||||
source: str # "direct", "prowlarr", "irc", etc.
|
||||
source_id: str # ID within that source
|
||||
|
||||
source: str # "direct", "prowlarr", "irc", etc.
|
||||
source_id: str # ID within that source
|
||||
title: str
|
||||
format: Optional[str] = None
|
||||
language: Optional[str] = None # ISO 639-1 code (e.g., "en", "de", "fr")
|
||||
size: Optional[str] = None
|
||||
size_bytes: Optional[int] = None
|
||||
download_url: Optional[str] = None
|
||||
info_url: Optional[str] = None # Link to release info page (e.g., tracker) - makes title clickable
|
||||
protocol: Optional[ReleaseProtocol] = None
|
||||
indexer: Optional[str] = None # Source name for display
|
||||
seeders: Optional[int] = None # For torrents
|
||||
peers: Optional[str] = None # For torrents: "seeders/leechers" display string
|
||||
content_type: Optional[str] = None # "ebook" or "audiobook" - preserved from search
|
||||
extra: Dict = field(default_factory=dict) # Source-specific metadata
|
||||
format: str | None = None
|
||||
language: str | None = None # ISO 639-1 code (e.g., "en", "de", "fr")
|
||||
size: str | None = None
|
||||
size_bytes: int | None = None
|
||||
download_url: str | None = None
|
||||
info_url: str | None = None # Link to release info page (e.g., tracker) - makes title clickable
|
||||
protocol: ReleaseProtocol | None = None
|
||||
indexer: str | None = None # Source name for display
|
||||
seeders: int | None = None # For torrents
|
||||
peers: str | None = None # For torrents: "seeders/leechers" display string
|
||||
content_type: str | None = None # "ebook" or "audiobook" - preserved from search
|
||||
extra: dict = field(default_factory=dict) # Source-specific metadata
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadProgress:
|
||||
"""DEPRECATED: Use progress_callback and status_callback instead."""
|
||||
status: str # "queued", "resolving", "downloading", "complete", "failed"
|
||||
progress: float # 0-100
|
||||
status_message: Optional[str] = None
|
||||
download_speed: Optional[int] = None
|
||||
eta: Optional[int] = None
|
||||
save_path: Optional[str] = None
|
||||
|
||||
status: str # "queued", "resolving", "downloading", "complete", "failed"
|
||||
progress: float # 0-100
|
||||
status_message: str | None = None
|
||||
download_speed: int | None = None
|
||||
eta: int | None = None
|
||||
save_path: str | None = None
|
||||
|
||||
|
||||
# --- Column Schema for Plugin-Driven UI ---
|
||||
|
||||
class ColumnRenderType(str, Enum):
|
||||
|
||||
class ColumnRenderType(StrEnum):
|
||||
"""How the frontend should render the column value."""
|
||||
TEXT = "text" # Plain text
|
||||
BADGE = "badge" # Colored badge (format, language)
|
||||
TAGS = "tags" # List of colored badges
|
||||
SIZE = "size" # File size formatting
|
||||
NUMBER = "number" # Numeric value
|
||||
PEERS = "peers" # Peers display: "S/L" with color based on seeder count
|
||||
|
||||
TEXT = "text" # Plain text
|
||||
BADGE = "badge" # Colored badge (format, language)
|
||||
TAGS = "tags" # List of colored badges
|
||||
SIZE = "size" # File size formatting
|
||||
NUMBER = "number" # Numeric value
|
||||
PEERS = "peers" # Peers display: "S/L" with color based on seeder count
|
||||
INDEXER_PROTOCOL = "indexer_protocol" # Text + colored dot for torrent/usenet
|
||||
FLAG_ICON = "flag_icon" # Icon with tooltip (VIP, freeleech, etc.)
|
||||
FLAG_ICON = "flag_icon" # Icon with tooltip (VIP, freeleech, etc.)
|
||||
FORMAT_CONTENT_TYPE = "format_content_type" # Content type icon + format badge
|
||||
|
||||
|
||||
class ColumnAlign(str, Enum):
|
||||
class ColumnAlign(StrEnum):
|
||||
"""Column alignment options."""
|
||||
|
||||
LEFT = "left"
|
||||
CENTER = "center"
|
||||
RIGHT = "right"
|
||||
@@ -107,74 +116,89 @@ class ColumnAlign(str, Enum):
|
||||
@dataclass
|
||||
class ColumnColorHint:
|
||||
"""Color hint for badge-type columns."""
|
||||
type: Literal["map", "static"] # "map" uses frontend colorMaps, "static" is fixed class
|
||||
value: str # Map name ("format", "language") or Tailwind class
|
||||
|
||||
type: Literal["map", "static"] # "map" uses frontend colorMaps, "static" is fixed class
|
||||
value: str # Map name ("format", "language") or Tailwind class
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColumnSchema:
|
||||
"""Definition for a single column in the release list."""
|
||||
key: str # Data path (e.g., "format", "extra.language")
|
||||
label: str # Accessibility label
|
||||
|
||||
key: str # Data path (e.g., "format", "extra.language")
|
||||
label: str # Accessibility label
|
||||
render_type: ColumnRenderType = ColumnRenderType.TEXT
|
||||
align: ColumnAlign = ColumnAlign.LEFT
|
||||
width: str = "auto" # CSS width (e.g., "80px", "minmax(0,2fr)")
|
||||
hide_mobile: bool = False # Hide on small screens
|
||||
color_hint: Optional[ColumnColorHint] = None # For BADGE render type
|
||||
fallback: str = "-" # Value to show when data is missing
|
||||
uppercase: bool = False # Force uppercase display
|
||||
sortable: bool = False # Show in sort dropdown (opt-in)
|
||||
sort_key: Optional[str] = None # Field to sort by (defaults to `key` if None)
|
||||
width: str = "auto" # CSS width (e.g., "80px", "minmax(0,2fr)")
|
||||
hide_mobile: bool = False # Hide on small screens
|
||||
color_hint: ColumnColorHint | None = None # For BADGE render type
|
||||
fallback: str = "-" # Value to show when data is missing
|
||||
uppercase: bool = False # Force uppercase display
|
||||
sortable: bool = False # Show in sort dropdown (opt-in)
|
||||
sort_key: str | None = None # Field to sort by (defaults to `key` if None)
|
||||
|
||||
|
||||
class LeadingCellType(str, Enum):
|
||||
class LeadingCellType(StrEnum):
|
||||
"""Type of leading cell to display in release rows."""
|
||||
|
||||
THUMBNAIL = "thumbnail" # Show book cover image
|
||||
BADGE = "badge" # Show colored badge (e.g., "Torrent", "Usenet")
|
||||
NONE = "none" # No leading cell
|
||||
BADGE = "badge" # Show colored badge (e.g., "Torrent", "Usenet")
|
||||
NONE = "none" # No leading cell
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeadingCellConfig:
|
||||
"""Configuration for the leading cell in release rows."""
|
||||
|
||||
type: LeadingCellType = LeadingCellType.THUMBNAIL
|
||||
key: Optional[str] = None # Field path for data (e.g., "extra.preview" or "extra.download_type")
|
||||
color_hint: Optional[ColumnColorHint] = None # For badge type - maps values to colors
|
||||
uppercase: bool = False # Force uppercase for badge text
|
||||
key: str | None = None # Field path for data (e.g., "extra.preview" or "extra.download_type")
|
||||
color_hint: ColumnColorHint | None = None # For badge type - maps values to colors
|
||||
uppercase: bool = False # Force uppercase for badge text
|
||||
|
||||
|
||||
@dataclass
|
||||
class SortOption:
|
||||
"""A sort option that appears in the sort dropdown without being tied to a column."""
|
||||
label: str # Display label in the sort dropdown
|
||||
sort_key: str # Field to sort by on the Release object
|
||||
|
||||
label: str # Display label in the sort dropdown
|
||||
sort_key: str # Field to sort by on the Release object
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceActionButton:
|
||||
"""Action button configuration for a release source."""
|
||||
label: str # Button text (e.g., "Refresh search")
|
||||
action: str = "expand" # Action type: "expand" triggers expand_search
|
||||
|
||||
label: str # Button text (e.g., "Refresh search")
|
||||
action: str = "expand" # Action type: "expand" triggers expand_search
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseColumnConfig:
|
||||
"""Complete column configuration for a release source."""
|
||||
columns: List[ColumnSchema]
|
||||
|
||||
columns: list[ColumnSchema]
|
||||
grid_template: str = "minmax(0,2fr) 60px 80px 80px" # CSS grid-template-columns
|
||||
leading_cell: Optional[LeadingCellConfig] = None # Defaults to thumbnail mode if None
|
||||
online_servers: Optional[List[str]] = None # For IRC: list of currently online server nicks
|
||||
available_indexers: Optional[List[str]] = None # For Prowlarr: list of all enabled indexer names
|
||||
default_indexers: Optional[List[str]] = None # For Prowlarr: indexers selected in settings (pre-selected in filter)
|
||||
cache_ttl_seconds: Optional[int] = None # How long to cache results (default: 5 min)
|
||||
supported_filters: Optional[List[str]] = None # Which filters this source supports: ["format", "language", "indexer"]
|
||||
extra_sort_options: Optional[List[SortOption]] = None # Additional sort options not tied to a column
|
||||
action_button: Optional[SourceActionButton] = None # Custom action button (replaces default expand search)
|
||||
leading_cell: LeadingCellConfig | None = None # Defaults to thumbnail mode if None
|
||||
online_servers: list[str] | None = None # For IRC: list of currently online server nicks
|
||||
available_indexers: list[str] | None = None # For Prowlarr: list of all enabled indexer names
|
||||
default_indexers: list[str] | None = (
|
||||
None # For Prowlarr: indexers selected in settings (pre-selected in filter)
|
||||
)
|
||||
cache_ttl_seconds: int | None = None # How long to cache results (default: 5 min)
|
||||
supported_filters: list[str] | None = (
|
||||
None # Which filters this source supports: ["format", "language", "indexer"]
|
||||
)
|
||||
extra_sort_options: list[SortOption] | None = (
|
||||
None # Additional sort options not tied to a column
|
||||
)
|
||||
action_button: SourceActionButton | None = (
|
||||
None # Custom action button (replaces default expand search)
|
||||
)
|
||||
|
||||
|
||||
def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]:
|
||||
def serialize_column_config(config: ReleaseColumnConfig) -> dict[str, Any]:
|
||||
"""Serialize column configuration for API response."""
|
||||
result: Dict[str, Any] = {
|
||||
result: dict[str, Any] = {
|
||||
"columns": [
|
||||
{
|
||||
"key": col.key,
|
||||
@@ -185,8 +209,10 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]:
|
||||
"hide_mobile": col.hide_mobile,
|
||||
"color_hint": {
|
||||
"type": col.color_hint.type,
|
||||
"value": col.color_hint.value
|
||||
} if col.color_hint else None,
|
||||
"value": col.color_hint.value,
|
||||
}
|
||||
if col.color_hint
|
||||
else None,
|
||||
"fallback": col.fallback,
|
||||
"uppercase": col.uppercase,
|
||||
"sortable": col.sortable,
|
||||
@@ -204,8 +230,10 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]:
|
||||
"key": config.leading_cell.key,
|
||||
"color_hint": {
|
||||
"type": config.leading_cell.color_hint.type,
|
||||
"value": config.leading_cell.color_hint.value
|
||||
} if config.leading_cell.color_hint else None,
|
||||
"value": config.leading_cell.color_hint.value,
|
||||
}
|
||||
if config.leading_cell.color_hint
|
||||
else None,
|
||||
"uppercase": config.leading_cell.uppercase,
|
||||
}
|
||||
|
||||
@@ -232,8 +260,7 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]:
|
||||
# Include extra sort options (sort entries not tied to a column)
|
||||
if config.extra_sort_options:
|
||||
result["extra_sort_options"] = [
|
||||
{"label": opt.label, "sort_key": opt.sort_key}
|
||||
for opt in config.extra_sort_options
|
||||
{"label": opt.label, "sort_key": opt.sort_key} for opt in config.extra_sort_options
|
||||
]
|
||||
|
||||
# Include action button if specified (replaces default expand search)
|
||||
@@ -286,26 +313,29 @@ def _default_column_config() -> ReleaseColumnConfig:
|
||||
|
||||
class ReleaseSource(ABC):
|
||||
"""Interface for searching a release source."""
|
||||
name: str # "direct", "prowlarr"
|
||||
display_name: str # "Direct Download", "Prowlarr"
|
||||
supported_content_types: List[str] = ["ebook", "audiobook"] # Content types this source supports
|
||||
can_be_default: bool = True # Whether this source can be selected as default in settings
|
||||
|
||||
name: str # "direct", "prowlarr"
|
||||
display_name: str # "Direct Download", "Prowlarr"
|
||||
supported_content_types: ClassVar[list[str]] = [
|
||||
"ebook",
|
||||
"audiobook",
|
||||
] # Content types this source supports
|
||||
can_be_default: bool = True # Whether this source can be selected as default in settings
|
||||
|
||||
@abstractmethod
|
||||
def search(
|
||||
self,
|
||||
book: BookMetadata,
|
||||
plan: "ReleaseSearchPlan",
|
||||
plan: ReleaseSearchPlan,
|
||||
*,
|
||||
expand_search: bool = False,
|
||||
content_type: str = "ebook"
|
||||
) -> List[Release]:
|
||||
content_type: str = "ebook",
|
||||
) -> list[Release]:
|
||||
"""Search for releases of a book."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""Check if this source is configured and reachable."""
|
||||
pass
|
||||
|
||||
def get_column_config(self) -> ReleaseColumnConfig:
|
||||
"""Get column configuration for release list UI. Override for custom columns."""
|
||||
@@ -316,15 +346,16 @@ class ReleaseSource(ABC):
|
||||
record_id: str,
|
||||
*,
|
||||
fetch_download_count: bool = True,
|
||||
) -> Optional[BrowseRecord]:
|
||||
) -> BrowseRecord | None:
|
||||
"""Resolve a source-native record for browse flows."""
|
||||
raise NotImplementedError(f"{self.display_name} does not support record lookup")
|
||||
msg = f"{self.display_name} does not support record lookup"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
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]:
|
||||
def get_destination_override(self, task: DownloadTask) -> Path | None:
|
||||
"""Return a source-specific destination override for a queued download."""
|
||||
return None
|
||||
|
||||
@@ -346,12 +377,11 @@ class DownloadHandler(ABC):
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, Optional[str]], None]
|
||||
) -> Optional[str]:
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> str | None:
|
||||
"""Execute download and return a path to the downloaded payload."""
|
||||
pass
|
||||
|
||||
def post_process_cleanup(self, task: DownloadTask, success: bool) -> None:
|
||||
def post_process_cleanup(self, task: DownloadTask, *, success: bool) -> None:
|
||||
"""Optional hook called after orchestrator post-processing.
|
||||
|
||||
This is primarily used for external download clients, where the handler may need
|
||||
@@ -362,58 +392,71 @@ class DownloadHandler(ABC):
|
||||
@abstractmethod
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
"""Cancel an in-progress download."""
|
||||
pass
|
||||
|
||||
|
||||
# --- Registry ---
|
||||
|
||||
_SOURCES: Dict[str, Type[ReleaseSource]] = {}
|
||||
_HANDLERS: Dict[str, Type[DownloadHandler]] = {}
|
||||
_SOURCES: dict[str, type[ReleaseSource]] = {}
|
||||
_HANDLERS: dict[str, type[DownloadHandler]] = {}
|
||||
|
||||
|
||||
def register_source(name: str):
|
||||
def register_source(
|
||||
name: str,
|
||||
) -> Callable[[type[ReleaseSource]], type[ReleaseSource]]:
|
||||
"""Decorator to register a release source."""
|
||||
def decorator(cls):
|
||||
|
||||
def decorator(cls: type[ReleaseSource]) -> type[ReleaseSource]:
|
||||
_SOURCES[name] = cls
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_handler(name: str):
|
||||
def register_handler(
|
||||
name: str,
|
||||
) -> Callable[[type[DownloadHandler]], type[DownloadHandler]]:
|
||||
"""Decorator to register a download handler."""
|
||||
def decorator(cls):
|
||||
|
||||
def decorator(cls: type[DownloadHandler]) -> type[DownloadHandler]:
|
||||
_HANDLERS[name] = cls
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_source(name: str) -> ReleaseSource:
|
||||
"""Get a release source instance by name."""
|
||||
if name not in _SOURCES:
|
||||
raise ValueError(f"Unknown release source: {name}")
|
||||
msg = f"Unknown release source: {name}"
|
||||
raise ValueError(msg)
|
||||
return _SOURCES[name]()
|
||||
|
||||
|
||||
def get_handler(name: str) -> DownloadHandler:
|
||||
"""Get a download handler instance by name."""
|
||||
if name not in _HANDLERS:
|
||||
raise ValueError(f"Unknown download handler: {name}")
|
||||
msg = f"Unknown download handler: {name}"
|
||||
raise ValueError(msg)
|
||||
return _HANDLERS[name]()
|
||||
|
||||
|
||||
def list_available_sources() -> List[dict]:
|
||||
def list_available_sources() -> list[dict]:
|
||||
"""List all registered sources with their availability status."""
|
||||
result = []
|
||||
for name, src_class in _SOURCES.items():
|
||||
instance = src_class()
|
||||
result.append({
|
||||
"name": name,
|
||||
"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),
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"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
|
||||
|
||||
|
||||
@@ -421,14 +464,14 @@ def get_source_display_name(name: str) -> str:
|
||||
"""Get display name for a source by its identifier."""
|
||||
if name in _SOURCES:
|
||||
return _SOURCES[name]().display_name
|
||||
return name.replace('_', ' ').title()
|
||||
return name.replace("_", " ").title()
|
||||
|
||||
|
||||
def browse_record_to_book_metadata(
|
||||
record: BrowseRecord,
|
||||
*,
|
||||
title_override: Optional[str] = None,
|
||||
author_override: Optional[str] = None,
|
||||
title_override: str | None = None,
|
||||
author_override: str | None = 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"
|
||||
@@ -469,7 +512,7 @@ def source_results_are_releases(name: str) -> bool:
|
||||
|
||||
# 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
|
||||
from shelfmark.release_sources import prowlarr # noqa: F401, E402
|
||||
from shelfmark.release_sources import irc # noqa: F401, E402
|
||||
from shelfmark.release_sources import audiobookbay # noqa: F401, E402
|
||||
from shelfmark.release_sources import audiobookbay as audiobookbay
|
||||
from shelfmark.release_sources import direct_download as direct_download
|
||||
from shelfmark.release_sources import irc as irc
|
||||
from shelfmark.release_sources import prowlarr as prowlarr
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""AudiobookBay release source - web scraping for audiobook torrents."""
|
||||
|
||||
# Import to trigger registration
|
||||
from shelfmark.release_sources.audiobookbay import source # noqa: F401, E402
|
||||
from shelfmark.release_sources.audiobookbay import handler # noqa: F401, E402
|
||||
from shelfmark.release_sources.audiobookbay import settings # noqa: F401, E402
|
||||
from shelfmark.release_sources.audiobookbay import handler as handler
|
||||
from shelfmark.release_sources.audiobookbay import settings as settings
|
||||
from shelfmark.release_sources.audiobookbay import source as source
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
"""AudiobookBay download handler - resolves magnet links and uses shared client lifecycle."""
|
||||
|
||||
from typing import Callable, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients
|
||||
from shelfmark.download.clients.base_handler import DownloadRequest, ExternalClientHandler
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
get_client,
|
||||
list_configured_clients,
|
||||
)
|
||||
from shelfmark.download.clients.base_handler import (
|
||||
DownloadRequest,
|
||||
ExternalClientHandler,
|
||||
)
|
||||
from shelfmark.release_sources import register_handler
|
||||
from shelfmark.release_sources.audiobookbay import scraper
|
||||
from shelfmark.release_sources.audiobookbay.utils import normalize_hostname
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
@@ -20,7 +31,7 @@ class AudiobookBayHandler(ExternalClientHandler):
|
||||
"""Handler for AudiobookBay downloads via configured torrent client."""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_detail_url(task: DownloadTask) -> Optional[str]:
|
||||
def _resolve_detail_url(task: DownloadTask) -> str | None:
|
||||
"""Resolve ABB detail URL from queued task metadata."""
|
||||
source_url = (task.source_url or "").strip()
|
||||
if source_url:
|
||||
@@ -32,7 +43,7 @@ class AudiobookBayHandler(ExternalClientHandler):
|
||||
return task_id
|
||||
return None
|
||||
|
||||
def _get_client(self, protocol: str) -> Optional[DownloadClient]:
|
||||
def _get_client(self, protocol: str) -> DownloadClient | None:
|
||||
"""Compatibility shim so module-level patching still works in tests."""
|
||||
return get_client(protocol)
|
||||
|
||||
@@ -43,13 +54,13 @@ class AudiobookBayHandler(ExternalClientHandler):
|
||||
def _resolve_download(
|
||||
self,
|
||||
task: DownloadTask,
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> Optional[DownloadRequest]:
|
||||
status_callback: Callable[[str, str | None], None],
|
||||
) -> DownloadRequest | None:
|
||||
"""Resolve ABB detail page into a magnet-link download request."""
|
||||
detail_url = self._resolve_detail_url(task)
|
||||
if not detail_url:
|
||||
status_callback("error", "Missing AudiobookBay details URL")
|
||||
logger.warning(f"Missing details URL for AudiobookBay task: {task.task_id}")
|
||||
logger.warning("Missing details URL for AudiobookBay task: %s", task.task_id)
|
||||
return None
|
||||
|
||||
hostname = normalize_hostname(config.get("ABB_HOSTNAME", ""))
|
||||
@@ -63,7 +74,7 @@ class AudiobookBayHandler(ExternalClientHandler):
|
||||
status_callback("error", "Failed to extract magnet link from detail page")
|
||||
return None
|
||||
|
||||
logger.info(f"Extracted magnet link for task {task.task_id}")
|
||||
logger.info("Extracted magnet link for task %s", task.task_id)
|
||||
|
||||
return DownloadRequest(
|
||||
url=magnet_link,
|
||||
@@ -79,5 +90,5 @@ class AudiobookBayHandler(ExternalClientHandler):
|
||||
been sent to the torrent client we do not remove it client-side. Users must
|
||||
cancel/remove it in their torrent client UI.
|
||||
"""
|
||||
logger.debug(f"Cancel requested for AudiobookBay task: {task_id}")
|
||||
logger.debug("Cancel requested for AudiobookBay task: %s", task_id)
|
||||
return False
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import List, Optional, Dict
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
@@ -66,11 +65,15 @@ def _is_homepage_redirect(final_url: str, hostname: str) -> bool:
|
||||
return normalized_final in {normalized_home, f"{normalized_home}/"}
|
||||
|
||||
|
||||
def _encode_search_query(query: str, exact_phrase: bool) -> str:
|
||||
def _encode_search_query(query: str, *, exact_phrase: bool) -> str:
|
||||
"""Encode search query using ABB's space-plus style and optional exact phrase wrapping."""
|
||||
search_query = query.strip()
|
||||
if exact_phrase and search_query and not (search_query.startswith('"') and search_query.endswith('"')):
|
||||
search_query = f"\"{search_query}\""
|
||||
if (
|
||||
exact_phrase
|
||||
and search_query
|
||||
and not (search_query.startswith('"') and search_query.endswith('"'))
|
||||
):
|
||||
search_query = f'"{search_query}"'
|
||||
# Keep ABB-friendly encoding style (spaces as '+') while percent-encoding quotes.
|
||||
return search_query.replace('"', "%22").replace(" ", "+")
|
||||
|
||||
@@ -110,18 +113,20 @@ def search_audiobookbay(
|
||||
query: str,
|
||||
max_pages: int = 1,
|
||||
hostname: str = "audiobookbay.lu",
|
||||
*,
|
||||
exact_phrase: bool = False,
|
||||
) -> List[Dict[str, str]]:
|
||||
) -> list[dict[str, str]]:
|
||||
"""Search AudiobookBay for audiobooks matching the query.
|
||||
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
max_pages: Maximum number of pages to fetch
|
||||
hostname: AudiobookBay hostname (e.g., "audiobookbay.lu")
|
||||
exact_phrase: Wrap query in quotes for exact phrase matching
|
||||
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: title, link, cover, language, format, bitrate, size, posted_date
|
||||
|
||||
"""
|
||||
results = []
|
||||
rate_limit_delay = config.get("ABB_RATE_LIMIT_DELAY", 1.0)
|
||||
@@ -130,12 +135,12 @@ def search_audiobookbay(
|
||||
# Bootstrap ABB session cookie (PHPSESSID). ABB increasingly serves reliable
|
||||
# search/detail pages only after session initialization, similar to browsers.
|
||||
_bootstrap_abb_session(hostname, session, SEARCH_PAGE_RETRY_ATTEMPTS)
|
||||
|
||||
|
||||
# Iterate through pages
|
||||
for page in range(1, max_pages + 1):
|
||||
# Construct URL - use + for spaces (matching audiobookbay-automated implementation)
|
||||
# This avoids aggressive encoding that PHP-based sites may reject.
|
||||
query_encoded = _encode_search_query(query, exact_phrase)
|
||||
query_encoded = _encode_search_query(query, exact_phrase=exact_phrase)
|
||||
# ABB search expects the legacy category query parameter.
|
||||
primary_url = _build_search_url(
|
||||
hostname,
|
||||
@@ -143,7 +148,7 @@ def search_audiobookbay(
|
||||
query_encoded,
|
||||
include_legacy_category=True,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
# Reuse shared HTTP fetch logic (without bypasser)
|
||||
page_html, final_url = downloader.html_get_page(
|
||||
@@ -183,84 +188,93 @@ def search_audiobookbay(
|
||||
)
|
||||
|
||||
if not page_html:
|
||||
logger.warning(f"Failed to fetch page {page}")
|
||||
logger.warning("Failed to fetch page %s", page)
|
||||
break
|
||||
|
||||
|
||||
# Check if we were redirected to the homepage (search was rejected/blocked)
|
||||
if was_home_redirect:
|
||||
# Search was redirected to homepage - this means the search failed
|
||||
# This can happen due to geo-blocking, rate limiting, or invalid query format
|
||||
if page == 1:
|
||||
logger.warning(f"Search query '{query}' was redirected to homepage - search may be blocked or invalid")
|
||||
logger.warning(
|
||||
"Search query '%s' was redirected to homepage - search may be blocked or invalid",
|
||||
query,
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(page_html, 'html.parser')
|
||||
|
||||
soup = BeautifulSoup(page_html, "html.parser")
|
||||
|
||||
# Extract book entries
|
||||
posts = soup.select('.post')
|
||||
posts = soup.select(".post")
|
||||
if not posts:
|
||||
# No more results
|
||||
break
|
||||
|
||||
|
||||
for post in posts:
|
||||
try:
|
||||
# Extract title
|
||||
title_elem = post.select_one('.postTitle > h2 > a')
|
||||
title_elem = post.select_one(".postTitle > h2 > a")
|
||||
if not title_elem:
|
||||
continue
|
||||
|
||||
|
||||
title = title_elem.text.strip()
|
||||
|
||||
|
||||
# Extract link (relative, needs hostname prefix)
|
||||
href = title_elem.get('href', '')
|
||||
href = title_elem.get("href", "")
|
||||
if not href:
|
||||
continue
|
||||
|
||||
|
||||
link = _normalize_result_url(href, hostname)
|
||||
if not link:
|
||||
continue
|
||||
|
||||
|
||||
# Extract cover image (try .postContent .center img first, then fallback to any img)
|
||||
cover = None
|
||||
cover_elem = post.select_one('.postContent .center img') or post.select_one('img')
|
||||
cover_elem = post.select_one(".postContent .center img") or post.select_one(
|
||||
"img"
|
||||
)
|
||||
if cover_elem:
|
||||
cover = _normalize_result_url(cover_elem.get('src', ''), hostname) or None
|
||||
|
||||
cover = _normalize_result_url(cover_elem.get("src", ""), hostname) or None
|
||||
|
||||
# Extract language from .postInfo
|
||||
language = None
|
||||
post_info = post.select_one('.postInfo')
|
||||
post_info = post.select_one(".postInfo")
|
||||
if post_info:
|
||||
info_text = post_info.get_text(separator=' ', strip=True).replace('\xa0', ' ')
|
||||
info_text = post_info.get_text(separator=" ", strip=True).replace(
|
||||
"\xa0", " "
|
||||
)
|
||||
lang_match = LANGUAGE_PATTERN.search(info_text)
|
||||
if lang_match:
|
||||
language = lang_match.group(1).strip()
|
||||
|
||||
|
||||
# Extract format, bitrate, size, and posted date from .postContent
|
||||
posted_date = None
|
||||
format_type = None
|
||||
bitrate = None
|
||||
size_str = None
|
||||
|
||||
post_content = post.select_one('.postContent')
|
||||
|
||||
post_content = post.select_one(".postContent")
|
||||
if post_content:
|
||||
content_text = post_content.get_text(separator=' ', strip=True).replace('\xa0', ' ')
|
||||
|
||||
content_text = post_content.get_text(separator=" ", strip=True).replace(
|
||||
"\xa0", " "
|
||||
)
|
||||
|
||||
# Extract posted date
|
||||
posted_match = POSTED_PATTERN.search(content_text)
|
||||
if posted_match:
|
||||
posted_date = posted_match.group(1).strip()
|
||||
|
||||
|
||||
# Extract format (e.g., "M4B", "MP3")
|
||||
format_match = FORMAT_PATTERN.search(content_text)
|
||||
if format_match:
|
||||
format_type = format_match.group(1).strip()
|
||||
|
||||
|
||||
# Extract bitrate (e.g., "256 Kbps")
|
||||
bitrate_match = BITRATE_PATTERN.search(content_text)
|
||||
if bitrate_match:
|
||||
bitrate = bitrate_match.group(1).strip()
|
||||
|
||||
|
||||
# Extract file size (e.g., "11.68 GBs" -> normalized to "11.68 GB")
|
||||
size_match = SIZE_PATTERN.search(content_text)
|
||||
if size_match:
|
||||
@@ -270,44 +284,44 @@ def search_audiobookbay(
|
||||
size_unit = size_unit[:-1]
|
||||
size_unit = size_unit.upper()
|
||||
size_str = f"{size_value} {size_unit}"
|
||||
|
||||
results.append({
|
||||
'title': title,
|
||||
'link': link,
|
||||
'cover': cover or None,
|
||||
'language': language,
|
||||
'format': format_type,
|
||||
'bitrate': bitrate,
|
||||
'size': size_str,
|
||||
'posted_date': posted_date,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Skipping post due to error: {e}")
|
||||
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"link": link,
|
||||
"cover": cover or None,
|
||||
"language": language,
|
||||
"format": format_type,
|
||||
"bitrate": bitrate,
|
||||
"size": size_str,
|
||||
"posted_date": posted_date,
|
||||
}
|
||||
)
|
||||
except (TypeError, ValueError, AttributeError, IndexError, KeyError) as e:
|
||||
logger.debug("Skipping post due to error: %s", e)
|
||||
continue
|
||||
|
||||
|
||||
# Rate limiting delay between pages
|
||||
if page < max_pages and rate_limit_delay > 0:
|
||||
time.sleep(rate_limit_delay)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error on page {page}: {e}")
|
||||
except Exception:
|
||||
logger.exception("Unexpected error on page %s", page)
|
||||
break
|
||||
|
||||
logger.info(f"Found {len(results)} results for query '{query}'")
|
||||
|
||||
logger.info("Found %s results for query '%s'", len(results), query)
|
||||
return results
|
||||
|
||||
|
||||
def extract_magnet_link(
|
||||
details_url: str,
|
||||
hostname: str = "audiobookbay.lu"
|
||||
) -> Optional[str]:
|
||||
def extract_magnet_link(details_url: str, hostname: str = "audiobookbay.lu") -> str | None:
|
||||
"""Extract info hash and trackers from book detail page, then construct magnet link.
|
||||
|
||||
|
||||
Args:
|
||||
details_url: URL of the book's detail page
|
||||
hostname: AudiobookBay hostname (for logging)
|
||||
|
||||
|
||||
Returns:
|
||||
Magnet link, or None if extraction fails
|
||||
|
||||
"""
|
||||
try:
|
||||
session = requests.Session()
|
||||
@@ -334,65 +348,64 @@ def extract_magnet_link(
|
||||
success_delay=0,
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
||||
if not detail_html:
|
||||
logger.warning("Failed to fetch details page")
|
||||
return None
|
||||
|
||||
soup = BeautifulSoup(detail_html, 'html.parser')
|
||||
|
||||
|
||||
soup = BeautifulSoup(detail_html, "html.parser")
|
||||
|
||||
# 1. Extract Info Hash
|
||||
# Look for <td>Info Hash</td> and get next sibling value
|
||||
info_hash = None
|
||||
info_hash_rows = soup.find_all('td')
|
||||
info_hash_rows = soup.find_all("td")
|
||||
for td in info_hash_rows:
|
||||
if td.text.strip().lower() == 'info hash':
|
||||
next_td = td.find_next_sibling('td')
|
||||
if td.text.strip().lower() == "info hash":
|
||||
next_td = td.find_next_sibling("td")
|
||||
if next_td:
|
||||
info_hash = next_td.text.strip()
|
||||
break
|
||||
|
||||
|
||||
# Alternative: search for text containing "Info Hash" and get next element
|
||||
if not info_hash:
|
||||
for elem in soup.find_all(string=INFO_HASH_LABEL_PATTERN):
|
||||
parent = elem.parent
|
||||
if parent and parent.name == 'td':
|
||||
next_td = parent.find_next_sibling('td')
|
||||
if parent and parent.name == "td":
|
||||
next_td = parent.find_next_sibling("td")
|
||||
if next_td:
|
||||
info_hash = next_td.text.strip()
|
||||
break
|
||||
|
||||
|
||||
if not info_hash:
|
||||
logger.warning("Info Hash not found on the page.")
|
||||
return None
|
||||
|
||||
|
||||
# Clean up info hash (remove whitespace, ensure uppercase)
|
||||
info_hash = re.sub(r'\s+', '', info_hash).upper()
|
||||
|
||||
info_hash = re.sub(r"\s+", "", info_hash).upper()
|
||||
|
||||
# 2. Extract Trackers
|
||||
# Find all <td> containing udp:// or http://
|
||||
trackers = []
|
||||
for td in soup.find_all('td'):
|
||||
for td in soup.find_all("td"):
|
||||
text = td.text.strip()
|
||||
if text.startswith(('udp://', 'http://', 'https://')):
|
||||
if text.startswith(("udp://", "http://", "https://")):
|
||||
trackers.append(text)
|
||||
|
||||
|
||||
# 3. Use default trackers if none found
|
||||
if not trackers:
|
||||
logger.debug("No trackers found on the page. Using default trackers.")
|
||||
trackers = DEFAULT_TRACKERS
|
||||
|
||||
|
||||
# 4. Construct Magnet Link
|
||||
# Format: magnet:?xt=urn:btih:{INFO_HASH}&tr={TRACKER1}&tr={TRACKER2}...
|
||||
tracker_params = "&".join(
|
||||
f"tr={quote(tracker)}"
|
||||
for tracker in trackers
|
||||
)
|
||||
tracker_params = "&".join(f"tr={quote(tracker)}" for tracker in trackers)
|
||||
magnet_link = f"magnet:?xt=urn:btih:{info_hash}&{tracker_params}"
|
||||
|
||||
logger.debug(f"Generated Magnet Link: {magnet_link[:100]}...")
|
||||
return magnet_link
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract magnet link: {e}")
|
||||
|
||||
logger.debug("Generated Magnet Link: %s...", magnet_link[:100])
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to extract magnet link")
|
||||
return None
|
||||
|
||||
else:
|
||||
return magnet_link
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user