diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 509f3af1..de509f8b 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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** diff --git a/shelfmark/download/clients/_coercion.py b/shelfmark/download/clients/_coercion.py index ff73ae6b..f7c096dc 100644 --- a/shelfmark/download/clients/_coercion.py +++ b/shelfmark/download/clients/_coercion.py @@ -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): diff --git a/shelfmark/download/clients/deluge.py b/shelfmark/download/clients/deluge.py index bfbae6f6..6c07399a 100644 --- a/shelfmark/download/clients/deluge.py +++ b/shelfmark/download/clients/deluge.py @@ -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")) diff --git a/shelfmark/download/clients/qbittorrent.py b/shelfmark/download/clients/qbittorrent.py index 05a417bf..389655d2 100644 --- a/shelfmark/download/clients/qbittorrent.py +++ b/shelfmark/download/clients/qbittorrent.py @@ -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: diff --git a/shelfmark/download/clients/rtorrent.py b/shelfmark/download/clients/rtorrent.py index 4eee95a5..7cdae371 100644 --- a/shelfmark/download/clients/rtorrent.py +++ b/shelfmark/download/clients/rtorrent.py @@ -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}") diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index 168badae..dd79b324 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -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( diff --git a/shelfmark/download/clients/transmission.py b/shelfmark/download/clients/transmission.py index 428d4944..e4d57d43 100644 --- a/shelfmark/download/clients/transmission.py +++ b/shelfmark/download/clients/transmission.py @@ -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( diff --git a/tests/prowlarr/test_download_dir_resolution.py b/tests/prowlarr/test_download_dir_resolution.py new file mode 100644 index 00000000..db8006c2 --- /dev/null +++ b/tests/prowlarr/test_download_dir_resolution.py @@ -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, + ) + == "" + ) diff --git a/tests/prowlarr/test_qbittorrent_client.py b/tests/prowlarr/test_qbittorrent_client.py index 68fbdc59..63aac331 100644 --- a/tests/prowlarr/test_qbittorrent_client.py +++ b/tests/prowlarr/test_qbittorrent_client.py @@ -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()."""