From 554f5fcbe707cf14c04ac894206ba5cc5c12589e Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:53:47 +0000 Subject: [PATCH] Patch: Various feature additions (#625) - Add admin config for self-settings options visibility. Remove delivery preferences or notifications from the view. - Add option to use Booklore's Bookdrop API destination instead of a specific library - Add download path options for all torrent clients --- docs/environment-variables.md | 1073 ++++++++++------- shelfmark/config/settings.py | 29 +- shelfmark/config/users_settings.py | 59 + shelfmark/core/self_user_routes.py | 96 +- shelfmark/download/clients/deluge.py | 3 + shelfmark/download/clients/qbittorrent.py | 3 + shelfmark/download/clients/settings.py | 21 + shelfmark/download/clients/transmission.py | 11 +- shelfmark/download/outputs/booklore.py | 81 +- .../components/settings/SelfSettingsModal.tsx | 14 +- .../settings/users/UserOverridesSections.tsx | 56 +- .../src/components/settings/users/index.ts | 7 +- src/frontend/src/services/api.ts | 1 + tests/config/test_download_settings.py | 28 + tests/config/test_users_settings.py | 23 + tests/core/test_booklore_multiuser.py | 17 + tests/core/test_booklore_upload.py | 53 + tests/core/test_self_user_routes.py | 141 +++ tests/prowlarr/test_deluge_client.py | 99 ++ tests/prowlarr/test_qbittorrent_client.py | 42 + tests/prowlarr/test_transmission_client.py | 37 + 21 files changed, 1410 insertions(+), 484 deletions(-) create mode 100644 tests/core/test_booklore_upload.py create mode 100644 tests/core/test_self_user_routes.py create mode 100644 tests/prowlarr/test_deluge_client.py diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 00672a5..e2d08c2 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -12,7 +12,10 @@ This document lists all configuration options that can be set via environment va - [Downloads](#downloads) - [Network](#network) - [Advanced](#advanced) +- [Prowlarr](#prowlarr) +- [AudiobookBay](#audiobookbay) - [IRC](#irc) +- [Download Clients](#download-clients) - [Metadata Providers](#metadata-providers) - [Hardcover](#metadata-providers-hardcover) - [Open Library](#metadata-providers-open-library) @@ -21,9 +24,6 @@ This document lists all configuration options that can be set via environment va - [Download Sources](#direct-download-download-sources) - [Cloudflare Bypass](#direct-download-cloudflare-bypass) - [Mirrors](#direct-download-mirrors) -- [Prowlarr](#prowlarr) - - [Configuration](#prowlarr-configuration) - - [Download Clients](#prowlarr-download-clients) --- @@ -240,7 +240,7 @@ The release source tab to open by default in the release modal. - **Type:** string (choice) - **Default:** `direct_download` -- **Options:** `direct_download` (Direct Download), `prowlarr` (Prowlarr) +- **Options:** `direct_download` (Direct Download), `prowlarr` (Prowlarr), `audiobookbay` (AudiobookBay) @@ -249,20 +249,32 @@ The release source tab to open by default in the release modal. | Variable | Description | Type | Default | |----------|-------------|------|---------| | `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` | -| `INGEST_DIR` | Directory where downloaded files are saved. | string | `/books` | +| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` | | `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` | -| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. | string | `{Author} - {Title} ({Year})` | -| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` | +| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. | string | `{Author} - {Title} ({Year})` | +| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` | | `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` | | `BOOKLORE_HOST` | Base URL of your Booklore instance | string | _none_ | | `BOOKLORE_USERNAME` | Booklore account username | string | _none_ | | `BOOKLORE_PASSWORD` | Booklore account password | string (secret) | _none_ | +| `BOOKLORE_DESTINATION` | Choose whether uploads go directly to a specific library path or to Bookdrop for review. | string (choice) | `library` | | `BOOKLORE_LIBRARY_ID` | Booklore library to upload into. | string (choice) | _none_ | | `BOOKLORE_PATH_ID` | Booklore library path for uploads. | string (choice) | _none_ | -| `DESTINATION_AUDIOBOOK` | Leave empty to use Books destination. | string | _none_ | +| `EMAIL_RECIPIENT` | Optional fallback email address when no per-user email recipient override is configured. | string | _none_ | +| `EMAIL_ATTACHMENT_SIZE_LIMIT_MB` | Maximum total attachment size per email. Email encoding adds overhead; keep this below your provider's limit. | number | `25` | +| `EMAIL_SMTP_HOST` | SMTP server hostname or IP (e.g., smtp.gmail.com). | string | _none_ | +| `EMAIL_SMTP_PORT` | SMTP server port (587 is typical for STARTTLS, 465 for SSL). | number | `587` | +| `EMAIL_SMTP_SECURITY` | Transport security mode for SMTP. | string (choice) | `starttls` | +| `EMAIL_SMTP_USERNAME` | SMTP username (leave empty for no authentication). | string | _none_ | +| `EMAIL_SMTP_PASSWORD` | SMTP password (required if Username is set). | string (secret) | _none_ | +| `EMAIL_FROM` | From address used for the email. You can include a display name (e.g., Shelfmark ). Leave blank to default to the SMTP username (when it is an email address). | string | _none_ | +| `EMAIL_SUBJECT_TEMPLATE` | Email subject. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}. | string | `{Title}` | +| `EMAIL_SMTP_TIMEOUT_SECONDS` | How long to wait for SMTP operations before failing. | number | `60` | +| `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` | +| `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ | | `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` | -| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. | string | `{Author} - {Title}` | -| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}` | +| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. | string | `{Author} - {Title}` | +| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}` | | `HARDLINK_TORRENTS_AUDIOBOOK` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `true` | | `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` | | `DOWNLOAD_TO_BROWSER` | Automatically download completed files to your browser. | boolean | `false` | @@ -280,13 +292,13 @@ Choose where completed book files are sent. - **Type:** string (choice) - **Default:** `folder` -- **Options:** `folder` (Folder), `booklore` (Booklore (API)) +- **Options:** `folder` (Folder), `email` (Email (SMTP)), `booklore` (Booklore (API)) #### `INGEST_DIR` **Destination** -Directory where downloaded files are saved. +Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). - **Type:** string - **Default:** `/books` @@ -306,7 +318,7 @@ Choose how downloaded book files are named and organized. **Naming Template** -Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. +Variables: {Author}, {Title}, {Year}, {User}. 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. - **Type:** string - **Default:** `{Author} - {Title} ({Year})` @@ -315,7 +327,7 @@ Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition} **Path Template** -Use / to create folders. Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. +Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. - **Type:** string - **Default:** `{Author}/{Title} ({Year})` @@ -359,6 +371,16 @@ Booklore account password - **Default:** _none_ - **Required:** Yes +#### `BOOKLORE_DESTINATION` + +**Upload Destination** + +Choose whether uploads go directly to a specific library path or to Bookdrop for review. + +- **Type:** string (choice) +- **Default:** `library` +- **Options:** `library` (Specific Library), `bookdrop` (Bookdrop) + #### `BOOKLORE_LIBRARY_ID` **Library** @@ -379,11 +401,115 @@ Booklore library path for uploads. - **Default:** _none_ - **Required:** Yes +#### `EMAIL_RECIPIENT` + +**Default Email Recipient** + +Optional fallback email address when no per-user email recipient override is configured. + +- **Type:** string +- **Default:** _none_ + +#### `EMAIL_ATTACHMENT_SIZE_LIMIT_MB` + +**Attachment Size Limit (MB)** + +Maximum total attachment size per email. Email encoding adds overhead; keep this below your provider's limit. + +- **Type:** number +- **Default:** `25` +- **Constraints:** min: 1, max: 600 + +#### `EMAIL_SMTP_HOST` + +**SMTP Host** + +SMTP server hostname or IP (e.g., smtp.gmail.com). + +- **Type:** string +- **Default:** _none_ +- **Required:** Yes + +#### `EMAIL_SMTP_PORT` + +**SMTP Port** + +SMTP server port (587 is typical for STARTTLS, 465 for SSL). + +- **Type:** number +- **Default:** `587` +- **Constraints:** min: 1, max: 65535 + +#### `EMAIL_SMTP_SECURITY` + +**SMTP Security** + +Transport security mode for SMTP. + +- **Type:** string (choice) +- **Default:** `starttls` +- **Options:** `none` (None), `starttls` (STARTTLS), `ssl` (SSL/TLS) + +#### `EMAIL_SMTP_USERNAME` + +**Username** + +SMTP username (leave empty for no authentication). + +- **Type:** string +- **Default:** _none_ + +#### `EMAIL_SMTP_PASSWORD` + +**Password** + +SMTP password (required if Username is set). + +- **Type:** string (secret) +- **Default:** _none_ + +#### `EMAIL_FROM` + +**From Address** + +From address used for the email. You can include a display name (e.g., Shelfmark ). Leave blank to default to the SMTP username (when it is an email address). + +- **Type:** string +- **Default:** _none_ + +#### `EMAIL_SUBJECT_TEMPLATE` + +**Subject Template** + +Email subject. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}. + +- **Type:** string +- **Default:** `{Title}` + +#### `EMAIL_SMTP_TIMEOUT_SECONDS` + +**SMTP Timeout (seconds)** + +How long to wait for SMTP operations before failing. + +- **Type:** number +- **Default:** `60` +- **Constraints:** min: 1, max: 600 + +#### `EMAIL_ALLOW_UNVERIFIED_TLS` + +**Allow Unverified TLS** + +Disable TLS certificate verification (not recommended). + +- **Type:** boolean +- **Default:** `false` + #### `DESTINATION_AUDIOBOOK` **Destination** -Leave empty to use Books destination. +Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. - **Type:** string - **Default:** _none_ @@ -402,7 +528,7 @@ Choose how downloaded audiobook files are named and organized. **Naming Template** -Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. +Variables: {Author}, {Title}, {Year}, {User}, {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. - **Type:** string - **Default:** `{Author} - {Title}` @@ -411,7 +537,7 @@ Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {P **Path Template** -Use / to create folders. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. +Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. - **Type:** string - **Default:** `{Author}/{Title}` @@ -578,7 +704,7 @@ Comma-separated hosts to bypass proxy (e.g., localhost,127.0.0.1,10.*,*.local) | `DOWNLOAD_PROGRESS_UPDATE_INTERVAL` | How often download progress is broadcast to the UI. | number | `1` | | `CUSTOM_SCRIPT` | Path to a script to run after each successful download. Must be executable. | string | _none_ | | `CUSTOM_SCRIPT_PATH_MODE` | Pass the path to the custom script as an absolute path or relative to the destination folder. | string (choice) | `absolute` | -| `CUSTOM_SCRIPT_JSON_PAYLOAD` | Send a JSON payload to the custom script via stdin. | boolean | `false` | +| `CUSTOM_SCRIPT_JSON_PAYLOAD` | Send a JSON payload to the script via stdin. Useful for multi-file imports (audiobooks) or richer metadata without relying on path parsing. | boolean | `false` | | `COVERS_CACHE_ENABLED` | Cache book covers on the server for faster loading. | boolean | `true` | | `COVERS_CACHE_TTL` | How long to keep cached covers. Set to 0 to keep forever (recommended for static artwork). | number | `0` | | `COVERS_CACHE_MAX_SIZE_MB` | Maximum disk space for cached covers. Oldest images are removed when limit is reached. | number | `500` | @@ -637,8 +763,6 @@ How often download progress is broadcast to the UI. Path to a script to run after each successful download. Must be executable. -See `docs/custom-scripts.md` for the user guide, including how the target path argument (`$1`) and optional JSON payload work. - - **Type:** string - **Default:** _none_ @@ -656,9 +780,7 @@ Pass the path to the custom script as an absolute path or relative to the destin **Custom Script JSON Payload** -Send a JSON payload to the custom script via stdin (in addition to the target path argument). - -See `docs/custom-scripts.md` for an example payload and usage patterns. +Send a JSON payload to the script via stdin. Useful for multi-file imports (audiobooks) or richer metadata without relying on path parsing. - **Type:** boolean - **Default:** `false` @@ -723,6 +845,131 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480 +## Prowlarr + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `PROWLARR_ENABLED` | Enable searching for books via Prowlarr indexers | boolean | `false` | +| `PROWLARR_URL` | Base URL of your Prowlarr instance | string | _none_ | +| `PROWLARR_API_KEY` | Found in Prowlarr: Settings > General > API Key | string (secret) | _none_ | +| `PROWLARR_INDEXERS` | Select which indexers to search. 📚 = has book categories. Leave empty to search all. | string (comma-separated) | _empty list_ | +| `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` | + +
+Detailed descriptions + +#### `PROWLARR_ENABLED` + +**Enable Prowlarr source** + +Enable searching for books via Prowlarr indexers + +- **Type:** boolean +- **Default:** `false` + +#### `PROWLARR_URL` + +**Prowlarr URL** + +Base URL of your Prowlarr instance + +- **Type:** string +- **Default:** _none_ +- **Required:** Yes + +#### `PROWLARR_API_KEY` + +**API Key** + +Found in Prowlarr: Settings > General > API Key + +- **Type:** string (secret) +- **Default:** _none_ +- **Required:** Yes + +#### `PROWLARR_INDEXERS` + +**Indexers to Search** + +Select which indexers to search. 📚 = has book categories. Leave empty to search all. + +- **Type:** string (comma-separated) +- **Default:** _empty list_ + +#### `PROWLARR_AUTO_EXPAND` + +**Auto-expand search on no results** + +Automatically retry search without category filtering if no results are found + +- **Type:** boolean +- **Default:** `false` + +
+ +## AudiobookBay + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `ABB_ENABLED` | Enable AudiobookBay as a release source for audiobooks. | boolean | `false` | +| `ABB_HOSTNAME` | AudiobookBay domain (e.g., audiobookbay.lu, audiobookbay.is). Required to enable searches. | string | _empty string_ | +| `ABB_PAGE_LIMIT` | Maximum number of search result pages to fetch (1-10). | number | `1` | +| `ABB_EXACT_PHRASE` | Wrap generated queries in quotes for stricter matching. If no results are found, Shelfmark retries without quotes. | boolean | `false` | +| `ABB_RATE_LIMIT_DELAY` | Delay between requests in seconds to avoid rate limiting (0-10). | number | `1.0` | + +
+Detailed descriptions + +#### `ABB_ENABLED` + +**Enable AudiobookBay** + +Enable AudiobookBay as a release source for audiobooks. + +- **Type:** boolean +- **Default:** `false` + +#### `ABB_HOSTNAME` + +**Hostname** + +AudiobookBay domain (e.g., audiobookbay.lu, audiobookbay.is). Required to enable searches. + +- **Type:** string +- **Default:** _empty string_ +- **Required:** Yes + +#### `ABB_PAGE_LIMIT` + +**Max Pages to Search** + +Maximum number of search result pages to fetch (1-10). + +- **Type:** number +- **Default:** `1` +- **Constraints:** min: 1, max: 10 + +#### `ABB_EXACT_PHRASE` + +**Prefer Exact-Phrase Search** + +Wrap generated queries in quotes for stricter matching. If no results are found, Shelfmark retries without quotes. + +- **Type:** boolean +- **Default:** `false` + +#### `ABB_RATE_LIMIT_DELAY` + +**Rate Limit Delay (seconds)** + +Delay between requests in seconds to avoid rate limiting (0-10). + +- **Type:** number +- **Default:** `1.0` +- **Constraints:** min: 0.0, max: 10.0 + +
+ ## IRC | Variable | Description | Type | Default | @@ -807,6 +1054,379 @@ How long to keep cached search results before they expire. +## Download Clients + +| Variable | Description | Type | Default | +|----------|-------------|------|---------| +| `PROWLARR_TORRENT_CLIENT` | Choose which torrent client to use | string (choice) | _empty string_ | +| `QBITTORRENT_URL` | Web UI URL of your qBittorrent instance | string | _none_ | +| `QBITTORRENT_USERNAME` | qBittorrent Web UI username | string | _none_ | +| `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ | +| `QBITTORRENT_CATEGORY` | Category to assign to book downloads in qBittorrent | string | `books` | +| `QBITTORRENT_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ | +| `QBITTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ | +| `QBITTORRENT_TAG` | Tag(s) to assign to qBittorrent downloads. Leave empty for no tags. | string | _empty list_ | +| `TRANSMISSION_URL` | URL of your Transmission instance (use https:// for TLS) | string | _none_ | +| `TRANSMISSION_USERNAME` | Transmission RPC username (if authentication enabled) | string | _none_ | +| `TRANSMISSION_PASSWORD` | Transmission RPC password | string (secret) | _none_ | +| `TRANSMISSION_CATEGORY` | Label to assign to book downloads in Transmission | string | `books` | +| `TRANSMISSION_CATEGORY_AUDIOBOOK` | Label for audiobook downloads. Leave empty to use the book label. | string | _empty string_ | +| `TRANSMISSION_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses Transmission default if not specified) | string | _none_ | +| `DELUGE_HOST` | Hostname/IP or full URL of your Deluge Web UI (deluge-web) | string | `localhost` | +| `DELUGE_PORT` | Deluge Web UI port (default: 8112) | string | `8112` | +| `DELUGE_PASSWORD` | Deluge Web UI password (default: deluge) | string (secret) | _none_ | +| `DELUGE_CATEGORY` | Label to assign to book downloads in Deluge | string | `books` | +| `DELUGE_CATEGORY_AUDIOBOOK` | Label for audiobook downloads. Leave empty to use the book label. | string | _empty string_ | +| `DELUGE_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses Deluge default if not specified) | string | _none_ | +| `RTORRENT_URL` | XML-RPC URL of your rTorrent instance | string | _none_ | +| `RTORRENT_USERNAME` | HTTP Basic auth username (if authentication enabled) | string | _none_ | +| `RTORRENT_PASSWORD` | HTTP Basic auth password | string (secret) | _none_ | +| `RTORRENT_LABEL` | Label to assign to book downloads in rTorrent | string | `cwabd` | +| `RTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ | +| `PROWLARR_USENET_CLIENT` | Choose which usenet client to use | string (choice) | _empty string_ | +| `NZBGET_URL` | URL of your NZBGet instance | string | _none_ | +| `NZBGET_USERNAME` | NZBGet control username | string | `nzbget` | +| `NZBGET_PASSWORD` | NZBGet control password | string (secret) | _none_ | +| `NZBGET_CATEGORY` | Category to assign to book downloads in NZBGet | string | `Books` | +| `NZBGET_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ | +| `SABNZBD_URL` | URL of your SABnzbd instance | string | _none_ | +| `SABNZBD_API_KEY` | Found in SABnzbd: Config > General > API Key | string (secret) | _none_ | +| `SABNZBD_CATEGORY` | Category to assign to book downloads in SABnzbd | string | `books` | +| `SABNZBD_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ | +| `PROWLARR_USENET_ACTION` | Move deletes the job from your usenet client after import; Copy keeps it in the client | string (choice) | `move` | + +
+Detailed descriptions + +#### `PROWLARR_TORRENT_CLIENT` + +**Torrent Client** + +Choose which torrent client to use + +- **Type:** string (choice) +- **Default:** _empty string_ +- **Options:** `""` (None), `qbittorrent` (qBittorrent), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent) + +#### `QBITTORRENT_URL` + +**qBittorrent URL** + +Web UI URL of your qBittorrent instance + +- **Type:** string +- **Default:** _none_ + +#### `QBITTORRENT_USERNAME` + +**Username** + +qBittorrent Web UI username + +- **Type:** string +- **Default:** _none_ + +#### `QBITTORRENT_PASSWORD` + +**Password** + +qBittorrent Web UI password + +- **Type:** string (secret) +- **Default:** _none_ + +#### `QBITTORRENT_CATEGORY` + +**Book Category** + +Category to assign to book downloads in qBittorrent + +- **Type:** string +- **Default:** `books` + +#### `QBITTORRENT_CATEGORY_AUDIOBOOK` + +**Audiobook Category** + +Category for audiobook downloads. Leave empty to use the book category. + +- **Type:** string +- **Default:** _empty string_ + +#### `QBITTORRENT_DOWNLOAD_DIR` + +**Download Directory** + +Server-side directory where torrents are downloaded (optional, uses qBittorrent default if not specified) + +- **Type:** string +- **Default:** _none_ + +#### `QBITTORRENT_TAG` + +**Tags** + +Tag(s) to assign to qBittorrent downloads. Leave empty for no tags. + +- **Type:** string +- **Default:** _empty list_ + +#### `TRANSMISSION_URL` + +**Transmission URL** + +URL of your Transmission instance (use https:// for TLS) + +- **Type:** string +- **Default:** _none_ + +#### `TRANSMISSION_USERNAME` + +**Username** + +Transmission RPC username (if authentication enabled) + +- **Type:** string +- **Default:** _none_ + +#### `TRANSMISSION_PASSWORD` + +**Password** + +Transmission RPC password + +- **Type:** string (secret) +- **Default:** _none_ + +#### `TRANSMISSION_CATEGORY` + +**Book Label** + +Label to assign to book downloads in Transmission + +- **Type:** string +- **Default:** `books` + +#### `TRANSMISSION_CATEGORY_AUDIOBOOK` + +**Audiobook Label** + +Label for audiobook downloads. Leave empty to use the book label. + +- **Type:** string +- **Default:** _empty string_ + +#### `TRANSMISSION_DOWNLOAD_DIR` + +**Download Directory** + +Server-side directory where torrents are downloaded (optional, uses Transmission default if not specified) + +- **Type:** string +- **Default:** _none_ + +#### `DELUGE_HOST` + +**Deluge Web UI Host/URL** + +Hostname/IP or full URL of your Deluge Web UI (deluge-web) + +- **Type:** string +- **Default:** `localhost` + +#### `DELUGE_PORT` + +**Deluge Web UI Port** + +Deluge Web UI port (default: 8112) + +- **Type:** string +- **Default:** `8112` + +#### `DELUGE_PASSWORD` + +**Password** + +Deluge Web UI password (default: deluge) + +- **Type:** string (secret) +- **Default:** _none_ + +#### `DELUGE_CATEGORY` + +**Book Label** + +Label to assign to book downloads in Deluge + +- **Type:** string +- **Default:** `books` + +#### `DELUGE_CATEGORY_AUDIOBOOK` + +**Audiobook Label** + +Label for audiobook downloads. Leave empty to use the book label. + +- **Type:** string +- **Default:** _empty string_ + +#### `DELUGE_DOWNLOAD_DIR` + +**Download Directory** + +Server-side directory where torrents are downloaded (optional, uses Deluge default if not specified) + +- **Type:** string +- **Default:** _none_ + +#### `RTORRENT_URL` + +**rTorrent URL** + +XML-RPC URL of your rTorrent instance + +- **Type:** string +- **Default:** _none_ + +#### `RTORRENT_USERNAME` + +**Username** + +HTTP Basic auth username (if authentication enabled) + +- **Type:** string +- **Default:** _none_ + +#### `RTORRENT_PASSWORD` + +**Password** + +HTTP Basic auth password + +- **Type:** string (secret) +- **Default:** _none_ + +#### `RTORRENT_LABEL` + +**Book Label** + +Label to assign to book downloads in rTorrent + +- **Type:** string +- **Default:** `cwabd` + +#### `RTORRENT_DOWNLOAD_DIR` + +**Download Directory** + +Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) + +- **Type:** string +- **Default:** _none_ + +#### `PROWLARR_USENET_CLIENT` + +**Usenet Client** + +Choose which usenet client to use + +- **Type:** string (choice) +- **Default:** _empty string_ +- **Options:** `""` (None), `nzbget` (NZBGet), `sabnzbd` (SABnzbd) + +#### `NZBGET_URL` + +**NZBGet URL** + +URL of your NZBGet instance + +- **Type:** string +- **Default:** _none_ + +#### `NZBGET_USERNAME` + +**Username** + +NZBGet control username + +- **Type:** string +- **Default:** `nzbget` + +#### `NZBGET_PASSWORD` + +**Password** + +NZBGet control password + +- **Type:** string (secret) +- **Default:** _none_ + +#### `NZBGET_CATEGORY` + +**Book Category** + +Category to assign to book downloads in NZBGet + +- **Type:** string +- **Default:** `Books` + +#### `NZBGET_CATEGORY_AUDIOBOOK` + +**Audiobook Category** + +Category for audiobook downloads. Leave empty to use the book category. + +- **Type:** string +- **Default:** _empty string_ + +#### `SABNZBD_URL` + +**SABnzbd URL** + +URL of your SABnzbd instance + +- **Type:** string +- **Default:** _none_ + +#### `SABNZBD_API_KEY` + +**API Key** + +Found in SABnzbd: Config > General > API Key + +- **Type:** string (secret) +- **Default:** _none_ + +#### `SABNZBD_CATEGORY` + +**Book Category** + +Category to assign to book downloads in SABnzbd + +- **Type:** string +- **Default:** `books` + +#### `SABNZBD_CATEGORY_AUDIOBOOK` + +**Audiobook Category** + +Category for audiobook downloads. Leave empty to use the book category. + +- **Type:** string +- **Default:** _empty string_ + +#### `PROWLARR_USENET_ACTION` + +**NZB Completion Action** + +Move deletes the job from your usenet client after import; Copy keeps it in the client + +- **Type:** string (choice) +- **Default:** `move` +- **Options:** `move` (Move), `copy` (Copy) + +
+ ## Metadata Providers ### Metadata Providers: Hardcover @@ -1240,410 +1860,3 @@ Comma-separated list of custom Welib mirror URLs. - **Default:** _none_ - -## Prowlarr - -### Prowlarr: Configuration - -| Variable | Description | Type | Default | -|----------|-------------|------|---------| -| `PROWLARR_ENABLED` | Enable searching for books via Prowlarr indexers | boolean | `false` | -| `PROWLARR_URL` | Base URL of your Prowlarr instance | string | _none_ | -| `PROWLARR_API_KEY` | Found in Prowlarr: Settings > General > API Key | string (secret) | _none_ | -| `PROWLARR_INDEXERS` | Select which indexers to search. 📚 = has book categories. Leave empty to search all. | string (comma-separated) | _empty list_ | -| `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` | - -
-Detailed descriptions - -#### `PROWLARR_ENABLED` - -**Enable Prowlarr source** - -Enable searching for books via Prowlarr indexers - -- **Type:** boolean -- **Default:** `false` - -#### `PROWLARR_URL` - -**Prowlarr URL** - -Base URL of your Prowlarr instance - -- **Type:** string -- **Default:** _none_ -- **Required:** Yes - -#### `PROWLARR_API_KEY` - -**API Key** - -Found in Prowlarr: Settings > General > API Key - -- **Type:** string (secret) -- **Default:** _none_ -- **Required:** Yes - -#### `PROWLARR_INDEXERS` - -**Indexers to Search** - -Select which indexers to search. 📚 = has book categories. Leave empty to search all. - -- **Type:** string (comma-separated) -- **Default:** _empty list_ - -#### `PROWLARR_AUTO_EXPAND` - -**Auto-expand search on no results** - -Automatically retry search without category filtering if no results are found - -- **Type:** boolean -- **Default:** `false` - -
- -### Prowlarr: Download Clients - -| Variable | Description | Type | Default | -|----------|-------------|------|---------| -| `PROWLARR_TORRENT_CLIENT` | Choose which torrent client to use | string (choice) | _empty string_ | -| `QBITTORRENT_URL` | Web UI URL of your qBittorrent instance | string | _none_ | -| `QBITTORRENT_USERNAME` | qBittorrent Web UI username | string | _none_ | -| `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ | -| `QBITTORRENT_CATEGORY` | Category to assign to book downloads in qBittorrent | string | `books` | -| `QBITTORRENT_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ | -| `QBITTORRENT_TAG` | Comma-separated list of tags to assign to qBittorrent downloads. Leave empty for no tags. | string | _empty string_ | -| `TRANSMISSION_URL` | URL of your Transmission instance | string | _none_ | -| `TRANSMISSION_USERNAME` | Transmission RPC username (if authentication enabled) | string | _none_ | -| `TRANSMISSION_PASSWORD` | Transmission RPC password | string (secret) | _none_ | -| `TRANSMISSION_CATEGORY` | Label to assign to book downloads in Transmission | string | `books` | -| `TRANSMISSION_CATEGORY_AUDIOBOOK` | Label for audiobook downloads. Leave empty to use the book label. | string | _empty string_ | -| `DELUGE_HOST` | Hostname/IP or full URL of your Deluge Web UI (deluge-web) | string | `localhost` | -| `DELUGE_PORT` | Deluge Web UI port (default: 8112) | string | `8112` | -| `DELUGE_PASSWORD` | Deluge Web UI password (default: deluge) | string (secret) | _none_ | -| `DELUGE_CATEGORY` | Label to assign to book downloads in Deluge | string | `books` | -| `DELUGE_CATEGORY_AUDIOBOOK` | Label for audiobook downloads. Leave empty to use the book label. | string | _empty string_ | -| `RTORRENT_URL` | XML-RPC URL of your rTorrent instance | string | _none_ | -| `RTORRENT_USERNAME` | HTTP Basic auth username (if authentication enabled) | string | _none_ | -| `RTORRENT_PASSWORD` | HTTP Basic auth password | string (secret) | _none_ | -| `RTORRENT_LABEL` | Label to assign to book downloads in rTorrent | string | `cwabd` | -| `RTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ | -| `PROWLARR_USENET_CLIENT` | Choose which usenet client to use | string (choice) | _empty string_ | -| `NZBGET_URL` | URL of your NZBGet instance | string | _none_ | -| `NZBGET_USERNAME` | NZBGet control username | string | `nzbget` | -| `NZBGET_PASSWORD` | NZBGet control password | string (secret) | _none_ | -| `NZBGET_CATEGORY` | Category to assign to book downloads in NZBGet | string | `Books` | -| `NZBGET_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ | -| `SABNZBD_URL` | URL of your SABnzbd instance | string | _none_ | -| `SABNZBD_API_KEY` | Found in SABnzbd: Config > General > API Key | string (secret) | _none_ | -| `SABNZBD_CATEGORY` | Category to assign to book downloads in SABnzbd | string | `books` | -| `SABNZBD_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ | -| `PROWLARR_USENET_ACTION` | Move deletes the job from your usenet client after import; Copy keeps it in the client | string (choice) | `move` | - -
-Detailed descriptions - -#### `PROWLARR_TORRENT_CLIENT` - -**Torrent Client** - -Choose which torrent client to use - -- **Type:** string (choice) -- **Default:** _empty string_ -- **Options:** `""` (None), `qbittorrent` (qBittorrent), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent) - -#### `QBITTORRENT_URL` - -**qBittorrent URL** - -Web UI URL of your qBittorrent instance - -- **Type:** string -- **Default:** _none_ - -#### `QBITTORRENT_USERNAME` - -**Username** - -qBittorrent Web UI username - -- **Type:** string -- **Default:** _none_ - -#### `QBITTORRENT_PASSWORD` - -**Password** - -qBittorrent Web UI password - -- **Type:** string (secret) -- **Default:** _none_ - -#### `QBITTORRENT_CATEGORY` - -**Book Category** - -Category to assign to book downloads in qBittorrent - -- **Type:** string -- **Default:** `books` - -#### `QBITTORRENT_CATEGORY_AUDIOBOOK` - -**Audiobook Category** - -Category for audiobook downloads. Leave empty to use the book category. - -- **Type:** string -- **Default:** _empty string_ - -#### `QBITTORRENT_TAG` - -**Tags** - -Comma-separated list of tags to assign to qBittorrent downloads. Leave empty for no tags. - -- **Type:** string -- **Default:** _empty string_ - -#### `TRANSMISSION_URL` - -**Transmission URL** - -URL of your Transmission instance - -- **Type:** string -- **Default:** _none_ - -#### `TRANSMISSION_USERNAME` - -**Username** - -Transmission RPC username (if authentication enabled) - -- **Type:** string -- **Default:** _none_ - -#### `TRANSMISSION_PASSWORD` - -**Password** - -Transmission RPC password - -- **Type:** string (secret) -- **Default:** _none_ - -#### `TRANSMISSION_CATEGORY` - -**Book Label** - -Label to assign to book downloads in Transmission - -- **Type:** string -- **Default:** `books` - -#### `TRANSMISSION_CATEGORY_AUDIOBOOK` - -**Audiobook Label** - -Label for audiobook downloads. Leave empty to use the book label. - -- **Type:** string -- **Default:** _empty string_ - -#### `DELUGE_HOST` - -**Deluge Web UI Host/URL** - -Hostname/IP or full URL of your Deluge Web UI (deluge-web) - -- **Type:** string -- **Default:** `localhost` - -#### `DELUGE_PORT` - -**Deluge Web UI Port** - -Deluge Web UI port (default: 8112) - -- **Type:** string -- **Default:** `8112` - -#### `DELUGE_PASSWORD` - -**Password** - -Deluge Web UI password (default: deluge) - -- **Type:** string (secret) -- **Default:** _none_ - -#### `DELUGE_CATEGORY` - -**Book Label** - -Label to assign to book downloads in Deluge - -- **Type:** string -- **Default:** `books` - -#### `DELUGE_CATEGORY_AUDIOBOOK` - -**Audiobook Label** - -Label for audiobook downloads. Leave empty to use the book label. - -- **Type:** string -- **Default:** _empty string_ - -#### `RTORRENT_URL` - -**rTorrent URL** - -XML-RPC URL of your rTorrent instance - -- **Type:** string -- **Default:** _none_ - -#### `RTORRENT_USERNAME` - -**Username** - -HTTP Basic auth username (if authentication enabled) - -- **Type:** string -- **Default:** _none_ - -#### `RTORRENT_PASSWORD` - -**Password** - -HTTP Basic auth password - -- **Type:** string (secret) -- **Default:** _none_ - -#### `RTORRENT_LABEL` - -**Book Label** - -Label to assign to book downloads in rTorrent - -- **Type:** string -- **Default:** `cwabd` - -#### `RTORRENT_DOWNLOAD_DIR` - -**Download Directory** - -Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) - -- **Type:** string -- **Default:** _none_ - -#### `PROWLARR_USENET_CLIENT` - -**Usenet Client** - -Choose which usenet client to use - -- **Type:** string (choice) -- **Default:** _empty string_ -- **Options:** `""` (None), `nzbget` (NZBGet), `sabnzbd` (SABnzbd) - -#### `NZBGET_URL` - -**NZBGet URL** - -URL of your NZBGet instance - -- **Type:** string -- **Default:** _none_ - -#### `NZBGET_USERNAME` - -**Username** - -NZBGet control username - -- **Type:** string -- **Default:** `nzbget` - -#### `NZBGET_PASSWORD` - -**Password** - -NZBGet control password - -- **Type:** string (secret) -- **Default:** _none_ - -#### `NZBGET_CATEGORY` - -**Book Category** - -Category to assign to book downloads in NZBGet - -- **Type:** string -- **Default:** `Books` - -#### `NZBGET_CATEGORY_AUDIOBOOK` - -**Audiobook Category** - -Category for audiobook downloads. Leave empty to use the book category. - -- **Type:** string -- **Default:** _empty string_ - -#### `SABNZBD_URL` - -**SABnzbd URL** - -URL of your SABnzbd instance - -- **Type:** string -- **Default:** _none_ - -#### `SABNZBD_API_KEY` - -**API Key** - -Found in SABnzbd: Config > General > API Key - -- **Type:** string (secret) -- **Default:** _none_ - -#### `SABNZBD_CATEGORY` - -**Book Category** - -Category to assign to book downloads in SABnzbd - -- **Type:** string -- **Default:** `books` - -#### `SABNZBD_CATEGORY_AUDIOBOOK` - -**Audiobook Category** - -Category for audiobook downloads. Leave empty to use the book category. - -- **Type:** string -- **Default:** _empty string_ - -#### `PROWLARR_USENET_ACTION` - -**NZB Completion Action** - -Move deletes the job from your usenet client after import; Copy keeps it in the client - -- **Type:** string (choice) -- **Default:** `move` -- **Options:** `move` (Move), `copy` (Copy) - -
diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index cec11e4..951f446 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -868,6 +868,25 @@ def download_settings(): 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", @@ -875,7 +894,10 @@ def download_settings(): options=get_booklore_library_options, required=True, user_overridable=True, - show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, + show_when=[ + {"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, + {"field": "BOOKLORE_DESTINATION", "value": "library"}, + ], ), SelectField( key="BOOKLORE_PATH_ID", @@ -885,7 +907,10 @@ def download_settings(): required=True, filter_by_field="BOOKLORE_LIBRARY_ID", user_overridable=True, - show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, + show_when=[ + {"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, + {"field": "BOOKLORE_DESTINATION", "value": "library"}, + ], ), ActionButton( key="test_booklore", diff --git a/shelfmark/config/users_settings.py b/shelfmark/config/users_settings.py index ad78560..4469280 100644 --- a/shelfmark/config/users_settings.py +++ b/shelfmark/config/users_settings.py @@ -9,6 +9,7 @@ from shelfmark.core.settings_registry import ( CheckboxField, CustomComponentField, HeadingField, + MultiSelectField, NumberField, SelectField, TableField, @@ -49,6 +50,21 @@ _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 " @@ -133,6 +149,38 @@ def _get_request_policy_rule_columns(): 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 { @@ -179,6 +227,17 @@ def users_settings(): 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", diff --git a/shelfmark/core/self_user_routes.py b/shelfmark/core/self_user_routes.py index ad05d09..58a238b 100644 --- a/shelfmark/core/self_user_routes.py +++ b/shelfmark/core/self_user_routes.py @@ -31,6 +31,14 @@ 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: @@ -108,6 +116,53 @@ def _serialize_self_user(user: Mapping[str, Any], auth_mode: str) -> dict[str, A return payload +def _normalize_visible_self_settings_sections(raw_sections: Any) -> list[str]: + """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.""" @@ -121,22 +176,27 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: 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() - 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 + 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 - 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 + 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 []) @@ -149,6 +209,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: "deliveryPreferences": delivery_preferences, "notificationPreferences": notification_preferences, "userOverridableKeys": user_overridable_keys, + "visibleUserSettingsSections": visible_self_settings_sections, } ) @@ -251,11 +312,8 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: if not isinstance(settings_payload, dict): return jsonify({"error": "Settings must be an object"}), 400 - allowed_user_settings_keys = { - key for key, _field in _get_ordered_user_overridable_fields("downloads") - } | { - key for key, _field in _get_ordered_user_overridable_fields("notifications") - } + 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 ) diff --git a/shelfmark/download/clients/deluge.py b/shelfmark/download/clients/deluge.py index f0f6b06..fa5c328 100644 --- a/shelfmark/download/clients/deluge.py +++ b/shelfmark/download/clients/deluge.py @@ -97,6 +97,7 @@ class DelugeClient(DownloadClient): 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 @@ -232,6 +233,8 @@ class DelugeClient(DownloadClient): 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 diff --git a/shelfmark/download/clients/qbittorrent.py b/shelfmark/download/clients/qbittorrent.py index 019f750..d7fb474 100644 --- a/shelfmark/download/clients/qbittorrent.py +++ b/shelfmark/download/clients/qbittorrent.py @@ -139,6 +139,7 @@ class QBittorrentClient(DownloadClient): 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", [])) @@ -306,6 +307,8 @@ class QBittorrentClient(DownloadClient): "category": category, "rename": name, } + if self._download_dir: + add_kwargs["save_path"] = self._download_dir if tags: add_kwargs["tags"] = ",".join(tags) diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index 2b83fa6..b70c42a 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -390,6 +390,13 @@ def prowlarr_clients_settings(): 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", @@ -444,6 +451,13 @@ def prowlarr_clients_settings(): 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( @@ -492,6 +506,13 @@ def prowlarr_clients_settings(): 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( diff --git a/shelfmark/download/clients/transmission.py b/shelfmark/download/clients/transmission.py index f5108f6..ccd116e 100644 --- a/shelfmark/download/clients/transmission.py +++ b/shelfmark/download/clients/transmission.py @@ -71,6 +71,7 @@ class TransmissionClient(DownloadClient): except Exception: pass self._category = config.get("TRANSMISSION_CATEGORY", "books") + self._download_dir = config.get("TRANSMISSION_DOWNLOAD_DIR", "") @staticmethod def is_configured() -> bool: @@ -115,18 +116,24 @@ class TransmissionClient(DownloadClient): resolved_category = category or self._category or "" 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=[resolved_category] if resolved_category else None, + **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=[resolved_category] if resolved_category else None, + **add_kwargs, ) torrent_hash = torrent.hashString.lower() diff --git a/shelfmark/download/outputs/booklore.py b/shelfmark/download/outputs/booklore.py index 03ed8b3..6301cfe 100644 --- a/shelfmark/download/outputs/booklore.py +++ b/shelfmark/download/outputs/booklore.py @@ -18,6 +18,8 @@ from shelfmark.download.staging import STAGE_MOVE, STAGE_NONE, build_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) @@ -36,6 +38,7 @@ class BookloreConfig: library_id: int path_id: int verify_tls: bool = True + upload_to_bookdrop: bool = False refresh_after_upload: bool = False @@ -48,6 +51,13 @@ def _parse_int(value: Any, label: str) -> int: 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, @@ -63,24 +73,32 @@ def build_booklore_config( if not password: raise BookloreError("Booklore password is required") - # Resolve library/path through config so user override precedence is centralized. - 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") + destination = _parse_destination( + values.get("BOOKLORE_DESTINATION", BOOKLORE_DESTINATION_LIBRARY) + ) + upload_to_bookdrop = destination == BOOKLORE_DESTINATION_BOOKDROP - library_id = _parse_int(library_id_val, "Booklore library ID") - path_id = _parse_int(path_id_val, "Booklore path ID") + # 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("/"), @@ -89,7 +107,8 @@ def build_booklore_config( library_id=library_id, path_id=path_id, verify_tls=True, - refresh_after_upload=True, # Always refresh library after upload + upload_to_bookdrop=upload_to_bookdrop, + refresh_after_upload=not upload_to_bookdrop, ) @@ -143,9 +162,14 @@ def booklore_list_libraries(booklore_config: BookloreConfig, token: str) -> list def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path: Path) -> None: - url = f"{booklore_config.base_url}/api/v1/files/upload" + 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}"} - params = {"libraryId": booklore_config.library_id, "pathId": booklore_config.path_id} response = None @@ -194,6 +218,10 @@ def _get_booklore_settings() -> Dict[str, Any]: "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"), } @@ -309,8 +337,17 @@ def _post_process_booklore( output_details={ "booklore": { "base_url": booklore_config.base_url, - "library_id": booklore_config.library_id, - "path_id": booklore_config.path_id, + "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), } }, diff --git a/src/frontend/src/components/settings/SelfSettingsModal.tsx b/src/frontend/src/components/settings/SelfSettingsModal.tsx index 7f391ae..a336703 100644 --- a/src/frontend/src/components/settings/SelfSettingsModal.tsx +++ b/src/frontend/src/components/settings/SelfSettingsModal.tsx @@ -9,7 +9,12 @@ import { import { SelectField } from './fields'; import { FieldWrapper } from './shared'; import { UserAccountCardContent, UserEditActions, UserIdentityHeader } from './users/UserCard'; -import { UserOverridesSections } from './users/UserOverridesSections'; +import { + DEFAULT_SELF_USER_OVERRIDE_SECTIONS, + normalizeUserOverrideSections, + UserOverridesSections, + type UserOverrideSectionId, +} from './users'; import { PerUserSettings } from './users/types'; import { useUserOverridesState } from './users/useUserOverridesState'; import { getStoredThemePreference, setThemePreference, THEME_FIELD } from '../../utils/themePreference'; @@ -52,6 +57,9 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings const [originalUser, setOriginalUser] = useState(null); const [deliveryPreferences, setDeliveryPreferences] = useState(null); const [notificationPreferences, setNotificationPreferences] = useState(null); + const [visibleSections, setVisibleSections] = useState( + DEFAULT_SELF_USER_OVERRIDE_SECTIONS + ); const [editPassword, setEditPassword] = useState(''); const [editPasswordConfirm, setEditPasswordConfirm] = useState(''); @@ -81,6 +89,9 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings setOriginalUser(context.user); setDeliveryPreferences(context.deliveryPreferences || null); setNotificationPreferences(context.notificationPreferences || null); + setVisibleSections( + normalizeUserOverrideSections(context.visibleUserSettingsSections, 'self') + ); applyUserOverridesContext({ settings: (context.user.settings || {}) as PerUserSettings, userOverridableKeys: context.userOverridableKeys || [], @@ -309,6 +320,7 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings children: ( section.id); +const USER_OVERRIDE_SECTION_ID_SET = new Set(USER_OVERRIDE_SECTION_ORDER); const USER_OVERRIDE_SECTION_META: Record = { delivery: { id: 'delivery', adminOnly: false }, @@ -48,6 +49,53 @@ const USER_OVERRIDE_SECTION_META: Record !USER_OVERRIDE_SECTION_META[sectionId].adminOnly); + +const isUserOverrideSectionId = (value: string): value is UserOverrideSectionId => ( + USER_OVERRIDE_SECTION_ID_SET.has(value as UserOverrideSectionId) +); + +export const normalizeUserOverrideSections = ( + sections: Iterable | null | undefined, + scope: UserOverrideScope, +): UserOverrideSectionId[] => { + const fallbackSections = scope === 'self' + ? DEFAULT_SELF_USER_OVERRIDE_SECTIONS + : USER_OVERRIDE_SECTION_ORDER; + + if (!sections) { + return fallbackSections; + } + + const requestedValues = Array.from(sections); + if (requestedValues.length === 0) { + return []; + } + + const requestedIds = new Set(); + requestedValues.forEach((value) => { + const normalizedValue = String(value ?? '').trim(); + if (isUserOverrideSectionId(normalizedValue)) { + requestedIds.add(normalizedValue); + } + }); + + if (requestedIds.size === 0) { + return fallbackSections; + } + + return USER_OVERRIDE_SECTION_ORDER.filter((sectionId) => { + if (!requestedIds.has(sectionId)) { + return false; + } + if (scope === 'self' && USER_OVERRIDE_SECTION_META[sectionId].adminOnly) { + return false; + } + return true; + }); +}; + export const UserOverridesSections = ({ scope, sections, @@ -60,13 +108,7 @@ export const UserOverridesSections = ({ globalUsersSettingsValues, onTestNotificationRoutes, }: UserOverridesSectionsProps) => { - const requestedSections = sections ?? USER_OVERRIDE_SECTION_ORDER; - const activeSections = requestedSections.filter((sectionId) => { - if (scope === 'self' && USER_OVERRIDE_SECTION_META[sectionId].adminOnly) { - return false; - } - return true; - }); + const activeSections = normalizeUserOverrideSections(sections, scope); const sectionNodes: UserOverrideSectionNode[] = []; diff --git a/src/frontend/src/components/settings/users/index.ts b/src/frontend/src/components/settings/users/index.ts index 4942ab6..e5f0c9f 100644 --- a/src/frontend/src/components/settings/users/index.ts +++ b/src/frontend/src/components/settings/users/index.ts @@ -11,7 +11,11 @@ export { UserListView } from './UserListView'; export { RequestPolicyGrid } from './RequestPolicyGrid'; export { UserNotificationOverridesSection } from './UserNotificationOverridesSection'; export { UserOverridesSection } from './UserOverridesSection'; -export { UserOverridesSections } from './UserOverridesSections'; +export { + UserOverridesSections, + DEFAULT_SELF_USER_OVERRIDE_SECTIONS, + normalizeUserOverrideSections, +} from './UserOverridesSections'; export { UserOverridesView } from './UserOverridesView'; export { useUserForm } from './useUserForm'; export { useUserMutations } from './useUserMutations'; @@ -27,4 +31,5 @@ export { export type { RequestPolicyContentType, } from './requestPolicyGridUtils'; +export type { UserOverrideSectionId } from './UserOverridesSections'; export type { RequestPolicyMode } from '../../../types'; diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index f11f2a9..b69c7a0 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -600,6 +600,7 @@ export interface SelfUserEditContext { deliveryPreferences: DeliveryPreferencesResponse | null; notificationPreferences: DeliveryPreferencesResponse | null; userOverridableKeys: string[]; + visibleUserSettingsSections?: string[]; } export const getAdminUsers = async (): Promise => { diff --git a/tests/config/test_download_settings.py b/tests/config/test_download_settings.py index 9c3421d..3455856 100644 --- a/tests/config/test_download_settings.py +++ b/tests/config/test_download_settings.py @@ -46,3 +46,31 @@ def test_download_settings_email_recipient_field_uses_default_label(): assert email_field.label == "Default Email Recipient" assert "Optional fallback" in email_field.description + + +def test_download_settings_booklore_destination_field_defaults_to_library(): + from shelfmark.config.settings import download_settings + + fields = download_settings() + destination_field = next(field for field in fields if getattr(field, "key", None) == "BOOKLORE_DESTINATION") + + assert destination_field.default == "library" + option_values = {option["value"] for option in destination_field.options} + assert option_values == {"library", "bookdrop"} + + +def test_download_settings_booklore_library_and_path_depend_on_library_destination(): + from shelfmark.config.settings import download_settings + + fields = download_settings() + library_field = next(field for field in fields if getattr(field, "key", None) == "BOOKLORE_LIBRARY_ID") + path_field = next(field for field in fields if getattr(field, "key", None) == "BOOKLORE_PATH_ID") + + assert library_field.show_when == [ + {"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, + {"field": "BOOKLORE_DESTINATION", "value": "library"}, + ] + assert path_field.show_when == [ + {"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, + {"field": "BOOKLORE_DESTINATION", "value": "library"}, + ] diff --git a/tests/config/test_users_settings.py b/tests/config/test_users_settings.py index 8ca3bed..5e18019 100644 --- a/tests/config/test_users_settings.py +++ b/tests/config/test_users_settings.py @@ -21,6 +21,7 @@ def test_users_tab_registers_request_policy_fields(): fields = _field_map("users") expected_keys = { "users_management", + "VISIBLE_SELF_SETTINGS_SECTIONS", "REQUESTS_ENABLED", "request_policy_editor", "MAX_PENDING_REQUESTS_PER_USER", @@ -70,6 +71,28 @@ def test_request_policy_fields_are_user_overridable(): } assert expected_keys.issubset(set(overridable_map)) assert "RESTRICT_SETTINGS_TO_ADMIN" not in overridable_map + assert "VISIBLE_SELF_SETTINGS_SECTIONS" not in overridable_map + + +def test_visible_self_settings_sections_field_defaults_and_options(): + fields = _field_map("users") + field = fields["VISIBLE_SELF_SETTINGS_SECTIONS"] + + assert field.default == ["delivery", "notifications"] + assert field.variant == "dropdown" + assert field.env_supported is False + assert field.options == [ + { + "value": "delivery", + "label": "Delivery Preferences", + "description": "Show personal delivery output and destination settings.", + }, + { + "value": "notifications", + "label": "Notifications", + "description": "Show personal notification route settings.", + }, + ] def test_users_tab_registers_custom_components(): diff --git a/tests/core/test_booklore_multiuser.py b/tests/core/test_booklore_multiuser.py index 772bd16..b027e93 100644 --- a/tests/core/test_booklore_multiuser.py +++ b/tests/core/test_booklore_multiuser.py @@ -10,6 +10,7 @@ class TestBuildBookloreConfigWithOverrides: "BOOKLORE_HOST": "http://booklore:6060", "BOOKLORE_USERNAME": "admin", "BOOKLORE_PASSWORD": "secret", + "BOOKLORE_DESTINATION": "library", "BOOKLORE_LIBRARY_ID": 1, "BOOKLORE_PATH_ID": 10, } @@ -58,6 +59,7 @@ class TestBuildBookloreConfigWithOverrides: config = build_booklore_config(self.BASE_SETTINGS, user_id=None) assert config.library_id == 1 assert config.path_id == 10 + assert config.upload_to_bookdrop is False def test_auth_fields_remain_global(self, monkeypatch): """Only Booklore library/path should be resolved with user context.""" @@ -73,3 +75,18 @@ class TestBuildBookloreConfigWithOverrides: assert config.base_url == "http://booklore:6060" assert config.username == "admin" assert config.library_id == 5 + + def test_bookdrop_destination_ignores_library_and_path_values(self): + settings = { + "BOOKLORE_HOST": "http://booklore:6060", + "BOOKLORE_USERNAME": "admin", + "BOOKLORE_PASSWORD": "secret", + "BOOKLORE_DESTINATION": "bookdrop", + } + + config = build_booklore_config(settings) + + assert config.upload_to_bookdrop is True + assert config.library_id == 0 + assert config.path_id == 0 + assert config.refresh_after_upload is False diff --git a/tests/core/test_booklore_upload.py b/tests/core/test_booklore_upload.py new file mode 100644 index 0000000..dc7e349 --- /dev/null +++ b/tests/core/test_booklore_upload.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock, patch + +from shelfmark.download.outputs.booklore import BookloreConfig, booklore_upload_file + + +def _booklore_config(upload_to_bookdrop: bool) -> BookloreConfig: + return BookloreConfig( + base_url="http://booklore:6060", + username="admin", + password="secret", + library_id=7, + path_id=21, + upload_to_bookdrop=upload_to_bookdrop, + refresh_after_upload=not upload_to_bookdrop, + ) + + +def test_booklore_upload_file_uses_library_endpoint_with_query_params(tmp_path): + file_path = tmp_path / "book.epub" + file_path.write_bytes(b"content") + response = MagicMock() + response.raise_for_status.return_value = None + + with patch( + "shelfmark.download.outputs.booklore.requests.post", + return_value=response, + ) as mock_post: + booklore_upload_file(_booklore_config(upload_to_bookdrop=False), "token", file_path) + + assert mock_post.call_count == 1 + args, kwargs = mock_post.call_args + assert args[0] == "http://booklore:6060/api/v1/files/upload" + assert kwargs["params"] == {"libraryId": 7, "pathId": 21} + assert kwargs["headers"] == {"Authorization": "Bearer token"} + + +def test_booklore_upload_file_uses_bookdrop_endpoint_without_query_params(tmp_path): + file_path = tmp_path / "book.epub" + file_path.write_bytes(b"content") + response = MagicMock() + response.raise_for_status.return_value = None + + with patch( + "shelfmark.download.outputs.booklore.requests.post", + return_value=response, + ) as mock_post: + booklore_upload_file(_booklore_config(upload_to_bookdrop=True), "token", file_path) + + assert mock_post.call_count == 1 + args, kwargs = mock_post.call_args + assert args[0] == "http://booklore:6060/api/v1/files/upload/bookdrop" + assert kwargs["params"] is None + assert kwargs["headers"] == {"Authorization": "Bearer token"} diff --git a/tests/core/test_self_user_routes.py b/tests/core/test_self_user_routes.py new file mode 100644 index 0000000..1d4756d --- /dev/null +++ b/tests/core/test_self_user_routes.py @@ -0,0 +1,141 @@ +"""Tests for self-service account edit context and update endpoints.""" + +import os +import tempfile +from typing import Any +from unittest.mock import patch + +import pytest +from flask import Flask + +from shelfmark.core.self_user_routes import register_self_user_routes +from shelfmark.core.user_db import UserDB + + +@pytest.fixture +def db_path(): + with tempfile.TemporaryDirectory() as tmpdir: + yield os.path.join(tmpdir, "shelfmark.db") + + +@pytest.fixture +def user_db(db_path): + db = UserDB(db_path) + db.initialize() + return db + + +@pytest.fixture +def app(user_db): + test_app = Flask(__name__) + test_app.config["SECRET_KEY"] = "test-secret" + test_app.config["TESTING"] = True + + register_self_user_routes(test_app, user_db) + return test_app + + +def _authed_client_for_user(app: Flask, user: dict) -> Any: + client = app.test_client() + with client.session_transaction() as sess: + sess["user_id"] = user["username"] + sess["db_user_id"] = user["id"] + sess["is_admin"] = False + return client + + +def test_users_me_edit_context_respects_visible_sections(app, user_db): + user = user_db.create_user(username="alice") + client = _authed_client_for_user(app, user) + + def build_preferences(_user_db, _user_id, tab_name): + if tab_name == "downloads": + return { + "tab": "downloads", + "keys": ["DESTINATION"], + "fields": [], + "globalValues": {}, + "userOverrides": {}, + "effective": {}, + } + raise AssertionError(f"Unexpected tab requested: {tab_name}") + + with patch("shelfmark.core.self_user_routes._get_auth_mode", return_value="builtin"): + with patch( + "shelfmark.core.self_user_routes.load_config_file", + side_effect=lambda tab_name: {"VISIBLE_SELF_SETTINGS_SECTIONS": ["delivery"]} if tab_name == "users" else {}, + ): + with patch( + "shelfmark.core.self_user_routes._build_user_preferences_payload", + side_effect=build_preferences, + ): + resp = client.get("/api/users/me/edit-context") + + assert resp.status_code == 200 + assert resp.json["visibleUserSettingsSections"] == ["delivery"] + assert resp.json["deliveryPreferences"]["tab"] == "downloads" + assert resp.json["notificationPreferences"] is None + assert resp.json["userOverridableKeys"] == ["DESTINATION"] + + +def test_users_me_update_rejects_hidden_section_settings(app, user_db): + user = user_db.create_user(username="alice") + client = _authed_client_for_user(app, user) + + def ordered_overrides(tab_name: str): + if tab_name == "downloads": + return [("DESTINATION", object())] + raise AssertionError(f"Unexpected tab requested: {tab_name}") + + with patch("shelfmark.core.self_user_routes._get_auth_mode", return_value="builtin"): + with patch( + "shelfmark.core.self_user_routes.load_config_file", + side_effect=lambda tab_name: {"VISIBLE_SELF_SETTINGS_SECTIONS": ["delivery"]} if tab_name == "users" else {}, + ): + with patch( + "shelfmark.core.self_user_routes._get_ordered_user_overridable_fields", + side_effect=ordered_overrides, + ): + resp = client.put( + "/api/users/me", + json={ + "settings": { + "USER_NOTIFICATION_ROUTES": [{"event": "all", "url": "ntfys://ntfy.sh/alice"}], + } + }, + ) + + assert resp.status_code == 400 + assert resp.json["error"] == "Some settings are admin-only" + assert "Setting not user-overridable: USER_NOTIFICATION_ROUTES" in resp.json["details"] + + +def test_users_me_update_accepts_visible_section_settings(app, user_db): + user = user_db.create_user(username="alice") + client = _authed_client_for_user(app, user) + + def ordered_overrides(tab_name: str): + if tab_name == "downloads": + return [("DESTINATION", object())] + raise AssertionError(f"Unexpected tab requested: {tab_name}") + + with patch("shelfmark.core.self_user_routes._get_auth_mode", return_value="builtin"): + with patch( + "shelfmark.core.self_user_routes.load_config_file", + side_effect=lambda tab_name: {"VISIBLE_SELF_SETTINGS_SECTIONS": ["delivery"]} if tab_name == "users" else {}, + ): + with patch( + "shelfmark.core.self_user_routes._get_ordered_user_overridable_fields", + side_effect=ordered_overrides, + ): + with patch( + "shelfmark.core.self_user_routes.validate_user_settings", + side_effect=lambda payload: (payload, []), + ): + resp = client.put( + "/api/users/me", + json={"settings": {"DESTINATION": "/books/alice"}}, + ) + + assert resp.status_code == 200 + assert user_db.get_user_settings(user["id"]).get("DESTINATION") == "/books/alice" diff --git a/tests/prowlarr/test_deluge_client.py b/tests/prowlarr/test_deluge_client.py new file mode 100644 index 0000000..ff1ea80 --- /dev/null +++ b/tests/prowlarr/test_deluge_client.py @@ -0,0 +1,99 @@ +"""Unit tests for the Deluge client.""" + +from unittest.mock import MagicMock, patch + +from shelfmark.download.clients.torrent_utils import TorrentInfo + + +def make_config_getter(values): + """Create a config.get function that returns values from a dict.""" + + def getter(key, default=""): + return values.get(key, default) + + return getter + + +class TestDelugeClientAddDownload: + """Tests for DelugeClient.add_download().""" + + def test_add_download_uses_configured_download_dir(self, monkeypatch): + """Add torrent should pass configured download location option.""" + config_values = { + "DELUGE_HOST": "http://localhost", + "DELUGE_PORT": "8112", + "DELUGE_PASSWORD": "password", + "DELUGE_CATEGORY": "books", + "DELUGE_DOWNLOAD_DIR": "/downloads/books", + } + monkeypatch.setattr( + "shelfmark.download.clients.deluge.config.get", + make_config_getter(config_values), + ) + + from shelfmark.download.clients.deluge import DelugeClient + + client = DelugeClient() + monkeypatch.setattr(client, "_ensure_connected", lambda: None) + mock_rpc_call = MagicMock(return_value="abcdef1234567890abcdef1234567890abcdef12") + monkeypatch.setattr(client, "_rpc_call", mock_rpc_call) + mock_try_set_label = MagicMock() + monkeypatch.setattr(client, "_try_set_label", mock_try_set_label) + + magnet = "magnet:?xt=urn:btih:ABCDEF1234567890ABCDEF1234567890ABCDEF12&dn=test" + with patch("shelfmark.download.clients.deluge.extract_torrent_info", autospec=True) as mock_extract: + mock_extract.return_value = TorrentInfo( + info_hash="abcdef1234567890abcdef1234567890abcdef12", + torrent_data=None, + is_magnet=True, + magnet_url=magnet, + ) + result = client.add_download(magnet, "Test") + + assert result == "abcdef1234567890abcdef1234567890abcdef12" + mock_rpc_call.assert_called_once_with( + "core.add_torrent_magnet", + magnet, + {"download_location": "/downloads/books"}, + ) + mock_try_set_label.assert_called_once_with( + "abcdef1234567890abcdef1234567890abcdef12", + "books", + ) + + def test_add_download_uses_empty_options_without_download_dir(self, monkeypatch): + """Add torrent should keep options empty when no directory is configured.""" + config_values = { + "DELUGE_HOST": "http://localhost", + "DELUGE_PORT": "8112", + "DELUGE_PASSWORD": "password", + "DELUGE_CATEGORY": "books", + } + monkeypatch.setattr( + "shelfmark.download.clients.deluge.config.get", + make_config_getter(config_values), + ) + + from shelfmark.download.clients.deluge import DelugeClient + + client = DelugeClient() + monkeypatch.setattr(client, "_ensure_connected", lambda: None) + mock_rpc_call = MagicMock(return_value="abcdef1234567890abcdef1234567890abcdef12") + monkeypatch.setattr(client, "_rpc_call", mock_rpc_call) + monkeypatch.setattr(client, "_try_set_label", MagicMock()) + + magnet = "magnet:?xt=urn:btih:ABCDEF1234567890ABCDEF1234567890ABCDEF12&dn=test" + with patch("shelfmark.download.clients.deluge.extract_torrent_info", autospec=True) as mock_extract: + mock_extract.return_value = TorrentInfo( + info_hash="abcdef1234567890abcdef1234567890abcdef12", + torrent_data=None, + is_magnet=True, + magnet_url=magnet, + ) + client.add_download(magnet, "Test") + + mock_rpc_call.assert_called_once_with( + "core.add_torrent_magnet", + magnet, + {}, + ) diff --git a/tests/prowlarr/test_qbittorrent_client.py b/tests/prowlarr/test_qbittorrent_client.py index 87f7f8f..0185128 100644 --- a/tests/prowlarr/test_qbittorrent_client.py +++ b/tests/prowlarr/test_qbittorrent_client.py @@ -587,6 +587,48 @@ class TestQBittorrentClientAddDownload: mock_client_instance.torrents_create_category.assert_called_once_with(name="books") + def test_add_download_uses_configured_download_dir(self, monkeypatch): + """Test that add_download passes configured download directory.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "books", + "QBITTORRENT_DOWNLOAD_DIR": "/downloads/books", + } + monkeypatch.setattr( + "shelfmark.download.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + mock_client_instance = MagicMock() + mock_client_instance.torrents_add.return_value = "Ok." + mock_client_instance._session.get.return_value = create_mock_session_response({}, status_code=200) + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import shelfmark.download.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + with patch( + "shelfmark.download.clients.qbittorrent.extract_torrent_info", + autospec=True, + ) as mock_extract: + mock_extract.return_value = TorrentInfo( + info_hash=valid_hash, + torrent_data=None, + is_magnet=True, + magnet_url=f"magnet:?xt=urn:btih:{valid_hash}&dn=test", + ) + + client = qb_module.QBittorrentClient() + client.add_download("magnet:?xt=urn:btih:test&dn=test", "Test") + + call_kwargs = mock_client_instance.torrents_add.call_args.kwargs + assert call_kwargs.get("save_path") == "/downloads/books" + class TestQBittorrentClientRemove: """Tests for QBittorrentClient.remove().""" diff --git a/tests/prowlarr/test_transmission_client.py b/tests/prowlarr/test_transmission_client.py index 1e4b385..189538f 100644 --- a/tests/prowlarr/test_transmission_client.py +++ b/tests/prowlarr/test_transmission_client.py @@ -474,6 +474,43 @@ class TestTransmissionClientAddDownload: call_kwargs = mock_client_instance.add_torrent.call_args assert call_kwargs.kwargs.get("labels") == ["mybooks"] + def test_add_download_uses_configured_download_dir(self, monkeypatch): + """Test that add_download passes configured download directory.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "mybooks", + "TRANSMISSION_DOWNLOAD_DIR": "/downloads/books", + } + monkeypatch.setattr( + "shelfmark.download.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_torrent = MockTorrent(hash_string="abc123") + mock_client_instance = MagicMock() + mock_client_instance.add_torrent.return_value = mock_torrent + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "shelfmark.download.clients.transmission" in sys.modules: + del sys.modules["shelfmark.download.clients.transmission"] + + from shelfmark.download.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + magnet = "magnet:?xt=urn:btih:abc123&dn=test" + client.add_download(magnet, "Test") + + call_kwargs = mock_client_instance.add_torrent.call_args + assert call_kwargs.kwargs.get("labels") == ["mybooks"] + assert call_kwargs.kwargs.get("download_dir") == "/downloads/books" + class TestTransmissionClientRemove: """Tests for TransmissionClient.remove()."""