mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-25 06:30:17 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05115f7b41 | ||
|
|
554f5fcbe7 | ||
|
|
8ff2d776ae | ||
|
|
6c351f4bf3 | ||
|
|
7fdf55f5fd | ||
|
|
dd6fd1e199 | ||
|
|
ccb39e674e | ||
|
|
1931eb96a5 | ||
|
|
b7bee132a1 | ||
|
|
68608b6162 | ||
|
|
af9d9ec8db | ||
|
|
a7064939ce | ||
|
|
5bed0b20f4 | ||
|
|
2d2f54729f | ||
|
|
b5923635a6 | ||
|
|
e09f5f7757 | ||
|
|
022e50a0ba | ||
|
|
a560089ce3 | ||
|
|
f84fb082ad | ||
|
|
b10458a48b | ||
|
|
f6dba959c9 | ||
|
|
e5ccabe1ef | ||
|
|
86082c999c | ||
|
|
301b2e5456 | ||
|
|
4fde128fc7 | ||
|
|
d050417e01 | ||
|
|
0a7785a333 | ||
|
|
10bfaec793 | ||
|
|
1f093de763 | ||
|
|
43e554b8ae | ||
|
|
3be99effe4 | ||
|
|
03c364e375 | ||
|
|
edf25150bd | ||
|
|
a030bca5d3 | ||
|
|
8470095534 | ||
|
|
4e00cf42f6 | ||
|
|
f7375d56e2 | ||
|
|
5a6db5f8a8 | ||
|
|
fd74021594 | ||
|
|
ba906c45df | ||
|
|
0d7a12ca7c |
@@ -229,5 +229,7 @@ pyrightconfig.json
|
||||
/downloaded_files
|
||||
/.local/
|
||||
*.local.*
|
||||
AGENTS.md
|
||||
.claude/
|
||||
.playwright-mcp/
|
||||
frontend-dist/
|
||||
|
||||
+5
-5
@@ -130,10 +130,11 @@ RUN apt-get update && \
|
||||
xvfb \
|
||||
# For screen recording
|
||||
ffmpeg \
|
||||
# --- Chromium ---
|
||||
# --- Chromium (unpinned - uses latest from Debian repos) ---
|
||||
# Chrome 144+ requires --enable-unsafe-swiftshader for WebGL in Docker.
|
||||
# This flag is set in internal_bypasser.py _get_browser_args()
|
||||
chromium \
|
||||
# --- ChromeDriver ---
|
||||
chromium-driver \
|
||||
chromium-common \
|
||||
# For tkinter (pyautogui)
|
||||
python3-tk \
|
||||
# For RAR extraction
|
||||
@@ -151,8 +152,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
|
||||
# Grant read/execute permissions to others
|
||||
RUN chmod -R o+rx /usr/bin/chromium && \
|
||||
chmod -R o+rx /usr/bin/chromedriver && \
|
||||
chmod -R o+w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers/
|
||||
chmod -R o+rwx /usr/local/lib/python3.10/site-packages/seleniumbase/drivers/
|
||||
|
||||
# Default command to run the application entrypoint script
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
|
||||
@@ -2,15 +2,14 @@ services:
|
||||
shelfmark-lite:
|
||||
image: ghcr.io/calibrain/shelfmark-lite:latest
|
||||
environment:
|
||||
# TZ: America/New_York
|
||||
# EXT_BYPASSER_URL: http://flaresolverr:8191 #If using Flaresolverr
|
||||
# PUID: 1000
|
||||
# PGID: 1000
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
ports:
|
||||
- 8084:8084
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /path/to/books:/books # Book destination directory
|
||||
- /path/to/books:/books # Default destination for book downloads
|
||||
- /path/to/config:/config # App configuration
|
||||
# Download client mount - path must match your torrent/usenet client's volume exactly
|
||||
# Required for torrent / usenet - path must match your download client's volume exactly
|
||||
# - /path/to/downloads:/path/to/downloads
|
||||
@@ -4,10 +4,9 @@ services:
|
||||
image: ghcr.io/calibrain/shelfmark:latest
|
||||
environment:
|
||||
FLASK_PORT: 8084
|
||||
# TZ: America/New_York
|
||||
USING_TOR: true
|
||||
# PUID: 1000
|
||||
# PGID: 1000
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
@@ -15,7 +14,7 @@ services:
|
||||
- 8084:8084
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /path/to/books:/books # Book destination directory
|
||||
- /path/to/books:/books # Default destination for book downloads
|
||||
- /path/to/config:/config # App configuration
|
||||
# Download client mount - path must match your torrent/usenet client's volume exactly
|
||||
# Required for torrent / usenet - path must match your download client's volume exactly
|
||||
# - /path/to/downloads:/path/to/downloads
|
||||
@@ -3,14 +3,13 @@ services:
|
||||
image: ghcr.io/calibrain/shelfmark:latest
|
||||
container_name: shelfmark
|
||||
environment:
|
||||
# TZ: America/New_York
|
||||
# PUID: 1000
|
||||
# PGID: 1000
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
ports:
|
||||
- 8084:8084
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /path/to/books:/books # Book destination directory
|
||||
- /path/to/books:/books # Default destination for book downloads
|
||||
- /path/to/config:/config # App configuration
|
||||
# Download client mount - path must match your torrent/usenet client's volume exactly
|
||||
# Required for torrent / usenet - path must match your download client's volume exactly
|
||||
# - /path/to/downloads:/path/to/downloads
|
||||
@@ -1,20 +0,0 @@
|
||||
# Uses external Cloudflare bypasser (FlareSolverr/ByParr) instead of built-in Selenium
|
||||
services:
|
||||
shelfmark-lite:
|
||||
image: ghcr.io/calibrain/shelfmark-lite:dev
|
||||
environment:
|
||||
# TZ: America/New_York
|
||||
EXT_BYPASSER_URL: http://flaresolverr:8191
|
||||
# PUID: 1000
|
||||
# PGID: 1000
|
||||
ports:
|
||||
- 8084:8084
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /path/to/books:/books # Book destination directory
|
||||
- /path/to/config:/config # App configuration
|
||||
# Download client mount - path must match your torrent/usenet client's volume exactly
|
||||
# - /path/to/downloads:/path/to/downloads
|
||||
|
||||
flaresolverr:
|
||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||
@@ -1,21 +0,0 @@
|
||||
# Routes all traffic through Tor - requires NET_ADMIN capability
|
||||
services:
|
||||
shelfmark-tor:
|
||||
image: ghcr.io/calibrain/shelfmark:dev
|
||||
environment:
|
||||
FLASK_PORT: 8084
|
||||
# TZ: America/New_York
|
||||
USING_TOR: true
|
||||
# PUID: 1000
|
||||
# PGID: 1000
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
ports:
|
||||
- 8084:8084
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /path/to/books:/books # Book destination directory
|
||||
- /path/to/config:/config # App configuration
|
||||
# Download client mount - path must match your torrent/usenet client's volume exactly
|
||||
# - /path/to/downloads:/path/to/downloads
|
||||
@@ -1,16 +0,0 @@
|
||||
services:
|
||||
shelfmark:
|
||||
image: ghcr.io/calibrain/shelfmark:dev
|
||||
container_name: shelfmark
|
||||
environment:
|
||||
# TZ: America/New_York
|
||||
# PUID: 1000
|
||||
# PGID: 1000
|
||||
ports:
|
||||
- 8084:8084
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /path/to/books:/books # Book destination directory
|
||||
- /path/to/config:/config # App configuration
|
||||
# Download client mount - path must match your torrent/usenet client's volume exactly
|
||||
# - /path/to/downloads:/path/to/downloads
|
||||
@@ -0,0 +1,39 @@
|
||||
# Bypass testing - switch between dev build and v1.0.1
|
||||
# Usage:
|
||||
# Test dev build: docker compose -f docker-compose.bypass-test.yml up shelfmark-dev
|
||||
# Test v1.0.1: docker compose -f docker-compose.bypass-test.yml up shelfmark-stable
|
||||
# Pull latest dev: docker compose -f docker-compose.bypass-test.yml build shelfmark-dev
|
||||
# Pull v1.0.1: docker compose -f docker-compose.bypass-test.yml pull shelfmark-stable
|
||||
|
||||
services:
|
||||
# Dev image from registry
|
||||
shelfmark-dev:
|
||||
image: ghcr.io/calibrain/shelfmark:dev
|
||||
container_name: shelfmark-bypass-dev
|
||||
environment:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
DEBUG: true
|
||||
ports:
|
||||
- 8084:8084
|
||||
volumes:
|
||||
- ./.local/bypass-test/config-dev:/config
|
||||
- ./.local/bypass-test/books:/books
|
||||
- ./.local/bypass-test/log-dev:/var/log/shelfmark
|
||||
- ./.local/bypass-test/tmp:/tmp/shelfmark
|
||||
|
||||
# Stable v1.0.1 for comparison
|
||||
shelfmark-stable:
|
||||
image: ghcr.io/calibrain/shelfmark:1.0.1
|
||||
container_name: shelfmark-bypass-stable
|
||||
environment:
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
DEBUG: true
|
||||
ports:
|
||||
- 8085:8084
|
||||
volumes:
|
||||
- ./.local/bypass-test/config-stable:/config
|
||||
- ./.local/bypass-test/books:/books
|
||||
- ./.local/bypass-test/log-stable:/var/log/shelfmark
|
||||
- ./.local/bypass-test/tmp:/tmp/shelfmark
|
||||
@@ -2,7 +2,7 @@
|
||||
services:
|
||||
shelfmark-lite-dev:
|
||||
extends:
|
||||
file: ./compose/edge/docker-compose.extbp.yml
|
||||
file: ./compose/docker-compose.lite.yml
|
||||
service: shelfmark-lite
|
||||
build:
|
||||
context: .
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
- ./.local/books:/books
|
||||
- ./.local/log:/var/log/shelfmark
|
||||
- ./.local/tmp:/tmp/shelfmark
|
||||
# Download client mount - path must match your torrent/usenet client's volume exactly
|
||||
# Required for torrent / usenet - path must match your download client's volume exactly
|
||||
# - /path/to/downloads:/path/to/downloads
|
||||
|
||||
flaresolverr:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
services:
|
||||
shelfmark-tor-dev:
|
||||
extends:
|
||||
file: ./compose/edge/docker-compose.tor.yml
|
||||
file: ./compose/docker-compose.tor.yml
|
||||
service: shelfmark-tor
|
||||
build:
|
||||
context: .
|
||||
@@ -16,5 +16,5 @@ services:
|
||||
- ./.local/books:/books
|
||||
- ./.local/log:/var/log/shelfmark
|
||||
- ./.local/tmp:/tmp/shelfmark
|
||||
# Download client mount - path must match your torrent/usenet client's volume exactly
|
||||
# Required for torrent / usenet - path must match your download client's volume exactly
|
||||
# - /path/to/downloads:/path/to/downloads
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
services:
|
||||
shelfmark-dev:
|
||||
extends:
|
||||
file: ./compose/edge/docker-compose.yml
|
||||
file: ./compose/docker-compose.yml
|
||||
service: shelfmark
|
||||
build:
|
||||
context: .
|
||||
@@ -18,5 +18,5 @@ services:
|
||||
- ./.local/log:/var/log/shelfmark
|
||||
- ./.local/tmp:/tmp/shelfmark
|
||||
- ./shelfmark:/app/shelfmark:ro
|
||||
# Download client mount - path must match your torrent/usenet client's volume exactly
|
||||
# Required for torrent / usenet - path must match your download client's volume exactly
|
||||
# - /path/to/downloads:/path/to/downloads
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
# - Prowlarr: http://localhost:9696 (no auth by default)
|
||||
# - qBittorrent: http://localhost:8080 (check container logs for temp password)
|
||||
# - Transmission: http://localhost:9091 (admin / admin)
|
||||
# - Deluge: http://localhost:8112 (admin / deluge)
|
||||
# - Deluge: http://localhost:8112 (password: deluge)
|
||||
# - NZBGet: http://localhost:6789 (nzbget / tegbzn6789)
|
||||
# - SABnzbd: http://localhost:8085 (complete setup wizard for API key)
|
||||
# - rTorrent: http://localhost:8000 (admin / admin - if auth enabled)
|
||||
#
|
||||
|
||||
|
||||
services:
|
||||
shelfmark:
|
||||
build:
|
||||
@@ -33,9 +33,10 @@ services:
|
||||
# Use Docker service names for URLs:
|
||||
# - qBittorrent: http://qbittorrent:8080
|
||||
# - Transmission: http://transmission:9091
|
||||
# - Deluge host: deluge (port 58846)
|
||||
# - Deluge Web UI: http://deluge:8112
|
||||
# - NZBGet: http://nzbget:6789
|
||||
# - SABnzbd: http://sabnzbd:8080
|
||||
# - rTorrent: http://rtorrent:80 (XMLRPC via HTTP) or rtorrent (port 5000 for SCGI)
|
||||
ports:
|
||||
- "8084:8084"
|
||||
volumes:
|
||||
@@ -62,6 +63,7 @@ services:
|
||||
- qbittorrent
|
||||
- transmission
|
||||
- deluge
|
||||
- rtorrent
|
||||
restart: unless-stopped
|
||||
|
||||
prowlarr:
|
||||
@@ -159,3 +161,21 @@ services:
|
||||
- "6881:6881"
|
||||
- "6881:6881/udp"
|
||||
restart: unless-stopped
|
||||
|
||||
rtorrent:
|
||||
image: crazymax/rtorrent-rutorrent:latest # linuxserver has deprecated their rtorrent image
|
||||
container_name: test-rtorrent
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- ./.local/test-clients/rtorrent/config:/config
|
||||
- ./.local/test-clients/downloads:/downloads
|
||||
ports:
|
||||
- "8000:8000" # XMLRPC
|
||||
- "8089:8080" # ruTorrent Web UI
|
||||
- "9000:9000" # SCGI port
|
||||
- "50000:50000" # Incoming connections
|
||||
- "6881:6881/udp"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Directory and Volume Setup
|
||||
|
||||
This guide explains how to configure directories and Docker volumes for Shelfmark. It focuses on the difference between the destination folder and your download client paths, and how to make those paths line up inside containers.
|
||||
|
||||
## Conceptual Overview
|
||||
|
||||
```
|
||||
DIRECT DOWNLOADS
|
||||
|
||||
Shelfmark downloads directly -> destination
|
||||
|
||||
TORRENT / USENET
|
||||
|
||||
Prowlarr -> Download client saves to <client path>
|
||||
-> Shelfmark reads from <client path>
|
||||
-> Shelfmark processes to destination
|
||||
```
|
||||
|
||||
Key point: For torrent and usenet downloads, Shelfmark must see the same file path that your download client reports. The container path must match in both containers.
|
||||
|
||||
## Direct Download Setup
|
||||
|
||||
Direct downloads do not use an external download client. A simple two-folder setup is enough.
|
||||
|
||||
Required volumes:
|
||||
|
||||
| Container path | Purpose | Notes |
|
||||
| --- | --- | --- |
|
||||
| `/config` | Settings, database, cover cache | Configurable via `CONFIG_DIR` |
|
||||
| `/books` | Destination folder for completed files | Configurable via `INGEST_DIR` and Settings -> Downloads -> Destination |
|
||||
|
||||
Example `docker-compose`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
shelfmark:
|
||||
image: ghcr.io/calibrain/shelfmark:latest
|
||||
volumes:
|
||||
- /path/to/config:/config
|
||||
- /path/to/books:/books
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Point `/books` to your library ingest folder (Calibre-Web, Booklore, Audiobookshelf, etc) for automatic import.
|
||||
- If you set Books Output Mode to Booklore (API), books are uploaded via API instead of written to `/books`. Audiobooks still use a destination folder.
|
||||
- Ensure `PUID`/`PGID` (or legacy `UID`/`GID`) match the owner of the host directories to avoid permission errors.
|
||||
|
||||
## Torrent / Usenet Setup
|
||||
|
||||
For torrents and usenet, your download client reports a path (for example `/data/torrents/books/MyBook.epub`). Shelfmark must be able to read that exact path inside its own container.
|
||||
|
||||
Required volumes:
|
||||
|
||||
| Container path | Purpose | Notes |
|
||||
| --- | --- | --- |
|
||||
| `/config` | Settings, database, cover cache | Configurable via `CONFIG_DIR` |
|
||||
| `/books` | Destination folder for processed files | Configurable via `INGEST_DIR` |
|
||||
| `<client path>` | Download client path | Must match the download client container path exactly |
|
||||
|
||||
Side-by-side example with qBittorrent:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
shelfmark:
|
||||
volumes:
|
||||
- /path/to/config:/config
|
||||
- /path/to/books:/books
|
||||
- /path/to/downloads:/data/torrents # Must match client
|
||||
|
||||
qbittorrent:
|
||||
volumes:
|
||||
- /path/to/downloads:/data/torrents # Same container path
|
||||
```
|
||||
|
||||
Host paths can be anything. The container path (for example `/data/torrents`) must be identical in both containers.
|
||||
|
||||
### Remote Path Mappings
|
||||
|
||||
If paths cannot match (different machines or a fixed setup), use Remote Path Mappings.
|
||||
|
||||
Where to configure:
|
||||
- Settings -> Advanced -> Remote Path Mappings
|
||||
|
||||
Example:
|
||||
- Client reports `/data/torrents/books/...`
|
||||
- Shelfmark can see the same files at `/downloads/books/...`
|
||||
- Add a mapping from Remote Path `/data/torrents` to Local Path `/downloads`
|
||||
|
||||
## File Processing Options
|
||||
|
||||
### Transfer Method (Torrent / Usenet Only)
|
||||
|
||||
Available methods:
|
||||
- Copy (default). Works everywhere.
|
||||
- Hardlink. Preserves seeding without duplicating files.
|
||||
|
||||
Hardlink requirements and behavior:
|
||||
- Source and destination must be on the same filesystem.
|
||||
- If hardlinking is enabled but not possible, Shelfmark falls back to copying.
|
||||
- Archive extraction is disabled while hardlinking is enabled.
|
||||
- Do not use hardlinking if your destination is a library ingest folder.
|
||||
|
||||
### File Organization
|
||||
|
||||
Shelfmark supports three organization modes for the destination:
|
||||
- None. Keep original filenames from the source.
|
||||
- Rename Only. Rename files using a template.
|
||||
- Rename and Organize. Create folders and rename using templates. Do not use with ingest folders.
|
||||
|
||||
Configure templates in Settings -> Downloads. Template syntax details are documented separately.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- "Download failed - file not found": Path mismatch between Shelfmark and the download client. Ensure container paths match or use Remote Path Mappings.
|
||||
- "Permission denied": `PUID`/`PGID` do not match the host directories. Ensure Shelfmark can read the client path and write to the destination.
|
||||
- "Hardlinks not working" or "Files being copied instead": Source and destination are on different filesystems. Move the destination or accept copy fallback.
|
||||
- "Downloads work but library does not see them": Destination does not point to the library ingest folder. Check Settings -> Downloads -> Destination.
|
||||
- CIFS/SMB shares: Use the `nobrl` mount option to avoid database lock errors. Example: `//server/share /mnt/share cifs nobrl,... 0 0`
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- Environment Variables Reference: `docs/environment-variables.md`
|
||||
- Custom Scripts: `docs/custom-scripts.md`
|
||||
- Installation: `docs/installation.md`
|
||||
- Troubleshooting: `docs/troubleshooting.md`
|
||||
@@ -0,0 +1,184 @@
|
||||
# Custom Scripts
|
||||
|
||||
Shelfmark can run an executable you provide after a download task completes successfully. The script runs after the selected output has finished (for example: transfer to the folder destination, or upload to Booklore).
|
||||
|
||||
|
||||
## Quick Start (Recommended)
|
||||
|
||||
1. Put your script on the machine that runs Shelfmark.
|
||||
1. Make it executable.
|
||||
1. Set it in Shelfmark (Settings -> Advanced -> Custom Script Path).
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
chmod +x /path/to/your/scripts/post_process.sh
|
||||
```
|
||||
|
||||
### Docker Users
|
||||
|
||||
If you run Shelfmark in Docker, the script must exist inside the container. The easiest way is to mount a folder of scripts, then point Shelfmark at the container path in the UI.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
shelfmark:
|
||||
image: ghcr.io/calibrain/shelfmark:latest
|
||||
volumes:
|
||||
- /path/to/your/scripts:/scripts:ro
|
||||
```
|
||||
|
||||
Then set:
|
||||
|
||||
- Settings -> Advanced -> Custom Script Path: `/scripts/post_process.sh`
|
||||
|
||||
<details>
|
||||
<summary>Docker Compose: Configure Via Environment Variables (Optional)</summary>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
shelfmark:
|
||||
environment:
|
||||
- CUSTOM_SCRIPT=/scripts/post_process.sh
|
||||
- CUSTOM_SCRIPT_PATH_MODE=absolute
|
||||
- CUSTOM_SCRIPT_JSON_PAYLOAD=true
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Script Behaviour
|
||||
|
||||
When enabled, Shelfmark runs your script once per successful task:
|
||||
|
||||
```bash
|
||||
<custom_script_path> "<target_path>"
|
||||
```
|
||||
|
||||
- `$1` is always set to the target path.
|
||||
- If **Custom Script JSON Payload** is enabled, Shelfmark writes a JSON document to stdin (UTF-8).
|
||||
- If JSON payload is disabled, stdin is empty (EOF).
|
||||
- Timeout: 300 seconds (5 minutes)
|
||||
- Exit code: `0` = success; anything else = the task is marked as **Error**
|
||||
- Concurrency: downloads can run in parallel, so your script may be invoked concurrently for different tasks.
|
||||
- Runtime: the script runs inside the Shelfmark container (if you use Docker) under the same user as Shelfmark.
|
||||
|
||||
## The Target Path (`$1`)
|
||||
|
||||
Shelfmark chooses a "best single path" for the task:
|
||||
|
||||
- If the output produced exactly one local file: that file path.
|
||||
- If the output produced multiple local files: a directory path (the common parent directory of those files).
|
||||
|
||||
What the target path refers to depends on the output mode:
|
||||
|
||||
- Folder output (`output.mode=folder`, `phase=post_transfer`): the final imported file or folder inside your destination.
|
||||
- Booklore output (`output.mode=booklore`, `phase=post_upload`): the local file or folder that was uploaded (the destination is remote).
|
||||
|
||||
By default, `$1` is an absolute path inside the Shelfmark container (or on your host, if you are not using Docker).
|
||||
|
||||
## JSON Payload (stdin)
|
||||
|
||||
Configure in: Settings -> Advanced -> Custom Script JSON Payload
|
||||
|
||||
When enabled, Shelfmark sends a versioned JSON payload to your script via stdin (and still passes `$1`). This is the recommended way to write robust scripts, especially for multi-file imports (audiobooks) and output-specific context (like Booklore).
|
||||
|
||||
- The JSON payload always includes absolute paths in `paths.*`, even if you set Custom Script Path Mode to `relative` for `$1`.
|
||||
- `output.mode` tells you which output ran.
|
||||
- `output.details` is output-specific. For Booklore output, `output.details.booklore` includes connection details such as `base_url`, `library_id`, and `path_id`.
|
||||
- `phase` indicates when the script is running. Current values: `post_transfer` (folder output), `post_upload` (Booklore output).
|
||||
- `transfer` is only included for outputs that do a local transfer (for example the folder output).
|
||||
|
||||
If JSON payload is disabled, stdin is empty (EOF). Don't `cat` stdin unless you've enabled the payload.
|
||||
|
||||
Example payload shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"phase": "post_transfer",
|
||||
"task": {
|
||||
"task_id": "abc123",
|
||||
"source": "direct",
|
||||
"title": "Foundation",
|
||||
"author": "Isaac Asimov"
|
||||
},
|
||||
"output": {
|
||||
"mode": "folder",
|
||||
"organization_mode": "organize"
|
||||
},
|
||||
"paths": {
|
||||
"destination": "/data/library/books",
|
||||
"target": "/data/library/books/Isaac Asimov/Foundation/Foundation.epub",
|
||||
"final_paths": [
|
||||
"/data/library/books/Isaac Asimov/Foundation/Foundation.epub"
|
||||
]
|
||||
},
|
||||
"transfer": {
|
||||
"op_counts": {"copy": 1, "move": 0, "hardlink": 0},
|
||||
"use_hardlink": false,
|
||||
"is_torrent": false,
|
||||
"preserve_source": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example (bash + jq) (JSON payload must be enabled):
|
||||
|
||||
```bash
|
||||
payload="$(cat)"
|
||||
mode="$(echo "$payload" | jq -r '.output.mode')"
|
||||
title="$(echo "$payload" | jq -r '.task.title')"
|
||||
final_paths="$(echo "$payload" | jq -r '.paths.final_paths[]')"
|
||||
echo "mode=$mode title=$title" >&2
|
||||
echo "$final_paths" >&2
|
||||
```
|
||||
|
||||
Example (Python) (works whether JSON payload is enabled or not):
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
|
||||
target = sys.argv[1]
|
||||
raw = sys.stdin.read()
|
||||
payload = json.loads(raw) if raw.strip() else None
|
||||
|
||||
print(f"target={target}", file=sys.stderr)
|
||||
if payload:
|
||||
print(f"mode={payload['output']['mode']} phase={payload['phase']}", file=sys.stderr)
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Advanced Options</summary>
|
||||
|
||||
### Absolute vs Relative Target Paths
|
||||
|
||||
Configure in: Settings -> Advanced -> Custom Script Path Mode
|
||||
|
||||
This setting controls what gets passed as `$1`:
|
||||
|
||||
- `absolute` (default): pass an absolute path.
|
||||
- `relative`: pass a path relative to the output's "destination root", and run the script with `$PWD` set to that root.
|
||||
|
||||
For folder output, the destination root is your configured destination folder. For Booklore output, it's the local upload folder.
|
||||
|
||||
Example (folder destination is `/data/library/books`, and the imported file ended up in `Isaac Asimov/Foundation/Foundation.epub`):
|
||||
|
||||
```bash
|
||||
# Absolute mode:
|
||||
$PWD is unchanged
|
||||
$1 = /data/library/books/Isaac Asimov/Foundation/Foundation.epub
|
||||
|
||||
# Relative mode:
|
||||
$PWD = /data/library/books
|
||||
$1 = Isaac Asimov/Foundation/Foundation.epub
|
||||
```
|
||||
|
||||
Note: if the target is the destination folder itself, `relative` mode may pass `.`.
|
||||
|
||||
</details>
|
||||
|
||||
## Notes And Caveats
|
||||
|
||||
- **Hardlinks and torrents:** if you use hardlinking to keep seeding, avoid scripts that modify file contents, since hardlinked files share data with the seeding copy.
|
||||
- **Booklore output mode:** scripts run after upload. `$1` will point at the local uploaded file (or staging folder).
|
||||
@@ -0,0 +1,3 @@
|
||||
# Developer Documentation
|
||||
|
||||
TODO
|
||||
@@ -190,6 +190,31 @@ HeadingField(
|
||||
)
|
||||
```
|
||||
|
||||
### CustomComponentField
|
||||
|
||||
Render a frontend-registered custom settings component while still using the
|
||||
decorator-based schema.
|
||||
|
||||
```python
|
||||
from shelfmark.core.settings_registry import CustomComponentField
|
||||
|
||||
CustomComponentField(
|
||||
key="request_policy_editor",
|
||||
component="request_policy_grid", # frontend registry key
|
||||
label="Request Policy Rules",
|
||||
description="Custom editor for policy defaults and matrix rules.",
|
||||
value_fields=[
|
||||
SelectField(key="REQUEST_POLICY_DEFAULT_EBOOK", label="Default Ebook Mode", default="download"),
|
||||
SelectField(key="REQUEST_POLICY_DEFAULT_AUDIOBOOK", label="Default Audiobook Mode", default="download"),
|
||||
TableField(key="REQUEST_POLICY_RULES", label="Rules", columns=_rule_columns, default=[]),
|
||||
],
|
||||
wrap_in_field_wrapper=True, # use standard FieldWrapper label/description layout
|
||||
)
|
||||
```
|
||||
|
||||
When `value_fields` is provided, those backing fields are included in
|
||||
serialization/save/validation automatically and are hidden from the default renderer.
|
||||
|
||||
## Common Field Properties
|
||||
|
||||
All field types support these common properties:
|
||||
@@ -206,6 +231,7 @@ All field types support these common properties:
|
||||
| `requires_restart` | `bool` | `False` | Whether changes require container restart |
|
||||
| `show_when` | `dict` | `None` | Conditional visibility (see below) |
|
||||
| `disabled_when` | `dict` | `None` | Conditional disable (see below) |
|
||||
| `hidden_in_ui` | `bool` | `False` | Hide from default renderer but keep in schema/save path |
|
||||
|
||||
## Conditional Visibility
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# Shelfmark Documentation
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,3 @@
|
||||
# Installation
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,147 @@
|
||||
# Reverse Proxy & Subpath Hosting
|
||||
|
||||
Shelfmark can run behind a reverse proxy at the root path (recommended) or under a subpath like `/shelfmark`.
|
||||
|
||||
## Root path setup (Recommended)
|
||||
|
||||
If you can serve Shelfmark at the root path (`https://shelfmark.example.com/`), leave `URL_BASE` empty. This is the simplest option and avoids extra subpath configuration.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name shelfmark.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://shelfmark:8084;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Subpath setup
|
||||
|
||||
Running Shelfmark under a subpath like `/shelfmark` is supported without extra rewrite rules.
|
||||
|
||||
### 1. Set the base path in Shelfmark
|
||||
|
||||
- **UI**: Settings → Advanced → Base Path → `/shelfmark/`
|
||||
- **Environment variable**: `URL_BASE=/shelfmark/`
|
||||
|
||||
### 2. Configure your reverse proxy
|
||||
|
||||
All Shelfmark paths (UI, API, assets, Socket.IO) are served under the base path. A single location block is enough.
|
||||
|
||||
---
|
||||
|
||||
### Without Authentication Proxy
|
||||
|
||||
**Complete Nginx configuration for subpath deployment:**
|
||||
|
||||
```nginx
|
||||
location /shelfmark/ {
|
||||
proxy_pass http://shelfmark:8084/shelfmark/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
proxy_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### With Authentication Proxy (Authelia, Authentik, etc.)
|
||||
|
||||
Shelfmark supports Proxy Authentication. When enabled, Shelfmark trusts the authenticated user from headers set by your auth proxy.
|
||||
|
||||
#### Shelfmark Settings
|
||||
|
||||
Configure in Settings → Security:
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Authentication Method | Proxy Authentication |
|
||||
| Proxy Auth User Header | `Remote-User` |
|
||||
| Proxy Auth Logout URL | `https://auth.example.com/logout` |
|
||||
| Proxy Auth Admin Group Header | `Remote-Groups` |
|
||||
| Proxy Auth Admin Group Name | `admins` (or your admin group) |
|
||||
|
||||
#### Nginx Configuration with Authelia
|
||||
|
||||
This example uses Authelia snippets. Adapt for your auth proxy.
|
||||
|
||||
**Authelia auth request snippet** (`/etc/nginx/snippets/authelia-authrequest.conf`):
|
||||
|
||||
```nginx
|
||||
location /authelia {
|
||||
internal;
|
||||
proxy_pass http://authelia:9091/api/authz/auth-request;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
**Authelia location snippet** (`/etc/nginx/snippets/authelia-location.conf`):
|
||||
|
||||
```nginx
|
||||
auth_request /authelia;
|
||||
auth_request_set $target_url $scheme://$http_host$request_uri;
|
||||
auth_request_set $user $upstream_http_remote_user;
|
||||
auth_request_set $groups $upstream_http_remote_groups;
|
||||
auth_request_set $name $upstream_http_remote_name;
|
||||
auth_request_set $email $upstream_http_remote_email;
|
||||
proxy_set_header Remote-User $user;
|
||||
proxy_set_header Remote-Groups $groups;
|
||||
proxy_set_header Remote-Name $name;
|
||||
proxy_set_header Remote-Email $email;
|
||||
error_page 401 =302 https://auth.example.com/?rd=$target_url;
|
||||
```
|
||||
|
||||
**Complete Nginx configuration with Authelia:**
|
||||
|
||||
```nginx
|
||||
# Include Authelia auth endpoint in your server block
|
||||
include /etc/nginx/snippets/authelia-authrequest.conf;
|
||||
|
||||
# Main shelfmark location
|
||||
location /shelfmark/ {
|
||||
include /etc/nginx/snippets/authelia-location.conf;
|
||||
|
||||
proxy_pass http://shelfmark:8084/shelfmark/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
proxy_send_timeout 86400;
|
||||
proxy_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health checks
|
||||
|
||||
Health checks work at `/shelfmark/api/health` when using a subpath configuration.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Troubleshooting
|
||||
|
||||
TODO
|
||||
+99
-24
@@ -1,10 +1,54 @@
|
||||
#!/bin/bash
|
||||
LOG_DIR=${LOG_ROOT:-/var/log/}/shelfmark
|
||||
mkdir -p $LOG_DIR
|
||||
LOG_FILE=${LOG_DIR}/shelfmark_entrypoint.log
|
||||
|
||||
# Cleanup any existing files or folders in the log directory
|
||||
rm -rf $LOG_DIR/*
|
||||
is_truthy() {
|
||||
case "${1,,}" in
|
||||
true|yes|1|y) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ENABLE_LOGGING_VALUE="${ENABLE_LOGGING:-true}"
|
||||
LOG_PIPE_DIR=""
|
||||
LOG_PIPE=""
|
||||
TEE_PID=""
|
||||
|
||||
start_file_logging() {
|
||||
local logfile="$1"
|
||||
|
||||
LOG_PIPE_DIR="$(mktemp -d)"
|
||||
LOG_PIPE="${LOG_PIPE_DIR}/shelfmark-log.pipe"
|
||||
mkfifo "$LOG_PIPE"
|
||||
|
||||
tee -a "$logfile" < "$LOG_PIPE" &
|
||||
TEE_PID=$!
|
||||
|
||||
exec 3>&1 4>&2
|
||||
exec > "$LOG_PIPE" 2>&1
|
||||
}
|
||||
|
||||
stop_file_logging() {
|
||||
if [ -z "${TEE_PID:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
exec 1>&3 2>&4
|
||||
exec 3>&- 4>&-
|
||||
|
||||
rm -f "$LOG_PIPE"
|
||||
rmdir "$LOG_PIPE_DIR" 2>/dev/null || true
|
||||
|
||||
wait "$TEE_PID" 2>/dev/null || true
|
||||
TEE_PID=""
|
||||
}
|
||||
|
||||
if is_truthy "$ENABLE_LOGGING_VALUE"; then
|
||||
LOG_DIR=${LOG_ROOT:-/var/log/}/shelfmark
|
||||
mkdir -p "$LOG_DIR"
|
||||
LOG_FILE="${LOG_DIR}/shelfmark_entrypoint.log"
|
||||
|
||||
# Cleanup any existing files or folders in the log directory
|
||||
rm -rf "$LOG_DIR"/*
|
||||
fi
|
||||
|
||||
(
|
||||
if [ "$USING_TOR" = "true" ]; then
|
||||
@@ -12,10 +56,16 @@ rm -rf $LOG_DIR/*
|
||||
fi
|
||||
)
|
||||
|
||||
exec 3>&1 4>&2
|
||||
exec > >(tee -a $LOG_FILE) 2>&1
|
||||
if is_truthy "$ENABLE_LOGGING_VALUE"; then
|
||||
start_file_logging "$LOG_FILE"
|
||||
fi
|
||||
|
||||
echo "Starting entrypoint script"
|
||||
echo "Log file: $LOG_FILE"
|
||||
if is_truthy "$ENABLE_LOGGING_VALUE"; then
|
||||
echo "Log file: $LOG_FILE"
|
||||
else
|
||||
echo "File logging disabled (ENABLE_LOGGING=$ENABLE_LOGGING_VALUE)"
|
||||
fi
|
||||
set -e
|
||||
|
||||
# Print build version
|
||||
@@ -98,6 +148,7 @@ test_write() {
|
||||
|
||||
make_writable() {
|
||||
folder=$1
|
||||
did_full_chown=0
|
||||
set +e
|
||||
test_write $folder
|
||||
is_writable=$?
|
||||
@@ -108,31 +159,58 @@ make_writable() {
|
||||
echo "Folder $folder is not writable, changing ownership"
|
||||
change_ownership $folder
|
||||
chmod -R g+r,g+w $folder || echo "Failed to change group permissions for ${folder}, continuing..."
|
||||
did_full_chown=1
|
||||
fi
|
||||
# Fix any misowned subdirectories/files (e.g., from previous runs as root)
|
||||
if [ -d "$folder" ]; then
|
||||
misowned_count=$(find "$folder" -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) 2>/dev/null | wc -l)
|
||||
if [ "$misowned_count" -gt 0 ]; then
|
||||
echo "Fixing ownership of $misowned_count files/directories in $folder"
|
||||
find "$folder" -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
|
||||
-exec chown "$RUN_UID:$RUN_GID" {} \; 2>/dev/null || true
|
||||
fi
|
||||
if [ "$did_full_chown" -eq 0 ] && [ -d "$folder" ]; then
|
||||
echo "Checking for misowned files/directories in $folder"
|
||||
# Stay on the same filesystem to avoid traversing mounted subpaths
|
||||
# (for example read-only bind mounts under /app in dev setups).
|
||||
find "$folder" -xdev -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
|
||||
-exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true
|
||||
fi
|
||||
test_write $folder || echo "Failed to test write to ${folder}, continuing..."
|
||||
}
|
||||
|
||||
fix_misowned() {
|
||||
folder=$1
|
||||
mkdir -p $folder
|
||||
echo "Checking for misowned files/directories in $folder"
|
||||
# Stay on the same filesystem to avoid traversing mounted subpaths
|
||||
# (for example read-only bind mounts under /app in dev setups).
|
||||
find "$folder" -xdev \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
|
||||
-exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Ensure proper ownership of application directories
|
||||
change_ownership() {
|
||||
folder=$1
|
||||
mkdir -p $folder
|
||||
echo "Changing ownership of $folder to $USERNAME:$RUN_GID"
|
||||
chown -R "${RUN_UID}" "${folder}" || echo "Failed to change user ownership for ${folder}, continuing..."
|
||||
chown -R ":${RUN_GID}" "${folder}" || echo "Failed to change group ownership for ${folder}, continuing..."
|
||||
chown -R "${RUN_UID}:${RUN_GID}" "${folder}" || echo "Failed to change ownership for ${folder}, continuing..."
|
||||
}
|
||||
|
||||
change_ownership /app
|
||||
change_ownership /var/log/shelfmark
|
||||
change_ownership /tmp/shelfmark
|
||||
fix_misowned /app
|
||||
fix_misowned /var/log/shelfmark
|
||||
fix_misowned /tmp/shelfmark
|
||||
|
||||
# SeleniumBase (internal bypasser) writes a patched chromedriver binary (uc_driver)
|
||||
# into its own drivers directory. Some NAS/docker setups can apply restrictive ACLs
|
||||
# to extracted image layers that block non-root writes; ensure the runtime UID owns it.
|
||||
if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
|
||||
set +e
|
||||
SELENIUMBASE_DRIVERS_DIR=$(python3 -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')" 2>/dev/null)
|
||||
set -e
|
||||
|
||||
if [ -n "$SELENIUMBASE_DRIVERS_DIR" ] && [ -d "$SELENIUMBASE_DRIVERS_DIR" ]; then
|
||||
change_ownership "$SELENIUMBASE_DRIVERS_DIR"
|
||||
|
||||
# If the driver already exists, ensure it's executable for the runtime user.
|
||||
if [ -f "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" ]; then
|
||||
chmod +x "${SELENIUMBASE_DRIVERS_DIR}/uc_driver" || echo "Failed to chmod uc_driver, continuing..."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test write to all folders
|
||||
make_writable ${CONFIG_DIR:-/config}
|
||||
@@ -263,8 +341,5 @@ UMASK_VALUE=${UMASK:-0022}
|
||||
echo "Setting umask to $UMASK_VALUE"
|
||||
umask $UMASK_VALUE
|
||||
|
||||
# Stop logging
|
||||
exec 1>&3 2>&4
|
||||
exec 3>&- 4>&-
|
||||
|
||||
stop_file_logging
|
||||
exec sudo -E -u "$USERNAME" HOME=/app $command
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
../baseline-browser-mapping/dist/cli.js
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "shelfmark",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
# [`baseline-browser-mapping`](https://github.com/web-platform-dx/web-features/packages/baseline-browser-mapping)
|
||||
|
||||
By the [W3C WebDX Community Group](https://www.w3.org/community/webdx/) and contributors.
|
||||
|
||||
`baseline-browser-mapping` provides:
|
||||
|
||||
- An `Array` of browsers compatible with Baseline Widely available and Baseline year feature sets via the [`getCompatibleVersions()` function](#get-baseline-widely-available-browser-versions-or-baseline-year-browser-versions).
|
||||
- An `Array`, `Object` or `CSV` as a string describing the Baseline feature set support of all browser versions included in the module's data set via the [`getAllVersions()` function](#get-data-for-all-browser-versions).
|
||||
|
||||
You can use `baseline-browser-mapping` to help you determine minimum browser version support for your chosen Baseline feature set; or to analyse the level of support for different Baseline feature sets in your site's traffic by joining the data with your analytics data.
|
||||
|
||||
## Install for local development
|
||||
|
||||
To install the package, run:
|
||||
|
||||
`npm install --save-dev baseline-browser-mapping`
|
||||
|
||||
`baseline-browser-mapping` depends on `web-features` and `@mdn/browser-compat-data` for core browser version selection, but the data is pre-packaged and minified. This package checks for updates to those modules and the supported [downstream browsers](#downstream-browsers) on a daily basis and is updated frequently. Consider adding a script to your `package.json` to update `baseline-browser-mapping` and using it as part of your build process to ensure your data is as up to date as possible:
|
||||
|
||||
```javascript
|
||||
"scripts": [
|
||||
"refresh-baseline-browser-mapping": "npm i --save-dev baseline-browser-mapping@latest"
|
||||
]
|
||||
```
|
||||
|
||||
The minimum supported NodeJS version for `baseline-browser-mapping` is v8 in alignment with `browserslist`. For NodeJS versions earlier than v13.2, the [`require('baseline-browser-mapping')`](https://nodejs.org/api/modules.html#requireid) syntax should be used to import the module.
|
||||
|
||||
## Keeping `baseline-browser-mapping` up to date
|
||||
|
||||
If you are only using this module to generate minimum browser versions for Baseline Widely available or Baseline year feature sets, you don't need to update this module frequently, as the backward looking data is reasonably stable.
|
||||
|
||||
However, if you are targeting Newly available, using the [`getAllVersions()`](#get-data-for-all-browser-versions) function or heavily relying on the data for downstream browsers, you should update this module more frequently. If you target a feature cut off date within the last two months and your installed version of `baseline-browser-mapping` has data that is more than 2 months old, you will receive a console warning advising you to update to the latest version when you call `getCompatibleVersions()` or `getAllVersions()`.
|
||||
|
||||
If you want to suppress these warnings you can use the `suppressWarnings: true` option in the configuration object passed to `getCompatibleVersions()` or `getAllVersions()`. Alternatively, you can use the `BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA=true` environment variable when running your build process. This module also respects the `BROWSERSLIST_IGNORE_OLD_DATA=true` environment variable. Environment variables can also be provided in a `.env` file from Node 20 onwards; however, this module does not load .env files automatically to avoid conflicts with other libraries with different requirements. You will need to use `process.loadEnvFile()` or a library like `dotenv` to load .env files before `baseline-browser-mapping` is called.
|
||||
|
||||
If you want to ensure [reproducible builds](https://www.wikiwand.com/en/articles/Reproducible_builds), we strongly recommend using the `widelyAvailableOnDate` option to fix the Widely available date on a per build basis to ensure dependent tools provide the same output and you do not produce data staleness warnings. If you are using [`browserslist`](https://github.com/browserslist/browserslist) to target Baseline Widely available, consider automatically updating your `browserslist` configuration in `package.json` or `.browserslistrc` to `baseline widely available on {YYYY-MM-DD}` as part of your build process to ensure the same or sufficiently similar list of minimum browsers is reproduced for historical builds.
|
||||
|
||||
## Importing `baseline-browser-mapping`
|
||||
|
||||
This module exposes two functions: `getCompatibleVersions()` and `getAllVersions()`, both which can be imported directly from `baseline-browser-mapping`:
|
||||
|
||||
```javascript
|
||||
import {
|
||||
getCompatibleVersions,
|
||||
getAllVersions,
|
||||
} from "baseline-browser-mapping";
|
||||
```
|
||||
|
||||
If you want to load the script and data directly in a web page without hosting it yourself, consider using a CDN:
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import {
|
||||
getCompatibleVersions,
|
||||
getAllVersions,
|
||||
} from "https://cdn.jsdelivr.net/npm/baseline-browser-mapping";
|
||||
</script>
|
||||
```
|
||||
|
||||
## Get Baseline Widely available browser versions or Baseline year browser versions
|
||||
|
||||
To get the current list of minimum browser versions compatible with Baseline Widely available features from the core browser set, call the `getCompatibleVersions()` function:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions();
|
||||
```
|
||||
|
||||
Executed on 7th March 2025, the above code returns the following browser versions:
|
||||
|
||||
```javascript
|
||||
[
|
||||
{ browser: "chrome", version: "105", release_date: "2022-09-02" },
|
||||
{
|
||||
browser: "chrome_android",
|
||||
version: "105",
|
||||
release_date: "2022-09-02",
|
||||
},
|
||||
{ browser: "edge", version: "105", release_date: "2022-09-02" },
|
||||
{ browser: "firefox", version: "104", release_date: "2022-08-23" },
|
||||
{
|
||||
browser: "firefox_android",
|
||||
version: "104",
|
||||
release_date: "2022-08-23",
|
||||
},
|
||||
{ browser: "safari", version: "15.6", release_date: "2022-09-02" },
|
||||
{
|
||||
browser: "safari_ios",
|
||||
version: "15.6",
|
||||
release_date: "2022-09-02",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The minimum versions of each browser are not necessarily the final release before the Widely available cutoff date of `TODAY - 30 MONTHS`. Some earlier versions will have supported the full Widely available feature set.
|
||||
|
||||
### `getCompatibleVersions()` configuration options
|
||||
|
||||
`getCompatibleVersions()` accepts an `Object` as an argument with configuration options. The defaults are as follows:
|
||||
|
||||
```javascript
|
||||
{
|
||||
targetYear: undefined,
|
||||
widelyAvailableOnDate: undefined,
|
||||
includeDownstreamBrowsers: false,
|
||||
listAllCompatibleVersions: false,
|
||||
suppressWarnings: false
|
||||
}
|
||||
```
|
||||
|
||||
#### `targetYear`
|
||||
|
||||
The `targetYear` option returns the minimum browser versions compatible with all **Baseline Newly available** features at the end of the specified calendar year. For example, calling:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
targetYear: 2020,
|
||||
});
|
||||
```
|
||||
|
||||
Returns the following versions:
|
||||
|
||||
```javascript
|
||||
[
|
||||
{ browser: "chrome", version: "87", release_date: "2020-11-19" },
|
||||
{
|
||||
browser: "chrome_android",
|
||||
version: "87",
|
||||
release_date: "2020-11-19",
|
||||
},
|
||||
{ browser: "edge", version: "87", release_date: "2020-11-19" },
|
||||
{ browser: "firefox", version: "83", release_date: "2020-11-17" },
|
||||
{
|
||||
browser: "firefox_android",
|
||||
version: "83",
|
||||
release_date: "2020-11-17",
|
||||
},
|
||||
{ browser: "safari", version: "14", release_date: "2020-09-16" },
|
||||
{ browser: "safari_ios", version: "14", release_date: "2020-09-16" },
|
||||
];
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The minimum version of each browser is not necessarily the final version released in that calendar year. In the above example, Firefox 84 was the final version released in 2020; however Firefox 83 supported all of the features that were interoperable at the end of 2020.
|
||||
> [!WARNING]
|
||||
> You cannot use `targetYear` and `widelyAavailableDate` together. Please only use one of these options at a time.
|
||||
|
||||
#### `widelyAvailableOnDate`
|
||||
|
||||
The `widelyAvailableOnDate` option returns the minimum versions compatible with Baseline Widely available on a specified date in the format `YYYY-MM-DD`:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
widelyAvailableOnDate: `2023-04-05`,
|
||||
});
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> This option is useful if you provide a versioned library that targets Baseline Widely available on each version's release date and you need to provide a statement on minimum supported browser versions in your documentation.
|
||||
|
||||
#### `includeDownstreamBrowsers`
|
||||
|
||||
Setting `includeDownstreamBrowsers` to `true` will include browsers outside of the Baseline core browser set where it is possible to map those browsers to an upstream Chromium or Gecko version:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
includeDownstreamBrowsers: true,
|
||||
});
|
||||
```
|
||||
|
||||
For more information on downstream browsers, see [the section on downstream browsers](#downstream-browsers) below.
|
||||
|
||||
#### `includeKaiOS`
|
||||
|
||||
KaiOS is an operating system and app framework based on the Gecko engine from Firefox. KaiOS is based on the Gecko engine and feature support can be derived from the upstream Gecko version that each KaiOS version implements. However KaiOS requires other considerations beyond feature compatibility to ensure a good user experience as it runs on device types that do not have either mouse and keyboard or touch screen input in the way that all the other browsers supported by this module do.
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
includeDownstreamBrowsers: true,
|
||||
includeKaiOS: true,
|
||||
});
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Including KaiOS requires you to include all downstream browsers using the `includeDownstreamBrowsers` option.
|
||||
|
||||
#### `listAllCompatibleVersions`
|
||||
|
||||
Setting `listAllCompatibleVersions` to true will include the minimum versions of each compatible browser, and all the subsequent versions:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
listAllCompatibleVersions: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### `suppressWarnings`
|
||||
|
||||
Setting `suppressWarnings` to `true` will suppress the console warning about old data:
|
||||
|
||||
```javascript
|
||||
getCompatibleVersions({
|
||||
suppressWarnings: true,
|
||||
});
|
||||
```
|
||||
|
||||
## Get data for all browser versions
|
||||
|
||||
You may want to obtain data on all the browser versions available in this module for use in an analytics solution or dashboard. To get details of each browser version's level of Baseline support, call the `getAllVersions()` function:
|
||||
|
||||
```javascript
|
||||
import { getAllVersions } from "baseline-browser-mapping";
|
||||
|
||||
getAllVersions();
|
||||
```
|
||||
|
||||
By default, this function returns an `Array` of `Objects` and excludes downstream browsers:
|
||||
|
||||
```javascript
|
||||
[
|
||||
...
|
||||
{
|
||||
browser: "firefox_android", // Browser name
|
||||
version: "125", // Browser version
|
||||
release_date: "2024-04-16", // Release date
|
||||
year: 2023, // Baseline year feature set the version supports
|
||||
wa_compatible: true // Whether the browser version supports Widely available
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
For browser versions in `@mdn/browser-compat-data` that were released before Baseline can be defined, i.e. Baseline 2015, the `year` property is always the string: `"pre_baseline"`.
|
||||
|
||||
### Understanding which browsers support Newly available features
|
||||
|
||||
You may want to understand which recent browser versions support all Newly available features. You can replace the `wa_compatible` property with a `supports` property using the `useSupport` option:
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
useSupports: true,
|
||||
});
|
||||
```
|
||||
|
||||
The `supports` property is optional and has two possible values:
|
||||
|
||||
- `widely` for browser versions that support all Widely available features.
|
||||
- `newly` for browser versions that support all Newly available features.
|
||||
|
||||
Browser versions that do not support Widely or Newly available will not include the `support` property in the `array` or `object` outputs, and in the CSV output, the `support` column will contain an empty string. Browser versions that support all Newly available features also support all Widely available features.
|
||||
|
||||
### `getAllVersions()` Configuration options
|
||||
|
||||
`getAllVersions()` accepts an `Object` as an argument with configuration options. The defaults are as follows:
|
||||
|
||||
```javascript
|
||||
{
|
||||
includeDownstreamBrowsers: false,
|
||||
outputFormat: "array",
|
||||
suppressWarnings: false
|
||||
}
|
||||
```
|
||||
|
||||
#### `includeDownstreamBrowsers` (in `getAllVersions()` output)
|
||||
|
||||
As with `getCompatibleVersions()`, you can set `includeDownstreamBrowsers` to `true` to include the Chromium and Gecko downstream browsers [listed below](#list-of-downstream-browsers).
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
includeDownstreamBrowsers: true,
|
||||
});
|
||||
```
|
||||
|
||||
Downstream browsers include the same properties as core browsers, as well as the `engine`they use and `engine_version`, for example:
|
||||
|
||||
```javascript
|
||||
[
|
||||
...
|
||||
{
|
||||
browser: "samsunginternet_android",
|
||||
version: "27.0",
|
||||
release_date: "2024-11-06",
|
||||
engine: "Blink",
|
||||
engine_version: "125",
|
||||
year: 2023,
|
||||
supports: "widely"
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
#### `includeKaiOS` (in `getAllVersions()` output)
|
||||
|
||||
As with `getCompatibleVersions()` you can include KaiOS in your output. The same requirement to have `includeDownstreamBrowsers: true` applies.
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
includeDownstreamBrowsers: true,
|
||||
includeKaiOS: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### `suppressWarnings` (in `getAllVersions()` output)
|
||||
|
||||
As with `getCompatibleVersions()`, you can set `suppressWarnings` to `true` to suppress the console warning about old data:
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
suppressWarnings: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### `outputFormat`
|
||||
|
||||
By default, this function returns an `Array` of `Objects` which can be manipulated in Javascript or output to JSON.
|
||||
|
||||
To return an `Object` that nests keys , set `outputFormat` to `object`:
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
outputFormat: "object",
|
||||
});
|
||||
```
|
||||
|
||||
In thise case, `getAllVersions()` returns a nested object with the browser [IDs listed below](#list-of-downstream-browsers) as keys, and versions as keys within them:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"chrome": {
|
||||
"53": {
|
||||
"year": 2016,
|
||||
"release_date": "2016-09-07"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Downstream browsers will include extra fields for `engine` and `engine_versions`
|
||||
|
||||
```javascript
|
||||
{
|
||||
...
|
||||
"webview_android": {
|
||||
"53": {
|
||||
"year": 2016,
|
||||
"release_date": "2016-09-07",
|
||||
"engine": "Blink",
|
||||
"engine_version": "53"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
To return a `String` in CSV format, set `outputFormat` to `csv`:
|
||||
|
||||
```javascript
|
||||
getAllVersions({
|
||||
outputFormat: "csv",
|
||||
});
|
||||
```
|
||||
|
||||
`getAllVersions` returns a `String` with a header row and comma-separated values for each browser version that you can write to a file or pass to another service. Core browsers will have "NULL" as the value for their `engine` and `engine_version`:
|
||||
|
||||
```csv
|
||||
"browser","version","year","supports","release_date","engine","engine_version"
|
||||
...
|
||||
"chrome","24","pre_baseline","","2013-01-10","NULL","NULL"
|
||||
...
|
||||
"chrome","53","2016","","2016-09-07","NULL","NULL"
|
||||
...
|
||||
"firefox","135","2024","widely","2025-02-04","NULL","NULL"
|
||||
"firefox","136","2024","newly","2025-03-04","NULL","NULL"
|
||||
...
|
||||
"ya_android","20.12","2020","year_only","2020-12-20","Blink","87"
|
||||
...
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The above example uses `"includeDownstreamBrowsers": true`
|
||||
|
||||
### Static resources
|
||||
|
||||
The outputs of `getAllVersions()` are available as JSON or CSV files generated on a daily basis and hosted on GitHub pages:
|
||||
|
||||
- Core browsers only
|
||||
- [Array](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_array.json)
|
||||
- [Object](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_object.json)
|
||||
- [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions.csv)
|
||||
- Core browsers only, with `supports` property
|
||||
- [Array](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_array_with_supports.json)
|
||||
- [Object](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_object_with_supports.json)
|
||||
- [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/all_versions_with_supports.csv)
|
||||
- Including downstream browsers
|
||||
- [Array](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_array.json)
|
||||
- [Object](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_object.json)
|
||||
- [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions.csv)
|
||||
- Including downstream browsers with `supports` property
|
||||
- [Array](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_array_with_supports.json)
|
||||
- [Object](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_object_with_supports.json)
|
||||
- [CSV](https://web-platform-dx.github.io/baseline-browser-mapping/with_downstream/all_versions_with_supports.csv)
|
||||
|
||||
These files are updated on a daily basis.
|
||||
|
||||
## CLI
|
||||
|
||||
`baseline-browser-mapping` includes a command line interface that exposes the same data and options as the `getCompatibleVersions()` function. To learn more about using the CLI, run:
|
||||
|
||||
```sh
|
||||
npx baseline-browser-mapping --help
|
||||
```
|
||||
|
||||
## Downstream browsers
|
||||
|
||||
### Limitations
|
||||
|
||||
The browser versions in this module come from two different sources:
|
||||
|
||||
- MDN's `browser-compat-data` module.
|
||||
- Parsed user agent strings provided by [useragents.io](https://useragents.io/)
|
||||
|
||||
MDN `browser-compat-data` is an authoritative source of information for the browsers it contains. The release dates for the Baseline core browser set and the mapping of downstream browsers to Chromium versions should be considered accurate.
|
||||
|
||||
Browser mappings from useragents.io are provided on a best effort basis. They assume that browser vendors are accurately stating the Chromium version they have implemented. The initial set of version mappings was derived from a bulk export in November 2024. This version was iterated over with a Regex match looking for a major Chrome version and a corresponding version of the browser in question, e.g.:
|
||||
|
||||
`Mozilla/5.0 (Linux; U; Android 10; en-US; STK-L21 Build/HUAWEISTK-L21) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/100.0.4896.58 UCBrowser/13.8.2.1324 Mobile Safari/537.36`
|
||||
|
||||
Shows UC Browser Mobile 13.8 implementing Chromium 100, and:
|
||||
|
||||
`Mozilla/5.0 (Linux; arm_64; Android 11; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.123 YaBrowser/24.10.2.123.00 SA/3 Mobile Safari/537.36`
|
||||
|
||||
Shows Yandex Browser Mobile 24.10 implementing Chromium 128. The Chromium version from this string is mapped to the corresponding Chrome version from MDN `browser-compat-data`.
|
||||
|
||||
> [!NOTE]
|
||||
> Where possible, approximate release dates have been included based on useragents.io "first seen" data. useragents.io does not have "first seen" dates prior to June 2020. However, these browsers' Baseline compatibility is determined by their Chromium or Gecko version, so their release dates are more informative than critical.
|
||||
|
||||
This data is updated on a daily basis using a [script](https://github.com/web-platform-dx/web-features/tree/main/scripts/refresh-downstream.ts) triggered by a GitHub [action](https://github.com/web-platform-dx/web-features/tree/main/.github/workflows/refresh_downstream.yml). Useragents.io provides a private API for this module which exposes the last 7 days of newly seen user agents for the currently tracked browsers. If a new major version of one of the tracked browsers is encountered with a Chromium version that meets or exceeds the previous latest version of that browser, it is added to the [src/data/downstream-browsers.json](src/data/downstream-browsers.json) file with the date it was first seen by useragents.io as its release date.
|
||||
|
||||
KaiOS is an exception - its upstream version mappings are handled separately from the other browsers because they happen very infrequently.
|
||||
|
||||
### List of downstream browsers
|
||||
|
||||
| Browser | ID | Core | Source |
|
||||
| --------------------- | ------------------------- | ------- | ------------------------- |
|
||||
| Chrome | `chrome` | `true` | MDN `browser-compat-data` |
|
||||
| Chrome for Android | `chrome_android` | `true` | MDN `browser-compat-data` |
|
||||
| Edge | `edge` | `true` | MDN `browser-compat-data` |
|
||||
| Firefox | `firefox` | `true` | MDN `browser-compat-data` |
|
||||
| Firefox for Android | `firefox_android` | `true` | MDN `browser-compat-data` |
|
||||
| Safari | `safari` | `true` | MDN `browser-compat-data` |
|
||||
| Safari on iOS | `safari_ios` | `true` | MDN `browser-compat-data` |
|
||||
| Opera | `opera` | `false` | MDN `browser-compat-data` |
|
||||
| Opera Android | `opera_android` | `false` | MDN `browser-compat-data` |
|
||||
| Samsung Internet | `samsunginternet_android` | `false` | MDN `browser-compat-data` |
|
||||
| WebView Android | `webview_android` | `false` | MDN `browser-compat-data` |
|
||||
| QQ Browser Mobile | `qq_android` | `false` | useragents.io |
|
||||
| UC Browser Mobile | `uc_android` | `false` | useragents.io |
|
||||
| Yandex Browser Mobile | `ya_android` | `false` | useragents.io |
|
||||
| KaiOS | `kai_os` | `false` | Manual |
|
||||
| Facebook for Android | `facebook_android` | `false` | useragents.io |
|
||||
| Instagram for Android | `instagram_android` | `false` | useragents.io |
|
||||
|
||||
> [!NOTE]
|
||||
> All the non-core browsers currently included implement Chromium or Gecko. Their inclusion in any of the above methods is based on the Baseline feature set supported by the Chromium or Gecko version they implement, not their release date.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "baseline-browser-mapping",
|
||||
"main": "./dist/index.cjs",
|
||||
"version": "2.9.19",
|
||||
"description": "A library for obtaining browser versions with their maximum supported Baseline feature set and Widely Available status.",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./legacy": {
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"jsdelivr": "./dist/index.js",
|
||||
"files": [
|
||||
"dist/*",
|
||||
"!dist/scripts/*",
|
||||
"LICENSE.txt",
|
||||
"README.md"
|
||||
],
|
||||
"types": "./dist/index.d.ts",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"fix-cli-permissions": "output=$(npx baseline-browser-mapping 2>&1); path=$(printf '%s\n' \"$output\" | sed -n 's/^.*: \\(.*\\): Permission denied$/\\1/p; t; s/^\\(.*\\): Permission denied$/\\1/p'); if [ -n \"$path\" ]; then echo \"Permission denied for: $path\"; echo \"Removing $path ...\"; rm -rf \"$path\"; else echo \"$output\"; fi",
|
||||
"test:format": "npx prettier --check .",
|
||||
"test:lint": "npx eslint .",
|
||||
"test:jasmine": "npx jasmine",
|
||||
"test:jasmine-browser": "npx jasmine-browser-runner runSpecs --config ./spec/support/jasmine-browser.js",
|
||||
"test": "npm run build && npm run fix-cli-permissions && npm run test:format && npm run test:lint && npm run test:jasmine && npm run test:jasmine-browser",
|
||||
"build": "rm -rf dist; npx prettier . --write; rollup -c; rm -rf ./dist/scripts/expose-data.d.ts ./dist/cli.d.ts",
|
||||
"refresh-downstream": "npx tsx scripts/refresh-downstream.ts",
|
||||
"refresh-static": "npx tsx scripts/refresh-static.ts",
|
||||
"update-data-file": "npx tsx scripts/update-data-file.ts; npx prettier ./src/data/data.js --write",
|
||||
"update-data-dependencies": "npm i @mdn/browser-compat-data@latest web-features@latest -D",
|
||||
"check-data-changes": "git diff --name-only | grep -q '^src/data/data.js$' && echo 'changes-available=TRUE' || echo 'changes-available=FALSE'"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@mdn/browser-compat-data": "^7.2.5",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^12.1.3",
|
||||
"@types/node": "^22.15.17",
|
||||
"eslint-plugin-new-with-error": "^5.0.0",
|
||||
"jasmine": "^5.8.0",
|
||||
"jasmine-browser-runner": "^3.0.0",
|
||||
"jasmine-spec-reporter": "^7.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"rollup": "^4.44.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.35.0",
|
||||
"web-features": "^3.14.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/web-platform-dx/baseline-browser-mapping.git"
|
||||
}
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "shelfmark",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.19"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.19"
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,12 @@ Formerly *Calibre Web Automated Book Downloader (CWABD)*
|
||||
|
||||
Shelfmark is a unified web interface for searching and aggregating books and audiobook downloads from multiple sources - all in one place. Works out of the box with popular web sources, no configuration required. Add metadata providers, additional release sources, and download clients to create a single hub for building your digital library.
|
||||
|
||||
**Fully standalone** - no external dependencies required. Works great alongside library tools like [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated), [Booklore](https://github.com/booklore-app/booklore) or [Audiobookshelf](https://github.com/advplyr/audiobookshelf) for automatic import.
|
||||
**Fully standalone** - no external dependencies required. Works great alongside the following library tools, with support for automatic imports:
|
||||
- [Calibre](https://calibre-ebook.com/)
|
||||
- [Calibre-Web](https://github.com/janeczku/calibre-web)
|
||||
- [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated)
|
||||
- [Booklore](https://github.com/booklore-app/booklore)
|
||||
- [Audiobookshelf](https://github.com/advplyr/audiobookshelf)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
@@ -41,9 +46,9 @@ Shelfmark is a unified web interface for searching and aggregating books and aud
|
||||
|
||||
### Installation
|
||||
|
||||
1. Download the docker-compose file:
|
||||
1. Download the [docker-compose file](compose/docker-compose.yml):
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.yml
|
||||
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.yml
|
||||
```
|
||||
|
||||
2. Start the service:
|
||||
@@ -51,8 +56,6 @@ Shelfmark is a unified web interface for searching and aggregating books and aud
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> **Edge users**: If you're tracking the main branch (`:dev` tag), use compose files from `compose/edge/` instead.
|
||||
|
||||
3. Open `http://localhost:8084`
|
||||
|
||||
That's it! Configure settings through the web interface as needed.
|
||||
@@ -98,9 +101,12 @@ Environment variables work for initial setup and Docker deployments. They serve
|
||||
| `SEARCH_MODE` | `direct` or `universal` | `direct` |
|
||||
| `USING_TOR` | Enable Tor routing (requires `NET_ADMIN` capability) | `false` |
|
||||
|
||||
See the full [Environment Variables Reference](docs/environment-variables.md) for all available options.
|
||||
|
||||
Some of the additional options available in Settings:
|
||||
- **Fast Download Key** - Use your paid account to skip Cloudflare challenges entirely and use faster, direct downloads
|
||||
- **Prowlarr** - Configure indexers and download clients to download books and audiobooks
|
||||
- **AudiobookBay** - Web scraping source for audiobook torrents (audiobooks only)
|
||||
- **IRC** - Add details for IRC book sources and download directly from the UI
|
||||
- **Library Link** - Add a link to your Calibre-Web or Booklore instance in the UI header
|
||||
- **File processing** - Customiseable download paths, file renaming and directory creation with template-based renaming
|
||||
@@ -120,7 +126,7 @@ The full-featured image with built-in Cloudflare bypass.
|
||||
#### Enable Tor Routing
|
||||
Routes all traffic through Tor for enhanced privacy:
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.tor.yml
|
||||
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.tor.yml
|
||||
docker compose -f docker-compose.tor.yml up -d
|
||||
```
|
||||
|
||||
@@ -134,11 +140,11 @@ A smaller image without the built-in Cloudflare bypasser. Ideal for:
|
||||
|
||||
- **External bypassers** - Already running FlareSolverr or ByParr for other services
|
||||
- **Fast downloads** - Using fast download sources
|
||||
- **Alternative sources only** - Exclusively using Prowlarr, IRC, or other sources
|
||||
- **Alternative sources only** - Exclusively using Prowlarr, AudiobookBay, IRC, or other sources
|
||||
- **Audiobooks** - Using Shelfmark exclusively for audiobooks
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.lite.yml
|
||||
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.lite.yml
|
||||
docker compose -f docker-compose.lite.yml up -d
|
||||
```
|
||||
|
||||
@@ -146,9 +152,17 @@ If you need Cloudflare bypass with the Lite image, configure an external resolve
|
||||
|
||||
## 🔐 Authentication
|
||||
|
||||
Authentication is optional but recommended for shared or exposed instances. Enable in Settings.
|
||||
Authentication is optional but recommended for shared or exposed instances. Three authentication methods are available in Settings:
|
||||
|
||||
**Alternative**: If you're running Calibre-Web, you can reuse its user database by mounting it:
|
||||
**1. Single Username/Password**
|
||||
|
||||
**2. Proxy (Forward) Authentication**
|
||||
|
||||
Proxy auth trusts headers set by your reverse proxy (e.g. `X-Auth-User`). Ensure Shelfmark is not directly exposed, and configure your proxy to strip/overwrite these headers for all inbound requests.
|
||||
|
||||
**3. Calibre-Web Database**
|
||||
|
||||
If you're running Calibre-Web, you can reuse its user database by mounting it:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
## New Features
|
||||
|
||||
### OIDC Authentication (#606, #612)
|
||||
- **OIDC login** with PKCE flow, auto-discovery, and group-based admin mapping
|
||||
- **Auto-provisioning** of OIDC users (configurable) and email-based account linking
|
||||
- **Password fallback** when OIDC is enabled to prevent admin lockout
|
||||
- Backwards compatible with all existing auth modes (no-auth, builtin, proxy, CWA)
|
||||
|
||||
### Multi-User Support (#606, #612, #613)
|
||||
- **User management** -create, edit, and delete users with admin/user roles
|
||||
- **Per-user settings** -custom download destinations, BookLore library/path, email recipients, and `{User}` template variable
|
||||
- **Per-user download visibility** -non-admins only see their own downloads
|
||||
|
||||
### Multi-User Request System (#615, #617, #620)
|
||||
- **Book request workflow** -users can request books with notes; admins review, approve, and fulfil requests
|
||||
- **Policy-based configuration** -set download/request/block policies per content type or per source (e.g. allow direct downloads, set Prowlarr to request-only)
|
||||
- **Per-user policy overrides** for tailored access control
|
||||
- **New Activity Sidebar** -replaces downloads sidebar, combining active downloads with requests; sidebar can now be pinned
|
||||
- Request retry support and admin-level request management
|
||||
|
||||
### Notification Support (#618)
|
||||
- **Apprise-based notifications** for request events and download completions
|
||||
- Configurable globally or per user, with full customization of events and notification services
|
||||
- Expanded activity cards with detailed request info and file management
|
||||
|
||||
### AudiobookBay Release Source (#619, #621, #623)
|
||||
- **New release source** -search AudiobookBay for audiobook torrents directly from the UI
|
||||
- Results include title, language, format, and size
|
||||
- Downloads via configured torrent client with audiobook-specific category support
|
||||
- Configurable hostname, max search pages, and rate limit delay
|
||||
|
||||
### Email Output Mode (#603, #604)
|
||||
- **Email delivery** as an alternative output mode for downloaded books
|
||||
- Per-user email recipient configuration
|
||||
|
||||
## Improvements
|
||||
- Admin-configurable visibility for self-settings options (delivery preferences, notifications) (#625)
|
||||
- BookLore Bookdrop API destination support as an alternative to specific library selection (#625)
|
||||
- Download path options for all torrent clients (#625)
|
||||
- Add tag support to qBittorrent downloads (#610 by @dawescc)
|
||||
- Add threading to file system operations for improved performance (#602)
|
||||
- Enhanced custom scripting -JSON download info, more consistent activation, decoupled from staging (#591)
|
||||
- Hardlink-before-move optimization for file transfers (#591)
|
||||
- New BookLore API file formats (#591)
|
||||
- Improved login cookie naming for reverse proxy compatibility (#591)
|
||||
- Fix Transmission URL parsing (#591)
|
||||
- Fix healthcheck starvation during large file processing (#591)
|
||||
@@ -14,4 +14,5 @@ emoji
|
||||
rarfile
|
||||
qbittorrent-api
|
||||
transmission-rpc
|
||||
deluge-client
|
||||
authlib>=1.6.6,<1.7
|
||||
apprise>=1.9.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pyvirtualdisplay
|
||||
pyautogui
|
||||
seleniumbase>=4.45.6
|
||||
seleniumbase==4.45.10
|
||||
python-xlib
|
||||
|
||||
Executable
+439
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate markdown documentation for environment variables from the settings registry.
|
||||
|
||||
This script extracts all settings that support environment variable configuration
|
||||
and generates a comprehensive markdown file documenting each option.
|
||||
|
||||
Usage:
|
||||
python scripts/generate_env_docs.py [--output path/to/output.md]
|
||||
|
||||
The generated documentation includes:
|
||||
- Environment variable name
|
||||
- Description
|
||||
- Type (string, number, boolean, etc.)
|
||||
- Default value
|
||||
- Organizational grouping by settings tab/group
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
|
||||
def get_field_type_name(field) -> str:
|
||||
"""Get a human-readable type name for a field."""
|
||||
from shelfmark.core.settings_registry import (
|
||||
CheckboxField,
|
||||
MultiSelectField,
|
||||
NumberField,
|
||||
OrderableListField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
TextField,
|
||||
)
|
||||
|
||||
if isinstance(field, CheckboxField):
|
||||
return "boolean"
|
||||
elif isinstance(field, NumberField):
|
||||
return "number"
|
||||
elif isinstance(field, SelectField):
|
||||
return "string (choice)"
|
||||
elif isinstance(field, MultiSelectField):
|
||||
return "string (comma-separated)"
|
||||
elif isinstance(field, OrderableListField):
|
||||
return "JSON array"
|
||||
elif isinstance(field, PasswordField):
|
||||
return "string (secret)"
|
||||
elif isinstance(field, TextField):
|
||||
return "string"
|
||||
else:
|
||||
return "string"
|
||||
|
||||
|
||||
def format_default_value(field) -> str:
|
||||
"""Format the default value for display."""
|
||||
default = field.default
|
||||
|
||||
if default is None:
|
||||
return "_none_"
|
||||
elif isinstance(default, bool):
|
||||
return f"`{str(default).lower()}`"
|
||||
elif isinstance(default, (int, float)):
|
||||
return f"`{default}`"
|
||||
elif isinstance(default, str):
|
||||
if default == "":
|
||||
return "_empty string_"
|
||||
return f"`{default}`"
|
||||
elif isinstance(default, list):
|
||||
if not default:
|
||||
return "_empty list_"
|
||||
# For simple lists, show comma-separated values
|
||||
if all(isinstance(item, str) for item in default):
|
||||
return f"`{','.join(default)}`"
|
||||
# For complex lists (e.g., OrderableListField defaults), summarize
|
||||
return f"_see UI for defaults_"
|
||||
else:
|
||||
return f"`{default}`"
|
||||
|
||||
|
||||
def get_select_options(field) -> Optional[List[str]]:
|
||||
"""Get the available options for a SelectField.
|
||||
|
||||
Returns options formatted as 'value (label)' or just 'value' if they match,
|
||||
so users know the actual values to use in environment variables.
|
||||
"""
|
||||
from shelfmark.core.settings_registry import SelectField
|
||||
|
||||
if not isinstance(field, SelectField):
|
||||
return None
|
||||
|
||||
options = field.options
|
||||
if callable(options):
|
||||
try:
|
||||
options = options()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not options:
|
||||
return None
|
||||
|
||||
result = []
|
||||
for opt in options:
|
||||
value = opt.get("value", "")
|
||||
label = opt.get("label", "")
|
||||
|
||||
# Format as "value (label)" unless they're the same or value is empty
|
||||
if value == "":
|
||||
result.append(f'`""` ({label})')
|
||||
elif value == label or not label:
|
||||
result.append(f"`{value}`")
|
||||
else:
|
||||
result.append(f"`{value}` ({label})")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _generate_bootstrap_env_docs() -> List[str]:
|
||||
"""Generate documentation for bootstrap environment variables from env.py."""
|
||||
# These are environment variables defined in env.py that are used before
|
||||
# the settings registry is available
|
||||
bootstrap_vars = [
|
||||
{
|
||||
"name": "CONFIG_DIR",
|
||||
"description": "Directory for storing configuration files and plugin settings.",
|
||||
"type": "string (path)",
|
||||
"default": "/config",
|
||||
},
|
||||
{
|
||||
"name": "LOG_ROOT",
|
||||
"description": "Root directory for log files.",
|
||||
"type": "string (path)",
|
||||
"default": "/var/log/",
|
||||
},
|
||||
{
|
||||
"name": "TMP_DIR",
|
||||
"description": "Staging directory for downloads before moving to destination.",
|
||||
"type": "string (path)",
|
||||
"default": "/tmp/shelfmark",
|
||||
},
|
||||
{
|
||||
"name": "ENABLE_LOGGING",
|
||||
"description": "Enable file logging under LOG_ROOT/shelfmark/ (including shelfmark.log and startup logs).",
|
||||
"type": "boolean",
|
||||
"default": "true",
|
||||
},
|
||||
{
|
||||
"name": "FLASK_HOST",
|
||||
"description": "Host address for the Flask web server.",
|
||||
"type": "string",
|
||||
"default": "0.0.0.0",
|
||||
},
|
||||
{
|
||||
"name": "FLASK_PORT",
|
||||
"description": "Port number for the Flask web server.",
|
||||
"type": "number",
|
||||
"default": "8084",
|
||||
},
|
||||
{
|
||||
"name": "SESSION_COOKIE_SECURE",
|
||||
"description": "Enable secure cookies (requires HTTPS).",
|
||||
"type": "boolean",
|
||||
"default": "false",
|
||||
},
|
||||
{
|
||||
"name": "CWA_DB_PATH",
|
||||
"description": "Path to the Calibre-Web database for authentication integration.",
|
||||
"type": "string (path)",
|
||||
"default": "/auth/app.db",
|
||||
},
|
||||
{
|
||||
"name": "DOCKERMODE",
|
||||
"description": "Indicates the application is running inside a Docker container.",
|
||||
"type": "boolean",
|
||||
"default": "false",
|
||||
},
|
||||
{
|
||||
"name": "ONBOARDING",
|
||||
"description": "Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage).",
|
||||
"type": "boolean",
|
||||
"default": "true",
|
||||
},
|
||||
]
|
||||
|
||||
lines = [
|
||||
"## Bootstrap Configuration",
|
||||
"",
|
||||
"These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.",
|
||||
"",
|
||||
"| Variable | Description | Type | Default |",
|
||||
"|----------|-------------|------|---------|",
|
||||
]
|
||||
|
||||
for var in bootstrap_vars:
|
||||
lines.append(f"| `{var['name']}` | {var['description']} | {var['type']} | `{var['default']}` |")
|
||||
|
||||
lines.append("")
|
||||
lines.append("<details>")
|
||||
lines.append("<summary>Detailed descriptions</summary>")
|
||||
lines.append("")
|
||||
|
||||
for var in bootstrap_vars:
|
||||
lines.append(f"#### `{var['name']}`")
|
||||
lines.append("")
|
||||
lines.append(var["description"])
|
||||
lines.append("")
|
||||
lines.append(f"- **Type:** {var['type']}")
|
||||
lines.append(f"- **Default:** `{var['default']}`")
|
||||
lines.append("")
|
||||
|
||||
lines.append("</details>")
|
||||
lines.append("")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def generate_env_docs() -> str:
|
||||
"""Generate markdown documentation for all environment variables."""
|
||||
# Import settings modules to ensure all settings are registered
|
||||
import shelfmark.config.settings # noqa: F401
|
||||
import shelfmark.release_sources.irc.settings # noqa: F401
|
||||
import shelfmark.release_sources.prowlarr.settings # noqa: F401
|
||||
import shelfmark.metadata_providers.hardcover # noqa: F401
|
||||
import shelfmark.metadata_providers.openlibrary # noqa: F401
|
||||
import shelfmark.metadata_providers.googlebooks # noqa: F401
|
||||
|
||||
from shelfmark.core.settings_registry import (
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
get_all_groups,
|
||||
get_all_settings_tabs,
|
||||
)
|
||||
|
||||
tabs = get_all_settings_tabs()
|
||||
groups = {g.name: g for g in get_all_groups()}
|
||||
|
||||
# Organize tabs by group
|
||||
grouped_tabs: Dict[Optional[str], List] = {None: []}
|
||||
for group_name in groups:
|
||||
grouped_tabs[group_name] = []
|
||||
|
||||
for tab in tabs:
|
||||
group_name = tab.group
|
||||
if group_name not in grouped_tabs:
|
||||
grouped_tabs[group_name] = []
|
||||
grouped_tabs[group_name].append(tab)
|
||||
|
||||
# Build markdown output
|
||||
lines = [
|
||||
"# Environment Variables",
|
||||
"",
|
||||
"This document lists all configuration options that can be set via environment variables.",
|
||||
"",
|
||||
"> **Auto-generated** - Do not edit manually. Run `python scripts/generate_env_docs.py` to regenerate.",
|
||||
"",
|
||||
"## Table of Contents",
|
||||
"",
|
||||
]
|
||||
|
||||
# Generate TOC
|
||||
toc_entries = [
|
||||
"- [Bootstrap Configuration](#bootstrap-configuration)",
|
||||
]
|
||||
|
||||
# Ungrouped tabs first
|
||||
for tab in grouped_tabs.get(None, []):
|
||||
anchor = tab.display_name.lower().replace(" ", "-")
|
||||
toc_entries.append(f"- [{tab.display_name}](#{anchor})")
|
||||
|
||||
# Then grouped tabs
|
||||
for group_name, group in groups.items():
|
||||
group_tabs = grouped_tabs.get(group_name, [])
|
||||
if group_tabs:
|
||||
anchor = group.display_name.lower().replace(" ", "-")
|
||||
toc_entries.append(f"- [{group.display_name}](#{anchor})")
|
||||
for tab in group_tabs:
|
||||
sub_anchor = f"{group.display_name}-{tab.display_name}".lower().replace(" ", "-")
|
||||
toc_entries.append(f" - [{tab.display_name}](#{sub_anchor})")
|
||||
|
||||
lines.extend(toc_entries)
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
# Add bootstrap environment variables documentation
|
||||
lines.extend(_generate_bootstrap_env_docs())
|
||||
|
||||
# Generate documentation for ungrouped tabs
|
||||
for tab in grouped_tabs.get(None, []):
|
||||
lines.extend(_generate_tab_docs(tab))
|
||||
|
||||
# Generate documentation for grouped tabs
|
||||
for group_name, group in groups.items():
|
||||
group_tabs = grouped_tabs.get(group_name, [])
|
||||
if not group_tabs:
|
||||
continue
|
||||
|
||||
lines.append(f"## {group.display_name}")
|
||||
lines.append("")
|
||||
|
||||
for tab in group_tabs:
|
||||
lines.extend(_generate_tab_docs(tab, group_prefix=group.display_name))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]:
|
||||
"""Generate documentation for a single settings tab."""
|
||||
from shelfmark.core.settings_registry import ActionButton, HeadingField
|
||||
|
||||
lines = []
|
||||
|
||||
# Section header
|
||||
if group_prefix:
|
||||
lines.append(f"### {group_prefix}: {tab.display_name}")
|
||||
anchor_id = f"{group_prefix}-{tab.display_name}".lower().replace(" ", "-")
|
||||
else:
|
||||
lines.append(f"## {tab.display_name}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Collect env-supported fields
|
||||
env_fields = []
|
||||
for field in tab.fields:
|
||||
# Skip non-value fields
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
continue
|
||||
|
||||
# Skip fields that don't support ENV vars
|
||||
if not getattr(field, "env_supported", True):
|
||||
continue
|
||||
|
||||
env_fields.append(field)
|
||||
|
||||
if not env_fields:
|
||||
lines.append("_No environment variables for this section._")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
# Generate table
|
||||
lines.append("| Variable | Description | Type | Default |")
|
||||
lines.append("|----------|-------------|------|---------|")
|
||||
|
||||
for field in env_fields:
|
||||
env_var = field.get_env_var_name()
|
||||
description = field.description or field.label
|
||||
# Clean up description for table (remove newlines, escape pipes)
|
||||
description = description.replace("\n", " ").replace("|", "\\|").strip()
|
||||
|
||||
field_type = get_field_type_name(field)
|
||||
default = format_default_value(field)
|
||||
|
||||
lines.append(f"| `{env_var}` | {description} | {field_type} | {default} |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Add detailed documentation for each field
|
||||
lines.append("<details>")
|
||||
lines.append("<summary>Detailed descriptions</summary>")
|
||||
lines.append("")
|
||||
|
||||
for field in env_fields:
|
||||
env_var = field.get_env_var_name()
|
||||
lines.append(f"#### `{env_var}`")
|
||||
lines.append("")
|
||||
lines.append(f"**{field.label}**")
|
||||
lines.append("")
|
||||
|
||||
if field.description:
|
||||
lines.append(field.description)
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"- **Type:** {get_field_type_name(field)}")
|
||||
lines.append(f"- **Default:** {format_default_value(field)}")
|
||||
|
||||
if getattr(field, "required", False):
|
||||
lines.append("- **Required:** Yes")
|
||||
|
||||
if getattr(field, "requires_restart", False):
|
||||
lines.append("- **Requires restart:** Yes")
|
||||
|
||||
# Show options for SelectField
|
||||
options = get_select_options(field)
|
||||
if options:
|
||||
lines.append(f"- **Options:** {', '.join(options)}")
|
||||
|
||||
# Show constraints for NumberField
|
||||
from shelfmark.core.settings_registry import NumberField
|
||||
if isinstance(field, NumberField):
|
||||
constraints = []
|
||||
if field.min_value is not None:
|
||||
constraints.append(f"min: {field.min_value}")
|
||||
if field.max_value is not None:
|
||||
constraints.append(f"max: {field.max_value}")
|
||||
if constraints:
|
||||
lines.append(f"- **Constraints:** {', '.join(constraints)}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
lines.append("</details>")
|
||||
lines.append("")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate markdown documentation for environment variables"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=Path,
|
||||
default=project_root / "docs" / "environment-variables.md",
|
||||
help="Output file path (default: docs/environment-variables.md)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stdout",
|
||||
action="store_true",
|
||||
help="Print to stdout instead of file",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
docs = generate_env_docs()
|
||||
|
||||
if args.stdout:
|
||||
print(docs)
|
||||
else:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(docs)
|
||||
print(f"Generated: {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+163
-39
@@ -23,9 +23,10 @@ Web UIs:
|
||||
- Deluge: http://localhost:8112
|
||||
- NZBGet: http://localhost:6789
|
||||
- SABnzbd: http://localhost:8085
|
||||
- rTorrent: http://localhost:8000 (web ui http://localhost:8089 via ruTorrent)
|
||||
|
||||
Prerequisites (for running this script locally):
|
||||
pip install requests transmission-rpc deluge-client qbittorrent-api
|
||||
pip install requests transmission-rpc qbittorrent-api
|
||||
|
||||
First-Time Setup:
|
||||
qBittorrent:
|
||||
@@ -37,10 +38,7 @@ First-Time Setup:
|
||||
- No setup needed, credentials pre-configured (admin/admin)
|
||||
|
||||
Deluge:
|
||||
1. Access Web UI at http://localhost:8112 (default password: deluge)
|
||||
2. Add auth line to .local/test-clients/deluge/config/auth:
|
||||
echo "admin:admin:10" >> .local/test-clients/deluge/config/auth
|
||||
3. Restart: docker restart test-deluge
|
||||
- Access Web UI at http://localhost:8112 (default password: deluge)
|
||||
|
||||
NZBGet:
|
||||
- No setup needed, credentials pre-configured (admin/admin)
|
||||
@@ -53,6 +51,7 @@ First-Time Setup:
|
||||
|
||||
import sys
|
||||
import time
|
||||
from xmlrpc import client
|
||||
|
||||
# Test configuration - matches docker-compose.test-clients.yml
|
||||
CONFIG = {
|
||||
@@ -78,10 +77,11 @@ CONFIG = {
|
||||
"password": "admin",
|
||||
},
|
||||
"deluge": {
|
||||
"host": "localhost",
|
||||
"port": 58846,
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"url": "http://localhost:8112",
|
||||
"password": "deluge",
|
||||
},
|
||||
"rtorrent": {
|
||||
"url": "http://localhost:8000/RPC2",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -325,46 +325,79 @@ def test_transmission():
|
||||
|
||||
|
||||
def test_deluge():
|
||||
"""Test Deluge connection."""
|
||||
"""Test Deluge Web UI (JSON-RPC) connection."""
|
||||
import requests
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("Testing Deluge")
|
||||
print("=" * 50)
|
||||
|
||||
base_url = CONFIG["deluge"]["url"].rstrip("/")
|
||||
password = CONFIG["deluge"]["password"]
|
||||
rpc_url = f"{base_url}/json"
|
||||
|
||||
def rpc_call(session: requests.Session, rpc_id: int, method: str, *params):
|
||||
payload = {"id": rpc_id, "method": method, "params": list(params)}
|
||||
resp = session.post(rpc_url, json=payload, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("error"):
|
||||
err = data["error"]
|
||||
if isinstance(err, dict):
|
||||
raise Exception(err.get("message") or str(err))
|
||||
raise Exception(str(err))
|
||||
return data.get("result")
|
||||
|
||||
try:
|
||||
from deluge_client import DelugeRPCClient
|
||||
session = requests.Session()
|
||||
|
||||
client = DelugeRPCClient(
|
||||
host=CONFIG["deluge"]["host"],
|
||||
port=CONFIG["deluge"]["port"],
|
||||
username=CONFIG["deluge"]["username"],
|
||||
password=CONFIG["deluge"]["password"],
|
||||
)
|
||||
# Authenticate to Deluge Web
|
||||
if rpc_call(session, 1, "auth.login", password) is not True:
|
||||
raise Exception("Authentication failed (check Deluge Web UI password)")
|
||||
|
||||
# Test connection
|
||||
client.connect()
|
||||
version = client.call("daemon.info")
|
||||
# Ensure Deluge Web is connected to a daemon
|
||||
if rpc_call(session, 2, "web.connected") is not True:
|
||||
hosts = rpc_call(session, 3, "web.get_hosts") or []
|
||||
if not hosts:
|
||||
raise Exception(
|
||||
"Deluge Web UI isn't connected to Deluge core (no hosts configured). "
|
||||
"Add/connect a daemon in Deluge Web UI → Connection Manager."
|
||||
)
|
||||
|
||||
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"}:
|
||||
host_id = entry[0]
|
||||
break
|
||||
|
||||
rpc_call(session, 4, "web.connect", host_id)
|
||||
|
||||
if rpc_call(session, 5, "web.connected") is not True:
|
||||
raise Exception(
|
||||
"Deluge Web UI couldn't connect to Deluge core. "
|
||||
"Check Deluge Web UI → Connection Manager."
|
||||
)
|
||||
|
||||
version = rpc_call(session, 6, "daemon.info")
|
||||
print(f" Connected to Deluge {version}")
|
||||
|
||||
# Get torrent list
|
||||
torrents = client.call("core.get_torrents_status", {}, ["name"])
|
||||
torrents = rpc_call(session, 7, "core.get_torrents_status", {}, ["name"]) or {}
|
||||
print(f" Active torrents: {len(torrents)}")
|
||||
|
||||
# Test adding a torrent (then remove it)
|
||||
print(" Testing add/remove torrent...")
|
||||
torrent_id = client.call("core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True})
|
||||
torrent_id = rpc_call(session, 8, "core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True})
|
||||
|
||||
if torrent_id:
|
||||
torrent_id = str(torrent_id)
|
||||
print(f" Added test torrent: {torrent_id[:20]}...")
|
||||
|
||||
# Get status
|
||||
status = client.call("core.get_torrent_status", torrent_id, ["state", "progress"])
|
||||
state = status.get(b"state", b"unknown")
|
||||
if isinstance(state, bytes):
|
||||
state = state.decode()
|
||||
print(f" Status: {state}")
|
||||
status = rpc_call(session, 9, "core.get_torrent_status", torrent_id, ["state", "progress"]) or {}
|
||||
state = status.get("state", "unknown") if isinstance(status, dict) else "unknown"
|
||||
progress = status.get("progress", 0) if isinstance(status, dict) else 0
|
||||
print(f" Status: {state} ({progress:.1f}%)")
|
||||
|
||||
# Remove it
|
||||
client.call("core.remove_torrent", torrent_id, True)
|
||||
rpc_call(session, 10, "core.remove_torrent", torrent_id, True)
|
||||
print(" Removed test torrent")
|
||||
else:
|
||||
print(" WARNING: Could not add test torrent")
|
||||
@@ -372,19 +405,109 @@ def test_deluge():
|
||||
print(" SUCCESS: Deluge is working!")
|
||||
return True
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(" ERROR: Could not connect to Deluge Web UI")
|
||||
print(" Is the container running? docker ps | grep deluge")
|
||||
return False
|
||||
except requests.exceptions.Timeout:
|
||||
print(" ERROR: Deluge Web UI connection timed out")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
if "auth" in str(e).lower() or "login" in str(e).lower():
|
||||
print(" Check Deluge Web UI password (default: deluge)")
|
||||
return False
|
||||
|
||||
def test_rtorrent():
|
||||
"""Test rTorrent connection."""
|
||||
print("\n" + "=" * 50)
|
||||
print("Testing rTorrent")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
import xmlrpc.client
|
||||
|
||||
url = "http://localhost:8000/RPC2"
|
||||
client = xmlrpc.client.ServerProxy(url)
|
||||
|
||||
# Test connection
|
||||
version = client.system.library_version()
|
||||
print(f" Connected to rTorrent {version}")
|
||||
|
||||
# default download directory test
|
||||
default_dir = client.directory.default()
|
||||
print(f" Default download directory: {default_dir}")
|
||||
|
||||
# Get torrent list
|
||||
torrents = client.download_list()
|
||||
print(f" Active torrents: {len(torrents)}")
|
||||
|
||||
# Test adding a torrent (then remove it)
|
||||
print(" Testing add/remove torrent...")
|
||||
|
||||
label = "automated"
|
||||
|
||||
commands = []
|
||||
if label:
|
||||
commands.append(f"d.custom1.set={label}")
|
||||
|
||||
download_dir = "/downloads"
|
||||
if download_dir:
|
||||
commands.append(f"d.directory_base.set={download_dir}")
|
||||
|
||||
# rtorrent is weird in that it doesn't return the torrent ID/hash on add
|
||||
client.load.start("", TEST_MAGNET, ";".join(commands))
|
||||
|
||||
# but we know that it is 3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0 from the magnet link
|
||||
torrent_id = "3B245504CF5F11BBDBE1201CEA6A6BF45AEE1BC0" # rtorrent uses uppercase hashes
|
||||
print(f" Added test torrent: {torrent_id}")
|
||||
|
||||
torrents = client.download_list()
|
||||
print(f" Active torrents: {len(torrents)}")
|
||||
|
||||
torrent_list = client.d.multicall.filtered(
|
||||
"",
|
||||
"default",
|
||||
f"equal={{d.hash=,cat={torrent_id}}}"
|
||||
"d.hash=",
|
||||
"d.state=",
|
||||
"d.completed_bytes=",
|
||||
"d.size_bytes=",
|
||||
"d.down.rate=",
|
||||
"d.up.rate=",
|
||||
"d.custom1=",
|
||||
"d.complete=",
|
||||
)
|
||||
torrent = torrent_list[0]
|
||||
|
||||
if not torrent:
|
||||
print(" ERROR: Could not find added torrent in list")
|
||||
return False
|
||||
|
||||
# let's test the base path call
|
||||
details = client.d.multicall.filtered(
|
||||
"",
|
||||
"default",
|
||||
f"equal=d.hash=,cat={torrent_id}",
|
||||
"d.base_path=",
|
||||
)
|
||||
|
||||
base_path = details[0][0] if details else None
|
||||
|
||||
print(f" Base path: {base_path}")
|
||||
client.d.erase(torrent_id)
|
||||
print(" Removed test torrent")
|
||||
|
||||
print(" SUCCESS: rTorrent is working!")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
print(" ERROR: deluge-client not installed")
|
||||
print(" Run: pip install deluge-client")
|
||||
print(" ERROR: xmlrpc.client not available")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
if "Connection refused" in str(e):
|
||||
print(" Is the container running? docker ps | grep deluge")
|
||||
elif "Bad login" in str(e) or "auth" in str(e).lower():
|
||||
print("\n Deluge auth setup required:")
|
||||
print(" 1. Add 'admin:admin:10' to .local/test-clients/deluge/config/auth")
|
||||
print(" 2. Restart: docker restart test-deluge")
|
||||
print(" 3. Or access Web UI at http://localhost:8112 (password: deluge)")
|
||||
print(" Is the container running? docker ps | grep rtorrent")
|
||||
return False
|
||||
|
||||
|
||||
@@ -410,6 +533,7 @@ def main():
|
||||
results["qbittorrent"] = test_qbittorrent()
|
||||
results["transmission"] = test_transmission()
|
||||
results["deluge"] = test_deluge()
|
||||
results["rtorrent"] = test_rtorrent()
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 50)
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import threading
|
||||
from typing import Optional, Dict, Any, Callable, List
|
||||
|
||||
from flask_socketio import SocketIO
|
||||
from flask_socketio import SocketIO, join_room, leave_room
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,6 +20,10 @@ class WebSocketManager:
|
||||
self._on_first_connect_callbacks: List[Callable[[], None]] = []
|
||||
self._on_all_disconnect_callbacks: List[Callable[[], None]] = []
|
||||
self._needs_rewarm = False # Flag to trigger warmup callbacks on next connect
|
||||
self._user_rooms: Dict[str, int] = {} # room_name -> ref count
|
||||
self._sid_rooms: Dict[str, str] = {} # sid -> room_name
|
||||
self._rooms_lock = threading.Lock()
|
||||
self._queue_status_fn: Optional[Callable] = None # Reference to queue_status()
|
||||
|
||||
def init_app(self, app, socketio: SocketIO):
|
||||
"""Initialize the WebSocket manager with Flask-SocketIO instance."""
|
||||
@@ -100,19 +104,86 @@ class WebSocketManager:
|
||||
"""Check if WebSocket is enabled and ready."""
|
||||
return self._enabled and self.socketio is not None
|
||||
|
||||
def set_queue_status_fn(self, fn: Callable):
|
||||
"""Set the queue_status function reference for per-room filtering."""
|
||||
self._queue_status_fn = fn
|
||||
|
||||
def _increment_user_room_locked(self, room: str):
|
||||
self._user_rooms[room] = self._user_rooms.get(room, 0) + 1
|
||||
|
||||
def _decrement_user_room_locked(self, room: str):
|
||||
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]):
|
||||
current_room = self._sid_rooms.get(sid)
|
||||
if current_room == room:
|
||||
return
|
||||
|
||||
if current_room is not None:
|
||||
leave_room(current_room, sid=sid)
|
||||
if current_room.startswith("user_"):
|
||||
self._decrement_user_room_locked(current_room)
|
||||
self._sid_rooms.pop(sid, None)
|
||||
|
||||
if room is not None:
|
||||
join_room(room, sid=sid)
|
||||
self._sid_rooms[sid] = room
|
||||
if room.startswith("user_"):
|
||||
self._increment_user_room_locked(room)
|
||||
|
||||
def sync_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None):
|
||||
"""Ensure a SID is in exactly one room matching the current session scope."""
|
||||
room: Optional[str] = None
|
||||
if is_admin:
|
||||
room = "admins"
|
||||
elif db_user_id is not None:
|
||||
room = f"user_{db_user_id}"
|
||||
|
||||
with self._rooms_lock:
|
||||
self._set_sid_room_locked(sid, room)
|
||||
|
||||
def join_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None):
|
||||
"""Join the appropriate room based on user role."""
|
||||
self.sync_user_room(sid, is_admin, db_user_id)
|
||||
|
||||
def leave_user_room(self, sid: str, is_admin: bool = False, db_user_id: Optional[int] = 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]):
|
||||
"""Broadcast status update to all connected clients."""
|
||||
"""Broadcast status update to all connected clients, filtered by user room."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
# When calling socketio.emit() outside event handlers, it broadcasts by default
|
||||
self.socketio.emit('status_update', status_data)
|
||||
logger.debug(f"Broadcasted status update to all clients")
|
||||
# Admins (and no-auth users) get full status
|
||||
self.socketio.emit('status_update', status_data, to="admins")
|
||||
|
||||
# Each user room gets filtered status
|
||||
with self._rooms_lock:
|
||||
active_rooms = list(self._user_rooms.keys())
|
||||
|
||||
if active_rooms and self._queue_status_fn:
|
||||
for room in active_rooms:
|
||||
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}")
|
||||
|
||||
logger.debug("Broadcasted status update to all rooms")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting status update: {e}")
|
||||
|
||||
def broadcast_download_progress(self, book_id: str, progress: float, status: str):
|
||||
def broadcast_download_progress(self, book_id: str, progress: float, status: str, user_id: Optional[int] = None):
|
||||
"""Broadcast download progress update for a specific book."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
@@ -123,8 +194,14 @@ class WebSocketManager:
|
||||
'progress': progress,
|
||||
'status': status
|
||||
}
|
||||
# When calling socketio.emit() outside event handlers, it broadcasts by default
|
||||
self.socketio.emit('download_progress', data)
|
||||
# Admins always see all progress
|
||||
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}")
|
||||
|
||||
@@ -10,6 +10,7 @@ import requests
|
||||
from shelfmark.bypass import BypassCancelledException
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.download import network
|
||||
@@ -29,10 +30,11 @@ BACKOFF_CAP = 10.0
|
||||
|
||||
def _fetch_via_bypasser(target_url: str) -> Optional[str]:
|
||||
"""Make a single request to the external bypasser service. Returns HTML or None."""
|
||||
bypasser_url = config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191")
|
||||
raw_bypasser_url = config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191")
|
||||
bypasser_path = config.get("EXT_BYPASSER_PATH", "/v1")
|
||||
bypasser_timeout = config.get("EXT_BYPASSER_TIMEOUT", 60000)
|
||||
|
||||
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.")
|
||||
return None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download.outputs.booklore import (
|
||||
BookloreConfig,
|
||||
BookloreError,
|
||||
booklore_list_libraries,
|
||||
booklore_login,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_BOOKLORE_OPTIONS_CACHE: dict[str, Any] = {
|
||||
"key": None,
|
||||
"library_options": [],
|
||||
"path_options": [],
|
||||
}
|
||||
|
||||
|
||||
def _get_booklore_cache_key(base_url: str, username: str, password: str) -> str:
|
||||
return f"{base_url}|{username}|{hash(password)}"
|
||||
|
||||
|
||||
def _get_booklore_select_options(
|
||||
base_url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
# library_id/path_id are not used for login/library listing
|
||||
booklore_config = BookloreConfig(
|
||||
base_url=base_url.rstrip("/"),
|
||||
username=username,
|
||||
password=password,
|
||||
library_id=1,
|
||||
path_id=1,
|
||||
verify_tls=True,
|
||||
refresh_after_upload=True,
|
||||
)
|
||||
|
||||
token = booklore_login(booklore_config)
|
||||
libraries = booklore_list_libraries(booklore_config, token) or []
|
||||
logger.debug("Booklore libraries response: %s", libraries)
|
||||
|
||||
library_options: list[dict[str, Any]] = []
|
||||
path_options: list[dict[str, Any]] = []
|
||||
|
||||
for library in libraries:
|
||||
if not isinstance(library, dict):
|
||||
continue
|
||||
|
||||
library_id = library.get("id")
|
||||
if library_id is None:
|
||||
continue
|
||||
|
||||
library_name = str(library.get("name") or f"Library {library_id}")
|
||||
library_id_str = str(library_id)
|
||||
|
||||
library_options.append({"value": library_id_str, "label": library_name})
|
||||
|
||||
paths = library.get("paths") or []
|
||||
if not isinstance(paths, list):
|
||||
continue
|
||||
|
||||
for path in paths:
|
||||
if not isinstance(path, dict):
|
||||
continue
|
||||
|
||||
path_id = path.get("id")
|
||||
if path_id is None:
|
||||
continue
|
||||
|
||||
path_label = str(path.get("path") or f"Path {path_id}")
|
||||
path_options.append(
|
||||
{
|
||||
"value": str(path_id),
|
||||
"label": f"{library_name}: {path_label}",
|
||||
"childOf": library_id_str,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Booklore options built: libraries=%d paths=%d",
|
||||
len(library_options),
|
||||
len(path_options),
|
||||
)
|
||||
|
||||
cache_key = _get_booklore_cache_key(base_url, username, password)
|
||||
_BOOKLORE_OPTIONS_CACHE.update(
|
||||
{
|
||||
"key": cache_key,
|
||||
"library_options": library_options,
|
||||
"path_options": path_options,
|
||||
}
|
||||
)
|
||||
|
||||
return library_options, path_options
|
||||
|
||||
|
||||
def _get_booklore_cached_options(
|
||||
base_url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
cache_key = _get_booklore_cache_key(base_url, username, password)
|
||||
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
|
||||
return (
|
||||
_BOOKLORE_OPTIONS_CACHE.get("library_options", []),
|
||||
_BOOKLORE_OPTIONS_CACHE.get("path_options", []),
|
||||
)
|
||||
|
||||
return _get_booklore_select_options(base_url, username, password)
|
||||
|
||||
|
||||
def get_booklore_library_options() -> list[dict[str, Any]]:
|
||||
"""Build Booklore library options dynamically from config."""
|
||||
if config.get("BOOKS_OUTPUT_MODE", "folder") != "booklore":
|
||||
return []
|
||||
|
||||
base_url = str(config.get("BOOKLORE_HOST", "") or "").strip().rstrip("/")
|
||||
username = str(config.get("BOOKLORE_USERNAME", "") or "").strip()
|
||||
password = config.get("BOOKLORE_PASSWORD", "") or ""
|
||||
|
||||
if not base_url or not username or not password:
|
||||
return []
|
||||
|
||||
cache_key = _get_booklore_cache_key(base_url, username, password)
|
||||
|
||||
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}")
|
||||
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
|
||||
return _BOOKLORE_OPTIONS_CACHE.get("library_options", [])
|
||||
return []
|
||||
|
||||
|
||||
def get_booklore_path_options() -> list[dict[str, Any]]:
|
||||
"""Build Booklore path options dynamically from config."""
|
||||
if config.get("BOOKS_OUTPUT_MODE", "folder") != "booklore":
|
||||
return []
|
||||
|
||||
base_url = str(config.get("BOOKLORE_HOST", "") or "").strip().rstrip("/")
|
||||
username = str(config.get("BOOKLORE_USERNAME", "") or "").strip()
|
||||
password = config.get("BOOKLORE_PASSWORD", "") or ""
|
||||
|
||||
if not base_url or not username or not password:
|
||||
return []
|
||||
|
||||
cache_key = _get_booklore_cache_key(base_url, username, password)
|
||||
|
||||
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}")
|
||||
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
|
||||
return _BOOKLORE_OPTIONS_CACHE.get("path_options", [])
|
||||
return []
|
||||
|
||||
|
||||
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:
|
||||
value = current_values.get(key)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
if default is None:
|
||||
return config.get(key)
|
||||
return config.get(key, default)
|
||||
|
||||
base_url = str(_get_value("BOOKLORE_HOST", "") or "").strip().rstrip("/")
|
||||
username = str(_get_value("BOOKLORE_USERNAME", "") or "").strip()
|
||||
password = _get_value("BOOKLORE_PASSWORD", "") or ""
|
||||
|
||||
if not base_url:
|
||||
return {"success": False, "message": "Booklore URL is required"}
|
||||
if not username:
|
||||
return {"success": False, "message": "Booklore username is required"}
|
||||
if not password:
|
||||
return {"success": False, "message": "Booklore password is required"}
|
||||
|
||||
try:
|
||||
library_options, _ = _get_booklore_select_options(base_url, username, password)
|
||||
|
||||
message = "Connected to Booklore"
|
||||
if library_options:
|
||||
message = f"Connected to Booklore ({len(library_options)} libraries)"
|
||||
|
||||
return {"success": True, "message": message}
|
||||
except BookloreError as exc:
|
||||
return {"success": False, "message": str(exc)}
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
value = current_values.get(key)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
if default is None:
|
||||
return config.get(key)
|
||||
return config.get(key, default)
|
||||
|
||||
settings = {
|
||||
"EMAIL_SMTP_HOST": _get_value("EMAIL_SMTP_HOST", ""),
|
||||
"EMAIL_SMTP_PORT": _get_value("EMAIL_SMTP_PORT", 587),
|
||||
"EMAIL_SMTP_SECURITY": _get_value("EMAIL_SMTP_SECURITY", "starttls"),
|
||||
"EMAIL_SMTP_USERNAME": _get_value("EMAIL_SMTP_USERNAME", ""),
|
||||
"EMAIL_SMTP_PASSWORD": _get_value("EMAIL_SMTP_PASSWORD", ""),
|
||||
"EMAIL_FROM": _get_value("EMAIL_FROM", ""),
|
||||
"EMAIL_SUBJECT_TEMPLATE": _get_value("EMAIL_SUBJECT_TEMPLATE", "{Title}"),
|
||||
"EMAIL_SMTP_TIMEOUT_SECONDS": _get_value("EMAIL_SMTP_TIMEOUT_SECONDS", 60),
|
||||
"EMAIL_ALLOW_UNVERIFIED_TLS": _get_value("EMAIL_ALLOW_UNVERIFIED_TLS", 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}"}
|
||||
|
||||
@@ -113,6 +113,7 @@ FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
|
||||
# =============================================================================
|
||||
|
||||
SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
|
||||
SESSION_COOKIE_NAME = "shelfmark_session"
|
||||
CWA_DB_PATH = _resolve_cwa_db_path()
|
||||
|
||||
|
||||
@@ -133,6 +134,14 @@ TOR_VARIANT_AVAILABLE = shutil.which("tor") is not None
|
||||
USING_TOR = string_to_bool(os.getenv("USING_TOR", "false"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Onboarding
|
||||
# =============================================================================
|
||||
|
||||
# Set to false to skip the onboarding wizard entirely (useful for ephemeral storage)
|
||||
ONBOARDING = string_to_bool(os.getenv("ONBOARDING", "true"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Debug/development settings
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Configuration migration helpers."""
|
||||
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
_DEPRECATED_SETTINGS_RESTRICTION_KEYS = (
|
||||
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN",
|
||||
"CWA_RESTRICT_SETTINGS_TO_ADMIN",
|
||||
"RESTRICT_SETTINGS_TO_ADMIN",
|
||||
)
|
||||
|
||||
|
||||
def _as_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _pick_legacy_settings_restriction(config: dict[str, Any]) -> bool | None:
|
||||
"""Pick the best legacy admin-restriction value to migrate."""
|
||||
auth_method = str(config.get("AUTH_METHOD", "")).strip().lower()
|
||||
|
||||
if (
|
||||
auth_method == "proxy"
|
||||
and "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config
|
||||
):
|
||||
return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN"))
|
||||
|
||||
if auth_method == "cwa" and "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config:
|
||||
return _as_bool(config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN"))
|
||||
|
||||
if "RESTRICT_SETTINGS_TO_ADMIN" in config:
|
||||
return _as_bool(config.get("RESTRICT_SETTINGS_TO_ADMIN"))
|
||||
|
||||
if "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config:
|
||||
return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN"))
|
||||
|
||||
if "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config:
|
||||
return _as_bool(config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN"))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def migrate_security_settings(
|
||||
*,
|
||||
load_security_config: Callable[[], dict[str, Any]],
|
||||
load_users_config: Callable[[], dict[str, Any]],
|
||||
save_users_config: Callable[[dict[str, Any]], None],
|
||||
ensure_config_dir: Callable[[], None],
|
||||
get_config_path: Callable[[], Any],
|
||||
sync_builtin_admin_user: Callable[[str, str], None],
|
||||
logger: Any,
|
||||
) -> None:
|
||||
"""Migrate legacy security keys and sync builtin admin credentials."""
|
||||
try:
|
||||
config = load_security_config()
|
||||
users_config = load_users_config()
|
||||
migrated_security = False
|
||||
migrated_users = False
|
||||
|
||||
if "USE_CWA_AUTH" in config:
|
||||
old_value = config.pop("USE_CWA_AUTH")
|
||||
if "AUTH_METHOD" not in config:
|
||||
if old_value:
|
||||
config["AUTH_METHOD"] = "cwa"
|
||||
logger.info("Migrated USE_CWA_AUTH=True to AUTH_METHOD='cwa'")
|
||||
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'")
|
||||
migrated_security = True
|
||||
else:
|
||||
logger.info("Removed deprecated USE_CWA_AUTH setting (AUTH_METHOD already exists)")
|
||||
migrated_security = True
|
||||
|
||||
if "RESTRICT_SETTINGS_TO_ADMIN" not in users_config:
|
||||
legacy_restrict = _pick_legacy_settings_restriction(config)
|
||||
if legacy_restrict is not None:
|
||||
save_users_config({"RESTRICT_SETTINGS_TO_ADMIN": legacy_restrict})
|
||||
migrated_users = True
|
||||
logger.info(
|
||||
"Migrated legacy settings-admin restriction to users.RESTRICT_SETTINGS_TO_ADMIN="
|
||||
f"{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}")
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
if migrated_security:
|
||||
ensure_config_dir()
|
||||
config_path = get_config_path()
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
logger.info("Security settings migration completed successfully")
|
||||
elif migrated_users:
|
||||
logger.info("Users settings migration completed successfully")
|
||||
else:
|
||||
logger.debug("No security settings migration needed")
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.debug("No existing security config file found - nothing to migrate")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to migrate security settings: {exc}")
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Notifications settings tab registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from shelfmark.core.notifications import NotificationEvent, send_test_notification
|
||||
from shelfmark.core.settings_registry import (
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
TableField,
|
||||
load_config_file,
|
||||
register_on_save,
|
||||
register_settings,
|
||||
)
|
||||
|
||||
_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*$")
|
||||
|
||||
_ROUTE_EVENT_ALL = "all"
|
||||
_ADMIN_EVENT_OPTIONS = [
|
||||
{"value": NotificationEvent.REQUEST_CREATED.value, "label": "New request submitted"},
|
||||
{"value": NotificationEvent.REQUEST_FULFILLED.value, "label": "Request approved"},
|
||||
{"value": NotificationEvent.REQUEST_REJECTED.value, "label": "Request rejected"},
|
||||
{"value": NotificationEvent.DOWNLOAD_COMPLETE.value, "label": "Download complete"},
|
||||
{"value": NotificationEvent.DOWNLOAD_FAILED.value, "label": "Download failed"},
|
||||
]
|
||||
_ROUTE_EVENT_OPTIONS = [
|
||||
{"value": _ROUTE_EVENT_ALL, "label": "All"},
|
||||
*_ADMIN_EVENT_OPTIONS,
|
||||
]
|
||||
_ROUTE_EVENT_ORDER = [option["value"] for option in _ROUTE_EVENT_OPTIONS]
|
||||
_ROUTE_EVENT_INDEX = {event: index for index, event in enumerate(_ROUTE_EVENT_ORDER)}
|
||||
_ALLOWED_ROUTE_EVENTS = set(_ROUTE_EVENT_ORDER)
|
||||
|
||||
_DEFAULT_ROUTE_ROWS = [{"event": [_ROUTE_EVENT_ALL], "url": ""}]
|
||||
|
||||
|
||||
def _looks_like_apprise_url(url: str) -> bool:
|
||||
split = urlsplit(url)
|
||||
if not split.scheme:
|
||||
return False
|
||||
if not _URL_SCHEME_RE.match(split.scheme):
|
||||
return False
|
||||
return " " not in url
|
||||
|
||||
|
||||
def _coerce_route_rows(value: Any) -> list[dict[str, Any]]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [row for row in value if isinstance(row, dict)]
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
return []
|
||||
|
||||
|
||||
def _coerce_route_event_values(value: Any) -> list[Any]:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, (tuple, set)):
|
||||
return list(value)
|
||||
return [value]
|
||||
|
||||
|
||||
def _normalize_route_events(value: Any) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for raw_event in _coerce_route_event_values(value):
|
||||
event = str(raw_event or "").strip().lower()
|
||||
if not event or event not in _ALLOWED_ROUTE_EVENTS:
|
||||
continue
|
||||
if event in seen:
|
||||
continue
|
||||
seen.add(event)
|
||||
normalized.append(event)
|
||||
|
||||
if _ROUTE_EVENT_ALL in seen:
|
||||
return [_ROUTE_EVENT_ALL]
|
||||
|
||||
return sorted(normalized, key=lambda event: _ROUTE_EVENT_INDEX[event])
|
||||
|
||||
|
||||
def _normalize_routes(value: Any) -> list[dict[str, Any]]:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen: set[tuple[tuple[str, ...], str]] = set()
|
||||
|
||||
for row in _coerce_route_rows(value):
|
||||
events = _normalize_route_events(row.get("event"))
|
||||
if not events:
|
||||
continue
|
||||
|
||||
url = str(row.get("url") or "").strip()
|
||||
key = (tuple(events), url)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
normalized.append({"event": events, "url": url})
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _count_invalid_route_events(value: Any) -> int:
|
||||
invalid = 0
|
||||
for row in _coerce_route_rows(value):
|
||||
raw_events = _coerce_route_event_values(row.get("event"))
|
||||
if not raw_events:
|
||||
invalid += 1
|
||||
continue
|
||||
|
||||
for raw_event in raw_events:
|
||||
event = str(raw_event or "").strip().lower()
|
||||
if not event or event not in _ALLOWED_ROUTE_EVENTS:
|
||||
invalid += 1
|
||||
return invalid
|
||||
|
||||
|
||||
def _count_invalid_route_urls(routes: list[dict[str, Any]]) -> int:
|
||||
return sum(1 for row in routes if row["url"] and not _looks_like_apprise_url(row["url"]))
|
||||
|
||||
|
||||
def _ensure_default_route_row(routes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return routes if routes else [dict(row) for row in _DEFAULT_ROUTE_ROWS]
|
||||
|
||||
|
||||
def _extract_unique_route_urls(routes: list[dict[str, Any]]) -> list[str]:
|
||||
urls: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for row in routes:
|
||||
url = row.get("url", "")
|
||||
if not url:
|
||||
continue
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
urls.append(url)
|
||||
return urls
|
||||
|
||||
|
||||
def build_notification_test_result(routes_input: Any, *, scope_label: str) -> dict[str, Any]:
|
||||
invalid_event_count = _count_invalid_route_events(routes_input)
|
||||
if invalid_event_count:
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
f"Found {invalid_event_count} invalid {scope_label} notification route event value(s). "
|
||||
"Fix route events before running a test."
|
||||
),
|
||||
}
|
||||
|
||||
normalized_routes = _normalize_routes(routes_input)
|
||||
invalid_url_count = _count_invalid_route_urls(normalized_routes)
|
||||
if invalid_url_count:
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
f"Found {invalid_url_count} invalid {scope_label} notification URL(s). "
|
||||
"Fix route URLs before running a test."
|
||||
),
|
||||
}
|
||||
|
||||
urls = _extract_unique_route_urls(normalized_routes)
|
||||
if not urls:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Add at least one {scope_label} notification URL route first.",
|
||||
}
|
||||
|
||||
return send_test_notification(urls)
|
||||
|
||||
|
||||
def normalize_notification_routes(value: Any) -> list[dict[str, Any]]:
|
||||
"""Normalize route table rows for notification preferences."""
|
||||
return _normalize_routes(value)
|
||||
|
||||
|
||||
def is_valid_notification_url(url: str) -> bool:
|
||||
"""Shared URL validation for notifications preferences."""
|
||||
return _looks_like_apprise_url(url)
|
||||
|
||||
|
||||
def _on_save_notifications(values: dict[str, Any]) -> dict[str, Any]:
|
||||
existing = load_config_file("notifications")
|
||||
effective: dict[str, Any] = dict(existing)
|
||||
effective.update(values)
|
||||
|
||||
admin_routes_input = effective.get("ADMIN_NOTIFICATION_ROUTES", [])
|
||||
invalid_admin_event_count = _count_invalid_route_events(admin_routes_input)
|
||||
if invalid_admin_event_count:
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
f"Found {invalid_admin_event_count} invalid global notification route event value(s)."
|
||||
),
|
||||
"values": values,
|
||||
}
|
||||
|
||||
normalized_admin_routes = _normalize_routes(admin_routes_input)
|
||||
invalid_admin_url_count = _count_invalid_route_urls(normalized_admin_routes)
|
||||
if invalid_admin_url_count:
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
f"Found {invalid_admin_url_count} invalid global notification URL(s). "
|
||||
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
|
||||
),
|
||||
"values": values,
|
||||
}
|
||||
|
||||
user_routes_input = effective.get("USER_NOTIFICATION_ROUTES", [])
|
||||
invalid_user_event_count = _count_invalid_route_events(user_routes_input)
|
||||
if invalid_user_event_count:
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
f"Found {invalid_user_event_count} invalid personal notification route event value(s)."
|
||||
),
|
||||
"values": values,
|
||||
}
|
||||
|
||||
normalized_user_routes = _normalize_routes(user_routes_input)
|
||||
invalid_user_url_count = _count_invalid_route_urls(normalized_user_routes)
|
||||
if invalid_user_url_count:
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
f"Found {invalid_user_url_count} invalid personal notification URL(s). "
|
||||
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
|
||||
),
|
||||
"values": values,
|
||||
}
|
||||
|
||||
admin_routes_touched = "ADMIN_NOTIFICATION_ROUTES" in values
|
||||
if admin_routes_touched:
|
||||
values["ADMIN_NOTIFICATION_ROUTES"] = _ensure_default_route_row(normalized_admin_routes)
|
||||
|
||||
user_routes_touched = "USER_NOTIFICATION_ROUTES" in values
|
||||
if user_routes_touched:
|
||||
values["USER_NOTIFICATION_ROUTES"] = _ensure_default_route_row(normalized_user_routes)
|
||||
|
||||
return {"error": False, "values": values}
|
||||
|
||||
|
||||
def _test_admin_notification_action(current_values: dict[str, Any]) -> dict[str, Any]:
|
||||
persisted = load_config_file("notifications")
|
||||
effective: dict[str, Any] = dict(persisted)
|
||||
if isinstance(current_values, dict):
|
||||
effective.update(current_values)
|
||||
|
||||
routes_input = effective.get("ADMIN_NOTIFICATION_ROUTES", [])
|
||||
return build_notification_test_result(routes_input, scope_label="global")
|
||||
|
||||
|
||||
register_on_save("notifications", _on_save_notifications)
|
||||
|
||||
|
||||
@register_settings("notifications", "Notifications", icon="bell", order=7)
|
||||
def notifications_settings():
|
||||
"""Global notifications settings."""
|
||||
return [
|
||||
HeadingField(
|
||||
key="notifications_heading",
|
||||
title="Global Notifications",
|
||||
description=(
|
||||
"Global notifications send selected events for all users to configured routes. "
|
||||
"Users can manage personal notifications in User Preferences."
|
||||
),
|
||||
),
|
||||
TableField(
|
||||
key="ADMIN_NOTIFICATION_ROUTES",
|
||||
label="",
|
||||
description=(
|
||||
"Create one route per URL. Start with All, then add event-specific routes "
|
||||
"for targeted delivery. Need format examples? "
|
||||
"[View Apprise URL formats](https://appriseit.com/services/)."
|
||||
),
|
||||
columns=[
|
||||
{
|
||||
"key": "event",
|
||||
"label": "Event",
|
||||
"type": "multiselect",
|
||||
"options": _ROUTE_EVENT_OPTIONS,
|
||||
"defaultValue": [_ROUTE_EVENT_ALL],
|
||||
"placeholder": "Select events...",
|
||||
},
|
||||
{
|
||||
"key": "url",
|
||||
"label": "Notification URL",
|
||||
"type": "text",
|
||||
"placeholder": "e.g. ntfys://ntfy.sh/shelfmark",
|
||||
},
|
||||
],
|
||||
default=[dict(row) for row in _DEFAULT_ROUTE_ROWS],
|
||||
add_label="Add Route",
|
||||
empty_message="No routes configured.",
|
||||
),
|
||||
ActionButton(
|
||||
key="test_admin_notification",
|
||||
label="Test Notification",
|
||||
description="Send a test notification to all configured global route URLs.",
|
||||
style="primary",
|
||||
callback=_test_admin_notification_action,
|
||||
),
|
||||
TableField(
|
||||
key="USER_NOTIFICATION_ROUTES",
|
||||
label="",
|
||||
description=(
|
||||
"Create one route per URL. Start with All, then add event-specific routes "
|
||||
"for targeted delivery. Need format examples? "
|
||||
"[View Apprise URL formats](https://appriseit.com/services/)."
|
||||
),
|
||||
columns=[
|
||||
{
|
||||
"key": "event",
|
||||
"label": "Event",
|
||||
"type": "multiselect",
|
||||
"options": _ROUTE_EVENT_OPTIONS,
|
||||
"defaultValue": [_ROUTE_EVENT_ALL],
|
||||
"placeholder": "Select events...",
|
||||
},
|
||||
{
|
||||
"key": "url",
|
||||
"label": "Notification URL",
|
||||
"type": "text",
|
||||
"placeholder": "e.g. ntfys://ntfy.sh/username-topic",
|
||||
},
|
||||
],
|
||||
default=[dict(row) for row in _DEFAULT_ROUTE_ROWS],
|
||||
add_label="Add Route",
|
||||
empty_message="No routes configured.",
|
||||
user_overridable=True,
|
||||
hidden_in_ui=True,
|
||||
),
|
||||
]
|
||||
+197
-118
@@ -1,100 +1,69 @@
|
||||
"""Authentication settings registration."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
from typing import Any, Dict, Callable
|
||||
|
||||
from shelfmark.config.migrations import migrate_security_settings
|
||||
from shelfmark.config.security_handlers import (
|
||||
on_save_security,
|
||||
test_oidc_connection,
|
||||
)
|
||||
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,
|
||||
)
|
||||
from shelfmark.core.user_db import sync_builtin_admin_user
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _clear_builtin_credentials() -> Dict[str, Any]:
|
||||
"""Clear built-in credentials to allow public access."""
|
||||
import json
|
||||
from shelfmark.core.settings_registry import _get_config_file_path, _ensure_config_dir
|
||||
def _auth_condition(auth_method: str) -> dict[str, str]:
|
||||
return {"field": "AUTH_METHOD", "value": auth_method}
|
||||
|
||||
try:
|
||||
config = load_config_file("security")
|
||||
config.pop("BUILTIN_USERNAME", None)
|
||||
config.pop("BUILTIN_PASSWORD_HASH", None)
|
||||
|
||||
_ensure_config_dir("security")
|
||||
config_path = _get_config_file_path("security")
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
def _ui_field(factory: Callable[..., Any], **kwargs: Any) -> Any:
|
||||
return factory(env_supported=False, **kwargs)
|
||||
|
||||
logger.info("Cleared credentials")
|
||||
return {"success": True, "message": "Credentials cleared. The app is now publicly accessible."}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear credentials: {e}")
|
||||
return {"success": False, "message": f"Failed to clear credentials: {str(e)}"}
|
||||
def _auth_ui_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) -> Any:
|
||||
return _ui_field(factory, show_when=_auth_condition(auth_method), **kwargs)
|
||||
|
||||
|
||||
def _migrate_security_settings() -> None:
|
||||
from shelfmark.core.settings_registry import (
|
||||
_get_config_file_path,
|
||||
_ensure_config_dir,
|
||||
save_config_file,
|
||||
)
|
||||
|
||||
migrate_security_settings(
|
||||
load_security_config=lambda: load_config_file("security"),
|
||||
load_users_config=lambda: load_config_file("users"),
|
||||
save_users_config=lambda values: save_config_file("users", values),
|
||||
ensure_config_dir=lambda: _ensure_config_dir("security"),
|
||||
get_config_path=lambda: _get_config_file_path("security"),
|
||||
sync_builtin_admin_user=sync_builtin_admin_user,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Custom save handler for security settings.
|
||||
return on_save_security(values)
|
||||
|
||||
Handles password validation and hashing:
|
||||
- If new password is provided, validate confirmation and hash it
|
||||
- If password fields are empty, preserve existing hash
|
||||
- Never store raw passwords
|
||||
- Ensure username is present if password is set
|
||||
|
||||
Returns:
|
||||
Dict with processed values to save and any validation errors.
|
||||
"""
|
||||
password = values.get("BUILTIN_PASSWORD", "")
|
||||
password_confirm = values.get("BUILTIN_PASSWORD_CONFIRM", "")
|
||||
|
||||
# Remove raw password fields - they should never be persisted
|
||||
values.pop("BUILTIN_PASSWORD", None)
|
||||
values.pop("BUILTIN_PASSWORD_CONFIRM", None)
|
||||
|
||||
# If password is provided, validate and hash it
|
||||
if password:
|
||||
if not values.get("BUILTIN_USERNAME"):
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Username cannot be empty",
|
||||
"values": values
|
||||
}
|
||||
|
||||
if password != password_confirm:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Passwords do not match",
|
||||
"values": values
|
||||
}
|
||||
|
||||
if len(password) < 4:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Password must be at least 4 characters",
|
||||
"values": values
|
||||
}
|
||||
|
||||
# Hash the password
|
||||
values["BUILTIN_PASSWORD_HASH"] = generate_password_hash(password)
|
||||
logger.info("Password hash updated")
|
||||
|
||||
# If no password provided but username is being set, preserve existing hash
|
||||
elif "BUILTIN_USERNAME" in values:
|
||||
existing = load_config_file("security")
|
||||
if "BUILTIN_PASSWORD_HASH" in existing:
|
||||
values["BUILTIN_PASSWORD_HASH"] = existing["BUILTIN_PASSWORD_HASH"]
|
||||
|
||||
return {"error": False, "values": values}
|
||||
def _test_oidc_connection() -> Dict[str, Any]:
|
||||
return test_oidc_connection(
|
||||
load_security_config=lambda: load_config_file("security"),
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
@register_settings("security", "Security", icon="shield", order=5)
|
||||
@@ -104,63 +73,173 @@ def security_settings():
|
||||
|
||||
cwa_db_available = CWA_DB_PATH is not None and CWA_DB_PATH.exists()
|
||||
|
||||
auth_method_options = [
|
||||
{"label": "No Authentication", "value": "none"},
|
||||
{"label": "Local", "value": "builtin"},
|
||||
{"label": "Proxy Authentication", "value": "proxy"},
|
||||
{"label": "OIDC (OpenID Connect)", "value": "oidc"},
|
||||
]
|
||||
if cwa_db_available:
|
||||
auth_method_options.append({"label": "Calibre-Web Database", "value": "cwa"})
|
||||
|
||||
auth_method_description = "Select the authentication method for accessing Shelfmark."
|
||||
if not cwa_db_available:
|
||||
auth_method_description += " Calibre-Web database option requires mounting your Calibre-Web app.db to /auth/app.db."
|
||||
|
||||
fields = [
|
||||
TextField(
|
||||
key="BUILTIN_USERNAME",
|
||||
label="Username",
|
||||
description="Set a username and password to require login. Leave both empty for public access.",
|
||||
placeholder="Enter username",
|
||||
SelectField(
|
||||
key="AUTH_METHOD",
|
||||
label="Authentication Method",
|
||||
description=auth_method_description,
|
||||
options=auth_method_options,
|
||||
default="none",
|
||||
env_supported=False,
|
||||
disabled_when={"field": "USE_CWA_AUTH", "value": True, "reason": "Using Calibre-Web database for authentication."},
|
||||
),
|
||||
PasswordField(
|
||||
key="BUILTIN_PASSWORD",
|
||||
label="Set Password",
|
||||
description="Fill in to set or change the password.",
|
||||
placeholder="Enter new password",
|
||||
env_supported=False,
|
||||
disabled_when={"field": "USE_CWA_AUTH", "value": True, "reason": "Using Calibre-Web database for authentication."},
|
||||
),
|
||||
PasswordField(
|
||||
key="BUILTIN_PASSWORD_CONFIRM",
|
||||
label="Confirm Password",
|
||||
placeholder="Confirm new password",
|
||||
env_supported=False,
|
||||
disabled_when={"field": "USE_CWA_AUTH", "value": True, "reason": "Using Calibre-Web database for authentication."},
|
||||
),
|
||||
ActionButton(
|
||||
key="clear_credentials",
|
||||
label="Clear Credentials",
|
||||
description="Remove login requirement and make the app publicly accessible.",
|
||||
style="danger",
|
||||
callback=_clear_builtin_credentials,
|
||||
disabled_when={"field": "USE_CWA_AUTH", "value": True, "reason": "Using Calibre-Web database for authentication."},
|
||||
key="open_users_tab",
|
||||
label="Go to Users",
|
||||
description="Configure local users and admin access in the Users tab.",
|
||||
style="primary",
|
||||
show_when=_auth_condition("builtin"),
|
||||
),
|
||||
CheckboxField(
|
||||
key="USE_CWA_AUTH",
|
||||
label="Use Calibre-Web Database",
|
||||
description=(
|
||||
"Use your existing Calibre-Web user credentials for authentication."
|
||||
),
|
||||
default=False,
|
||||
env_supported=False,
|
||||
disabled=not cwa_db_available,
|
||||
disabled_reason="Mount your Calibre-Web app.db to /auth/app.db in docker compose to enable.",
|
||||
_auth_ui_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_USER_HEADER",
|
||||
label="Proxy Auth User Header",
|
||||
description="The HTTP header your proxy uses to pass the authenticated username.",
|
||||
placeholder="e.g. X-Auth-User",
|
||||
default="X-Auth-User",
|
||||
),
|
||||
CheckboxField(
|
||||
key="RESTRICT_SETTINGS_TO_ADMIN",
|
||||
label="Restrict Settings to Admins",
|
||||
description=(
|
||||
"Only users with admin role in Calibre-Web can access settings."
|
||||
),
|
||||
default=False,
|
||||
env_supported=False,
|
||||
show_when={"field": "USE_CWA_AUTH", "value": True},
|
||||
_auth_ui_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_LOGOUT_URL",
|
||||
label="Proxy Auth Logout URL",
|
||||
description="The URL to redirect users to for logging out. Leave empty to disable logout functionality.",
|
||||
placeholder="https://myauth.example.com/logout",
|
||||
default="",
|
||||
),
|
||||
_auth_ui_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_ADMIN_GROUP_HEADER",
|
||||
label="Proxy Auth Admin Group Header",
|
||||
description="Optional: header your proxy uses to pass user groups/roles.",
|
||||
placeholder="e.g. X-Auth-Groups",
|
||||
default="X-Auth-Groups",
|
||||
),
|
||||
_auth_ui_field(
|
||||
TextField,
|
||||
"proxy",
|
||||
key="PROXY_AUTH_ADMIN_GROUP_NAME",
|
||||
label="Proxy Auth Admin Group",
|
||||
description="Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection.",
|
||||
placeholder="e.g. admins",
|
||||
default="",
|
||||
),
|
||||
]
|
||||
|
||||
oidc_specs = [
|
||||
(
|
||||
TextField,
|
||||
{
|
||||
"key": "OIDC_DISCOVERY_URL",
|
||||
"label": "Discovery URL",
|
||||
"description": "OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration.",
|
||||
"placeholder": "https://auth.example.com/.well-known/openid-configuration",
|
||||
"required": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
TextField,
|
||||
{
|
||||
"key": "OIDC_CLIENT_ID",
|
||||
"label": "Client ID",
|
||||
"description": "OAuth2 client ID from your identity provider.",
|
||||
"placeholder": "shelfmark",
|
||||
"required": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
PasswordField,
|
||||
{
|
||||
"key": "OIDC_CLIENT_SECRET",
|
||||
"label": "Client Secret",
|
||||
"description": "OAuth2 client secret from your identity provider.",
|
||||
"required": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
TagListField,
|
||||
{
|
||||
"key": "OIDC_SCOPES",
|
||||
"label": "Scopes",
|
||||
"description": "OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization.",
|
||||
"default": ["openid", "email", "profile"],
|
||||
},
|
||||
),
|
||||
(
|
||||
TextField,
|
||||
{
|
||||
"key": "OIDC_GROUP_CLAIM",
|
||||
"label": "Group Claim Name",
|
||||
"description": "The name of the claim in the ID token that contains user groups.",
|
||||
"placeholder": "groups",
|
||||
"default": "groups",
|
||||
},
|
||||
),
|
||||
(
|
||||
TextField,
|
||||
{
|
||||
"key": "OIDC_ADMIN_GROUP",
|
||||
"label": "Admin Group Name",
|
||||
"description": "Users in this group will be given admin access (if enabled below). Leave empty to use database roles only.",
|
||||
"placeholder": "shelfmark-admins",
|
||||
"default": "",
|
||||
},
|
||||
),
|
||||
(
|
||||
CheckboxField,
|
||||
{
|
||||
"key": "OIDC_USE_ADMIN_GROUP",
|
||||
"label": "Use Admin Group for Authorization",
|
||||
"description": "When enabled, users in the Admin Group are granted admin access. When disabled, admin access is determined solely by database roles.",
|
||||
"default": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
CheckboxField,
|
||||
{
|
||||
"key": "OIDC_AUTO_PROVISION",
|
||||
"label": "Auto-Provision Users",
|
||||
"description": "Automatically create a user account on first OIDC login. When disabled, users must be pre-created by an admin.",
|
||||
"default": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
TextField,
|
||||
{
|
||||
"key": "OIDC_BUTTON_LABEL",
|
||||
"label": "Login Button Label",
|
||||
"description": "Custom label for the OIDC sign-in button on the login page.",
|
||||
"placeholder": "Sign in with OIDC",
|
||||
"default": "",
|
||||
},
|
||||
),
|
||||
]
|
||||
fields.extend(_auth_ui_field(factory, "oidc", **spec) for factory, spec in oidc_specs)
|
||||
fields.append(
|
||||
ActionButton(
|
||||
key="test_oidc",
|
||||
label="Test Connection",
|
||||
description="Fetch the OIDC discovery document and validate configuration.",
|
||||
style="primary",
|
||||
callback=_test_oidc_connection,
|
||||
show_when=_auth_condition("oidc"),
|
||||
)
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
# Register the on_save handler for this tab
|
||||
register_on_save("security", _on_save_security)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Operational handlers for security settings (save/actions)."""
|
||||
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
_OIDC_LOCKOUT_MESSAGE = "Create a local admin account first (Users tab) before enabling OIDC. This ensures you can still log in with a password if SSO 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.initialize()
|
||||
return any(user.get("password_hash") and user.get("role") == "admin" for user in user_db.list_users())
|
||||
|
||||
|
||||
def on_save_security(
|
||||
values: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Validate security values before persistence."""
|
||||
normalized_values = values.copy()
|
||||
|
||||
discovery_url = normalized_values.get("OIDC_DISCOVERY_URL")
|
||||
if discovery_url is not None:
|
||||
normalized_values["OIDC_DISCOVERY_URL"] = normalize_http_url(
|
||||
str(discovery_url),
|
||||
default_scheme="https",
|
||||
)
|
||||
|
||||
proxy_logout_url = normalized_values.get("PROXY_AUTH_LOGOUT_URL")
|
||||
if proxy_logout_url is not None:
|
||||
normalized_values["PROXY_AUTH_LOGOUT_URL"] = normalize_http_url(
|
||||
str(proxy_logout_url),
|
||||
default_scheme="https",
|
||||
strip_trailing_slash=False,
|
||||
)
|
||||
|
||||
if normalized_values.get("AUTH_METHOD") == "oidc" and not _has_local_password_admin():
|
||||
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": normalized_values}
|
||||
|
||||
return {"error": False, "values": normalized_values}
|
||||
|
||||
|
||||
def test_oidc_connection(
|
||||
*,
|
||||
load_security_config: Callable[[], dict[str, Any]],
|
||||
logger: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch and validate the configured OIDC discovery document."""
|
||||
import requests
|
||||
|
||||
try:
|
||||
discovery_url = load_security_config().get("OIDC_DISCOVERY_URL", "")
|
||||
if not discovery_url:
|
||||
return {"success": False, "message": "Discovery URL is not configured."}
|
||||
|
||||
response = requests.get(discovery_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
document = response.json()
|
||||
|
||||
required_fields = ["issuer", "authorization_endpoint", "token_endpoint"]
|
||||
missing_fields = [field for field in required_fields if field not in document]
|
||||
if missing_fields:
|
||||
return {"success": False, "message": f"Discovery document missing fields: {', '.join(missing_fields)}"}
|
||||
|
||||
return {"success": True, "message": f"Connected to {document['issuer']}"}
|
||||
except Exception as exc:
|
||||
logger.error(f"OIDC connection test failed: {exc}")
|
||||
return {"success": False, "message": f"Connection failed: {str(exc)}"}
|
||||
+588
-30
@@ -3,8 +3,73 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Validate advanced settings before persisting."""
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
mappings = values.get("PROWLARR_REMOTE_PATH_MAPPINGS")
|
||||
if mappings is None:
|
||||
return {"error": False, "values": values}
|
||||
|
||||
if not isinstance(mappings, list):
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Remote path mappings must be a list",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
logger.debug("Processing %d remote path mapping entries", len(mappings))
|
||||
|
||||
cleaned = []
|
||||
for i, entry in enumerate(mappings):
|
||||
if not isinstance(entry, dict):
|
||||
logger.debug("Skipping entry %d: not a dict", i)
|
||||
continue
|
||||
|
||||
host = str(entry.get("host", "") or "").strip().lower()
|
||||
remote_path = str(entry.get("remotePath", "") or "").strip()
|
||||
local_path = str(entry.get("localPath", "") or "").strip()
|
||||
|
||||
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",
|
||||
i,
|
||||
host,
|
||||
remote_path,
|
||||
local_path,
|
||||
)
|
||||
continue
|
||||
|
||||
if not local_path.startswith("/"):
|
||||
return {
|
||||
"error": True,
|
||||
"message": f"Local Path must be an absolute path (got: {local_path})",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
cleaned.append({"host": host, "remotePath": remote_path, "localPath": local_path})
|
||||
|
||||
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"])
|
||||
|
||||
values["PROWLARR_REMOTE_PATH_MAPPINGS"] = cleaned
|
||||
return {"error": False, "values": values}
|
||||
|
||||
|
||||
from shelfmark.config import env
|
||||
from shelfmark.config.booklore_settings import (
|
||||
get_booklore_library_options,
|
||||
get_booklore_path_options,
|
||||
test_booklore_connection,
|
||||
)
|
||||
from shelfmark.config.email_settings import test_email_connection
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -53,13 +118,17 @@ def _log_external_bypasser_warning() -> None:
|
||||
from shelfmark.core.settings_registry import (
|
||||
register_settings,
|
||||
register_group,
|
||||
register_on_save,
|
||||
load_config_file,
|
||||
TextField,
|
||||
PasswordField,
|
||||
NumberField,
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
MultiSelectField,
|
||||
TagListField,
|
||||
OrderableListField,
|
||||
TableField,
|
||||
HeadingField,
|
||||
ActionButton,
|
||||
)
|
||||
@@ -111,6 +180,7 @@ _FORMAT_OPTIONS = [
|
||||
_AUDIOBOOK_FORMAT_OPTIONS = [
|
||||
{"value": "m4b", "label": "M4B"},
|
||||
{"value": "mp3", "label": "MP3"},
|
||||
{"value": "m4a", "label": "M4A"},
|
||||
{"value": "zip", "label": "ZIP"},
|
||||
{"value": "rar", "label": "RAR"},
|
||||
]
|
||||
@@ -150,21 +220,37 @@ def _get_release_source_options():
|
||||
if source.get("can_be_default", True)
|
||||
]
|
||||
|
||||
|
||||
|
||||
_LANGUAGE_OPTIONS = [{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE]
|
||||
|
||||
def _get_aa_base_url_options():
|
||||
"""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.utils import normalize_http_url
|
||||
|
||||
options = [{"value": "auto", "label": "Auto (Recommended)"}]
|
||||
|
||||
# Get all mirrors (defaults + custom)
|
||||
all_mirrors = get_aa_mirrors()
|
||||
|
||||
# If AA_BASE_URL is configured to a custom mirror that isn't present in the
|
||||
# defaults/additional list, include it so the UI can display the active value.
|
||||
configured_url = normalize_http_url(
|
||||
config.get("AA_BASE_URL", "auto"),
|
||||
default_scheme="https",
|
||||
allow_special=("auto",),
|
||||
)
|
||||
if configured_url and configured_url != "auto" and configured_url not in all_mirrors:
|
||||
all_mirrors = [configured_url] + all_mirrors
|
||||
|
||||
for url in all_mirrors:
|
||||
domain = url.replace("https://", "").replace("http://", "")
|
||||
is_custom = url not in DEFAULT_AA_MIRRORS
|
||||
label = f"{domain} (custom)" if is_custom else domain
|
||||
if configured_url and url == configured_url and is_custom:
|
||||
label = f"{domain} (configured)"
|
||||
options.append({"value": url, "label": label})
|
||||
|
||||
return options
|
||||
@@ -277,7 +363,6 @@ def general_settings():
|
||||
label="Audiobook Library URL",
|
||||
description="Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text.",
|
||||
placeholder="http://audiobookshelf:8080",
|
||||
env_supported=False,
|
||||
),
|
||||
HeadingField(
|
||||
key="search_defaults_heading",
|
||||
@@ -341,7 +426,6 @@ def search_mode_settings():
|
||||
description="Default sort order for search results.",
|
||||
options=_AA_SORT_OPTIONS,
|
||||
default="relevance",
|
||||
env_supported=False, # UI-only setting
|
||||
show_when={"field": "SEARCH_MODE", "value": "direct"},
|
||||
),
|
||||
HeadingField(
|
||||
@@ -372,7 +456,6 @@ def search_mode_settings():
|
||||
description="The release source tab to open by default in the release modal.",
|
||||
options=_get_release_source_options, # Callable - evaluated lazily to avoid circular imports
|
||||
default="direct_download",
|
||||
env_supported=False, # UI-only setting, not configurable via ENV
|
||||
show_when={"field": "SEARCH_MODE", "value": "universal"},
|
||||
),
|
||||
]
|
||||
@@ -511,6 +594,142 @@ def network_settings():
|
||||
]
|
||||
|
||||
|
||||
def _contains_path_separators(value: Any) -> bool:
|
||||
return isinstance(value, str) and ("/" in value or "\\" in value)
|
||||
|
||||
|
||||
def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate download settings before persisting."""
|
||||
existing = load_config_file("downloads")
|
||||
effective: dict[str, Any] = dict(existing)
|
||||
effective.update(values)
|
||||
|
||||
# Books: only validate templates when saving to a folder.
|
||||
books_output_mode = effective.get("BOOKS_OUTPUT_MODE", "folder")
|
||||
if books_output_mode == "folder" and effective.get("FILE_ORGANIZATION", "rename") == "rename":
|
||||
template = effective.get("TEMPLATE_RENAME", "")
|
||||
if _contains_path_separators(template):
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Books Naming Template cannot contain '/' or '\\' in Rename mode. Use Organize mode to create folders.",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
# Audiobooks are always folder output.
|
||||
if effective.get("FILE_ORGANIZATION_AUDIOBOOK", "rename") == "rename":
|
||||
template = effective.get("TEMPLATE_AUDIOBOOK_RENAME", "")
|
||||
if _contains_path_separators(template):
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Audiobooks Naming Template cannot contain '/' or '\\' in Rename mode. Use Organize mode to create folders.",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
# Email output (SMTP) validation.
|
||||
if books_output_mode == "email":
|
||||
from email.utils import parseaddr
|
||||
|
||||
def _is_plain_email_address(addr: str) -> bool:
|
||||
parsed = parseaddr(addr or "")[1]
|
||||
return bool(parsed) and "@" in parsed and parsed == addr
|
||||
|
||||
# Preferred model: single recipient for global default and per-user override.
|
||||
raw_recipient = str(effective.get("EMAIL_RECIPIENT", "") or "").strip()
|
||||
|
||||
# Optional global fallback: validate only when a default recipient is provided.
|
||||
if raw_recipient and not _is_plain_email_address(raw_recipient):
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Email recipient must be a valid plain email address.",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
smtp_host = str(effective.get("EMAIL_SMTP_HOST", "") or "").strip()
|
||||
if not smtp_host:
|
||||
return {"error": True, "message": "SMTP host is required", "values": values}
|
||||
|
||||
security = str(effective.get("EMAIL_SMTP_SECURITY", "starttls") or "").strip().lower()
|
||||
if security not in {"none", "starttls", "ssl"}:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "SMTP security must be one of: none, starttls, ssl",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
try:
|
||||
port = int(effective.get("EMAIL_SMTP_PORT", 587))
|
||||
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}
|
||||
|
||||
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}
|
||||
|
||||
if timeout_seconds < 1:
|
||||
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}
|
||||
|
||||
try:
|
||||
attachment_limit_mb = int(effective.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25))
|
||||
except (TypeError, ValueError):
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Attachment size limit (MB) must be a number",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
if attachment_limit_mb < 1 or attachment_limit_mb > 600:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Attachment size limit (MB) must be between 1 and 600",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
from_addr = str(effective.get("EMAIL_FROM", "") or "").strip()
|
||||
if not from_addr:
|
||||
# If From is empty, default to the SMTP username when it looks like an email address.
|
||||
username_email = parseaddr(username)[1]
|
||||
if username_email and "@" in username_email:
|
||||
from_addr = f"Shelfmark <{username_email}>"
|
||||
values["EMAIL_FROM"] = from_addr
|
||||
else:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "From address is required (or set SMTP username to an email address).",
|
||||
"values": values,
|
||||
}
|
||||
else:
|
||||
from_email = parseaddr(from_addr)[1]
|
||||
if not from_email or "@" not in from_email:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "From address must be a valid email address",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
# Persist any normalization/coercion for fields that may have been edited this save.
|
||||
if "EMAIL_RECIPIENT" in values:
|
||||
values["EMAIL_RECIPIENT"] = raw_recipient
|
||||
if "EMAIL_SMTP_SECURITY" in values:
|
||||
values["EMAIL_SMTP_SECURITY"] = security
|
||||
if "EMAIL_SMTP_PORT" in values:
|
||||
values["EMAIL_SMTP_PORT"] = port
|
||||
if "EMAIL_SMTP_TIMEOUT_SECONDS" in values:
|
||||
values["EMAIL_SMTP_TIMEOUT_SECONDS"] = timeout_seconds
|
||||
if "EMAIL_ATTACHMENT_SIZE_LIMIT_MB" in values:
|
||||
values["EMAIL_ATTACHMENT_SIZE_LIMIT_MB"] = attachment_limit_mb
|
||||
|
||||
return {"error": False, "values": values}
|
||||
|
||||
|
||||
@register_settings("downloads", "Downloads", icon="folder", order=5)
|
||||
def download_settings():
|
||||
"""Configure download behavior and file locations."""
|
||||
@@ -522,13 +741,42 @@ def download_settings():
|
||||
title="Books",
|
||||
description="Configure where ebooks, comics, and magazines are saved.",
|
||||
),
|
||||
SelectField(
|
||||
key="BOOKS_OUTPUT_MODE",
|
||||
label="Output Mode",
|
||||
description="Choose where completed book files are sent.",
|
||||
options=[
|
||||
{
|
||||
"value": "folder",
|
||||
"label": "Folder",
|
||||
"description": "Save files to the destination folder",
|
||||
},
|
||||
{
|
||||
"value": "email",
|
||||
"label": "Email (SMTP)",
|
||||
"description": "Send files as an email attachment",
|
||||
},
|
||||
{
|
||||
"value": "booklore",
|
||||
"label": "Booklore (API)",
|
||||
"description": "Upload files directly to Booklore",
|
||||
},
|
||||
],
|
||||
default="folder",
|
||||
user_overridable=True,
|
||||
),
|
||||
TextField(
|
||||
key="DESTINATION",
|
||||
label="Destination",
|
||||
description="Directory where downloaded files are saved.",
|
||||
description="Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}).",
|
||||
default="/books",
|
||||
required=True,
|
||||
env_var="INGEST_DIR", # Legacy env var name for backwards compatibility
|
||||
user_overridable=True,
|
||||
show_when={
|
||||
"field": "BOOKS_OUTPUT_MODE",
|
||||
"value": "folder",
|
||||
},
|
||||
),
|
||||
SelectField(
|
||||
key="FILE_ORGANIZATION",
|
||||
@@ -542,34 +790,44 @@ def download_settings():
|
||||
},
|
||||
{
|
||||
"value": "rename",
|
||||
"label": "Rename",
|
||||
"label": "Rename Only",
|
||||
"description": "Rename files using a template"
|
||||
},
|
||||
{
|
||||
"value": "organize",
|
||||
"label": "Organize",
|
||||
"label": "Rename and Organize",
|
||||
"description": "Create folders and rename files using a template. Do not use with ingest folders."
|
||||
},
|
||||
],
|
||||
default="rename",
|
||||
show_when={
|
||||
"field": "BOOKS_OUTPUT_MODE",
|
||||
"value": "folder",
|
||||
},
|
||||
),
|
||||
# Rename mode template - filename only
|
||||
TextField(
|
||||
key="TEMPLATE_RENAME",
|
||||
label="Naming Template",
|
||||
description="Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}",
|
||||
description="Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.",
|
||||
default="{Author} - {Title} ({Year})",
|
||||
placeholder="{Author} - {Title} ({Year})",
|
||||
show_when={"field": "FILE_ORGANIZATION", "value": "rename"},
|
||||
show_when=[
|
||||
{"field": "BOOKS_OUTPUT_MODE", "value": "folder"},
|
||||
{"field": "FILE_ORGANIZATION", "value": "rename"},
|
||||
],
|
||||
),
|
||||
# Organize mode template - folders allowed
|
||||
TextField(
|
||||
key="TEMPLATE_ORGANIZE",
|
||||
label="Path Template",
|
||||
description="Use / to create folders. Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}",
|
||||
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.",
|
||||
default="{Author}/{Title} ({Year})",
|
||||
placeholder="{Author}/{Series/}{Title} ({Year})",
|
||||
show_when={"field": "FILE_ORGANIZATION", "value": "organize"},
|
||||
show_when=[
|
||||
{"field": "BOOKS_OUTPUT_MODE", "value": "folder"},
|
||||
{"field": "FILE_ORGANIZATION", "value": "organize"},
|
||||
],
|
||||
),
|
||||
CheckboxField(
|
||||
key="HARDLINK_TORRENTS",
|
||||
@@ -577,6 +835,194 @@ def download_settings():
|
||||
description="Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder.",
|
||||
default=False,
|
||||
universal_only=True,
|
||||
show_when={
|
||||
"field": "BOOKS_OUTPUT_MODE",
|
||||
"value": "folder",
|
||||
},
|
||||
),
|
||||
HeadingField(
|
||||
key="booklore_heading",
|
||||
title="Booklore",
|
||||
description="Upload books directly to Booklore via API. Audiobooks always use folder mode.",
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
),
|
||||
TextField(
|
||||
key="BOOKLORE_HOST",
|
||||
label="Booklore URL",
|
||||
description="Base URL of your Booklore instance",
|
||||
placeholder="http://booklore:6060",
|
||||
required=True,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
),
|
||||
TextField(
|
||||
key="BOOKLORE_USERNAME",
|
||||
label="Username",
|
||||
description="Booklore account username",
|
||||
required=True,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
),
|
||||
PasswordField(
|
||||
key="BOOKLORE_PASSWORD",
|
||||
label="Password",
|
||||
description="Booklore account password",
|
||||
required=True,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
),
|
||||
SelectField(
|
||||
key="BOOKLORE_DESTINATION",
|
||||
label="Upload Destination",
|
||||
description="Choose whether uploads go directly to a specific library path or to Bookdrop for review.",
|
||||
options=[
|
||||
{
|
||||
"value": "library",
|
||||
"label": "Specific Library",
|
||||
"description": "Upload directly into the selected library path.",
|
||||
},
|
||||
{
|
||||
"value": "bookdrop",
|
||||
"label": "Bookdrop",
|
||||
"description": "Upload into Bookdrop and review metadata before importing to a library.",
|
||||
},
|
||||
],
|
||||
default="library",
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
),
|
||||
SelectField(
|
||||
key="BOOKLORE_LIBRARY_ID",
|
||||
label="Library",
|
||||
description="Booklore library to upload into.",
|
||||
options=get_booklore_library_options,
|
||||
required=True,
|
||||
user_overridable=True,
|
||||
show_when=[
|
||||
{"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
{"field": "BOOKLORE_DESTINATION", "value": "library"},
|
||||
],
|
||||
),
|
||||
SelectField(
|
||||
key="BOOKLORE_PATH_ID",
|
||||
label="Path",
|
||||
description="Booklore library path for uploads.",
|
||||
options=get_booklore_path_options,
|
||||
required=True,
|
||||
filter_by_field="BOOKLORE_LIBRARY_ID",
|
||||
user_overridable=True,
|
||||
show_when=[
|
||||
{"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
{"field": "BOOKLORE_DESTINATION", "value": "library"},
|
||||
],
|
||||
),
|
||||
ActionButton(
|
||||
key="test_booklore",
|
||||
label="Test Connection",
|
||||
description="Verify your Booklore configuration",
|
||||
style="primary",
|
||||
callback=test_booklore_connection,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
|
||||
),
|
||||
HeadingField(
|
||||
key="email_heading",
|
||||
title="Email",
|
||||
description="Send books as email attachments via SMTP. Audiobooks always use folder mode.",
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
TextField(
|
||||
key="EMAIL_RECIPIENT",
|
||||
label="Default Email Recipient",
|
||||
description="Optional fallback email address when no per-user email recipient override is configured.",
|
||||
placeholder="reader@example.com",
|
||||
user_overridable=True,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
NumberField(
|
||||
key="EMAIL_ATTACHMENT_SIZE_LIMIT_MB",
|
||||
label="Attachment Size Limit (MB)",
|
||||
description="Maximum total attachment size per email. Email encoding adds overhead; keep this below your provider's limit.",
|
||||
default=25,
|
||||
min_value=1,
|
||||
max_value=600,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
TextField(
|
||||
key="EMAIL_SMTP_HOST",
|
||||
label="SMTP Host",
|
||||
description="SMTP server hostname or IP (e.g., smtp.gmail.com).",
|
||||
placeholder="smtp.example.com",
|
||||
required=True,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
NumberField(
|
||||
key="EMAIL_SMTP_PORT",
|
||||
label="SMTP Port",
|
||||
description="SMTP server port (587 is typical for STARTTLS, 465 for SSL).",
|
||||
default=587,
|
||||
min_value=1,
|
||||
max_value=65535,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
SelectField(
|
||||
key="EMAIL_SMTP_SECURITY",
|
||||
label="SMTP Security",
|
||||
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": "ssl", "label": "SSL/TLS", "description": "Connect using TLS (SMTPS)."},
|
||||
],
|
||||
default="starttls",
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
TextField(
|
||||
key="EMAIL_SMTP_USERNAME",
|
||||
label="Username",
|
||||
description="SMTP username (leave empty for no authentication).",
|
||||
placeholder="user@example.com",
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
PasswordField(
|
||||
key="EMAIL_SMTP_PASSWORD",
|
||||
label="Password",
|
||||
description="SMTP password (required if Username is set).",
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
TextField(
|
||||
key="EMAIL_FROM",
|
||||
label="From Address",
|
||||
description="From address used for the email. You can include a display name (e.g., Shelfmark <mail@example.com>). Leave blank to default to the SMTP username (when it is an email address).",
|
||||
placeholder="Shelfmark <mail@example.com>",
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
TextField(
|
||||
key="EMAIL_SUBJECT_TEMPLATE",
|
||||
label="Subject Template",
|
||||
description="Email subject. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}.",
|
||||
default="{Title}",
|
||||
placeholder="{Title}",
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
NumberField(
|
||||
key="EMAIL_SMTP_TIMEOUT_SECONDS",
|
||||
label="SMTP Timeout (seconds)",
|
||||
description="How long to wait for SMTP operations before failing.",
|
||||
default=60,
|
||||
min_value=1,
|
||||
max_value=600,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
CheckboxField(
|
||||
key="EMAIL_ALLOW_UNVERIFIED_TLS",
|
||||
label="Allow Unverified TLS",
|
||||
description="Disable TLS certificate verification (not recommended).",
|
||||
default=False,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_email",
|
||||
label="Test SMTP Connection",
|
||||
description="Verify your SMTP configuration (connect + optional login).",
|
||||
style="primary",
|
||||
callback=test_email_connection,
|
||||
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
|
||||
),
|
||||
|
||||
# === AUDIOBOOKS SECTION ===
|
||||
@@ -590,8 +1036,8 @@ def download_settings():
|
||||
TextField(
|
||||
key="DESTINATION_AUDIOBOOK",
|
||||
label="Destination",
|
||||
description="Leave empty to use Books destination.",
|
||||
placeholder="/audiobooks",
|
||||
description="Directory where downloaded audiobook files are saved. Leave empty to use the Books destination.",
|
||||
user_overridable=True,
|
||||
universal_only=True,
|
||||
),
|
||||
SelectField(
|
||||
@@ -600,8 +1046,8 @@ def download_settings():
|
||||
description="Choose how downloaded audiobook files are named and organized.",
|
||||
options=[
|
||||
{"value": "none", "label": "None", "description": "Keep original filename from source"},
|
||||
{"value": "rename", "label": "Rename", "description": "Rename files using a template"},
|
||||
{"value": "organize", "label": "Organize", "description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders."},
|
||||
{"value": "rename", "label": "Rename Only", "description": "Rename files using a template"},
|
||||
{"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,
|
||||
@@ -610,7 +1056,7 @@ def download_settings():
|
||||
TextField(
|
||||
key="TEMPLATE_AUDIOBOOK_RENAME",
|
||||
label="Naming Template",
|
||||
description="Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}",
|
||||
description="Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.",
|
||||
default="{Author} - {Title}",
|
||||
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
|
||||
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
|
||||
@@ -620,7 +1066,7 @@ def download_settings():
|
||||
TextField(
|
||||
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
|
||||
label="Path Template",
|
||||
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}",
|
||||
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.",
|
||||
default="{Author}/{Title}",
|
||||
placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}",
|
||||
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"},
|
||||
@@ -644,14 +1090,12 @@ def download_settings():
|
||||
label="Auto-Open Downloads Sidebar",
|
||||
description="Automatically open the downloads sidebar when a new download is queued.",
|
||||
default=False,
|
||||
env_supported=False, # UI-only setting
|
||||
),
|
||||
CheckboxField(
|
||||
key="DOWNLOAD_TO_BROWSER",
|
||||
label="Download to Browser",
|
||||
description="Automatically download completed files to your browser.",
|
||||
default=False,
|
||||
env_supported=False, # UI-only setting
|
||||
),
|
||||
NumberField(
|
||||
key="MAX_CONCURRENT_DOWNLOADS",
|
||||
@@ -673,8 +1117,12 @@ def download_settings():
|
||||
]
|
||||
|
||||
|
||||
# Register the on_save handler for this tab
|
||||
register_on_save("downloads", _on_save_downloads)
|
||||
|
||||
|
||||
def _get_fast_source_options():
|
||||
"""Fast download sources - display only, not configurable."""
|
||||
"""Fast download sources - configurable list shown in settings."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
has_donator_key = bool(config.get("AA_DONATOR_KEY", ""))
|
||||
@@ -777,7 +1225,6 @@ def download_source_settings():
|
||||
description="Always tried first, no waiting or bypass required.",
|
||||
options=_get_fast_source_options,
|
||||
default=_get_fast_source_defaults(),
|
||||
env_supported=False,
|
||||
),
|
||||
OrderableListField(
|
||||
key="SOURCE_PRIORITY",
|
||||
@@ -912,30 +1359,76 @@ def cloudflare_bypass_settings():
|
||||
),
|
||||
]
|
||||
|
||||
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
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
raw_urls = values.get("AA_MIRROR_URLS")
|
||||
if raw_urls is None:
|
||||
return {"error": False, "values": values}
|
||||
|
||||
if isinstance(raw_urls, str):
|
||||
parts = [p.strip() for p in raw_urls.split(",") if p.strip()]
|
||||
elif isinstance(raw_urls, list):
|
||||
parts = [str(p).strip() for p in raw_urls if str(p).strip()]
|
||||
else:
|
||||
parts = []
|
||||
|
||||
normalized: list[str] = []
|
||||
for url in parts:
|
||||
if url.lower() == "auto":
|
||||
continue
|
||||
norm = normalize_http_url(url, default_scheme="https")
|
||||
if norm and norm not in normalized:
|
||||
normalized.append(norm)
|
||||
|
||||
if not normalized:
|
||||
logger.warning("AA_MIRROR_URLS saved empty/invalid; falling back to defaults")
|
||||
normalized = [normalize_http_url(url, default_scheme="https") for url in DEFAULT_AA_MIRRORS]
|
||||
normalized = [url for url in normalized if url]
|
||||
|
||||
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():
|
||||
"""Configure download source mirrors."""
|
||||
from shelfmark.core.mirrors import DEFAULT_ZLIB_MIRRORS, DEFAULT_WELIB_MIRRORS
|
||||
from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS, DEFAULT_ZLIB_MIRRORS, DEFAULT_WELIB_MIRRORS
|
||||
|
||||
return [
|
||||
# === PRIMARY SOURCE ===
|
||||
HeadingField(
|
||||
key="aa_mirrors_heading",
|
||||
title="Primary Source",
|
||||
description="Primary mirror with auto-probe on startup. Additional mirrors used as fallback.",
|
||||
title="Anna's Archive",
|
||||
description="Choose a primary mirror, or use Auto to try mirrors from your list below. The mirror list controls which options appear in the dropdown and the order used in Auto mode.",
|
||||
),
|
||||
SelectField(
|
||||
key="AA_BASE_URL",
|
||||
label="Primary Mirror",
|
||||
description="Select 'Auto' to probe mirrors on startup, or choose a specific mirror.",
|
||||
description="Select 'Auto' to try mirrors from your list on startup and fall back on failures. Choosing a specific mirror locks Shelfmark to that mirror (no fallback).",
|
||||
options=_get_aa_base_url_options,
|
||||
default="auto",
|
||||
),
|
||||
TagListField(
|
||||
key="AA_MIRROR_URLS",
|
||||
label="Mirrors",
|
||||
description="Editable list of AA mirrors. Used to populate the Primary Mirror dropdown and the order used when Auto is selected. Type a URL and press Enter to add. Order matters for auto-rotation",
|
||||
placeholder="https://annas-archive.gl",
|
||||
default=DEFAULT_AA_MIRRORS,
|
||||
),
|
||||
TextField(
|
||||
key="AA_ADDITIONAL_URLS",
|
||||
label="Additional Mirrors",
|
||||
description="Comma-separated list of custom mirror URLs.",
|
||||
label="Additional Mirrors (Legacy)",
|
||||
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 ===
|
||||
@@ -995,10 +1488,11 @@ def advanced_settings():
|
||||
"""Advanced settings for power users."""
|
||||
return [
|
||||
TextField(
|
||||
key="CUSTOM_SCRIPT",
|
||||
label="Custom Script Path",
|
||||
description="Path to a script to run after each successful download. Must be executable.",
|
||||
placeholder="/path/to/script.sh",
|
||||
key="URL_BASE",
|
||||
label="Base Path",
|
||||
description="Optional URL path prefix. Use a path like /shelfmark (no hostname). Leave blank for root.",
|
||||
placeholder="/shelfmark",
|
||||
requires_restart=True,
|
||||
),
|
||||
CheckboxField(
|
||||
key="DEBUG",
|
||||
@@ -1025,6 +1519,67 @@ def advanced_settings():
|
||||
max_value=10,
|
||||
requires_restart=True,
|
||||
),
|
||||
TextField(
|
||||
key="CUSTOM_SCRIPT",
|
||||
label="Custom Script Path",
|
||||
description="Path to a script to run after each successful download. Must be executable.",
|
||||
placeholder="/path/to/script.sh",
|
||||
),
|
||||
SelectField(
|
||||
key="CUSTOM_SCRIPT_PATH_MODE",
|
||||
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."},
|
||||
],
|
||||
default="absolute",
|
||||
),
|
||||
CheckboxField(
|
||||
key="CUSTOM_SCRIPT_JSON_PAYLOAD",
|
||||
label="Custom Script JSON Payload",
|
||||
description="Send a JSON payload to the script via stdin. Useful for multi-file imports (audiobooks) or richer metadata without relying on path parsing.",
|
||||
default=False,
|
||||
),
|
||||
HeadingField(
|
||||
key="remote_path_mappings_heading",
|
||||
title="Remote Path Mappings",
|
||||
description="Map download client paths to paths inside Shelfmark. Needed when volume mounts differ between containers.",
|
||||
),
|
||||
TableField(
|
||||
key="PROWLARR_REMOTE_PATH_MAPPINGS",
|
||||
label="Path Mappings",
|
||||
columns=[
|
||||
{
|
||||
"key": "host",
|
||||
"label": "Client",
|
||||
"type": "select",
|
||||
"options": [
|
||||
{"value": "qbittorrent", "label": "qBittorrent"},
|
||||
{"value": "transmission", "label": "Transmission"},
|
||||
{"value": "deluge", "label": "Deluge"},
|
||||
{"value": "rtorrent", "label": "rTorrent"},
|
||||
{"value": "nzbget", "label": "NZBGet"},
|
||||
{"value": "sabnzbd", "label": "SABnzbd"},
|
||||
],
|
||||
"defaultValue": "qbittorrent",
|
||||
},
|
||||
{
|
||||
"key": "remotePath",
|
||||
"label": "Remote Path",
|
||||
"type": "path",
|
||||
},
|
||||
{
|
||||
"key": "localPath",
|
||||
"label": "Local Path",
|
||||
"type": "path",
|
||||
},
|
||||
],
|
||||
default=[],
|
||||
add_label="Add Mapping",
|
||||
empty_message="No mappings configured.",
|
||||
env_supported=False,
|
||||
),
|
||||
HeadingField(
|
||||
key="covers_cache_heading",
|
||||
title="Cover Image Cache",
|
||||
@@ -1096,3 +1651,6 @@ def advanced_settings():
|
||||
callback=_clear_metadata_cache,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
register_on_save("advanced", _on_save_advanced)
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Users settings tab registration.
|
||||
|
||||
This registers a 'users' tab in the settings sidebar.
|
||||
The actual user management is handled by a custom frontend component
|
||||
that talks to /api/admin/users endpoints.
|
||||
"""
|
||||
|
||||
from shelfmark.core.settings_registry import (
|
||||
CheckboxField,
|
||||
CustomComponentField,
|
||||
HeadingField,
|
||||
MultiSelectField,
|
||||
NumberField,
|
||||
SelectField,
|
||||
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 = [
|
||||
{
|
||||
"value": "download",
|
||||
"label": "Download",
|
||||
"description": "Everything can be downloaded directly.",
|
||||
},
|
||||
{
|
||||
"value": "request_release",
|
||||
"label": "Request Release",
|
||||
"description": "Users must request a specific release.",
|
||||
},
|
||||
{
|
||||
"value": "request_book",
|
||||
"label": "Request Book",
|
||||
"description": "Users request a book, admin picks the release.",
|
||||
},
|
||||
{
|
||||
"value": "blocked",
|
||||
"label": "Blocked",
|
||||
"description": "No downloads or requests allowed.",
|
||||
},
|
||||
]
|
||||
|
||||
_REQUEST_MATRIX_MODE_OPTIONS = [
|
||||
option for option in _REQUEST_DEFAULT_MODE_OPTIONS if option["value"] != "request_book"
|
||||
]
|
||||
|
||||
_SELF_SETTINGS_SECTION_OPTIONS = [
|
||||
{
|
||||
"value": "delivery",
|
||||
"label": "Delivery Preferences",
|
||||
"description": "Show personal delivery output and destination settings.",
|
||||
},
|
||||
{
|
||||
"value": "notifications",
|
||||
"label": "Notifications",
|
||||
"description": "Show personal notification route settings.",
|
||||
},
|
||||
]
|
||||
_SELF_SETTINGS_SECTION_VALUES = {option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS}
|
||||
_SELF_SETTINGS_SECTION_DEFAULTS = [option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS]
|
||||
|
||||
_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE = {
|
||||
"builtin": (
|
||||
"Create and manage user accounts directly. Passwords are stored locally and users sign in "
|
||||
"with their username and password."
|
||||
),
|
||||
"oidc": (
|
||||
"Users sign in through your identity provider. New accounts can be created automatically on "
|
||||
"first login when auto-provisioning is enabled, or you can pre-create users here and they\u2019ll "
|
||||
"be linked by email on first sign-in."
|
||||
),
|
||||
"proxy": (
|
||||
"Users are authenticated by your reverse proxy. Accounts are automatically created on first "
|
||||
"sign-in. If a local user with a matching username already exists, it will be linked instead."
|
||||
),
|
||||
"cwa": (
|
||||
"User accounts are synced from your Calibre-Web database. Users are matched by email, and new "
|
||||
"accounts are created here when new CWA users are found."
|
||||
),
|
||||
"none": "Authentication is disabled. Anyone can access Shelfmark without signing in.",
|
||||
"default": "Authentication is disabled. Anyone can access Shelfmark without signing in.",
|
||||
}
|
||||
|
||||
|
||||
def _get_request_source_options():
|
||||
"""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
|
||||
|
||||
|
||||
def _get_request_policy_rule_columns():
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"key": "source",
|
||||
"label": "Source",
|
||||
"type": "select",
|
||||
"options": _get_request_source_options(),
|
||||
"defaultValue": "",
|
||||
"placeholder": "Select source...",
|
||||
},
|
||||
{
|
||||
"key": "content_type",
|
||||
"label": "Content Type",
|
||||
"type": "select",
|
||||
"options": content_type_options,
|
||||
"defaultValue": "",
|
||||
"placeholder": "Select content type...",
|
||||
"filterByField": "source",
|
||||
},
|
||||
{
|
||||
"key": "mode",
|
||||
"label": "Mode",
|
||||
"type": "select",
|
||||
"options": _REQUEST_MATRIX_MODE_OPTIONS,
|
||||
"defaultValue": "",
|
||||
"placeholder": "Select mode...",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _on_save_users(values):
|
||||
"""Validate users/request-policy settings before persistence."""
|
||||
if "VISIBLE_SELF_SETTINGS_SECTIONS" in values:
|
||||
raw_sections = values["VISIBLE_SELF_SETTINGS_SECTIONS"]
|
||||
if raw_sections is None:
|
||||
candidate_sections: list[str] = []
|
||||
elif isinstance(raw_sections, str):
|
||||
candidate_sections = [s.strip() for s in raw_sections.split(",") if s.strip()]
|
||||
elif isinstance(raw_sections, (list, tuple, set)):
|
||||
candidate_sections = [str(section).strip() for section in raw_sections if str(section).strip()]
|
||||
else:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "VISIBLE_SELF_SETTINGS_SECTIONS must be a list of section identifiers",
|
||||
"values": values,
|
||||
}
|
||||
|
||||
normalized_sections: list[str] = []
|
||||
for section in candidate_sections:
|
||||
if section not in _SELF_SETTINGS_SECTION_VALUES:
|
||||
allowed = ", ".join(sorted(_SELF_SETTINGS_SECTION_VALUES))
|
||||
return {
|
||||
"error": True,
|
||||
"message": (
|
||||
"VISIBLE_SELF_SETTINGS_SECTIONS contains an unsupported section "
|
||||
f"'{section}'. Supported values: {allowed}"
|
||||
),
|
||||
"values": values,
|
||||
}
|
||||
if section not in normalized_sections:
|
||||
normalized_sections.append(section)
|
||||
|
||||
values["VISIBLE_SELF_SETTINGS_SECTIONS"] = normalized_sections
|
||||
|
||||
if "REQUEST_POLICY_DEFAULT_EBOOK" in values:
|
||||
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_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_RULES" in values:
|
||||
normalized_rules, errors = validate_policy_rules(values["REQUEST_POLICY_RULES"])
|
||||
if errors:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "; ".join(errors),
|
||||
"values": values,
|
||||
}
|
||||
values["REQUEST_POLICY_RULES"] = normalized_rules
|
||||
|
||||
return {"error": False, "values": values}
|
||||
|
||||
|
||||
register_on_save("users", _on_save_users)
|
||||
|
||||
|
||||
@register_settings("users", "Users & Requests", icon="users", order=6)
|
||||
def users_settings():
|
||||
"""User management tab - rendered as a custom component on the frontend."""
|
||||
return [
|
||||
HeadingField(
|
||||
key="users_heading",
|
||||
title="Users",
|
||||
description=_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE["default"],
|
||||
description_by_auth_mode=_USERS_HEADING_DESCRIPTION_BY_AUTH_MODE,
|
||||
),
|
||||
CustomComponentField(
|
||||
key="users_management",
|
||||
component="users_management",
|
||||
),
|
||||
MultiSelectField(
|
||||
key="VISIBLE_SELF_SETTINGS_SECTIONS",
|
||||
label="Visible Self-Settings Sections",
|
||||
description=(
|
||||
"Choose which personal settings sections are shown in My Account for non-admin users."
|
||||
),
|
||||
options=_SELF_SETTINGS_SECTION_OPTIONS,
|
||||
default=_SELF_SETTINGS_SECTION_DEFAULTS,
|
||||
variant="dropdown",
|
||||
env_supported=False,
|
||||
),
|
||||
HeadingField(
|
||||
key="requests_heading",
|
||||
title="Requests",
|
||||
description=(
|
||||
"Choose what users can download directly and what needs approval first."
|
||||
),
|
||||
),
|
||||
CheckboxField(
|
||||
key="REQUESTS_ENABLED",
|
||||
label="Enable Requests",
|
||||
description=(
|
||||
"Turn this off to let everyone download directly without needing approval."
|
||||
),
|
||||
default=False,
|
||||
user_overridable=True,
|
||||
),
|
||||
CustomComponentField(
|
||||
key="request_policy_editor",
|
||||
component="request_policy_grid",
|
||||
label="Request Rules",
|
||||
description=(
|
||||
"Fine-tune access per source. Source rules can only be the same or more restrictive than the default above."
|
||||
),
|
||||
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||
wrap_in_field_wrapper=True,
|
||||
value_fields=[
|
||||
SelectField(
|
||||
key="REQUEST_POLICY_DEFAULT_EBOOK",
|
||||
label="Default Ebook Mode",
|
||||
description=(
|
||||
"Sets the baseline for all ebook sources."
|
||||
),
|
||||
options=_REQUEST_DEFAULT_MODE_OPTIONS,
|
||||
default="download",
|
||||
user_overridable=True,
|
||||
),
|
||||
SelectField(
|
||||
key="REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
label="Default Audiobook Mode",
|
||||
description=(
|
||||
"Sets the baseline for all audiobook sources."
|
||||
),
|
||||
options=_REQUEST_DEFAULT_MODE_OPTIONS,
|
||||
default="download",
|
||||
user_overridable=True,
|
||||
),
|
||||
TableField(
|
||||
key="REQUEST_POLICY_RULES",
|
||||
label="Request Rules",
|
||||
description=(
|
||||
"Fine-tune access per source. Source rules can only be the same or more restrictive than the default above."
|
||||
),
|
||||
columns=_get_request_policy_rule_columns,
|
||||
default=[],
|
||||
add_label="Add Rule",
|
||||
empty_message="No request policy rules configured.",
|
||||
env_supported=False,
|
||||
user_overridable=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
NumberField(
|
||||
key="MAX_PENDING_REQUESTS_PER_USER",
|
||||
label="Max pending requests per user",
|
||||
description="How many open requests a user can have at a time.",
|
||||
default=20,
|
||||
min_value=1,
|
||||
max_value=1000,
|
||||
user_overridable=True,
|
||||
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="REQUESTS_ALLOW_NOTES",
|
||||
label="Allow notes on requests",
|
||||
description="Let users add a note when they submit a request.",
|
||||
default=True,
|
||||
user_overridable=True,
|
||||
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,486 @@
|
||||
"""Activity API routes (snapshot, dismiss, history)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import Flask, jsonify, request, session
|
||||
|
||||
from shelfmark.core.activity_service import ActivityService
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _require_authenticated(resolve_auth_mode: Callable[[], str]):
|
||||
auth_mode = resolve_auth_mode()
|
||||
if auth_mode == "none":
|
||||
return None
|
||||
if "user_id" not in session:
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_db_user_id(require_in_auth_mode: bool = True):
|
||||
raw_db_user_id = session.get("db_user_id")
|
||||
if raw_db_user_id is None:
|
||||
if not require_in_auth_mode:
|
||||
return None, None
|
||||
return None, (
|
||||
jsonify(
|
||||
{
|
||||
"error": "User identity unavailable for activity workflow",
|
||||
"code": "user_identity_unavailable",
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
try:
|
||||
return int(raw_db_user_id), None
|
||||
except (TypeError, ValueError):
|
||||
return None, (
|
||||
jsonify(
|
||||
{
|
||||
"error": "User identity unavailable for activity workflow",
|
||||
"code": "user_identity_unavailable",
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
|
||||
|
||||
def _emit_activity_event(ws_manager: Any | None, *, room: str, payload: dict[str, Any]) -> None:
|
||||
if ws_manager is None:
|
||||
return
|
||||
try:
|
||||
socketio = getattr(ws_manager, "socketio", None)
|
||||
is_enabled = getattr(ws_manager, "is_enabled", None)
|
||||
if socketio is None or not callable(is_enabled) or not is_enabled():
|
||||
return
|
||||
socketio.emit("activity_update", payload, to=room)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to emit activity_update event: %s", exc)
|
||||
|
||||
|
||||
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()
|
||||
user_cache: dict[int, str] = {}
|
||||
for row in request_rows:
|
||||
requester_id = row["user_id"]
|
||||
if requester_id not in user_cache:
|
||||
requester = user_db.get_user(user_id=requester_id)
|
||||
user_cache[requester_id] = requester.get("username", "") if requester else ""
|
||||
row["username"] = user_cache[requester_id]
|
||||
return request_rows
|
||||
|
||||
if db_user_id is None:
|
||||
return []
|
||||
return user_db.list_requests(user_id=db_user_id)
|
||||
|
||||
|
||||
def _parse_download_item_key(item_key: str) -> str | None:
|
||||
if not isinstance(item_key, str) or not item_key.startswith("download:"):
|
||||
return None
|
||||
task_id = item_key.split(":", 1)[1].strip()
|
||||
return task_id or None
|
||||
|
||||
|
||||
def _parse_request_item_key(item_key: str) -> int | None:
|
||||
if not isinstance(item_key, str) or not item_key.startswith("request:"):
|
||||
return None
|
||||
raw_id = item_key.split(":", 1)[1].strip()
|
||||
try:
|
||||
parsed = int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def _task_id_from_download_item_key(item_key: str) -> str | None:
|
||||
task_id = _parse_download_item_key(item_key)
|
||||
if task_id is None:
|
||||
return None
|
||||
return task_id
|
||||
|
||||
|
||||
def _merge_terminal_snapshot_backfill(
|
||||
*,
|
||||
status: dict[str, dict[str, Any]],
|
||||
terminal_rows: list[dict[str, Any]],
|
||||
) -> None:
|
||||
existing_task_ids: set[str] = set()
|
||||
for bucket_key in ("queued", "resolving", "locating", "downloading", "complete", "error", "cancelled"):
|
||||
bucket = status.get(bucket_key)
|
||||
if not isinstance(bucket, dict):
|
||||
continue
|
||||
existing_task_ids.update(str(task_id) for task_id in bucket.keys())
|
||||
|
||||
for row in terminal_rows:
|
||||
item_key = row.get("item_key")
|
||||
if not isinstance(item_key, str):
|
||||
continue
|
||||
task_id = _task_id_from_download_item_key(item_key)
|
||||
if not task_id or task_id in existing_task_ids:
|
||||
continue
|
||||
|
||||
final_status = row.get("final_status")
|
||||
if final_status not in {"complete", "error", "cancelled"}:
|
||||
continue
|
||||
|
||||
snapshot = row.get("snapshot")
|
||||
if not isinstance(snapshot, dict):
|
||||
continue
|
||||
raw_download = snapshot.get("download")
|
||||
if not isinstance(raw_download, dict):
|
||||
continue
|
||||
|
||||
download_payload = dict(raw_download)
|
||||
if not isinstance(download_payload.get("id"), str):
|
||||
download_payload["id"] = task_id
|
||||
|
||||
if final_status not in status or not isinstance(status.get(final_status), dict):
|
||||
status[final_status] = {}
|
||||
status[final_status][task_id] = download_payload
|
||||
existing_task_ids.add(task_id)
|
||||
|
||||
|
||||
def _collect_active_download_item_keys(status: dict[str, dict[str, Any]]) -> set[str]:
|
||||
active_keys: set[str] = set()
|
||||
for bucket_key in ("queued", "resolving", "locating", "downloading"):
|
||||
bucket = status.get(bucket_key)
|
||||
if not isinstance(bucket, dict):
|
||||
continue
|
||||
for task_id in bucket.keys():
|
||||
normalized_task_id = str(task_id).strip()
|
||||
if not normalized_task_id:
|
||||
continue
|
||||
active_keys.add(f"download:{normalized_task_id}")
|
||||
return active_keys
|
||||
|
||||
|
||||
def _extract_request_source_id(row: dict[str, Any]) -> str | None:
|
||||
release_data = row.get("release_data")
|
||||
if not isinstance(release_data, dict):
|
||||
return None
|
||||
source_id = release_data.get("source_id")
|
||||
if not isinstance(source_id, str):
|
||||
return None
|
||||
normalized = source_id.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _request_terminal_status(row: dict[str, Any]) -> str | None:
|
||||
request_status = row.get("status")
|
||||
if request_status == "pending":
|
||||
return None
|
||||
if request_status == "rejected":
|
||||
return "rejected"
|
||||
if request_status == "cancelled":
|
||||
return "cancelled"
|
||||
if request_status != "fulfilled":
|
||||
return None
|
||||
|
||||
delivery_state = str(row.get("delivery_state") or "").strip().lower()
|
||||
if delivery_state in {"error", "cancelled"}:
|
||||
return delivery_state
|
||||
return "complete"
|
||||
|
||||
|
||||
def _minimal_request_snapshot(request_row: dict[str, Any], request_id: int) -> dict[str, Any]:
|
||||
book_data = request_row.get("book_data")
|
||||
release_data = request_row.get("release_data")
|
||||
if not isinstance(book_data, dict):
|
||||
book_data = {}
|
||||
if not isinstance(release_data, dict):
|
||||
release_data = {}
|
||||
|
||||
minimal_request = {
|
||||
"id": request_id,
|
||||
"user_id": request_row.get("user_id"),
|
||||
"status": request_row.get("status"),
|
||||
"request_level": request_row.get("request_level"),
|
||||
"delivery_state": request_row.get("delivery_state"),
|
||||
"book_data": book_data,
|
||||
"release_data": release_data,
|
||||
"note": request_row.get("note"),
|
||||
"admin_note": request_row.get("admin_note"),
|
||||
"created_at": request_row.get("created_at"),
|
||||
"updated_at": request_row.get("updated_at"),
|
||||
}
|
||||
username = request_row.get("username")
|
||||
if isinstance(username, str):
|
||||
minimal_request["username"] = username
|
||||
return {"kind": "request", "request": minimal_request}
|
||||
|
||||
|
||||
def _get_existing_activity_log_id_for_item(
|
||||
*,
|
||||
activity_service: ActivityService,
|
||||
user_db: UserDB,
|
||||
item_type: str,
|
||||
item_key: str,
|
||||
) -> int | None:
|
||||
if item_type not in {"request", "download"}:
|
||||
return None
|
||||
if not isinstance(item_key, str) or not item_key.strip():
|
||||
return None
|
||||
|
||||
existing_log_id = activity_service.get_latest_activity_log_id(
|
||||
item_type=item_type,
|
||||
item_key=item_key,
|
||||
)
|
||||
if existing_log_id is not None or item_type != "request":
|
||||
return existing_log_id
|
||||
|
||||
request_id = _parse_request_item_key(item_key)
|
||||
if request_id is None:
|
||||
return None
|
||||
row = user_db.get_request(request_id)
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
final_status = _request_terminal_status(row)
|
||||
if final_status is None:
|
||||
return None
|
||||
|
||||
source_id = _extract_request_source_id(row)
|
||||
payload = activity_service.record_terminal_snapshot(
|
||||
user_id=row.get("user_id"),
|
||||
item_type="request",
|
||||
item_key=item_key,
|
||||
origin="request",
|
||||
final_status=final_status,
|
||||
snapshot=_minimal_request_snapshot(row, request_id),
|
||||
request_id=request_id,
|
||||
source_id=source_id,
|
||||
)
|
||||
return int(payload["id"])
|
||||
|
||||
|
||||
def register_activity_routes(
|
||||
app: Flask,
|
||||
user_db: UserDB,
|
||||
*,
|
||||
activity_service: ActivityService,
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
resolve_status_scope: Callable[[], tuple[bool, int | None, bool]],
|
||||
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,
|
||||
) -> None:
|
||||
"""Register activity routes."""
|
||||
|
||||
@app.route("/api/activity/snapshot", methods=["GET"])
|
||||
def api_activity_snapshot():
|
||||
auth_gate = _require_authenticated(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
if not can_access_status:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "User identity unavailable for activity workflow",
|
||||
"code": "user_identity_unavailable",
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
|
||||
viewer_db_user_id, _ = _resolve_db_user_id(require_in_auth_mode=False)
|
||||
scoped_user_id = None if is_admin else db_user_id
|
||||
status = queue_status(user_id=scoped_user_id)
|
||||
updated_requests = sync_request_delivery_states(
|
||||
user_db,
|
||||
queue_status=status,
|
||||
user_id=scoped_user_id,
|
||||
)
|
||||
emit_request_updates(updated_requests)
|
||||
request_rows = _list_visible_requests(user_db, is_admin=is_admin, db_user_id=db_user_id)
|
||||
|
||||
if not is_admin and db_user_id is not None:
|
||||
try:
|
||||
terminal_rows = activity_service.get_undismissed_terminal_downloads(db_user_id, limit=200)
|
||||
_merge_terminal_snapshot_backfill(status=status, terminal_rows=terminal_rows)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to merge terminal snapshot backfill rows: %s", exc)
|
||||
|
||||
if viewer_db_user_id is not None:
|
||||
active_download_keys = _collect_active_download_item_keys(status)
|
||||
if active_download_keys:
|
||||
try:
|
||||
activity_service.clear_dismissals_for_item_keys(
|
||||
user_id=viewer_db_user_id,
|
||||
item_type="download",
|
||||
item_keys=active_download_keys,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to clear stale download dismissals for active tasks: %s", exc)
|
||||
|
||||
dismissed: list[dict[str, str]] = []
|
||||
# Admins can view unscoped queue status, but dismissals remain per-viewer.
|
||||
if viewer_db_user_id is not None:
|
||||
dismissed = activity_service.get_dismissal_set(viewer_db_user_id)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": status,
|
||||
"requests": request_rows,
|
||||
"dismissed": dismissed,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/activity/dismiss", methods=["POST"])
|
||||
def api_activity_dismiss():
|
||||
auth_gate = _require_authenticated(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
db_user_id, db_gate = _resolve_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
return db_gate
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
activity_log_id = data.get("activity_log_id")
|
||||
if activity_log_id is None:
|
||||
try:
|
||||
activity_log_id = _get_existing_activity_log_id_for_item(
|
||||
activity_service=activity_service,
|
||||
user_db=user_db,
|
||||
item_type=data.get("item_type"),
|
||||
item_key=data.get("item_key"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to resolve activity snapshot id for dismiss payload: %s", exc)
|
||||
activity_log_id = None
|
||||
|
||||
try:
|
||||
dismissal = activity_service.dismiss_item(
|
||||
user_id=db_user_id,
|
||||
item_type=data.get("item_type"),
|
||||
item_key=data.get("item_key"),
|
||||
activity_log_id=activity_log_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
_emit_activity_event(
|
||||
ws_manager,
|
||||
room=f"user_{db_user_id}",
|
||||
payload={
|
||||
"kind": "dismiss",
|
||||
"user_id": db_user_id,
|
||||
"item_type": dismissal["item_type"],
|
||||
"item_key": dismissal["item_key"],
|
||||
},
|
||||
)
|
||||
|
||||
return jsonify({"status": "dismissed", "item": dismissal})
|
||||
|
||||
@app.route("/api/activity/dismiss-many", methods=["POST"])
|
||||
def api_activity_dismiss_many():
|
||||
auth_gate = _require_authenticated(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
db_user_id, db_gate = _resolve_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
return db_gate
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
items = data.get("items")
|
||||
if not isinstance(items, list):
|
||||
return jsonify({"error": "items must be an array"}), 400
|
||||
|
||||
normalized_items: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
return jsonify({"error": "items must contain objects"}), 400
|
||||
|
||||
activity_log_id = item.get("activity_log_id")
|
||||
if activity_log_id is None:
|
||||
try:
|
||||
activity_log_id = _get_existing_activity_log_id_for_item(
|
||||
activity_service=activity_service,
|
||||
user_db=user_db,
|
||||
item_type=item.get("item_type"),
|
||||
item_key=item.get("item_key"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to resolve activity snapshot id for dismiss-many item: %s", exc)
|
||||
activity_log_id = None
|
||||
|
||||
normalized_payload = {
|
||||
"item_type": item.get("item_type"),
|
||||
"item_key": item.get("item_key"),
|
||||
}
|
||||
if activity_log_id is not None:
|
||||
normalized_payload["activity_log_id"] = activity_log_id
|
||||
normalized_items.append(normalized_payload)
|
||||
|
||||
try:
|
||||
dismissed_count = activity_service.dismiss_many(user_id=db_user_id, items=normalized_items)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
_emit_activity_event(
|
||||
ws_manager,
|
||||
room=f"user_{db_user_id}",
|
||||
payload={
|
||||
"kind": "dismiss_many",
|
||||
"user_id": db_user_id,
|
||||
"count": dismissed_count,
|
||||
},
|
||||
)
|
||||
|
||||
return jsonify({"status": "dismissed", "count": dismissed_count})
|
||||
|
||||
@app.route("/api/activity/history", methods=["GET"])
|
||||
def api_activity_history():
|
||||
auth_gate = _require_authenticated(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
db_user_id, db_gate = _resolve_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
return db_gate
|
||||
|
||||
limit = request.args.get("limit", type=int, default=50) or 50
|
||||
offset = request.args.get("offset", type=int, default=0) or 0
|
||||
|
||||
try:
|
||||
history = activity_service.get_history(db_user_id, limit=limit, offset=offset)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(history)
|
||||
|
||||
@app.route("/api/activity/history", methods=["DELETE"])
|
||||
def api_activity_history_clear():
|
||||
auth_gate = _require_authenticated(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
db_user_id, db_gate = _resolve_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
return db_gate
|
||||
|
||||
deleted_count = activity_service.clear_history(db_user_id)
|
||||
_emit_activity_event(
|
||||
ws_manager,
|
||||
room=f"user_{db_user_id}",
|
||||
payload={
|
||||
"kind": "history_cleared",
|
||||
"user_id": db_user_id,
|
||||
"count": deleted_count,
|
||||
},
|
||||
)
|
||||
return jsonify({"status": "cleared", "deleted_count": deleted_count})
|
||||
@@ -0,0 +1,618 @@
|
||||
"""Persistence helpers for Activity dismissals and terminal snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
VALID_ITEM_TYPES = frozenset({"download", "request"})
|
||||
VALID_ORIGINS = frozenset({"direct", "request", "requested"})
|
||||
VALID_FINAL_STATUSES = frozenset({"complete", "error", "cancelled", "rejected"})
|
||||
|
||||
|
||||
def _now_timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _normalize_item_type(item_type: Any) -> str:
|
||||
if not isinstance(item_type, str):
|
||||
raise ValueError("item_type must be a string")
|
||||
normalized = item_type.strip().lower()
|
||||
if normalized not in VALID_ITEM_TYPES:
|
||||
raise ValueError("item_type must be one of: download, request")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_item_key(item_key: Any) -> str:
|
||||
if not isinstance(item_key, str):
|
||||
raise ValueError("item_key must be a string")
|
||||
normalized = item_key.strip()
|
||||
if not normalized:
|
||||
raise ValueError("item_key must not be empty")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_origin(origin: Any) -> str:
|
||||
if not isinstance(origin, str):
|
||||
raise ValueError("origin must be a string")
|
||||
normalized = origin.strip().lower()
|
||||
if normalized not in VALID_ORIGINS:
|
||||
raise ValueError("origin must be one of: direct, request, requested")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_final_status(final_status: Any) -> str:
|
||||
if not isinstance(final_status, str):
|
||||
raise ValueError("final_status must be a string")
|
||||
normalized = final_status.strip().lower()
|
||||
if normalized not in VALID_FINAL_STATUSES:
|
||||
raise ValueError("final_status must be one of: complete, error, cancelled, rejected")
|
||||
return normalized
|
||||
|
||||
|
||||
def build_item_key(item_type: str, raw_id: Any) -> str:
|
||||
"""Build a stable item key used by dismiss/history APIs."""
|
||||
normalized_type = _normalize_item_type(item_type)
|
||||
if normalized_type == "request":
|
||||
try:
|
||||
request_id = int(raw_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("request item IDs must be integers") from exc
|
||||
if request_id < 1:
|
||||
raise ValueError("request item IDs must be positive integers")
|
||||
return f"request:{request_id}"
|
||||
|
||||
if not isinstance(raw_id, str):
|
||||
raise ValueError("download item IDs must be strings")
|
||||
task_id = raw_id.strip()
|
||||
if not task_id:
|
||||
raise ValueError("download item IDs must not be empty")
|
||||
return f"download:{task_id}"
|
||||
|
||||
|
||||
def build_request_item_key(request_id: int) -> str:
|
||||
"""Build a request item key."""
|
||||
return build_item_key("request", request_id)
|
||||
|
||||
|
||||
def build_download_item_key(task_id: str) -> str:
|
||||
"""Build a download item key."""
|
||||
return build_item_key("download", task_id)
|
||||
|
||||
|
||||
def _parse_request_id_from_item_key(item_key: Any) -> int | None:
|
||||
if not isinstance(item_key, str) or not item_key.startswith("request:"):
|
||||
return None
|
||||
raw_value = item_key.split(":", 1)[1].strip()
|
||||
try:
|
||||
parsed = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def _request_final_status(request_status: Any, delivery_state: Any) -> str | None:
|
||||
status = str(request_status or "").strip().lower()
|
||||
if status == "pending":
|
||||
return None
|
||||
if status == "rejected":
|
||||
return "rejected"
|
||||
if status == "cancelled":
|
||||
return "cancelled"
|
||||
if status != "fulfilled":
|
||||
return None
|
||||
|
||||
delivery = str(delivery_state or "").strip().lower()
|
||||
if delivery in {"error", "cancelled"}:
|
||||
return delivery
|
||||
return "complete"
|
||||
|
||||
|
||||
class ActivityService:
|
||||
"""Service for per-user activity dismissals and terminal history snapshots."""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
self._db_path = db_path
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
@staticmethod
|
||||
def _coerce_positive_int(value: Any, field: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field} must be an integer") from exc
|
||||
if parsed < 1:
|
||||
raise ValueError(f"{field} must be a positive integer")
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_column(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _build_legacy_request_snapshot(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
request_id: int,
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
request_row = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
status,
|
||||
delivery_state,
|
||||
request_level,
|
||||
book_data,
|
||||
release_data,
|
||||
note,
|
||||
admin_note,
|
||||
created_at,
|
||||
reviewed_at
|
||||
FROM download_requests
|
||||
WHERE id = ?
|
||||
""",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
if request_row is None:
|
||||
return None, None
|
||||
|
||||
row_dict = dict(request_row)
|
||||
book_data = self._parse_json_column(row_dict.get("book_data"))
|
||||
release_data = self._parse_json_column(row_dict.get("release_data"))
|
||||
if not isinstance(book_data, dict):
|
||||
book_data = {}
|
||||
if not isinstance(release_data, dict):
|
||||
release_data = {}
|
||||
|
||||
snapshot = {
|
||||
"kind": "request",
|
||||
"request": {
|
||||
"id": int(row_dict["id"]),
|
||||
"user_id": row_dict.get("user_id"),
|
||||
"status": row_dict.get("status"),
|
||||
"delivery_state": row_dict.get("delivery_state"),
|
||||
"request_level": row_dict.get("request_level"),
|
||||
"book_data": book_data,
|
||||
"release_data": release_data,
|
||||
"note": row_dict.get("note"),
|
||||
"admin_note": row_dict.get("admin_note"),
|
||||
"created_at": row_dict.get("created_at"),
|
||||
"updated_at": row_dict.get("reviewed_at") or row_dict.get("created_at"),
|
||||
},
|
||||
}
|
||||
final_status = _request_final_status(row_dict.get("status"), row_dict.get("delivery_state"))
|
||||
return snapshot, final_status
|
||||
|
||||
def record_terminal_snapshot(
|
||||
self,
|
||||
*,
|
||||
user_id: int | None,
|
||||
item_type: str,
|
||||
item_key: str,
|
||||
origin: str,
|
||||
final_status: str,
|
||||
snapshot: dict[str, Any],
|
||||
request_id: int | None = None,
|
||||
source_id: str | None = None,
|
||||
terminal_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record a durable terminal-state snapshot for an activity item."""
|
||||
normalized_item_type = _normalize_item_type(item_type)
|
||||
normalized_item_key = _normalize_item_key(item_key)
|
||||
normalized_origin = _normalize_origin(origin)
|
||||
normalized_final_status = _normalize_final_status(final_status)
|
||||
if not isinstance(snapshot, dict):
|
||||
raise ValueError("snapshot must be an object")
|
||||
|
||||
if user_id is not None:
|
||||
user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
if request_id is not None:
|
||||
request_id = self._coerce_positive_int(request_id, "request_id")
|
||||
if source_id is not None and not isinstance(source_id, str):
|
||||
raise ValueError("source_id must be a string when provided")
|
||||
if source_id is not None:
|
||||
source_id = source_id.strip() or None
|
||||
|
||||
effective_terminal_at = terminal_at if isinstance(terminal_at, str) and terminal_at.strip() else _now_timestamp()
|
||||
serialized_snapshot = json.dumps(snapshot, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO activity_log (
|
||||
user_id,
|
||||
item_type,
|
||||
item_key,
|
||||
request_id,
|
||||
source_id,
|
||||
origin,
|
||||
final_status,
|
||||
snapshot_json,
|
||||
terminal_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
normalized_item_type,
|
||||
normalized_item_key,
|
||||
request_id,
|
||||
source_id,
|
||||
normalized_origin,
|
||||
normalized_final_status,
|
||||
serialized_snapshot,
|
||||
effective_terminal_at,
|
||||
),
|
||||
)
|
||||
snapshot_id = int(cursor.lastrowid)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM activity_log WHERE id = ?",
|
||||
(snapshot_id,),
|
||||
).fetchone()
|
||||
payload = self._row_to_dict(row)
|
||||
if payload is None:
|
||||
raise ValueError("Failed to read back recorded activity snapshot")
|
||||
return payload
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_latest_activity_log_id(self, *, item_type: str, item_key: str) -> int | None:
|
||||
"""Get the newest snapshot ID for an item key."""
|
||||
normalized_item_type = _normalize_item_type(item_type)
|
||||
normalized_item_key = _normalize_item_key(item_key)
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM activity_log
|
||||
WHERE item_type = ? AND item_key = ?
|
||||
ORDER BY terminal_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(normalized_item_type, normalized_item_key),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return int(row["id"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def dismiss_item(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
item_type: str,
|
||||
item_key: str,
|
||||
activity_log_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Dismiss an item for a specific user (upsert)."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
normalized_item_type = _normalize_item_type(item_type)
|
||||
normalized_item_key = _normalize_item_key(item_key)
|
||||
normalized_log_id = (
|
||||
self._coerce_positive_int(activity_log_id, "activity_log_id")
|
||||
if activity_log_id is not None
|
||||
else self.get_latest_activity_log_id(
|
||||
item_type=normalized_item_type,
|
||||
item_key=normalized_item_key,
|
||||
)
|
||||
)
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO activity_dismissals (
|
||||
user_id,
|
||||
item_type,
|
||||
item_key,
|
||||
activity_log_id,
|
||||
dismissed_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, item_type, item_key)
|
||||
DO UPDATE SET
|
||||
activity_log_id = excluded.activity_log_id,
|
||||
dismissed_at = excluded.dismissed_at
|
||||
""",
|
||||
(
|
||||
normalized_user_id,
|
||||
normalized_item_type,
|
||||
normalized_item_key,
|
||||
normalized_log_id,
|
||||
_now_timestamp(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM activity_dismissals
|
||||
WHERE user_id = ? AND item_type = ? AND item_key = ?
|
||||
""",
|
||||
(normalized_user_id, normalized_item_type, normalized_item_key),
|
||||
).fetchone()
|
||||
payload = self._row_to_dict(row)
|
||||
if payload is None:
|
||||
raise ValueError("Failed to read back dismissal row")
|
||||
return payload
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def dismiss_many(self, *, user_id: int, items: Iterable[dict[str, Any]]) -> int:
|
||||
"""Dismiss many items for one user."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
normalized_items: list[tuple[str, str, int | None]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("items must contain objects")
|
||||
normalized_item_type = _normalize_item_type(item.get("item_type"))
|
||||
normalized_item_key = _normalize_item_key(item.get("item_key"))
|
||||
raw_log_id = item.get("activity_log_id")
|
||||
normalized_log_id = (
|
||||
self._coerce_positive_int(raw_log_id, "activity_log_id")
|
||||
if raw_log_id is not None
|
||||
else self.get_latest_activity_log_id(
|
||||
item_type=normalized_item_type,
|
||||
item_key=normalized_item_key,
|
||||
)
|
||||
)
|
||||
normalized_items.append((normalized_item_type, normalized_item_key, normalized_log_id))
|
||||
|
||||
if not normalized_items:
|
||||
return 0
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
timestamp = _now_timestamp()
|
||||
for item_type, item_key, activity_log_id in normalized_items:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO activity_dismissals (
|
||||
user_id,
|
||||
item_type,
|
||||
item_key,
|
||||
activity_log_id,
|
||||
dismissed_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, item_type, item_key)
|
||||
DO UPDATE SET
|
||||
activity_log_id = excluded.activity_log_id,
|
||||
dismissed_at = excluded.dismissed_at
|
||||
""",
|
||||
(
|
||||
normalized_user_id,
|
||||
item_type,
|
||||
item_key,
|
||||
activity_log_id,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return len(normalized_items)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_dismissal_set(self, user_id: int) -> list[dict[str, str]]:
|
||||
"""Return dismissed item keys for one user."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT item_type, item_key
|
||||
FROM activity_dismissals
|
||||
WHERE user_id = ?
|
||||
ORDER BY dismissed_at DESC, id DESC
|
||||
""",
|
||||
(normalized_user_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"item_type": str(row["item_type"]),
|
||||
"item_key": str(row["item_key"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def clear_dismissals_for_item_keys(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
item_type: str,
|
||||
item_keys: Iterable[str],
|
||||
) -> int:
|
||||
"""Clear dismissals for one user + item type + item keys."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
normalized_item_type = _normalize_item_type(item_type)
|
||||
normalized_keys = {
|
||||
_normalize_item_key(item_key)
|
||||
for item_key in item_keys
|
||||
if isinstance(item_key, str) and item_key.strip()
|
||||
}
|
||||
if not normalized_keys:
|
||||
return 0
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.executemany(
|
||||
"""
|
||||
DELETE FROM activity_dismissals
|
||||
WHERE user_id = ? AND item_type = ? AND item_key = ?
|
||||
""",
|
||||
(
|
||||
(normalized_user_id, normalized_item_type, item_key)
|
||||
for item_key in normalized_keys
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cursor.rowcount or 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_history(self, user_id: int, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
|
||||
"""Return paged dismissal history for one user."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
normalized_limit = max(1, min(int(limit), 200))
|
||||
normalized_offset = max(0, int(offset))
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
d.id,
|
||||
d.user_id,
|
||||
d.item_type,
|
||||
d.item_key,
|
||||
d.activity_log_id,
|
||||
d.dismissed_at,
|
||||
l.snapshot_json,
|
||||
l.origin,
|
||||
l.final_status,
|
||||
l.terminal_at,
|
||||
l.request_id,
|
||||
l.source_id
|
||||
FROM activity_dismissals d
|
||||
LEFT JOIN activity_log l ON l.id = d.activity_log_id
|
||||
WHERE d.user_id = ?
|
||||
ORDER BY d.dismissed_at DESC, d.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(normalized_user_id, normalized_limit, normalized_offset),
|
||||
).fetchall()
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
row_dict = dict(row)
|
||||
raw_snapshot_json = row_dict.pop("snapshot_json", None)
|
||||
snapshot_payload = None
|
||||
if isinstance(raw_snapshot_json, str):
|
||||
try:
|
||||
snapshot_payload = json.loads(raw_snapshot_json)
|
||||
except (ValueError, TypeError):
|
||||
snapshot_payload = None
|
||||
|
||||
if snapshot_payload is None and row_dict.get("item_type") == "request":
|
||||
request_id = row_dict.get("request_id")
|
||||
if request_id is None:
|
||||
request_id = _parse_request_id_from_item_key(row_dict.get("item_key"))
|
||||
try:
|
||||
normalized_request_id = int(request_id) if request_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
normalized_request_id = None
|
||||
|
||||
if normalized_request_id and normalized_request_id > 0:
|
||||
fallback_snapshot, fallback_final_status = self._build_legacy_request_snapshot(
|
||||
conn,
|
||||
normalized_request_id,
|
||||
)
|
||||
if fallback_snapshot is not None:
|
||||
snapshot_payload = fallback_snapshot
|
||||
if not row_dict.get("origin"):
|
||||
row_dict["origin"] = "request"
|
||||
if not row_dict.get("final_status") and fallback_final_status is not None:
|
||||
row_dict["final_status"] = fallback_final_status
|
||||
|
||||
row_dict["snapshot"] = snapshot_payload
|
||||
payload.append(row_dict)
|
||||
return payload
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_undismissed_terminal_downloads(self, user_id: int, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
"""Return latest undismissed terminal download snapshots for one user."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
normalized_limit = max(1, min(int(limit), 500))
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
l.id,
|
||||
l.user_id,
|
||||
l.item_type,
|
||||
l.item_key,
|
||||
l.request_id,
|
||||
l.source_id,
|
||||
l.origin,
|
||||
l.final_status,
|
||||
l.snapshot_json,
|
||||
l.terminal_at
|
||||
FROM activity_log l
|
||||
LEFT JOIN activity_dismissals d
|
||||
ON d.user_id = ?
|
||||
AND d.item_type = l.item_type
|
||||
AND d.item_key = l.item_key
|
||||
WHERE l.user_id = ?
|
||||
AND l.item_type = 'download'
|
||||
AND l.final_status IN ('complete', 'error', 'cancelled')
|
||||
AND d.id IS NULL
|
||||
ORDER BY l.terminal_at DESC, l.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(normalized_user_id, normalized_user_id, normalized_limit * 2),
|
||||
).fetchall()
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
seen_item_keys: set[str] = set()
|
||||
for row in rows:
|
||||
row_dict = dict(row)
|
||||
item_key = str(row_dict.get("item_key") or "")
|
||||
if not item_key or item_key in seen_item_keys:
|
||||
continue
|
||||
seen_item_keys.add(item_key)
|
||||
|
||||
raw_snapshot_json = row_dict.pop("snapshot_json", None)
|
||||
snapshot_payload = None
|
||||
if isinstance(raw_snapshot_json, str):
|
||||
try:
|
||||
snapshot_payload = json.loads(raw_snapshot_json)
|
||||
except (ValueError, TypeError):
|
||||
snapshot_payload = None
|
||||
row_dict["snapshot"] = snapshot_payload
|
||||
payload.append(row_dict)
|
||||
if len(payload) >= normalized_limit:
|
||||
break
|
||||
|
||||
return payload
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def clear_history(self, user_id: int) -> int:
|
||||
"""Delete all dismissals for a user and return deleted row count."""
|
||||
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM activity_dismissals WHERE user_id = ?",
|
||||
(normalized_user_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cursor.rowcount or 0)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,439 @@
|
||||
"""Admin user management API routes.
|
||||
|
||||
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 flask import Flask, jsonify, request, session
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shelfmark.config.booklore_settings import (
|
||||
get_booklore_library_options,
|
||||
get_booklore_path_options,
|
||||
)
|
||||
from shelfmark.config.env import CWA_DB_PATH
|
||||
from shelfmark.core.admin_settings_routes import (
|
||||
register_admin_settings_routes,
|
||||
validate_user_settings,
|
||||
)
|
||||
from shelfmark.core.auth_modes import (
|
||||
AUTH_SOURCE_BUILTIN,
|
||||
AUTH_SOURCE_CWA,
|
||||
AUTH_SOURCE_OIDC,
|
||||
AUTH_SOURCE_PROXY,
|
||||
determine_auth_mode,
|
||||
has_local_password_admin,
|
||||
normalize_auth_source,
|
||||
)
|
||||
from shelfmark.core.cwa_user_sync import sync_cwa_users_from_rows
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _get_user_edit_capabilities(
|
||||
user: dict[str, Any],
|
||||
security_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return backend-authored capability flags for the user edit form."""
|
||||
auth_source = normalize_auth_source(
|
||||
user.get("auth_source"),
|
||||
user.get("oidc_subject"),
|
||||
)
|
||||
if security_config is None and auth_source == AUTH_SOURCE_OIDC:
|
||||
security_config = load_config_file("security")
|
||||
|
||||
oidc_use_admin_group = bool((security_config or {}).get("OIDC_USE_ADMIN_GROUP", True))
|
||||
role_managed_by_oidc_group = auth_source == AUTH_SOURCE_OIDC and oidc_use_admin_group
|
||||
can_edit_role = auth_source == AUTH_SOURCE_BUILTIN or (
|
||||
auth_source == AUTH_SOURCE_OIDC and not role_managed_by_oidc_group
|
||||
)
|
||||
|
||||
return {
|
||||
"authSource": auth_source,
|
||||
"canSetPassword": auth_source == AUTH_SOURCE_BUILTIN,
|
||||
"canEditRole": can_edit_role,
|
||||
"canEditEmail": auth_source in {AUTH_SOURCE_BUILTIN, AUTH_SOURCE_PROXY},
|
||||
"canEditDisplayName": auth_source != AUTH_SOURCE_OIDC,
|
||||
}
|
||||
|
||||
|
||||
def _get_auth_mode():
|
||||
"""Get current auth mode from config."""
|
||||
try:
|
||||
config = load_config_file("security")
|
||||
return determine_auth_mode(
|
||||
config,
|
||||
CWA_DB_PATH,
|
||||
has_local_admin=has_local_password_admin(),
|
||||
)
|
||||
except Exception:
|
||||
return "none"
|
||||
|
||||
|
||||
def _require_admin(f):
|
||||
"""Decorator to require admin session for admin routes.
|
||||
|
||||
In no-auth mode, everyone has access (is_admin defaults True).
|
||||
In auth-required modes, requires an authenticated session with admin role.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_mode = _get_auth_mode()
|
||||
if auth_mode != "none":
|
||||
if "user_id" not in session:
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
if not session.get("is_admin", False):
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def _sanitize_user(user: dict) -> dict:
|
||||
"""Remove sensitive fields from user dict before returning to client."""
|
||||
sanitized = dict(user)
|
||||
sanitized.pop("password_hash", None)
|
||||
return sanitized
|
||||
|
||||
|
||||
def _oidc_role_management_message(security_config: dict[str, Any]) -> str:
|
||||
admin_group = security_config.get("OIDC_ADMIN_GROUP", "")
|
||||
if admin_group:
|
||||
return (
|
||||
"Admin roles for OIDC users are managed by the "
|
||||
f"'{admin_group}' group in your identity provider"
|
||||
)
|
||||
return (
|
||||
"Disable 'Use Admin Group for Authorization' in security settings "
|
||||
"to manage roles manually"
|
||||
)
|
||||
|
||||
|
||||
def _is_user_active(user: dict[str, Any], auth_method: str) -> bool:
|
||||
"""Determine whether a user can authenticate in the current auth mode."""
|
||||
source = normalize_auth_source(user.get("auth_source"), user.get("oidc_subject"))
|
||||
if source == AUTH_SOURCE_BUILTIN:
|
||||
return auth_method in (AUTH_SOURCE_BUILTIN, AUTH_SOURCE_OIDC)
|
||||
return source == auth_method
|
||||
|
||||
|
||||
def _serialize_user(
|
||||
user: dict[str, Any],
|
||||
auth_method: str,
|
||||
security_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Sanitize and enrich a user payload for API responses."""
|
||||
payload = _sanitize_user(user)
|
||||
payload["auth_source"] = normalize_auth_source(
|
||||
payload.get("auth_source"),
|
||||
payload.get("oidc_subject"),
|
||||
)
|
||||
payload["is_active"] = _is_user_active(payload, auth_method)
|
||||
payload["edit_capabilities"] = _get_user_edit_capabilities(
|
||||
payload,
|
||||
security_config=security_config,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _sync_all_cwa_users(user_db: UserDB) -> dict[str, int]:
|
||||
"""Sync all users from the Calibre-Web database into users.db."""
|
||||
if not CWA_DB_PATH or not CWA_DB_PATH.exists():
|
||||
raise FileNotFoundError("Calibre-Web database is not available")
|
||||
|
||||
db_path = os.fspath(CWA_DB_PATH)
|
||||
db_uri = f"file:{db_path}?mode=ro&immutable=1"
|
||||
conn = sqlite3.connect(db_uri, uri=True)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name, role, email FROM user")
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return sync_cwa_users_from_rows(user_db, rows)
|
||||
|
||||
|
||||
def register_admin_routes(app: Flask, user_db: UserDB) -> None:
|
||||
"""Register admin user management routes on the Flask app."""
|
||||
|
||||
@app.route("/api/admin/users", methods=["GET"])
|
||||
@_require_admin
|
||||
def admin_list_users():
|
||||
"""List all users."""
|
||||
users = user_db.list_users()
|
||||
auth_mode = _get_auth_mode()
|
||||
security_config = load_config_file("security")
|
||||
return jsonify([
|
||||
_serialize_user(u, auth_mode, security_config=security_config)
|
||||
for u in users
|
||||
])
|
||||
|
||||
@app.route("/api/admin/users", methods=["POST"])
|
||||
@_require_admin
|
||||
def admin_create_user():
|
||||
"""Create a new user with password authentication."""
|
||||
data = request.get_json() or {}
|
||||
auth_mode = _get_auth_mode()
|
||||
|
||||
username = (data.get("username") or "").strip()
|
||||
password = data.get("password", "")
|
||||
email = (data.get("email") or "").strip() or None
|
||||
display_name = (data.get("display_name") or "").strip() or None
|
||||
role = data.get("role", "user")
|
||||
|
||||
if auth_mode in {AUTH_SOURCE_PROXY, AUTH_SOURCE_CWA}:
|
||||
return jsonify({
|
||||
"error": "Local user creation is disabled in this authentication mode",
|
||||
"message": (
|
||||
"Users are provisioned by your external authentication source. "
|
||||
"Switch to builtin or OIDC mode to create local users."
|
||||
),
|
||||
}), 400
|
||||
|
||||
if not username:
|
||||
return jsonify({"error": "Username is required"}), 400
|
||||
if not password or len(password) < 4:
|
||||
return jsonify({"error": "Password must be at least 4 characters"}), 400
|
||||
if role not in ("admin", "user"):
|
||||
return jsonify({"error": "Role must be 'admin' or 'user'"}), 400
|
||||
|
||||
# First user is always admin
|
||||
if not user_db.list_users():
|
||||
role = "admin"
|
||||
|
||||
# Check if username already exists
|
||||
if user_db.get_user(username=username):
|
||||
return jsonify({"error": "Username already exists"}), 409
|
||||
|
||||
password_hash = generate_password_hash(password)
|
||||
try:
|
||||
user = user_db.create_user(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
auth_source=AUTH_SOURCE_BUILTIN,
|
||||
role=role,
|
||||
)
|
||||
except ValueError:
|
||||
return jsonify({"error": "Username already exists"}), 409
|
||||
logger.info(
|
||||
"Shelfmark user created "
|
||||
f"(source=manual_admin_create, created_by={session.get('user_id', 'unknown')}, "
|
||||
f"username={username}, role={role}, auth_source={AUTH_SOURCE_BUILTIN})"
|
||||
)
|
||||
return jsonify(
|
||||
_serialize_user(
|
||||
user,
|
||||
_get_auth_mode(),
|
||||
security_config=load_config_file("security"),
|
||||
)
|
||||
), 201
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>", methods=["GET"])
|
||||
@_require_admin
|
||||
def admin_get_user(user_id):
|
||||
"""Get a user by ID with their settings."""
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
result = _serialize_user(
|
||||
user,
|
||||
_get_auth_mode(),
|
||||
security_config=load_config_file("security"),
|
||||
)
|
||||
result["settings"] = user_db.get_user_settings(user_id)
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>", methods=["PUT"])
|
||||
@_require_admin
|
||||
def admin_update_user(user_id):
|
||||
"""Update user fields and/or settings."""
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
security_config = load_config_file("security")
|
||||
auth_source = normalize_auth_source(
|
||||
user.get("auth_source"),
|
||||
user.get("oidc_subject"),
|
||||
)
|
||||
capabilities = _get_user_edit_capabilities(user, security_config=security_config)
|
||||
|
||||
# Handle optional password update
|
||||
password = data.get("password", "")
|
||||
if password:
|
||||
if not capabilities["canSetPassword"]:
|
||||
return jsonify({
|
||||
"error": f"Cannot set password for {auth_source.upper()} users",
|
||||
"message": "Password authentication is only available for local users.",
|
||||
}), 400
|
||||
if len(password) < 4:
|
||||
return jsonify({"error": "Password must be at least 4 characters"}), 400
|
||||
user_db.update_user(user_id, password_hash=generate_password_hash(password))
|
||||
|
||||
# Update user fields
|
||||
user_fields = {}
|
||||
for field in ("role", "email", "display_name"):
|
||||
if field in data:
|
||||
user_fields[field] = data[field]
|
||||
|
||||
if "role" in user_fields and user_fields["role"] not in ("admin", "user"):
|
||||
return jsonify({"error": "Role must be 'admin' or 'user'"}), 400
|
||||
|
||||
role_changed = "role" in user_fields and user_fields["role"] != user.get("role")
|
||||
email_changed = "email" in user_fields and user_fields["email"] != user.get("email")
|
||||
display_name_changed = (
|
||||
"display_name" in user_fields
|
||||
and user_fields["display_name"] != user.get("display_name")
|
||||
)
|
||||
|
||||
if role_changed and not capabilities["canEditRole"]:
|
||||
if auth_source == AUTH_SOURCE_OIDC:
|
||||
return jsonify({
|
||||
"error": "Cannot change role for OIDC user when group-based authorization is enabled",
|
||||
"message": _oidc_role_management_message(security_config),
|
||||
}), 400
|
||||
|
||||
return jsonify({
|
||||
"error": f"Cannot change role for {auth_source.upper()} users",
|
||||
"message": "Role is managed by the external authentication source.",
|
||||
}), 400
|
||||
|
||||
if email_changed and not capabilities["canEditEmail"]:
|
||||
if auth_source == AUTH_SOURCE_CWA:
|
||||
return jsonify({
|
||||
"error": "Cannot change email for CWA users",
|
||||
"message": "Email is synced from Calibre-Web.",
|
||||
}), 400
|
||||
|
||||
return jsonify({
|
||||
"error": "Cannot change email for OIDC users",
|
||||
"message": "Email is managed by your identity provider.",
|
||||
}), 400
|
||||
|
||||
if display_name_changed and not capabilities["canEditDisplayName"]:
|
||||
return jsonify({
|
||||
"error": "Cannot change display name for OIDC users",
|
||||
"message": "Display name is managed by your identity provider.",
|
||||
}), 400
|
||||
|
||||
# Allow demoting the last admin account.
|
||||
# Auth mode resolution automatically falls back to "none" when no
|
||||
# local password admin remains.
|
||||
|
||||
# Avoid unnecessary writes for no-op field updates.
|
||||
for field in ("role", "email", "display_name"):
|
||||
if field in user_fields and user_fields[field] == user.get(field):
|
||||
user_fields.pop(field)
|
||||
|
||||
if user_fields:
|
||||
user_db.update_user(user_id, **user_fields)
|
||||
|
||||
# Update per-user settings
|
||||
if "settings" in data:
|
||||
if not isinstance(data["settings"], dict):
|
||||
return jsonify({"error": "Settings must be an object"}), 400
|
||||
|
||||
validated_settings, validation_errors = validate_user_settings(data["settings"])
|
||||
if validation_errors:
|
||||
return jsonify({
|
||||
"error": "Invalid settings payload",
|
||||
"details": validation_errors,
|
||||
}), 400
|
||||
|
||||
user_db.set_user_settings(user_id, validated_settings)
|
||||
# Ensure runtime reads see updated per-user overrides immediately.
|
||||
try:
|
||||
from shelfmark.core.config import config as app_config
|
||||
app_config.refresh()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
updated = user_db.get_user(user_id=user_id)
|
||||
result = _serialize_user(
|
||||
updated,
|
||||
_get_auth_mode(),
|
||||
security_config=security_config,
|
||||
)
|
||||
result["settings"] = user_db.get_user_settings(user_id)
|
||||
logger.info(f"Admin updated user {user_id}")
|
||||
return jsonify(result)
|
||||
|
||||
@app.route("/api/admin/users/sync-cwa", methods=["POST"])
|
||||
@_require_admin
|
||||
def admin_sync_cwa_users():
|
||||
"""Manually sync users from Calibre-Web into users.db."""
|
||||
auth_mode = _get_auth_mode()
|
||||
if auth_mode != AUTH_SOURCE_CWA:
|
||||
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
|
||||
|
||||
message = (
|
||||
f"Synced {summary['total']} CWA users "
|
||||
f"({summary['created']} created, {summary['updated']} updated, "
|
||||
f"{summary.get('deleted', 0)} deleted)."
|
||||
)
|
||||
logger.info(message)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": message,
|
||||
**summary,
|
||||
})
|
||||
|
||||
register_admin_settings_routes(app, user_db, _require_admin)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>", methods=["DELETE"])
|
||||
@_require_admin
|
||||
def admin_delete_user(user_id):
|
||||
"""Delete a user."""
|
||||
# Prevent self-deletion
|
||||
if session.get("db_user_id") == user_id:
|
||||
return jsonify({"error": "Cannot delete your own account"}), 400
|
||||
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
auth_mode = _get_auth_mode()
|
||||
auth_source = normalize_auth_source(
|
||||
user.get("auth_source"),
|
||||
user.get("oidc_subject"),
|
||||
)
|
||||
if auth_source == AUTH_SOURCE_CWA and auth_source == auth_mode:
|
||||
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']}")
|
||||
return jsonify({"success": True})
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Admin settings-introspection routes and settings validation helpers."""
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
from shelfmark.config.notifications_settings import (
|
||||
build_notification_test_result,
|
||||
is_valid_notification_url,
|
||||
normalize_notification_routes,
|
||||
)
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_settings_overrides import (
|
||||
build_user_preferences_payload as _build_user_preferences_payload,
|
||||
get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields,
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
valid: dict[str, Any] = {}
|
||||
errors: list[str] = []
|
||||
for key, value in settings.items():
|
||||
if key not in field_map:
|
||||
errors.append(f"Unknown setting: {key}")
|
||||
elif key not in overridable_map:
|
||||
errors.append(f"Setting not user-overridable: {key}")
|
||||
else:
|
||||
# null means "clear the per-user override; use global default"
|
||||
if value is None:
|
||||
valid[key] = None
|
||||
continue
|
||||
|
||||
if key in {"REQUEST_POLICY_DEFAULT_EBOOK", "REQUEST_POLICY_DEFAULT_AUDIOBOOK"}:
|
||||
if parse_policy_mode(value) is None:
|
||||
errors.append(f"Invalid policy mode for {key}: {value}")
|
||||
continue
|
||||
|
||||
if key == "REQUEST_POLICY_RULES":
|
||||
normalized_rules, rule_errors = validate_policy_rules(value)
|
||||
if rule_errors:
|
||||
errors.extend(rule_errors)
|
||||
continue
|
||||
valid[key] = normalized_rules
|
||||
continue
|
||||
|
||||
if key == "USER_NOTIFICATION_ROUTES":
|
||||
normalized_routes = normalize_notification_routes(value)
|
||||
invalid_count = sum(
|
||||
1
|
||||
for row in normalized_routes
|
||||
if row.get("url") and not is_valid_notification_url(str(row.get("url")))
|
||||
)
|
||||
if invalid_count:
|
||||
errors.append(
|
||||
(
|
||||
f"Invalid value for {key}: found {invalid_count} invalid URL(s). "
|
||||
"Use URL values with a valid scheme, e.g. discord://... or ntfys://..."
|
||||
)
|
||||
)
|
||||
continue
|
||||
valid[key] = normalized_routes
|
||||
continue
|
||||
|
||||
valid[key] = value
|
||||
|
||||
return valid, errors
|
||||
|
||||
|
||||
def build_user_notification_test_response(
|
||||
*,
|
||||
user_id: int,
|
||||
payload: Any,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
routes_input = app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=user_id)
|
||||
if isinstance(payload, dict):
|
||||
if "USER_NOTIFICATION_ROUTES" in payload:
|
||||
routes_input = payload.get("USER_NOTIFICATION_ROUTES")
|
||||
elif "routes" in payload:
|
||||
routes_input = payload.get("routes")
|
||||
|
||||
result = build_notification_test_result(routes_input, scope_label="personal")
|
||||
status_code = 200 if result.get("success", False) else 400
|
||||
return result, status_code
|
||||
|
||||
|
||||
def register_admin_settings_routes(
|
||||
app: Flask,
|
||||
user_db: UserDB,
|
||||
require_admin: Callable[[Callable[..., Any]], Callable[..., Any]],
|
||||
) -> None:
|
||||
@app.route("/api/admin/download-defaults", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_download_defaults():
|
||||
config = load_config_file("downloads")
|
||||
defaults = {
|
||||
key: ("" if (value := config.get(key, field.default)) is None else value)
|
||||
for key, field in _get_ordered_user_overridable_fields("downloads")
|
||||
}
|
||||
|
||||
security_config = load_config_file("security")
|
||||
defaults["OIDC_ADMIN_GROUP"] = security_config.get("OIDC_ADMIN_GROUP", "")
|
||||
defaults["OIDC_USE_ADMIN_GROUP"] = security_config.get("OIDC_USE_ADMIN_GROUP", True)
|
||||
defaults["OIDC_AUTO_PROVISION"] = security_config.get("OIDC_AUTO_PROVISION", True)
|
||||
return jsonify(defaults)
|
||||
|
||||
@app.route("/api/admin/booklore-options", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_booklore_options():
|
||||
from shelfmark.core import admin_routes
|
||||
|
||||
return jsonify({
|
||||
"libraries": admin_routes.get_booklore_library_options(),
|
||||
"paths": admin_routes.get_booklore_path_options(),
|
||||
})
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/delivery-preferences", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_delivery_preferences(user_id):
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
try:
|
||||
payload = _build_user_preferences_payload(user_db, user_id, "downloads")
|
||||
except ValueError:
|
||||
return jsonify({"error": "Downloads settings tab not found"}), 500
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/notification-preferences", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_notification_preferences(user_id):
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
try:
|
||||
payload = _build_user_preferences_payload(user_db, user_id, "notifications")
|
||||
except ValueError:
|
||||
return jsonify({"error": "Notifications settings tab not found"}), 500
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/notification-preferences/test", methods=["POST"])
|
||||
@require_admin
|
||||
def admin_test_notification_preferences(user_id):
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
result, status_code = build_user_notification_test_response(
|
||||
user_id=user_id,
|
||||
payload=payload,
|
||||
)
|
||||
return jsonify(result), status_code
|
||||
|
||||
@app.route("/api/admin/settings/overrides-summary", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_settings_overrides_summary():
|
||||
settings_registry = _get_settings_registry()
|
||||
|
||||
tab_name = (request.args.get("tab") or "downloads").strip()
|
||||
if not settings_registry.get_settings_tab(tab_name):
|
||||
return jsonify({"error": f"Unknown settings tab: {tab_name}"}), 404
|
||||
|
||||
overridable_keys = list(settings_registry.get_user_overridable_fields(tab_name=tab_name))
|
||||
keys_payload: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for user_record in user_db.list_users():
|
||||
user_settings = user_db.get_user_settings(user_record["id"])
|
||||
if not isinstance(user_settings, dict):
|
||||
continue
|
||||
|
||||
for key in overridable_keys:
|
||||
if key not in user_settings or user_settings[key] is None:
|
||||
continue
|
||||
entry = keys_payload.setdefault(key, {"count": 0, "users": []})
|
||||
entry["users"].append({
|
||||
"userId": user_record["id"],
|
||||
"username": user_record["username"],
|
||||
"value": user_settings[key],
|
||||
})
|
||||
|
||||
for summary in keys_payload.values():
|
||||
summary["count"] = len(summary["users"])
|
||||
|
||||
return jsonify({"tab": tab_name, "keys": keys_payload})
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>/effective-settings", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_effective_settings(user_id):
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.settings_registry import is_value_from_env
|
||||
|
||||
field_map = _get_settings_registry().get_user_overridable_fields()
|
||||
user_settings = user_db.get_user_settings(user_id)
|
||||
tab_config_cache: dict[str, dict[str, Any]] = {}
|
||||
effective: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for key, (field, tab_name) in sorted(field_map.items()):
|
||||
value = app_config.get(key, field.default, user_id=user_id)
|
||||
source = "default"
|
||||
|
||||
if field.env_supported and is_value_from_env(field):
|
||||
source = "env_var"
|
||||
elif key in user_settings and user_settings[key] is not None:
|
||||
source = "user_override"
|
||||
value = user_settings[key]
|
||||
else:
|
||||
tab_config = tab_config_cache.setdefault(tab_name, load_config_file(tab_name))
|
||||
if key in tab_config:
|
||||
source = "global_config"
|
||||
|
||||
effective[key] = {"value": value, "source": source}
|
||||
|
||||
return jsonify(effective)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Authentication mode, auth-source normalization, and admin access policy helpers."""
|
||||
|
||||
import os
|
||||
from typing import Any, Mapping
|
||||
|
||||
AUTH_SOURCE_BUILTIN = "builtin"
|
||||
AUTH_SOURCE_OIDC = "oidc"
|
||||
AUTH_SOURCE_PROXY = "proxy"
|
||||
AUTH_SOURCE_CWA = "cwa"
|
||||
AUTH_SOURCES = (
|
||||
AUTH_SOURCE_BUILTIN,
|
||||
AUTH_SOURCE_OIDC,
|
||||
AUTH_SOURCE_PROXY,
|
||||
AUTH_SOURCE_CWA,
|
||||
)
|
||||
AUTH_SOURCE_SET = frozenset(AUTH_SOURCES)
|
||||
_ALWAYS_ADMIN_SETTINGS_TABS = frozenset({"security", "users"})
|
||||
|
||||
|
||||
def has_local_password_admin(user_db: Any | None = None) -> bool:
|
||||
"""Return True when at least one local admin with a password exists."""
|
||||
try:
|
||||
db = user_db
|
||||
if db is None:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
config_root = os.environ.get("CONFIG_DIR", "/config")
|
||||
db = UserDB(os.path.join(config_root, "users.db"))
|
||||
db.initialize()
|
||||
|
||||
return any(
|
||||
user.get("password_hash") and user.get("role") == "admin"
|
||||
for user in db.list_users()
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def normalize_auth_source(
|
||||
source: Any,
|
||||
oidc_subject: Any = None,
|
||||
) -> str:
|
||||
"""Resolve a stable auth source value from persisted fields."""
|
||||
normalized = str(source or "").strip().lower()
|
||||
if normalized in AUTH_SOURCE_SET:
|
||||
return normalized
|
||||
if oidc_subject:
|
||||
return AUTH_SOURCE_OIDC
|
||||
return AUTH_SOURCE_BUILTIN
|
||||
|
||||
|
||||
def determine_auth_mode(
|
||||
security_config: Mapping[str, Any],
|
||||
cwa_db_path: Any | None,
|
||||
*,
|
||||
has_local_admin: bool = True,
|
||||
) -> str:
|
||||
"""Determine active auth mode from security config and runtime prerequisites."""
|
||||
auth_mode = security_config.get("AUTH_METHOD", "none")
|
||||
|
||||
if auth_mode == AUTH_SOURCE_CWA and cwa_db_path:
|
||||
return AUTH_SOURCE_CWA
|
||||
|
||||
if auth_mode == AUTH_SOURCE_BUILTIN and has_local_admin:
|
||||
return AUTH_SOURCE_BUILTIN
|
||||
|
||||
if auth_mode == AUTH_SOURCE_PROXY and security_config.get("PROXY_AUTH_USER_HEADER"):
|
||||
return AUTH_SOURCE_PROXY
|
||||
|
||||
if (
|
||||
auth_mode == AUTH_SOURCE_OIDC
|
||||
and has_local_admin
|
||||
and security_config.get("OIDC_DISCOVERY_URL")
|
||||
and security_config.get("OIDC_CLIENT_ID")
|
||||
):
|
||||
return AUTH_SOURCE_OIDC
|
||||
|
||||
return "none"
|
||||
|
||||
|
||||
def 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")
|
||||
|
||||
|
||||
def get_settings_tab_from_path(path: str) -> str | None:
|
||||
"""Extract tab name from /api/settings/<tab>[...] paths."""
|
||||
if not path.startswith("/api/settings/"):
|
||||
return None
|
||||
|
||||
suffix = path[len("/api/settings/"):]
|
||||
if not suffix:
|
||||
return None
|
||||
|
||||
return suffix.split("/", 1)[0] or None
|
||||
|
||||
|
||||
def should_restrict_settings_to_admin(
|
||||
_users_config: Mapping[str, Any],
|
||||
) -> bool:
|
||||
"""Settings/onboarding is always admin-only."""
|
||||
return True
|
||||
|
||||
|
||||
def requires_admin_for_settings_access(
|
||||
path: str,
|
||||
users_config: Mapping[str, Any],
|
||||
) -> bool:
|
||||
"""Return whether this settings/onboarding request requires admin privileges."""
|
||||
tab_name = get_settings_tab_from_path(path)
|
||||
if tab_name in _ALWAYS_ADMIN_SETTINGS_TABS:
|
||||
return True
|
||||
|
||||
return should_restrict_settings_to_admin(users_config)
|
||||
|
||||
|
||||
def get_auth_check_admin_status(
|
||||
_auth_mode: str,
|
||||
_users_config: Mapping[str, Any],
|
||||
session_data: Mapping[str, Any],
|
||||
) -> bool:
|
||||
"""Resolve /api/auth/check `is_admin` as the session's real admin role."""
|
||||
if "user_id" not in session_data:
|
||||
return False
|
||||
|
||||
return bool(session_data.get("is_admin", False))
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Configuration singleton with ENV > config file > default resolution."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# Import lazily to avoid circular imports
|
||||
_registry_module = None
|
||||
_env_module = None
|
||||
_user_db_module = None
|
||||
|
||||
|
||||
def _get_registry():
|
||||
@@ -26,6 +29,15 @@ def _get_env():
|
||||
return _env_module
|
||||
|
||||
|
||||
def _get_user_db_module():
|
||||
"""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.
|
||||
@@ -36,7 +48,6 @@ class Config:
|
||||
|
||||
_instance: Optional['Config'] = None
|
||||
_lock = Lock()
|
||||
|
||||
def __new__(cls) -> 'Config':
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
@@ -51,6 +62,10 @@ class Config:
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._field_map: Dict[str, tuple] = {} # key -> (field, tab_name)
|
||||
self._cache_lock = Lock()
|
||||
self._user_settings_cache: Dict[int, Dict[str, Any]] = {}
|
||||
self._user_settings_cache_lock = Lock()
|
||||
self._user_db = None
|
||||
self._user_db_load_attempted = False
|
||||
self._initialized = True
|
||||
self._loaded = False
|
||||
|
||||
@@ -69,6 +84,7 @@ class Config:
|
||||
# This handles cases where config is accessed before settings are registered
|
||||
try:
|
||||
import shelfmark.config.settings # noqa: F401 - main app settings
|
||||
import shelfmark.config.notifications_settings # noqa: F401 - notifications settings
|
||||
import shelfmark.release_sources # noqa: F401 - plugin settings
|
||||
import shelfmark.metadata_providers # noqa: F401 - plugin settings
|
||||
except ImportError:
|
||||
@@ -111,19 +127,85 @@ class Config:
|
||||
with self._cache_lock:
|
||||
self._loaded = False
|
||||
self._load_settings()
|
||||
with self._user_settings_cache_lock:
|
||||
self._user_settings_cache.clear()
|
||||
self._user_db = None
|
||||
self._user_db_load_attempted = False
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
def _get_user_db(self):
|
||||
"""Get or initialize a UserDB handle if available."""
|
||||
if self._user_db is not None:
|
||||
return self._user_db
|
||||
if self._user_db_load_attempted:
|
||||
return None
|
||||
|
||||
self._user_db_load_attempted = True
|
||||
try:
|
||||
user_db_cls = _get_user_db_module()
|
||||
db_path = os.path.join(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
|
||||
|
||||
def _get_user_settings(self, user_id: int) -> Dict[str, Any]:
|
||||
"""Get cached per-user settings from user DB."""
|
||||
with self._user_settings_cache_lock:
|
||||
if user_id in self._user_settings_cache:
|
||||
return self._user_settings_cache[user_id]
|
||||
|
||||
user_db = self._get_user_db()
|
||||
if user_db is None:
|
||||
return {}
|
||||
|
||||
try:
|
||||
settings = user_db.get_user_settings(user_id)
|
||||
except (sqlite3.OperationalError, OSError, ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
|
||||
with self._user_settings_cache_lock:
|
||||
self._user_settings_cache[user_id] = settings
|
||||
return settings
|
||||
|
||||
def _get_user_override(self, user_id: int, key: str) -> Any:
|
||||
"""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.
|
||||
|
||||
Args:
|
||||
key: The setting key (e.g., 'MAX_RETRY')
|
||||
default: Default value if setting not found
|
||||
user_id: Optional DB user ID for per-user setting overrides
|
||||
|
||||
Returns:
|
||||
The setting value, or default if not found
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
if key in self._field_map:
|
||||
field, _ = self._field_map[key]
|
||||
registry = _get_registry()
|
||||
|
||||
# Deployment-level ENV values always win.
|
||||
if field.env_supported and registry.is_value_from_env(field):
|
||||
return self._cache.get(key, default)
|
||||
|
||||
# User overrides are only available for explicitly overridable fields.
|
||||
if user_id is not None and getattr(field, "user_overridable", False):
|
||||
user_value = self._get_user_override(user_id, key)
|
||||
if user_value is not None:
|
||||
return user_value
|
||||
|
||||
return self._cache.get(key, default)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Helpers for provisioning and syncing Calibre-Web users into users.db."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
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
|
||||
|
||||
_CWA_ALIAS_SUFFIX = "__cwa"
|
||||
|
||||
|
||||
def _normalize_email(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
email = str(value).strip()
|
||||
return email or None
|
||||
|
||||
|
||||
def upsert_cwa_user(
|
||||
user_db: UserDB,
|
||||
cwa_username: str,
|
||||
cwa_email: str | None,
|
||||
role: str,
|
||||
context: str | None = None,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
"""Create/update a CWA-backed user with collision-safe matching."""
|
||||
normalized_email = _normalize_email(cwa_email)
|
||||
collision_strategy = "alias" if normalized_email else "takeover"
|
||||
user, action = upsert_external_user(
|
||||
user_db,
|
||||
auth_source="cwa",
|
||||
username=cwa_username,
|
||||
email=normalized_email,
|
||||
role=role,
|
||||
allow_email_link=True,
|
||||
collision_strategy=collision_strategy,
|
||||
alias_suffix=_CWA_ALIAS_SUFFIX,
|
||||
context=context,
|
||||
)
|
||||
if user is None:
|
||||
raise RuntimeError("Unexpected CWA user sync result: no user returned")
|
||||
return user, action
|
||||
|
||||
|
||||
def sync_cwa_users_from_rows(
|
||||
user_db: UserDB,
|
||||
rows: Iterable[tuple[Any, Any, Any]],
|
||||
) -> dict[str, int]:
|
||||
"""Sync CWA users from raw `(name, role_flags, email)` rows."""
|
||||
created = 0
|
||||
updated = 0
|
||||
active_cwa_user_ids: set[int] = set()
|
||||
for username, role_flags, email in rows:
|
||||
normalized_username = str(username or "").strip()
|
||||
if not normalized_username:
|
||||
continue
|
||||
|
||||
role = "admin" if (int(role_flags or 0) & 1) == 1 else "user"
|
||||
user, action = upsert_cwa_user(
|
||||
user_db,
|
||||
cwa_username=normalized_username,
|
||||
cwa_email=_normalize_email(email),
|
||||
role=role,
|
||||
context="cwa_manual_sync",
|
||||
)
|
||||
active_cwa_user_ids.add(int(user["id"]))
|
||||
if action == "created":
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
deleted = 0
|
||||
for existing_user in user_db.list_users():
|
||||
if normalize_auth_source(
|
||||
existing_user.get("auth_source"),
|
||||
existing_user.get("oidc_subject"),
|
||||
) != AUTH_SOURCE_CWA:
|
||||
continue
|
||||
|
||||
existing_id = int(existing_user.get("id") or 0)
|
||||
if existing_id in active_cwa_user_ids:
|
||||
continue
|
||||
|
||||
user_db.delete_user(existing_id)
|
||||
deleted += 1
|
||||
|
||||
return {
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"deleted": deleted,
|
||||
"total": created + updated,
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Shared external identity matching and provisioning helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import 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
|
||||
|
||||
UNSET = object()
|
||||
|
||||
CollisionStrategy = Literal["takeover", "suffix", "alias"]
|
||||
MatchReason = Literal[
|
||||
"subject_match",
|
||||
"existing_source_username_match",
|
||||
"unique_email_match",
|
||||
]
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _normalize_username(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _normalize_email(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
email = str(value).strip()
|
||||
return email or None
|
||||
|
||||
|
||||
def _normalize_display_name(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
name = str(value).strip()
|
||||
return name or None
|
||||
|
||||
|
||||
def _email_key(value: str | None) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _normalize_role(value: Any) -> str:
|
||||
return "admin" if str(value or "").strip().lower() == "admin" else "user"
|
||||
|
||||
|
||||
def _get_by_subject(user_db: UserDB, subject_field: str | None, subject: str | None) -> dict[str, Any] | None:
|
||||
if not subject_field or not subject:
|
||||
return None
|
||||
if subject_field == "oidc_subject":
|
||||
return user_db.get_user(oidc_subject=subject)
|
||||
return None
|
||||
|
||||
|
||||
def find_unique_user_by_email(user_db: UserDB, email: str | None) -> dict[str, Any] | None:
|
||||
key = _email_key(_normalize_email(email))
|
||||
if not key:
|
||||
return None
|
||||
|
||||
matches = [u for u in user_db.list_users() if _email_key(u.get("email")) == key]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def find_external_user_match(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
auth_source: str,
|
||||
username: str,
|
||||
email: str | None,
|
||||
subject_field: str | None = None,
|
||||
subject: str | None = None,
|
||||
allow_email_link: bool = False,
|
||||
) -> tuple[dict[str, Any] | None, MatchReason | None]:
|
||||
"""Find an existing local user that should be linked to an external identity."""
|
||||
normalized_username = _normalize_username(username)
|
||||
normalized_email = _normalize_email(email)
|
||||
|
||||
by_subject = _get_by_subject(user_db, subject_field, subject)
|
||||
if by_subject is not None:
|
||||
return by_subject, "subject_match"
|
||||
|
||||
by_username = user_db.get_user(username=normalized_username)
|
||||
if by_username and normalize_auth_source(
|
||||
by_username.get("auth_source"),
|
||||
by_username.get("oidc_subject"),
|
||||
) == auth_source:
|
||||
return by_username, "existing_source_username_match"
|
||||
|
||||
if allow_email_link:
|
||||
return find_unique_user_by_email(user_db, normalized_email), "unique_email_match"
|
||||
return None, None
|
||||
|
||||
|
||||
def _build_updates(
|
||||
*,
|
||||
auth_source: str,
|
||||
role: str,
|
||||
sync_role: bool,
|
||||
email: str | None | object,
|
||||
display_name: str | None | object,
|
||||
subject_field: str | None,
|
||||
subject: str | None,
|
||||
) -> dict[str, Any]:
|
||||
updates: dict[str, Any] = {"auth_source": auth_source}
|
||||
if sync_role:
|
||||
updates["role"] = _normalize_role(role)
|
||||
if email is not UNSET:
|
||||
updates["email"] = _normalize_email(email)
|
||||
if display_name is not UNSET:
|
||||
updates["display_name"] = _normalize_display_name(display_name)
|
||||
if subject_field == "oidc_subject" and subject:
|
||||
updates["oidc_subject"] = subject
|
||||
return updates
|
||||
|
||||
|
||||
def _next_suffix_username(user_db: UserDB, base_username: str) -> str:
|
||||
candidate = base_username
|
||||
suffix = 1
|
||||
while user_db.get_user(username=candidate):
|
||||
candidate = f"{base_username}_{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _find_existing_alias_user(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
auth_source: str,
|
||||
alias_base: str,
|
||||
) -> dict[str, Any] | None:
|
||||
pattern = re.compile(rf"^{re.escape(alias_base)}(?:_\d+)?$")
|
||||
candidates = [
|
||||
user for user in user_db.list_users()
|
||||
if pattern.match(str(user.get("username") or ""))
|
||||
and normalize_auth_source(user.get("auth_source"), user.get("oidc_subject")) == auth_source
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return sorted(candidates, key=lambda user: int(user.get("id") or 0))[0]
|
||||
|
||||
|
||||
def _resolve_create_username(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
auth_source: str,
|
||||
requested_username: str,
|
||||
strategy: CollisionStrategy,
|
||||
alias_suffix: str,
|
||||
) -> tuple[str | None, dict[str, Any] | None, str]:
|
||||
existing = user_db.get_user(username=requested_username)
|
||||
if not existing:
|
||||
return requested_username, None, "new_username_available"
|
||||
|
||||
if strategy == "takeover":
|
||||
return None, existing, "username_collision_takeover"
|
||||
|
||||
if strategy == "suffix":
|
||||
return _next_suffix_username(user_db, requested_username), None, "username_collision_suffix"
|
||||
|
||||
alias_base = f"{requested_username}{alias_suffix}"
|
||||
alias_existing = _find_existing_alias_user(
|
||||
user_db,
|
||||
auth_source=auth_source,
|
||||
alias_base=alias_base,
|
||||
)
|
||||
if alias_existing is not None:
|
||||
return None, alias_existing, "reuse_existing_alias"
|
||||
return _next_suffix_username(user_db, alias_base), None, "username_collision_alias"
|
||||
|
||||
|
||||
def upsert_external_user(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
auth_source: str,
|
||||
username: str,
|
||||
role: str,
|
||||
email: str | None | object = UNSET,
|
||||
display_name: str | None | object = UNSET,
|
||||
subject_field: str | None = None,
|
||||
subject: str | None = None,
|
||||
allow_email_link: bool = False,
|
||||
sync_role: bool = True,
|
||||
allow_create: bool = True,
|
||||
collision_strategy: CollisionStrategy = "takeover",
|
||||
alias_suffix: str | None = None,
|
||||
context: str | None = None,
|
||||
) -> tuple[dict[str, Any] | None, str]:
|
||||
"""Create/update a user from an external auth identity.
|
||||
|
||||
Returns `(user, action)` where action is one of:
|
||||
- `"updated"`
|
||||
- `"created"`
|
||||
- `"not_found"` (when `allow_create=False` and no link target exists)
|
||||
"""
|
||||
normalized_username = _normalize_username(username)
|
||||
if not normalized_username:
|
||||
raise ValueError("External username is required")
|
||||
|
||||
normalized_email = _normalize_email(email) if email is not UNSET else None
|
||||
normalized_display_name = (
|
||||
_normalize_display_name(display_name) if display_name is not UNSET else None
|
||||
)
|
||||
normalized_role = _normalize_role(role)
|
||||
|
||||
matched, match_reason = find_external_user_match(
|
||||
user_db,
|
||||
auth_source=auth_source,
|
||||
username=normalized_username,
|
||||
email=normalized_email,
|
||||
subject_field=subject_field,
|
||||
subject=subject,
|
||||
allow_email_link=allow_email_link,
|
||||
)
|
||||
updates = _build_updates(
|
||||
auth_source=auth_source,
|
||||
role=normalized_role,
|
||||
sync_role=sync_role,
|
||||
email=normalized_email if email is not UNSET else UNSET,
|
||||
display_name=normalized_display_name if display_name is not UNSET else UNSET,
|
||||
subject_field=subject_field,
|
||||
subject=subject,
|
||||
)
|
||||
if matched is not None:
|
||||
user_db.update_user(matched["id"], **updates)
|
||||
mapped = user_db.get_user(user_id=matched["id"]) or matched
|
||||
logger.info(
|
||||
"External user mapped to existing Shelfmark user "
|
||||
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']})"
|
||||
)
|
||||
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})"
|
||||
)
|
||||
return None, "not_found"
|
||||
|
||||
resolved_alias_suffix = alias_suffix or f"__{auth_source}"
|
||||
create_username, takeover_target, create_reason = _resolve_create_username(
|
||||
user_db,
|
||||
auth_source=auth_source,
|
||||
requested_username=normalized_username,
|
||||
strategy=collision_strategy,
|
||||
alias_suffix=resolved_alias_suffix,
|
||||
)
|
||||
if takeover_target is not None:
|
||||
user_db.update_user(takeover_target["id"], **updates)
|
||||
mapped = user_db.get_user(user_id=takeover_target["id"]) or takeover_target
|
||||
logger.info(
|
||||
"External user mapped to existing Shelfmark user "
|
||||
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']})"
|
||||
)
|
||||
return mapped, "updated"
|
||||
|
||||
create_kwargs: dict[str, Any] = {
|
||||
"username": create_username,
|
||||
"auth_source": auth_source,
|
||||
"role": normalized_role,
|
||||
}
|
||||
if email is not UNSET:
|
||||
create_kwargs["email"] = normalized_email
|
||||
if display_name is not UNSET:
|
||||
create_kwargs["display_name"] = normalized_display_name
|
||||
if subject_field == "oidc_subject" and subject:
|
||||
create_kwargs["oidc_subject"] = subject
|
||||
|
||||
created = user_db.create_user(**create_kwargs)
|
||||
logger.info(
|
||||
"External user created Shelfmark user "
|
||||
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']})"
|
||||
)
|
||||
return created, "created"
|
||||
+29
-13
@@ -39,22 +39,38 @@ class CustomLogger(logging.Logger):
|
||||
self.debug(msg, *args, exc_info=has_exception, **kwargs)
|
||||
|
||||
def log_resource_usage(self):
|
||||
import psutil
|
||||
# Best-effort only; this should never raise during exception logging.
|
||||
try:
|
||||
import psutil
|
||||
|
||||
# Sum RSS of all processes for actual app memory
|
||||
app_memory_mb = 0
|
||||
for proc in psutil.process_iter(['memory_info']):
|
||||
# Sum RSS of all processes for actual app memory (container-friendly),
|
||||
# but fall back gracefully on platforms that restrict process enumeration.
|
||||
app_memory_mb = 0.0
|
||||
try:
|
||||
if proc.info['memory_info']:
|
||||
app_memory_mb += proc.info['memory_info'].rss / (1024 * 1024)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
for proc in psutil.process_iter(['memory_info']):
|
||||
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):
|
||||
try:
|
||||
app_memory_mb = psutil.Process().memory_info().rss / (1024 * 1024)
|
||||
except Exception:
|
||||
app_memory_mb = 0.0
|
||||
|
||||
memory = psutil.virtual_memory()
|
||||
system_used_mb = memory.used / (1024 * 1024)
|
||||
available_mb = memory.available / (1024 * 1024)
|
||||
cpu_percent = psutil.cpu_percent()
|
||||
self.debug(f"Container Memory: App={app_memory_mb:.2f} MB, System={system_used_mb:.2f} MB, Available={available_mb:.2f} MB, CPU: {cpu_percent:.2f}%")
|
||||
memory = psutil.virtual_memory()
|
||||
system_used_mb = memory.used / (1024 * 1024)
|
||||
available_mb = memory.available / (1024 * 1024)
|
||||
cpu_percent = psutil.cpu_percent()
|
||||
self.debug(
|
||||
f"Container Memory: App={app_memory_mb:.2f} MB, System={system_used_mb:.2f} MB, "
|
||||
f"Available={available_mb:.2f} MB, CPU: {cpu_percent:.2f}%"
|
||||
)
|
||||
except Exception:
|
||||
# Avoid breaking the original log call if psutil is missing or restricted.
|
||||
return
|
||||
|
||||
|
||||
def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
|
||||
+76
-35
@@ -2,6 +2,8 @@
|
||||
|
||||
from typing import List
|
||||
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
|
||||
# Lazy import to avoid circular imports
|
||||
_config_module = None
|
||||
|
||||
@@ -17,10 +19,8 @@ def _get_config():
|
||||
|
||||
# Default mirror lists (hardcoded fallbacks)
|
||||
DEFAULT_AA_MIRRORS = [
|
||||
"https://annas-archive.se",
|
||||
"https://annas-archive.gl",
|
||||
"https://annas-archive.li",
|
||||
"https://annas-archive.pm",
|
||||
"https://annas-archive.in",
|
||||
]
|
||||
|
||||
DEFAULT_LIBGEN_MIRRORS = [
|
||||
@@ -44,22 +44,52 @@ DEFAULT_WELIB_MIRRORS = [
|
||||
]
|
||||
|
||||
|
||||
def _normalize_mirror_url(url: str) -> str:
|
||||
return normalize_http_url(url, default_scheme="https")
|
||||
|
||||
|
||||
def get_aa_mirrors() -> List[str]:
|
||||
"""
|
||||
Get Anna's Archive mirrors from config + defaults.
|
||||
Get Anna's Archive mirrors.
|
||||
|
||||
Returns:
|
||||
List of AA mirror URLs, starting with defaults then custom additions.
|
||||
Ordered list of AA mirror URLs.
|
||||
|
||||
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:
|
||||
- The list is used to populate the AA mirror dropdown in Settings.
|
||||
- When AA_BASE_URL is set to 'auto', mirrors are tried in the order listed.
|
||||
"""
|
||||
mirrors = list(DEFAULT_AA_MIRRORS)
|
||||
config = _get_config()
|
||||
|
||||
additional = config.get("AA_ADDITIONAL_URLS", "")
|
||||
if additional:
|
||||
for url in additional.split(","):
|
||||
url = url.strip()
|
||||
if url and url not in mirrors:
|
||||
mirrors.append(url)
|
||||
mirrors: list[str] = []
|
||||
|
||||
configured_list = config.get("AA_MIRROR_URLS", None)
|
||||
if isinstance(configured_list, list):
|
||||
for url in configured_list:
|
||||
normalized = _normalize_mirror_url(str(url))
|
||||
if normalized and normalized not in mirrors:
|
||||
mirrors.append(normalized)
|
||||
elif isinstance(configured_list, str) and configured_list.strip():
|
||||
# Allow comma-separated env/manual configs.
|
||||
for url in configured_list.split(","):
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized not in mirrors:
|
||||
mirrors.append(normalized)
|
||||
|
||||
if not mirrors:
|
||||
mirrors = [_normalize_mirror_url(url) for url in DEFAULT_AA_MIRRORS]
|
||||
mirrors = [url for url in mirrors if url]
|
||||
|
||||
# Backwards-compatible append-only behavior for legacy configs/env.
|
||||
additional = config.get("AA_ADDITIONAL_URLS", "")
|
||||
if additional:
|
||||
for url in additional.split(","):
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized not in mirrors:
|
||||
mirrors.append(normalized)
|
||||
|
||||
return mirrors
|
||||
|
||||
@@ -71,15 +101,16 @@ def get_libgen_mirrors() -> List[str]:
|
||||
Returns:
|
||||
List of LibGen mirror URLs (defaults first, then custom additions).
|
||||
"""
|
||||
mirrors = list(DEFAULT_LIBGEN_MIRRORS)
|
||||
mirrors = [_normalize_mirror_url(url) for url in DEFAULT_LIBGEN_MIRRORS]
|
||||
mirrors = [url for url in mirrors if url]
|
||||
config = _get_config()
|
||||
|
||||
additional = config.get("LIBGEN_ADDITIONAL_URLS", "")
|
||||
if additional:
|
||||
for url in additional.split(","):
|
||||
url = url.strip()
|
||||
if url and url not in mirrors:
|
||||
mirrors.append(url)
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized not in mirrors:
|
||||
mirrors.append(normalized)
|
||||
|
||||
return mirrors
|
||||
|
||||
@@ -93,21 +124,24 @@ def get_zlib_mirrors() -> List[str]:
|
||||
"""
|
||||
config = _get_config()
|
||||
|
||||
primary = config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0])
|
||||
primary = _normalize_mirror_url(config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0]))
|
||||
if not primary:
|
||||
primary = _normalize_mirror_url(DEFAULT_ZLIB_MIRRORS[0])
|
||||
mirrors = [primary]
|
||||
|
||||
# Add other defaults (excluding primary)
|
||||
for url in DEFAULT_ZLIB_MIRRORS:
|
||||
if url != primary:
|
||||
mirrors.append(url)
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized != primary:
|
||||
mirrors.append(normalized)
|
||||
|
||||
# Add custom mirrors
|
||||
additional = config.get("ZLIB_ADDITIONAL_URLS", "")
|
||||
if additional:
|
||||
for url in additional.split(","):
|
||||
url = url.strip()
|
||||
if url and url not in mirrors:
|
||||
mirrors.append(url)
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized not in mirrors:
|
||||
mirrors.append(normalized)
|
||||
|
||||
return mirrors
|
||||
|
||||
@@ -120,7 +154,8 @@ def get_zlib_primary_url() -> str:
|
||||
Primary Z-Library mirror URL.
|
||||
"""
|
||||
config = _get_config()
|
||||
return config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0])
|
||||
primary = _normalize_mirror_url(config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0]))
|
||||
return primary or _normalize_mirror_url(DEFAULT_ZLIB_MIRRORS[0])
|
||||
|
||||
|
||||
def get_zlib_url_template() -> str:
|
||||
@@ -143,21 +178,24 @@ def get_welib_mirrors() -> List[str]:
|
||||
"""
|
||||
config = _get_config()
|
||||
|
||||
primary = config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0])
|
||||
primary = _normalize_mirror_url(config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0]))
|
||||
if not primary:
|
||||
primary = _normalize_mirror_url(DEFAULT_WELIB_MIRRORS[0])
|
||||
mirrors = [primary]
|
||||
|
||||
# Add other defaults (excluding primary)
|
||||
for url in DEFAULT_WELIB_MIRRORS:
|
||||
if url != primary:
|
||||
mirrors.append(url)
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized != primary:
|
||||
mirrors.append(normalized)
|
||||
|
||||
# Add custom mirrors
|
||||
additional = config.get("WELIB_ADDITIONAL_URLS", "")
|
||||
if additional:
|
||||
for url in additional.split(","):
|
||||
url = url.strip()
|
||||
if url and url not in mirrors:
|
||||
mirrors.append(url)
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized and normalized not in mirrors:
|
||||
mirrors.append(normalized)
|
||||
|
||||
return mirrors
|
||||
|
||||
@@ -170,7 +208,8 @@ def get_welib_primary_url() -> str:
|
||||
Primary Welib mirror URL.
|
||||
"""
|
||||
config = _get_config()
|
||||
return config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0])
|
||||
primary = _normalize_mirror_url(config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0]))
|
||||
return primary or _normalize_mirror_url(DEFAULT_WELIB_MIRRORS[0])
|
||||
|
||||
|
||||
def get_welib_url_template() -> str:
|
||||
@@ -197,17 +236,19 @@ def get_zlib_cookie_domains() -> set:
|
||||
|
||||
# Add all default domains
|
||||
for url in DEFAULT_ZLIB_MIRRORS:
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
domains.add(domain)
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized:
|
||||
domain = normalized.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
domains.add(domain)
|
||||
|
||||
# Add custom domains
|
||||
config = _get_config()
|
||||
additional = config.get("ZLIB_ADDITIONAL_URLS", "")
|
||||
if additional:
|
||||
for url in additional.split(","):
|
||||
url = url.strip()
|
||||
if url:
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized:
|
||||
domain = normalized.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
domains.add(domain)
|
||||
|
||||
return domains
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from enum import Enum
|
||||
import re
|
||||
import time
|
||||
@@ -35,6 +35,7 @@ class QueueStatus(str, Enum):
|
||||
"""Enum for possible book queue statuses."""
|
||||
QUEUED = "queued"
|
||||
RESOLVING = "resolving"
|
||||
LOCATING = "locating"
|
||||
DOWNLOADING = "downloading"
|
||||
COMPLETE = "complete"
|
||||
AVAILABLE = "available"
|
||||
@@ -75,6 +76,7 @@ class DownloadTask:
|
||||
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
|
||||
|
||||
# Series info (for library naming templates)
|
||||
series_name: Optional[str] = None
|
||||
@@ -88,6 +90,16 @@ class DownloadTask:
|
||||
# See SearchMode enum for behavioral differences
|
||||
search_mode: Optional[SearchMode] = 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)
|
||||
|
||||
# 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
|
||||
|
||||
# Runtime state
|
||||
priority: int = 0
|
||||
added_time: float = field(default_factory=time.time)
|
||||
|
||||
+83
-32
@@ -3,24 +3,34 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Union
|
||||
from typing import Dict, Optional, Union, Mapping
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
TOKEN_PATTERN = re.compile(
|
||||
r'\{([- ._/\[(]*)' # prefix: space, dash, dot, underscore, slash, brackets
|
||||
r'([A-Za-z]+)' # token name
|
||||
r'([- ._/\])]*)\}' # suffix: space, dash, dot, underscore, slash, brackets
|
||||
)
|
||||
# Known variable tokens, sorted longest-first to avoid partial matches
|
||||
# e.g., "SeriesPosition" must match before "Series"
|
||||
KNOWN_TOKENS = [
|
||||
'seriesposition',
|
||||
'partnumber',
|
||||
'subtitle',
|
||||
'author',
|
||||
'series',
|
||||
'title',
|
||||
'year',
|
||||
'user',
|
||||
]
|
||||
|
||||
# Match any {...} block for template parsing
|
||||
BRACE_PATTERN = re.compile(r'\{([^}]+)\}')
|
||||
|
||||
# Characters that are invalid in filenames on various filesystems
|
||||
INVALID_CHARS = re.compile(r'[\\:*?"<>|]')
|
||||
INVALID_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||
|
||||
|
||||
def _sanitize(name: str, max_length: int = 245) -> str:
|
||||
def _sanitize(name: Optional[str], max_length: int = 245) -> str:
|
||||
"""Sanitize a string for filesystem use."""
|
||||
if not name:
|
||||
return ""
|
||||
@@ -31,7 +41,7 @@ def _sanitize(name: str, max_length: int = 245) -> str:
|
||||
return sanitized[:max_length]
|
||||
|
||||
|
||||
def sanitize_filename(name: str, max_length: int = 245) -> str:
|
||||
def sanitize_filename(name: Optional[str], max_length: int = 245) -> str:
|
||||
"""Sanitize a string for use as a filename or path component."""
|
||||
return _sanitize(name, max_length)
|
||||
|
||||
@@ -40,7 +50,7 @@ def sanitize_filename(name: str, max_length: int = 245) -> str:
|
||||
sanitize_path_component = sanitize_filename
|
||||
|
||||
|
||||
def format_series_position(position: Optional[Union[int, float]]) -> str:
|
||||
def format_series_position(position: Optional[Union[str, int, float]]) -> str:
|
||||
if position is None:
|
||||
return ""
|
||||
|
||||
@@ -78,7 +88,9 @@ def assign_part_numbers(
|
||||
|
||||
def parse_naming_template(
|
||||
template: str,
|
||||
metadata: Dict[str, Optional[Union[str, int, float]]],
|
||||
metadata: Mapping[str, Optional[Union[str, int, float]]],
|
||||
*,
|
||||
allow_path_separators: bool = True,
|
||||
) -> str:
|
||||
if not template:
|
||||
return ""
|
||||
@@ -86,35 +98,74 @@ def parse_naming_template(
|
||||
# Normalize metadata keys to lowercase for case-insensitive matching
|
||||
normalized = {k.lower(): v for k, v in metadata.items()}
|
||||
|
||||
def replace_token(match: re.Match) -> str:
|
||||
prefix = match.group(1)
|
||||
token_name = match.group(2).lower()
|
||||
suffix = match.group(3)
|
||||
def find_token(content: str) -> tuple[Optional[str], int]:
|
||||
content_lower = content.lower()
|
||||
for token in KNOWN_TOKENS:
|
||||
idx = content_lower.find(token)
|
||||
if idx != -1:
|
||||
return token, idx
|
||||
return None, -1
|
||||
|
||||
# Get the value for this token
|
||||
value = normalized.get(token_name)
|
||||
|
||||
# Special handling for series position
|
||||
if token_name == 'seriesposition':
|
||||
def token_value(token: str) -> str:
|
||||
value = normalized.get(token)
|
||||
if token == 'seriesposition':
|
||||
value = format_series_position(value)
|
||||
|
||||
# Convert to string
|
||||
if value is None:
|
||||
value = ""
|
||||
else:
|
||||
value = str(value).strip()
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
# If value is empty, return empty string (no prefix/suffix)
|
||||
def render_block(content: str) -> Optional[str]:
|
||||
token, idx = find_token(content)
|
||||
if token is None:
|
||||
return None
|
||||
|
||||
prefix = content[:idx]
|
||||
suffix = content[idx + len(token):]
|
||||
value = token_value(token)
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
# Sanitize the value
|
||||
if not allow_path_separators:
|
||||
value = value.replace("/", "_")
|
||||
value = sanitize_filename(value)
|
||||
|
||||
return f"{prefix}{value}{suffix}"
|
||||
|
||||
# Replace all tokens
|
||||
result = TOKEN_PATTERN.sub(replace_token, template)
|
||||
# Process brace blocks in order so we can support conditional literal blocks like:
|
||||
# { - Part }{PartNumber}
|
||||
matches = list(BRACE_PATTERN.finditer(template))
|
||||
if not matches:
|
||||
result = template
|
||||
else:
|
||||
parts: list[str] = []
|
||||
cursor = 0
|
||||
for idx, match in enumerate(matches):
|
||||
parts.append(template[cursor:match.start()])
|
||||
content = match.group(1)
|
||||
rendered = render_block(content)
|
||||
|
||||
if rendered is not None:
|
||||
parts.append(rendered)
|
||||
else:
|
||||
conditional_literal = False
|
||||
include_literal = False
|
||||
if idx + 1 < len(matches) and match.end() == matches[idx + 1].start():
|
||||
next_content = matches[idx + 1].group(1)
|
||||
next_token, _next_idx = find_token(next_content)
|
||||
if next_token is not None:
|
||||
conditional_literal = True
|
||||
include_literal = bool(token_value(next_token))
|
||||
if include_literal:
|
||||
parts.append(content)
|
||||
elif not conditional_literal:
|
||||
# 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))
|
||||
|
||||
cursor = match.end()
|
||||
|
||||
parts.append(template[cursor:])
|
||||
result = "".join(parts)
|
||||
|
||||
# Clean up any double slashes that might result from empty tokens
|
||||
result = re.sub(r'/+', '/', result)
|
||||
@@ -140,10 +191,10 @@ def parse_naming_template(
|
||||
def build_library_path(
|
||||
base_path: str,
|
||||
template: str,
|
||||
metadata: Dict[str, Optional[Union[str, int, float]]],
|
||||
metadata: Mapping[str, Optional[Union[str, int, float]]],
|
||||
extension: Optional[str] = None,
|
||||
) -> Path:
|
||||
relative = parse_naming_template(template, metadata)
|
||||
relative = parse_naming_template(template, metadata, allow_path_separators=True)
|
||||
|
||||
if not relative:
|
||||
# Fallback to title if template produces empty result
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Apprise notification dispatch for global and per-user events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Iterable
|
||||
|
||||
try:
|
||||
import apprise
|
||||
except Exception: # 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
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Small pool for non-blocking dispatch. Notification sends are I/O bound and infrequent.
|
||||
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="Notify")
|
||||
_ROUTE_EVENT_ALL = "all"
|
||||
_APPRISE_APP_ID = "Shelfmark"
|
||||
_APPRISE_APP_DESC = "Shelfmark notifications"
|
||||
_APPRISE_LOGO_URL = (
|
||||
"https://raw.githubusercontent.com/calibrain/shelfmark/main/src/frontend/public/logo.png"
|
||||
)
|
||||
|
||||
|
||||
class NotificationEvent(str, Enum):
|
||||
"""Global notification event identifiers."""
|
||||
|
||||
REQUEST_CREATED = "request_created"
|
||||
REQUEST_FULFILLED = "request_fulfilled"
|
||||
REQUEST_REJECTED = "request_rejected"
|
||||
DOWNLOAD_COMPLETE = "download_complete"
|
||||
DOWNLOAD_FAILED = "download_failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationContext:
|
||||
"""Context used to render notification templates."""
|
||||
|
||||
event: NotificationEvent
|
||||
title: str
|
||||
author: str
|
||||
username: str | None = None
|
||||
content_type: str | None = None
|
||||
format: str | None = None
|
||||
source: str | None = None
|
||||
admin_note: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
def _normalize_urls(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
raw_values: list[Any]
|
||||
if isinstance(value, list):
|
||||
raw_values = value
|
||||
elif isinstance(value, str):
|
||||
# Support legacy/manual configs.
|
||||
raw_values = [segment for part in value.splitlines() for segment in part.split(",")]
|
||||
else:
|
||||
raw_values = [value]
|
||||
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw_url in raw_values:
|
||||
url = str(raw_url or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
normalized.append(url)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_routes(value: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
allowed_events = {_ROUTE_EVENT_ALL, *(event.value for event in NotificationEvent)}
|
||||
normalized: list[dict[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
for row in value:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
|
||||
raw_events = row.get("event")
|
||||
if isinstance(raw_events, list):
|
||||
event_values = raw_events
|
||||
elif isinstance(raw_events, (tuple, set)):
|
||||
event_values = list(raw_events)
|
||||
else:
|
||||
event_values = [raw_events]
|
||||
|
||||
url = str(row.get("url") or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
|
||||
row_events: list[str] = []
|
||||
for raw_event in event_values:
|
||||
event = str(raw_event or "").strip().lower()
|
||||
if event not in allowed_events:
|
||||
continue
|
||||
if event in row_events:
|
||||
continue
|
||||
row_events.append(event)
|
||||
|
||||
if _ROUTE_EVENT_ALL in row_events:
|
||||
row_events = [_ROUTE_EVENT_ALL]
|
||||
|
||||
for event in row_events:
|
||||
key = (event, url)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
normalized.append({"event": event, "url": url})
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _resolve_admin_routes() -> list[dict[str, str]]:
|
||||
return _normalize_routes(app_config.get("ADMIN_NOTIFICATION_ROUTES", []))
|
||||
|
||||
|
||||
def _normalize_user_id(value: Any) -> int | None:
|
||||
try:
|
||||
user_id = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if user_id < 1:
|
||||
return None
|
||||
return user_id
|
||||
|
||||
|
||||
def _resolve_user_routes(user_id: int | None) -> list[dict[str, str]]:
|
||||
normalized_user_id = _normalize_user_id(user_id)
|
||||
if normalized_user_id is None:
|
||||
return []
|
||||
|
||||
return _normalize_routes(
|
||||
app_config.get("USER_NOTIFICATION_ROUTES", [], user_id=normalized_user_id)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_route_urls_for_event(
|
||||
routes: list[dict[str, str]],
|
||||
event: NotificationEvent,
|
||||
) -> list[str]:
|
||||
selected: list[str] = []
|
||||
seen: set[str] = set()
|
||||
event_value = event.value
|
||||
|
||||
for row in routes:
|
||||
row_event = row.get("event", "")
|
||||
if row_event not in {_ROUTE_EVENT_ALL, event_value}:
|
||||
continue
|
||||
|
||||
url = row.get("url", "")
|
||||
if not url or url in seen:
|
||||
continue
|
||||
|
||||
seen.add(url)
|
||||
selected.append(url)
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
def _resolve_notify_type(event: NotificationEvent) -> Any:
|
||||
if apprise is None:
|
||||
fallback = {
|
||||
NotificationEvent.REQUEST_CREATED: "info",
|
||||
NotificationEvent.REQUEST_FULFILLED: "success",
|
||||
NotificationEvent.REQUEST_REJECTED: "warning",
|
||||
NotificationEvent.DOWNLOAD_COMPLETE: "success",
|
||||
NotificationEvent.DOWNLOAD_FAILED: "failure",
|
||||
}
|
||||
return fallback[event]
|
||||
|
||||
mapping = {
|
||||
NotificationEvent.REQUEST_CREATED: apprise.NotifyType.INFO,
|
||||
NotificationEvent.REQUEST_FULFILLED: apprise.NotifyType.SUCCESS,
|
||||
NotificationEvent.REQUEST_REJECTED: apprise.NotifyType.WARNING,
|
||||
NotificationEvent.DOWNLOAD_COMPLETE: apprise.NotifyType.SUCCESS,
|
||||
NotificationEvent.DOWNLOAD_FAILED: apprise.NotifyType.FAILURE,
|
||||
}
|
||||
return mapping[event]
|
||||
|
||||
|
||||
def _clean_text(value: Any, fallback: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
return text or fallback
|
||||
|
||||
|
||||
def _render_message(context: NotificationContext) -> tuple[str, str]:
|
||||
event = context.event
|
||||
title = _clean_text(context.title, "Unknown title")
|
||||
author = _clean_text(context.author, "Unknown author")
|
||||
username = _clean_text(context.username, "A user")
|
||||
|
||||
if event == NotificationEvent.REQUEST_CREATED:
|
||||
return "New Request", f'{username} requested "{title}" by {author}'
|
||||
if event == NotificationEvent.REQUEST_FULFILLED:
|
||||
return "Request Approved", f'Request for "{title}" by {author} was approved.'
|
||||
if event == NotificationEvent.REQUEST_REJECTED:
|
||||
note = _clean_text(context.admin_note, "")
|
||||
note_line = f"\nNote: {note}" if note else ""
|
||||
return "Request Rejected", f'Request for "{title}" by {author} was rejected.{note_line}'
|
||||
if event == NotificationEvent.DOWNLOAD_COMPLETE:
|
||||
return "Download Complete", f'"{title}" by {author} downloaded successfully.'
|
||||
|
||||
error_message = _clean_text(context.error_message, "")
|
||||
error_line = f"\nError: {error_message}" if error_message else ""
|
||||
return "Download Failed", f'Failed to download "{title}" by {author}.{error_line}'
|
||||
|
||||
|
||||
def _dispatch_to_apprise(
|
||||
urls: Iterable[str],
|
||||
*,
|
||||
title: str,
|
||||
body: str,
|
||||
notify_type: Any,
|
||||
) -> dict[str, Any]:
|
||||
normalized_urls = _normalize_urls(list(urls))
|
||||
if not normalized_urls:
|
||||
return {"success": False, "message": "No notification URLs configured"}
|
||||
|
||||
if apprise is None:
|
||||
return {"success": False, "message": "Apprise is not installed"}
|
||||
|
||||
apobj = _create_apprise_client()
|
||||
if apobj is None:
|
||||
return {"success": False, "message": "Apprise is not installed"}
|
||||
valid_urls = 0
|
||||
invalid_urls = 0
|
||||
for url in normalized_urls:
|
||||
try:
|
||||
added = bool(apobj.add(url))
|
||||
except Exception:
|
||||
added = False
|
||||
if added:
|
||||
valid_urls += 1
|
||||
else:
|
||||
invalid_urls += 1
|
||||
|
||||
if valid_urls == 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No valid notification URLs configured",
|
||||
}
|
||||
|
||||
try:
|
||||
delivered = bool(apobj.notify(title=title, body=body, notify_type=notify_type))
|
||||
except Exception as exc:
|
||||
return {"success": False, "message": f"Notification send failed: {type(exc).__name__}: {exc}"}
|
||||
|
||||
if not delivered:
|
||||
return {"success": False, "message": "Notification delivery failed"}
|
||||
|
||||
message = f"Notification sent to {valid_urls} URL(s)"
|
||||
if invalid_urls:
|
||||
message += f" ({invalid_urls} invalid URL(s) skipped)"
|
||||
return {"success": True, "message": message}
|
||||
|
||||
|
||||
def _create_apprise_client() -> Any:
|
||||
if apprise is None:
|
||||
return None
|
||||
|
||||
apprise_cls = getattr(apprise, "Apprise", None)
|
||||
if apprise_cls is None:
|
||||
return None
|
||||
|
||||
apprise_asset_cls = getattr(apprise, "AppriseAsset", None)
|
||||
if apprise_asset_cls is None:
|
||||
return apprise_cls()
|
||||
|
||||
try:
|
||||
asset = apprise_asset_cls(
|
||||
app_id=_APPRISE_APP_ID,
|
||||
app_desc=_APPRISE_APP_DESC,
|
||||
image_url_logo=_APPRISE_LOGO_URL,
|
||||
)
|
||||
except TypeError:
|
||||
# Support older Apprise versions that do not expose image_url_logo.
|
||||
asset = apprise_asset_cls(
|
||||
app_id=_APPRISE_APP_ID,
|
||||
app_desc=_APPRISE_APP_DESC,
|
||||
)
|
||||
except Exception:
|
||||
return apprise_cls()
|
||||
|
||||
try:
|
||||
return apprise_cls(asset=asset)
|
||||
except Exception:
|
||||
return apprise_cls()
|
||||
|
||||
|
||||
def _send_admin_event(event: NotificationEvent, context: NotificationContext, urls: list[str]) -> dict[str, Any]:
|
||||
title, body = _render_message(context)
|
||||
notify_type = _resolve_notify_type(event)
|
||||
return _dispatch_to_apprise(urls, title=title, body=body, notify_type=notify_type)
|
||||
|
||||
|
||||
def notify_admin(event: NotificationEvent, context: NotificationContext) -> None:
|
||||
"""Send a global admin notification for an event if subscribed."""
|
||||
routes = _resolve_admin_routes()
|
||||
urls = _resolve_route_urls_for_event(routes, event)
|
||||
if not urls:
|
||||
return
|
||||
|
||||
try:
|
||||
_executor.submit(_dispatch_admin_async, event, context, urls)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to queue admin notification '%s': %s", event.value, exc)
|
||||
|
||||
|
||||
def notify_user(user_id: int | None, event: NotificationEvent, context: NotificationContext) -> None:
|
||||
"""Send a per-user notification for an event if subscribed."""
|
||||
normalized_user_id = _normalize_user_id(user_id)
|
||||
if normalized_user_id is None:
|
||||
return
|
||||
|
||||
routes = _resolve_user_routes(normalized_user_id)
|
||||
urls = _resolve_route_urls_for_event(routes, event)
|
||||
if not urls:
|
||||
return
|
||||
|
||||
try:
|
||||
_executor.submit(_dispatch_user_async, normalized_user_id, event, context, urls)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to queue user notification '%s' for user_id=%s: %s",
|
||||
event.value,
|
||||
normalized_user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def _dispatch_admin_async(event: NotificationEvent, context: NotificationContext, urls: list[str]) -> None:
|
||||
result = _send_admin_event(event, context, urls)
|
||||
if not result.get("success", False):
|
||||
logger.warning("Admin notification failed for event '%s': %s", event.value, result.get("message"))
|
||||
|
||||
|
||||
def _dispatch_user_async(
|
||||
user_id: int,
|
||||
event: NotificationEvent,
|
||||
context: NotificationContext,
|
||||
urls: list[str],
|
||||
) -> None:
|
||||
result = _send_admin_event(event, context, urls)
|
||||
if not result.get("success", False):
|
||||
logger.warning(
|
||||
"User notification failed for event '%s' (user_id=%s): %s",
|
||||
event.value,
|
||||
user_id,
|
||||
result.get("message"),
|
||||
)
|
||||
|
||||
|
||||
def send_test_notification(urls: list[str]) -> dict[str, Any]:
|
||||
"""Send a synchronous test notification to the provided URLs."""
|
||||
normalized_urls = _normalize_urls(urls)
|
||||
if not normalized_urls:
|
||||
return {"success": False, "message": "No notification URLs configured"}
|
||||
|
||||
test_context = NotificationContext(
|
||||
event=NotificationEvent.REQUEST_CREATED,
|
||||
title="Shelfmark Test Notification",
|
||||
author="Shelfmark",
|
||||
username="Shelfmark",
|
||||
)
|
||||
return _send_admin_event(NotificationEvent.REQUEST_CREATED, test_context, normalized_urls)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""OIDC authentication helpers.
|
||||
|
||||
Handles group claim parsing, user info extraction, and user provisioning.
|
||||
Flask route handlers are registered separately in main.py.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
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]:
|
||||
"""Extract group list from an ID token claim.
|
||||
|
||||
Supports list, comma-separated string, or pipe-separated string.
|
||||
Returns empty list if claim is missing.
|
||||
"""
|
||||
raw = id_token.get(group_claim)
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return [str(g).strip() for g in raw if str(g).strip()]
|
||||
if isinstance(raw, str):
|
||||
delimiter = "," if "," in raw else "|"
|
||||
return [g.strip() for g in raw.split(delimiter) if g.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def extract_user_info(id_token: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract user info from OIDC ID token claims.
|
||||
|
||||
Returns a dict with keys: oidc_subject, username, email, display_name.
|
||||
Falls back through preferred_username -> email -> sub for username.
|
||||
"""
|
||||
sub = id_token.get("sub", "")
|
||||
email = id_token.get("email")
|
||||
display_name = id_token.get("name")
|
||||
username = id_token.get("preferred_username") or email or sub
|
||||
|
||||
return {
|
||||
"oidc_subject": sub,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"display_name": display_name,
|
||||
}
|
||||
|
||||
|
||||
def provision_oidc_user(
|
||||
db: UserDB,
|
||||
user_info: Dict[str, Any],
|
||||
is_admin: Optional[bool] = None,
|
||||
allow_email_link: bool = False,
|
||||
allow_create: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Create or update a user from OIDC claims.
|
||||
|
||||
Matching and collision handling use the shared external user linker:
|
||||
- OIDC subject first
|
||||
- optionally unique email linking (when `allow_email_link=True`)
|
||||
- username conflict resolution via numeric suffix.
|
||||
|
||||
Returns None when no existing user is matchable and `allow_create=False`.
|
||||
"""
|
||||
oidc_subject = user_info["oidc_subject"]
|
||||
user, _ = upsert_external_user(
|
||||
db,
|
||||
auth_source="oidc",
|
||||
username=user_info["username"] or oidc_subject,
|
||||
role="admin" if is_admin else "user",
|
||||
email=user_info.get("email"),
|
||||
display_name=user_info.get("display_name"),
|
||||
subject_field="oidc_subject",
|
||||
subject=oidc_subject,
|
||||
allow_email_link=allow_email_link,
|
||||
sync_role=is_admin is not None,
|
||||
allow_create=allow_create,
|
||||
collision_strategy="suffix",
|
||||
context="oidc_login",
|
||||
)
|
||||
return user
|
||||
@@ -0,0 +1,210 @@
|
||||
"""OIDC Flask route handlers using Authlib.
|
||||
|
||||
Registers /api/auth/oidc/login and /api/auth/oidc/callback endpoints.
|
||||
Business logic remains in oidc_auth.py.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from authlib.jose.errors import InvalidClaimError
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
from flask import Flask, jsonify, redirect, request, session
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.oidc_auth import (
|
||||
extract_user_info,
|
||||
parse_group_claims,
|
||||
provision_oidc_user,
|
||||
)
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
oauth = OAuth()
|
||||
|
||||
|
||||
def _normalize_claims(raw_claims: Any) -> dict[str, Any]:
|
||||
"""Return a plain dict for claims from Authlib token/userinfo payloads."""
|
||||
if raw_claims is None:
|
||||
return {}
|
||||
if isinstance(raw_claims, dict):
|
||||
return raw_claims
|
||||
if hasattr(raw_claims, "to_dict"):
|
||||
return raw_claims.to_dict() # type: ignore[no-any-return]
|
||||
try:
|
||||
return dict(raw_claims)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _is_email_verified(claims: dict[str, Any]) -> bool:
|
||||
"""Normalize provider-specific email_verified values into a strict boolean."""
|
||||
value = claims.get("email_verified", False)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() == "true"
|
||||
return False
|
||||
|
||||
|
||||
def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
|
||||
"""Register and return an OIDC client from the current security config."""
|
||||
config = load_config_file("security")
|
||||
discovery_url = config.get("OIDC_DISCOVERY_URL", "")
|
||||
client_id = config.get("OIDC_CLIENT_ID", "")
|
||||
|
||||
if not discovery_url or not client_id:
|
||||
raise ValueError("OIDC not configured")
|
||||
|
||||
configured_scopes = config.get("OIDC_SCOPES", ["openid", "email", "profile"])
|
||||
if isinstance(configured_scopes, list):
|
||||
scope_values = [str(scope).strip() for scope in configured_scopes if str(scope).strip()]
|
||||
elif isinstance(configured_scopes, str):
|
||||
delimiter = "," if "," in configured_scopes else " "
|
||||
scope_values = [scope.strip() for scope in configured_scopes.split(delimiter) if scope.strip()]
|
||||
else:
|
||||
scope_values = []
|
||||
|
||||
scopes = list(dict.fromkeys(["openid"] + scope_values))
|
||||
|
||||
admin_group = config.get("OIDC_ADMIN_GROUP", "")
|
||||
group_claim = config.get("OIDC_GROUP_CLAIM", "groups")
|
||||
use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True)
|
||||
if admin_group and use_admin_group and group_claim and group_claim not in scopes:
|
||||
scopes.append(group_claim)
|
||||
|
||||
oauth._clients.pop("shelfmark_idp", None)
|
||||
oauth.register(
|
||||
name="shelfmark_idp",
|
||||
client_id=client_id,
|
||||
client_secret=config.get("OIDC_CLIENT_SECRET", ""),
|
||||
server_metadata_url=discovery_url,
|
||||
client_kwargs={
|
||||
"scope": " ".join(scopes),
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
overwrite=True,
|
||||
)
|
||||
|
||||
client = oauth.create_client("shelfmark_idp")
|
||||
if client is None:
|
||||
raise RuntimeError("OIDC client initialization failed")
|
||||
|
||||
return client, config
|
||||
|
||||
|
||||
def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
"""Register OIDC authentication routes on the Flask app."""
|
||||
oauth.init_app(app)
|
||||
|
||||
@app.route("/api/auth/oidc/login", methods=["GET"])
|
||||
def oidc_login():
|
||||
"""Initiate OIDC login flow and redirect to the provider."""
|
||||
try:
|
||||
client, _ = _get_oidc_client()
|
||||
redirect_uri = request.url_root.rstrip("/") + "/api/auth/oidc/callback"
|
||||
return client.authorize_redirect(redirect_uri)
|
||||
except ValueError:
|
||||
return jsonify({"error": "OIDC not configured"}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"OIDC login error: {e}")
|
||||
return jsonify({"error": "OIDC login failed"}), 500
|
||||
|
||||
@app.route("/api/auth/oidc/callback", methods=["GET"])
|
||||
def oidc_callback():
|
||||
"""Handle OIDC callback from identity provider."""
|
||||
try:
|
||||
error = request.args.get("error")
|
||||
if error:
|
||||
logger.warning(f"OIDC callback error from IdP: {error}")
|
||||
return jsonify({"error": "Authentication failed"}), 400
|
||||
|
||||
client, config = _get_oidc_client()
|
||||
try:
|
||||
token = client.authorize_access_token()
|
||||
except InvalidClaimError as e:
|
||||
claim_name = getattr(e, "claim_name", "unknown")
|
||||
discovery_url = str(config.get("OIDC_DISCOVERY_URL", ""))
|
||||
provider_issuer = ""
|
||||
try:
|
||||
metadata = client.load_server_metadata()
|
||||
if isinstance(metadata, dict):
|
||||
provider_issuer = str(metadata.get("issuer", ""))
|
||||
except Exception as metadata_error:
|
||||
logger.debug(f"OIDC metadata lookup failed during claim diagnostics: {metadata_error}")
|
||||
|
||||
logger.error(
|
||||
"OIDC callback claim validation failed: claim=%s error=%s discovery_url=%s provider_issuer=%s",
|
||||
claim_name,
|
||||
e,
|
||||
discovery_url or "<unset>",
|
||||
provider_issuer or "<unknown>",
|
||||
)
|
||||
if claim_name == "iss":
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": (
|
||||
"OIDC issuer validation failed. Verify your discovery URL and IdP issuer/"
|
||||
"external URL configuration."
|
||||
)
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
return jsonify({"error": f"OIDC token claim validation failed: {claim_name}"}), 400
|
||||
claims = _normalize_claims(token.get("userinfo"))
|
||||
|
||||
# If userinfo isn't present in token payload, request it explicitly.
|
||||
if not claims:
|
||||
try:
|
||||
claims = _normalize_claims(client.userinfo(token=token))
|
||||
except TypeError:
|
||||
claims = _normalize_claims(client.userinfo())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch OIDC userinfo: {e}")
|
||||
|
||||
if not claims:
|
||||
raise ValueError("OIDC authentication failed: missing user claims")
|
||||
|
||||
group_claim = config.get("OIDC_GROUP_CLAIM", "groups")
|
||||
admin_group = config.get("OIDC_ADMIN_GROUP", "")
|
||||
use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True)
|
||||
auto_provision = config.get("OIDC_AUTO_PROVISION", True)
|
||||
|
||||
user_info = extract_user_info(claims)
|
||||
groups = parse_group_claims(claims, group_claim)
|
||||
|
||||
is_admin = None
|
||||
if admin_group and use_admin_group:
|
||||
is_admin = admin_group in groups
|
||||
|
||||
allow_email_link = bool(user_info.get("email")) and _is_email_verified(claims)
|
||||
user = provision_oidc_user(
|
||||
user_db,
|
||||
user_info,
|
||||
is_admin=is_admin,
|
||||
allow_email_link=allow_email_link,
|
||||
allow_create=bool(auto_provision),
|
||||
)
|
||||
if user is None:
|
||||
logger.warning(
|
||||
f"OIDC login rejected: auto-provision disabled for {user_info['username']}"
|
||||
)
|
||||
return jsonify({"error": "Account not found. Contact your administrator."}), 403
|
||||
|
||||
session["user_id"] = user["username"]
|
||||
session["is_admin"] = user.get("role") == "admin"
|
||||
session["db_user_id"] = user["id"]
|
||||
session.permanent = True
|
||||
|
||||
logger.info(f"OIDC login successful: {user['username']} (admin={is_admin})")
|
||||
return redirect(request.script_root or "/")
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"OIDC callback error: {e}")
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"OIDC callback error: {e}")
|
||||
return jsonify({"error": "Authentication failed"}), 500
|
||||
@@ -34,6 +34,12 @@ def _get_config_dir() -> Path:
|
||||
|
||||
def is_onboarding_complete() -> bool:
|
||||
"""Check if onboarding has been completed."""
|
||||
from shelfmark.config.env import ONBOARDING
|
||||
|
||||
# If onboarding is disabled via env var, treat as complete
|
||||
if not ONBOARDING:
|
||||
return True
|
||||
|
||||
config_file = _get_config_dir() / "settings.json"
|
||||
if not config_file.exists():
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Remote path mapping utilities.
|
||||
|
||||
Used when an external download client reports a completed download path that does
|
||||
not exist inside the Shelfmark runtime environment (commonly different Docker
|
||||
volume mounts).
|
||||
|
||||
A mapping rewrites a remote path prefix into a local path prefix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemotePathMapping:
|
||||
host: str
|
||||
remote_path: str
|
||||
local_path: str
|
||||
|
||||
|
||||
def _normalize_prefix(path: str) -> str:
|
||||
normalized = str(path or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
normalized = normalized.replace("\\", "/")
|
||||
|
||||
if normalized != "/":
|
||||
normalized = normalized.rstrip("/")
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def _normalize_host(host: str) -> str:
|
||||
return str(host or "").strip().lower()
|
||||
|
||||
|
||||
def parse_remote_path_mappings(value: Any) -> list[RemotePathMapping]:
|
||||
if not value or not isinstance(value, list):
|
||||
return []
|
||||
|
||||
mappings: list[RemotePathMapping] = []
|
||||
|
||||
for row in value:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
|
||||
host = _normalize_host(row.get("host", ""))
|
||||
remote_path = _normalize_prefix(row.get("remotePath", ""))
|
||||
local_path = _normalize_prefix(row.get("localPath", ""))
|
||||
|
||||
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.sort(key=lambda m: len(m.remote_path), reverse=True)
|
||||
return mappings
|
||||
|
||||
|
||||
def remap_remote_to_local_with_match(
|
||||
*,
|
||||
mappings: Iterable[RemotePathMapping],
|
||||
host: str,
|
||||
remote_path: str | Path,
|
||||
) -> tuple[Path, bool]:
|
||||
host_normalized = _normalize_host(host)
|
||||
remote_normalized = _normalize_prefix(str(remote_path))
|
||||
|
||||
if not remote_normalized:
|
||||
return Path(str(remote_path)), False
|
||||
|
||||
# Windows paths are case-insensitive, so we need case-insensitive matching
|
||||
# for paths that look like Windows paths (e.g., D:/Torrents)
|
||||
is_windows = _is_windows_path(remote_normalized)
|
||||
|
||||
for mapping in mappings:
|
||||
if _normalize_host(mapping.host) != host_normalized:
|
||||
continue
|
||||
|
||||
remote_prefix = _normalize_prefix(mapping.remote_path)
|
||||
if not remote_prefix:
|
||||
continue
|
||||
|
||||
# For Windows paths, do case-insensitive prefix matching
|
||||
if is_windows:
|
||||
remote_lower = remote_normalized.lower()
|
||||
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 + "/")
|
||||
|
||||
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):]
|
||||
local_prefix = _normalize_prefix(mapping.local_path)
|
||||
|
||||
if remainder.startswith("/"):
|
||||
remainder = remainder[1:]
|
||||
|
||||
remapped = Path(local_prefix) / remainder if remainder else Path(local_prefix)
|
||||
return remapped, True
|
||||
|
||||
return Path(remote_normalized), False
|
||||
|
||||
|
||||
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,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
return remapped
|
||||
|
||||
|
||||
def get_client_host_identifier(client: Any) -> Optional[str]:
|
||||
"""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()
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,31 @@
|
||||
"""WSGI middleware for hosting Shelfmark under a URL prefix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Optional
|
||||
|
||||
|
||||
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:
|
||||
self.app = app
|
||||
self.prefix = prefix.rstrip("/")
|
||||
self.bypass_paths = set(bypass_paths or [])
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
path = environ.get("PATH_INFO", "") or ""
|
||||
|
||||
if path in self.bypass_paths:
|
||||
return self.app(environ, start_response)
|
||||
|
||||
if not self.prefix:
|
||||
return self.app(environ, start_response)
|
||||
|
||||
if path == self.prefix or path.startswith(self.prefix + "/"):
|
||||
environ["SCRIPT_NAME"] = self.prefix
|
||||
environ["PATH_INFO"] = path[len(self.prefix):] or "/"
|
||||
return self.app(environ, start_response)
|
||||
|
||||
start_response("404 Not Found", [("Content-Type", "text/plain")])
|
||||
return [b"Not Found"]
|
||||
+75
-16
@@ -5,7 +5,7 @@ import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Lock, Event
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from typing import Dict, List, Optional, Tuple, Any, Callable
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.models import QueueStatus, QueueItem, DownloadTask
|
||||
@@ -22,6 +22,9 @@ 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
|
||||
|
||||
@property
|
||||
def _status_timeout(self) -> timedelta:
|
||||
@@ -79,16 +82,47 @@ class BookQueue:
|
||||
self._status[book_id] = status
|
||||
self._status_timestamps[book_id] = datetime.now()
|
||||
|
||||
def set_terminal_status_hook(
|
||||
self,
|
||||
hook: Optional[Callable[[str, QueueStatus, DownloadTask], None]],
|
||||
) -> None:
|
||||
"""Register a callback invoked when a task first enters a terminal status."""
|
||||
with self._lock:
|
||||
self._terminal_status_hook = hook
|
||||
|
||||
def update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||
"""Update status of a book in the queue."""
|
||||
hook: Optional[Callable[[str, QueueStatus, DownloadTask], None]] = None
|
||||
hook_task: Optional[DownloadTask] = None
|
||||
with self._lock:
|
||||
previous_status = self._status.get(book_id)
|
||||
self._update_status(book_id, status)
|
||||
|
||||
terminal_statuses = {
|
||||
QueueStatus.COMPLETE,
|
||||
QueueStatus.AVAILABLE,
|
||||
QueueStatus.ERROR,
|
||||
QueueStatus.DONE,
|
||||
QueueStatus.CANCELLED,
|
||||
}
|
||||
if (
|
||||
status in terminal_statuses
|
||||
and previous_status != status
|
||||
and self._terminal_status_hook is not None
|
||||
):
|
||||
current_task = self._task_data.get(book_id)
|
||||
if current_task is not None:
|
||||
hook = self._terminal_status_hook
|
||||
hook_task = current_task
|
||||
|
||||
# Clean up active download tracking when finished
|
||||
if status in [QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
||||
self._active_downloads.pop(book_id, None)
|
||||
self._cancel_flags.pop(book_id, None)
|
||||
|
||||
if hook is not None and hook_task is not None:
|
||||
hook(book_id, status, hook_task)
|
||||
|
||||
def update_download_path(self, task_id: str, download_path: str) -> None:
|
||||
"""Update the download path of a task in the queue."""
|
||||
with self._lock:
|
||||
@@ -107,14 +141,22 @@ class BookQueue:
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].status_message = message
|
||||
|
||||
def get_status(self) -> Dict[QueueStatus, Dict[str, DownloadTask]]:
|
||||
"""Get current queue status grouped by status."""
|
||||
def get_status(self, user_id: Optional[int] = 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
|
||||
(plus legacy tasks with no user_id). If None, return all.
|
||||
"""
|
||||
self.refresh()
|
||||
with self._lock:
|
||||
result: Dict[QueueStatus, Dict[str, DownloadTask]] = {status: {} for status in QueueStatus}
|
||||
for task_id, status in self._status.items():
|
||||
if task_id in self._task_data:
|
||||
result[status][task_id] = self._task_data[task_id]
|
||||
task = self._task_data[task_id]
|
||||
if user_id is not None and task.user_id is not None and task.user_id != user_id:
|
||||
continue
|
||||
result[status][task_id] = task
|
||||
return result
|
||||
|
||||
def get_queue_order(self) -> List[Dict[str, Any]]:
|
||||
@@ -154,17 +196,11 @@ class BookQueue:
|
||||
current_status = self._status.get(task_id)
|
||||
|
||||
# Allow cancellation during any active state
|
||||
if current_status in [QueueStatus.RESOLVING, QueueStatus.DOWNLOADING]:
|
||||
if current_status in [QueueStatus.RESOLVING, QueueStatus.LOCATING, QueueStatus.DOWNLOADING]:
|
||||
# Signal active download to stop
|
||||
if task_id in self._cancel_flags:
|
||||
self._cancel_flags[task_id].set()
|
||||
self._update_status(task_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
elif current_status == QueueStatus.QUEUED:
|
||||
# Remove from queue and mark as cancelled
|
||||
self._update_status(task_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
elif current_status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
|
||||
if current_status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
|
||||
# Clear completed/errored/cancelled items from tracking
|
||||
self._status.pop(task_id, None)
|
||||
self._status_timestamps.pop(task_id, None)
|
||||
@@ -173,7 +209,11 @@ class BookQueue:
|
||||
self._active_downloads.pop(task_id, None)
|
||||
return True
|
||||
|
||||
return False
|
||||
if current_status in [QueueStatus.RESOLVING, QueueStatus.LOCATING, QueueStatus.DOWNLOADING, QueueStatus.QUEUED]:
|
||||
self.update_status(task_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def set_priority(self, task_id: str, new_priority: int) -> bool:
|
||||
"""Change the priority of a queued task (lower = higher priority)."""
|
||||
@@ -245,11 +285,30 @@ class BookQueue:
|
||||
return True
|
||||
return any(status == QueueStatus.QUEUED for status in self._status.values())
|
||||
|
||||
def clear_completed(self) -> int:
|
||||
"""Remove all completed, errored, or cancelled tasks from tracking."""
|
||||
def clear_completed(self, user_id: Optional[int] = None) -> int:
|
||||
"""Remove terminal tasks from tracking, optionally scoped to one user.
|
||||
|
||||
Args:
|
||||
user_id: If provided, only clear tasks belonging to this user,
|
||||
plus legacy tasks with no user_id. If None, clear all.
|
||||
"""
|
||||
terminal_statuses = {QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED}
|
||||
with self._lock:
|
||||
to_remove = [task_id for task_id, status in self._status.items() if status in terminal_statuses]
|
||||
to_remove: list[str] = []
|
||||
for task_id, status in self._status.items():
|
||||
if status not in terminal_statuses:
|
||||
continue
|
||||
|
||||
if user_id is None:
|
||||
to_remove.append(task_id)
|
||||
continue
|
||||
|
||||
task = self._task_data.get(task_id)
|
||||
if task is None:
|
||||
# Without task ownership metadata we cannot safely scope removal.
|
||||
continue
|
||||
if task.user_id is None or task.user_id == user_id:
|
||||
to_remove.append(task_id)
|
||||
|
||||
for task_id in to_remove:
|
||||
self._status.pop(task_id, None)
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Request-policy resolution helpers.
|
||||
|
||||
This module is intentionally pure and side-effect free so it can be reused by
|
||||
routes/services and tested independently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
class PolicyMode(str, Enum):
|
||||
"""Allowed request-policy modes.
|
||||
|
||||
Ordered from most to least permissive. The content-type default acts as a
|
||||
ceiling — matrix rules can only match or restrict further, never upgrade
|
||||
beyond the default.
|
||||
"""
|
||||
|
||||
DOWNLOAD = "download"
|
||||
REQUEST_RELEASE = "request_release"
|
||||
REQUEST_BOOK = "request_book"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
|
||||
# Permissiveness ordering: lower index = more permissive.
|
||||
_MODE_PERMISSIVENESS: dict[PolicyMode, int] = {
|
||||
PolicyMode.DOWNLOAD: 0,
|
||||
PolicyMode.REQUEST_RELEASE: 1,
|
||||
PolicyMode.REQUEST_BOOK: 2,
|
||||
PolicyMode.BLOCKED: 3,
|
||||
}
|
||||
|
||||
# Modes allowed in REQUEST_POLICY_RULES matrix rows.
|
||||
MATRIX_ALLOWED_MODES = frozenset({PolicyMode.DOWNLOAD, PolicyMode.REQUEST_RELEASE, PolicyMode.BLOCKED})
|
||||
|
||||
|
||||
def cap_mode(mode: PolicyMode, ceiling: PolicyMode) -> PolicyMode:
|
||||
"""Cap a resolved mode so it cannot be more permissive than the ceiling."""
|
||||
if _MODE_PERMISSIVENESS[mode] < _MODE_PERMISSIVENESS[ceiling]:
|
||||
return ceiling
|
||||
return mode
|
||||
|
||||
|
||||
REQUEST_POLICY_KEYS = frozenset(
|
||||
{
|
||||
"REQUESTS_ENABLED",
|
||||
"REQUEST_POLICY_DEFAULT_EBOOK",
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||
"REQUEST_POLICY_RULES",
|
||||
"MAX_PENDING_REQUESTS_PER_USER",
|
||||
"REQUESTS_ALLOW_NOTES",
|
||||
}
|
||||
)
|
||||
|
||||
REQUEST_POLICY_DEFAULT_FALLBACK_MODE = PolicyMode.REQUEST_BOOK
|
||||
|
||||
DEFAULT_SUPPORTED_CONTENT_TYPES = ("ebook", "audiobook")
|
||||
|
||||
|
||||
def filter_request_policy_settings(settings: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return only uppercase request-policy keys from a settings JSON object."""
|
||||
if not isinstance(settings, Mapping):
|
||||
return {}
|
||||
return {key: settings[key] for key in REQUEST_POLICY_KEYS if key in settings}
|
||||
|
||||
|
||||
def merge_request_policy_settings(
|
||||
global_settings: Mapping[str, Any] | None,
|
||||
user_settings: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge global settings with per-user request-policy overrides."""
|
||||
merged = filter_request_policy_settings(global_settings)
|
||||
user_filtered = filter_request_policy_settings(user_settings)
|
||||
|
||||
# Preserve global rules by default and treat user rules as per-cell overlays.
|
||||
# This allows per-user REQUEST_POLICY_RULES payloads to store only explicit
|
||||
# differences instead of replacing the full global matrix.
|
||||
global_rules = list(_iter_rules(merged.get("REQUEST_POLICY_RULES", [])))
|
||||
user_has_rules = "REQUEST_POLICY_RULES" in user_filtered
|
||||
|
||||
for key, value in user_filtered.items():
|
||||
if key == "REQUEST_POLICY_RULES":
|
||||
continue
|
||||
merged[key] = value
|
||||
|
||||
if user_has_rules:
|
||||
merged_rules: dict[tuple[str, str], tuple[str, str, PolicyMode]] = {
|
||||
(source, content_type): (source, content_type, mode)
|
||||
for source, content_type, mode in global_rules
|
||||
}
|
||||
for source, content_type, mode in _iter_rules(user_filtered.get("REQUEST_POLICY_RULES", [])):
|
||||
merged_rules[(source, content_type)] = (source, content_type, mode)
|
||||
merged["REQUEST_POLICY_RULES"] = [
|
||||
{"source": source, "content_type": content_type, "mode": mode.value}
|
||||
for source, content_type, mode in merged_rules.values()
|
||||
]
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def normalize_content_type(content_type: Any) -> str:
|
||||
"""Normalize arbitrary content type values to `ebook` or `audiobook`."""
|
||||
if not isinstance(content_type, str):
|
||||
return "ebook"
|
||||
|
||||
value = content_type.strip().lower()
|
||||
if not value:
|
||||
return "ebook"
|
||||
|
||||
if value in {"audiobook", "audiobooks", "audio", "book (audiobook)"}:
|
||||
return "audiobook"
|
||||
|
||||
return "ebook"
|
||||
|
||||
|
||||
def normalize_source(source: Any) -> str:
|
||||
"""Normalize source values for policy matching."""
|
||||
if not isinstance(source, str):
|
||||
return "*"
|
||||
|
||||
value = source.strip().lower()
|
||||
return value or "*"
|
||||
|
||||
|
||||
def parse_policy_mode(mode: Any) -> PolicyMode | None:
|
||||
"""Parse an arbitrary mode value into a PolicyMode enum member."""
|
||||
if isinstance(mode, PolicyMode):
|
||||
return mode
|
||||
if not isinstance(mode, str):
|
||||
return None
|
||||
try:
|
||||
return PolicyMode(mode.strip().lower())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_rule_content_type(content_type: Any) -> str | None:
|
||||
if not isinstance(content_type, str):
|
||||
return None
|
||||
value = content_type.strip().lower()
|
||||
if not value:
|
||||
return None
|
||||
if value in {"*", "any"}:
|
||||
return "*"
|
||||
if value in {"ebook", "book", "books", "book (fiction)"}:
|
||||
return "ebook"
|
||||
if value in {"audiobook", "audiobooks", "audio", "book (audiobook)"}:
|
||||
return "audiobook"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_rule_source(source: Any) -> str | None:
|
||||
if not isinstance(source, str):
|
||||
return None
|
||||
value = source.strip().lower()
|
||||
if not value:
|
||||
return None
|
||||
if value in {"*", "any"}:
|
||||
return "*"
|
||||
return value
|
||||
|
||||
|
||||
def get_source_content_type_capabilities() -> dict[str, set[str]]:
|
||||
"""Return source -> supported content type map from registered sources."""
|
||||
try:
|
||||
from shelfmark.release_sources import list_available_sources
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
capabilities: dict[str, set[str]] = {}
|
||||
for source in list_available_sources():
|
||||
raw_name = source.get("name")
|
||||
name = normalize_source(raw_name)
|
||||
if not name or name == "*":
|
||||
continue
|
||||
|
||||
raw_types = source.get("supported_content_types", DEFAULT_SUPPORTED_CONTENT_TYPES)
|
||||
if isinstance(raw_types, str) or not isinstance(raw_types, Sequence):
|
||||
raw_types = DEFAULT_SUPPORTED_CONTENT_TYPES
|
||||
|
||||
normalized_types: set[str] = set()
|
||||
for content_type in raw_types:
|
||||
normalized_type = _normalize_rule_content_type(content_type)
|
||||
if normalized_type and normalized_type != "*":
|
||||
normalized_types.add(normalized_type)
|
||||
|
||||
if not normalized_types:
|
||||
normalized_types = set(DEFAULT_SUPPORTED_CONTENT_TYPES)
|
||||
capabilities[name] = normalized_types
|
||||
return capabilities
|
||||
|
||||
|
||||
def validate_policy_rules(
|
||||
rules: Any,
|
||||
source_capabilities: Mapping[str, set[str]] | None = None,
|
||||
) -> tuple[list[dict[str, str]], list[str]]:
|
||||
"""Validate and normalize policy rule rows.
|
||||
|
||||
Validation covers:
|
||||
- row shape and required keys
|
||||
- valid mode/content_type values
|
||||
- known source names
|
||||
- source/content-type compatibility from source declarations
|
||||
"""
|
||||
capabilities = source_capabilities if source_capabilities is not None else get_source_content_type_capabilities()
|
||||
normalized_capabilities = {
|
||||
normalize_source(source): {normalize_content_type(content_type) for content_type in content_types}
|
||||
for source, content_types in capabilities.items()
|
||||
}
|
||||
|
||||
normalized_rules: list[dict[str, str]] = []
|
||||
errors: list[str] = []
|
||||
|
||||
if rules is None:
|
||||
return normalized_rules, errors
|
||||
if not isinstance(rules, list):
|
||||
return normalized_rules, ["REQUEST_POLICY_RULES must be a list"]
|
||||
|
||||
for index, rule in enumerate(rules):
|
||||
row_label = f"Rule {index + 1}"
|
||||
if not isinstance(rule, Mapping):
|
||||
errors.append(f"{row_label}: must be an object")
|
||||
continue
|
||||
|
||||
source = _normalize_rule_source(rule.get("source"))
|
||||
raw_content_type = rule.get("content_type")
|
||||
content_type = _normalize_rule_content_type(rule.get("content_type"))
|
||||
raw_mode = rule.get("mode")
|
||||
mode = parse_policy_mode(rule.get("mode"))
|
||||
|
||||
if source is None:
|
||||
errors.append(f"{row_label}: source is required")
|
||||
continue
|
||||
if (
|
||||
raw_content_type is None
|
||||
or (isinstance(raw_content_type, str) and not raw_content_type.strip())
|
||||
):
|
||||
errors.append(f"{row_label}: content_type is required")
|
||||
continue
|
||||
if content_type is None:
|
||||
errors.append(f"{row_label}: invalid content_type '{rule.get('content_type')}'")
|
||||
continue
|
||||
if (
|
||||
raw_mode is None
|
||||
or (isinstance(raw_mode, str) and not raw_mode.strip())
|
||||
):
|
||||
errors.append(f"{row_label}: mode is required")
|
||||
continue
|
||||
if mode is None:
|
||||
errors.append(f"{row_label}: invalid mode '{rule.get('mode')}'")
|
||||
continue
|
||||
if mode not in MATRIX_ALLOWED_MODES:
|
||||
errors.append(f"{row_label}: mode '{mode.value}' is not allowed in matrix rules (use content-type defaults instead)")
|
||||
continue
|
||||
|
||||
if source != "*" and source not in normalized_capabilities:
|
||||
errors.append(f"{row_label}: unknown source '{source}'")
|
||||
continue
|
||||
|
||||
if (
|
||||
source != "*"
|
||||
and content_type != "*"
|
||||
and source in normalized_capabilities
|
||||
and content_type not in normalized_capabilities[source]
|
||||
):
|
||||
errors.append(
|
||||
f"{row_label}: source '{source}' does not support content_type '{content_type}'"
|
||||
)
|
||||
continue
|
||||
|
||||
normalized_rules.append(
|
||||
{
|
||||
"source": source,
|
||||
"content_type": content_type,
|
||||
"mode": mode.value,
|
||||
}
|
||||
)
|
||||
|
||||
return normalized_rules, errors
|
||||
|
||||
|
||||
def _iter_rules(rules: Any) -> Iterable[tuple[str, str, PolicyMode]]:
|
||||
if not isinstance(rules, list):
|
||||
return []
|
||||
|
||||
normalized: list[tuple[str, str, PolicyMode]] = []
|
||||
for rule in rules:
|
||||
if not isinstance(rule, Mapping):
|
||||
continue
|
||||
source = _normalize_rule_source(rule.get("source"))
|
||||
content_type = _normalize_rule_content_type(rule.get("content_type"))
|
||||
mode = parse_policy_mode(rule.get("mode"))
|
||||
if (
|
||||
source is None
|
||||
or content_type is None
|
||||
or mode is None
|
||||
or mode not in MATRIX_ALLOWED_MODES
|
||||
):
|
||||
continue
|
||||
normalized.append((source, content_type, mode))
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_policy_mode(
|
||||
*,
|
||||
source: Any,
|
||||
content_type: Any,
|
||||
global_settings: Mapping[str, Any] | None,
|
||||
user_settings: Mapping[str, Any] | None = None,
|
||||
) -> PolicyMode:
|
||||
"""Resolve an effective policy mode for a request context.
|
||||
|
||||
Resolution:
|
||||
1. Resolve the content-type default (ceiling).
|
||||
2. Match rules in specificity order.
|
||||
3. Cap the matched rule at the ceiling.
|
||||
4. If no rule matches, return the ceiling.
|
||||
|
||||
The content-type default acts as a ceiling — matrix rules can only
|
||||
match or restrict further, never upgrade beyond the default.
|
||||
"""
|
||||
|
||||
effective = merge_request_policy_settings(global_settings, user_settings)
|
||||
normalized_source = normalize_source(source)
|
||||
normalized_content_type = normalize_content_type(content_type)
|
||||
|
||||
# Resolve the content-type default (ceiling)
|
||||
default_key = (
|
||||
"REQUEST_POLICY_DEFAULT_AUDIOBOOK"
|
||||
if normalized_content_type == "audiobook"
|
||||
else "REQUEST_POLICY_DEFAULT_EBOOK"
|
||||
)
|
||||
default_mode = parse_policy_mode(effective.get(default_key))
|
||||
ceiling = default_mode if default_mode is not None else REQUEST_POLICY_DEFAULT_FALLBACK_MODE
|
||||
|
||||
# Match rules in specificity order
|
||||
rules = tuple(_iter_rules(effective.get("REQUEST_POLICY_RULES", [])))
|
||||
candidates = (
|
||||
(normalized_source, normalized_content_type),
|
||||
(normalized_source, "*"),
|
||||
("*", normalized_content_type),
|
||||
("*", "*"),
|
||||
)
|
||||
for candidate_source, candidate_content_type in candidates:
|
||||
for rule_source, rule_content_type, rule_mode in rules:
|
||||
if rule_source == candidate_source and rule_content_type == candidate_content_type:
|
||||
return cap_mode(rule_mode, ceiling)
|
||||
|
||||
return ceiling
|
||||
@@ -0,0 +1,818 @@
|
||||
"""Request API routes and policy snapshot endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import Flask, jsonify, request, session
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_policy import (
|
||||
PolicyMode,
|
||||
REQUEST_POLICY_DEFAULT_FALLBACK_MODE,
|
||||
get_source_content_type_capabilities,
|
||||
merge_request_policy_settings,
|
||||
normalize_content_type,
|
||||
normalize_source,
|
||||
parse_policy_mode,
|
||||
resolve_policy_mode,
|
||||
)
|
||||
from shelfmark.core.requests_service import (
|
||||
RequestServiceError,
|
||||
cancel_request,
|
||||
create_request,
|
||||
fulfil_request,
|
||||
reject_request,
|
||||
)
|
||||
from shelfmark.core.activity_service import ActivityService, build_request_item_key
|
||||
from shelfmark.core.notifications import (
|
||||
NotificationContext,
|
||||
NotificationEvent,
|
||||
notify_admin,
|
||||
notify_user,
|
||||
)
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _load_users_request_policy_settings() -> dict[str, Any]:
|
||||
"""Load global request-policy settings from users config."""
|
||||
return load_config_file("users")
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off", ""}:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _as_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed
|
||||
|
||||
|
||||
def _error_response(
|
||||
message: str,
|
||||
status_code: int,
|
||||
*,
|
||||
code: str | None = None,
|
||||
required_mode: str | None = None,
|
||||
):
|
||||
payload: dict[str, Any] = {"error": message}
|
||||
if code is not None:
|
||||
payload["code"] = code
|
||||
if required_mode is not None:
|
||||
payload["required_mode"] = required_mode
|
||||
return jsonify(payload), status_code
|
||||
|
||||
|
||||
def _require_request_endpoints_available(resolve_auth_mode: Callable[[], str]):
|
||||
auth_mode = resolve_auth_mode()
|
||||
if auth_mode == "none":
|
||||
return _error_response(
|
||||
"Request workflow is unavailable in no-auth mode",
|
||||
403,
|
||||
code="requests_unavailable",
|
||||
)
|
||||
if "user_id" not in session:
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
return None
|
||||
|
||||
|
||||
def _require_db_user_id() -> tuple[int | None, Any | None]:
|
||||
raw_user_id = session.get("db_user_id")
|
||||
if raw_user_id is None:
|
||||
return None, _error_response(
|
||||
"User identity is unavailable for request workflow",
|
||||
403,
|
||||
code="user_identity_unavailable",
|
||||
)
|
||||
try:
|
||||
return int(raw_user_id), None
|
||||
except (TypeError, ValueError):
|
||||
return None, _error_response(
|
||||
"User identity is unavailable for request workflow",
|
||||
403,
|
||||
code="user_identity_unavailable",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_effective_policy(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
db_user_id: int | None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], bool]:
|
||||
global_settings = _load_users_request_policy_settings()
|
||||
user_settings = user_db.get_user_settings(db_user_id) if db_user_id is not None else {}
|
||||
effective = merge_request_policy_settings(global_settings, user_settings)
|
||||
requests_enabled = _as_bool(effective.get("REQUESTS_ENABLED"), False)
|
||||
return global_settings, user_settings, effective, requests_enabled
|
||||
|
||||
|
||||
def _emit_request_event(
|
||||
ws_manager: Any,
|
||||
*,
|
||||
event_name: str,
|
||||
payload: dict[str, Any],
|
||||
room: str,
|
||||
) -> None:
|
||||
if ws_manager is None:
|
||||
return
|
||||
try:
|
||||
socketio = getattr(ws_manager, "socketio", None)
|
||||
is_enabled = getattr(ws_manager, "is_enabled", None)
|
||||
if socketio is None or not callable(is_enabled) or not is_enabled():
|
||||
return
|
||||
socketio.emit(event_name, payload, to=room)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to emit WebSocket event '{event_name}' to room '{room}': {exc}")
|
||||
|
||||
|
||||
def _extract_release_source_id(release_data: Any) -> str | None:
|
||||
if not isinstance(release_data, dict):
|
||||
return None
|
||||
source_id = release_data.get("source_id")
|
||||
if not isinstance(source_id, str):
|
||||
return None
|
||||
normalized = source_id.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _record_terminal_request_snapshot(
|
||||
activity_service: ActivityService | None,
|
||||
*,
|
||||
request_row: dict[str, Any],
|
||||
) -> None:
|
||||
if activity_service is None:
|
||||
return
|
||||
|
||||
request_status = request_row.get("status")
|
||||
if request_status not in {"rejected", "cancelled"}:
|
||||
return
|
||||
|
||||
raw_request_id = request_row.get("id")
|
||||
try:
|
||||
request_id = int(raw_request_id)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if request_id < 1:
|
||||
return
|
||||
|
||||
raw_user_id = request_row.get("user_id")
|
||||
try:
|
||||
user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
user_id = None
|
||||
|
||||
source_id = _extract_release_source_id(request_row.get("release_data"))
|
||||
|
||||
try:
|
||||
activity_service.record_terminal_snapshot(
|
||||
user_id=user_id,
|
||||
item_type="request",
|
||||
item_key=build_request_item_key(request_id),
|
||||
origin="request",
|
||||
final_status=request_status,
|
||||
snapshot={"kind": "request", "request": request_row},
|
||||
request_id=request_id,
|
||||
source_id=source_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to record terminal request snapshot for request %s: %s", request_id, exc)
|
||||
|
||||
|
||||
def _normalize_optional_text(value: Any) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _resolve_title_from_book_data(book_data: Any) -> str:
|
||||
if isinstance(book_data, dict):
|
||||
title = _normalize_optional_text(book_data.get("title"))
|
||||
if title is not None:
|
||||
return title
|
||||
return "Unknown title"
|
||||
|
||||
|
||||
def _resolve_request_title(request_row: dict[str, Any]) -> str:
|
||||
return _resolve_title_from_book_data(request_row.get("book_data"))
|
||||
|
||||
|
||||
def _format_user_label(username: str | None, user_id: int | None = None) -> str:
|
||||
normalized_username = _normalize_optional_text(username)
|
||||
if normalized_username is not None:
|
||||
return normalized_username
|
||||
if user_id is not None and user_id > 0:
|
||||
return f"user#{user_id}"
|
||||
return "unknown user"
|
||||
|
||||
|
||||
def _resolve_request_username(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
request_row: dict[str, Any],
|
||||
fallback_username: str | None = None,
|
||||
) -> str | None:
|
||||
normalized_fallback = _normalize_optional_text(fallback_username)
|
||||
raw_user_id = request_row.get("user_id")
|
||||
try:
|
||||
request_user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
return normalized_fallback
|
||||
|
||||
requester = user_db.get_user(user_id=request_user_id)
|
||||
if not isinstance(requester, dict):
|
||||
return normalized_fallback
|
||||
return _normalize_optional_text(requester.get("username")) or normalized_fallback
|
||||
|
||||
|
||||
def _resolve_request_source_and_format(request_row: dict[str, Any]) -> tuple[str, str | None]:
|
||||
release_data = request_row.get("release_data")
|
||||
if isinstance(release_data, dict):
|
||||
source = normalize_source(release_data.get("source") or request_row.get("source_hint"))
|
||||
release_format = _normalize_optional_text(
|
||||
release_data.get("format")
|
||||
or release_data.get("filetype")
|
||||
or release_data.get("extension")
|
||||
)
|
||||
return source, release_format
|
||||
return normalize_source(request_row.get("source_hint")), None
|
||||
|
||||
|
||||
def _resolve_request_user_id(request_row: dict[str, Any]) -> int | None:
|
||||
raw_user_id = request_row.get("user_id")
|
||||
try:
|
||||
user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return user_id if user_id > 0 else None
|
||||
|
||||
|
||||
def _notify_admin_for_request_event(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
event: NotificationEvent,
|
||||
request_row: dict[str, Any],
|
||||
fallback_username: str | None = None,
|
||||
) -> None:
|
||||
book_data = request_row.get("book_data")
|
||||
if not isinstance(book_data, dict):
|
||||
book_data = {}
|
||||
|
||||
source, release_format = _resolve_request_source_and_format(request_row)
|
||||
context = NotificationContext(
|
||||
event=event,
|
||||
title=str(book_data.get("title") or "Unknown title"),
|
||||
author=str(book_data.get("author") or "Unknown author"),
|
||||
username=_resolve_request_username(
|
||||
user_db,
|
||||
request_row=request_row,
|
||||
fallback_username=fallback_username,
|
||||
),
|
||||
content_type=normalize_content_type(
|
||||
request_row.get("content_type") or book_data.get("content_type")
|
||||
),
|
||||
format=release_format,
|
||||
source=source,
|
||||
admin_note=_normalize_optional_text(request_row.get("admin_note")),
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
owner_user_id = _resolve_request_user_id(request_row)
|
||||
try:
|
||||
notify_admin(event, context)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to trigger admin notification for request event '%s': %s",
|
||||
event.value,
|
||||
exc,
|
||||
)
|
||||
if owner_user_id is None:
|
||||
return
|
||||
try:
|
||||
notify_user(owner_user_id, event, context)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to trigger user notification for request event '%s' (user_id=%s): %s",
|
||||
event.value,
|
||||
owner_user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def register_request_routes(
|
||||
app: Flask,
|
||||
user_db: UserDB,
|
||||
*,
|
||||
resolve_auth_mode: Callable[[], str],
|
||||
queue_release: Callable[..., tuple[bool, str | None]],
|
||||
activity_service: ActivityService | None = None,
|
||||
ws_manager: Any | None = None,
|
||||
) -> None:
|
||||
"""Register request policy and request lifecycle routes."""
|
||||
|
||||
@app.route("/api/request-policy", methods=["GET"])
|
||||
def api_request_policy():
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
is_admin = bool(session.get("is_admin", False))
|
||||
db_user_id: int | None = None
|
||||
if not is_admin:
|
||||
db_user_id, db_gate = _require_db_user_id()
|
||||
if db_gate is not None:
|
||||
return db_gate
|
||||
else:
|
||||
raw_id = session.get("db_user_id")
|
||||
if raw_id is not None:
|
||||
try:
|
||||
db_user_id = int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
db_user_id = None
|
||||
|
||||
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
|
||||
user_db,
|
||||
db_user_id=db_user_id,
|
||||
)
|
||||
|
||||
default_ebook_mode = parse_policy_mode(effective.get("REQUEST_POLICY_DEFAULT_EBOOK"))
|
||||
default_audio_mode = parse_policy_mode(effective.get("REQUEST_POLICY_DEFAULT_AUDIOBOOK"))
|
||||
|
||||
source_capabilities = get_source_content_type_capabilities()
|
||||
source_modes = []
|
||||
for source_name in sorted(source_capabilities):
|
||||
supported_types = sorted(
|
||||
source_capabilities[source_name],
|
||||
key=lambda ct: (ct != "ebook", ct),
|
||||
)
|
||||
modes = {
|
||||
content_type: resolve_policy_mode(
|
||||
source=source_name,
|
||||
content_type=content_type,
|
||||
global_settings=global_settings,
|
||||
user_settings=user_settings,
|
||||
).value
|
||||
for content_type in supported_types
|
||||
}
|
||||
source_modes.append(
|
||||
{
|
||||
"source": source_name,
|
||||
"supported_content_types": supported_types,
|
||||
"modes": modes,
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"requests_enabled": requests_enabled,
|
||||
"is_admin": is_admin,
|
||||
"allow_notes": _as_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True),
|
||||
"defaults": {
|
||||
"ebook": (
|
||||
default_ebook_mode.value
|
||||
if default_ebook_mode is not None
|
||||
else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value
|
||||
),
|
||||
"audiobook": (
|
||||
default_audio_mode.value
|
||||
if default_audio_mode is not None
|
||||
else REQUEST_POLICY_DEFAULT_FALLBACK_MODE.value
|
||||
),
|
||||
},
|
||||
"rules": effective.get("REQUEST_POLICY_RULES", []),
|
||||
"source_modes": source_modes,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/requests", methods=["POST"])
|
||||
def api_create_request():
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
db_user_id, db_gate = _require_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
return db_gate
|
||||
actor_username = _normalize_optional_text(session.get("user_id"))
|
||||
actor_label = _format_user_label(actor_username, db_user_id)
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
context = data.get("context") or {}
|
||||
if not isinstance(context, dict):
|
||||
return jsonify({"error": "context must be an object"}), 400
|
||||
|
||||
source = normalize_source(context.get("source"))
|
||||
release_data = data.get("release_data")
|
||||
request_level = context.get("request_level")
|
||||
if request_level is None:
|
||||
request_level = "book" if release_data is None else "release"
|
||||
|
||||
book_data = data.get("book_data")
|
||||
if not isinstance(book_data, dict):
|
||||
return jsonify({"error": "book_data must be an object"}), 400
|
||||
request_title = _resolve_title_from_book_data(book_data)
|
||||
|
||||
content_type = normalize_content_type(
|
||||
context.get("content_type")
|
||||
or data.get("content_type")
|
||||
or book_data.get("content_type")
|
||||
)
|
||||
|
||||
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
|
||||
user_db,
|
||||
db_user_id=db_user_id,
|
||||
)
|
||||
if not requests_enabled:
|
||||
logger.debug(
|
||||
"Request not created for '%s' by %s: requests are disabled",
|
||||
request_title,
|
||||
actor_label,
|
||||
)
|
||||
return _error_response(
|
||||
"Request workflow is disabled by policy",
|
||||
403,
|
||||
code="requests_unavailable",
|
||||
)
|
||||
|
||||
max_pending = _as_int(
|
||||
effective.get("MAX_PENDING_REQUESTS_PER_USER"),
|
||||
default=20,
|
||||
)
|
||||
if max_pending < 1:
|
||||
max_pending = 1
|
||||
if max_pending > 1000:
|
||||
max_pending = 1000
|
||||
allow_notes = _as_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True)
|
||||
note_value = data.get("note") if allow_notes else None
|
||||
|
||||
resolved_mode = resolve_policy_mode(
|
||||
source=source,
|
||||
content_type=content_type,
|
||||
global_settings=global_settings,
|
||||
user_settings=user_settings,
|
||||
)
|
||||
logger.debug(
|
||||
"request create policy user=%s db_user_id=%s source=%s content_type=%s request_level=%s resolved_mode=%s",
|
||||
session.get("user_id"),
|
||||
db_user_id,
|
||||
source,
|
||||
content_type,
|
||||
request_level,
|
||||
resolved_mode.value,
|
||||
)
|
||||
|
||||
if resolved_mode == PolicyMode.BLOCKED:
|
||||
logger.debug(
|
||||
"Request blocked by policy for '%s' by %s",
|
||||
request_title,
|
||||
actor_label,
|
||||
)
|
||||
return _error_response(
|
||||
"Requesting is blocked by policy",
|
||||
403,
|
||||
code="policy_blocked",
|
||||
required_mode=PolicyMode.BLOCKED.value,
|
||||
)
|
||||
|
||||
if resolved_mode == PolicyMode.REQUEST_BOOK:
|
||||
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
|
||||
if requested_level != "book":
|
||||
logger.debug(
|
||||
"Request not created for '%s' by %s: policy requires book-level requests",
|
||||
request_title,
|
||||
actor_label,
|
||||
)
|
||||
return _error_response(
|
||||
"Policy requires book-level requests",
|
||||
403,
|
||||
code="policy_requires_request",
|
||||
required_mode=PolicyMode.REQUEST_BOOK.value,
|
||||
)
|
||||
|
||||
try:
|
||||
created = create_request(
|
||||
user_db,
|
||||
user_id=db_user_id,
|
||||
source_hint=source,
|
||||
content_type=content_type,
|
||||
request_level=request_level,
|
||||
policy_mode=resolved_mode.value,
|
||||
book_data=book_data,
|
||||
release_data=release_data,
|
||||
note=note_value,
|
||||
max_pending_per_user=max_pending,
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
|
||||
event_payload = {
|
||||
"request_id": created["id"],
|
||||
"status": created["status"],
|
||||
"title": _resolve_request_title(created),
|
||||
}
|
||||
logger.info(
|
||||
"Request created #%s for '%s' by %s",
|
||||
created["id"],
|
||||
event_payload["title"],
|
||||
actor_label,
|
||||
)
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="new_request",
|
||||
payload=event_payload,
|
||||
room="admins",
|
||||
)
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{db_user_id}",
|
||||
)
|
||||
|
||||
_notify_admin_for_request_event(
|
||||
user_db,
|
||||
event=NotificationEvent.REQUEST_CREATED,
|
||||
request_row=created,
|
||||
fallback_username=actor_username,
|
||||
)
|
||||
|
||||
return jsonify(created), 201
|
||||
|
||||
@app.route("/api/requests", methods=["GET"])
|
||||
def api_list_requests():
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
db_user_id, db_gate = _require_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
return db_gate
|
||||
|
||||
status = request.args.get("status")
|
||||
limit = request.args.get("limit", type=int)
|
||||
offset = request.args.get("offset", type=int, default=0) or 0
|
||||
|
||||
try:
|
||||
rows = user_db.list_requests(
|
||||
user_id=db_user_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(rows)
|
||||
|
||||
@app.route("/api/requests/<int:request_id>", methods=["DELETE"])
|
||||
def api_cancel_request(request_id: int):
|
||||
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
|
||||
if auth_gate is not None:
|
||||
return auth_gate
|
||||
|
||||
db_user_id, db_gate = _require_db_user_id()
|
||||
if db_gate is not None or db_user_id is None:
|
||||
return db_gate
|
||||
|
||||
try:
|
||||
updated = cancel_request(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
actor_user_id=db_user_id,
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
|
||||
_record_terminal_request_snapshot(activity_service, request_row=updated)
|
||||
|
||||
event_payload = {
|
||||
"request_id": updated["id"],
|
||||
"status": updated["status"],
|
||||
"title": _resolve_request_title(updated),
|
||||
}
|
||||
actor_label = _format_user_label(_normalize_optional_text(session.get("user_id")), db_user_id)
|
||||
logger.info(
|
||||
"Request cancelled #%s for '%s' by %s",
|
||||
updated["id"],
|
||||
event_payload["title"],
|
||||
actor_label,
|
||||
)
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{db_user_id}",
|
||||
)
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room="admins",
|
||||
)
|
||||
|
||||
return jsonify(updated)
|
||||
|
||||
@app.route("/api/admin/requests", methods=["GET"])
|
||||
def api_admin_list_requests():
|
||||
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
|
||||
|
||||
status = request.args.get("status")
|
||||
limit = request.args.get("limit", type=int)
|
||||
offset = request.args.get("offset", type=int, default=0) or 0
|
||||
|
||||
try:
|
||||
rows = user_db.list_requests(status=status, limit=limit, offset=offset)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
user_cache: dict[int, str] = {}
|
||||
for row in rows:
|
||||
requester_id = row["user_id"]
|
||||
if requester_id not in user_cache:
|
||||
requester = user_db.get_user(user_id=requester_id)
|
||||
user_cache[requester_id] = requester.get("username", "") if requester else ""
|
||||
row["username"] = user_cache[requester_id]
|
||||
|
||||
return jsonify(rows)
|
||||
|
||||
@app.route("/api/admin/requests/count", methods=["GET"])
|
||||
def api_admin_request_counts():
|
||||
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 ("pending", "fulfilled", "rejected", "cancelled")
|
||||
}
|
||||
return jsonify(
|
||||
{
|
||||
"pending": by_status["pending"],
|
||||
"total": sum(by_status.values()),
|
||||
"by_status": by_status,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/admin/requests/<int:request_id>/fulfil", methods=["POST"])
|
||||
def api_admin_fulfil_request(request_id: 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
|
||||
|
||||
raw_admin_id = session.get("db_user_id")
|
||||
if raw_admin_id is None:
|
||||
return jsonify({"error": "Admin user identity unavailable"}), 403
|
||||
try:
|
||||
admin_user_id = int(raw_admin_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Admin user identity unavailable"}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
try:
|
||||
updated = fulfil_request(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
admin_user_id=admin_user_id,
|
||||
queue_release=queue_release,
|
||||
release_data=data.get("release_data"),
|
||||
admin_note=data.get("admin_note"),
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
|
||||
event_payload = {
|
||||
"request_id": updated["id"],
|
||||
"status": updated["status"],
|
||||
"title": _resolve_request_title(updated),
|
||||
}
|
||||
admin_label = _format_user_label(_normalize_optional_text(session.get("user_id")), admin_user_id)
|
||||
requester_label = _format_user_label(
|
||||
_resolve_request_username(user_db, request_row=updated),
|
||||
_resolve_request_user_id(updated),
|
||||
)
|
||||
logger.info(
|
||||
"Request fulfilled #%s for '%s' by %s (requested by %s)",
|
||||
updated["id"],
|
||||
event_payload["title"],
|
||||
admin_label,
|
||||
requester_label,
|
||||
)
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{updated['user_id']}",
|
||||
)
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room="admins",
|
||||
)
|
||||
|
||||
_notify_admin_for_request_event(
|
||||
user_db,
|
||||
event=NotificationEvent.REQUEST_FULFILLED,
|
||||
request_row=updated,
|
||||
)
|
||||
|
||||
return jsonify(updated)
|
||||
|
||||
@app.route("/api/admin/requests/<int:request_id>/reject", methods=["POST"])
|
||||
def api_admin_reject_request(request_id: 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
|
||||
|
||||
raw_admin_id = session.get("db_user_id")
|
||||
if raw_admin_id is None:
|
||||
return jsonify({"error": "Admin user identity unavailable"}), 403
|
||||
try:
|
||||
admin_user_id = int(raw_admin_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Admin user identity unavailable"}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
|
||||
try:
|
||||
updated = reject_request(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
admin_user_id=admin_user_id,
|
||||
admin_note=data.get("admin_note"),
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||
|
||||
_record_terminal_request_snapshot(activity_service, request_row=updated)
|
||||
|
||||
event_payload = {
|
||||
"request_id": updated["id"],
|
||||
"status": updated["status"],
|
||||
"title": _resolve_request_title(updated),
|
||||
}
|
||||
admin_label = _format_user_label(_normalize_optional_text(session.get("user_id")), admin_user_id)
|
||||
requester_label = _format_user_label(
|
||||
_resolve_request_username(user_db, request_row=updated),
|
||||
_resolve_request_user_id(updated),
|
||||
)
|
||||
logger.info(
|
||||
"Request rejected #%s for '%s' by %s (requested by %s)",
|
||||
updated["id"],
|
||||
event_payload["title"],
|
||||
admin_label,
|
||||
requester_label,
|
||||
)
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room=f"user_{updated['user_id']}",
|
||||
)
|
||||
_emit_request_event(
|
||||
ws_manager,
|
||||
event_name="request_update",
|
||||
payload=event_payload,
|
||||
room="admins",
|
||||
)
|
||||
|
||||
_notify_admin_for_request_event(
|
||||
user_db,
|
||||
event=NotificationEvent.REQUEST_REJECTED,
|
||||
request_row=updated,
|
||||
)
|
||||
|
||||
return jsonify(updated)
|
||||
@@ -0,0 +1,544 @@
|
||||
"""Request lifecycle helpers and service-level validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from typing import Any, Callable, TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.request_policy import normalize_content_type, parse_policy_mode
|
||||
|
||||
|
||||
VALID_REQUEST_STATUSES = frozenset({"pending", "fulfilled", "rejected", "cancelled"})
|
||||
TERMINAL_REQUEST_STATUSES = frozenset({"fulfilled", "rejected", "cancelled"})
|
||||
VALID_REQUEST_LEVELS = frozenset({"book", "release"})
|
||||
VALID_DELIVERY_STATES = frozenset(
|
||||
{
|
||||
"none",
|
||||
"unknown",
|
||||
"queued",
|
||||
"resolving",
|
||||
"locating",
|
||||
"downloading",
|
||||
"complete",
|
||||
"error",
|
||||
"cancelled",
|
||||
}
|
||||
)
|
||||
MAX_REQUEST_NOTE_LENGTH = 1000
|
||||
MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
class RequestServiceError(ValueError):
|
||||
"""Structured error raised by request lifecycle service methods."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int = 400,
|
||||
code: str | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
|
||||
|
||||
def normalize_request_status(status: Any) -> str:
|
||||
"""Validate and normalize request status values."""
|
||||
if not isinstance(status, str):
|
||||
raise ValueError(f"Invalid request status: {status}")
|
||||
normalized = status.strip().lower()
|
||||
if normalized not in VALID_REQUEST_STATUSES:
|
||||
raise ValueError(f"Invalid request status: {status}")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_policy_mode(mode: Any) -> str:
|
||||
"""Validate and normalize policy mode values."""
|
||||
parsed = parse_policy_mode(mode)
|
||||
if parsed is None:
|
||||
raise ValueError(f"Invalid policy_mode: {mode}")
|
||||
return parsed.value
|
||||
|
||||
|
||||
def normalize_request_level(request_level: Any) -> str:
|
||||
"""Validate and normalize request level values."""
|
||||
if not isinstance(request_level, str):
|
||||
raise ValueError(f"Invalid request_level: {request_level}")
|
||||
normalized = request_level.strip().lower()
|
||||
if normalized not in VALID_REQUEST_LEVELS:
|
||||
raise ValueError(f"Invalid request_level: {request_level}")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_delivery_state(state: Any) -> str:
|
||||
"""Validate and normalize delivery-state values."""
|
||||
if not isinstance(state, str):
|
||||
raise ValueError(f"Invalid delivery_state: {state}")
|
||||
normalized = state.strip().lower()
|
||||
if normalized not in VALID_DELIVERY_STATES:
|
||||
raise ValueError(f"Invalid delivery_state: {state}")
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_request_level_payload(request_level: Any, release_data: Any) -> str:
|
||||
"""Validate request_level and release_data shape coupling."""
|
||||
normalized_level = normalize_request_level(request_level)
|
||||
if normalized_level == "release" and release_data is None:
|
||||
raise ValueError("request_level=release requires non-null release_data")
|
||||
if normalized_level == "book" and release_data is not None:
|
||||
raise ValueError("request_level=book requires null release_data")
|
||||
return normalized_level
|
||||
|
||||
|
||||
def validate_status_transition(current_status: Any, new_status: Any) -> tuple[str, str]:
|
||||
"""Validate request status transitions and terminal immutability."""
|
||||
current = normalize_request_status(current_status)
|
||||
new = normalize_request_status(new_status)
|
||||
if current in TERMINAL_REQUEST_STATUSES and new != current:
|
||||
raise ValueError("Terminal request statuses are immutable")
|
||||
return current, new
|
||||
|
||||
|
||||
def _normalize_match_text(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def normalize_note(note: Any) -> 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)
|
||||
normalized = note.strip()
|
||||
if len(normalized) > MAX_REQUEST_NOTE_LENGTH:
|
||||
raise RequestServiceError(
|
||||
f"note must be <= {MAX_REQUEST_NOTE_LENGTH} characters",
|
||||
status_code=400,
|
||||
)
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _validate_book_data(book_data: Any) -> dict[str, Any]:
|
||||
if not isinstance(book_data, dict):
|
||||
raise RequestServiceError("book_data must be an object", status_code=400)
|
||||
|
||||
required_fields = ("title", "author", "provider", "provider_id")
|
||||
missing = [field for field in required_fields if not _normalize_match_text(book_data.get(field))]
|
||||
if missing:
|
||||
raise RequestServiceError(
|
||||
f"book_data missing required field(s): {', '.join(missing)}",
|
||||
status_code=400,
|
||||
)
|
||||
return dict(book_data)
|
||||
|
||||
|
||||
def _validate_json_blob_size(field: str, payload: Any) -> 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
|
||||
|
||||
payload_size = len(serialized.encode("utf-8"))
|
||||
if payload_size > MAX_REQUEST_JSON_BLOB_BYTES:
|
||||
raise RequestServiceError(
|
||||
f"{field} must be <= {MAX_REQUEST_JSON_BLOB_BYTES} bytes",
|
||||
status_code=400,
|
||||
code="request_payload_too_large",
|
||||
)
|
||||
|
||||
|
||||
def _find_duplicate_pending_request(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
user_id: int,
|
||||
title: str,
|
||||
author: str,
|
||||
content_type: str,
|
||||
) -> dict[str, Any] | None:
|
||||
pending_rows = user_db.list_requests(user_id=user_id, status="pending")
|
||||
for row in pending_rows:
|
||||
row_book_data = row.get("book_data") or {}
|
||||
if not isinstance(row_book_data, dict):
|
||||
continue
|
||||
|
||||
row_title = _normalize_match_text(row_book_data.get("title"))
|
||||
row_author = _normalize_match_text(row_book_data.get("author"))
|
||||
row_content_type = normalize_content_type(
|
||||
row.get("content_type") or row_book_data.get("content_type")
|
||||
)
|
||||
if row_title == title and row_author == author and row_content_type == content_type:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _now_timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _extract_release_source_id(release_data: Any) -> str | None:
|
||||
if not isinstance(release_data, dict):
|
||||
return None
|
||||
source_id = release_data.get("source_id")
|
||||
if not isinstance(source_id, str):
|
||||
return None
|
||||
normalized = source_id.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _existing_delivery_state(request_row: dict[str, Any]) -> str:
|
||||
raw_state = request_row.get("delivery_state")
|
||||
if not isinstance(raw_state, str):
|
||||
return "none"
|
||||
normalized = raw_state.strip().lower()
|
||||
return normalized if normalized in VALID_DELIVERY_STATES else "none"
|
||||
|
||||
|
||||
def sync_delivery_states_from_queue_status(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
queue_status: dict[str, dict[str, Any]],
|
||||
user_id: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Persist delivery-state transitions for fulfilled requests based on queue status."""
|
||||
source_delivery_states: dict[str, str] = {}
|
||||
for status_key in ("queued", "resolving", "locating", "downloading", "complete", "error", "cancelled"):
|
||||
status_bucket = queue_status.get(status_key)
|
||||
if not isinstance(status_bucket, dict):
|
||||
continue
|
||||
for source_id in status_bucket:
|
||||
source_delivery_states[source_id] = status_key
|
||||
|
||||
if not source_delivery_states:
|
||||
return []
|
||||
|
||||
fulfilled_rows = user_db.list_requests(user_id=user_id, status="fulfilled")
|
||||
updated: list[dict[str, Any]] = []
|
||||
|
||||
for row in fulfilled_rows:
|
||||
source_id = _extract_release_source_id(row.get("release_data"))
|
||||
if source_id is None:
|
||||
continue
|
||||
|
||||
delivery_state = source_delivery_states.get(source_id)
|
||||
if delivery_state is None:
|
||||
continue
|
||||
|
||||
if _existing_delivery_state(row) == delivery_state:
|
||||
continue
|
||||
|
||||
updated.append(
|
||||
user_db.update_request(
|
||||
row["id"],
|
||||
delivery_state=delivery_state,
|
||||
delivery_updated_at=_now_timestamp(),
|
||||
)
|
||||
)
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
def create_request(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
user_id: int,
|
||||
source_hint: str | None,
|
||||
content_type: Any,
|
||||
request_level: Any,
|
||||
policy_mode: Any,
|
||||
book_data: Any,
|
||||
release_data: Any = None,
|
||||
note: Any = None,
|
||||
max_pending_per_user: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a pending request after service-level validation."""
|
||||
validated_book_data = _validate_book_data(book_data)
|
||||
normalized_note = normalize_note(note)
|
||||
normalized_content_type = normalize_content_type(
|
||||
content_type or validated_book_data.get("content_type")
|
||||
)
|
||||
validated_book_data["content_type"] = normalized_content_type
|
||||
|
||||
try:
|
||||
normalized_request_level = validate_request_level_payload(request_level, release_data)
|
||||
normalized_policy_mode = normalize_policy_mode(policy_mode)
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
|
||||
_validate_json_blob_size("book_data", validated_book_data)
|
||||
_validate_json_blob_size("release_data", release_data)
|
||||
|
||||
if max_pending_per_user is not None:
|
||||
pending_count = user_db.count_user_pending_requests(user_id)
|
||||
if pending_count >= max_pending_per_user:
|
||||
raise RequestServiceError(
|
||||
"Maximum pending requests reached for this user",
|
||||
status_code=409,
|
||||
code="max_pending_reached",
|
||||
)
|
||||
|
||||
duplicate = _find_duplicate_pending_request(
|
||||
user_db,
|
||||
user_id=user_id,
|
||||
title=_normalize_match_text(validated_book_data.get("title")),
|
||||
author=_normalize_match_text(validated_book_data.get("author")),
|
||||
content_type=normalized_content_type,
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise RequestServiceError(
|
||||
"Duplicate pending request exists for this title/author/content_type",
|
||||
status_code=409,
|
||||
code="duplicate_pending_request",
|
||||
)
|
||||
|
||||
try:
|
||||
return user_db.create_request(
|
||||
user_id=user_id,
|
||||
source_hint=source_hint,
|
||||
content_type=normalized_content_type,
|
||||
request_level=normalized_request_level,
|
||||
policy_mode=normalized_policy_mode,
|
||||
book_data=validated_book_data,
|
||||
release_data=release_data,
|
||||
note=normalized_note,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=400) from exc
|
||||
|
||||
|
||||
def ensure_request_access(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
request_id: int,
|
||||
actor_user_id: int | None,
|
||||
is_admin: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Get request by ID and enforce ownership for non-admin actors."""
|
||||
request_row = user_db.get_request(request_id)
|
||||
if request_row is None:
|
||||
raise RequestServiceError("Request not found", 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)
|
||||
|
||||
return request_row
|
||||
|
||||
|
||||
def cancel_request(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
request_id: int,
|
||||
actor_user_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Cancel a pending request owned by the actor."""
|
||||
request_row = ensure_request_access(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
actor_user_id=actor_user_id,
|
||||
is_admin=False,
|
||||
)
|
||||
if request_row["status"] != "pending":
|
||||
raise RequestServiceError(
|
||||
"Request is already in a terminal state",
|
||||
status_code=409,
|
||||
code="stale_transition",
|
||||
)
|
||||
|
||||
try:
|
||||
return user_db.update_request(
|
||||
request_id,
|
||||
expected_current_status="pending",
|
||||
status="cancelled",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
|
||||
def reject_request(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
request_id: int,
|
||||
admin_user_id: int,
|
||||
admin_note: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reject a pending request as admin."""
|
||||
request_row = ensure_request_access(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
actor_user_id=admin_user_id,
|
||||
is_admin=True,
|
||||
)
|
||||
if request_row["status"] != "pending":
|
||||
raise RequestServiceError(
|
||||
"Request is already in a terminal state",
|
||||
status_code=409,
|
||||
code="stale_transition",
|
||||
)
|
||||
|
||||
normalized_admin_note = None
|
||||
if admin_note is not None:
|
||||
if not isinstance(admin_note, str):
|
||||
raise RequestServiceError("admin_note must be a string", status_code=400)
|
||||
normalized_admin_note = admin_note.strip() or None
|
||||
|
||||
try:
|
||||
return user_db.update_request(
|
||||
request_id,
|
||||
expected_current_status="pending",
|
||||
status="rejected",
|
||||
admin_note=normalized_admin_note,
|
||||
reviewed_by=admin_user_id,
|
||||
reviewed_at=_now_timestamp(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
|
||||
def fulfil_request(
|
||||
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,
|
||||
) -> dict[str, Any]:
|
||||
"""Fulfil a pending request and queue the release under requesting-user identity."""
|
||||
request_row = ensure_request_access(
|
||||
user_db,
|
||||
request_id=request_id,
|
||||
actor_user_id=admin_user_id,
|
||||
is_admin=True,
|
||||
)
|
||||
if request_row["status"] != "pending":
|
||||
raise RequestServiceError(
|
||||
"Request is already in a terminal state",
|
||||
status_code=409,
|
||||
code="stale_transition",
|
||||
)
|
||||
|
||||
normalized_admin_note = None
|
||||
if admin_note is not None:
|
||||
if not isinstance(admin_note, str):
|
||||
raise RequestServiceError("admin_note must be a string", status_code=400)
|
||||
normalized_admin_note = admin_note.strip() or None
|
||||
|
||||
selected_release_data = release_data if release_data is not None else request_row.get("release_data")
|
||||
if selected_release_data is not None and not isinstance(selected_release_data, dict):
|
||||
raise RequestServiceError("release_data must be an object", status_code=400)
|
||||
|
||||
if request_row["request_level"] == "book" and selected_release_data is None:
|
||||
raise RequestServiceError(
|
||||
"release_data is required to fulfil book-level requests",
|
||||
status_code=400,
|
||||
)
|
||||
if request_row["request_level"] == "release" and selected_release_data is None:
|
||||
raise RequestServiceError(
|
||||
"release_data is required to fulfil release-level requests",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
_validate_json_blob_size("release_data", selected_release_data)
|
||||
|
||||
requester = user_db.get_user(user_id=request_row["user_id"])
|
||||
if requester is None:
|
||||
raise RequestServiceError("Requesting user not found", status_code=404)
|
||||
|
||||
queued_release_data = dict(selected_release_data)
|
||||
queued_release_data["_request_id"] = request_id
|
||||
|
||||
success, error = queue_release(
|
||||
queued_release_data,
|
||||
0,
|
||||
user_id=request_row["user_id"],
|
||||
username=requester.get("username"),
|
||||
)
|
||||
if not success:
|
||||
raise RequestServiceError(
|
||||
error or "Failed to queue release",
|
||||
status_code=409,
|
||||
code="queue_failed",
|
||||
)
|
||||
|
||||
try:
|
||||
return user_db.update_request(
|
||||
request_id,
|
||||
expected_current_status="pending",
|
||||
status="fulfilled",
|
||||
release_data=selected_release_data,
|
||||
delivery_state="queued",
|
||||
delivery_updated_at=_now_timestamp(),
|
||||
last_failure_reason=None,
|
||||
admin_note=normalized_admin_note,
|
||||
reviewed_by=admin_user_id,
|
||||
reviewed_at=_now_timestamp(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
|
||||
|
||||
|
||||
def reopen_failed_request(
|
||||
user_db: "UserDB",
|
||||
*,
|
||||
request_id: int,
|
||||
failure_reason: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Reopen a failed fulfilled request so admins can re-approve with a new release."""
|
||||
normalized_failure_reason = None
|
||||
if isinstance(failure_reason, str):
|
||||
normalized_failure_reason = failure_reason.strip() or None
|
||||
|
||||
with user_db._lock:
|
||||
conn = user_db._connect()
|
||||
try:
|
||||
current_row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
current_request = user_db._parse_request_row(current_row)
|
||||
if current_request is None:
|
||||
return None
|
||||
|
||||
if current_request.get("status") != "fulfilled":
|
||||
return None
|
||||
current_delivery_state = _existing_delivery_state(current_request)
|
||||
# Terminal hook callbacks can run before delivery-state sync persists "error".
|
||||
# Allow reopening fulfilled requests unless they are already complete.
|
||||
if current_delivery_state == "complete":
|
||||
return None
|
||||
if current_delivery_state not in {"error", "cancelled"} and normalized_failure_reason is None:
|
||||
return None
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE download_requests
|
||||
SET status = 'pending',
|
||||
delivery_state = 'none',
|
||||
delivery_updated_at = NULL,
|
||||
release_data = NULL,
|
||||
last_failure_reason = ?,
|
||||
reviewed_by = NULL,
|
||||
reviewed_at = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(normalized_failure_reason, request_id),
|
||||
)
|
||||
updated_row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
return user_db._parse_request_row(updated_row)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
MANUAL_QUERY_MAX_LEN = 256
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.metadata_providers import (
|
||||
BookMetadata,
|
||||
group_languages_by_localized_title,
|
||||
build_localized_search_titles,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseSearchVariant:
|
||||
"""A single search variant (title + author) associated with languages."""
|
||||
|
||||
title: str
|
||||
author: str
|
||||
languages: Optional[List[str]] = None
|
||||
|
||||
@property
|
||||
def query(self) -> str:
|
||||
return " ".join(part for part in [self.title, self.author] if part).strip()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseSearchPlan:
|
||||
"""Pre-computed search inputs shared across release sources."""
|
||||
|
||||
languages: Optional[List[str]]
|
||||
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)
|
||||
|
||||
@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]]:
|
||||
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] = []
|
||||
for lang in languages:
|
||||
if not lang:
|
||||
continue
|
||||
s = str(lang).strip()
|
||||
if not s:
|
||||
continue
|
||||
normalized.append(s)
|
||||
|
||||
if any(lang.lower() == "all" for lang in normalized):
|
||||
return None
|
||||
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _pick_search_author(book: BookMetadata) -> str:
|
||||
if book.search_author:
|
||||
return book.search_author
|
||||
|
||||
if not book.authors:
|
||||
return ""
|
||||
|
||||
first = book.authors[0]
|
||||
if "," in first:
|
||||
first = first.split(",")[0].strip()
|
||||
|
||||
return first
|
||||
|
||||
|
||||
def _pick_search_title(book: BookMetadata) -> str:
|
||||
return book.search_title or book.title
|
||||
|
||||
|
||||
def build_release_search_plan(
|
||||
book: BookMetadata,
|
||||
languages: Optional[List[str]] = None,
|
||||
manual_query: Optional[str] = None,
|
||||
indexers: Optional[List[str]] = None,
|
||||
) -> ReleaseSearchPlan:
|
||||
resolved_languages = _normalize_languages(languages)
|
||||
|
||||
resolved_manual_query = None
|
||||
if manual_query:
|
||||
resolved_manual_query = manual_query.strip()[:MANUAL_QUERY_MAX_LEN] or None
|
||||
|
||||
author = _pick_search_author(book)
|
||||
base_title = _pick_search_title(book)
|
||||
|
||||
if resolved_manual_query:
|
||||
# Manual override: use the raw query as-is (no language/title expansion).
|
||||
variant = ReleaseSearchVariant(title=resolved_manual_query, author="", languages=None)
|
||||
return ReleaseSearchPlan(
|
||||
languages=resolved_languages,
|
||||
isbn_candidates=[],
|
||||
author="",
|
||||
title_variants=[variant],
|
||||
grouped_title_variants=[variant],
|
||||
manual_query=resolved_manual_query,
|
||||
indexers=indexers,
|
||||
)
|
||||
|
||||
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:
|
||||
isbn_candidates.append(book.isbn_10)
|
||||
|
||||
titles_by_language = book.titles_by_language or None
|
||||
if book.search_title and titles_by_language:
|
||||
titles_by_language = {
|
||||
k: v
|
||||
for k, v in titles_by_language.items()
|
||||
if str(k).strip().lower() not in {"en", "eng", "english"}
|
||||
}
|
||||
|
||||
grouped = group_languages_by_localized_title(
|
||||
base_title=base_title,
|
||||
languages=resolved_languages,
|
||||
titles_by_language=titles_by_language,
|
||||
)
|
||||
|
||||
grouped_variants: List[ReleaseSearchVariant] = [
|
||||
ReleaseSearchVariant(title=title, author=author, languages=langs)
|
||||
for title, langs in grouped
|
||||
if title
|
||||
]
|
||||
|
||||
expanded_titles = build_localized_search_titles(
|
||||
base_title=base_title,
|
||||
languages=resolved_languages,
|
||||
titles_by_language=titles_by_language,
|
||||
excluded_languages={"en", "eng", "english"},
|
||||
)
|
||||
|
||||
title_variants: List[ReleaseSearchVariant] = [
|
||||
ReleaseSearchVariant(title=title, author=author, languages=None)
|
||||
for title in expanded_titles
|
||||
if title
|
||||
]
|
||||
|
||||
# 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
|
||||
]
|
||||
|
||||
return ReleaseSearchPlan(
|
||||
languages=resolved_languages,
|
||||
isbn_candidates=isbn_candidates,
|
||||
author=author,
|
||||
title_variants=title_variants,
|
||||
grouped_title_variants=grouped_variants,
|
||||
manual_query=None,
|
||||
indexers=indexers,
|
||||
)
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Self-service user account routes."""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from flask import Flask, jsonify, request, session
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shelfmark.config.env import CWA_DB_PATH
|
||||
from shelfmark.core.admin_settings_routes import (
|
||||
build_user_notification_test_response,
|
||||
validate_user_settings,
|
||||
)
|
||||
from shelfmark.core.auth_modes import (
|
||||
AUTH_SOURCE_BUILTIN,
|
||||
AUTH_SOURCE_CWA,
|
||||
AUTH_SOURCE_OIDC,
|
||||
AUTH_SOURCE_PROXY,
|
||||
determine_auth_mode,
|
||||
has_local_password_admin,
|
||||
normalize_auth_source,
|
||||
)
|
||||
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,
|
||||
get_ordered_user_overridable_fields as _get_ordered_user_overridable_fields,
|
||||
)
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
MIN_PASSWORD_LENGTH = 4
|
||||
_VISIBLE_SELF_SETTINGS_SECTIONS_KEY = "VISIBLE_SELF_SETTINGS_SECTIONS"
|
||||
_SELF_SETTINGS_SECTION_DELIVERY = "delivery"
|
||||
_SELF_SETTINGS_SECTION_NOTIFICATIONS = "notifications"
|
||||
_VALID_SELF_SETTINGS_SECTIONS = (
|
||||
_SELF_SETTINGS_SECTION_DELIVERY,
|
||||
_SELF_SETTINGS_SECTION_NOTIFICATIONS,
|
||||
)
|
||||
_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS = list(_VALID_SELF_SETTINGS_SECTIONS)
|
||||
|
||||
|
||||
def _get_auth_mode() -> str:
|
||||
"""Get current auth mode from config."""
|
||||
try:
|
||||
config = load_config_file("security")
|
||||
return determine_auth_mode(
|
||||
config,
|
||||
CWA_DB_PATH,
|
||||
has_local_admin=has_local_password_admin(),
|
||||
)
|
||||
except Exception:
|
||||
return "none"
|
||||
|
||||
|
||||
def _require_authenticated_user(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Decorator requiring an authenticated session linked to a local user row."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_mode = _get_auth_mode()
|
||||
if auth_mode != "none" and "user_id" not in session:
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
if "db_user_id" not in session:
|
||||
return jsonify({"error": "Authenticated session is missing local user context"}), 403
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def _get_current_user(user_db: UserDB) -> tuple[int | None, dict[str, Any] | None, tuple[Any, int] | None]:
|
||||
raw_user_id = session.get("db_user_id")
|
||||
try:
|
||||
user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
return None, None, (jsonify({"error": "Invalid user context"}), 400)
|
||||
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return None, None, (jsonify({"error": "User not found"}), 404)
|
||||
return user_id, user, None
|
||||
|
||||
|
||||
def _is_user_active(user: Mapping[str, Any], auth_method: str) -> bool:
|
||||
source = normalize_auth_source(user.get("auth_source"), user.get("oidc_subject"))
|
||||
if source == AUTH_SOURCE_BUILTIN:
|
||||
return auth_method in (AUTH_SOURCE_BUILTIN, AUTH_SOURCE_OIDC)
|
||||
return source == auth_method
|
||||
|
||||
|
||||
def _get_self_edit_capabilities(user: Mapping[str, Any]) -> dict[str, Any]:
|
||||
auth_source = normalize_auth_source(
|
||||
user.get("auth_source"),
|
||||
user.get("oidc_subject"),
|
||||
)
|
||||
|
||||
return {
|
||||
"authSource": auth_source,
|
||||
"canSetPassword": auth_source == AUTH_SOURCE_BUILTIN,
|
||||
"canEditRole": False,
|
||||
"canEditEmail": auth_source in {AUTH_SOURCE_BUILTIN, AUTH_SOURCE_PROXY},
|
||||
"canEditDisplayName": auth_source != AUTH_SOURCE_OIDC,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_self_user(user: Mapping[str, Any], auth_mode: str) -> dict[str, Any]:
|
||||
payload = dict(user)
|
||||
payload.pop("password_hash", None)
|
||||
payload["auth_source"] = normalize_auth_source(
|
||||
payload.get("auth_source"),
|
||||
payload.get("oidc_subject"),
|
||||
)
|
||||
payload["is_active"] = _is_user_active(payload, auth_mode)
|
||||
payload["edit_capabilities"] = _get_self_edit_capabilities(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _normalize_visible_self_settings_sections(raw_sections: Any) -> list[str]:
|
||||
"""Normalize users.VISIBLE_SELF_SETTINGS_SECTIONS to a safe ordered list."""
|
||||
if raw_sections is None:
|
||||
return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS)
|
||||
|
||||
if isinstance(raw_sections, str):
|
||||
candidate_sections = [s.strip() for s in raw_sections.split(",") if s.strip()]
|
||||
elif isinstance(raw_sections, (list, tuple, set)):
|
||||
candidate_sections = [str(section).strip() for section in raw_sections if str(section).strip()]
|
||||
else:
|
||||
return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS)
|
||||
|
||||
normalized_sections: list[str] = []
|
||||
for section in candidate_sections:
|
||||
if section in _VALID_SELF_SETTINGS_SECTIONS and section not in normalized_sections:
|
||||
normalized_sections.append(section)
|
||||
|
||||
if not normalized_sections and candidate_sections:
|
||||
# Invalid non-empty config should fail-safe to showing defaults.
|
||||
return list(_DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS)
|
||||
|
||||
return normalized_sections
|
||||
|
||||
|
||||
def _get_visible_self_settings_sections() -> list[str]:
|
||||
users_config = load_config_file("users")
|
||||
raw_sections = users_config.get(_VISIBLE_SELF_SETTINGS_SECTIONS_KEY)
|
||||
return _normalize_visible_self_settings_sections(raw_sections)
|
||||
|
||||
|
||||
def _get_allowed_self_settings_keys(visible_sections: list[str]) -> set[str]:
|
||||
allowed_keys: set[str] = set()
|
||||
visible_sections_set = set(visible_sections)
|
||||
|
||||
if _SELF_SETTINGS_SECTION_DELIVERY in visible_sections_set:
|
||||
allowed_keys |= {
|
||||
key for key, _field in _get_ordered_user_overridable_fields("downloads")
|
||||
}
|
||||
|
||||
if _SELF_SETTINGS_SECTION_NOTIFICATIONS in visible_sections_set:
|
||||
allowed_keys |= {
|
||||
key for key, _field in _get_ordered_user_overridable_fields("notifications")
|
||||
}
|
||||
|
||||
return allowed_keys
|
||||
|
||||
|
||||
def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
|
||||
"""Register self-service user endpoints."""
|
||||
|
||||
@app.route("/api/users/me/edit-context", methods=["GET"])
|
||||
@_require_authenticated_user
|
||||
def users_me_edit_context():
|
||||
user_id, user, user_error = _get_current_user(user_db)
|
||||
if user_error:
|
||||
return user_error
|
||||
|
||||
auth_mode = _get_auth_mode()
|
||||
serialized_user = _serialize_self_user(user, auth_mode)
|
||||
serialized_user["settings"] = user_db.get_user_settings(user_id)
|
||||
visible_self_settings_sections = _get_visible_self_settings_sections()
|
||||
|
||||
delivery_preferences = None
|
||||
if _SELF_SETTINGS_SECTION_DELIVERY in visible_self_settings_sections:
|
||||
try:
|
||||
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}")
|
||||
delivery_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")
|
||||
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}")
|
||||
notification_preferences = None
|
||||
|
||||
user_overridable_keys = sorted(
|
||||
set(delivery_preferences.get("keys", []) if delivery_preferences else [])
|
||||
| set(notification_preferences.get("keys", []) if notification_preferences else [])
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"user": serialized_user,
|
||||
"deliveryPreferences": delivery_preferences,
|
||||
"notificationPreferences": notification_preferences,
|
||||
"userOverridableKeys": user_overridable_keys,
|
||||
"visibleUserSettingsSections": visible_self_settings_sections,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/users/me/notification-preferences/test", methods=["POST"])
|
||||
@_require_authenticated_user
|
||||
def users_me_test_notification_preferences():
|
||||
user_id, _user, user_error = _get_current_user(user_db)
|
||||
if user_error:
|
||||
return user_error
|
||||
if user_id is None:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
result, status_code = build_user_notification_test_response(
|
||||
user_id=user_id,
|
||||
payload=payload,
|
||||
)
|
||||
return jsonify(result), status_code
|
||||
|
||||
@app.route("/api/users/me", methods=["PUT"])
|
||||
@_require_authenticated_user
|
||||
def users_me_update():
|
||||
user_id, user, user_error = _get_current_user(user_db)
|
||||
if user_error:
|
||||
return user_error
|
||||
|
||||
data = request.get_json() or {}
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Request body must be a JSON object"}), 400
|
||||
|
||||
capabilities = _get_self_edit_capabilities(user)
|
||||
auth_source = capabilities["authSource"]
|
||||
|
||||
password = data.get("password", "")
|
||||
if password:
|
||||
if not capabilities["canSetPassword"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Cannot set password for {auth_source.upper()} users",
|
||||
"message": "Password authentication is only available for local users.",
|
||||
}
|
||||
), 400
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return jsonify({"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}), 400
|
||||
user_db.update_user(user_id, password_hash=generate_password_hash(password))
|
||||
|
||||
user_fields: dict[str, Any] = {}
|
||||
if "email" in data:
|
||||
incoming_email = data.get("email")
|
||||
if incoming_email is None:
|
||||
user_fields["email"] = None
|
||||
else:
|
||||
user_fields["email"] = str(incoming_email).strip() or None
|
||||
if "display_name" in data:
|
||||
incoming_display_name = data.get("display_name")
|
||||
user_fields["display_name"] = (
|
||||
str(incoming_display_name).strip() or None
|
||||
if incoming_display_name is not None
|
||||
else None
|
||||
)
|
||||
|
||||
email_changed = "email" in user_fields and user_fields["email"] != user.get("email")
|
||||
display_name_changed = (
|
||||
"display_name" in user_fields
|
||||
and user_fields["display_name"] != user.get("display_name")
|
||||
)
|
||||
|
||||
if email_changed and not capabilities["canEditEmail"]:
|
||||
if auth_source == AUTH_SOURCE_CWA:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change email for CWA users",
|
||||
"message": "Email is synced from Calibre-Web.",
|
||||
}
|
||||
), 400
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change email for OIDC users",
|
||||
"message": "Email is managed by your identity provider.",
|
||||
}
|
||||
), 400
|
||||
|
||||
if display_name_changed and not capabilities["canEditDisplayName"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cannot change display name for OIDC users",
|
||||
"message": "Display name is managed by your identity provider.",
|
||||
}
|
||||
), 400
|
||||
|
||||
for field in ("email", "display_name"):
|
||||
if field in user_fields and user_fields[field] == user.get(field):
|
||||
user_fields.pop(field)
|
||||
|
||||
if user_fields:
|
||||
user_db.update_user(user_id, **user_fields)
|
||||
|
||||
if "settings" in data:
|
||||
settings_payload = data["settings"]
|
||||
if not isinstance(settings_payload, dict):
|
||||
return jsonify({"error": "Settings must be an object"}), 400
|
||||
|
||||
visible_self_settings_sections = _get_visible_self_settings_sections()
|
||||
allowed_user_settings_keys = _get_allowed_self_settings_keys(visible_self_settings_sections)
|
||||
disallowed_keys = sorted(
|
||||
key for key in settings_payload if key not in allowed_user_settings_keys
|
||||
)
|
||||
if disallowed_keys:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Some settings are admin-only",
|
||||
"details": [
|
||||
f"Setting not user-overridable: {key}" for key in disallowed_keys
|
||||
],
|
||||
}
|
||||
), 400
|
||||
|
||||
validated_settings, validation_errors = validate_user_settings(settings_payload)
|
||||
if validation_errors:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Invalid settings payload",
|
||||
"details": validation_errors,
|
||||
}
|
||||
), 400
|
||||
|
||||
user_db.set_user_settings(user_id, validated_settings)
|
||||
try:
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
app_config.refresh()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
updated = user_db.get_user(user_id=user_id)
|
||||
if not updated:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
result = _serialize_self_user(updated, _get_auth_mode())
|
||||
result["settings"] = user_db.get_user_settings(user_id)
|
||||
logger.info(f"User {user_id} updated their own account")
|
||||
return jsonify(result)
|
||||
@@ -22,12 +22,14 @@ class FieldBase:
|
||||
required: bool = False # Whether field must have a value
|
||||
env_var: Optional[str] = None # Override env var name (defaults to key)
|
||||
env_supported: bool = True # Whether this setting can be set via ENV var (False = UI-only)
|
||||
user_overridable: bool = False # Whether admins can set per-user overrides for this field
|
||||
disabled: bool = False # Whether field is disabled/greyed out
|
||||
disabled_reason: str = "" # Explanation shown when disabled
|
||||
show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True}
|
||||
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions
|
||||
disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."}
|
||||
requires_restart: bool = False # Whether changing this setting requires a container restart
|
||||
universal_only: bool = False # Only show in Universal search mode (hide in Direct mode)
|
||||
hidden_in_ui: bool = False # Keep field in schema/save path but hide default renderer
|
||||
|
||||
def get_env_var_name(self) -> str:
|
||||
"""Get the environment variable name for this field."""
|
||||
@@ -71,6 +73,7 @@ class SelectField(FieldBase):
|
||||
"""Single-choice dropdown."""
|
||||
# Options can be a list or a callable that returns a list (for lazy evaluation)
|
||||
options: Any = field(default_factory=list) # [{value: "", label: ""}] or callable
|
||||
filter_by_field: Optional[str] = None # Field key whose value filters options via childOf property
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -82,6 +85,14 @@ class MultiSelectField(FieldBase):
|
||||
variant: str = "pills" # "pills" (default) or "dropdown" for checkbox dropdown style
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagListField(FieldBase):
|
||||
"""Editable list of free-form string values (tag/chip input)."""
|
||||
placeholder: str = ""
|
||||
default: List[str] = field(default_factory=list)
|
||||
normalize_urls: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderableListField(FieldBase):
|
||||
# Options can be a list or a callable that returns a list (for lazy evaluation)
|
||||
@@ -93,16 +104,55 @@ class OrderableListField(FieldBase):
|
||||
default: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableField(FieldBase):
|
||||
"""Editable table of structured rows."""
|
||||
|
||||
# Column definitions: [{key, label, type, placeholder?, options?, defaultValue?}, ...]
|
||||
columns: Any = field(default_factory=list) # list or callable
|
||||
|
||||
# Value format: list of objects
|
||||
default: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
add_label: str = "Add"
|
||||
empty_message: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomComponentField:
|
||||
"""Render a custom frontend component inside settings content."""
|
||||
|
||||
key: str
|
||||
component: str # Frontend component registry key
|
||||
label: str = ""
|
||||
description: str = ""
|
||||
bind_keys: List[str] = field(default_factory=list) # Related value keys this component edits
|
||||
value_fields: List[Any] = field(default_factory=list) # Backing value schema for this component
|
||||
wrap_in_field_wrapper: bool = False # Whether to render with standard FieldWrapper layout
|
||||
disabled: bool = False
|
||||
disabled_reason: str = ""
|
||||
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None
|
||||
universal_only: bool = False
|
||||
|
||||
def get_field_type(self) -> str:
|
||||
return "CustomComponentField"
|
||||
|
||||
def get_bind_keys(self) -> List[str]:
|
||||
if self.bind_keys:
|
||||
return self.bind_keys
|
||||
return [getattr(f, "key") for f in self.value_fields if getattr(f, "key", None)]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionButton:
|
||||
key: str # Action identifier
|
||||
label: str # Button text
|
||||
description: str = "" # Help text
|
||||
style: str = "default" # "default", "primary", "danger"
|
||||
callback: Optional[Callable[[], Dict[str, Any]]] = None # Returns {"success": bool, "message": str}
|
||||
callback: Optional[Callable[..., Dict[str, Any]]] = None # Returns {"success": bool, "message": str}
|
||||
disabled: bool = False # Whether button is disabled/greyed out
|
||||
disabled_reason: str = "" # Explanation shown when disabled
|
||||
show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True}
|
||||
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions
|
||||
disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."}
|
||||
|
||||
def get_field_type(self) -> str:
|
||||
@@ -120,9 +170,10 @@ class HeadingField:
|
||||
key: str # Unique identifier
|
||||
title: str # Heading title
|
||||
description: str = "" # Description text (supports markdown-style links)
|
||||
description_by_auth_mode: Optional[Dict[str, str]] = None # Optional auth-mode specific description map
|
||||
link_url: str = "" # Optional URL for a link
|
||||
link_text: str = "" # Text for the link (defaults to URL if not provided)
|
||||
show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True}
|
||||
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions
|
||||
universal_only: bool = False # Only show in Universal search mode (hide in Direct mode)
|
||||
|
||||
def get_field_type(self) -> str:
|
||||
@@ -130,7 +181,20 @@ class HeadingField:
|
||||
|
||||
|
||||
# Type alias for all field types
|
||||
SettingsField = Union[TextField, PasswordField, NumberField, CheckboxField, SelectField, MultiSelectField, OrderableListField, ActionButton, HeadingField]
|
||||
SettingsField = Union[
|
||||
TextField,
|
||||
PasswordField,
|
||||
NumberField,
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
MultiSelectField,
|
||||
TagListField,
|
||||
OrderableListField,
|
||||
TableField,
|
||||
CustomComponentField,
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -225,6 +289,48 @@ def get_all_settings_tabs() -> List[SettingsTab]:
|
||||
return sorted(_SETTINGS_REGISTRY.values(), key=lambda t: (t.order, t.name))
|
||||
|
||||
|
||||
def _iter_value_fields(tab: SettingsTab):
|
||||
"""Yield value-bearing fields for a tab."""
|
||||
for field in tab.fields:
|
||||
if isinstance(field, CustomComponentField):
|
||||
for value_field in field.value_fields:
|
||||
if isinstance(value_field, (ActionButton, HeadingField, CustomComponentField)):
|
||||
continue
|
||||
yield value_field
|
||||
continue
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
continue
|
||||
yield field
|
||||
|
||||
|
||||
def get_settings_field_map(tab_name: Optional[str] = None) -> Dict[str, tuple[SettingsField, str]]:
|
||||
"""Return key -> (field, tab_name) map for value-bearing settings fields."""
|
||||
tabs: List[SettingsTab]
|
||||
if tab_name:
|
||||
tab = get_settings_tab(tab_name)
|
||||
if not tab:
|
||||
return {}
|
||||
tabs = [tab]
|
||||
else:
|
||||
tabs = get_all_settings_tabs()
|
||||
|
||||
field_map: Dict[str, tuple[SettingsField, str]] = {}
|
||||
for tab in tabs:
|
||||
for field in _iter_value_fields(tab):
|
||||
field_map[field.key] = (field, tab.name)
|
||||
return field_map
|
||||
|
||||
|
||||
def get_user_overridable_fields(tab_name: Optional[str] = None) -> Dict[str, tuple[SettingsField, str]]:
|
||||
"""Return key -> (field, tab_name) map for fields marked user_overridable."""
|
||||
field_map = get_settings_field_map(tab_name=tab_name)
|
||||
return {
|
||||
key: (field, tab)
|
||||
for key, (field, tab) in field_map.items()
|
||||
if getattr(field, "user_overridable", False)
|
||||
}
|
||||
|
||||
|
||||
def list_registered_settings() -> List[str]:
|
||||
"""List all registered settings tab names."""
|
||||
return list(_SETTINGS_REGISTRY.keys())
|
||||
@@ -323,11 +429,7 @@ def initialize_default_configs() -> bool:
|
||||
|
||||
# Collect default values for all fields
|
||||
defaults = {}
|
||||
for field in tab.fields:
|
||||
# Skip non-value fields
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
continue
|
||||
|
||||
for field in _iter_value_fields(tab):
|
||||
# Only include fields that have a non-None default
|
||||
if field.default is not None:
|
||||
defaults[field.key] = field.default
|
||||
@@ -359,11 +461,7 @@ def sync_env_to_config() -> None:
|
||||
for tab in get_all_settings_tabs():
|
||||
values_to_sync = {}
|
||||
|
||||
for field in tab.fields:
|
||||
# Skip non-value fields
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
continue
|
||||
|
||||
for field in _iter_value_fields(tab):
|
||||
# Skip fields that don't support ENV vars
|
||||
if not getattr(field, 'env_supported', True):
|
||||
continue
|
||||
@@ -383,6 +481,61 @@ def sync_env_to_config() -> None:
|
||||
logger.debug(f"Synced {len(values_to_sync)} ENV values to {tab.name} config: {list(values_to_sync.keys())}")
|
||||
|
||||
migrate_legacy_settings()
|
||||
migrate_mirror_settings()
|
||||
|
||||
|
||||
def migrate_mirror_settings() -> None:
|
||||
"""
|
||||
Migrate legacy AA mirror config into the new editable mirror list setting.
|
||||
|
||||
Legacy:
|
||||
- AA_ADDITIONAL_URLS: comma-separated extra URLs appended to defaults
|
||||
|
||||
New:
|
||||
- AA_MIRROR_URLS: full ordered list of available mirrors (used for Auto mode and for Settings options)
|
||||
"""
|
||||
mirrors_config = load_config_file("mirrors")
|
||||
|
||||
from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
|
||||
raw_list = mirrors_config.get("AA_MIRROR_URLS")
|
||||
raw_additional = mirrors_config.get("AA_ADDITIONAL_URLS", "")
|
||||
|
||||
def _normalize_list(values: list[str]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for item in values:
|
||||
if str(item).strip().lower() == "auto":
|
||||
continue
|
||||
norm = normalize_http_url(str(item), default_scheme="https")
|
||||
if norm and norm not in out:
|
||||
out.append(norm)
|
||||
return out
|
||||
|
||||
# If already a proper list, just ensure it's non-empty.
|
||||
if isinstance(raw_list, list):
|
||||
normalized = _normalize_list([str(v) for v in raw_list])
|
||||
if normalized:
|
||||
return
|
||||
save_config_file("mirrors", {"AA_MIRROR_URLS": _normalize_list(DEFAULT_AA_MIRRORS)})
|
||||
return
|
||||
|
||||
# If saved as a string, convert to list.
|
||||
if isinstance(raw_list, str) and raw_list.strip():
|
||||
parts = [p.strip() for p in raw_list.split(",") if p.strip()]
|
||||
normalized = _normalize_list(parts)
|
||||
if normalized:
|
||||
save_config_file("mirrors", {"AA_MIRROR_URLS": normalized})
|
||||
return
|
||||
save_config_file("mirrors", {"AA_MIRROR_URLS": _normalize_list(DEFAULT_AA_MIRRORS)})
|
||||
return
|
||||
|
||||
# If there's legacy additional mirrors, seed the full list so the UI reflects reality.
|
||||
if isinstance(raw_additional, str) and raw_additional.strip():
|
||||
additional_parts = [p.strip() for p in raw_additional.split(",") if p.strip()]
|
||||
combined = _normalize_list(DEFAULT_AA_MIRRORS + additional_parts)
|
||||
if combined:
|
||||
save_config_file("mirrors", {"AA_MIRROR_URLS": combined})
|
||||
|
||||
|
||||
def migrate_legacy_settings() -> None:
|
||||
@@ -495,7 +648,7 @@ def migrate_legacy_settings() -> None:
|
||||
|
||||
|
||||
def get_setting_value(field: SettingsField, tab_name: str) -> Any:
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
if isinstance(field, (ActionButton, HeadingField, CustomComponentField)):
|
||||
return None # Actions and headings don't have values
|
||||
|
||||
# 1. Check environment variable (if supported for this field)
|
||||
@@ -527,6 +680,8 @@ def _parse_env_value(value: str, field: SettingsField) -> Any:
|
||||
return field.default
|
||||
elif isinstance(field, MultiSelectField):
|
||||
return [v.strip() for v in value.split(',') if v.strip()]
|
||||
elif isinstance(field, TagListField):
|
||||
return [v.strip() for v in value.split(',') if v.strip()]
|
||||
elif isinstance(field, OrderableListField):
|
||||
# Parse JSON array: [{"id": "...", "enabled": true}, ...]
|
||||
try:
|
||||
@@ -534,13 +689,21 @@ def _parse_env_value(value: str, field: SettingsField) -> Any:
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid JSON for {field.key}, using default")
|
||||
return field.default
|
||||
elif isinstance(field, TableField):
|
||||
# Parse JSON array: [{"col": "value"}, ...]
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, list) else field.default
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid JSON for {field.key}, using default")
|
||||
return field.default
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
def is_value_from_env(field: SettingsField) -> bool:
|
||||
"""Check if a field's value comes from an environment variable."""
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
if isinstance(field, (ActionButton, HeadingField, CustomComponentField)):
|
||||
return False
|
||||
# UI-only settings never come from ENV (env_supported=False)
|
||||
if not getattr(field, 'env_supported', True):
|
||||
@@ -560,14 +723,46 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
|
||||
Returns:
|
||||
Dict representation of the field.
|
||||
"""
|
||||
# CustomComponentField has a custom structure - handle separately
|
||||
if isinstance(field, CustomComponentField):
|
||||
result: Dict[str, Any] = {
|
||||
"key": field.key,
|
||||
"label": field.label,
|
||||
"type": field.get_field_type(),
|
||||
"description": field.description,
|
||||
"component": field.component,
|
||||
"bindKeys": field.get_bind_keys(),
|
||||
"wrapInFieldWrapper": field.wrap_in_field_wrapper,
|
||||
"disabled": field.disabled,
|
||||
"disabledReason": field.disabled_reason,
|
||||
}
|
||||
if field.value_fields:
|
||||
bound_fields = []
|
||||
for value_field in field.value_fields:
|
||||
serialized_bound_field = serialize_field(
|
||||
value_field,
|
||||
tab_name,
|
||||
include_value=include_value,
|
||||
)
|
||||
serialized_bound_field["hiddenInUi"] = True
|
||||
bound_fields.append(serialized_bound_field)
|
||||
result["boundFields"] = bound_fields
|
||||
if field.show_when:
|
||||
result["showWhen"] = field.show_when
|
||||
if field.universal_only:
|
||||
result["universalOnly"] = True
|
||||
return result
|
||||
|
||||
# HeadingField has a different structure - handle separately
|
||||
if isinstance(field, HeadingField):
|
||||
result = {
|
||||
result: Dict[str, Any] = {
|
||||
"key": field.key,
|
||||
"type": field.get_field_type(),
|
||||
"title": field.title,
|
||||
"description": field.description,
|
||||
}
|
||||
if field.description_by_auth_mode:
|
||||
result["descriptionByAuthMode"] = field.description_by_auth_mode
|
||||
if field.link_url:
|
||||
result["linkUrl"] = field.link_url
|
||||
result["linkText"] = field.link_text or field.link_url
|
||||
@@ -577,7 +772,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
|
||||
result["universalOnly"] = True
|
||||
return result
|
||||
|
||||
result = {
|
||||
result: Dict[str, Any] = {
|
||||
"key": field.key,
|
||||
"label": field.label,
|
||||
"type": field.get_field_type(),
|
||||
@@ -586,6 +781,8 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
|
||||
"disabled": getattr(field, 'disabled', False),
|
||||
"disabledReason": getattr(field, 'disabled_reason', ''),
|
||||
"requiresRestart": getattr(field, 'requires_restart', False),
|
||||
"userOverridable": getattr(field, 'user_overridable', False),
|
||||
"hiddenInUi": getattr(field, 'hidden_in_ui', False),
|
||||
}
|
||||
|
||||
# Add optional properties if set
|
||||
@@ -613,21 +810,63 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
|
||||
result["options"] = options
|
||||
if field.default is not None:
|
||||
result["default"] = field.default
|
||||
if field.filter_by_field:
|
||||
result["filterByField"] = field.filter_by_field
|
||||
elif isinstance(field, MultiSelectField):
|
||||
# Support callable options for lazy evaluation (avoids circular imports)
|
||||
options = field.options() if callable(field.options) else field.options
|
||||
result["options"] = options
|
||||
result["variant"] = field.variant
|
||||
elif isinstance(field, TagListField):
|
||||
result["placeholder"] = field.placeholder
|
||||
result["normalizeUrls"] = field.normalize_urls
|
||||
elif isinstance(field, OrderableListField):
|
||||
# Support callable options for lazy evaluation (avoids circular imports)
|
||||
options = field.options() if callable(field.options) else field.options
|
||||
result["options"] = options
|
||||
elif isinstance(field, TableField):
|
||||
columns = field.columns() if callable(field.columns) else field.columns
|
||||
result["columns"] = columns
|
||||
result["addLabel"] = field.add_label
|
||||
result["emptyMessage"] = field.empty_message
|
||||
elif isinstance(field, ActionButton):
|
||||
result["style"] = field.style
|
||||
result["description"] = field.description
|
||||
|
||||
if include_value and not isinstance(field, (ActionButton, HeadingField)):
|
||||
if include_value and not isinstance(field, (ActionButton, HeadingField, CustomComponentField)):
|
||||
value = get_setting_value(field, tab_name)
|
||||
|
||||
# Ensure select values are serialized as strings so the frontend can
|
||||
# reliably match against string option values.
|
||||
if isinstance(field, SelectField) and value is not None:
|
||||
value = str(value)
|
||||
elif isinstance(field, MultiSelectField):
|
||||
if value is None:
|
||||
value = []
|
||||
elif isinstance(value, list):
|
||||
value = [str(v) for v in value]
|
||||
elif isinstance(value, str):
|
||||
# Support legacy/manual configs where MultiSelect values were saved
|
||||
# as comma-separated strings.
|
||||
value = [v.strip() for v in value.split(",") if v.strip()]
|
||||
else:
|
||||
value = []
|
||||
elif isinstance(field, TagListField):
|
||||
if value is None:
|
||||
value = []
|
||||
elif isinstance(value, list):
|
||||
value = [str(v) for v in value]
|
||||
elif isinstance(value, str):
|
||||
# Support legacy/manual configs where lists were saved as comma-separated strings.
|
||||
value = [v.strip() for v in value.split(",") if v.strip()]
|
||||
else:
|
||||
value = []
|
||||
elif isinstance(field, TableField):
|
||||
if value is None:
|
||||
value = []
|
||||
elif not isinstance(value, list):
|
||||
value = []
|
||||
|
||||
result["value"] = value if value is not None else ""
|
||||
result["fromEnv"] = is_value_from_env(field)
|
||||
|
||||
@@ -696,7 +935,7 @@ def execute_action(tab_name: str, action_key: str, current_values: Optional[Dict
|
||||
try:
|
||||
# Check if callback accepts current_values parameter
|
||||
sig = inspect.signature(field.callback)
|
||||
if 'current_values' in sig.parameters:
|
||||
if "current_values" in sig.parameters:
|
||||
return field.callback(current_values=current_values or {})
|
||||
else:
|
||||
return field.callback()
|
||||
@@ -749,6 +988,23 @@ def _apply_dns_settings(config) -> None:
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to apply DNS settings: {e}")
|
||||
|
||||
def _apply_aa_mirror_settings(config) -> None:
|
||||
"""
|
||||
Apply AA mirror settings changes to the network module.
|
||||
|
||||
This ensures AA_BASE_URL / AA_ADDITIONAL_URLS changes take effect immediately
|
||||
without requiring a container restart.
|
||||
"""
|
||||
try:
|
||||
from shelfmark.download import network
|
||||
|
||||
# Reload AA mirror list and configured base URL from refreshed config.
|
||||
network.init_aa(force=True)
|
||||
except ImportError:
|
||||
pass # Network module not available
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to apply AA mirror settings: {e}")
|
||||
|
||||
|
||||
def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
tab = get_settings_tab(tab_name)
|
||||
@@ -756,7 +1012,10 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"success": False, "message": f"Unknown settings tab: {tab_name}", "updated": [], "requiresRestart": False}
|
||||
|
||||
# Build a map of field keys to fields (exclude non-value fields)
|
||||
field_map = {f.key: f for f in tab.fields if not isinstance(f, (ActionButton, HeadingField))}
|
||||
field_map = {
|
||||
key: field
|
||||
for key, (field, _) in get_settings_field_map(tab_name=tab_name).items()
|
||||
}
|
||||
|
||||
# Filter out values that are set via env vars or unknown
|
||||
values_to_save = {}
|
||||
@@ -816,16 +1075,31 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Save to config file
|
||||
if save_config_file(tab_name, values_to_save):
|
||||
# Refresh the config singleton so live settings take effect immediately
|
||||
config_obj = None
|
||||
try:
|
||||
from shelfmark.core.config import config
|
||||
config.refresh()
|
||||
from shelfmark.core.config import config as config_obj
|
||||
|
||||
config_obj.refresh()
|
||||
except ImportError:
|
||||
pass # Config module not yet available during initial setup
|
||||
config_obj = None # Config module not yet available during initial setup
|
||||
|
||||
# Apply DNS settings changes live (network tab)
|
||||
dns_keys = {"CUSTOM_DNS", "CUSTOM_DNS_MANUAL", "USE_DOH"}
|
||||
if tab_name == "network" and dns_keys.intersection(values_to_save.keys()):
|
||||
_apply_dns_settings(config)
|
||||
if (
|
||||
config_obj is not None
|
||||
and tab_name == "network"
|
||||
and dns_keys.intersection(values_to_save.keys())
|
||||
):
|
||||
_apply_dns_settings(config_obj)
|
||||
|
||||
# Apply AA mirror settings changes live (mirrors tab)
|
||||
aa_keys = {"AA_BASE_URL", "AA_MIRROR_URLS", "AA_ADDITIONAL_URLS"}
|
||||
if (
|
||||
config_obj is not None
|
||||
and tab_name == "mirrors"
|
||||
and aa_keys.intersection(values_to_save.keys())
|
||||
):
|
||||
_apply_aa_mirror_settings(config_obj)
|
||||
|
||||
# Sync metadata provider selection when a provider's enabled state changes
|
||||
tab = get_settings_tab(tab_name)
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
"""SQLite user database for multi-user support."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from shelfmark.core.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.requests_service import (
|
||||
normalize_delivery_state,
|
||||
normalize_policy_mode,
|
||||
normalize_request_level,
|
||||
normalize_request_status,
|
||||
validate_request_level_payload,
|
||||
validate_status_transition,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_CREATE_TABLES_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
password_hash TEXT,
|
||||
oidc_subject TEXT UNIQUE,
|
||||
auth_source TEXT NOT NULL DEFAULT 'builtin',
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
delivery_state TEXT NOT NULL DEFAULT 'none',
|
||||
source_hint TEXT,
|
||||
content_type TEXT NOT NULL,
|
||||
request_level TEXT NOT NULL,
|
||||
policy_mode TEXT NOT NULL,
|
||||
book_data TEXT NOT NULL,
|
||||
release_data TEXT,
|
||||
note TEXT,
|
||||
admin_note TEXT,
|
||||
reviewed_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
reviewed_at TIMESTAMP,
|
||||
delivery_updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_user_status_created_at
|
||||
ON download_requests (user_id, status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_status_created_at
|
||||
ON download_requests (status, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_type TEXT NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
request_id INTEGER,
|
||||
source_id TEXT,
|
||||
origin TEXT NOT NULL,
|
||||
final_status TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
terminal_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_log_user_terminal
|
||||
ON activity_log (user_id, terminal_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_log_lookup
|
||||
ON activity_log (user_id, item_type, item_key, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_dismissals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_type TEXT NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
activity_log_id INTEGER REFERENCES activity_log(id) ON DELETE SET NULL,
|
||||
dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, item_type, item_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_dismissals_user_dismissed_at
|
||||
ON activity_dismissals (user_id, dismissed_at DESC);
|
||||
"""
|
||||
|
||||
|
||||
def get_users_db_path(config_dir: Optional[str] = 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")
|
||||
|
||||
|
||||
def sync_builtin_admin_user(
|
||||
username: str,
|
||||
password_hash: str,
|
||||
db_path: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Ensure a local admin user exists for configured builtin credentials."""
|
||||
normalized_username = (username or "").strip()
|
||||
normalized_hash = password_hash or ""
|
||||
if not normalized_username or not normalized_hash:
|
||||
return
|
||||
|
||||
user_db = UserDB(db_path or get_users_db_path())
|
||||
user_db.initialize()
|
||||
|
||||
existing = user_db.get_user(username=normalized_username)
|
||||
if existing:
|
||||
updates: dict[str, Any] = {}
|
||||
if existing.get("password_hash") != normalized_hash:
|
||||
updates["password_hash"] = normalized_hash
|
||||
if existing.get("role") != "admin":
|
||||
updates["role"] = "admin"
|
||||
if existing.get("auth_source") != AUTH_SOURCE_BUILTIN:
|
||||
updates["auth_source"] = AUTH_SOURCE_BUILTIN
|
||||
if updates:
|
||||
user_db.update_user(existing["id"], **updates)
|
||||
logger.info(f"Updated local admin user '{normalized_username}' from builtin settings")
|
||||
return
|
||||
|
||||
user_db.create_user(
|
||||
username=normalized_username,
|
||||
password_hash=normalized_hash,
|
||||
auth_source=AUTH_SOURCE_BUILTIN,
|
||||
role="admin",
|
||||
)
|
||||
logger.info(f"Created local admin user '{normalized_username}' from builtin settings")
|
||||
|
||||
|
||||
class UserDB:
|
||||
"""Thread-safe SQLite user database."""
|
||||
|
||||
_VALID_AUTH_SOURCES = set(AUTH_SOURCE_SET)
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Create database and tables if they don't exist."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.executescript(_CREATE_TABLES_SQL)
|
||||
self._migrate_auth_source_column(conn)
|
||||
self._migrate_request_delivery_columns(conn)
|
||||
self._migrate_activity_tables(conn)
|
||||
conn.commit()
|
||||
# WAL mode must be changed outside an open transaction.
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _migrate_auth_source_column(self, conn: sqlite3.Connection) -> None:
|
||||
"""Ensure users.auth_source exists and backfill historical rows."""
|
||||
columns = conn.execute("PRAGMA table_info(users)").fetchall()
|
||||
column_names = {str(col["name"]) for col in columns}
|
||||
|
||||
if "auth_source" not in column_names:
|
||||
conn.execute(
|
||||
"ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'builtin'"
|
||||
)
|
||||
|
||||
# Backfill OIDC-origin users created before auth_source existed.
|
||||
conn.execute(
|
||||
"UPDATE users SET auth_source = 'oidc' WHERE oidc_subject IS NOT NULL"
|
||||
)
|
||||
# Defensive cleanup for any legacy null/blank values.
|
||||
conn.execute(
|
||||
"UPDATE users SET auth_source = 'builtin' WHERE auth_source IS NULL OR auth_source = ''"
|
||||
)
|
||||
|
||||
def _migrate_request_delivery_columns(self, conn: sqlite3.Connection) -> None:
|
||||
"""Ensure request delivery-state columns exist and backfill historical rows."""
|
||||
columns = conn.execute("PRAGMA table_info(download_requests)").fetchall()
|
||||
column_names = {str(col["name"]) for col in columns}
|
||||
|
||||
if "delivery_state" not in column_names:
|
||||
conn.execute(
|
||||
"ALTER TABLE download_requests ADD COLUMN delivery_state TEXT NOT NULL DEFAULT 'none'"
|
||||
)
|
||||
if "delivery_updated_at" not in column_names:
|
||||
conn.execute("ALTER TABLE download_requests ADD COLUMN delivery_updated_at TIMESTAMP")
|
||||
if "last_failure_reason" not in column_names:
|
||||
conn.execute("ALTER TABLE download_requests ADD COLUMN last_failure_reason TEXT")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE download_requests
|
||||
SET delivery_state = 'unknown'
|
||||
WHERE status = 'fulfilled' AND (delivery_state IS NULL OR TRIM(delivery_state) = '' OR delivery_state = 'none')
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE download_requests
|
||||
SET delivery_state = 'none'
|
||||
WHERE status != 'fulfilled' AND (delivery_state IS NULL OR TRIM(delivery_state) = '')
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE download_requests
|
||||
SET delivery_updated_at = COALESCE(delivery_updated_at, reviewed_at, created_at)
|
||||
WHERE delivery_state != 'none' AND delivery_updated_at IS NULL
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE download_requests
|
||||
SET delivery_state = 'complete'
|
||||
WHERE delivery_state = 'cleared'
|
||||
"""
|
||||
)
|
||||
|
||||
def _migrate_activity_tables(self, conn: sqlite3.Connection) -> None:
|
||||
"""Ensure activity log and dismissal tables exist with current columns/indexes."""
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS activity_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_type TEXT NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
request_id INTEGER,
|
||||
source_id TEXT,
|
||||
origin TEXT NOT NULL,
|
||||
final_status TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
terminal_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_log_user_terminal
|
||||
ON activity_log (user_id, terminal_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_log_lookup
|
||||
ON activity_log (user_id, item_type, item_key, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_dismissals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_type TEXT NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
activity_log_id INTEGER REFERENCES activity_log(id) ON DELETE SET NULL,
|
||||
dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, item_type, item_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_dismissals_user_dismissed_at
|
||||
ON activity_dismissals (user_id, dismissed_at DESC);
|
||||
"""
|
||||
)
|
||||
|
||||
dismissal_columns = conn.execute("PRAGMA table_info(activity_dismissals)").fetchall()
|
||||
dismissal_column_names = {str(col["name"]) for col in dismissal_columns}
|
||||
if "activity_log_id" not in dismissal_column_names:
|
||||
conn.execute("ALTER TABLE activity_dismissals ADD COLUMN activity_log_id INTEGER")
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
email: Optional[str] = None,
|
||||
display_name: Optional[str] = None,
|
||||
password_hash: Optional[str] = None,
|
||||
oidc_subject: Optional[str] = None,
|
||||
auth_source: str = "builtin",
|
||||
role: str = "user",
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new user. Raises ValueError if username or oidc_subject already exists."""
|
||||
if auth_source not in self._VALID_AUTH_SOURCES:
|
||||
raise ValueError(f"Invalid auth_source: {auth_source}")
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO users (
|
||||
username, email, display_name, password_hash, oidc_subject, auth_source, role
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
username,
|
||||
email,
|
||||
display_name,
|
||||
password_hash,
|
||||
oidc_subject,
|
||||
auth_source,
|
||||
role,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
user_id = cursor.lastrowid
|
||||
return self._get_user_by_id(conn, user_id)
|
||||
except sqlite3.IntegrityError as e:
|
||||
raise ValueError(f"User already exists: {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]]:
|
||||
"""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()
|
||||
elif oidc_subject is not None:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM users WHERE oidc_subject = ?", (oidc_subject,)
|
||||
).fetchone()
|
||||
else:
|
||||
return None
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _get_user_by_id(self, conn: sqlite3.Connection, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
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",
|
||||
}
|
||||
|
||||
def update_user(self, user_id: int, **kwargs) -> None:
|
||||
"""Update user fields. Raises ValueError if user not found or invalid column."""
|
||||
if not kwargs:
|
||||
return
|
||||
for k in kwargs:
|
||||
if k not in self._ALLOWED_UPDATE_COLUMNS:
|
||||
raise ValueError(f"Invalid column: {k}")
|
||||
if "auth_source" in kwargs and kwargs["auth_source"] not in self._VALID_AUTH_SOURCES:
|
||||
raise ValueError(f"Invalid auth_source: {kwargs['auth_source']}")
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
# Verify user exists
|
||||
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]
|
||||
conn.execute(f"UPDATE users SET {sets} WHERE id = ?", values)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_user(self, user_id: int) -> None:
|
||||
"""Delete a user and their settings."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_users(self) -> List[Dict[str, Any]]:
|
||||
"""List all users."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute("SELECT * FROM users ORDER BY id").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_user_settings(self, user_id: int) -> Dict[str, Any]:
|
||||
"""Get per-user settings. Returns empty dict if none set."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT settings_json FROM user_settings WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return json.loads(row["settings_json"])
|
||||
return {}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def set_user_settings(self, user_id: int, settings: Dict[str, Any]) -> None:
|
||||
"""Merge settings into user's existing settings."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
existing = {}
|
||||
row = conn.execute(
|
||||
"SELECT settings_json FROM user_settings WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
existing = json.loads(row["settings_json"])
|
||||
|
||||
existing.update(settings)
|
||||
# Remove keys set to None (meaning "clear this override")
|
||||
existing = {k: v for k, v in existing.items() if v is not None}
|
||||
settings_json = json.dumps(existing)
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO user_settings (user_id, settings_json) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET settings_json = ?""",
|
||||
(user_id, settings_json, settings_json),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _serialize_json(value: Any, field: str) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return json.dumps(value)
|
||||
except TypeError as exc:
|
||||
raise ValueError(f"{field} must be JSON-serializable") from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_request_row(row: Optional[sqlite3.Row]) -> Optional[Dict[str, Any]]:
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
payload = dict(row)
|
||||
for key in ("book_data", "release_data"):
|
||||
raw_value = payload.get(key)
|
||||
if raw_value is None:
|
||||
payload[key] = None
|
||||
continue
|
||||
try:
|
||||
payload[key] = json.loads(raw_value)
|
||||
except (ValueError, TypeError):
|
||||
payload[key] = None
|
||||
return payload
|
||||
|
||||
def create_request(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
content_type: str,
|
||||
request_level: str,
|
||||
policy_mode: str,
|
||||
book_data: Dict[str, Any],
|
||||
release_data: Optional[Dict[str, Any]] = None,
|
||||
status: str = "pending",
|
||||
source_hint: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
admin_note: Optional[str] = None,
|
||||
reviewed_by: Optional[int] = None,
|
||||
reviewed_at: Optional[str] = None,
|
||||
delivery_state: str = "none",
|
||||
delivery_updated_at: Optional[str] = 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")
|
||||
if release_data is not None and not isinstance(release_data, dict):
|
||||
raise ValueError("release_data must be an object when provided")
|
||||
if not content_type:
|
||||
raise ValueError("content_type is required")
|
||||
|
||||
normalized_status = normalize_request_status(status)
|
||||
normalized_delivery_state = normalize_delivery_state(delivery_state)
|
||||
normalized_policy_mode = normalize_policy_mode(policy_mode)
|
||||
normalized_request_level = validate_request_level_payload(request_level, release_data)
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO download_requests (
|
||||
user_id,
|
||||
status,
|
||||
delivery_state,
|
||||
source_hint,
|
||||
content_type,
|
||||
request_level,
|
||||
policy_mode,
|
||||
book_data,
|
||||
release_data,
|
||||
note,
|
||||
admin_note,
|
||||
reviewed_by,
|
||||
reviewed_at,
|
||||
delivery_updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
normalized_status,
|
||||
normalized_delivery_state,
|
||||
source_hint,
|
||||
content_type,
|
||||
normalized_request_level,
|
||||
normalized_policy_mode,
|
||||
self._serialize_json(book_data, "book_data"),
|
||||
self._serialize_json(release_data, "release_data"),
|
||||
note,
|
||||
admin_note,
|
||||
reviewed_by,
|
||||
reviewed_at,
|
||||
delivery_updated_at,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
request_id = cursor.lastrowid
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
parsed = self._parse_request_row(row)
|
||||
if parsed is None:
|
||||
raise ValueError(f"Request {request_id} not found after creation")
|
||||
return parsed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_request(self, request_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a request row by ID."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
return self._parse_request_row(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_requests(
|
||||
self,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List requests with optional user/status filters."""
|
||||
where_clauses: List[str] = []
|
||||
params: List[Any] = []
|
||||
|
||||
if user_id is not None:
|
||||
where_clauses.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
|
||||
if status is not None:
|
||||
where_clauses.append("status = ?")
|
||||
params.append(normalize_request_status(status))
|
||||
|
||||
query = "SELECT * FROM download_requests"
|
||||
if where_clauses:
|
||||
query += " WHERE " + " AND ".join(where_clauses)
|
||||
query += " ORDER BY created_at DESC, id DESC"
|
||||
|
||||
if limit is not None:
|
||||
query += " LIMIT ?"
|
||||
params.append(int(limit))
|
||||
if offset:
|
||||
query += " OFFSET ?"
|
||||
params.append(offset)
|
||||
elif offset:
|
||||
query += " LIMIT -1 OFFSET ?"
|
||||
params.append(offset)
|
||||
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
results: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
parsed = self._parse_request_row(row)
|
||||
if parsed is not None:
|
||||
results.append(parsed)
|
||||
return results
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
_ALLOWED_REQUEST_UPDATE_COLUMNS = {
|
||||
"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,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update request fields and return the updated record."""
|
||||
if not kwargs:
|
||||
request = self.get_request(request_id)
|
||||
if request is None:
|
||||
raise ValueError(f"Request {request_id} not found")
|
||||
if expected_current_status is not None:
|
||||
normalized_expected_status = normalize_request_status(expected_current_status)
|
||||
if request["status"] != normalized_expected_status:
|
||||
raise ValueError("Request state changed before update")
|
||||
return request
|
||||
|
||||
for key in kwargs:
|
||||
if key not in self._ALLOWED_REQUEST_UPDATE_COLUMNS:
|
||||
raise ValueError(f"Invalid request column: {key}")
|
||||
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
current = self._parse_request_row(row)
|
||||
if current is None:
|
||||
raise ValueError(f"Request {request_id} not found")
|
||||
|
||||
if expected_current_status is not None:
|
||||
normalized_expected_status = normalize_request_status(expected_current_status)
|
||||
if current["status"] != normalized_expected_status:
|
||||
raise ValueError("Request state changed before update")
|
||||
|
||||
updates = dict(kwargs)
|
||||
|
||||
if "status" in updates:
|
||||
_, normalized_status = validate_status_transition(
|
||||
current["status"],
|
||||
updates["status"],
|
||||
)
|
||||
updates["status"] = normalized_status
|
||||
|
||||
if "policy_mode" in updates:
|
||||
updates["policy_mode"] = normalize_policy_mode(updates["policy_mode"])
|
||||
|
||||
if "delivery_state" in updates:
|
||||
updates["delivery_state"] = normalize_delivery_state(updates["delivery_state"])
|
||||
|
||||
if "delivery_updated_at" in updates:
|
||||
delivery_updated_at = updates["delivery_updated_at"]
|
||||
if delivery_updated_at is not None and not isinstance(delivery_updated_at, str):
|
||||
raise ValueError("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")
|
||||
|
||||
candidate_request_level = updates.get("request_level", current["request_level"])
|
||||
candidate_release_data = (
|
||||
updates["release_data"] if "release_data" in updates else current["release_data"]
|
||||
)
|
||||
candidate_status = updates.get("status", current["status"])
|
||||
normalized_request_level = normalize_request_level(candidate_request_level)
|
||||
normalized_candidate_status = normalize_request_status(candidate_status)
|
||||
|
||||
if normalized_request_level == "release" and candidate_release_data is None:
|
||||
raise ValueError("request_level=release requires non-null release_data")
|
||||
if (
|
||||
normalized_request_level == "book"
|
||||
and candidate_release_data is not None
|
||||
and normalized_candidate_status != "fulfilled"
|
||||
):
|
||||
raise ValueError("request_level=book requires null release_data")
|
||||
if "request_level" in updates:
|
||||
updates["request_level"] = normalized_request_level
|
||||
|
||||
if "book_data" in updates:
|
||||
if not isinstance(updates["book_data"], dict):
|
||||
raise ValueError("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")
|
||||
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]
|
||||
conn.execute(
|
||||
f"UPDATE download_requests SET {set_clause} WHERE id = ?",
|
||||
values,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
updated_row = conn.execute(
|
||||
"SELECT * FROM download_requests WHERE id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
parsed = self._parse_request_row(updated_row)
|
||||
if parsed is None:
|
||||
raise ValueError(f"Request {request_id} not found after update")
|
||||
return parsed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def count_pending_requests(self) -> int:
|
||||
"""Count all pending requests."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS count FROM download_requests WHERE status = 'pending'"
|
||||
).fetchone()
|
||||
return int(row["count"]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def count_user_pending_requests(self, user_id: int) -> int:
|
||||
"""Count pending requests for a specific user."""
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS count FROM download_requests WHERE user_id = ? AND status = 'pending'",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return int(row["count"]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Shared helpers for user-overridable settings metadata and payloads."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shelfmark.core.settings_registry import load_config_file
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
|
||||
def get_settings_registry():
|
||||
# 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
|
||||
from shelfmark.core import settings_registry
|
||||
|
||||
return settings_registry
|
||||
|
||||
|
||||
def get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]:
|
||||
settings_registry = get_settings_registry()
|
||||
tab = settings_registry.get_settings_tab(tab_name)
|
||||
if not tab:
|
||||
return []
|
||||
overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name)
|
||||
return [(field.key, field) for field in tab.fields if field.key in overridable_map]
|
||||
|
||||
|
||||
def build_user_preferences_payload(user_db: UserDB, user_id: int, tab_name: str) -> dict[str, Any]:
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
settings_registry = get_settings_registry()
|
||||
ordered_fields = get_ordered_user_overridable_fields(tab_name)
|
||||
if not ordered_fields:
|
||||
tab_label = tab_name.capitalize()
|
||||
raise ValueError(f"{tab_label} settings tab not found")
|
||||
|
||||
tab_config = load_config_file(tab_name)
|
||||
user_settings = user_db.get_user_settings(user_id)
|
||||
ordered_keys = [key for key, _ in ordered_fields]
|
||||
|
||||
fields_payload: list[dict[str, Any]] = []
|
||||
global_values: dict[str, Any] = {}
|
||||
effective: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for key, field in ordered_fields:
|
||||
serialized = settings_registry.serialize_field(field, tab_name, include_value=False)
|
||||
serialized["fromEnv"] = bool(field.env_supported and settings_registry.is_value_from_env(field))
|
||||
fields_payload.append(serialized)
|
||||
|
||||
global_values[key] = app_config.get(key, field.default)
|
||||
|
||||
source = "default"
|
||||
value = app_config.get(key, field.default, user_id=user_id)
|
||||
if field.env_supported and settings_registry.is_value_from_env(field):
|
||||
source = "env_var"
|
||||
elif key in user_settings and user_settings[key] is not None:
|
||||
source = "user_override"
|
||||
value = user_settings[key]
|
||||
elif key in tab_config:
|
||||
source = "global_config"
|
||||
|
||||
effective[key] = {"value": value, "source": source}
|
||||
|
||||
user_overrides = {
|
||||
key: user_settings[key]
|
||||
for key in ordered_keys
|
||||
if key in user_settings and user_settings[key] is not None
|
||||
}
|
||||
|
||||
return {
|
||||
"tab": tab_name,
|
||||
"keys": ordered_keys,
|
||||
"fields": fields_payload,
|
||||
"globalValues": global_values,
|
||||
"userOverrides": user_overrides,
|
||||
"effective": effective,
|
||||
}
|
||||
+148
-5
@@ -1,8 +1,79 @@
|
||||
"""Shared utility functions for the Shelfmark."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def normalize_http_url(
|
||||
url: Optional[str],
|
||||
*,
|
||||
default_scheme: str = "http",
|
||||
strip_trailing_slash: bool = True,
|
||||
allow_special: tuple[str, ...] = (),
|
||||
) -> str:
|
||||
"""Normalize a configured HTTP URL for requests and links."""
|
||||
if not isinstance(url, str):
|
||||
return ""
|
||||
|
||||
normalized = url.strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
if (normalized.startswith("\"") and normalized.endswith("\"")) or (
|
||||
normalized.startswith("'") and normalized.endswith("'")
|
||||
):
|
||||
normalized = normalized[1:-1].strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
if allow_special:
|
||||
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
|
||||
|
||||
if normalized.startswith(("/", "./", "../")):
|
||||
return normalized
|
||||
|
||||
if "://" not in normalized:
|
||||
scheme = default_scheme.strip().rstrip(":/")
|
||||
if scheme:
|
||||
normalized = f"{scheme}://{normalized}"
|
||||
|
||||
if strip_trailing_slash:
|
||||
normalized = normalized.rstrip("/")
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_base_path(value: Optional[str]) -> str:
|
||||
"""Normalize a URL base path for reverse proxy subpath deployments."""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
|
||||
path = value.strip()
|
||||
if not path:
|
||||
return ""
|
||||
|
||||
if "://" in path:
|
||||
parsed = urlparse(path)
|
||||
path = parsed.path or ""
|
||||
|
||||
if not path or path == "/":
|
||||
return ""
|
||||
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
return path.rstrip("/")
|
||||
|
||||
|
||||
def is_audiobook(content_type: Optional[str]) -> bool:
|
||||
@@ -49,21 +120,88 @@ _LEGACY_CONTENT_TYPE_TO_CONFIG_KEY = {
|
||||
"other": "INGEST_DIR_OTHER",
|
||||
}
|
||||
|
||||
_USER_PLACEHOLDER_PATTERN = re.compile(r"\{user\}", re.IGNORECASE)
|
||||
_INVALID_USER_PATH_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||
|
||||
def get_destination(is_audiobook: bool = False) -> Path:
|
||||
|
||||
def _sanitize_user_for_path(username: str) -> str:
|
||||
"""Sanitize username for path usage in destination placeholders."""
|
||||
sanitized = _INVALID_USER_PATH_CHARS.sub("_", username.strip())
|
||||
return sanitized.strip(" .")
|
||||
|
||||
|
||||
def _resolve_destination_username(
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
) -> str:
|
||||
explicit = str(username or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
if user_id is None:
|
||||
return ""
|
||||
|
||||
try:
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
user_db = UserDB(os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db"))
|
||||
user_db.initialize()
|
||||
user = user_db.get_user(user_id=user_id)
|
||||
if not user:
|
||||
return ""
|
||||
return str(user.get("username") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _expand_user_destination_placeholder(
|
||||
path_value: str,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Expand `{User}` placeholders in destination paths."""
|
||||
if not isinstance(path_value, str):
|
||||
return path_value
|
||||
|
||||
if not _USER_PLACEHOLDER_PATTERN.search(path_value):
|
||||
return path_value
|
||||
|
||||
resolved_username = _sanitize_user_for_path(
|
||||
_resolve_destination_username(user_id=user_id, username=username)
|
||||
)
|
||||
return _USER_PLACEHOLDER_PATTERN.sub(resolved_username, path_value)
|
||||
|
||||
|
||||
def get_destination(
|
||||
is_audiobook: bool = False,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
) -> Path:
|
||||
"""Get base destination directory. Audiobooks fall back to main destination."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
if is_audiobook:
|
||||
# Audiobook destination with fallback to main destination
|
||||
audiobook_dest = config.get("DESTINATION_AUDIOBOOK", "")
|
||||
audiobook_dest = config.get("DESTINATION_AUDIOBOOK", "", user_id=user_id)
|
||||
if audiobook_dest:
|
||||
return Path(audiobook_dest)
|
||||
return Path(
|
||||
_expand_user_destination_placeholder(
|
||||
str(audiobook_dest),
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
)
|
||||
|
||||
# Main destination (also fallback for audiobooks)
|
||||
# Check new setting first, then legacy INGEST_DIR
|
||||
destination = config.get("DESTINATION", "") or config.get("INGEST_DIR", "/books")
|
||||
return Path(destination)
|
||||
destination = config.get("DESTINATION", "", user_id=user_id) or config.get("INGEST_DIR", "/books")
|
||||
return Path(
|
||||
_expand_user_destination_placeholder(
|
||||
str(destination),
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path]:
|
||||
@@ -122,6 +260,11 @@ def transform_cover_url(cover_url: Optional[str], cache_id: str) -> Optional[str
|
||||
if not is_covers_cache_enabled():
|
||||
return cover_url
|
||||
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
# Encode the original URL and create a proxy URL
|
||||
encoded_url = base64.urlsafe_b64encode(cover_url.encode()).decode()
|
||||
base_path = normalize_base_path(app_config.get("URL_BASE", ""))
|
||||
if base_path:
|
||||
return f"{base_path}/api/covers/{cache_id}?url={encoded_url}"
|
||||
return f"/api/covers/{cache_id}?url={encoded_url}"
|
||||
|
||||
+22
-232
@@ -3,107 +3,20 @@
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.naming import parse_naming_template, sanitize_filename
|
||||
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, atomic_move
|
||||
from shelfmark.download.fs import atomic_write
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _get_supported_formats() -> List[str]:
|
||||
"""Get current supported formats from config singleton."""
|
||||
formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"])
|
||||
# Handle both list (from MultiSelectField) and comma-separated string (legacy/env)
|
||||
if isinstance(formats, str):
|
||||
return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()]
|
||||
return [fmt.lower() for fmt in formats]
|
||||
|
||||
|
||||
def _get_supported_audiobook_formats() -> List[str]:
|
||||
"""Get current supported audiobook formats from config singleton."""
|
||||
formats = config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"])
|
||||
# Handle both list (from MultiSelectField) and comma-separated string (legacy/env)
|
||||
if isinstance(formats, str):
|
||||
return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()]
|
||||
return [fmt.lower() for fmt in formats]
|
||||
|
||||
|
||||
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 = config.get(key, "rename")
|
||||
|
||||
# Handle legacy settings migration
|
||||
if mode not in ("none", "rename", "organize"):
|
||||
legacy_key = "PROCESSING_MODE_AUDIOBOOK" if is_audiobook else "PROCESSING_MODE"
|
||||
legacy_mode = config.get(legacy_key, "ingest")
|
||||
if legacy_mode == "library":
|
||||
return "organize"
|
||||
if config.get("USE_BOOK_TITLE", True):
|
||||
return "rename"
|
||||
return "none"
|
||||
|
||||
return mode
|
||||
|
||||
|
||||
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":
|
||||
key = "TEMPLATE_AUDIOBOOK_ORGANIZE"
|
||||
else:
|
||||
key = "TEMPLATE_AUDIOBOOK_RENAME"
|
||||
else:
|
||||
if organization_mode == "organize":
|
||||
key = "TEMPLATE_ORGANIZE"
|
||||
else:
|
||||
key = "TEMPLATE_RENAME"
|
||||
|
||||
template = config.get(key, "")
|
||||
|
||||
# Fallback to legacy keys if new keys are empty
|
||||
if not template:
|
||||
legacy_key = "TEMPLATE_AUDIOBOOK" if is_audiobook else "TEMPLATE"
|
||||
template = config.get(legacy_key, "")
|
||||
|
||||
if not template:
|
||||
legacy_key = "LIBRARY_TEMPLATE_AUDIOBOOK" if is_audiobook else "LIBRARY_TEMPLATE"
|
||||
template = config.get(legacy_key, "")
|
||||
|
||||
if not template:
|
||||
if organization_mode == "organize":
|
||||
return "{Author}/{Title} ({Year})"
|
||||
return "{Author} - {Title} ({Year})"
|
||||
|
||||
return template
|
||||
|
||||
|
||||
def _build_filename_from_task(task, extension: str, organization_mode: str) -> str:
|
||||
"""Build a filename from task metadata using the configured template."""
|
||||
is_audiobook = check_audiobook(task.content_type)
|
||||
|
||||
template = _get_template(is_audiobook, organization_mode)
|
||||
metadata = {
|
||||
"Author": task.author,
|
||||
"Title": task.title,
|
||||
"Subtitle": getattr(task, 'subtitle', None),
|
||||
"Year": task.year,
|
||||
"Series": getattr(task, 'series_name', None),
|
||||
"SeriesPosition": getattr(task, 'series_position', None),
|
||||
}
|
||||
|
||||
filename = parse_naming_template(template, metadata)
|
||||
if filename:
|
||||
return f"{sanitize_filename(filename)}.{extension}"
|
||||
return ""
|
||||
|
||||
# Check for rarfile availability at module load
|
||||
try:
|
||||
import rarfile
|
||||
@@ -142,9 +55,9 @@ def _is_supported_file(file_path: Path, content_type: Optional[str] = None) -> b
|
||||
"""Check if file matches user's supported formats setting based on content type."""
|
||||
ext = file_path.suffix.lower().lstrip(".")
|
||||
if check_audiobook(content_type):
|
||||
supported_formats = _get_supported_audiobook_formats()
|
||||
supported_formats = get_supported_audiobook_formats()
|
||||
else:
|
||||
supported_formats = _get_supported_formats()
|
||||
supported_formats = get_supported_formats()
|
||||
return ext in supported_formats
|
||||
|
||||
|
||||
@@ -225,6 +138,21 @@ def extract_archive(
|
||||
return matched_files, warnings, rejected_files
|
||||
|
||||
|
||||
def extract_archive_raw(
|
||||
archive_path: Path,
|
||||
output_dir: Path,
|
||||
) -> Tuple[List[Path], List[str]]:
|
||||
"""Extract archive without filtering (returns all extracted files)."""
|
||||
suffix = archive_path.suffix.lower().lstrip(".")
|
||||
|
||||
if suffix == "zip":
|
||||
return _extract_zip(archive_path, output_dir)
|
||||
if suffix == "rar":
|
||||
return _extract_rar(archive_path, output_dir)
|
||||
|
||||
raise ArchiveExtractionError(f"Unsupported archive format: {suffix}")
|
||||
|
||||
|
||||
def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]:
|
||||
"""Extract files from ZipFile or RarFile to output_dir with security checks."""
|
||||
extracted_files = []
|
||||
@@ -309,141 +237,3 @@ def _extract_rar(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List
|
||||
raise ArchiveExtractionError(f"Permission denied: {e}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveResult:
|
||||
"""Result of archive processing."""
|
||||
|
||||
success: bool
|
||||
final_paths: List[Path]
|
||||
message: str
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def process_archive(
|
||||
archive_path: Path,
|
||||
temp_dir: Path,
|
||||
ingest_dir: Path,
|
||||
archive_id: str,
|
||||
task: Optional["DownloadTask"] = None,
|
||||
) -> ArchiveResult:
|
||||
"""Extract archive, filter to supported formats, move to ingest directory."""
|
||||
extract_dir = temp_dir / f"extract_{archive_id}"
|
||||
content_type = task.content_type if task else None
|
||||
is_audiobook = check_audiobook(content_type)
|
||||
file_type_label = "audiobook" if is_audiobook else "book"
|
||||
|
||||
try:
|
||||
# Create temp extraction directory
|
||||
os.makedirs(extract_dir, exist_ok=True)
|
||||
os.makedirs(ingest_dir, exist_ok=True)
|
||||
|
||||
# Extract to temp directory (filters based on content type)
|
||||
extracted_files, warnings, rejected_files = extract_archive(archive_path, extract_dir, content_type)
|
||||
|
||||
if not extracted_files:
|
||||
# Clean up and return error
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
|
||||
if rejected_files:
|
||||
# Found files but they weren't in supported formats
|
||||
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
|
||||
rejected_list = ", ".join(rejected_exts)
|
||||
supported_formats = _get_supported_audiobook_formats() if is_audiobook else _get_supported_formats()
|
||||
logger.warning(
|
||||
f"Found {len(rejected_files)} {file_type_label}(s) in archive but format not supported. "
|
||||
f"Rejected: {rejected_list}. Supported: {', '.join(sorted(supported_formats))}"
|
||||
)
|
||||
return ArchiveResult(
|
||||
success=False,
|
||||
final_paths=[],
|
||||
message="",
|
||||
error=f"Found {len(rejected_files)} {file_type_label}(s) but format not supported ({rejected_list}). Enable in Settings > Formats.",
|
||||
)
|
||||
|
||||
return ArchiveResult(
|
||||
success=False,
|
||||
final_paths=[],
|
||||
message="",
|
||||
error=f"No {file_type_label} files found in archive",
|
||||
)
|
||||
|
||||
for warning in warnings:
|
||||
logger.debug(warning)
|
||||
|
||||
logger.info(f"Extracted {len(extracted_files)} {file_type_label} file(s) from archive")
|
||||
|
||||
# Move book files to ingest folder
|
||||
final_paths = []
|
||||
|
||||
# Determine file organization mode
|
||||
is_audiobook = check_audiobook(task.content_type) if task else False
|
||||
organization_mode = _get_file_organization(is_audiobook) if task else "none"
|
||||
|
||||
for extracted_file in extracted_files:
|
||||
# For multi-file archives (book packs, series), always preserve original filenames
|
||||
# since metadata title only applies to the searched book, not the whole pack.
|
||||
# For single files, respect FILE_ORGANIZATION setting.
|
||||
if len(extracted_files) == 1 and organization_mode != "none" and task:
|
||||
# Use the extracted file's actual extension, not the archive's extension
|
||||
extracted_format = extracted_file.suffix.lower().lstrip('.')
|
||||
filename = _build_filename_from_task(task, extracted_format, organization_mode)
|
||||
if not filename:
|
||||
filename = extracted_file.name
|
||||
else:
|
||||
filename = extracted_file.name
|
||||
|
||||
dest_path = ingest_dir / filename
|
||||
final_path = atomic_move(extracted_file, dest_path)
|
||||
final_paths.append(final_path)
|
||||
logger.debug(f"Moved to ingest: {final_path.name}")
|
||||
|
||||
# Clean up temp extraction directory and archive
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
|
||||
# Build success message with format info
|
||||
formats = [p.suffix.lstrip(".").upper() for p in final_paths]
|
||||
if len(formats) == 1:
|
||||
message = f"Complete ({formats[0]})"
|
||||
else:
|
||||
message = f"Complete ({len(formats)} files)"
|
||||
|
||||
return ArchiveResult(
|
||||
success=True,
|
||||
final_paths=final_paths,
|
||||
message=message,
|
||||
)
|
||||
|
||||
except PasswordProtectedError:
|
||||
logger.error(f"Password-protected archive: {archive_path.name}")
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
return ArchiveResult(
|
||||
success=False,
|
||||
final_paths=[],
|
||||
message="",
|
||||
error="Archive is password protected",
|
||||
)
|
||||
|
||||
except CorruptedArchiveError as e:
|
||||
logger.error(f"Corrupted archive: {e}")
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
return ArchiveResult(
|
||||
success=False,
|
||||
final_paths=[],
|
||||
message="",
|
||||
error=f"Corrupted archive: {e}",
|
||||
)
|
||||
|
||||
except ArchiveExtractionError as e:
|
||||
logger.error(f"Archive extraction failed: {e}")
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
archive_path.unlink(missing_ok=True)
|
||||
return ArchiveResult(
|
||||
success=False,
|
||||
final_paths=[],
|
||||
message="",
|
||||
error=f"Extraction failed: {e}",
|
||||
)
|
||||
|
||||
+143
-12
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Download client infrastructure for Prowlarr integration.
|
||||
Shared download client infrastructure for external release sources.
|
||||
|
||||
This module provides:
|
||||
- DownloadState: Enum of valid download states
|
||||
@@ -11,13 +11,89 @@ Clients register themselves via the @register_client decorator.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Tuple, Type, Union
|
||||
from functools import wraps
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union, cast, Any
|
||||
|
||||
import requests
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Type variable for generic return type
|
||||
T = TypeVar('T')
|
||||
|
||||
# Exceptions that should trigger a retry
|
||||
RETRYABLE_EXCEPTIONS = (
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.HTTPError,
|
||||
)
|
||||
|
||||
|
||||
def with_retry(
|
||||
max_attempts: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 10.0,
|
||||
jitter: float = 0.5,
|
||||
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
||||
"""
|
||||
Decorator for retrying API calls with exponential backoff.
|
||||
|
||||
Args:
|
||||
max_attempts: Maximum number of attempts (default 3)
|
||||
base_delay: Initial delay in seconds (default 1.0)
|
||||
max_delay: Maximum delay cap in seconds (default 10.0)
|
||||
jitter: Random jitter factor 0-1 to add to delay (default 0.5)
|
||||
|
||||
Retries on:
|
||||
- Connection errors
|
||||
- Timeouts
|
||||
- HTTP 5xx server errors
|
||||
|
||||
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:
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
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:
|
||||
raise
|
||||
last_exception = e
|
||||
except RETRYABLE_EXCEPTIONS as e:
|
||||
last_exception = e
|
||||
|
||||
if attempt < max_attempts:
|
||||
# Calculate delay with exponential backoff
|
||||
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
||||
# Add jitter to prevent thundering herd
|
||||
delay += random.uniform(0, delay * jitter)
|
||||
_logger.debug(
|
||||
f"Retry {attempt}/{max_attempts} for {func.__name__} "
|
||||
f"after {delay:.1f}s (error: {last_exception})"
|
||||
)
|
||||
time.sleep(delay)
|
||||
|
||||
# All retries exhausted
|
||||
if last_exception is None:
|
||||
raise RuntimeError("Retry failed without exception")
|
||||
raise cast(Exception, last_exception)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
class DownloadState(Enum):
|
||||
"""Valid states for a download."""
|
||||
@@ -97,6 +173,49 @@ class DownloadClient(ABC):
|
||||
protocol: str
|
||||
name: str
|
||||
|
||||
def _log_error(self, method: str, e: Exception, level: str = "error") -> str:
|
||||
"""
|
||||
Log a client error with consistent formatting.
|
||||
|
||||
Args:
|
||||
method: Name of the method that failed (e.g., "get_status")
|
||||
e: The exception that was raised
|
||||
level: Log level - "error" or "debug"
|
||||
|
||||
Returns:
|
||||
Formatted error message string (for use in DownloadStatus.error())
|
||||
"""
|
||||
error_type = type(e).__name__
|
||||
msg = f"{self.name} {method} failed ({error_type}): {e}"
|
||||
if level == "debug":
|
||||
_logger.debug(msg)
|
||||
else:
|
||||
_logger.error(msg)
|
||||
|
||||
# Reset connection state if client tracks it (e.g., Deluge)
|
||||
if hasattr(self, "_connected"):
|
||||
self._connected = False
|
||||
|
||||
return f"{error_type}: {e}"
|
||||
|
||||
def _build_path(self, *components: str) -> Optional[str]:
|
||||
"""
|
||||
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]
|
||||
if len(valid) != len(components):
|
||||
return None
|
||||
|
||||
# Join and normalize
|
||||
return os.path.normpath(os.path.join(*valid))
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
"""Validate that subclasses define required class attributes."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
@@ -139,14 +258,22 @@ class DownloadClient(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_download(self, url: str, name: str, category: str = "cwabd") -> str:
|
||||
"""
|
||||
Add a download to the client.
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
|
||||
"""Add a download to the client.
|
||||
|
||||
Args:
|
||||
url: Download URL (magnet link, .torrent URL, or NZB URL)
|
||||
name: Display name for the download
|
||||
category: Category/label for organization
|
||||
category: Category/label for organization (None = client default)
|
||||
expected_hash: Optional info_hash hint (torrents only)
|
||||
|
||||
Returns:
|
||||
Client-specific download ID (hash for torrents, ID for NZBGet).
|
||||
@@ -196,7 +323,9 @@ class DownloadClient(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
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.
|
||||
|
||||
@@ -205,6 +334,7 @@ class DownloadClient(ABC):
|
||||
|
||||
Args:
|
||||
url: Download URL (magnet link, .torrent URL, or NZB URL)
|
||||
category: Category to filter by (usenet clients only)
|
||||
|
||||
Returns:
|
||||
Tuple of (download_id, status) if found, None if not found.
|
||||
@@ -293,8 +423,9 @@ def get_all_clients() -> Dict[str, List[Type[DownloadClient]]]:
|
||||
|
||||
# Import client implementations to trigger registration
|
||||
# These imports are at the bottom to avoid circular imports
|
||||
from shelfmark.release_sources.prowlarr.clients import qbittorrent # noqa: F401, E402
|
||||
from shelfmark.release_sources.prowlarr.clients import nzbget # noqa: F401, E402
|
||||
from shelfmark.release_sources.prowlarr.clients import sabnzbd # noqa: F401, E402
|
||||
from shelfmark.release_sources.prowlarr.clients import transmission # noqa: F401, E402
|
||||
from shelfmark.release_sources.prowlarr.clients import deluge # noqa: F401, E402
|
||||
from shelfmark.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
|
||||
@@ -0,0 +1,753 @@
|
||||
"""Shared download handler for external torrent/usenet clients."""
|
||||
|
||||
import shutil
|
||||
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 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,
|
||||
get_client,
|
||||
list_configured_clients,
|
||||
)
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
from shelfmark.release_sources import DownloadHandler
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# How often to poll the download client for status (seconds)
|
||||
POLL_INTERVAL = 2
|
||||
# How long to wait for completed files to appear (seconds)
|
||||
COMPLETED_PATH_RETRY_INTERVAL = 5
|
||||
COMPLETED_PATH_MAX_ATTEMPTS = 12 # 12 attempts * 5s = 60s grace period
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadRequest:
|
||||
"""Source-specific download parameters resolved before sending to a client."""
|
||||
|
||||
url: str
|
||||
protocol: str
|
||||
release_name: str
|
||||
expected_hash: Optional[str]
|
||||
|
||||
|
||||
def _diagnose_path_issue(path: str) -> str:
|
||||
"""
|
||||
Analyze a path and return diagnostic hints for common issues.
|
||||
|
||||
Args:
|
||||
path: The path that failed to be accessed
|
||||
|
||||
Returns:
|
||||
A hint string to help users diagnose the issue.
|
||||
"""
|
||||
# Detect Windows-style paths (won't work in Linux containers)
|
||||
if len(path) >= 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. "
|
||||
f"Ensure your download client uses Linux-style paths (/path/to/files)."
|
||||
)
|
||||
|
||||
# Detect backslashes (Windows path separators)
|
||||
if "\\" in path:
|
||||
return (
|
||||
f"Path '{path}' contains backslashes. "
|
||||
f"This may indicate a Windows path or incorrect path escaping. "
|
||||
f"Linux paths should use forward slashes (/)."
|
||||
)
|
||||
|
||||
# Generic hint for Linux paths
|
||||
return (
|
||||
f"Path '{path}' is not accessible from Shelfmark's container. "
|
||||
f"Ensure both containers have matching volume mounts for this directory, "
|
||||
f"or configure Remote Path Mappings in Settings > Advanced."
|
||||
)
|
||||
|
||||
|
||||
class ExternalClientHandler(DownloadHandler, ABC):
|
||||
"""Shared lifecycle handler for sources that hand off to torrent/usenet clients."""
|
||||
|
||||
def __init__(self):
|
||||
# Track downloads that may need client-side cleanup after Shelfmark completes import.
|
||||
# task_id -> (client, download_id, protocol)
|
||||
self._cleanup_refs: dict[str, tuple[DownloadClient, str, str]] = {}
|
||||
|
||||
@abstractmethod
|
||||
def _resolve_download(
|
||||
self,
|
||||
task: DownloadTask,
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> Optional[DownloadRequest]:
|
||||
"""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]:
|
||||
"""Resolve the active client for a protocol."""
|
||||
return get_client(protocol)
|
||||
|
||||
def _list_configured_clients(self) -> list[str]:
|
||||
"""List protocols with configured clients."""
|
||||
return list_configured_clients()
|
||||
|
||||
def _poll_interval(self) -> float:
|
||||
"""Polling interval for status checks (seconds)."""
|
||||
return POLL_INTERVAL
|
||||
|
||||
def _completed_path_retry_interval(self) -> float:
|
||||
"""Retry interval while waiting for completed files (seconds)."""
|
||||
return COMPLETED_PATH_RETRY_INTERVAL
|
||||
|
||||
def _completed_path_max_attempts(self) -> int:
|
||||
"""Maximum attempts when waiting for completed files."""
|
||||
return COMPLETED_PATH_MAX_ATTEMPTS
|
||||
|
||||
def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> Optional[str]:
|
||||
"""Get audiobook category if configured and applicable, else None for default."""
|
||||
if not is_audiobook(task.content_type):
|
||||
return None
|
||||
|
||||
# Client-specific audiobook category config keys
|
||||
audiobook_keys = {
|
||||
"qbittorrent": "QBITTORRENT_CATEGORY_AUDIOBOOK",
|
||||
"transmission": "TRANSMISSION_CATEGORY_AUDIOBOOK",
|
||||
"deluge": "DELUGE_CATEGORY_AUDIOBOOK",
|
||||
"nzbget": "NZBGET_CATEGORY_AUDIOBOOK",
|
||||
"sabnzbd": "SABNZBD_CATEGORY_AUDIOBOOK",
|
||||
}
|
||||
audiobook_key = audiobook_keys.get(client.name)
|
||||
return config.get(audiobook_key, "") or None if audiobook_key else None
|
||||
|
||||
def post_process_cleanup(self, task: DownloadTask, success: bool) -> None:
|
||||
if not success:
|
||||
self._cleanup_refs.pop(task.task_id, None)
|
||||
return
|
||||
|
||||
client_ref = self._cleanup_refs.pop(task.task_id, None)
|
||||
if client_ref is None:
|
||||
return
|
||||
|
||||
client, download_id, protocol = client_ref
|
||||
if protocol != "usenet":
|
||||
return
|
||||
|
||||
# "Move" means copy into ingest then let the usenet client delete its own files.
|
||||
if config.get("PROWLARR_USENET_ACTION", "move") != "move":
|
||||
return
|
||||
|
||||
try:
|
||||
self._delete_local_download_data(client, download_id)
|
||||
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}"
|
||||
)
|
||||
|
||||
def _remove_usenet_download(
|
||||
self,
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
*,
|
||||
delete_files: bool,
|
||||
archive: bool = True,
|
||||
) -> None:
|
||||
"""Remove a usenet download with SABnzbd-specific archive handling."""
|
||||
if getattr(client, "name", "") == "sabnzbd":
|
||||
client.remove(download_id, delete_files=delete_files, archive=archive)
|
||||
else:
|
||||
client.remove(download_id, delete_files=delete_files)
|
||||
|
||||
def _delete_local_download_data(self, client: DownloadClient, download_id: str) -> None:
|
||||
"""Best-effort local deletion of client download data."""
|
||||
try:
|
||||
raw_path = client.get_download_path(download_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to resolve download path for {client.name} {download_id}: {e}")
|
||||
return
|
||||
|
||||
if not raw_path:
|
||||
logger.debug(f"No download path available for {client.name} {download_id}")
|
||||
return
|
||||
|
||||
from shelfmark.core.path_mappings import (
|
||||
get_client_host_identifier,
|
||||
parse_remote_path_mappings,
|
||||
remap_remote_to_local_with_match,
|
||||
)
|
||||
|
||||
source_path_obj = Path(raw_path)
|
||||
host = get_client_host_identifier(client) or ""
|
||||
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
|
||||
mappings = parse_remote_path_mappings(mapping_value)
|
||||
remapped, matched_mapping = remap_remote_to_local_with_match(
|
||||
mappings=mappings,
|
||||
host=host,
|
||||
remote_path=source_path_obj,
|
||||
)
|
||||
|
||||
delete_path = remapped if matched_mapping else source_path_obj
|
||||
|
||||
if str(delete_path) in ("", "/"):
|
||||
logger.warning(f"Refusing to delete unsafe path for {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}")
|
||||
return
|
||||
|
||||
try:
|
||||
if run_blocking_io(delete_path.is_dir):
|
||||
run_blocking_io(shutil.rmtree, delete_path)
|
||||
else:
|
||||
run_blocking_io(delete_path.unlink)
|
||||
logger.info(f"Deleted local download data for {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}")
|
||||
|
||||
def _safe_remove_download(self, client, download_id: str, protocol: str, reason: str) -> None:
|
||||
"""Best-effort removal of a failed/cancelled download from the client.
|
||||
|
||||
Safety policy:
|
||||
- torrents: never remove or delete client data (avoid breaking seeding)
|
||||
- usenet: keep legacy behavior (delete client files on removal)
|
||||
"""
|
||||
|
||||
if protocol != "usenet":
|
||||
logger.info(
|
||||
"Skipping download client cleanup for protocol=%s after %s (client=%s id=%s)",
|
||||
protocol,
|
||||
reason,
|
||||
getattr(client, "name", "client"),
|
||||
download_id,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Permanent delete for failed usenet downloads (SABnzbd archive=0).
|
||||
self._delete_local_download_data(client, download_id)
|
||||
self._remove_usenet_download(client, download_id, delete_files=True, archive=False)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to remove download {download_id} from {client.name} after {reason}: {e}"
|
||||
)
|
||||
|
||||
def _handle_cancelled_download(
|
||||
self,
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
protocol: str,
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> None:
|
||||
if protocol == "usenet":
|
||||
logger.info(f"Download cancelled, removing from {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}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Download cancelled for protocol={protocol}; leaving in {client.name}: {download_id}"
|
||||
)
|
||||
status_callback("cancelled", "Cancelled")
|
||||
|
||||
def _resolve_download_path_once(
|
||||
self,
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
*,
|
||||
log_details: bool,
|
||||
) -> tuple[Optional[Path], Optional[str]]:
|
||||
"""Resolve and validate the completed download path once."""
|
||||
try:
|
||||
raw_path = client.get_download_path(download_id)
|
||||
except Exception as e:
|
||||
message = (
|
||||
f"Could not locate completed download in {client.name} (path not returned). "
|
||||
f"Check volume mappings and category settings."
|
||||
)
|
||||
if log_details:
|
||||
logger.error(
|
||||
f"Failed to resolve download path for {client.name} {download_id}: {e}"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Failed to resolve download path for {client.name} {download_id}: {e}"
|
||||
)
|
||||
return None, message
|
||||
|
||||
if not raw_path:
|
||||
message = (
|
||||
f"Could not locate completed download in {client.name} (path not returned). "
|
||||
f"Check volume mappings and category settings."
|
||||
)
|
||||
if log_details:
|
||||
logger.error(f"Download client returned empty path for {client.name} {download_id}")
|
||||
else:
|
||||
logger.debug(f"Download client returned empty path for {client.name} {download_id}")
|
||||
return None, message
|
||||
|
||||
from shelfmark.core.path_mappings import (
|
||||
get_client_host_identifier,
|
||||
parse_remote_path_mappings,
|
||||
remap_remote_to_local_with_match,
|
||||
)
|
||||
|
||||
source_path_obj = Path(raw_path)
|
||||
host = get_client_host_identifier(client) or ""
|
||||
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
|
||||
mappings = parse_remote_path_mappings(mapping_value)
|
||||
|
||||
if log_details:
|
||||
logger.debug(
|
||||
"Attempting path remap: client=%s, host=%s, path=%s, mappings=%s",
|
||||
client.name,
|
||||
host,
|
||||
source_path_obj,
|
||||
[(m.host, m.remote_path, m.local_path) for m in mappings],
|
||||
)
|
||||
|
||||
remapped, matched_mapping = remap_remote_to_local_with_match(
|
||||
mappings=mappings,
|
||||
host=host,
|
||||
remote_path=source_path_obj,
|
||||
)
|
||||
|
||||
if log_details:
|
||||
remapped_exists = run_blocking_io(remapped.exists)
|
||||
logger.debug(
|
||||
"Remap result: %s -> %s (exists=%s, changed=%s, matched=%s)",
|
||||
source_path_obj,
|
||||
remapped,
|
||||
remapped_exists,
|
||||
remapped != source_path_obj,
|
||||
matched_mapping,
|
||||
)
|
||||
|
||||
if matched_mapping:
|
||||
if run_blocking_io(remapped.exists):
|
||||
logger.info(
|
||||
"Remapped download path for %s (%s): %s -> %s",
|
||||
client.name,
|
||||
download_id,
|
||||
source_path_obj,
|
||||
remapped,
|
||||
)
|
||||
return remapped, None
|
||||
|
||||
message = (
|
||||
f"Remapped path '{remapped}' does not exist. "
|
||||
f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings."
|
||||
)
|
||||
if log_details:
|
||||
logger.error(
|
||||
f"Download path does not exist after remapping: {raw_path} -> {remapped}. "
|
||||
f"Client: {client.name}, ID: {download_id}."
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Download path does not exist after remapping: {raw_path} -> {remapped}. "
|
||||
f"Client: {client.name}, ID: {download_id}."
|
||||
)
|
||||
return None, message
|
||||
|
||||
if mappings:
|
||||
if run_blocking_io(source_path_obj.exists):
|
||||
logger.info(
|
||||
"No remote path mapping matched for %s (%s); using client path: %s",
|
||||
client.name,
|
||||
download_id,
|
||||
source_path_obj,
|
||||
)
|
||||
return source_path_obj, None
|
||||
|
||||
hint = _diagnose_path_issue(raw_path)
|
||||
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}"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Download path does not exist and no remote path mapping matched for {client.name} "
|
||||
f"({download_id}): {raw_path}. {hint}"
|
||||
)
|
||||
return None, message
|
||||
|
||||
if not run_blocking_io(source_path_obj.exists):
|
||||
hint = _diagnose_path_issue(raw_path)
|
||||
message = hint
|
||||
if log_details:
|
||||
logger.error(
|
||||
f"Download path does not exist: {raw_path}. "
|
||||
f"Client: {client.name}, ID: {download_id}. {hint}"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Download path does not exist: {raw_path}. "
|
||||
f"Client: {client.name}, ID: {download_id}. {hint}"
|
||||
)
|
||||
return None, message
|
||||
|
||||
return source_path_obj, None
|
||||
|
||||
def _wait_for_completed_path(
|
||||
self,
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
*,
|
||||
cancel_flag: Optional[Event],
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> tuple[Optional[Path], Optional[str]]:
|
||||
"""Wait briefly for completed files to appear on disk."""
|
||||
last_error: Optional[str] = None
|
||||
max_attempts = self._completed_path_max_attempts()
|
||||
retry_interval = self._completed_path_retry_interval()
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
return None, last_error
|
||||
|
||||
log_details = attempt == max_attempts
|
||||
resolved_path, error = self._resolve_download_path_once(
|
||||
client,
|
||||
download_id,
|
||||
log_details=log_details,
|
||||
)
|
||||
if resolved_path:
|
||||
return resolved_path, None
|
||||
|
||||
last_error = error
|
||||
|
||||
if attempt < max_attempts:
|
||||
status_callback("locating", "Waiting for completed files...")
|
||||
logger.debug(
|
||||
"Completed files not available yet for %s (%s) (attempt %d/%d)",
|
||||
client.name,
|
||||
download_id,
|
||||
attempt,
|
||||
max_attempts,
|
||||
)
|
||||
|
||||
if cancel_flag:
|
||||
if cancel_flag.wait(timeout=retry_interval):
|
||||
return None, last_error
|
||||
else:
|
||||
time.sleep(retry_interval)
|
||||
|
||||
return None, last_error
|
||||
|
||||
def _build_progress_message(self, status) -> str:
|
||||
"""Build a progress message from download status."""
|
||||
msg = f"{status.progress:.0f}%"
|
||||
|
||||
if status.download_speed and status.download_speed > 0:
|
||||
speed_mb = status.download_speed / 1024 / 1024
|
||||
msg += f" ({speed_mb:.1f} MB/s)"
|
||||
|
||||
if status.eta and status.eta > 0:
|
||||
if status.eta < 60:
|
||||
msg += f" - {status.eta}s left"
|
||||
elif status.eta < 3600:
|
||||
msg += f" - {status.eta // 60}m left"
|
||||
else:
|
||||
msg += f" - {status.eta // 3600}h {(status.eta % 3600) // 60}m left"
|
||||
|
||||
return msg
|
||||
|
||||
def download(
|
||||
self,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> Optional[str]:
|
||||
"""Execute download via configured torrent/usenet client. Returns file path or None."""
|
||||
try:
|
||||
if cancel_flag.is_set():
|
||||
status_callback("cancelled", "Cancelled")
|
||||
return None
|
||||
|
||||
request = self._resolve_download(task, status_callback)
|
||||
if not request:
|
||||
return None
|
||||
|
||||
client = self._get_client(request.protocol)
|
||||
if not client:
|
||||
configured = self._list_configured_clients()
|
||||
if not configured:
|
||||
status_callback(
|
||||
"error",
|
||||
"No download clients configured. Configure qBittorrent or NZBGet in settings.",
|
||||
)
|
||||
else:
|
||||
status_callback("error", f"No {request.protocol} client configured")
|
||||
return None
|
||||
|
||||
# Check if this download already exists in the client
|
||||
status_callback("resolving", f"Checking {client.name}")
|
||||
category = self._get_category_for_task(client, task)
|
||||
existing = client.find_existing(request.url, category=category)
|
||||
|
||||
if existing:
|
||||
download_id, existing_status = existing
|
||||
logger.info(f"Found existing download in {client.name}: {download_id}")
|
||||
|
||||
# If already complete, skip straight to file handling
|
||||
if existing_status.complete:
|
||||
logger.info("Existing download is complete, copying file directly")
|
||||
status_callback("resolving", "Found existing download, copying to library")
|
||||
|
||||
source_path_obj, path_error = self._wait_for_completed_path(
|
||||
client=client,
|
||||
download_id=download_id,
|
||||
cancel_flag=cancel_flag,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
if not source_path_obj:
|
||||
if cancel_flag.is_set():
|
||||
return None
|
||||
status_callback(
|
||||
"error",
|
||||
path_error
|
||||
or f"Could not locate existing download in {client.name}. Check that the file still exists.",
|
||||
)
|
||||
return None
|
||||
|
||||
result = self._handle_completed_file(
|
||||
source_path=source_path_obj,
|
||||
protocol=request.protocol,
|
||||
task=task,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
|
||||
if result:
|
||||
self._on_download_complete(task)
|
||||
self._cleanup_refs[task.task_id] = (client, download_id, request.protocol)
|
||||
return result
|
||||
|
||||
# Existing but still downloading - join the progress polling
|
||||
logger.info("Existing download in progress, joining poll loop")
|
||||
status_callback("downloading", "Resuming existing download")
|
||||
else:
|
||||
# No existing download - add new
|
||||
status_callback("resolving", f"Sending to {client.name}")
|
||||
try:
|
||||
download_id = client.add_download(
|
||||
url=request.url,
|
||||
name=request.release_name,
|
||||
category=category,
|
||||
expected_hash=request.expected_hash,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add to {client.name}: {e}")
|
||||
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}'")
|
||||
|
||||
# Poll for progress
|
||||
return self._poll_and_complete(
|
||||
client=client,
|
||||
download_id=download_id,
|
||||
protocol=request.protocol,
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=progress_callback,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"External client download error: {e}")
|
||||
status_callback("error", str(e))
|
||||
return None
|
||||
|
||||
def _poll_and_complete(
|
||||
self,
|
||||
client: DownloadClient,
|
||||
download_id: str,
|
||||
protocol: str,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
progress_callback: Callable[[float], None],
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> Optional[str]:
|
||||
"""Poll the download client for progress and handle completion."""
|
||||
poll_interval = self._poll_interval()
|
||||
# Track consecutive "not found" errors - torrents may take time to appear in client
|
||||
not_found_count = 0
|
||||
max_not_found_retries = 15 # 15 retries * poll interval ~= 30s grace period
|
||||
|
||||
try:
|
||||
logger.debug(f"Starting poll for {download_id} (content_type={task.content_type})")
|
||||
while not cancel_flag.is_set():
|
||||
status = client.get_status(download_id)
|
||||
progress_callback(status.progress)
|
||||
|
||||
# Check for completion
|
||||
if status.complete:
|
||||
if status.state == DownloadState.ERROR:
|
||||
logger.error(f"Download {download_id} completed with error: {status.message}")
|
||||
status_callback("error", status.message or "Download failed")
|
||||
self._safe_remove_download(client, download_id, protocol, "completion error")
|
||||
return None
|
||||
# Download complete - break to handle file
|
||||
logger.debug(f"Download {download_id} complete, file_path={status.file_path}")
|
||||
break
|
||||
|
||||
# Check for error state
|
||||
if status.state == DownloadState.ERROR:
|
||||
message = (status.message or "").strip()
|
||||
message_lower = message.lower()
|
||||
|
||||
# Only treat *actual* "not found" as retryable.
|
||||
# qBittorrent auth/network/API failures should surface immediately (more actionable)
|
||||
# and must not be confused with "torrent missing".
|
||||
retryable_not_found = any(
|
||||
token in message_lower
|
||||
for token in (
|
||||
"torrent not found",
|
||||
"not found in qbittorrent",
|
||||
"download not found",
|
||||
)
|
||||
)
|
||||
|
||||
non_retryable = any(
|
||||
token in message_lower
|
||||
for token in (
|
||||
"authentication failed",
|
||||
"cannot connect",
|
||||
"timed out",
|
||||
"api request failed",
|
||||
)
|
||||
)
|
||||
|
||||
if retryable_not_found and not non_retryable:
|
||||
not_found_count += 1
|
||||
if not_found_count < max_not_found_retries:
|
||||
logger.debug(
|
||||
f"Download {download_id} not yet visible in client "
|
||||
f"(attempt {not_found_count}/{max_not_found_retries})"
|
||||
)
|
||||
status_callback("resolving", "Waiting for download client...")
|
||||
if cancel_flag.wait(timeout=poll_interval):
|
||||
break
|
||||
continue
|
||||
|
||||
logger.error(
|
||||
f"Download {download_id} not found after {max_not_found_retries} attempts"
|
||||
)
|
||||
else:
|
||||
# Fail fast on actionable errors (auth, connectivity, API issues)
|
||||
logger.error(f"Download {download_id} error state: {status.message}")
|
||||
|
||||
status_callback("error", status.message or "Download failed")
|
||||
self._safe_remove_download(client, download_id, protocol, "download error")
|
||||
return None
|
||||
|
||||
# Reset not-found counter on successful status check
|
||||
not_found_count = 0
|
||||
|
||||
# Build status message - use client message if provided, else build progress
|
||||
msg = status.message or self._build_progress_message(status)
|
||||
if status.state == DownloadState.PROCESSING:
|
||||
# Post-processing (e.g., SABnzbd verifying/extracting)
|
||||
status_callback("resolving", msg)
|
||||
else:
|
||||
status_callback("downloading", msg)
|
||||
|
||||
# Wait for next poll (interruptible by cancel)
|
||||
if cancel_flag.wait(timeout=poll_interval):
|
||||
break
|
||||
|
||||
# Handle cancellation
|
||||
if cancel_flag.is_set():
|
||||
self._handle_cancelled_download(client, download_id, protocol, status_callback)
|
||||
return None
|
||||
|
||||
# Handle completed file (wait briefly for files to appear)
|
||||
source_path_obj, path_error = self._wait_for_completed_path(
|
||||
client=client,
|
||||
download_id=download_id,
|
||||
cancel_flag=cancel_flag,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
if not source_path_obj:
|
||||
if cancel_flag.is_set():
|
||||
self._handle_cancelled_download(client, download_id, protocol, status_callback)
|
||||
return None
|
||||
status_callback(
|
||||
"error",
|
||||
path_error
|
||||
or f"Could not locate completed download in {client.name} (path not returned). Check volume mappings and category settings.",
|
||||
)
|
||||
return None
|
||||
|
||||
result = self._handle_completed_file(
|
||||
source_path=source_path_obj,
|
||||
protocol=protocol,
|
||||
task=task,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
|
||||
# Clean up on success
|
||||
if result:
|
||||
self._on_download_complete(task)
|
||||
self._cleanup_refs[task.task_id] = (client, download_id, protocol)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during download polling: {e}")
|
||||
status_callback("error", str(e))
|
||||
self._safe_remove_download(client, download_id, protocol, "polling exception")
|
||||
return None
|
||||
|
||||
def _handle_completed_file(
|
||||
self,
|
||||
source_path: Path,
|
||||
protocol: str,
|
||||
task: DownloadTask,
|
||||
status_callback: Callable[[str, Optional[str]], None],
|
||||
) -> Optional[str]:
|
||||
"""Handle a completed download and return its path.
|
||||
|
||||
For external download clients (torrents/usenet), staging large payloads into TMP_DIR
|
||||
is expensive (and can duplicate multi-GB files). Instead, return the client's
|
||||
completed path and let the orchestrator perform any required transfer (copy/move/
|
||||
hardlink) directly from that source.
|
||||
|
||||
Torrents also set ``task.original_download_path`` so the orchestrator can detect
|
||||
seeding data and enable hardlinking when configured.
|
||||
"""
|
||||
try:
|
||||
if protocol == "torrent":
|
||||
task.original_download_path = str(source_path)
|
||||
|
||||
logger.debug(f"Download complete, returning original path: {source_path}")
|
||||
return str(source_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to finalize completed download at {source_path}: {e}")
|
||||
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}")
|
||||
return True
|
||||
@@ -0,0 +1,401 @@
|
||||
"""Deluge download client for Prowlarr integration.
|
||||
|
||||
This implementation talks to Deluge via the Web UI JSON-RPC API (``/json``).
|
||||
|
||||
Why Web UI API instead of daemon RPC (port 58846)?
|
||||
- Matches the approach used by common automation apps
|
||||
- Avoids requiring Deluge daemon ``auth`` file credentials (username/password)
|
||||
|
||||
Requirements:
|
||||
- ``deluge-web`` must be enabled and reachable from Shelfmark
|
||||
- Deluge Web UI must be connected (or connectable) to a Deluge daemon
|
||||
"""
|
||||
|
||||
import base64
|
||||
from typing import Any, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
class DelugeRpcError(RuntimeError):
|
||||
def __init__(self, message: str, code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _get_error_message(error: Any) -> Tuple[str, int | None]:
|
||||
if isinstance(error, dict):
|
||||
return str(error.get("message") or error), error.get("code")
|
||||
return str(error), None
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class DelugeClient(DownloadClient):
|
||||
"""Deluge download client using Deluge Web UI JSON-RPC."""
|
||||
|
||||
protocol = "torrent"
|
||||
name = "deluge"
|
||||
|
||||
def __init__(self):
|
||||
raw_host = str(config.get("DELUGE_HOST", "localhost") or "")
|
||||
raw_port = str(config.get("DELUGE_PORT", "8112") or "8112")
|
||||
password = str(config.get("DELUGE_PASSWORD", "") or "")
|
||||
|
||||
if not raw_host:
|
||||
raise ValueError("DELUGE_HOST is required")
|
||||
if not password:
|
||||
raise ValueError("DELUGE_PASSWORD is required")
|
||||
|
||||
scheme = "http"
|
||||
base_path = ""
|
||||
|
||||
# Allow DELUGE_HOST to be either a hostname OR a full URL
|
||||
# (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")
|
||||
|
||||
host = raw_host
|
||||
port = int(raw_port)
|
||||
|
||||
if raw_host.startswith(("http://", "https://")):
|
||||
parsed = urlparse(raw_host)
|
||||
scheme = parsed.scheme or "http"
|
||||
host = parsed.hostname or "localhost"
|
||||
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)
|
||||
|
||||
self._rpc_url = f"{scheme}://{host}:{port}{base_path}/json"
|
||||
self._password = password
|
||||
self._session = requests.Session()
|
||||
|
||||
self._authenticated = False
|
||||
self._connected = False
|
||||
self._rpc_id = 0
|
||||
|
||||
self._category = str(config.get("DELUGE_CATEGORY", "books") or "books")
|
||||
self._download_dir = str(config.get("DELUGE_DOWNLOAD_DIR", "") or "")
|
||||
|
||||
def _next_rpc_id(self) -> int:
|
||||
self._rpc_id += 1
|
||||
return self._rpc_id
|
||||
|
||||
def _rpc_call(self, method: str, *params: Any, timeout: int = 15) -> Any:
|
||||
payload = {
|
||||
"id": self._next_rpc_id(),
|
||||
"method": method,
|
||||
"params": list(params),
|
||||
}
|
||||
|
||||
response = self._session.post(self._rpc_url, json=payload, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
if data.get("error"):
|
||||
message, code = _get_error_message(data["error"])
|
||||
raise DelugeRpcError(message, code)
|
||||
|
||||
return data.get("result")
|
||||
|
||||
def _login(self) -> None:
|
||||
result = self._rpc_call("auth.login", self._password)
|
||||
if result is not True:
|
||||
raise DelugeRpcError("Deluge Web UI authentication failed")
|
||||
self._authenticated = True
|
||||
|
||||
def _select_daemon_host_id(self, hosts: list) -> str:
|
||||
# Hosts returned by web.get_hosts look like:
|
||||
# [[host_id, host, port, status], ...]
|
||||
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:
|
||||
return str(entry[0])
|
||||
|
||||
for entry in hosts:
|
||||
if isinstance(entry, list) and len(entry) >= 4 and str(entry[3]).lower() == "online":
|
||||
return str(entry[0])
|
||||
|
||||
return str(hosts[0][0])
|
||||
|
||||
def _ensure_connected(self) -> None:
|
||||
if not self._authenticated:
|
||||
self._login()
|
||||
|
||||
if self._connected:
|
||||
return
|
||||
|
||||
if self._rpc_call("web.connected") is True:
|
||||
self._connected = True
|
||||
return
|
||||
|
||||
hosts = self._rpc_call("web.get_hosts") or []
|
||||
if not hosts:
|
||||
raise DelugeRpcError(
|
||||
"Deluge Web UI isn't connected to Deluge core (no hosts configured). "
|
||||
"Add/connect a daemon in Deluge Web UI → Connection Manager."
|
||||
)
|
||||
|
||||
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(
|
||||
"Deluge Web UI couldn't connect to Deluge core. "
|
||||
"Check daemon status in Deluge Web UI → Connection Manager."
|
||||
)
|
||||
|
||||
self._connected = True
|
||||
|
||||
def _get_daemon_version(self) -> Any:
|
||||
"""Fetch daemon version, preferring daemon.get_version when available."""
|
||||
try:
|
||||
methods = self._rpc_call("system.listMethods")
|
||||
if isinstance(methods, list) and "daemon.get_version" in methods:
|
||||
return self._rpc_call("daemon.get_version")
|
||||
except Exception:
|
||||
# Fall back to daemon.info to preserve existing behavior.
|
||||
pass
|
||||
|
||||
return self._rpc_call("daemon.info")
|
||||
|
||||
def _try_set_label(self, torrent_id: str, label: str) -> None:
|
||||
"""Best-effort label assignment (requires Deluge Label plugin)."""
|
||||
if not label:
|
||||
return
|
||||
|
||||
try:
|
||||
# label.add will error if the plugin is unavailable or the label exists.
|
||||
try:
|
||||
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}")
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
client = config.get("PROWLARR_TORRENT_CLIENT", "")
|
||||
host = config.get("DELUGE_HOST", "")
|
||||
password = config.get("DELUGE_PASSWORD", "")
|
||||
return client == "deluge" and bool(host) and bool(password)
|
||||
|
||||
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)}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
category_value = str(category or self._category)
|
||||
|
||||
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")
|
||||
|
||||
options: dict[str, Any] = {}
|
||||
if self._download_dir:
|
||||
options["download_location"] = self._download_dir
|
||||
|
||||
if torrent_info.is_magnet:
|
||||
magnet_url = torrent_info.magnet_url or url
|
||||
torrent_id = self._rpc_call("core.add_torrent_magnet", magnet_url, options)
|
||||
else:
|
||||
torrent_data = torrent_info.torrent_data
|
||||
if torrent_data is None:
|
||||
raise Exception("Failed to fetch torrent file")
|
||||
|
||||
torrent_data_bytes: bytes = torrent_data
|
||||
filedump = base64.b64encode(torrent_data_bytes).decode("ascii")
|
||||
torrent_id = self._rpc_call(
|
||||
"core.add_torrent_file",
|
||||
f"{name}.torrent",
|
||||
filedump,
|
||||
options,
|
||||
)
|
||||
|
||||
if not torrent_id:
|
||||
raise Exception("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
|
||||
|
||||
except Exception as e:
|
||||
self._authenticated = False
|
||||
self._connected = False
|
||||
logger.error(f"Deluge add failed: {e}")
|
||||
raise
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
status = self._rpc_call(
|
||||
"core.get_torrent_status",
|
||||
download_id,
|
||||
["state", "progress", "download_payload_rate", "eta", "save_path", "name"],
|
||||
)
|
||||
|
||||
if not status:
|
||||
return DownloadStatus.error("Torrent not found")
|
||||
|
||||
# Deluge states: Downloading, Seeding, Paused, Checking, Queued, Error, Moving
|
||||
state_map = {
|
||||
"Downloading": ("downloading", None),
|
||||
"Seeding": ("seeding", "Seeding"),
|
||||
"Paused": ("paused", "Paused"),
|
||||
"Checking": ("checking", "Checking files"),
|
||||
"Queued": ("queued", "Queued"),
|
||||
"Error": ("error", "Error"),
|
||||
"Moving": ("processing", "Moving files"),
|
||||
"Allocating": ("downloading", "Allocating space"),
|
||||
}
|
||||
|
||||
deluge_state = status.get("state", "Unknown")
|
||||
state, message = state_map.get(str(deluge_state), ("unknown", str(deluge_state)))
|
||||
|
||||
progress = float(status.get("progress", 0))
|
||||
# Don't mark complete while files are being moved
|
||||
complete = progress >= 100 and deluge_state != "Moving"
|
||||
|
||||
if complete:
|
||||
message = "Complete"
|
||||
|
||||
eta = status.get("eta")
|
||||
if eta is not None:
|
||||
try:
|
||||
eta = int(eta)
|
||||
except Exception:
|
||||
eta = None
|
||||
|
||||
if eta is not None and (eta < 0 or eta > 604800):
|
||||
eta = None
|
||||
|
||||
file_path = None
|
||||
if complete:
|
||||
# Output path is save_path + torrent name
|
||||
file_path = self._build_path(
|
||||
str(status.get("save_path", "")),
|
||||
str(status.get("name", "")),
|
||||
)
|
||||
|
||||
return DownloadStatus(
|
||||
progress=progress,
|
||||
state="complete" if complete else state,
|
||||
message=message,
|
||||
complete=complete,
|
||||
file_path=file_path,
|
||||
download_speed=status.get("download_payload_rate"),
|
||||
eta=eta,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return DownloadStatus.error(self._log_error("get_status", e))
|
||||
|
||||
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 "")
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self._log_error("remove", e)
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
status = self._rpc_call(
|
||||
"core.get_torrent_status",
|
||||
download_id,
|
||||
["save_path", "name"],
|
||||
)
|
||||
|
||||
if status:
|
||||
return self._build_path(
|
||||
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
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
torrent_info = extract_torrent_info(url)
|
||||
if not torrent_info.info_hash:
|
||||
return None
|
||||
|
||||
status = self._rpc_call(
|
||||
"core.get_torrent_status",
|
||||
torrent_info.info_hash,
|
||||
["state"],
|
||||
)
|
||||
|
||||
if status:
|
||||
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}")
|
||||
return None
|
||||
+65
-27
@@ -11,10 +11,12 @@ import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.release_sources.prowlarr.clients import (
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
with_retry,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -29,11 +31,13 @@ class NZBGetClient(DownloadClient):
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize NZBGet client with settings from config."""
|
||||
url = config.get("NZBGET_URL", "")
|
||||
if not url:
|
||||
raw_url = config.get("NZBGET_URL", "")
|
||||
if not raw_url:
|
||||
raise ValueError("NZBGET_URL is required")
|
||||
|
||||
self.url = url.rstrip("/")
|
||||
self.url = normalize_http_url(raw_url)
|
||||
if not self.url:
|
||||
raise ValueError("NZBGET_URL is invalid")
|
||||
self.username = config.get("NZBGET_USERNAME", "nzbget")
|
||||
self.password = config.get("NZBGET_PASSWORD", "")
|
||||
self._category = config.get("NZBGET_CATEGORY", "Books")
|
||||
@@ -42,10 +46,11 @@ class NZBGetClient(DownloadClient):
|
||||
def is_configured() -> bool:
|
||||
"""Check if NZBGet is configured and selected as the usenet client."""
|
||||
client = config.get("PROWLARR_USENET_CLIENT", "")
|
||||
url = config.get("NZBGET_URL", "")
|
||||
url = normalize_http_url(config.get("NZBGET_URL", ""))
|
||||
return client == "nzbget" and bool(url)
|
||||
|
||||
def _rpc_call(self, method: str, params: list = None) -> Any:
|
||||
@with_retry()
|
||||
def _rpc_call(self, method: str, params: Optional[list] = None) -> Any:
|
||||
"""
|
||||
Make a JSON-RPC call to NZBGet.
|
||||
|
||||
@@ -57,7 +62,7 @@ class NZBGetClient(DownloadClient):
|
||||
Result from NZBGet.
|
||||
|
||||
Raises:
|
||||
Exception: If RPC call fails.
|
||||
Exception: If RPC call fails after retries.
|
||||
"""
|
||||
rpc_url = f"{self.url}/jsonrpc"
|
||||
|
||||
@@ -96,7 +101,14 @@ class NZBGetClient(DownloadClient):
|
||||
except Exception as e:
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
|
||||
def add_download(self, url: str, name: str, category: str = None) -> str:
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Add NZB by URL.
|
||||
|
||||
@@ -107,6 +119,7 @@ class NZBGetClient(DownloadClient):
|
||||
url: NZB URL (can be Prowlarr proxy URL)
|
||||
name: Display name for the download
|
||||
category: Category for organization (uses configured default if not specified)
|
||||
expected_hash: Optional info_hash hint (unused)
|
||||
|
||||
Returns:
|
||||
NZBGet download ID (NZBID).
|
||||
@@ -226,7 +239,19 @@ class NZBGetClient(DownloadClient):
|
||||
for item in history:
|
||||
if item.get("NZBID") == nzb_id:
|
||||
status = item.get("Status", "")
|
||||
dest_dir = item.get("DestDir", "")
|
||||
# Prefer FinalDir (post-processing result) over DestDir (original)
|
||||
final_dir = item.get("FinalDir", "") or None
|
||||
dest_dir = item.get("DestDir", "") or None
|
||||
file_path = final_dir or dest_dir # Use FinalDir if available
|
||||
|
||||
# Normalize for consistent downstream use.
|
||||
if isinstance(file_path, str) and file_path:
|
||||
import os
|
||||
|
||||
file_path = os.path.normpath(file_path)
|
||||
else:
|
||||
file_path = None
|
||||
|
||||
|
||||
if "SUCCESS" in status:
|
||||
return DownloadStatus(
|
||||
@@ -234,7 +259,7 @@ class NZBGetClient(DownloadClient):
|
||||
state="complete",
|
||||
message="Complete",
|
||||
complete=True,
|
||||
file_path=dest_dir,
|
||||
file_path=file_path,
|
||||
)
|
||||
else:
|
||||
return DownloadStatus(
|
||||
@@ -242,41 +267,54 @@ class NZBGetClient(DownloadClient):
|
||||
state="error",
|
||||
message=f"Download failed: {status}",
|
||||
complete=True,
|
||||
file_path=None,
|
||||
file_path=file_path,
|
||||
)
|
||||
|
||||
# Not found in queue or history
|
||||
return DownloadStatus.error("Download not found")
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
logger.error(f"NZBGet get_status failed ({error_type}): {e}")
|
||||
return DownloadStatus.error(f"{error_type}: {e}")
|
||||
return DownloadStatus.error(self._log_error("get_status", e))
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
"""
|
||||
Remove a download from NZBGet.
|
||||
"""Remove a download from NZBGet.
|
||||
|
||||
NZBGet can remove items from either the active queue (Group* commands) or from
|
||||
history (History* commands). Completed downloads are typically in history.
|
||||
|
||||
Args:
|
||||
download_id: NZBGet NZBID
|
||||
delete_files: Whether to permanently delete (vs move to history)
|
||||
delete_files: Whether to permanently delete downloaded files
|
||||
|
||||
Returns:
|
||||
True if successful.
|
||||
"""
|
||||
try:
|
||||
nzb_id = int(download_id)
|
||||
# editqueue params: Command (str), Param (str), IDs (int[])
|
||||
# GroupFinalDelete = permanent removal, GroupDelete = move to history
|
||||
command = "GroupFinalDelete" if delete_files else "GroupDelete"
|
||||
result = self._rpc_call("editqueue", [command, "", [nzb_id]])
|
||||
if result:
|
||||
logger.info(f"Removed NZB from NZBGet: {download_id}")
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
logger.error(f"NZBGet remove failed ({error_type}): {e}")
|
||||
except (TypeError, ValueError) as e:
|
||||
self._log_error("remove", e)
|
||||
return False
|
||||
|
||||
if delete_files:
|
||||
# Keep HistoryDelete as a fallback for
|
||||
# older NZBGet versions where HistoryFinalDelete may not exist.
|
||||
commands = ["GroupFinalDelete", "HistoryFinalDelete", "HistoryDelete"]
|
||||
else:
|
||||
commands = ["GroupDelete", "HistoryDelete"]
|
||||
|
||||
last_error: Optional[Exception] = 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
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,636 @@
|
||||
"""qBittorrent download client for Prowlarr integration."""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
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
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_tags(raw_tags: object) -> list[str]:
|
||||
"""Normalize tag input to a clean, de-duplicated list of strings."""
|
||||
if raw_tags is None:
|
||||
return []
|
||||
|
||||
if isinstance(raw_tags, str):
|
||||
parts = [part.strip() for part in raw_tags.split(",")]
|
||||
elif isinstance(raw_tags, (list, tuple, set)):
|
||||
parts = []
|
||||
for item in raw_tags:
|
||||
if item is None:
|
||||
continue
|
||||
parts.append(str(item).strip())
|
||||
else:
|
||||
parts = [str(raw_tags).strip()] if raw_tags else []
|
||||
|
||||
tags: list[str] = []
|
||||
seen = set()
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
if part in seen:
|
||||
continue
|
||||
seen.add(part)
|
||||
tags.append(part)
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class QBittorrentClient(DownloadClient):
|
||||
"""qBittorrent download client."""
|
||||
|
||||
def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, Optional[str]]:
|
||||
"""Check whether qBittorrent has registered a torrent yet.
|
||||
|
||||
Uses `/api/v2/torrents/properties?hash=<hash>`.
|
||||
|
||||
Returns:
|
||||
(loaded, error_message)
|
||||
|
||||
Notes:
|
||||
A false result with no error means "not loaded yet".
|
||||
"""
|
||||
import requests
|
||||
|
||||
url = f"{self._base_url}/api/v2/torrents/properties"
|
||||
params = {"hash": torrent_hash}
|
||||
|
||||
try:
|
||||
self._client.auth_log_in()
|
||||
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")
|
||||
self._client.auth_log_in()
|
||||
response = self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
if response.status_code == 403:
|
||||
return False, "qBittorrent authentication failed (HTTP 403)"
|
||||
|
||||
# qBittorrent returns 404/409-ish responses depending on version when missing.
|
||||
if response.status_code == 404:
|
||||
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:
|
||||
return False, None
|
||||
if status:
|
||||
return False, f"qBittorrent API request failed (HTTP {status})"
|
||||
return False, "qBittorrent API request failed"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return False, f"Cannot connect to qBittorrent at {self._base_url}"
|
||||
except requests.exceptions.Timeout:
|
||||
return False, f"qBittorrent request timed out at {self._base_url}"
|
||||
except Exception as e:
|
||||
return False, f"qBittorrent API error: {type(e).__name__}: {e}"
|
||||
|
||||
protocol = "torrent"
|
||||
name = "qbittorrent"
|
||||
|
||||
def __init__(self):
|
||||
"""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")
|
||||
|
||||
# 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")
|
||||
|
||||
# qbittorrent-api accepts either a full URL or host:port; prefer the normalized URL
|
||||
# for consistency.
|
||||
self._client = Client(
|
||||
host=self._base_url,
|
||||
username=config.get("QBITTORRENT_USERNAME", ""),
|
||||
password=config.get("QBITTORRENT_PASSWORD", ""),
|
||||
)
|
||||
self._category = config.get("QBITTORRENT_CATEGORY", "books")
|
||||
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]]:
|
||||
"""Get torrent info using GET.
|
||||
|
||||
Behaviors:
|
||||
- Retry once on HTTP 403 by re-authenticating.
|
||||
- Keep "API/auth/connect" errors distinct from "torrent missing".
|
||||
- If a hash-specific query returns empty, fall back to listing by category
|
||||
and matching locally.
|
||||
|
||||
Returns:
|
||||
(torrents, error_message)
|
||||
"""
|
||||
import requests
|
||||
|
||||
url = f"{self._base_url}/api/v2/torrents/info"
|
||||
|
||||
def do_request(params: dict[str, str]) -> requests.Response:
|
||||
# Ensure session is authenticated before using it directly
|
||||
self._client.auth_log_in()
|
||||
return self._client._session.get(url, params=params, timeout=10)
|
||||
|
||||
def parse_response(
|
||||
response: requests.Response,
|
||||
*,
|
||||
request_params: dict[str, str],
|
||||
) -> tuple[list[SimpleNamespace], Optional[str]]:
|
||||
if response.status_code == 403:
|
||||
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:
|
||||
logger.warning("qBittorrent authentication failed (HTTP 403)")
|
||||
return [], "qBittorrent authentication failed (HTTP 403)"
|
||||
|
||||
response.raise_for_status()
|
||||
torrents = response.json()
|
||||
return [SimpleNamespace(**t) for t in torrents], None
|
||||
|
||||
try:
|
||||
primary_params: dict[str, str] = {}
|
||||
if torrent_hash:
|
||||
primary_params["hashes"] = torrent_hash
|
||||
|
||||
response = do_request(primary_params)
|
||||
torrents, error = parse_response(response, request_params=primary_params)
|
||||
if error:
|
||||
return [], error
|
||||
|
||||
if torrent_hash and not torrents:
|
||||
# Fallback 1: list by configured category
|
||||
category_params: dict[str, str] = {}
|
||||
if self._category:
|
||||
category_params["category"] = self._category
|
||||
|
||||
category_response = do_request(category_params)
|
||||
category_torrents, category_error = parse_response(
|
||||
category_response, request_params=category_params
|
||||
)
|
||||
if category_error:
|
||||
return [], category_error
|
||||
|
||||
if category_torrents:
|
||||
return category_torrents, None
|
||||
|
||||
# Fallback 2: list everything (handles per-task categories like audiobooks)
|
||||
all_response = do_request({})
|
||||
all_torrents, all_error = parse_response(all_response, request_params={})
|
||||
if all_error:
|
||||
return [], all_error
|
||||
|
||||
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}")
|
||||
return [], f"qBittorrent API request failed (HTTP {status})"
|
||||
|
||||
logger.warning(f"qBittorrent API error: {e}")
|
||||
return [], "qBittorrent API request failed"
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.warning(f"Cannot connect to qBittorrent at {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}")
|
||||
return [], f"qBittorrent request timed out at {self._base_url}"
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get torrents info: {e}")
|
||||
# requests raises InvalidSchema when the base URL doesn't include http(s)
|
||||
if type(e).__name__ == "InvalidSchema":
|
||||
return (
|
||||
[],
|
||||
"qBittorrent URL is invalid (missing http:// or https://). "
|
||||
f"Configured: {self._base_url}",
|
||||
)
|
||||
return [], f"qBittorrent API error: {type(e).__name__}: {e}"
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Check if qBittorrent is configured and selected as the torrent client."""
|
||||
client = config.get("PROWLARR_TORRENT_CLIENT", "")
|
||||
url = normalize_http_url(config.get("QBITTORRENT_URL", ""))
|
||||
return client == "qbittorrent" and bool(url)
|
||||
|
||||
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)}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: str | None = None,
|
||||
expected_hash: str | None = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Add torrent by URL (magnet or .torrent).
|
||||
|
||||
Args:
|
||||
url: Magnet link or .torrent URL
|
||||
name: Display name for the torrent
|
||||
category: Category for organization (uses configured default if not specified)
|
||||
expected_hash: Optional info_hash hint (from Prowlarr)
|
||||
|
||||
Returns:
|
||||
Torrent hash (info_hash).
|
||||
|
||||
Raises:
|
||||
Exception: If adding fails.
|
||||
"""
|
||||
try:
|
||||
# Use configured category if not explicitly provided
|
||||
category = category or self._category
|
||||
tags = self._tags
|
||||
|
||||
# Ensure category exists (may already exist, which is fine)
|
||||
try:
|
||||
self._client.torrents_create_category(name=category)
|
||||
except Exception as e:
|
||||
# Conflict409Error means category exists - that's expected
|
||||
# Log other errors but continue since download may still work
|
||||
if "Conflict" not in type(e).__name__ and "409" not in str(e):
|
||||
logger.debug(f"Could not create category '{category}': {type(e).__name__}: {e}")
|
||||
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
expected_hash = torrent_info.info_hash
|
||||
torrent_data = torrent_info.torrent_data
|
||||
|
||||
# Add the torrent - use file content if we have it, otherwise URL
|
||||
add_kwargs = {
|
||||
"category": category,
|
||||
"rename": name,
|
||||
}
|
||||
if self._download_dir:
|
||||
add_kwargs["save_path"] = self._download_dir
|
||||
if tags:
|
||||
add_kwargs["tags"] = ",".join(tags)
|
||||
|
||||
if torrent_data:
|
||||
result = self._client.torrents_add(
|
||||
torrent_files=torrent_data,
|
||||
**add_kwargs,
|
||||
)
|
||||
else:
|
||||
# Use magnet URL if available, otherwise original URL
|
||||
add_url = torrent_info.magnet_url or url
|
||||
result = self._client.torrents_add(
|
||||
urls=add_url,
|
||||
**add_kwargs,
|
||||
)
|
||||
|
||||
logger.debug(f"qBittorrent add result: {result}")
|
||||
|
||||
if result == "Ok.":
|
||||
if not expected_hash:
|
||||
raise Exception("Could not determine torrent hash from URL")
|
||||
|
||||
# Wait for torrent to appear in client.
|
||||
# Use `/torrents/properties?hash=` rather than relying on `torrents/info`
|
||||
# listing being immediately consistent.
|
||||
for _ in range(10):
|
||||
loaded, error = self._is_torrent_loaded(expected_hash)
|
||||
if error:
|
||||
logger.debug(f"qBittorrent add_download: {error}")
|
||||
if loaded:
|
||||
logger.info(f"Added torrent: {expected_hash}")
|
||||
return expected_hash.lower()
|
||||
time.sleep(0.5)
|
||||
|
||||
# Client said Ok, trust it
|
||||
logger.warning(f"Torrent not yet visible, returning expected hash")
|
||||
return expected_hash
|
||||
|
||||
raise Exception(f"Failed to add torrent: {result}")
|
||||
except Exception as e:
|
||||
logger.error(f"qBittorrent add failed: {e}")
|
||||
raise
|
||||
|
||||
def get_status(self, download_id: str) -> DownloadStatus:
|
||||
"""
|
||||
Get torrent status by hash.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
|
||||
Returns:
|
||||
Current download status.
|
||||
"""
|
||||
try:
|
||||
torrents, error = self._get_torrents_info(download_id)
|
||||
if error:
|
||||
return DownloadStatus.error(error)
|
||||
|
||||
torrent = next(
|
||||
(
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(getattr(t, "hash"), download_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not torrent:
|
||||
return DownloadStatus.error("Torrent not found in qBittorrent")
|
||||
|
||||
# Map qBittorrent states to our states and user-friendly messages
|
||||
state_info = {
|
||||
"downloading": ("downloading", None), # None = use default progress message
|
||||
"stalledDL": ("downloading", "Stalled"),
|
||||
"metaDL": ("downloading", "Fetching metadata"),
|
||||
"forcedDL": ("downloading", None),
|
||||
"allocating": ("downloading", "Allocating space"),
|
||||
"uploading": ("seeding", "Seeding"),
|
||||
"stalledUP": ("seeding", "Seeding (stalled)"),
|
||||
"forcedUP": ("seeding", "Seeding"),
|
||||
"pausedDL": ("paused", "Paused"),
|
||||
"pausedUP": ("paused", "Paused"),
|
||||
"queuedDL": ("queued", "Queued"),
|
||||
"queuedUP": ("queued", "Queued"),
|
||||
"checkingDL": ("checking", "Checking files"),
|
||||
"checkingUP": ("checking", "Checking files"),
|
||||
"checkingResumeData": ("checking", "Checking resume data"),
|
||||
"moving": ("processing", "Moving files"),
|
||||
"error": ("error", "Error"),
|
||||
"missingFiles": ("error", "Missing files"),
|
||||
"unknown": ("unknown", "Unknown state"),
|
||||
}
|
||||
|
||||
torrent_state = getattr(torrent, "state", "unknown")
|
||||
state, message = state_info.get(torrent_state, ("unknown", str(torrent_state)))
|
||||
|
||||
torrent_progress = getattr(torrent, "progress", 0.0)
|
||||
# Don't mark complete while files are being moved to final location
|
||||
# (qBittorrent moves files from incomplete → complete folder)
|
||||
complete = torrent_progress >= 1.0 and torrent_state != "moving"
|
||||
|
||||
# For active downloads without a special message, leave message as None
|
||||
# so the handler can build the progress message
|
||||
if complete:
|
||||
message = "Complete"
|
||||
|
||||
torrent_eta = getattr(torrent, "eta", 0)
|
||||
eta = torrent_eta if isinstance(torrent_eta, int) and 0 < torrent_eta < 604800 else None
|
||||
|
||||
# Get file path for completed downloads
|
||||
file_path = None
|
||||
if complete:
|
||||
file_path = self._resolve_completed_download_path(torrent)
|
||||
|
||||
torrent_speed = getattr(torrent, "dlspeed", None)
|
||||
torrent_speed = torrent_speed if isinstance(torrent_speed, int) else None
|
||||
|
||||
return DownloadStatus(
|
||||
progress=float(torrent_progress) * 100,
|
||||
state="complete" if complete else state,
|
||||
message=message,
|
||||
complete=complete,
|
||||
file_path=file_path,
|
||||
download_speed=torrent_speed,
|
||||
eta=eta,
|
||||
)
|
||||
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.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
delete_files: Whether to also delete files
|
||||
|
||||
Returns:
|
||||
True if successful.
|
||||
"""
|
||||
try:
|
||||
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 "")
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self._log_error("remove", e)
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
"""Get the path where torrent files are located.
|
||||
|
||||
Prefer `content_path` when available.
|
||||
|
||||
When `content_path` is missing (commonly with qBittorrent-like emulators such
|
||||
as Amarr), derive the path using:
|
||||
- `/api/v2/torrents/properties?hash=<hash>` for `save_path`
|
||||
- `/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}")
|
||||
return None
|
||||
|
||||
torrent = next(
|
||||
(
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(getattr(t, "hash"), download_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not torrent:
|
||||
return None
|
||||
|
||||
return self._resolve_completed_download_path(torrent)
|
||||
except Exception as e:
|
||||
self._log_error("get_download_path", e, level="debug")
|
||||
return None
|
||||
|
||||
def _resolve_completed_download_path(self, torrent: SimpleNamespace) -> Optional[str]:
|
||||
"""Resolve the completed path for a torrent.
|
||||
|
||||
Centralizes the logic shared by `get_status()` and `get_download_path()`:
|
||||
- accept `content_path` only when it's not equal to `save_path`
|
||||
- when the torrent is complete and both `content_path` and `save_path` are present,
|
||||
prefer a path rooted at `save_path` to avoid races where qBittorrent briefly reports
|
||||
a temp/incomplete `content_path` and then moves the payload
|
||||
- otherwise derive via properties+files
|
||||
- finally fall back to `save_path + name`
|
||||
"""
|
||||
|
||||
torrent_progress = getattr(torrent, "progress", 0.0)
|
||||
try:
|
||||
progress = float(torrent_progress)
|
||||
except (TypeError, ValueError):
|
||||
progress = 0.0
|
||||
|
||||
# Prefer content_path, but treat content_path == save_path as invalid.
|
||||
content_path = getattr(torrent, "content_path", "")
|
||||
save_path = getattr(torrent, "save_path", "")
|
||||
if content_path and (not save_path or str(content_path) != str(save_path)):
|
||||
# When using a temp/incomplete directory, qBittorrent can briefly keep reporting
|
||||
# `content_path` under that temp path right at completion, then move the files
|
||||
# into `save_path`. Returning the temp path can race with that move.
|
||||
if save_path and progress >= 1.0:
|
||||
# Use the basename of content_path under save_path (works for single-file
|
||||
# torrents and multi-file torrents where content_path is a top-level dir).
|
||||
try:
|
||||
content_basename = str(Path(str(content_path)).name)
|
||||
except Exception:
|
||||
content_basename = ""
|
||||
rooted = self._build_path(str(save_path), content_basename)
|
||||
if rooted:
|
||||
return rooted
|
||||
|
||||
return str(content_path)
|
||||
|
||||
download_id = getattr(torrent, "hash", "")
|
||||
if isinstance(download_id, str) and download_id:
|
||||
derived = self._derive_download_path_from_files(download_id)
|
||||
if derived:
|
||||
return derived
|
||||
|
||||
# Legacy fallback: save_path + name (for older clients/emulators)
|
||||
return self._build_path(
|
||||
getattr(torrent, "save_path", ""),
|
||||
getattr(torrent, "name", ""),
|
||||
)
|
||||
|
||||
def _derive_download_path_from_files(self, download_id: str) -> Optional[str]:
|
||||
"""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:
|
||||
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
|
||||
self._client.auth_log_in()
|
||||
resp = self._client._session.get(url, params=params, timeout=10)
|
||||
return resp
|
||||
|
||||
try:
|
||||
properties_url = f"{self._base_url}/api/v2/torrents/properties"
|
||||
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:
|
||||
return None
|
||||
props_resp.raise_for_status()
|
||||
props = props_resp.json() if isinstance(props_resp.json(), dict) else {}
|
||||
|
||||
save_path = props.get("save_path") or props.get("savePath") or ""
|
||||
if not isinstance(save_path, str) or not save_path:
|
||||
return None
|
||||
|
||||
files_resp = get_with_auth(files_url, {"hash": download_id})
|
||||
if files_resp.status_code == 404:
|
||||
return None
|
||||
files_resp.raise_for_status()
|
||||
files = files_resp.json() if isinstance(files_resp.json(), list) else []
|
||||
if not files:
|
||||
return None
|
||||
|
||||
first_name = files[0].get("name") if isinstance(files[0], dict) else None
|
||||
if not isinstance(first_name, str) or not first_name:
|
||||
return None
|
||||
|
||||
# Get the first path segment (qBittorrent returns '/' even on Windows).
|
||||
first_name_norm = first_name.replace("\\", "/")
|
||||
top_level = first_name_norm.split("/", 1)[0]
|
||||
if not top_level:
|
||||
return None
|
||||
|
||||
return os.path.normpath(os.path.join(save_path, top_level))
|
||||
except Exception as e:
|
||||
logger.debug(f"qBittorrent could not derive path from files: {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
"""Check if a torrent for this URL already exists in qBittorrent."""
|
||||
try:
|
||||
torrent_info = extract_torrent_info(url)
|
||||
if not torrent_info.info_hash:
|
||||
return None
|
||||
|
||||
torrents, error = self._get_torrents_info(torrent_info.info_hash)
|
||||
if error:
|
||||
logger.debug(f"qBittorrent find_existing: {error}")
|
||||
return None
|
||||
|
||||
torrent = next(
|
||||
(
|
||||
t
|
||||
for t in torrents
|
||||
if isinstance(getattr(t, "hash", None), str)
|
||||
and _hashes_match(getattr(t, "hash"), torrent_info.info_hash)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if torrent and isinstance(getattr(torrent, "hash", None), str):
|
||||
torrent_hash = getattr(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}")
|
||||
return None
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
rTorrent download client for Prowlarr integration.
|
||||
|
||||
Uses xmlrpc to communicate with rTorrent's RPC interface.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class RTorrentClient(DownloadClient):
|
||||
"""rTorrent download client using xmlrpc."""
|
||||
|
||||
protocol = "torrent"
|
||||
name = "rtorrent"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize rTorrent client with settings from config."""
|
||||
from xmlrpc.client import ServerProxy
|
||||
|
||||
raw_url = config.get("RTORRENT_URL", "")
|
||||
if not raw_url:
|
||||
raise ValueError("RTORRENT_URL is required")
|
||||
|
||||
self._base_url = normalize_http_url(raw_url)
|
||||
if not self._base_url:
|
||||
raise ValueError("RTORRENT_URL is invalid")
|
||||
|
||||
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._rpc = ServerProxy(self._base_url)
|
||||
self._download_dir = config.get("RTORRENT_DOWNLOAD_DIR", "")
|
||||
self._label = config.get("RTORRENT_LABEL", "")
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Check if rTorrent is configured and selected as the torrent client."""
|
||||
client = config.get("PROWLARR_TORRENT_CLIENT", "")
|
||||
url = normalize_http_url(config.get("RTORRENT_URL", ""))
|
||||
return client == "rtorrent" and bool(url)
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
"""Test connection to rTorrent."""
|
||||
try:
|
||||
version = self._rpc.system.client_version()
|
||||
return True, f"Connected to rTorrent {version}"
|
||||
except Exception as e:
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Add torrent by URL (magnet or .torrent).
|
||||
|
||||
Args:
|
||||
url: Magnet link or .torrent URL
|
||||
name: Display name for the torrent
|
||||
category: Category for organization (uses configured label if not specified)
|
||||
expected_hash: Optional info_hash hint (from Prowlarr)
|
||||
|
||||
Returns:
|
||||
Torrent hash (info_hash).
|
||||
|
||||
Raises:
|
||||
Exception: If adding fails.
|
||||
"""
|
||||
try:
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
|
||||
commands = []
|
||||
|
||||
label = category or self._label
|
||||
if label:
|
||||
logger.debug(f"Setting rTorrent label: {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}")
|
||||
commands.append(f"d.directory_base.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)
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Adding torrent URL to rTorrent for: {name} with commands: {commands} with URL: {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")
|
||||
|
||||
logger.debug(f"Added torrent to rTorrent: {torrent_hash}")
|
||||
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.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
|
||||
Returns:
|
||||
Current download status.
|
||||
"""
|
||||
try:
|
||||
# rtorrent is somehow case sensitive and requires uppercase hashes for look
|
||||
download_id = download_id.upper()
|
||||
torrent_list = self._rpc.d.multicall.filtered(
|
||||
"",
|
||||
"default",
|
||||
f"equal={{d.hash=,cat={download_id}}}",
|
||||
"d.hash=",
|
||||
"d.state=",
|
||||
"d.completed_bytes=",
|
||||
"d.size_bytes=",
|
||||
"d.down.rate=",
|
||||
"d.up.rate=",
|
||||
"d.custom1=",
|
||||
"d.complete=",
|
||||
)
|
||||
logger.debug(f"Fetched torrent status from rTorrent for: {download_id} - {torrent_list}")
|
||||
if not torrent_list:
|
||||
logger.warning(f"Torrent not found in rTorrent: {download_id}")
|
||||
return DownloadStatus.error("Torrent not found")
|
||||
|
||||
torrent = torrent_list[0]
|
||||
if not torrent:
|
||||
logger.warning(f"Torrent data is empty for: {download_id}")
|
||||
return DownloadStatus.error("Torrent not found")
|
||||
|
||||
logger.debug(f"Torrent data for {download_id}: {torrent}")
|
||||
(
|
||||
torrent_hash,
|
||||
state,
|
||||
bytes_downloaded,
|
||||
bytes_total,
|
||||
down_rate,
|
||||
up_rate,
|
||||
custom_category,
|
||||
complete,
|
||||
) = torrent
|
||||
|
||||
try:
|
||||
state = int(state)
|
||||
except Exception:
|
||||
state = 0
|
||||
|
||||
complete = bool(complete)
|
||||
|
||||
if bytes_total > 0:
|
||||
progress = (bytes_downloaded / bytes_total) * 100
|
||||
else:
|
||||
progress = 0
|
||||
|
||||
bytes_left = max(0, bytes_total - bytes_downloaded)
|
||||
|
||||
state_map = {
|
||||
0: ("paused", "Paused"),
|
||||
1: ("downloading", "Downloading"),
|
||||
2: ("downloading", "Downloading"),
|
||||
3: ("downloading", "Downloading"),
|
||||
4: ("seeding", "Seeding"),
|
||||
}
|
||||
|
||||
state_str, message = state_map.get(state, ("unknown", "Unknown state"))
|
||||
|
||||
if complete and not message:
|
||||
message = "Complete"
|
||||
|
||||
eta = None
|
||||
if down_rate > 0 and bytes_left > 0:
|
||||
eta_seconds = bytes_left / down_rate
|
||||
if eta_seconds < 604800:
|
||||
eta = int(eta_seconds)
|
||||
|
||||
file_path = None
|
||||
if complete:
|
||||
file_path = self._get_torrent_path(download_id)
|
||||
|
||||
return DownloadStatus(
|
||||
progress=min(100, progress),
|
||||
state="complete" if complete else state_str,
|
||||
message=message,
|
||||
complete=complete,
|
||||
file_path=file_path,
|
||||
download_speed=down_rate if down_rate > 0 else None,
|
||||
eta=eta,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
logger.error(f"rTorrent get_status failed ({error_type}): {e}")
|
||||
return DownloadStatus.error(f"{error_type}: {e}")
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
"""
|
||||
Remove a torrent from rTorrent.
|
||||
|
||||
Args:
|
||||
download_id: Torrent info_hash
|
||||
delete_files: Whether to also delete files
|
||||
|
||||
Returns:
|
||||
True if successful.
|
||||
"""
|
||||
try:
|
||||
if delete_files:
|
||||
self._rpc.d.delete_tied(download_id)
|
||||
self._rpc.d.erase(download_id)
|
||||
else:
|
||||
self._rpc.d.stop(download_id)
|
||||
self._rpc.d.erase(download_id)
|
||||
|
||||
logger.info(
|
||||
f"Removed torrent from rTorrent: {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}")
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
"""
|
||||
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}")
|
||||
return None
|
||||
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
"""Check if a torrent for this URL already exists in rTorrent."""
|
||||
try:
|
||||
torrent_info = extract_torrent_info(url)
|
||||
if not torrent_info.info_hash:
|
||||
return None
|
||||
|
||||
try:
|
||||
status = self.get_status(torrent_info.info_hash)
|
||||
if status.state != DownloadStatus.error("").state:
|
||||
return (torrent_info.info_hash, status)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking for existing torrent: {e}")
|
||||
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
|
||||
except Exception:
|
||||
return "/downloads"
|
||||
|
||||
def _get_torrent_path(self, download_id: str) -> Optional[str]:
|
||||
"""Get the file path of a torrent by hash.
|
||||
|
||||
Uses `d.base_path` for the item output path. In the xmlrpc interface
|
||||
this corresponds to `d.get_base_path()`.
|
||||
"""
|
||||
try:
|
||||
# rTorrent is case sensitive for hashes; use uppercase as in get_status()
|
||||
download_hash = download_id.upper()
|
||||
details = self._rpc.d.multicall.filtered(
|
||||
"",
|
||||
"default",
|
||||
f"equal={{d.hash=,cat={download_hash}}}",
|
||||
"d.base_path=",
|
||||
)
|
||||
if not details:
|
||||
return None
|
||||
path = details[0][0]
|
||||
return path if path else None
|
||||
except Exception:
|
||||
return None
|
||||
+189
-32
@@ -5,15 +5,18 @@ Uses SABnzbd's REST API directly via requests (no external dependency).
|
||||
"""
|
||||
|
||||
from typing import Any, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.release_sources.prowlarr.clients import (
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
with_retry,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -66,32 +69,62 @@ def _parse_speed(slot: dict) -> Optional[int]:
|
||||
class SABnzbdClient(DownloadClient):
|
||||
"""SABnzbd download client using REST API."""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_completed_storage_path(storage: str, title: str) -> str:
|
||||
"""Normalize SABnzbd's `storage` into a stable "job root" folder.
|
||||
|
||||
Walks up parent directories looking for a directory named exactly like the
|
||||
job `title`.
|
||||
|
||||
This helps when SABnzbd reports a nested path (e.g. sorting/post-processing)
|
||||
but we want the root folder for the completed job.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
storage = storage or ""
|
||||
title = (title or "").strip()
|
||||
if not storage or not title:
|
||||
return storage
|
||||
|
||||
# SAB returns absolute paths; don't require existence on disk.
|
||||
path = Path(storage)
|
||||
best_match: Path | None = None
|
||||
|
||||
for parent in [path, *path.parents]:
|
||||
if parent.name == title:
|
||||
best_match = parent
|
||||
|
||||
return str(best_match) if best_match is not None else storage
|
||||
|
||||
protocol = "usenet"
|
||||
name = "sabnzbd"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize SABnzbd client with settings from config."""
|
||||
url = config.get("SABNZBD_URL", "")
|
||||
if not url:
|
||||
raw_url = config.get("SABNZBD_URL", "")
|
||||
if not raw_url:
|
||||
raise ValueError("SABNZBD_URL is required")
|
||||
|
||||
api_key = config.get("SABNZBD_API_KEY", "")
|
||||
if not api_key:
|
||||
raise ValueError("SABNZBD_API_KEY is required")
|
||||
|
||||
self.url = url.rstrip("/")
|
||||
self.url = normalize_http_url(raw_url)
|
||||
if not self.url:
|
||||
raise ValueError("SABNZBD_URL is invalid")
|
||||
self.api_key = api_key
|
||||
self._category = config.get("SABNZBD_CATEGORY", "cwabd")
|
||||
self._category = config.get("SABNZBD_CATEGORY", "books")
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Check if SABnzbd is configured and selected as the usenet client."""
|
||||
client = config.get("PROWLARR_USENET_CLIENT", "")
|
||||
url = config.get("SABNZBD_URL", "")
|
||||
url = normalize_http_url(config.get("SABNZBD_URL", ""))
|
||||
api_key = config.get("SABNZBD_API_KEY", "")
|
||||
return client == "sabnzbd" and bool(url) and bool(api_key)
|
||||
|
||||
def _api_call(self, mode: str, params: dict = None) -> Any:
|
||||
@with_retry()
|
||||
def _api_call(self, mode: str, params: Optional[dict] = None) -> Any:
|
||||
"""
|
||||
Make an API call to SABnzbd.
|
||||
|
||||
@@ -103,7 +136,7 @@ class SABnzbdClient(DownloadClient):
|
||||
JSON response from SABnzbd.
|
||||
|
||||
Raises:
|
||||
Exception: If API call fails.
|
||||
Exception: If API call fails after retries.
|
||||
"""
|
||||
api_url = f"{self.url}/api"
|
||||
|
||||
@@ -127,6 +160,101 @@ class SABnzbdClient(DownloadClient):
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
JSON response from SABnzbd.
|
||||
"""
|
||||
api_url = f"{self.url}/api"
|
||||
request_params = {
|
||||
"apikey": self.api_key,
|
||||
"mode": "addfile",
|
||||
"output": "json",
|
||||
"nzbname": nzb_name,
|
||||
"cat": category,
|
||||
}
|
||||
files = {"name": (filename, nzb_content, "application/x-nzb")}
|
||||
|
||||
response = requests.post(api_url, params=request_params, files=files, timeout=30)
|
||||
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}")
|
||||
|
||||
return result
|
||||
|
||||
def _fetch_nzb_content(self, url: str) -> bytes:
|
||||
"""Fetch NZB content, including Prowlarr auth headers when appropriate."""
|
||||
headers = self._get_prowlarr_headers(url)
|
||||
response = requests.get(url, timeout=30, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
def _get_prowlarr_headers(self, url: str) -> dict:
|
||||
# TODO: Move this source-specific Prowlarr auth handling into a source hook.
|
||||
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
|
||||
if not api_key:
|
||||
return {}
|
||||
|
||||
prowlarr_url = normalize_http_url(config.get("PROWLARR_URL", ""))
|
||||
if not prowlarr_url:
|
||||
return {}
|
||||
|
||||
try:
|
||||
target = urlparse(url)
|
||||
base = urlparse(prowlarr_url)
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
if target.hostname and base.hostname and target.hostname.lower() == base.hostname.lower():
|
||||
return {"X-Api-Key": api_key}
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _build_nzb_filename(name: str, url: str) -> str:
|
||||
base_name = (name or "download").strip() or "download"
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path or ""
|
||||
lower_path = path.lower()
|
||||
|
||||
if lower_path.endswith(".nzb.gz"):
|
||||
suffix = ".nzb.gz"
|
||||
elif lower_path.endswith(".nzb"):
|
||||
suffix = ".nzb"
|
||||
else:
|
||||
suffix = ""
|
||||
|
||||
lower_base = base_name.lower()
|
||||
if suffix and lower_base.endswith(suffix):
|
||||
return base_name
|
||||
if suffix == ".nzb.gz" and lower_base.endswith(".nzb"):
|
||||
return f"{base_name}.gz"
|
||||
if suffix:
|
||||
return f"{base_name}{suffix}"
|
||||
if lower_base.endswith((".nzb", ".nzb.gz")):
|
||||
return base_name
|
||||
return f"{base_name}.nzb"
|
||||
|
||||
@staticmethod
|
||||
def _extract_nzo_id(result: Any) -> str:
|
||||
if not isinstance(result, dict):
|
||||
raise Exception("SABnzbd returned invalid response")
|
||||
|
||||
nzo_ids = result.get("nzo_ids") or result.get("nzo_id")
|
||||
if isinstance(nzo_ids, list) and nzo_ids:
|
||||
return str(nzo_ids[0])
|
||||
if isinstance(nzo_ids, str) and nzo_ids:
|
||||
return nzo_ids
|
||||
if isinstance(nzo_ids, int):
|
||||
return str(nzo_ids)
|
||||
|
||||
raise Exception("SABnzbd returned no nzo_id")
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
"""Test connection to SABnzbd."""
|
||||
try:
|
||||
@@ -140,7 +268,14 @@ class SABnzbdClient(DownloadClient):
|
||||
except Exception as e:
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
|
||||
def add_download(self, url: str, name: str, category: str = None) -> str:
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Add NZB by URL.
|
||||
|
||||
@@ -148,6 +283,7 @@ class SABnzbdClient(DownloadClient):
|
||||
url: NZB URL (can be Prowlarr proxy URL)
|
||||
name: Display name for the download
|
||||
category: Category for organization (uses configured default if not specified)
|
||||
expected_hash: Optional info_hash hint (unused)
|
||||
|
||||
Returns:
|
||||
SABnzbd nzo_id.
|
||||
@@ -160,7 +296,16 @@ class SABnzbdClient(DownloadClient):
|
||||
|
||||
try:
|
||||
logger.debug(f"Adding NZB to SABnzbd: {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
|
||||
except Exception as e:
|
||||
logger.warning(f"SABnzbd addfile failed, falling back to addurl: {e}")
|
||||
|
||||
try:
|
||||
result = self._api_call(
|
||||
"addurl",
|
||||
{
|
||||
@@ -169,15 +314,9 @@ class SABnzbdClient(DownloadClient):
|
||||
"cat": category,
|
||||
},
|
||||
)
|
||||
|
||||
# SABnzbd returns {"status": True, "nzo_ids": ["SABnzbd_nzo_xxx"]}
|
||||
nzo_ids = result.get("nzo_ids", [])
|
||||
if nzo_ids:
|
||||
nzo_id = nzo_ids[0]
|
||||
logger.info(f"Added NZB to SABnzbd: {nzo_id}")
|
||||
return nzo_id
|
||||
|
||||
raise Exception("SABnzbd returned no nzo_id")
|
||||
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}")
|
||||
raise
|
||||
@@ -241,24 +380,31 @@ class SABnzbdClient(DownloadClient):
|
||||
if slot.get("nzo_id") == download_id:
|
||||
status_text = slot.get("status", "").upper()
|
||||
storage = slot.get("storage", "")
|
||||
if storage is None:
|
||||
storage = ""
|
||||
logger.debug(f"SABnzbd history: {download_id} status={status_text} storage='{storage}'")
|
||||
|
||||
if status_text == "COMPLETED":
|
||||
title = slot.get("name") or slot.get("nzb_name") or ""
|
||||
resolved_storage = self._resolve_completed_storage_path(storage, title)
|
||||
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
state="complete",
|
||||
message="Complete",
|
||||
complete=True,
|
||||
file_path=storage,
|
||||
file_path=resolved_storage,
|
||||
)
|
||||
elif 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)
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
state="error",
|
||||
message=fail_message,
|
||||
complete=True,
|
||||
file_path=None,
|
||||
file_path=resolved_storage,
|
||||
)
|
||||
else:
|
||||
# Post-processing states: Queued, QuickCheck, Verifying,
|
||||
@@ -276,9 +422,7 @@ class SABnzbdClient(DownloadClient):
|
||||
logger.warning(f"SABnzbd: download {download_id} not found in queue or history")
|
||||
return DownloadStatus.error("Download not found")
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
logger.error(f"SABnzbd get_status failed ({error_type}): {e}")
|
||||
return DownloadStatus.error(f"{error_type}: {e}")
|
||||
return DownloadStatus.error(self._log_error("get_status", e))
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False, archive: bool = True) -> bool:
|
||||
"""
|
||||
@@ -292,8 +436,9 @@ 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.
|
||||
try:
|
||||
# First try to remove from queue
|
||||
result = self._api_call(
|
||||
"queue",
|
||||
{
|
||||
@@ -306,8 +451,11 @@ class SABnzbdClient(DownloadClient):
|
||||
if result.get("status"):
|
||||
logger.info(f"Removed NZB from SABnzbd queue: {download_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"SABnzbd queue delete skipped for {download_id}: {e}")
|
||||
|
||||
# If not in queue, try to remove from history
|
||||
# If not in queue (or queue delete failed), try to remove from history.
|
||||
try:
|
||||
result = self._api_call(
|
||||
"history",
|
||||
{
|
||||
@@ -322,13 +470,12 @@ class SABnzbdClient(DownloadClient):
|
||||
action = "archived" if archive else "removed"
|
||||
logger.info(f"NZB {action} from SABnzbd history: {download_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
logger.error(f"SABnzbd remove failed ({error_type}): {e}")
|
||||
self._log_error("remove", e)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get the path where NZB files are located.
|
||||
@@ -342,7 +489,9 @@ class SABnzbdClient(DownloadClient):
|
||||
status = self.get_status(download_id)
|
||||
return status.file_path
|
||||
|
||||
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
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.
|
||||
|
||||
@@ -353,6 +502,7 @@ class SABnzbdClient(DownloadClient):
|
||||
|
||||
Args:
|
||||
url: NZB URL
|
||||
category: Category to filter by (defaults to configured category)
|
||||
|
||||
Returns:
|
||||
Tuple of (nzo_id, status) if found, None if not found.
|
||||
@@ -378,10 +528,15 @@ class SABnzbdClient(DownloadClient):
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
# Search queue
|
||||
# Use provided category or fall back to configured default
|
||||
search_category = category or self._category
|
||||
|
||||
# Search queue (SABnzbd uses "cat" field for category in queue)
|
||||
queue_result = self._api_call("queue")
|
||||
queue = queue_result.get("queue", {})
|
||||
for slot in queue.get("slots", []):
|
||||
if slot.get("cat", "") != search_category:
|
||||
continue
|
||||
slot_name = slot.get("filename", "")
|
||||
if filename.lower() in slot_name.lower():
|
||||
nzo_id = slot.get("nzo_id")
|
||||
@@ -390,10 +545,12 @@ class SABnzbdClient(DownloadClient):
|
||||
logger.debug(f"Found existing NZB in SABnzbd queue: {nzo_id}")
|
||||
return (nzo_id, status)
|
||||
|
||||
# Search history
|
||||
# Search history (SABnzbd uses "category" field in history)
|
||||
history_result = self._api_call("history", {"limit": 100})
|
||||
history = history_result.get("history", {})
|
||||
for slot in history.get("slots", []):
|
||||
if slot.get("category", "") != search_category:
|
||||
continue
|
||||
slot_name = slot.get("name", "")
|
||||
if filename.lower() in slot_name.lower():
|
||||
nzo_id = slot.get("nzo_id")
|
||||
@@ -0,0 +1,678 @@
|
||||
"""Shared download client settings registration."""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from shelfmark.core.settings_registry import (
|
||||
register_settings,
|
||||
HeadingField,
|
||||
TextField,
|
||||
PasswordField,
|
||||
ActionButton,
|
||||
SelectField,
|
||||
TagListField,
|
||||
)
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
|
||||
|
||||
# ==================== Test Connection Callbacks ====================
|
||||
|
||||
def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Test the qBittorrent connection using current form values."""
|
||||
from shelfmark.core.config import config
|
||||
|
||||
current_values = current_values or {}
|
||||
|
||||
raw_url = current_values.get("QBITTORRENT_URL") or config.get("QBITTORRENT_URL", "")
|
||||
username = current_values.get("QBITTORRENT_USERNAME") or config.get("QBITTORRENT_USERNAME", "")
|
||||
password = current_values.get("QBITTORRENT_PASSWORD") or config.get("QBITTORRENT_PASSWORD", "")
|
||||
|
||||
if not raw_url:
|
||||
return {"success": False, "message": "qBittorrent URL is required"}
|
||||
|
||||
try:
|
||||
from qbittorrentapi import Client
|
||||
|
||||
url = normalize_http_url(raw_url)
|
||||
if not url:
|
||||
return {"success": False, "message": "qBittorrent URL is invalid"}
|
||||
|
||||
client = Client(host=url, username=username, password=password)
|
||||
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)}"}
|
||||
|
||||
|
||||
def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = 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 (
|
||||
parse_transmission_url,
|
||||
)
|
||||
|
||||
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", "")
|
||||
|
||||
if not raw_url:
|
||||
return {"success": False, "message": "Transmission URL is required"}
|
||||
|
||||
url = normalize_http_url(raw_url)
|
||||
if not url:
|
||||
return {"success": False, "message": "Transmission URL is invalid"}
|
||||
|
||||
try:
|
||||
from transmission_rpc import Client
|
||||
|
||||
# Parse URL to extract host, port, and path
|
||||
protocol, host, port, path = parse_transmission_url(url)
|
||||
|
||||
client_kwargs = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"path": path,
|
||||
"username": username if username else None,
|
||||
"password": password if password else None,
|
||||
"protocol": protocol,
|
||||
}
|
||||
try:
|
||||
client = Client(**client_kwargs)
|
||||
except TypeError as e:
|
||||
if "protocol" not in str(e):
|
||||
raise
|
||||
client_kwargs.pop("protocol", None)
|
||||
client = Client(**client_kwargs)
|
||||
if protocol == "https" and hasattr(client, "protocol"):
|
||||
try:
|
||||
setattr(client, "protocol", protocol)
|
||||
except Exception:
|
||||
pass
|
||||
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)}"}
|
||||
|
||||
|
||||
def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = 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 {}
|
||||
|
||||
raw_host = current_values.get("DELUGE_HOST") or config.get("DELUGE_HOST", "localhost")
|
||||
raw_port = current_values.get("DELUGE_PORT") or config.get("DELUGE_PORT", "8112")
|
||||
password = current_values.get("DELUGE_PASSWORD") or config.get("DELUGE_PASSWORD", "")
|
||||
|
||||
if not raw_host:
|
||||
return {"success": False, "message": "Deluge host is required"}
|
||||
if not password:
|
||||
return {"success": False, "message": "Deluge password is required"}
|
||||
|
||||
raw_host = str(raw_host)
|
||||
raw_host = normalize_http_url(raw_host, strip_trailing_slash=False) if raw_host else ""
|
||||
if not raw_host:
|
||||
return {"success": False, "message": "Deluge host is invalid"}
|
||||
|
||||
raw_port = str(raw_port or "8112")
|
||||
|
||||
scheme = "http"
|
||||
base_path = ""
|
||||
host = raw_host
|
||||
port = int(raw_port) if raw_port.isdigit() else 8112
|
||||
|
||||
# Allow DELUGE_HOST to be a full URL (e.g. http://deluge:8112)
|
||||
if raw_host.startswith(("http://", "https://")):
|
||||
parsed = urlparse(raw_host)
|
||||
scheme = parsed.scheme or "http"
|
||||
host = parsed.hostname or "localhost"
|
||||
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)
|
||||
|
||||
rpc_url = f"{scheme}://{host}:{port}{base_path}/json"
|
||||
|
||||
def rpc_call(session: requests.Session, rpc_id: int, method: str, *params: Any) -> Any:
|
||||
payload = {"id": rpc_id, "method": method, "params": list(params)}
|
||||
resp = session.post(rpc_url, json=payload, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("error"):
|
||||
error = data["error"]
|
||||
if isinstance(error, dict):
|
||||
raise Exception(error.get("message") or str(error))
|
||||
raise Exception(str(error))
|
||||
return data.get("result")
|
||||
|
||||
def get_daemon_version(session: requests.Session, rpc_id: int) -> Any:
|
||||
try:
|
||||
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:
|
||||
# Fall back to daemon.info to preserve existing behavior.
|
||||
pass
|
||||
|
||||
return rpc_call(session, rpc_id + 1, "daemon.info")
|
||||
|
||||
try:
|
||||
session = requests.Session()
|
||||
|
||||
if rpc_call(session, 1, "auth.login", password) is not True:
|
||||
return {"success": False, "message": "Deluge Web UI authentication failed"}
|
||||
|
||||
if rpc_call(session, 2, "web.connected") is not True:
|
||||
hosts = rpc_call(session, 3, "web.get_hosts") or []
|
||||
if not hosts:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Deluge Web UI isn't connected to Deluge core (no hosts configured). Add/connect a daemon in Deluge Web UI → Connection Manager.",
|
||||
}
|
||||
|
||||
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"}:
|
||||
host_id = entry[0]
|
||||
break
|
||||
|
||||
rpc_call(session, 4, "web.connect", host_id)
|
||||
|
||||
if rpc_call(session, 5, "web.connected") is not True:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Deluge Web UI couldn't connect to Deluge core. Check Deluge Web UI → Connection Manager.",
|
||||
}
|
||||
|
||||
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)}"}
|
||||
|
||||
|
||||
def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Test the rTorrent connection using current form values."""
|
||||
from shelfmark.core.config import config
|
||||
from urllib.parse import urlparse
|
||||
from xmlrpc.client import ServerProxy
|
||||
|
||||
current_values = current_values or {}
|
||||
|
||||
raw_url = current_values.get("RTORRENT_URL") or config.get("RTORRENT_URL", "")
|
||||
username = current_values.get("RTORRENT_USERNAME") or config.get("RTORRENT_USERNAME", "")
|
||||
password = current_values.get("RTORRENT_PASSWORD") or config.get("RTORRENT_PASSWORD", "")
|
||||
|
||||
if not raw_url:
|
||||
return {"success": False, "message": "rTorrent URL is required"}
|
||||
|
||||
url = normalize_http_url(raw_url)
|
||||
if not url:
|
||||
return {"success": False, "message": "rTorrent URL is invalid"}
|
||||
|
||||
try:
|
||||
# Add HTTP auth to URL if credentials provided
|
||||
if username and password:
|
||||
parsed = urlparse(url)
|
||||
url = f"{parsed.scheme}://{username}:{password}@{parsed.netloc}{parsed.path}"
|
||||
|
||||
rpc = ServerProxy(url.rstrip("/"))
|
||||
version = rpc.system.client_version()
|
||||
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]:
|
||||
"""Test the NZBGet connection using current form values."""
|
||||
import requests
|
||||
from shelfmark.core.config import config
|
||||
|
||||
current_values = current_values or {}
|
||||
|
||||
raw_url = current_values.get("NZBGET_URL") or config.get("NZBGET_URL", "")
|
||||
username = current_values.get("NZBGET_USERNAME") or config.get("NZBGET_USERNAME", "nzbget")
|
||||
password = current_values.get("NZBGET_PASSWORD") or config.get("NZBGET_PASSWORD", "")
|
||||
|
||||
if not raw_url:
|
||||
return {"success": False, "message": "NZBGet URL is required"}
|
||||
|
||||
url = normalize_http_url(raw_url)
|
||||
if not url:
|
||||
return {"success": False, "message": "NZBGet URL is invalid"}
|
||||
|
||||
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)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if "error" in result and result["error"]:
|
||||
raise Exception(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)}"}
|
||||
|
||||
|
||||
def _test_sabnzbd_connection(current_values: Optional[Dict[str, Any]] = 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 {}
|
||||
|
||||
raw_url = current_values.get("SABNZBD_URL") or config.get("SABNZBD_URL", "")
|
||||
api_key = current_values.get("SABNZBD_API_KEY") or config.get("SABNZBD_API_KEY", "")
|
||||
|
||||
if not raw_url:
|
||||
return {"success": False, "message": "SABnzbd URL is required"}
|
||||
|
||||
url = normalize_http_url(raw_url)
|
||||
if not url:
|
||||
return {"success": False, "message": "SABnzbd URL is invalid"}
|
||||
if not api_key:
|
||||
return {"success": False, "message": "API key is required"}
|
||||
|
||||
try:
|
||||
api_url = f"{url.rstrip('/')}/api"
|
||||
params = {"apikey": api_key, "mode": "version", "output": "json"}
|
||||
response = requests.get(api_url, params=params, timeout=30)
|
||||
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)}"}
|
||||
|
||||
|
||||
# ==================== Download Clients Tab ====================
|
||||
|
||||
@register_settings(
|
||||
name="prowlarr_clients",
|
||||
display_name="Download Clients",
|
||||
icon="cog",
|
||||
order=110,
|
||||
)
|
||||
def prowlarr_clients_settings():
|
||||
"""Download client settings shared by external release sources."""
|
||||
return [
|
||||
# --- Torrent Client Selection ---
|
||||
HeadingField(
|
||||
key="torrent_heading",
|
||||
title="Torrent Client",
|
||||
description="Select and configure a torrent client for downloading torrent releases.",
|
||||
),
|
||||
SelectField(
|
||||
key="PROWLARR_TORRENT_CLIENT",
|
||||
label="Torrent Client",
|
||||
description="Choose which torrent client to use",
|
||||
options=[
|
||||
{"value": "", "label": "None"},
|
||||
{"value": "qbittorrent", "label": "qBittorrent"},
|
||||
{"value": "transmission", "label": "Transmission"},
|
||||
{"value": "deluge", "label": "Deluge"},
|
||||
{"value": "rtorrent", "label": "rTorrent"},
|
||||
],
|
||||
default="",
|
||||
),
|
||||
|
||||
# --- qBittorrent Settings ---
|
||||
TextField(
|
||||
key="QBITTORRENT_URL",
|
||||
label="qBittorrent URL",
|
||||
description="Web UI URL of your qBittorrent instance",
|
||||
placeholder="http://qbittorrent:8080",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
TextField(
|
||||
key="QBITTORRENT_USERNAME",
|
||||
label="Username",
|
||||
description="qBittorrent Web UI username",
|
||||
placeholder="admin",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
PasswordField(
|
||||
key="QBITTORRENT_PASSWORD",
|
||||
label="Password",
|
||||
description="qBittorrent Web UI password",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_qbittorrent",
|
||||
label="Test Connection",
|
||||
description="Verify your qBittorrent configuration",
|
||||
style="primary",
|
||||
callback=_test_qbittorrent_connection,
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
TextField(
|
||||
key="QBITTORRENT_CATEGORY",
|
||||
label="Book Category",
|
||||
description="Category to assign to book downloads in qBittorrent",
|
||||
placeholder="books",
|
||||
default="books",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
TextField(
|
||||
key="QBITTORRENT_CATEGORY_AUDIOBOOK",
|
||||
label="Audiobook Category",
|
||||
description="Category for audiobook downloads. Leave empty to use the book category.",
|
||||
placeholder="",
|
||||
default="",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
TextField(
|
||||
key="QBITTORRENT_DOWNLOAD_DIR",
|
||||
label="Download Directory",
|
||||
description="Server-side directory where torrents are downloaded (optional, uses qBittorrent default if not specified)",
|
||||
placeholder="/downloads",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
TagListField(
|
||||
key="QBITTORRENT_TAG",
|
||||
label="Tags",
|
||||
description="Tag(s) to assign to qBittorrent downloads. Leave empty for no tags.",
|
||||
placeholder="",
|
||||
default=[],
|
||||
normalize_urls=False,
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
|
||||
# --- Transmission Settings ---
|
||||
TextField(
|
||||
key="TRANSMISSION_URL",
|
||||
label="Transmission URL",
|
||||
description="URL of your Transmission instance (use https:// for TLS)",
|
||||
placeholder="http://transmission:9091",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
),
|
||||
TextField(
|
||||
key="TRANSMISSION_USERNAME",
|
||||
label="Username",
|
||||
description="Transmission RPC username (if authentication enabled)",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
),
|
||||
PasswordField(
|
||||
key="TRANSMISSION_PASSWORD",
|
||||
label="Password",
|
||||
description="Transmission RPC password",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_transmission",
|
||||
label="Test Connection",
|
||||
description="Verify your Transmission configuration",
|
||||
style="primary",
|
||||
callback=_test_transmission_connection,
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
),
|
||||
TextField(
|
||||
key="TRANSMISSION_CATEGORY",
|
||||
label="Book Label",
|
||||
description="Label to assign to book downloads in Transmission",
|
||||
placeholder="books",
|
||||
default="books",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
),
|
||||
TextField(
|
||||
key="TRANSMISSION_CATEGORY_AUDIOBOOK",
|
||||
label="Audiobook Label",
|
||||
description="Label for audiobook downloads. Leave empty to use the book label.",
|
||||
placeholder="",
|
||||
default="",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
),
|
||||
TextField(
|
||||
key="TRANSMISSION_DOWNLOAD_DIR",
|
||||
label="Download Directory",
|
||||
description="Server-side directory where torrents are downloaded (optional, uses Transmission default if not specified)",
|
||||
placeholder="/downloads",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
),
|
||||
|
||||
# --- Deluge Settings ---
|
||||
TextField(
|
||||
key="DELUGE_HOST",
|
||||
label="Deluge Web UI Host/URL",
|
||||
description="Hostname/IP or full URL of your Deluge Web UI (deluge-web)",
|
||||
placeholder="http://deluge:8112",
|
||||
default="localhost",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
),
|
||||
TextField(
|
||||
key="DELUGE_PORT",
|
||||
label="Deluge Web UI Port",
|
||||
description="Deluge Web UI port (default: 8112)",
|
||||
placeholder="8112",
|
||||
default="8112",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
),
|
||||
PasswordField(
|
||||
key="DELUGE_PASSWORD",
|
||||
label="Password",
|
||||
description="Deluge Web UI password (default: deluge)",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_deluge",
|
||||
label="Test Connection",
|
||||
description="Verify your Deluge configuration",
|
||||
style="primary",
|
||||
callback=_test_deluge_connection,
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
),
|
||||
TextField(
|
||||
key="DELUGE_CATEGORY",
|
||||
label="Book Label",
|
||||
description="Label to assign to book downloads in Deluge",
|
||||
placeholder="books",
|
||||
default="books",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
),
|
||||
TextField(
|
||||
key="DELUGE_CATEGORY_AUDIOBOOK",
|
||||
label="Audiobook Label",
|
||||
description="Label for audiobook downloads. Leave empty to use the book label.",
|
||||
placeholder="",
|
||||
default="",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
),
|
||||
TextField(
|
||||
key="DELUGE_DOWNLOAD_DIR",
|
||||
label="Download Directory",
|
||||
description="Server-side directory where torrents are downloaded (optional, uses Deluge default if not specified)",
|
||||
placeholder="/downloads",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
),
|
||||
|
||||
# --- rTorrent Settings ---
|
||||
TextField(
|
||||
key="RTORRENT_URL",
|
||||
label="rTorrent URL",
|
||||
description="XML-RPC URL of your rTorrent instance",
|
||||
placeholder="http://rtorrent:6881/RPC2",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
||||
),
|
||||
TextField(
|
||||
key="RTORRENT_USERNAME",
|
||||
label="Username",
|
||||
description="HTTP Basic auth username (if authentication enabled)",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
||||
),
|
||||
PasswordField(
|
||||
key="RTORRENT_PASSWORD",
|
||||
label="Password",
|
||||
description="HTTP Basic auth password",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_rtorrent",
|
||||
label="Test Connection",
|
||||
description="Verify your rTorrent configuration",
|
||||
style="primary",
|
||||
callback=_test_rtorrent_connection,
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
||||
),
|
||||
TextField(
|
||||
key="RTORRENT_LABEL",
|
||||
label="Book Label",
|
||||
description="Label to assign to book downloads in rTorrent",
|
||||
placeholder="cwabd",
|
||||
default="cwabd",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
||||
),
|
||||
TextField(
|
||||
key="RTORRENT_DOWNLOAD_DIR",
|
||||
label="Download Directory",
|
||||
description="Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified)",
|
||||
placeholder="/downloads",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
||||
),
|
||||
# Note: Torrent client download path must be mounted identically in both containers.
|
||||
# Torrents are always copied (not moved) to preserve seeding capability.
|
||||
|
||||
# --- Usenet Client Selection ---
|
||||
HeadingField(
|
||||
key="usenet_heading",
|
||||
title="Usenet Client",
|
||||
description="Select and configure a usenet client for downloading NZB releases.",
|
||||
),
|
||||
SelectField(
|
||||
key="PROWLARR_USENET_CLIENT",
|
||||
label="Usenet Client",
|
||||
description="Choose which usenet client to use",
|
||||
options=[
|
||||
{"value": "", "label": "None"},
|
||||
{"value": "nzbget", "label": "NZBGet"},
|
||||
{"value": "sabnzbd", "label": "SABnzbd"},
|
||||
],
|
||||
default="",
|
||||
),
|
||||
|
||||
# --- NZBGet Settings ---
|
||||
TextField(
|
||||
key="NZBGET_URL",
|
||||
label="NZBGet URL",
|
||||
description="URL of your NZBGet instance",
|
||||
placeholder="http://nzbget:6789",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
|
||||
),
|
||||
TextField(
|
||||
key="NZBGET_USERNAME",
|
||||
label="Username",
|
||||
description="NZBGet control username",
|
||||
placeholder="nzbget",
|
||||
default="nzbget",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
|
||||
),
|
||||
PasswordField(
|
||||
key="NZBGET_PASSWORD",
|
||||
label="Password",
|
||||
description="NZBGet control password",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_nzbget",
|
||||
label="Test Connection",
|
||||
description="Verify your NZBGet configuration",
|
||||
style="primary",
|
||||
callback=_test_nzbget_connection,
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
|
||||
),
|
||||
TextField(
|
||||
key="NZBGET_CATEGORY",
|
||||
label="Book Category",
|
||||
description="Category to assign to book downloads in NZBGet",
|
||||
placeholder="Books",
|
||||
default="Books",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
|
||||
),
|
||||
TextField(
|
||||
key="NZBGET_CATEGORY_AUDIOBOOK",
|
||||
label="Audiobook Category",
|
||||
description="Category for audiobook downloads. Leave empty to use the book category.",
|
||||
placeholder="",
|
||||
default="",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
|
||||
),
|
||||
|
||||
# --- SABnzbd Settings ---
|
||||
TextField(
|
||||
key="SABNZBD_URL",
|
||||
label="SABnzbd URL",
|
||||
description="URL of your SABnzbd instance",
|
||||
placeholder="http://sabnzbd:8080",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
|
||||
),
|
||||
PasswordField(
|
||||
key="SABNZBD_API_KEY",
|
||||
label="API Key",
|
||||
description="Found in SABnzbd: Config > General > API Key",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_sabnzbd",
|
||||
label="Test Connection",
|
||||
description="Verify your SABnzbd configuration",
|
||||
style="primary",
|
||||
callback=_test_sabnzbd_connection,
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
|
||||
),
|
||||
TextField(
|
||||
key="SABNZBD_CATEGORY",
|
||||
label="Book Category",
|
||||
description="Category to assign to book downloads in SABnzbd",
|
||||
placeholder="books",
|
||||
default="books",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
|
||||
),
|
||||
TextField(
|
||||
key="SABNZBD_CATEGORY_AUDIOBOOK",
|
||||
label="Audiobook Category",
|
||||
description="Category for audiobook downloads. Leave empty to use the book category.",
|
||||
placeholder="",
|
||||
default="",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
|
||||
),
|
||||
# Note: Usenet client download path must be mounted identically in both containers.
|
||||
SelectField(
|
||||
key="PROWLARR_USENET_ACTION",
|
||||
label="NZB Completion Action",
|
||||
description="Move deletes the job from your usenet client after import; Copy keeps it in the client",
|
||||
options=[
|
||||
{"value": "move", "label": "Move"},
|
||||
{"value": "copy", "label": "Copy"},
|
||||
],
|
||||
default="move",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "notEmpty": True},
|
||||
),
|
||||
]
|
||||
+106
-15
@@ -5,10 +5,11 @@ import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.parse import parse_qs, urljoin, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -30,39 +31,79 @@ class TorrentInfo:
|
||||
magnet_url: Optional[str] = None
|
||||
"""The actual magnet URL, if available."""
|
||||
|
||||
def with_info_hash(self, info_hash: Optional[str]) -> "TorrentInfo":
|
||||
"""Return a copy with the info_hash replaced when provided."""
|
||||
if info_hash:
|
||||
return TorrentInfo(
|
||||
info_hash=info_hash,
|
||||
torrent_data=self.torrent_data,
|
||||
is_magnet=self.is_magnet,
|
||||
magnet_url=self.magnet_url,
|
||||
)
|
||||
return self
|
||||
|
||||
def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo:
|
||||
"""Extract info_hash from magnet link or .torrent URL."""
|
||||
|
||||
def extract_torrent_info(
|
||||
url: str,
|
||||
fetch_torrent: bool = True,
|
||||
expected_hash: Optional[str] = None,
|
||||
) -> TorrentInfo:
|
||||
"""Extract info_hash from magnet link or .torrent URL.
|
||||
|
||||
Notes:
|
||||
When the URL points at Prowlarr's proxied download endpoint, it typically
|
||||
requires the `X-Api-Key` header. If `PROWLARR_API_KEY` is configured,
|
||||
include it for the torrent fetch request.
|
||||
|
||||
Redirects to magnet links are handled explicitly so we can extract a
|
||||
hash from the magnet when available.
|
||||
"""
|
||||
is_magnet = url.startswith("magnet:")
|
||||
|
||||
# Try to extract hash from magnet URL
|
||||
if is_magnet:
|
||||
info_hash = extract_hash_from_magnet(url)
|
||||
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=url)
|
||||
|
||||
# Not a magnet - try to fetch and parse the .torrent file
|
||||
if not fetch_torrent:
|
||||
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False)
|
||||
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||
|
||||
headers: dict[str, str] = {"Accept": "application/x-bittorrent"}
|
||||
# TODO: Move this source-specific Prowlarr auth handling into a source hook.
|
||||
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
|
||||
if api_key:
|
||||
headers["X-Api-Key"] = api_key
|
||||
|
||||
def resolve_url(current: str, location: str) -> str:
|
||||
if not location:
|
||||
return current
|
||||
# Support relative redirect locations
|
||||
return urljoin(current, location)
|
||||
|
||||
try:
|
||||
logger.debug(f"Fetching torrent file from: {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)
|
||||
resp = requests.get(url, timeout=30, allow_redirects=False, headers=headers)
|
||||
|
||||
# Check if this is a redirect to a magnet link
|
||||
if resp.status_code in (301, 302, 303, 307, 308):
|
||||
redirect_url = resp.headers.get("Location", "")
|
||||
redirect_url = resolve_url(url, resp.headers.get("Location", ""))
|
||||
if redirect_url.startswith("magnet:"):
|
||||
logger.debug(f"Download URL redirected to magnet link")
|
||||
logger.debug("Download URL redirected to magnet link")
|
||||
info_hash = extract_hash_from_magnet(redirect_url)
|
||||
if not info_hash and expected_hash:
|
||||
info_hash = expected_hash
|
||||
return TorrentInfo(
|
||||
info_hash=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)
|
||||
resp = requests.get(redirect_url, timeout=30, headers=headers)
|
||||
|
||||
resp.raise_for_status()
|
||||
torrent_data = resp.content
|
||||
@@ -75,13 +116,15 @@ def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo:
|
||||
if text_content.startswith("magnet:"):
|
||||
logger.debug("Download URL returned magnet link as response body")
|
||||
info_hash = extract_hash_from_magnet(text_content)
|
||||
if not info_hash and expected_hash:
|
||||
info_hash = expected_hash
|
||||
return TorrentInfo(
|
||||
info_hash=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)
|
||||
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}")
|
||||
else:
|
||||
@@ -89,12 +132,15 @@ def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo:
|
||||
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}")
|
||||
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False)
|
||||
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||
|
||||
|
||||
def parse_transmission_url(url: str) -> Tuple[str, int, str]:
|
||||
"""Parse Transmission URL into (host, port, path)."""
|
||||
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()
|
||||
if protocol not in ("http", "https"):
|
||||
protocol = "http"
|
||||
host = parsed.hostname or "localhost"
|
||||
port = parsed.port or 9091
|
||||
path = parsed.path or "/transmission/rpc"
|
||||
@@ -103,7 +149,7 @@ def parse_transmission_url(url: str) -> Tuple[str, int, str]:
|
||||
if not path.endswith("/rpc"):
|
||||
path = path.rstrip("/") + "/transmission/rpc"
|
||||
|
||||
return host, port, path
|
||||
return protocol, host, port, path
|
||||
|
||||
|
||||
def bencode_decode(data: bytes) -> tuple:
|
||||
@@ -181,7 +227,10 @@ def extract_info_hash_from_torrent(torrent_data: bytes) -> Optional[str]:
|
||||
return None
|
||||
|
||||
info_bencoded = bencode_encode(decoded[b'info'])
|
||||
return hashlib.sha1(info_bencoded).hexdigest().lower()
|
||||
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}")
|
||||
return None
|
||||
@@ -195,7 +244,42 @@ def extract_hash_from_magnet(magnet_url: str) -> Optional[str]:
|
||||
parsed = urlparse(magnet_url)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
for xt in params.get("xt", []):
|
||||
def extract_btmh(value: str) -> Optional[str]:
|
||||
raw_value = value.strip()
|
||||
if not raw_value:
|
||||
return None
|
||||
|
||||
data: Optional[bytes] = None
|
||||
if re.fullmatch(r"[a-fA-F0-9]+", raw_value):
|
||||
if len(raw_value) % 2 != 0:
|
||||
return None
|
||||
try:
|
||||
data = bytes.fromhex(raw_value)
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
padded = raw_value.upper() + "=" * (-len(raw_value) % 8)
|
||||
try:
|
||||
data = base64.b32decode(padded, casefold=True)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
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:
|
||||
return digest.hex().lower()
|
||||
|
||||
if len(data) == 32:
|
||||
return data.hex().lower()
|
||||
|
||||
return None
|
||||
|
||||
xt_values = params.get("xt", [])
|
||||
|
||||
for xt in xt_values:
|
||||
# Format: urn:btih:<hash> (32 or 40 chars)
|
||||
match = re.match(r"urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})", xt)
|
||||
if match:
|
||||
@@ -215,4 +299,11 @@ def extract_hash_from_magnet(magnet_url: str) -> Optional[str]:
|
||||
# Fallback: return as-is
|
||||
return hash_value.lower()
|
||||
|
||||
for xt in xt_values:
|
||||
if xt.startswith("urn:btmh:"):
|
||||
btmh_value = xt[len("urn:btmh:"):]
|
||||
btmh_hash = extract_btmh(btmh_value)
|
||||
if btmh_hash:
|
||||
return btmh_hash
|
||||
|
||||
return None
|
||||
+77
-34
@@ -6,14 +6,16 @@ Uses the transmission-rpc library to communicate with Transmission's RPC API.
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.release_sources.prowlarr.clients import (
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.clients import (
|
||||
DownloadClient,
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.release_sources.prowlarr.clients.torrent_utils import (
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
parse_transmission_url,
|
||||
)
|
||||
@@ -32,30 +34,50 @@ class TransmissionClient(DownloadClient):
|
||||
"""Initialize Transmission client with settings from config."""
|
||||
from transmission_rpc import Client
|
||||
|
||||
url = config.get("TRANSMISSION_URL", "")
|
||||
if not url:
|
||||
raw_url = config.get("TRANSMISSION_URL", "")
|
||||
if not raw_url:
|
||||
raise ValueError("TRANSMISSION_URL is required")
|
||||
|
||||
url = normalize_http_url(raw_url)
|
||||
if not url:
|
||||
raise ValueError("TRANSMISSION_URL is invalid")
|
||||
|
||||
username = config.get("TRANSMISSION_USERNAME", "")
|
||||
password = config.get("TRANSMISSION_PASSWORD", "")
|
||||
|
||||
# Parse URL to extract host, port, and path
|
||||
host, port, path = parse_transmission_url(url)
|
||||
protocol, host, port, path = parse_transmission_url(url)
|
||||
|
||||
self._client = Client(
|
||||
host=host,
|
||||
port=port,
|
||||
path=path,
|
||||
username=username if username else None,
|
||||
password=password if password else None,
|
||||
)
|
||||
self._category = config.get("TRANSMISSION_CATEGORY", "cwabd")
|
||||
client_kwargs = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"path": path,
|
||||
"username": username if username else None,
|
||||
"password": password if password else None,
|
||||
"protocol": protocol,
|
||||
}
|
||||
try:
|
||||
self._client = Client(**client_kwargs)
|
||||
except TypeError as e:
|
||||
# Older transmission-rpc versions may not accept protocol as a kwarg.
|
||||
if "protocol" not in str(e):
|
||||
raise
|
||||
client_kwargs.pop("protocol", None)
|
||||
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
|
||||
self._category = config.get("TRANSMISSION_CATEGORY", "books")
|
||||
self._download_dir = config.get("TRANSMISSION_DOWNLOAD_DIR", "")
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
"""Check if Transmission is configured and selected as the torrent client."""
|
||||
client = config.get("PROWLARR_TORRENT_CLIENT", "")
|
||||
url = config.get("TRANSMISSION_URL", "")
|
||||
url = normalize_http_url(config.get("TRANSMISSION_URL", ""))
|
||||
return client == "transmission" and bool(url)
|
||||
|
||||
def test_connection(self) -> Tuple[bool, str]:
|
||||
@@ -67,7 +89,14 @@ class TransmissionClient(DownloadClient):
|
||||
except Exception as e:
|
||||
return False, f"Connection failed: {str(e)}"
|
||||
|
||||
def add_download(self, url: str, name: str, category: str = None) -> str:
|
||||
def add_download(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
category: Optional[str] = None,
|
||||
expected_hash: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""
|
||||
Add torrent by URL (magnet or .torrent).
|
||||
|
||||
@@ -75,6 +104,7 @@ class TransmissionClient(DownloadClient):
|
||||
url: Magnet link or .torrent URL
|
||||
name: Display name for the torrent
|
||||
category: Category for organization (uses configured default if not specified)
|
||||
expected_hash: Optional info_hash hint (from Prowlarr)
|
||||
|
||||
Returns:
|
||||
Torrent hash (info_hash).
|
||||
@@ -83,21 +113,27 @@ class TransmissionClient(DownloadClient):
|
||||
Exception: If adding fails.
|
||||
"""
|
||||
try:
|
||||
category = category or self._category
|
||||
resolved_category = category or self._category or ""
|
||||
|
||||
torrent_info = extract_torrent_info(url)
|
||||
torrent_info = extract_torrent_info(url, expected_hash=expected_hash)
|
||||
add_kwargs = {}
|
||||
|
||||
if resolved_category:
|
||||
add_kwargs["labels"] = [resolved_category]
|
||||
if self._download_dir:
|
||||
add_kwargs["download_dir"] = self._download_dir
|
||||
|
||||
if torrent_info.torrent_data:
|
||||
torrent = self._client.add_torrent(
|
||||
torrent=torrent_info.torrent_data,
|
||||
labels=[category],
|
||||
**add_kwargs,
|
||||
)
|
||||
else:
|
||||
# Use magnet URL if available, otherwise original URL
|
||||
add_url = torrent_info.magnet_url or url
|
||||
torrent = self._client.add_torrent(
|
||||
torrent=add_url,
|
||||
labels=[category],
|
||||
**add_kwargs,
|
||||
)
|
||||
|
||||
torrent_hash = torrent.hashString.lower()
|
||||
@@ -163,9 +199,14 @@ class TransmissionClient(DownloadClient):
|
||||
# Get file path for completed downloads
|
||||
file_path = None
|
||||
if complete:
|
||||
download_dir = torrent.download_dir
|
||||
name = torrent.name
|
||||
file_path = f"{download_dir}/{name}"
|
||||
# Output path is downloadDir + torrent name (with ':' replaced)
|
||||
torrent_name = getattr(torrent, 'name', '')
|
||||
if isinstance(torrent_name, str):
|
||||
torrent_name = torrent_name.replace(':', '_')
|
||||
file_path = self._build_path(
|
||||
getattr(torrent, 'download_dir', ''),
|
||||
torrent_name,
|
||||
)
|
||||
|
||||
return DownloadStatus(
|
||||
progress=progress,
|
||||
@@ -180,9 +221,7 @@ class TransmissionClient(DownloadClient):
|
||||
except KeyError:
|
||||
return DownloadStatus.error("Torrent not found")
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
logger.error(f"Transmission get_status failed ({error_type}): {e}")
|
||||
return DownloadStatus.error(f"{error_type}: {e}")
|
||||
return DownloadStatus.error(self._log_error("get_status", e))
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
"""
|
||||
@@ -206,8 +245,7 @@ class TransmissionClient(DownloadClient):
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
logger.error(f"Transmission remove failed ({error_type}): {e}")
|
||||
self._log_error("remove", e)
|
||||
return False
|
||||
|
||||
def get_download_path(self, download_id: str) -> Optional[str]:
|
||||
@@ -221,16 +259,21 @@ class TransmissionClient(DownloadClient):
|
||||
Content path (file or directory), or None.
|
||||
"""
|
||||
try:
|
||||
torrent = self._client.get_torrent(download_id)
|
||||
download_dir = torrent.download_dir
|
||||
name = torrent.name
|
||||
return f"{download_dir}/{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:
|
||||
error_type = type(e).__name__
|
||||
logger.debug(f"Transmission get_download_path failed ({error_type}): {e}")
|
||||
self._log_error("get_download_path", e, level="debug")
|
||||
return None
|
||||
|
||||
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
def find_existing(
|
||||
self, url: str, category: Optional[str] = None
|
||||
) -> Optional[Tuple[str, DownloadStatus]]:
|
||||
"""Check if a torrent for this URL already exists in Transmission."""
|
||||
try:
|
||||
torrent_info = extract_torrent_info(url)
|
||||
+355
-73
@@ -8,12 +8,99 @@ import errno
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, TypeVar, cast
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.download.permissions_debug import log_transfer_permission_context
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
try:
|
||||
from gevent import monkey as _gevent_monkey
|
||||
from gevent.threadpool import ThreadPool as _GeventThreadPool
|
||||
except Exception:
|
||||
_gevent_monkey = None
|
||||
_GeventThreadPool = None
|
||||
|
||||
T = TypeVar("T")
|
||||
_IO_THREADPOOL: Optional["_GeventThreadPool"] = None
|
||||
|
||||
|
||||
def _use_gevent_threadpool() -> bool:
|
||||
return bool(
|
||||
_gevent_monkey
|
||||
and _GeventThreadPool
|
||||
and _gevent_monkey.is_module_patched("threading")
|
||||
)
|
||||
|
||||
|
||||
def _get_io_threadpool() -> "_GeventThreadPool":
|
||||
global _IO_THREADPOOL
|
||||
if _IO_THREADPOOL is None:
|
||||
pool_size = max(2, min(8, os.cpu_count() or 2))
|
||||
_IO_THREADPOOL = _GeventThreadPool(pool_size)
|
||||
return _IO_THREADPOOL
|
||||
|
||||
|
||||
def _call_and_capture(func: Callable[..., T], args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[bool, T | Exception]:
|
||||
try:
|
||||
return True, func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
return False, exc
|
||||
|
||||
|
||||
def run_blocking_io(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,
|
||||
even when the caller expects and handles those errors (e.g. FileExistsError for
|
||||
collision retries, EXDEV for cross-device moves). Capture and re-raise in the
|
||||
caller to avoid noisy, misleading tracebacks.
|
||||
"""
|
||||
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)
|
||||
raise exc
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
_VERIFY_IO_WAIT_SECONDS = 3.0
|
||||
|
||||
|
||||
def _verify_transfer_size(
|
||||
dest: Path,
|
||||
expected_size: int,
|
||||
action: str,
|
||||
) -> None:
|
||||
"""Verify file transfer completed successfully.
|
||||
|
||||
Some filesystems (especially remote NAS/CIFS/NFS) can report stale sizes briefly
|
||||
after large writes. Do a second stat after a short delay before declaring failure.
|
||||
"""
|
||||
# On network filesystems, `stat()` can block long enough to starve the gevent hub.
|
||||
actual_size = run_blocking_io(dest.stat).st_size
|
||||
if actual_size == expected_size:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"File {action} size mismatch, waiting for filesystem sync: {dest} "
|
||||
f"({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(
|
||||
f"File {action} incomplete, data loss may have occurred. "
|
||||
f"'{dest}' was {actual_size} bytes instead of expected {expected_size}."
|
||||
)
|
||||
|
||||
|
||||
def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path:
|
||||
"""Write data to a file with atomic collision detection.
|
||||
@@ -40,11 +127,16 @@ def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path:
|
||||
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
|
||||
try:
|
||||
# O_CREAT | O_EXCL fails atomically if file exists
|
||||
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
fd = run_blocking_io(
|
||||
os.open,
|
||||
str(try_path),
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
||||
0o666,
|
||||
)
|
||||
try:
|
||||
os.write(fd, data)
|
||||
run_blocking_io(os.write, fd, data)
|
||||
finally:
|
||||
os.close(fd)
|
||||
run_blocking_io(os.close, fd)
|
||||
if attempt > 0:
|
||||
logger.info(f"File collision resolved: {try_path.name}")
|
||||
return try_path
|
||||
@@ -61,51 +153,139 @@ def _is_permission_error(e: Exception) -> bool:
|
||||
|
||||
def _system_op(op: str, source: Path, dest: Path) -> None:
|
||||
"""Execute system command (mv or cp) as final fallback."""
|
||||
logger.info(f"Attempting system {op} as final fallback: {source} -> {dest}")
|
||||
subprocess.run(
|
||||
logger.warning("Attempting system %s as final fallback: %s -> %s", op, source, dest)
|
||||
run_blocking_io(
|
||||
subprocess.run,
|
||||
[op, "-f", str(source), str(dest)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None:
|
||||
"""Handle NFS permission errors by falling back to copyfile -> system op."""
|
||||
"""Handle NFS/SMB permission errors by falling back to copyfile -> system op."""
|
||||
expected_size = run_blocking_io(source.stat).st_size
|
||||
|
||||
try:
|
||||
# Fallback 1: copy content only
|
||||
shutil.copyfile(str(source), str(dest))
|
||||
|
||||
run_blocking_io(shutil.copyfile, str(source), str(dest))
|
||||
_verify_transfer_size(dest, expected_size, "copy")
|
||||
|
||||
if is_move:
|
||||
# Verify copy success before removing source
|
||||
if dest.exists() and dest.stat().st_size == source.stat().st_size:
|
||||
source.unlink()
|
||||
return
|
||||
else:
|
||||
raise IOError(f"Copy verification failed for {source} -> {dest}")
|
||||
run_blocking_io(source.unlink)
|
||||
return
|
||||
|
||||
except Exception as copy_error:
|
||||
# Clean up failed copy attempt if it exists
|
||||
if dest.exists():
|
||||
dest.unlink(missing_ok=True)
|
||||
|
||||
logger.error(f"Fallback copyfile failed: {copy_error}")
|
||||
|
||||
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)
|
||||
|
||||
# Fallback 2: system command
|
||||
op = "mv" if is_move else "cp"
|
||||
try:
|
||||
_system_op(op, source, dest)
|
||||
# Best-effort verify after external command.
|
||||
if run_blocking_io(dest.exists):
|
||||
_verify_transfer_size(dest, expected_size, op)
|
||||
if is_move:
|
||||
run_blocking_io(source.unlink, missing_ok=True)
|
||||
except subprocess.CalledProcessError as sys_error:
|
||||
logger.error(f"System {op} failed: {sys_error.stderr}")
|
||||
dest.unlink(missing_ok=True)
|
||||
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)
|
||||
run_blocking_io(dest.unlink, missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _claim_destination(path: Path) -> bool:
|
||||
"""Atomically claim a destination path by creating a placeholder file.
|
||||
|
||||
Returns True if the placeholder was created. Caller must replace or unlink it.
|
||||
"""
|
||||
try:
|
||||
fd = run_blocking_io(
|
||||
os.open,
|
||||
str(path),
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
||||
0o666,
|
||||
)
|
||||
except FileExistsError:
|
||||
return False
|
||||
else:
|
||||
run_blocking_io(os.close, fd)
|
||||
return True
|
||||
|
||||
|
||||
def _hardlink_not_supported(error: OSError) -> bool:
|
||||
err = error.errno
|
||||
return err in {
|
||||
errno.EXDEV,
|
||||
errno.EMLINK,
|
||||
errno.EPERM,
|
||||
errno.EACCES,
|
||||
getattr(errno, "ENOTSUP", errno.EPERM),
|
||||
getattr(errno, "EOPNOTSUPP", errno.EPERM),
|
||||
errno.EINVAL,
|
||||
}
|
||||
|
||||
|
||||
def _create_temp_path(dest_path: Path) -> Path:
|
||||
fd, temp_path = run_blocking_io(
|
||||
tempfile.mkstemp,
|
||||
prefix=f".{dest_path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=str(dest_path.parent),
|
||||
)
|
||||
run_blocking_io(os.close, fd)
|
||||
return Path(temp_path)
|
||||
|
||||
|
||||
def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
|
||||
"""Publish a temp file to its final path without overwriting existing files.
|
||||
|
||||
Returns True on success, False if the destination already exists.
|
||||
"""
|
||||
try:
|
||||
run_blocking_io(os.link, str(temp_path), str(dest_path))
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
return True
|
||||
except FileExistsError:
|
||||
return False
|
||||
except OSError as e:
|
||||
if _is_permission_error(e):
|
||||
log_transfer_permission_context(
|
||||
"publish_hardlink",
|
||||
source=temp_path,
|
||||
dest=dest_path,
|
||||
error=e,
|
||||
)
|
||||
if _hardlink_not_supported(e):
|
||||
logger.debug(
|
||||
"Hardlink publish unsupported; falling back to claim+replace: %s -> %s (%s)",
|
||||
temp_path,
|
||||
dest_path,
|
||||
e,
|
||||
)
|
||||
claimed = _claim_destination(dest_path)
|
||||
if not claimed:
|
||||
return False
|
||||
try:
|
||||
run_blocking_io(os.replace, str(temp_path), str(dest_path))
|
||||
except Exception:
|
||||
run_blocking_io(dest_path.unlink, missing_ok=True)
|
||||
raise
|
||||
return True
|
||||
raise
|
||||
|
||||
|
||||
def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path:
|
||||
"""Move a file with collision detection.
|
||||
|
||||
Uses os.rename() for same-filesystem moves (atomic, triggers inotify events),
|
||||
falls back to exclusive create + shutil.move for cross-filesystem moves.
|
||||
falls back to copy-then-publish for cross-filesystem moves.
|
||||
|
||||
Note: We use os.rename() instead of hardlink+unlink because os.rename()
|
||||
triggers proper inotify IN_MOVED_TO events that file watchers (like Calibre's
|
||||
@@ -130,49 +310,111 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
|
||||
|
||||
# Check for existing file (os.rename would overwrite on Unix)
|
||||
if try_path.exists():
|
||||
continue
|
||||
claimed = False
|
||||
if run_blocking_io(try_path.exists):
|
||||
# Some filesystems can report false positives for exists() with
|
||||
# special characters. Probe with O_EXCL to confirm.
|
||||
claimed = _claim_destination(try_path)
|
||||
if not claimed:
|
||||
continue
|
||||
|
||||
try:
|
||||
# os.rename is atomic on same filesystem and triggers inotify events
|
||||
os.rename(str(source_path), str(try_path))
|
||||
if claimed:
|
||||
run_blocking_io(os.replace, str(source_path), str(try_path))
|
||||
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
|
||||
except FileExistsError:
|
||||
# Race condition: file created between exists() check and rename()
|
||||
if claimed:
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
continue
|
||||
except OSError as e:
|
||||
# Cross-filesystem - fall back to exclusive create + move
|
||||
# Cross-filesystem - copy to temp and publish atomically.
|
||||
if e.errno != errno.EXDEV:
|
||||
if claimed:
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
raise
|
||||
|
||||
expected_size = run_blocking_io(source_path.stat).st_size
|
||||
if claimed:
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
claimed = False
|
||||
|
||||
temp_path: Optional[Path] = None
|
||||
try:
|
||||
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.move(str(source_path), str(try_path))
|
||||
temp_path = _create_temp_path(try_path)
|
||||
try:
|
||||
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
|
||||
except (PermissionError, OSError) as copy_error:
|
||||
if _is_permission_error(copy_error):
|
||||
logger.debug(
|
||||
"Permission error during move-copy, falling back to copyfile (%s -> %s): %s",
|
||||
source_path,
|
||||
temp_path,
|
||||
copy_error,
|
||||
)
|
||||
_perform_nfs_fallback(source_path, temp_path, is_move=False)
|
||||
else:
|
||||
raise
|
||||
|
||||
_verify_transfer_size(temp_path, expected_size, "move")
|
||||
published = _publish_temp_file(temp_path, try_path)
|
||||
if not published:
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
continue
|
||||
|
||||
try:
|
||||
_verify_transfer_size(try_path, expected_size, "move")
|
||||
except Exception:
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
raise
|
||||
|
||||
run_blocking_io(source_path.unlink)
|
||||
|
||||
if attempt > 0:
|
||||
logger.info(f"File collision resolved: {try_path.name}")
|
||||
return try_path
|
||||
|
||||
except FileExistsError:
|
||||
if temp_path:
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
continue
|
||||
except Exception:
|
||||
# Clean up the placeholder if move failed
|
||||
if try_path.exists() and try_path.stat().st_size == 0:
|
||||
try_path.unlink(missing_ok=True)
|
||||
if temp_path:
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
raise
|
||||
except FileExistsError:
|
||||
continue
|
||||
|
||||
except (PermissionError, OSError) as e:
|
||||
# Handle NFS permission errors (e.g. inability to set metadata)
|
||||
if _is_permission_error(e):
|
||||
logger.debug(f"Permission error during move, falling back to copyfile: {e}")
|
||||
log_transfer_permission_context(
|
||||
"atomic_move",
|
||||
source=source_path,
|
||||
dest=try_path,
|
||||
error=e,
|
||||
)
|
||||
logger.debug(
|
||||
"Permission error during move, falling back to copyfile (%s -> %s): %s",
|
||||
source_path,
|
||||
try_path,
|
||||
e,
|
||||
)
|
||||
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
|
||||
except Exception as fallback_error:
|
||||
# Fallback failed, chain exceptions for better debugging
|
||||
logger.error(f"NFS fallback also failed: {fallback_error}")
|
||||
logger.error(
|
||||
"NFS fallback also failed (%s -> %s): %s",
|
||||
source_path,
|
||||
try_path,
|
||||
fallback_error,
|
||||
)
|
||||
raise e from fallback_error
|
||||
raise
|
||||
|
||||
@@ -200,12 +442,29 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100)
|
||||
for attempt in range(max_attempts):
|
||||
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
|
||||
try:
|
||||
os.link(str(source_path), str(try_path))
|
||||
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
|
||||
except FileExistsError:
|
||||
continue
|
||||
except OSError as e:
|
||||
if _is_permission_error(e) or e.errno in (errno.EXDEV, errno.EMLINK):
|
||||
if _is_permission_error(e):
|
||||
log_transfer_permission_context(
|
||||
"atomic_hardlink",
|
||||
source=source_path,
|
||||
dest=try_path,
|
||||
error=e,
|
||||
)
|
||||
logger.debug(
|
||||
"Hardlink failed (%s), falling back to copy: %s -> %s",
|
||||
e,
|
||||
source_path,
|
||||
dest_path,
|
||||
)
|
||||
return atomic_copy(source_path, dest_path, max_attempts=max_attempts)
|
||||
raise
|
||||
|
||||
raise RuntimeError(f"Could not create hardlink after {max_attempts} attempts: {dest_path}")
|
||||
|
||||
@@ -213,8 +472,8 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100)
|
||||
def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path:
|
||||
"""Copy a file with atomic collision detection.
|
||||
|
||||
Uses exclusive create to claim destination, then copies via temp file
|
||||
to avoid partial files on failure.
|
||||
Uses a temp file in the destination directory and publishes it atomically,
|
||||
avoiding partial files on failure.
|
||||
|
||||
Args:
|
||||
source_path: Source file to copy
|
||||
@@ -230,40 +489,63 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
base = dest_path.stem
|
||||
ext = dest_path.suffix
|
||||
parent = dest_path.parent
|
||||
expected_size = run_blocking_io(source_path.stat).st_size
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
|
||||
try:
|
||||
# Atomically claim the destination by creating an exclusive file
|
||||
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.close(fd)
|
||||
|
||||
# Copy to temp file first, then replace to avoid partial files
|
||||
temp_path = try_path.parent / f".{try_path.name}.tmp"
|
||||
try:
|
||||
try:
|
||||
shutil.copy2(str(source_path), str(temp_path))
|
||||
except (PermissionError, OSError) as e:
|
||||
# Handle NFS permission errors immediately here
|
||||
if _is_permission_error(e):
|
||||
logger.debug(f"Permission error during copy, falling back to copyfile: {e}")
|
||||
try:
|
||||
_perform_nfs_fallback(source_path, temp_path, is_move=False)
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"NFS fallback also failed: {fallback_error}")
|
||||
raise e from fallback_error
|
||||
else:
|
||||
raise
|
||||
|
||||
temp_path.replace(try_path)
|
||||
if attempt > 0:
|
||||
logger.info(f"File collision resolved: {try_path.name}")
|
||||
return try_path
|
||||
except Exception:
|
||||
try_path.unlink(missing_ok=True)
|
||||
temp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
except FileExistsError:
|
||||
if run_blocking_io(try_path.exists):
|
||||
continue
|
||||
temp_path: Optional[Path] = None
|
||||
try:
|
||||
temp_path = _create_temp_path(try_path)
|
||||
try:
|
||||
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
|
||||
except (PermissionError, OSError) as e:
|
||||
# Handle NFS permission errors immediately here
|
||||
if _is_permission_error(e):
|
||||
log_transfer_permission_context(
|
||||
"atomic_copy",
|
||||
source=source_path,
|
||||
dest=temp_path,
|
||||
error=e,
|
||||
)
|
||||
logger.debug(
|
||||
"Permission error during copy, falling back to copyfile (%s -> %s): %s",
|
||||
source_path,
|
||||
temp_path,
|
||||
e,
|
||||
)
|
||||
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",
|
||||
source_path,
|
||||
temp_path,
|
||||
fallback_error,
|
||||
)
|
||||
raise e from fallback_error
|
||||
else:
|
||||
raise
|
||||
|
||||
_verify_transfer_size(temp_path, expected_size, "copy")
|
||||
published = _publish_temp_file(temp_path, try_path)
|
||||
if not published:
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
continue
|
||||
|
||||
try:
|
||||
_verify_transfer_size(try_path, expected_size, "copy")
|
||||
except Exception:
|
||||
run_blocking_io(try_path.unlink, missing_ok=True)
|
||||
raise
|
||||
|
||||
if attempt > 0:
|
||||
logger.info(f"File collision resolved: {try_path.name}")
|
||||
return try_path
|
||||
except Exception:
|
||||
if temp_path:
|
||||
run_blocking_io(temp_path.unlink, missing_ok=True)
|
||||
raise
|
||||
|
||||
raise RuntimeError(f"Could not copy file after {max_attempts} attempts: {dest_path}")
|
||||
|
||||
+130
-18
@@ -3,9 +3,9 @@
|
||||
import random
|
||||
import time
|
||||
from io import BytesIO
|
||||
from threading import Event
|
||||
from threading import Event, Thread
|
||||
from typing import Callable, Optional
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
@@ -176,8 +176,25 @@ def html_get_page(
|
||||
selector: Optional[network.AAMirrorSelector] = None,
|
||||
cancel_flag: Optional[Event] = None,
|
||||
status_callback: Optional[Callable[[str, Optional[str]], None]] = None,
|
||||
) -> str:
|
||||
"""Fetch HTML content from a URL with retry mechanism."""
|
||||
allow_bypasser_fallback: bool = True,
|
||||
include_response_url: bool = False,
|
||||
success_delay: float = 1.0,
|
||||
session: Optional[requests.Session] = None,
|
||||
) -> str | tuple[str, str]:
|
||||
"""Fetch HTML content from a URL with retry mechanism.
|
||||
|
||||
Args:
|
||||
allow_bypasser_fallback: If False, 403 errors will trigger mirror rotation
|
||||
instead of switching to the bypasser. Use for search operations.
|
||||
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
|
||||
return html
|
||||
|
||||
retry = retry if retry is not None else app_config.MAX_RETRY
|
||||
selector = selector or network.AAMirrorSelector()
|
||||
original_url = url
|
||||
@@ -188,34 +205,129 @@ def html_get_page(
|
||||
# Check for cancellation before each attempt
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
logger.info(f"html_get_page cancelled before attempt {attempt}")
|
||||
return ""
|
||||
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")
|
||||
status_callback("resolving", "Bypassing protection...")
|
||||
heartbeat_stop = Event()
|
||||
heartbeat_thread: Optional[Thread] = None
|
||||
if status_callback:
|
||||
def _heartbeat() -> 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")
|
||||
heartbeat_thread.start()
|
||||
try:
|
||||
result = get_bypassed_page(current_url, selector, cancel_flag)
|
||||
return result or ""
|
||||
return _result(result or "", current_url)
|
||||
except Exception as e:
|
||||
logger.warning(f"Bypasser error: {type(e).__name__}: {e}")
|
||||
return ""
|
||||
return _result("", current_url)
|
||||
finally:
|
||||
heartbeat_stop.set()
|
||||
if heartbeat_thread:
|
||||
heartbeat_thread.join(timeout=1)
|
||||
|
||||
logger.debug(f"GET: {current_url}")
|
||||
# Try with CF cookies/UA if available (from previous bypass)
|
||||
headers = {}
|
||||
cookies = _apply_cf_bypass(current_url, headers)
|
||||
response = requests.get(current_url, proxies=get_proxies(current_url), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers)
|
||||
response.raise_for_status()
|
||||
time.sleep(1)
|
||||
return response.text
|
||||
|
||||
# Use a browser-like UA by default (AA can behave differently for python-requests UA).
|
||||
headers = {"User-Agent": DOWNLOAD_HEADERS["User-Agent"]}
|
||||
|
||||
# AA mirrors sometimes redirect to other (seized/dead) mirror domains. If we let
|
||||
# requests follow those redirects, the request fails on DNS and we rotate away
|
||||
# from an otherwise working mirror. Handle AA redirects manually instead.
|
||||
is_aa_url = network.should_rotate_dns_for_url(current_url)
|
||||
allow_redirects = not is_aa_url
|
||||
|
||||
redirects_followed = 0
|
||||
while True:
|
||||
# Try with CF cookies/UA if available (from previous bypass)
|
||||
cookies = _apply_cf_bypass(current_url, headers)
|
||||
request_client = session or requests
|
||||
response = request_client.get(
|
||||
current_url,
|
||||
proxies=get_proxies(current_url),
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
cookies=cookies,
|
||||
headers=headers,
|
||||
allow_redirects=allow_redirects,
|
||||
)
|
||||
|
||||
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}")
|
||||
|
||||
redirect_url = urljoin(current_url, location)
|
||||
current_host = urlparse(current_url).hostname or ""
|
||||
redirect_host = urlparse(redirect_url).hostname or ""
|
||||
|
||||
# If an AA mirror redirects to a different hostname, treat that as a mirror
|
||||
# failure and rotate rather than following the redirect (auto mode only).
|
||||
if current_host and redirect_host and current_host != redirect_host:
|
||||
if not network.is_aa_auto_mode():
|
||||
logger.warning(
|
||||
"AA mirror locked to %s but redirected to %s: %s",
|
||||
current_host,
|
||||
redirect_host,
|
||||
current_url,
|
||||
)
|
||||
return _result("", current_url)
|
||||
|
||||
new_url = _try_rotation(original_url, current_url, selector)
|
||||
if new_url:
|
||||
current_url = new_url
|
||||
# Reset per-request state for the new host.
|
||||
headers = {"User-Agent": DOWNLOAD_HEADERS["User-Agent"]}
|
||||
is_aa_url = network.should_rotate_dns_for_url(current_url)
|
||||
allow_redirects = not is_aa_url
|
||||
redirects_followed = 0
|
||||
continue
|
||||
|
||||
logger.warning(
|
||||
"AA redirect from %s to %s but mirrors exhausted: %s",
|
||||
current_host,
|
||||
redirect_host,
|
||||
current_url,
|
||||
)
|
||||
return _result("", current_url)
|
||||
|
||||
# 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}")
|
||||
current_url = redirect_url
|
||||
continue
|
||||
|
||||
response.raise_for_status()
|
||||
if success_delay > 0:
|
||||
time.sleep(success_delay)
|
||||
return _result(response.text, response.url)
|
||||
|
||||
except Exception as e:
|
||||
status = _get_status_code(e)
|
||||
|
||||
# 403 = Cloudflare/DDoS-Guard protection
|
||||
if status == 403:
|
||||
# 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}")
|
||||
return _result("", current_url)
|
||||
|
||||
if _is_cf_bypass_enabled() and not use_bypasser_now:
|
||||
# Before switching to bypasser, check if cookies have become available
|
||||
# (another concurrent download may have completed bypass and extracted cookies)
|
||||
@@ -231,12 +343,12 @@ def html_get_page(
|
||||
use_bypasser_now = True
|
||||
continue
|
||||
logger.warning(f"403 error, giving up: {current_url}")
|
||||
return ""
|
||||
return _result("", current_url)
|
||||
|
||||
# 404 = Not found
|
||||
if status == 404:
|
||||
logger.warning(f"404 error: {current_url}")
|
||||
return ""
|
||||
return _result("", current_url)
|
||||
|
||||
# Try mirror/DNS rotation on retryable errors
|
||||
if _is_retryable_error(e):
|
||||
@@ -252,7 +364,7 @@ def html_get_page(
|
||||
else:
|
||||
logger.error(f"Giving up after {retry} attempts: {current_url}")
|
||||
|
||||
return ""
|
||||
return _result("", current_url)
|
||||
|
||||
|
||||
def download_url(
|
||||
|
||||
@@ -12,6 +12,7 @@ import ipaddress
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.config import config as app_config
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
@@ -279,9 +280,33 @@ def _decode_port(port: Union[str, bytes, int, None]) -> int:
|
||||
return int(port)
|
||||
|
||||
def _is_local_address(host_str: str) -> bool:
|
||||
"""Check if an address is local or private and should bypass custom DNS."""
|
||||
if host_str == 'localhost':
|
||||
"""Check if an address is local/private and should bypass custom DNS.
|
||||
|
||||
Returns True for:
|
||||
- 'localhost'
|
||||
- Private/loopback/link-local IP addresses
|
||||
- Simple hostnames without a dot (e.g., 'booklore', 'prowlarr') - likely Docker service names
|
||||
- Hostnames ending in common internal TLDs (.local, .internal, .lan, .home, .docker)
|
||||
"""
|
||||
if not host_str:
|
||||
return False
|
||||
|
||||
host_lower = host_str.lower()
|
||||
|
||||
# Check for localhost
|
||||
if host_lower == 'localhost':
|
||||
return True
|
||||
|
||||
# Check for simple hostnames (no dot = likely internal Docker/container name)
|
||||
if '.' not in host_str:
|
||||
return True
|
||||
|
||||
# Check for common internal TLDs
|
||||
internal_tlds = ('.local', '.internal', '.lan', '.home', '.docker', '.localdomain')
|
||||
if any(host_lower.endswith(tld) for tld in internal_tlds):
|
||||
return True
|
||||
|
||||
# Check for private/loopback/link-local IP addresses
|
||||
try:
|
||||
addr = ipaddress.ip_address(host_str)
|
||||
return addr.is_private or addr.is_loopback or addr.is_link_local
|
||||
@@ -705,12 +730,30 @@ def rotate_dns_and_reset_aa() -> bool:
|
||||
return False
|
||||
# Reset AA URL to first available auto option if using auto AA
|
||||
global _aa_base_url, _current_aa_url_index
|
||||
configured_url = app_config.get("AA_BASE_URL", "auto")
|
||||
if configured_url == "auto" or _aa_base_url in _aa_urls:
|
||||
configured_url = normalize_http_url(
|
||||
app_config.get("AA_BASE_URL", "auto"),
|
||||
default_scheme="https",
|
||||
allow_special=("auto",),
|
||||
)
|
||||
if not configured_url:
|
||||
configured_url = "auto"
|
||||
|
||||
if configured_url == "auto":
|
||||
# Auto mode always resets to the first mirror to restart the cascade
|
||||
_current_aa_url_index = 0
|
||||
_aa_base_url = _aa_urls[0] if _aa_urls else "https://annas-archive.se"
|
||||
_aa_base_url = _aa_urls[0] if _aa_urls else "https://annas-archive.gl"
|
||||
logger.info(f"After DNS switch, resetting AA URL to: {_aa_base_url}")
|
||||
_save_state(aa_url=_aa_base_url)
|
||||
else:
|
||||
# Keep the user's configured primary mirror (if it exists in the list),
|
||||
# otherwise keep the configured URL as-is (custom/env).
|
||||
if configured_url in _aa_urls:
|
||||
_current_aa_url_index = _aa_urls.index(configured_url)
|
||||
else:
|
||||
_current_aa_url_index = 0
|
||||
_aa_base_url = configured_url
|
||||
logger.info(f"After DNS switch, keeping configured AA URL: {_aa_base_url}")
|
||||
_save_state(aa_url=_aa_base_url)
|
||||
return True
|
||||
|
||||
def set_dns_provider(provider: str, manual_servers: list[str] | None = None, use_doh: bool | None = None) -> bool:
|
||||
@@ -876,7 +919,18 @@ def _initialize_aa_state() -> None:
|
||||
_aa_urls = _build_aa_urls()
|
||||
|
||||
# Get configured base URL from config
|
||||
configured_url = app_config.get("AA_BASE_URL", "auto")
|
||||
configured_url = normalize_http_url(
|
||||
app_config.get("AA_BASE_URL", "auto"),
|
||||
default_scheme="https",
|
||||
allow_special=("auto",),
|
||||
)
|
||||
if not configured_url:
|
||||
configured_url = "auto"
|
||||
|
||||
# If AA_BASE_URL is pinned to a custom URL that's not in the mirror list, we still
|
||||
# want to treat it as the active base (and rewrite known mirror links to it).
|
||||
if configured_url != "auto" and configured_url not in _aa_urls:
|
||||
_aa_urls = [configured_url] + _aa_urls
|
||||
|
||||
if configured_url == "auto":
|
||||
if state.get('aa_base_url') and state['aa_base_url'] in _aa_urls:
|
||||
@@ -993,6 +1047,17 @@ def get_aa_base_url():
|
||||
_ensure_initialized()
|
||||
return _aa_base_url
|
||||
|
||||
def is_aa_auto_mode() -> bool:
|
||||
"""Return True when AA_BASE_URL is set to 'auto' (mirror failover enabled)."""
|
||||
configured_url = normalize_http_url(
|
||||
app_config.get("AA_BASE_URL", "auto"),
|
||||
default_scheme="https",
|
||||
allow_special=("auto",),
|
||||
)
|
||||
if not configured_url:
|
||||
configured_url = "auto"
|
||||
return configured_url == "auto"
|
||||
|
||||
def get_available_aa_urls():
|
||||
"""Get list of configured AA URLs (copy)."""
|
||||
_ensure_initialized()
|
||||
@@ -1044,12 +1109,17 @@ class AAMirrorSelector:
|
||||
Returns (new_base, action) where action is 'mirror', 'dns', or 'exhausted'.
|
||||
"""
|
||||
self.attempts_this_dns += 1
|
||||
if self.attempts_this_dns >= len(self.aa_urls):
|
||||
max_attempts = len(self.aa_urls) if is_aa_auto_mode() else 1
|
||||
if self.attempts_this_dns >= max_attempts:
|
||||
if allow_dns and rotate_dns_and_reset_aa():
|
||||
self._ensure_fresh_state(reset_attempts=True)
|
||||
return self.current_base, "dns"
|
||||
return None, "exhausted"
|
||||
|
||||
if not is_aa_auto_mode():
|
||||
# Mirror is explicitly configured; do not fail over to other mirrors.
|
||||
return None, "exhausted"
|
||||
|
||||
next_index = (self._index + 1) % len(self.aa_urls)
|
||||
set_aa_url_index(next_index)
|
||||
self._ensure_fresh_state(reset_attempts=False)
|
||||
|
||||
+183
-689
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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], Optional[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputRegistration:
|
||||
mode: str
|
||||
supports_task: Callable[[DownloadTask], bool]
|
||||
handler: OutputHandler
|
||||
priority: int = 0
|
||||
|
||||
|
||||
_OUTPUT_REGISTRY: list[OutputRegistration] = []
|
||||
_OUTPUTS_LOADED = False
|
||||
|
||||
|
||||
def register_output(
|
||||
mode: str,
|
||||
supports_task: Callable[[DownloadTask], bool],
|
||||
priority: int = 0,
|
||||
) -> Callable[[OutputHandler], OutputHandler]:
|
||||
def decorator(handler: OutputHandler) -> OutputHandler:
|
||||
_OUTPUT_REGISTRY.append(
|
||||
OutputRegistration(
|
||||
mode=mode,
|
||||
supports_task=supports_task,
|
||||
handler=handler,
|
||||
priority=priority,
|
||||
)
|
||||
)
|
||||
_OUTPUT_REGISTRY.sort(key=lambda entry: entry.priority, reverse=True)
|
||||
return handler
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def load_output_handlers() -> None:
|
||||
global _OUTPUTS_LOADED
|
||||
if _OUTPUTS_LOADED:
|
||||
return
|
||||
|
||||
from . import booklore # noqa: F401
|
||||
from . import email # noqa: F401
|
||||
from . import folder # noqa: F401
|
||||
|
||||
_OUTPUTS_LOADED = True
|
||||
|
||||
|
||||
def _normalize_output_mode(value: object) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def _derive_output_mode(task: DownloadTask) -> str:
|
||||
"""Return the desired output mode for a task.
|
||||
|
||||
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
|
||||
|
||||
# Legacy / defensive fallback: derive from current config.
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
|
||||
if check_audiobook(getattr(task, "content_type", None)):
|
||||
return "folder"
|
||||
|
||||
return _normalize_output_mode(config.get("BOOKS_OUTPUT_MODE", "folder")) or "folder"
|
||||
|
||||
|
||||
def resolve_output_handler(task: DownloadTask) -> Optional[OutputRegistration]:
|
||||
load_output_handlers()
|
||||
desired_mode = _derive_output_mode(task)
|
||||
|
||||
# Prefer a direct mode match. `supports_task` becomes a capability check
|
||||
# (e.g., prevent email/booklore for audiobooks).
|
||||
for entry in _OUTPUT_REGISTRY:
|
||||
if entry.mode == desired_mode and entry.supports_task(task):
|
||||
return entry
|
||||
|
||||
# If the requested output isn't supported for this task, fall back to folder.
|
||||
for entry in _OUTPUT_REGISTRY:
|
||||
if entry.mode == "folder" and entry.supports_task(task):
|
||||
return entry
|
||||
|
||||
# Last-resort fallback: keep the legacy "first supporting handler" behavior.
|
||||
for entry in _OUTPUT_REGISTRY:
|
||||
if entry.supports_task(task):
|
||||
return entry
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,388 @@
|
||||
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
|
||||
|
||||
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_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir
|
||||
|
||||
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_FORMATS_LABEL = ", ".join(
|
||||
ext.lstrip(".").upper() for ext in sorted(BOOKLORE_SUPPORTED_EXTENSIONS)
|
||||
)
|
||||
|
||||
|
||||
class BookloreError(Exception):
|
||||
"""Raised when Booklore integration fails."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BookloreConfig:
|
||||
base_url: str
|
||||
username: str
|
||||
password: str
|
||||
library_id: int
|
||||
path_id: int
|
||||
verify_tls: bool = True
|
||||
upload_to_bookdrop: bool = False
|
||||
refresh_after_upload: bool = False
|
||||
|
||||
|
||||
def _parse_int(value: Any, label: str) -> int:
|
||||
if value is None or value == "":
|
||||
raise BookloreError(f"{label} is required")
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise BookloreError(f"{label} must be a number") from exc
|
||||
|
||||
|
||||
def _parse_destination(value: Any) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized == BOOKLORE_DESTINATION_BOOKDROP:
|
||||
return BOOKLORE_DESTINATION_BOOKDROP
|
||||
return BOOKLORE_DESTINATION_LIBRARY
|
||||
|
||||
|
||||
def build_booklore_config(
|
||||
values: Mapping[str, Any],
|
||||
user_id: Optional[int] = 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("Booklore URL is required")
|
||||
if not username:
|
||||
raise BookloreError("Booklore username is required")
|
||||
if not password:
|
||||
raise BookloreError("Booklore password is required")
|
||||
|
||||
destination = _parse_destination(
|
||||
values.get("BOOKLORE_DESTINATION", BOOKLORE_DESTINATION_LIBRARY)
|
||||
)
|
||||
upload_to_bookdrop = destination == BOOKLORE_DESTINATION_BOOKDROP
|
||||
|
||||
# Resolve library/path through config so user override precedence is centralized.
|
||||
library_id = 0
|
||||
path_id = 0
|
||||
if not upload_to_bookdrop:
|
||||
if user_id is not None:
|
||||
library_id_val = core_config.config.get(
|
||||
"BOOKLORE_LIBRARY_ID",
|
||||
values.get("BOOKLORE_LIBRARY_ID"),
|
||||
user_id=user_id,
|
||||
)
|
||||
path_id_val = core_config.config.get(
|
||||
"BOOKLORE_PATH_ID",
|
||||
values.get("BOOKLORE_PATH_ID"),
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
library_id_val = values.get("BOOKLORE_LIBRARY_ID")
|
||||
path_id_val = values.get("BOOKLORE_PATH_ID")
|
||||
|
||||
library_id = _parse_int(library_id_val, "Booklore library ID")
|
||||
path_id = _parse_int(path_id_val, "Booklore path ID")
|
||||
|
||||
return BookloreConfig(
|
||||
base_url=base_url.rstrip("/"),
|
||||
username=username,
|
||||
password=password,
|
||||
library_id=library_id,
|
||||
path_id=path_id,
|
||||
verify_tls=True,
|
||||
upload_to_bookdrop=upload_to_bookdrop,
|
||||
refresh_after_upload=not upload_to_bookdrop,
|
||||
)
|
||||
|
||||
|
||||
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}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload, timeout=30, verify=booklore_config.verify_tls)
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
raise BookloreError("Could not connect to Booklore") from exc
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise BookloreError("Booklore connection timed out") from exc
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise BookloreError(f"Booklore login failed: {exc}") from exc
|
||||
|
||||
if response.status_code in {401, 403}:
|
||||
raise BookloreError("Booklore authentication failed")
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
raise BookloreError(f"Booklore login failed ({response.status_code})") from exc
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise BookloreError("Invalid Booklore login response") from exc
|
||||
|
||||
token = data.get("accessToken")
|
||||
if not token:
|
||||
raise BookloreError("Booklore did not return an access token")
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def booklore_list_libraries(booklore_config: BookloreConfig, token: str) -> list[dict[str, Any]]:
|
||||
url = f"{booklore_config.base_url}/api/v1/libraries"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=30, verify=booklore_config.verify_tls)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise BookloreError(f"Failed to fetch Booklore libraries: {exc}") from exc
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise BookloreError("Invalid Booklore libraries response") from exc
|
||||
|
||||
|
||||
def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path: Path) -> None:
|
||||
if booklore_config.upload_to_bookdrop:
|
||||
url = f"{booklore_config.base_url}/api/v1/files/upload/bookdrop"
|
||||
params = None
|
||||
else:
|
||||
url = f"{booklore_config.base_url}/api/v1/files/upload"
|
||||
params = {"libraryId": booklore_config.library_id, "pathId": booklore_config.path_id}
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = None
|
||||
|
||||
try:
|
||||
with file_path.open("rb") as handle:
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
files={"file": (file_path.name, handle)},
|
||||
timeout=60,
|
||||
verify=booklore_config.verify_tls,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
message = response.text.strip() if response is not None else ""
|
||||
if message:
|
||||
message = f": {message[:200]}"
|
||||
status_code = response.status_code if response is not None else "unknown"
|
||||
raise BookloreError(f"Booklore upload failed ({status_code}){message}") from exc
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
raise BookloreError("Could not connect to Booklore") from exc
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise BookloreError("Booklore upload timed out") from exc
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise BookloreError(f"Booklore upload failed: {exc}") from exc
|
||||
|
||||
|
||||
def booklore_refresh_library(booklore_config: BookloreConfig, token: str) -> None:
|
||||
url = f"{booklore_config.base_url}/api/v1/libraries/{booklore_config.library_id}/refresh"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
response = requests.put(url, headers=headers, timeout=30, verify=booklore_config.verify_tls)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise BookloreError(f"Booklore refresh failed: {exc}") from exc
|
||||
|
||||
|
||||
def _supports_booklore(task: DownloadTask) -> bool:
|
||||
return not check_audiobook(task.content_type)
|
||||
|
||||
|
||||
def _get_booklore_settings() -> Dict[str, Any]:
|
||||
return {
|
||||
"BOOKLORE_HOST": core_config.config.get("BOOKLORE_HOST", ""),
|
||||
"BOOKLORE_USERNAME": core_config.config.get("BOOKLORE_USERNAME", ""),
|
||||
"BOOKLORE_PASSWORD": core_config.config.get("BOOKLORE_PASSWORD", ""),
|
||||
"BOOKLORE_DESTINATION": core_config.config.get(
|
||||
"BOOKLORE_DESTINATION",
|
||||
BOOKLORE_DESTINATION_LIBRARY,
|
||||
),
|
||||
"BOOKLORE_LIBRARY_ID": core_config.config.get("BOOKLORE_LIBRARY_ID"),
|
||||
"BOOKLORE_PATH_ID": core_config.config.get("BOOKLORE_PATH_ID"),
|
||||
}
|
||||
|
||||
|
||||
def _booklore_format_error(rejected_files: List[Path]) -> str:
|
||||
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
|
||||
rejected_list = ", ".join(rejected_exts)
|
||||
return (
|
||||
f"Booklore does not support {rejected_list}. "
|
||||
f"Supported formats: {BOOKLORE_SUPPORTED_FORMATS_LABEL}"
|
||||
)
|
||||
|
||||
|
||||
def _post_process_booklore(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
) -> Optional[str]:
|
||||
from shelfmark.download.postprocess.pipeline import (
|
||||
CustomScriptContext,
|
||||
OutputPlan,
|
||||
cleanup_output_staging,
|
||||
is_managed_workspace_path,
|
||||
maybe_run_custom_script,
|
||||
prepare_output_files,
|
||||
)
|
||||
|
||||
if cancel_flag.is_set():
|
||||
logger.info("Task %s: cancelled before Booklore upload", task.task_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
booklore_config = build_booklore_config(
|
||||
_get_booklore_settings(),
|
||||
user_id=task.user_id,
|
||||
)
|
||||
except BookloreError as e:
|
||||
logger.warning("Task %s: Booklore configuration error: %s", task.task_id, e)
|
||||
status_callback("error", str(e))
|
||||
return None
|
||||
|
||||
status_callback("resolving", "Preparing Booklore upload")
|
||||
|
||||
stage_action = STAGE_MOVE if is_managed_workspace_path(temp_file) else STAGE_NONE
|
||||
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,
|
||||
stage_action=stage_action,
|
||||
staging_dir=staging_dir,
|
||||
allow_archive_extraction=True,
|
||||
)
|
||||
|
||||
prepared = prepare_output_files(
|
||||
temp_file,
|
||||
task,
|
||||
BOOKLORE_OUTPUT_MODE,
|
||||
status_callback,
|
||||
output_plan=output_plan,
|
||||
)
|
||||
if not prepared:
|
||||
return None
|
||||
|
||||
logger.debug("Task %s: prepared %d file(s) for Booklore upload", task.task_id, len(prepared.files))
|
||||
|
||||
try:
|
||||
unsupported_files = [
|
||||
file_path
|
||||
for file_path in prepared.files
|
||||
if file_path.suffix.lower() not in BOOKLORE_SUPPORTED_EXTENSIONS
|
||||
]
|
||||
if unsupported_files:
|
||||
error_message = _booklore_format_error(unsupported_files)
|
||||
logger.warning("Task %s: %s", task.task_id, error_message)
|
||||
status_callback("error", error_message)
|
||||
return None
|
||||
|
||||
token = booklore_login(booklore_config)
|
||||
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 ({index}/{len(prepared.files)})")
|
||||
booklore_upload_file(booklore_config, token, file_path)
|
||||
|
||||
if booklore_config.refresh_after_upload:
|
||||
try:
|
||||
booklore_refresh_library(booklore_config, token)
|
||||
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))
|
||||
|
||||
destination: Optional[Path]
|
||||
if len(prepared.files) == 1:
|
||||
destination = prepared.files[0].parent
|
||||
else:
|
||||
try:
|
||||
destination = Path(os.path.commonpath([str(p.parent) for p in prepared.files]))
|
||||
except ValueError:
|
||||
destination = prepared.files[0].parent if prepared.files else None
|
||||
|
||||
script_context = CustomScriptContext(
|
||||
task=task,
|
||||
phase="post_upload",
|
||||
output_mode=BOOKLORE_OUTPUT_MODE,
|
||||
destination=destination,
|
||||
final_paths=prepared.files,
|
||||
output_details={
|
||||
"booklore": {
|
||||
"base_url": booklore_config.base_url,
|
||||
"destination": (
|
||||
BOOKLORE_DESTINATION_BOOKDROP
|
||||
if booklore_config.upload_to_bookdrop
|
||||
else BOOKLORE_DESTINATION_LIBRARY
|
||||
),
|
||||
"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,
|
||||
"refresh_after_upload": bool(booklore_config.refresh_after_upload),
|
||||
}
|
||||
},
|
||||
)
|
||||
if not maybe_run_custom_script(script_context, status_callback=status_callback):
|
||||
return None
|
||||
|
||||
message = "Uploaded to Booklore"
|
||||
if len(prepared.files) > 1:
|
||||
message = f"Uploaded to Booklore ({len(prepared.files)} files)"
|
||||
status_callback("complete", message)
|
||||
return f"booklore://{task.task_id}"
|
||||
|
||||
except BookloreError as e:
|
||||
logger.warning("Task %s: Booklore upload failed: %s", task.task_id, e)
|
||||
status_callback("error", str(e))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error_trace("Task %s: unexpected error uploading to Booklore: %s", task.task_id, e)
|
||||
status_callback("error", f"Booklore upload failed: {e}")
|
||||
return None
|
||||
finally:
|
||||
cleanup_output_staging(
|
||||
prepared.output_plan,
|
||||
prepared.working_path,
|
||||
task,
|
||||
prepared.cleanup_paths,
|
||||
)
|
||||
|
||||
|
||||
@register_output(BOOKLORE_OUTPUT_MODE, supports_task=_supports_booklore, priority=10)
|
||||
def process_booklore_output(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
) -> Optional[str]:
|
||||
return _post_process_booklore(temp_file, task, cancel_flag, status_callback)
|
||||
@@ -0,0 +1,428 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import smtplib
|
||||
import ssl
|
||||
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
|
||||
|
||||
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_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
EMAIL_OUTPUT_MODE = "email"
|
||||
|
||||
SECURITY_NONE = "none"
|
||||
SECURITY_STARTTLS = "starttls"
|
||||
SECURITY_SSL = "ssl"
|
||||
ALLOWED_SECURITY = {SECURITY_NONE, SECURITY_STARTTLS, SECURITY_SSL}
|
||||
|
||||
|
||||
class EmailOutputError(Exception):
|
||||
"""Raised when the email output integration fails."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailSmtpConfig:
|
||||
host: str
|
||||
port: int
|
||||
security: str
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
from_addr: str = ""
|
||||
timeout_seconds: int = 60
|
||||
allow_unverified_tls: bool = False
|
||||
subject_template: str = "{Title}"
|
||||
|
||||
|
||||
def _parse_int(value: Any, label: str, *, minimum: int = 1) -> int:
|
||||
if value is None or value == "":
|
||||
raise EmailOutputError(f"{label} is required")
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise EmailOutputError(f"{label} must be a number") from exc
|
||||
if parsed < minimum:
|
||||
raise EmailOutputError(f"{label} must be >= {minimum}")
|
||||
return parsed
|
||||
|
||||
|
||||
def build_email_smtp_config(values: Mapping[str, Any]) -> EmailSmtpConfig:
|
||||
host = str(values.get("EMAIL_SMTP_HOST", "") or "").strip()
|
||||
port = _parse_int(values.get("EMAIL_SMTP_PORT", 587), "SMTP port", minimum=1)
|
||||
|
||||
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))}")
|
||||
|
||||
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)
|
||||
allow_unverified_tls = bool(values.get("EMAIL_ALLOW_UNVERIFIED_TLS", False))
|
||||
|
||||
if not host:
|
||||
raise EmailOutputError("SMTP host is required")
|
||||
if username and not password:
|
||||
raise EmailOutputError("SMTP password is required when username is set")
|
||||
|
||||
if not from_addr:
|
||||
# If From is not configured, fall back to the SMTP username if it is an email address.
|
||||
username_email = parseaddr(username)[1]
|
||||
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).")
|
||||
|
||||
return EmailSmtpConfig(
|
||||
host=host,
|
||||
port=port,
|
||||
security=security,
|
||||
username=username,
|
||||
password=password,
|
||||
from_addr=from_addr,
|
||||
timeout_seconds=timeout_seconds,
|
||||
allow_unverified_tls=allow_unverified_tls,
|
||||
subject_template=subject_template or "{Title}",
|
||||
)
|
||||
|
||||
|
||||
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),
|
||||
"EMAIL_SMTP_SECURITY": core_config.config.get("EMAIL_SMTP_SECURITY", SECURITY_STARTTLS),
|
||||
"EMAIL_SMTP_USERNAME": core_config.config.get("EMAIL_SMTP_USERNAME", ""),
|
||||
"EMAIL_SMTP_PASSWORD": core_config.config.get("EMAIL_SMTP_PASSWORD", ""),
|
||||
"EMAIL_FROM": core_config.config.get("EMAIL_FROM", ""),
|
||||
"EMAIL_SUBJECT_TEMPLATE": core_config.config.get("EMAIL_SUBJECT_TEMPLATE", "{Title}"),
|
||||
"EMAIL_SMTP_TIMEOUT_SECONDS": core_config.config.get("EMAIL_SMTP_TIMEOUT_SECONDS", 60),
|
||||
"EMAIL_ALLOW_UNVERIFIED_TLS": core_config.config.get("EMAIL_ALLOW_UNVERIFIED_TLS", False),
|
||||
}
|
||||
|
||||
|
||||
def _render_subject(template: str, task: DownloadTask) -> str:
|
||||
mapping = {
|
||||
"Author": task.author or "",
|
||||
"Title": task.title or "",
|
||||
"Year": task.year or "",
|
||||
"Series": task.series_name or "",
|
||||
"SeriesPosition": task.series_position or "",
|
||||
"Subtitle": task.subtitle or "",
|
||||
"Format": task.format or "",
|
||||
}
|
||||
try:
|
||||
rendered = template.format(**mapping)
|
||||
except Exception:
|
||||
rendered = template
|
||||
|
||||
rendered = " ".join(str(rendered).split()).strip()
|
||||
return rendered or "Shelfmark"
|
||||
|
||||
|
||||
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 = ""
|
||||
return domain or "shelfmark.local"
|
||||
|
||||
|
||||
def compose_email_message(
|
||||
smtp_config: EmailSmtpConfig,
|
||||
*,
|
||||
task: DownloadTask,
|
||||
recipient: str,
|
||||
files: list[Path],
|
||||
) -> EmailMessage:
|
||||
message = EmailMessage()
|
||||
message["From"] = smtp_config.from_addr
|
||||
message["To"] = recipient
|
||||
message["Subject"] = _render_subject(smtp_config.subject_template, task)
|
||||
message["Date"] = formatdate(localtime=True)
|
||||
message["Message-ID"] = make_msgid(domain=_msgid_domain(smtp_config.from_addr))
|
||||
|
||||
# Keep email body empty; attachments carry the content.
|
||||
message.set_content("")
|
||||
|
||||
for file_path in files:
|
||||
filename = file_path.name
|
||||
data = file_path.read_bytes()
|
||||
|
||||
content_type, encoding = mimetypes.guess_type(filename)
|
||||
if content_type is None or encoding is not None:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
main_type, sub_type = content_type.split("/", 1)
|
||||
message.add_attachment(data, maintype=main_type, subtype=sub_type, filename=filename)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def _create_tls_context(allow_unverified: bool) -> ssl.SSLContext:
|
||||
context = ssl.create_default_context()
|
||||
if allow_unverified:
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
return context
|
||||
|
||||
|
||||
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
|
||||
try:
|
||||
if smtp_config.security == SECURITY_SSL:
|
||||
context = _create_tls_context(smtp_config.allow_unverified_tls)
|
||||
smtp = smtplib.SMTP_SSL(
|
||||
smtp_config.host,
|
||||
smtp_config.port,
|
||||
timeout=smtp_config.timeout_seconds,
|
||||
context=context,
|
||||
)
|
||||
else:
|
||||
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)
|
||||
smtp.starttls(context=context)
|
||||
smtp.ehlo()
|
||||
|
||||
if smtp_config.username:
|
||||
smtp.login(smtp_config.username, smtp_config.password)
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
raise EmailOutputError("SMTP authentication failed") from exc
|
||||
except (smtplib.SMTPConnectError, smtplib.SMTPServerDisconnected, TimeoutError, OSError) as exc:
|
||||
raise EmailOutputError(f"Could not connect to SMTP server: {exc}") from exc
|
||||
finally:
|
||||
if smtp is not None:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def send_email_message(smtp_config: EmailSmtpConfig, message: EmailMessage) -> None:
|
||||
smtp: Optional[smtplib.SMTP] = None
|
||||
try:
|
||||
if smtp_config.security == SECURITY_SSL:
|
||||
context = _create_tls_context(smtp_config.allow_unverified_tls)
|
||||
smtp = smtplib.SMTP_SSL(
|
||||
smtp_config.host,
|
||||
smtp_config.port,
|
||||
timeout=smtp_config.timeout_seconds,
|
||||
context=context,
|
||||
)
|
||||
else:
|
||||
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)
|
||||
smtp.starttls(context=context)
|
||||
smtp.ehlo()
|
||||
|
||||
if smtp_config.username:
|
||||
smtp.login(smtp_config.username, smtp_config.password)
|
||||
|
||||
smtp.send_message(message)
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
raise EmailOutputError("SMTP authentication failed") from exc
|
||||
except (smtplib.SMTPException, TimeoutError, OSError) as exc:
|
||||
raise EmailOutputError(f"Failed to send email: {exc}") from exc
|
||||
finally:
|
||||
if smtp is not None:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _supports_email(task: DownloadTask) -> bool:
|
||||
return not check_audiobook(task.content_type)
|
||||
|
||||
|
||||
def _post_process_email(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
) -> Optional[str]:
|
||||
from shelfmark.download.postprocess.pipeline import (
|
||||
CustomScriptContext,
|
||||
OutputPlan,
|
||||
cleanup_output_staging,
|
||||
is_managed_workspace_path,
|
||||
maybe_run_custom_script,
|
||||
prepare_output_files,
|
||||
)
|
||||
|
||||
if cancel_flag.is_set():
|
||||
logger.info("Task %s: cancelled before email send", task.task_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
smtp_config = build_email_smtp_config(_get_email_settings())
|
||||
except EmailOutputError as exc:
|
||||
logger.warning("Task %s: email configuration error: %s", task.task_id, exc)
|
||||
status_callback("error", str(exc))
|
||||
return None
|
||||
|
||||
output_args = task.output_args or {}
|
||||
if not isinstance(output_args, dict):
|
||||
output_args = {}
|
||||
|
||||
recipient = str(output_args.get("to", "") or "").strip()
|
||||
label = str(output_args.get("label", "") or "").strip() or recipient
|
||||
if not recipient:
|
||||
status_callback(
|
||||
"error",
|
||||
"No email recipient configured. Set a per-user email recipient or a default in Downloads -> Books.",
|
||||
)
|
||||
return None
|
||||
|
||||
status_callback("resolving", "Preparing email")
|
||||
|
||||
stage_action = STAGE_MOVE if is_managed_workspace_path(temp_file) else STAGE_NONE
|
||||
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,
|
||||
stage_action=stage_action,
|
||||
staging_dir=staging_dir,
|
||||
allow_archive_extraction=True,
|
||||
)
|
||||
|
||||
prepared = prepare_output_files(
|
||||
temp_file,
|
||||
task,
|
||||
EMAIL_OUTPUT_MODE,
|
||||
status_callback,
|
||||
output_plan=output_plan,
|
||||
)
|
||||
if not prepared:
|
||||
return None
|
||||
|
||||
try:
|
||||
limit_mb_raw = core_config.config.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25)
|
||||
try:
|
||||
attachment_limit_mb = int(limit_mb_raw)
|
||||
except (TypeError, ValueError):
|
||||
attachment_limit_mb = 25
|
||||
|
||||
if attachment_limit_mb > 0:
|
||||
limit_bytes = attachment_limit_mb * 1024 * 1024
|
||||
file_sizes: list[tuple[Path, int]] = []
|
||||
total_bytes = 0
|
||||
|
||||
for file_path in prepared.files:
|
||||
try:
|
||||
size_bytes = file_path.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
file_sizes.append((file_path, size_bytes))
|
||||
total_bytes += size_bytes
|
||||
|
||||
too_large = [(path, size) for path, size in file_sizes if size > limit_bytes]
|
||||
if too_large:
|
||||
path, size = max(too_large, key=lambda item: item[1])
|
||||
status_callback(
|
||||
"error",
|
||||
f"Attachment '{path.name}' is {size / (1024 * 1024):.1f} MB (limit {attachment_limit_mb} MB)",
|
||||
)
|
||||
return None
|
||||
|
||||
# Most providers enforce a message size limit and attachments are base64-encoded (~33% overhead).
|
||||
estimated_encoded_bytes = int(total_bytes * 4 / 3)
|
||||
if estimated_encoded_bytes > limit_bytes:
|
||||
status_callback(
|
||||
"error",
|
||||
(
|
||||
f"Attachments total {total_bytes / (1024 * 1024):.1f} MB "
|
||||
f"(estimated encoded {estimated_encoded_bytes / (1024 * 1024):.1f} MB) "
|
||||
f"exceeds limit {attachment_limit_mb} MB"
|
||||
),
|
||||
)
|
||||
return None
|
||||
|
||||
if cancel_flag.is_set():
|
||||
logger.info("Task %s: cancelled before email send", task.task_id)
|
||||
return None
|
||||
|
||||
status_callback("resolving", f"Sending email to {label}")
|
||||
message = compose_email_message(
|
||||
smtp_config,
|
||||
task=task,
|
||||
recipient=recipient,
|
||||
files=prepared.files,
|
||||
)
|
||||
send_email_message(smtp_config, message)
|
||||
|
||||
script_context = CustomScriptContext(
|
||||
task=task,
|
||||
phase="post_email",
|
||||
output_mode=EMAIL_OUTPUT_MODE,
|
||||
destination=prepared.files[0].parent if prepared.files else None,
|
||||
final_paths=prepared.files,
|
||||
output_details={
|
||||
"email": {
|
||||
"to": recipient,
|
||||
"label": label,
|
||||
"host": smtp_config.host,
|
||||
"port": smtp_config.port,
|
||||
"security": smtp_config.security,
|
||||
}
|
||||
},
|
||||
)
|
||||
if not maybe_run_custom_script(script_context, status_callback=status_callback):
|
||||
return None
|
||||
|
||||
status_callback("complete", f"Sent to {label}")
|
||||
return f"email://{task.task_id}"
|
||||
|
||||
except EmailOutputError as exc:
|
||||
logger.warning("Task %s: email send failed: %s", task.task_id, exc)
|
||||
status_callback("error", str(exc))
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.error_trace("Task %s: unexpected error sending email: %s", task.task_id, exc)
|
||||
status_callback("error", f"Email send failed: {exc}")
|
||||
return None
|
||||
finally:
|
||||
cleanup_output_staging(
|
||||
prepared.output_plan,
|
||||
prepared.working_path,
|
||||
task,
|
||||
prepared.cleanup_paths,
|
||||
)
|
||||
|
||||
|
||||
@register_output(EMAIL_OUTPUT_MODE, supports_task=_supports_email, priority=10)
|
||||
def process_email_output(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
) -> Optional[str]:
|
||||
return _post_process_email(temp_file, task, cancel_flag, status_callback)
|
||||
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Any, Optional, List
|
||||
|
||||
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
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
FOLDER_OUTPUT_MODE = "folder"
|
||||
|
||||
|
||||
def _format_op_counts(op_counts: dict[str, int]) -> str:
|
||||
parts = [f"{op}={count}" for op, count in op_counts.items() if count]
|
||||
return ", ".join(parts) if parts else "none"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ProcessingPlan:
|
||||
destination: Path
|
||||
organization_mode: str
|
||||
use_hardlink: bool
|
||||
allow_archive_extraction: bool
|
||||
stage_action: StageAction
|
||||
staging_dir: Path
|
||||
hardlink_source: Optional[Path]
|
||||
output_mode: str = FOLDER_OUTPUT_MODE
|
||||
|
||||
|
||||
def _supports_folder_output(task: DownloadTask) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _build_processing_plan(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
status_callback,
|
||||
) -> Optional[_ProcessingPlan]:
|
||||
from shelfmark.download.postprocess.pipeline import (
|
||||
build_output_plan,
|
||||
get_final_destination,
|
||||
validate_destination,
|
||||
)
|
||||
from shelfmark.download.postprocess.policy import get_file_organization
|
||||
|
||||
is_audiobook = check_audiobook(task.content_type)
|
||||
organization_mode = get_file_organization(is_audiobook)
|
||||
destination = get_final_destination(task)
|
||||
|
||||
if not validate_destination(destination, status_callback):
|
||||
return None
|
||||
|
||||
output_plan = build_output_plan(
|
||||
temp_file,
|
||||
task,
|
||||
output_mode=FOLDER_OUTPUT_MODE,
|
||||
destination=destination,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
if not output_plan.transfer_plan:
|
||||
return None
|
||||
|
||||
transfer_plan = output_plan.transfer_plan
|
||||
hardlink_source = transfer_plan.source_path if transfer_plan.use_hardlink else None
|
||||
|
||||
return _ProcessingPlan(
|
||||
destination=destination,
|
||||
organization_mode=organization_mode,
|
||||
use_hardlink=transfer_plan.use_hardlink,
|
||||
allow_archive_extraction=transfer_plan.allow_archive_extraction,
|
||||
stage_action=output_plan.stage_action,
|
||||
staging_dir=output_plan.staging_dir,
|
||||
hardlink_source=hardlink_source,
|
||||
)
|
||||
|
||||
|
||||
@register_output(FOLDER_OUTPUT_MODE, supports_task=_supports_folder_output, priority=0)
|
||||
def process_folder_output(
|
||||
temp_file: Path,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
status_callback,
|
||||
) -> Optional[str]:
|
||||
"""Post-process download to the configured folder destination."""
|
||||
from shelfmark.download.postprocess.pipeline import (
|
||||
CustomScriptContext,
|
||||
CustomScriptTransferSummary,
|
||||
cleanup_output_staging,
|
||||
is_torrent_source,
|
||||
log_plan_steps,
|
||||
prepare_output_files,
|
||||
maybe_run_custom_script,
|
||||
record_step,
|
||||
transfer_book_files,
|
||||
)
|
||||
|
||||
plan = _build_processing_plan(temp_file, task, status_callback)
|
||||
if not plan:
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
"Processing plan for task %s: mode=%s destination=%s hardlink=%s stage_action=%s extract_archives=%s",
|
||||
task.task_id,
|
||||
plan.organization_mode,
|
||||
plan.destination,
|
||||
plan.use_hardlink,
|
||||
plan.stage_action,
|
||||
plan.allow_archive_extraction,
|
||||
)
|
||||
|
||||
prepared = prepare_output_files(
|
||||
temp_file,
|
||||
task,
|
||||
output_mode=plan.output_mode,
|
||||
status_callback=status_callback,
|
||||
destination=plan.destination,
|
||||
)
|
||||
if not prepared:
|
||||
return None
|
||||
|
||||
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))
|
||||
|
||||
# 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
|
||||
is_torrent = is_torrent_source(source_path, task)
|
||||
|
||||
usenet_action = core_config.config.get("PROWLARR_USENET_ACTION", "move")
|
||||
is_usenet = task.source == "prowlarr" and not task.original_download_path
|
||||
|
||||
# For external usenet downloads, always copy from the client path.
|
||||
# "Move" is implemented as a client-side cleanup after import.
|
||||
preserve_source = is_usenet
|
||||
|
||||
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)
|
||||
cleanup_output_staging(
|
||||
prepared.output_plan,
|
||||
prepared.working_path,
|
||||
task,
|
||||
prepared.cleanup_paths,
|
||||
)
|
||||
return None
|
||||
|
||||
if use_hardlink:
|
||||
op_label = "Hardlinking"
|
||||
elif is_usenet and usenet_action == "move" and prepared.output_plan.stage_action == STAGE_NONE:
|
||||
# Presented as a move, but implemented as copy + client cleanup.
|
||||
op_label = "Moving"
|
||||
elif copy_for_label:
|
||||
op_label = "Copying"
|
||||
else:
|
||||
op_label = "Moving"
|
||||
|
||||
status_callback("resolving", f"{op_label} file")
|
||||
record_step(
|
||||
steps,
|
||||
"transfer",
|
||||
op=op_label.lower(),
|
||||
source=str(source_path),
|
||||
dest=str(plan.destination),
|
||||
hardlink=use_hardlink,
|
||||
torrent=copy_for_label,
|
||||
)
|
||||
if prepared.output_plan.stage_action != STAGE_NONE:
|
||||
record_step(steps, "cleanup_staging", path=str(prepared.working_path))
|
||||
log_plan_steps(task.task_id, steps)
|
||||
|
||||
final_paths, error, op_counts = transfer_book_files(
|
||||
prepared.files,
|
||||
destination=plan.destination,
|
||||
task=task,
|
||||
use_hardlink=use_hardlink,
|
||||
is_torrent=is_torrent,
|
||||
preserve_source=preserve_source,
|
||||
organization_mode=plan.organization_mode,
|
||||
)
|
||||
|
||||
if error:
|
||||
logger.warning("Task %s: transfer failed: %s", task.task_id, error)
|
||||
status_callback("error", error)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Task %s: transferred %d file(s) to %s (ops: %s)",
|
||||
task.task_id,
|
||||
len(final_paths),
|
||||
plan.destination,
|
||||
_format_op_counts(op_counts),
|
||||
)
|
||||
if use_hardlink and op_counts.get("copy", 0):
|
||||
logger.warning(
|
||||
"Task %s: hardlink requested but %d of %d file(s) copied (fallback)",
|
||||
task.task_id,
|
||||
op_counts.get("copy", 0),
|
||||
len(final_paths),
|
||||
)
|
||||
|
||||
script_context = CustomScriptContext(
|
||||
task=task,
|
||||
phase="post_transfer",
|
||||
output_mode=plan.output_mode,
|
||||
organization_mode=plan.organization_mode,
|
||||
destination=plan.destination,
|
||||
final_paths=final_paths,
|
||||
transfer=CustomScriptTransferSummary(
|
||||
op_counts=op_counts,
|
||||
use_hardlink=use_hardlink,
|
||||
is_torrent=is_torrent,
|
||||
preserve_source=preserve_source,
|
||||
),
|
||||
)
|
||||
|
||||
if not maybe_run_custom_script(script_context, status_callback=status_callback, steps=steps):
|
||||
cleanup_output_staging(
|
||||
prepared.output_plan,
|
||||
prepared.working_path,
|
||||
task,
|
||||
prepared.cleanup_paths,
|
||||
)
|
||||
return None
|
||||
|
||||
cleanup_output_staging(
|
||||
prepared.output_plan,
|
||||
prepared.working_path,
|
||||
task,
|
||||
prepared.cleanup_paths,
|
||||
)
|
||||
|
||||
message = "Complete" if len(final_paths) == 1 else f"Complete ({len(final_paths)} files)"
|
||||
status_callback("complete", message)
|
||||
|
||||
return str(final_paths[0])
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Permission/ownership diagnostics for filesystem operations.
|
||||
|
||||
This module centralizes best-effort debug logging used by download post-processing
|
||||
and atomic filesystem operations.
|
||||
|
||||
It is intentionally defensive: failures collecting context should never mask the
|
||||
original error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
def _run_io(func, *args, **kwargs):
|
||||
"""Best-effort offload for potentially blocking filesystem calls.
|
||||
|
||||
Keep this module import-cycle safe: `shelfmark.download.fs` imports this module,
|
||||
so we only import `run_blocking_io` lazily at call-time.
|
||||
"""
|
||||
try:
|
||||
from shelfmark.download.fs import run_blocking_io as _run_blocking_io
|
||||
except Exception:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
try:
|
||||
return _run_blocking_io(func, *args, **kwargs)
|
||||
except Exception:
|
||||
# Fall back to direct call if threadpool offload is unavailable.
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
def _format_uid(uid: int) -> str:
|
||||
try:
|
||||
import pwd
|
||||
|
||||
return pwd.getpwuid(uid).pw_name
|
||||
except Exception:
|
||||
return str(uid)
|
||||
|
||||
|
||||
def _format_gid(gid: int) -> str:
|
||||
try:
|
||||
import grp
|
||||
|
||||
return grp.getgrgid(gid).gr_name
|
||||
except Exception:
|
||||
return str(gid)
|
||||
|
||||
|
||||
def log_path_permission_context(label: str, path: Path) -> None:
|
||||
"""Log useful permission/ownership context for a path.
|
||||
|
||||
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
|
||||
groups = os.getgroups() if hasattr(os, "getgroups") else []
|
||||
|
||||
if euid is not None and egid is not None:
|
||||
logger.debug(
|
||||
"Permission context (%s): euid=%s(%d) egid=%s(%d) groups=%s",
|
||||
label,
|
||||
_format_uid(euid),
|
||||
euid,
|
||||
_format_gid(egid),
|
||||
egid,
|
||||
[f"{_format_gid(g)}({g})" for g in groups],
|
||||
)
|
||||
|
||||
for probe in [path, path.parent]:
|
||||
try:
|
||||
resolved = _run_io(probe.resolve)
|
||||
except Exception:
|
||||
resolved = probe
|
||||
|
||||
try:
|
||||
st = _run_io(probe.stat)
|
||||
logger.debug(
|
||||
"Path permissions (%s): path=%s resolved=%s mode=%s owner=%s(%d) group=%s(%d) dir=%s symlink=%s",
|
||||
label,
|
||||
probe,
|
||||
resolved,
|
||||
oct(st.st_mode & 0o777),
|
||||
_format_uid(st.st_uid),
|
||||
st.st_uid,
|
||||
_format_gid(st.st_gid),
|
||||
st.st_gid,
|
||||
_run_io(probe.is_dir),
|
||||
_run_io(probe.is_symlink),
|
||||
)
|
||||
except Exception as 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
|
||||
groups = os.getgroups() if hasattr(os, "getgroups") else []
|
||||
|
||||
if euid is not None and egid is not None:
|
||||
logger.debug(
|
||||
"Permission context (%s): euid=%s(%d) egid=%s(%d) groups=%s error=%s",
|
||||
label,
|
||||
_format_uid(euid),
|
||||
euid,
|
||||
_format_gid(egid),
|
||||
egid,
|
||||
[f"{_format_gid(g)}({g})" for g in groups],
|
||||
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)
|
||||
except Exception as context_error:
|
||||
logger.debug("Permission context (%s): failed to collect: %s", label, context_error)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Post-download processing pipeline.
|
||||
|
||||
This package contains the post-download processing pipeline (staging, scanning,
|
||||
archive extraction, transfers, and safe cleanup) and the router that selects an
|
||||
output handler.
|
||||
|
||||
Output handlers live in `shelfmark.download.outputs` and should depend on
|
||||
`pipeline` (not `router`) to avoid circular imports.
|
||||
"""
|
||||
|
||||
from .router import post_process_download
|
||||
@@ -0,0 +1,296 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
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
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
DEFAULT_CUSTOM_SCRIPT_TIMEOUT_SECONDS = 300 # 5 minutes
|
||||
|
||||
|
||||
def resolve_custom_script_target(target_path: Path, destination: Path, path_mode: str) -> Path:
|
||||
"""Resolve the path that should be passed as the custom script argument.
|
||||
|
||||
In absolute mode, we pass the full target path.
|
||||
|
||||
In relative mode, we pass a path relative to the destination folder. If the
|
||||
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
|
||||
|
||||
try:
|
||||
return target_path.relative_to(destination)
|
||||
except ValueError:
|
||||
if target_path.is_absolute():
|
||||
return Path(target_path.name)
|
||||
return target_path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustomScriptExecution:
|
||||
script_path: str
|
||||
target_arg: Path
|
||||
target_abs: Path
|
||||
destination: Path
|
||||
mode: str
|
||||
phase: str
|
||||
payload_json: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustomScriptTransferSummary:
|
||||
op_counts: dict[str, int]
|
||||
use_hardlink: bool
|
||||
is_torrent: bool
|
||||
preserve_source: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustomScriptContext:
|
||||
task: DownloadTask
|
||||
phase: str
|
||||
output_mode: str
|
||||
destination: Optional[Path] = None
|
||||
final_paths: list[Path] = field(default_factory=list)
|
||||
target_path: Optional[Path] = None
|
||||
organization_mode: Optional[str] = None
|
||||
transfer: Optional[CustomScriptTransferSummary] = None
|
||||
output_details: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def prepare_custom_script_execution(
|
||||
script_path: str,
|
||||
*,
|
||||
target_path: Path,
|
||||
destination: Path,
|
||||
path_mode: str,
|
||||
phase: str,
|
||||
payload: Optional[dict[str, Any]] = None,
|
||||
) -> CustomScriptExecution:
|
||||
mode = (path_mode or "absolute").strip().lower()
|
||||
if mode != "relative":
|
||||
mode = "absolute"
|
||||
|
||||
target_arg = resolve_custom_script_target(target_path, destination, mode)
|
||||
return CustomScriptExecution(
|
||||
script_path=str(script_path),
|
||||
target_arg=target_arg,
|
||||
target_abs=target_path,
|
||||
destination=destination,
|
||||
mode=mode,
|
||||
phase=phase,
|
||||
payload_json=json.dumps(payload, indent=2, sort_keys=True) + "\n" if payload else None,
|
||||
)
|
||||
|
||||
|
||||
def run_custom_script(
|
||||
execution: CustomScriptExecution,
|
||||
*,
|
||||
task_id: str,
|
||||
status_callback,
|
||||
timeout_seconds: int = DEFAULT_CUSTOM_SCRIPT_TIMEOUT_SECONDS,
|
||||
) -> bool:
|
||||
cwd: Optional[str] = None
|
||||
if execution.mode == "relative":
|
||||
# Make relative paths unambiguous by running the script from the destination folder.
|
||||
cwd = str(execution.destination)
|
||||
|
||||
logger.info(
|
||||
"Task %s: running custom script %s on %s (%s)",
|
||||
task_id,
|
||||
execution.script_path,
|
||||
execution.target_arg,
|
||||
execution.phase,
|
||||
)
|
||||
|
||||
try:
|
||||
# If we are not sending a JSON payload, close stdin so scripts that try
|
||||
# to read it won't block indefinitely.
|
||||
stdin = None if execution.payload_json is not None else subprocess.DEVNULL
|
||||
result = run_blocking_io(
|
||||
subprocess.run,
|
||||
[execution.script_path, str(execution.target_arg)],
|
||||
check=True,
|
||||
timeout=timeout_seconds,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
stdin=stdin,
|
||||
input=execution.payload_json,
|
||||
)
|
||||
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)
|
||||
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)
|
||||
status_callback("error", f"Custom script not executable: {execution.script_path}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(
|
||||
"Task %s: custom script timed out after %ss: %s",
|
||||
task_id,
|
||||
timeout_seconds,
|
||||
execution.script_path,
|
||||
)
|
||||
status_callback("error", "Custom script timed out")
|
||||
return False
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = exc.stderr.strip() if exc.stderr else "No error output"
|
||||
logger.error(
|
||||
"Task %s: custom script failed (exit code %s): %s",
|
||||
task_id,
|
||||
exc.returncode,
|
||||
stderr,
|
||||
)
|
||||
status_callback("error", f"Custom script failed: {stderr[:100]}")
|
||||
return False
|
||||
|
||||
|
||||
def _choose_custom_script_target(
|
||||
*,
|
||||
explicit_target: Optional[Path],
|
||||
destination: Optional[Path],
|
||||
final_paths: list[Path],
|
||||
) -> Optional[Path]:
|
||||
if explicit_target is not None:
|
||||
return explicit_target
|
||||
|
||||
if len(final_paths) == 1:
|
||||
return final_paths[0]
|
||||
|
||||
if len(final_paths) > 1:
|
||||
try:
|
||||
return Path(os.path.commonpath([str(p.parent) for p in final_paths]))
|
||||
except ValueError:
|
||||
return destination or final_paths[0].parent
|
||||
|
||||
return destination
|
||||
|
||||
|
||||
def _build_custom_script_payload(context: CustomScriptContext, *, target_path: Path) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"version": 1,
|
||||
"phase": context.phase,
|
||||
"task": {
|
||||
"task_id": context.task.task_id,
|
||||
"source": context.task.source,
|
||||
"search_mode": context.task.search_mode.value if context.task.search_mode else None,
|
||||
"title": context.task.title,
|
||||
"author": context.task.author,
|
||||
"year": context.task.year,
|
||||
"format": context.task.format,
|
||||
"content_type": context.task.content_type,
|
||||
"series_name": context.task.series_name,
|
||||
"series_position": context.task.series_position,
|
||||
"subtitle": context.task.subtitle,
|
||||
"original_download_path": context.task.original_download_path,
|
||||
},
|
||||
"output": {
|
||||
"mode": context.output_mode,
|
||||
"organization_mode": context.organization_mode,
|
||||
},
|
||||
"paths": {
|
||||
"destination": str(context.destination) if context.destination else None,
|
||||
"target": str(target_path),
|
||||
"final_paths": [str(p) for p in context.final_paths],
|
||||
},
|
||||
}
|
||||
|
||||
if context.output_details:
|
||||
payload["output"]["details"] = context.output_details
|
||||
|
||||
if context.transfer:
|
||||
payload["transfer"] = {
|
||||
"op_counts": context.transfer.op_counts,
|
||||
"use_hardlink": context.transfer.use_hardlink,
|
||||
"is_torrent": context.transfer.is_torrent,
|
||||
"preserve_source": context.transfer.preserve_source,
|
||||
}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def maybe_run_custom_script(
|
||||
context: CustomScriptContext,
|
||||
*,
|
||||
status_callback,
|
||||
steps: Optional[list[PlanStep]] = None,
|
||||
) -> bool:
|
||||
"""Run the custom script hook (if configured).
|
||||
|
||||
The output handler provides a `CustomScriptContext` describing what it did.
|
||||
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
|
||||
|
||||
target_path = _choose_custom_script_target(
|
||||
explicit_target=context.target_path,
|
||||
destination=context.destination,
|
||||
final_paths=context.final_paths,
|
||||
)
|
||||
if not target_path:
|
||||
logger.warning(
|
||||
"Task %s: custom script configured but no target could be determined; skipping",
|
||||
context.task.task_id,
|
||||
)
|
||||
return True
|
||||
|
||||
path_mode = core_config.config.get("CUSTOM_SCRIPT_PATH_MODE", "absolute")
|
||||
|
||||
payload: Optional[dict[str, Any]] = None
|
||||
if core_config.config.get("CUSTOM_SCRIPT_JSON_PAYLOAD", False):
|
||||
payload = _build_custom_script_payload(context, target_path=target_path)
|
||||
|
||||
# If no destination is available for this output, fall back to the target's
|
||||
# parent directory so the script can still run consistently.
|
||||
execution_destination = context.destination or target_path.parent
|
||||
|
||||
execution = prepare_custom_script_execution(
|
||||
script_path,
|
||||
target_path=target_path,
|
||||
destination=execution_destination,
|
||||
path_mode=path_mode,
|
||||
phase=context.phase,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
if steps is not None:
|
||||
payload_bytes = len(execution.payload_json.encode("utf-8")) if execution.payload_json else 0
|
||||
record_step(
|
||||
steps,
|
||||
"custom_script",
|
||||
script=str(execution.script_path),
|
||||
target=str(execution.target_arg),
|
||||
target_abs=str(execution.target_abs),
|
||||
mode=str(execution.mode),
|
||||
phase=str(execution.phase),
|
||||
payload_stdin=bool(execution.payload_json),
|
||||
payload_bytes=payload_bytes,
|
||||
)
|
||||
log_plan_steps(context.task.task_id, steps)
|
||||
|
||||
return run_custom_script(execution, task_id=context.task.task_id, status_callback=status_callback)
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.utils import (
|
||||
get_aa_content_type_dir,
|
||||
get_destination,
|
||||
is_audiobook as check_audiobook,
|
||||
)
|
||||
from shelfmark.download.fs import run_blocking_io
|
||||
from shelfmark.download.permissions_debug import log_path_permission_context
|
||||
|
||||
logger = setup_logger("shelfmark.download.postprocess.pipeline")
|
||||
|
||||
|
||||
def validate_destination(destination: Path, status_callback) -> bool:
|
||||
"""Validate destination path is absolute, exists, and writable."""
|
||||
|
||||
if not destination.is_absolute():
|
||||
logger.warning(f"Destination must be absolute: {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}")
|
||||
status_callback("error", f"Destination is not a directory: {destination}")
|
||||
return False
|
||||
|
||||
if not destination_exists:
|
||||
try:
|
||||
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})")
|
||||
status_callback("error", f"Cannot create destination: {destination} ({exc})")
|
||||
return False
|
||||
|
||||
test_path = destination / f".shelfmark_write_test_{uuid.uuid4().hex}.tmp"
|
||||
|
||||
try:
|
||||
test_content = (
|
||||
f"This file was created to verify if '{destination}' is writable. "
|
||||
"It should've been automatically deleted. Feel free to delete it.\n"
|
||||
)
|
||||
run_blocking_io(test_path.write_text, test_content)
|
||||
run_blocking_io(test_path.unlink, missing_ok=True)
|
||||
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})")
|
||||
status_callback("error", f"Destination not writable: {destination} ({exc})")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_final_destination(task: DownloadTask) -> Path:
|
||||
"""Get final destination directory, with content-type routing support."""
|
||||
|
||||
is_audiobook = check_audiobook(task.content_type)
|
||||
|
||||
if task.source == "direct_download" and not is_audiobook:
|
||||
override = get_aa_content_type_dir(task.content_type)
|
||||
if override:
|
||||
return override
|
||||
|
||||
return get_destination(is_audiobook, user_id=task.user_id, username=task.username)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user