diff --git a/docs/environment-variables.md b/docs/environment-variables.md index d2fc92b..a1972e1 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -186,9 +186,11 @@ Default language filter for searches. |----------|-------------|------|---------| | `SEARCH_MODE` | How you want to search for and download books. | string (choice) | `direct` | | `AA_DEFAULT_SORT` | Default sort order for search results. | string (choice) | `relevance` | +| `SHOW_RELEASE_SOURCE_LINKS` | Show clickable release-source links in release and details modals. Metadata provider links stay enabled. | boolean | `true` | | `METADATA_PROVIDER` | Choose which metadata provider to use for book searches. | string (choice) | `openlibrary` | | `METADATA_PROVIDER_AUDIOBOOK` | Metadata provider for audiobook searches. Uses the book provider if not set. | string (choice) | _empty string_ | -| `DEFAULT_RELEASE_SOURCE` | The release source tab to open by default in the release modal. | string (choice) | `direct_download` | +| `DEFAULT_RELEASE_SOURCE` | The release source tab to open by default in the release modal for books. | string (choice) | `direct_download` | +| `DEFAULT_RELEASE_SOURCE_AUDIOBOOK` | The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set. | string (choice) | _empty string_ |
Detailed descriptions @@ -213,6 +215,15 @@ Default sort order for search results. - **Default:** `relevance` - **Options:** `relevance` (Most relevant), `newest` (Newest (publication year)), `oldest` (Oldest (publication year)), `largest` (Largest (filesize)), `smallest` (Smallest (filesize)), `newest_added` (Newest (open sourced)), `oldest_added` (Oldest (open sourced)) +#### `SHOW_RELEASE_SOURCE_LINKS` + +**Show Release Source Links** + +Show clickable release-source links in release and details modals. Metadata provider links stay enabled. + +- **Type:** boolean +- **Default:** `true` + #### `METADATA_PROVIDER` **Book Metadata Provider** @@ -221,7 +232,7 @@ Choose which metadata provider to use for book searches. - **Type:** string (choice) - **Default:** `openlibrary` -- **Options:** `""` (No providers enabled) +- **Options:** `hardcover` (Hardcover), `openlibrary` (Open Library), `googlebooks` (Google Books) #### `METADATA_PROVIDER_AUDIOBOOK` @@ -231,17 +242,27 @@ Metadata provider for audiobook searches. Uses the book provider if not set. - **Type:** string (choice) - **Default:** _empty string_ -- **Options:** `""` (Use book provider), `""` (No providers enabled) +- **Options:** `""` (Use book provider), `hardcover` (Hardcover), `openlibrary` (Open Library), `googlebooks` (Google Books) #### `DEFAULT_RELEASE_SOURCE` -**Default Release Source** +**Default Book Release Source** -The release source tab to open by default in the release modal. +The release source tab to open by default in the release modal for books. - **Type:** string (choice) - **Default:** `direct_download` -- **Options:** `direct_download` (Direct Download), `prowlarr` (Prowlarr), `audiobookbay` (AudiobookBay) +- **Options:** `direct_download` (Direct Download), `prowlarr` (Prowlarr) + +#### `DEFAULT_RELEASE_SOURCE_AUDIOBOOK` + +**Default Audiobook Release Source** + +The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set. + +- **Type:** string (choice) +- **Default:** _empty string_ +- **Options:** `""` (Use book release source), `prowlarr` (Prowlarr), `audiobookbay` (AudiobookBay)
@@ -278,7 +299,7 @@ The release source tab to open by default in the release modal. | `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {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` | +| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ | | `MAX_CONCURRENT_DOWNLOADS` | Maximum number of simultaneous downloads. | number | `3` | | `STATUS_TIMEOUT` | How long to keep completed/failed downloads in the queue display. | number | `3600` | @@ -561,14 +582,14 @@ Automatically open the downloads sidebar when a new download is queued. - **Type:** boolean - **Default:** `false` -#### `DOWNLOAD_TO_BROWSER` +#### `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` **Download to Browser** -Automatically download completed files to your browser. +Automatically download completed files to your browser for the selected content types. -- **Type:** boolean -- **Default:** `false` +- **Type:** string (comma-separated) +- **Default:** _empty list_ #### `MAX_CONCURRENT_DOWNLOADS` @@ -1249,6 +1270,7 @@ How long to keep cached search results before they expire. | `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_TORRENT_ACTION` | Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding | string (choice) | `keep` | | `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` | @@ -1490,6 +1512,16 @@ Server-side directory where torrents are downloaded (optional, uses rTorrent def - **Type:** string - **Default:** _none_ +#### `PROWLARR_TORRENT_ACTION` + +**Torrent Completion Action** + +Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding + +- **Type:** string (choice) +- **Default:** `keep` +- **Options:** `keep` (Keep), `remove` (Remove) + #### `PROWLARR_USENET_CLIENT` **Usenet Client** diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 5edfa24..5e59e73 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -185,6 +185,23 @@ _AUDIOBOOK_FORMAT_OPTIONS = [ {"value": "rar", "label": "RAR"}, ] +_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS = [ + { + "value": "book", + "label": "Books", + "description": "Automatically download completed book files to this browser.", + }, + { + "value": "audiobook", + "label": "Audiobooks", + "description": "Automatically download completed audiobook files to this browser.", + }, +] + +_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES = { + option["value"] for option in _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS +} + def _get_metadata_provider_options(): """Build metadata provider options dynamically from enabled providers only.""" @@ -210,17 +227,30 @@ def _get_metadata_provider_options_with_none(): return [{"value": "", "label": "Use book provider"}] + _get_metadata_provider_options() -def _get_release_source_options(): - """Build release source options dynamically from registered sources.""" +def _get_release_source_options_for_content_type(content_type: str): + """Build release source options dynamically for a specific content type.""" from shelfmark.release_sources import list_available_sources return [ {"value": source["name"], "label": source["display_name"]} for source in list_available_sources() if source.get("can_be_default", True) + and content_type in source.get("supported_content_types", ["ebook", "audiobook"]) ] +def _get_book_release_source_options(): + """Build default release source options for book searches.""" + return _get_release_source_options_for_content_type("ebook") + + +def _get_audiobook_release_source_options(): + """Build default release source options for audiobook searches.""" + return [{"value": "", "label": "Use book release source"}] + _get_release_source_options_for_content_type( + "audiobook" + ) + + _LANGUAGE_OPTIONS = [{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE] @@ -429,6 +459,15 @@ def search_mode_settings(): default="relevance", show_when={"field": "SEARCH_MODE", "value": "direct"}, ), + CheckboxField( + key="SHOW_RELEASE_SOURCE_LINKS", + label="Show Release Source Links", + description=( + "Show clickable release-source links in release and details modals. " + "Metadata provider links stay enabled." + ), + default=True, + ), HeadingField( key="universal_mode_heading", title="Universal Mode Settings", @@ -455,13 +494,22 @@ def search_mode_settings(): ), SelectField( key="DEFAULT_RELEASE_SOURCE", - label="Default Release Source", - description="The release source tab to open by default in the release modal.", - options=_get_release_source_options, # Callable - evaluated lazily to avoid circular imports + label="Default Book Release Source", + description="The release source tab to open by default in the release modal for books.", + options=_get_book_release_source_options, # Callable - evaluated lazily to avoid circular imports default="direct_download", show_when={"field": "SEARCH_MODE", "value": "universal"}, user_overridable=True, ), + SelectField( + key="DEFAULT_RELEASE_SOURCE_AUDIOBOOK", + label="Default Audiobook Release Source", + description="The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set.", + options=_get_audiobook_release_source_options, # Callable - evaluated lazily to avoid circular imports + default="", + show_when={"field": "SEARCH_MODE", "value": "universal"}, + user_overridable=True, + ), ] @@ -619,6 +667,41 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]: effective: dict[str, Any] = dict(existing) effective.update(values) + if "DOWNLOAD_TO_BROWSER_CONTENT_TYPES" in effective: + raw_content_types = effective.get("DOWNLOAD_TO_BROWSER_CONTENT_TYPES") + if raw_content_types is None: + normalized_content_types: list[str] = [] + elif isinstance(raw_content_types, list): + normalized_content_types = [ + str(value).strip().lower() + for value in raw_content_types + if str(value).strip() + ] + else: + return { + "error": True, + "message": "Download to Browser must be a list.", + "values": values, + } + + deduped_content_types: list[str] = [] + for content_type in normalized_content_types: + if content_type not in _DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES: + allowed = ", ".join(sorted(_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_VALUES)) + return { + "error": True, + "message": ( + "Download to Browser contains an unsupported content type " + f"'{content_type}'. Supported values: {allowed}" + ), + "values": values, + } + if content_type not in deduped_content_types: + deduped_content_types.append(content_type) + + values["DOWNLOAD_TO_BROWSER_CONTENT_TYPES"] = deduped_content_types + effective["DOWNLOAD_TO_BROWSER_CONTENT_TYPES"] = deduped_content_types + # Books: only validate templates when saving to a folder. books_output_mode = effective.get("BOOKS_OUTPUT_MODE", "folder") if books_output_mode == "folder" and effective.get("FILE_ORGANIZATION", "rename") == "rename": @@ -1106,11 +1189,14 @@ def download_settings(): description="Automatically open the downloads sidebar when a new download is queued.", default=False, ), - CheckboxField( - key="DOWNLOAD_TO_BROWSER", + MultiSelectField( + key="DOWNLOAD_TO_BROWSER_CONTENT_TYPES", label="Download to Browser", - description="Automatically download completed files to your browser.", - default=False, + description="Automatically download completed files to your browser for the selected content types.", + options=_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS, + default=[], + variant="dropdown", + user_overridable=True, ), NumberField( key="MAX_CONCURRENT_DOWNLOADS", diff --git a/shelfmark/config/users_settings.py b/shelfmark/config/users_settings.py index c9c7929..8b7f46a 100644 --- a/shelfmark/config/users_settings.py +++ b/shelfmark/config/users_settings.py @@ -76,6 +76,7 @@ _SEARCH_PREFERENCE_PROVIDER_KEYS = {"METADATA_PROVIDER", "METADATA_PROVIDER_AUDI _SEARCH_PREFERENCE_VALIDATABLE_KEYS = { "SEARCH_MODE", "DEFAULT_RELEASE_SOURCE", + "DEFAULT_RELEASE_SOURCE_AUDIOBOOK", *_SEARCH_PREFERENCE_PROVIDER_KEYS, } @@ -117,6 +118,18 @@ def _get_request_source_options(): return options +def _get_valid_release_source_names_for_content_type(content_type: str) -> set[str]: + """Return registered release source names that support the requested content type.""" + from shelfmark.release_sources import list_available_sources + + valid_sources: set[str] = set() + for source in list_available_sources(): + supported_types = source.get("supported_content_types", ["ebook", "audiobook"]) + if content_type in supported_types: + valid_sources.add(source["name"]) + return valid_sources + + def _get_request_policy_rule_columns(): source_capabilities = get_source_content_type_capabilities() content_type_options = [] @@ -189,16 +202,16 @@ def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | N ) return normalized_value, None - if key == "DEFAULT_RELEASE_SOURCE": + if key in {"DEFAULT_RELEASE_SOURCE", "DEFAULT_RELEASE_SOURCE_AUDIOBOOK"}: if normalized_value == "": return "", None - from shelfmark.release_sources import list_available_sources - - valid_sources = {source["name"] for source in list_available_sources()} + valid_sources = _get_valid_release_source_names_for_content_type( + "audiobook" if key == "DEFAULT_RELEASE_SOURCE_AUDIOBOOK" else "ebook" + ) if normalized_value not in valid_sources: return ( value, - "DEFAULT_RELEASE_SOURCE must be a valid release source name or empty", + f"{key} must be a valid release source name or empty", ) return normalized_value, None diff --git a/shelfmark/core/admin_settings_routes.py b/shelfmark/core/admin_settings_routes.py index cfefbe0..d3a21eb 100644 --- a/shelfmark/core/admin_settings_routes.py +++ b/shelfmark/core/admin_settings_routes.py @@ -78,10 +78,39 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li "METADATA_PROVIDER", "METADATA_PROVIDER_AUDIOBOOK", "DEFAULT_RELEASE_SOURCE", + "DEFAULT_RELEASE_SOURCE_AUDIOBOOK", }: valid[key] = normalized_search_value continue + if key == "DOWNLOAD_TO_BROWSER_CONTENT_TYPES": + if not isinstance(value, list): + errors.append(f"Invalid value for {key}: must be a list") + continue + + candidate_values = [ + str(entry).strip().lower() + for entry in value + if str(entry).strip() + ] + normalized_values: list[str] = [] + has_invalid_value = False + for entry in candidate_values: + if entry not in {"book", "audiobook"}: + errors.append( + f"Invalid value for {key}: unsupported content type '{entry}'" + ) + has_invalid_value = True + continue + if entry not in normalized_values: + normalized_values.append(entry) + + if has_invalid_value: + continue + + valid[key] = normalized_values + continue + valid[key] = value return valid, errors diff --git a/shelfmark/core/settings_registry.py b/shelfmark/core/settings_registry.py index 1474afa..50c3d7d 100644 --- a/shelfmark/core/settings_registry.py +++ b/shelfmark/core/settings_registry.py @@ -481,6 +481,7 @@ def sync_env_to_config() -> None: logger.debug(f"Synced {len(values_to_sync)} ENV values to {tab.name} config: {list(values_to_sync.keys())}") migrate_legacy_settings() + migrate_download_to_browser_settings() migrate_mirror_settings() @@ -691,6 +692,57 @@ def migrate_legacy_settings() -> None: logger.info(f"Migrated content-type routing settings: {list(migrated_sources.keys())}") +def migrate_download_to_browser_settings() -> None: + """Migrate the legacy download-to-browser toggle to content-type selection.""" + downloads_config = load_config_file("downloads") + legacy_key = "DOWNLOAD_TO_BROWSER" + new_key = "DOWNLOAD_TO_BROWSER_CONTENT_TYPES" + config_path = _get_config_file_path("downloads") + + legacy_value: Any = None + legacy_present = False + + if legacy_key in downloads_config: + legacy_value = downloads_config.get(legacy_key) + legacy_present = True + elif new_key not in downloads_config and os.environ.get(new_key) is None and legacy_key in os.environ: + legacy_value = os.environ.get(legacy_key) + legacy_present = True + + if not legacy_present and legacy_key not in downloads_config: + return + + updated_downloads = dict(downloads_config) + changed = False + + if new_key not in updated_downloads and legacy_present: + enabled = False + if isinstance(legacy_value, bool): + enabled = legacy_value + elif isinstance(legacy_value, str): + enabled = legacy_value.strip().lower() in {"true", "1", "yes", "on"} + else: + enabled = bool(legacy_value) + + updated_downloads[new_key] = ["book", "audiobook"] if enabled else [] + changed = True + + if legacy_key in updated_downloads: + updated_downloads.pop(legacy_key, None) + changed = True + + if not changed: + return + + try: + _ensure_config_dir("downloads") + with open(config_path, "w") as f: + json.dump(updated_downloads, f, indent=2) + logger.info("Migrated download-to-browser setting to content-type selection") + except Exception as exc: + logger.error(f"Failed to migrate download-to-browser settings: {exc}") + + def get_setting_value(field: SettingsField, tab_name: str) -> Any: if isinstance(field, (ActionButton, HeadingField, CustomComponentField)): return None # Actions and headings don't have values diff --git a/shelfmark/download/clients/base_handler.py b/shelfmark/download/clients/base_handler.py index 9ee547b..193da1d 100644 --- a/shelfmark/download/clients/base_handler.py +++ b/shelfmark/download/clients/base_handler.py @@ -38,6 +38,8 @@ class DownloadRequest: protocol: str release_name: str expected_hash: Optional[str] + seeding_time_limit: Optional[int] = None # minutes + ratio_limit: Optional[float] = None def _diagnose_path_issue(path: str) -> str: @@ -140,20 +142,28 @@ class ExternalClientHandler(DownloadHandler, ABC): return client, download_id, protocol = client_ref - if protocol != "usenet": - return - # "Move" means copy into ingest then let the usenet client delete its own files. - if config.get("PROWLARR_USENET_ACTION", "move") != "move": - return + if protocol == "usenet": + # "Move" means copy into ingest then let the usenet client delete its own files. + if config.get("PROWLARR_USENET_ACTION", "move") != "move": + return + try: + self._delete_local_download_data(client, download_id) + self._remove_usenet_download(client, download_id, delete_files=True, archive=True) + except Exception as e: + logger.warning( + f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}" + ) - try: - self._delete_local_download_data(client, download_id) - self._remove_usenet_download(client, download_id, delete_files=True, archive=True) - except Exception as e: - logger.warning( - f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}" - ) + elif protocol == "torrent": + if config.get("PROWLARR_TORRENT_ACTION", "keep") != "remove": + return + try: + client.remove(download_id, delete_files=False) + except Exception as e: + logger.warning( + f"Failed to remove torrent {download_id} from {getattr(client, 'name', 'client')}: {e}" + ) def _remove_usenet_download( self, @@ -553,6 +563,8 @@ class ExternalClientHandler(DownloadHandler, ABC): name=request.release_name, category=category, expected_hash=request.expected_hash, + seeding_time_limit=request.seeding_time_limit, + ratio_limit=request.ratio_limit, ) except Exception as e: logger.error(f"Failed to add to {client.name}: {e}") diff --git a/shelfmark/download/clients/deluge.py b/shelfmark/download/clients/deluge.py index 43e7be7..5458961 100644 --- a/shelfmark/download/clients/deluge.py +++ b/shelfmark/download/clients/deluge.py @@ -237,6 +237,15 @@ class DelugeClient(DownloadClient): if self._download_dir: options["download_location"] = self._download_dir + # Per-torrent seeding limits from indexer + seeding_time_limit = kwargs.get("seeding_time_limit") + if seeding_time_limit is not None: + options["seed_time_limit"] = int(seeding_time_limit) + ratio_limit = kwargs.get("ratio_limit") + if ratio_limit is not None: + options["stop_at_ratio"] = float(ratio_limit) + options["stop_at_ratio_enabled"] = True + if torrent_info.is_magnet: magnet_url = torrent_info.magnet_url or url torrent_id = self._rpc_call("core.add_torrent_magnet", magnet_url, options) diff --git a/shelfmark/download/clients/qbittorrent.py b/shelfmark/download/clients/qbittorrent.py index 188351a..1fdc650 100644 --- a/shelfmark/download/clients/qbittorrent.py +++ b/shelfmark/download/clients/qbittorrent.py @@ -61,6 +61,23 @@ def _normalize_tags(raw_tags: object) -> list[str]: return tags +def _normalize_add_result(raw_result: object) -> str: + """Normalize qBittorrent add responses to a comparable string.""" + if raw_result is None: + return "" + + if isinstance(raw_result, bytes): + return raw_result.decode("utf-8", errors="replace").strip() + + return str(raw_result).strip() + + +def _is_explicit_add_failure(raw_result: object) -> bool: + """Detect add responses that clearly indicate failure.""" + normalized = _normalize_add_result(raw_result).rstrip(".").lower() + return normalized in {"fail", "fails", "error", "errors"} + + @register_client("torrent") class QBittorrentClient(DownloadClient): """qBittorrent download client.""" @@ -291,13 +308,16 @@ class QBittorrentClient(DownloadClient): tags = self._tags # Ensure category exists (may already exist, which is fine) - try: - self._client.torrents_create_category(name=category) - except Exception as e: - # Conflict409Error means category exists - that's expected - # Log other errors but continue since download may still work - if "Conflict" not in type(e).__name__ and "409" not in str(e): - logger.debug(f"Could not create category '{category}': {type(e).__name__}: {e}") + if category: + try: + self._client.torrents_create_category(name=category) + except Exception as e: + # Conflict409Error means category exists - that's expected + # Log other errors but continue since download may still work + if "Conflict" not in type(e).__name__ and "409" not in str(e): + logger.debug( + f"Could not create category '{category}': {type(e).__name__}: {e}" + ) torrent_info = extract_torrent_info(url, expected_hash=expected_hash) expected_hash = torrent_info.info_hash @@ -305,14 +325,23 @@ class QBittorrentClient(DownloadClient): # Add the torrent - use file content if we have it, otherwise URL add_kwargs = { - "category": category, "rename": name, } + if category: + add_kwargs["category"] = category if self._download_dir: add_kwargs["save_path"] = self._download_dir if tags: add_kwargs["tags"] = ",".join(tags) + # Per-torrent seeding limits from indexer + seeding_time_limit = kwargs.get("seeding_time_limit") + if seeding_time_limit is not None: + add_kwargs["seeding_time_limit"] = int(seeding_time_limit) + ratio_limit = kwargs.get("ratio_limit") + if ratio_limit is not None: + add_kwargs["ratio_limit"] = float(ratio_limit) + if torrent_data: result = self._client.torrents_add( torrent_files=torrent_data, @@ -326,29 +355,32 @@ class QBittorrentClient(DownloadClient): **add_kwargs, ) - logger.debug(f"qBittorrent add result: {result}") + result_text = _normalize_add_result(result) + logger.debug(f"qBittorrent add result: {result_text}") - if result == "Ok.": - if not expected_hash: - raise Exception("Could not determine torrent hash from URL") + if not expected_hash: + raise Exception("Could not determine torrent hash from URL") - # Wait for torrent to appear in client. - # Use `/torrents/properties?hash=` rather than relying on `torrents/info` - # listing being immediately consistent. - for _ in range(10): - loaded, error = self._is_torrent_loaded(expected_hash) - if error: - logger.debug(f"qBittorrent add_download: {error}") - if loaded: - logger.info(f"Added torrent: {expected_hash}") - return expected_hash.lower() - time.sleep(0.5) + if _is_explicit_add_failure(result): + raise Exception(f"Failed to add torrent: {result_text}") - # Client said Ok, trust it - logger.warning(f"Torrent not yet visible, returning expected hash") - return expected_hash + # Some qBittorrent-compatible clients return HTTP 200 with an empty body + # instead of qBittorrent's literal "Ok." response. Prefer verifying that + # the torrent becomes visible over trusting the response body alone. + for _ in range(10): + loaded, error = self._is_torrent_loaded(expected_hash) + if error: + logger.debug(f"qBittorrent add_download: {error}") + if loaded: + logger.info(f"Added torrent: {expected_hash}") + return expected_hash.lower() + time.sleep(0.5) - raise Exception(f"Failed to add torrent: {result}") + logger.warning( + "Torrent add was not confirmed within the visibility grace period " + f"(response={result_text or ''}), returning expected hash" + ) + return expected_hash except Exception as e: logger.error(f"qBittorrent add failed: {e}") raise diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index 7a6221c..c07cd1c 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -610,7 +610,17 @@ def prowlarr_clients_settings(): show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "rtorrent"}, ), # Note: Torrent client download path must be mounted identically in both containers. - # Torrents are always copied (not moved) to preserve seeding capability. + SelectField( + key="PROWLARR_TORRENT_ACTION", + label="Torrent Completion Action", + description="Remove deletes the torrent from your client immediately after import (stops seeding, files are kept); Keep leaves it in the client to continue seeding", + options=[ + {"value": "keep", "label": "Keep"}, + {"value": "remove", "label": "Remove"}, + ], + default="keep", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True}, + ), # --- Usenet Client Selection --- HeadingField( diff --git a/shelfmark/download/clients/transmission.py b/shelfmark/download/clients/transmission.py index 9db3094..6d8d9a9 100644 --- a/shelfmark/download/clients/transmission.py +++ b/shelfmark/download/clients/transmission.py @@ -188,6 +188,22 @@ class TransmissionClient(DownloadClient): torrent_hash = torrent.hashString.lower() logger.info(f"Added torrent to Transmission: {torrent_hash}") + # Apply per-torrent seeding limits from indexer + seed_kwargs = {} + seeding_time_limit = kwargs.get("seeding_time_limit") + if seeding_time_limit is not None: + seed_kwargs["seed_idle_limit"] = int(seeding_time_limit) + seed_kwargs["seed_idle_mode"] = 1 # per-torrent + ratio_limit = kwargs.get("ratio_limit") + if ratio_limit is not None: + seed_kwargs["seed_ratio_limit"] = float(ratio_limit) + seed_kwargs["seed_ratio_mode"] = 1 # per-torrent + if seed_kwargs: + try: + self._client.change_torrent(ids=torrent_hash, **seed_kwargs) + except Exception as e: + logger.warning(f"Failed to set seeding limits for {torrent_hash}: {e}") + return torrent_hash except Exception as e: diff --git a/shelfmark/download/fs.py b/shelfmark/download/fs.py index 1d7f4bd..b46441d 100644 --- a/shelfmark/download/fs.py +++ b/shelfmark/download/fs.py @@ -88,6 +88,7 @@ def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T: _VERIFY_IO_WAIT_SECONDS = 3.0 +_PUBLISH_VERIFY_RETRY_SECONDS = 0.25 def _verify_transfer_size( @@ -119,6 +120,44 @@ def _verify_transfer_size( ) +def _is_stale_handle_error(error: Exception) -> bool: + return isinstance(error, OSError) and error.errno == getattr(errno, "ESTALE", 116) + + +def _verify_published_file( + dest: Path, + expected_size: int, + action: str, +) -> None: + """Best-effort verify after publishing a temp file into place. + + The temp file was already verified before publish. Some NFS mounts can report + a transient stale handle immediately after `os.replace()` makes the final path + visible, so retry once and then trust the successful publish instead of + turning the handoff into a false failure. + """ + try: + _verify_transfer_size(dest, expected_size, action) + return + except OSError as error: + if not _is_stale_handle_error(error): + raise + + time.sleep(_PUBLISH_VERIFY_RETRY_SECONDS) + + try: + _verify_transfer_size(dest, expected_size, action) + except OSError as retry_error: + if not _is_stale_handle_error(retry_error): + raise + logger.warning( + "Skipping post-publish verification for %s after stale handle on %s: %s", + action, + dest, + retry_error, + ) + + def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path: """Write data to a file with atomic collision detection. @@ -417,7 +456,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> continue try: - _verify_transfer_size(try_path, expected_size, "move") + _verify_published_file(try_path, expected_size, "move") except Exception: run_blocking_io(try_path.unlink, missing_ok=True) raise @@ -594,7 +633,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> continue try: - _verify_transfer_size(try_path, expected_size, "copy") + _verify_published_file(try_path, expected_size, "copy") except Exception: run_blocking_io(try_path.unlink, missing_ok=True) raise diff --git a/shelfmark/main.py b/shelfmark/main.py index 311e777..8937800 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -981,6 +981,11 @@ def api_config() -> Union[Response, Tuple[Response, int]]: "direct_download", user_id=db_user_id, ) + default_release_source_audiobook = app_config.get( + "DEFAULT_RELEASE_SOURCE_AUDIOBOOK", + "", + user_id=db_user_id, + ) configured_metadata_provider = app_config.get( "METADATA_PROVIDER", "", @@ -1007,9 +1012,15 @@ def api_config() -> Union[Response, Tuple[Response, int]]: "metadata_sort_options": get_provider_sort_options(metadata_ui_provider), "metadata_search_fields": get_provider_search_fields(metadata_ui_provider), "default_release_source": default_release_source, + "default_release_source_audiobook": default_release_source_audiobook, + "show_release_source_links": app_config.get("SHOW_RELEASE_SOURCE_LINKS", True), "books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"), "auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True), - "download_to_browser": app_config.get("DOWNLOAD_TO_BROWSER", False), + "download_to_browser_content_types": app_config.get( + "DOWNLOAD_TO_BROWSER_CONTENT_TYPES", + [], + user_id=db_user_id, + ), "settings_enabled": _is_config_dir_writable(), "onboarding_complete": _get_onboarding_complete(), # Default sort orders diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index 2ce4097..6a50fa0 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -74,11 +74,19 @@ class ProwlarrHandler(ExternalClientHandler): release_name = prowlarr_result.get("title") or task.title or "Unknown" expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None + # Seed criteria from the indexer (Torznab attributes) + raw_seed_time = prowlarr_result.get("minimumSeedTime") + seeding_time_limit = int(raw_seed_time) if raw_seed_time is not None else None + raw_ratio = prowlarr_result.get("minimumRatio") + ratio_limit = float(raw_ratio) if raw_ratio is not None else None + return DownloadRequest( url=download_url, protocol=protocol, release_name=release_name, expected_hash=expected_hash, + seeding_time_limit=seeding_time_limit, + ratio_limit=ratio_limit, ) def _on_download_complete(self, task: DownloadTask) -> None: diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 8d05973..137cea4 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -556,6 +556,16 @@ function App() { const detectChanges = useCallback((prev: StatusData, curr: StatusData) => { if (!prev || Object.keys(prev).length === 0) return; + const autoDownloadContentTypes = Array.isArray(config?.download_to_browser_content_types) + ? config.download_to_browser_content_types + : []; + const canAutoDownloadContentType = (contentType?: string): boolean => { + const contentTypeKey = String(contentType || '').trim().toLowerCase() === 'audiobook' + ? 'audiobook' + : 'book'; + return autoDownloadContentTypes.includes(contentTypeKey); + }; + // Check for new items in queue const prevQueued = prev.queued || {}; const currQueued = curr.queued || {}; @@ -592,7 +602,7 @@ function App() { showToast(`${book.title || 'Book'} completed`, 'success'); // Auto-download to browser if enabled - if (config?.download_to_browser && book.download_path) { + if (book.download_path && canAutoDownloadContentType(book.content_type)) { const link = document.createElement('a'); link.href = withBasePath(`/api/localdownload?id=${encodeURIComponent(bookId)}`); link.download = ''; @@ -2232,6 +2242,7 @@ function App() { ? getUniversalActionButtonState(selectedBook.id) : getDirectActionButtonState(selectedBook.id) } + showReleaseSourceLinks={config?.show_release_source_links !== false} /> )} @@ -2255,9 +2266,11 @@ function App() { bookLanguages={bookLanguages} currentStatus={statusForButtonState} defaultReleaseSource={config?.default_release_source} + defaultAudiobookReleaseSource={config?.default_release_source_audiobook} onSearchSeries={isBrowseFulfilMode || !canSearchSeriesForBook(activeReleaseBook) ? undefined : handleSearchSeries} defaultShowManualQuery={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'} isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'} + showReleaseSourceLinks={config?.show_release_source_links !== false} onShowToast={showToast} /> )} diff --git a/src/frontend/src/components/AdvancedFilters.tsx b/src/frontend/src/components/AdvancedFilters.tsx index 07acf0d..8c76b25 100644 --- a/src/frontend/src/components/AdvancedFilters.tsx +++ b/src/frontend/src/components/AdvancedFilters.tsx @@ -194,7 +194,7 @@ export const AdvancedFilters = ({ return renderWrapper ? ( renderWrapper(settingsForm) ) : ( -
+
{settingsForm}
); diff --git a/src/frontend/src/components/ConfigSetupBanner.tsx b/src/frontend/src/components/ConfigSetupBanner.tsx index ad6b688..0e0c94d 100644 --- a/src/frontend/src/components/ConfigSetupBanner.tsx +++ b/src/frontend/src/components/ConfigSetupBanner.tsx @@ -73,7 +73,7 @@ export const ConfigSetupBanner = ({ {/* Modal */}
{/* Header */} -
+

{showContinueButton ? 'Config Volume Required' : 'New Feature: Settings Page'}

@@ -113,8 +113,8 @@ export const ConfigSetupBanner = ({

{/* Code snippet */} -
-
+
docker-compose.yml
@@ -139,13 +139,13 @@ export const ConfigSetupBanner = ({
{/* Footer */} -
+
{showContinueButton ? ( <> -
+
{showPasswordLogin && ( diff --git a/src/frontend/src/components/OnBehalfConfirmationModal.tsx b/src/frontend/src/components/OnBehalfConfirmationModal.tsx index 87c0038..3529575 100644 --- a/src/frontend/src/components/OnBehalfConfirmationModal.tsx +++ b/src/frontend/src/components/OnBehalfConfirmationModal.tsx @@ -93,13 +93,13 @@ export const OnBehalfConfirmationModal = ({ />
-
+

Download as {actingAsName}?

@@ -120,18 +120,18 @@ export const OnBehalfConfirmationModal = ({

This download will use {actingAsName}'s output preferences and destination settings.

-
+

Title

{itemTitle}

-
@@ -1509,7 +1546,7 @@ export const ReleaseModal = ({ )} {/* Source tabs + filters - sticky within scroll container */} -
+
{sourcesLoading ? (
@@ -1654,7 +1691,7 @@ export const ReleaseModal = ({ {availableFormats.length > 1 && ( <> {allSortOptions.length > 0 && ( -
+
)}
@@ -2019,7 +2057,7 @@ export const ReleaseModal = ({ {/* Sticky search status indicator - stays at bottom of visible scroll area */} {searchStatus && searchStatus.source === activeTab && currentTabLoading && (
-
+
{searchStatus.phase !== 'complete' && searchStatus.phase !== 'error' && (
)} diff --git a/src/frontend/src/components/RequestConfirmationModal.tsx b/src/frontend/src/components/RequestConfirmationModal.tsx index cb5faa5..0c5cf6a 100644 --- a/src/frontend/src/components/RequestConfirmationModal.tsx +++ b/src/frontend/src/components/RequestConfirmationModal.tsx @@ -139,13 +139,13 @@ export const RequestConfirmationModal = ({ />
-
+

Request Book

@@ -163,9 +163,9 @@ export const RequestConfirmationModal = ({
-
+
-
+
{preview.preview ? ( setNote(truncateRequestNote(event.target.value))} maxLength={MAX_REQUEST_NOTE_LENGTH} rows={4} - className="w-full px-3 py-2 rounded-lg border-hairline border-(--border-muted) bg-(--bg) text-sm resize-y min-h-[96px] focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500" + className="w-full px-3 py-2 rounded-lg border border-(--border-muted) bg-(--bg) text-sm resize-y min-h-[96px] focus:outline-hidden focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500" placeholder="Add context for admins reviewing this request..." disabled={isSubmitting} /> @@ -223,12 +223,12 @@ export const RequestConfirmationModal = ({ )}
-