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:
amura11andClaude Sonnet 5.5 authored and GitHub committed 2026-10-21 10:23:14 -04:00
1 parent 86943f5a77
commit ceb23dda7c
9 files changed
+428 -13

No files matched your search

+120
View File
@@ -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**
+17 -1
View File
@@ -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):
+13 -2
View File
@@ -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"))
+15 -2
View File
@@ -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:
+20 -2
View File
@@ -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}")
+125 -4
View File
@@ -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(
+17 -2
View File
@@ -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,
)
== ""
)
+57
View File
@@ -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()."""