mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-10 12:28:27 +00:00
feat(clients): separate audiobook download directory for torrent clients (#1500)
## Summary Related to #1048 (covers the "separate book and audiobook locations" request, not per-category routing). Every download client already had a separate audiobook label/category, but the four torrent clients only had a single save directory, so books and audiobooks ended up mixed in the client's download folder. Each torrent client (qBittorrent, Transmission, Deluge, rTorrent) gets an opt-in **Separate Book and Audiobook Directories** toggle (`<CLIENT>_SEPARATE_DOWNLOAD_DIRS`, default off): - **Off (default):** unchanged. The single `<CLIENT>_DOWNLOAD_DIR` is used for everything, so existing setups behave exactly as before. - **On:** `<CLIENT>_DOWNLOAD_DIR_BOOK` and `<CLIENT>_DOWNLOAD_DIR_AUDIOBOOK` replace the shared field. A directory left empty means the client's own default; it never falls back to the shared directory or another Shelfmark setting. The content type was already passed to each client's `add_download`, so a small shared helper (`resolve_download_dir`) does the selection. Usenet clients are unchanged: their save location comes from the category configured in the client, and a separate audiobook category is already sent. ## Screenshots Default Setting <img width="817" height="271" alt="Screenshot 2026-10-20 at 14-54-26 Shelfmark" src="https://github.com/user-attachments/assets/29941f83-9034-4ea5-bcb2-8e91ce77e63a" /> Separate Locations <img width="817" height="425" alt="Screenshot 2026-10-20 at 14-54-58 Shelfmark" src="https://github.com/user-attachments/assets/772b7194-a072-4945-bac5-b6959550fd75" /> ## Not included Routing to many folders by document type (the main request in #1048) still only exists for Direct download via Content-Type Routing. Extending it to torrent and usenet sources needs a decision on what to route by. ## Testing - `pytest tests --ignore=tests/e2e` passes (3561 passed); ruff clean. The `tests/e2e/platform` suite needs a running cluster and was not run. - New unit tests for the helper (including no fallback to the shared directory) and a qBittorrent add test covering book vs audiobook directories. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
86943f5a77
commit
ceb23dda7c
9 files changed
+428
-13
No files matched your search
@@ -1884,24 +1884,36 @@ How long to keep cached search results before they expire.
|
||||
| `DEBRIDLINK_API_KEY` | Debrid-Link API Key from your Debrid-Link account settings | string (secret) | _none_ |
|
||||
| `REALDEBRID_API_KEY` | Real-Debrid API Key (Secret Token) from your Real-Debrid account settings | string (secret) | _none_ |
|
||||
| `TORBOX_API_KEY` | TorBox API Key from your TorBox account settings | string (secret) | _none_ |
|
||||
| `QBITTORRENT_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ |
|
||||
| `QBITTORRENT_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ |
|
||||
| `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_API_KEY` | Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set. | 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_ |
|
||||
| `TRANSMISSION_SEPARATE_DOWNLOAD_DIRS` | Download books and audiobooks to different directories instead of one shared directory. | boolean | `false` |
|
||||
| `QBITTORRENT_SEPARATE_DOWNLOAD_DIRS` | Download books and audiobooks to different directories instead of one shared directory. | boolean | `false` |
|
||||
| `TRANSMISSION_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses Transmission default if not specified) | string | _none_ |
|
||||
| `TRANSMISSION_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses Transmission default if not specified) | string | _none_ |
|
||||
| `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 (comma-separated) | _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_ |
|
||||
| `DELUGE_SEPARATE_DOWNLOAD_DIRS` | Download books and audiobooks to different directories instead of one shared directory. | boolean | `false` |
|
||||
| `TRANSMISSION_CATEGORY` | Label to assign to book downloads in Transmission | string | `books` |
|
||||
| `DELUGE_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses Deluge default if not specified) | string | _none_ |
|
||||
| `DELUGE_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses Deluge default if not specified) | string | _none_ |
|
||||
| `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_ |
|
||||
| `RTORRENT_SEPARATE_DOWNLOAD_DIRS` | Download books and audiobooks to different directories instead of one shared directory. | boolean | `false` |
|
||||
| `DELUGE_CATEGORY` | Label to assign to book downloads in Deluge | string | `books` |
|
||||
| `RTORRENT_DOWNLOAD_DIR_BOOK` | Server-side directory where book torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
|
||||
| `RTORRENT_DOWNLOAD_DIR_AUDIOBOOK` | Server-side directory where audiobook torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ |
|
||||
| `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_ |
|
||||
@@ -2031,6 +2043,15 @@ Category to assign to book downloads in qBittorrent
|
||||
- **Default:** `books`
|
||||
|
||||
#### `QBITTORRENT_CATEGORY_AUDIOBOOK`
|
||||
#### `QBITTORRENT_SEPARATE_DOWNLOAD_DIRS`
|
||||
|
||||
**Separate Book and Audiobook Directories**
|
||||
|
||||
Download books and audiobooks to different directories instead of one shared directory.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
|
||||
**Audiobook Category**
|
||||
|
||||
@@ -2040,6 +2061,7 @@ Category for audiobook downloads. Leave empty to use the book category.
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `QBITTORRENT_DOWNLOAD_DIR`
|
||||
#### `QBITTORRENT_DOWNLOAD_DIR_BOOK`
|
||||
|
||||
**Download Directory**
|
||||
|
||||
@@ -2048,6 +2070,23 @@ Server-side directory where torrents are downloaded (optional, uses qBittorrent
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
|
||||
**Book Download Directory**
|
||||
|
||||
Server-side directory where book torrents are downloaded (optional, uses qBittorrent default if not specified)
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `QBITTORRENT_DOWNLOAD_DIR_AUDIOBOOK`
|
||||
|
||||
**Audiobook Download Directory**
|
||||
|
||||
Server-side directory where audiobook torrents are downloaded (optional, uses qBittorrent default if not specified)
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `QBITTORRENT_TAG`
|
||||
|
||||
**Tags**
|
||||
@@ -2094,6 +2133,15 @@ Label to assign to book downloads in Transmission
|
||||
- **Default:** `books`
|
||||
|
||||
#### `TRANSMISSION_CATEGORY_AUDIOBOOK`
|
||||
#### `TRANSMISSION_SEPARATE_DOWNLOAD_DIRS`
|
||||
|
||||
**Separate Book and Audiobook Directories**
|
||||
|
||||
Download books and audiobooks to different directories instead of one shared directory.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
|
||||
**Audiobook Label**
|
||||
|
||||
@@ -2103,6 +2151,7 @@ Label for audiobook downloads. Leave empty to use the book label.
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `TRANSMISSION_DOWNLOAD_DIR`
|
||||
#### `TRANSMISSION_DOWNLOAD_DIR_BOOK`
|
||||
|
||||
**Download Directory**
|
||||
|
||||
@@ -2111,6 +2160,23 @@ Server-side directory where torrents are downloaded (optional, uses Transmission
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
|
||||
**Book Download Directory**
|
||||
|
||||
Server-side directory where book torrents are downloaded (optional, uses Transmission default if not specified)
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `TRANSMISSION_DOWNLOAD_DIR_AUDIOBOOK`
|
||||
|
||||
**Audiobook Download Directory**
|
||||
|
||||
Server-side directory where audiobook torrents are downloaded (optional, uses Transmission default if not specified)
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `DELUGE_HOST`
|
||||
|
||||
**Deluge Web UI Host/URL**
|
||||
@@ -2148,6 +2214,15 @@ Label to assign to book downloads in Deluge
|
||||
- **Default:** `books`
|
||||
|
||||
#### `DELUGE_CATEGORY_AUDIOBOOK`
|
||||
#### `DELUGE_SEPARATE_DOWNLOAD_DIRS`
|
||||
|
||||
**Separate Book and Audiobook Directories**
|
||||
|
||||
Download books and audiobooks to different directories instead of one shared directory.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
|
||||
**Audiobook Label**
|
||||
|
||||
@@ -2157,6 +2232,7 @@ Label for audiobook downloads. Leave empty to use the book label.
|
||||
- **Default:** _empty string_
|
||||
|
||||
#### `DELUGE_DOWNLOAD_DIR`
|
||||
#### `DELUGE_DOWNLOAD_DIR_BOOK`
|
||||
|
||||
**Download Directory**
|
||||
|
||||
@@ -2165,6 +2241,23 @@ Server-side directory where torrents are downloaded (optional, uses Deluge defau
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
|
||||
**Book Download Directory**
|
||||
|
||||
Server-side directory where book torrents are downloaded (optional, uses Deluge default if not specified)
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `DELUGE_DOWNLOAD_DIR_AUDIOBOOK`
|
||||
|
||||
**Audiobook Download Directory**
|
||||
|
||||
Server-side directory where audiobook torrents are downloaded (optional, uses Deluge default if not specified)
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `RTORRENT_URL`
|
||||
|
||||
**rTorrent URL**
|
||||
@@ -2202,6 +2295,15 @@ Label to assign to ebook downloads in rTorrent
|
||||
- **Default:** `cwabd`
|
||||
|
||||
#### `RTORRENT_AUDIOBOOK_LABEL`
|
||||
#### `RTORRENT_SEPARATE_DOWNLOAD_DIRS`
|
||||
|
||||
**Separate Book and Audiobook Directories**
|
||||
|
||||
Download books and audiobooks to different directories instead of one shared directory.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
|
||||
**Audiobook Label**
|
||||
|
||||
@@ -2211,6 +2313,7 @@ Label to assign to audiobook downloads in rTorrent (falls back to Book Label if
|
||||
- **Default:** _none_
|
||||
|
||||
#### `RTORRENT_DOWNLOAD_DIR`
|
||||
#### `RTORRENT_DOWNLOAD_DIR_BOOK`
|
||||
|
||||
**Download Directory**
|
||||
|
||||
@@ -2219,6 +2322,23 @@ Server-side directory where torrents are downloaded (optional, uses rTorrent def
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
|
||||
**Book Download Directory**
|
||||
|
||||
Server-side directory where book torrents are downloaded (optional, uses rTorrent default if not specified)
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `RTORRENT_DOWNLOAD_DIR_AUDIOBOOK`
|
||||
|
||||
**Audiobook Download Directory**
|
||||
|
||||
Server-side directory where audiobook torrents are downloaded (optional, uses rTorrent default if not specified)
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** _none_
|
||||
|
||||
#### `PROWLARR_TORRENT_ACTION`
|
||||
|
||||
**Torrent Completion Action**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Shared coercion helpers for download client config and option values."""
|
||||
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.core.utils import is_audiobook, normalize_http_url
|
||||
|
||||
|
||||
def config_text(value: object, default: str = "") -> str:
|
||||
@@ -12,6 +12,22 @@ def config_text(value: object, default: str = "") -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
def resolve_download_dir(
|
||||
*,
|
||||
separate: bool,
|
||||
shared_dir: str,
|
||||
book_dir: str,
|
||||
audiobook_dir: str,
|
||||
content_type: object,
|
||||
) -> str:
|
||||
"""Pick the client save directory; empty means use the client's own default."""
|
||||
if not separate:
|
||||
return shared_dir
|
||||
if is_audiobook(content_type if isinstance(content_type, str) else None):
|
||||
return audiobook_dir
|
||||
return book_dir
|
||||
|
||||
|
||||
def normalize_http_config_url(value: object, *, require_string: bool = False) -> str:
|
||||
"""Normalize HTTP(S) config URLs with optional strict string-only input handling."""
|
||||
if require_string and not isinstance(value, str):
|
||||
|
||||
@@ -30,6 +30,7 @@ from shelfmark.download.clients._coercion import (
|
||||
coerce_optional_float,
|
||||
coerce_optional_int,
|
||||
config_text,
|
||||
resolve_download_dir,
|
||||
)
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
@@ -131,6 +132,9 @@ class DelugeClient(DownloadClient):
|
||||
|
||||
self._category = config_text(config.get("DELUGE_CATEGORY", "books"), "books")
|
||||
self._download_dir = config_text(config.get("DELUGE_DOWNLOAD_DIR", ""))
|
||||
self._separate_download_dirs = bool(config.get("DELUGE_SEPARATE_DOWNLOAD_DIRS", False))
|
||||
self._download_dir_book = config_text(config.get("DELUGE_DOWNLOAD_DIR_BOOK", ""))
|
||||
self._download_dir_audiobook = config_text(config.get("DELUGE_DOWNLOAD_DIR_AUDIOBOOK", ""))
|
||||
|
||||
def _next_rpc_id(self) -> int:
|
||||
self._rpc_id += 1
|
||||
@@ -286,8 +290,15 @@ class DelugeClient(DownloadClient):
|
||||
_raise_runtime_error(message)
|
||||
|
||||
options: dict[str, Any] = {}
|
||||
if self._download_dir:
|
||||
options["download_location"] = self._download_dir
|
||||
download_dir = resolve_download_dir(
|
||||
separate=self._separate_download_dirs,
|
||||
shared_dir=self._download_dir,
|
||||
book_dir=self._download_dir_book,
|
||||
audiobook_dir=self._download_dir_audiobook,
|
||||
content_type=kwargs.get("content_type"),
|
||||
)
|
||||
if download_dir:
|
||||
options["download_location"] = download_dir
|
||||
|
||||
# Per-torrent seeding limits from indexer
|
||||
seeding_time_limit = coerce_optional_int(kwargs.get("seeding_time_limit"))
|
||||
|
||||
@@ -23,6 +23,7 @@ from shelfmark.download.clients._coercion import (
|
||||
coerce_optional_int,
|
||||
config_text,
|
||||
normalize_http_config_url,
|
||||
resolve_download_dir,
|
||||
)
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
@@ -253,6 +254,11 @@ class QBittorrentClient(DownloadClient):
|
||||
)
|
||||
self._category = config_text(config.get("QBITTORRENT_CATEGORY", "books"))
|
||||
self._download_dir = config_text(config.get("QBITTORRENT_DOWNLOAD_DIR", ""))
|
||||
self._separate_download_dirs = bool(config.get("QBITTORRENT_SEPARATE_DOWNLOAD_DIRS", False))
|
||||
self._download_dir_book = config_text(config.get("QBITTORRENT_DOWNLOAD_DIR_BOOK", ""))
|
||||
self._download_dir_audiobook = config_text(
|
||||
config.get("QBITTORRENT_DOWNLOAD_DIR_AUDIOBOOK", "")
|
||||
)
|
||||
self._tags = _normalize_tags(config.get("QBITTORRENT_TAG", []))
|
||||
# download_id -> qBittorrent's current primary hash, for identities that no
|
||||
# longer match it directly. See _resolve_torrent().
|
||||
@@ -533,8 +539,15 @@ class QBittorrentClient(DownloadClient):
|
||||
add_kwargs: _QBittorrentAddKwargs = {"rename": name}
|
||||
if category:
|
||||
add_kwargs["category"] = category
|
||||
if self._download_dir:
|
||||
add_kwargs["save_path"] = self._download_dir
|
||||
download_dir = resolve_download_dir(
|
||||
separate=self._separate_download_dirs,
|
||||
shared_dir=self._download_dir,
|
||||
book_dir=self._download_dir_book,
|
||||
audiobook_dir=self._download_dir_audiobook,
|
||||
content_type=kwargs.get("content_type"),
|
||||
)
|
||||
if download_dir:
|
||||
add_kwargs["save_path"] = download_dir
|
||||
if tags:
|
||||
add_kwargs["tags"] = ",".join(tags)
|
||||
if seeding_time_limit is not None:
|
||||
|
||||
@@ -22,7 +22,11 @@ from shelfmark.download.clients import (
|
||||
DownloadStatus,
|
||||
register_client,
|
||||
)
|
||||
from shelfmark.download.clients._coercion import config_text, normalize_http_config_url
|
||||
from shelfmark.download.clients._coercion import (
|
||||
config_text,
|
||||
normalize_http_config_url,
|
||||
resolve_download_dir,
|
||||
)
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
)
|
||||
@@ -127,6 +131,11 @@ class RTorrentClient(DownloadClient):
|
||||
self._download_dir = config_text(config.get("RTORRENT_DOWNLOAD_DIR", ""))
|
||||
self._label = config_text(config.get("RTORRENT_LABEL", ""))
|
||||
self._audiobook_label = config_text(config.get("RTORRENT_AUDIOBOOK_LABEL", ""))
|
||||
self._separate_download_dirs = bool(config.get("RTORRENT_SEPARATE_DOWNLOAD_DIRS", False))
|
||||
self._download_dir_book = config_text(config.get("RTORRENT_DOWNLOAD_DIR_BOOK", ""))
|
||||
self._download_dir_audiobook = config_text(
|
||||
config.get("RTORRENT_DOWNLOAD_DIR_AUDIOBOOK", "")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
@@ -187,7 +196,16 @@ class RTorrentClient(DownloadClient):
|
||||
logger.debug("Setting rTorrent label: %s", label)
|
||||
commands.append(f"d.custom1.set={label}")
|
||||
|
||||
download_dir = self._download_dir or self._get_download_dir()
|
||||
download_dir = (
|
||||
resolve_download_dir(
|
||||
separate=self._separate_download_dirs,
|
||||
shared_dir=self._download_dir,
|
||||
book_dir=self._download_dir_book,
|
||||
audiobook_dir=self._download_dir_audiobook,
|
||||
content_type=content_type,
|
||||
)
|
||||
or self._get_download_dir()
|
||||
)
|
||||
if download_dir:
|
||||
logger.debug("Setting rTorrent download directory: %s", download_dir)
|
||||
commands.append(f"d.directory.set={download_dir}")
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, NoReturn, Protocol, TypeGuard
|
||||
|
||||
from shelfmark.core.settings_registry import (
|
||||
ActionButton,
|
||||
CheckboxField,
|
||||
HeadingField,
|
||||
NumberField,
|
||||
PasswordField,
|
||||
@@ -764,12 +765,42 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
default="",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
CheckboxField(
|
||||
key="QBITTORRENT_SEPARATE_DOWNLOAD_DIRS",
|
||||
label="Separate Book and Audiobook Directories",
|
||||
description="Download books and audiobooks to different directories instead of one shared directory.",
|
||||
default=False,
|
||||
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"},
|
||||
show_when=[
|
||||
{"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
{"field": "QBITTORRENT_SEPARATE_DOWNLOAD_DIRS", "value": False},
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
key="QBITTORRENT_DOWNLOAD_DIR_BOOK",
|
||||
label="Book Download Directory",
|
||||
description="Server-side directory where book torrents are downloaded (optional, uses qBittorrent default if not specified)",
|
||||
placeholder="/downloads/books",
|
||||
show_when=[
|
||||
{"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
{"field": "QBITTORRENT_SEPARATE_DOWNLOAD_DIRS", "value": True},
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
key="QBITTORRENT_DOWNLOAD_DIR_AUDIOBOOK",
|
||||
label="Audiobook Download Directory",
|
||||
description="Server-side directory where audiobook torrents are downloaded (optional, uses qBittorrent default if not specified)",
|
||||
placeholder="/downloads/audiobooks",
|
||||
show_when=[
|
||||
{"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
{"field": "QBITTORRENT_SEPARATE_DOWNLOAD_DIRS", "value": True},
|
||||
],
|
||||
),
|
||||
TagListField(
|
||||
key="QBITTORRENT_TAG",
|
||||
@@ -824,12 +855,42 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
default="",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
),
|
||||
CheckboxField(
|
||||
key="TRANSMISSION_SEPARATE_DOWNLOAD_DIRS",
|
||||
label="Separate Book and Audiobook Directories",
|
||||
description="Download books and audiobooks to different directories instead of one shared directory.",
|
||||
default=False,
|
||||
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"},
|
||||
show_when=[
|
||||
{"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
{"field": "TRANSMISSION_SEPARATE_DOWNLOAD_DIRS", "value": False},
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
key="TRANSMISSION_DOWNLOAD_DIR_BOOK",
|
||||
label="Book Download Directory",
|
||||
description="Server-side directory where book torrents are downloaded (optional, uses Transmission default if not specified)",
|
||||
placeholder="/downloads/books",
|
||||
show_when=[
|
||||
{"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
{"field": "TRANSMISSION_SEPARATE_DOWNLOAD_DIRS", "value": True},
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
key="TRANSMISSION_DOWNLOAD_DIR_AUDIOBOOK",
|
||||
label="Audiobook Download Directory",
|
||||
description="Server-side directory where audiobook torrents are downloaded (optional, uses Transmission default if not specified)",
|
||||
placeholder="/downloads/audiobooks",
|
||||
show_when=[
|
||||
{"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
{"field": "TRANSMISSION_SEPARATE_DOWNLOAD_DIRS", "value": True},
|
||||
],
|
||||
),
|
||||
# --- Deluge Settings ---
|
||||
TextField(
|
||||
@@ -878,12 +939,42 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
default="",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
),
|
||||
CheckboxField(
|
||||
key="DELUGE_SEPARATE_DOWNLOAD_DIRS",
|
||||
label="Separate Book and Audiobook Directories",
|
||||
description="Download books and audiobooks to different directories instead of one shared directory.",
|
||||
default=False,
|
||||
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"},
|
||||
show_when=[
|
||||
{"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
{"field": "DELUGE_SEPARATE_DOWNLOAD_DIRS", "value": False},
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
key="DELUGE_DOWNLOAD_DIR_BOOK",
|
||||
label="Book Download Directory",
|
||||
description="Server-side directory where book torrents are downloaded (optional, uses Deluge default if not specified)",
|
||||
placeholder="/downloads/books",
|
||||
show_when=[
|
||||
{"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
{"field": "DELUGE_SEPARATE_DOWNLOAD_DIRS", "value": True},
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
key="DELUGE_DOWNLOAD_DIR_AUDIOBOOK",
|
||||
label="Audiobook Download Directory",
|
||||
description="Server-side directory where audiobook torrents are downloaded (optional, uses Deluge default if not specified)",
|
||||
placeholder="/downloads/audiobooks",
|
||||
show_when=[
|
||||
{"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
{"field": "DELUGE_SEPARATE_DOWNLOAD_DIRS", "value": True},
|
||||
],
|
||||
),
|
||||
# --- rTorrent Settings ---
|
||||
TextField(
|
||||
@@ -928,12 +1019,42 @@ def prowlarr_clients_settings() -> list[SettingsField]:
|
||||
placeholder="audiobooks",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
||||
),
|
||||
CheckboxField(
|
||||
key="RTORRENT_SEPARATE_DOWNLOAD_DIRS",
|
||||
label="Separate Book and Audiobook Directories",
|
||||
description="Download books and audiobooks to different directories instead of one shared directory.",
|
||||
default=False,
|
||||
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"},
|
||||
show_when=[
|
||||
{"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
||||
{"field": "RTORRENT_SEPARATE_DOWNLOAD_DIRS", "value": False},
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
key="RTORRENT_DOWNLOAD_DIR_BOOK",
|
||||
label="Book Download Directory",
|
||||
description="Server-side directory where book torrents are downloaded (optional, uses rTorrent default if not specified)",
|
||||
placeholder="/downloads/books",
|
||||
show_when=[
|
||||
{"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
||||
{"field": "RTORRENT_SEPARATE_DOWNLOAD_DIRS", "value": True},
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
key="RTORRENT_DOWNLOAD_DIR_AUDIOBOOK",
|
||||
label="Audiobook Download Directory",
|
||||
description="Server-side directory where audiobook torrents are downloaded (optional, uses rTorrent default if not specified)",
|
||||
placeholder="/downloads/audiobooks",
|
||||
show_when=[
|
||||
{"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"},
|
||||
{"field": "RTORRENT_SEPARATE_DOWNLOAD_DIRS", "value": True},
|
||||
],
|
||||
),
|
||||
# Note: Torrent client download path must be mounted identically in both containers.
|
||||
SelectField(
|
||||
|
||||
@@ -21,6 +21,7 @@ from shelfmark.download.clients._coercion import (
|
||||
coerce_optional_int,
|
||||
config_text,
|
||||
normalize_http_config_url,
|
||||
resolve_download_dir,
|
||||
)
|
||||
from shelfmark.download.clients.torrent_utils import (
|
||||
extract_torrent_info,
|
||||
@@ -188,6 +189,13 @@ class TransmissionClient(DownloadClient):
|
||||
_apply_transmission_ssl_verify(self._client, url)
|
||||
self._category = config_text(config.get("TRANSMISSION_CATEGORY", "books"))
|
||||
self._download_dir = config_text(config.get("TRANSMISSION_DOWNLOAD_DIR", ""))
|
||||
self._separate_download_dirs = bool(
|
||||
config.get("TRANSMISSION_SEPARATE_DOWNLOAD_DIRS", False)
|
||||
)
|
||||
self._download_dir_book = config_text(config.get("TRANSMISSION_DOWNLOAD_DIR_BOOK", ""))
|
||||
self._download_dir_audiobook = config_text(
|
||||
config.get("TRANSMISSION_DOWNLOAD_DIR_AUDIOBOOK", "")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_configured() -> bool:
|
||||
@@ -238,8 +246,15 @@ class TransmissionClient(DownloadClient):
|
||||
|
||||
if resolved_category:
|
||||
add_kwargs["labels"] = [resolved_category]
|
||||
if self._download_dir:
|
||||
add_kwargs["download_dir"] = self._download_dir
|
||||
download_dir = resolve_download_dir(
|
||||
separate=self._separate_download_dirs,
|
||||
shared_dir=self._download_dir,
|
||||
book_dir=self._download_dir_book,
|
||||
audiobook_dir=self._download_dir_audiobook,
|
||||
content_type=kwargs.get("content_type"),
|
||||
)
|
||||
if download_dir:
|
||||
add_kwargs["download_dir"] = download_dir
|
||||
|
||||
if torrent_info.torrent_data:
|
||||
torrent = self._client.add_torrent(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests for per-content-type torrent client download directories."""
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.download.clients._coercion import resolve_download_dir
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("separate", "content_type", "expected"),
|
||||
[
|
||||
(False, "audiobook", "/shared"),
|
||||
(False, "ebook", "/shared"),
|
||||
(False, None, "/shared"),
|
||||
(True, "audiobook", "/audio"),
|
||||
(True, "ebook", "/books"),
|
||||
(True, None, "/books"),
|
||||
],
|
||||
)
|
||||
def test_resolve_download_dir(separate, content_type, expected):
|
||||
assert (
|
||||
resolve_download_dir(
|
||||
separate=separate,
|
||||
shared_dir="/shared",
|
||||
book_dir="/books",
|
||||
audiobook_dir="/audio",
|
||||
content_type=content_type,
|
||||
)
|
||||
== expected
|
||||
)
|
||||
|
||||
|
||||
def test_separate_dirs_do_not_fall_back_to_shared_dir():
|
||||
"""Empty per-type directories mean the client default, never the shared directory."""
|
||||
for content_type in ("audiobook", "ebook"):
|
||||
assert (
|
||||
resolve_download_dir(
|
||||
separate=True,
|
||||
shared_dir="/shared",
|
||||
book_dir="",
|
||||
audiobook_dir="",
|
||||
content_type=content_type,
|
||||
)
|
||||
== ""
|
||||
)
|
||||
@@ -1590,6 +1590,63 @@ class TestQBittorrentClientAddDownload:
|
||||
call_kwargs = mock_client_instance.torrents_add.call_args.kwargs
|
||||
assert call_kwargs.get("save_path") == "/downloads/books"
|
||||
|
||||
def test_add_download_uses_separate_book_and_audiobook_dirs(self, monkeypatch):
|
||||
"""With separate directories on, books and audiobooks use their own directories."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "books",
|
||||
"QBITTORRENT_DOWNLOAD_DIR": "/downloads/shared",
|
||||
"QBITTORRENT_SEPARATE_DOWNLOAD_DIRS": True,
|
||||
"QBITTORRENT_DOWNLOAD_DIR_BOOK": "/downloads/books",
|
||||
"QBITTORRENT_DOWNLOAD_DIR_AUDIOBOOK": "/downloads/audiobooks",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
mock_torrent = MockTorrent(hash_val=valid_hash)
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.torrents_add.return_value = "Ok."
|
||||
mock_client_instance._session.get.return_value = create_mock_session_response(
|
||||
[mock_torrent], 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", content_type="audiobook"
|
||||
)
|
||||
call_kwargs = mock_client_instance.torrents_add.call_args.kwargs
|
||||
assert call_kwargs.get("save_path") == "/downloads/audiobooks"
|
||||
|
||||
client.add_download(
|
||||
"magnet:?xt=urn:btih:test&dn=test", "Test", content_type="ebook"
|
||||
)
|
||||
call_kwargs = mock_client_instance.torrents_add.call_args.kwargs
|
||||
assert call_kwargs.get("save_path") == "/downloads/books"
|
||||
|
||||
|
||||
class TestQBittorrentClientRemove:
|
||||
"""Tests for QBittorrentClient.remove()."""
|
||||
|
||||
Reference in new issue
Block a user